@aifinpay/agent 1.8.4 → 2.0.0-rc.13

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 (93) hide show
  1. package/PAYMENT_RECEIPTS.md +110 -0
  2. package/README.md +93 -35
  3. package/dist/agent.d.ts +6 -1
  4. package/dist/agent.d.ts.map +1 -1
  5. package/dist/agent.js +50 -12
  6. package/dist/agent.js.map +1 -1
  7. package/dist/agentHistory.d.ts +15 -0
  8. package/dist/agentHistory.d.ts.map +1 -0
  9. package/dist/agentHistory.js +47 -0
  10. package/dist/agentHistory.js.map +1 -0
  11. package/dist/agentPassport.d.ts +95 -0
  12. package/dist/agentPassport.d.ts.map +1 -0
  13. package/dist/agentPassport.js +177 -0
  14. package/dist/agentPassport.js.map +1 -0
  15. package/dist/aifp1.d.ts +135 -8
  16. package/dist/aifp1.d.ts.map +1 -1
  17. package/dist/aifp1.js +317 -50
  18. package/dist/aifp1.js.map +1 -1
  19. package/dist/apiUrl.d.ts +3 -0
  20. package/dist/apiUrl.d.ts.map +1 -0
  21. package/dist/apiUrl.js +8 -0
  22. package/dist/apiUrl.js.map +1 -0
  23. package/dist/chains.d.ts +4 -0
  24. package/dist/chains.d.ts.map +1 -0
  25. package/dist/chains.js +24 -0
  26. package/dist/chains.js.map +1 -0
  27. package/dist/deploymentResolver.d.ts +87 -0
  28. package/dist/deploymentResolver.d.ts.map +1 -0
  29. package/dist/deploymentResolver.js +145 -0
  30. package/dist/deploymentResolver.js.map +1 -0
  31. package/dist/facilitators/aifinpay.d.ts +4 -5
  32. package/dist/facilitators/aifinpay.d.ts.map +1 -1
  33. package/dist/facilitators/aifinpay.js +49 -19
  34. package/dist/facilitators/aifinpay.js.map +1 -1
  35. package/dist/facilitators/base.d.ts +12 -1
  36. package/dist/facilitators/base.d.ts.map +1 -1
  37. package/dist/facilitators/detect.d.ts +3 -3
  38. package/dist/facilitators/detect.d.ts.map +1 -1
  39. package/dist/facilitators/detect.js +3 -22
  40. package/dist/facilitators/detect.js.map +1 -1
  41. package/dist/facilitators/standard-x402.d.ts +4 -8
  42. package/dist/facilitators/standard-x402.d.ts.map +1 -1
  43. package/dist/facilitators/standard-x402.js +218 -92
  44. package/dist/facilitators/standard-x402.js.map +1 -1
  45. package/dist/index.d.ts +24 -18
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +22 -34
  48. package/dist/index.js.map +1 -1
  49. package/dist/localIdentity.d.ts +17 -0
  50. package/dist/localIdentity.d.ts.map +1 -0
  51. package/dist/localIdentity.js +81 -0
  52. package/dist/localIdentity.js.map +1 -0
  53. package/dist/settlement.d.ts +198 -0
  54. package/dist/settlement.d.ts.map +1 -0
  55. package/dist/settlement.js +429 -0
  56. package/dist/settlement.js.map +1 -0
  57. package/dist/settlementHttp.d.ts +10 -0
  58. package/dist/settlementHttp.d.ts.map +1 -0
  59. package/dist/settlementHttp.js +70 -0
  60. package/dist/settlementHttp.js.map +1 -0
  61. package/dist/settlementV14.d.ts +136 -0
  62. package/dist/settlementV14.d.ts.map +1 -0
  63. package/dist/settlementV14.js +170 -0
  64. package/dist/settlementV14.js.map +1 -0
  65. package/dist/solanaDeploymentResolver.d.ts +90 -0
  66. package/dist/solanaDeploymentResolver.d.ts.map +1 -0
  67. package/dist/solanaDeploymentResolver.js +143 -0
  68. package/dist/solanaDeploymentResolver.js.map +1 -0
  69. package/dist/solanaV14Deployments.generated.d.ts +35 -0
  70. package/dist/solanaV14Deployments.generated.d.ts.map +1 -0
  71. package/dist/solanaV14Deployments.generated.js +61 -0
  72. package/dist/solanaV14Deployments.generated.js.map +1 -0
  73. package/dist/splitterRoutes.d.ts +120 -0
  74. package/dist/splitterRoutes.d.ts.map +1 -0
  75. package/dist/splitterRoutes.generated.d.ts +22 -0
  76. package/dist/splitterRoutes.generated.d.ts.map +1 -0
  77. package/dist/splitterRoutes.generated.js +459 -0
  78. package/dist/splitterRoutes.generated.js.map +1 -0
  79. package/dist/splitterRoutes.js +73 -0
  80. package/dist/splitterRoutes.js.map +1 -0
  81. package/dist/unifiedAgent.d.ts +28 -18
  82. package/dist/unifiedAgent.d.ts.map +1 -1
  83. package/dist/unifiedAgent.js +85 -325
  84. package/dist/unifiedAgent.js.map +1 -1
  85. package/dist/v14Deployments.generated.d.ts +53 -0
  86. package/dist/v14Deployments.generated.d.ts.map +1 -0
  87. package/dist/v14Deployments.generated.js +89 -0
  88. package/dist/v14Deployments.generated.js.map +1 -0
  89. package/dist/wallet.d.ts +11 -52
  90. package/dist/wallet.d.ts.map +1 -1
  91. package/dist/wallet.js +27 -51
  92. package/dist/wallet.js.map +1 -1
  93. package/package.json +7 -4
