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
package/dist/checks.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Every check the signer makes before a signature exists — pure functions, no
3
+ * I/O, one arm per property, each named in its refusal.
4
+ *
5
+ * WHY THESE AND NOT "RE-ENCODE AND COMPARE". Rebuilding `data` from `call.args`
6
+ * and comparing proves only that the server agrees with itself: `call.args`
7
+ * comes from the same server (design Rev. 10, finding 5). So the checks below
8
+ * compare the transaction against values the signer knows INDEPENDENTLY — its own
9
+ * address, the pinned chain and contract, its local policy and escrow book, the
10
+ * chain's own escrow record and clock, and the payee's signature under the
11
+ * pinned domain. Re-encoding is still done (an envelope whose `data` does not
12
+ * say what `call` says is refused outright), but it is the first arm, not the
13
+ * guarantee.
14
+ *
15
+ * The role × phase rule is the app's own matrix, byte-copied
16
+ * (generated/v3-actions.ts) — the signer and `prepare` cannot disagree about
17
+ * whether the payer may still object.
18
+ */
19
+ import { ZeroAddress, getAddress, isAddress, verifyTypedData } from "ethers";
20
+ import { refuse } from "./errors.js";
21
+ import { ACCEPTANCE_TYPES, ACTION_FN, PER_ESCROW_ACTIONS, V3_LIMITS, acceptanceDomain, canonicalArgs, encodeCall, escrowIdFor, } from "./calldata.js";
22
+ import { v3ActionBlocker, v3RoleOf } from "./generated/v3-actions.js";
23
+ const lc = (s) => String(s).toLowerCase();
24
+ function fail(code, reasons) {
25
+ if (reasons.length)
26
+ refuse(code, `refused before signing: ${reasons.join("; ")}`);
27
+ }
28
+ /**
29
+ * The envelope of a prepared transaction against what the signer knows: chain and
30
+ * contract from its own pinned config, the sender from its own key, the function
31
+ * from the action it was asked to perform, and `data` re-encoded from the ABI.
32
+ */
33
+ export function checkPreparedEnvelope(p, action, pinned, me) {
34
+ const x = p;
35
+ const r = [];
36
+ if (!x || typeof x !== "object" || !x.call || typeof x.call !== "object" || !Array.isArray(x.call.args)) {
37
+ refuse("PREPARED_MISMATCH", "refused before signing: the prepared transaction has no call");
38
+ }
39
+ if (x.chainId !== pinned.chainId)
40
+ r.push(`chainId ${String(x.chainId)} is not the pinned ${pinned.chainId}`);
41
+ if (lc(x.to) !== lc(pinned.address))
42
+ r.push(`to ${String(x.to)} is not the pinned contract ${pinned.address}`);
43
+ if (x.action !== action)
44
+ r.push(`action ${String(x.action)} is not the requested ${action}`);
45
+ if (x.call.fn !== ACTION_FN[action])
46
+ r.push(`call.fn ${String(x.call.fn)} is not ${ACTION_FN[action]}`);
47
+ if (!x.expect || lc(x.expect.from) !== lc(me))
48
+ r.push(`expect.from ${String(x.expect?.from)} is not this key ${me}`);
49
+ if (!/^\d+$/.test(String(x.value)))
50
+ r.push("value is not a decimal wei string");
51
+ else if (action !== "fund" && BigInt(x.value) !== BigInt(0))
52
+ r.push(`value ${x.value} must be 0 for ${action}`);
53
+ fail("PREPARED_MISMATCH", r);
54
+ let encoded = null;
55
+ try {
56
+ encoded = encodeCall(x.call.fn, x.call.args);
57
+ }
58
+ catch {
59
+ r.push("call.args do not encode under the pinned ABI");
60
+ }
61
+ if (encoded !== null && encoded !== lc(x.data))
62
+ r.push("data is not what call.fn(call.args) encodes to");
63
+ if (PER_ESCROW_ACTIONS.has(action) && lc(x.call.args[0]) !== lc(x.escrowId))
64
+ r.push("call.args[0] is not the escrowId");
65
+ if (action === "withdraw" && lc(x.call.args[0]) !== lc(me))
66
+ r.push("withdraw pays an account that is not this key");
67
+ if (action === "withdraw" && lc(x.call.args[1]) !== lc(ZeroAddress))
68
+ r.push("withdraw names a token other than native XRP");
69
+ fail("PREPARED_MISMATCH", r);
70
+ return x;
71
+ }
72
+ /** A locally built call and the server's prepared one must be the same transaction. */
73
+ export function checkMatchesLocal(local, p) {
74
+ const r = [];
75
+ if (lc(local.to) !== lc(p.to))
76
+ r.push("prepare names a different contract than the signer built for");
77
+ if (lc(local.data) !== lc(p.data))
78
+ r.push("prepare's data differs from the calldata the signer built itself");
79
+ if (BigInt(p.value) !== local.value)
80
+ r.push("prepare's value differs from the signer's");
81
+ fail("PREPARE_DISAGREES", r);
82
+ }
83
+ export function fundTermsFrom(p) {
84
+ const [ref, salt, t, payeeSig] = canonicalArgs("fund", p.call.args);
85
+ return {
86
+ ref,
87
+ salt,
88
+ t: {
89
+ payee: getAddress(t.payee),
90
+ token: getAddress(t.token),
91
+ amount: BigInt(t.amount),
92
+ deadline: BigInt(t.deadline),
93
+ reviewPeriod: Number(t.reviewPeriod),
94
+ arbiter: getAddress(t.arbiter),
95
+ arbitrationPeriod: Number(t.arbitrationPeriod),
96
+ acceptBy: BigInt(t.acceptBy),
97
+ },
98
+ payeeSig,
99
+ };
100
+ }
101
+ /** Who signed this acceptance, under the PINNED domain (never the server's). Null when it does not recover. */
102
+ export function acceptanceSigner(pinned, escrowId, t, sig) {
103
+ try {
104
+ return verifyTypedData(acceptanceDomain(pinned.chainId, pinned.address), ACCEPTANCE_TYPES, { escrowId, ...t }, sig);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /** Seconds a fund needs between signing and `acceptBy` so it can still land. */
111
+ export const ACCEPT_BY_MARGIN_SECONDS = 120;
112
+ /** The local invariants for `fund` (design Rev. 10, "Lokale Invarianten für fund"). Returns whether the payee is a known counterparty. */
113
+ export function checkFund(i) {
114
+ const { terms: f, policy: pol } = i;
115
+ const r = [];
116
+ const me = getAddress(i.me);
117
+ const id = escrowIdFor(me, f.ref, f.salt);
118
+ if (lc(id) !== lc(i.prepared.escrowId))
119
+ r.push("escrowIdFor(this key, ref, salt) is not the prepared escrowId");
120
+ if (i.slotStatus !== 0)
121
+ r.push("that escrow slot is already taken on chain");
122
+ if (f.t.token !== ZeroAddress)
123
+ r.push("token is not native XRP");
124
+ if (BigInt(i.prepared.value) !== f.t.amount)
125
+ r.push("value is not the escrowed amount");
126
+ if (f.t.amount <= BigInt(0))
127
+ r.push("amount is zero");
128
+ if (f.t.payee === ZeroAddress || f.t.payee === me)
129
+ r.push("payee is empty or this key");
130
+ const payeeKnown = pol.payees.includes(f.t.payee.toLowerCase());
131
+ const limitWei = BigInt(payeeKnown ? pol.perEscrowMaxWei : pol.unknownPayeePerEscrowMaxWei);
132
+ if (f.t.amount > limitWei) {
133
+ r.push(`amount exceeds the local per-escrow limit${payeeKnown ? "" : " for a payee not in the local counterparty book"}`);
134
+ }
135
+ if (i.maxAmountWei !== undefined && i.maxAmountWei !== null && f.t.amount > i.maxAmountWei)
136
+ r.push("amount exceeds the maximum named for this call");
137
+ if (i.daySumWei + f.t.amount > BigInt(pol.dailyMaxWei))
138
+ r.push("amount would exceed the local 24-hour limit");
139
+ if (f.t.arbiter !== ZeroAddress && !pol.arbiters.includes(f.t.arbiter.toLowerCase()))
140
+ r.push("the arbiter is not one the local policy accepts");
141
+ if (f.t.arbiter === ZeroAddress && f.t.arbitrationPeriod !== 0)
142
+ r.push("arbitrationPeriod without an arbiter");
143
+ if (f.t.arbiter !== ZeroAddress && (f.t.arbitrationPeriod < V3_LIMITS.MIN_ARBITRATION_SECONDS || f.t.arbitrationPeriod > V3_LIMITS.MAX_ARBITRATION_SECONDS)) {
144
+ r.push("arbitrationPeriod outside the contract's bounds");
145
+ }
146
+ if (f.t.reviewPeriod < pol.minReviewSeconds)
147
+ r.push("the review period is shorter than the local minimum");
148
+ if (f.t.reviewPeriod > V3_LIMITS.MAX_REVIEW_SECONDS)
149
+ r.push("the review period exceeds the contract's maximum");
150
+ const now = BigInt(i.chainNowSec);
151
+ if (f.t.deadline <= now)
152
+ r.push("the deadline has passed on chain");
153
+ if (f.t.deadline > now + BigInt(V3_LIMITS.MAX_DEADLINE_SECONDS))
154
+ r.push("the deadline is beyond the contract's maximum duration");
155
+ if (f.t.acceptBy < now + BigInt(ACCEPT_BY_MARGIN_SECONDS))
156
+ r.push("the acceptance has expired on chain (or expires within two minutes)");
157
+ const signer = acceptanceSigner(i.pinned, id, f.t, f.payeeSig);
158
+ if (!signer || signer !== f.t.payee)
159
+ r.push("payeeSig is not the payee's signature over these terms under the pinned domain");
160
+ fail("FUND_CHECK_FAILED", r);
161
+ return { payeeKnown, limitWei };
162
+ }
163
+ export function chainFacts(e) {
164
+ return {
165
+ funded: e.status === 1,
166
+ payer: e.payer,
167
+ payee: e.payee,
168
+ arbiter: e.arbiter === ZeroAddress ? null : e.arbiter,
169
+ deadline: e.deadline,
170
+ reviewPeriod: e.reviewPeriod,
171
+ arbitrationPeriod: e.arbitrationPeriod,
172
+ claimedAt: e.claimedAt,
173
+ objected: e.objected,
174
+ objectedAt: e.objectedAt,
175
+ claimCount: e.claimCount,
176
+ };
177
+ }
178
+ /**
179
+ * Actions the payer may only sign for an escrow in the local book: the two that
180
+ * move the payer's money TOWARDS the payee or lengthen its lock. `object` is not
181
+ * one of them: it is the payer's veto with a clock, it moves nothing, and it is
182
+ * gated on the CHAIN alone (the on-chain payer must be this key) — a deleted or
183
+ * tampered book must never cost a payer the objection window (post-review H1).
184
+ * Only these actions read the book at all; every other action reads it, if at
185
+ * all, only to find an escrow ID, and a broken book is a loud note there.
186
+ */
187
+ export const BOOK_BOUND_ACTIONS = new Set(["release", "extendDeadline"]);
188
+ /**
189
+ * A post-funding action against the CHAIN's record of the escrow and the chain's
190
+ * clock: the app's role × phase matrix, the local book for the payer's money
191
+ * moves, and the action's own argument.
192
+ */
193
+ export function checkEscrowAction(action, me, e, chainNowSec, opts) {
194
+ const facts = chainFacts(e);
195
+ const role = v3RoleOf(me, facts);
196
+ const blocker = v3ActionBlocker(action, role, facts, chainNowSec);
197
+ if (blocker)
198
+ refuse("PHASE", `the escrow does not allow ${action} for you right now: ${blocker}`);
199
+ const r = [];
200
+ // The matrix already names the payer by the chain; say it in this module's words too,
201
+ // so the objection's only gate is visible where it is enforced.
202
+ if (action === "object" && lc(e.payer) !== lc(me))
203
+ r.push("the on-chain payer is not this key");
204
+ if (BOOK_BOUND_ACTIONS.has(action)) {
205
+ if (!opts.book)
206
+ r.push("this escrow is not in the local book — the signer only moves money on escrows it funded itself");
207
+ else {
208
+ if (opts.book.role !== "payer")
209
+ r.push("the local book records this key as payee, not payer");
210
+ if (lc(e.payer) !== lc(me))
211
+ r.push("the on-chain payer is not this key");
212
+ if (lc(e.payee) !== opts.book.payee)
213
+ r.push("the on-chain payee differs from the payee in the local book");
214
+ }
215
+ }
216
+ if (action === "extendDeadline") {
217
+ const nd = opts.newDeadline ?? null;
218
+ if (nd === null)
219
+ r.push("newDeadline is required");
220
+ else if (nd <= BigInt(e.deadline))
221
+ r.push("newDeadline must be after the current deadline");
222
+ else if (nd > BigInt(chainNowSec + V3_LIMITS.MAX_DEADLINE_SECONDS))
223
+ r.push("newDeadline is beyond the contract's maximum duration");
224
+ }
225
+ if (action === "submitClaim" && !/^0x[0-9a-f]{64}$/.test(lc(opts.proofHash ?? "")))
226
+ r.push("proofHash must be a 32-byte hex value (the manifestHash of a sealed submission)");
227
+ if (action === "object" && !/^0x[0-9a-f]{64}$/.test(lc(opts.reasonHash ?? "")))
228
+ r.push("reasonHash must be a 32-byte hex value");
229
+ fail("CHECK_FAILED", r);
230
+ }
231
+ export function checkWithdraw(me, credit) {
232
+ if (!isAddress(me))
233
+ refuse("CHECK_FAILED", "withdraw needs this key's address");
234
+ if (credit <= BigInt(0))
235
+ refuse("PHASE", "there is no credit to withdraw for this key: NothingToWithdraw");
236
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cascrow-signer — command line. `cascrow-signer help` lists the commands.
4
+ *
5
+ * Output: one JSON object on stdout per command; diagnostics, warnings and
6
+ * alarms on stderr. Neither ever carries the key, the passphrase, the keystore
7
+ * or a signed transaction.
8
+ */
9
+ import { createInterface } from "node:readline";
10
+ import { Writable } from "node:stream";
11
+ import { SignerError } from "./errors.js";
12
+ import { SIGNER_VERSION, configFromEnv, createSigner, initSigner } from "./api.js";
13
+ import { takePassphraseEnv } from "./keystore.js";
14
+ import { signerHome } from "./files.js";
15
+ import { serveStdio } from "./mcp.js";
16
+ function parseArgs(argv) {
17
+ const out = { _: [], flags: {} };
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const a = argv[i];
20
+ if (a.startsWith("--")) {
21
+ const [k, inline] = a.slice(2).split("=", 2);
22
+ const v = inline ?? (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true");
23
+ (out.flags[k] ??= []).push(v);
24
+ }
25
+ else
26
+ out._.push(a);
27
+ }
28
+ return out;
29
+ }
30
+ const one = (a, k) => a.flags[k]?.[a.flags[k].length - 1];
31
+ function ttyIo() {
32
+ const isTTY = !!process.stdin.isTTY && !!process.stderr.isTTY;
33
+ const ask = (q, hidden = false) => new Promise((resolve) => {
34
+ let muted = false;
35
+ const output = new Writable({
36
+ write(chunk, _enc, cb) {
37
+ if (!muted)
38
+ process.stderr.write(chunk);
39
+ cb();
40
+ },
41
+ });
42
+ const rl = createInterface({ input: process.stdin, output, terminal: true });
43
+ rl.question(q, (v) => {
44
+ rl.close();
45
+ if (hidden)
46
+ process.stderr.write("\n");
47
+ resolve(v);
48
+ });
49
+ muted = hidden;
50
+ });
51
+ return {
52
+ isTTY,
53
+ err: (l) => process.stderr.write(`${l}\n`),
54
+ prompt: isTTY ? ask : undefined,
55
+ confirm: isTTY ? async (q) => /^y(es)?$/i.test((await ask(`${q} [y/N] `)).trim()) : undefined,
56
+ };
57
+ }
58
+ const HELP = `cascrow-signer ${SIGNER_VERSION} — your MilestoneEscrowV3 key, on your machine.
59
+
60
+ Setup
61
+ init create a key and its encrypted keystore (~/.cascrow/signer)
62
+ policy show | policy set --per-escrow-xrp N --daily-xrp N [--unknown-payee-xrp N]
63
+ [--min-review-hours H] [--payee 0x…]… [--arbiter 0x…]… (terminal only)
64
+ link link this address to your cascrow account (CASCROW_API_KEY)
65
+ status
66
+
67
+ As payee As payer
68
+ accept --milestone ID fund --milestone ID [--max-xrp N]
69
+ submit-claim --milestone ID object --escrow ID --reason TEXT
70
+ --proof-hash 0x… | --file PATH release --escrow ID
71
+ return-funds --escrow ID extend-deadline --escrow ID --new-deadline UNIX
72
+ claims | watch [--interval S] [--exec CMD] [--webhook URL]
73
+ Either party
74
+ claim --escrow ID refund --escrow ID withdraw speed-up --tx HASH
75
+ mcp run as an MCP server on stdio
76
+
77
+ Every command takes --network testnet|mainnet (or CASCROW_SIGNER_NETWORK) and --home DIR.
78
+ The passphrase: a terminal prompt, CASCROW_KEYSTORE_PASSPHRASE_FILE (a 0600 file) or
79
+ CASCROW_KEYSTORE_PASSPHRASE — read once, then removed from the environment.`;
80
+ async function main() {
81
+ // FIRST, for every command: the passphrase variables leave the environment before
82
+ // anything else runs, so no child process (watch --exec) can inherit them (H3).
83
+ const envPassphrase = takePassphraseEnv(process.env);
84
+ const a = parseArgs(process.argv.slice(2));
85
+ const [cmd, sub] = a._;
86
+ // The MCP server is never a TTY for the policy, whatever its stdin is.
87
+ const io = cmd === "mcp" ? { isTTY: false, err: (l) => process.stderr.write(`${l}\n`) } : ttyIo();
88
+ if (!cmd || cmd === "help" || one(a, "help")) {
89
+ process.stdout.write(`${HELP}\n`);
90
+ return;
91
+ }
92
+ const home = one(a, "home") ?? signerHome();
93
+ const print = (v) => {
94
+ process.stdout.write(`${JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x), 2)}\n`);
95
+ };
96
+ if (cmd === "init") {
97
+ let pass = envPassphrase();
98
+ if (!pass && io.isTTY && io.prompt) {
99
+ pass = await io.prompt("New keystore passphrase (12+ characters): ", true);
100
+ if ((await io.prompt("Repeat it: ", true)) !== pass)
101
+ throw new SignerError("PASSPHRASE_MISMATCH", "the passphrases differ");
102
+ }
103
+ if (!pass)
104
+ throw new SignerError("PASSPHRASE_REQUIRED", "run init on a terminal, or set CASCROW_KEYSTORE_PASSPHRASE_FILE");
105
+ const r = await initSigner(home, pass);
106
+ print({ ...r, next: "Set your limits on this terminal: cascrow-signer policy set --per-escrow-xrp … --daily-xrp …" });
107
+ return;
108
+ }
109
+ // The passphrase was taken out of the environment above; it is read from that copy
110
+ // once, when a command first needs the key. The MCP server reads it at start. A
111
+ // terminal still gets the prompt when neither variable was set.
112
+ const mcpPassphrase = cmd === "mcp" ? envPassphrase() : undefined;
113
+ const passphrase = async () => {
114
+ if (cmd === "mcp")
115
+ return mcpPassphrase ?? null;
116
+ const p = envPassphrase();
117
+ if (p)
118
+ return p;
119
+ return io.isTTY && io.prompt ? io.prompt("Keystore passphrase: ", true) : null;
120
+ };
121
+ const s = createSigner(configFromEnv(process.env, io, {
122
+ home,
123
+ ...(one(a, "network") ? { network: one(a, "network") } : {}),
124
+ passphrase,
125
+ }));
126
+ const ref = { escrowId: one(a, "escrow"), milestoneId: one(a, "milestone") };
127
+ switch (cmd) {
128
+ case "status": return print(s.status());
129
+ case "link": return print(await s.link());
130
+ case "policy":
131
+ if (sub === "set") {
132
+ return print(await s.setPolicy({
133
+ perEscrowMaxXrp: one(a, "per-escrow-xrp") ?? "",
134
+ dailyMaxXrp: one(a, "daily-xrp") ?? "",
135
+ unknownPayeePerEscrowMaxXrp: one(a, "unknown-payee-xrp"),
136
+ minReviewHours: one(a, "min-review-hours") ? Number(one(a, "min-review-hours")) : undefined,
137
+ payees: a.flags.payee,
138
+ arbiters: a.flags.arbiter,
139
+ }));
140
+ }
141
+ return print(await s.policy());
142
+ case "accept": return print(await s.accept({ milestoneId: ref.milestoneId ?? "", minAmountXrp: one(a, "min-xrp") }));
143
+ case "fund": return print(await s.fund({ milestoneId: ref.milestoneId ?? "", maxAmountXrp: one(a, "max-xrp") }));
144
+ case "submit-claim": return print(await s.submitClaim({ ...ref, proofHash: one(a, "proof-hash"), evidence: { files: a.flags.file, texts: a.flags.text } }));
145
+ case "object": return print(await s.object({ ...ref, reason: one(a, "reason") }));
146
+ case "release": return print(await s.release(ref));
147
+ case "extend-deadline": return print(await s.extendDeadline({ ...ref, newDeadline: one(a, "new-deadline") }));
148
+ case "claim": return print(await s.claim(ref));
149
+ case "refund": return print(await s.refund(ref));
150
+ case "return-funds": return print(await s.returnFunds(ref));
151
+ case "withdraw": return print(await s.withdraw());
152
+ case "claims": return print(await s.onchainClaims());
153
+ case "watch": return s.watch({ intervalSec: Number(one(a, "interval") ?? 60), exec: one(a, "exec"), webhook: one(a, "webhook"), once: one(a, "once") === "true" });
154
+ case "speed-up": return print(await s.speedUp(one(a, "tx") ?? ""));
155
+ case "mcp": return serveStdio(s, SIGNER_VERSION);
156
+ default:
157
+ throw new SignerError("UNKNOWN_COMMAND", `unknown command ${cmd} — cascrow-signer help`);
158
+ }
159
+ }
160
+ main().catch((e) => {
161
+ process.stderr.write(`${e instanceof Error ? e.message : "error"}\n`);
162
+ process.exitCode = 1;
163
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What every action needs: the pinned network and its RPC, the cascrow API
3
+ * client, the signer's home, the journal, how to reach the person (Io), and how
4
+ * to get the passphrase. Built once per CLI run or MCP server.
5
+ */
6
+ import { Rpc } from "./network.js";
7
+ import type { SignerNetwork, SignerNetworkName, TestnetOverride } from "./network.js";
8
+ import { Remote } from "./remote.js";
9
+ import { Journal } from "./journal.js";
10
+ import type { UnlockedKey } from "./keystore.js";
11
+ export type Io = {
12
+ /** True only for a human at a terminal. The MCP server is never a TTY. */
13
+ isTTY: boolean;
14
+ /** Diagnostics and alarms — stderr in the CLI and the MCP server. */
15
+ err(line: string): void;
16
+ /** Ask a yes/no question (TTY only). */
17
+ confirm?(question: string): Promise<boolean>;
18
+ /** Read a line, optionally without echo (TTY only). */
19
+ prompt?(question: string, hidden?: boolean): Promise<string>;
20
+ };
21
+ export type SignerConfig = {
22
+ home: string;
23
+ network: SignerNetworkName;
24
+ override?: TestnetOverride | null;
25
+ apiBaseUrl: string;
26
+ apiKey: string;
27
+ io: Io;
28
+ /** Where the passphrase comes from. Default: CASCROW_KEYSTORE_PASSPHRASE_FILE / _PASSPHRASE, then a TTY prompt. */
29
+ passphrase?: () => Promise<string | null>;
30
+ /** Keystore scrypt cost for `init` (tests use a small one). */
31
+ scryptN?: number;
32
+ };
33
+ export declare class SignerContext {
34
+ readonly cfg: SignerConfig;
35
+ readonly home: string;
36
+ readonly net: SignerNetwork;
37
+ readonly rpc: Rpc;
38
+ readonly remote: Remote;
39
+ readonly journal: Journal;
40
+ readonly io: Io;
41
+ private cachedPassphrase;
42
+ constructor(cfg: SignerConfig);
43
+ /** The keystore address, read without the passphrase. */
44
+ address(): string;
45
+ passphrase(): Promise<string | null>;
46
+ unlock(): Promise<UnlockedKey>;
47
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What every action needs: the pinned network and its RPC, the cascrow API
3
+ * client, the signer's home, the journal, how to reach the person (Io), and how
4
+ * to get the passphrase. Built once per CLI run or MCP server.
5
+ */
6
+ import { Rpc, resolveNetwork } from "./network.js";
7
+ import { Remote } from "./remote.js";
8
+ import { Journal } from "./journal.js";
9
+ import { ensureHome } from "./files.js";
10
+ import { keystoreAddress, passphraseFromEnv, unlock } from "./keystore.js";
11
+ export class SignerContext {
12
+ cfg;
13
+ home;
14
+ net;
15
+ rpc;
16
+ remote;
17
+ journal;
18
+ io;
19
+ cachedPassphrase;
20
+ constructor(cfg) {
21
+ this.cfg = cfg;
22
+ this.home = ensureHome(cfg.home);
23
+ this.io = cfg.io;
24
+ this.net = resolveNetwork(cfg.network, cfg.override ?? null, (l) => cfg.io.err(l));
25
+ this.rpc = new Rpc(this.net.rpcUrl);
26
+ this.remote = new Remote({ baseUrl: cfg.apiBaseUrl, apiKey: cfg.apiKey });
27
+ this.journal = new Journal(this.home);
28
+ }
29
+ /** The keystore address, read without the passphrase. */
30
+ address() {
31
+ return keystoreAddress(this.home);
32
+ }
33
+ async passphrase() {
34
+ if (this.cachedPassphrase !== undefined)
35
+ return this.cachedPassphrase;
36
+ let p = null;
37
+ if (this.cfg.passphrase)
38
+ p = await this.cfg.passphrase();
39
+ else {
40
+ p = passphraseFromEnv();
41
+ if (!p && this.io.isTTY && this.io.prompt)
42
+ p = await this.io.prompt("Keystore passphrase: ", true);
43
+ }
44
+ this.cachedPassphrase = p;
45
+ return p;
46
+ }
47
+ async unlock() {
48
+ return unlock(this.home, await this.passphrase());
49
+ }
50
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * One error type for every refusal the signer makes. `code` is stable (tools and
3
+ * scripts branch on it); `message` says what to do. A message never carries key
4
+ * material, a passphrase, a keystore or a signed transaction — the canary test
5
+ * (signer-canary.test.ts) reads every one of them.
6
+ */
7
+ export declare class SignerError extends Error {
8
+ readonly code: string;
9
+ constructor(code: string, message: string);
10
+ }
11
+ export declare function refuse(code: string, message: string): never;
package/dist/errors.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * One error type for every refusal the signer makes. `code` is stable (tools and
3
+ * scripts branch on it); `message` says what to do. A message never carries key
4
+ * material, a passphrase, a keystore or a signed transaction — the canary test
5
+ * (signer-canary.test.ts) reads every one of them.
6
+ */
7
+ export class SignerError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(`${message} [${code}]`);
11
+ this.name = "SignerError";
12
+ this.code = code;
13
+ }
14
+ }
15
+ export function refuse(code, message) {
16
+ throw new SignerError(code, message);
17
+ }
@@ -0,0 +1,13 @@
1
+ export declare function signerHome(env?: Record<string, string | undefined>): string;
2
+ /** Create (0700) or check the home directory. */
3
+ export declare function ensureHome(dir: string): string;
4
+ export declare function assertPrivateFile(p: string): void;
5
+ /** Write a new 0600 file; refuse if it already exists (never overwrite a keystore). */
6
+ export declare function writeNewPrivateFile(p: string, data: string): void;
7
+ /** Replace a 0600 file atomically (temp file + rename). */
8
+ export declare function replacePrivateFile(p: string, data: string): void;
9
+ export declare function readPrivateFile(p: string): string;
10
+ /** Deterministic JSON: object keys sorted, so a MAC does not depend on key order. */
11
+ export declare function stableJson(v: unknown): string;
12
+ export declare function macOf(key: Buffer, body: unknown): string;
13
+ export declare function macMatches(key: Buffer, body: unknown, mac: unknown): boolean;
package/dist/files.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The signer's home directory and the rules for the files in it.
3
+ *
4
+ * `~/.cascrow/signer/` (or CASCROW_SIGNER_HOME) is 0700 and every file in it
5
+ * 0600. A file or directory that group or others can read is REFUSED, not
6
+ * repaired: a keystore that has been world-readable may already have been
7
+ * copied, and silently chmod-ing it would hide that. Windows has no POSIX mode
8
+ * bits, so the check is skipped there and the README says so.
9
+ */
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { createHmac, timingSafeEqual } from "node:crypto";
14
+ import { refuse } from "./errors.js";
15
+ const POSIX = process.platform !== "win32";
16
+ export function signerHome(env = process.env) {
17
+ const override = env.CASCROW_SIGNER_HOME?.trim();
18
+ return override ? path.resolve(override) : path.join(os.homedir(), ".cascrow", "signer");
19
+ }
20
+ function assertNotOpen(p, what) {
21
+ if (!POSIX)
22
+ return;
23
+ const st = fs.statSync(p);
24
+ if ((st.mode & 0o077) !== 0) {
25
+ refuse("KEYSTORE_PERMISSIONS", `${what} ${p} is readable or writable by other users (mode ${(st.mode & 0o777).toString(8)}). ` +
26
+ `The signer refuses to use it. If the key may have been copied, create a new one; otherwise run: chmod ${what === "directory" ? "700" : "600"} ${p}`);
27
+ }
28
+ if (typeof process.getuid === "function" && st.uid !== process.getuid()) {
29
+ refuse("KEYSTORE_PERMISSIONS", `${what} ${p} belongs to another user. The signer refuses to use it.`);
30
+ }
31
+ }
32
+ /** Create (0700) or check the home directory. */
33
+ export function ensureHome(dir) {
34
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ assertNotOpen(dir, "directory");
36
+ return dir;
37
+ }
38
+ export function assertPrivateFile(p) {
39
+ assertNotOpen(p, "file");
40
+ }
41
+ /** Write a new 0600 file; refuse if it already exists (never overwrite a keystore). */
42
+ export function writeNewPrivateFile(p, data) {
43
+ const fd = fs.openSync(p, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
44
+ try {
45
+ fs.writeSync(fd, data);
46
+ fs.fsyncSync(fd);
47
+ }
48
+ finally {
49
+ fs.closeSync(fd);
50
+ }
51
+ }
52
+ /** Replace a 0600 file atomically (temp file + rename). */
53
+ export function replacePrivateFile(p, data) {
54
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
55
+ writeNewPrivateFile(tmp, data);
56
+ fs.renameSync(tmp, p);
57
+ }
58
+ export function readPrivateFile(p) {
59
+ assertPrivateFile(p);
60
+ return fs.readFileSync(p, "utf8");
61
+ }
62
+ /** Deterministic JSON: object keys sorted, so a MAC does not depend on key order. */
63
+ export function stableJson(v) {
64
+ if (v === null || typeof v !== "object")
65
+ return JSON.stringify(v);
66
+ if (Array.isArray(v))
67
+ return `[${v.map(stableJson).join(",")}]`;
68
+ const o = v;
69
+ return `{${Object.keys(o).filter((k) => o[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${stableJson(o[k])}`).join(",")}}`;
70
+ }
71
+ export function macOf(key, body) {
72
+ return createHmac("sha256", key).update(stableJson(body)).digest("hex");
73
+ }
74
+ export function macMatches(key, body, mac) {
75
+ if (typeof mac !== "string" || !/^[0-9a-f]{64}$/.test(mac))
76
+ return false;
77
+ return timingSafeEqual(Buffer.from(macOf(key, body), "hex"), Buffer.from(mac, "hex"));
78
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "1440000": {
3
+ "network": "mainnet",
4
+ "address": null,
5
+ "deployBlock": null,
6
+ "runtimeCodeHash": null
7
+ },
8
+ "1449000": {
9
+ "network": "testnet",
10
+ "address": "0xa1f10a285d9de4cb1ff613829e3a26222db65f45",
11
+ "deployBlock": 8816108,
12
+ "runtimeCodeHash": "0xdc62dd32622f11da841429a87e9c24cb84ba2151e783712457457027651fc974"
13
+ },
14
+ "$comment": "Where MilestoneEscrowV3 is deployed, per EVM chain ID. Owner-maintained: filled in by the deployer in the same commit as the deploy, never at runtime. cascrow-signer carries a byte copy (scripts/sync-signer-artifacts.mjs; signer-artifact-parity.test.ts) and REFUSES a network whose entry lacks address, deployBlock or runtimeCodeHash. runtimeCodeHash = keccak256 of eth_getCode at address; the signer compares it before every send. Empty until the contract is deployed."
15
+ }