@aifinpay/agent 2.0.0-rc.7 → 2.0.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 (121) hide show
  1. package/LICENSE +21 -0
  2. package/PAYMENT_RECEIPTS.md +114 -0
  3. package/README.md +85 -28
  4. package/dist/agent.d.ts +8 -1
  5. package/dist/agent.d.ts.map +1 -1
  6. package/dist/agent.js +50 -17
  7. package/dist/agent.js.map +1 -1
  8. package/dist/agentHistory.d.ts +55 -0
  9. package/dist/agentHistory.d.ts.map +1 -0
  10. package/dist/agentHistory.js +145 -0
  11. package/dist/agentHistory.js.map +1 -0
  12. package/dist/agentPassport.d.ts +71 -7
  13. package/dist/agentPassport.d.ts.map +1 -1
  14. package/dist/agentPassport.js +135 -27
  15. package/dist/agentPassport.js.map +1 -1
  16. package/dist/aifp1.d.ts +126 -5
  17. package/dist/aifp1.d.ts.map +1 -1
  18. package/dist/aifp1.js +407 -108
  19. package/dist/aifp1.js.map +1 -1
  20. package/dist/apiUrl.d.ts +3 -0
  21. package/dist/apiUrl.d.ts.map +1 -0
  22. package/dist/apiUrl.js +8 -0
  23. package/dist/apiUrl.js.map +1 -0
  24. package/dist/chains.d.ts +7 -0
  25. package/dist/chains.d.ts.map +1 -1
  26. package/dist/chains.js +19 -1
  27. package/dist/chains.js.map +1 -1
  28. package/dist/crossChain.d.ts.map +1 -1
  29. package/dist/crossChain.js.map +1 -1
  30. package/dist/crypto.d.ts.map +1 -1
  31. package/dist/crypto.js.map +1 -1
  32. package/dist/deploymentResolver.d.ts +95 -0
  33. package/dist/deploymentResolver.d.ts.map +1 -0
  34. package/dist/deploymentResolver.js +169 -0
  35. package/dist/deploymentResolver.js.map +1 -0
  36. package/dist/errors.d.ts +17 -0
  37. package/dist/errors.d.ts.map +1 -1
  38. package/dist/errors.js +14 -0
  39. package/dist/errors.js.map +1 -1
  40. package/dist/facilitators/aifinpay.d.ts +4 -5
  41. package/dist/facilitators/aifinpay.d.ts.map +1 -1
  42. package/dist/facilitators/aifinpay.js +50 -21
  43. package/dist/facilitators/aifinpay.js.map +1 -1
  44. package/dist/facilitators/base.d.ts +12 -1
  45. package/dist/facilitators/base.d.ts.map +1 -1
  46. package/dist/facilitators/coinbase.d.ts.map +1 -1
  47. package/dist/facilitators/coinbase.js +3 -7
  48. package/dist/facilitators/coinbase.js.map +1 -1
  49. package/dist/facilitators/detect.d.ts.map +1 -1
  50. package/dist/facilitators/detect.js +2 -7
  51. package/dist/facilitators/detect.js.map +1 -1
  52. package/dist/facilitators/standard-x402.d.ts.map +1 -1
  53. package/dist/facilitators/standard-x402.js +40 -30
  54. package/dist/facilitators/standard-x402.js.map +1 -1
  55. package/dist/generated/solanaV14Deployments.generated.d.ts +25 -0
  56. package/dist/generated/solanaV14Deployments.generated.d.ts.map +1 -0
  57. package/dist/generated/solanaV14Deployments.generated.js +39 -0
  58. package/dist/generated/solanaV14Deployments.generated.js.map +1 -0
  59. package/dist/generated/splitterRoutes.generated.d.ts +24 -0
  60. package/dist/generated/splitterRoutes.generated.d.ts.map +1 -0
  61. package/dist/generated/splitterRoutes.generated.js +453 -0
  62. package/dist/generated/splitterRoutes.generated.js.map +1 -0
  63. package/dist/generated/v14Deployments.generated.d.ts +49 -0
  64. package/dist/generated/v14Deployments.generated.d.ts.map +1 -0
  65. package/dist/generated/v14Deployments.generated.js +415 -0
  66. package/dist/generated/v14Deployments.generated.js.map +1 -0
  67. package/dist/index.d.ts +27 -13
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +16 -7
  70. package/dist/index.js.map +1 -1
  71. package/dist/localIdentity.d.ts +17 -0
  72. package/dist/localIdentity.d.ts.map +1 -0
  73. package/dist/localIdentity.js +95 -0
  74. package/dist/localIdentity.js.map +1 -0
  75. package/dist/settlement.d.ts +34 -4
  76. package/dist/settlement.d.ts.map +1 -1
  77. package/dist/settlement.js +233 -75
  78. package/dist/settlement.js.map +1 -1
  79. package/dist/settlementHttp.d.ts +10 -0
  80. package/dist/settlementHttp.d.ts.map +1 -0
  81. package/dist/settlementHttp.js +78 -0
  82. package/dist/settlementHttp.js.map +1 -0
  83. package/dist/settlementV14.d.ts +10 -9
  84. package/dist/settlementV14.d.ts.map +1 -1
  85. package/dist/settlementV14.js +43 -55
  86. package/dist/settlementV14.js.map +1 -1
  87. package/dist/solanaDeploymentResolver.d.ts +98 -0
  88. package/dist/solanaDeploymentResolver.d.ts.map +1 -0
  89. package/dist/solanaDeploymentResolver.js +162 -0
  90. package/dist/solanaDeploymentResolver.js.map +1 -0
  91. package/dist/solanaV14Deployments.generated.d.ts +25 -0
  92. package/dist/solanaV14Deployments.generated.d.ts.map +1 -0
  93. package/dist/solanaV14Deployments.generated.js +39 -0
  94. package/dist/solanaV14Deployments.generated.js.map +1 -0
  95. package/dist/spendLedger.d.ts.map +1 -1
  96. package/dist/spendLedger.js +1 -3
  97. package/dist/spendLedger.js.map +1 -1
  98. package/dist/splitterRoutes.d.ts +11 -4
  99. package/dist/splitterRoutes.d.ts.map +1 -1
  100. package/dist/splitterRoutes.generated.d.ts +11 -9
  101. package/dist/splitterRoutes.generated.d.ts.map +1 -1
  102. package/dist/splitterRoutes.generated.js +72 -18
  103. package/dist/splitterRoutes.generated.js.map +1 -1
  104. package/dist/splitterRoutes.js +7 -5
  105. package/dist/splitterRoutes.js.map +1 -1
  106. package/dist/unifiedAgent.d.ts +96 -28
  107. package/dist/unifiedAgent.d.ts.map +1 -1
  108. package/dist/unifiedAgent.js +219 -348
  109. package/dist/unifiedAgent.js.map +1 -1
  110. package/dist/v14Deployments.generated.d.ts +49 -0
  111. package/dist/v14Deployments.generated.d.ts.map +1 -0
  112. package/dist/v14Deployments.generated.js +413 -0
  113. package/dist/v14Deployments.generated.js.map +1 -0
  114. package/dist/wallet.d.ts.map +1 -1
  115. package/dist/wallet.js +2 -2
  116. package/dist/wallet.js.map +1 -1
  117. package/package.json +27 -6
  118. package/dist/cli.d.ts +0 -3
  119. package/dist/cli.d.ts.map +0 -1
  120. package/dist/cli.js +0 -127
  121. package/dist/cli.js.map +0 -1
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,72 @@ 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 ? { amount: formatUnits(ns.total_wei, ns.decimals), asset: ns.asset } : null;
85
+ // Fee as basis points, from the split the quote already carries. Stated so the
86
+ // agent sees the rate, not just the total — the same reason the 402 does.
87
+ let feeBps = null;
88
+ try {
89
+ const total = BigInt(q.settlement.gross_units);
90
+ const merchant = BigInt(q.settlement.merchant_units);
91
+ if (total > 0n)
92
+ feeBps = Number(((total - merchant) * 10000n) / total);
93
+ }
94
+ catch {
95
+ /* leave null rather than guess */
96
+ }
97
+ const where = q.scope === "merchant"
98
+ ? `anything on ${q.merchant_id}`
99
+ : q.scope === "prefix"
100
+ ? `any path under ${q.resource}`
101
+ : q.resource;
102
+ const payPart = pay ? `${pay.amount} ${pay.asset}` : `$${q.amount}`;
103
+ const headline = `Pay ${payPart} ($${q.amount}) for ${q.requests} request${q.requests === 1 ? "" : "s"} ` +
104
+ `to ${where}` +
105
+ (feeBps != null ? ` (incl. ${(feeBps / 100).toFixed(2)}% fee)` : "") +
106
+ `, valid until ${q.expires_at}.`;
107
+ return {
108
+ headline,
109
+ amount_usd: q.amount,
110
+ pay,
111
+ requests: q.requests,
112
+ unit_quota: q.unit_quota,
113
+ scope: q.scope,
114
+ resource: q.resource,
115
+ expires_at: q.expires_at,
116
+ fee_bps: feeBps,
117
+ };
118
+ }
119
+ /** wei/lamports → a decimal string with the asset's own decimals, no float. */
120
+ function formatUnits(raw, decimals) {
121
+ const n = BigInt(raw);
122
+ const base = 10n ** BigInt(decimals);
123
+ const whole = n / base;
124
+ const frac = (n % base).toString().padStart(decimals, "0").replace(/0+$/, "");
125
+ return frac ? `${whole}.${frac}` : `${whole}`;
126
+ }
72
127
  // ── Scope, ported from the server ─────────────────────────────────────────