package/dist/aifp1.js CHANGED
@@ -34,7 +34,10 @@
34
34
  // the whole protocol without an RPC endpoint.
35
35
  // ──────────────────────────────────────────────────────────────────────────
36
36
  import { createHash } from "node:crypto";
37
+ import { keccak256, stringToHex } from "viem";
37
38
  import { AiFinPayError } from "./errors.js";
39
+ import { SettlementConfirmationPendingError } from "./settlement.js";
40
+ import { settlementHttp, SettlementHttpError } from "./settlementHttp.js";
38
41
  // ── Errors ────────────────────────────────────────────────────────────────
39
42
  //
40
43
  // Specific classes rather than one message, because the recoveries differ: a
@@ -55,20 +58,70 @@ export class Aifp1SettlementUnsupportedError extends Aifp1Error {
55
58
  * Carries the tx hash because that is the only thing standing between the
56
59
  * caller and a lost payment: the quote is still settleable through
57
60
  * `POST /v1/pay` by hand with the same Idempotency-Key, and without the hash
58
- * in the error there is nothing to retry with.
61
+ * in the error there is nothing to retry with. `recovery` additionally preserves
62
+ * the quote nonce and issuer domain needed to sign a fresh receipt claim.
59
63
  */
60
64
  export class Aifp1PayError extends Aifp1Error {
61
65
  txRef;
62
66
  quoteId;
63
- constructor(msg, txRef, quoteId) {
67
+ recovery;
68
+ constructor(msg, txRef, quoteId, recovery) {
64
69
  super(msg);
65
70
  this.txRef = txRef;
66
71
  this.quoteId = quoteId;
72
+ this.recovery = recovery;
67
73
  }
68
74
  }
75
+ /** Recover an already settled Polygon payment. Never sends an on-chain transaction. */
76
+ export function recoverAifp1Payment(recovery, signer, options = {}) {
77
+ return submitPayment({ ...signer, agentId: signer.payerAddress, fetchImpl: signer.fetchImpl ?? fetch }, recovery.apiBaseUrl, recovery.quote, recovery.txRef, recovery.asset, { paymentIssuer: recovery.paymentIssuer, ...options });
78
+ }
69
79
  /** The gateway rejected a receipt we believed covered the request. */
70
80
  export class Aifp1ReceiptRejectedError extends Aifp1Error {
71
81
  }
82
+ export function describeQuote(q) {
83
+ const ns = q.native_settlement;
84
+ const pay = ns
85
+ ? { amount: formatUnits(ns.total_wei, ns.decimals), asset: ns.asset }
86
+ : null;
87
+ // Fee as basis points, from the split the quote already carries. Stated so the
88
+ // agent sees the rate, not just the total — the same reason the 402 does.
89
+ let feeBps = null;
90
+ try {
91
+ const total = BigInt(q.settlement.gross_units);
92
+ const merchant = BigInt(q.settlement.merchant_units);
93
+ if (total > 0n)
94
+ feeBps = Number(((total - merchant) * 10000n) / total);
95
+ }
96
+ catch { /* leave null rather than guess */ }
97
+ const where = q.scope === "merchant" ? `anything on ${q.merchant_id}`
98
+ : q.scope === "prefix" ? `any path under ${q.resource}`
99
+ : q.resource;
100
+ const payPart = pay ? `${pay.amount} ${pay.asset}` : `$${q.amount}`;
101
+ const headline = `Pay ${payPart} ($${q.amount}) for ${q.requests} request${q.requests === 1 ? "" : "s"} ` +
102
+ `to ${where}` +
103
+ (feeBps != null ? ` (incl. ${(feeBps / 100).toFixed(2)}% fee)` : "") +
104
+ `, valid until ${q.expires_at}.`;
105
+ return {
106
+ headline,
107
+ amount_usd: q.amount,
108
+ pay,
109
+ requests: q.requests,
110
+ unit_quota: q.unit_quota,
111
+ scope: q.scope,
112
+ resource: q.resource,
113
+ expires_at: q.expires_at,
114
+ fee_bps: feeBps,
115
+ };
116
+ }
117
+ /** wei/lamports → a decimal string with the asset's own decimals, no float. */
118
+ function formatUnits(raw, decimals) {
119
+ const n = BigInt(raw);
120
+ const base = 10n ** BigInt(decimals);
121
+ const whole = n / base;
122
+ const frac = (n % base).toString().padStart(decimals, "0").replace(/0+$/, "");
123
+ return frac ? `${whole}.${frac}` : `${whole}`;
124
+ }
72
125
  // ── Scope, ported from the server ─────────────────────────────────────────
73
126
  //
74
127
  // backend/aifp/scope.js decides whether a receipt covers a request, and this
@@ -123,7 +176,10 @@ export function prefixHint(path) {
123
176
  * treated as "allow all", because that is the shape a config bug takes.
124
177
  */
125
178
  export const DEFAULT_GATEWAY_ORIGINS = ["https://gateway.aifinpay.io"];
126
- export function parseGatewayUrl(url, allowedOrigins = DEFAULT_GATEWAY_ORIGINS) {
179
+ export function parseGatewayUrl(url, allowedOrigins = DEFAULT_GATEWAY_ORIGINS, resourcePathMode = "gateway") {
180
+ if (resourcePathMode !== "gateway" && resourcePathMode !== "direct") {
181
+ throw new Aifp1Error("resourcePathMode must be 'gateway' or 'direct'");
182
+ }
127
183
  const u = new URL(url);
128
184
  if (!allowedOrigins.length) {
129
185
  throw new Aifp1Error("no allowed gateway origins configured — refusing to pay any host. "
@@ -134,6 +190,9 @@ export function parseGatewayUrl(url, allowedOrigins = DEFAULT_GATEWAY_ORIGINS) {
134
190
  + "Refusing to settle: a 402 is unauthenticated, so paying an unrecognised host "
135
191
  + "means paying whoever answered.");
136
192
  }
193
+ if (resourcePathMode === "direct") {
194
+ return { site: u.origin, slug: "", restPath: u.pathname };
195
+ }
137
196
  const segments = u.pathname.split("/").filter(Boolean);
138
197
  if (segments.length === 0) {
139
198
  throw new Aifp1Error(`${url} has no merchant slug — a gateway URL looks like https://gateway.aifinpay.io/{slug}/path`);
@@ -229,7 +288,9 @@ export class Aifp1ReceiptCache {
229
288
  async coalesce(site, buy) {
230
289
  const running = this.inflight.get(site);
231
290
  if (running) {
232
- await running.catch(() => { });
291
+ // The winner may already have paid. Share its failure and recovery
292
+ // context; swallowing it would let every waiter buy another batch.
293
+ await running;
233
294
  return "retry";
234
295
  }
235
296
  const p = buy().finally(() => { this.inflight.delete(site); });
@@ -344,6 +405,40 @@ function resolveScope(opts, challenge) {
344
405
  resource: scope === "prefix" ? prefixHint(challenge.resource) : challenge.resource,
345
406
  };
346
407
  }
408
+ function validateCanonicalQuoteEconomics(quote) {
409
+ try {
410
+ const s = quote.settlement;
411
+ const gross = BigInt(s.gross_units);
412
+ const payer = BigInt(s.payer_total_units);
413
+ const total = BigInt(s.total_units);
414
+ const merchant = BigInt(s.merchant_units);
415
+ const protocol = BigInt(s.protocol_fee_units);
416
+ const creator = BigInt(s.creator_units);
417
+ const expectedProtocol = gross / 100n;
418
+ // Older backend clients consume this unfortunately named breakdown. It
419
+ // cannot be dropped during a rolling upgrade. Only accept it as inclusive
420
+ // when the explicit semantics and every leg agree with the canonical data.
421
+ const legacy = s.fee_on_top;
422
+ const matches = (value, expected) => typeof value === "string" && /^\d+$/.test(value) && BigInt(value) === expected;
423
+ const inclusiveLegacy = s.settlement_semantics === "gross-inclusive"
424
+ && legacy !== null && typeof legacy === "object" && !Array.isArray(legacy)
425
+ && matches(legacy.provider, merchant)
426
+ && matches(legacy.treasury, protocol)
427
+ && matches(legacy.creator, creator);
428
+ if ((s.fee_on_top !== false && !inclusiveLegacy)
429
+ || gross <= 0n
430
+ || payer !== gross
431
+ || total !== gross
432
+ || creator !== 0n
433
+ || protocol !== expectedProtocol
434
+ || merchant !== gross - expectedProtocol) {
435
+ throw new Error("split mismatch");
436
+ }
437
+ }
438
+ catch {
439
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} does not implement canonical AIFP-1 gross economics (payer 100%, merchant 99%, protocol 1%, creator 0%)`);
440
+ }
441
+ }
347
442
  /** Is this response the AIFP-1 paywall asking for money? */
348
443
  function isAifp1Challenge(body) {
349
444
  const b = body;
@@ -367,7 +462,13 @@ function quotaRemaining(resp) {
367
462
  * already has.
368
463
  */
369
464
  export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
370
- const { site, restPath } = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS);
465
+ if (opts.maxAmountUsd !== undefined
466
+ && (!Number.isFinite(opts.maxAmountUsd) || opts.maxAmountUsd <= 0)) {
467
+ throw new Aifp1QuoteError("maxAmountUsd must be a positive finite USD amount");
468
+ }
469
+ const direct = opts.resourcePathMode === "direct";
470
+ let { site, restPath } = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS, opts.resourcePathMode);
471
+ let directMerchant;
371
472
  // A paid flow sends the request twice, so the body has to survive being sent
372
473
  // twice. A stream cannot, and finding that out after paying would mean money
373
474
  // moved for a request that can no longer be made.
@@ -384,6 +485,8 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
384
485
  // silently dropping every header the caller set, including their auth.
385
486
  const headers = new Headers(init.headers);
386
487
  headers.set("AIFP-Agent-Id", deps.agentId);
488
+ if (direct)
489
+ headers.delete("AIFP-Receipt");
387
490
  if (receiptJwt)
388
491
  headers.set("AIFP-Receipt", receiptJwt);
389
492
  return deps.fetchImpl(url, {
@@ -392,13 +495,29 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
392
495
  // The receipt is a bearer token. fetch follows redirects by default and
393
496
  // only strips authorization/cookie/host on a cross-origin hop — a custom
394
497
  // header rides along, so one 302 from a merchant upstream would hand the
395
- // batch to another host. Stop at the redirect and let the caller decide.
396
- ...(receiptJwt ? { redirect: "manual" } : {}),
498
+ // batch to another host. Direct probes also stop here: a redirect must
499
+ // not turn an untrusted origin's 402 into an authorized purchase.
500
+ ...((receiptJwt || direct) ? { redirect: "manual" } : {}),
397
501
  });
398
502
  };
399
503
  // 1. Spend a batch we already own, if one covers this path.
400
- const held = deps.cache.find(site, restPath);
401
- const resp = await send(held?.jwt);
504
+ let held = direct ? undefined : deps.cache.find(site, restPath);
505
+ let resp = await send(held?.jwt);
506
+ if (direct && resp.status === 402) {
507
+ // A direct origin can serve several merchants. Learn this resource's
508
+ // merchant before attaching a bearer receipt, even for a cached batch.
509
+ const probe = await resp.clone().json().catch(() => null);
510
+ if (isAifp1Challenge(probe)) {
511
+ if (probe.resource !== restPath) {
512
+ throw new Aifp1QuoteError("direct AIFP-1 challenge resource must match the full URL pathname");
513
+ }
514
+ directMerchant = probe.merchant_id;
515
+ site = `direct:${JSON.stringify([site, directMerchant])}`;
516
+ held = deps.cache.find(site, restPath);
517
+ if (held)
518
+ resp = await send(held.jwt);
519
+ }
520
+ }
402
521
  if (held) {
403
522
  // Absent header, not "zero left": the gateway sets AIFP-Quota-Remaining
404
523
  // only on a metered paid call, and `Number(null)` is 0 — reading it
@@ -440,6 +559,9 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
440
559
  const challenge = await resp.clone().json().catch(() => null);
441
560
  if (!isAifp1Challenge(challenge))
442
561
  return resp;
562
+ if (direct && (challenge.merchant_id !== directMerchant || challenge.resource !== restPath)) {
563
+ throw new Aifp1QuoteError("direct AIFP-1 resource or merchant changed during receipt verification");
564
+ }
443
565
  // The batch we were spending is spent (or expired) — the gateway just said
444
566
  // so. Forget it before quoting the next one, or find() would keep handing
445
567
  // back a receipt that cannot pay for anything.
@@ -454,27 +576,80 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
454
576
  // spend it, and if it does not they buy their own, which is a different
455
577
  // batch rather than a duplicate.
456
578
  const buyBatch = async () => {
579
+ if (typeof deps.signPaymentAuthorization !== 'function' || !/^0x[0-9a-fA-F]{40}$/.test(deps.payerAddress || '')) {
580
+ throw new Aifp1QuoteError('a settlement wallet signer is required to receive a payment receipt');
581
+ }
457
582
  // 2. Quote.
458
583
  const apiBase = (opts.apiBaseUrl ?? DEFAULT_API_BASE).replace(/\/$/, "");
459
584
  const { scope, resource } = resolveScope(opts, challenge);
460
585
  const quote = await requestQuote(deps, apiBase, {
461
586
  merchant_id: challenge.merchant_id,
587
+ payer: deps.payerAddress,
462
588
  ...(resource !== undefined ? { resource } : {}),
463
589
  scope,
464
590
  units: opts.units ?? defaultUnitsFor(challenge),
465
591
  agent_id: deps.agentId,
466
- });
592
+ }, opts.apiTimeoutMs);
593
+ if (quote.payer && quote.payer.toLowerCase() !== deps.payerAddress.toLowerCase()) {
594
+ throw new Aifp1QuoteError('quote names a different paying wallet');
595
+ }
596
+ if (quote.payment_authorization && quote.payment_authorization.scheme !== 'wallet-signature-v1') {
597
+ throw new Aifp1QuoteError('unsupported receipt authorization scheme');
598
+ }
599
+ trustedPaymentIssuer(quote, opts.paymentIssuer);
467
600
  // The merchant we are about to pay must be the merchant that refused us.
468
601
  // Cheap, and the failure it catches is the one worth catching.
469
602
  if (quote.merchant_id !== challenge.merchant_id) {
470
603
  throw new Aifp1QuoteError(`quote ${quote.quote_id} is for merchant ${quote.merchant_id} but ${site} refused as ${challenge.merchant_id} — refusing to pay`);
471
604
  }
605
+ validateCanonicalQuoteEconomics(quote);
606
+ const quoteExpiryMs = Date.parse(quote.expires_at);
607
+ if (!Number.isFinite(quoteExpiryMs) || quoteExpiryMs <= Date.now()) {
608
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} is expired or has invalid expires_at`);
609
+ }
472
610
  // 3. Budget. The quote states the batch total in USD, so both caps are
473
611
  // checked against the real figure, before anything is signed.
474
- const amountUsd = Number(quote.amount);
612
+ let amountUsd = Number(quote.amount);
475
613
  if (!Number.isFinite(amountUsd) || amountUsd < 0) {
476
614
  throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
477
615
  }
616
+ if (opts.settlementPin) {
617
+ const price = opts.nativeUsdPrice;
618
+ const age = price ? Date.now() - price.observedAtMs : NaN;
619
+ if (!price || !Number.isFinite(price.usd) || price.usd <= 0 || !Number.isFinite(age) || age < -5_000 || age > 60_000) {
620
+ throw new Aifp1QuoteError("a fresh independent nativeUsdPrice is required before native payment; quote-provided FX is not trusted");
621
+ }
622
+ const native = quote.native_settlement;
623
+ if (!native || !/^[0-9]{1,78}$/.test(native.total_wei)) {
624
+ throw new Aifp1QuoteError("quote has no valid native debit to check against the independent price");
625
+ }
626
+ const debitUsd = Number(BigInt(native.total_wei)) / 1e18 * price.usd;
627
+ if (!Number.isFinite(debitUsd) || debitUsd <= 0 || Math.abs(debitUsd - amountUsd) > Math.max(0.02 * amountUsd, 1e-6)) {
628
+ throw new Aifp1QuoteError("native debit disagrees with the independent USD price");
629
+ }
630
+ // Reserve and charge the larger amount, so rounding/FX tolerance cannot
631
+ // turn into a cumulative bypass of the user's daily USD budget.
632
+ amountUsd = Math.max(amountUsd, debitUsd);
633
+ const call = quote.settlement_call;
634
+ const args = call?.args;
635
+ if (native.asset !== 'POL' || native.decimals !== 18 || !quote.accepted_assets.includes('POL')) {
636
+ throw new Aifp1QuoteError('native receipt settlement requires accepted POL with 18 decimals');
637
+ }
638
+ if (!call || call.chain !== 'polygon' || call.splitter_version !== '1.3'
639
+ || call.contract?.toLowerCase() !== opts.settlementPin.splitter.toLowerCase()
640
+ || call.asset !== 'POL' || call.function !== 'payNative((bytes32,address,uint256,address,uint256,string))'
641
+ || call.arg_encoding !== 'tuple' || call.value_wei !== native.total_wei
642
+ || args?.payment_id?.toLowerCase() !== keccak256(stringToHex(quote.quote_id)).toLowerCase()
643
+ || args?.merchant?.toLowerCase() !== quote.pay_to.polygon?.toLowerCase()
644
+ || args?.gross_amount !== native.total_wei || args?.order_id !== quote.quote_id
645
+ || args?.valid_until !== Math.floor(quoteExpiryMs / 1000)
646
+ || args?.ip_creator?.toLowerCase() !== '0x0000000000000000000000000000000000000000') {
647
+ throw new Aifp1QuoteError('quote settlement_call does not match the reviewed v1.3 payment; refusing a payment the receipt verifier cannot accept');
648
+ }
649
+ }
650
+ if (opts.maxAmountUsd !== undefined && amountUsd > opts.maxAmountUsd) {
651
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} costs $${amountUsd}, above maxAmountUsd $${opts.maxAmountUsd}`);
652
+ }
478
653
  if (!deps.checkPerCall(amountUsd))
479
654
  return null;
480
655
  const reservation = await deps.reserveDaily(amountUsd);
@@ -511,33 +686,71 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
511
686
  throw new Aifp1QuoteError(`quote ${quote.quote_id} states total_wei ${native.total_wei} at rate "${native.rate_usd}" — `
512
687
  + `unusable, and the caps were checked against $${amountUsd}`);
513
688
  }
514
- // 2% covers the fee-inclusive rounding the splitter does on each leg and a
689
+ // 2% covers native conversion rounding and a
515
690
  // rate printed to six places; anything wider is a disagreement, not drift.
516
691
  if (Math.abs(weiUsd - amountUsd) > Math.max(0.02 * amountUsd, 1e-6)) {
517
692
  throw new Aifp1QuoteError(`quote ${quote.quote_id} would debit ${native.total_wei} wei ≈ $${weiUsd.toFixed(6)} `
518
693
  + `but states $${amountUsd} — refusing to sign a payment the budget caps did not see`);
519
694
  }
520
- const txRef = await deps.settle({
521
- merchantWallet: quote.pay_to.polygon,
522
- totalWei: BigInt(native.total_wei),
523
- // The binding the server verifies on-chain: the Payment event's orderId
524
- // must equal the quote id, or /v1/pay answers order_id_mismatch.
525
- orderId: quote.quote_id,
526
- });
695
+ const nativeGross = BigInt(native.gross_wei ?? native.total_wei);
696
+ const nativeMerchant = BigInt(native.merchant_wei);
697
+ const nativeTreasury = BigInt(native.treasury_wei);
698
+ const nativeCreator = BigInt(native.creator_wei);
699
+ if (native.settlement_semantics !== "gross-inclusive"
700
+ || BigInt(native.payer_total_wei ?? native.total_wei) !== nativeGross
701
+ || BigInt(native.total_wei) !== nativeGross
702
+ || nativeCreator !== 0n
703
+ || nativeTreasury !== nativeGross / 100n
704
+ || nativeMerchant !== nativeGross - nativeTreasury) {
705
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} native settlement does not match canonical AIFP-1 gross split`);
706
+ }
707
+ const validUntil = BigInt(native.valid_until ?? Math.floor(quoteExpiryMs / 1000));
708
+ if (validUntil !== BigInt(Math.floor(quoteExpiryMs / 1000))) {
709
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} valid_until does not match expires_at`);
710
+ }
711
+ let txRef;
712
+ try {
713
+ txRef = await deps.settle({
714
+ merchantWallet: quote.pay_to.polygon,
715
+ grossWei: nativeGross,
716
+ merchantWei: nativeMerchant,
717
+ treasuryWei: nativeTreasury,
718
+ creatorWei: nativeCreator,
719
+ validUntil,
720
+ // The binding the server verifies on-chain: the Payment event's orderId
721
+ // must equal the quote id, or /v1/pay answers order_id_mismatch.
722
+ orderId: quote.quote_id,
723
+ });
724
+ }
725
+ catch (error) {
726
+ if (error instanceof SettlementConfirmationPendingError && error.stage === "settlement") {
727
+ // Treat unknown confirmation as spent until reconciliation. Releasing
728
+ // the reservation here would let a retry spend the same budget again.
729
+ settled = true;
730
+ const recovery = { apiBaseUrl: apiBase, quote, txRef: error.txHash, asset: native.asset, paymentIssuer: opts.paymentIssuer };
731
+ try {
732
+ if (typeof reservation === "string")
733
+ await deps.commit(reservation, amountUsd);
734
+ }
735
+ catch {
736
+ throw new Aifp1PayError("payment broadcast; confirmation and budget reconciliation required", error.txHash, quote.quote_id, recovery);
737
+ }
738
+ throw new Aifp1PayError("payment broadcast; recover its confirmation and receipt without paying again", error.txHash, quote.quote_id, recovery);
739
+ }
740
+ throw error;
741
+ }
527
742
  // Money has moved. The reservation becomes settled spend here and not one
528
743
  // line later — everything below can still fail, and none of those failures
529
744
  // give the funds back.
530
745
  settled = true;
531
- if (typeof reservation === "string")
532
- await deps.commit(reservation, amountUsd);
533
- const paid = await submitPayment(deps, apiBase, quote, txRef, native.asset, opts);
534
- // 5. Retry the original request with the receipt.
535
- const receiptResp = await send(paid.receipt);
536
- if (receiptResp.status === 402) {
537
- const body = await receiptResp.clone().text().catch(() => "");
538
- throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with a freshly settled receipt `
539
- + `${paid.receipt_id} (tx ${txRef}): ${body.slice(0, 300)}`);
746
+ try {
747
+ if (typeof reservation === "string")
748
+ await deps.commit(reservation, amountUsd);
749
+ }
750
+ catch {
751
+ throw new Aifp1PayError("payment settled; budget reconciliation and receipt recovery required", txRef, quote.quote_id, { apiBaseUrl: apiBase, quote, txRef, asset: native.asset, paymentIssuer: opts.paymentIssuer });
540
752
  }
753
+ const paid = await submitPayment(deps, apiBase, quote, txRef, native.asset, opts);
541
754
  // 6. Keep the batch. This is the whole point of the design: the next call
542
755
  // this receipt covers costs a header, not a transaction.
543
756
  const entry = {
@@ -552,13 +765,6 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
552
765
  expiresAt: Date.parse(paid.expires_at),
553
766
  amountUsd,
554
767
  };
555
- // The gateway's own count if it gave one; otherwise the batch minus the
556
- // weight of the route that refused us, which is the only figure we can
557
- // justify locally.
558
- const remaining = quotaRemaining(receiptResp);
559
- entry.remaining = remaining !== null
560
- ? remaining
561
- : Math.max(0, paid.unit_quota - challenge.unit_weight);
562
768
  // Cache unconditionally. This used to be guarded on a finite expiresAt,
563
769
  // which meant an unparseable expires_at silently binned a batch that had
564
770
  // just been settled on-chain — money spent, JWT existing nowhere else,
@@ -574,7 +780,26 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
574
780
  // and answers 402 the moment the receipt really is spent or stale.
575
781
  entry.expiresAt = jwtExpiryMs(paid.receipt) ?? Number.POSITIVE_INFINITY;
576
782
  }
783
+ // Store the purchased access BEFORE contacting the merchant again. A
784
+ // transport failure here must not discard a paid batch and buy it twice.
577
785
  deps.cache.put(entry);
786
+ let receiptResp;
787
+ try {
788
+ receiptResp = await send(paid.receipt);
789
+ }
790
+ catch {
791
+ throw new Aifp1PayError("payment settled and receipt cached; content request failed, retry without paying again", txRef, quote.quote_id, { apiBaseUrl: apiBase, quote, txRef, asset: native.asset, paymentIssuer: opts.paymentIssuer });
792
+ }
793
+ if (receiptResp.status === 402) {
794
+ throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with settled receipt ${paid.receipt_id} (tx ${txRef}); recover the existing payment`);
795
+ }
796
+ // The gateway's own count if it gave one; otherwise the batch minus the
797
+ // weight of the route that refused us, which is the only figure we can
798
+ // justify locally.
799
+ const remaining = quotaRemaining(receiptResp);
800
+ entry.remaining = remaining !== null
801
+ ? remaining
802
+ : Math.max(0, paid.unit_quota - challenge.unit_weight);
578
803
  deps.onPaid?.({
579
804
  merchantId: paid.merchant_id, amountUsd, txRef, receiptId: paid.receipt_id,
580
805
  });
@@ -596,19 +821,19 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
596
821
  return buyBatch();
597
822
  }
598
823
  // ── /v1/quote ─────────────────────────────────────────────────────────────
599
- async function requestQuote(deps, apiBase, body) {
824
+ async function requestQuote(deps, apiBase, body, timeoutMs) {
600
825
  let r;
826
+ let text;
601
827
  try {
602
- r = await deps.fetchImpl(`${apiBase}/v1/quote`, {
828
+ ({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/quote`, {
603
829
  method: "POST",
604
830
  headers: { "content-type": "application/json", "AIFP-Agent-Id": deps.agentId },
605
831
  body: JSON.stringify(body),
606
- });
832
+ }, timeoutMs));
607
833
  }
