@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,121 @@
1
+ export { ACCOUNT_BATCH_MODE, assertMetalAccount, authorizeMandateCall, metalAccountAbi, metalAccountCode, metalAccountFactoryAbi } from "./account.js";
2
+ export {
3
+ FEE_PER_GAS,
4
+ MANDATE_SIGNATURE_TAG,
5
+ BINDING_GAS_PER_BYTE,
6
+ MANDATE_GAS,
7
+ REASONS,
8
+ REFUSALS,
9
+ bindCall,
10
+ decodeAgentKey,
11
+ describeRefusal,
12
+ encodeAgentKey,
13
+ mandateBindingAbi,
14
+ mandateReference,
15
+ pullCall,
16
+ reasonName,
17
+ referencePrefix,
18
+ refusalOf,
19
+ replaceCall,
20
+ revokeCall,
21
+ sameAgentKey,
22
+ type AgentKey,
23
+ type Binding,
24
+ type Constraints,
25
+ type L3aFields,
26
+ type MandateCall,
27
+ type Payee,
28
+ type Pull,
29
+ type ReasonName,
30
+ type Terms,
31
+ } from "./binding.js";
32
+ export { publicMessage, readBoundAgentKey, readCheck, readMandateState, redactUrls, sendMandateCall } from "./chain.js";
33
+ export {
34
+ buildCheckoutJwt,
35
+ buildL3b,
36
+ checkoutHash,
37
+ lineItemOf,
38
+ readCheckoutJwt,
39
+ readL3b,
40
+ serializeL3b,
41
+ signCheckoutJwt,
42
+ signL3b,
43
+ signPayment,
44
+ verifyCheckoutMandate,
45
+ x402Offer,
46
+ type CheckoutExpectation,
47
+ type CheckoutJwt,
48
+ type L3bFields,
49
+ type ReadL3b,
50
+ type SignedL3b,
51
+ type SignedPayment,
52
+ type UnsignedL3b,
53
+ } from "./checkout.js";
54
+ export { MANDATE_EXTENSION_KEY, SIGNED_OFFER_REQUIRED, asksSignedOffer, mandateExtension, mandateRefOf, signedOfferOf, type MandateExtension } from "./extension.js";
55
+ export { requireSignedOffer, type RequireSignedOfferConfig } from "./seller.js";
56
+ export {
57
+ MANDATE_MAX_VALIDITY_SECONDS,
58
+ MANDATE_VALID_AFTER_LEAD_SECONDS,
59
+ MandateExactScheme,
60
+ createMandatePaymentClient,
61
+ type MandatePaymentClient,
62
+ type MandatePaymentClientConfig,
63
+ type MandateSchemeConfig,
64
+ } from "./client.js";
65
+ export { ingestCredential, type Ingested } from "./ingest.js";
66
+ export { jcs, type Json, type JsonObject } from "./jcs.js";
67
+ export {
68
+ buildL3a,
69
+ decodeMandateBlob,
70
+ encodeMandateBlob,
71
+ jsonString,
72
+ lowS,
73
+ mandateNonce,
74
+ namesAddress,
75
+ serializeL3a,
76
+ signL3a,
77
+ transactionIdOf,
78
+ verifyL3aSignature,
79
+ type SignedL3a,
80
+ type UnsignedL3a,
81
+ } from "./l3a.js";
82
+ export {
83
+ STEP_TITLES,
84
+ verifyMandate,
85
+ verifyMandatePayment,
86
+ type BoundMandate,
87
+ type MandateVerification,
88
+ type VerifiedPayment,
89
+ type VerifyMandateOptions,
90
+ type VerifyStep,
91
+ } from "./verify.js";
92
+ export {
93
+ CredentialError,
94
+ INSTRUMENTS,
95
+ METAL_L1_VCT,
96
+ MAX_TERM_OBJECT_BYTES,
97
+ UNCAPPED_OCCURRENCES,
98
+ agentKeyOfJwk,
99
+ extractTerms,
100
+ hashCredential,
101
+ issuerKeyId,
102
+ parseCredential,
103
+ recurrenceBound,
104
+ verifyCredential,
105
+ type Credential,
106
+ type CredentialFailure,
107
+ type MandateTerms,
108
+ type TrustedIssuer,
109
+ } from "./vi/credential.js";
110
+ export { createCredential, trustedIssuer, type CredentialParams } from "./vi/issue.js";
111
+ export {
112
+ createEs256Key,
113
+ jwkThumbprint,
114
+ p256DigestSigner,
115
+ parseP256Jwk,
116
+ privateKeyHex,
117
+ type Es256Key,
118
+ type P256Jwk,
119
+ type P256PrivateJwk,
120
+ type SignDigest,
121
+ } from "./vi/sd-jwt.js";
@@ -0,0 +1,42 @@
1
+ // Mandate Ingestion: a Verifiable Intent credential verified off chain
2
+ // against Metal's L1 profile and the caller's trusted issuers, its terms
3
+ // extracted and hashed into what `bindCall` and `replaceCall` take.
4
+
5
+ import type { Address, Hex } from "viem";
6
+ import { type Binding, mandateReference } from "./binding.js";
7
+ import { type Credential, type MandateTerms, type TrustedIssuer, hashCredential, issuerKeyId, verifyCredential } from "./vi/credential.js";
8
+
9
+ export interface Ingested extends Binding {
10
+ credential: Credential;
11
+ /** The trusted issuer that attested it. */
12
+ issuer: TrustedIssuer;
13
+ /** The mandate reference binding will derive. */
14
+ reference: Hex;
15
+ /** Everything extracted, the descriptive fields among it. */
16
+ extracted: MandateTerms;
17
+ }
18
+
19
+ /**
20
+ * Verifies `credential` for binding on `account` against the trusted issuers
21
+ * at `now`, and extracts what the mandate binds. Throws `CredentialError`
22
+ * naming the failure; no binding is built for a credential that fails.
23
+ */
24
+ export async function ingestCredential(
25
+ credential: Credential,
26
+ opts: { trustedIssuers: readonly TrustedIssuer[]; account: Address; now?: number },
27
+ ): Promise<Ingested> {
28
+ const { issuer, terms } = await verifyCredential(credential, opts);
29
+ const hashC = hashCredential(credential);
30
+ return {
31
+ credential,
32
+ issuer,
33
+ principal: terms.principal,
34
+ hashC,
35
+ issuerKey: issuerKeyId(issuer.jwk),
36
+ agentKey: terms.agentKey,
37
+ constraints: terms.constraints,
38
+ terms: terms.terms,
39
+ reference: mandateReference(terms.principal, terms.agentKey, hashC),
40
+ extracted: terms,
41
+ };
42
+ }
@@ -0,0 +1,16 @@
1
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
2
+ export type JsonObject = { [key: string]: Json };
3
+
4
+ /**
5
+ * RFC 8785. Numbers use ECMAScript Number serialization and keys sort by
6
+ * UTF-16 code units, both of which the RFC defines in terms of
7
+ * `JSON.stringify` and the default string ordering.
8
+ */
9
+ export function jcs(value: Json): string {
10
+ if (typeof value === "number" && !Number.isFinite(value)) throw new Error("JCS cannot encode a non-finite number");
11
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
12
+ if (typeof value !== "object") throw new Error(`JCS cannot encode ${typeof value}`);
13
+ if (Array.isArray(value)) return `[${value.map(jcs).join(",")}]`;
14
+ const keys = Object.keys(value).sort();
15
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${jcs(value[k] as Json)}`).join(",")}}`;
16
+ }
@@ -0,0 +1,216 @@
1
+ // The per-payment mandate: a Verifiable Intent L3a in the profile Mandate
2
+ // Binding rebuilds, serialised byte for byte as the VI reference serialises
3
+ // it (compact JSON in the reference's key order, the L2 payee object
4
+ // verbatim), with `aud` the chain as `eip155:<chainId>`, `iat`/`exp` the
5
+ // ERC-3009 window, and `transaction_id` the SHA-256 of the RFC 8785 form of
6
+ // the seller's x402 payment requirements. The ERC-3009 nonce is
7
+ // `bytes12(mandate reference) ‖ bytes20(SHA-256(signing input))`; the x402
8
+ // payload's signature is `MNDT || abi.encode(Pull, L3a fields)`, which the
9
+ // facilitator decodes into `pull`'s arguments.
10
+
11
+ import { p256 } from "@noble/curves/nist";
12
+ import {
13
+ type Address,
14
+ type Hex,
15
+ bytesToHex,
16
+ concat,
17
+ decodeAbiParameters,
18
+ encodeAbiParameters,
19
+ getAbiItem,
20
+ hexToBigInt,
21
+ hexToBytes,
22
+ isAddressEqual,
23
+ numberToHex,
24
+ size,
25
+ slice,
26
+ zeroAddress,
27
+ } from "viem";
28
+ import { type AgentKey, type L3aFields, type Pull, type Terms, mandateBindingAbi, referencePrefix } from "./binding.js";
29
+ import { MANDATE_SIGNATURE_TAG } from "./binding.js";
30
+ import { type Json, jcs } from "./jcs.js";
31
+ import { type SignDigest, b64Ascii, b64url, hashAscii, sha256Hex } from "./vi/sd-jwt.js";
32
+
33
+ const P256_N: bigint = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
34
+ const P256_HALF_N = P256_N / 2n;
35
+
36
+ /** SHA-256 of the RFC 8785 form of the seller's x402 payment requirements, the L3a's `transaction_id`. */
37
+ export function transactionIdOf(requirements: Json): Hex {
38
+ return sha256Hex(Buffer.from(jcs(requirements), "utf8"));
39
+ }
40
+
41
+ /** A JSON string's contents as the reference writes them unescaped: printable ASCII other than `"` and `\`. */
42
+ export function jsonString(value: string, what: string): string {
43
+ if (value.length === 0 || !/^[\x20-\x7e]*$/.test(value) || /["\\]/.test(value)) {
44
+ throw new Error(`${what} must be printable ASCII without " or \\`);
45
+ }
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * Whether a compact JSON object has exactly one `"id":` member, the string
51
+ * `0x` and `account`'s 40 hex digits, and no `\u00XX` escape of a printable
52
+ * ASCII character (which could hide a second `id`), as Mandate Binding reads it.
53
+ */
54
+ export function namesAddress(object: string, account: Address): boolean {
55
+ if (/\\u00[2-7]/.test(object) || /"id"\s+:/.test(object)) return false;
56
+ const members = object.split('"id":');
57
+ if (members.length !== 2) return false;
58
+ const match = /^"(0x[0-9a-fA-F]{40})"/.exec(members[1] as string);
59
+ return match !== null && isAddressEqual(match[1] as Address, account);
60
+ }
61
+
62
+ function b64(hex: Hex): string {
63
+ return b64url(hexToBytes(hex));
64
+ }
65
+
66
+ /** An L3a without its signature: the parts of its serialisation, and the digest the agent signs and the nonce carries. */
67
+ export interface UnsignedL3a {
68
+ signingInput: string;
69
+ merchantDisclosure: string;
70
+ paymentDisclosure: string;
71
+ /** SHA-256 of the signing input. */
72
+ digest: Hex;
73
+ }
74
+
75
+ /** The payee object and `sd_hash` an L3a to `to` carries, as Mandate Binding resolves them; undefined when it cannot be for `to`. */
76
+ function payeeTerms(terms: Terms, to: Address, fields: Pick<L3aFields, "payee" | "linkedPayee">) {
77
+ const bound = terms.payees.find((p) => isAddressEqual(p.account, to));
78
+ if (bound !== undefined) return { object: bound.object, sdHash: bound.sdHash, outside: false };
79
+ // The agent's own object opens with its id, so the one id member a parser reads is the one the chain checked.
80
+ if (!fields.payee.startsWith('{"id":"') || !namesAddress(fields.payee, to)) return undefined;
81
+ if (hexToBigInt(terms.openSdHash) !== 0n) return { object: fields.payee, sdHash: terms.openSdHash, outside: false };
82
+ const linked = terms.payees.find((p) => isAddressEqual(p.account, fields.linkedPayee));
83
+ return linked === undefined ? undefined : { object: fields.payee, sdHash: linked.sdHash, outside: true };
84
+ }
85
+
86
+ /**
87
+ * The L3a for the transfer `pull` under a mandate's rebuild `terms`, as
88
+ * Mandate Binding rebuilds it on `chainId`. Throws where Mandate Binding
89
+ * would find no L3a (a value that is not whole minor units, unwritable
90
+ * strings, a payee it cannot resolve).
91
+ */
92
+ export function buildL3a(
93
+ chainId: number,
94
+ terms: Terms,
95
+ pull: Pick<Pull, "to" | "value" | "validAfter" | "validBefore">,
96
+ fields: Omit<L3aFields, "r" | "s">,
97
+ ): UnsignedL3a {
98
+ if (pull.value % terms.unitsPerMinor !== 0n) throw new Error(`${pull.value} is not a whole number of ${terms.currency} minor units`);
99
+ if (size(fields.merchantSalt) !== 16 || size(fields.paymentSalt) !== 16 || size(fields.transactionId) !== 32) {
100
+ throw new Error("salts are 16 bytes and transaction_id 32");
101
+ }
102
+ const payee = payeeTerms(terms, pull.to, fields);
103
+ if (payee === undefined) throw new Error(`no payee terms for ${pull.to}`);
104
+ const header = `{"alg":"ES256","typ":"kb-sd-jwt","kid":"${jsonString(terms.kid, "kid")}"}`;
105
+ const merchantDisclosure = b64Ascii(`["${b64(fields.merchantSalt)}",${payee.object}]`);
106
+ const paymentDisclosure = b64Ascii(
107
+ `["${b64(fields.paymentSalt)}",{"vct":"mandate.payment.1","transaction_id":"${b64(fields.transactionId)}",` +
108
+ `"payee":${payee.object},"payment_amount":{"currency":"${jsonString(terms.currency, "currency")}",` +
109
+ `"amount":${pull.value / terms.unitsPerMinor}},"payment_instrument":${terms.instrument}}]`,
110
+ );
111
+ const payload =
112
+ `{"nonce":"${jsonString(fields.nonce, "nonce")}","aud":"eip155:${chainId}","sd_hash":"${b64(payee.sdHash)}",` +
113
+ `"iat":${pull.validAfter},"delegate_payload":[{"...":"${hashAscii(merchantDisclosure)}"},{"...":"${hashAscii(paymentDisclosure)}"}],` +
114
+ `"_sd_alg":"sha-256","iss":"${jsonString(fields.iss, "iss")}","exp":${pull.validBefore}}`;
115
+ const signingInput = `${b64Ascii(header)}.${b64Ascii(payload)}`;
116
+ return { signingInput, merchantDisclosure, paymentDisclosure, digest: sha256Hex(Buffer.from(signingInput, "ascii")) };
117
+ }
118
+
119
+ /** `bytes12(mandate reference) ‖ bytes20(digest)`. */
120
+ export function mandateNonce(ref: Hex, digest: Hex): Hex {
121
+ return concat([referencePrefix(ref), slice(digest, 0, 20)]).toLowerCase() as Hex;
122
+ }
123
+
124
+ /** The L3a's SD-JWT serialisation: `header.payload.signature~merchant disclosure~payment disclosure~`. */
125
+ export function serializeL3a(l3a: UnsignedL3a, r: Hex, s: Hex): string {
126
+ return `${l3a.signingInput}.${b64url(hexToBytes(concat([r, s])))}~${l3a.merchantDisclosure}~${l3a.paymentDisclosure}~`;
127
+ }
128
+
129
+ /** `s` mapped into the lower half of the curve order, the one encoding Mandate Binding accepts. */
130
+ export function lowS(s: Hex): Hex {
131
+ const value = hexToBigInt(s);
132
+ return numberToHex(value > P256_HALF_N ? P256_N - value : value, { size: 32 });
133
+ }
134
+
135
+ /** Whether `r ‖ s` is the agent key's low-s ES256 signature over the L3a whose signing digest is `digest`, as the precompile checks it. */
136
+ export function verifyL3aSignature(key: AgentKey, digest: Hex, r: Hex, s: Hex): boolean {
137
+ if (hexToBigInt(s) > P256_HALF_N) return false;
138
+ try {
139
+ return p256.verify(hexToBytes(concat([r, s])), hexToBytes(digest), hexToBytes(concat(["0x04", key.x, key.y])), { prehash: false, lowS: true });
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /** A per-payment mandate, signed, with the authorisation it commits to. */
146
+ export interface SignedL3a {
147
+ pull: Pull;
148
+ fields: L3aFields;
149
+ /** The L3a SD-JWT as the VI reference serialises it. */
150
+ serialized: string;
151
+ digest: Hex;
152
+ }
153
+
154
+ /**
155
+ * Builds and signs the agent's per-payment mandate for a transfer under a
156
+ * mandate: `ref` and `token` are the mandate's; a `to` the mandate does not
157
+ * list gets a payee object naming it (its CAIP-10 account as `website`),
158
+ * which under a mandate that names no payee is the payment's own object, and
159
+ * under one that does is linked through the first allowed payee so the chain
160
+ * can record its refusal. `s` is normalised whatever `signDigest` returns.
161
+ */
162
+ export async function signL3a(args: {
163
+ chainId: number;
164
+ ref: Hex;
165
+ token: Address;
166
+ terms: Terms;
167
+ to: Address;
168
+ value: bigint;
169
+ validAfter: bigint;
170
+ validBefore: bigint;
171
+ transactionId: Hex;
172
+ iss: string;
173
+ signDigest: SignDigest;
174
+ }): Promise<SignedL3a> {
175
+ const inSet = args.terms.payees.some((p) => isAddressEqual(p.account, args.to));
176
+ const open = hexToBigInt(args.terms.openSdHash) !== 0n;
177
+ const first = args.terms.payees[0];
178
+ if (!inSet && !open && first === undefined) throw new Error("the mandate has no payees");
179
+ const unsigned = {
180
+ nonce: b64url(crypto.getRandomValues(new Uint8Array(16))),
181
+ iss: args.iss,
182
+ merchantSalt: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
183
+ paymentSalt: bytesToHex(crypto.getRandomValues(new Uint8Array(16))),
184
+ transactionId: args.transactionId,
185
+ payee: inSet ? "" : `{"id":"${args.to}","name":"${args.to}","website":"eip155:${args.chainId}:${args.to}"}`,
186
+ linkedPayee: inSet || open ? zeroAddress : (first?.account ?? zeroAddress),
187
+ };
188
+ const l3a = buildL3a(args.chainId, args.terms, args, unsigned);
189
+ const signature = await args.signDigest(l3a.digest);
190
+ if (size(signature) !== 64) throw new Error("an ES256 signature is r ‖ s, 64 bytes");
191
+ const r = slice(signature, 0, 32);
192
+ const s = lowS(slice(signature, 32, 64));
193
+ const pull: Pull = {
194
+ token: args.token,
195
+ to: args.to,
196
+ value: args.value,
197
+ validAfter: args.validAfter,
198
+ validBefore: args.validBefore,
199
+ nonce: mandateNonce(args.ref, l3a.digest),
200
+ };
201
+ return { pull, fields: { ...unsigned, r, s }, serialized: serializeL3a(l3a, r, s), digest: l3a.digest };
202
+ }
203
+
204
+ const pullInputs = getAbiItem({ abi: mandateBindingAbi, name: "pull" }).inputs;
205
+
206
+ /** The x402 payload's signature: `MNDT || abi.encode(Pull, L3a fields)`, the arguments of `pull`. */
207
+ export function encodeMandateBlob(pull: Pull, fields: L3aFields): Hex {
208
+ return concat([MANDATE_SIGNATURE_TAG, encodeAbiParameters(pullInputs, [pull, fields])]);
209
+ }
210
+
211
+ /** The Pull and L3a fields an x402 payload's signature carries, as Mandate Binding's `check` and `pull` take them. */
212
+ export function decodeMandateBlob(blob: Hex): { pull: Pull; fields: L3aFields } {
213
+ if (size(blob) < 4 || slice(blob, 0, 4) !== MANDATE_SIGNATURE_TAG) throw new Error("not a MetalAccount mandate signature");
214
+ const [pull, fields] = decodeAbiParameters(pullInputs, slice(blob, 4));
215
+ return { pull, fields };
216
+ }
@@ -0,0 +1,91 @@
1
+ // The seller's side of a signed order: a check, before the facilitator is
2
+ // asked to verify, that a payment under a mandate carries the agent's signed
3
+ // offer for this very offer, under the agent key the payer's account binds.
4
+
5
+ import type { x402ResourceServer } from "@x402/core/server";
6
+ import { type Address, type Hex, type PublicClient, createPublicClient, getAddress, http, isAddress, isHex, isAddressEqual } from "viem";
7
+ import { accountFactoryFor, metalNetwork } from "../networks.js";
8
+ import { assertMetalAccount } from "./account.js";
9
+ import { referencePrefix } from "./binding.js";
10
+ import { decodeMandateBlob } from "./l3a.js";
11
+ import { readBoundAgentKey } from "./chain.js";
12
+ import { lineItemOf, verifyCheckoutMandate, x402Offer } from "./checkout.js";
13
+ import type { Json } from "./jcs.js";
14
+ import { asksSignedOffer, mandateRefOf, signedOfferOf } from "./extension.js";
15
+
16
+ /**
17
+ * Where the check reads the agent key a mandate binds on the payer's account,
18
+ * a viem client or the endpoint to make one for, and, for a factory other than
19
+ * the one `@metalntwx/metal-pay` names for the offer's network, the
20
+ * MetalAccountFactory whose clones are trusted.
21
+ */
22
+ export type RequireSignedOfferConfig = { accountFactory?: Address } & ({ publicClient: PublicClient } | { rpcUrl: string });
23
+
24
+ /**
25
+ * On a route whose offer asks for it (`extra.signedOffer: "required"`), a
26
+ * payment under a mandate must carry the agent's signed order, checked before
27
+ * the facilitator is asked: the payer's account binds the mandate the payment
28
+ * names, and the signed order is that mandate's key over the offer the
29
+ * server served, for this payee, buying its one line item (`extra.resource`
30
+ * when the route names one, else the payee). A refusal is a 402 whose reason
31
+ * names the check that failed, a payment that names no mandate included. A
32
+ * route that does not ask is left to the facilitator.
33
+ */
34
+ export function requireSignedOffer(server: x402ResourceServer, config: RequireSignedOfferConfig): x402ResourceServer {
35
+ const publicClient = "publicClient" in config ? config.publicClient : createPublicClient({ transport: http(config.rpcUrl) });
36
+ const refuse = (reason: string) => ({ abort: true as const, reason });
37
+ // x402 treats a hook that throws as one that passed, so a check that cannot
38
+ // finish refuses the payment instead. The refusal reaches the payer, so it
39
+ // says nothing of the failure: a node's error can carry the endpoint's URL,
40
+ // and with it the endpoint's credential.
41
+ server.onBeforeVerify(async (context) => {
42
+ if (!asksSignedOffer(context.requirements)) return;
43
+ try {
44
+ return await checkSignedOffer(context);
45
+ } catch {
46
+ return refuse("the signed order could not be checked");
47
+ }
48
+ });
49
+ async function checkSignedOffer({ paymentPayload, requirements }: Parameters<Parameters<x402ResourceServer["onBeforeVerify"]>[0]>[0]) {
50
+ const ref = mandateRefOf(paymentPayload);
51
+ if (ref === "") return refuse("this offer is paid with the agent's signed order, which the payment lacks");
52
+ if (!isHex(ref) || ref.length !== 66) return refuse("the mandate reference is not 32 bytes");
53
+ const signedOffer = signedOfferOf(paymentPayload);
54
+ if (signedOffer === "") return refuse("the payment names a mandate but carries no signed order");
55
+ const payload = paymentPayload.payload as { signature?: unknown; authorization?: Record<string, unknown> };
56
+ const authorization = payload.authorization;
57
+ const from = authorization?.from;
58
+ if (typeof from !== "string" || !isAddress(from)) return refuse("the authorization names no payer account");
59
+ let accountFactory = config.accountFactory;
60
+ if (accountFactory === undefined) {
61
+ const network = metalNetwork(requirements.network);
62
+ if (network === undefined) return refuse(`${requirements.network} is not a Metal network, and no accountFactory was set`);
63
+ try { accountFactory = accountFactoryFor(network, undefined, "set accountFactory"); }
64
+ catch (error) { return refuse(error instanceof Error ? error.message : String(error)); }
65
+ }
66
+ try { await assertMetalAccount(publicClient, getAddress(from), accountFactory); }
67
+ catch { return refuse("the payer is not a MetalAccount of the trusted factory"); }
68
+ const bound = await readBoundAgentKey(publicClient, getAddress(from), ref as Hex);
69
+ if (bound === undefined) return refuse(`${from} binds no mandate ${ref}`);
70
+ const offer = x402Offer(requirements as unknown as Record<string, Json | undefined>);
71
+ let pull, fields;
72
+ try {
73
+ if (typeof payload.signature !== "string" || !isHex(payload.signature) || authorization === undefined) return refuse("the payment carries no mandate signature");
74
+ ({ pull, fields } = decodeMandateBlob(payload.signature));
75
+ if (String(authorization.nonce).slice(0, 26).toLowerCase() !== referencePrefix(ref as Hex).toLowerCase()) return refuse("the authorization nonce names another mandate");
76
+ if (pull.nonce.toLowerCase() !== String(authorization.nonce).toLowerCase()
77
+ || !isAddressEqual(pull.to, authorization.to as Address)
78
+ || !isAddressEqual(pull.to, requirements.payTo as Address)
79
+ || pull.value !== BigInt(String(authorization.value))
80
+ || pull.validAfter !== BigInt(String(authorization.validAfter))
81
+ || pull.validBefore !== BigInt(String(authorization.validBefore))
82
+ || !isAddressEqual(pull.token, requirements.asset as Address)) return refuse("the mandate signature does not describe the payment authorization");
83
+ } catch { return refuse("the payment carries a malformed mandate authorization"); }
84
+ // One check, each signature verified once: the signed order is this agent key's over the offer served, for this payee, and it is the order the payment names.
85
+ const check = verifyCheckoutMandate(signedOffer, {
86
+ agent: bound.agentKey, kid: bound.kid, payee: pull.to, offer, lineItemId: lineItemOf(offer), window: pull, transactionId: fields.transactionId, amount: pull.value,
87
+ });
88
+ return check.ok ? undefined : refuse(check.problems.join("; "));
89
+ }
90
+ return server;
91
+ }