73
128
  //
74
129
  // backend/aifp/scope.js decides whether a receipt covers a request, and this
@@ -96,7 +151,9 @@ export function scopeCovers(scope, resource, path) {
96
151
  * and a single-segment path → "/" (the whole site).
97
152
  */
98
153
  export function prefixHint(path) {
99
- const segs = String(path || "/").split("/").filter(Boolean);
154
+ const segs = String(path || "/")
155
+ .split("/")
156
+ .filter(Boolean);
100
157
  return segs.length > 1 ? `/${segs[0]}/` : "/";
101
158
  }
102
159
  /**
@@ -123,16 +180,22 @@ export function prefixHint(path) {
123
180
  * treated as "allow all", because that is the shape a config bug takes.
124
181
  */
125
182
  export const DEFAULT_GATEWAY_ORIGINS = ["https://gateway.aifinpay.io"];
126
- export function parseGatewayUrl(url, allowedOrigins = DEFAULT_GATEWAY_ORIGINS) {
183
+ export function parseGatewayUrl(url, allowedOrigins = DEFAULT_GATEWAY_ORIGINS, resourcePathMode = "gateway") {
184
+ if (resourcePathMode !== "gateway" && resourcePathMode !== "direct") {
185
+ throw new Aifp1Error("resourcePathMode must be 'gateway' or 'direct'");
186
+ }
127
187
  const u = new URL(url);
128
188
  if (!allowedOrigins.length) {
129
- throw new Aifp1Error("no allowed gateway origins configured — refusing to pay any host. "
130
- + "Pass gatewayOrigins explicitly for a self-hosted gateway.");
189
+ throw new Aifp1Error("no allowed gateway origins configured — refusing to pay any host. " +
190
+ "Pass gatewayOrigins explicitly for a self-hosted gateway.");
131
191
  }
132
192
  if (!allowedOrigins.includes(u.origin)) {
133
- throw new Aifp1Error(`${u.origin} is not a known AiFinPay gateway (allowed: ${allowedOrigins.join(", ")}). `
134
- + "Refusing to settle: a 402 is unauthenticated, so paying an unrecognised host "
135
- + "means paying whoever answered.");
193
+ throw new Aifp1Error(`${u.origin} is not a known AiFinPay gateway (allowed: ${allowedOrigins.join(", ")}). ` +
194
+ "Refusing to settle: a 402 is unauthenticated, so paying an unrecognised host " +
195
+ "means paying whoever answered.");
196
+ }
197
+ if (resourcePathMode === "direct") {
198
+ return { site: u.origin, slug: "", restPath: u.pathname };
136
199
  }
137
200
  const segments = u.pathname.split("/").filter(Boolean);
138
201
  if (segments.length === 0) {
@@ -185,9 +248,7 @@ export function jwtExpiryMs(jwt) {
185
248
  * A random key would satisfy the header requirement and none of the above.
186
249
  */
187
250
  export function idempotencyKeyFor(p) {
188
- const digest = createHash("sha256")
189
- .update(`aifp1|${p.quoteId}|${p.asset}|${p.chain}|${p.txRef}`)
190
- .digest("hex");
251
+ const digest = createHash("sha256").update(`aifp1|${p.quoteId}|${p.asset}|${p.chain}|${p.txRef}`).digest("hex");
191
252
  return `aifp1-${digest}`; // 70 chars — well under the server's 200 limit
192
253
  }
193
254
  /**
@@ -229,10 +290,14 @@ export class Aifp1ReceiptCache {
229
290
  async coalesce(site, buy) {
230
291
  const running = this.inflight.get(site);
231
292
  if (running) {
232
- await running.catch(() => { });
293
+ // The winner may already have paid. Share its failure and recovery
294
+ // context; swallowing it would let every waiter buy another batch.
295
+ await running;
233
296
  return "retry";
234
297
  }
235
- const p = buy().finally(() => { this.inflight.delete(site); });
298
+ const p = buy().finally(() => {
299
+ this.inflight.delete(site);
300
+ });
236
301
  this.inflight.set(site, p);
237
302
  return p;
238
303
  }
@@ -254,11 +319,11 @@ export class Aifp1ReceiptCache {
254
319
  */
255
320
  find(site, restPath, merchantId) {
256
321
  const now = Date.now();
257
- return this.entries.find((e) => e.site === site
258
- && (merchantId === undefined || e.merchantId === merchantId)
259
- && e.remaining > 0
260
- && e.expiresAt - EXPIRY_MARGIN_MS > now
261
- && scopeCovers(e.scope, e.resource, restPath));
322
+ return this.entries.find((e) => e.site === site &&
323
+ (merchantId === undefined || e.merchantId === merchantId) &&
324
+ e.remaining > 0 &&
325
+ e.expiresAt - EXPIRY_MARGIN_MS > now &&
326
+ scopeCovers(e.scope, e.resource, restPath));
262
327
  }
263
328
  put(entry) {
264
329
  this.entries.push(entry);
@@ -278,7 +343,16 @@ export class Aifp1ReceiptCache {
278
343
  const now = Date.now();
279
344
  return this.entries.filter((e) => e.expiresAt > now);
280
345
  }
281
- get size() { return this.entries.length; }
346
+ /**
347
+ * Redacted view for dashboards and MCP tools: everything except the bearer
348
+ * `jwt` (a credential — only the paying agent holds it). Safe to log.
349
+ */
350
+ summary() {
351
+ return this.list().map(({ jwt: _jwt, ...rest }) => rest);
352
+ }
353
+ get size() {
354
+ return this.entries.length;
355
+ }
282
356
  }
283
357
  /** Default batch size: 1000 billing units — the $0.10 floor, and the size the
284
358
  * gateway's own how_to_pay examples suggest (routes/gateway.js). If an
@@ -295,7 +369,7 @@ export class Aifp1ReceiptCache {
295
369
  * dollars. Fixing the constant would have fixed today and broken the next
296
370
  * re-price, so the count is derived instead.
297
371
  */
298
- const DEFAULT_BATCH_USD = 0.10;
372
+ const DEFAULT_BATCH_USD = 0.1;
299
373
  /** Used only when a 402 omits base_unit_price_usd — matches $0.10 at $0.0005. */
300
374
  const FALLBACK_UNITS = 200;
301
375
  /** Units to buy so the batch is worth about DEFAULT_BATCH_USD. */
@@ -354,13 +428,25 @@ function validateCanonicalQuoteEconomics(quote) {
354
428
  const protocol = BigInt(s.protocol_fee_units);
355
429
  const creator = BigInt(s.creator_units);
356
430
  const expectedProtocol = gross / 100n;
357
- if (s.fee_on_top !== false
358
- || gross <= 0n
359
- || payer !== gross
360
- || total !== gross
361
- || creator !== 0n
362
- || protocol !== expectedProtocol
363
- || merchant !== gross - expectedProtocol) {
431
+ // Older backend clients consume this unfortunately named breakdown. It
432
+ // cannot be dropped during a rolling upgrade. Only accept it as inclusive
433
+ // when the explicit semantics and every leg agree with the canonical data.
434
+ const legacy = s.fee_on_top;
435
+ const matches = (value, expected) => typeof value === "string" && /^\d+$/.test(value) && BigInt(value) === expected;
436
+ const inclusiveLegacy = s.settlement_semantics === "gross-inclusive" &&
437
+ legacy !== null &&
438
+ typeof legacy === "object" &&
439
+ !Array.isArray(legacy) &&
440
+ matches(legacy.provider, merchant) &&
441
+ matches(legacy.treasury, protocol) &&
442
+ matches(legacy.creator, creator);
443
+ if ((s.fee_on_top !== false && !inclusiveLegacy) ||
444
+ gross <= 0n ||
445
+ payer !== gross ||
446
+ total !== gross ||
447
+ creator !== 0n ||
448
+ protocol !== expectedProtocol ||
449
+ merchant !== gross - expectedProtocol) {
364
450
  throw new Error("split mismatch");
365
451
  }
366
452
  }
@@ -371,8 +457,7 @@ function validateCanonicalQuoteEconomics(quote) {
371
457
  /** Is this response the AIFP-1 paywall asking for money? */
372
458
  function isAifp1Challenge(body) {
373
459
  const b = body;
374
- return !!b && b.protocol === "AIFP-1" && typeof b.merchant_id === "string"
375
- && typeof b.resource === "string";
460
+ return !!b && b.protocol === "AIFP-1" && typeof b.merchant_id === "string" && typeof b.resource === "string";
376
461
  }
377
462
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
378
463
  /** Billing units the gateway says are left, or null when it did not say. */
@@ -391,16 +476,22 @@ function quotaRemaining(resp) {
391
476
  * already has.
392
477
  */
393
478
  export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
394
- const { site, restPath } = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS);
479
+ if (opts.maxAmountUsd !== undefined && (!Number.isFinite(opts.maxAmountUsd) || opts.maxAmountUsd <= 0)) {
480
+ throw new Aifp1QuoteError("maxAmountUsd must be a positive finite USD amount");
481
+ }
482
+ const direct = opts.resourcePathMode === "direct";
483
+ let { site, restPath } = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS, opts.resourcePathMode);
484
+ let directMerchant;
395
485
  // A paid flow sends the request twice, so the body has to survive being sent
396
486
  // twice. A stream cannot, and finding that out after paying would mean money
397
487
  // moved for a request that can no longer be made.
398
- if (init.body !== undefined && init.body !== null
399
- && typeof init.body === "object"
400
- && typeof init.body.getReader === "function") {
401
- throw new Aifp1Error("aifp1Fetch cannot use a stream body: a paywalled request is sent twice "
402
- + "(once to learn the price, once with the receipt) and a stream can only "
403
- + "be read once. Pass a string, Buffer, or URLSearchParams.");
488
+ if (init.body !== undefined &&
489
+ init.body !== null &&
490
+ typeof init.body === "object" &&
491
+ typeof init.body.getReader === "function") {
492
+ throw new Aifp1Error("aifp1Fetch cannot use a stream body: a paywalled request is sent twice " +
493
+ "(once to learn the price, once with the receipt) and a stream can only " +
494
+ "be read once. Pass a string, Buffer, or URLSearchParams.");
404
495
  }
405
496
  const send = (receiptJwt) => {
406
497
  // new Headers() rather than a spread: RequestInit.headers is legally a
@@ -408,6 +499,8 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
408
499
  // silently dropping every header the caller set, including their auth.
409
500
  const headers = new Headers(init.headers);
410
501
  headers.set("AIFP-Agent-Id", deps.agentId);
502
+ if (direct)
503
+ headers.delete("AIFP-Receipt");
411
504
  if (receiptJwt)
412
505
  headers.set("AIFP-Receipt", receiptJwt);
413
506
  return deps.fetchImpl(url, {
@@ -416,13 +509,32 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
416
509
  // The receipt is a bearer token. fetch follows redirects by default and
417
510
  // only strips authorization/cookie/host on a cross-origin hop — a custom
418
511
  // header rides along, so one 302 from a merchant upstream would hand the
419
- // batch to another host. Stop at the redirect and let the caller decide.
420
- ...(receiptJwt ? { redirect: "manual" } : {}),
512
+ // batch to another host. Direct probes also stop here: a redirect must
513
+ // not turn an untrusted origin's 402 into an authorized purchase.
514
+ ...(receiptJwt || direct ? { redirect: "manual" } : {}),
421
515
  });
422
516
  };
423
517
  // 1. Spend a batch we already own, if one covers this path.
424
- const held = deps.cache.find(site, restPath);
425
- const resp = await send(held?.jwt);
518
+ let held = direct ? undefined : deps.cache.find(site, restPath);
519
+ let resp = await send(held?.jwt);
520
+ if (direct && resp.status === 402) {
521
+ // A direct origin can serve several merchants. Learn this resource's
522
+ // merchant before attaching a bearer receipt, even for a cached batch.
523
+ const probe = await resp
524
+ .clone()
525
+ .json()
526
+ .catch(() => null);
527
+ if (isAifp1Challenge(probe)) {
528
+ if (probe.resource !== restPath) {
529
+ throw new Aifp1QuoteError("direct AIFP-1 challenge resource must match the full URL pathname");
530
+ }
531
+ directMerchant = probe.merchant_id;
532
+ site = `direct:${JSON.stringify([site, directMerchant])}`;
533
+ held = deps.cache.find(site, restPath);
534
+ if (held)
535
+ resp = await send(held.jwt);
536
+ }
537
+ }
426
538
  if (held) {
427
539
  // Absent header, not "zero left": the gateway sets AIFP-Quota-Remaining
428
540
  // only on a metered paid call, and `Number(null)` is 0 — reading it
@@ -446,12 +558,17 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
446
558
  // away a paid receipt and settles a second one on the next call.
447
559
  //
448
560
  // The gateway labels its own answers: {"error":"AIFP-403", ...}.
449
- const detail = await resp.clone().text().catch(() => "");
561
+ const detail = await resp
562
+ .clone()
563
+ .text()
564
+ .catch(() => "");
450
565
  let ours = false;
451
566
  try {
452
567
  ours = JSON.parse(detail)?.error === "AIFP-403";
453
568
  }
454
- catch { /* upstream body, not ours */ }
569
+ catch {
570
+ /* upstream body, not ours */
571
+ }
455
572
  if (!ours)
456
573
  return resp; // the merchant's 403, receipt intact
457
574
  deps.cache.evict(held);
@@ -461,9 +578,15 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
461
578
  }
462
579
  // 402 — but only AIFP-1 is ours. x402 and anything else passes through
463
580
  // untouched; call() owns those flows.
464
- const challenge = await resp.clone().json().catch(() => null);
581
+ const challenge = await resp
582
+ .clone()
583
+ .json()
584
+ .catch(() => null);
465
585
  if (!isAifp1Challenge(challenge))
466
586
  return resp;
587
+ if (direct && (challenge.merchant_id !== directMerchant || challenge.resource !== restPath)) {
588
+ throw new Aifp1QuoteError("direct AIFP-1 resource or merchant changed during receipt verification");
589
+ }
467
590
  // The batch we were spending is spent (or expired) — the gateway just said
468
591
  // so. Forget it before quoting the next one, or find() would keep handing
469
592
  // back a receipt that cannot pay for anything.
@@ -478,16 +601,27 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
478
601
  // spend it, and if it does not they buy their own, which is a different
479
602
  // batch rather than a duplicate.
480
603
  const buyBatch = async () => {
604
+ if (typeof deps.signPaymentAuthorization !== "function" || !/^0x[0-9a-fA-F]{40}$/.test(deps.payerAddress || "")) {
605
+ throw new Aifp1QuoteError("a settlement wallet signer is required to receive a payment receipt");
606
+ }
481
607
  // 2. Quote.
482
608
  const apiBase = (opts.apiBaseUrl ?? DEFAULT_API_BASE).replace(/\/$/, "");
483
609
  const { scope, resource } = resolveScope(opts, challenge);
484
610
  const quote = await requestQuote(deps, apiBase, {
485
611
  merchant_id: challenge.merchant_id,
612
+ payer: deps.payerAddress,
486
613
  ...(resource !== undefined ? { resource } : {}),
487
614
  scope,
488
615
  units: opts.units ?? defaultUnitsFor(challenge),
489
616
  agent_id: deps.agentId,
490
- });
617
+ }, opts.apiTimeoutMs);
618
+ if (quote.payer && quote.payer.toLowerCase() !== deps.payerAddress.toLowerCase()) {
619
+ throw new Aifp1QuoteError("quote names a different paying wallet");
620
+ }
621
+ if (quote.payment_authorization && quote.payment_authorization.scheme !== "wallet-signature-v1") {
622
+ throw new Aifp1QuoteError("unsupported receipt authorization scheme");
623
+ }
624
+ trustedPaymentIssuer(quote, opts.paymentIssuer);
491
625
  // The merchant we are about to pay must be the merchant that refused us.
492
626
  // Cheap, and the failure it catches is the one worth catching.
493
627
  if (quote.merchant_id !== challenge.merchant_id) {
@@ -500,10 +634,59 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
500
634
  }
501
635
  // 3. Budget. The quote states the batch total in USD, so both caps are
502
636
  // checked against the real figure, before anything is signed.
503
- const amountUsd = Number(quote.amount);
637
+ let amountUsd = Number(quote.amount);
504
638
  if (!Number.isFinite(amountUsd) || amountUsd < 0) {
505
639
  throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
506
640
  }
641
+ if (opts.settlementPin) {
642
+ const price = opts.nativeUsdPrice;
643
+ const age = price ? Date.now() - price.observedAtMs : NaN;
644
+ if (!price ||
645
+ !Number.isFinite(price.usd) ||
646
+ price.usd <= 0 ||
647
+ !Number.isFinite(age) ||
648
+ age < -5_000 ||
649
+ age > 60_000) {
650
+ throw new Aifp1QuoteError("a fresh independent nativeUsdPrice is required before native payment; quote-provided FX is not trusted");
651
+ }
652
+ const native = quote.native_settlement;
653
+ if (!native || !/^[0-9]{1,78}$/.test(native.total_wei)) {
654
+ throw new Aifp1QuoteError("quote has no valid native debit to check against the independent price");
655
+ }
656
+ const debitUsd = (Number(BigInt(native.total_wei)) / 1e18) * price.usd;
657
+ if (!Number.isFinite(debitUsd) ||
658
+ debitUsd <= 0 ||
659
+ Math.abs(debitUsd - amountUsd) > Math.max(0.02 * amountUsd, 1e-6)) {
660
+ throw new Aifp1QuoteError("native debit disagrees with the independent USD price");
661
+ }
662
+ // Reserve and charge the larger amount, so rounding/FX tolerance cannot
663
+ // turn into a cumulative bypass of the user's daily USD budget.
664
+ amountUsd = Math.max(amountUsd, debitUsd);
665
+ const call = quote.settlement_call;
666
+ const args = call?.args;
667
+ if (native.asset !== "POL" || native.decimals !== 18 || !quote.accepted_assets.includes("POL")) {
668
+ throw new Aifp1QuoteError("native receipt settlement requires accepted POL with 18 decimals");
669
+ }
670
+ if (!call ||
671
+ call.chain !== "polygon" ||
672
+ call.splitter_version !== "1.3" ||
673
+ call.contract?.toLowerCase() !== opts.settlementPin.splitter.toLowerCase() ||
674
+ call.asset !== "POL" ||
675
+ call.function !== "payNative((bytes32,address,uint256,address,uint256,string))" ||
676
+ call.arg_encoding !== "tuple" ||
677
+ call.value_wei !== native.total_wei ||
678
+ args?.payment_id?.toLowerCase() !== keccak256(stringToHex(quote.quote_id)).toLowerCase() ||
679
+ args?.merchant?.toLowerCase() !== quote.pay_to.polygon?.toLowerCase() ||
680
+ args?.gross_amount !== native.total_wei ||
681
+ args?.order_id !== quote.quote_id ||
682
+ args?.valid_until !== Math.floor(quoteExpiryMs / 1000) ||
683
+ args?.ip_creator?.toLowerCase() !== "0x0000000000000000000000000000000000000000") {
684
+ throw new Aifp1QuoteError("quote settlement_call does not match the reviewed v1.3 payment; refusing a payment the receipt verifier cannot accept");
685
+ }
686
+ }
687
+ if (opts.maxAmountUsd !== undefined && amountUsd > opts.maxAmountUsd) {
688
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} costs $${amountUsd}, above maxAmountUsd $${opts.maxAmountUsd}`);
689
+ }
507
690
  if (!deps.checkPerCall(amountUsd))
508
691
  return null;
509
692
  const reservation = await deps.reserveDaily(amountUsd);
@@ -514,13 +697,13 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
514
697
  // 4. Settle on-chain, then exchange the tx for a receipt.
515
698
  const native = quote.native_settlement;
516
699
  if (!native) {
517
- throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} carries no native_settlement — the backend had no live POL rate, `
518
- + `and the deployed B2BSplitter has no ERC-20 entrypoint, so this SDK cannot settle `
519
- + `${quote.accepted_assets.join("/")}. Retry when a POL rate is available or settle the quote yourself.`);
700
+ throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} carries no native_settlement — the backend had no live POL rate, ` +
701
+ `and the deployed B2BSplitter has no ERC-20 entrypoint, so this SDK cannot settle ` +
702
+ `${quote.accepted_assets.join("/")}. Retry when a POL rate is available or settle the quote yourself.`);
520
703
  }
521
704
  if (!quote.accepted_chains.includes("polygon") || !quote.pay_to.polygon) {
522
- throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} does not accept polygon (accepts ${quote.accepted_chains.join(", ")}) — `
523
- + `AIFP-1 settlement verification is Polygon-only server-side (aifp/verify-settlement.js)`);
705
+ throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} does not accept polygon (accepts ${quote.accepted_chains.join(", ")}) — ` +
706
+ `AIFP-1 settlement verification is Polygon-only server-side (aifp/verify-settlement.js)`);
524
707
  }
525
708
  // The caps above were checked in USD; the wallet is about to be debited in
526
709
  // wei. Nothing tied the two together, so a quote could pass a $0.10 cap and
@@ -533,60 +716,88 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
533
716
  // numbers must agree, and if they do not, the safe reading is not "trust
534
717
  // the USD" — it is that this quote is not what it says it is.
535
718
  const quotedRate = Number(native.rate_usd);
536
- const weiUsd = Number.isFinite(quotedRate) && quotedRate > 0
537
- ? (Number(BigInt(native.total_wei)) / 1e18) * quotedRate
538
- : NaN;
719
+ const weiUsd = Number.isFinite(quotedRate) && quotedRate > 0 ? (Number(BigInt(native.total_wei)) / 1e18) * quotedRate : NaN;
539
720
  if (!Number.isFinite(weiUsd)) {
540
- throw new Aifp1QuoteError(`quote ${quote.quote_id} states total_wei ${native.total_wei} at rate "${native.rate_usd}" — `
541
- + `unusable, and the caps were checked against $${amountUsd}`);
721
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} states total_wei ${native.total_wei} at rate "${native.rate_usd}" — ` +
722
+ `unusable, and the caps were checked against $${amountUsd}`);
542
723
  }
