@aifinpay/agent 1.7.0 → 1.8.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/dist/aifp1.js ADDED
@@ -0,0 +1,681 @@
1
+ // ──────────────────────────────────────────────────────────────────────────
2
+ // AIFP-1 client — paying a merchant paywall at gateway.aifinpay.io.
3
+ //
4
+ // The SDK could already pay a *bridge* (the x402 / pay_matic 402 handled inside
5
+ // AiFinPayAgent.call()), but not a *merchant paywall*. Those are different
6
+ // protocols with different money shapes, and only the second one is what a
7
+ // merchant switches on when they put their site behind AiFinPay:
8
+ //
9
+ // GET https://gateway.aifinpay.io/{slug}/some/path
10
+ // → 402 {error:"AIFP-402", protocol:"AIFP-1", merchant_id, resource,
11
+ // unit_weight, how_to_pay[], scopes{}} (routes/gateway.js)
12
+ // → POST /v1/quote → a binding quote (routes/aifp.js)
13
+ // → settle the quote on-chain yourself, order_id = quote_id
14
+ // → POST /v1/pay → an Ed25519 JWT receipt
15
+ // → retry with `AIFP-Receipt: <jwt>`
16
+ //
17
+ // THE PART THAT MATTERS: a receipt is a prepaid BATCH, not a ticket.
18
+ //
19
+ // It carries `unit_quota` billing units and each call spends the weight of the
20
+ // route it hits (routes/gateway.js meters `aifp:used:<receipt_id>` by the
21
+ // weight from the merchant's own registry). The minimum batch is $0.10
22
+ // (aifp/pricing.js MIN_BATCH_UNITS) and settling costs gas on top, so a client
23
+ // that quotes-settles-pays per request turns a $0.0001 page view into eleven
24
+ // cents and a chain transaction. Reuse is not an optimisation here; it is the
25
+ // difference between a payable site and an unpayable one.
26
+ //
27
+ // So this module keeps receipts and spends them down: it matches on merchant +
28
+ // scope, tracks the quota the gateway reports back in `AIFP-Quota-Remaining`,
29
+ // and only re-quotes when the batch is genuinely spent or expired.
30
+ //
31
+ // Deliberately a free function over a `deps` bag rather than a method: the
32
+ // money-moving step (settleSplitterNative) and the budget ledger live on
33
+ // AiFinPayAgent, but nothing else here does, and a test should be able to drive
34
+ // the whole protocol without an RPC endpoint.
35
+ // ──────────────────────────────────────────────────────────────────────────
36
+ import { createHash } from "node:crypto";
37
+ import { AiFinPayError } from "./errors.js";
38
+ // ── Errors ────────────────────────────────────────────────────────────────
39
+ //
40
+ // Specific classes rather than one message, because the recoveries differ: a
41
+ // quote refusal is worth retrying with different arguments, a settlement the
42
+ // gateway then refuses is not something an agent should paper over, and an
43
+ // agent that silently pays the wrong merchant is worse than one that throws.
44
+ export class Aifp1Error extends AiFinPayError {
45
+ }
46
+ /** /v1/quote refused, or answered something that is not a quote. */
47
+ export class Aifp1QuoteError extends Aifp1Error {
48
+ }
49
+ /** The quote cannot be settled by this SDK (no asset we can send). */
50
+ export class Aifp1SettlementUnsupportedError extends Aifp1Error {
51
+ }
52
+ /**
53
+ * Money moved on-chain but /v1/pay did not issue a receipt.
54
+ *
55
+ * Carries the tx hash because that is the only thing standing between the
56
+ * caller and a lost payment: the quote is still settleable through
57
+ * `POST /v1/pay` by hand with the same Idempotency-Key, and without the hash
58
+ * in the error there is nothing to retry with.
59
+ */
60
+ export class Aifp1PayError extends Aifp1Error {
61
+ txRef;
62
+ quoteId;
63
+ constructor(msg, txRef, quoteId) {
64
+ super(msg);
65
+ this.txRef = txRef;
66
+ this.quoteId = quoteId;
67
+ }
68
+ }
69
+ /** The gateway rejected a receipt we believed covered the request. */
70
+ export class Aifp1ReceiptRejectedError extends Aifp1Error {
71
+ }
72
+ // ── Scope, ported from the server ─────────────────────────────────────────
73
+ //
74
+ // backend/aifp/scope.js decides whether a receipt covers a request, and this
75
+ // is the same decision made one round-trip earlier. It is a port, not an
76
+ // approximation: a client that is *stricter* wastes money on batches it
77
+ // already owns, and one that is *looser* sends a receipt the gateway answers
78
+ // 403 for. Keep the two in step.
79
+ /** Mirror of scope.js scopeCovers(). */
80
+ export function scopeCovers(scope, resource, path) {
81
+ if (scope === "merchant")
82
+ return true;
83
+ if (scope === "prefix") {
84
+ if (resource === "/")
85
+ return true; // whole site, spelled as a prefix
86
+ if (path === resource)
87
+ return true; // the prefix path itself
88
+ // The trailing slash is what stops /articles covering /articles-internal.
89
+ const boundary = resource.endsWith("/") ? resource : resource + "/";
90
+ return path.startsWith(boundary);
91
+ }
92
+ return path === resource; // 'exact', and anything unrecognised
93
+ }
94
+ /**
95
+ * Mirror of routes/gateway.js prefixHint(): /articles/2026/thing → /articles/,
96
+ * and a single-segment path → "/" (the whole site).
97
+ */
98
+ export function prefixHint(path) {
99
+ const segs = String(path || "/").split("/").filter(Boolean);
100
+ return segs.length > 1 ? `/${segs[0]}/` : "/";
101
+ }
102
+ /**
103
+ * Split a gateway URL the way routes/gateway.js splits it: first path segment
104
+ * is the merchant slug, the rest is the merchant-relative resource, and the
105
+ * query string is not part of either.
106
+ *
107
+ * `site` (origin + slug) is the cache key. It is the only merchant identity
108
+ * available before the first 402 answers with a merchant_id, and slug →
109
+ * merchant is a 1:1 lookup server-side (gw.resolveSlug).
110
+ */
111
+ /**
112
+ * Gateway origins this client will settle against.
113
+ *
114
+ * Without this the client paid whoever answered. parseGatewayUrl looked only
115
+ * at the path, so ANY host could return {error:"AIFP-402", protocol:"AIFP-1",
116
+ * merchant_id:"mrch_theirs"} and the agent would quote, settle real POL to the
117
+ * address in that quote, and hand over a receipt. The 402 is unauthenticated
118
+ * by construction — it is the answer to a request that carried no credentials —
119
+ * so the only thing separating a paywall from a trap is knowing whose paywall
120
+ * it is.
121
+ *
122
+ * Override for self-hosted gateways; an empty list is refused rather than
123
+ * treated as "allow all", because that is the shape a config bug takes.
124
+ */
125
+ export const DEFAULT_GATEWAY_ORIGINS = ["https://gateway.aifinpay.io"];
126
+ export function parseGatewayUrl(url, allowedOrigins = DEFAULT_GATEWAY_ORIGINS) {
127
+ const u = new URL(url);
128
+ if (!allowedOrigins.length) {
129
+ throw new Aifp1Error("no allowed gateway origins configured — refusing to pay any host. "
130
+ + "Pass gatewayOrigins explicitly for a self-hosted gateway.");
131
+ }
132
+ if (!allowedOrigins.includes(u.origin)) {
133
+ throw new Aifp1Error(`${u.origin} is not a known AiFinPay gateway (allowed: ${allowedOrigins.join(", ")}). `
134
+ + "Refusing to settle: a 402 is unauthenticated, so paying an unrecognised host "
135
+ + "means paying whoever answered.");
136
+ }
137
+ const segments = u.pathname.split("/").filter(Boolean);
138
+ if (segments.length === 0) {
139
+ throw new Aifp1Error(`${url} has no merchant slug — a gateway URL looks like https://gateway.aifinpay.io/{slug}/path`);
140
+ }
141
+ const slug = segments[0];
142
+ return {
143
+ site: `${u.origin}/${slug}`,
144
+ slug,
145
+ restPath: "/" + segments.slice(1).join("/"),
146
+ };
147
+ }
148
+ /**
149
+ * The `exp` of a receipt JWT, in ms, without verifying it — we are not
150
+ * authenticating the token here, only recovering a expiry the response failed
151
+ * to state. Returns null if the claim is absent or unreadable.
152
+ */
153
+ export function jwtExpiryMs(jwt) {
154
+ try {
155
+ const payload = jwt.split(".")[1];
156
+ if (!payload)
157
+ return null;
158
+ const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
159
+ return typeof json.exp === "number" ? json.exp * 1000 : null;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ // ── Idempotency ───────────────────────────────────────────────────────────
166
+ /**
167
+ * The Idempotency-Key for one /v1/pay call. Required by the server
168
+ * (routes/aifp.js: 400 without it) and load-bearing here for a different
169
+ * reason: it is what stops one settled transaction being turned into two
170
+ * receipts, or a network blip during /v1/pay from becoming a second on-chain
171
+ * payment on retry.
172
+ *
173
+ * Derived from exactly the four fields the server itself hashes into its
174
+ * idempotency record — quote_id, asset, chain, tx_ref. That equivalence is the
175
+ * point:
176
+ *
177
+ * • the same payment retried ⇒ the same key ⇒ the server replays the first
178
+ * response instead of issuing a second receipt;
179
+ * • a different payment ⇒ a different key, because a different payment must
180
+ * differ in at least one of those four (quote_id alone is single-use —
181
+ * `quote.used` makes a second settlement of one quote a 409);
182
+ * • and the server's "same key, different body → 409" branch is unreachable
183
+ * from this client, since the key is a pure function of that body.
184
+ *
185
+ * A random key would satisfy the header requirement and none of the above.
186
+ */
187
+ export function idempotencyKeyFor(p) {
188
+ const digest = createHash("sha256")
189
+ .update(`aifp1|${p.quoteId}|${p.asset}|${p.chain}|${p.txRef}`)
190
+ .digest("hex");
191
+ return `aifp1-${digest}`; // 70 chars — well under the server's 200 limit
192
+ }
193
+ /**
194
+ * How close to expiry a receipt is still worth sending.
195
+ *
196
+ * A JWT that expires while the request is in flight comes back as a 402 with
197
+ * `receipt expired`, which costs a round-trip and, worse, looks exactly like
198
+ * an exhausted batch — so the client would re-quote and pay for a batch it
199
+ * still had. Ten seconds of margin is cheap insurance against clock skew.
200
+ */
201
+ const EXPIRY_MARGIN_MS = 10_000;
202
+ /**
203
+ * The receipts this agent holds, and the rules for spending them.
204
+ *
205
+ * In-memory and per-agent-instance: a receipt is a bearer credential, and
206
+ * writing one to disk by default would be a bigger decision than a cache
207
+ * deserves. An agent that wants batches to survive a restart can hold the
208
+ * entries itself (they are plain data) and re-seed a cache.
209
+ */
210
+ export class Aifp1ReceiptCache {
211
+ entries = [];
212
+ /**
213
+ * Batch purchases in flight, keyed by site.
214
+ *
215
+ * The cache is empty between deciding to buy and the receipt arriving, so
216
+ * ten workers sharing one agent all missed, all got a 402, and all settled
217
+ * their own batch — ten on-chain payments and ten minimum charges where one
218
+ * would have covered every worker. It recurred at each quota boundary, so
219
+ * the more traffic an agent had the more it overpaid.
220
+ *
221
+ * Second and later callers await the first purchase, then re-check the
222
+ * cache: whoever wins buys a batch wide enough that the rest find it
223
+ * covering their path too. If it does not cover them (a different prefix)
224
+ * they fall through and buy their own, which is correct — that is a
225
+ * different batch, not a duplicate.
226
+ */
227
+ inflight = new Map();
228
+ /** Run `buy` for `site`, or await the purchase already running for it. */
229
+ async coalesce(site, buy) {
230
+ const running = this.inflight.get(site);
231
+ if (running) {
232
+ await running.catch(() => { });
233
+ return "retry";
234
+ }
235
+ const p = buy().finally(() => { this.inflight.delete(site); });
236
+ this.inflight.set(site, p);
237
+ return p;
238
+ }
239
+ /**
240
+ * The receipt to send for this request, if we hold one.
241
+ *
242
+ * Three conditions, all necessary:
243
+ * • same merchant — a receipt's `aud` is the merchant id, so sending one
244
+ * elsewhere is at best a 403 and at worst paying the wrong party;
245
+ * • the scope covers the path — the same test the gateway will apply;
246
+ * • quota left and not expired.
247
+ *
248
+ * `remaining > 0` rather than `remaining >= weight`: we only learn a route's
249
+ * weight from a 402 refusing it, so the weight of a path we have not been
250
+ * refused on is genuinely unknown here. The gateway's meter is authoritative
251
+ * and answers 402 "quota exhausted" when the batch cannot cover the call —
252
+ * at which point we re-quote. Guessing a weight would only add a way to be
253
+ * wrong in the direction of not spending a batch we own.
254
+ */
255
+ find(site, restPath, merchantId) {
256
+ const now = Date.now();
257
+ return this.entries.find((e) => e.site === site
258
+ && (merchantId === undefined || e.merchantId === merchantId)
259
+ && e.remaining > 0
260
+ && e.expiresAt - EXPIRY_MARGIN_MS > now
261
+ && scopeCovers(e.scope, e.resource, restPath));
262
+ }
263
+ put(entry) {
264
+ this.entries.push(entry);
265
+ }
266
+ /** The gateway's own count wins over ours — it is the one doing the metering. */
267
+ noteQuotaRemaining(entry, remaining) {
268
+ if (Number.isFinite(remaining))
269
+ entry.remaining = Math.max(0, remaining);
270
+ }
271
+ evict(entry) {
272
+ const i = this.entries.indexOf(entry);
273
+ if (i >= 0)
274
+ this.entries.splice(i, 1);
275
+ }
276
+ /** Everything we still hold, expired entries dropped. Plain data, copyable. */
277
+ list() {
278
+ const now = Date.now();
279
+ return this.entries.filter((e) => e.expiresAt > now);
280
+ }
281
+ get size() { return this.entries.length; }
282
+ }
283
+ /** Default batch size: 1000 billing units — the $0.10 floor, and the size the
284
+ * gateway's own how_to_pay examples suggest (routes/gateway.js). If an
285
+ * operator has raised AIFP_MIN_BATCH_UNITS, /v1/quote answers 400 naming the
286
+ * real minimum, which is loud rather than silently underpaid. */
287
+ /**
288
+ * What a default batch costs, in USD — not how many units it is.
289
+ *
290
+ * This was a fixed 1000 units, "= $0.10 at the base unit price", and it was.
291
+ * Then the tiers were re-priced on 2026-08-07 and the base unit went from
292
+ * $0.0001 to $0.0005, so the same constant silently became a $0.50 batch:
293
+ * five times the money, with the comment beside it still saying ten cents.
294
+ * A unit count is a number about our internal accounting; an agent budgets in
295
+ * dollars. Fixing the constant would have fixed today and broken the next
296
+ * re-price, so the count is derived instead.
297
+ */
298
+ const DEFAULT_BATCH_USD = 0.10;
299
+ /** Used only when a 402 omits base_unit_price_usd — matches $0.10 at $0.0005. */
300
+ const FALLBACK_UNITS = 200;
301
+ /** Units to buy so the batch is worth about DEFAULT_BATCH_USD. */
302
+ export function defaultUnitsFor(challenge) {
303
+ const base = Number(challenge.base_unit_price_usd);
304
+ if (!Number.isFinite(base) || base <= 0)
305
+ return FALLBACK_UNITS;
306
+ return Math.max(1, Math.ceil(DEFAULT_BATCH_USD / base));
307
+ }
308
+ const DEFAULT_API_BASE = "https://api.aifinpay.io";
309
+ const DEFAULT_SETTLEMENT_CONFIRM_MS = 60_000;
310
+ /**
311
+ * Which scope to buy, and why the default is "prefix".
312
+ *
313
+ * The server's default is "exact" — one path per batch — because that is what
314
+ * every pre-scope client sent and back-compat had to hold. It is the right
315
+ * shape for an API an agent hammers, and the wrong one for everything else:
316
+ * each distinct URL then needs its own quote, its own on-chain settlement and
317
+ * its own $0.10 floor, so an agent reading a hundred articles pays $10 for
318
+ * $0.01 of content and burns a hundred transactions' gas doing it. A client
319
+ * that inherits the server's default inherits that bill, so this one does not.
320
+ *
321
+ * "prefix" over "merchant" is the deliberate part. Both cost exactly the same
322
+ * — scope decides *where* units may be spent, never how many a call costs
323
+ * (aifp/scope.js) — so the trade is not money, it is blast radius: the receipt
324
+ * is a bearer JWT, and a merchant-scoped one is the agent's entire prepaid
325
+ * balance with that merchant in a single token. "prefix" buys the case that
326
+ * was actually ruinous (crawling one section of a site) while keeping the
327
+ * token bound to the section we were refused on. It is also the server's own
328
+ * suggestion — prefixHint() is what the 402's `scopes.examples` offers.
329
+ *
330
+ * Note that prefixHint collapses a single-segment path to "/", i.e. the whole
331
+ * site, so the shallow-crawl case is covered by one batch anyway.
332
+ *
333
+ * Pass `scope: "merchant"` when the agent is crawling across sections and the
334
+ * round-trips matter more than the blast radius.
335
+ */
336
+ function resolveScope(opts, challenge) {
337
+ const scope = opts.scope ?? "prefix";
338
+ if (scope === "merchant")
339
+ return { scope, resource: undefined }; // server stores "*"
340
+ if (opts.resource)
341
+ return { scope, resource: opts.resource };
342
+ return {
343
+ scope,
344
+ resource: scope === "prefix" ? prefixHint(challenge.resource) : challenge.resource,
345
+ };
346
+ }
347
+ /** Is this response the AIFP-1 paywall asking for money? */
348
+ function isAifp1Challenge(body) {
349
+ const b = body;
350
+ return !!b && b.protocol === "AIFP-1" && typeof b.merchant_id === "string"
351
+ && typeof b.resource === "string";
352
+ }
353
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
354
+ /** Billing units the gateway says are left, or null when it did not say. */
355
+ function quotaRemaining(resp) {
356
+ const raw = resp.headers.get("AIFP-Quota-Remaining");
357
+ if (raw === null)
358
+ return null;
359
+ const n = Number(raw);
360
+ return Number.isFinite(n) ? Math.max(0, n) : null;
361
+ }
362
+ /**
363
+ * Fetch a gateway URL, paying the AIFP-1 paywall if it asks.
364
+ *
365
+ * Returns the merchant's response. Returns null only when a budget cap was hit
366
+ * and `on_limit_exceeded` is "skip" — the same contract AiFinPayAgent.call()
367
+ * already has.
368
+ */
369
+ export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
370
+ const { site, restPath } = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS);
371
+ // A paid flow sends the request twice, so the body has to survive being sent
372
+ // twice. A stream cannot, and finding that out after paying would mean money
373
+ // moved for a request that can no longer be made.
374
+ if (init.body !== undefined && init.body !== null
375
+ && typeof init.body === "object"
376
+ && typeof init.body.getReader === "function") {
377
+ throw new Aifp1Error("aifp1Fetch cannot use a stream body: a paywalled request is sent twice "
378
+ + "(once to learn the price, once with the receipt) and a stream can only "
379
+ + "be read once. Pass a string, Buffer, or URLSearchParams.");
380
+ }
381
+ const send = (receiptJwt) => {
382
+ // new Headers() rather than a spread: RequestInit.headers is legally a
383
+ // Headers instance or an array of pairs, and spreading either yields {} —
384
+ // silently dropping every header the caller set, including their auth.
385
+ const headers = new Headers(init.headers);
386
+ headers.set("AIFP-Agent-Id", deps.agentId);
387
+ if (receiptJwt)
388
+ headers.set("AIFP-Receipt", receiptJwt);
389
+ return deps.fetchImpl(url, {
390
+ ...init,
391
+ headers,
392
+ // The receipt is a bearer token. fetch follows redirects by default and
393
+ // only strips authorization/cookie/host on a cross-origin hop — a custom
394
+ // header rides along, so one 302 from a merchant upstream would hand the
395
+ // batch to another host. Stop at the redirect and let the caller decide.
396
+ ...(receiptJwt ? { redirect: "manual" } : {}),
397
+ });
398
+ };
399
+ // 1. Spend a batch we already own, if one covers this path.
400
+ const held = deps.cache.find(site, restPath);
401
+ const resp = await send(held?.jwt);
402
+ if (held) {
403
+ // Absent header, not "zero left": the gateway sets AIFP-Quota-Remaining
404
+ // only on a metered paid call, and `Number(null)` is 0 — reading it
405
+ // unguarded would retire a full batch the first time a route was free.
406
+ const remaining = quotaRemaining(resp);
407
+ if (remaining !== null)
408
+ deps.cache.noteQuotaRemaining(held, remaining);
409
+ }
410
+ if (resp.status !== 402) {
411
+ // Not a paywall refusal. That includes a 403, which for a receipt we sent
412
+ // means the gateway disagreed with our coverage or our receipt is not
413
+ // valid for this merchant. Drop it rather than keep re-sending a token the
414
+ // server has already refused, and say so — paying again to work around a
415
+ // coverage disagreement would settle a second batch for the same content.
416
+ if (held && resp.status === 403) {
417
+ // Not every 403 here is about the receipt. routes/gateway.js proxies the
418
+ // merchant's upstream status verbatim, so hotlink protection or an
419
+ // expired API key on their side arrives as a 403 too — and the gateway's
420
+ // own refusals include merchant policy "block", which is checked before
421
+ // the receipt is even read. Evicting a valid batch on any of those throws
422
+ // away a paid receipt and settles a second one on the next call.
423
+ //
424
+ // The gateway labels its own answers: {"error":"AIFP-403", ...}.
425
+ const detail = await resp.clone().text().catch(() => "");
426
+ let ours = false;
427
+ try {
428
+ ours = JSON.parse(detail)?.error === "AIFP-403";
429
+ }
430
+ catch { /* upstream body, not ours */ }
431
+ if (!ours)
432
+ return resp; // the merchant's 403, receipt intact
433
+ deps.cache.evict(held);
434
+ throw new Aifp1ReceiptRejectedError(`gateway refused receipt ${held.receiptId} for ${restPath} (403): ${detail.slice(0, 300)}`);
435
+ }
436
+ return resp;
437
+ }
438
+ // 402 — but only AIFP-1 is ours. x402 and anything else passes through
439
+ // untouched; call() owns those flows.
440
+ const challenge = await resp.clone().json().catch(() => null);
441
+ if (!isAifp1Challenge(challenge))
442
+ return resp;
443
+ // The batch we were spending is spent (or expired) — the gateway just said
444
+ // so. Forget it before quoting the next one, or find() would keep handing
445
+ // back a receipt that cannot pay for anything.
446
+ if (held)
447
+ deps.cache.evict(held);
448
+ // Buying a batch, serialised per site.
449
+ //
450
+ // Ten workers sharing one agent used to arrive here together, all miss the
451
+ // cache, and all settle their own batch: ten on-chain payments and ten
452
+ // minimum charges where one covered every one of them. Losers await the
453
+ // winner and then re-check — if the batch it bought covers their path they
454
+ // spend it, and if it does not they buy their own, which is a different
455
+ // batch rather than a duplicate.
456
+ const buyBatch = async () => {
457
+ // 2. Quote.
458
+ const apiBase = (opts.apiBaseUrl ?? DEFAULT_API_BASE).replace(/\/$/, "");
459
+ const { scope, resource } = resolveScope(opts, challenge);
460
+ const quote = await requestQuote(deps, apiBase, {
461
+ merchant_id: challenge.merchant_id,
462
+ ...(resource !== undefined ? { resource } : {}),
463
+ scope,
464
+ units: opts.units ?? defaultUnitsFor(challenge),
465
+ agent_id: deps.agentId,
466
+ });
467
+ // The merchant we are about to pay must be the merchant that refused us.
468
+ // Cheap, and the failure it catches is the one worth catching.
469
+ if (quote.merchant_id !== challenge.merchant_id) {
470
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} is for merchant ${quote.merchant_id} but ${site} refused as ${challenge.merchant_id} — refusing to pay`);
471
+ }
472
+ // 3. Budget. The quote states the batch total in USD, so both caps are
473
+ // checked against the real figure, before anything is signed.
474
+ const amountUsd = Number(quote.amount);
475
+ if (!Number.isFinite(amountUsd) || amountUsd < 0) {
476
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
477
+ }
478
+ if (!deps.checkPerCall(amountUsd))
479
+ return null;
480
+ const reservation = await deps.reserveDaily(amountUsd);
481
+ if (reservation === "skip")
482
+ return null;
483
+ let settled = false;
484
+ try {
485
+ // 4. Settle on-chain, then exchange the tx for a receipt.
486
+ const native = quote.native_settlement;
487
+ if (!native) {
488
+ throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} carries no native_settlement — the backend had no live POL rate, `
489
+ + `and the deployed B2BSplitter has no ERC-20 entrypoint, so this SDK cannot settle `
490
+ + `${quote.accepted_assets.join("/")}. Retry when a POL rate is available or settle the quote yourself.`);
491
+ }
492
+ if (!quote.accepted_chains.includes("polygon") || !quote.pay_to.polygon) {
493
+ throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} does not accept polygon (accepts ${quote.accepted_chains.join(", ")}) — `
494
+ + `AIFP-1 settlement verification is Polygon-only server-side (aifp/verify-settlement.js)`);
495
+ }
496
+ // The caps above were checked in USD; the wallet is about to be debited in
497
+ // wei. Nothing tied the two together, so a quote could pass a $0.10 cap and
498
+ // spend any amount of POL — the client had no reason to notice, because it
499
+ // never converted one into the other.
500
+ //
501
+ // The quote states the rate it used and fixes it for its lifetime
502
+ // (routes/aifp.js: rate_fixed_at, "a quote that repriced itself would let a
503
+ // payment that was correct when sent become underpaid"). So the three
504
+ // numbers must agree, and if they do not, the safe reading is not "trust
505
+ // the USD" — it is that this quote is not what it says it is.
506
+ const quotedRate = Number(native.rate_usd);
507
+ const weiUsd = Number.isFinite(quotedRate) && quotedRate > 0
508
+ ? (Number(BigInt(native.total_wei)) / 1e18) * quotedRate
509
+ : NaN;
510
+ if (!Number.isFinite(weiUsd)) {
511
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} states total_wei ${native.total_wei} at rate "${native.rate_usd}" — `
512
+ + `unusable, and the caps were checked against $${amountUsd}`);
513
+ }
514
+ // 2% covers the fee-inclusive rounding the splitter does on each leg and a
515
+ // rate printed to six places; anything wider is a disagreement, not drift.
516
+ if (Math.abs(weiUsd - amountUsd) > Math.max(0.02 * amountUsd, 1e-6)) {
517
+ throw new Aifp1QuoteError(`quote ${quote.quote_id} would debit ${native.total_wei} wei ≈ $${weiUsd.toFixed(6)} `
518
+ + `but states $${amountUsd} — refusing to sign a payment the budget caps did not see`);
519
+ }
520
+ const txRef = await deps.settle({
521
+ merchantWallet: quote.pay_to.polygon,
522
+ totalWei: BigInt(native.total_wei),
523
+ // The binding the server verifies on-chain: the Payment event's orderId
524
+ // must equal the quote id, or /v1/pay answers order_id_mismatch.
525
+ orderId: quote.quote_id,
526
+ });
527
+ // Money has moved. The reservation becomes settled spend here and not one
528
+ // line later — everything below can still fail, and none of those failures
529
+ // give the funds back.
530
+ settled = true;
531
+ if (typeof reservation === "string")
532
+ await deps.commit(reservation, amountUsd);
533
+ const paid = await submitPayment(deps, apiBase, quote, txRef, native.asset, opts);
534
+ // 5. Retry the original request with the receipt.
535
+ const receiptResp = await send(paid.receipt);
536
+ if (receiptResp.status === 402) {
537
+ const body = await receiptResp.clone().text().catch(() => "");
538
+ throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with a freshly settled receipt `
539
+ + `${paid.receipt_id} (tx ${txRef}): ${body.slice(0, 300)}`);
540
+ }
541
+ // 6. Keep the batch. This is the whole point of the design: the next call
542
+ // this receipt covers costs a header, not a transaction.
543
+ const entry = {
544
+ site,
545
+ merchantId: paid.merchant_id,
546
+ receiptId: paid.receipt_id,
547
+ jwt: paid.receipt,
548
+ scope: paid.scope,
549
+ resource: paid.resource,
550
+ unitQuota: paid.unit_quota,
551
+ remaining: paid.unit_quota,
552
+ expiresAt: Date.parse(paid.expires_at),
553
+ amountUsd,
554
+ };
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
+ // Cache unconditionally. This used to be guarded on a finite expiresAt,
563
+ // which meant an unparseable expires_at silently binned a batch that had
564
+ // just been settled on-chain — money spent, JWT existing nowhere else,
565
+ // and the next call settling another one. A bad timestamp is a reason to
566
+ // distrust the expiry, not the receipt: fall back to the JWT's own exp,
567
+ // and to "expired" only if that is missing too, so find() re-quotes
568
+ // instead of the entry lingering forever.
569
+ if (!Number.isFinite(entry.expiresAt)) {
570
+ // Prefer the token's own exp; if that is unreadable too, keep the batch
571
+ // rather than dating it into the past. The asymmetry decides it: assume
572
+ // expired and we settle a second batch for certain, assume valid and the
573
+ // worst case is one wasted request, because the gateway is the authority
574
+ // and answers 402 the moment the receipt really is spent or stale.
575
+ entry.expiresAt = jwtExpiryMs(paid.receipt) ?? Number.POSITIVE_INFINITY;
576
+ }
577
+ deps.cache.put(entry);
578
+ deps.onPaid?.({
579
+ merchantId: paid.merchant_id, amountUsd, txRef, receiptId: paid.receipt_id,
580
+ });
581
+ return receiptResp;
582
+ }
583
+ finally {
584
+ // Anything that did not move money gives the cap back now rather than
585
+ // waiting for the reservation to expire — same rule as call().
586
+ if (typeof reservation === "string" && !settled)
587
+ await deps.release(reservation);
588
+ }
589
+ };
590
+ const outcome = await deps.cache.coalesce(site, buyBatch);
591
+ if (outcome !== "retry")
592
+ return outcome;
593
+ const fresh = deps.cache.find(site, restPath);
594
+ if (fresh)
595
+ return send(fresh.jwt);
596
+ return buyBatch();
597
+ }
598
+ // ── /v1/quote ─────────────────────────────────────────────────────────────
599
+ async function requestQuote(deps, apiBase, body) {
600
+ let r;
601
+ try {
602
+ r = await deps.fetchImpl(`${apiBase}/v1/quote`, {
603
+ method: "POST",
604
+ headers: { "content-type": "application/json", "AIFP-Agent-Id": deps.agentId },
605
+ body: JSON.stringify(body),
606
+ });
607
+ }
608
+ catch (e) {
609
+ throw new Aifp1QuoteError(`POST ${apiBase}/v1/quote failed: ${e.message}`);
610
+ }
611
+ const text = await r.text();
612
+ if (!r.ok) {
613
+ // The server's own detail is the useful part — a scope refused for a
614
+ // non-flat-rated tier, a batch under the minimum, an owner policy — and
615
+ // none of that is guessable from the status alone.
616
+ throw new Aifp1QuoteError(`POST /v1/quote → ${r.status}: ${text.slice(0, 400)}`);
617
+ }
618
+ let quote;
619
+ try {
620
+ quote = JSON.parse(text);
621
+ }
622
+ catch {
623
+ throw new Aifp1QuoteError(`POST /v1/quote → 200 but not JSON: ${text.slice(0, 200)}`);
624
+ }
625
+ if (!quote.quote_id) {
626
+ throw new Aifp1QuoteError(`POST /v1/quote → 200 without a quote_id: ${text.slice(0, 200)}`);
627
+ }
628
+ return quote;
629
+ }
630
+ // ── /v1/pay ───────────────────────────────────────────────────────────────
631
+ /**
632
+ * Exchange a settled transaction for a receipt.
633
+ *
634
+ * The retry loop exists for exactly one server answer: AIFP-425, "settlement
635
+ * not yet confirmed — retry with the same Idempotency-Key shortly". Our money
636
+ * is already on-chain at that point, so giving up would mean a paid batch with
637
+ * no receipt to spend it. Every retry reuses the same key, so a retry that
638
+ * crosses with the server's first success replays that success rather than
639
+ * minting a second receipt.
640
+ */
641
+ async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
642
+ const chain = "polygon";
643
+ const idempotencyKey = idempotencyKeyFor({ quoteId: quote.quote_id, chain, asset, txRef });
644
+ const deadline = Date.now() + (opts.settlementConfirmMs ?? DEFAULT_SETTLEMENT_CONFIRM_MS);
645
+ let lastDetail = "";
646
+ for (let attempt = 0;; attempt++) {
647
+ let r;
648
+ try {
649
+ r = await deps.fetchImpl(`${apiBase}/v1/pay`, {
650
+ method: "POST",
651
+ headers: {
652
+ "content-type": "application/json",
653
+ "Idempotency-Key": idempotencyKey,
654
+ "AIFP-Agent-Id": deps.agentId,
655
+ },
656
+ body: JSON.stringify({
657
+ quote_id: quote.quote_id, chain, asset, tx_ref: txRef, agent_id: deps.agentId,
658
+ }),
659
+ });
660
+ }
661
+ catch (e) {
662
+ throw new Aifp1PayError(`POST ${apiBase}/v1/pay failed after settling: ${e.message}`, txRef, quote.quote_id);
663
+ }
664
+ const text = await r.text();
665
+ if (r.ok) {
666
+ const paid = JSON.parse(text);
667
+ if (!paid.receipt) {
668
+ throw new Aifp1PayError(`/v1/pay → 200 without a receipt: ${text.slice(0, 300)}`, txRef, quote.quote_id);
669
+ }
670
+ return paid;
671
+ }
672
+ lastDetail = text.slice(0, 400);
673
+ // 425 is the only status worth retrying: the chain has not caught up with
674
+ // us yet. Everything else is a verdict.
675
+ if (r.status !== 425 || Date.now() >= deadline) {
676
+ throw new Aifp1PayError(`POST /v1/pay → ${r.status} after on-chain settlement ${txRef} for quote ${quote.quote_id}: ${lastDetail}`, txRef, quote.quote_id);
677
+ }
678
+ await sleep(Math.min(1000 * 2 ** attempt, 8000));
679
+ }
680
+ }
681
+ //# sourceMappingURL=aifp1.js.map