@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,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,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { METAL_NETWORKS, rpcUrlFor } from "./networks.js";
3
+
4
+ describe("rpcUrlFor", () => {
5
+ it("prefers the override, then the network's default RPC", () => {
6
+ expect(rpcUrlFor(METAL_NETWORKS.devnet, undefined, "set RPC")).toBe("http://127.0.0.1:8545");
7
+ expect(rpcUrlFor(METAL_NETWORKS.devnet, "http://override", "set RPC")).toBe("http://override");
8
+ expect(rpcUrlFor(METAL_NETWORKS.testnet, "http://override", "set RPC")).toBe("http://override");
9
+ });
10
+
11
+ it("says how to get an endpoint of your own on a network without a default", () => {
12
+ expect(() => rpcUrlFor(METAL_NETWORKS.testnet, undefined, "set METAL_RPC_URL")).toThrow(
13
+ 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`),
14
+ );
15
+ });
16
+ });
@@ -0,0 +1,152 @@
1
+ import type { Network } from "@x402/core/types";
2
+ import type { Address } 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: "0x4D188AC7815a9EAEa219a49D687B71898A487578",
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
+ /** A dollar token payers authorize ERC-3009 transfers of. */
102
+ export interface DollarToken {
103
+ address: `0x${string}`;
104
+ symbol: string;
105
+ /** MIP20 `name()`: the EIP-712 domain name ERC-3009 signatures use. */
106
+ name: string;
107
+ /** MIP20's constant EIP-712 domain version. */
108
+ version: string;
109
+ decimals: number;
110
+ }
111
+
112
+ /** The default gas token and the asset every offer here is priced in. */
113
+ export const USDZ = {
114
+ address: "0x000000000000000000000000000000000000B5d5",
115
+ symbol: "*USDZ",
116
+ name: "USD Zero",
117
+ version: "1",
118
+ decimals: 18,
119
+ } as const satisfies DollarToken;
120
+
121
+ /**
122
+ * Convert a decimal amount of `token` ("0.01", 2, "$1.50") to base units as
123
+ * the string x402 requirements carry. Rejects more fractional digits than
124
+ * the token has; money is never rounded.
125
+ */
126
+ export function baseUnits(amount: string | number, token: DollarToken): string {
127
+ const text = String(amount).trim().replace(/^\$/, "");
128
+ const match = /^(\d+)(?:\.(\d+))?$/.exec(text);
129
+ if (!match) throw new Error(`not a decimal ${token.symbol} amount: ${String(amount)}`);
130
+ const whole = match[1] ?? "0";
131
+ const fraction = match[2] ?? "";
132
+ if (fraction.length > token.decimals) {
133
+ throw new Error(`${token.symbol} has ${token.decimals} decimals; ${text} has ${fraction.length}`);
134
+ }
135
+ const units = BigInt(whole) * 10n ** BigInt(token.decimals) +
136
+ BigInt(fraction.padEnd(token.decimals, "0") || "0");
137
+ return units.toString();
138
+ }
139
+
140
+ /** `baseUnits` in `*USDZ`. */
141
+ export function usdzBaseUnits(amount: string | number): string {
142
+ return baseUnits(amount, USDZ);
143
+ }
144
+
145
+ /** Format base units back to a decimal `*USDZ` string for humans, trailing zeros trimmed. */
146
+ export function formatUsdz(amount: bigint | string): string {
147
+ const units = BigInt(amount);
148
+ const scale = 10n ** BigInt(USDZ.decimals);
149
+ const whole = units / scale;
150
+ const fraction = (units % scale).toString().padStart(USDZ.decimals, "0").replace(/0+$/, "");
151
+ return fraction ? `${whole}.${fraction}` : whole.toString();
152
+ }
@@ -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
+ });
package/src/node.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { mkdir, open, readFile, rename } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ import { createClearingSigner } from "./clearing-codec.js";
6
+ import type {
7
+ ClearingClientState,
8
+ ClearingClientStore,
9
+ } from "./clearing-client.js";
10
+ import type { Hex } from "viem";
11
+
12
+ /** A persistent P-256 account and receipt journal for a single server process. */
13
+ export async function openClearingWallet(directory: string) {
14
+ await mkdir(directory, { recursive: true, mode: 0o700 });
15
+ // SQLite holds an OS lock for the wallet lifetime and releases it on crash.
16
+ const owner = new DatabaseSync(join(directory, "owner.sqlite"));
17
+ try {
18
+ owner.exec(
19
+ "PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner (id INTEGER)",
20
+ );
21
+ } catch (error) {
22
+ owner.close();
23
+ throw new Error("clearing wallet is already in use", { cause: error });
24
+ }
25
+ try {
26
+ const keyPath = join(directory, "account.key");
27
+ let key: Hex;
28
+ try {
29
+ key = (await readFile(keyPath, "utf8")).trim() as Hex;
30
+ } catch (error) {
31
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
32
+ for (;;) {
33
+ key = `0x${randomBytes(32).toString("hex")}`;
34
+ try {
35
+ createClearingSigner(key);
36
+ break;
37
+ } catch {
38
+ /* Rejection sample the scalar. */
39
+ }
40
+ }
41
+ const file = await open(keyPath, "wx", 0o600);
42
+ try {
43
+ await file.writeFile(key);
44
+ await file.sync();
45
+ } finally {
46
+ await file.close();
47
+ }
48
+ }
49
+ const parent = await open(directory, "r");
50
+ try {
51
+ await parent.sync();
52
+ } finally {
53
+ await parent.close();
54
+ }
55
+ const signer = createClearingSigner(key);
56
+ const statePath = join(directory, "payments.json");
57
+ const store: ClearingClientStore = {
58
+ async load() {
59
+ try {
60
+ return JSON.parse(
61
+ await readFile(statePath, "utf8"),
62
+ ) as ClearingClientState;
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
65
+ throw error;
66
+ }
67
+ },
68
+ async save(state) {
69
+ const file = await open(statePath + ".next", "w", 0o600);
70
+ try {
71
+ await file.writeFile(JSON.stringify(state));
72
+ await file.sync();
73
+ } finally {
74
+ await file.close();
75
+ }
76
+ await rename(statePath + ".next", statePath);
77
+ const parent = await open(directory, "r");
78
+ try {
79
+ await parent.sync();
80
+ } finally {
81
+ await parent.close();
82
+ }
83
+ },
84
+ };
85
+ return {
86
+ signer,
87
+ store,
88
+ async close() {
89
+ owner.close();
90
+ },
91
+ };
92
+ } catch (error) {
93
+ owner.close();
94
+ throw error;
95
+ }
96
+ }