@aifinpay/agent 1.6.0 → 1.8.0
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.d.ts +301 -0
- package/dist/aifp1.d.ts.map +1 -0
- package/dist/aifp1.js +661 -0
- package/dist/aifp1.js.map +1 -0
- package/dist/crossChain.d.ts +0 -8
- package/dist/crossChain.d.ts.map +1 -1
- package/dist/crossChain.js +66 -0
- package/dist/crossChain.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/spendLedger.d.ts +54 -0
- package/dist/spendLedger.d.ts.map +1 -0
- package/dist/spendLedger.js +185 -0
- package/dist/spendLedger.js.map +1 -0
- package/dist/unifiedAgent.d.ts +123 -2
- package/dist/unifiedAgent.d.ts.map +1 -1
- package/dist/unifiedAgent.js +452 -143
- package/dist/unifiedAgent.js.map +1 -1
- package/package.json +2 -2
package/dist/aifp1.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
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
|
+
const DEFAULT_UNITS = 1000;
|
|
288
|
+
const DEFAULT_API_BASE = "https://api.aifinpay.io";
|
|
289
|
+
const DEFAULT_SETTLEMENT_CONFIRM_MS = 60_000;
|
|
290
|
+
/**
|
|
291
|
+
* Which scope to buy, and why the default is "prefix".
|
|
292
|
+
*
|
|
293
|
+
* The server's default is "exact" — one path per batch — because that is what
|
|
294
|
+
* every pre-scope client sent and back-compat had to hold. It is the right
|
|
295
|
+
* shape for an API an agent hammers, and the wrong one for everything else:
|
|
296
|
+
* each distinct URL then needs its own quote, its own on-chain settlement and
|
|
297
|
+
* its own $0.10 floor, so an agent reading a hundred articles pays $10 for
|
|
298
|
+
* $0.01 of content and burns a hundred transactions' gas doing it. A client
|
|
299
|
+
* that inherits the server's default inherits that bill, so this one does not.
|
|
300
|
+
*
|
|
301
|
+
* "prefix" over "merchant" is the deliberate part. Both cost exactly the same
|
|
302
|
+
* — scope decides *where* units may be spent, never how many a call costs
|
|
303
|
+
* (aifp/scope.js) — so the trade is not money, it is blast radius: the receipt
|
|
304
|
+
* is a bearer JWT, and a merchant-scoped one is the agent's entire prepaid
|
|
305
|
+
* balance with that merchant in a single token. "prefix" buys the case that
|
|
306
|
+
* was actually ruinous (crawling one section of a site) while keeping the
|
|
307
|
+
* token bound to the section we were refused on. It is also the server's own
|
|
308
|
+
* suggestion — prefixHint() is what the 402's `scopes.examples` offers.
|
|
309
|
+
*
|
|
310
|
+
* Note that prefixHint collapses a single-segment path to "/", i.e. the whole
|
|
311
|
+
* site, so the shallow-crawl case is covered by one batch anyway.
|
|
312
|
+
*
|
|
313
|
+
* Pass `scope: "merchant"` when the agent is crawling across sections and the
|
|
314
|
+
* round-trips matter more than the blast radius.
|
|
315
|
+
*/
|
|
316
|
+
function resolveScope(opts, challenge) {
|
|
317
|
+
const scope = opts.scope ?? "prefix";
|
|
318
|
+
if (scope === "merchant")
|
|
319
|
+
return { scope, resource: undefined }; // server stores "*"
|
|
320
|
+
if (opts.resource)
|
|
321
|
+
return { scope, resource: opts.resource };
|
|
322
|
+
return {
|
|
323
|
+
scope,
|
|
324
|
+
resource: scope === "prefix" ? prefixHint(challenge.resource) : challenge.resource,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/** Is this response the AIFP-1 paywall asking for money? */
|
|
328
|
+
function isAifp1Challenge(body) {
|
|
329
|
+
const b = body;
|
|
330
|
+
return !!b && b.protocol === "AIFP-1" && typeof b.merchant_id === "string"
|
|
331
|
+
&& typeof b.resource === "string";
|
|
332
|
+
}
|
|
333
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
334
|
+
/** Billing units the gateway says are left, or null when it did not say. */
|
|
335
|
+
function quotaRemaining(resp) {
|
|
336
|
+
const raw = resp.headers.get("AIFP-Quota-Remaining");
|
|
337
|
+
if (raw === null)
|
|
338
|
+
return null;
|
|
339
|
+
const n = Number(raw);
|
|
340
|
+
return Number.isFinite(n) ? Math.max(0, n) : null;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Fetch a gateway URL, paying the AIFP-1 paywall if it asks.
|
|
344
|
+
*
|
|
345
|
+
* Returns the merchant's response. Returns null only when a budget cap was hit
|
|
346
|
+
* and `on_limit_exceeded` is "skip" — the same contract AiFinPayAgent.call()
|
|
347
|
+
* already has.
|
|
348
|
+
*/
|
|
349
|
+
export async function aifp1Fetch(deps, url, init = {}, opts = {}) {
|
|
350
|
+
const { site, restPath } = parseGatewayUrl(url, opts.gatewayOrigins ?? DEFAULT_GATEWAY_ORIGINS);
|
|
351
|
+
// A paid flow sends the request twice, so the body has to survive being sent
|
|
352
|
+
// twice. A stream cannot, and finding that out after paying would mean money
|
|
353
|
+
// moved for a request that can no longer be made.
|
|
354
|
+
if (init.body !== undefined && init.body !== null
|
|
355
|
+
&& typeof init.body === "object"
|
|
356
|
+
&& typeof init.body.getReader === "function") {
|
|
357
|
+
throw new Aifp1Error("aifp1Fetch cannot use a stream body: a paywalled request is sent twice "
|
|
358
|
+
+ "(once to learn the price, once with the receipt) and a stream can only "
|
|
359
|
+
+ "be read once. Pass a string, Buffer, or URLSearchParams.");
|
|
360
|
+
}
|
|
361
|
+
const send = (receiptJwt) => {
|
|
362
|
+
// new Headers() rather than a spread: RequestInit.headers is legally a
|
|
363
|
+
// Headers instance or an array of pairs, and spreading either yields {} —
|
|
364
|
+
// silently dropping every header the caller set, including their auth.
|
|
365
|
+
const headers = new Headers(init.headers);
|
|
366
|
+
headers.set("AIFP-Agent-Id", deps.agentId);
|
|
367
|
+
if (receiptJwt)
|
|
368
|
+
headers.set("AIFP-Receipt", receiptJwt);
|
|
369
|
+
return deps.fetchImpl(url, {
|
|
370
|
+
...init,
|
|
371
|
+
headers,
|
|
372
|
+
// The receipt is a bearer token. fetch follows redirects by default and
|
|
373
|
+
// only strips authorization/cookie/host on a cross-origin hop — a custom
|
|
374
|
+
// header rides along, so one 302 from a merchant upstream would hand the
|
|
375
|
+
// batch to another host. Stop at the redirect and let the caller decide.
|
|
376
|
+
...(receiptJwt ? { redirect: "manual" } : {}),
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
// 1. Spend a batch we already own, if one covers this path.
|
|
380
|
+
const held = deps.cache.find(site, restPath);
|
|
381
|
+
const resp = await send(held?.jwt);
|
|
382
|
+
if (held) {
|
|
383
|
+
// Absent header, not "zero left": the gateway sets AIFP-Quota-Remaining
|
|
384
|
+
// only on a metered paid call, and `Number(null)` is 0 — reading it
|
|
385
|
+
// unguarded would retire a full batch the first time a route was free.
|
|
386
|
+
const remaining = quotaRemaining(resp);
|
|
387
|
+
if (remaining !== null)
|
|
388
|
+
deps.cache.noteQuotaRemaining(held, remaining);
|
|
389
|
+
}
|
|
390
|
+
if (resp.status !== 402) {
|
|
391
|
+
// Not a paywall refusal. That includes a 403, which for a receipt we sent
|
|
392
|
+
// means the gateway disagreed with our coverage or our receipt is not
|
|
393
|
+
// valid for this merchant. Drop it rather than keep re-sending a token the
|
|
394
|
+
// server has already refused, and say so — paying again to work around a
|
|
395
|
+
// coverage disagreement would settle a second batch for the same content.
|
|
396
|
+
if (held && resp.status === 403) {
|
|
397
|
+
// Not every 403 here is about the receipt. routes/gateway.js proxies the
|
|
398
|
+
// merchant's upstream status verbatim, so hotlink protection or an
|
|
399
|
+
// expired API key on their side arrives as a 403 too — and the gateway's
|
|
400
|
+
// own refusals include merchant policy "block", which is checked before
|
|
401
|
+
// the receipt is even read. Evicting a valid batch on any of those throws
|
|
402
|
+
// away a paid receipt and settles a second one on the next call.
|
|
403
|
+
//
|
|
404
|
+
// The gateway labels its own answers: {"error":"AIFP-403", ...}.
|
|
405
|
+
const detail = await resp.clone().text().catch(() => "");
|
|
406
|
+
let ours = false;
|
|
407
|
+
try {
|
|
408
|
+
ours = JSON.parse(detail)?.error === "AIFP-403";
|
|
409
|
+
}
|
|
410
|
+
catch { /* upstream body, not ours */ }
|
|
411
|
+
if (!ours)
|
|
412
|
+
return resp; // the merchant's 403, receipt intact
|
|
413
|
+
deps.cache.evict(held);
|
|
414
|
+
throw new Aifp1ReceiptRejectedError(`gateway refused receipt ${held.receiptId} for ${restPath} (403): ${detail.slice(0, 300)}`);
|
|
415
|
+
}
|
|
416
|
+
return resp;
|
|
417
|
+
}
|
|
418
|
+
// 402 — but only AIFP-1 is ours. x402 and anything else passes through
|
|
419
|
+
// untouched; call() owns those flows.
|
|
420
|
+
const challenge = await resp.clone().json().catch(() => null);
|
|
421
|
+
if (!isAifp1Challenge(challenge))
|
|
422
|
+
return resp;
|
|
423
|
+
// The batch we were spending is spent (or expired) — the gateway just said
|
|
424
|
+
// so. Forget it before quoting the next one, or find() would keep handing
|
|
425
|
+
// back a receipt that cannot pay for anything.
|
|
426
|
+
if (held)
|
|
427
|
+
deps.cache.evict(held);
|
|
428
|
+
// Buying a batch, serialised per site.
|
|
429
|
+
//
|
|
430
|
+
// Ten workers sharing one agent used to arrive here together, all miss the
|
|
431
|
+
// cache, and all settle their own batch: ten on-chain payments and ten
|
|
432
|
+
// minimum charges where one covered every one of them. Losers await the
|
|
433
|
+
// winner and then re-check — if the batch it bought covers their path they
|
|
434
|
+
// spend it, and if it does not they buy their own, which is a different
|
|
435
|
+
// batch rather than a duplicate.
|
|
436
|
+
const buyBatch = async () => {
|
|
437
|
+
// 2. Quote.
|
|
438
|
+
const apiBase = (opts.apiBaseUrl ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
439
|
+
const { scope, resource } = resolveScope(opts, challenge);
|
|
440
|
+
const quote = await requestQuote(deps, apiBase, {
|
|
441
|
+
merchant_id: challenge.merchant_id,
|
|
442
|
+
...(resource !== undefined ? { resource } : {}),
|
|
443
|
+
scope,
|
|
444
|
+
units: opts.units ?? DEFAULT_UNITS,
|
|
445
|
+
agent_id: deps.agentId,
|
|
446
|
+
});
|
|
447
|
+
// The merchant we are about to pay must be the merchant that refused us.
|
|
448
|
+
// Cheap, and the failure it catches is the one worth catching.
|
|
449
|
+
if (quote.merchant_id !== challenge.merchant_id) {
|
|
450
|
+
throw new Aifp1QuoteError(`quote ${quote.quote_id} is for merchant ${quote.merchant_id} but ${site} refused as ${challenge.merchant_id} — refusing to pay`);
|
|
451
|
+
}
|
|
452
|
+
// 3. Budget. The quote states the batch total in USD, so both caps are
|
|
453
|
+
// checked against the real figure, before anything is signed.
|
|
454
|
+
const amountUsd = Number(quote.amount);
|
|
455
|
+
if (!Number.isFinite(amountUsd) || amountUsd < 0) {
|
|
456
|
+
throw new Aifp1QuoteError(`quote ${quote.quote_id} has an unusable amount "${quote.amount}"`);
|
|
457
|
+
}
|
|
458
|
+
if (!deps.checkPerCall(amountUsd))
|
|
459
|
+
return null;
|
|
460
|
+
const reservation = await deps.reserveDaily(amountUsd);
|
|
461
|
+
if (reservation === "skip")
|
|
462
|
+
return null;
|
|
463
|
+
let settled = false;
|
|
464
|
+
try {
|
|
465
|
+
// 4. Settle on-chain, then exchange the tx for a receipt.
|
|
466
|
+
const native = quote.native_settlement;
|
|
467
|
+
if (!native) {
|
|
468
|
+
throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} carries no native_settlement — the backend had no live POL rate, `
|
|
469
|
+
+ `and the deployed B2BSplitter has no ERC-20 entrypoint, so this SDK cannot settle `
|
|
470
|
+
+ `${quote.accepted_assets.join("/")}. Retry when a POL rate is available or settle the quote yourself.`);
|
|
471
|
+
}
|
|
472
|
+
if (!quote.accepted_chains.includes("polygon") || !quote.pay_to.polygon) {
|
|
473
|
+
throw new Aifp1SettlementUnsupportedError(`quote ${quote.quote_id} does not accept polygon (accepts ${quote.accepted_chains.join(", ")}) — `
|
|
474
|
+
+ `AIFP-1 settlement verification is Polygon-only server-side (aifp/verify-settlement.js)`);
|
|
475
|
+
}
|
|
476
|
+
// The caps above were checked in USD; the wallet is about to be debited in
|
|
477
|
+
// wei. Nothing tied the two together, so a quote could pass a $0.10 cap and
|
|
478
|
+
// spend any amount of POL — the client had no reason to notice, because it
|
|
479
|
+
// never converted one into the other.
|
|
480
|
+
//
|
|
481
|
+
// The quote states the rate it used and fixes it for its lifetime
|
|
482
|
+
// (routes/aifp.js: rate_fixed_at, "a quote that repriced itself would let a
|
|
483
|
+
// payment that was correct when sent become underpaid"). So the three
|
|
484
|
+
// numbers must agree, and if they do not, the safe reading is not "trust
|
|
485
|
+
// the USD" — it is that this quote is not what it says it is.
|
|
486
|
+
const quotedRate = Number(native.rate_usd);
|
|
487
|
+
const weiUsd = Number.isFinite(quotedRate) && quotedRate > 0
|
|
488
|
+
? (Number(BigInt(native.total_wei)) / 1e18) * quotedRate
|
|
489
|
+
: NaN;
|
|
490
|
+
if (!Number.isFinite(weiUsd)) {
|
|
491
|
+
throw new Aifp1QuoteError(`quote ${quote.quote_id} states total_wei ${native.total_wei} at rate "${native.rate_usd}" — `
|
|
492
|
+
+ `unusable, and the caps were checked against $${amountUsd}`);
|
|
493
|
+
}
|
|
494
|
+
// 2% covers the fee-inclusive rounding the splitter does on each leg and a
|
|
495
|
+
// rate printed to six places; anything wider is a disagreement, not drift.
|
|
496
|
+
if (Math.abs(weiUsd - amountUsd) > Math.max(0.02 * amountUsd, 1e-6)) {
|
|
497
|
+
throw new Aifp1QuoteError(`quote ${quote.quote_id} would debit ${native.total_wei} wei ≈ $${weiUsd.toFixed(6)} `
|
|
498
|
+
+ `but states $${amountUsd} — refusing to sign a payment the budget caps did not see`);
|
|
499
|
+
}
|
|
500
|
+
const txRef = await deps.settle({
|
|
501
|
+
merchantWallet: quote.pay_to.polygon,
|
|
502
|
+
totalWei: BigInt(native.total_wei),
|
|
503
|
+
// The binding the server verifies on-chain: the Payment event's orderId
|
|
504
|
+
// must equal the quote id, or /v1/pay answers order_id_mismatch.
|
|
505
|
+
orderId: quote.quote_id,
|
|
506
|
+
});
|
|
507
|
+
// Money has moved. The reservation becomes settled spend here and not one
|
|
508
|
+
// line later — everything below can still fail, and none of those failures
|
|
509
|
+
// give the funds back.
|
|
510
|
+
settled = true;
|
|
511
|
+
if (typeof reservation === "string")
|
|
512
|
+
await deps.commit(reservation, amountUsd);
|
|
513
|
+
const paid = await submitPayment(deps, apiBase, quote, txRef, native.asset, opts);
|
|
514
|
+
// 5. Retry the original request with the receipt.
|
|
515
|
+
const receiptResp = await send(paid.receipt);
|
|
516
|
+
if (receiptResp.status === 402) {
|
|
517
|
+
const body = await receiptResp.clone().text().catch(() => "");
|
|
518
|
+
throw new Aifp1ReceiptRejectedError(`gateway still answered 402 for ${restPath} with a freshly settled receipt `
|
|
519
|
+
+ `${paid.receipt_id} (tx ${txRef}): ${body.slice(0, 300)}`);
|
|
520
|
+
}
|
|
521
|
+
// 6. Keep the batch. This is the whole point of the design: the next call
|
|
522
|
+
// this receipt covers costs a header, not a transaction.
|
|
523
|
+
const entry = {
|
|
524
|
+
site,
|
|
525
|
+
merchantId: paid.merchant_id,
|
|
526
|
+
receiptId: paid.receipt_id,
|
|
527
|
+
jwt: paid.receipt,
|
|
528
|
+
scope: paid.scope,
|
|
529
|
+
resource: paid.resource,
|
|
530
|
+
unitQuota: paid.unit_quota,
|
|
531
|
+
remaining: paid.unit_quota,
|
|
532
|
+
expiresAt: Date.parse(paid.expires_at),
|
|
533
|
+
amountUsd,
|
|
534
|
+
};
|
|
535
|
+
// The gateway's own count if it gave one; otherwise the batch minus the
|
|
536
|
+
// weight of the route that refused us, which is the only figure we can
|
|
537
|
+
// justify locally.
|
|
538
|
+
const remaining = quotaRemaining(receiptResp);
|
|
539
|
+
entry.remaining = remaining !== null
|
|
540
|
+
? remaining
|
|
541
|
+
: Math.max(0, paid.unit_quota - challenge.unit_weight);
|
|
542
|
+
// Cache unconditionally. This used to be guarded on a finite expiresAt,
|
|
543
|
+
// which meant an unparseable expires_at silently binned a batch that had
|
|
544
|
+
// just been settled on-chain — money spent, JWT existing nowhere else,
|
|
545
|
+
// and the next call settling another one. A bad timestamp is a reason to
|
|
546
|
+
// distrust the expiry, not the receipt: fall back to the JWT's own exp,
|
|
547
|
+
// and to "expired" only if that is missing too, so find() re-quotes
|
|
548
|
+
// instead of the entry lingering forever.
|
|
549
|
+
if (!Number.isFinite(entry.expiresAt)) {
|
|
550
|
+
// Prefer the token's own exp; if that is unreadable too, keep the batch
|
|
551
|
+
// rather than dating it into the past. The asymmetry decides it: assume
|
|
552
|
+
// expired and we settle a second batch for certain, assume valid and the
|
|
553
|
+
// worst case is one wasted request, because the gateway is the authority
|
|
554
|
+
// and answers 402 the moment the receipt really is spent or stale.
|
|
555
|
+
entry.expiresAt = jwtExpiryMs(paid.receipt) ?? Number.POSITIVE_INFINITY;
|
|
556
|
+
}
|
|
557
|
+
deps.cache.put(entry);
|
|
558
|
+
deps.onPaid?.({
|
|
559
|
+
merchantId: paid.merchant_id, amountUsd, txRef, receiptId: paid.receipt_id,
|
|
560
|
+
});
|
|
561
|
+
return receiptResp;
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
// Anything that did not move money gives the cap back now rather than
|
|
565
|
+
// waiting for the reservation to expire — same rule as call().
|
|
566
|
+
if (typeof reservation === "string" && !settled)
|
|
567
|
+
await deps.release(reservation);
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
const outcome = await deps.cache.coalesce(site, buyBatch);
|
|
571
|
+
if (outcome !== "retry")
|
|
572
|
+
return outcome;
|
|
573
|
+
const fresh = deps.cache.find(site, restPath);
|
|
574
|
+
if (fresh)
|
|
575
|
+
return send(fresh.jwt);
|
|
576
|
+
return buyBatch();
|
|
577
|
+
}
|
|
578
|
+
// ── /v1/quote ─────────────────────────────────────────────────────────────
|
|
579
|
+
async function requestQuote(deps, apiBase, body) {
|
|
580
|
+
let r;
|
|
581
|
+
try {
|
|
582
|
+
r = await deps.fetchImpl(`${apiBase}/v1/quote`, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: { "content-type": "application/json", "AIFP-Agent-Id": deps.agentId },
|
|
585
|
+
body: JSON.stringify(body),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
throw new Aifp1QuoteError(`POST ${apiBase}/v1/quote failed: ${e.message}`);
|
|
590
|
+
}
|
|
591
|
+
const text = await r.text();
|
|
592
|
+
if (!r.ok) {
|
|
593
|
+
// The server's own detail is the useful part — a scope refused for a
|
|
594
|
+
// non-flat-rated tier, a batch under the minimum, an owner policy — and
|
|
595
|
+
// none of that is guessable from the status alone.
|
|
596
|
+
throw new Aifp1QuoteError(`POST /v1/quote → ${r.status}: ${text.slice(0, 400)}`);
|
|
597
|
+
}
|
|
598
|
+
let quote;
|
|
599
|
+
try {
|
|
600
|
+
quote = JSON.parse(text);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
throw new Aifp1QuoteError(`POST /v1/quote → 200 but not JSON: ${text.slice(0, 200)}`);
|
|
604
|
+
}
|
|
605
|
+
if (!quote.quote_id) {
|
|
606
|
+
throw new Aifp1QuoteError(`POST /v1/quote → 200 without a quote_id: ${text.slice(0, 200)}`);
|
|
607
|
+
}
|
|
608
|
+
return quote;
|
|
609
|
+
}
|
|
610
|
+
// ── /v1/pay ───────────────────────────────────────────────────────────────
|
|
611
|
+
/**
|
|
612
|
+
* Exchange a settled transaction for a receipt.
|
|
613
|
+
*
|
|
614
|
+
* The retry loop exists for exactly one server answer: AIFP-425, "settlement
|
|
615
|
+
* not yet confirmed — retry with the same Idempotency-Key shortly". Our money
|
|
616
|
+
* is already on-chain at that point, so giving up would mean a paid batch with
|
|
617
|
+
* no receipt to spend it. Every retry reuses the same key, so a retry that
|
|
618
|
+
* crosses with the server's first success replays that success rather than
|
|
619
|
+
* minting a second receipt.
|
|
620
|
+
*/
|
|
621
|
+
async function submitPayment(deps, apiBase, quote, txRef, asset, opts) {
|
|
622
|
+
const chain = "polygon";
|
|
623
|
+
const idempotencyKey = idempotencyKeyFor({ quoteId: quote.quote_id, chain, asset, txRef });
|
|
624
|
+
const deadline = Date.now() + (opts.settlementConfirmMs ?? DEFAULT_SETTLEMENT_CONFIRM_MS);
|
|
625
|
+
let lastDetail = "";
|
|
626
|
+
for (let attempt = 0;; attempt++) {
|
|
627
|
+
let r;
|
|
628
|
+
try {
|
|
629
|
+
r = await deps.fetchImpl(`${apiBase}/v1/pay`, {
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: {
|
|
632
|
+
"content-type": "application/json",
|
|
633
|
+
"Idempotency-Key": idempotencyKey,
|
|
634
|
+
"AIFP-Agent-Id": deps.agentId,
|
|
635
|
+
},
|
|
636
|
+
body: JSON.stringify({
|
|
637
|
+
quote_id: quote.quote_id, chain, asset, tx_ref: txRef, agent_id: deps.agentId,
|
|
638
|
+
}),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
throw new Aifp1PayError(`POST ${apiBase}/v1/pay failed after settling: ${e.message}`, txRef, quote.quote_id);
|
|
643
|
+
}
|
|
644
|
+
const text = await r.text();
|
|
645
|
+
if (r.ok) {
|
|
646
|
+
const paid = JSON.parse(text);
|
|
647
|
+
if (!paid.receipt) {
|
|
648
|
+
throw new Aifp1PayError(`/v1/pay → 200 without a receipt: ${text.slice(0, 300)}`, txRef, quote.quote_id);
|
|
649
|
+
}
|
|
650
|
+
return paid;
|
|
651
|
+
}
|
|
652
|
+
lastDetail = text.slice(0, 400);
|
|
653
|
+
// 425 is the only status worth retrying: the chain has not caught up with
|
|
654
|
+
// us yet. Everything else is a verdict.
|
|
655
|
+
if (r.status !== 425 || Date.now() >= deadline) {
|
|
656
|
+
throw new Aifp1PayError(`POST /v1/pay → ${r.status} after on-chain settlement ${txRef} for quote ${quote.quote_id}: ${lastDetail}`, txRef, quote.quote_id);
|
|
657
|
+
}
|
|
658
|
+
await sleep(Math.min(1000 * 2 ** attempt, 8000));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
//# sourceMappingURL=aifp1.js.map
|