543
724
  // 2% covers native conversion rounding and a
544
725
  // rate printed to six places; anything wider is a disagreement, not drift.
545
726
  if (Math.abs(weiUsd - amountUsd) > Math.max(0.02 * amountUsd, 1e-6)) {
546
- throw new Aifp1QuoteError(`quote ${quote.quote_id} would debit ${native.total_wei} wei ≈ $${weiUsd.toFixed(6)} `
547
- + `but states $${amountUsd} — refusing to sign a payment the budget caps did not see`);
727
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} would debit ${native.total_wei} wei ≈ $${weiUsd.toFixed(6)} ` +
728
+ `but states $${amountUsd} — refusing to sign a payment the budget caps did not see`);
548
729
  }
549
730
  const nativeGross = BigInt(native.gross_wei ?? native.total_wei);
550
731
  const nativeMerchant = BigInt(native.merchant_wei);
551
732
  const nativeTreasury = BigInt(native.treasury_wei);
552
733
  const nativeCreator = BigInt(native.creator_wei);
553
- if (native.settlement_semantics !== "gross-inclusive"
554
- || BigInt(native.payer_total_wei ?? native.total_wei) !== nativeGross
555
- || BigInt(native.total_wei) !== nativeGross
556
- || nativeCreator !== 0n
557
- || nativeTreasury !== nativeGross / 100n
558
- || nativeMerchant !== nativeGross - nativeTreasury) {
734
+ if (native.settlement_semantics !== "gross-inclusive" ||
735
+ BigInt(native.payer_total_wei ?? native.total_wei) !== nativeGross ||
736
+ BigInt(native.total_wei) !== nativeGross ||
737
+ nativeCreator !== 0n ||
738
+ nativeTreasury !== nativeGross / 100n ||
739
+ nativeMerchant !== nativeGross - nativeTreasury) {
559
740
  throw new Aifp1QuoteError(`quote ${quote.quote_id} native settlement does not match canonical AIFP-1 gross split`);
560
741
  }
561
742
  const validUntil = BigInt(native.valid_until ?? Math.floor(quoteExpiryMs / 1000));
562
743
  if (validUntil !== BigInt(Math.floor(quoteExpiryMs / 1000))) {
563
744
  throw new Aifp1QuoteError(`quote ${quote.quote_id} valid_until does not match expires_at`);
564
745
  }
565
- const txRef = await deps.settle({
566
- merchantWallet: quote.pay_to.polygon,
567
- grossWei: nativeGross,
568
- merchantWei: nativeMerchant,
569
- treasuryWei: nativeTreasury,
570
- creatorWei: nativeCreator,
571
- validUntil,
572
- // The binding the server verifies on-chain: the Payment event's orderId
573
- // must equal the quote id, or /v1/pay answers order_id_mismatch.
574
- orderId: quote.quote_id,
575
- });
746
+ let txRef;
747
+ try {
748
+ txRef = await deps.settle({
749
+ merchantWallet: quote.pay_to.polygon,
750
+ grossWei: nativeGross,
751
+ merchantWei: nativeMerchant,
752
+ treasuryWei: nativeTreasury,
753
+ creatorWei: nativeCreator,
754
+ validUntil,
755
+ // The binding the server verifies on-chain: the Payment event's orderId
756
+ // must equal the quote id, or /v1/pay answers order_id_mismatch.
757
+ orderId: quote.quote_id,
758
+ });
759
+ }
760
+ catch (error) {
761
+ if (error instanceof SettlementConfirmationPendingError && error.stage === "settlement") {
762
+ // Treat unknown confirmation as spent until reconciliation. Releasing
763
+ // the reservation here would let a retry spend the same budget again.
764
+ settled = true;
765
+ const recovery = {
766
+ apiBaseUrl: apiBase,
767
+ quote,
768
+ txRef: error.txHash,
769
+ asset: native.asset,
770
+ paymentIssuer: opts.paymentIssuer,
771
+ };
772
+ try {
773
+ if (typeof reservation === "string")
774
+ await deps.commit(reservation, amountUsd);
775
+ }
776
+ catch {
777
+ throw new Aifp1PayError("payment broadcast; confirmation and budget reconciliation required", error.txHash, quote.quote_id, recovery);
778
+ }
779
+ throw new Aifp1PayError("payment broadcast; recover its confirmation and receipt without paying again", error.txHash, quote.quote_id, recovery);
780
+ }
781
+ throw error;
782
+ }
576
783
  // Money has moved. The reservation becomes settled spend here and not one
577
784
  // line later — everything below can still fail, and none of those failures
578
785
  // give the funds back.
579
786
  settled = true;
580
- if (typeof reservation === "string")
581
- await deps.commit(reservation, amountUsd);
582
- const paid = await submitPayment(deps, apiBase, quote, txRef, native.asset, opts);
583
- // 5. Retry the original request with the receipt.
584
- const receiptResp = await send(paid.receipt);
585
- if (receiptResp.status === 402) {
586
- const body = await receiptResp.clone().text().catch(() => "");
587
- throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with a freshly settled receipt `
588
- + `${paid.receipt_id} (tx ${txRef}): ${body.slice(0, 300)}`);
787
+ try {
788
+ if (typeof reservation === "string")
789
+ await deps.commit(reservation, amountUsd);
790
+ }
791
+ catch {
792
+ throw new Aifp1PayError("payment settled; budget reconciliation and receipt recovery required", txRef, quote.quote_id, {
793
+ apiBaseUrl: apiBase,
794
+ quote,
795
+ txRef,
796
+ asset: native.asset,
797
+ paymentIssuer: opts.paymentIssuer,
798
+ });
589
799
  }
