cascrow-signer 0.1.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 (52) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +35 -0
  4. package/README.md +130 -0
  5. package/SECURITY.md +71 -0
  6. package/dist/acceptance.d.ts +70 -0
  7. package/dist/acceptance.js +169 -0
  8. package/dist/actions.d.ts +60 -0
  9. package/dist/actions.js +319 -0
  10. package/dist/api.d.ts +83 -0
  11. package/dist/api.js +105 -0
  12. package/dist/book.d.ts +26 -0
  13. package/dist/book.js +62 -0
  14. package/dist/calldata.d.ts +62 -0
  15. package/dist/calldata.js +121 -0
  16. package/dist/chain.d.ts +59 -0
  17. package/dist/chain.js +233 -0
  18. package/dist/checks.d.ts +101 -0
  19. package/dist/checks.js +236 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +163 -0
  22. package/dist/context.d.ts +47 -0
  23. package/dist/context.js +50 -0
  24. package/dist/errors.d.ts +11 -0
  25. package/dist/errors.js +17 -0
  26. package/dist/files.d.ts +13 -0
  27. package/dist/files.js +78 -0
  28. package/dist/generated/milestone-escrow-v3.deployments.json +15 -0
  29. package/dist/generated/milestone-escrow-v3.json +1179 -0
  30. package/dist/generated/v3-actions.d.ts +49 -0
  31. package/dist/generated/v3-actions.js +102 -0
  32. package/dist/generated/v3-proof-manifest.d.ts +19 -0
  33. package/dist/generated/v3-proof-manifest.js +79 -0
  34. package/dist/journal.d.ts +41 -0
  35. package/dist/journal.js +186 -0
  36. package/dist/keystore.d.ts +30 -0
  37. package/dist/keystore.js +119 -0
  38. package/dist/link.d.ts +17 -0
  39. package/dist/link.js +65 -0
  40. package/dist/mcp.d.ts +10 -0
  41. package/dist/mcp.js +81 -0
  42. package/dist/network.d.ts +53 -0
  43. package/dist/network.js +122 -0
  44. package/dist/policy.d.ts +31 -0
  45. package/dist/policy.js +92 -0
  46. package/dist/remote.d.ts +31 -0
  47. package/dist/remote.js +104 -0
  48. package/dist/tools.d.ts +20 -0
  49. package/dist/tools.js +112 -0
  50. package/dist/watch.d.ts +35 -0
  51. package/dist/watch.js +129 -0
  52. package/package.json +64 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Which MilestoneEscrowV3 call a party may make right now — ONE matrix, read by
