@metalntwx/metal-pay 0.2.0 → 0.3.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 (70) hide show
  1. package/README.md +72 -33
  2. package/dist/clearing-abi.d.ts +1836 -0
  3. package/dist/clearing-abi.js +2361 -0
  4. package/dist/clearing-client.d.ts +49 -159
  5. package/dist/clearing-client.js +191 -564
  6. package/dist/clearing-codec.d.ts +57 -0
  7. package/dist/clearing-codec.js +173 -0
  8. package/dist/clearing-custody.d.ts +191 -0
  9. package/dist/clearing-custody.js +490 -0
  10. package/dist/clearing-server.d.ts +30 -49
  11. package/dist/clearing-server.js +87 -67
  12. package/dist/clearing-x402.d.ts +29 -0
  13. package/dist/clearing-x402.js +203 -0
  14. package/dist/client.d.ts +7 -13
  15. package/dist/client.js +16 -18
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +4 -1
  18. package/dist/mandates/binding.d.ts +4 -27
  19. package/dist/mandates/binding.js +5 -9
  20. package/dist/mandates/checkout.js +5 -5
  21. package/dist/mandates/client.js +4 -3
  22. package/dist/mandates/l3a.d.ts +2 -2
  23. package/dist/mandates/l3a.js +5 -4
  24. package/dist/mandates/seller.d.ts +1 -1
  25. package/dist/mandates/seller.js +1 -1
  26. package/dist/mandates/verify.d.ts +4 -6
  27. package/dist/mandates/verify.js +42 -66
  28. package/dist/mandates/vi/credential.js +4 -4
  29. package/dist/node.d.ts +11 -0
  30. package/dist/node.js +97 -0
  31. package/package.json +18 -11
  32. package/src/clearing-abi.ts +2361 -0
  33. package/src/clearing-client.ts +303 -0
  34. package/src/clearing-codec.test.ts +57 -0
  35. package/src/clearing-codec.ts +251 -0
  36. package/src/clearing-custody.test.ts +534 -0
  37. package/src/clearing-custody.ts +665 -0
  38. package/src/clearing-fixture.json +37 -0
  39. package/src/clearing-server.test.ts +388 -0
  40. package/src/clearing-server.ts +123 -0
  41. package/src/clearing-x402.test.ts +181 -0
  42. package/src/clearing-x402.ts +279 -0
  43. package/src/client.test.ts +325 -0
  44. package/src/client.ts +156 -0
  45. package/src/index.ts +33 -0
  46. package/src/mandates/account.ts +91 -0
  47. package/src/mandates/binding.ts +280 -0
  48. package/src/mandates/chain.ts +68 -0
  49. package/src/mandates/checkout.ts +368 -0
  50. package/src/mandates/client.ts +159 -0
  51. package/src/mandates/extension.ts +48 -0
  52. package/src/mandates/index.ts +121 -0
  53. package/src/mandates/ingest.ts +42 -0
  54. package/src/mandates/jcs.ts +16 -0
  55. package/src/mandates/l3a.ts +216 -0
  56. package/src/mandates/seller.ts +91 -0
  57. package/src/mandates/verify.ts +532 -0
  58. package/src/mandates/vi/credential.ts +477 -0
  59. package/src/mandates/vi/issue.ts +118 -0
  60. package/src/mandates/vi/sd-jwt.ts +189 -0
  61. package/src/networks.test.ts +16 -0
  62. package/src/networks.ts +152 -0
  63. package/src/node.test.ts +24 -0
  64. package/src/node.ts +96 -0
  65. package/src/server.test.ts +223 -0
  66. package/src/server.ts +77 -0
  67. package/dist/clearing.d.ts +0 -204
  68. package/dist/clearing.js +0 -151
  69. package/dist/file-storage.d.ts +0 -39
  70. package/dist/file-storage.js +0 -195
