@metalntwx/metal-pay 0.2.0 → 0.3.1

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 (75) hide show
  1. package/README.md +104 -38
  2. package/dist/clearing-abi.d.ts +1836 -0
  3. package/dist/clearing-abi.js +2361 -0
  4. package/dist/clearing-client.d.ts +96 -159
  5. package/dist/clearing-client.js +412 -553
  6. package/dist/clearing-codec.d.ts +57 -0
  7. package/dist/clearing-codec.js +173 -0
  8. package/dist/clearing-custody.d.ts +202 -0
  9. package/dist/clearing-custody.js +552 -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 +38 -0
  13. package/dist/clearing-x402.js +249 -0
  14. package/dist/client.d.ts +14 -15
  15. package/dist/client.js +53 -22
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +4 -1
  18. package/dist/mandates/account.d.ts +9 -4
  19. package/dist/mandates/account.js +15 -11
  20. package/dist/mandates/binding.d.ts +4 -27
  21. package/dist/mandates/binding.js +5 -9
  22. package/dist/mandates/checkout.js +5 -5
  23. package/dist/mandates/client.js +4 -3
  24. package/dist/mandates/l3a.d.ts +2 -2
  25. package/dist/mandates/l3a.js +5 -4
  26. package/dist/mandates/seller.d.ts +1 -1
  27. package/dist/mandates/seller.js +4 -4
  28. package/dist/mandates/verify.d.ts +4 -6
  29. package/dist/mandates/verify.js +147 -82
  30. package/dist/mandates/vi/credential.js +4 -4
  31. package/dist/networks.d.ts +9 -1
  32. package/dist/networks.js +105 -4
  33. package/dist/node.d.ts +11 -0
  34. package/dist/node.js +97 -0
  35. package/package.json +18 -11
  36. package/src/clearing-abi.ts +2361 -0
  37. package/src/clearing-client.test.ts +850 -0
  38. package/src/clearing-client.ts +612 -0
  39. package/src/clearing-codec.test.ts +57 -0
  40. package/src/clearing-codec.ts +251 -0
  41. package/src/clearing-custody.test.ts +763 -0
  42. package/src/clearing-custody.ts +743 -0
  43. package/src/clearing-fixture.json +37 -0
  44. package/src/clearing-server.test.ts +450 -0
  45. package/src/clearing-server.ts +123 -0
  46. package/src/clearing-x402.test.ts +181 -0
  47. package/src/clearing-x402.ts +328 -0
  48. package/src/client.test.ts +333 -0
  49. package/src/client.ts +195 -0
  50. package/src/index.ts +33 -0
  51. package/src/mandates/account.ts +95 -0
  52. package/src/mandates/binding.ts +280 -0
  53. package/src/mandates/chain.ts +68 -0
  54. package/src/mandates/checkout.ts +368 -0
  55. package/src/mandates/client.ts +159 -0
  56. package/src/mandates/extension.ts +48 -0
  57. package/src/mandates/index.ts +121 -0
  58. package/src/mandates/ingest.ts +42 -0
  59. package/src/mandates/jcs.ts +16 -0
  60. package/src/mandates/l3a.ts +216 -0
  61. package/src/mandates/seller.ts +91 -0
  62. package/src/mandates/verify.ts +625 -0
  63. package/src/mandates/vi/credential.ts +477 -0
  64. package/src/mandates/vi/issue.ts +118 -0
  65. package/src/mandates/vi/sd-jwt.ts +189 -0
  66. package/src/networks.test.ts +177 -0
  67. package/src/networks.ts +242 -0
  68. package/src/node.test.ts +24 -0
  69. package/src/node.ts +96 -0
  70. package/src/server.test.ts +223 -0
  71. package/src/server.ts +77 -0
  72. package/dist/clearing.d.ts +0 -204
  73. package/dist/clearing.js +0 -151
  74. package/dist/file-storage.d.ts +0 -39
  75. package/dist/file-storage.js +0 -195