800
+ const paid = await submitPayment(deps, apiBase, quote, txRef, native.asset, opts);
590
801
  // 6. Keep the batch. This is the whole point of the design: the next call
591
802
  // this receipt covers costs a header, not a transaction.
592
803
  const entry = {
@@ -601,13 +812,6 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
601
812
  expiresAt: Date.parse(paid.expires_at),
602
813
  amountUsd,
603
814
  };
604
- // The gateway's own count if it gave one; otherwise the batch minus the
605
- // weight of the route that refused us, which is the only figure we can
606
- // justify locally.
607
- const remaining = quotaRemaining(receiptResp);
608
- entry.remaining = remaining !== null
609
- ? remaining
610
- : Math.max(0, paid.unit_quota - challenge.unit_weight);
611
815
  // Cache unconditionally. This used to be guarded on a finite expiresAt,
612
816
  // which meant an unparseable expires_at silently binned a batch that had
613
817
  // just been settled on-chain — money spent, JWT existing nowhere else,
@@ -623,9 +827,35 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
623
827
  // and answers 402 the moment the receipt really is spent or stale.
624
828
  entry.expiresAt = jwtExpiryMs(paid.receipt) ?? Number.POSITIVE_INFINITY;
625
829
  }
830
+ // Store the purchased access BEFORE contacting the merchant again. A
831
+ // transport failure here must not discard a paid batch and buy it twice.
626
832
  deps.cache.put(entry);