608
834
  catch (e) {
609
835
  throw new Aifp1QuoteError(`POST ${apiBase}/v1/quote failed: ${e.message}`);
610
836
  }
611
- const text = await r.text();
612
837
  if (!r.ok) {
613
838
  // The server's own detail is the useful part — a scope refused for a
614
839
  // non-flat-rated tier, a batch under the minimum, an owner policy — and
@@ -642,11 +867,28 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
642
867
  const chain = "polygon";
643
868
  const idempotencyKey = idempotencyKeyFor({ quoteId: quote.quote_id, chain, asset, txRef });
644
869
  const deadline = Date.now() + (opts.settlementConfirmMs ?? DEFAULT_SETTLEMENT_CONFIRM_MS);
870
+ const recovery = { apiBaseUrl: apiBase, quote, txRef, asset, paymentIssuer: opts.paymentIssuer };
871
+ const failure = (message) => new Aifp1PayError(message, txRef, quote.quote_id, recovery);
645
872
  let lastDetail = "";
646
873
  for (let attempt = 0;; attempt++) {
874
+ if (attempt > 0 && Date.now() >= deadline)
875
+ throw failure("payment confirmation deadline elapsed; recover the existing transaction");
876
+ const expiresAt = Math.floor(Date.now() / 1000) + 240;
877
+ const payer = deps.payerAddress.toLowerCase();
878
+ // Construct this locally; never sign an arbitrary server-provided message.
879
+ const message = paymentAuthorizationMessage({ quote, apiBase, chain, txRef,
880
+ asset, idempotencyKey, payer, expiresAt, issuer: opts.paymentIssuer });
881
+ let signature;
882
+ try {
883
+ signature = await deps.signPaymentAuthorization(message);
884
+ }
885
+ catch (e) {
886
+ throw failure(`payment already settled; wallet could not sign receipt authorization: ${e.message}`);
887
+ }
647
888
  let r;
889
+ let text;
648
890
  try {
649
- r = await deps.fetchImpl(`${apiBase}/v1/pay`, {
891
+ ({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/pay`, {
650
892
  method: "POST",
651
893
  headers: {
652
894
  "content-type": "application/json",
@@ -655,27 +897,52 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
655
897
  },
656
898
  body: JSON.stringify({
657
899
  quote_id: quote.quote_id, chain, asset, tx_ref: txRef, agent_id: deps.agentId,
900
+ payment_authorization: { payer, expires_at: expiresAt, signature },
658
901
  }),
659
- });
902
+ }, Math.max(1, Math.min(opts.apiTimeoutMs ?? 15_000, deadline - Date.now()))));
660
903
  }
661
904
  catch (e) {
662
- throw new Aifp1PayError(`POST ${apiBase}/v1/pay failed after settling: ${e.message}`, txRef, quote.quote_id);
905
+ if (Date.now() >= deadline || (e instanceof SettlementHttpError && e.code !== "request_timeout")) {
906
+ throw failure(`POST ${apiBase}/v1/pay failed after settling: ${e.message}`);
907
+ }
908
+ await sleep(Math.min(1000 * 2 ** attempt, 8000, Math.max(0, deadline - Date.now())));
909
+ continue;
663
910
  }
664
- const text = await r.text();
665
911
  if (r.ok) {
666
- const paid = JSON.parse(text);
912
+ let paid;
913
+ try {
914
+ paid = JSON.parse(text);
915
+ }
916
+ catch {
917
+ throw failure(`/v1/pay returned invalid JSON after settlement`);
918
+ }
667
919
  if (!paid.receipt) {
668
- throw new Aifp1PayError(`/v1/pay → 200 without a receipt: ${text.slice(0, 300)}`, txRef, quote.quote_id);
920
+ throw failure(`/v1/pay → 200 without a receipt: ${text.slice(0, 300)}`);
669
921
  }
670
922
  return paid;
671
923
  }
672
924
  lastDetail = text.slice(0, 400);
673
- // 425 is the only status worth retrying: the chain has not caught up with
674
- // us yet. Everything else is a verdict.
675
- if (r.status !== 425 || Date.now() >= deadline) {
676
- throw new Aifp1PayError(`POST /v1/pay → ${r.status} after on-chain settlement ${txRef} for quote ${quote.quote_id}: ${lastDetail}`, txRef, quote.quote_id);
925
+ // A 503 after settlement may mean accounting is unavailable, or that a
926
+ // committed response was lost. Retry issuance, never the transfer.
927
+ if (![425, 503].includes(r.status) || Date.now() >= deadline) {
928
+ throw failure(`POST /v1/pay → ${r.status} after on-chain settlement ${txRef} for quote ${quote.quote_id}: ${lastDetail}`);
677
929
  }
678
- await sleep(Math.min(1000 * 2 ** attempt, 8000));
930
+ await sleep(Math.min(1000 * 2 ** attempt, 8000, Math.max(0, deadline - Date.now())));
931
+ }
932
+ }
933
+ /** Fixed, domain-separated receipt claim; sign with the wallet that settled. */
934
+ export function paymentAuthorizationMessage(p) {
935
+ return JSON.stringify([
936
+ 'AiFinPay receipt authorization v1',
937
+ trustedPaymentIssuer(p.quote, p.issuer),
938
+ p.quote.quote_id, p.quote.nonce, p.quote.merchant_id, p.quote.network_mode || 'live',
939
+ p.chain, p.txRef, p.asset || '', p.idempotencyKey, p.payer, p.expiresAt,
940
+ ]);
941
+ }
942
+ function trustedPaymentIssuer(quote, configured = "https://api.aifinpay.io") {
943
+ if (quote.payment_authorization?.domain && quote.payment_authorization.domain !== configured) {
944
+ throw new Aifp1QuoteError("receipt authorization domain does not match the trusted paymentIssuer");
679
945
  }
946
+ return configured;
680
947
  }
681
948
  //# sourceMappingURL=aifp1.js.map