@@ -0,0 +1,189 @@
1
+ // SD-JWT as Verifiable Intent v0.1 uses it: ES256 over the compact form,
2
+ // SHA-256 disclosure digests, base64url throughout, JSON written as the
3
+ // reference's Python `json.dumps(separators=(",", ":"))` writes it.
4
+
5
+ import { createHash, randomBytes, webcrypto } from "node:crypto";
6
+ import { p256 } from "@noble/curves/nist";
7
+ import { type Hex, bytesToHex, concat, hexToBytes, numberToHex } from "viem";
8
+ import type { Json, JsonObject } from "../jcs.js";
9
+
10
+ export type P256Jwk = { kty: "EC"; crv: "P-256"; x: string; y: string };
11
+ export type P256PrivateJwk = P256Jwk & { d: string };
12
+
13
+ /** A P-256 key pair with the `kid` its signatures name. */
14
+ export type Es256Key = { kid: string; publicJwk: P256Jwk; privateJwk: P256PrivateJwk };
15
+
16
+ export type SdJwt = {
17
+ header: JsonObject;
18
+ payload: JsonObject;
19
+ /** `b64(header) "." b64(payload)` as serialised. */
20
+ signingInput: string;
21
+ signature: Uint8Array;
22
+ disclosures: string[];
23
+ };
24
+
25
+ export function b64url(bytes: Uint8Array): string {
26
+ return Buffer.from(bytes).toString("base64url");
27
+ }
28
+
29
+ export function fromB64url(text: string): Uint8Array {
30
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("not base64url");
31
+ return new Uint8Array(Buffer.from(text, "base64url"));
32
+ }
33
+
34
+ export function sha256(bytes: Uint8Array | string): Uint8Array {
35
+ return new Uint8Array(createHash("sha256").update(bytes).digest());
36
+ }
37
+
38
+ export function sha256Hex(bytes: Uint8Array | string): Hex {
39
+ return bytesToHex(sha256(bytes));
40
+ }
41
+
42
+ /** base64url(SHA-256(ASCII text)): a disclosure's digest, and `sd_hash` over a serialised SD-JWT. */
43
+ export function hashAscii(text: string): string {
44
+ return b64url(sha256(Buffer.from(text, "ascii")));
45
+ }
46
+
47
+ /** base64url of `text`'s ASCII bytes: a JWS segment or a disclosure. */
48
+ export function b64Ascii(text: string): string {
49
+ return b64url(Buffer.from(text, "ascii"));
50
+ }
51
+
52
+ /** An EC P-256 public JWK, its coordinates 32 bytes each; throws naming `what` otherwise. */
53
+ export function parseP256Jwk(value: unknown, what: string): P256Jwk {
54
+ const o = (typeof value === "object" && value !== null ? value : {}) as Record<string, unknown>;
55
+ const coordinate = (c: unknown): c is string => typeof c === "string" && /^[A-Za-z0-9_-]{43}$/.test(c) && fromB64url(c).length === 32;
56
+ if (o.kty !== "EC" || o.crv !== "P-256" || !coordinate(o.x) || !coordinate(o.y)) throw new Error(`${what} must be an EC P-256 public key`);
57
+ return { kty: "EC", crv: "P-256", x: o.x, y: o.y };
58
+ }
59
+
60
+ /**
61
+ * Python's `json.dumps(value, separators=(",", ":"))` with its default
62
+ * `ensure_ascii`: every character outside printable ASCII escaped as `\uXXXX`.
63
+ * The VI reference writes every JWT segment and disclosure this way and
64
+ * re-encodes decoded segments this way before checking a signature.
65
+ */
66
+ export function compactJson(value: Json): string {
67
+ return JSON.stringify(value).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
68
+ }
69
+
70
+ /** RFC 7638: SHA-256 over the required EC members in lexicographic order. */
71
+ export function jwkThumbprintBytes({ crv, kty, x, y }: P256Jwk): Uint8Array {
72
+ return sha256(Buffer.from(JSON.stringify({ crv, kty, x, y }), "utf8"));
73
+ }
74
+
75
+ /** The RFC 7638 thumbprint, base64url: an agent key's `kid`. */
76
+ export function jwkThumbprint(jwk: P256Jwk): string {
77
+ return b64url(jwkThumbprintBytes(jwk));
78
+ }
79
+
80
+ export function randomSalt(): string {
81
+ return b64url(randomBytes(16));
82
+ }
83
+
84
+ export function createDisclosure(value: Json, claimName?: string, salt: string = randomSalt()): string {
85
+ const entry: Json[] = claimName === undefined ? [salt, value] : [salt, claimName, value];
86
+ return b64url(Buffer.from(compactJson(entry), "ascii"));
87
+ }
88
+
89
+ export function decodeDisclosure(disclosure: string): Json[] {
90
+ const value: unknown = JSON.parse(Buffer.from(fromB64url(disclosure)).toString("utf8"));
91
+ if (!Array.isArray(value) || (value.length !== 2 && value.length !== 3) || typeof value[0] !== "string") {
92
+ throw new Error("disclosure is not [salt, value] or [salt, name, value]");
93
+ }
94
+ return value;
95
+ }
96
+
97
+ export function signingInputOf(header: JsonObject, payload: JsonObject): string {
98
+ return `${b64url(Buffer.from(compactJson(header), "ascii"))}.${b64url(Buffer.from(compactJson(payload), "ascii"))}`;
99
+ }
100
+
101
+ /** `jwt~d1~…~dn~`, the SD-JWT compact serialisation. */
102
+ export function serializeSdJwt(signingInput: string, signature: Uint8Array, disclosures: readonly string[]): string {
103
+ return `${[`${signingInput}.${b64url(signature)}`, ...disclosures].join("~")}~`;
104
+ }
105
+
106
+ const ES256 = { name: "ECDSA", namedCurve: "P-256" } as const;
107
+ const ES256_SIGN = { name: "ECDSA", hash: "SHA-256" } as const;
108
+
109
+ export async function es256Sign(signingInput: string, privateJwk: P256PrivateJwk): Promise<Uint8Array> {
110
+ const key = await webcrypto.subtle.importKey("jwk", privateJwk, ES256, false, ["sign"]);
111
+ return new Uint8Array(await webcrypto.subtle.sign(ES256_SIGN, key, Buffer.from(signingInput, "ascii")));
112
+ }
113
+
114
+ export async function es256Verify(signingInput: string, signature: Uint8Array, publicJwk: P256Jwk): Promise<boolean> {
115
+ const { kty, crv, x, y } = publicJwk;
116
+ try {
117
+ const key = await webcrypto.subtle.importKey("jwk", { kty, crv, x, y }, ES256, false, ["verify"]);
118
+ return await webcrypto.subtle.verify(ES256_SIGN, key, Buffer.from(signature), Buffer.from(signingInput, "ascii"));
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ export async function signSdJwt(header: JsonObject, payload: JsonObject, disclosures: string[], privateJwk: P256PrivateJwk): Promise<string> {
125
+ const signingInput = signingInputOf(header, payload);
126
+ return serializeSdJwt(signingInput, await es256Sign(signingInput, privateJwk), disclosures);
127
+ }
128
+
129
+ function jsonObject(segment: string): JsonObject {
130
+ const value: unknown = JSON.parse(Buffer.from(fromB64url(segment)).toString("utf8"));
131
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("JWT segment is not a JSON object");
132
+ return value as JsonObject;
133
+ }
134
+
135
+ export function decodeSdJwt(serialized: string): SdJwt {
136
+ if (!serialized.endsWith("~")) throw new Error("SD-JWT must end with ~");
137
+ const [jwt = "", ...disclosures] = serialized.slice(0, -1).split("~");
138
+ const segments = jwt.split(".");
139
+ if (segments.length !== 3) throw new Error("JWT must have three segments");
140
+ const [header = "", payload = "", signature = ""] = segments;
141
+ if (disclosures.some((d) => d.length === 0)) throw new Error("empty disclosure");
142
+ return {
143
+ header: jsonObject(header),
144
+ payload: jsonObject(payload),
145
+ signingInput: `${header}.${payload}`,
146
+ signature: fromB64url(signature),
147
+ disclosures,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * The reference's signature check: re-encode the decoded header and payload
153
+ * compactly and verify over that, so a segment serialised any other way
154
+ * fails, as it does in the reference.
155
+ */
156
+ export async function verifySdJwtSignature(sdJwt: SdJwt, publicJwk: P256Jwk): Promise<boolean> {
157
+ return es256Verify(signingInputOf(sdJwt.header, sdJwt.payload), sdJwt.signature, publicJwk);
158
+ }
159
+
160
+ /** A fresh P-256 key. An agent key's `kid` is its RFC 7638 thumbprint, the name a credential gives it. */
161
+ export async function createEs256Key(kid?: string): Promise<Es256Key> {
162
+ const { privateKey } = await webcrypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
163
+ const { x, y, d } = await webcrypto.subtle.exportKey("jwk", privateKey);
164
+ if (x === undefined || y === undefined || d === undefined) throw new Error("webcrypto exported an incomplete P-256 JWK");
165
+ const publicJwk: P256Jwk = { kty: "EC", crv: "P-256", x, y };
166
+ return { kid: kid ?? jwkThumbprint(publicJwk), publicJwk, privateJwk: { ...publicJwk, d } };
167
+ }
168
+
169
+ /** The raw private scalar of an `Es256Key`, for `p256DigestSigner`. */
170
+ export function privateKeyHex(key: Es256Key): Hex {
171
+ return bytesToHex(fromB64url(key.privateJwk.d));
172
+ }
173
+
174
+ /** Signs a 32-byte SHA-256 digest with a P-256 key, returning `r ‖ s`. */
175
+ export type SignDigest = (digest: Hex) => Promise<Hex>;
176
+
177
+ /**
178
+ * ES256 over a signing input whose SHA-256 is `digest`, as the P-256
179
+ * precompile verifies it (the digest is the message hash, not hashed again),
180
+ * with `s` in the lower half of the curve order.
181
+ */
182
+ export function p256DigestSigner(privateKey: Hex): SignDigest {
183
+ const d = hexToBytes(privateKey);
184
+ if (d.length !== 32) throw new Error("a P-256 private key is 32 bytes");
185
+ return async (digest) => {
186
+ const signature = p256.sign(hexToBytes(digest), d, { prehash: false, lowS: true });
187
+ return concat([numberToHex(signature.r, { size: 32 }), numberToHex(signature.s, { size: 32 })]);
188
+ };
189
+ }
@@ -0,0 +1,177 @@
1
+ import { createServer } from "node:http";
2
+ import { inspect } from "node:util";
3
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
4
+ import { createPublicClient, erc20Abi, http, type HttpTransport } from "viem";
5
+ import { METAL_NETWORKS, rpcTransport, rpcUrlFor } from "./networks.js";
6
+
7
+ describe("rpcUrlFor", () => {
8
+ it("prefers the override, then the network's default RPC", () => {
9
+ expect(rpcUrlFor(METAL_NETWORKS.devnet, undefined, "set RPC")).toBe("http://127.0.0.1:8545");
10
+ expect(rpcUrlFor(METAL_NETWORKS.devnet, "http://override", "set RPC")).toBe("http://override");
11
+ expect(rpcUrlFor(METAL_NETWORKS.testnet, "http://override", "set RPC")).toBe("http://override");
12
+ });
13
+
14
+ it("says how to get an endpoint of your own on a network without a default", () => {
15
+ expect(() => rpcUrlFor(METAL_NETWORKS.testnet, undefined, "set METAL_RPC_URL")).toThrow(
16
+ new Error(`testnet needs an RPC endpoint of your own: sign up at https://console-testnet.metalntwx.com, create an endpoint, then set METAL_RPC_URL`),
17
+ );
18
+ });
19
+ });
20
+
21
+ const PASSWORD_ECHO = "ops:s3cr3t-pass";
22
+ const PASSWORD = "s3cr3t-pass";
23
+
24
+ describe("rpcTransport", () => {
25
+ /** An endpoint that fails each request over HTTP, never answers it, or answers it with a JSON-RPC error. */
26
+ const node = createServer((req, res) => {
27
+ let body = "";
28
+ req.on("data", (c) => (body += c));
29
+ req.on("end", () => {
30
+ const { id } = JSON.parse(body);
31
+ if (req.url?.startsWith("/http-error/")) return res.writeHead(502).end("bad gateway");
32
+ if (req.url?.startsWith("/hang/")) return;
33
+ res.setHeader("content-type", "application/json");
34
+ // An endpoint whose error names a different path on its own host than the one requested,
35
+ // as an Express-style 404 or a proxy's own error page can.
36
+ // A server error that echoes the request path alone, or a header that names it, or the credentials it was given.
37
+ if (req.url?.startsWith("/echoes-path-only/"))
38
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Cannot POST ${req.url}` } }));
39
+ if (req.url?.startsWith("/echoes-header/")) return res.writeHead(502, { "content-type": "text/plain", location: req.url, "x-upstream": req.url }).end("bad gateway");
40
+ // A provider that names the key alone, a percent-encoded path as it decodes it, or the password alone.
41
+ if (req.url?.startsWith("/echoes-segment/"))
42
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Unauthorized: invalid project id ${req.url.split("?")[0]!.slice("/echoes-segment/".length)}` } }));
43
+ if (req.url?.startsWith("/echoes-query/"))
44
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Unauthorized: invalid api key ${new URL(req.url, "http://x").searchParams.get("key")}` } }));
45
+ if (req.url?.startsWith("/echoes-decoded/"))
46
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Unauthorized: ${decodeURIComponent(req.url.slice("/echoes-decoded/".length))}` } }));
47
+ if (req.url?.startsWith("/echoes-password/"))
48
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `bad password ${PASSWORD}` } }));
49
+ if (req.url?.startsWith("/echoes-in-data/"))
50
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: "upstream failed", data: { reason: `Cannot POST ${req.url}`, trace: [`at ${req.url}`] } } }));
51
+ if (req.url?.startsWith("/echoes-userinfo/"))
52
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `bad credentials ${PASSWORD_ECHO}` } }));
53
+ if (req.url?.startsWith("/echoes-another-path/"))
54
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Cannot POST ${origin}/leaked-key-in-another-path/${req.url.slice("/echoes-another-path/".length)}` } }));
55
+ res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: "header not found" } }));
56
+ });
57
+ });
58
+ let origin: string;
59
+ beforeAll(async () => {
60
+ origin = await new Promise<string>((resolve) =>
61
+ node.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${(node.address() as { port: number }).port}`)),
62
+ );
63
+ });
64
+ afterAll(() => new Promise<void>((resolve) => node.close(() => resolve()) && node.closeAllConnections()));
65
+ const CREDENTIAL = "k3y-0f-the-endpoint";
66
+ /** The error, its causes and their fields, as a log prints them. */
67
+ const printed = (error: unknown) => inspect(error, { depth: Infinity });
68
+ const failures = (url: string, transport = rpcTransport) => {
69
+ const client = createPublicClient({ transport: transport(url) });
70
+ return Promise.all([
71
+ client.getChainId().catch((e: unknown) => e),
72
+ client.readContract({ address: `0x${"33".repeat(20)}`, abi: erc20Abi, functionName: "totalSupply" }).catch((e: unknown) => e),
73
+ ]);
74
+ };
75
+
76
+ it("throws viem's errors without the RPC URL, in them or their causes", async () => {
77
+ const urls = [`${origin}/http-error/${CREDENTIAL}`, `${origin}/rpc-error/${CREDENTIAL}`, `${origin}/q?key=${CREDENTIAL}`, `http://127.0.0.1:1/${CREDENTIAL}`];
78
+ for (const url of urls) {
79
+ for (const error of await failures(url)) {
80
+ expect(error).toBeInstanceOf(Error);
81
+ expect(printed(error)).toContain("[RPC URL hidden]");
82
+ expect(printed(error)).not.toContain(CREDENTIAL);
83
+ expect(printed(error)).not.toMatch(/https?:\/\/127\.0\.0\.1/);
84
+ }
85
+ }
86
+ });
87
+
88
+ it("names an RPC endpoint that gives no answer, or a server error, as unreachable, and one that answers with an RPC error as answering", async () => {
89
+ // A short timeout and no retries, so a request that hangs fails at once.
90
+ const quick = (url: string) => ((options) => rpcTransport(url)({ ...options, timeout: 300, retryCount: 0 })) as HttpTransport;
91
+ for (const url of [`${origin}/hang/${CREDENTIAL}`, `${origin}/http-error/${CREDENTIAL}`, `http://127.0.0.1:1/${CREDENTIAL}`]) {
92
+ for (const error of await failures(url, quick)) {
93
+ expect(String(error)).toMatch(/^\w+: the RPC endpoint could not be reached: /);
94
+ expect(printed(error)).not.toContain(CREDENTIAL);
95
+ }
96
+ }
97
+ for (const error of await failures(`${origin}/rpc-error/${CREDENTIAL}`, quick)) expect(String(error)).not.toMatch(/could not be reached/);
98
+ });
99
+
100
+ it("is the check that fails: viem's own transport prints the URL", async () => {
101
+ for (const error of await failures(`${origin}/http-error/${CREDENTIAL}`, (url) => http(url))) expect(printed(error)).toContain(CREDENTIAL);
102
+ });
103
+
104
+ /** Every string an error carries, in its fields, its causes and its headers, as a log or a catch block could read them. */
105
+ const strings = (value: unknown, seen = new Set<unknown>()): string[] => {
106
+ if (typeof value === "string") return [value];
107
+ if (value === null || typeof value !== "object" || seen.has(value)) return [];
108
+ seen.add(value);
109
+ if (value instanceof Headers) return [...value].flat();
110
+ const fields = Object.getOwnPropertyNames(value).map((key) => (value as Record<string, unknown>)[key]);
111
+ return fields.flatMap((field) => strings(field, seen));
112
+ };
113
+
114
+ it("hides the path and query of the RPC URL where the endpoint echoes them without the host", async () => {
115
+ for (const url of [`${origin}/echoes-path-only/${CREDENTIAL}`, `${origin}/echoes-path-only/v1?key=${CREDENTIAL}`]) {
116
+ for (const error of await failures(url)) {
117
+ expect(error).toBeInstanceOf(Error);
118
+ expect(strings(error).join("\n")).not.toContain(CREDENTIAL);
119
+ }
120
+ }
121
+ });
122
+
123
+ it("hides the RPC URL where the endpoint names it in a response header", async () => {
124
+ for (const error of await failures(`${origin}/echoes-header/${CREDENTIAL}`)) {
125
+ expect(error).toBeInstanceOf(Error);
126
+ expect(strings(error).join("\n")).not.toContain(CREDENTIAL);
127
+ }
128
+ });
129
+
130
+ it("hides the RPC URL wherever the endpoint puts it in its error, nested objects and lists included", async () => {
131
+ for (const error of await failures(`${origin}/echoes-in-data/${CREDENTIAL}`)) {
132
+ expect(error).toBeInstanceOf(Error);
133
+ expect(strings(error).join("\n")).not.toContain(CREDENTIAL);
134
+ }
135
+ });
136
+
137
+ it("hides a key the endpoint echoes alone: a path segment, a query value, or a percent-encoded segment as it decodes it", async () => {
138
+ for (const url of [
139
+ `${origin}/echoes-segment/${CREDENTIAL}`,
140
+ `${origin}/echoes-segment/v1/${CREDENTIAL}/rpc?x=1`,
141
+ `${origin}/echoes-query/v1?key=${CREDENTIAL}`,
142
+ `${origin}/echoes-decoded/${encodeURIComponent("key with space and more")}`,
143
+ ]) {
144
+ for (const error of await failures(url)) {
145
+ expect(error).toBeInstanceOf(Error);
146
+ const text = strings(error).join("\n");
147
+ expect(text).not.toContain(CREDENTIAL);
148
+ expect(text).not.toContain("key with space and more");
149
+ }
150
+ }
151
+ });
152
+
153
+ it("hides a password the endpoint echoes alone", async () => {
154
+ const [user] = PASSWORD_ECHO.split(":");
155
+ for (const error of await failures(`http://${user}:${PASSWORD}@${origin.slice("http://".length)}/echoes-password/x`)) {
156
+ expect(error).toBeInstanceOf(Error);
157
+ expect(strings(error).join("\n")).not.toContain(PASSWORD);
158
+ }
159
+ });
160
+
161
+ it("hides the user and password of the RPC URL where the endpoint echoes them", async () => {
162
+ const [user, password] = PASSWORD_ECHO.split(":");
163
+ for (const error of await failures(`http://${user}:${password}@${origin.slice("http://".length)}/echoes-userinfo/x`)) {
164
+ expect(error).toBeInstanceOf(Error);
165
+ expect(strings(error).join("\n")).not.toContain(PASSWORD_ECHO);
166
+ expect(strings(error).join("\n")).not.toContain(password);
167
+ }
168
+ });
169
+
170
+ it("hides a same-host URL that differs from rpcUrl, which only the same-host check catches", async () => {
171
+ for (const error of await failures(`${origin}/echoes-another-path/${CREDENTIAL}`)) {
172
+ expect(printed(error)).toContain("[RPC URL hidden]");
173
+ expect(printed(error)).not.toContain(CREDENTIAL);
174
+ expect(printed(error)).not.toMatch(/https?:\/\/127\.0\.0\.1/);
175
+ }
176
+ });
177
+ });
@@ -0,0 +1,242 @@
1
+ import type { Network } from "@x402/core/types";
2
+ import { HttpRequestError, TimeoutError, http, type Address, type HttpTransport } from "viem";
3
+
4
+ export type MetalNetworkName = "devnet" | "testnet";
5
+
6
+ export interface MetalNetwork {
7
+ name: MetalNetworkName;
8
+ /** CAIP-2 identifier, the `network` field of every x402 message. */
9
+ network: Network;
10
+ chainId: number;
11
+ /**
12
+ * The default JSON-RPC endpoint, or, on a network where each user brings an
13
+ * endpoint of their own, the console that issues them.
14
+ */
15
+ rpc: { url: string } | { consoleUrl: string };
16
+ /**
17
+ * Metal's x402 facilitator for this network, the URL an `HTTPFacilitatorClient`
18
+ * verifies and settles through. On devnet it is the one `just x402-facilitator`
19
+ * runs on this machine.
20
+ */
21
+ facilitatorUrl: string;
22
+ /**
23
+ * The MetalAccountFactory whose clones hold mandates, the trust anchor a
24
+ * seller and a verifier check a principal's code against. On devnet it is
25
+ * the first transaction of Anvil dev account #10, which `just deploy` and
26
+ * the e2e send; that key is public, so a public network deploys
27
+ * `contracts/script/DeployMetalAccount.s.sol` from a key its operator holds
28
+ * and records the address here. A network without one takes the address
29
+ * from the caller. Verifying a payment made under another factory, such as
30
+ * one from before a network reset, takes that factory from the caller too.
31
+ */
32
+ accountFactory?: Address;
33
+ }
34
+
35
+ /**
36
+ * The MetalAccountFactory to trust on `network`: `override` when given, else
37
+ * the network's own. On a network with neither it throws, saying how the
38
+ * caller passes one (`how`, such as "pass --factory").
39
+ */
40
+ export function accountFactoryFor(network: MetalNetwork, override: Address | undefined, how: string): Address {
41
+ if (override !== undefined) return override;
42
+ if (network.accountFactory !== undefined) return network.accountFactory;
43
+ throw new Error(`${network.name} names no MetalAccountFactory; ${how}`);
44
+ }
45
+
46
+ /**
47
+ * `devnet` is the chain `just devnet` runs on this machine; `testnet` is the
48
+ * hosted Metal testnet.
49
+ */
50
+ export const METAL_NETWORKS: Record<MetalNetworkName, MetalNetwork> = {
51
+ devnet: {
52
+ name: "devnet",
53
+ network: "eip155:1337",
54
+ chainId: 1337,
55
+ rpc: { url: "http://127.0.0.1:8545" },
56
+ facilitatorUrl: "http://127.0.0.1:7090/x402/facilitator",
57
+ // Anvil account #10's first transaction, which `just deploy` and the e2e send.
58
+ accountFactory: "0xfbfbfDdd6e35dA57b7B0F9a2C10E34Be70B3A4E9",
59
+ },
60
+ testnet: {
61
+ name: "testnet",
62
+ network: "eip155:63825",
63
+ chainId: 63825,
64
+ rpc: { consoleUrl: "https://console-testnet.metalntwx.com" },
65
+ facilitatorUrl: "https://x402-testnet.metalntwx.com/x402/facilitator",
66
+ // What `just estate-deploy-account-factory testnet` recorded in
67
+ // deploy/estate/environments/testnet/account-factory.json for this chain.
68
+ accountFactory: "0xcEc6e13AC50078AE3f1e163d5fbd257DAcED8023",
69
+ },
70
+ };
71
+
72
+ /** Resolve a name or a CAIP-2 identifier to a Metal network; undefined for any other chain. */
73
+ export function metalNetwork(nameOrNetwork: string): MetalNetwork | undefined {
74
+ return Object.values(METAL_NETWORKS).find(
75
+ (n) => n.name === nameOrNetwork || n.network === nameOrNetwork,
76
+ );
77
+ }
78
+
79
+ /** A network name or a `MetalNetwork` as a `MetalNetwork`; throws for any other name. */
80
+ export function resolveNetwork(entry: MetalNetworkName | MetalNetwork): MetalNetwork {
81
+ if (typeof entry !== "string") return entry;
82
+ const found = metalNetwork(entry);
83
+ if (!found) throw new Error(`unknown Metal network: ${entry}`);
84
+ return found;
85
+ }
86
+
87
+ /**
88
+ * The JSON-RPC endpoint to reach `network` at: `override` when given, else the
89
+ * network's default. On a network without a default and no override it throws,
90
+ * saying how to get an endpoint from the console and, using `how` ("set
91
+ * METAL_RPC_URL"), how the caller passes it.
92
+ */
93
+ export function rpcUrlFor(network: MetalNetwork, override: string | undefined, how: string): string {
94
+ if (override !== undefined) return override;
95
+ if ("url" in network.rpc) return network.rpc.url;
96
+ throw new Error(
97
+ `${network.name} needs an RPC endpoint of your own: sign up at ${network.rpc.consoleUrl}, create an endpoint, then ${how}`,
98
+ );
99
+ }
100
+
101
+ /** What an RPC URL reads as in an error. */
102
+ const HIDDEN_RPC_URL = "[RPC URL hidden]";
103
+
104
+ /**
105
+ * viem's `http` transport to `rpcUrl`, whose errors do not carry the URL: it
106
+ * can hold the endpoint's credential, so each error it throws, and each of
107
+ * that error's causes, names it as `[RPC URL hidden]`. An endpoint that
108
+ * gives no answer, or a server error, is named as one that could not be
109
+ * reached.
110
+ */
111
+ export function rpcTransport(rpcUrl: string): HttpTransport {
112
+ const transport = http(rpcUrl);
113
+ const parsed = URL.canParse(rpcUrl) ? new URL(rpcUrl) : undefined;
114
+ const host = parsed?.host;
115
+ // What an endpoint can echo of the URL without its host: the path and
116
+ // query, which can hold a key, and the user and password.
117
+ const decoded = (text: string) => {
118
+ try {
119
+ return decodeURIComponent(text);
120
+ } catch {
121
+ return text;
122
+ }
123
+ };
124
+ const path = parsed ? parsed.pathname + parsed.search : "";
125
+ const secrets = [rpcUrl];
126
+ if (path.length > 1) secrets.push(path);
127
+ // A key is often echoed alone: each path segment and query value long enough to be one.
128
+ if (parsed) {
129
+ const parts = [...decoded(parsed.pathname).split("/"), ...[...parsed.searchParams.values()]];
130
+ for (const part of parts) if (part.length >= 8) secrets.push(part);
131
+ }
132
+ if (parsed?.username || parsed?.password) {
133
+ const [user, password] = [decoded(parsed.username), decoded(parsed.password)];
134
+ secrets.push(`${user}:${password}`);
135
+ if (password.length >= 4) secrets.push(password);
136
+ }
137
+ // viem prints the URL as given, or without its user and password.
138
+ const hide = (text: string) =>
139
+ secrets
140
+ .reduce((hidden, secret) => hidden.replaceAll(secret, HIDDEN_RPC_URL), text)
141
+ .replace(/https?:\/\/[^\s"'<>]+/g, (url) => (URL.canParse(url) && new URL(url).host === host ? HIDDEN_RPC_URL : url));
142
+ // Every string the error holds, in its fields, its causes, its arrays and
143
+ // its response headers, as viem or the endpoint put it there: the fields
144
+ // that name the URL are not a list to keep.
145
+ const scrub = (value: unknown, seen: Set<unknown>) => {
146
+ if (value === null || typeof value !== "object" || seen.has(value)) return;
147
+ seen.add(value);
148
+ for (const key of Object.getOwnPropertyNames(value)) {
149
+ const fields = value as Record<string, unknown>;
150
+ let field: unknown;
151
+ try {
152
+ field = fields[key];
153
+ } catch {
154
+ continue;
155
+ }
156
+ let hidden: unknown = field;
157
+ if (typeof field === "string") hidden = hide(field);
158
+ else if (field instanceof Headers) {
159
+ const entries = [...field].map(([name, text]) => [name, hide(text)] as [string, string]);
160
+ if (entries.some(([name, text]) => field.get(name) !== text)) hidden = new Headers(entries);
161
+ } else scrub(field, seen);
162
+ if (hidden !== field)
163
+ try {
164
+ fields[key] = hidden;
165
+ } catch {
166
+ // A property that cannot be written holds what it held.
167
+ }
168
+ }
169
+ };
170
+ return ((options) => {
171
+ const made = transport(options);
172
+ return {
173
+ ...made,
174
+ request: (async (args, requestOptions) => {
175
+ try {
176
+ return await made.request(args, requestOptions);
177
+ } catch (error) {
178
+ if (error instanceof TimeoutError || (error instanceof HttpRequestError && (error.status === undefined || error.status >= 500))) {
179
+ error.shortMessage = `the RPC endpoint could not be reached: ${error.shortMessage}`;
180
+ error.message = `the RPC endpoint could not be reached: ${error.message}`;
181
+ }
182
+ scrub(error, new Set());
183
+ throw error;
184
+ }
185
+ }) as typeof made.request,
186
+ };
187
+ }) as HttpTransport;
188
+ }
189
+
190
+ /** A dollar token payers authorize ERC-3009 transfers of. */
191
+ export interface DollarToken {
192
+ address: `0x${string}`;
193
+ symbol: string;
194
+ /** MIP20 `name()`: the EIP-712 domain name ERC-3009 signatures use. */
195
+ name: string;
196
+ /** MIP20's constant EIP-712 domain version. */
197
+ version: string;
198
+ decimals: number;
199
+ }
200
+
201
+ /** The default gas token and the asset every offer here is priced in. */
202
+ export const USDZ = {
203
+ address: "0x000000000000000000000000000000000000B5d5",
204
+ symbol: "*USDZ",
205
+ name: "USD Zero",
206
+ version: "1",
207
+ decimals: 18,
208
+ } as const satisfies DollarToken;
209
+
210
+ /**
211
+ * Convert a decimal amount of `token` ("0.01", 2, "$1.50") to base units as
212
+ * the string x402 requirements carry. Rejects more fractional digits than
213
+ * the token has; money is never rounded.
214
+ */
215
+ export function baseUnits(amount: string | number, token: DollarToken): string {
216
+ const text = String(amount).trim().replace(/^\$/, "");
217
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(text);
218
+ if (!match) throw new Error(`not a decimal ${token.symbol} amount: ${String(amount)}`);
219
+ const whole = match[1] ?? "0";
220
+ const fraction = match[2] ?? "";
221
+ if (fraction.length > token.decimals) {
222
+ throw new Error(`${token.symbol} has ${token.decimals} decimals; ${text} has ${fraction.length}`);
223
+ }
224
+ const units = BigInt(whole) * 10n ** BigInt(token.decimals) +
225
+ BigInt(fraction.padEnd(token.decimals, "0") || "0");
226
+ return units.toString();
227
+ }
228
+
229
+ /** `baseUnits` in `*USDZ`. */
230
+ export function usdzBaseUnits(amount: string | number): string {
231
+ return baseUnits(amount, USDZ);
232
+ }
233
+
234
+ /** Format base units back to a decimal `*USDZ` string for humans, trailing zeros trimmed. */
235
+ export function formatUsdz(amount: bigint | string): string {
236
+ const units = BigInt(amount);
237
+ const magnitude = units < 0n ? -units : units;
238
+ const scale = 10n ** BigInt(USDZ.decimals);
239
+ const whole = magnitude / scale;
240
+ const fraction = (magnitude % scale).toString().padStart(USDZ.decimals, "0").replace(/0+$/, "");
241
+ return `${units < 0n ? "-" : ""}${fraction ? `${whole}.${fraction}` : whole}`;
242
+ }
@@ -0,0 +1,24 @@
1
+ import { it, expect } from "vitest";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { openClearingWallet } from "./node.js";
6
+ it("retains one account and its payment state across restart, with exclusive ownership", async () => {
7
+ const directory = await mkdtemp(join(tmpdir(), "clearing-wallet-"));
8
+ try {
9
+ const wallet = await openClearingWallet(directory);
10
+ const publicKey = wallet.signer.publicKey;
11
+ await expect(openClearingWallet(directory)).rejects.toThrow(
12
+ "already in use",
13
+ );
14
+ const state = { entries: [], sequence: "0", payments: {} };
15
+ await wallet.store.save(state);
16
+ await wallet.close();
17
+ const reopened = await openClearingWallet(directory);
18
+ expect(reopened.signer.publicKey).toBe(publicKey);
19
+ expect(await reopened.store.load()).toEqual(state);
20
+ await reopened.close();
21
+ } finally {
22
+ await rm(directory, { recursive: true, force: true });
23
+ }
24
+ });