@aifinpay/agent 2.3.2 → 2.5.1

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.
Files changed (70) hide show
  1. package/PAYMENT_RECEIPTS.md +194 -19
  2. package/README.md +37 -17
  3. package/dist/agentHistory.d.ts +9 -0
  4. package/dist/agentHistory.d.ts.map +1 -1
  5. package/dist/agentHistory.js +110 -16
  6. package/dist/agentHistory.js.map +1 -1
  7. package/dist/aifp1.d.ts +124 -17
  8. package/dist/aifp1.d.ts.map +1 -1
  9. package/dist/aifp1.js +580 -82
  10. package/dist/aifp1.js.map +1 -1
  11. package/dist/deploymentErrors.d.ts +5 -0
  12. package/dist/deploymentErrors.d.ts.map +1 -0
  13. package/dist/deploymentErrors.js +8 -0
  14. package/dist/deploymentErrors.js.map +1 -0
  15. package/dist/deploymentResolver.d.ts +2 -3
  16. package/dist/deploymentResolver.d.ts.map +1 -1
  17. package/dist/deploymentResolver.js +2 -6
  18. package/dist/deploymentResolver.js.map +1 -1
  19. package/dist/generated/solanaV14Deployments.generated.d.ts +1 -0
  20. package/dist/generated/solanaV14Deployments.generated.d.ts.map +1 -1
  21. package/dist/generated/solanaV14Deployments.generated.js +4 -2
  22. package/dist/generated/solanaV14Deployments.generated.js.map +1 -1
  23. package/dist/generated/v14Deployments.generated.d.ts +1 -1
  24. package/dist/generated/v14Deployments.generated.js +1 -1
  25. package/dist/index.d.ts +8 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/paymentChains.d.ts +95 -0
  30. package/dist/paymentChains.d.ts.map +1 -0
  31. package/dist/paymentChains.js +116 -0
  32. package/dist/paymentChains.js.map +1 -0
  33. package/dist/settlement.d.ts +5 -5
  34. package/dist/settlementSolanaV14.d.ts +135 -0
  35. package/dist/settlementSolanaV14.d.ts.map +1 -0
  36. package/dist/settlementSolanaV14.js +733 -0
  37. package/dist/settlementSolanaV14.js.map +1 -0
  38. package/dist/settlementV14.d.ts +9 -2
  39. package/dist/settlementV14.d.ts.map +1 -1
  40. package/dist/settlementV14.js +81 -14
  41. package/dist/settlementV14.js.map +1 -1
  42. package/dist/solanaDeploymentResolver.d.ts +2 -1
  43. package/dist/solanaDeploymentResolver.d.ts.map +1 -1
  44. package/dist/solanaDeploymentResolver.js +1 -32
  45. package/dist/solanaDeploymentResolver.js.map +1 -1
  46. package/dist/spendLedger.d.ts +74 -33
  47. package/dist/spendLedger.d.ts.map +1 -1
  48. package/dist/spendLedger.js +523 -130
  49. package/dist/spendLedger.js.map +1 -1
  50. package/dist/unifiedAgent.d.ts +17 -8
  51. package/dist/unifiedAgent.d.ts.map +1 -1
  52. package/dist/unifiedAgent.js +222 -37
  53. package/dist/unifiedAgent.js.map +1 -1
  54. package/package.json +4 -4
  55. package/dist/cli.d.ts +0 -3
  56. package/dist/cli.d.ts.map +0 -1
  57. package/dist/cli.js +0 -127
  58. package/dist/cli.js.map +0 -1
  59. package/dist/solanaV14Deployments.generated.d.ts +0 -35
  60. package/dist/solanaV14Deployments.generated.d.ts.map +0 -1
  61. package/dist/solanaV14Deployments.generated.js +0 -61
  62. package/dist/solanaV14Deployments.generated.js.map +0 -1
  63. package/dist/splitterRoutes.generated.d.ts +0 -22
  64. package/dist/splitterRoutes.generated.d.ts.map +0 -1
  65. package/dist/splitterRoutes.generated.js +0 -459
  66. package/dist/splitterRoutes.generated.js.map +0 -1
  67. package/dist/v14Deployments.generated.d.ts +0 -53
  68. package/dist/v14Deployments.generated.d.ts.map +0 -1
  69. package/dist/v14Deployments.generated.js +0 -89
  70. package/dist/v14Deployments.generated.js.map +0 -1
package/dist/aifp1.js CHANGED
@@ -33,13 +33,15 @@
33
33
  // AiFinPayAgent, but nothing else here does, and a test should be able to drive
34
34
  // the whole protocol without an RPC endpoint.
35
35
  // ──────────────────────────────────────────────────────────────────────────
36
- import { createHash, createPublicKey, verify as verifySignature } from "node:crypto";
36
+ import { createHash, createPublicKey, randomBytes, verify as verifySignature } from "node:crypto";
37
+ import bs58 from "bs58";
37
38
  import { keccak256, parseUnits, stringToHex } from "viem";
38
- import { V14_DEPLOYMENTS } from "./generated/v14Deployments.generated.js";
39
+ import { paymentChain, paymentStableAsset } from "./paymentChains.js";
39
40
  import { AiFinPayError } from "./errors.js";
40
- import { validateV14SettlementCall } from "./settlementV14.js";
41
+ import { validateV14SettlementCall, V14SettlementError } from "./settlementV14.js";
41
42
  import { SettlementConfirmationPendingError } from "./settlement.js";
42
43
  import { settlementHttp, SettlementHttpError } from "./settlementHttp.js";
44
+ import { authorizedSolanaV14Inventory, solanaV14Inventory, validateSolanaV14Call, solanaStableMint, assertPreparedSolanaV14Recovery, readSolanaV14FinalizedFailure, solanaLamportCostUsd, SolanaV14Error, } from "./settlementSolanaV14.js";
43
45
  // ── Errors ────────────────────────────────────────────────────────────────
44
46
  //
45
47
  // Specific classes rather than one message, because the recoveries differ: a
@@ -74,12 +76,109 @@ export class Aifp1PayError extends Aifp1Error {
74
76
  this.recovery = recovery;
75
77
  }
76
78
  }