833
+ let receiptResp;
834
+ try {
835
+ receiptResp = await send(paid.receipt);
836
+ }
837
+ catch {
838
+ throw new Aifp1PayError("payment settled and receipt cached; content request failed, retry without paying again", txRef, quote.quote_id, {
839
+ apiBaseUrl: apiBase,
840
+ quote,
841
+ txRef,
842
+ asset: native.asset,
843
+ paymentIssuer: opts.paymentIssuer,
844
+ });
845
+ }
846
+ if (receiptResp.status === 402) {
847
+ throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with settled receipt ${paid.receipt_id} (tx ${txRef}); recover the existing payment`);
848
+ }
849
+ // The gateway's own count if it gave one; otherwise the batch minus the
850
+ // weight of the route that refused us, which is the only figure we can
851
+ // justify locally.
852
+ const remaining = quotaRemaining(receiptResp);
853
+ entry.remaining = remaining !== null ? remaining : Math.max(0, paid.unit_quota - challenge.unit_weight);
627
854
  deps.onPaid?.({
628
- merchantId: paid.merchant_id, amountUsd, txRef, receiptId: paid.receipt_id,
855
+ merchantId: paid.merchant_id,
856
+ amountUsd,
857
+ txRef,
858
+ receiptId: paid.receipt_id,
629
859
  });
630
860
  return receiptResp;
631
861
  }
@@ -645,19 +875,19 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
645
875
  return buyBatch();
646
876
  }
647
877
  // ── /v1/quote ─────────────────────────────────────────────────────────────
648
- async function requestQuote(deps, apiBase, body) {
878
+ async function requestQuote(deps, apiBase, body, timeoutMs) {
649
879
  let r;
880
+ let text;
650
881
  try {
651
- r = await deps.fetchImpl(`${apiBase}/v1/quote`, {
882
+ ({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/quote`, {
652
883
  method: "POST",
653
884
  headers: { "content-type": "application/json", "AIFP-Agent-Id": deps.agentId },
654
885
  body: JSON.stringify(body),
655
- });
886
+ }, timeoutMs));
656
887
  }