3
+ * `prepare` (server) and by the V3 panel (browser), design Rev. 10.
4
+ *
5
+ * WHY ONE MODULE. "Role × state → allowed call" written twice is the dominant
6
+ * defect of this codebase by construction: the button would offer what prepare
7
+ * refuses, or prepare would build what the contract reverts. The rules below are
8
+ * the contract's own `require`s (MilestoneEscrowV3.sol `release`, `object`,
9
+ * `extendDeadline`, `submitClaim`, `returnFunds`, `claim`, `refund`), evaluated
10
+ * over CHAIN facts (a `getEscrow` read), never over the milestone's app status —
11
+ * the payer's objection window must not depend on how fresh cascrow's index is.
12
+ *
13
+ * `fund` is not here: before funding there is no escrow to read, and whether a
14
+ * payer may fund is the acceptance's question (terms, acceptBy, gates).
15
+ * `withdraw` is account-level (`credit(account, token)`), not per escrow.
16
+ * `resolve` belongs to the arbiter, who calls the contract directly; cascrow
17
+ * offers no path for it. Pure, no V3 root, no signer: browser- and I9-safe.
18
+ */
19
+ export declare const V3_ESCROW_ACTIONS: readonly ["submitClaim", "claim", "release", "object", "refund", "extendDeadline", "returnFunds"];
20
+ export type V3EscrowAction = (typeof V3_ESCROW_ACTIONS)[number];
21
+ export type V3Role = "payer" | "payee" | "other";
22
+ /** The chain facts the matrix needs — the shape of a `getEscrow` read, times in unix seconds. */
23
+ export type V3ChainFacts = {
24
+ funded: boolean;
25
+ payer: string;
26
+ payee: string;
27
+ arbiter: string | null;
28
+ deadline: number;
29
+ reviewPeriod: number;
30
+ arbitrationPeriod: number;
31
+ claimedAt: number;
32
+ objected: boolean;
33
+ objectedAt: number;
34
+ claimCount: number;
35
+ };
36
+ export declare const V3_MAX_CLAIMS = 3;
37
+ export declare function v3RoleOf(address: string | null | undefined, f: Pick<V3ChainFacts, "payer" | "payee">): V3Role;
38
+ /** A claim is live: made and not objected (the contract's `_live`). */
39
+ export declare function v3ClaimLive(f: Pick<V3ChainFacts, "claimedAt" | "objected">): boolean;
40
+ /**
41
+ * Why `action` by `role` would revert at `nowSec`, or null if the contract accepts
42
+ * it. The reason strings are the contract's custom error names, so a client can
43
+ * map one vocabulary. `nowSec` is CHAIN time (latest block timestamp) — a local
44
+ * clock ahead of the chain would offer an objection the contract already refuses.
45
+ */
46
+ export declare function v3ActionBlocker(action: V3EscrowAction, role: V3Role, f: V3ChainFacts, nowSec: number): string | null;
47
+ export declare function allowedV3Actions(role: V3Role, f: V3ChainFacts, nowSec: number): V3EscrowAction[];
48
+ /** When the payer's objection window closes (unix seconds), or null without a live claim. */
49
+ export declare function v3ObjectionDeadline(f: Pick<V3ChainFacts, "claimedAt" | "objected" | "reviewPeriod">): number | null;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Which MilestoneEscrowV3 call a party may make right now — ONE matrix, read by
3
+ * `prepare` (server) and by the V3 panel (browser), design Rev. 10.
4
+ *
5
+ * WHY ONE MODULE. "Role × state → allowed call" written twice is the dominant
6
+ * defect of this codebase by construction: the button would offer what prepare
7
+ * refuses, or prepare would build what the contract reverts. The rules below are
8
+ * the contract's own `require`s (MilestoneEscrowV3.sol `release`, `object`,
9
+ * `extendDeadline`, `submitClaim`, `returnFunds`, `claim`, `refund`), evaluated
10
+ * over CHAIN facts (a `getEscrow` read), never over the milestone's app status —
11
+ * the payer's objection window must not depend on how fresh cascrow's index is.
12
+ *
13
+ * `fund` is not here: before funding there is no escrow to read, and whether a
14
+ * payer may fund is the acceptance's question (terms, acceptBy, gates).
15
+ * `withdraw` is account-level (`credit(account, token)`), not per escrow.
16
+ * `resolve` belongs to the arbiter, who calls the contract directly; cascrow
17
+ * offers no path for it. Pure, no V3 root, no signer: browser- and I9-safe.
18
+ */
19
+ export const V3_ESCROW_ACTIONS = [
20
+ "submitClaim",
21
+ "claim",
22
+ "release",
23
+ "object",
24
+ "refund",
25
+ "extendDeadline",
26
+ "returnFunds",
27
+ ];
28
+ export const V3_MAX_CLAIMS = 3;
29
+ export function v3RoleOf(address, f) {
30
+ const a = address?.toLowerCase();
31
+ if (!a)
32
+ return "other";
33
+ if (a === f.payer.toLowerCase())
34
+ return "payer";
35
+ if (a === f.payee.toLowerCase())
36
+ return "payee";
37
+ return "other";
38
+ }
39
+ /** A claim is live: made and not objected (the contract's `_live`). */
40
+ export function v3ClaimLive(f) {
41
+ return f.claimedAt !== 0 && !f.objected;
42
+ }
43
+ /**
44
+ * Why `action` by `role` would revert at `nowSec`, or null if the contract accepts
45
+ * it. The reason strings are the contract's custom error names, so a client can
46
+ * map one vocabulary. `nowSec` is CHAIN time (latest block timestamp) — a local
47
+ * clock ahead of the chain would offer an objection the contract already refuses.
48
+ */
49
+ export function v3ActionBlocker(action, role, f, nowSec) {
50
+ if (!f.funded)
51
+ return "NotFunded";
52
+ const live = v3ClaimLive(f);
53
+ const disputed = f.objected && f.arbiter !== null;
54
+ switch (action) {
55
+ case "release":
56
+ return role === "payer" ? null : "NotPayer";
57
+ case "extendDeadline":
58
+ return role === "payer" ? null : "NotPayer";
59
+ case "returnFunds":
60
+ return role === "payee" ? null : "NotPayee";
61
+ case "object":
62
+ if (role !== "payer")
63
+ return "NotPayer";
64
+ if (!live)
65
+ return "NoLiveClaim";
66
+ return nowSec >= f.claimedAt + f.reviewPeriod ? "TooLate" : null;
67
+ case "submitClaim":
68
+ if (role !== "payee")
69
+ return "NotPayee";
70
+ if (nowSec > f.deadline)
71
+ return "TooLate";
72
+ if (live)
73
+ return "ClaimLive";
74
+ if (disputed)
75
+ return "Disputed";
76
+ return f.claimCount >= V3_MAX_CLAIMS ? "ClaimLimitReached" : null;
77
+ case "claim":
78
+ // permissionless on chain; cascrow offers it to the parties only
79
+ if (role === "other")
80
+ return "NotParty";
81
+ if (!live)
82
+ return "NoLiveClaim";
83
+ return nowSec < f.claimedAt + f.reviewPeriod ? "TooEarly" : null;
84
+ case "refund":
85
+ if (role === "other")
86
+ return "NotParty";
87
+ if (nowSec <= f.deadline)
88
+ return "TooEarly";
89
+ if (live)
90
+ return "ClaimLive";
91
+ if (disputed && nowSec <= f.objectedAt + f.arbitrationPeriod)
92
+ return "TooEarly";
93
+ return null;
94
+ }
95
+ }
96
+ export function allowedV3Actions(role, f, nowSec) {
97
+ return V3_ESCROW_ACTIONS.filter((a) => v3ActionBlocker(a, role, f, nowSec) === null);
98
+ }
99
+ /** When the payer's objection window closes (unix seconds), or null without a live claim. */
100
+ export function v3ObjectionDeadline(f) {
101
+ return v3ClaimLive(f) ? f.claimedAt + f.reviewPeriod : null;
102
+ }
@@ -0,0 +1,19 @@
1
+ export type ManifestEvidence = {
2
+ kind: "file";
3
+ sha256: string;
4
+ } | {
5
+ kind: "text";
6
+ text: string;
7
+ } | {
8
+ kind: "github";
9
+ commitSha: string;
10
+ diffSha256: string;
11
+ };
12
+ /** Accepts "0x…" or bare hex in either case; returns lowercase 0x-bytes32 or throws. */
13
+ export declare function normaliseBytes32(h: string): string;
14
+ export declare function contentHashFor(e: ManifestEvidence): string;
15
+ export declare function milestoneIdHash(milestoneId: string): string;
16
+ /** Content hashes sorted and checked; throws on an empty set or a duplicate. */
17
+ export declare function sortedContentHashes(hashes: readonly string[]): string[];
18
+ export declare function manifestHash(milestoneId: string, contentHashes: readonly string[]): string;
19
+ export declare function manifestHashForEvidence(milestoneId: string, evidence: readonly ManifestEvidence[]): string;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The proofHash a MilestoneEscrowV3 payee puts on chain with `submitClaim`, and
3
+ * the one encoding every party recomputes it with (design Rev. 10, B2).
4
+ *
5
+ * WHY ONE MODULE. The server seals a V3Submission and stores this hash; the
6
+ * local signer (`cascrow-signer`) recomputes it from the files before it signs
7
+ * `submitClaim`; the browser recomputes it before it sends the transaction; a
8
+ * third party checking a claim recomputes it from the published evidence. Four
9
+ * implementations that disagree by one byte would make an honest claim match
10
+ * nothing — and the resolver treats "matches nothing" as `noVerdict`, which ends
11
+ * as the builder's failure. The vectors in `v3-proof-manifest.test.ts` are the
12
+ * contract the copies are tested against.
13
+ *
14
+ * manifestHash = keccak256(abi.encode(bytes32 keccak256(utf8(milestoneId)),
15
+ * bytes32[] sortedContentHashes))
16
+ *
17
+ * Content hash per evidence type (`contentHashFor`), each a lowercase 0x-bytes32:
18
+ * file sha256 of the bytes (Proof.fileHash)
19
+ * text sha256 of the UTF-8 evidence text (mcp/submit, agent-execution)
20
+ * github sha256(utf8(commitSha + ":" + diffSha256)) — never the URL, which
21
+ * names a PR that can change after hashing
22
+ *
23
+ * Sorting is bytewise over the lowercase hex, duplicates are refused (one file
24
+ * twice is one piece of evidence, and counting it twice would change the hash of
25
+ * the same submission). Pure, no V3 root, no signer: safe on either side of the
26
+ * I9 import graph and in the browser.
27
+ */
28
+ import { AbiCoder, keccak256, sha256, toUtf8Bytes } from "ethers";
29
+ const BYTES32 = /^0x[0-9a-f]{64}$/;
30
+ const HEX40 = /^[0-9a-f]{40}$/;
31
+ const HEX64 = /^[0-9a-f]{64}$/;
32
+ /** Accepts "0x…" or bare hex in either case; returns lowercase 0x-bytes32 or throws. */
33
+ export function normaliseBytes32(h) {
34
+ const s = h.toLowerCase().startsWith("0x") ? h.toLowerCase() : `0x${h.toLowerCase()}`;
35
+ if (!BYTES32.test(s))
36
+ throw new Error(`not a 32-byte hex value: ${h.slice(0, 80)}`);
37
+ return s;
38
+ }
39
+ export function contentHashFor(e) {
40
+ switch (e.kind) {
41
+ case "file":
42
+ return normaliseBytes32(e.sha256);
43
+ case "text":
44
+ return sha256(toUtf8Bytes(e.text)).toLowerCase();
45
+ case "github": {
46
+ const commit = e.commitSha.toLowerCase();
47
+ const diff = e.diffSha256.toLowerCase().replace(/^0x/, "");
48
+ if (!HEX40.test(commit) && !HEX64.test(commit))
49
+ throw new Error("commitSha must be a 40- or 64-hex git object id");
50
+ if (!HEX64.test(diff))
51
+ throw new Error("diffSha256 must be 64 hex characters");
52
+ return sha256(toUtf8Bytes(`${commit}:${diff}`)).toLowerCase();
53
+ }
54
+ }
55
+ }
56
+ export function milestoneIdHash(milestoneId) {
57
+ if (!milestoneId)
58
+ throw new Error("milestoneId is required");
59
+ return keccak256(toUtf8Bytes(milestoneId)).toLowerCase();
60
+ }
61
+ /** Content hashes sorted and checked; throws on an empty set or a duplicate. */
62
+ export function sortedContentHashes(hashes) {
63
+ if (hashes.length === 0)
64
+ throw new Error("a manifest needs at least one piece of evidence");
65
+ const out = hashes.map(normaliseBytes32).sort();
66
+ for (let i = 1; i < out.length; i++) {
67
+ if (out[i] === out[i - 1])
68
+ throw new Error(`duplicate evidence in manifest: ${out[i]}`);
69
+ }
70
+ return out;
71
+ }
72
+ export function manifestHash(milestoneId, contentHashes) {
73
+ const sorted = sortedContentHashes(contentHashes);
74
+ const encoded = AbiCoder.defaultAbiCoder().encode(["bytes32", "bytes32[]"], [milestoneIdHash(milestoneId), sorted]);
75
+ return keccak256(encoded).toLowerCase();
76
+ }
77
+ export function manifestHashForEvidence(milestoneId, evidence) {
78
+ return manifestHash(milestoneId, evidence.map(contentHashFor));
79
+ }
@@ -0,0 +1,41 @@
1
+ export declare const JOURNAL_FILE = "journal.jsonl";
2
+ export declare const LOCK_FILE = "journal.lock";
3
+ export declare const DAY_SECONDS: number;
4
+ export type Reservation = {
5
+ id: string;
6
+ action: string;
7
+ escrowId: string | null;
8
+ amountWei: bigint;
9
+ at: number;
10
+ released: boolean;
11
+ signed: {
12
+ hash: string;
13
+ nonce: number;
14
+ tx: Record<string, unknown>;
15
+ } | null;
16
+ sentHashes: string[];
17
+ unknown: boolean;
18
+ };
19
+ /** Run `fn` holding both locks. Every reservation, signature and send happens inside one of these. */
20
+ export declare function withLock<T>(home: string, fn: () => Promise<T>, timeoutMs?: number): Promise<T>;
21
+ export declare class Journal {
22
+ readonly home: string;
23
+ readonly file: string;
24
+ constructor(home: string);
25
+ exists(): boolean;
26
+ /** Create the journal (init, or after a deletion was reconciled against the chain). */
27
+ create(recreated?: boolean): void;
28
+ private append;
29
+ /** All reservations, folded. Throws JOURNAL_UNREADABLE on a corrupt line — a limit is never computed over a guess. */
30
+ read(): Reservation[];
31
+ reserve(action: string, escrowId: string | null, amountWei: bigint, nowSec: number): string;
32
+ signed(id: string, hash: string, nonce: number, tx: Record<string, unknown>): void;
33
+ sent(id: string, hash: string): void;
34
+ unknown(id: string, hash: string, reason: string): void;
35
+ release(id: string, reason: string): void;
36
+ /** Reservations that still count toward the rolling 24 h window. */
37
+ daySum(nowSec: number): {
38
+ sumWei: bigint;
39
+ escrowIds: Set<string>;
40
+ };
41
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * What this signer has committed to spend, and the lock every send happens under.
3
+ *
4
+ * ORDER, for every transaction: take the lock → reserve (the amount counts from
5
+ * here) → sign → send → record. A reservation is released only when it provably
6
+ * spent nothing: it failed before a signature existed, or its nonce was consumed
7
+ * by a different transaction. A transaction whose send answer was lost counts
8
+ * until its nonce is used — an unknown outcome is not a free one.
9
+ *
10
+ * TWO LOCKS. An in-process mutex (two tool calls of one MCP server) and an
11
+ * O_EXCL lockfile (two processes: the MCP server and a terminal). Without both,
12
+ * two parallel funds each read "0 spent today" and each pass the daily limit.
13
+ *
14
+ * THE DAY SUM is the journal's reservations in the last 24 h PLUS every
15
+ * `Funded(payer = me)` event in the same window that the journal does not know.
16
+ * A deleted journal therefore does not reset the limit: the chain still has the
17
+ * fundings. If the journal is gone AND the chain cannot be read, `fund` is
18
+ * refused — the limit is not guessed.
19
+ */
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { randomUUID } from "node:crypto";
23
+ import { refuse } from "./errors.js";
24
+ import { ensureHome } from "./files.js";
25
+ export const JOURNAL_FILE = "journal.jsonl";
26
+ export const LOCK_FILE = "journal.lock";
27
+ export const DAY_SECONDS = 24 * 3600;
28
+ const inProcess = new Map();
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ function pidAlive(pid) {
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ }
35
+ catch (e) {
36
+ return e.code === "EPERM";
37
+ }
38
+ }
39
+ async function acquireFileLock(home, timeoutMs) {
40
+ const lock = path.join(home, LOCK_FILE);
41
+ const started = Date.now();
42
+ for (;;) {
43
+ try {
44
+ const fd = fs.openSync(lock, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
45
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
46
+ fs.closeSync(fd);
47
+ return () => {
48
+ try {
49
+ fs.unlinkSync(lock);
50
+ }
51
+ catch {
52
+ /* already gone */
53
+ }
54
+ };
55
+ }
56
+ catch (e) {
57
+ if (e.code !== "EEXIST")
58
+ throw e;
59
+ }
60
+ // Held. A lock whose owner is dead is stale; anything else is waited for.
61
+ try {
62
+ const held = JSON.parse(fs.readFileSync(lock, "utf8"));
63
+ if (typeof held.pid === "number" && !pidAlive(held.pid)) {
64
+ fs.unlinkSync(lock);
65
+ continue;
66
+ }
67
+ }
68
+ catch {
69
+ /* being written right now — wait */
70
+ }
71
+ if (Date.now() - started > timeoutMs) {
72
+ refuse("JOURNAL_LOCKED", `another cascrow-signer process holds ${lock}; nothing was signed. If no signer is running, delete that file.`);
73
+ }
74
+ await sleep(50);
75
+ }
76
+ }
77
+ /** Run `fn` holding both locks. Every reservation, signature and send happens inside one of these. */
78
+ export async function withLock(home, fn, timeoutMs = 30_000) {
79
+ ensureHome(home);
80
+ const prev = inProcess.get(home) ?? Promise.resolve();
81
+ let release;
82
+ const mine = new Promise((r) => (release = r));
83
+ const chain = prev.then(() => mine);
84
+ inProcess.set(home, chain);
85
+ await prev.catch(() => undefined);
86
+ try {
87
+ const unlockFile = await acquireFileLock(home, timeoutMs);
88
+ try {
89
+ return await fn();
90
+ }
91
+ finally {
92
+ unlockFile();
93
+ }
94
+ }
95
+ finally {
96
+ release();
97
+ if (inProcess.get(home) === chain)
98
+ inProcess.delete(home);
99
+ }
100
+ }
101
+ export class Journal {
102
+ home;
103
+ file;
104
+ constructor(home) {
105
+ this.home = home;
106
+ this.file = path.join(home, JOURNAL_FILE);
107
+ }
108
+ exists() {
109
+ return fs.existsSync(this.file);
110
+ }
111
+ /** Create the journal (init, or after a deletion was reconciled against the chain). */
112
+ create(recreated = false) {
113
+ if (this.exists())
114
+ return;
115
+ const fd = fs.openSync(this.file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
116
+ fs.writeSync(fd, JSON.stringify({ t: "header", v: 1, at: Math.floor(Date.now() / 1000), ...(recreated ? { recreated: true } : {}) }) + "\n");
117
+ fs.closeSync(fd);
118
+ }
119
+ append(e) {
120
+ fs.appendFileSync(this.file, JSON.stringify(e) + "\n", { mode: 0o600 });
121
+ }
122
+ /** All reservations, folded. Throws JOURNAL_UNREADABLE on a corrupt line — a limit is never computed over a guess. */
123
+ read() {
124
+ if (!this.exists())
125
+ return [];
126
+ const out = new Map();
127
+ const lines = fs.readFileSync(this.file, "utf8").split("\n").filter((l) => l.trim());
128
+ for (const [i, line] of lines.entries()) {
129
+ let e;
130
+ try {
131
+ e = JSON.parse(line);
132
+ }
133
+ catch {
134
+ refuse("JOURNAL_UNREADABLE", `the spending journal ${this.file} is corrupt at line ${i + 1}; funding is refused until it is repaired`);
135
+ }
136
+ if (e.t === "header")
137
+ continue;
138
+ if (e.t === "reserve") {
139
+ out.set(e.id, { id: e.id, action: e.action, escrowId: e.escrowId, amountWei: BigInt(e.amountWei), at: e.at, released: false, signed: null, sentHashes: [], unknown: false });
140
+ continue;
141
+ }
142
+ const r = out.get(e.id);
143
+ if (!r)
144
+ continue;
145
+ if (e.t === "signed")
146
+ r.signed = { hash: e.hash, nonce: e.nonce, tx: e.tx };
147
+ else if (e.t === "sent")
148
+ r.sentHashes.push(e.hash);
149
+ else if (e.t === "release")
150
+ r.released = true;
151
+ else if (e.t === "unknown")
152
+ r.unknown = true;
153
+ }
154
+ return [...out.values()];
155
+ }
156
+ reserve(action, escrowId, amountWei, nowSec) {
157
+ const id = randomUUID();
158
+ this.append({ t: "reserve", id, action, escrowId, amountWei: amountWei.toString(), at: nowSec });
159
+ return id;
160
+ }
161
+ signed(id, hash, nonce, tx) {
162
+ this.append({ t: "signed", id, hash, nonce, tx });
163
+ }
164
+ sent(id, hash) {
165
+ this.append({ t: "sent", id, hash });
166
+ }
167
+ unknown(id, hash, reason) {
168
+ this.append({ t: "unknown", id, hash, reason: reason.slice(0, 200) });
169
+ }
170
+ release(id, reason) {
171
+ this.append({ t: "release", id, reason: reason.slice(0, 200) });
172
+ }
173
+ /** Reservations that still count toward the rolling 24 h window. */
174
+ daySum(nowSec) {
175
+ let sum = BigInt(0);
176
+ const ids = new Set();
177
+ for (const r of this.read()) {
178
+ if (r.action !== "fund" || r.released || r.at <= nowSec - DAY_SECONDS)
179
+ continue;
180
+ sum += r.amountWei;
181
+ if (r.escrowId)
182
+ ids.add(r.escrowId.toLowerCase());
183
+ }
184
+ return { sumWei: sum, escrowIds: ids };
185
+ }
186
+ }
@@ -0,0 +1,30 @@
1
+ import type { TransactionLike, TypedDataDomain, TypedDataField } from "ethers";
2
+ export declare const KEYSTORE_FILE = "keystore.json";
3
+ export type PassphraseEnv = Record<string, string | undefined>;
4
+ /** Read the passphrase from the environment (file first), then remove it from the environment. */
5
+ export declare function passphraseFromEnv(env?: PassphraseEnv): string | null;
6
+ /**
7
+ * Take both passphrase variables OUT of the environment now and read them later,
8
+ * once. The CLI calls this before it parses its command (post-review H3): every
9
+ * command, including `watch`, which needs no passphrase and so never read — and
10
+ * never deleted — the variables, while its `--exec` child inherited them.
11
+ */
12
+ export declare function takePassphraseEnv(env?: PassphraseEnv): () => string | null;
13
+ export type UnlockedKey = {
14
+ readonly address: string;
15
+ signTransaction(tx: TransactionLike): Promise<string>;
16
+ signTypedData(domain: TypedDataDomain, types: Record<string, TypedDataField[]>, value: Record<string, unknown>): Promise<string>;
17
+ signMessage(message: string): Promise<string>;
18
+ /** HMAC key for policy and book files, derived from the private key; never the key itself. */
19
+ readonly macKey: Buffer;
20
+ };
21
+ export declare function keystorePath(home: string): string;
22
+ /** Generate a new key locally and store it encrypted. Refuses to overwrite an existing keystore. */
23
+ export declare function createKeystore(home: string, passphrase: string, opts?: {
24
+ scryptN?: number;
25
+ }): Promise<{
26
+ address: string;
27
+ }>;
28
+ /** The address stored in the keystore, read without the passphrase. */
29
+ export declare function keystoreAddress(home: string): string;
30
+ export declare function unlock(home: string, passphrase: string | null): Promise<UnlockedKey>;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The ONLY module that builds a `Wallet`. The key is generated here, encrypted
3
+ * here (ethers' scrypt keystore, `Wallet.encrypt`), and used here — it is never
4
+ * returned, logged, printed or sent. What leaves this module is an address, a
5
+ * signature, a signed transaction bound for the public RPC (network.ts), and a
6
+ * MAC key DERIVED from the private key for the policy file.
7
+ *
8
+ * Passphrase sources, in order: an explicit value (the CLI's TTY prompt passes
9
+ * it here), CASCROW_KEYSTORE_PASSPHRASE_FILE (must be 0600), then
10
+ * CASCROW_KEYSTORE_PASSPHRASE. Both variables are DELETED from process.env once
11
+ * read — and the CLI takes them out at start for every command
12
+ * (`takePassphraseEnv`), so a child process (`watch --exec`) never inherits them.
13
+ *
14
+ * No OS keychain on purpose: it needs a native dependency, and this package has
15
+ * exactly one runtime dependency so its supply chain can be read in an afternoon.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { createHmac } from "node:crypto";
20
+ import { Transaction, Wallet, encryptKeystoreJson, getBytes } from "ethers";
21
+ import { refuse } from "./errors.js";
22
+ import { assertPrivateFile, ensureHome, readPrivateFile, writeNewPrivateFile } from "./files.js";
23
+ export const KEYSTORE_FILE = "keystore.json";
24
+ /** Read the passphrase from the environment (file first), then remove it from the environment. */
25
+ export function passphraseFromEnv(env = process.env) {
26
+ const file = env.CASCROW_KEYSTORE_PASSPHRASE_FILE?.trim();
27
+ const direct = env.CASCROW_KEYSTORE_PASSPHRASE;
28
+ delete env.CASCROW_KEYSTORE_PASSPHRASE_FILE;
29
+ delete env.CASCROW_KEYSTORE_PASSPHRASE;
30
+ if (file) {
31
+ assertPrivateFile(file);
32
+ const v = fs.readFileSync(file, "utf8").replace(/\r?\n$/, "");
33
+ if (!v)
34
+ refuse("PASSPHRASE_REQUIRED", `the passphrase file ${file} is empty`);
35
+ return v;
36
+ }
37
+ return direct && direct.length > 0 ? direct : null;
38
+ }
39
+ /**
40
+ * Take both passphrase variables OUT of the environment now and read them later,
41
+ * once. The CLI calls this before it parses its command (post-review H3): every
42
+ * command, including `watch`, which needs no passphrase and so never read — and
43
+ * never deleted — the variables, while its `--exec` child inherited them.
44
+ */
45
+ export function takePassphraseEnv(env = process.env) {
46
+ const snapshot = {
47
+ CASCROW_KEYSTORE_PASSPHRASE_FILE: env.CASCROW_KEYSTORE_PASSPHRASE_FILE,
48
+ CASCROW_KEYSTORE_PASSPHRASE: env.CASCROW_KEYSTORE_PASSPHRASE,
49
+ };
50
+ delete env.CASCROW_KEYSTORE_PASSPHRASE_FILE;
51
+ delete env.CASCROW_KEYSTORE_PASSPHRASE;
52
+ let read = false;
53
+ let value = null;
54
+ return () => {
55
+ if (!read) {
56
+ value = passphraseFromEnv(snapshot);
57
+ read = true;
58
+ }
59
+ return value;
60
+ };
61
+ }
62
+ function wrap(w) {
63
+ const macKey = createHmac("sha256", Buffer.from(getBytes(w.privateKey))).update("cascrow-signer/local-files-mac/v1").digest();
64
+ return Object.freeze({
65
+ address: w.address,
66
+ signTransaction: (tx) => w.signTransaction(Transaction.from(tx)),
67
+ signTypedData: (d, t, v) => w.signTypedData(d, t, v),
68
+ signMessage: (m) => w.signMessage(m),
69
+ macKey,
70
+ });
71
+ }
72
+ export function keystorePath(home) {
73
+ return path.join(home, KEYSTORE_FILE);
74
+ }
75
+ /** Generate a new key locally and store it encrypted. Refuses to overwrite an existing keystore. */
76
+ export async function createKeystore(home, passphrase, opts = {}) {
77
+ if (!passphrase || passphrase.length < 12)
78
+ refuse("PASSPHRASE_WEAK", "the passphrase must be at least 12 characters");
79
+ ensureHome(home);
80
+ const file = keystorePath(home);
81
+ if (fs.existsSync(file))
82
+ refuse("KEYSTORE_EXISTS", `a keystore already exists at ${file}; the signer never overwrites one`);
83
+ const w = Wallet.createRandom();
84
+ const json = opts.scryptN
85
+ ? await encryptKeystoreJson({ address: w.address, privateKey: w.privateKey }, passphrase, { scrypt: { N: opts.scryptN } })
86
+ : await w.encrypt(passphrase);
87
+ writeNewPrivateFile(file, json);
88
+ return { address: w.address };
89
+ }
90
+ /** The address stored in the keystore, read without the passphrase. */
91
+ export function keystoreAddress(home) {
92
+ const raw = readKeystore(home);
93
+ const addr = JSON.parse(raw).address;
94
+ if (!addr || !/^(0x)?[0-9a-fA-F]{40}$/.test(addr))
95
+ refuse("KEYSTORE_UNREADABLE", "the keystore has no address field");
96
+ return addr.startsWith("0x") ? addr : `0x${addr}`;
97
+ }
98
+ function readKeystore(home) {
99
+ ensureHome(home);
100
+ const file = keystorePath(home);
101
+ if (!fs.existsSync(file))
102
+ refuse("KEYSTORE_MISSING", `no keystore at ${file} — run: cascrow-signer init`);
103
+ return readPrivateFile(file);
104
+ }
105
+ export async function unlock(home, passphrase) {
106
+ if (!passphrase) {
107
+ refuse("PASSPHRASE_REQUIRED", "the keystore passphrase is required (TTY prompt, CASCROW_KEYSTORE_PASSPHRASE_FILE or CASCROW_KEYSTORE_PASSPHRASE)");
108
+ }
109
+ const raw = readKeystore(home);
110
+ let w;
111
+ try {
112
+ const decrypted = await Wallet.fromEncryptedJson(raw, passphrase);
113
+ w = decrypted instanceof Wallet ? decrypted : new Wallet(decrypted.privateKey);
114
+ }
115
+ catch {
116
+ refuse("PASSPHRASE_WRONG", "the keystore could not be decrypted with this passphrase");
117
+ }
118
+ return wrap(w);
119
+ }
package/dist/link.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { SignerContext } from "./context.js";
2
+ export declare const CHALLENGE_PREFIX = "Sign this message to prove wallet ownership for Cascrow: ";
3
+ /** The nonce the challenge token carries, read from its payload (the server verifies the token; we only read it). */
4
+ export declare function challengeNonce(token: string): string;
5
+ /** Refuses anything but the exact challenge sentence for this token's nonce. */
6
+ export declare function checkChallenge(message: unknown, token: unknown): string;
7
+ export declare function link(ctx: SignerContext): Promise<{
8
+ linked: boolean;
9
+ walletAddress: string;
10
+ changed: boolean;
11
+ message: string;
12
+ } | {
13
+ linked: boolean;
14
+ walletAddress: string;
15
+ changed: boolean;
16
+ message?: undefined;
17
+ }>;