79
+ /** Canonical failed original transaction reconciled to one fee-only debit. */
80
+ export class Aifp1FinalizedFailureError extends Aifp1PayError {
81
+ feeAmountUsd;
82
+ actualFeeLamports;
83
+ code = "AIFP1_FINALIZED_FAILURE";
84
+ constructor(recovery, feeAmountUsd, actualFeeLamports) {
85
+ super("Original Solana transaction finalized with failure; fee-only debit reconciled without resending", recovery.txRef, recovery.quote.quote_id, recovery);
86
+ this.feeAmountUsd = feeAmountUsd;
87
+ this.actualFeeLamports = actualFeeLamports;
88
+ }
89
+ }
90
+ /** Exact owner/quote binding for a locally selected capped v1.4 payment. */
91
+ export function paymentBudgetBinding(payment, payer, walletIdentity) {
92
+ const call = payment.quote.settlement_call;
93
+ if (call?.splitter_version !== "1.4")
94
+ throw new Aifp1QuoteError("Bound budgets require the original v1.4 call");
95
+ return {
96
+ apiBaseUrl: payment.apiBaseUrl.replace(/\/+$/, ""),
97
+ paymentIssuer: payment.paymentIssuer ?? "https://api.aifinpay.io",
98
+ payer: payment.chain === "solana" ? payer : payer.toLowerCase(),
99
+ ...(walletIdentity ? { walletIdentity } : {}),
100
+ ...(payment.chain === "solana" && payment.admissionSolUsdPrice !== undefined
101
+ ? {
102
+ solanaAdmissionRateUsd: payment.admissionSolUsdPrice,
103
+ solanaMaxFeeLamports: payment.maxFeeLamports,
104
+ solanaTransactionFeeLamports: payment.transactionFeeLamports,
105
+ }
106
+ : {}),
107
+ merchantId: payment.quote.merchant_id,
108
+ scope: payment.quote.scope,
109
+ resource: payment.quote.resource,
110
+ networkMode: payment.quote.network_mode ?? "live",
111
+ chain: payment.chain === "solana"
112
+ ? `solana:${call.network}:${call.contract}:${call.idl_sha256}`
113
+ : authorizedV14Chain(payment.chain),
114
+ asset: payment.asset,
115
+ token: payment.chain === "solana" ? call.args.quote.token : call.args.quote.token.toLowerCase(),
116
+ grossAmount: call.args.quote.grossAmount,
117
+ quoteId: payment.quote.quote_id,
118
+ };
119
+ }
77
120
  /** Recover an already settled payment. Never sends an on-chain transaction. */
