uvd-x402-sdk 2.87.0 → 2.89.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.
Files changed (73) hide show
  1. package/README.md +235 -0
  2. package/dist/adapters/index.d.mts +1 -1
  3. package/dist/adapters/index.d.ts +1 -1
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/adapters/index.mjs.map +1 -1
  6. package/dist/backend/index.d.mts +2 -2
  7. package/dist/backend/index.d.ts +2 -2
  8. package/dist/backend/index.js +138 -0
  9. package/dist/backend/index.js.map +1 -1
  10. package/dist/backend/index.mjs +137 -1
  11. package/dist/backend/index.mjs.map +1 -1
  12. package/dist/erc8128/index.js.map +1 -1
  13. package/dist/erc8128/index.mjs.map +1 -1
  14. package/dist/{index-D8buGZWY.d.mts → index-13ZUDF7c.d.mts} +32 -2
  15. package/dist/{index-CWZUj5mz.d.ts → index-B5B9EK3x.d.ts} +32 -2
  16. package/dist/{index-DOBhTF-j.d.mts → index-BWN0Haa0.d.mts} +476 -2
  17. package/dist/{index-DOBhTF-j.d.ts → index-BWN0Haa0.d.ts} +476 -2
  18. package/dist/{index-BHfYxpzI.d.mts → index-CAJg-_cr.d.mts} +181 -2
  19. package/dist/{index-D67eNxY8.d.ts → index-Cd1Mpznb.d.ts} +181 -2
  20. package/dist/index.d.mts +4 -4
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.js +472 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.mjs +464 -9
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/providers/algorand/index.d.mts +1 -1
  27. package/dist/providers/algorand/index.d.ts +1 -1
  28. package/dist/providers/algorand/index.js.map +1 -1
  29. package/dist/providers/algorand/index.mjs.map +1 -1
  30. package/dist/providers/evm/index.d.mts +1 -1
  31. package/dist/providers/evm/index.d.ts +1 -1
  32. package/dist/providers/evm/index.js.map +1 -1
  33. package/dist/providers/evm/index.mjs.map +1 -1
  34. package/dist/providers/near/index.d.mts +1 -1
  35. package/dist/providers/near/index.d.ts +1 -1
  36. package/dist/providers/near/index.js.map +1 -1
  37. package/dist/providers/near/index.mjs.map +1 -1
  38. package/dist/providers/solana/index.d.mts +1 -1
  39. package/dist/providers/solana/index.d.ts +1 -1
  40. package/dist/providers/solana/index.js.map +1 -1
  41. package/dist/providers/solana/index.mjs.map +1 -1
  42. package/dist/providers/stellar/index.d.mts +1 -1
  43. package/dist/providers/stellar/index.d.ts +1 -1
  44. package/dist/providers/stellar/index.js.map +1 -1
  45. package/dist/providers/stellar/index.mjs.map +1 -1
  46. package/dist/providers/sui/index.d.mts +1 -1
  47. package/dist/providers/sui/index.d.ts +1 -1
  48. package/dist/providers/sui/index.js.map +1 -1
  49. package/dist/providers/sui/index.mjs.map +1 -1
  50. package/dist/providers/xrpl/index.d.mts +1 -1
  51. package/dist/providers/xrpl/index.d.ts +1 -1
  52. package/dist/providers/xrpl/index.js.map +1 -1
  53. package/dist/providers/xrpl/index.mjs.map +1 -1
  54. package/dist/react/index.d.mts +3 -3
  55. package/dist/react/index.d.ts +3 -3
  56. package/dist/react/index.js +463 -8
  57. package/dist/react/index.js.map +1 -1
  58. package/dist/react/index.mjs +463 -8
  59. package/dist/react/index.mjs.map +1 -1
  60. package/dist/react/picker/index.d.mts +1 -1
  61. package/dist/react/picker/index.d.ts +1 -1
  62. package/dist/utils/index.d.mts +2 -2
  63. package/dist/utils/index.d.ts +2 -2
  64. package/dist/utils/index.js.map +1 -1
  65. package/dist/utils/index.mjs.map +1 -1
  66. package/dist/{validation-mSLW4tcO.d.mts → validation-B95g0MqC.d.mts} +1 -1
  67. package/dist/{validation-C7p3a6Ep.d.ts → validation-_Tcjm2gu.d.ts} +1 -1
  68. package/package.json +1 -1
  69. package/src/backend/index.ts +260 -0
  70. package/src/client/X402Client.ts +232 -9
  71. package/src/index.ts +28 -0
  72. package/src/policy.ts +804 -0
  73. package/src/types/index.ts +41 -0
