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.
- package/README.md +235 -0
- package/dist/adapters/index.d.mts +1 -1
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/index.mjs.map +1 -1
- package/dist/backend/index.d.mts +2 -2
- package/dist/backend/index.d.ts +2 -2
- package/dist/backend/index.js +138 -0
- package/dist/backend/index.js.map +1 -1
- package/dist/backend/index.mjs +137 -1
- package/dist/backend/index.mjs.map +1 -1
- package/dist/erc8128/index.js.map +1 -1
- package/dist/erc8128/index.mjs.map +1 -1
- package/dist/{index-D8buGZWY.d.mts → index-13ZUDF7c.d.mts} +32 -2
- package/dist/{index-CWZUj5mz.d.ts → index-B5B9EK3x.d.ts} +32 -2
- package/dist/{index-DOBhTF-j.d.mts → index-BWN0Haa0.d.mts} +476 -2
- package/dist/{index-DOBhTF-j.d.ts → index-BWN0Haa0.d.ts} +476 -2
- package/dist/{index-BHfYxpzI.d.mts → index-CAJg-_cr.d.mts} +181 -2
- package/dist/{index-D67eNxY8.d.ts → index-Cd1Mpznb.d.ts} +181 -2
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +472 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +464 -9
- package/dist/index.mjs.map +1 -1
- package/dist/providers/algorand/index.d.mts +1 -1
- package/dist/providers/algorand/index.d.ts +1 -1
- package/dist/providers/algorand/index.js.map +1 -1
- package/dist/providers/algorand/index.mjs.map +1 -1
- package/dist/providers/evm/index.d.mts +1 -1
- package/dist/providers/evm/index.d.ts +1 -1
- package/dist/providers/evm/index.js.map +1 -1
- package/dist/providers/evm/index.mjs.map +1 -1
- package/dist/providers/near/index.d.mts +1 -1
- package/dist/providers/near/index.d.ts +1 -1
- package/dist/providers/near/index.js.map +1 -1
- package/dist/providers/near/index.mjs.map +1 -1
- package/dist/providers/solana/index.d.mts +1 -1
- package/dist/providers/solana/index.d.ts +1 -1
- package/dist/providers/solana/index.js.map +1 -1
- package/dist/providers/solana/index.mjs.map +1 -1
- package/dist/providers/stellar/index.d.mts +1 -1
- package/dist/providers/stellar/index.d.ts +1 -1
- package/dist/providers/stellar/index.js.map +1 -1
- package/dist/providers/stellar/index.mjs.map +1 -1
- package/dist/providers/sui/index.d.mts +1 -1
- package/dist/providers/sui/index.d.ts +1 -1
- package/dist/providers/sui/index.js.map +1 -1
- package/dist/providers/sui/index.mjs.map +1 -1
- package/dist/providers/xrpl/index.d.mts +1 -1
- package/dist/providers/xrpl/index.d.ts +1 -1
- package/dist/providers/xrpl/index.js.map +1 -1
- package/dist/providers/xrpl/index.mjs.map +1 -1
- package/dist/react/index.d.mts +3 -3
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.js +463 -8
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +463 -8
- package/dist/react/index.mjs.map +1 -1
- package/dist/react/picker/index.d.mts +1 -1
- package/dist/react/picker/index.d.ts +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.d.ts +2 -2
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/index.mjs.map +1 -1
- package/dist/{validation-mSLW4tcO.d.mts → validation-B95g0MqC.d.mts} +1 -1
- package/dist/{validation-C7p3a6Ep.d.ts → validation-_Tcjm2gu.d.ts} +1 -1
- package/package.json +1 -1
- package/src/backend/index.ts +260 -0
- package/src/client/X402Client.ts +232 -9
- package/src/index.ts +28 -0
- package/src/policy.ts +804 -0
- 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
|
+
}
|