@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.
- 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 +126 -5
- package/dist/aifp1.d.ts.map +1 -1
- package/dist/aifp1.js +407 -108
- 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,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
|
-
|
|
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 || "/")
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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(() => {
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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 &&
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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.
|
|
420
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
723
|
-
//
|
|
724
|
-
if (r.status
|
|
725
|
-
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}`);
|
|
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
|