@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.
@@ -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.70,
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: 3000,
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: 3000,
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: 3000,
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
- constructor(needed_usd, available_usd, msg) {
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
- const withinBudget = this.checkBudget(cost);
808
- if (!withinBudget)
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
- if (provider.mode === "session") {
811
- throw new AiFinPayError(`Provider ${provider.name} requires session mode — use openSession() (phase-3 feature)`);
812
- }
813
- const url = opts.bridgeUrl ?? provider.bridge_url;
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
- challenge = (await initialResp.json());
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 ps = challenge.pay_solana;
861
- // Guard: the bridge controls the challenge — verify the demanded
862
- // amount is in the same ballpark as the declared cost / caps before
863
- // signing anything. Stops a misquoting bridge from draining the agent.
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 lamports = Number(ps.total_lamports ?? ps.merchant_amount_lamports);
866
- const solUsd = parseFloat(process.env.AIFINPAY_SOL_USD ?? "200");
867
- const estUsd = (Number.isFinite(lamports) ? lamports / 1e9 : 0) * solUsd;
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
- const solTxSig = await this.submitSolanaB2BPayWithSplit(ps);
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-solana-tx": solTxSig,
875
- "x-order-id": ps.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 Solana payment ${solTxSig}: ${detail.slice(0, 300)}`);
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: solTxSig });
884
- // @internal — attach settlement metadata for consumers (MCP, telemetry
885
- // dashboards) that need to surface the tx hash without an extra RPC
886
- // call. Response headers are read-only after construction so we use
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
- // ── EVM branch (B2BSplitter.payMatic — generic native-token atomic split) ──
893
- // Settles on any chain in SPLITTER_DEPLOYMENTS (polygon default; base,
894
- // optimism, unichain, botchain, xrplevm). Native token only — the
895
- // deployed splitter payment path has no ERC-20/USDC entrypoint.
896
- if (!challenge.pay_matic) {
897
- throw new X402Error(`bridge ${provider.name} returned 402 but no pay_matic block — only legacy AiFinPay/Coinbase facilitators not yet wired into AiFinPayAgent.call()`);
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
- const pm = challenge.pay_matic;
900
- const deployment = SPLITTER_DEPLOYMENTS[chain];
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
- // Guard: same ballpark check as the Solana branch — never sign for an
911
- // amount wildly above the declared cost / per-call cap. Native-token
912
- // USD price per chain via SPLITTER_DEPLOYMENTS.nativeUsdEnv.
913
- {
914
- const wei = Number(pm.total_wei);
915
- const nativeUsd = parseFloat(process.env[deployment.nativeUsdEnv] ?? String(deployment.nativeUsdDefault));
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 `splitter_version` wins — a server that states it knows what
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 challenge and
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 = pm.splitter_version
952
- ?? await this.detectSplitterVersion(splitterAddress, chain, deployment.version);
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: splitterAddress,
1206
+ address: p.splitter,
956
1207
  abi: SPLITTER_PAY_NATIVE_ABI,
957
1208
  functionName: "payNative",
958
1209
  args: [
959
- paymentIdFor(pm.order_id),
960
- pm.merchant_wallet,
1210
+ paymentIdFor(p.orderId),
1211
+ p.merchantWallet,
961
1212
  ipCreator,
962
- pm.order_id,
1213
+ p.orderId,
963
1214
  ],
964
- value: BigInt(pm.total_wei),
1215
+ value: p.totalWei,
965
1216
  chain: deployment.chain,
966
1217
  account: this.evmAccount,
967
1218
  })
968
1219
  : await walletClient.writeContract({
969
- address: splitterAddress,
1220
+ address: p.splitter,
970
1221
  abi: SPLITTER_PAY_MATIC_ABI,
971
1222
  functionName: "payMatic",
972
1223
  args: [
973
- pm.merchant_wallet,
1224
+ p.merchantWallet,
974
1225
  ipCreator,
975
- pm.order_id,
1226
+ p.orderId,
976
1227
  ],
977
- value: BigInt(pm.total_wei),
1228
+ value: p.totalWei,
978
1229
  chain: deployment.chain,
979
1230
  account: this.evmAccount,
980
1231
  });
981
- // 3. Wait for receipt (inclusion is enough on these fast-block chains).
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
- // 4. Retry the bridge with payment proof.
987
- const paidResp = await this.inner.fetchImpl(fullUrl, buildInit({
988
- "x-tx-hash": txHash,
989
- "x-order-id": pm.order_id,
990
- }));
991
- if (!paidResp.ok) {
992
- const detail = await paidResp.text().catch(() => "<unreadable>");
993
- throw new AiFinPayError(`Bridge retry failed ${paidResp.status} after on-chain payment ${txHash}: ${detail.slice(0, 300)}`);
994
- }
995
- this.spend24h.add(cost);
996
- if (this.telemetry)
997
- this.reportTelemetry({ kind: "call", provider: provider.name, chain, cost, tx: txHash });
998
- // @internal — see Solana branch above for rationale on property-attach.
999
- paidResp.aifinpayTx = txHash;
1000
- paidResp.aifinpayChain = chain;
1001
- return paidResp;
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
  //