@aifinpay/agent 2.0.0-rc.8 → 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.
- package/LICENSE +21 -0
- package/PAYMENT_RECEIPTS.md +114 -0
- package/README.md +85 -28
- package/dist/agent.d.ts +8 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +50 -17
- package/dist/agent.js.map +1 -1
- package/dist/agentHistory.d.ts +55 -0
- package/dist/agentHistory.d.ts.map +1 -0
- package/dist/agentHistory.js +145 -0
- package/dist/agentHistory.js.map +1 -0
- package/dist/agentPassport.d.ts +71 -7
- package/dist/agentPassport.d.ts.map +1 -1
- package/dist/agentPassport.js +135 -27
- package/dist/agentPassport.js.map +1 -1
- package/dist/aifp1.d.ts +94 -5
- package/dist/aifp1.d.ts.map +1 -1
- package/dist/aifp1.js +370 -114
- package/dist/aifp1.js.map +1 -1
- package/dist/apiUrl.d.ts +3 -0
- package/dist/apiUrl.d.ts.map +1 -0
- package/dist/apiUrl.js +8 -0
- package/dist/apiUrl.js.map +1 -0
- package/dist/chains.d.ts +7 -0
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +19 -1
- package/dist/chains.js.map +1 -1
- package/dist/crossChain.d.ts.map +1 -1
- package/dist/crossChain.js.map +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js.map +1 -1
- package/dist/deploymentResolver.d.ts +95 -0
- package/dist/deploymentResolver.d.ts.map +1 -0
- package/dist/deploymentResolver.js +169 -0
- package/dist/deploymentResolver.js.map +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -1
- package/dist/facilitators/aifinpay.d.ts +4 -5
- package/dist/facilitators/aifinpay.d.ts.map +1 -1
- package/dist/facilitators/aifinpay.js +50 -21
- package/dist/facilitators/aifinpay.js.map +1 -1
- package/dist/facilitators/base.d.ts +12 -1
- package/dist/facilitators/base.d.ts.map +1 -1
- package/dist/facilitators/coinbase.d.ts.map +1 -1
- package/dist/facilitators/coinbase.js +3 -7
- package/dist/facilitators/coinbase.js.map +1 -1
- package/dist/facilitators/detect.d.ts.map +1 -1
- package/dist/facilitators/detect.js +2 -7
- package/dist/facilitators/detect.js.map +1 -1
- package/dist/facilitators/standard-x402.d.ts.map +1 -1
- package/dist/facilitators/standard-x402.js +40 -30
- package/dist/facilitators/standard-x402.js.map +1 -1
- package/dist/generated/solanaV14Deployments.generated.d.ts +25 -0
- package/dist/generated/solanaV14Deployments.generated.d.ts.map +1 -0
- package/dist/generated/solanaV14Deployments.generated.js +39 -0
- package/dist/generated/solanaV14Deployments.generated.js.map +1 -0
- package/dist/generated/splitterRoutes.generated.d.ts +24 -0
- package/dist/generated/splitterRoutes.generated.d.ts.map +1 -0
- package/dist/generated/splitterRoutes.generated.js +453 -0
- package/dist/generated/splitterRoutes.generated.js.map +1 -0
- package/dist/generated/v14Deployments.generated.d.ts +49 -0
- package/dist/generated/v14Deployments.generated.d.ts.map +1 -0
- package/dist/generated/v14Deployments.generated.js +415 -0
- package/dist/generated/v14Deployments.generated.js.map +1 -0
- package/dist/index.d.ts +27 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -7
- package/dist/index.js.map +1 -1
- package/dist/localIdentity.d.ts +17 -0
- package/dist/localIdentity.d.ts.map +1 -0
- package/dist/localIdentity.js +95 -0
- package/dist/localIdentity.js.map +1 -0
- package/dist/settlement.d.ts +34 -4
- package/dist/settlement.d.ts.map +1 -1
- package/dist/settlement.js +233 -75
- package/dist/settlement.js.map +1 -1
- package/dist/settlementHttp.d.ts +10 -0
- package/dist/settlementHttp.d.ts.map +1 -0
- package/dist/settlementHttp.js +78 -0
- package/dist/settlementHttp.js.map +1 -0
- package/dist/settlementV14.d.ts +10 -9
- package/dist/settlementV14.d.ts.map +1 -1
- package/dist/settlementV14.js +43 -55
- package/dist/settlementV14.js.map +1 -1
- package/dist/solanaDeploymentResolver.d.ts +98 -0
- package/dist/solanaDeploymentResolver.d.ts.map +1 -0
- package/dist/solanaDeploymentResolver.js +162 -0
- package/dist/solanaDeploymentResolver.js.map +1 -0
- package/dist/solanaV14Deployments.generated.d.ts +25 -0
- package/dist/solanaV14Deployments.generated.d.ts.map +1 -0
- package/dist/solanaV14Deployments.generated.js +39 -0
- package/dist/solanaV14Deployments.generated.js.map +1 -0
- package/dist/spendLedger.d.ts.map +1 -1
- package/dist/spendLedger.js +1 -3
- package/dist/spendLedger.js.map +1 -1
- package/dist/splitterRoutes.d.ts +11 -4
- package/dist/splitterRoutes.d.ts.map +1 -1
- package/dist/splitterRoutes.generated.d.ts +11 -9
- package/dist/splitterRoutes.generated.d.ts.map +1 -1
- package/dist/splitterRoutes.generated.js +72 -18
- package/dist/splitterRoutes.generated.js.map +1 -1
- package/dist/splitterRoutes.js +7 -5
- package/dist/splitterRoutes.js.map +1 -1
- package/dist/unifiedAgent.d.ts +96 -28
- package/dist/unifiedAgent.d.ts.map +1 -1
- package/dist/unifiedAgent.js +219 -348
- package/dist/unifiedAgent.js.map +1 -1
- package/dist/v14Deployments.generated.d.ts +49 -0
- package/dist/v14Deployments.generated.d.ts.map +1 -0
- package/dist/v14Deployments.generated.js +413 -0
- package/dist/v14Deployments.generated.js.map +1 -0
- package/dist/wallet.d.ts.map +1 -1
- package/dist/wallet.js +2 -2
- package/dist/wallet.js.map +1 -1
- package/package.json +27 -6
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -127
- 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,25 +58,30 @@ 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
|
-
|
|
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
|
}
|
|
72
82
|
export function describeQuote(q) {
|
|
73
83
|
const ns = q.native_settlement;
|
|
74
|
-
const pay = ns
|
|
75
|
-
? { amount: formatUnits(ns.total_wei, ns.decimals), asset: ns.asset }
|
|
76
|
-
: null;
|
|
84
|
+
const pay = ns ? { amount: formatUnits(ns.total_wei, ns.decimals), asset: ns.asset } : null;
|
|
77
85
|
// Fee as basis points, from the split the quote already carries. Stated so the
|
|
78
86
|
// agent sees the rate, not just the total — the same reason the 402 does.
|
|
79
87
|
let feeBps = null;
|
|
@@ -83,9 +91,13 @@ export function describeQuote(q) {
|
|
|
83
91
|
if (total > 0n)
|
|
84
92
|
feeBps = Number(((total - merchant) * 10000n) / total);
|
|
85
93
|
}
|
|
86
|
-
catch {
|
|
87
|
-
|
|
88
|
-
|
|
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}`
|
|
89
101
|
: q.resource;
|
|
90
102
|
const payPart = pay ? `${pay.amount} ${pay.asset}` : `$${q.amount}`;
|
|
91
103
|
const headline = `Pay ${payPart} ($${q.amount}) for ${q.requests} request${q.requests === 1 ? "" : "s"} ` +
|
|
@@ -139,7 +151,9 @@ export function scopeCovers(scope, resource, path) {
|
|
|
139
151
|
* and a single-segment path → "/" (the whole site).
|
|
140
152
|
*/
|
|
141
153
|
export function prefixHint(path) {
|
|
142
|
-
const segs = String(path || "/")
|
|
154
|
+
const segs = String(path || "/")
|
|
155
|
+
.split("/")
|
|
156
|
+
.filter(Boolean);
|
|
143
157
|
return segs.length > 1 ? `/${segs[0]}/` : "/";
|
|
144
158
|
}
|
|
145
159
|
/**
|
|
@@ -166,16 +180,22 @@ export function prefixHint(path) {
|
|
|
166
180
|
* treated as "allow all", because that is the shape a config bug takes.
|
|
167
181
|
*/
|
|
168
182
|
export const DEFAULT_GATEWAY_ORIGINS = ["https://gateway.aifinpay.io"];
|
|
169
|
-
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
|
+
}
|
|
170
187
|
const u = new URL(url);
|
|
171
188
|
if (!allowedOrigins.length) {
|
|
172
|
-
throw new Aifp1Error("no allowed gateway origins configured — refusing to pay any host. "
|
|
173
|
-
|
|
189
|
+
throw new Aifp1Error("no allowed gateway origins configured — refusing to pay any host. " +
|
|
190
|
+
"Pass gatewayOrigins explicitly for a self-hosted gateway.");
|
|
174
191
|
}
|
|
175
192
|
if (!allowedOrigins.includes(u.origin)) {
|
|
176
|
-
throw new Aifp1Error(`${u.origin} is not a known AiFinPay gateway (allowed: ${allowedOrigins.join(", ")}). `
|
|
177
|
-
|
|
178
|
-
|
|
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 };
|
|
179
199
|
}
|
|
180
200
|
const segments = u.pathname.split("/").filter(Boolean);
|
|
181
201
|
if (segments.length === 0) {
|
|
@@ -228,9 +248,7 @@ export function jwtExpiryMs(jwt) {
|
|
|
228
248
|
* A random key would satisfy the header requirement and none of the above.
|
|
229
249
|
*/
|
|
230
250
|
export function idempotencyKeyFor(p) {
|
|
231
|
-
const digest = createHash("sha256")
|
|
232
|
-
.update(`aifp1|${p.quoteId}|${p.asset}|${p.chain}|${p.txRef}`)
|
|
233
|
-
.digest("hex");
|
|
251
|
+
const digest = createHash("sha256").update(`aifp1|${p.quoteId}|${p.asset}|${p.chain}|${p.txRef}`).digest("hex");
|
|
234
252
|
return `aifp1-${digest}`; // 70 chars — well under the server's 200 limit
|
|
235
253
|
}
|
|
236
254
|
/**
|
|
@@ -272,10 +290,14 @@ export class Aifp1ReceiptCache {
|
|
|
272
290
|
async coalesce(site, buy) {
|
|
273
291
|
const running = this.inflight.get(site);
|
|
274
292
|
if (running) {
|
|
275
|
-
|
|
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;
|
|
276
296
|
return "retry";
|
|
277
297
|
}
|
|
278
|
-
const p = buy().finally(() => {
|
|
298
|
+
const p = buy().finally(() => {
|
|
299
|
+
this.inflight.delete(site);
|
|
300
|
+
});
|
|
279
301
|
this.inflight.set(site, p);
|
|
280
302
|
return p;
|
|
281
303
|
}
|
|
@@ -297,11 +319,11 @@ export class Aifp1ReceiptCache {
|
|
|
297
319
|
*/
|
|
298
320
|
find(site, restPath, merchantId) {
|
|
299
321
|
const now = Date.now();
|
|
300
|
-
return this.entries.find((e) => e.site === site
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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));
|
|
305
327
|
}
|
|
306
328
|
put(entry) {
|
|
307
329
|
this.entries.push(entry);
|
|
@@ -321,7 +343,16 @@ export class Aifp1ReceiptCache {
|
|
|
321
343
|
const now = Date.now();
|
|
322
344
|
return this.entries.filter((e) => e.expiresAt > now);
|
|
323
345
|
}
|
|
324
|
-
|
|
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
|
+
}
|
|
325
356
|
}
|
|
326
357
|
/** Default batch size: 1000 billing units — the $0.10 floor, and the size the
|
|
327
358
|
* gateway's own how_to_pay examples suggest (routes/gateway.js). If an
|
|
@@ -338,7 +369,7 @@ export class Aifp1ReceiptCache {
|
|
|
338
369
|
* dollars. Fixing the constant would have fixed today and broken the next
|
|
339
370
|
* re-price, so the count is derived instead.
|
|
340
371
|
*/
|
|
341
|
-
const DEFAULT_BATCH_USD = 0.
|
|
372
|
+
const DEFAULT_BATCH_USD = 0.1;
|
|
342
373
|
/** Used only when a 402 omits base_unit_price_usd — matches $0.10 at $0.0005. */
|
|
343
374
|
const FALLBACK_UNITS = 200;
|
|
344
375
|
/** Units to buy so the batch is worth about DEFAULT_BATCH_USD. */
|
|
@@ -397,13 +428,25 @@ function validateCanonicalQuoteEconomics(quote) {
|
|
|
397
428
|
const protocol = BigInt(s.protocol_fee_units);
|
|
398
429
|
const creator = BigInt(s.creator_units);
|
|
399
430
|
const expectedProtocol = gross / 100n;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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) {
|
|
407
450
|
throw new Error("split mismatch");
|
|
408
451
|
}
|
|
409
452
|
}
|
|
@@ -414,8 +457,7 @@ function validateCanonicalQuoteEconomics(quote) {
|
|
|
414
457
|
/** Is this response the AIFP-1 paywall asking for money? */
|
|
415
458
|
function isAifp1Challenge(body) {
|
|
416
459
|
const b = body;
|
|
417
|
-
return !!b && b.protocol === "AIFP-1" && typeof b.merchant_id === "string"
|
|
418
|
-
&& typeof b.resource === "string";
|
|
460
|
+
return !!b && b.protocol === "AIFP-1" && typeof b.merchant_id === "string" && typeof b.resource === "string";
|
|
419
461
|
}
|
|
420
462
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
421
463
|
/** Billing units the gateway says are left, or null when it did not say. */
|
|
@@ -434,16 +476,22 @@ function quotaRemaining(resp) {
|
|
|
434
476
|
* already has.
|
|
435
477
|
*/
|
|
436
478
|
export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
437
|
-
|
|
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;
|
|
438
485
|
// A paid flow sends the request twice, so the body has to survive being sent
|
|
439
486
|
// twice. A stream cannot, and finding that out after paying would mean money
|
|
440
487
|
// moved for a request that can no longer be made.
|
|
441
|
-
if (init.body !== undefined &&
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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.");
|
|
447
495
|
}
|
|
448
496
|
const send = (receiptJwt) => {
|
|
449
497
|
// new Headers() rather than a spread: RequestInit.headers is legally a
|
|
@@ -451,6 +499,8 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
451
499
|
// silently dropping every header the caller set, including their auth.
|
|
452
500
|
const headers = new Headers(init.headers);
|
|
453
501
|
headers.set("AIFP-Agent-Id", deps.agentId);
|
|
502
|
+
if (direct)
|
|
503
|
+
headers.delete("AIFP-Receipt");
|
|
454
504
|
if (receiptJwt)
|
|
455
505
|
headers.set("AIFP-Receipt", receiptJwt);
|
|
456
506
|
return deps.fetchImpl(url, {
|
|
@@ -459,13 +509,32 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
459
509
|
// The receipt is a bearer token. fetch follows redirects by default and
|
|
460
510
|
// only strips authorization/cookie/host on a cross-origin hop — a custom
|
|
461
511
|
// header rides along, so one 302 from a merchant upstream would hand the
|
|
462
|
-
// batch to another host.
|
|
463
|
-
|
|
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" } : {}),
|
|
464
515
|
});
|
|
465
516
|
};
|
|
466
517
|
// 1. Spend a batch we already own, if one covers this path.
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
}
|
|
469
538
|
if (held) {
|
|
470
539
|
// Absent header, not "zero left": the gateway sets AIFP-Quota-Remaining
|
|
471
540
|
// only on a metered paid call, and `Number(null)` is 0 — reading it
|
|
@@ -489,12 +558,17 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
489
558
|
// away a paid receipt and settles a second one on the next call.
|
|
490
559
|
//
|
|
491
560
|
// The gateway labels its own answers: {"error":"AIFP-403", ...}.
|
|
492
|
-
const detail = await resp
|
|
561
|
+
const detail = await resp
|
|
562
|
+
.clone()
|
|
563
|
+
.text()
|
|
564
|
+
.catch(() => "");
|
|
493
565
|
let ours = false;
|
|
494
566
|
try {
|
|
495
567
|
ours = JSON.parse(detail)?.error === "AIFP-403";
|
|
496
568
|
}
|
|
497
|
-
catch {
|
|
569
|
+
catch {
|
|
570
|
+
/* upstream body, not ours */
|
|
571
|
+
}
|
|
498
572
|
if (!ours)
|
|
499
573
|
return resp; // the merchant's 403, receipt intact
|
|
500
574
|
deps.cache.evict(held);
|
|
@@ -504,9 +578,15 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
504
578
|
}
|
|
505
579
|
// 402 — but only AIFP-1 is ours. x402 and anything else passes through
|
|
506
580
|
// untouched; call() owns those flows.
|
|
507
|
-
const challenge = await resp
|
|
581
|
+
const challenge = await resp
|
|
582
|
+
.clone()
|
|
583
|
+
.json()
|
|
584
|
+
.catch(() => null);
|
|
508
585
|
if (!isAifp1Challenge(challenge))
|
|
509
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
|
+
}
|
|
510
590
|
// The batch we were spending is spent (or expired) — the gateway just said
|
|
511
591
|
// so. Forget it before quoting the next one, or find() would keep handing
|
|
512
592
|
// back a receipt that cannot pay for anything.
|
|
@@ -521,16 +601,27 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
521
601
|
// spend it, and if it does not they buy their own, which is a different
|
|
522
602
|
// batch rather than a duplicate.
|
|
523
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
|
+
}
|
|
524
607
|
// 2. Quote.
|
|
525
608
|
const apiBase = (opts.apiBaseUrl ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
526
609
|
const { scope, resource } = resolveScope(opts, challenge);
|
|
527
610
|
const quote = await requestQuote(deps, apiBase, {
|
|
528
611
|
merchant_id: challenge.merchant_id,
|
|
612
|
+
payer: deps.payerAddress,
|
|
529
613
|
...(resource !== undefined ? { resource } : {}),
|
|
530
614
|
scope,
|
|
531
615
|
units: opts.units ?? defaultUnitsFor(challenge),
|
|
532
616
|
agent_id: deps.agentId,
|
|
533
|
-
});
|
|
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);
|
|
534
625
|
// The merchant we are about to pay must be the merchant that refused us.
|
|
535
626
|
// Cheap, and the failure it catches is the one worth catching.
|
|
536
627
|
if (quote.merchant_id !== challenge.merchant_id) {
|
|
@@ -543,10 +634,59 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
543
634
|
}
|
|
544
635
|
// 3. Budget. The quote states the batch total in USD, so both caps are
|
|
545
636
|
// checked against the real figure, before anything is signed.
|
|
546
|
-
|
|
637
|
+
let amountUsd = Number(quote.amount);
|
|
547
638
|
if (!Number.isFinite(amountUsd) || amountUsd < 0) {
|
|
548
639
|
throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
|
|
549
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
|
+
}
|
|
550
690
|
if (!deps.checkPerCall(amountUsd))
|
|
551
691
|
return null;
|
|
552
692
|
const reservation = await deps.reserveDaily(amountUsd);
|
|
@@ -557,13 +697,13 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
557
697
|
// 4. Settle on-chain, then exchange the tx for a receipt.
|
|
558
698
|
const native = quote.native_settlement;
|
|
559
699
|
if (!native) {
|
|
560
|
-
throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} carries no native_settlement — the backend had no live POL rate, `
|
|
561
|
-
|
|
562
|
-
|
|
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.`);
|
|
563
703
|
}
|
|
564
704
|
if (!quote.accepted_chains.includes("polygon") || !quote.pay_to.polygon) {
|
|
565
|
-
throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} does not accept polygon (accepts ${quote.accepted_chains.join(", ")}) — `
|
|
566
|
-
|
|
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)`);
|
|
567
707
|
}
|
|
568
708
|
// The caps above were checked in USD; the wallet is about to be debited in
|
|
569
709
|
// wei. Nothing tied the two together, so a quote could pass a $0.10 cap and
|
|
@@ -576,60 +716,88 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
576
716
|
// numbers must agree, and if they do not, the safe reading is not "trust
|
|
577
717
|
// the USD" — it is that this quote is not what it says it is.
|
|
578
718
|
const quotedRate = Number(native.rate_usd);
|
|
579
|
-
const weiUsd = Number.isFinite(quotedRate) && quotedRate > 0
|
|
580
|
-
? (Number(BigInt(native.total_wei)) / 1e18) * quotedRate
|
|
581
|
-
: NaN;
|
|
719
|
+
const weiUsd = Number.isFinite(quotedRate) && quotedRate > 0 ? (Number(BigInt(native.total_wei)) / 1e18) * quotedRate : NaN;
|
|
582
720
|
if (!Number.isFinite(weiUsd)) {
|
|
583
|
-
throw new Aifp1QuoteError(`quote ${quote.quote_id} states total_wei ${native.total_wei} at rate "${native.rate_usd}" — `
|
|
584
|
-
|
|
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}`);
|
|
585
723
|
}
|
|
586
724
|
// 2% covers native conversion rounding and a
|
|
587
725
|
// rate printed to six places; anything wider is a disagreement, not drift.
|
|
588
726
|
if (Math.abs(weiUsd - amountUsd) > Math.max(0.02 * amountUsd, 1e-6)) {
|
|
589
|
-
throw new Aifp1QuoteError(`quote ${quote.quote_id} would debit ${native.total_wei} wei ≈ $${weiUsd.toFixed(6)} `
|
|
590
|
-
|
|
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`);
|
|
591
729
|
}
|
|
592
730
|
const nativeGross = BigInt(native.gross_wei ?? native.total_wei);
|
|
593
731
|
const nativeMerchant = BigInt(native.merchant_wei);
|
|
594
732
|
const nativeTreasury = BigInt(native.treasury_wei);
|
|
595
733
|
const nativeCreator = BigInt(native.creator_wei);
|
|
596
|
-
if (native.settlement_semantics !== "gross-inclusive"
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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) {
|
|
602
740
|
throw new Aifp1QuoteError(`quote ${quote.quote_id} native settlement does not match canonical AIFP-1 gross split`);
|
|
603
741
|
}
|
|
604
742
|
const validUntil = BigInt(native.valid_until ?? Math.floor(quoteExpiryMs / 1000));
|
|
605
743
|
if (validUntil !== BigInt(Math.floor(quoteExpiryMs / 1000))) {
|
|
606
744
|
throw new Aifp1QuoteError(`quote ${quote.quote_id} valid_until does not match expires_at`);
|
|
607
745
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
}
|
|
619
783
|
// Money has moved. The reservation becomes settled spend here and not one
|
|
620
784
|
// line later — everything below can still fail, and none of those failures
|
|
621
785
|
// give the funds back.
|
|
622
786
|
settled = true;
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
+
});
|
|
632
799
|
}
|
|
800
|
+
const paid = await submitPayment(deps, apiBase, quote, txRef, native.asset, opts);
|
|
633
801
|
// 6. Keep the batch. This is the whole point of the design: the next call
|
|
634
802
|
// this receipt covers costs a header, not a transaction.
|
|
635
803
|
const entry = {
|
|
@@ -644,13 +812,6 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
644
812
|
expiresAt: Date.parse(paid.expires_at),
|
|
645
813
|
amountUsd,
|
|
646
814
|
};
|
|
647
|
-
// The gateway's own count if it gave one; otherwise the batch minus the
|
|
648
|
-
// weight of the route that refused us, which is the only figure we can
|
|
649
|
-
// justify locally.
|
|
650
|
-
const remaining = quotaRemaining(receiptResp);
|
|
651
|
-
entry.remaining = remaining !== null
|
|
652
|
-
? remaining
|
|
653
|
-
: Math.max(0, paid.unit_quota - challenge.unit_weight);
|
|
654
815
|
// Cache unconditionally. This used to be guarded on a finite expiresAt,
|
|
655
816
|
// which meant an unparseable expires_at silently binned a batch that had
|
|
656
817
|
// just been settled on-chain — money spent, JWT existing nowhere else,
|
|
@@ -666,9 +827,35 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
666
827
|
// and answers 402 the moment the receipt really is spent or stale.
|
|
667
828
|
entry.expiresAt = jwtExpiryMs(paid.receipt) ?? Number.POSITIVE_INFINITY;
|
|
668
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.
|
|
669
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);
|
|
670
854
|
deps.onPaid?.({
|
|
671
|
-
merchantId: paid.merchant_id,
|
|
855
|
+
merchantId: paid.merchant_id,
|
|
856
|
+
amountUsd,
|
|
857
|
+
txRef,
|
|
858
|
+
receiptId: paid.receipt_id,
|
|
672
859
|
});
|
|
673
860
|
return receiptResp;
|
|
674
861
|
}
|
|
@@ -688,19 +875,19 @@ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
|
688
875
|
return buyBatch();
|
|
689
876
|
}
|
|
690
877
|
// ── /v1/quote ─────────────────────────────────────────────────────────────
|
|
691
|
-
async function requestQuote(deps, apiBase, body) {
|
|
878
|
+
async function requestQuote(deps, apiBase, body, timeoutMs) {
|
|
692
879
|
let r;
|
|
880
|
+
let text;
|
|
693
881
|
try {
|
|
694
|
-
r = await deps.fetchImpl
|
|
882
|
+
({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/quote`, {
|
|
695
883
|
method: "POST",
|
|
696
884
|
headers: { "content-type": "application/json", "AIFP-Agent-Id": deps.agentId },
|
|
697
885
|
body: JSON.stringify(body),
|
|
698
|
-
});
|
|
886
|
+
}, timeoutMs));
|
|
699
887
|
}
|
|
700
888
|
catch (e) {
|
|
701
889
|
throw new Aifp1QuoteError(`POST ${apiBase}/v1/quote failed: ${e.message}`);
|
|
702
890
|
}
|
|
703
|
-
const text = await r.text();
|
|
704
891
|
if (!r.ok) {
|
|
705
892
|
// The server's own detail is the useful part — a scope refused for a
|
|
706
893
|
// non-flat-rated tier, a batch under the minimum, an owner policy — and
|
|
@@ -734,11 +921,43 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
|
|
|
734
921
|
const chain = "polygon";
|
|
735
922
|
const idempotencyKey = idempotencyKeyFor({ quoteId: quote.quote_id, chain, asset, txRef });
|
|
736
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);
|
|
737
932
|
let lastDetail = "";
|
|
738
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
|
+
}
|
|
739
957
|
let r;
|
|
958
|
+
let text;
|
|
740
959
|
try {
|
|
741
|
-
r = await deps.fetchImpl
|
|
960
|
+
({ response: r, text } = await settlementHttp(deps.fetchImpl, `${apiBase}/v1/pay`, {
|
|
742
961
|
method: "POST",
|
|
743
962
|
headers: {
|
|
744
963
|
"content-type": "application/json",
|
|
@@ -746,28 +965,65 @@ async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
|
|
|
746
965
|
"AIFP-Agent-Id": deps.agentId,
|
|
747
966
|
},
|
|
748
967
|
body: JSON.stringify({
|
|
749
|
-
quote_id: quote.quote_id,
|
|
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 },
|
|
750
974
|
}),
|
|
751
|
-
});
|
|
975
|
+
}, Math.max(1, Math.min(opts.apiTimeoutMs ?? 15_000, deadline - Date.now()))));
|
|
752
976
|
}
|
|
753
977
|
catch (e) {
|
|
754
|
-
|
|
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;
|
|
755
983
|
}
|
|
756
|
-
const text = await r.text();
|
|
757
984
|
if (r.ok) {
|
|
758
|
-
|
|
985
|
+
let paid;
|
|
986
|
+
try {
|
|
987
|
+
paid = JSON.parse(text);
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
throw failure(`/v1/pay returned invalid JSON after settlement`);
|
|
991
|
+
}
|
|
759
992
|
if (!paid.receipt) {
|
|
760
|
-
throw
|
|
993
|
+
throw failure(`/v1/pay → 200 without a receipt: ${text.slice(0, 300)}`);
|
|
761
994
|
}
|
|
762
995
|
return paid;
|
|
763
996
|
}
|
|
764
997
|
lastDetail = text.slice(0, 400);
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
if (r.status
|
|
768
|
-
throw
|
|
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}`);
|
|
769
1002
|
}
|
|
770
|
-
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");
|
|
771
1026
|
}
|
|
1027
|
+
return configured;
|
|
772
1028
|
}
|
|
773
1029
|
//# sourceMappingURL=aifp1.js.map
|