package/src/policy.ts ADDED
@@ -0,0 +1,804 @@
1
+ /**
2
+ * uvd-x402-sdk - What this buyer is allowed to sign, decided before it signs.
3
+ *
4
+ * # The rule this module exists to enforce
5
+ *
6
+ * A catalog listing is a claim somebody else made about their own price. The
7
+ * `402` that comes back from the actual request is the offer. They can differ,
8
+ * legitimately: a seller may have repriced, and the listing may be a copy of a
9
+ * copy. So the buying decision cannot be made against the listing. It has to be
10
+ * made against **the offer in hand**, every time, before anything is signed.
11
+ *
12
+ * Two things follow, and both are load-bearing:
13
+ *
14
+ * - **A divergence from the listing is not, by itself, a refusal.** If the offer
15
+ * costs more than the catalog said but still sits inside a policy the operator
16
+ * already authorised, the payment proceeds. Stopping to ask would turn every
17
+ * ordinary reprice into a halt, and an agent that halts on ordinary commerce
18
+ * is an agent nobody can leave running. There is no human-confirmation hook in
19
+ * this path.
20
+ * - **A policy is never widened to fit an offer.** Not by a byte, not once, not
21
+ * "because the seller says so". There is deliberately no method on this type
22
+ * that raises a limit from inside an evaluation. If the offer exceeds what was
23
+ * authorised the answer is a refusal with a concrete cause, and the caller
24
+ * decides whether to authorise more.
25
+ *
26
+ * # Order of evaluation
27
+ *
28
+ * Fixed, and part of the contract, because the FIRST failing check is the one
29
+ * reported and a caller branches on it:
30
+ *
31
+ * 1. Was any offer readable at all? -> `no-readable-offer`
32
+ * 2. Has it expired? -> `offer-expired`
33
+ * 3. Is the recipient one we are willing to pay? -> `recipient-not-permitted`
34
+ * 4. Was its asset budgeted at all? -> `asset-not-budgeted`
35
+ * 5. Does it exceed the per-payment limit for that asset? -> `per-payment-limit`
36
+ * 6. Does it exceed what remains of the cumulative limit? -> `cumulative-limit`
37
+ *
38
+ * The comparison against the listing is made LAST and refuses nothing: it is
39
+ * evidence for the caller, never a gate.
40
+ *
41
+ * This is the same contract the Rust facilitator's `x402-reqwest` crate
42
+ * implements (`PurchasePolicy`, release 2.25.0), written down once so a buyer in
43
+ * either language refuses the same payments for the same stated reasons.
44
+ *
45
+ * @module
46
+ */
47
+
48
+ import { X402Error } from './types';
49
+ import type { X402PaymentOffer } from './types';
50
+
51
+ // ============================================================================
52
+ // The offer-validity extension
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Extension key under which a seller declares how long its offer stands.
57
+ *
58
+ * Defined once, here, and read by nobody else's constant: a seller and a buyer
59
+ * naming different keys have nothing to say to each other. The version is IN
60
+ * the key because the offer-and-receipt transport can still change, and a value
61
+ * read from an unversioned key could never be compared with anything later. A
62
+ * key we do not recognise is ignored, which means "no declared expiry" -- a
63
+ * different thing from "expired".
64
+ */
65
+ export const OFFER_VALIDITY_EXTENSION = 'offer-receipt/1';
66
+
67
+ /**
68
+ * Read `validUntil` (Unix seconds) out of a 402 challenge's `extensions`.
69
+ *
70
+ * Shape: `extensions["offer-receipt/1"].info.validUntil`. The `{info, schema}`
71
+ * envelope is the one every merged x402 extension uses; reading the number from
72
+ * anywhere else would be reading a field nobody agreed to publish.
73
+ *
74
+ * Anything unreadable is `undefined`, **never zero**: "the seller said
75
+ * something we could not read" must not become "this offer expired in 1970".
76
+ * A string, a float, a negative, a number past `Number.MAX_SAFE_INTEGER` -- all
77
+ * unreadable, all absent.
78
+ */
79
+ export function offerValidUntil(
80
+ extensions: Record<string, unknown> | undefined | null
81
+ ): number | undefined {
82
+ if (!extensions || typeof extensions !== 'object') return undefined;
83
+ const entry = (extensions as Record<string, unknown>)[OFFER_VALIDITY_EXTENSION];
84
+ if (!entry || typeof entry !== 'object') return undefined;
85
+ const info = (entry as Record<string, unknown>).info;
86
+ if (!info || typeof info !== 'object') return undefined;
87
+ const value = (info as Record<string, unknown>).validUntil;
88
+ if (typeof value !== 'number') return undefined;
89
+ if (!Number.isSafeInteger(value) || value < 0) return undefined;
90
+ return value;
91
+ }
92
+
93
+ // ============================================================================
94
+ // Addresses
95
+ // ============================================================================
96
+
97
+ /**
98
+ * Canonical form of a recipient or token address, for comparison.
99
+ *
100
+ * # Why this is not `toLowerCase()`
101
+ *
102
+ * It was, in the Rust crate, and that is only correct for one family. EVM
103
+ * addresses are hex and arrive both checksummed and lowercase, so folding case
104
+ * is right and necessary. **Base58 is case-sensitive** -- Solana and XRPL
105
+ * addresses use both cases as distinct symbols -- so lowercasing one does not
106
+ * produce the same address spelled differently, it produces a string that is not
107
+ * an address.
108
+ *
109
+ * An allowlist written in the seller's own spelling would then never match, and
110
+ * every payment to a legitimate Solana payee would be refused with
111
+ * `recipient-not-permitted`. Worse in the other direction: two distinct base58
112
+ * addresses can fold to the same lowercase string, so an allowlist could admit
113
+ * an address nobody put on it.
114
+ *
115
+ * So: hex is folded, everything else is compared exactly.
116
+ */
117
+ export function canonicalRecipient(address: string): string {
118
+ const trimmed = address.trim();
119
+ const rest =
120
+ trimmed.startsWith('0x') || trimmed.startsWith('0X') ? trimmed.slice(2) : null;
121
+ const isHex = rest !== null && rest.length > 0 && /^[0-9a-fA-F]+$/.test(rest);
122
+ return isHex ? trimmed.toLowerCase() : trimmed;
123
+ }
124
+
125
+ // ============================================================================
126
+ // Assets
127
+ // ============================================================================
128
+
129
+ /**
130
+ * A token on a network -- the unit a budget is denominated in.
131
+ *
132
+ * Both halves are required. The same USDC contract address on two networks is
133
+ * two different assets, and a budget for one is not a budget for the other: the
134
+ * EVM signer takes its EIP-712 domain from the seller's own `extra`, so it will
135
+ * sign for whatever token and network the seller names.
136
+ */
137
+ export interface PolicyAsset {
138
+ /**
139
+ * SDK chain name (`'base'`) when the 402's network resolved to one, otherwise
140
+ * the network string exactly as the 402 wrote it.
141
+ *
142
+ * Resolving first is what lets one written policy cover both 402 dialects:
143
+ * a v1 challenge says `base` and a v2 one says `eip155:8453`, and a budget
144
+ * keyed on the raw string would silently miss half of a seller's offers.
145
+ */
146
+ network: string;
147
+ /** Token contract address. Compared canonicalised, never raw. */
148
+ address: string;
149
+ }
150
+
151
+ /**
152
+ * Stable map key for an asset. Not part of the wire format; comparison only.
153
+ *
154
+ * The network is folded to lowercase because SDK chain names and CAIP-2 strings
155
+ * are lowercase by convention, so `'Base'` and `'base'` are the same network
156
+ * written two ways -- never two networks. Leaving the case in fails closed
157
+ * (`asset-not-budgeted`, so no wrong payment), but it trips the operator over a
158
+ * capital letter and the refusal would point at the budget instead of the typo.
159
+ * The address keeps its by-family treatment: see {@link canonicalRecipient}.
160
+ */
161
+ export function assetKey(asset: PolicyAsset): string {
162
+ return `${asset.network.trim().toLowerCase()}|${canonicalRecipient(asset.address)}`;
163
+ }
164
+
165
+ /** Human form, for a refusal message. */
166
+ function assetLabel(asset: PolicyAsset): string {
167
+ return asset.address ? `${asset.address} on ${asset.network}` : `(no asset declared) on ${asset.network}`;
168
+ }
169
+
170
+ /**
171
+ * The asset an offer is priced in.
172
+ *
173
+ * An offer that declares no `asset` yields an empty address, which no budget can
174
+ * ever hold -- {@link PurchasePolicy.perPayment} rejects an empty address -- so
175
+ * such an offer is refused with `asset-not-budgeted` unless unlisted assets were
176
+ * explicitly permitted. That is the safe direction: an unnamed token is a token
177
+ * nobody budgeted for.
178
+ */
179
+ export function offerAsset(offer: X402PaymentOffer): PolicyAsset {
180
+ return {
181
+ network: offer.chainName ?? offer.network,
182
+ address: typeof offer.asset === 'string' ? offer.asset : '',
183
+ };
184
+ }
185
+
186
+ // ============================================================================
187
+ // Refusals
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Closed vocabulary of reasons a payment was not signed, in kebab-case, so an
192
+ * SDK in another language branches on it without parsing English.
193
+ *
194
+ * There is no `other`: a refusal a caller cannot interpret is a refusal it will
195
+ * paper over.
196
+ */
197
+ export type PolicyRefusalCode =
198
+ | 'no-readable-offer'
199
+ | 'offer-expired'
200
+ | 'recipient-not-permitted'
201
+ | 'asset-not-budgeted'
202
+ | 'per-payment-limit'
203
+ | 'cumulative-limit';
204
+
205
+ /**
206
+ * Why a payment was not signed. Every variant carries the numbers that caused
207
+ * it, so a caller can log the decision without re-deriving it.
208
+ */
209
+ export type PolicyRefusal =
210
+ | {
211
+ code: 'no-readable-offer';
212
+ /** Scheme names the seller offered, for the ones this build cannot read. */
213
+ offered: string[];
214
+ message: string;
215
+ }
216
+ | { code: 'offer-expired'; validUntil: number; now: number; message: string }
217
+ | { code: 'recipient-not-permitted'; payTo: string; message: string }
218
+ | { code: 'asset-not-budgeted'; asset: PolicyAsset; message: string }
219
+ | {
220
+ code: 'per-payment-limit';
221
+ requested: bigint;
222
+ allowed: bigint;
223
+ asset: PolicyAsset;
224
+ message: string;
225
+ }
226
+ | {
227
+ code: 'cumulative-limit';
228
+ requested: bigint;
229
+ spent: bigint;
230
+ wouldTotal: bigint;
231
+ allowed: bigint;
232
+ asset: PolicyAsset;
233
+ message: string;
234
+ };
235
+
236
+ /**
237
+ * A 402 whose offers this build could not read, turned into a refusal that says
238
+ * so.
239
+ *
240
+ * The point is the message. A caller that learns the seller offered
241
+ * `batch-settlement` knows to look for a facilitator that implements it, where
242
+ * "could not parse the response" would have sent it looking for a bug in its own
243
+ * code. Discovering the service keeps working even though buying it
244
+ * automatically does not.
245
+ */
246
+ export function noReadableOffer(offered: string[]): PolicyRefusal {
247
+ return {
248
+ code: 'no-readable-offer',
249
+ offered,
250
+ message:
251
+ 'no offer in this challenge is one this build can pay; offered: ' +
252
+ JSON.stringify(offered),
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Error thrown on the real payment path when the policy refuses.
258
+ *
259
+ * Extends {@link X402Error}, so a consumer already catching `X402Error` keeps
260
+ * catching this; the typed cause is on {@link PolicyRefusedError.refusal} and
261
+ * its stable code on `refusal.code`.
262
+ */
263
+ export class PolicyRefusedError extends X402Error {
264
+ public readonly refusal: PolicyRefusal;
265
+
266
+ constructor(refusal: PolicyRefusal) {
267
+ super(`payment refused by policy: ${refusal.message}`, 'POLICY_REFUSED', refusal);
268
+ this.name = 'PolicyRefusedError';
269
+ this.refusal = refusal;
270
+ }
271
+
272
+ /** Stable kebab code of the refusal, for branching. */
273
+ get policyCode(): PolicyRefusalCode {
274
+ return this.refusal.code;
275
+ }
276
+ }
277
+
278
+ // ============================================================================
279
+ // The listing, and how the offer compares to it
280
+ // ============================================================================
281
+
282
+ /**
283
+ * What a catalog listing advertised, for comparison against the real offer.
284
+ *
285
+ * Optional throughout. A buyer that never read a listing simply evaluates the
286
+ * offer against its policy, which is the same decision with one fewer input.
287
+ */
288
+ export interface AdvertisedQuote {
289
+ asset: PolicyAsset;
290
+ /** Price in the token's own atomic units. */
291
+ amount: bigint;
292
+ }
293
+
294
+ /** How the offer in hand compares to what the catalog advertised. */
295
+ export type QuoteComparison =
296
+ | { code: 'not-compared' }
297
+ | { code: 'matches' }
298
+ | { code: 'amount-differs'; advertised: bigint; offered: bigint }
299
+ /**
300
+ * A different asset entirely. The same number in another currency is not the
301
+ * same price, so there is nothing to compare -- and no number is compared.
302
+ */
303
+ | { code: 'different-asset'; advertised: PolicyAsset; offered: PolicyAsset };
304
+
305
+ /** A payment this policy permits, and what it noticed on the way. */
306
+ export interface PolicyApproval {
307
+ asset: PolicyAsset;
308
+ /** Price in the token's own atomic units. */
309
+ amount: bigint;
310
+ /**
311
+ * How the offer compared to the listing, when there was one. Carried so a
312
+ * caller can log or surface it; it never changed the decision.
313
+ */
314
+ versusQuote: QuoteComparison;
315
+ }
316
+
317
+ /** The outcome of an evaluation. Explicit, so no caller needs a `try`. */
318
+ export type PolicyDecision =
319
+ | { ok: true; approval: PolicyApproval }
320
+ | { ok: false; refusal: PolicyRefusal };
321
+
322
+ /** Inputs to {@link PurchasePolicy.evaluate} besides the offer itself. */
323
+ export interface EvaluateOptions {
324
+ /**
325
+ * Unix seconds. Passed rather than read so the decision is testable at an
326
+ * exact instant: money decisions that depend on a hidden clock cannot be
327
+ * pinned.
328
+ */
329
+ now: number;
330
+ /** `validUntil` the seller declared, if any. See {@link offerValidUntil}. */
331
+ validUntil?: number;
332
+ /** What the catalog advertised, if the caller read a listing. */
333
+ quote?: AdvertisedQuote;
334
+ }
335
+
336
+ // ============================================================================
337
+ // The policy
338
+ // ============================================================================
339
+
340
+ /**
341
+ * The spending rules a caller authorised in advance.
342
+ *
343
+ * Every builder returns a new policy that **shares the running total**: two
344
+ * copies of one policy spend from the same purse, which is what makes a
345
+ * cumulative limit mean anything when a client is copied per request.
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * const USDC_BASE = { network: 'base', address: '0x8335...2913' };
350
+ * const policy = PurchasePolicy.create()
351
+ * .perPayment(USDC_BASE, 50_000n) // 0.05 USDC, atomic units
352
+ * .cumulative(USDC_BASE, 1_000_000n) // 1 USDC total, for this policy's life
353
+ * .onlyPay(['0xe4dc...e545']);
354
+ *
355
+ * const client = new X402Client({ defaultChain: 'base', policy });
356
+ * ```
357
+ */
358
+ export class PurchasePolicy {
359
+ private readonly perPaymentLimits: Map<string, bigint>;
360
+ private readonly cumulativeLimits: Map<string, bigint>;
361
+ /** Shared by reference across every copy: one purse, many holders. */
362
+ private readonly spentTotals: Map<string, bigint>;
363
+ private readonly recipients: Set<string> | null;
364
+ private readonly assetsByKey: Map<string, PolicyAsset>;
365
+ private readonly unlistedAssetsAllowed: boolean;
366
+
367
+ private constructor(
368
+ perPaymentLimits: Map<string, bigint>,
369
+ cumulativeLimits: Map<string, bigint>,
370
+ spentTotals: Map<string, bigint>,
371
+ recipients: Set<string> | null,
372
+ assetsByKey: Map<string, PolicyAsset>,
373
+ unlistedAssetsAllowed: boolean
374
+ ) {
375
+ this.perPaymentLimits = perPaymentLimits;
376
+ this.cumulativeLimits = cumulativeLimits;
377
+ this.spentTotals = spentTotals;
378
+ this.recipients = recipients;
379
+ this.assetsByKey = assetsByKey;
380
+ this.unlistedAssetsAllowed = unlistedAssetsAllowed;
381
+ }
382
+
383
+ /**
384
+ * A policy that pays nothing until it is told what it may pay.
385
+ *
386
+ * # Why the default is deny
387
+ *
388
+ * The limits are a map keyed by asset, and a map answers "no entry" for every
389
+ * asset nobody thought of. Permitting on a missing entry means a budget in
390
+ * USDC is **no budget at all** for any other token: a seller offering the same
391
+ * resource priced in something unlisted walks straight past the ceiling, and
392
+ * the wallet will sign it, because the EVM signer takes the EIP-712 domain
393
+ * from the seller's own `extra` and will happily sign for a token and a
394
+ * network it has never heard of.
395
+ *
396
+ * So an asset with no stated ceiling is refused. A caller that genuinely wants
397
+ * to pay anything says so once, by name, with
398
+ * {@link PurchasePolicy.allowUnlistedAssets}, and that sentence is then in
399
+ * their code where a reader can find it.
400
+ */
401
+ static create(): PurchasePolicy {
402
+ return new PurchasePolicy(new Map(), new Map(), new Map(), null, new Map(), false);
403
+ }
404
+
405
+ /**
406
+ * A policy that permits an asset it was never told about.
407
+ *
408
+ * This is what {@link X402Client} holds when the caller supplied no policy,
409
+ * and it exists for exactly one reason: this SDK had no budget before 2.89.0,
410
+ * and turning one on silently would refuse payments that callers are making
411
+ * today. Named rather than defaulted, so choosing it is visible. The asymmetry
412
+ * is deliberate -- whoever sits down to WRITE a policy deserves the safe
413
+ * default.
414
+ */
415
+ static permissive(): PurchasePolicy {
416
+ return new PurchasePolicy(new Map(), new Map(), new Map(), null, new Map(), true);
417
+ }
418
+
419
+ /** Permit assets with no configured ceiling. */
420
+ allowUnlistedAssets(): PurchasePolicy {
421
+ return this.with({ unlistedAssetsAllowed: true });
422
+ }
423
+
424
+ /**
425
+ * Refuse assets with no configured ceiling. The default; here so the intent
426
+ * can be written down at a call site that wants it explicit.
427
+ */
428
+ denyUnlistedAssets(): PurchasePolicy {
429
+ return this.with({ unlistedAssetsAllowed: false });
430
+ }
431
+
432
+ /** Most this policy will pay in ONE payment of `asset`, in atomic units. */
433
+ perPayment(asset: PolicyAsset, amount: bigint): PurchasePolicy {
434
+ const key = this.register(asset);
435
+ const perPaymentLimits = new Map(this.perPaymentLimits);
436
+ perPaymentLimits.set(key, requireCeiling(amount, 'perPayment'));
437
+ return this.with({ perPaymentLimits });
438
+ }
439
+
440
+ /**
441
+ * Most this policy will pay in `asset` in TOTAL, across every payment it
442
+ * approves for as long as it lives.
443
+ */
444
+ cumulative(asset: PolicyAsset, amount: bigint): PurchasePolicy {
445
+ const key = this.register(asset);
446
+ const cumulativeLimits = new Map(this.cumulativeLimits);
447
+ cumulativeLimits.set(key, requireCeiling(amount, 'cumulative'));
448
+ return this.with({ cumulativeLimits });
449
+ }
450
+
451
+ /**
452
+ * Restrict payment to a set of recipients.
453
+ *
454
+ * Addresses are canonicalised **by family**, not by lowercasing the string:
455
+ * see {@link canonicalRecipient}.
456
+ */
457
+ onlyPay(recipients: Iterable<string>): PurchasePolicy {
458
+ const set = new Set<string>();
459
+ for (const r of recipients) set.add(canonicalRecipient(r));
460
+ return this.with({ recipients: set });
461
+ }
462
+
463
+ /**
464
+ * Total recorded so far for `asset`, in atomic units.
465
+ *
466
+ * A total that is not a sane bigint means something corrupted the purse.
467
+ * Reporting zero would silently restore the caller's whole budget, so this
468
+ * reports the CEILING instead: for money, the safe direction is to refuse,
469
+ * never to permit.
470
+ */
471
+ spent(asset: PolicyAsset): bigint {
472
+ const key = assetKey(asset);
473
+ const value = this.spentTotals.get(key);
474
+ if (value === undefined) return 0n;
475
+ if (typeof value !== 'bigint' || value < 0n) {
476
+ return this.cumulativeLimits.get(key) ?? 0n;
477
+ }
478
+ return value;
479
+ }
480
+
481
+ /**
482
+ * Record that a payment actually happened.
483
+ *
484
+ * Separate from {@link PurchasePolicy.evaluate} on purpose. Signing can still
485
+ * fail and a settlement can still be refused; a cumulative limit that counted
486
+ * attempts rather than payments would lock a caller out of money it never
487
+ * spent. Call this when a payment settled.
488
+ *
489
+ * A negative amount is ignored: handing budget back is not something a
490
+ * settlement can do, and treating it as one would be a way to widen a policy
491
+ * from outside.
492
+ */
493
+ recordSpend(asset: PolicyAsset, amount: bigint): void {
494
+ if (typeof amount !== 'bigint' || amount <= 0n) return;
495
+ const key = assetKey(asset);
496
+ if (!this.assetsByKey.has(key)) this.assetsByKey.set(key, asset);
497
+ const current = this.spent(asset);
498
+ this.spentTotals.set(key, current + amount);
499
+ }
500
+
501
+ /**
502
+ * Whether this policy pays an asset it holds no ceiling for. Read-only: there
503
+ * is no setter that flips it from inside an evaluation.
504
+ */
505
+ get permitsUnlistedAssets(): boolean {
506
+ return this.unlistedAssetsAllowed;
507
+ }
508
+
509
+ /**
510
+ * Decide whether this offer may be signed. Steps 2 through 6 of the contract;
511
+ * step 1 (`no-readable-offer`) is decided on the challenge, before an offer
512
+ * exists to evaluate -- see {@link decideOnChallenge}.
513
+ *
514
+ * **Approving does not record the spend.** See
515
+ * {@link PurchasePolicy.recordSpend}.
516
+ */
517
+ evaluate(offer: X402PaymentOffer, options: EvaluateOptions): PolicyDecision {
518
+ const { now, validUntil, quote } = options;
519
+
520
+ // 2. Expiry. Before anything about money: terms that have lapsed are not
521
+ // terms, whatever they say. `validUntil === now` still stands -- it is the
522
+ // last instant the offer is up.
523
+ if (validUntil !== undefined && now > validUntil) {
524
+ return {
525
+ ok: false,
526
+ refusal: {
527
+ code: 'offer-expired',
528
+ validUntil,
529
+ now,
530
+ message:
531
+ `this offer expired at ${validUntil} (now ${now}); ` +
532
+ 'ask the seller for new terms',
533
+ },
534
+ };
535
+ }
536
+
537
+ // 3. Recipient.
538
+ if (this.recipients !== null) {
539
+ const payTo = canonicalRecipient(offer.payTo);
540
+ if (!this.recipients.has(payTo)) {
541
+ return {
542
+ ok: false,
543
+ refusal: {
544
+ code: 'recipient-not-permitted',
545
+ payTo: offer.payTo,
546
+ message: `this policy does not pay ${offer.payTo}`,
547
+ },
548
+ };
549
+ }
550
+ }
551
+
552
+ const asset = offerAsset(offer);
553
+ const key = assetKey(asset);
554
+ const requested = readAmount(offer.amount);
555
+
556
+ // 4. An asset nobody budgeted for. Checked BEFORE the ceilings, because the
557
+ // ceilings are a map and a map has no opinion about a key it does not
558
+ // hold -- which is precisely how an unlisted token would sail past a
559
+ // budget that looks complete. And the caller has to be told "budget that
560
+ // asset", not "raise a ceiling that does not exist".
561
+ if (
562
+ !this.unlistedAssetsAllowed &&
563
+ !this.perPaymentLimits.has(key) &&
564
+ !this.cumulativeLimits.has(key)
565
+ ) {
566
+ return {
567
+ ok: false,
568
+ refusal: {
569
+ code: 'asset-not-budgeted',
570
+ asset,
571
+ message:
572
+ `this policy has no budget for ${assetLabel(asset)}; ` +
573
+ 'it pays only what it was told it may pay',
574
+ },
575
+ };
576
+ }
577
+
578
+ // 5. Per-payment ceiling.
579
+ const perPaymentAllowed = this.perPaymentLimits.get(key);
580
+ if (perPaymentAllowed !== undefined && requested > perPaymentAllowed) {
581
+ return {
582
+ ok: false,
583
+ refusal: {
584
+ code: 'per-payment-limit',
585
+ requested,
586
+ allowed: perPaymentAllowed,
587
+ asset,
588
+ message:
589
+ `offer of ${requested} exceeds the per-payment limit of ` +
590
+ `${perPaymentAllowed} for ${assetLabel(asset)}`,
591
+ },
592
+ };
593
+ }
594
+
595
+ // 6. Cumulative ceiling.
596
+ const cumulativeAllowed = this.cumulativeLimits.get(key);
597
+ if (cumulativeAllowed !== undefined) {
598
+ const spent = this.spent(asset);
599
+ const wouldTotal = spent + requested;
600
+ if (wouldTotal > cumulativeAllowed) {
601
+ return {
602
+ ok: false,
603
+ refusal: {
604
+ code: 'cumulative-limit',
605
+ requested,
606
+ spent,
607
+ wouldTotal,
608
+ allowed: cumulativeAllowed,
609
+ asset,
610
+ message:
611
+ `offer of ${requested} would take spend to ${wouldTotal}, past the ` +
612
+ `cumulative limit of ${cumulativeAllowed} for ${assetLabel(asset)} ` +
613
+ `(already spent ${spent})`,
614
+ },
615
+ };
616
+ }
617
+ }
618
+
619
+ // The comparison against the listing is made LAST and refuses nothing. It is
620
+ // evidence for the caller, not a gate: a seller repricing inside a policy the
621
+ // operator already authorised is ordinary commerce.
622
+ return {
623
+ ok: true,
624
+ approval: { asset, amount: requested, versusQuote: compareToQuote(asset, requested, quote) },
625
+ };
626
+ }
627
+
628
+ /** Copy sharing the same purse. Cloning is what a per-request client does. */
629
+ clone(): PurchasePolicy {
630
+ return this.with({});
631
+ }
632
+
633
+ /**
634
+ * Rebuild with some fields replaced, **always sharing `spentTotals`**. This is
635
+ * the one place that decides copies share a purse, so it is the one place to
636
+ * read when asking whether they do.
637
+ */
638
+ private with(changes: {
639
+ perPaymentLimits?: Map<string, bigint>;
640
+ cumulativeLimits?: Map<string, bigint>;
641
+ recipients?: Set<string> | null;
642
+ unlistedAssetsAllowed?: boolean;
643
+ }): PurchasePolicy {
644
+ return new PurchasePolicy(
645
+ changes.perPaymentLimits ?? this.perPaymentLimits,
646
+ changes.cumulativeLimits ?? this.cumulativeLimits,
647
+ this.spentTotals,
648
+ changes.recipients !== undefined ? changes.recipients : this.recipients,
649
+ this.assetsByKey,
650
+ changes.unlistedAssetsAllowed ?? this.unlistedAssetsAllowed
651
+ );
652
+ }
653
+
654
+ /** Remember an asset's written form and reject one no budget could match. */
655
+ private register(asset: PolicyAsset): string {
656
+ if (!asset || typeof asset.address !== 'string' || asset.address.trim() === '') {
657
+ throw new X402Error(
658
+ 'A budget needs a token address; an asset with none can never match an offer',
659
+ 'INVALID_CONFIG',
660
+ { asset }
661
+ );
662
+ }
663
+ if (typeof asset.network !== 'string' || asset.network.trim() === '') {
664
+ throw new X402Error(
665
+ 'A budget needs a network; the same address on two networks is two assets',
666
+ 'INVALID_CONFIG',
667
+ { asset }
668
+ );
669
+ }
670
+ const key = assetKey(asset);
671
+ this.assetsByKey.set(key, asset);
672
+ return key;
673
+ }
674
+ }
675
+
676
+ /** A ceiling has to be a non-negative bigint; anything else is a config bug. */
677
+ function requireCeiling(amount: bigint, field: string): bigint {
678
+ if (typeof amount !== 'bigint' || amount < 0n) {
679
+ throw new X402Error(
680
+ `${field} must be a non-negative bigint in atomic units`,
681
+ 'INVALID_AMOUNT',
682
+ { amount: String(amount) }
683
+ );
684
+ }
685
+ return amount;
686
+ }
687
+
688
+ /**
689
+ * An offer's price as a bigint.
690
+ *
691
+ * Arithmetic is bigint throughout, never `number`: atomic units of an 18-decimal
692
+ * token pass `Number.MAX_SAFE_INTEGER` at 0.01 of a token, and a ceiling
693
+ * compared as a float is a ceiling that rounds.
694
+ *
695
+ * An amount that is not a non-negative integer is read as the largest value we
696
+ * can represent for the comparison, so it fails every ceiling rather than
697
+ * passing as zero: a price we cannot read is not a price we may pay.
698
+ */
699
+ function readAmount(amount: string): bigint {
700
+ const text = String(amount).trim();
701
+ if (!/^\d+$/.test(text)) return UNREADABLE_AMOUNT;
702
+ return BigInt(text);
703
+ }
704
+
705
+ /**
706
+ * Stand-in for a price we could not read. Absurdly large on purpose: it exceeds
707
+ * every ceiling a caller would write, so an unreadable amount is refused by the
708
+ * limit checks instead of sliding through as zero.
709
+ */
710
+ const UNREADABLE_AMOUNT = (1n << 256n) - 1n;
711
+
712
+ function compareToQuote(
713
+ asset: PolicyAsset,
714
+ requested: bigint,
715
+ quote: AdvertisedQuote | undefined
716
+ ): QuoteComparison {
717
+ if (!quote) return { code: 'not-compared' };
718
+ if (assetKey(quote.asset) !== assetKey(asset)) {
719
+ // Rule 4: a different asset is not the same price. No numbers are compared.
720
+ return { code: 'different-asset', advertised: quote.asset, offered: asset };
721
+ }
722
+ if (quote.amount !== requested) {
723
+ return { code: 'amount-differs', advertised: quote.amount, offered: requested };
724
+ }
725
+ return { code: 'matches' };
726
+ }
727
+
728
+ // ============================================================================
729
+ // The whole decision, in one place
730
+ // ============================================================================
731
+
732
+ /** A 402 challenge as the buyer read it, whole. */
733
+ export interface ReadChallenge {
734
+ /** Offers this build could read. */
735
+ offers: X402PaymentOffer[];
736
+ /**
737
+ * Scheme names of the entries it could not. Rule 7: an `accepts` with one
738
+ * unreadable entry KEEPS the readable ones and counts the others by scheme
739
+ * name, so a refusal can say what the seller offered.
740
+ */
741
+ unreadable: string[];
742
+ /**
743
+ * How many entries were unreadable, which is not the same as how many could be
744
+ * NAMED: an entry too broken to even carry a `scheme` still proves the seller
745
+ * sent offers. Without it, a challenge whose only entry was nameless garbage
746
+ * would read as "the seller sent nothing" -- a different fact, and the wrong
747
+ * one to report. Defaults to `unreadable.length`.
748
+ */
749
+ unreadableCount?: number;
750
+ /** The challenge's top-level `extensions`, where `validUntil` lives. */
751
+ extensions?: Record<string, unknown>;
752
+ }
753
+
754
+ /** Inputs to {@link decideOnChallenge} besides the challenge. */
755
+ export interface DecideOptions {
756
+ /** Unix seconds. Passed, never read, so the decision can be pinned in a test. */
757
+ now: number;
758
+ /** What the catalog advertised, if the caller read a listing. */
759
+ quote?: AdvertisedQuote;
760
+ }
761
+
762
+ /**
763
+ * The whole buying decision: all six steps, in the fixed order, against the
764
+ * offer in hand.
765
+ *
766
+ * Takes the challenge WHOLE, deliberately. Passing `offers` alone is what
767
+ * dropped the seller's `validUntil` on the floor in the Rust crate for a full
768
+ * commit with every unit test green, and a signature that takes the parts
769
+ * invites doing it again.
770
+ *
771
+ * Callable without a network stack on purpose: a decision that can only be
772
+ * exercised by driving a real HTTP client is a decision nobody tests.
773
+ *
774
+ * @param offer - The offer already selected out of `challenge.offers`. Selection
775
+ * is the caller's (cheapest, or its own `select`); this decides whether the
776
+ * selected one may be paid.
777
+ */
778
+ export function decideOnChallenge(
779
+ policy: PurchasePolicy,
780
+ challenge: ReadChallenge,
781
+ offer: X402PaymentOffer | null,
782
+ options: DecideOptions
783
+ ): PolicyDecision {
784
+ // 1. Was any offer readable at all? A challenge that carried offers, none of
785
+ // which this build can read, is not "no matching payment method": it is a
786
+ // seller asking for a scheme we do not implement, and saying so names what
787
+ // they wanted.
788
+ const unreadableCount = challenge.unreadableCount ?? challenge.unreadable.length;
789
+ if (challenge.offers.length === 0 && unreadableCount > 0) {
790
+ return { ok: false, refusal: noReadableOffer(challenge.unreadable) };
791
+ }
792
+ if (!offer) {
793
+ // Nothing selected out of a challenge that had readable offers, or a seller
794
+ // that sent no offers at all. Either way there is no offer in hand to judge,
795
+ // and the refusal names what the seller offered -- an empty list when it
796
+ // offered nothing.
797
+ return { ok: false, refusal: noReadableOffer(challenge.unreadable) };
798
+ }
799
+ return policy.evaluate(offer, {
800
+ now: options.now,
801
+ validUntil: offerValidUntil(challenge.extensions),
802
+ quote: options.quote,
803
+ });
804
+ }