@aifinpay/agent 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aifp1.d.ts +301 -0
- package/dist/aifp1.d.ts.map +1 -0
- package/dist/aifp1.js +661 -0
- package/dist/aifp1.js.map +1 -0
- package/dist/crossChain.d.ts +0 -8
- package/dist/crossChain.d.ts.map +1 -1
- package/dist/crossChain.js +66 -0
- package/dist/crossChain.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/spendLedger.d.ts +54 -0
- package/dist/spendLedger.d.ts.map +1 -0
- package/dist/spendLedger.js +185 -0
- package/dist/spendLedger.js.map +1 -0
- package/dist/unifiedAgent.d.ts +123 -2
- package/dist/unifiedAgent.d.ts.map +1 -1
- package/dist/unifiedAgent.js +452 -143
- package/dist/unifiedAgent.js.map +1 -1
- package/package.json +2 -2
package/dist/unifiedAgent.js
CHANGED
|
@@ -13,13 +13,15 @@ import nacl from "tweetnacl";
|
|
|
13
13
|
import { blake2b } from "@noble/hashes/blake2b";
|
|
14
14
|
import bs58 from "bs58";
|
|
15
15
|
import { createHash, randomBytes } from "node:crypto";
|
|
16
|
-
import { createPublicClient, createWalletClient, http, keccak256, toHex, toFunctionSelector, } from "viem";
|
|
16
|
+
import { createPublicClient, createWalletClient, http, keccak256, toHex, toFunctionSelector, formatEther, } from "viem";
|
|
17
17
|
import { privateKeyToAccount, } from "viem/accounts";
|
|
18
18
|
import { defineChain } from "viem";
|
|
19
19
|
import { polygon, base, arbitrum, optimism, bsc, mainnet, unichain } from "viem/chains";
|
|
20
20
|
import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, } from "@solana/web3.js";
|
|
21
21
|
import { AiFinPayError, X402Error, } from "./errors.js";
|
|
22
22
|
import { Agent } from "./agent.js";
|
|
23
|
+
import { MemorySpendLedger, FileSpendLedger } from "./spendLedger.js";
|
|
24
|
+
import { aifp1Fetch, Aifp1ReceiptCache, } from "./aifp1.js";
|
|
23
25
|
import { bridgeQuote as crossChainQuote, bridgeExecute as crossChainExecute, bridgeWaitForArrival, USDC_NATIVE, } from "./crossChain.js";
|
|
24
26
|
// ── B2BSplitter contract ABI ─────────────────────────────────────────────
|
|
25
27
|
// `payMatic` is the splitter's generic NATIVE-token payment entrypoint —
|
|
@@ -106,7 +108,7 @@ export const SPLITTER_DEPLOYMENTS = {
|
|
|
106
108
|
usdc: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
107
109
|
explorer: "https://polygonscan.com",
|
|
108
110
|
nativeUsdEnv: "AIFINPAY_MATIC_USD", // legacy name kept for back-compat
|
|
109
|
-
nativeUsdDefault: 0.
|
|
111
|
+
nativeUsdDefault: 0.073, // reference only; ~$0.073 on 2026-08-03
|
|
110
112
|
},
|
|
111
113
|
base: {
|
|
112
114
|
version: "1.1",
|
|
@@ -117,7 +119,7 @@ export const SPLITTER_DEPLOYMENTS = {
|
|
|
117
119
|
usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
118
120
|
explorer: "https://basescan.org",
|
|
119
121
|
nativeUsdEnv: "AIFINPAY_ETH_USD",
|
|
120
|
-
nativeUsdDefault:
|
|
122
|
+
nativeUsdDefault: 1870, // reference only; ~$1870 on 2026-08-03
|
|
121
123
|
},
|
|
122
124
|
optimism: {
|
|
123
125
|
version: "1.2",
|
|
@@ -128,7 +130,7 @@ export const SPLITTER_DEPLOYMENTS = {
|
|
|
128
130
|
usdc: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
|
|
129
131
|
explorer: "https://optimistic.etherscan.io",
|
|
130
132
|
nativeUsdEnv: "AIFINPAY_ETH_USD",
|
|
131
|
-
nativeUsdDefault:
|
|
133
|
+
nativeUsdDefault: 1870, // reference only; ~$1870 on 2026-08-03
|
|
132
134
|
},
|
|
133
135
|
unichain: {
|
|
134
136
|
version: "1.1",
|
|
@@ -139,7 +141,7 @@ export const SPLITTER_DEPLOYMENTS = {
|
|
|
139
141
|
usdc: "0x078D782b760474a361dDA0AF3839290b0EF57AD6",
|
|
140
142
|
explorer: "https://uniscan.xyz",
|
|
141
143
|
nativeUsdEnv: "AIFINPAY_ETH_USD",
|
|
142
|
-
nativeUsdDefault:
|
|
144
|
+
nativeUsdDefault: 1870, // reference only; ~$1870 on 2026-08-03
|
|
143
145
|
},
|
|
144
146
|
botchain: {
|
|
145
147
|
version: "1.2",
|
|
@@ -217,10 +219,17 @@ export class WrongChainBalanceError extends AiFinPayError {
|
|
|
217
219
|
export class InsufficientFundsError extends AiFinPayError {
|
|
218
220
|
needed_usd;
|
|
219
221
|
available_usd;
|
|
220
|
-
|
|
222
|
+
details;
|
|
223
|
+
/**
|
|
224
|
+
* `details` carries the native amounts, which are what the caller acts on —
|
|
225
|
+
* the USD figures depend on a price feed that may be unavailable, and an
|
|
226
|
+
* error about missing funds is the worst possible place to invent a number.
|
|
227
|
+
*/
|
|
228
|
+
constructor(needed_usd, available_usd, msg, details) {
|
|
221
229
|
super(msg);
|
|
222
230
|
this.needed_usd = needed_usd;
|
|
223
231
|
this.available_usd = available_usd;
|
|
232
|
+
this.details = details;
|
|
224
233
|
}
|
|
225
234
|
}
|
|
226
235
|
export class BudgetCapExceededError extends AiFinPayError {
|
|
@@ -284,7 +293,9 @@ export class AiFinPayAgent {
|
|
|
284
293
|
solanaRpc;
|
|
285
294
|
cachedRegistry;
|
|
286
295
|
budgetCaps;
|
|
287
|
-
spend24h = new SpendTracker();
|
|
296
|
+
spend24h = new SpendTracker(); // reporting only; the cap uses the ledger
|
|
297
|
+
_ledger;
|
|
298
|
+
ledgerOverride;
|
|
288
299
|
telemetry;
|
|
289
300
|
_polygonPublic;
|
|
290
301
|
_polygonWallet;
|
|
@@ -301,6 +312,14 @@ export class AiFinPayAgent {
|
|
|
301
312
|
? [opts.registryUrl]
|
|
302
313
|
: DEFAULT_REGISTRY_PATHS.map((path) => `${inner.baseUrl}${path}`);
|
|
303
314
|
this.budgetCaps = opts.budgetCaps ?? {};
|
|
315
|
+
// `spendLedger` was declared, documented ("pass your own when the agent
|
|
316
|
+
// runs on more than one host") and read by the `ledger` getter, but never
|
|
317
|
+
// assigned from the options — so the option was a no-op and every agent
|
|
318
|
+
// with a daily cap wrote to ~/.aifinpay regardless. A fleet that supplied
|
|
319
|
+
// a shared ledger silently got a per-host one instead, which is exactly
|
|
320
|
+
// the failure the option exists to prevent.
|
|
321
|
+
if (opts.spendLedger)
|
|
322
|
+
this.ledgerOverride = opts.spendLedger;
|
|
304
323
|
this.telemetry = opts.telemetry !== false;
|
|
305
324
|
this.polygonRpc = opts.polygonRpc ?? "https://polygon.drpc.org";
|
|
306
325
|
this.solanaRpc = opts.solanaRpc
|
|
@@ -608,6 +627,61 @@ export class AiFinPayAgent {
|
|
|
608
627
|
* without submitting an on-chain tx. Throws BudgetCapExceededError
|
|
609
628
|
* in the default "throw" mode.
|
|
610
629
|
*/
|
|
630
|
+
/**
|
|
631
|
+
* The ledger that enforces the daily cap.
|
|
632
|
+
*
|
|
633
|
+
* Chosen late, because setBudget() is usually called after construction and
|
|
634
|
+
* the choice depends on whether a daily cap exists at all. Durability is
|
|
635
|
+
* engaged only when one does: a library that writes to a user's disk because
|
|
636
|
+
* it was imported would be rude, and one that promises a daily limit and
|
|
637
|
+
* forgets it on restart is worse.
|
|
638
|
+
*/
|
|
639
|
+
get ledger() {
|
|
640
|
+
if (this.ledgerOverride)
|
|
641
|
+
return this.ledgerOverride;
|
|
642
|
+
if (!this._ledger) {
|
|
643
|
+
this._ledger = this.budgetCaps.daily_usd !== undefined
|
|
644
|
+
? FileSpendLedger.forAgent(this.evmAddress)
|
|
645
|
+
: new MemorySpendLedger();
|
|
646
|
+
}
|
|
647
|
+
return this._ledger;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Claim room in the daily cap before paying.
|
|
651
|
+
*
|
|
652
|
+
* Returns a reservation id, or "skip"/throws per on_limit_exceeded. The cap
|
|
653
|
+
* comparison happens inside the ledger, atomically: a caller that read the
|
|
654
|
+
* total and then reserved would have rebuilt the race this replaces, where
|
|
655
|
+
* two concurrent calls both read the same total, both passed, and both paid.
|
|
656
|
+
*/
|
|
657
|
+
async reserveDaily(costUsd) {
|
|
658
|
+
const cap = this.budgetCaps.daily_usd;
|
|
659
|
+
if (cap === undefined)
|
|
660
|
+
return null; // no daily cap to enforce
|
|
661
|
+
const id = await this.ledger.reserve(costUsd, cap, 24 * 3600 * 1000);
|
|
662
|
+
if (id)
|
|
663
|
+
return id;
|
|
664
|
+
const err = new BudgetCapExceededError("daily", `this call would take daily spend past the $${cap} cap`);
|
|
665
|
+
if ((this.budgetCaps.on_limit_exceeded ?? "throw") === "skip")
|
|
666
|
+
return "skip";
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* The per-call cap, which needs no ledger.
|
|
671
|
+
*
|
|
672
|
+
* It is a property of one call and cannot be raced: nothing about a
|
|
673
|
+
* concurrent call changes whether THIS one is too expensive. Only the daily
|
|
674
|
+
* cap accumulates, and only it goes through reserve/commit.
|
|
675
|
+
*/
|
|
676
|
+
checkPerCall(costUsd) {
|
|
677
|
+
const cap = this.budgetCaps.per_call_usd;
|
|
678
|
+
if (cap === undefined || costUsd <= cap)
|
|
679
|
+
return true;
|
|
680
|
+
const err = new BudgetCapExceededError("per_call", `cost $${costUsd} exceeds per-call cap $${cap}`);
|
|
681
|
+
if ((this.budgetCaps.on_limit_exceeded ?? "throw") === "skip")
|
|
682
|
+
return false;
|
|
683
|
+
throw err;
|
|
684
|
+
}
|
|
611
685
|
checkBudget(costUsd) {
|
|
612
686
|
const mode = this.budgetCaps.on_limit_exceeded ?? "throw";
|
|
613
687
|
if (this.budgetCaps.per_call_usd !== undefined && costUsd > this.budgetCaps.per_call_usd) {
|
|
@@ -644,6 +718,107 @@ export class AiFinPayAgent {
|
|
|
644
718
|
*
|
|
645
719
|
* Returns false (skip) instead of throwing when on_limit_exceeded="skip".
|
|
646
720
|
*/
|
|
721
|
+
/**
|
|
722
|
+
* The native-token USD price for the pre-sign guard.
|
|
723
|
+
*
|
|
724
|
+
* Order: an explicit env var, then the protocol's own price feed, then
|
|
725
|
+
* nothing. "Nothing" is deliberate and is the whole point of this method.
|
|
726
|
+
* The guard used to fall back to a constant compiled into the SDK; POL sat
|
|
727
|
+
* at 0.70 while trading near 0.073, so a real $0.01 payment estimated at
|
|
728
|
+
* $0.096, cleared the $0.06 ceiling and threw BudgetCapExceededError. The
|
|
729
|
+
* payment never left, and the error blamed the bridge for demanding too
|
|
730
|
+
* much.
|
|
731
|
+
*
|
|
732
|
+
* A guard that blocks on a number it cannot justify is worse than no guard,
|
|
733
|
+
* so an unknown price returns NaN and guardChallengeAmount declines to
|
|
734
|
+
* block. Overpayment protection is given up only in the case where we have
|
|
735
|
+
* no basis for it, which is the honest trade.
|
|
736
|
+
*/
|
|
737
|
+
priceCache;
|
|
738
|
+
async nativeUsdFor(deployment) {
|
|
739
|
+
const env = process.env[deployment.nativeUsdEnv];
|
|
740
|
+
if (env !== undefined) {
|
|
741
|
+
const v = parseFloat(env);
|
|
742
|
+
if (Number.isFinite(v) && v > 0)
|
|
743
|
+
return { usd: v, source: deployment.nativeUsdEnv };
|
|
744
|
+
}
|
|
745
|
+
const symbol = deployment.chain.nativeCurrency.symbol.toUpperCase();
|
|
746
|
+
const fresh = this.priceCache && Date.now() - this.priceCache.at < 60_000;
|
|
747
|
+
if (!fresh) {
|
|
748
|
+
try {
|
|
749
|
+
const r = await this.inner.fetchImpl(`${this.inner.baseUrl}/api/price/native`, {
|
|
750
|
+
signal: AbortSignal.timeout(5_000),
|
|
751
|
+
});
|
|
752
|
+
if (r.ok) {
|
|
753
|
+
const body = (await r.json());
|
|
754
|
+
if (body?.usd)
|
|
755
|
+
this.priceCache = { at: Date.now(), usd: body.usd };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
// A feed that will not answer is not a payment failure. Fall through
|
|
760
|
+
// to "unknown" rather than to a guess.
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const live = this.priceCache?.usd?.[symbol];
|
|
764
|
+
if (typeof live === "number" && Number.isFinite(live) && live > 0) {
|
|
765
|
+
return { usd: live, source: "aifinpay price feed" };
|
|
766
|
+
}
|
|
767
|
+
return { usd: NaN, source: "unknown" };
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Refuse to broadcast a transaction the account cannot pay for, and say so
|
|
771
|
+
* in terms the caller can act on.
|
|
772
|
+
*
|
|
773
|
+
* Without this the SDK handed back viem's ContractFunctionExecutionError —
|
|
774
|
+
* several paragraphs explaining that cost is `gas * gas fee + value` — for
|
|
775
|
+
* the one condition an agent operator can fix in ten seconds. Worse, the SDK
|
|
776
|
+
* has exported an InsufficientFundsError since the first release and never
|
|
777
|
+
* threw it, so anyone who wrote `catch (e instanceof InsufficientFundsError)`
|
|
778
|
+
* had a branch that could not run.
|
|
779
|
+
*/
|
|
780
|
+
async assertCanAffordNative(publicClient, deployment, valueWei) {
|
|
781
|
+
const address = this.evmAccount.address;
|
|
782
|
+
let balance;
|
|
783
|
+
let gasPrice;
|
|
784
|
+
try {
|
|
785
|
+
[balance, gasPrice] = await Promise.all([
|
|
786
|
+
publicClient.getBalance({ address }),
|
|
787
|
+
publicClient.getGasPrice(),
|
|
788
|
+
]);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
// An RPC that will not answer must not turn into a funding error; let
|
|
792
|
+
// the actual send produce the actual failure.
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
// A splitter payment lands around 120k gas; the headroom covers a fee
|
|
796
|
+
// spike between this check and inclusion. Being generous here only makes
|
|
797
|
+
// the check quieter, never wrong — a send that fails anyway still fails.
|
|
798
|
+
const gasBudget = gasPrice * 200000n;
|
|
799
|
+
const needed = valueWei + gasBudget;
|
|
800
|
+
if (balance >= needed)
|
|
801
|
+
return;
|
|
802
|
+
const symbol = deployment.chain.nativeCurrency.symbol;
|
|
803
|
+
const chainName = deployment.chain.name;
|
|
804
|
+
const short = (wei) => {
|
|
805
|
+
const s = formatEther(wei);
|
|
806
|
+
return s.includes(".") ? s.replace(/(\.\d{6})\d+$/, "$1").replace(/\.?0+$/, "") || "0" : s;
|
|
807
|
+
};
|
|
808
|
+
const { usd } = await this.nativeUsdFor(deployment);
|
|
809
|
+
const toUsd = (wei) => Number.isFinite(usd) ? (Number(wei) / 1e18) * usd : Number.NaN;
|
|
810
|
+
throw new InsufficientFundsError(toUsd(needed), toUsd(balance), `Agent ${address} holds ${short(balance)} ${symbol} on ${chainName}, but this call needs ` +
|
|
811
|
+
`about ${short(needed)} ${symbol} — ${short(valueWei)} for the payment and up to ` +
|
|
812
|
+
`${short(gasBudget)} for gas. The gas figure is a ceiling: it assumes 200k gas at the ` +
|
|
813
|
+
`current price, while a splitter payment typically uses closer to 120k. Fund the ` +
|
|
814
|
+
`address with ${symbol} on ${chainName} and retry.`, {
|
|
815
|
+
address,
|
|
816
|
+
chain: chainName,
|
|
817
|
+
symbol,
|
|
818
|
+
needed_wei: needed.toString(),
|
|
819
|
+
available_wei: balance.toString(),
|
|
820
|
+
});
|
|
821
|
+
}
|
|
647
822
|
guardChallengeAmount(estUsd, declaredCost, providerName) {
|
|
648
823
|
if (!Number.isFinite(estUsd) || estUsd <= 0)
|
|
649
824
|
return true; // can't estimate — don't block
|
|
@@ -804,141 +979,216 @@ export class AiFinPayAgent {
|
|
|
804
979
|
// null/NaN cost can't poison the SpendTracker and silently disable caps.
|
|
805
980
|
const rawCost = opts.cost ?? provider.price_usd;
|
|
806
981
|
const cost = typeof rawCost === "number" && Number.isFinite(rawCost) ? rawCost : 0;
|
|
807
|
-
|
|
808
|
-
|
|
982
|
+
// The per-call cap still refuses outright; the daily one is reserved, so
|
|
983
|
+
// that two calls in flight cannot both be told there is room.
|
|
984
|
+
if (!this.checkPerCall(cost))
|
|
809
985
|
return null;
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
if (!url) {
|
|
815
|
-
throw new AiFinPayError(`Provider ${provider.name} has no bridge_url`);
|
|
816
|
-
}
|
|
817
|
-
const path = (() => {
|
|
818
|
-
if (provider.service_type === "search")
|
|
819
|
-
return "/search";
|
|
820
|
-
if (provider.service_type === "inference")
|
|
821
|
-
return "/chat/completions";
|
|
822
|
-
if (provider.service_type === "compute")
|
|
823
|
-
return "/run";
|
|
824
|
-
if (provider.service_type === "analytics")
|
|
825
|
-
return "/query";
|
|
826
|
-
return "/";
|
|
827
|
-
})();
|
|
828
|
-
const buildInit = (extraHeaders = {}) => ({
|
|
829
|
-
method: opts.method ?? "POST",
|
|
830
|
-
headers: { "content-type": "application/json", ...extraHeaders },
|
|
831
|
-
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
832
|
-
// CallOptions.timeoutMs was previously declared but never applied.
|
|
833
|
-
signal: opts.signal
|
|
834
|
-
?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined),
|
|
835
|
-
});
|
|
836
|
-
// 1. Initial unauthenticated POST → expect 402 challenge from the bridge.
|
|
837
|
-
const fullUrl = url.replace(/\/$/, "") + path;
|
|
838
|
-
const initialResp = await this.inner.fetchImpl(fullUrl, buildInit());
|
|
839
|
-
if (initialResp.status !== 402) {
|
|
840
|
-
// Bridge didn't ask for payment — pass response through unchanged.
|
|
841
|
-
// Nothing was paid, so the call does NOT count against budget caps.
|
|
842
|
-
if (this.telemetry)
|
|
843
|
-
this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, free: true });
|
|
844
|
-
return initialResp;
|
|
845
|
-
}
|
|
846
|
-
let challenge;
|
|
986
|
+
const reservation = await this.reserveDaily(cost);
|
|
987
|
+
if (reservation === "skip")
|
|
988
|
+
return null;
|
|
989
|
+
let settled = false;
|
|
847
990
|
try {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
catch (e) {
|
|
851
|
-
throw new X402Error(`bridge returned 402 with non-JSON body`);
|
|
852
|
-
}
|
|
853
|
-
// ── Solana branch (b2b_pay_with_split atomic split, live 2026-05-18) ──
|
|
854
|
-
if (chain === "solana") {
|
|
855
|
-
if (!challenge.pay_solana) {
|
|
856
|
-
throw new X402Error(`Bridge ${provider.name} returned 402 without a pay_solana block. ` +
|
|
857
|
-
`Either pick chain: "polygon" or ask the operator to set ` +
|
|
858
|
-
`BRIDGE_MERCHANT_SOLANA on the bridge service.`);
|
|
991
|
+
if (provider.mode === "session") {
|
|
992
|
+
throw new AiFinPayError(`Provider ${provider.name} requires session mode — use openSession() (phase-3 feature)`);
|
|
859
993
|
}
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
994
|
+
const url = opts.bridgeUrl ?? provider.bridge_url;
|
|
995
|
+
if (!url) {
|
|
996
|
+
throw new AiFinPayError(`Provider ${provider.name} has no bridge_url`);
|
|
997
|
+
}
|
|
998
|
+
const path = (() => {
|
|
999
|
+
if (provider.service_type === "search")
|
|
1000
|
+
return "/search";
|
|
1001
|
+
if (provider.service_type === "inference")
|
|
1002
|
+
return "/chat/completions";
|
|
1003
|
+
if (provider.service_type === "compute")
|
|
1004
|
+
return "/run";
|
|
1005
|
+
if (provider.service_type === "analytics")
|
|
1006
|
+
return "/query";
|
|
1007
|
+
return "/";
|
|
1008
|
+
})();
|
|
1009
|
+
const buildInit = (extraHeaders = {}) => ({
|
|
1010
|
+
method: opts.method ?? "POST",
|
|
1011
|
+
headers: { "content-type": "application/json", ...extraHeaders },
|
|
1012
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
1013
|
+
// CallOptions.timeoutMs was previously declared but never applied.
|
|
1014
|
+
signal: opts.signal
|
|
1015
|
+
?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined),
|
|
1016
|
+
});
|
|
1017
|
+
// 1. Initial unauthenticated POST → expect 402 challenge from the bridge.
|
|
1018
|
+
const fullUrl = url.replace(/\/$/, "") + path;
|
|
1019
|
+
const initialResp = await this.inner.fetchImpl(fullUrl, buildInit());
|
|
1020
|
+
if (initialResp.status !== 402) {
|
|
1021
|
+
// Bridge didn't ask for payment — pass response through unchanged.
|
|
1022
|
+
// Nothing was paid, so the call does NOT count against budget caps.
|
|
1023
|
+
if (this.telemetry)
|
|
1024
|
+
this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, free: true });
|
|
1025
|
+
return initialResp;
|
|
1026
|
+
}
|
|
1027
|
+
let challenge;
|
|
1028
|
+
try {
|
|
1029
|
+
challenge = (await initialResp.json());
|
|
1030
|
+
}
|
|
1031
|
+
catch (e) {
|
|
1032
|
+
throw new X402Error(`bridge returned 402 with non-JSON body`);
|
|
1033
|
+
}
|
|
1034
|
+
// ── Solana branch (b2b_pay_with_split atomic split, live 2026-05-18) ──
|
|
1035
|
+
if (chain === "solana") {
|
|
1036
|
+
if (!challenge.pay_solana) {
|
|
1037
|
+
throw new X402Error(`Bridge ${provider.name} returned 402 without a pay_solana block. ` +
|
|
1038
|
+
`Either pick chain: "polygon" or ask the operator to set ` +
|
|
1039
|
+
`BRIDGE_MERCHANT_SOLANA on the bridge service.`);
|
|
1040
|
+
}
|
|
1041
|
+
const ps = challenge.pay_solana;
|
|
1042
|
+
// Guard: the bridge controls the challenge — verify the demanded
|
|
1043
|
+
// amount is in the same ballpark as the declared cost / caps before
|
|
1044
|
+
// signing anything. Stops a misquoting bridge from draining the agent.
|
|
1045
|
+
{
|
|
1046
|
+
const lamports = Number(ps.total_lamports ?? ps.merchant_amount_lamports);
|
|
1047
|
+
const solUsd = parseFloat(process.env.AIFINPAY_SOL_USD ?? "200");
|
|
1048
|
+
const estUsd = (Number.isFinite(lamports) ? lamports / 1e9 : 0) * solUsd;
|
|
1049
|
+
const guarded = this.guardChallengeAmount(estUsd, cost, provider.name);
|
|
1050
|
+
if (!guarded)
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
const solTxSig = await this.submitSolanaB2BPayWithSplit(ps);
|
|
1054
|
+
const paidResp = await this.inner.fetchImpl(fullUrl, buildInit({
|
|
1055
|
+
"x-solana-tx": solTxSig,
|
|
1056
|
+
"x-order-id": ps.order_id,
|
|
1057
|
+
}));
|
|
1058
|
+
if (!paidResp.ok) {
|
|
1059
|
+
const detail = await paidResp.text().catch(() => "<unreadable>");
|
|
1060
|
+
throw new AiFinPayError(`Bridge retry failed ${paidResp.status} after Solana payment ${solTxSig}: ${detail.slice(0, 300)}`);
|
|
1061
|
+
}
|
|
1062
|
+
// Money moved here, and only here. The reservation becomes a
|
|
1063
|
+
// settled amount; the finally below then has nothing to release.
|
|
1064
|
+
this.spend24h.add(cost);
|
|
1065
|
+
if (typeof reservation === "string") {
|
|
1066
|
+
await this.ledger.commit(reservation, cost);
|
|
1067
|
+
}
|
|
1068
|
+
settled = true;
|
|
1069
|
+
if (this.telemetry)
|
|
1070
|
+
this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx: solTxSig });
|
|
1071
|
+
// @internal — attach settlement metadata for consumers (MCP, telemetry
|
|
1072
|
+
// dashboards) that need to surface the tx hash without an extra RPC
|
|
1073
|
+
// call. Response headers are read-only after construction so we use
|
|
1074
|
+
// an instance property + cast on the read side.
|
|
1075
|
+
paidResp.aifinpayTx = solTxSig;
|
|
1076
|
+
paidResp.aifinpayChain = "solana";
|
|
1077
|
+
return paidResp;
|
|
1078
|
+
}
|
|
1079
|
+
// ── EVM branch (B2BSplitter.payMatic — generic native-token atomic split) ──
|
|
1080
|
+
// Settles on any chain in SPLITTER_DEPLOYMENTS (polygon default; base,
|
|
1081
|
+
// optimism, unichain, botchain, xrplevm). Native token only — the
|
|
1082
|
+
// deployed splitter payment path has no ERC-20/USDC entrypoint.
|
|
1083
|
+
if (!challenge.pay_matic) {
|
|
1084
|
+
throw new X402Error(`bridge ${provider.name} returned 402 but no pay_matic block — only legacy AiFinPay/Coinbase facilitators not yet wired into AiFinPayAgent.call()`);
|
|
1085
|
+
}
|
|
1086
|
+
const pm = challenge.pay_matic;
|
|
1087
|
+
const deployment = SPLITTER_DEPLOYMENTS[chain];
|
|
1088
|
+
if (!deployment) {
|
|
1089
|
+
throw new AiFinPayError(`No B2BSplitter deployment registered for chain "${chain}" — supported: ${Object.keys(SPLITTER_DEPLOYMENTS).join(", ")}`);
|
|
1090
|
+
}
|
|
1091
|
+
// Refuse to pay a quote denominated for another chain's native token:
|
|
1092
|
+
// total_wei quoted for POL on Polygon would be a massive overpay if
|
|
1093
|
+
// blindly re-sent as ETH on Base. Legacy bridges always emit "polygon".
|
|
1094
|
+
if (pm.chain && pm.chain !== chain) {
|
|
1095
|
+
throw new X402Error(`bridge ${provider.name} quoted pay_matic for chain "${pm.chain}" but the call was routed to "${chain}" — refusing cross-denomination payment`);
|
|
1096
|
+
}
|
|
1097
|
+
// Guard: same ballpark check as the Solana branch — never sign for an
|
|
1098
|
+
// amount wildly above the declared cost / per-call cap. Native-token
|
|
1099
|
+
// USD price per chain via SPLITTER_DEPLOYMENTS.nativeUsdEnv.
|
|
864
1100
|
{
|
|
865
|
-
const
|
|
866
|
-
const
|
|
867
|
-
|
|
1101
|
+
const wei = Number(pm.total_wei);
|
|
1102
|
+
const { usd: nativeUsd } = await this.nativeUsdFor(deployment);
|
|
1103
|
+
// NaN when no price is known — guardChallengeAmount then declines to
|
|
1104
|
+
// block rather than blocking on a guess. See nativeUsdFor().
|
|
1105
|
+
const estUsd = (Number.isFinite(wei) ? wei / 1e18 : 0) * nativeUsd;
|
|
868
1106
|
const guarded = this.guardChallengeAmount(estUsd, cost, provider.name);
|
|
869
1107
|
if (!guarded)
|
|
870
1108
|
return null;
|
|
871
1109
|
}
|
|
872
|
-
|
|
1110
|
+
// 2. Submit the B2BSplitter payment on the selected chain's mainnet and
|
|
1111
|
+
// wait for it to land. Prefer the challenge's splitter address (current
|
|
1112
|
+
// bridge behaviour); fall back to the registry address for bridges that
|
|
1113
|
+
// omit it.
|
|
1114
|
+
const splitterAddress = pm.splitter
|
|
1115
|
+
?? deployment.splitter;
|
|
1116
|
+
const txHash = await this.settleSplitterNative({
|
|
1117
|
+
chain,
|
|
1118
|
+
splitter: splitterAddress,
|
|
1119
|
+
splitterVersion: pm.splitter_version,
|
|
1120
|
+
merchantWallet: pm.merchant_wallet,
|
|
1121
|
+
totalWei: BigInt(pm.total_wei),
|
|
1122
|
+
orderId: pm.order_id,
|
|
1123
|
+
ipCreator: pm.ip_creator,
|
|
1124
|
+
});
|
|
1125
|
+
// 3. Retry the bridge with payment proof.
|
|
873
1126
|
const paidResp = await this.inner.fetchImpl(fullUrl, buildInit({
|
|
874
|
-
"x-
|
|
875
|
-
"x-order-id":
|
|
1127
|
+
"x-tx-hash": txHash,
|
|
1128
|
+
"x-order-id": pm.order_id,
|
|
876
1129
|
}));
|
|
877
1130
|
if (!paidResp.ok) {
|
|
878
1131
|
const detail = await paidResp.text().catch(() => "<unreadable>");
|
|
879
|
-
throw new AiFinPayError(`Bridge retry failed ${paidResp.status} after
|
|
1132
|
+
throw new AiFinPayError(`Bridge retry failed ${paidResp.status} after on-chain payment ${txHash}: ${detail.slice(0, 300)}`);
|
|
880
1133
|
}
|
|
1134
|
+
// Money moved here, and only here. The reservation becomes a
|
|
1135
|
+
// settled amount; the finally below then has nothing to release.
|
|
881
1136
|
this.spend24h.add(cost);
|
|
1137
|
+
if (typeof reservation === "string") {
|
|
1138
|
+
await this.ledger.commit(reservation, cost);
|
|
1139
|
+
}
|
|
1140
|
+
settled = true;
|
|
882
1141
|
if (this.telemetry)
|
|
883
|
-
this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx:
|
|
884
|
-
// @internal —
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
// an instance property + cast on the read side.
|
|
888
|
-
paidResp.aifinpayTx = solTxSig;
|
|
889
|
-
paidResp.aifinpayChain = "solana";
|
|
1142
|
+
this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx: txHash });
|
|
1143
|
+
// @internal — see Solana branch above for rationale on property-attach.
|
|
1144
|
+
paidResp.aifinpayTx = txHash;
|
|
1145
|
+
paidResp.aifinpayChain = chain;
|
|
890
1146
|
return paidResp;
|
|
891
1147
|
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1148
|
+
finally {
|
|
1149
|
+
// Every path that did not move money gives the budget back now rather
|
|
1150
|
+
// than waiting for the reservation to expire — an early return, a
|
|
1151
|
+
// refused challenge, a revert, a thrown error. Missing one of them
|
|
1152
|
+
// would leak the cap and eventually stop the agent paying at all,
|
|
1153
|
+
// which is why this is a finally and not a branch.
|
|
1154
|
+
if (typeof reservation === "string" && !settled) {
|
|
1155
|
+
await this.ledger.release(reservation);
|
|
1156
|
+
}
|
|
898
1157
|
}
|
|
899
|
-
|
|
900
|
-
|
|
1158
|
+
}
|
|
1159
|
+
// ── EVM B2BSplitter native settlement — one quote in, one tx hash out ────
|
|
1160
|
+
//
|
|
1161
|
+
// Extracted from call() when a second caller needed the same settlement.
|
|
1162
|
+
// The version handling below is the reason it is one method and not two
|
|
1163
|
+
// copies: it was already wrong once, and a duplicate would only be fixed
|
|
1164
|
+
// in whichever copy the next revert happened to come from.
|
|
1165
|
+
//
|
|
1166
|
+
// Returns only after the transaction is included AND succeeded — a hash for
|
|
1167
|
+
// a reverted transaction would otherwise be handed to a merchant as proof of
|
|
1168
|
+
// a payment that never moved.
|
|
1169
|
+
async settleSplitterNative(p) {
|
|
1170
|
+
// call() has already checked this for the bridge flow; repeated here
|
|
1171
|
+
// because settlement is now reachable without going through call().
|
|
1172
|
+
const deployment = SPLITTER_DEPLOYMENTS[p.chain];
|
|
901
1173
|
if (!deployment) {
|
|
902
|
-
throw new AiFinPayError(`No B2BSplitter deployment registered for chain "${chain}" — supported: ${Object.keys(SPLITTER_DEPLOYMENTS).join(", ")}`);
|
|
903
|
-
}
|
|
904
|
-
// Refuse to pay a quote denominated for another chain's native token:
|
|
905
|
-
// total_wei quoted for POL on Polygon would be a massive overpay if
|
|
906
|
-
// blindly re-sent as ETH on Base. Legacy bridges always emit "polygon".
|
|
907
|
-
if (pm.chain && pm.chain !== chain) {
|
|
908
|
-
throw new X402Error(`bridge ${provider.name} quoted pay_matic for chain "${pm.chain}" but the call was routed to "${chain}" — refusing cross-denomination payment`);
|
|
1174
|
+
throw new AiFinPayError(`No B2BSplitter deployment registered for chain "${p.chain}" — supported: ${Object.keys(SPLITTER_DEPLOYMENTS).join(", ")}`);
|
|
909
1175
|
}
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
const estUsd = (Number.isFinite(wei) ? wei / 1e18 : 0) * nativeUsd;
|
|
917
|
-
const guarded = this.guardChallengeAmount(estUsd, cost, provider.name);
|
|
918
|
-
if (!guarded)
|
|
919
|
-
return null;
|
|
920
|
-
}
|
|
921
|
-
// 2. Submit B2BSplitter.payMatic on the selected chain's mainnet.
|
|
922
|
-
// Prefer the challenge's splitter address (current bridge behaviour);
|
|
923
|
-
// fall back to the registry address for bridges that omit it.
|
|
924
|
-
// ipCreator routing: prefer the challenge's explicit ip_creator; else
|
|
925
|
-
// route the royalty slot to the splitter's treasury (mirrors the Solana
|
|
926
|
-
// branch). Passing address(0) would skip the transfer and permanently
|
|
927
|
-
// strand the 1bp inside B2BSplitter — the contract has no sweep function.
|
|
928
|
-
const splitterAddress = pm.splitter
|
|
929
|
-
?? deployment.splitter;
|
|
930
|
-
const ipCreator = pm.ip_creator
|
|
931
|
-
?? await this.splitterTreasury(splitterAddress, chain)
|
|
1176
|
+
// ipCreator routing: prefer the caller's explicit ip_creator; else route
|
|
1177
|
+
// the royalty slot to the splitter's treasury (mirrors the Solana branch).
|
|
1178
|
+
// Passing address(0) would skip the transfer and permanently strand the
|
|
1179
|
+
// 1bp inside B2BSplitter — the contract has no sweep function.
|
|
1180
|
+
const ipCreator = p.ipCreator
|
|
1181
|
+
?? await this.splitterTreasury(p.splitter, p.chain)
|
|
932
1182
|
?? "0x0000000000000000000000000000000000000000";
|
|
933
|
-
const { publicClient, walletClient } = this.splitterClients(chain);
|
|
1183
|
+
const { publicClient, walletClient } = this.splitterClients(p.chain);
|
|
934
1184
|
// The entrypoint follows the deployed contract, not the SDK release: v1.2
|
|
935
1185
|
// (Polygon, Optimism, BOT Chain, XRPL EVM as of 2026-07-31) takes a bytes32
|
|
936
1186
|
// paymentId and rejects one it has already settled, while Base and Unichain
|
|
937
1187
|
// still run v1.1. Sending v1.2 calldata to a v1.1 contract reverts with no
|
|
938
1188
|
// useful reason, so this must be decided per chain.
|
|
939
|
-
// An explicit `
|
|
1189
|
+
// An explicit `splitterVersion` wins — a server that states it knows what
|
|
940
1190
|
// it deployed. What it must NOT fall back to is this registry's
|
|
941
|
-
// chain -> version entry, because the ADDRESS came from the
|
|
1191
|
+
// chain -> version entry, because the ADDRESS came from the server and
|
|
942
1192
|
// the version would come from a table: two sources that can disagree.
|
|
943
1193
|
//
|
|
944
1194
|
// In production they did. The Exa bridge still hands out the pre-v1.2
|
|
@@ -948,57 +1198,116 @@ export class AiFinPayAgent {
|
|
|
948
1198
|
//
|
|
949
1199
|
// So when the server is silent, ask the contract at the address we were
|
|
950
1200
|
// actually handed. The registry is used only if the chain cannot be read.
|
|
951
|
-
const splitterVersion =
|
|
952
|
-
?? await this.detectSplitterVersion(
|
|
1201
|
+
const splitterVersion = p.splitterVersion
|
|
1202
|
+
?? await this.detectSplitterVersion(p.splitter, p.chain, deployment.version);
|
|
1203
|
+
await this.assertCanAffordNative(publicClient, deployment, p.totalWei);
|
|
953
1204
|
const txHash = splitterVersion === "1.2"
|
|
954
1205
|
? await walletClient.writeContract({
|
|
955
|
-
address:
|
|
1206
|
+
address: p.splitter,
|
|
956
1207
|
abi: SPLITTER_PAY_NATIVE_ABI,
|
|
957
1208
|
functionName: "payNative",
|
|
958
1209
|
args: [
|
|
959
|
-
paymentIdFor(
|
|
960
|
-
|
|
1210
|
+
paymentIdFor(p.orderId),
|
|
1211
|
+
p.merchantWallet,
|
|
961
1212
|
ipCreator,
|
|
962
|
-
|
|
1213
|
+
p.orderId,
|
|
963
1214
|
],
|
|
964
|
-
value:
|
|
1215
|
+
value: p.totalWei,
|
|
965
1216
|
chain: deployment.chain,
|
|
966
1217
|
account: this.evmAccount,
|
|
967
1218
|
})
|
|
968
1219
|
: await walletClient.writeContract({
|
|
969
|
-
address:
|
|
1220
|
+
address: p.splitter,
|
|
970
1221
|
abi: SPLITTER_PAY_MATIC_ABI,
|
|
971
1222
|
functionName: "payMatic",
|
|
972
1223
|
args: [
|
|
973
|
-
|
|
1224
|
+
p.merchantWallet,
|
|
974
1225
|
ipCreator,
|
|
975
|
-
|
|
1226
|
+
p.orderId,
|
|
976
1227
|
],
|
|
977
|
-
value:
|
|
1228
|
+
value: p.totalWei,
|
|
978
1229
|
chain: deployment.chain,
|
|
979
1230
|
account: this.evmAccount,
|
|
980
1231
|
});
|
|
981
|
-
//
|
|
1232
|
+
// Wait for receipt (inclusion is enough on these fast-block chains).
|
|
982
1233
|
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
|
|
983
1234
|
if (receipt.status !== "success") {
|
|
984
1235
|
throw new AiFinPayError(`${deployment.chain.name} tx reverted: ${txHash}`);
|
|
985
1236
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1237
|
+
return txHash;
|
|
1238
|
+
}
|
|
1239
|
+
// ── AIFP-1 merchant paywall (gateway.aifinpay.io) ───────────────────────
|
|
1240
|
+
//
|
|
1241
|
+
// The protocol lives in aifp1.ts; this is the wiring. Two things only the
|
|
1242
|
+
// agent can supply: the on-chain settlement above, and the budget caps —
|
|
1243
|
+
// aifp1Fetch owns neither, exactly as call() owns them for the bridge flow.
|
|
1244
|
+
_aifp1Cache = new Aifp1ReceiptCache();
|
|
1245
|
+
/**
|
|
1246
|
+
* The prepaid batches this agent is holding.
|
|
1247
|
+
*
|
|
1248
|
+
* Exposed because a batch is money the agent has already spent and has not
|
|
1249
|
+
* finished using: an operator deciding whether to shut an agent down, or a
|
|
1250
|
+
* test asserting that a receipt was reused, both need to see it. The entries
|
|
1251
|
+
* contain bearer JWTs — treat them like keys, and do not log them.
|
|
1252
|
+
*/
|
|
1253
|
+
get aifp1Receipts() { return this._aifp1Cache; }
|
|
1254
|
+
/**
|
|
1255
|
+
* Fetch a paywalled URL, paying the AIFP-1 gateway if it asks.
|
|
1256
|
+
*
|
|
1257
|
+
* const res = await agent.fetchPaid("https://gateway.aifinpay.io/acme/articles/2026/x");
|
|
1258
|
+
*
|
|
1259
|
+
* A URL that is not paywalled — or that answers a 402 belonging to some
|
|
1260
|
+
* other protocol — comes back untouched and costs nothing. A paywalled one
|
|
1261
|
+
* is quoted, settled on-chain, exchanged for a receipt and retried, and the
|
|
1262
|
+
* receipt is kept: subsequent calls it covers cost a header rather than a
|
|
1263
|
+
* transaction. See aifp1.ts for why that reuse is the entire design.
|
|
1264
|
+
*
|
|
1265
|
+
* Returns `null` on the same terms as call(): a budget cap was hit and
|
|
1266
|
+
* `budgetCaps.on_limit_exceeded` is "skip".
|
|
1267
|
+
*/
|
|
1268
|
+
async fetchPaid(url, init = {}, opts = {}) {
|
|
1269
|
+
const deployment = SPLITTER_DEPLOYMENTS.polygon;
|
|
1270
|
+
const deps = {
|
|
1271
|
+
fetchImpl: this.inner.fetchImpl,
|
|
1272
|
+
cache: this._aifp1Cache,
|
|
1273
|
+
// A 0x address, because AIFP agent policies are address-keyed
|
|
1274
|
+
// (backend/aifp/agent-policy.js normalizeAddress) — a Solana pubkey here
|
|
1275
|
+
// would silently opt the agent out of its owner's own limits.
|
|
1276
|
+
agentId: opts.agentId ?? this.evmAddress,
|
|
1277
|
+
settle: (p) => this.settleSplitterNative({
|
|
1278
|
+
// Polygon-only, and not by preference: /v1/pay verifies the settlement
|
|
1279
|
+
// by reading the Splitter's Payment event, and that verifier implements
|
|
1280
|
+
// Polygon alone (backend/aifp/verify-settlement.js — Solana returns
|
|
1281
|
+
// notImplemented, anything else unsupported_chain). Settling elsewhere
|
|
1282
|
+
// would move real money for a receipt that can never be issued.
|
|
1283
|
+
chain: "polygon",
|
|
1284
|
+
// The address is not carried in the quote, so it comes from this SDK's
|
|
1285
|
+
// own verified registry. The backend checks the tx against its
|
|
1286
|
+
// configured SPLITTER_ADDRESS_POLYGON; if an operator has pointed that
|
|
1287
|
+
// somewhere else, /v1/pay answers no_splitter_payment_event, which is
|
|
1288
|
+
// loud. Override here if you are running against such a deployment.
|
|
1289
|
+
splitter: opts.splitter ?? deployment.splitter,
|
|
1290
|
+
// Left undetected on purpose — settleSplitterNative asks the contract,
|
|
1291
|
+
// which is the only source that cannot disagree with itself.
|
|
1292
|
+
merchantWallet: p.merchantWallet,
|
|
1293
|
+
totalWei: p.totalWei,
|
|
1294
|
+
orderId: p.orderId,
|
|
1295
|
+
}),
|
|
1296
|
+
checkPerCall: (usd) => this.checkPerCall(usd),
|
|
1297
|
+
reserveDaily: (usd) => this.reserveDaily(usd),
|
|
1298
|
+
commit: (id, usd) => this.ledger.commit(id, usd),
|
|
1299
|
+
release: (id) => this.ledger.release(id),
|
|
1300
|
+
onPaid: ({ merchantId, amountUsd, txRef }) => {
|
|
1301
|
+
this.spend24h.add(amountUsd);
|
|
1302
|
+
if (this.telemetry) {
|
|
1303
|
+
this.reportTelemetry({
|
|
1304
|
+
kind: "aifp1", merchant: merchantId, chain: "polygon",
|
|
1305
|
+
cost: amountUsd, tx: txRef,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
1310
|
+
return aifp1Fetch(deps, url, init, opts);
|
|
1002
1311
|
}
|
|
1003
1312
|
// ── Solana b2b_pay_with_split — build, sign, send via @solana/web3.js ───
|
|
1004
1313
|
//
|