78
121
  export function recoverAifp1Payment(recovery, signer, options = {}) {
122
+ if (recovery.family === "solana") {
123
+ const call = recovery.quote.settlement_call;
124
+ if (!options.solanaNetwork || !options.solanaEnvironment)
125
+ throw new Aifp1QuoteError("Solana recovery requires independent owner environment/network");
126
+ const d = solanaV14Inventory(options.solanaEnvironment, options.solanaNetwork);
127
+ assertPreparedSolanaV14Recovery(call, recovery.solana, d, signer.payerAddress, recovery.quote.quote_id);
128
+ if (recovery.chain !== "solana" ||
129
+ recovery.txRef !== recovery.solana.hash ||
130
+ call.asset !== recovery.asset ||
131
+ recovery.quote.payer !== signer.payerAddress ||
132
+ recovery.quote.accepted_chains.join() !== "solana" ||
133
+ recovery.quote.accepted_assets.join() !== recovery.asset)
134
+ throw new Aifp1QuoteError("Solana recovery purchase mismatch");
135
+ return (async () => {
136
+ const binding = paymentBudgetBinding(recovery, signer.payerAddress, recovery.budgetBindingVersion === 2 ? signer.walletIdentity : undefined);
137
+ if (recovery.budgetReservationId !== undefined) {
138
+ if (!signer.assertReservation || !signer.completeReservation)
139
+ throw new Aifp1QuoteError("Original bound ledger hooks required");
140
+ await signer.assertReservation(recovery.budgetReservationId, recovery.txRef, binding);
141
+ }
142
+ if (signer.solanaRpc && signer.finalizeFailedReservation && recovery.budgetReservationId !== undefined) {
143
+ let fee;
144
+ try {
145
+ solanaLamportCostUsd(0n, recovery.admissionSolUsdPrice);
146
+ if (!/^(?:0|[1-9][0-9]*)$/.test(recovery.transactionFeeLamports) ||
147
+ !/^[1-9][0-9]*$/.test(recovery.maxFeeLamports))
148
+ throw new Aifp1QuoteError("Original fee cap metadata is required");
149
+ fee = await readSolanaV14FinalizedFailure(call, recovery.solana, {
150
+ rpc: signer.solanaRpc,
151
+ deployment: d,
152
+ payer: signer.payerAddress,
153
+ orderId: recovery.quote.quote_id,
154
+ transactionFeeLamports: BigInt(recovery.transactionFeeLamports),
155
+ maxFeeLamports: BigInt(recovery.maxFeeLamports),
156
+ });
157
+ }
158
+ catch (error) {
159
+ throw new Aifp1PayError(error instanceof Error ? error.message : "Failure proof is incomplete; retain original reservation", recovery.txRef, recovery.quote.quote_id, recovery);
160
+ }
161
+ if (fee !== null) {
162
+ const feeUsd = solanaLamportCostUsd(fee, recovery.admissionSolUsdPrice);
163
+ try {
164
+ await signer.finalizeFailedReservation(recovery.budgetReservationId, recovery.txRef, binding, feeUsd);
165
+ }
166
+ catch (error) {
167
+ throw new Aifp1PayError(error instanceof Error ? error.message : "Canonical failure fee journal requires reconciliation", recovery.txRef, recovery.quote.quote_id, recovery);
168
+ }
169
+ throw new Aifp1FinalizedFailureError(recovery, feeUsd, fee);
170
+ }
171
+ }
172
+ const paid = await submitPayment({ ...signer, agentId: signer.payerAddress, fetchImpl: signer.fetchImpl ?? fetch }, recovery.apiBaseUrl, recovery.quote, recovery.txRef, recovery.asset, "solana", { paymentIssuer: recovery.paymentIssuer, ...options });
173
+ if (recovery.budgetReservationId !== undefined)
174
+ await signer.completeReservation(recovery.budgetReservationId, recovery.txRef, binding);
175
+ return paid;
176
+ })();
177
+ }
79
178
  const chain = authorizedV14Chain(recovery.chain);
80
179
  const call = recovery.quote.settlement_call;
81
180
  if (chain !== "polygon" && call?.splitter_version !== "1.4") {
82
- throw new Aifp1QuoteError("Base recovery requires the original v1.4 settlement call");
181
+ throw new Aifp1QuoteError(`Recovery on ${chain} requires the original v1.4 settlement call`);
83
182
  }
84
183
  if (call?.splitter_version === "1.4" &&
85
184
  (call.chain !== chain ||
@@ -89,6 +188,23 @@ export function recoverAifp1Payment(recovery, signer, options = {}) {
89
188
  !recovery.quote.accepted_assets.includes(recovery.asset))) {
90
189
  throw new Aifp1QuoteError("recovery chain or asset disagrees with the saved purchase");
91
190
  }
191
+ if (recovery.budgetReservationId !== undefined) {
192
+ if (typeof recovery.budgetReservationId !== "string" ||
193
+ !recovery.budgetReservationId ||
194
+ !/^0x(?:[0-9a-fA-F]{2})+$/.test(recovery.serializedTransaction ?? "") ||
195
+ keccak256(recovery.serializedTransaction) !== recovery.txRef ||
196
+ !signer.assertReservation ||
197
+ !signer.completeReservation) {
198
+ throw new Aifp1QuoteError("Bound budget recovery requires the original signed bytes and ledger hooks");
199
+ }
200
+ const binding = paymentBudgetBinding(recovery, signer.payerAddress, recovery.budgetBindingVersion === 2 ? signer.walletIdentity : undefined);
201
+ return (async () => {
202
+ await signer.assertReservation(recovery.budgetReservationId, recovery.txRef, binding);
203
+ const paid = await submitPayment({ ...signer, agentId: signer.payerAddress, fetchImpl: signer.fetchImpl ?? fetch }, recovery.apiBaseUrl, recovery.quote, recovery.txRef, recovery.asset, chain, { paymentIssuer: recovery.paymentIssuer, ...options });
204
+ await signer.completeReservation(recovery.budgetReservationId, recovery.txRef, binding);
205
+ return paid;
206
+ })();
207
+ }
92
208
  return submitPayment({ ...signer, agentId: signer.payerAddress, fetchImpl: signer.fetchImpl ?? fetch }, recovery.apiBaseUrl, recovery.quote, recovery.txRef, recovery.asset, chain, { paymentIssuer: recovery.paymentIssuer, ...options });
93
209
  }
94
210
  /** The gateway rejected a receipt we believed covered the request. */
@@ -96,7 +212,9 @@ export class Aifp1ReceiptRejectedError extends Aifp1Error {
96
212
  }
97
213
  export function describeQuote(q) {
98
214
  const ns = q.native_settlement;
99
- const pay = ns ? { amount: formatUnits(ns.total_wei, ns.decimals), asset: ns.asset } : null;
215
+ const pay = ns
216
+ ? { amount: formatUnits("total_lamports" in ns ? ns.total_lamports : ns.total_wei, ns.decimals), asset: ns.asset }
217
+ : null;
100
218
  // Fee as basis points, from the split the quote already carries. Stated so the
101
219
  // agent sees the rate, not just the total — the same reason the 402 does.
102
220
  let feeBps = null;
@@ -485,6 +603,134 @@ function validateCanonicalQuoteEconomics(quote) {
485
603
  throw new Aifp1QuoteError(`quote ${quote.quote_id} does not implement canonical AIFP-1 gross economics (payer 100%, merchant 99%, protocol 1%, creator 0%)`);
486
604
  }
487
605
  }
606
+ function validateSolanaQuote(quote, auth, payer, expiryMs, d, historical = false) {
607
+ const call = quote.settlement_call;
608
+ validateSolanaV14Call(call, d, payer, quote.quote_id, historical);
609
+ const asset = auth.asset ?? "SOL", q = call.args.quote, gross = BigInt(q.grossAmount), treasury = gross / 100n;
610
+ if (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/.test(quote.amount) ||
611
+ quote.accepted_chains.join() !== "solana" ||
612
+ quote.accepted_assets.join() !== asset ||
613
+ call.asset !== asset ||
614
+ quote.payer !== payer ||
615
+ q.merchant !== quote.pay_to.solana ||
616
+ q.validUntil !== String(Math.floor(expiryMs / 1000)) ||
617
+ quote.settlement.total_units !== parseUnits(quote.amount, 6).toString())
618
+ throw new Aifp1QuoteError("Solana quote amount/payer/network/merchant binding mismatch");
619
+ if (asset === "SOL") {
620
+ const n = quote.native_settlement;
621
+ if (!n ||
622
+ n.asset !== "SOL" ||
623
+ n.decimals !== 9 ||
624
+ n.settlement_semantics !== "gross-inclusive" ||
625
+ n.total_lamports !== q.grossAmount ||
626
+ n.merchant_lamports !== String(gross - treasury) ||
627
+ n.treasury_lamports !== String(treasury) ||
628
+ n.creator_lamports !== "0" ||
629
+ quote.token_settlement !== undefined ||
630
+ (n.valid_until !== undefined && String(n.valid_until) !== q.validUntil))
631
+ throw new Aifp1QuoteError("SOL9 gross-inclusive amount mismatch");
632
+ }
633
+ else {
634
+ const t = quote.token_settlement;
635
+ if (quote.native_settlement !== undefined ||
636
+ !t ||
637
+ t.asset !== asset ||
638
+ t.token !== solanaStableMint(auth.network, asset) ||
639
+ t.decimals !== 6 ||
640
+ t.settlement_semantics !== "gross-inclusive" ||
641
+ t.total_units !== q.grossAmount ||
642
+ t.total_units !== parseUnits(quote.amount, 6).toString() ||
643
+ t.merchant_units !== String(gross - treasury) ||
644
+ t.protocol_fee_units !== String(treasury) ||
645
+ t.creator_units !== "0")
646
+ throw new Aifp1QuoteError("SPL6 token gross-inclusive amount mismatch");
647
+ }
648
+ }
649
+ async function settleSolanaBatch(deps, opts, quote, plan, amountUsd, binding, reservation, site, send, markSettled) {
650
+ const auth = opts.solanaV14;
651
+ if (typeof reservation === "string" &&
652
+ (!deps.prepareReservation || !deps.assertReservation || !deps.completeReservation))
653
+ throw new Aifp1QuoteError("Solana capped payments require durable bound-journal hooks");
654
+ let prepared;
655
+ const recoveryFor = (tx) => ({
656
+ family: "solana",
657
+ chain: "solana",
658
+ apiBaseUrl: opts.apiBaseUrl ?? DEFAULT_API_BASE,
659
+ paymentIssuer: opts.paymentIssuer,
660
+ ...(deps.walletIdentity ? { budgetBindingVersion: 2 } : {}),
661
+ quote,
662
+ asset: auth.asset ?? "SOL",
663
+ txRef: tx.hash,
664
+ solana: tx,
665
+ reservedAmountUsd: amountUsd,
666
+ admissionSolUsdPrice: binding.solanaAdmissionRateUsd,
667
+ maxFeeLamports: binding.solanaMaxFeeLamports,
668
+ transactionFeeLamports: binding.solanaTransactionFeeLamports,
669
+ ...(typeof reservation === "string" ? { budgetReservationId: reservation } : {}),
670
+ });
671
+ let result;
672
+ try {
673
+ result = await deps.settleSolana(plan, async (tx) => {
674
+ assertPreparedSolanaV14Recovery(quote.settlement_call, tx, plan.deployment, deps.payerAddress, quote.quote_id);
675
+ if (typeof reservation === "string")
676
+ await deps.prepareReservation(reservation, tx.hash, binding);
677
+ await auth.onPrepared(recoveryFor(tx));
678
+ prepared = tx;
679
+ });
680
+ }
681
+ catch (error) {
682
+ if (prepared) {
683
+ markSettled(); // Unknown funds remain reserved even after blockhash expiry.
684
+ if (error instanceof SolanaV14Error &&
685
+ error.code === "SOLANA_V14_TRANSACTION_REVERTED" &&
686
+ error.prepared?.hash === prepared.hash &&
687
+ error.actualFeeLamports !== undefined &&
688
+ error.actualFeeLamports <= BigInt(binding.solanaMaxFeeLamports) &&
689
+ typeof reservation === "string" &&
690
+ deps.finalizeFailedReservation) {
691
+ const feeUsd = solanaLamportCostUsd(error.actualFeeLamports, binding.solanaAdmissionRateUsd);
692
+ try {
693
+ await deps.finalizeFailedReservation(reservation, prepared.hash, binding, feeUsd);
694
+ }
695
+ catch (error) {
696
+ throw new Aifp1PayError(error instanceof Error ? error.message : "Canonical failure fee journal requires reconciliation", prepared.hash, quote.quote_id, recoveryFor(prepared));
697
+ }
698
+ throw new Aifp1FinalizedFailureError(recoveryFor(prepared), feeUsd, error.actualFeeLamports);
699
+ }
700
+ throw new Aifp1PayError("Solana transaction prepared; recover its original signature without resending", prepared.hash, quote.quote_id, recoveryFor(prepared));
701
+ }
702
+ throw error;
703
+ }
704
+ markSettled();
705
+ const recovery = recoveryFor(result);
706
+ try {
707
+ if (typeof reservation === "string")
708
+ await deps.commit(reservation, amountUsd);
709
+ const paid = await submitPayment(deps, recovery.apiBaseUrl, quote, result.hash, recovery.asset, "solana", opts);
710
+ if (typeof reservation === "string")
711
+ await deps.completeReservation(reservation, result.hash, binding);
712
+ deps.cache.put({
713
+ site,
714
+ merchantId: paid.merchant_id,
715
+ receiptId: paid.receipt_id,
716
+ jwt: paid.receipt,
717
+ scope: paid.scope,
718
+ resource: paid.resource,
719
+ unitQuota: paid.unit_quota,
720
+ remaining: paid.unit_quota,
721
+ expiresAt: Date.parse(paid.expires_at),
722
+ amountUsd,
723
+ });
724
+ const response = await send(paid.receipt);
725
+ if (response.status === 402)
726
+ throw new Aifp1ReceiptRejectedError("Gateway rejected paid Solana receipt; recover existing purchase");
727
+ deps.onPaid?.({ merchantId: paid.merchant_id, amountUsd, txRef: result.hash, receiptId: paid.receipt_id });
728
+ return response;
729
+ }
730
+ catch (error) {
731
+ throw new Aifp1PayError(error instanceof Error ? error.message : "Solana receipt recovery required", result.hash, quote.quote_id, recovery);
732
+ }
733
+ }
488
734
  /** Is this response the AIFP-1 paywall asking for money? */
489
735
  function isAifp1Challenge(body) {
490
736
  const b = body;
@@ -510,8 +756,15 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
510
756
  if (opts.maxAmountUsd !== undefined && (!Number.isFinite(opts.maxAmountUsd) || opts.maxAmountUsd < 0)) {
511
757
  throw new Aifp1QuoteError("maxAmountUsd must be nonnegative and finite");
512
758
  }
513
- const chain = authorizedV14Chain(opts.v14?.chain);
514
- const nativeAsset = chain === "base" ? "ETH" : "POL";
759
+ if (opts.v14 && opts.solanaV14)
760
+ throw new Aifp1QuoteError("Only one payment family may be authorized");
761
+ const sol = opts.solanaV14;
762
+ const evmChain = authorizedV14Chain(opts.v14?.chain);
763
+ const chain = sol ? "solana" : evmChain;
764
+ const solDeployment = sol ? authorizedSolanaV14Inventory(sol.environment, sol.network) : undefined;
765
+ if (sol && (typeof sol.maxFeeLamports !== "bigint" || sol.maxFeeLamports <= 0n))
766
+ throw new Aifp1QuoteError("Explicit positive Solana fee plus rent budget required");
767
+ const nativeAsset = sol ? "SOL" : paymentChain(evmChain).native;
515
768
  const direct = opts.resourcePathMode === "direct";
516
769
  const parsed = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS, opts.resourcePathMode);
517
770
  let site = parsed.site;
@@ -641,36 +894,163 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
641
894
  const buyBatch = async () => {
642
895
  if (opts.maxAmountUsd === 0)
643
896
  throw new Aifp1QuoteError("No payment budget remains; cached access can still be used");
644
- if (typeof deps.signPaymentAuthorization !== "function" || !/^0x[0-9a-fA-F]{40}$/.test(deps.payerAddress || "")) {
897
+ let validPayer = /^0x[0-9a-fA-F]{40}$/.test(deps.payerAddress || "");
898
+ if (sol) {
899
+ try {
900
+ validPayer =
901
+ bs58.decode(deps.payerAddress).length === 32 &&
902
+ bs58.encode(bs58.decode(deps.payerAddress)) === deps.payerAddress;
903
+ }
904
+ catch {
905
+ validPayer = false;
906
+ }
907
+ }
908
+ if (typeof deps.signPaymentAuthorization !== "function" || !validPayer) {
645
909
  throw new Aifp1QuoteError("a settlement wallet signer is required to receive a payment receipt");
646
910
  }
647
911
  // 2. Quote.
648
- const stableAsset = pinnedStableAsset(opts.v14?.asset, chain);
912
+ const stableAsset = sol
913
+ ? sol.asset && sol.asset !== "SOL"
914
+ ? (solanaStableMint(sol.network, sol.asset), sol.asset)
915
+ : null
916
+ : pinnedStableAsset(opts.v14?.asset, evmChain);
649
917
  const apiBase = (opts.apiBaseUrl ?? DEFAULT_API_BASE).replace(/\/$/, "");
650
918
  const { scope, resource } = resolveScope(opts, challenge);
651
- const quote = await requestQuote(deps, apiBase, {
919
+ const quoteBody = {
652
920
  merchant_id: challenge.merchant_id,
653
921
  payer: deps.payerAddress,
654
922
  ...(resource !== undefined ? { resource } : {}),
655
923
  scope,
656
924
  units: opts.units ?? defaultUnitsFor(challenge),
657
925
  agent_id: deps.agentId,
658
- ...(opts.v14 ? { asset: stableAsset ?? nativeAsset } : {}),
659
- }, opts.apiTimeoutMs);
660
- if (quote.payer && quote.payer.toLowerCase() !== deps.payerAddress.toLowerCase()) {
926
+ ...(opts.v14 || sol ? { asset: stableAsset ?? nativeAsset } : {}),
927
+ ...(sol || opts.v14?.chain !== undefined ? { settlement_chain: chain } : {}),
928
+ };
929
+ let admission;
930
+ let admissionBinding;
931
+ let admissionContext;
932
+ if (sol) {
933
+ if (!deps.beginQuoteAdmission ||
934
+ !deps.adoptQuoteAdmission ||
935
+ !deps.closeQuoteAdmission ||
936
+ !deps.quoteOwnerContext)
937
+ throw new Aifp1QuoteError("Solana requires a durable quote-admission ledger and independent owner context");
938
+ admissionBinding = {
939
+ apiBaseUrl: apiBase,
940
+ paymentIssuer: opts.paymentIssuer ?? DEFAULT_API_BASE,
941
+ payer: deps.payerAddress,
942
+ merchantId: challenge.merchant_id,
943
+ scope,
944
+ resource: resource ?? "*",
945
+ networkMode: sol.network === "devnet" ? "test" : "live",
946
+ chain: `solana:${sol.network}:${solDeployment.programId}:${solDeployment.idl.sha256}`,
947
+ asset: stableAsset ?? nativeAsset,
948
+ token: stableAsset ? solanaStableMint(sol.network, stableAsset) : "11111111111111111111111111111111",
949
+ grossAmount: "0",
950
+ quoteId: "quote-admission",
951
+ quoteAdmissionVersion: "1",
952
+ ...(deps.walletIdentity ? { walletIdentity: deps.walletIdentity } : {}),
953
+ };
954
+ admissionContext = JSON.stringify([
955
+ deps.quoteOwnerContext,
956
+ sol.environment,
957
+ sol.network,
958
+ solDeployment.programId,
959
+ solDeployment.idl.sha256,
960
+ sol.maxFeeLamports.toString(),
961
+ opts.maxAmountUsd ?? null,
962
+ new URL(url).origin,
963
+ { ...quoteBody, units: opts.units ?? null },
964
+ ]);
965
+ admission = await deps.beginQuoteAdmission(admissionBinding, admissionContext, async () => {
966
+ const authorization = {
967
+ payer: deps.payerAddress,
968
+ network: sol.network,
969
+ network_mode: sol.network === "devnet" ? "test" : "live",
970
+ nonce: randomBytes(32).toString("hex"),
971
+ expires_at: Math.floor(Date.now() / 1000) + 240,
972
+ };
973
+ const statement = solanaQuoteAuthorizationMessage(quoteBody, authorization, opts.paymentIssuer ?? DEFAULT_API_BASE);
974
+ const signature = await deps.signPaymentAuthorization(statement);
975
+ return {
976
+ requestBody: JSON.stringify({ ...quoteBody, quote_authorization: { ...authorization, signature } }),
977
+ statement,
978
+ };
979
+ });
980
+ // The file contains no authority to change the owner-selected request or key.
981
+ try {
982
+ const body = JSON.parse(admission.requestBody), auth = body.quote_authorization;
983
+ const expected = { ...quoteBody, ...(opts.units === undefined ? { units: body.units } : {}) };
984
+ const { quote_authorization: _auth, ...originalBody } = body;
985
+ const statement = solanaQuoteAuthorizationMessage(body, auth, opts.paymentIssuer ?? DEFAULT_API_BASE);
986
+ const publicKey = createPublicKey({
987
+ key: Buffer.concat([
988
+ Buffer.from("302a300506032b6570032100", "hex"),
989
+ Buffer.from(bs58.decode(deps.payerAddress)),
990
+ ]),
991
+ format: "der",
992
+ type: "spki",
993
+ });
994
+ if (admission.phase !== "pending" ||
995
+ JSON.stringify(originalBody) !== JSON.stringify(expected) ||
996
+ auth.payer !== deps.payerAddress ||
997
+ auth.network !== sol.network ||
998
+ auth.network_mode !== admissionBinding.networkMode ||
999
+ !/^[0-9a-f]{64}$/.test(auth.nonce) ||
1000
+ !Number.isSafeInteger(auth.expires_at) ||
1001
+ auth.expires_at <= 0 ||
1002
+ !Number.isSafeInteger(body.units) ||
1003
+ body.units <= 0 ||
1004
+ statement !== admission.statement ||
1005
+ bs58.decode(auth.signature).length !== 64 ||
1006
+ bs58.encode(bs58.decode(auth.signature)) !== auth.signature ||
1007
+ !verifySignature(null, Buffer.from(statement), publicKey, bs58.decode(auth.signature)))
1008
+ throw new Error("binding");
1009
+ }
1010
+ catch {
1011
+ throw new Aifp1QuoteError("Stored quote admission does not match the exact owner-authorized request");
1012
+ }
1013
+ }
1014
+ const quote = admission?.quoteJson
1015
+ ? JSON.parse(admission.quoteJson)
1016
+ : await requestQuote(deps, apiBase, admission?.requestBody ?? quoteBody, opts.apiTimeoutMs, admission
1017
+ ? async (status, detail) => {
1018
+ const body = JSON.parse(admission.requestBody), auth = body.quote_authorization;
1019
+ if (status === 410 &&
1020
+ detail?.error === "AIFP-410-SOLANA" &&
1021
+ detail.reason === "solana_quote_authorization_expired" &&
1022
+ detail.admission_status === "not_admitted" &&
1023
+ auth.expires_at <= Math.floor(Date.now() / 1000) &&
1024
+ detail.authorization_nonce === auth.nonce &&
1025
+ detail.authorization_statement_hash ===
1026
+ createHash("sha256").update(admission.statement).digest("hex") &&
1027
+ detail.network === auth.network &&
1028
+ detail.network_mode === auth.network_mode &&
1029
+ detail.payer === deps.payerAddress)
1030
+ await deps.closeQuoteAdmission(admission.id, admissionBinding, admissionContext, "not-admitted");
1031
+ }
1032
+ : undefined);
1033
+ if (admission && !admission.quoteJson) {
1034
+ const quoteJson = JSON.stringify(quote);
1035
+ await deps.adoptQuoteAdmission(admission.id, admissionBinding, admissionContext, quoteJson);
1036
+ admission.quoteJson = quoteJson;
1037
+ }
1038
+ if (quote.payer &&
1039
+ (sol ? quote.payer !== deps.payerAddress : quote.payer.toLowerCase() !== deps.payerAddress.toLowerCase())) {
661
1040
  throw new Aifp1QuoteError("quote names a different paying wallet");
662
1041
  }
663
1042
  if (quote.payment_authorization && quote.payment_authorization.scheme !== "wallet-signature-v1") {
664
1043
  throw new Aifp1QuoteError("unsupported receipt authorization scheme");
665
1044
  }
666
1045
  trustedPaymentIssuer(quote, opts.paymentIssuer);
667
- const merchantWallet = quote.pay_to[chain] ?? quote.pay_to.evm;
668
- if (opts.v14 &&
1046
+ const merchantWallet = sol ? quote.pay_to.solana : (quote.pay_to[chain] ?? quote.pay_to.evm);
1047
+ if ((opts.v14 || sol) &&
669
1048
  (quote.accepted_chains.length !== 1 ||
670
1049
  quote.accepted_chains[0] !== chain ||
671
1050
  quote.settlement_call?.chain !== chain ||
672
1051
  !merchantWallet ||
673
- (quote.pay_to[chain] !== undefined &&
1052
+ (!sol &&
1053
+ quote.pay_to[chain] !== undefined &&
674
1054
  quote.pay_to.evm !== undefined &&
675
1055
  quote.pay_to[chain].toLowerCase() !== quote.pay_to.evm.toLowerCase()))) {
676
1056
  throw new Aifp1QuoteError("quote does not match the independently selected payment chain");
@@ -680,19 +1060,28 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
680
1060
  if (quote.merchant_id !== challenge.merchant_id) {
681
1061
  throw new Aifp1QuoteError(`quote ${quote.quote_id} is for merchant ${quote.merchant_id} but ${site} refused as ${challenge.merchant_id} — refusing to pay`);
682
1062
  }
683
- if (opts.v14 &&
1063
+ if ((opts.v14 || sol) &&
684
1064
  (quote.scope !== scope ||
685
1065
  quote.resource !== (resource ?? "*") ||
686
1066
  quote.currency !== "USD" ||
687
- (quote.network_mode ?? "live") !== "live" ||
1067
+ (quote.network_mode ?? "live") !== (sol?.network === "devnet" ? "test" : "live") ||
688
1068
  !Number.isSafeInteger(quote.unit_quota) ||
689
1069
  quote.unit_quota <= 0)) {
690
1070
  throw new Aifp1QuoteError("v1.4 quote does not match the requested scope, resource or live currency");
691
1071
  }
692
1072
  validateCanonicalQuoteEconomics(quote);
693
1073
  const quoteExpiryMs = Date.parse(quote.expires_at);
694
- if (!Number.isFinite(quoteExpiryMs) || quoteExpiryMs <= Date.now()) {
695
- throw new Aifp1QuoteError(`quote ${quote.quote_id} is expired or has invalid expires_at`);
1074
+ if (!Number.isFinite(quoteExpiryMs))
1075
+ throw new Aifp1QuoteError("Quote has invalid expiry; retain its admission");
1076
+ if (quoteExpiryMs <= Date.now()) {
1077
+ if (sol && admission && !admission.wasReserved) {
1078
+ validateSolanaQuote(quote, sol, deps.payerAddress, quoteExpiryMs, solDeployment, true);
1079
+ if (!deps.verifyHistoricalSolanaQuote)
1080
+ throw new Aifp1QuoteError("Historical quote signer verification is required");
1081
+ await deps.verifyHistoricalSolanaQuote(quote.settlement_call, sol, quote.quote_id);
1082
+ await deps.closeQuoteAdmission(admission.id, admissionBinding, admissionContext, "expired-unbroadcast", admission.quoteJson);
1083
+ }
1084
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} is expired; no transaction or new quote was submitted`);
696
1085
  }
697
1086
  // 3. Budget. The quote states the batch total in USD, so both caps are
698
1087
  // checked against the real figure, before anything is signed.
@@ -700,8 +1089,43 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
700
1089
  if (!Number.isFinite(amountUsd) || amountUsd < 0) {
701
1090
  throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
702
1091
  }
703
- if (stableAsset) {
704
- validateStableV14Quote(quote, stableAsset, deps.payerAddress, quoteExpiryMs, chain);
1092
+ let solPlan;
1093
+ let solRate;
1094
+ if (sol) {
1095
+ validateSolanaQuote(quote, sol, deps.payerAddress, quoteExpiryMs, solDeployment);
1096
+ const price = typeof opts.nativeUsdPrice === "function" ? await opts.nativeUsdPrice() : opts.nativeUsdPrice;
1097
+ const age = price ? Date.now() - price.observedAtMs : NaN;
1098
+ if (!price ||
1099
+ !Number.isFinite(price.usd) ||
1100
+ price.usd <= 0 ||
1101
+ !Number.isFinite(age) ||
1102
+ age < -5000 ||
1103
+ age > 60000)
1104
+ throw new Aifp1QuoteError("Fresh independent SOL/USD price required for gross and fee/rent budgeting");
1105
+ solRate = String(price.usd);
1106
+ solanaLamportCostUsd(0n, solRate); // Exact finite independently sourced rate.
1107
+ const gross = BigInt(quote.settlement_call.args.quote.grossAmount);
1108
+ if (!stableAsset) {
1109
+ const debitUsd = solanaLamportCostUsd(gross, solRate);
1110
+ const quotedRate = Number(quote.native_settlement?.rate_usd);
1111
+ if (!Number.isFinite(debitUsd) ||
1112
+ debitUsd <= 0 ||
1113
+ !Number.isFinite(quotedRate) ||
1114
+ quotedRate <= 0 ||
1115
+ Math.abs(debitUsd - amountUsd) > Math.max(amountUsd * 0.02, 1e-6) ||
1116
+ Math.abs(solanaLamportCostUsd(gross, String(quotedRate)) - amountUsd) > Math.max(amountUsd * 0.02, 1e-6))
1117
+ throw new Aifp1QuoteError("SOL gross disagrees with independent/quoted USD rate");
1118
+ amountUsd = Math.max(amountUsd, Math.ceil(debitUsd * 1e6) / 1e6);
1119
+ }
1120
+ if (!deps.prepareSolana || !deps.settleSolana)
1121
+ throw new Aifp1SettlementUnsupportedError("Solana executor unavailable");
1122
+ solPlan = await deps.prepareSolana(quote.settlement_call, sol, quote.quote_id);
1123
+ if (solPlan.feeRentLamports < 0n || solPlan.feeRentLamports > sol.maxFeeLamports)
1124
+ throw new Aifp1QuoteError("Solana fee/rent cap exceeded");
1125
+ amountUsd += solanaLamportCostUsd(solPlan.feeRentLamports, solRate);
1126
+ }
1127
+ else if (stableAsset) {
1128
+ validateStableV14Quote(quote, stableAsset, deps.payerAddress, quoteExpiryMs, evmChain);
705
1129
  }
706
1130
  else if (opts.settlementPin || opts.v14) {
707
1131
  const price = typeof opts.nativeUsdPrice === "function" ? await opts.nativeUsdPrice() : opts.nativeUsdPrice;
@@ -729,7 +1153,8 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
729
1153
  amountUsd = Math.max(amountUsd, debitUsd);
730
1154
  const call = quote.settlement_call;
731
1155
  const args = call?.args;
732
- if (native.asset !== nativeAsset ||
1156
+ if (quote.token_settlement !== undefined ||
1157
+ native.asset !== nativeAsset ||
733
1158
  native.decimals !== 18 ||
734
1159
  (opts.v14
735
1160
  ? quote.accepted_assets.length !== 1 || quote.accepted_assets[0] !== nativeAsset
@@ -773,11 +1198,45 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
773
1198
  }
774
1199
  if (!deps.checkPerCall(amountUsd))
775
1200
  return null;
776
- const reservation = await deps.reserveDaily(amountUsd);
1201
+ const budgetContext = {
1202
+ apiBaseUrl: apiBase,
1203
+ quote,
1204
+ asset: stableAsset ?? nativeAsset,
1205
+ chain,
1206
+ paymentIssuer: opts.paymentIssuer,
1207
+ ...(sol
1208
+ ? {
1209
+ admissionSolUsdPrice: solRate,
1210
+ maxFeeLamports: sol.maxFeeLamports.toString(),
1211
+ transactionFeeLamports: solPlan.transactionFeeLamports.toString(),
1212
+ }
1213
+ : {}),
1214
+ };
1215
+ const binding = opts.v14 || sol ? paymentBudgetBinding(budgetContext, deps.payerAddress, deps.walletIdentity) : undefined;
1216
+ const reservation = await deps.reserveDaily(amountUsd, binding, admission?.id);
777
1217
  if (reservation === "skip")
778
1218
  return null;
779
1219
  let settled = false;
1220
+ let preparedTx;
1221
+ const recoveryFor = (txRef) => ({
1222
+ ...budgetContext,
1223
+ chain: evmChain,
1224
+ txRef,
1225
+ ...(deps.walletIdentity ? { budgetBindingVersion: 2 } : {}),
1226
+ ...(binding && typeof reservation === "string" ? { budgetReservationId: reservation } : {}),
1227
+ ...(preparedTx?.hash === txRef ? { serializedTransaction: preparedTx.serializedTransaction } : {}),
1228
+ });
780
1229
  try {
1230
+ if (sol) {
1231
+ return await settleSolanaBatch(deps, opts, quote, solPlan, amountUsd, binding, reservation, site, send, () => {
1232
+ settled = true;
1233
+ });
1234
+ }
1235
+ if (binding &&
1236
+ typeof reservation === "string" &&
1237
+ (!deps.prepareReservation || !deps.assertReservation || !deps.completeReservation)) {
1238
+ throw new Aifp1QuoteError("Capped v1.4 payments require durable bound-journal ledger hooks before broadcast");
1239
+ }
781
1240
  // 4. Settle on-chain, then exchange the tx for a receipt.
782
1241
  let settleParams;
783
1242
  let paidAsset;
@@ -866,41 +1325,31 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
866
1325
  ...(opts.v14
867
1326
  ? {
868
1327
  onPrepared: async (tx) => {
1328
+ if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(tx.serializedTransaction) ||
1329
+ keccak256(tx.serializedTransaction) !== tx.hash) {
1330
+ throw new Aifp1QuoteError("Prepared transaction hash disagrees with its signed bytes");
1331
+ }
1332
+ if (binding && typeof reservation === "string")
1333
+ await deps.prepareReservation(reservation, tx.hash, binding);
869
1334
  await opts.v14.onPrepared({
870
- apiBaseUrl: apiBase,
871
- quote,
872
- txRef: tx.hash,
873
- asset: paidAsset,
874
- chain,
875
- paymentIssuer: opts.paymentIssuer,
1335
+ ...recoveryFor(tx.hash),
876
1336
  serializedTransaction: tx.serializedTransaction,
877
1337
  });
1338
+ preparedTx = tx;
878
1339
  },
879
1340
  }
880
1341
  : {}),
881
1342
  });
882
1343
  }
883
1344
  catch (error) {
884
- if (error instanceof SettlementConfirmationPendingError && error.stage === "settlement") {
1345
+ const pending = error instanceof SettlementConfirmationPendingError && error.stage === "settlement";
1346
+ const provenRevert = error instanceof V14SettlementError && error.code === "V14_TRANSACTION_REVERTED";
1347
+ if (pending || (preparedTx && !provenRevert)) {
885
1348
  // Treat unknown confirmation as spent until reconciliation. Releasing
886
1349
  // the reservation here would let a retry spend the same budget again.
887
1350
  settled = true;
888
- const recovery = {
889
- apiBaseUrl: apiBase,
890
- quote,
891
- txRef: error.txHash,
892
- asset: paidAsset,
893
- chain,
894
- paymentIssuer: opts.paymentIssuer,
895
- };
896
- try {
897
- if (typeof reservation === "string")
898
- await deps.commit(reservation, amountUsd);
899
- }
900
- catch {
901
- throw new Aifp1PayError("payment broadcast; confirmation and budget reconciliation required", error.txHash, quote.quote_id, recovery);
902
- }
903
- throw new Aifp1PayError("payment broadcast; recover its confirmation and receipt without paying again", error.txHash, quote.quote_id, recovery);
1351
+ const hash = pending ? error.txHash : preparedTx.hash;
1352
+ throw new Aifp1PayError("payment broadcast; recover its confirmation and receipt without paying again", hash, quote.quote_id, recoveryFor(hash));
904
1353
  }
905
1354
  throw error;
906
1355
  }
@@ -913,16 +1362,17 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
913
1362
  await deps.commit(reservation, amountUsd);
914
1363
  }
915
1364
  catch {
916
- throw new Aifp1PayError("payment settled; budget reconciliation and receipt recovery required", txRef, quote.quote_id, {
917
- apiBaseUrl: apiBase,
918
- quote,
919
- txRef,
920
- asset: paidAsset,
921
- chain,
922
- paymentIssuer: opts.paymentIssuer,
923
- });
1365
+ throw new Aifp1PayError("payment settled; budget reconciliation and receipt recovery required", txRef, quote.quote_id, recoveryFor(txRef));
1366
+ }
1367
+ let paid;
1368
+ try {
1369
+ paid = await submitPayment(deps, apiBase, quote, txRef, paidAsset, chain, opts);
1370
+ if (binding && typeof reservation === "string")
1371
+ await deps.completeReservation(reservation, txRef, binding);
1372
+ }
1373
+ catch (error) {
1374
+ throw new Aifp1PayError(error instanceof Error ? error.message : "Payment receipt/budget recovery required", txRef, quote.quote_id, recoveryFor(txRef));
924
1375
  }
925
- const paid = await submitPayment(deps, apiBase, quote, txRef, paidAsset, chain, opts);
926
1376
  // 6. Keep the batch. This is the whole point of the design: the next call
927
1377
  // this receipt covers costs a header, not a transaction.
928
1378
  const entry = {
@@ -960,14 +1410,7 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
960
1410
  receiptResp = await send(paid.receipt);
961
1411
  }
962
1412
  catch {
963
- throw new Aifp1PayError("payment settled and receipt cached; content request failed, retry without paying again", txRef, quote.quote_id, {
964
- apiBaseUrl: apiBase,
965
- quote,
966
- txRef,
967
- asset: paidAsset,
968
- chain,
969
- paymentIssuer: opts.paymentIssuer,
970
- });
1413
+ throw new Aifp1PayError("payment settled and receipt cached; content request failed, retry without paying again", txRef, quote.quote_id, recoveryFor(txRef));
971
1414
  }
972
1415
  if (receiptResp.status === 402) {
973
1416
  throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with settled receipt ${paid.receipt_id} (tx ${txRef}); recover the existing payment`);
@@ -986,8 +1429,7 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
986
1429
  return receiptResp;
987
1430
  }
988
1431
  finally {
989
- // Anything that did not move money gives the cap back now rather than
990
- // waiting for the reservation to expire — same rule as call().
1432
+ // Only a proven prebroadcast failure/reverted settlement gives the cap back.
991
1433
  if (typeof reservation === "string" && !settled)
992
1434
  await deps.release(reservation);
993
1435
  }
@@ -1005,26 +1447,25 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
1005
1447
  function authorizedV14Chain(chain) {
1006
1448
  if (chain === undefined)
1007
1449
  return "polygon";
1008
- if (chain !== "polygon" && chain !== "base")
1450
+ if (!paymentChain(chain))
1009
1451
  throw new Aifp1QuoteError(`unsupported v1.4 chain "${chain}"`);
1010
1452
  return chain;
1011
1453
  }
1012
1454
  /** Native by default; stablecoin symbols must be pinned for the selected chain. */
1013
1455
  function pinnedStableAsset(asset, chain) {
1014
- if (asset === undefined || asset === (chain === "base" ? "ETH" : "POL"))
1456
+ if (asset === undefined || asset === paymentChain(chain).native)
1015
1457
  return null;
1016
- const pinned = V14_DEPLOYMENTS[chain]?.splitter.assets.find((a) => a.symbol === asset);
1458
+ const pinned = paymentStableAsset(chain, asset);
1017
1459
  if (!pinned) {
1018
- throw new Aifp1QuoteError(`v14.asset "${asset}" is not a stablecoin pinned for ${chain} v1.4 ` +
1019
- `(${(V14_DEPLOYMENTS[chain]?.splitter.assets ?? []).map((a) => a.symbol).join(", ") || "none"})`);
1460
+ throw new Aifp1QuoteError(`v14.asset "${asset}" is not a stablecoin pinned for ${chain} v1.4`);
1020
1461
  }
1021
1462
  return pinned.symbol;
1022
1463
  }
1023
1464
  /**
1024
1465
  * Check a v1.4 stablecoin quote before any budget is reserved. A token quote
1025
1466
  * has no FX to cross-check, so the binding is exact instead: the signed gross
1026
- * equals the stated settlement units, which equal the stated USD amount in
1027
- * 6-decimal micro-dollars; the token is the pinned one; the approval is for
1467
+ * equals the pinned-decimal conversion of the stated USD micro-dollars;
1468
+ * optional token metadata is checked in exact token units; the approval is for
1028
1469
  * exactly that gross; and the quote accepts nothing else.
1029
1470
  */
1030
1471
  function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
@@ -1034,17 +1475,37 @@ function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
1034
1475
  }
1035
1476
  const signed = call;
1036
1477
  validateV14SettlementCall(signed, { orderId: quote.quote_id, payer });
1037
- const token = V14_DEPLOYMENTS[chain]?.splitter.assets.find((a) => a.symbol === asset)?.address;
1478
+ const pin = paymentStableAsset(chain, asset);
1479
+ const token = pin?.address;
1038
1480
  const merchant = quote.pay_to[chain] ?? quote.pay_to.evm;
1039
1481
  const q = signed.args.quote;
1040
1482
  let micro;
1041
1483
  try {
1484
+ if (!/^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,6})?$/.test(String(quote.amount)))
1485
+ throw new Error("non-exact USD amount");
1042
1486
  micro = parseUnits(String(quote.amount), 6);
1043
1487
  }
1044
1488
  catch {
1045
1489
  throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
1046
1490
  }
1047
1491
  const units = quote.settlement?.total_units;
1492
+ const gross = pin ? micro * 10n ** BigInt(pin.decimals - 6) : 0n;
1493
+ const metadata = quote.token_settlement;
1494
+ // Split the actual token gross, not the rounded legacy micro-dollar legs.
1495
+ const treasury = gross / 100n;
1496
+ if ((pin?.decimals === 18 && metadata === undefined) ||
1497
+ (metadata !== undefined &&
1498
+ (!metadata ||
1499
+ typeof metadata !== "object" ||
1500
+ metadata.asset !== asset ||
1501
+ metadata.token?.toLowerCase() !== token?.toLowerCase() ||
1502
+ metadata.decimals !== pin?.decimals ||
1503
+ metadata.settlement_semantics !== "gross-inclusive" ||
1504
+ metadata.total_units !== String(gross) ||
1505
+ metadata.merchant_units !== String(gross - treasury) ||
1506
+ metadata.protocol_fee_units !== String(treasury) ||
1507
+ metadata.creator_units !== "0")))
1508
+ throw new Aifp1QuoteError("token_settlement disagrees with pinned token, decimals or exact gross-inclusive split");
1048
1509
  if (signed.chain !== chain ||
1049
1510
  signed.asset !== asset ||
1050
1511
  signed.route !== "merchant-aifp1" ||
@@ -1058,8 +1519,9 @@ function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
1058
1519
  quote.accepted_assets.length !== 1 ||
1059
1520
  quote.accepted_assets[0] !== asset ||
1060
1521
  units === undefined ||
1061
- q.grossAmount !== units ||
1062
- BigInt(q.grossAmount) !== micro ||
1522
+ units !== String(micro) ||
1523
+ q.grossAmount !== String(gross) ||
1524
+ gross >= 2n ** 256n ||
1063
1525
  micro <= 0n ||
1064
1526
  !signed.approval ||
1065
1527
  signed.approval.token.toLowerCase() !== token.toLowerCase() ||
@@ -1069,20 +1531,28 @@ function validateStableV14Quote(quote, asset, payer, quoteExpiryMs, chain) {
1069
1531
  throw new Aifp1QuoteError("signed v1.4 stablecoin call disagrees with the requested asset, merchant, amount, approval or expiry");
1070
1532
  }
1071
1533
  }
1072
- async function requestQuote(deps, apiBase, body, timeoutMs) {
1534
+ async function requestQuote(deps, apiBase, body, timeoutMs, onRefusal) {
1073
1535
  let r;
1074
1536
  let text;
1075
1537
  try {
1076
1538
  ({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/quote`, {
1077
1539
  method: "POST",
1078
1540
  headers: { "content-type": "application/json", "AIFP-Agent-Id": deps.agentId },
1079
- body: JSON.stringify(body),
1541
+ body: typeof body === "string" ? body : JSON.stringify(body),
1080
1542
  }, timeoutMs));
1081
1543
  }
1082
1544
  catch (e) {
1083
1545
  throw new Aifp1QuoteError(`POST ${apiBase}/v1/quote failed: ${e.message}`);
1084
1546
  }
1085
1547
  if (!r.ok) {
1548
+ let detail = null;
1549
+ try {
1550
+ detail = JSON.parse(text);
1551
+ }
1552
+ catch {
1553
+ /* Unknown response keeps the admission. */
1554
+ }
1555
+ await onRefusal?.(r.status, detail);
1086
1556
  // The server's own detail is the useful part — a scope refused for a
1087
1557
  // non-flat-rated tier, a batch under the minimum, an owner policy — and
1088
1558
  // none of that is guessable from the status alone.
@@ -1100,6 +1570,28 @@ async function requestQuote(deps, apiBase, body, timeoutMs) {
1100
1570
  }
1101
1571
  return quote;
1102
1572
  }
1573
+ /** Sign only this locally constructed request; a server cannot choose the
1574
+ * ownership statement. Retry the same body/nonce after an uncertain response. */
1575
+ export function solanaQuoteAuthorizationMessage(body, auth, issuer) {
1576
+ return JSON.stringify([
1577
+ "AiFinPay quote authorization v1",
1578
+ issuer,
1579
+ auth.network,
1580
+ auth.network_mode,
1581
+ body.merchant_id,
1582
+ body.resource ?? null,
1583
+ body.tier ?? "standard",
1584
+ body.requests ?? null,
1585
+ body.units ?? null,
1586
+ body.currency ?? "USD",
1587
+ body.scope ?? "exact",
1588
+ "solana",
1589
+ body.asset ?? "SOL",
1590
+ auth.payer,
1591
+ auth.nonce,
1592
+ auth.expires_at,
1593
+ ]);
1594
+ }
1103
1595
  // ── /v1/pay ───────────────────────────────────────────────────────────────
1104
1596
  /**
1105
1597
  * Exchange a settled transaction for a receipt.
@@ -1128,7 +1620,7 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, chain, opts) {
1128
1620
  if (attempt > 0 && Date.now() >= deadline)
1129
1621
  throw failure("payment confirmation deadline elapsed; recover the existing transaction");
1130
1622
  const expiresAt = Math.floor(Date.now() / 1000) + 240;
1131
- const payer = deps.payerAddress.toLowerCase();
1623
+ const payer = chain === "solana" ? deps.payerAddress : deps.payerAddress.toLowerCase();
1132
1624
  // Construct this locally; never sign an arbitrary server-provided message.
1133
1625
  const message = paymentAuthorizationMessage({
1134
1626
  quote,
@@ -1244,14 +1736,20 @@ async function verifyPaidReceipt(paid, quote, asset, chain, txRef, payer, issuer
1244
1736
  if (claims.iss !== issuer ||
1245
1737
  claims.aud !== quote.merchant_id ||
1246
1738
  typeof claims.sub !== "string" ||
1247
- claims.sub.toLowerCase() !== payer.toLowerCase() ||
1739
+ (chain === "solana" ? claims.sub !== payer : claims.sub.toLowerCase() !== payer.toLowerCase()) ||
1248
1740
  claims.tx_ref !== txRef ||
1249
1741
  claims.scope !== quote.scope ||
1250
1742
  claims.resource !== quote.resource ||
1251
1743
  claims.chain !== chain ||
1252
- claims.asset !== asset ||
1744
+ (chain === "solana" &&
1745
+ (claims.network !== quote.settlement_call.network ||
1746
+ claims.program !== quote.settlement_call.contract ||
1747
+ paid.network !== claims.network ||
1748
+ paid.program !== claims.program)) ||
1749
+ typeof claims.asset !== "string" ||
1750
+ claims.asset.toUpperCase() !== asset.toUpperCase() ||
1253
1751
  claims.currency !== "USD" ||
1254
- (claims.network_mode ?? "live") !== "live" ||
1752
+ (claims.network_mode ?? "live") !== (quote.network_mode ?? "live") ||
1255
1753
  Number(claims.amount) !== Number(quote.amount) ||
1256
1754
  claims.unit_quota !== quote.unit_quota ||
1257
1755
  !Number.isSafeInteger(claims.exp) ||