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