@@ -0,0 +1,368 @@
1
+ // The agent's signed order: the checkout JWT the agent signs over the payee's
2
+ // own offer, and the Verifiable Intent L3b (the checkout mandate) that
3
+ // carries it to the payee. A seller that asks for one gets, with the
4
+ // payment, proof that the agent was authorised for this offer; the L3a's
5
+ // `transaction_id` is then the checkout JWT's hash, so the payment mandate
6
+ // the chain verifies and the signed order the payee verifies name one offer.
7
+ //
8
+ // checkout JWT an ES256 JWS under the agent key: header
9
+ // `{"alg":"ES256","typ":"JWT","kid":<agent kid>}`, payload the
10
+ // offer in RFC 8785 form (the seller's x402 payment
11
+ // requirements, or a payout instruction), with an `id` member
12
+ // naming the payee's account
13
+ // checkout_hash base64url(SHA-256(ASCII(checkout JWT)))
14
+ // L3b `header.payload.signature~checkout disclosure~`, the
15
+ // disclosure `{vct: "mandate.checkout.1", checkout_jwt,
16
+ // checkout_hash, line_items: [{id, quantity: 1}]}`, `aud` the
17
+ // payee, `sd_hash` over the L2 checkout view the payee is
18
+ // given (`base ~ checkout disclosure ~ item disclosures ~`),
19
+ // serialised as the VI reference serialises a `kb-sd-jwt`
20
+ //
21
+ // One line item per payment, `quantity: 1`, under the credential's wildcard
22
+ // `line_items` constraint; its `id` names what was bought (the offer's
23
+ // resource, else the payee). The payee verifies the L3b under the agent key
24
+ // the mandate binds, against the offer it served; a verifier holding the
25
+ // credential also checks the `sd_hash`.
26
+
27
+ import { type Address, type Hex, concat, getAddress, hexToBytes, isAddressEqual, size } from "viem";
28
+ import type { AgentKey, Terms } from "./binding.js";
29
+ import { type Json, type JsonObject, jcs } from "./jcs.js";
30
+ import { type SignedL3a, jsonString, lowS, signL3a, verifyL3aSignature } from "./l3a.js";
31
+ import { type SignDigest, b64Ascii, b64url, fromB64url, hashAscii, sha256Hex } from "./vi/sd-jwt.js";
32
+
33
+ const CHECKOUT_VCT = "mandate.checkout.1";
34
+
35
+ /** The offer as the checkout JWT's payload text: RFC 8785, every character outside printable ASCII escaped as the reference escapes it. */
36
+ export function checkoutOfferText(offer: JsonObject): string {
37
+ if (typeof offer.id !== "string") throw new Error("an offer names its payee in an id member");
38
+ return jcs(offer).replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
39
+ }
40
+
41
+ /** The checkout JWT's signing input under the agent key `kid`, and its digest. */
42
+ export function buildCheckoutJwt(kid: string, offer: JsonObject): { signingInput: string; digest: Hex } {
43
+ const header = `{"alg":"ES256","typ":"JWT","kid":"${jsonString(kid, "kid")}"}`;
44
+ const signingInput = `${b64Ascii(header)}.${b64Ascii(checkoutOfferText(offer))}`;
45
+ return { signingInput, digest: sha256Hex(Buffer.from(signingInput, "ascii")) };
46
+ }
47
+
48
+ /** The checkout JWT the agent signs over `offer`, its `s` in the lower half of the curve order. */
49
+ export async function signCheckoutJwt(kid: string, offer: JsonObject, signDigest: SignDigest): Promise<string> {
50
+ const { signingInput, digest } = buildCheckoutJwt(kid, offer);
51
+ const signature = await signDigest(digest);
52
+ if (size(signature) !== 64) throw new Error("an ES256 signature is r ‖ s, 64 bytes");
53
+ const r = `0x${signature.slice(2, 66)}` as Hex;
54
+ const s = lowS(`0x${signature.slice(66)}` as Hex);
55
+ return `${signingInput}.${b64url(hexToBytes(concat([r, s])))}`;
56
+ }
57
+
58
+ /** SHA-256 over the checkout JWT's ASCII: the L3b's `checkout_hash` and the L3a's `transaction_id`, as 32 bytes. */
59
+ export function checkoutHash(jwt: string): Hex {
60
+ return sha256Hex(Buffer.from(jwt, "ascii"));
61
+ }
62
+
63
+ /** A checkout JWT read back: its signing input, agent kid, the offer and the signature. */
64
+ export interface CheckoutJwt {
65
+ signingInput: string;
66
+ kid: string;
67
+ offer: JsonObject;
68
+ r: Hex;
69
+ s: Hex;
70
+ }
71
+
72
+ export function readCheckoutJwt(jwt: string): CheckoutJwt {
73
+ const segments = jwt.split(".");
74
+ if (segments.length !== 3) throw new Error("a checkout JWT has three segments");
75
+ const [h, p, sig] = segments as [string, string, string];
76
+ const header = JSON.parse(Buffer.from(fromB64url(h)).toString("utf8")) as Record<string, unknown>;
77
+ if (header.alg !== "ES256" || header.typ !== "JWT" || typeof header.kid !== "string") throw new Error("a checkout JWT is an ES256 JWT naming the mandate key");
78
+ const offer = JSON.parse(Buffer.from(fromB64url(p)).toString("utf8")) as unknown;
79
+ if (offer === null || typeof offer !== "object" || Array.isArray(offer)) throw new Error("a checkout JWT's payload is an object");
80
+ const signature = fromB64url(sig);
81
+ if (signature.length !== 64) throw new Error("a checkout JWT's signature is r ‖ s");
82
+ const r = `0x${Buffer.from(signature.subarray(0, 32)).toString("hex")}` as Hex;
83
+ const s = `0x${Buffer.from(signature.subarray(32)).toString("hex")}` as Hex;
84
+ return { signingInput: `${h}.${p}`, kid: header.kid, offer: offer as JsonObject, r, s };
85
+ }
86
+
87
+ /** The agent's own fields of an L3b, beside what the mandate and the offer fix. */
88
+ export interface L3bFields {
89
+ nonce: string;
90
+ iss: string;
91
+ /** The checkout disclosure's salt, 16 bytes. */
92
+ checkoutSalt: Hex;
93
+ /** The one line item's id: what was bought. */
94
+ lineItemId: string;
95
+ r: Hex;
96
+ s: Hex;
97
+ }
98
+
99
+ /** An L3b without its signature: its serialisation's parts and the digest the agent signs. */
100
+ export interface UnsignedL3b {
101
+ signingInput: string;
102
+ checkoutDisclosure: string;
103
+ /** SHA-256 of the signing input. */
104
+ digest: Hex;
105
+ }
106
+
107
+ /**
108
+ * The L3b for a checkout JWT under a mandate: `aud` the payee's account,
109
+ * `iat`/`exp` the payment's window, `sd_hash` the hash of the L2 checkout
110
+ * view the payee is given (`MandateTerms.checkoutSdHash`).
111
+ */
112
+ export function buildL3b(args: {
113
+ kid: string;
114
+ payee: Address;
115
+ window: { validAfter: bigint; validBefore: bigint };
116
+ checkoutJwt: string;
117
+ checkoutSdHash: Hex;
118
+ fields: Omit<L3bFields, "r" | "s">;
119
+ }): UnsignedL3b {
120
+ const { kid, payee, window, checkoutJwt, checkoutSdHash, fields } = args;
121
+ if (size(fields.checkoutSalt) !== 16) throw new Error("the checkout salt is 16 bytes");
122
+ if (!/^[A-Za-z0-9_.~+/=-]+$/.test(checkoutJwt) || checkoutJwt.split(".").length !== 3) throw new Error("the checkout JWT is a compact JWS");
123
+ const header = `{"alg":"ES256","typ":"kb-sd-jwt","kid":"${jsonString(kid, "kid")}"}`;
124
+ const checkoutDisclosure = b64Ascii(
125
+ `["${b64url(hexToBytes(fields.checkoutSalt))}",{"vct":"${CHECKOUT_VCT}","checkout_jwt":"${checkoutJwt}",` +
126
+ `"checkout_hash":"${b64url(hexToBytes(checkoutHash(checkoutJwt)))}","line_items":[{"id":"${jsonString(fields.lineItemId, "line item id")}","quantity":1}]}]`,
127
+ );
128
+ const payload =
129
+ `{"nonce":"${jsonString(fields.nonce, "nonce")}","aud":"${getAddress(payee)}","sd_hash":"${b64url(hexToBytes(checkoutSdHash))}",` +
130
+ `"iat":${window.validAfter},"delegate_payload":[{"...":"${hashAscii(checkoutDisclosure)}"}],` +
131
+ `"_sd_alg":"sha-256","iss":"${jsonString(fields.iss, "iss")}","exp":${window.validBefore}}`;
132
+ const signingInput = `${b64Ascii(header)}.${b64Ascii(payload)}`;
133
+ return { signingInput, checkoutDisclosure, digest: sha256Hex(Buffer.from(signingInput, "ascii")) };
134
+ }
135
+
136
+ /** The L3b's SD-JWT serialisation: `header.payload.signature~checkout disclosure~`. */
137
+ export function serializeL3b(l3b: UnsignedL3b, r: Hex, s: Hex): string {
138
+ return `${l3b.signingInput}.${b64url(hexToBytes(concat([r, s])))}~${l3b.checkoutDisclosure}~`;
139
+ }
140
+
141
+ /** A checkout mandate, signed. */
142
+ export interface SignedL3b {
143
+ fields: L3bFields;
144
+ serialized: string;
145
+ digest: Hex;
146
+ checkoutJwt: string;
147
+ /** The checkout JWT's hash: the L3a's `transaction_id`. */
148
+ checkoutHash: Hex;
149
+ }
150
+
151
+ /** Signs the checkout JWT over `offer` and the L3b that carries it. `s` is normalised whatever `signDigest` returns. */
152
+ export async function signL3b(args: {
153
+ kid: string;
154
+ payee: Address;
155
+ offer: JsonObject;
156
+ lineItemId: string;
157
+ window: { validAfter: bigint; validBefore: bigint };
158
+ checkoutSdHash: Hex;
159
+ iss: string;
160
+ signDigest: SignDigest;
161
+ }): Promise<SignedL3b> {
162
+ const checkoutJwt = await signCheckoutJwt(args.kid, args.offer, args.signDigest);
163
+ const fields = {
164
+ nonce: b64url(crypto.getRandomValues(new Uint8Array(16))),
165
+ iss: args.iss,
166
+ checkoutSalt: `0x${Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("hex")}` as Hex,
167
+ lineItemId: args.lineItemId,
168
+ };
169
+ const l3b = buildL3b({ kid: args.kid, payee: args.payee, window: args.window, checkoutJwt, checkoutSdHash: args.checkoutSdHash, fields });
170
+ const signature = await args.signDigest(l3b.digest);
171
+ if (size(signature) !== 64) throw new Error("an ES256 signature is r ‖ s, 64 bytes");
172
+ const r = `0x${signature.slice(2, 66)}` as Hex;
173
+ const s = lowS(`0x${signature.slice(66)}` as Hex);
174
+ return { fields: { ...fields, r, s }, serialized: serializeL3b(l3b, r, s), digest: l3b.digest, checkoutJwt, checkoutHash: checkoutHash(checkoutJwt) };
175
+ }
176
+
177
+ /** An L3b read back: what the payee and the Open Verifier check. */
178
+ export interface ReadL3b {
179
+ signingInput: string;
180
+ kid: string;
181
+ typ: unknown;
182
+ nonce: unknown;
183
+ aud: unknown;
184
+ iss: unknown;
185
+ iat: unknown;
186
+ exp: unknown;
187
+ sdHash: unknown;
188
+ delegateHash: unknown;
189
+ checkoutDisclosure: string;
190
+ checkoutJwt: string;
191
+ checkoutHash: unknown;
192
+ lineItems: unknown;
193
+ r: Hex;
194
+ s: Hex;
195
+ }
196
+
197
+ export function readL3b(serialized: string): ReadL3b {
198
+ const parts = serialized.split("~");
199
+ if (parts.length !== 3 || parts[2] !== "") throw new Error("an L3b is jwt~checkout disclosure~");
200
+ const [jwt, checkoutDisclosure] = parts as [string, string];
201
+ const segments = jwt.split(".");
202
+ if (segments.length !== 3) throw new Error("an L3b JWT has three segments");
203
+ const [h, p, sig] = segments as [string, string, string];
204
+ const header = JSON.parse(Buffer.from(fromB64url(h)).toString("utf8")) as Record<string, unknown>;
205
+ const payload = JSON.parse(Buffer.from(fromB64url(p)).toString("utf8")) as Record<string, unknown>;
206
+ const [, final] = JSON.parse(Buffer.from(fromB64url(checkoutDisclosure)).toString("utf8")) as [string, Record<string, unknown>];
207
+ if (typeof header.kid !== "string") throw new Error("an L3b names the mandate key");
208
+ if (final.vct !== CHECKOUT_VCT || typeof final.checkout_jwt !== "string") throw new Error(`the disclosure is not a ${CHECKOUT_VCT} with a checkout JWT`);
209
+ const signature = fromB64url(sig);
210
+ if (signature.length !== 64) throw new Error("an L3b's signature is r ‖ s");
211
+ const delegates = Array.isArray(payload.delegate_payload) ? (payload.delegate_payload as { "..."?: unknown }[]) : [];
212
+ return {
213
+ signingInput: `${h}.${p}`,
214
+ kid: header.kid,
215
+ typ: header.typ,
216
+ nonce: payload.nonce,
217
+ aud: payload.aud,
218
+ iss: payload.iss,
219
+ iat: payload.iat,
220
+ exp: payload.exp,
221
+ sdHash: payload.sd_hash,
222
+ delegateHash: delegates.length === 1 ? delegates[0]?.["..."] : undefined,
223
+ checkoutDisclosure,
224
+ checkoutJwt: final.checkout_jwt,
225
+ checkoutHash: final.checkout_hash,
226
+ lineItems: final.line_items,
227
+ r: `0x${Buffer.from(signature.subarray(0, 32)).toString("hex")}` as Hex,
228
+ s: `0x${Buffer.from(signature.subarray(32)).toString("hex")}` as Hex,
229
+ };
230
+ }
231
+
232
+ export interface CheckoutExpectation {
233
+ agent: AgentKey;
234
+ /** The agent key's kid, which the mandate binds. */
235
+ kid: string;
236
+ /** The payee's account: the L3b's `aud`, the offer's `id` and, where the offer states one, its `payTo`. */
237
+ payee: Address;
238
+ /** The offer the payee served, which the checkout JWT must carry verbatim. A verifier that did not serve it leaves it out, and the JWT's own offer stands. */
239
+ offer?: JsonObject;
240
+ /** The line item the offer stands for; the offer's own when omitted. */
241
+ lineItemId?: string;
242
+ /** The L2 checkout view's hash, checked when the verifier holds the credential. */
243
+ checkoutSdHash?: Hex;
244
+ /** The payment's window, which the checkout mandate's `iat` and `exp` must equal; without it, the mandate must be current at `now`. */
245
+ window?: { validAfter: bigint; validBefore: bigint };
246
+ now?: number;
247
+ /** The per-payment mandate's `transaction_id`, as 32 bytes, which must be the checkout hash. */
248
+ transactionId?: Hex;
249
+ /** The transfer's amount in base units, which the offer's `amount` must state where it states one. */
250
+ amount?: bigint;
251
+ }
252
+
253
+ /**
254
+ * The one check of a checkout mandate, as the reference's merchant rules
255
+ * say: the agent key the mandate binds signed it (`kid` and the signature),
256
+ * it is a `kb-sd-jwt` for this payee within its window, its one disclosure is
257
+ * the one it commits to, the checkout JWT is the agent's signature over the
258
+ * offer naming this payee (the one served, when the verifier served it),
259
+ * `checkout_hash` is that JWT's hash, and it buys one line item. With the
260
+ * credential's checkout view, the `sd_hash` too; with the per-payment
261
+ * mandate's `transaction_id` and the transfer's amount, that they are the
262
+ * checkout hash and the offer's amount. Each signature is verified once.
263
+ */
264
+ export function verifyCheckoutMandate(serialized: string, expect: CheckoutExpectation): { ok: boolean; problems: string[] } {
265
+ try {
266
+ return checkCheckoutMandate(serialized, expect);
267
+ } catch (error) {
268
+ return { ok: false, problems: [`the checkout mandate is malformed: ${error instanceof Error ? error.message : String(error)}`] };
269
+ }
270
+ }
271
+
272
+ function checkCheckoutMandate(serialized: string, expect: CheckoutExpectation): { ok: boolean; problems: string[] } {
273
+ const problems: string[] = [];
274
+ let l3b: ReadL3b;
275
+ try {
276
+ l3b = readL3b(serialized);
277
+ } catch (error) {
278
+ return { ok: false, problems: [`the checkout mandate does not parse: ${error instanceof Error ? error.message : String(error)}`] };
279
+ }
280
+ if (l3b.typ !== "kb-sd-jwt") problems.push("the checkout mandate's typ is not kb-sd-jwt");
281
+ if (l3b.kid !== expect.kid) problems.push("the checkout mandate names another mandate key");
282
+ if (!verifyL3aSignature(expect.agent, sha256Hex(Buffer.from(l3b.signingInput, "ascii")), l3b.r, l3b.s)) problems.push("the checkout mandate is not signed by the mandate key");
283
+ if (typeof l3b.aud !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(l3b.aud) || !isAddressEqual(l3b.aud as Address, expect.payee)) problems.push("the checkout mandate is for another payee");
284
+ const now = expect.now ?? Math.floor(Date.now() / 1000);
285
+ if (!Number.isSafeInteger(l3b.iat) || !Number.isSafeInteger(l3b.exp) || (l3b.exp as number) <= (l3b.iat as number)) problems.push("the checkout mandate's window is malformed");
286
+ else if (expect.window !== undefined) {
287
+ if (BigInt(l3b.iat as number) !== expect.window.validAfter || BigInt(l3b.exp as number) !== expect.window.validBefore) problems.push("the checkout mandate's window is not the payment's");
288
+ } else if (now < (l3b.iat as number) - 300 || now > (l3b.exp as number) + 300) problems.push("the checkout mandate is outside its window");
289
+ if (l3b.delegateHash !== hashAscii(l3b.checkoutDisclosure)) problems.push("the checkout mandate does not commit to its disclosure");
290
+ if (expect.checkoutSdHash !== undefined && l3b.sdHash !== b64url(hexToBytes(expect.checkoutSdHash))) problems.push("the checkout mandate's sd_hash is not the mandate's checkout view");
291
+ if (l3b.checkoutHash !== b64url(hexToBytes(checkoutHash(l3b.checkoutJwt)))) problems.push("checkout_hash is not the checkout JWT's hash");
292
+ if (expect.transactionId !== undefined && expect.transactionId.toLowerCase() !== checkoutHash(l3b.checkoutJwt)) problems.push("the per-payment mandate's transaction_id is not the checkout hash");
293
+ let jwt: CheckoutJwt | undefined;
294
+ try {
295
+ jwt = readCheckoutJwt(l3b.checkoutJwt);
296
+ } catch (error) {
297
+ problems.push(`the checkout JWT does not parse: ${error instanceof Error ? error.message : String(error)}`);
298
+ }
299
+ let lineItemId = expect.lineItemId;
300
+ if (jwt !== undefined) {
301
+ if (jwt.kid !== expect.kid || !verifyL3aSignature(expect.agent, sha256Hex(Buffer.from(jwt.signingInput, "ascii")), jwt.r, jwt.s)) problems.push("the checkout JWT is not signed by the mandate key");
302
+ if (expect.offer !== undefined && jwt.signingInput !== buildCheckoutJwt(expect.kid, expect.offer).signingInput) problems.push("the checkout JWT is not over the offer the payee served");
303
+ const offer = expect.offer ?? jwt.offer;
304
+ lineItemId ??= lineItemOf(offer);
305
+ if (typeof jwt.offer.id !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(jwt.offer.id) || !isAddressEqual(jwt.offer.id as Address, expect.payee)) problems.push("the offer does not name this payee");
306
+ if (typeof offer.payTo === "string" && !(/^0x[0-9a-fA-F]{40}$/.test(offer.payTo) && isAddressEqual(offer.payTo as Address, expect.payee))) problems.push("the offer's payTo is not the transfer's payee");
307
+ if (expect.amount !== undefined && typeof offer.amount === "string" && offer.amount !== expect.amount.toString()) problems.push("the offer's amount is not the transfer's");
308
+ }
309
+ const items = l3b.lineItems;
310
+ if (!Array.isArray(items) || items.length !== 1) problems.push("the checkout mandate must buy exactly one line item");
311
+ else {
312
+ const item: unknown = items[0];
313
+ if (item === null || typeof item !== "object" || Array.isArray(item)) problems.push("the line item is one unit of a named item");
314
+ else if (typeof (item as { id?: unknown }).id !== "string" || (item as { quantity?: unknown }).quantity !== 1) problems.push("the line item is one unit of a named item");
315
+ else if (lineItemId !== undefined && (item as { id: string }).id !== lineItemId) problems.push("the line item is not the offer's");
316
+ }
317
+ return { ok: problems.length === 0, problems };
318
+ }
319
+
320
+ /** A payment's two signatures by the agent key: the per-payment mandate, and the checkout mandate whose checkout hash it names. */
321
+ export interface SignedPayment extends SignedL3a {
322
+ checkout: SignedL3b;
323
+ }
324
+
325
+ /**
326
+ * Signs a payment under a mandate: the checkout JWT over `offer` and the
327
+ * checkout mandate for the payee, then the per-payment mandate for the
328
+ * transfer with the checkout hash as its `transaction_id`, both over the
329
+ * same window.
330
+ */
331
+ export async function signPayment(args: {
332
+ chainId: number;
333
+ ref: Hex;
334
+ token: Address;
335
+ terms: Terms;
336
+ to: Address;
337
+ value: bigint;
338
+ validAfter: bigint;
339
+ validBefore: bigint;
340
+ iss: string;
341
+ signDigest: SignDigest;
342
+ offer: JsonObject;
343
+ lineItemId: string;
344
+ checkoutSdHash: Hex;
345
+ }): Promise<SignedPayment> {
346
+ const { offer, lineItemId, checkoutSdHash, ...transfer } = args;
347
+ const window = { validAfter: args.validAfter, validBefore: args.validBefore };
348
+ const checkout = await signL3b({ kid: args.terms.kid, payee: args.to, offer, lineItemId, window, checkoutSdHash, iss: args.iss, signDigest: args.signDigest });
349
+ const signed = await signL3a({ ...transfer, transactionId: checkout.checkoutHash });
350
+ return { ...signed, checkout };
351
+ }
352
+
353
+ /** The line item an offer stands for: an x402 seller's `extra.resource` when it names one, else the payee it pays. */
354
+ export function lineItemOf(offer: JsonObject): string {
355
+ const extra = offer.extra;
356
+ const resource = extra !== null && typeof extra === "object" && !Array.isArray(extra) ? (extra as JsonObject).resource : undefined;
357
+ if (typeof resource === "string" && resource.length > 0) return resource;
358
+ return typeof offer.payTo === "string" ? offer.payTo : String(offer.id);
359
+ }
360
+
361
+ /** The offer an x402 payment is for: the payment requirements as served (members the server left unset dropped), naming the payee in `id`. */
362
+ export function x402Offer(requirements: Record<string, Json | undefined>): JsonObject {
363
+ if (typeof requirements.payTo !== "string") throw new Error("x402 requirements name payTo");
364
+ const offer: JsonObject = { id: getAddress(requirements.payTo as Address) };
365
+ for (const [key, value] of Object.entries(requirements)) if (value !== undefined) offer[key] = value;
366
+ return offer;
367
+ }
368
+
@@ -0,0 +1,159 @@
1
+ // Paying under a mandate: an `exact` client for an agent that pays from its
2
+ // principal's account. Each payment is the agent's per-payment mandate, a
3
+ // Verifiable Intent L3a in the profile Mandate Binding rebuilds, and that is
4
+ // its only signature. The payload is a standard ERC-3009
5
+ // `TransferWithAuthorization` whose `from` is the principal's account, whose
6
+ // nonce is `bytes12(mandate reference) ‖ bytes20(SHA-256(L3a signing input))`,
7
+ // whose window is the L3a's `iat` and `exp`, and whose signature is
8
+ // `MNDT || abi.encode(Pull, L3a fields)`, the arguments of `pull` on the
9
+ // principal's account. Sellers see an ordinary `exact` payment; Metal's
10
+ // facilitator submits it as that `pull`, where Mandate Binding accepts or
11
+ // refuses it. A seller that asks for the agent's signed order gets it in
12
+ // the payload's `extensions`, and the per-payment mandate names its hash.
13
+ //
14
+ // `@x402/evm`'s `exact` client draws its own nonce and signs EIP-712 as
15
+ // `from`, so this scheme builds the authorisation itself, as `@x402/evm`'s
16
+ // `ExactEIP3009Payload`.
17
+
18
+ import { x402Client } from "@x402/core/client";
19
+ import type { PaymentPayloadResult, PaymentRequirements, SchemeNetworkClient } from "@x402/core/types";
20
+ import type { ExactEIP3009Payload } from "@x402/evm";
21
+ import { wrapFetchWithPayment } from "@x402/fetch";
22
+ import { type Address, type Hex, getAddress } from "viem";
23
+ import { type MetalPaymentClient, allowAsset, keepPaymentOnOrigin } from "../client.js";
24
+ import { type MetalNetwork, type MetalNetworkName, USDZ, formatUsdz, resolveNetwork } from "../networks.js";
25
+ import { mandateReference } from "./binding.js";
26
+ import { type SignedPayment, lineItemOf, signPayment, x402Offer } from "./checkout.js";
27
+ import { asksSignedOffer, mandateExtension } from "./extension.js";
28
+ import type { Json } from "./jcs.js";
29
+ import { type SignedL3a, encodeMandateBlob, signL3a, transactionIdOf } from "./l3a.js";
30
+ import { type Credential, type MandateTerms, extractTerms, hashCredential } from "./vi/credential.js";
31
+ import type { SignDigest } from "./vi/sd-jwt.js";
32
+
33
+ /**
34
+ * The longest a per-payment mandate stays valid. An offer's
35
+ * `maxTimeoutSeconds` can only shorten it, so a seller cannot obtain one it
36
+ * may submit long after the agent chose to pay.
37
+ */
38
+ export const MANDATE_MAX_VALIDITY_SECONDS = 600;
39
+
40
+ /**
41
+ * How far before signing the window opens. MIP20 requires `now > validAfter`,
42
+ * and a facilitator simulates against the latest block, whose timestamp can
43
+ * trail the clock by a block or more.
44
+ */
45
+ export const MANDATE_VALID_AFTER_LEAD_SECONDS = 5;
46
+
47
+ export interface MandateSchemeConfig {
48
+ /** The credential the mandate was bound from: the principal, reference, token and rebuild terms derive from it. */
49
+ credential: Credential;
50
+ /** Signs an L3a's SHA-256 signing digest with the agent key: `p256DigestSigner`. */
51
+ signDigest: SignDigest;
52
+ /** Called with each per-payment mandate once it is signed, before the payment is sent; with the signed order when one was made. */
53
+ onSigned?: (payment: SignedL3a | SignedPayment) => void;
54
+ /** When to sign the seller's offer: when its offer asks (the default), for every payment, or never, which a seller that asks then refuses. */
55
+ signedOffer?: "when-asked" | "always" | "never";
56
+ }
57
+
58
+ function chainIdOf(network: string): number {
59
+ const match = /^eip155:(\d+)$/.exec(network);
60
+ if (!match) throw new Error(`not an EVM network: ${network}`);
61
+ return Number(match[1]);
62
+ }
63
+
64
+ /** The `exact` scheme for an agent paying under a mandate. */
65
+ export class MandateExactScheme implements SchemeNetworkClient {
66
+ readonly scheme = "exact";
67
+ /** The account the payments come from. */
68
+ readonly principal: Address;
69
+ readonly reference: Hex;
70
+ readonly token: Address;
71
+ private readonly mandate: MandateTerms;
72
+
73
+ constructor(private readonly config: MandateSchemeConfig) {
74
+ this.mandate = extractTerms(config.credential);
75
+ this.principal = this.mandate.principal;
76
+ this.token = this.mandate.constraints.token;
77
+ this.reference = mandateReference(this.principal, this.mandate.agentKey, hashCredential(config.credential));
78
+ }
79
+
80
+ async createPaymentPayload(x402Version: number, requirements: PaymentRequirements): Promise<PaymentPayloadResult> {
81
+ const method = requirements.extra?.["assetTransferMethod"] ?? "eip3009";
82
+ if (method !== "eip3009") throw new Error(`a mandate pays by ERC-3009 only, not ${String(method)}`);
83
+ if (getAddress(requirements.asset) !== this.token) throw new Error(`asset ${requirements.asset} is not the mandate's token ${this.token}`);
84
+ if (BigInt(requirements.amount) % this.mandate.terms.unitsPerMinor !== 0n) {
85
+ throw new Error(`offer of ${formatUsdz(requirements.amount)} ${USDZ.symbol} is not a whole number of cents, which is all a mandate pays`);
86
+ }
87
+ const now = BigInt(Math.floor(Date.now() / 1000));
88
+ const transfer = {
89
+ chainId: chainIdOf(requirements.network),
90
+ ref: this.reference,
91
+ token: this.token,
92
+ terms: this.mandate.terms,
93
+ to: getAddress(requirements.payTo),
94
+ value: BigInt(requirements.amount),
95
+ validAfter: now - BigInt(MANDATE_VALID_AFTER_LEAD_SECONDS),
96
+ validBefore: now + BigInt(Math.min(requirements.maxTimeoutSeconds, MANDATE_MAX_VALIDITY_SECONDS)),
97
+ iss: this.mandate.terms.kid,
98
+ signDigest: this.config.signDigest,
99
+ };
100
+ const mode = this.config.signedOffer ?? "when-asked";
101
+ const withOffer = mode === "always" || (mode === "when-asked" && asksSignedOffer(requirements));
102
+ let signed: SignedL3a | SignedPayment;
103
+ let extensions: Record<string, unknown> | undefined;
104
+ if (withOffer) {
105
+ const offer = x402Offer(requirements as unknown as Record<string, Json | undefined>);
106
+ const payment = await signPayment({ ...transfer, offer, lineItemId: lineItemOf(offer), checkoutSdHash: this.mandate.checkoutSdHash });
107
+ signed = payment;
108
+ extensions = mandateExtension(this.reference, payment.checkout.serialized);
109
+ } else {
110
+ signed = await signL3a({ ...transfer, transactionId: transactionIdOf(requirements as unknown as Json) });
111
+ }
112
+ this.config.onSigned?.(signed);
113
+ const payload: ExactEIP3009Payload = {
114
+ authorization: {
115
+ from: this.principal,
116
+ to: signed.pull.to,
117
+ value: signed.pull.value.toString(),
118
+ validAfter: signed.pull.validAfter.toString(),
119
+ validBefore: signed.pull.validBefore.toString(),
120
+ nonce: signed.pull.nonce,
121
+ },
122
+ signature: encodeMandateBlob(signed.pull, signed.fields),
123
+ };
124
+ return { x402Version, payload, ...(extensions === undefined ? {} : { extensions }) };
125
+ }
126
+ }
127
+
128
+ export interface MandatePaymentClientConfig extends MandateSchemeConfig {
129
+ /** The one network this client pays on; offers for any other are refused. */
130
+ network: MetalNetworkName | MetalNetwork;
131
+ /** The fetch to wrap; `globalThis.fetch` by default. */
132
+ fetch?: typeof globalThis.fetch;
133
+ }
134
+
135
+ export interface MandatePaymentClient extends MetalPaymentClient {
136
+ /** The account the payments come from. */
137
+ principal: Address;
138
+ /** The mandate the payments are made under. */
139
+ reference: Hex;
140
+ }
141
+
142
+ /**
143
+ * One call to pay under a mandate. The client sets no limit of its own: the
144
+ * mandate bound on the principal's account decides, and Metal refuses a
145
+ * payment outside it at settlement.
146
+ */
147
+ export function createMandatePaymentClient({ network, fetch = globalThis.fetch, ...config }: MandatePaymentClientConfig): MandatePaymentClient {
148
+ const resolved = resolveNetwork(network);
149
+ const scheme = new MandateExactScheme(config);
150
+ const client = new x402Client().register(resolved.network, scheme);
151
+ allowAsset(client, resolved.network, scheme.token);
152
+ return {
153
+ fetchWithPayment: wrapFetchWithPayment(keepPaymentOnOrigin(fetch), client),
154
+ client,
155
+ network: resolved,
156
+ principal: scheme.principal,
157
+ reference: scheme.reference,
158
+ };
159
+ }
@@ -0,0 +1,48 @@
1
+ // What a payment under a mandate carries in the x402 payload's `extensions`
2
+ // field under `metal-mandate`, when the seller asked for the agent's signed
3
+ // offer: the mandate reference and the signed order (a Verifiable Intent
4
+ // checkout mandate, L3b). Neither is covered by the payer's signature: the
5
+ // per-payment mandate the authorisation carries is the signature, and its
6
+ // `transaction_id` is the signed order's `checkout_hash`, which ties the two.
7
+ // A seller's offer asks for one with `extra.signedOffer: "required"`.
8
+
9
+ import type { PaymentPayload, PaymentRequirements } from "@x402/core/types";
10
+
11
+ export const MANDATE_EXTENSION_KEY = "metal-mandate";
12
+
13
+ /** What a seller puts in a route's `extra` to be paid only with the agent's signed order. */
14
+ export const SIGNED_OFFER_REQUIRED = { signedOffer: "required" } as const;
15
+
16
+ export interface MandateExtension {
17
+ /** The mandate reference, `0x`-prefixed. */
18
+ ref: string;
19
+ /** The signed order, serialised. */
20
+ checkoutMandate: string;
21
+ }
22
+
23
+ /** The `extensions` entry for a payment under `ref` with its signed order. */
24
+ export function mandateExtension(ref: string, checkoutMandate: string): Record<string, MandateExtension> {
25
+ return { [MANDATE_EXTENSION_KEY]: { ref, checkoutMandate } };
26
+ }
27
+
28
+ /** Whether an offer asks for the agent's signed order. */
29
+ export function asksSignedOffer(requirements: Pick<PaymentRequirements, "extra">): boolean {
30
+ return requirements.extra?.["signedOffer"] === SIGNED_OFFER_REQUIRED.signedOffer;
31
+ }
32
+
33
+ function extensionOf(payload: Pick<PaymentPayload, "extensions">): Partial<MandateExtension> {
34
+ const value = payload.extensions?.[MANDATE_EXTENSION_KEY];
35
+ return value !== null && typeof value === "object" ? (value as Partial<MandateExtension>) : {};
36
+ }
37
+
38
+ /** The mandate reference a payment carries; empty when it carries none. */
39
+ export function mandateRefOf(payload: Pick<PaymentPayload, "extensions">): string {
40
+ const { ref } = extensionOf(payload);
41
+ return typeof ref === "string" ? ref : "";
42
+ }
43
+
44
+ /** The signed order a payment carries; empty when it carries none. */
45
+ export function signedOfferOf(payload: Pick<PaymentPayload, "extensions">): string {
46
+ const { checkoutMandate } = extensionOf(payload);
47
+ return typeof checkoutMandate === "string" ? checkoutMandate : "";
48
+ }