657
888
  catch (e) {
658
889
  throw new Aifp1QuoteError(`POST ${apiBase}/v1/quote failed: ${e.message}`);
659
890
  }
660
- const text = await r.text();
661
891
  if (!r.ok) {
662
892
  // The server's own detail is the useful part — a scope refused for a
663
893
  // non-flat-rated tier, a batch under the minimum, an owner policy — and
@@ -691,11 +921,43 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
691
921
  const chain = "polygon";
692
922
  const idempotencyKey = idempotencyKeyFor({ quoteId: quote.quote_id, chain, asset, txRef });
693
923
  const deadline = Date.now() + (opts.settlementConfirmMs ?? DEFAULT_SETTLEMENT_CONFIRM_MS);
924
+ const recovery = {
925
+ apiBaseUrl: apiBase,
926
+ quote,
927
+ txRef,
928
+ asset,
929
+ paymentIssuer: opts.paymentIssuer,
930
+ };
931
+ const failure = (message) => new Aifp1PayError(message, txRef, quote.quote_id, recovery);
694
932
  let lastDetail = "";
695
933
  for (let attempt = 0;; attempt++) {
934
+ if (attempt > 0 && Date.now() >= deadline)
935
+ throw failure("payment confirmation deadline elapsed; recover the existing transaction");
936
+ const expiresAt = Math.floor(Date.now() / 1000) + 240;
937
+ const payer = deps.payerAddress.toLowerCase();
938
+ // Construct this locally; never sign an arbitrary server-provided message.
939
+ const message = paymentAuthorizationMessage({
940
+ quote,
941
+ apiBase,
942
+ chain,
943
+ txRef,
944
+ asset,
945
+ idempotencyKey,
946
+ payer,
947
+ expiresAt,
948
+ issuer: opts.paymentIssuer,
949
+ });
950
+ let signature;
951
+ try {
952
+ signature = await deps.signPaymentAuthorization(message);
953
+ }
954
+ catch (e) {
955
+ throw failure(`payment already settled; wallet could not sign receipt authorization: ${e.message}`);
956
+ }
696
957
  let r;
958
+ let text;
697
959
  try {
698
- r = await deps.fetchImpl(`${apiBase}/v1/pay`, {
960
+ ({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/pay`, {
699
961
  method: "POST",
700
962
  headers: {
701
963
  "content-type": "application/json",
@@ -703,28 +965,65 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
703
965
  "AIFP-Agent-Id": deps.agentId,
704
966
  },
705
967
  body: JSON.stringify({
706
- quote_id: quote.quote_id, chain, asset, tx_ref: txRef, agent_id: deps.agentId,
968
+ quote_id: quote.quote_id,
969
+ chain,
970
+ asset,
971
+ tx_ref: txRef,
972
+ agent_id: deps.agentId,
973
+ payment_authorization: { payer, expires_at: expiresAt, signature },
707
974
  }),
708
- });
975
+ }, Math.max(1, Math.min(opts.apiTimeoutMs ?? 15_000, deadline - Date.now()))));
709
976
  }
710
977
  catch (e) {
711
- throw new Aifp1PayError(`POST ${apiBase}/v1/pay failed after settling: ${e.message}`, txRef, quote.quote_id);
978
+ if (Date.now() >= deadline || (e instanceof SettlementHttpError && e.code !== "request_timeout")) {
979
+ throw failure(`POST ${apiBase}/v1/pay failed after settling: ${e.message}`);
980
+ }
981
+ await sleep(Math.min(1000 * 2 ** attempt, 8000, Math.max(0, deadline - Date.now())));
982
+ continue;
712
983
  }
713
- const text = await r.text();
714
984
  if (r.ok) {
715
- const paid = JSON.parse(text);
985
+ let paid;
986
+ try {
987
+ paid = JSON.parse(text);
988
+ }
989
+ catch {
990
+ throw failure(`/v1/pay returned invalid JSON after settlement`);
991
+ }
716
992
  if (!paid.receipt) {
717
- throw new Aifp1PayError(`/v1/pay → 200 without a receipt: ${text.slice(0, 300)}`, txRef, quote.quote_id);
993
+ throw failure(`/v1/pay → 200 without a receipt: ${text.slice(0, 300)}`);
718
994
  }
719
995
  return paid;
720
996
  }
721
997
  lastDetail = text.slice(0, 400);
722
- // 425 is the only status worth retrying: the chain has not caught up with
723
- // us yet. Everything else is a verdict.
724
- if (r.status !== 425 || Date.now() >= deadline) {
725
- throw new Aifp1PayError(`POST /v1/pay → ${r.status} after on-chain settlement ${txRef} for quote ${quote.quote_id}: ${lastDetail}`, txRef, quote.quote_id);
998
+ // A 503 after settlement may mean accounting is unavailable, or that a
999
+ // committed response was lost. Retry issuance, never the transfer.
1000
+ if (![425, 503].includes(r.status) || Date.now() >= deadline) {
1001
+ throw failure(`POST /v1/pay → ${r.status} after on-chain settlement ${txRef} for quote ${quote.quote_id}: ${lastDetail}`);
726
1002
  }
727
- await sleep(Math.min(1000 * 2 ** attempt, 8000));
1003
+ await sleep(Math.min(1000 * 2 ** attempt, 8000, Math.max(0, deadline - Date.now())));
1004
+ }
1005
+ }
1006
+ /** Fixed, domain-separated receipt claim; sign with the wallet that settled. */
1007
+ export function paymentAuthorizationMessage(p) {
1008
+ return JSON.stringify([
1009
+ "AiFinPay receipt authorization v1",
1010
+ trustedPaymentIssuer(p.quote, p.issuer),
1011
+ p.quote.quote_id,
1012
+ p.quote.nonce,
1013
+ p.quote.merchant_id,
1014
+ p.quote.network_mode || "live",
1015
+ p.chain,
1016
+ p.txRef,
1017
+ p.asset || "",
1018
+ p.idempotencyKey,
1019
+ p.payer,
1020
+ p.expiresAt,
1021
+ ]);
1022
+ }
1023
+ function trustedPaymentIssuer(quote, configured = "https://api.aifinpay.io") {
1024
+ if (quote.payment_authorization?.domain && quote.payment_authorization.domain !== configured) {
1025
+ throw new Aifp1QuoteError("receipt authorization domain does not match the trusted paymentIssuer");
728
1026
  }
1027
+ return configured;
729
1028
  }
730
1029
  //# sourceMappingURL=aifp1.js.map