@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,280 @@
1
+ // MetalAccount mandate ABI, typed agent keys, references, self-calls and pulls.
2
+
3
+ import { type Address, type Hex, concat, encodeFunctionData, encodePacked, keccak256, parseAbi, size, slice } from "viem";
4
+
5
+ /** Domain tag for the payment-only ERC-1271 path on MetalAccount. */
6
+ export const MANDATE_SIGNATURE_TAG: Hex = "0x4d4e4454";
7
+
8
+ export const mandateBindingAbi = parseAbi([
9
+ "struct Pull { address token; address to; uint256 value; uint256 validAfter; uint256 validBefore; bytes32 nonce; }",
10
+ "struct L3a { string nonce; string iss; bytes16 merchantSalt; bytes16 paymentSalt; bytes32 transactionId; bytes32 r; bytes32 s; string payee; address linkedPayee; }",
11
+ "struct Constraints { address token; uint256 perPaymentCap; uint256 budget; uint256 expiry; uint256 recurrenceStart; uint256 recurrenceEnd; uint256 maxOccurrences; }",
12
+ "struct Payee { address account; bytes32 sdHash; string object; }",
13
+ "struct Terms { string kid; string instrument; string currency; uint256 unitsPerMinor; Payee[] payees; bytes32 openSdHash; }",
14
+ "struct PayeeTerms { bytes32 sdHash; string object; }",
15
+ "struct MandateState { address principal; bool revoked; bytes32 agentX; bytes32 agentY; string kid; bytes32 openSdHash; uint64 boundBlock; uint64 occurrences; uint128 spent; }",
16
+ "function mandateReference(address principal, bytes agentKey, bytes32 hashC) pure returns (bytes32)",
17
+ "function mandates(bytes32 ref) view returns (MandateState)",
18
+ "function payee(bytes32 ref, address account) view returns (PayeeTerms)",
19
+ "function mandateOfPrefix(bytes12 prefix) view returns (bytes32)",
20
+ "function digestUsed(bytes20 l3aDigest) view returns (bool)",
21
+ "function bind(bytes32 hashC, bytes32 issuerKey, bytes agentKey, Constraints c, Terms t) returns (bytes32)",
22
+ "function replace(bytes32 predecessor, bytes32 hashC, bytes32 issuerKey, bytes agentKey, Constraints c, Terms t) returns (bytes32 successor)",
23
+ "function revoke(bytes32 ref)",
24
+ "function isValidSignature(bytes32 hash, bytes blob) view returns (bytes4)",
25
+ "function check(Pull p, L3a l) view returns (uint8)",
26
+ "function pull(Pull p, L3a l) returns (bool paid)",
27
+ "event MandateBound(bytes32 indexed ref, bytes32 hashC, bytes32 issuerKey, bytes agentKey, Constraints constraints, Terms terms)",
28
+ "event MandatePayment(bytes32 indexed ref, address indexed to, bytes20 l3aDigest, bytes agentKey, uint256 amount, uint256 spent, uint256 occurrences)",
29
+ "event MandateRefused(bytes32 indexed ref, address indexed to, bytes20 l3aDigest, bytes agentKey, uint256 amount, uint8 reason)",
30
+ "event MandateRevoked(bytes32 indexed ref)",
31
+ "event MandateReplaced(bytes32 indexed predecessor, bytes32 indexed successor)",
32
+ "error OnlySelf()",
33
+ "error MissingCredentialHash()",
34
+ "error MissingIssuerKey()",
35
+ "error InvalidBudget()",
36
+ "error InvalidRecurrence()",
37
+ "error AlreadyExpired()",
38
+ "error InvalidAgentKey()",
39
+ "error InvalidTerms()",
40
+ "error PrefixTaken()",
41
+ "error UnknownMandate()",
42
+ "error AlreadyRevoked()",
43
+ "error TokenChanged()",
44
+ "error SuccessorExhausted()",
45
+ "error BadAgentSignature()",
46
+ "error NonceAlreadyUsed()",
47
+ "error NotYetValid()",
48
+ "error RefusalLimitReached()",
49
+ ]);
50
+
51
+ /** `MandateTypes.Reason` in declaration order, which is the order Mandate Binding checks them in; `None` is a pass. */
52
+ export const REASONS = [
53
+ "None",
54
+ "NoMandate",
55
+ "Revoked",
56
+ "Expired",
57
+ "BadSignature",
58
+ "NonceUsed",
59
+ "OutsideWindow",
60
+ "PayeeNotAllowed",
61
+ "OverPerPaymentCap",
62
+ "OverBudget",
63
+ "OutsideRecurrence",
64
+ "OverOccurrences",
65
+ ] as const;
66
+
67
+ export type ReasonName = (typeof REASONS)[number];
68
+
69
+ export function reasonName(reason: number): ReasonName {
70
+ const name = REASONS[reason];
71
+ if (name === undefined) throw new Error(`unknown Reason ${reason}`);
72
+ return name;
73
+ }
74
+
75
+ /** Each refusal as the facilitator answers it and `metal-agent` prints it: its x402 reason, and what it means. */
76
+ export const REFUSALS: Record<Exclude<ReasonName, "None">, { reason: string; meaning: string }> = {
77
+ NoMandate: { reason: "mandate_not_bound", meaning: "no such mandate is bound on the account" },
78
+ Revoked: { reason: "mandate_revoked", meaning: "the mandate is revoked, or replaced: the agent pays under the credential that replaced it" },
79
+ Expired: { reason: "mandate_expired", meaning: "the mandate has expired" },
80
+ BadSignature: { reason: "mandate_bad_signature", meaning: "the payment is not signed by the mandate key" },
81
+ NonceUsed: { reason: "mandate_nonce_used", meaning: "this payment was already made or refused" },
82
+ OutsideWindow: { reason: "mandate_outside_window", meaning: "the payment's validity window is not open" },
83
+ PayeeNotAllowed: { reason: "mandate_payee_not_listed", meaning: "the payee is not one the mandate allows" },
84
+ OverPerPaymentCap: { reason: "mandate_cap_exceeded", meaning: "the amount is above the mandate's per-payment cap" },
85
+ OverBudget: { reason: "mandate_budget_spent", meaning: "the amount would take the mandate past its budget" },
86
+ OutsideRecurrence: { reason: "mandate_outside_recurrence", meaning: "the mandate has not started yet, or its dates have ended" },
87
+ OverOccurrences: { reason: "mandate_occurrences_used", meaning: "the mandate's payment count is used up" },
88
+ };
89
+
90
+ /** `mandate_cap_exceeded (the amount is above the mandate's per-payment cap)`: a refusal as a person reads it. */
91
+ export function describeRefusal(reason: Exclude<ReasonName, "None">): string {
92
+ const { reason: name, meaning } = REFUSALS[reason];
93
+ return `${name} (${meaning})`;
94
+ }
95
+
96
+ /** The Mandate Binding reason behind an x402 refusal, or `undefined` for one that is not a mandate's. */
97
+ export function refusalOf(reason: string): Exclude<ReasonName, "None"> | undefined {
98
+ return (Object.keys(REFUSALS) as Exclude<ReasonName, "None">[]).find((name) => REFUSALS[name].reason === reason);
99
+ }
100
+
101
+ /** Amounts in the token's base units; times are inclusive Unix seconds. The budget is the most all payments may add up to over the mandate's life; it never resets. */
102
+ export interface Constraints {
103
+ token: Address;
104
+ perPaymentCap: bigint;
105
+ budget: bigint;
106
+ expiry: bigint;
107
+ recurrenceStart: bigint;
108
+ recurrenceEnd: bigint;
109
+ maxOccurrences: bigint;
110
+ }
111
+
112
+ /** An allowed payee's rebuild terms: the L3a `sd_hash` over L2 and its disclosure, and its payee object as L2 serialises it. */
113
+ export interface Payee {
114
+ account: Address;
115
+ sdHash: Hex;
116
+ object: string;
117
+ }
118
+
119
+ /**
120
+ * What every per-payment mandate under a mandate is rebuilt from, besides the
121
+ * transfer and the agent's own fields. A mandate names its payees, or names
122
+ * none and carries `openSdHash`, the L3a `sd_hash` over L2 with no payee
123
+ * disclosed, under which the agent pays any address.
124
+ */
125
+ export interface Terms {
126
+ /** The agent key's `kid`, its RFC 7638 thumbprint. */
127
+ kid: string;
128
+ /** The payment instrument object, compact JSON as L2 serialises it. */
129
+ instrument: string;
130
+ currency: string;
131
+ /** Token base units per minor unit of `currency`. */
132
+ unitsPerMinor: bigint;
133
+ payees: readonly Payee[];
134
+ /** Zero when the mandate names its payees. */
135
+ openSdHash: Hex;
136
+ }
137
+
138
+ /** The fields of an ERC-3009 authorisation from the principal's account. */
139
+ export interface Pull {
140
+ token: Address;
141
+ to: Address;
142
+ value: bigint;
143
+ validAfter: bigint;
144
+ validBefore: bigint;
145
+ nonce: Hex;
146
+ }
147
+
148
+ /**
149
+ * What the agent chose in one per-payment mandate (L3a); the transfer and the
150
+ * bound terms fix the rest. `payee` and `linkedPayee` are empty (`""` and the
151
+ * zero address) for a payee in the set.
152
+ */
153
+ export interface L3aFields {
154
+ nonce: string;
155
+ iss: string;
156
+ merchantSalt: Hex;
157
+ paymentSalt: Hex;
158
+ transactionId: Hex;
159
+ r: Hex;
160
+ s: Hex;
161
+ payee: string;
162
+ linkedPayee: Address;
163
+ }
164
+
165
+ /** A P-256 agent key by its affine coordinates, the only key type Mandate Binding binds. */
166
+ export interface AgentKey {
167
+ x: Hex;
168
+ y: Hex;
169
+ }
170
+
171
+ const KEY_P256 = "0x01";
172
+
173
+ /** `0x01 ‖ x ‖ y`, 65 bytes. */
174
+ export function encodeAgentKey(key: AgentKey): Hex {
175
+ if (size(key.x) !== 32 || size(key.y) !== 32) throw new Error("P-256 coordinates are 32 bytes each");
176
+ return concat([KEY_P256, key.x, key.y]).toLowerCase() as Hex;
177
+ }
178
+
179
+ export function decodeAgentKey(encoded: Hex): AgentKey {
180
+ if (size(encoded) !== 65 || slice(encoded, 0, 1) !== KEY_P256) throw new Error(`not a typed P-256 agent key: ${encoded}`);
181
+ return { x: slice(encoded, 1, 33), y: slice(encoded, 33, 65) };
182
+ }
183
+
184
+ export function sameAgentKey(a: AgentKey, b: AgentKey): boolean {
185
+ return encodeAgentKey(a) === encodeAgentKey(b);
186
+ }
187
+
188
+ /** `keccak256(principal ‖ typed agent key ‖ hash(C))`, tightly packed. */
189
+ export function mandateReference(principal: Address, agentKey: AgentKey, hashC: Hex): Hex {
190
+ return keccak256(encodePacked(["address", "bytes", "bytes32"], [principal, encodeAgentKey(agentKey), hashC]));
191
+ }
192
+
193
+ /** The 12 bytes of a reference every payment under it carries at the front of its nonce. */
194
+ export function referencePrefix(refOrNonce: Hex): Hex {
195
+ return slice(refOrNonce, 0, 12).toLowerCase() as Hex;
196
+ }
197
+
198
+ /**
199
+ * Gas for the calls built here. Metal's `eth_fillTransaction` fills a zero fee
200
+ * and a fee-carrying `eth_estimateGas` fails, so every call carries its limit.
201
+ * A direct pull rebuilds the L3a and runs the P-256 check (441,600 gas on
202
+ * Metal) and the transfer. A bind or replace stores the terms it carries, so
203
+ * its gas scales with its calldata: `BINDING_GAS_PER_BYTE` per byte.
204
+ */
205
+ export const MANDATE_GAS = {
206
+ revoke: 100_000n,
207
+ pull: 900_000n,
208
+ } as const;
209
+
210
+ /**
211
+ * Gas per calldata byte of a bind or replace. Storing the terms costs about
212
+ * 810 per byte on Metal, measured from one payee (2,244 bytes, 1,170,948 gas)
213
+ * to twenty (8,932 bytes, 6,573,273 gas); 950 leaves room for longer payee
214
+ * objects, whose bytes are stored too.
215
+ */
216
+ export const BINDING_GAS_PER_BYTE = 950n;
217
+
218
+ function bindingCall(to: Address, data: Hex): MandateCall {
219
+ return call(to, data, BigInt(size(data)) * BINDING_GAS_PER_BYTE);
220
+ }
221
+
222
+ /** What Metal charges per gas, in `*USDZ` base units. */
223
+ export const FEE_PER_GAS = 1n;
224
+
225
+ /** A call with its gas and fees; the sender adds its nonce and the chain id. */
226
+ export interface MandateCall {
227
+ to: Address;
228
+ data: Hex;
229
+ gas: bigint;
230
+ maxFeePerGas: bigint;
231
+ maxPriorityFeePerGas: bigint;
232
+ }
233
+
234
+ function call(to: Address, data: Hex, gas: bigint): MandateCall {
235
+ return { to, data, gas, maxFeePerGas: FEE_PER_GAS, maxPriorityFeePerGas: FEE_PER_GAS };
236
+ }
237
+
238
+ function termsArg(t: Terms) {
239
+ return { ...t, payees: t.payees.map((p) => ({ ...p })) };
240
+ }
241
+
242
+ /** What `bind` and `replace` take besides the predecessor. */
243
+ export interface Binding {
244
+ principal: Address;
245
+ hashC: Hex;
246
+ /** The RFC 7638 thumbprint of the key that signed C's issuer layer, as 32 bytes. */
247
+ issuerKey: Hex;
248
+ agentKey: AgentKey;
249
+ constraints: Constraints;
250
+ terms: Terms;
251
+ }
252
+
253
+ /** The principal's call to its own account binding a mandate. */
254
+ export function bindCall(b: Binding): MandateCall {
255
+ const data = encodeFunctionData({
256
+ abi: mandateBindingAbi,
257
+ functionName: "bind",
258
+ args: [b.hashC, b.issuerKey, encodeAgentKey(b.agentKey), b.constraints, termsArg(b.terms)],
259
+ });
260
+ return bindingCall(b.principal, data);
261
+ }
262
+
263
+ /** The principal's call binding a successor, carrying the period's spend and occurrences, and revoking `predecessor` with it. */
264
+ export function replaceCall(predecessor: Hex, b: Binding): MandateCall {
265
+ const data = encodeFunctionData({
266
+ abi: mandateBindingAbi,
267
+ functionName: "replace",
268
+ args: [predecessor, b.hashC, b.issuerKey, encodeAgentKey(b.agentKey), b.constraints, termsArg(b.terms)],
269
+ });
270
+ return bindingCall(b.principal, data);
271
+ }
272
+
273
+ export function revokeCall(principal: Address, ref: Hex): MandateCall {
274
+ return call(principal, encodeFunctionData({ abi: mandateBindingAbi, functionName: "revoke", args: [ref] }), MANDATE_GAS.revoke);
275
+ }
276
+
277
+ /** A per-payment mandate submitted to `pull` by anyone holding it (the agent's client, a facilitator, the paying PSP as sponsor). */
278
+ export function pullCall(principal: Address, pull: Pull, fields: L3aFields): MandateCall {
279
+ return call(principal, encodeFunctionData({ abi: mandateBindingAbi, functionName: "pull", args: [pull, fields] }), MANDATE_GAS.pull);
280
+ }
@@ -0,0 +1,68 @@
1
+ // Chain reads and the one send the library needs, all through a viem client.
2
+
3
+ import { type Account, type Address, type Chain, type Hash, type Hex, type PublicClient, type Transport, type WalletClient, isAddressEqual } from "viem";
4
+ import { type AgentKey, type L3aFields, type MandateCall, type Pull, type ReasonName, mandateBindingAbi, reasonName } from "./binding.js";
5
+
6
+ /** Mandate Binding's `check` on the principal's account at a block: the reason the per-payment mandate would be refused, or `None`. */
7
+ export async function readCheck(client: PublicClient, principal: Address, pull: Pull, fields: L3aFields, blockNumber?: bigint): Promise<ReasonName> {
8
+ const reason = await client.readContract({
9
+ address: principal,
10
+ abi: mandateBindingAbi,
11
+ functionName: "check",
12
+ args: [pull, fields],
13
+ ...(blockNumber === undefined ? {} : { blockNumber }),
14
+ });
15
+ return reasonName(reason);
16
+ }
17
+
18
+ /** A mandate's running state at a block (the latest by default): revoked or not, its spend, its occurrences, and the block it was bound in. */
19
+ export async function readMandateState(client: PublicClient, principal: Address, ref: Hex, blockNumber?: bigint): Promise<{ revoked: boolean; spent: bigint; occurrences: bigint; boundBlock: bigint }> {
20
+ const at = blockNumber === undefined ? {} : { blockNumber };
21
+ const mandate = await client.readContract({ address: principal, abi: mandateBindingAbi, functionName: "mandates", args: [ref], ...at });
22
+ return { revoked: mandate.revoked, spent: mandate.spent, occurrences: mandate.occurrences, boundBlock: mandate.boundBlock };
23
+ }
24
+
25
+ /** The agent key and its `kid` the mandate `ref` binds on `principal`; undefined when no such mandate is bound. */
26
+ export async function readBoundAgentKey(client: PublicClient, principal: Address, ref: Hex): Promise<{ agentKey: AgentKey; kid: string } | undefined> {
27
+ const mandate = await client.readContract({ address: principal, abi: mandateBindingAbi, functionName: "mandates", args: [ref] });
28
+ if (!isAddressEqual(mandate.principal, principal)) return undefined;
29
+ return { agentKey: { x: mandate.agentX, y: mandate.agentY }, kid: mandate.kid };
30
+ }
31
+
32
+ /** Sends a call with its fees, the gas an estimate shows it needs, and the nonce and chain id set here, so nothing is left for the node to fill. */
33
+ export async function sendMandateCall(publicClient: PublicClient, walletClient: WalletClient<Transport, Chain, Account>, call: MandateCall): Promise<Hash> {
34
+ // Estimated first, without fees (Metal refuses a fee-carrying estimate from
35
+ // a sender holding no gas token): a call that would revert costs nothing and
36
+ // says why, and the gas sent covers what this call's terms and quorum use,
37
+ // with a fifth more, never below the limit the call was built with.
38
+ let estimate: bigint;
39
+ try {
40
+ estimate = await publicClient.estimateGas({ account: walletClient.account.address, to: call.to, data: call.data });
41
+ } catch (error) {
42
+ throw new Error(`the call to ${call.to} would fail: ${publicMessage(error)}`);
43
+ }
44
+ const gas = estimate + estimate / 5n > call.gas ? estimate + estimate / 5n : call.gas;
45
+ const [nonce, chainId] = await Promise.all([
46
+ publicClient.getTransactionCount({ address: walletClient.account.address, blockTag: "pending" }),
47
+ publicClient.getChainId(),
48
+ ]);
49
+ return walletClient.sendTransaction({ ...call, gas, nonce, chainId, chain: walletClient.chain, account: walletClient.account });
50
+ }
51
+
52
+ /**
53
+ * An error's message with nothing a node or transport added: viem's short
54
+ * message, which names what failed, never the request, its URL or headers.
55
+ * An RPC endpoint's URL can carry its credential.
56
+ */
57
+ export function publicMessage(error: unknown): string {
58
+ if (error instanceof Error) {
59
+ const short = (error as { shortMessage?: unknown }).shortMessage;
60
+ return redactUrls(typeof short === "string" ? short : error.message.split("\n")[0] ?? "");
61
+ }
62
+ return redactUrls(String(error));
63
+ }
64
+
65
+ /** `text` with every URL reduced to its scheme and host, its userinfo, path and query removed. */
66
+ export function redactUrls(text: string): string {
67
+ return text.replace(/\b([a-z][a-z0-9+.-]*:\/\/)(?:[^\s/@"'<>]*@)?([^\s/?#"'<>]+)([/?#][^\s"'<>]*)?/gi, (_, scheme: string, host: string, rest?: string) => `${scheme}${host}${rest ? "/…" : ""}`);
68
+ }