@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,477 @@
1
+ // Verifiable Intent v0.1 credentials under Metal's L1 profile, as Mandate
2
+ // Ingestion reads them. L1 is the issuer's credential under Metal's own `vct`,
3
+ // binding the principal's key (`cnf.jwk`) to the principal's Metal account
4
+ // (`sub`). L2 is the principal's autonomous-mode mandate pair: one open
5
+ // checkout mandate and one open payment mandate, both naming the agent key in
6
+ // `cnf.jwk`. What Mandate Binding enforces comes from the payment mandate;
7
+ // the rest is descriptive and never enforced.
8
+ //
9
+ // Conventions fixed here for the chain:
10
+ // - the payee is the Metal address in the payee object's `id`;
11
+ // - the payment instrument is `{"type":"metal.mip20","id":<token>,…}`, a MIP20
12
+ // token from `INSTRUMENTS`, and amounts are integer minor units of its
13
+ // currency, `unitsPerMinor` token base units each;
14
+ // - `agent_recurrence` dates are UTC: a `YYYY-MM-DD` start is 00:00:00Z of that
15
+ // day and a `YYYY-MM-DD` end is 23:59:59Z of that day, both inclusive; an
16
+ // RFC 3339 date-time with a `Z` or numeric offset is that instant; a
17
+ // date-time without an offset is refused;
18
+ // - the budget is the L2's lifetime total: VI v0.1's `budget` caps spend
19
+ // over the whole mandate pair and never resets.
20
+
21
+ import { type Address, type Hex, bytesToHex, getAddress, isAddress } from "viem";
22
+ import type { AgentKey, Constraints, Terms } from "../binding.js";
23
+ import { type Json, type JsonObject, jcs } from "../jcs.js";
24
+ import { namesAddress } from "../l3a.js";
25
+ import { USDZ } from "../../networks.js";
26
+ import {
27
+ type P256Jwk,
28
+ type SdJwt,
29
+ compactJson,
30
+ decodeDisclosure,
31
+ decodeSdJwt,
32
+ fromB64url,
33
+ hashAscii,
34
+ jwkThumbprint,
35
+ jwkThumbprintBytes,
36
+ parseP256Jwk,
37
+ sha256Hex,
38
+ verifySdJwtSignature,
39
+ } from "./sd-jwt.js";
40
+
41
+ export type Credential = { format: "verifiable-intent"; version: "0.1"; layer1: string; layer2: string };
42
+
43
+ /** Metal's L1 profile: the `vct` verifiers pass to the VI reference as `expected_l1_vct`. */
44
+ export const METAL_L1_VCT = "https://credentials.metal.example/mandate";
45
+ export const CHECKOUT_VCT = "mandate.checkout.open.1";
46
+ export const PAYMENT_VCT = "mandate.payment.open.1";
47
+ export const INSTRUMENT_TYPE = "metal.mip20";
48
+ const ZERO_HASH: Hex = `0x${"00".repeat(32)}`;
49
+
50
+ /**
51
+ * The payment instruments ingestion accepts: a MIP20 token, the ISO 4217
52
+ * currency its credential amounts are stated in, and the token's base units
53
+ * per minor unit. `*USDZ` (18 decimals) is dollar-denominated, 10^16 per cent.
54
+ */
55
+ export const INSTRUMENTS = [{ token: USDZ.address as Address, currency: "USD", unitsPerMinor: 10n ** BigInt(USDZ.decimals - 2) }] as const;
56
+
57
+ /** An issuer whose L1 credentials a verifier accepts: its `iss`, and the key its L1 header `kid` names. */
58
+ export interface TrustedIssuer {
59
+ iss: string;
60
+ kid: string;
61
+ jwk: P256Jwk;
62
+ }
63
+
64
+ export type CredentialFailure =
65
+ | "MalformedCredential"
66
+ | "MalformedLayer"
67
+ | "UntrustedIssuer"
68
+ | "IssuerSignature"
69
+ | "PrincipalSignature"
70
+ | "SdHash"
71
+ | "Expired"
72
+ | "NotYetValid"
73
+ | "WrongAccount"
74
+ | "UndisclosedClaim"
75
+ | "MalformedMandate"
76
+ | "MissingConstraint"
77
+ | "Unenforceable";
78
+
79
+ export class CredentialError extends Error {
80
+ constructor(
81
+ readonly reason: CredentialFailure,
82
+ detail: string,
83
+ ) {
84
+ super(`${reason}: ${detail}`);
85
+ this.name = "CredentialError";
86
+ }
87
+ }
88
+
89
+ function fail(reason: CredentialFailure, detail: string): never {
90
+ throw new CredentialError(reason, detail);
91
+ }
92
+
93
+ function isObject(value: Json | undefined): value is JsonObject {
94
+ return value !== null && typeof value === "object" && !Array.isArray(value);
95
+ }
96
+
97
+ function obj(value: Json | undefined, what: string, reason: CredentialFailure = "MalformedMandate"): JsonObject {
98
+ return isObject(value) ? value : fail(reason, `${what} must be an object`);
99
+ }
100
+
101
+ function list(value: Json | undefined, what: string): Json[] {
102
+ return Array.isArray(value) ? value : fail("MalformedMandate", `${what} must be an array`);
103
+ }
104
+
105
+ function str(o: JsonObject, key: string, reason: CredentialFailure = "MalformedMandate"): string {
106
+ const value = o[key];
107
+ return typeof value === "string" ? value : fail(reason, `${key} must be a string`);
108
+ }
109
+
110
+ function int(o: JsonObject, key: string, reason: CredentialFailure = "MalformedMandate"): number {
111
+ const value = o[key];
112
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : fail(reason, `${key} must be an integer`);
113
+ }
114
+
115
+ function positive(o: JsonObject, key: string, what: string): bigint {
116
+ const value = o[key];
117
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
118
+ ? BigInt(value)
119
+ : fail("MalformedMandate", `${what}.${key} must be a positive integer`);
120
+ }
121
+
122
+ function address(value: string, what: string): Address {
123
+ return isAddress(value, { strict: false }) ? getAddress(value) : fail("MalformedMandate", `${what} is not an address`);
124
+ }
125
+
126
+ export function parseCredential(json: unknown): Credential {
127
+ if (json === null || typeof json !== "object" || Array.isArray(json)) fail("MalformedCredential", "credential must be an object");
128
+ const keys = Object.keys(json).sort().join(",");
129
+ if (keys !== "format,layer1,layer2,version") fail("MalformedCredential", `expected format, version, layer1, layer2; got ${keys}`);
130
+ const { format, version, layer1, layer2 } = json as Record<string, unknown>;
131
+ if (format !== "verifiable-intent") fail("MalformedCredential", "format must be verifiable-intent");
132
+ if (version !== "0.1") fail("MalformedCredential", "version must be 0.1");
133
+ if (typeof layer1 !== "string" || typeof layer2 !== "string") fail("MalformedCredential", "layer1 and layer2 must be strings");
134
+ return { format, version, layer1, layer2 };
135
+ }
136
+
137
+ /** hash(C): SHA-256 over the RFC 8785 form of the whole credential. */
138
+ export function hashCredential(c: Credential): Hex {
139
+ return sha256Hex(Buffer.from(jcs(c), "utf8"));
140
+ }
141
+
142
+ function p256Jwk(value: Json | undefined, what: string, reason: CredentialFailure): P256Jwk {
143
+ try {
144
+ return parseP256Jwk(obj(value, what, reason), what);
145
+ } catch (error) {
146
+ return fail(reason, error instanceof Error ? error.message : String(error));
147
+ }
148
+ }
149
+
150
+ /** A P-256 JWK as the typed agent key a mandate binds. */
151
+ export function agentKeyOfJwk(jwk: P256Jwk): AgentKey {
152
+ return { x: bytesToHex(fromB64url(jwk.x)), y: bytesToHex(fromB64url(jwk.y)) };
153
+ }
154
+
155
+ /** The issuer key identifier `bind` records: the RFC 7638 thumbprint as 32 bytes. */
156
+ export function issuerKeyId(jwk: P256Jwk): Hex {
157
+ return bytesToHex(jwkThumbprintBytes(jwk));
158
+ }
159
+
160
+ function decodeLayer(serialized: string, name: string, typ: string): SdJwt {
161
+ let sdJwt: SdJwt;
162
+ try {
163
+ sdJwt = decodeSdJwt(serialized);
164
+ } catch (error) {
165
+ return fail("MalformedLayer", `${name}: ${error instanceof Error ? error.message : String(error)}`);
166
+ }
167
+ if (sdJwt.header.alg !== "ES256" || sdJwt.header.typ !== typ) fail("MalformedLayer", `${name} header must be ES256 ${typ}`);
168
+ if (sdJwt.payload._sd_alg !== undefined && sdJwt.payload._sd_alg !== "sha-256") fail("MalformedLayer", `${name} _sd_alg must be sha-256`);
169
+ return sdJwt;
170
+ }
171
+
172
+ type Layers = { l1: SdJwt; l2: SdJwt };
173
+
174
+ function decodeLayers(c: Credential): Layers {
175
+ const l1 = decodeLayer(c.layer1, "layer1", "sd+jwt");
176
+ if (l1.payload.vct !== METAL_L1_VCT) fail("MalformedLayer", `layer1 vct must be Metal's profile, ${METAL_L1_VCT}`);
177
+ return { l1, l2: decodeLayer(c.layer2, "layer2", "kb-sd-jwt+kb") };
178
+ }
179
+
180
+ type Disclosed = { encoded: string; value: Json[] };
181
+
182
+ function disclosuresByHash(sdJwt: SdJwt, name: string): Map<string, Disclosed> {
183
+ const committed = new Set(Array.isArray(sdJwt.payload._sd) ? sdJwt.payload._sd : []);
184
+ const byHash = new Map<string, Disclosed>();
185
+ for (const encoded of sdJwt.disclosures) {
186
+ const hash = hashAscii(encoded);
187
+ if (!committed.has(hash)) fail("UndisclosedClaim", `${name} carries a disclosure absent from _sd`);
188
+ try {
189
+ byHash.set(hash, { encoded, value: decodeDisclosure(encoded) });
190
+ } catch (error) {
191
+ fail("MalformedLayer", `${name}: ${error instanceof Error ? error.message : String(error)}`);
192
+ }
193
+ }
194
+ return byHash;
195
+ }
196
+
197
+ /**
198
+ * The payment mandate's constraints Mandate Binding evaluates, each with the
199
+ * members it may carry. `frequency` in `agent_recurrence` only guides timing
200
+ * in VI and is kept as descriptive. Anything else (a `min`, a merchant-managed
201
+ * `recurrence`, an unknown type or member) would narrow the agent's authority
202
+ * in a way the chain does not enforce, so ingestion refuses the credential.
203
+ */
204
+ const EVALUABLE: Record<string, readonly string[]> = {
205
+ "mandate.payment.amount_range": ["type", "currency", "max"],
206
+ "mandate.payment.budget": ["type", "currency", "max"],
207
+ "mandate.payment.allowed_payees": ["type", "allowed"],
208
+ "mandate.payment.agent_recurrence": ["type", "frequency", "start_date", "end_date", "max_occurrences"],
209
+ "mandate.payment.reference": ["type", "conditional_transaction_id"],
210
+ };
211
+
212
+ const REQUIRED = ["mandate.payment.amount_range", "mandate.payment.budget", "mandate.payment.agent_recurrence"];
213
+
214
+ function paymentConstraints(payment: JsonObject): Map<string, JsonObject> {
215
+ const byType = new Map<string, JsonObject>();
216
+ for (const c of list(payment.constraints, "payment constraints")) {
217
+ const constraint = obj(c, "a payment constraint");
218
+ const type = typeof constraint.type === "string" ? constraint.type : fail("MalformedMandate", "a constraint has no type");
219
+ const members = EVALUABLE[type] ?? fail("Unenforceable", `${type} is not a constraint Mandate Binding evaluates`);
220
+ const extra = Object.keys(constraint).filter((k) => !members.includes(k));
221
+ if (extra.length > 0) fail("Unenforceable", `${type} carries ${extra.join(", ")}, which Mandate Binding does not evaluate`);
222
+ if (byType.has(type)) fail("MalformedMandate", `more than one ${type} constraint`);
223
+ byType.set(type, constraint);
224
+ }
225
+ for (const type of REQUIRED) if (!byType.has(type)) fail("MissingConstraint", `a standing allowance must state ${type}`);
226
+ return byType;
227
+ }
228
+
229
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
230
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
231
+
232
+ /** A recurrence date as inclusive Unix seconds under the UTC convention in the header comment. */
233
+ export function recurrenceBound(value: string, edge: "start" | "end"): bigint {
234
+ const date = DATE.exec(value);
235
+ if (date !== null) {
236
+ const ms = Date.UTC(Number(date[1]), Number(date[2]) - 1, Number(date[3]));
237
+ if (new Date(ms).toISOString().slice(0, 10) !== value) fail("MalformedMandate", `${value} is not a calendar date`);
238
+ return BigInt(ms / 1000 + (edge === "end" ? 86_399 : 0));
239
+ }
240
+ if (DATE_TIME.test(value)) {
241
+ // `Date.parse` moves a day past its month's end, or an hour of 24, to the
242
+ // next day; a bound is the instant the credential states or nothing.
243
+ const ms = Date.parse(value);
244
+ const [y, mo, d] = value.slice(0, 10).split("-").map(Number) as [number, number, number];
245
+ const sameDay = new Date(Date.UTC(y, mo - 1, d)).toISOString().slice(0, 10) === value.slice(0, 10);
246
+ if (Number.isNaN(ms) || !sameDay || Number(value.slice(11, 13)) > 23) fail("MalformedMandate", `${value} is not a date-time`);
247
+ return BigInt(edge === "start" ? Math.ceil(ms / 1000) : Math.floor(ms / 1000));
248
+ }
249
+ return fail("MalformedMandate", `agent_recurrence dates must be YYYY-MM-DD or an RFC 3339 date-time with an offset, not ${value}`);
250
+ }
251
+
252
+ /** The largest occurrence count, bound when `agent_recurrence` states no `max_occurrences`. */
253
+ export const UNCAPPED_OCCURRENCES = 2n ** 256n - 1n;
254
+
255
+ /** What a mandate binds from a credential, and what it keeps aside. */
256
+ export interface MandateTerms {
257
+ /** L1 `sub`: the principal's Metal account. */
258
+ principal: Address;
259
+ agentKey: AgentKey;
260
+ agentJwk: P256Jwk & { kid: string };
261
+ constraints: Constraints;
262
+ terms: Terms;
263
+ /** L2's base JWT, which every L3a `sd_hash` covers. */
264
+ l2Base: string;
265
+ /** The L2 payment mandate disclosure, as serialised. */
266
+ paymentDisclosure: string;
267
+ /** The L2 checkout mandate disclosure, as serialised. */
268
+ checkoutDisclosure: string;
269
+ /** SHA-256 of the L2 checkout view a payee is given (`base ~ checkout disclosure ~ item disclosures ~`): every signed offer's `sd_hash`. */
270
+ checkoutSdHash: Hex;
271
+ /** Each allowed payee's L2 disclosure, as serialised, by account. */
272
+ payeeDisclosures: ReadonlyMap<Address, string>;
273
+ /** Fields no chain enforces, kept for disputes. */
274
+ descriptive: Record<string, Json>;
275
+ }
276
+
277
+ /**
278
+ * The most bytes a payee object or the payment instrument may serialise to.
279
+ * Every payment rebuilds its L3a around them, and a payment the facilitator
280
+ * relays has 1,000,000 gas: a 537-byte payee object measured 824,960 gas for
281
+ * a direct payment on Metal, a 1 KB one 933,217 and a 4 KB one 1,494,925.
282
+ */
283
+ export const MAX_TERM_OBJECT_BYTES = 512;
284
+
285
+ function bounded(serialised: string, what: string): string {
286
+ const bytes = Buffer.byteLength(serialised, "utf8");
287
+ return bytes <= MAX_TERM_OBJECT_BYTES ? serialised : fail("MalformedMandate", `${what} is ${bytes} bytes; at most ${MAX_TERM_OBJECT_BYTES}, so every payment fits the facilitator's gas`);
288
+ }
289
+
290
+ // A payee object or instrument is written into every L3a as L2 serialises it.
291
+ // The reference writes each disclosure with json.dumps; requiring the decoded
292
+ // disclosure to re-serialise to the same bytes makes the compact form of any
293
+ // object inside it the reference's bytes for that object.
294
+ function referenceSerialised(d: Disclosed, what: string): void {
295
+ if (compactJson(d.value) !== Buffer.from(fromB64url(d.encoded)).toString("utf8")) {
296
+ fail("MalformedMandate", `${what} is not serialised as the Verifiable Intent reference serialises it`);
297
+ }
298
+ }
299
+
300
+ function resolveTerms({ l1, l2 }: Layers, c: Credential): MandateTerms {
301
+ const l2Disclosures = disclosuresByHash(l2, "layer2");
302
+ const refHash = (ref: Json): string => {
303
+ const hash = obj(ref, "disclosure reference")["..."];
304
+ return typeof hash === "string" ? hash : fail("MalformedMandate", 'a disclosure reference must be {"...": hash}');
305
+ };
306
+ const resolve = (ref: Json): Disclosed => {
307
+ const entry = l2Disclosures.get(refHash(ref)) ?? fail("UndisclosedClaim", "a disclosure reference does not resolve");
308
+ if (entry.value.length !== 2) fail("MalformedMandate", "a referenced disclosure must be an array element");
309
+ return entry;
310
+ };
311
+ const valueOf = (d: Disclosed, what: string) => obj(d.value[1], what);
312
+
313
+ const delegates = list(l2.payload.delegate_payload, "delegate_payload");
314
+ if (delegates.length !== 2) fail("MalformedMandate", "delegate_payload must reference one checkout and one payment mandate");
315
+ const byVct = new Map(
316
+ delegates.map((ref) => {
317
+ const d = resolve(ref);
318
+ return [valueOf(d, "a delegated mandate").vct, { hash: refHash(ref), d }];
319
+ }),
320
+ );
321
+ const checkout = byVct.get(CHECKOUT_VCT) ?? fail("MalformedMandate", `delegate_payload lacks ${CHECKOUT_VCT}`);
322
+ const payment = byVct.get(PAYMENT_VCT) ?? fail("MalformedMandate", `delegate_payload lacks ${PAYMENT_VCT}`);
323
+ const checkoutMandate = valueOf(checkout.d, "the checkout mandate");
324
+ const paymentMandate = valueOf(payment.d, "the payment mandate");
325
+ referenceSerialised(payment.d, "the payment mandate disclosure");
326
+
327
+ const constraints = paymentConstraints(paymentMandate);
328
+ const get = (type: string) => constraints.get(type) as JsonObject;
329
+ if (str(get("mandate.payment.reference") ?? fail("MalformedMandate", "no mandate.payment.reference constraint"), "conditional_transaction_id") !== checkout.hash) {
330
+ fail("MalformedMandate", "the payment mandate's reference does not name the checkout mandate");
331
+ }
332
+
333
+ // A payment mandate without allowed_payees lets the agent pay any address;
334
+ // an empty list is not that (the format forbids reading it so), it is a mandate that pays nobody.
335
+ const payeeDisclosures = new Map<Address, string>();
336
+ const allowedPayees = get("mandate.payment.allowed_payees") as JsonObject | undefined;
337
+ const payees = (allowedPayees === undefined ? [] : list(allowedPayees.allowed, "allowed_payees")).map((ref) => {
338
+ const d = resolve(ref);
339
+ referenceSerialised(d, "a payee disclosure");
340
+ const object = valueOf(d, "a payee");
341
+ const account = address(str(object, "id"), "a payee id");
342
+ if (payeeDisclosures.has(account)) fail("MalformedMandate", `allowed_payees names ${account} twice`);
343
+ payeeDisclosures.set(account, d.encoded);
344
+ const serialised = bounded(compactJson(object), `the payee object for ${account}`);
345
+ if (!namesAddress(serialised, account)) fail("MalformedMandate", `the payee object for ${account} must name it in exactly one id member`);
346
+ return { account, object: serialised };
347
+ });
348
+ if (allowedPayees !== undefined && payees.length === 0) fail("MalformedMandate", "allowed_payees is empty");
349
+
350
+ const checkoutJwk = obj(obj(checkoutMandate.cnf, "checkout cnf").jwk, "checkout cnf.jwk");
351
+ const paymentJwk = obj(obj(paymentMandate.cnf, "payment cnf").jwk, "payment cnf.jwk");
352
+ const agentJwk = { ...p256Jwk(paymentJwk, "payment cnf.jwk", "MalformedMandate"), kid: str(paymentJwk, "kid") };
353
+ if (agentJwk.kid !== jwkThumbprint(agentJwk)) fail("MalformedMandate", "the mandate key's kid is not its RFC 7638 thumbprint");
354
+ if (jcs(checkoutJwk) !== jcs(paymentJwk)) fail("MalformedMandate", "the checkout and payment mandates name different mandate keys");
355
+ if (l2.payload.aud !== agentJwk.kid) fail("MalformedMandate", "layer2 aud is not the mandate key's kid");
356
+ if (/["\\]|[^\x20-\x7e]/.test(agentJwk.kid)) fail("MalformedMandate", "the mandate key's kid is not printable ASCII");
357
+
358
+ const instrumentObject = obj(paymentMandate.payment_instrument, "payment_instrument");
359
+ if (instrumentObject.type !== INSTRUMENT_TYPE) fail("MalformedMandate", `payment_instrument.type must be ${INSTRUMENT_TYPE}`);
360
+ const token = address(str(instrumentObject, "id"), "payment_instrument.id");
361
+ const instrument = INSTRUMENTS.find((i) => i.token.toLowerCase() === token.toLowerCase()) ?? fail("MalformedMandate", `payment_instrument.id ${token} is not a known token`);
362
+ if (!namesAddress(bounded(compactJson(instrumentObject), "payment_instrument"), token)) fail("MalformedMandate", "payment_instrument must name its token in exactly one id member");
363
+ const amountRange = get("mandate.payment.amount_range");
364
+ const budget = get("mandate.payment.budget");
365
+ for (const [name, c] of [["amount_range", amountRange], ["budget", budget]] as const) {
366
+ if (c.currency !== instrument.currency) fail("MalformedMandate", `${name} currency must be ${instrument.currency}`);
367
+ }
368
+ const recurrence = get("mandate.payment.agent_recurrence");
369
+ const recurrenceStart = recurrenceBound(str(recurrence, "start_date"), "start");
370
+ const recurrenceEnd = recurrenceBound(str(recurrence, "end_date"), "end");
371
+ if (recurrenceStart > recurrenceEnd) fail("MalformedMandate", "agent_recurrence ends before it starts");
372
+ const maxOccurrences = recurrence.max_occurrences === undefined ? UNCAPPED_OCCURRENCES : positive(recurrence, "max_occurrences", "agent_recurrence");
373
+
374
+ const iat = int(l2.payload, "iat", "MalformedLayer");
375
+ const exp = int(l2.payload, "exp", "MalformedLayer");
376
+ if (exp <= iat) fail("MalformedLayer", "layer2 exp must follow iat");
377
+ const principal = address(str(l1.payload, "sub", "MalformedLayer"), "layer1 sub");
378
+ const l2Base = c.layer2.split("~")[0] as string;
379
+ const paymentDisclosure = payment.d.encoded;
380
+
381
+ const itemsOf = (c: Json) => (isObject(c) && c.type === "mandate.checkout.line_items" ? c.items ?? null : null);
382
+ // The checkout view: the checkout disclosure, then each acceptable item the
383
+ // checkout mandate references by disclosure, in its own order.
384
+ const itemDisclosures: string[] = [];
385
+ for (const c of list(checkoutMandate.constraints ?? [], "checkout constraints")) {
386
+ for (const entry of list(itemsOf(c) ?? [], "line items")) {
387
+ for (const ref of list(obj(entry, "a line item").acceptable_items ?? [], "acceptable_items")) {
388
+ if (isObject(ref) && typeof ref["..."] === "string") itemDisclosures.push(resolve(ref).encoded);
389
+ }
390
+ }
391
+ }
392
+ const checkoutDisclosure = checkout.d.encoded;
393
+ const checkoutSdHash = sha256Hex(Buffer.from([l2Base, checkoutDisclosure, ...itemDisclosures].join("~") + "~", "ascii"));
394
+ return {
395
+ principal,
396
+ agentKey: agentKeyOfJwk(agentJwk),
397
+ agentJwk,
398
+ constraints: {
399
+ token: instrument.token,
400
+ perPaymentCap: positive(amountRange, "max", "amount_range") * instrument.unitsPerMinor,
401
+ budget: positive(budget, "max", "budget") * instrument.unitsPerMinor,
402
+ expiry: BigInt(Math.min(exp, int(l1.payload, "exp", "MalformedLayer"))),
403
+ recurrenceStart,
404
+ recurrenceEnd,
405
+ maxOccurrences,
406
+ },
407
+ terms: {
408
+ kid: agentJwk.kid,
409
+ instrument: compactJson(instrumentObject),
410
+ currency: instrument.currency,
411
+ unitsPerMinor: instrument.unitsPerMinor,
412
+ payees: payees.map(({ account, object }) => ({
413
+ account,
414
+ object,
415
+ sdHash: sha256Hex(Buffer.from(`${l2Base}~${paymentDisclosure}~${payeeDisclosures.get(account)}~`, "ascii")),
416
+ })),
417
+ openSdHash: allowedPayees === undefined ? sha256Hex(Buffer.from(`${l2Base}~${paymentDisclosure}~`, "ascii")) : ZERO_HASH,
418
+ },
419
+ l2Base,
420
+ paymentDisclosure,
421
+ checkoutDisclosure,
422
+ checkoutSdHash,
423
+ payeeDisclosures,
424
+ descriptive: {
425
+ issuer: l1.payload.iss ?? null,
426
+ promptSummary: checkoutMandate.prompt_summary ?? null,
427
+ lineItems: list(checkoutMandate.constraints ?? [], "checkout constraints").map(itemsOf).find((i) => i !== null) ?? null,
428
+ frequency: recurrence.frequency ?? null,
429
+ instrument: instrumentObject,
430
+ payees: payees.map(({ object }) => JSON.parse(object) as Json),
431
+ issuedAt: iat,
432
+ },
433
+ };
434
+ }
435
+
436
+ /**
437
+ * The terms a credential states, without verifying its signatures: the
438
+ * principal, the agent key, the constraints Mandate Binding enforces and the
439
+ * rebuild terms. Throws `CredentialError` naming the first failure.
440
+ */
441
+ export function extractTerms(c: Credential): MandateTerms {
442
+ return resolveTerms(decodeLayers(c), c);
443
+ }
444
+
445
+ /**
446
+ * Verifies a credential off chain as the VI reference does under Metal's L1
447
+ * profile: L1 under a trusted issuer's key (its `iss` and header `kid` select
448
+ * the key), L1's `sub` the account being bound, L2 under the principal key L1
449
+ * binds, L2's `sd_hash` binding L1, both layers valid at `now`, and terms
450
+ * Mandate Binding can enforce. Returns the issuer and the terms.
451
+ */
452
+ export async function verifyCredential(
453
+ c: Credential,
454
+ opts: { trustedIssuers: readonly TrustedIssuer[]; account: Address; now?: number },
455
+ ): Promise<{ issuer: TrustedIssuer; terms: MandateTerms }> {
456
+ const now = opts.now ?? Math.floor(Date.now() / 1000);
457
+ const layers = decodeLayers(c);
458
+ const { l1, l2 } = layers;
459
+ const iss = str(l1.payload, "iss", "MalformedLayer");
460
+ const kid = l1.header.kid;
461
+ const issuer = opts.trustedIssuers.find((i) => i.iss === iss && i.kid === kid) ?? fail("UntrustedIssuer", `no trusted issuer ${iss} with key ${String(kid)}`);
462
+ if (!(await verifySdJwtSignature(l1, issuer.jwk))) fail("IssuerSignature", "layer1 does not verify under the issuer key");
463
+ const principalJwk = p256Jwk(obj(l1.payload.cnf, "layer1 cnf", "MalformedLayer").jwk, "layer1 cnf.jwk", "MalformedLayer");
464
+ if (!(await verifySdJwtSignature(l2, principalJwk))) fail("PrincipalSignature", "layer2 does not verify under layer1 cnf.jwk");
465
+ if (str(l2.payload, "sd_hash", "SdHash") !== hashAscii(c.layer1)) fail("SdHash", "layer2 sd_hash does not bind layer1");
466
+ for (const [name, payload] of [["layer1", l1.payload], ["layer2", l2.payload]] as const) {
467
+ if (now > int(payload, "exp", "MalformedLayer")) fail("Expired", `${name} expired`);
468
+ if (now < int(payload, "iat", "MalformedLayer")) fail("NotYetValid", `${name} issued in the future`);
469
+ }
470
+ const terms = resolveTerms(layers, c);
471
+ if (terms.principal.toLowerCase() !== opts.account.toLowerCase()) fail("WrongAccount", `layer1 sub ${terms.principal} is not the account ${opts.account}`);
472
+ // What binding refuses as a mandate nothing could ever be paid under.
473
+ const { recurrenceStart, recurrenceEnd, expiry } = terms.constraints;
474
+ if (recurrenceStart > expiry) fail("MalformedMandate", "agent_recurrence starts after the mandate expires");
475
+ if (recurrenceEnd < BigInt(now)) fail("Expired", "the agent_recurrence window has ended");
476
+ return { issuer, terms };
477
+ }
@@ -0,0 +1,118 @@
1
+ // Issuing a Verifiable Intent v0.1 credential under Metal's L1 profile, laid
2
+ // out as the VI reference lays out an autonomous-mode credential: the
3
+ // developer path's self-issued credential, and the test fixtures. A PSP
4
+ // issuing for its customers produces the same layers with its own keys.
5
+
6
+ import { randomBytes } from "node:crypto";
7
+ import { type Address, getAddress } from "viem";
8
+ import type { Json, JsonObject } from "../jcs.js";
9
+ import { METAL_L1_VCT, CHECKOUT_VCT, type Credential, INSTRUMENTS, INSTRUMENT_TYPE, PAYMENT_VCT, type TrustedIssuer } from "./credential.js";
10
+ import { type Es256Key, type P256Jwk, b64url, createDisclosure, hashAscii, jwkThumbprint, signSdJwt } from "./sd-jwt.js";
11
+
12
+ export interface CredentialParams {
13
+ /** The issuer's `iss` and signing key; on the developer path, the developer's own. */
14
+ issuer: { iss: string; key: Es256Key };
15
+ /** The principal's Metal account: L1 `sub`, and the account the mandate binds on. */
16
+ principal: Address;
17
+ /** The principal's device key, which signs L2. */
18
+ principalKey: Es256Key;
19
+ agentKey: P256Jwk;
20
+ /** Decimal amounts in the instrument's currency, e.g. "10.00". */
21
+ perPaymentCap: string;
22
+ budget: string;
23
+ /** The addresses the agent may pay; none lets it pay any address within the other limits. */
24
+ payees: readonly { address: Address; name: string; website: string }[];
25
+ /** `agent_recurrence`: dates as `YYYY-MM-DD` (UTC days) or RFC 3339 date-times with an offset. */
26
+ recurrence: { frequency: string; startDate: string; endDate: string; maxOccurrences?: number };
27
+ expiresAt: number;
28
+ issuedAt?: number;
29
+ description: string;
30
+ }
31
+
32
+ export function trustedIssuer(issuer: CredentialParams["issuer"]): TrustedIssuer {
33
+ return { iss: issuer.iss, kid: issuer.key.kid, jwk: issuer.key.publicJwk };
34
+ }
35
+
36
+ // "10.00" to integer minor units; more fractional digits than the currency has is an error.
37
+ function minorUnits(value: string, what: string): number {
38
+ const digits = 2;
39
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(value);
40
+ if (match === null || (match[2] ?? "").length > digits) throw new Error(`${what} must be a decimal amount with at most ${digits} decimals`);
41
+ const units = Number(match[1]) * 10 ** digits + Number((match[2] ?? "").padEnd(digits, "0") || "0");
42
+ if (!Number.isSafeInteger(units) || units <= 0) throw new Error(`${what} must be positive`);
43
+ return units;
44
+ }
45
+
46
+ export async function createCredential(params: CredentialParams): Promise<Credential> {
47
+ const iat = params.issuedAt ?? Math.floor(Date.now() / 1000);
48
+ if (params.expiresAt <= iat) throw new Error("expiresAt must follow issuedAt");
49
+ const [instrument] = INSTRUMENTS;
50
+ const { kty, crv, x, y } = params.agentKey;
51
+ const jwk = { kty, crv, x, y, kid: jwkThumbprint(params.agentKey) };
52
+ const subject = getAddress(params.principal);
53
+
54
+ const layer1 = await signSdJwt(
55
+ { alg: "ES256", typ: "sd+jwt", kid: params.issuer.key.kid },
56
+ { iss: params.issuer.iss, sub: subject, iat, exp: params.expiresAt, vct: METAL_L1_VCT, cnf: { jwk: params.principalKey.publicJwk }, _sd: [], _sd_alg: "sha-256" },
57
+ [],
58
+ params.issuer.key.privateJwk,
59
+ );
60
+
61
+ const merchantDisclosures = params.payees.map((p) => createDisclosure({ id: getAddress(p.address), name: p.name, website: p.website }));
62
+ const merchantRefs: Json[] = merchantDisclosures.map((d) => ({ "...": hashAscii(d) }));
63
+ // A mandate with no payees omits allowed_merchants and allowed_payees: the format reads an empty
64
+ // allowlist as nobody, and an absent one as anyone.
65
+ const listed = merchantRefs.length > 0;
66
+ const checkout: JsonObject = {
67
+ vct: CHECKOUT_VCT,
68
+ cnf: { jwk },
69
+ constraints: [
70
+ ...(listed ? [{ type: "mandate.checkout.allowed_merchants", allowed: merchantRefs }] : []),
71
+ // The reference requires a line_items constraint on every open checkout mandate; an empty
72
+ // acceptable_items list is the spec's wildcard, so any item from the allowed payees.
73
+ { type: "mandate.checkout.line_items", items: [{ id: "line-1", acceptable_items: [], quantity: 1 }], match_mode: "minimum" },
74
+ ],
75
+ prompt_summary: params.description,
76
+ };
77
+ const checkoutDisclosure = createDisclosure(checkout);
78
+ const { frequency, startDate, endDate, maxOccurrences } = params.recurrence;
79
+ const payment: JsonObject = {
80
+ vct: PAYMENT_VCT,
81
+ cnf: { jwk },
82
+ constraints: [
83
+ { type: "mandate.payment.amount_range", currency: instrument.currency, max: minorUnits(params.perPaymentCap, "perPaymentCap") },
84
+ ...(listed ? [{ type: "mandate.payment.allowed_payees", allowed: merchantRefs }] : []),
85
+ { type: "mandate.payment.budget", currency: instrument.currency, max: minorUnits(params.budget, "budget") },
86
+ {
87
+ type: "mandate.payment.agent_recurrence",
88
+ frequency,
89
+ start_date: startDate,
90
+ end_date: endDate,
91
+ ...(maxOccurrences === undefined ? {} : { max_occurrences: maxOccurrences }),
92
+ },
93
+ { type: "mandate.payment.reference", conditional_transaction_id: hashAscii(checkoutDisclosure) },
94
+ ],
95
+ payment_instrument: { type: INSTRUMENT_TYPE, id: instrument.token, description: "*USDZ on Metal" },
96
+ };
97
+ const paymentDisclosure = createDisclosure(payment);
98
+ const l2Disclosures = [...merchantDisclosures, checkoutDisclosure, paymentDisclosure];
99
+
100
+ const layer2 = await signSdJwt(
101
+ { alg: "ES256", typ: "kb-sd-jwt+kb", kid: params.principalKey.kid },
102
+ {
103
+ nonce: b64url(randomBytes(16)),
104
+ aud: jwk.kid,
105
+ iat,
106
+ sd_hash: hashAscii(layer1),
107
+ delegate_payload: [{ "...": hashAscii(checkoutDisclosure) }, { "...": hashAscii(paymentDisclosure) }],
108
+ _sd_alg: "sha-256",
109
+ _sd: l2Disclosures.map(hashAscii),
110
+ iss: subject,
111
+ exp: params.expiresAt,
112
+ },
113
+ l2Disclosures,
114
+ params.principalKey.privateJwk,
115
+ );
116
+
117
+ return { format: "verifiable-intent", version: "0.1", layer1, layer2 };
118
+ }