cascrow-signer 0.1.0 → 0.2.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.
package/dist/actions.js CHANGED
@@ -15,13 +15,13 @@
15
15
  * refusal, never read as "down" (post-review G1/H2).
16
16
  */
17
17
  import fs from "node:fs";
18
- import { getAddress, keccak256, parseEther, sha256, toUtf8Bytes } from "ethers";
18
+ import { ZeroAddress, formatEther, getAddress, keccak256, parseEther, sha256, toUtf8Bytes } from "ethers";
19
19
  import { SignerError, refuse } from "./errors.js";
20
- import { PER_ESCROW_ACTIONS, encodeCall } from "./calldata.js";
20
+ import { ACTION_FN, PER_ESCROW_ACTIONS, decodeCall, encodeCall } from "./calldata.js";
21
21
  import { BOOK_BOUND_ACTIONS, checkEscrowAction, checkFund, checkMatchesLocal, checkPreparedEnvelope, checkWithdraw, fundTermsFrom } from "./checks.js";
22
- import { fundedByMeLastDay, latestBlock, readCredit, readEscrow, sendSigned, settleUnknown } from "./chain.js";
23
- import { withLock } from "./journal.js";
24
- import { loadPolicy, savePolicy } from "./policy.js";
22
+ import { fundedByMeLastDay, latestBlock, readCredit, readEscrow, resendWithHigherFee, sendSigned, settleUnknown } from "./chain.js";
23
+ import { daySum, standsAlone, withLock } from "./journal.js";
24
+ import { effectivePolicy, loadPolicy, loadPolicyIfSet, savePolicy } from "./policy.js";
25
25
  import { Book } from "./book.js";
26
26
  import { RemoteError } from "./remote.js";
27
27
  import { verifyEndpoint } from "./network.js";
@@ -43,6 +43,37 @@ async function report(ctx, milestoneId, txHash, notes) {
43
43
  return false;
44
44
  }
45
45
  }
46
+ // ─── the person at the terminal ──────────────────────────────────────────────
47
+ const iso = (sec) => new Date(Number(sec) * 1000).toISOString();
48
+ const inDays = (sec) => `${((Number(sec) - Date.now() / 1000) / 86400).toFixed(1)} days from now`;
49
+ /**
50
+ * On a terminal: show what is about to be signed and ask y/N; a "no" signs nothing
51
+ * (DECLINED). Off a terminal (the MCP server) this does nothing — the policy is what
52
+ * bounds an agent. A terminal Io without a way to ask is refused, not waved through.
53
+ */
54
+ export async function confirmOnTerminal(ctx, question, lines) {
55
+ if (!ctx.io.isTTY)
56
+ return;
57
+ if (!ctx.io.confirm)
58
+ refuse("CONFIRM_UNAVAILABLE", "this terminal session cannot ask for confirmation; nothing was signed");
59
+ for (const l of lines)
60
+ ctx.io.err(l);
61
+ if (!(await ctx.io.confirm(question)))
62
+ refuse("DECLINED", "you declined; nothing was signed");
63
+ }
64
+ /** What a payer's `fund` commits to, in the words a person checks (S-02). */
65
+ export function fundSummary(ctx, f, payeeKnown) {
66
+ const t = f.t;
67
+ return [
68
+ `You are about to FUND an escrow on ${ctx.net.name} (chain ${ctx.net.chainId}, contract ${ctx.net.address}):`,
69
+ ` amount: ${formatEther(t.amount)} XRP — leaves your account in this transaction`,
70
+ ` payee: ${t.payee}${payeeKnown ? " (in your counterparty book)" : " (NOT in your counterparty book)"}`,
71
+ ` deadline: ${iso(t.deadline)} (${inDays(t.deadline)}) — until then the money is locked unless you release it, the payee claims or returns it`,
72
+ ` review period: ${(t.reviewPeriod / 3600).toFixed(1)} h — the time you have to object to a claim`,
73
+ ` arbiter: ${t.arbiter === ZeroAddress ? "none" : `${t.arbiter} (arbitration period ${(t.arbitrationPeriod / 86400).toFixed(1)} days)`}`,
74
+ ` offer open until ${iso(t.acceptBy)}`,
75
+ ];
76
+ }
46
77
  export async function fund(ctx, input) {
47
78
  if (!input.milestoneId)
48
79
  refuse("INPUT", "milestoneId is required");
@@ -73,45 +104,47 @@ export async function fund(ctx, input) {
73
104
  savePolicy(ctx.home, key.macKey, policy, ctx.io);
74
105
  }
75
106
  }
107
+ // A person at a terminal sees the terms and says yes before anything is signed (S-02).
108
+ // Before the lock: a human reading must not hold up the MCP server's own sends.
109
+ await confirmOnTerminal(ctx, "Sign and send this funding?", fundSummary(ctx, terms, policy.payees.includes(terms.t.payee.toLowerCase())));
76
110
  const c = chainCtx(ctx);
111
+ const journal = ctx.journal.keyed(key.macKey);
77
112
  return withLock(ctx.home, async () => {
78
113
  await verifyEndpoint(ctx.rpc, ctx.net);
79
114
  const head = await latestBlock(c);
80
- const journalPresent = ctx.journal.exists();
81
- const fromJournal = ctx.journal.daySum(head.timestamp);
115
+ // JOURNAL_TAMPERED here, before anything else: a limit is never computed over a journal that was changed.
116
+ const v = journal.verifyForSpending();
117
+ const alone = standsAlone(v, head.timestamp);
82
118
  let fromChain;
83
119
  try {
84
120
  fromChain = await fundedByMeLastDay(c, me, head);
85
121
  }
86
122
  catch (e) {
87
- if (!journalPresent) {
88
- refuse("JOURNAL_UNREADABLE", "the spending journal is missing and the chain's Funded events could not be read to rebuild today's total — funding is refused rather than guessing the daily limit");
123
+ if (!alone) {
124
+ refuse("JOURNAL_UNREADABLE", `the spending journal is ${v.state === "missing" ? "missing" : v.state === "v1" ? "a 0.1.0 journal, not yet protected" : "younger than 24 hours (recreated or migrated)"} and the chain's Funded events could not be read to rebuild today's total — funding is refused rather than guessing the daily limit; retry when the RPC answers`);
89
125
  }
90
126
  notes.push(`could not cross-check today's total against the chain (${e instanceof Error ? e.message : "error"}); used the local journal`);
91
127
  fromChain = new Map();
92
128
  }
93
- let day = fromJournal.sumWei;
94
- for (const [id, amt] of fromChain)
95
- if (!fromJournal.escrowIds.has(id))
96
- day += amt;
97
- if (!journalPresent)
129
+ if (v.state === "missing")
98
130
  notes.push("the spending journal was missing; today's total was rebuilt from Funded events on chain");
131
+ if (v.state === "v1")
132
+ notes.push("the spending journal was written by 0.1.0; it is now protected by a MAC chain — its reservations still count, its releases do not");
99
133
  try {
100
- await settleUnknown(c, me, ctx.journal);
134
+ await settleUnknown(c, me, journal, v);
101
135
  }
102
136
  catch {
103
137
  /* unknown outcomes keep counting — the safe direction */
104
138
  }
139
+ const day = daySum(journal.verifyForSpending(), fromChain, head.timestamp);
105
140
  const slot = await readEscrow(c, p.escrowId);
106
141
  const { payeeKnown } = checkFund({ prepared: p, terms, me, pinned: ctx.net, policy, daySumWei: day, chainNowSec: head.timestamp, slotStatus: slot.status, maxAmountWei });
107
142
  if (!payeeKnown)
108
143
  notes.push("payee not in your counterparty book — the lower limit for unknown payees applied");
109
- if (!journalPresent)
110
- ctx.journal.create(true);
111
- const rid = ctx.journal.reserve("fund", p.escrowId, terms.t.amount, head.timestamp);
144
+ const rid = journal.reserve("fund", p.escrowId, terms.t.amount, head.timestamp);
112
145
  book.add({ escrowId: p.escrowId, role: "payer", milestoneId: input.milestoneId, payer: me, payee: terms.t.payee, amountWei: terms.t.amount.toString(), chainId: ctx.net.chainId, contract: ctx.net.address, at: head.timestamp });
113
146
  const data = encodeCall("fund", p.call.args);
114
- const sent = await sendSigned(c, key, ctx.journal, rid, { to: ctx.net.address, data, value: terms.t.amount });
147
+ const sent = await sendSigned(c, key, journal, rid, { to: ctx.net.address, data, value: terms.t.amount });
115
148
  if (sent.note)
116
149
  notes.push(sent.note);
117
150
  const reported = await report(ctx, input.milestoneId, sent.txHash, notes);
@@ -191,6 +224,49 @@ function readBookLoosely(ctx, book, notes) {
191
224
  return [];
192
225
  }
193
226
  }
227
+ /**
228
+ * An action that does not read the journal for a decision still says so loudly when the
229
+ * journal fails its check — it is the signal that something wrote into the signer's
230
+ * directory — and goes ahead: the objection window does not wait for a repair.
231
+ */
232
+ function noteJournalIntegrity(ctx, journal, notes) {
233
+ let state;
234
+ let at = null;
235
+ try {
236
+ const v = journal.verify();
237
+ state = v.state;
238
+ at = v.brokenAt;
239
+ }
240
+ catch {
241
+ state = "unreadable";
242
+ }
243
+ if (state !== "tampered" && state !== "unreadable")
244
+ return;
245
+ const n = `WARNING: the spending journal ${journal.file} ${state === "tampered" ? `failed its integrity check at line ${at}` : "could not be read"}. This action went ahead (it needs no journal); fund stays refused until the journal is moved aside.`;
246
+ notes.push(n);
247
+ ctx.io.err(n);
248
+ }
249
+ /**
250
+ * `returnFunds` gives the escrow to the payer for good (S-03). On a terminal the person
251
+ * confirms it; off a terminal it is signed only when the policy says so — set on a
252
+ * terminal, never through MCP. The refusal names both ways out, and an agent can pass
253
+ * either on to the person.
254
+ */
255
+ async function gateReturnFunds(ctx, escrowId, limits) {
256
+ if (ctx.io.isTTY) {
257
+ const e = await readEscrow(chainCtx(ctx), escrowId);
258
+ await confirmOnTerminal(ctx, "Give this money back to the payer?", [
259
+ `You are about to RETURN escrow ${escrowId} to its payer ${e.payer}:`,
260
+ ` ${formatEther(e.amount)} XRP go back to the payer for good — you cannot claim this escrow afterwards.`,
261
+ ]);
262
+ return;
263
+ }
264
+ if (!limits.allowReturnFundsWithoutTerminal) {
265
+ refuse("RETURN_FUNDS_NEEDS_TERMINAL", "returnFunds gives the escrowed money back to the payer for good, and this signer is not on a terminal — nothing was signed. " +
266
+ "The person who owns this key can run `cascrow-signer return-funds --escrow " + escrowId + "` on a terminal, or allow it for agents there: " +
267
+ "cascrow-signer policy set … --allow-agent-return-funds");
268
+ }
269
+ }
194
270
  export async function escrowAction(ctx, action, input) {
195
271
  const key = await ctx.unlock();
196
272
  const me = key.address;
@@ -269,15 +345,22 @@ export async function escrowAction(ctx, action, input) {
269
345
  const data = encodeCall(action, args);
270
346
  if (prepared)
271
347
  checkMatchesLocal({ to: ctx.net.address, data, value: BigInt(0) }, prepared);
348
+ // Only the two actions the policy bounds read it (S-03). `object` never does: a damaged
349
+ // policy must not cost a payer the objection window.
350
+ const limits = action === "extendDeadline" || action === "returnFunds" ? effectivePolicy(loadPolicyIfSet(ctx.home, key.macKey, me)) : null;
351
+ if (action === "returnFunds")
352
+ await gateReturnFunds(ctx, escrowId, limits);
353
+ const journal = ctx.journal.keyed(key.macKey);
272
354
  return withLock(ctx.home, async () => {
273
355
  await verifyEndpoint(ctx.rpc, ctx.net);
274
356
  const head = await latestBlock(c);
275
357
  const e = await readEscrow(c, escrowId);
276
358
  const findIn = (entries) => entries.find((x) => x.escrowId === escrowId && x.chainId === ctx.net.chainId && x.contract === ctx.net.address.toLowerCase()) ?? null;
277
359
  const entry = bookBound ? book.find(escrowId, ctx.net.chainId, ctx.net.address) : input.milestoneId ? null : findIn(readBookLoosely(ctx, book, notes));
278
- checkEscrowAction(action, me, e, head.timestamp, { book: entry, newDeadline, proofHash, reasonHash });
279
- const rid = ctx.journal.exists() ? ctx.journal.reserve(action, escrowId, BigInt(0), head.timestamp) : (ctx.journal.create(true), ctx.journal.reserve(action, escrowId, BigInt(0), head.timestamp));
280
- const sent = await sendSigned(c, key, ctx.journal, rid, { to: ctx.net.address, data, value: BigInt(0) });
360
+ checkEscrowAction(action, me, e, head.timestamp, { book: entry, newDeadline, proofHash, reasonHash, ...(limits ? { maxDeadlineSeconds: limits.maxDeadlineSeconds } : {}) });
361
+ noteJournalIntegrity(ctx, journal, notes);
362
+ const rid = journal.reserve(action, escrowId, BigInt(0), head.timestamp);
363
+ const sent = await sendSigned(c, key, journal, rid, { to: ctx.net.address, data, value: BigInt(0) });
281
364
  if (sent.note)
282
365
  notes.push(sent.note);
283
366
  const milestoneId = input.milestoneId ?? entry?.milestoneId ?? null;
@@ -306,14 +389,85 @@ export async function withdraw(ctx) {
306
389
  const head = await latestBlock(c);
307
390
  const credit = await readCredit(c, me);
308
391
  checkWithdraw(me, credit);
309
- if (!ctx.journal.exists())
310
- ctx.journal.create(true);
311
- const rid = ctx.journal.reserve("withdraw", null, BigInt(0), head.timestamp);
392
+ const journal = ctx.journal.keyed(key.macKey);
393
+ noteJournalIntegrity(ctx, journal, notes);
394
+ const rid = journal.reserve("withdraw", null, BigInt(0), head.timestamp);
312
395
  const data = encodeCall("withdraw", [me, "0x0000000000000000000000000000000000000000"]);
313
- const sent = await sendSigned(c, key, ctx.journal, rid, { to: ctx.net.address, data, value: BigInt(0) });
396
+ const sent = await sendSigned(c, key, journal, rid, { to: ctx.net.address, data, value: BigInt(0) });
314
397
  if (sent.note)
315
398
  notes.push(sent.note);
316
399
  return { action: "withdraw", escrowId: "", txHash: sent.txHash, sent: sent.sent, reported: false, from: me, creditWei: credit.toString(), notes };
317
400
  });
318
401
  }
402
+ /**
403
+ * Replace one of this signer's pending transactions with the same nonce and a 25 % higher
404
+ * fee — the way to get a pending objection mined. Call inside `withLock`.
405
+ *
406
+ * Re-signing is signing, so it is checked like a first signature (S-01): the entry must
407
+ * be AUTHENTIC (in the verified part of the journal — never a 0.1.0 line, never a line
408
+ * after a break), it must move no value (a funding is never replaced: its limit was
409
+ * checked for the transaction as signed), its calldata must decode to the action it was
410
+ * reserved for and name the same escrow, and the action is checked against the chain and
411
+ * the policy again exactly as `escrowAction` checks it. Only then is it re-signed.
412
+ */
413
+ export async function speedUp(ctx, key, txHash) {
414
+ if (!/^0x[0-9a-fA-F]{64}$/.test(txHash))
415
+ refuse("INPUT", "txHash must be 0x + 64 hex characters");
416
+ const me = getAddress(key.address);
417
+ const journal = ctx.journal.keyed(key.macKey);
418
+ const v = journal.verify();
419
+ const h = txHash.toLowerCase();
420
+ const r = v.reservations.find((x) => !x.legacy && x.signed && (x.signed.hash.toLowerCase() === h || x.sentHashes.some((y) => y.toLowerCase() === h)));
421
+ if (!r || !r.signed) {
422
+ refuse("NOT_IN_JOURNAL", v.state === "tampered"
423
+ ? `this transaction is not in the verified part of the spending journal (it fails its integrity check at line ${v.brokenAt}); nothing was signed`
424
+ : v.state === "v1"
425
+ ? "the spending journal was written by 0.1.0 and its entries are not re-signed; nothing was signed"
426
+ : "this transaction was not signed by this signer; nothing was signed");
427
+ }
428
+ if (r.action === "fund" || r.amountWei !== BigInt(0) || String(r.signed.tx.value) !== "0") {
429
+ refuse("SPEEDUP_REFUSED", "a funding is never replaced by the signer — wait for it to be mined or dropped; its reservation keeps counting toward your 24-hour limit until its nonce is used. Nothing was signed");
430
+ }
431
+ if (!Object.prototype.hasOwnProperty.call(ACTION_FN, r.action))
432
+ refuse("SPEEDUP_REFUSED", `the journal entry names an unknown action ${r.action}`);
433
+ const action = r.action;
434
+ let decoded;
435
+ try {
436
+ decoded = decodeCall(String(r.signed.tx.data));
437
+ }
438
+ catch {
439
+ refuse("SPEEDUP_REFUSED", "the journal entry's calldata does not decode under the pinned ABI; nothing was signed");
440
+ }
441
+ if (decoded.fn !== ACTION_FN[action])
442
+ refuse("SPEEDUP_REFUSED", `the journal entry's calldata calls ${decoded.fn}, not the ${action} it was reserved for; nothing was signed`);
443
+ const c = chainCtx(ctx);
444
+ await verifyEndpoint(ctx.rpc, ctx.net);
445
+ const head = await latestBlock(c);
446
+ if (action === "withdraw") {
447
+ if (String(decoded.args[0]).toLowerCase() !== me.toLowerCase() || String(decoded.args[1]).toLowerCase() !== ZeroAddress.toLowerCase()) {
448
+ refuse("SPEEDUP_REFUSED", "the journal entry's withdraw does not pay this key in native XRP; nothing was signed");
449
+ }
450
+ checkWithdraw(me, await readCredit(c, me));
451
+ }
452
+ else if (PER_ESCROW_ACTIONS.has(action)) {
453
+ const escrowId = String(decoded.args[0]).toLowerCase();
454
+ if (!r.escrowId || r.escrowId.toLowerCase() !== escrowId)
455
+ refuse("SPEEDUP_REFUSED", "the journal entry's calldata names a different escrow than its reservation; nothing was signed");
456
+ const e = await readEscrow(c, escrowId);
457
+ const entry = BOOK_BOUND_ACTIONS.has(action) ? new Book(ctx.home, key.macKey).find(escrowId, ctx.net.chainId, ctx.net.address) : null;
458
+ const arg1 = decoded.args[1] === undefined ? null : String(decoded.args[1]);
459
+ const limits = action === "extendDeadline" ? effectivePolicy(loadPolicyIfSet(ctx.home, key.macKey, me)) : null;
460
+ checkEscrowAction(action, me, e, head.timestamp, {
461
+ book: entry,
462
+ newDeadline: action === "extendDeadline" && arg1 !== null ? BigInt(arg1) : null,
463
+ proofHash: action === "submitClaim" ? arg1 : null,
464
+ reasonHash: action === "object" ? arg1 : null,
465
+ ...(limits ? { maxDeadlineSeconds: limits.maxDeadlineSeconds } : {}),
466
+ });
467
+ }
468
+ else {
469
+ refuse("SPEEDUP_REFUSED", `${action} is not an action the signer replaces; nothing was signed`);
470
+ }
471
+ return resendWithHigherFee(c, key, journal, r);
472
+ }
319
473
  export { PER_ESCROW_ACTIONS };
package/dist/api.d.ts CHANGED
@@ -3,16 +3,18 @@ import type { Io, SignerConfig } from "./context.js";
3
3
  import type { PolicyInput } from "./policy.js";
4
4
  import type { EscrowActionInput, FundInput } from "./actions.js";
5
5
  import type { AcceptInput } from "./acceptance.js";
6
+ import type { LinkOptions } from "./link.js";
6
7
  import type { WatchOptions } from "./watch.js";
7
8
  export { SignerError } from "./errors.js";
8
9
  export { SignerContext } from "./context.js";
9
10
  export type { Io, SignerConfig } from "./context.js";
10
11
  export { LOCAL_ONLY_TOOLS } from "./tools.js";
11
12
  export { checkAcceptance } from "./acceptance.js";
12
- export { checkChallenge, CHALLENGE_PREFIX } from "./link.js";
13
+ export { checkChallenge, buildChallengeMessage, challengeStatement, CHALLENGE_AUDIENCE } from "./link.js";
14
+ export type { ChallengeExpectation, ChallengeFields, ChallengePurpose, LinkOptions } from "./link.js";
13
15
  export { localProofHash } from "./actions.js";
14
16
  export { resolveNetwork, PUBLIC_NETWORKS } from "./network.js";
15
- export declare const SIGNER_VERSION = "0.1.0";
17
+ export declare const SIGNER_VERSION = "0.2.0";
16
18
  /** Create the keystore and the spending journal. Does not need a network or cascrow. */
17
19
  export declare function initSigner(home: string, passphrase: string, opts?: {
18
20
  scryptN?: number;
@@ -25,12 +27,13 @@ export declare function configFromEnv(env: Env, io: Io, overrides?: Partial<Sign
25
27
  export declare function createSigner(cfg: SignerConfig): {
26
28
  ctx: SignerContext;
27
29
  address: () => string;
28
- link: () => Promise<{
30
+ link: (o?: LinkOptions) => Promise<{
29
31
  linked: boolean;
30
32
  walletAddress: string;
31
33
  changed: boolean;
32
34
  message: string;
33
35
  } | {
36
+ previous?: string | undefined;
34
37
  linked: boolean;
35
38
  walletAddress: string;
36
39
  changed: boolean;
@@ -67,7 +70,19 @@ export declare function createSigner(cfg: SignerConfig): {
67
70
  }>;
68
71
  watch: (o?: WatchOptions) => Promise<void>;
69
72
  speedUp: (txHash: string) => Promise<import("./chain.js").SendResult>;
70
- policy: () => Promise<import("./policy.js").Policy>;
73
+ /** The stored policy with the limits it ENFORCES — a policy written before 0.2.0 shows the defaults it now gets. */
74
+ policy: () => Promise<{
75
+ maxDeadlineSeconds: number;
76
+ allowReturnFundsWithoutTerminal: boolean;
77
+ v: 1;
78
+ address: string;
79
+ perEscrowMaxWei: string;
80
+ unknownPayeePerEscrowMaxWei: string;
81
+ dailyMaxWei: string;
82
+ minReviewSeconds: number;
83
+ payees: string[];
84
+ arbiters: string[];
85
+ }>;
71
86
  setPolicy: (input: PolicyInput) => Promise<import("./policy.js").Policy>;
72
87
  status: () => {
73
88
  address: string;
package/dist/api.js CHANGED
@@ -12,29 +12,35 @@
12
12
  */
13
13
  import { refuse } from "./errors.js";
14
14
  import { SignerContext } from "./context.js";
15
- import { createKeystore } from "./keystore.js";
15
+ import fs from "node:fs";
16
+ import { createKeystore, keystorePath } from "./keystore.js";
16
17
  import { Journal } from "./journal.js";
17
- import { buildPolicy, loadPolicy, savePolicy } from "./policy.js";
18
- import { escrowAction, fund, withdraw } from "./actions.js";
18
+ import { buildPolicy, effectivePolicy, loadPolicy, savePolicy } from "./policy.js";
19
+ import { escrowAction, fund, speedUp, withdraw } from "./actions.js";
19
20
  import { accept } from "./acceptance.js";
20
21
  import { link } from "./link.js";
21
22
  import { onchainClaims, watch } from "./watch.js";
22
- import { speedUp } from "./chain.js";
23
23
  import { withLock } from "./journal.js";
24
24
  import { ensureHome, signerHome } from "./files.js";
25
25
  export { SignerError } from "./errors.js";
26
26
  export { SignerContext } from "./context.js";
27
27
  export { LOCAL_ONLY_TOOLS } from "./tools.js";
28
28
  export { checkAcceptance } from "./acceptance.js";
29
- export { checkChallenge, CHALLENGE_PREFIX } from "./link.js";
29
+ export { checkChallenge, buildChallengeMessage, challengeStatement, CHALLENGE_AUDIENCE } from "./link.js";
30
30
  export { localProofHash } from "./actions.js";
31
31
  export { resolveNetwork, PUBLIC_NETWORKS } from "./network.js";
32
- export const SIGNER_VERSION = "0.1.0";
32
+ export const SIGNER_VERSION = "0.2.0";
33
33
  /** Create the keystore and the spending journal. Does not need a network or cascrow. */
34
34
  export async function initSigner(home, passphrase, opts = {}) {
35
35
  ensureHome(home);
36
- const { address } = await createKeystore(home, passphrase, opts);
37
- new Journal(home).create();
36
+ const journal = new Journal(home);
37
+ // A journal left by an earlier key carries that key's MACs: the new key could never
38
+ // verify it, and every fund would be refused as tampered. Refused before a key exists.
39
+ if (journal.exists() && !fs.existsSync(keystorePath(home))) {
40
+ refuse("JOURNAL_EXISTS", `a spending journal from an earlier key is in ${home}; move it aside (or use another --home) before creating a new key`);
41
+ }
42
+ const { address, macKey } = await createKeystore(home, passphrase, opts);
43
+ journal.keyed(macKey).create(Math.floor(Date.now() / 1000));
38
44
  return { address, home };
39
45
  }
40
46
  export function configFromEnv(env, io, overrides = {}) {
@@ -64,7 +70,7 @@ export function createSigner(cfg) {
64
70
  return {
65
71
  ctx,
66
72
  address: () => ctx.address(),
67
- link: () => link(ctx),
73
+ link: (o) => link(ctx, o),
68
74
  accept: (i) => accept(ctx, i),
69
75
  fund: (i) => fund(ctx, i),
70
76
  submitClaim: act("submitClaim"),
@@ -79,11 +85,13 @@ export function createSigner(cfg) {
79
85
  watch: (o) => watch(ctx, o),
80
86
  speedUp: async (txHash) => {
81
87
  const key = await ctx.unlock();
82
- return withLock(ctx.home, () => speedUp({ rpc: ctx.rpc, net: ctx.net }, key, ctx.journal, txHash));
88
+ return withLock(ctx.home, () => speedUp(ctx, key, txHash));
83
89
  },
90
+ /** The stored policy with the limits it ENFORCES — a policy written before 0.2.0 shows the defaults it now gets. */
84
91
  policy: async () => {
85
92
  const key = await ctx.unlock();
86
- return loadPolicy(ctx.home, key.macKey, key.address);
93
+ const p = loadPolicy(ctx.home, key.macKey, key.address);
94
+ return { ...p, ...effectivePolicy(p) };
87
95
  },
88
96
  setPolicy: async (input) => {
89
97
  const key = await ctx.unlock();
package/dist/chain.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ChainEscrow } from "./calldata.js";
2
2
  import { Rpc } from "./network.js";
3
3
  import type { SignerNetwork } from "./network.js";
4
- import { Journal } from "./journal.js";
4
+ import type { KeyedJournal, Reservation, VerifiedJournal } from "./journal.js";
5
5
  import type { UnlockedKey } from "./keystore.js";
6
6
  export type ChainCtx = {
7
7
  rpc: Rpc;
@@ -41,19 +41,25 @@ export type SendResult = {
41
41
  * this function's scope and in the RPC request body — it is never returned,
42
42
  * logged or sent anywhere else.
43
43
  */
44
- export declare function sendSigned(c: ChainCtx, key: UnlockedKey, journal: Journal, reservationId: string, tx: {
44
+ export declare function sendSigned(c: ChainCtx, key: UnlockedKey, journal: KeyedJournal, reservationId: string, tx: {
45
45
  to: string;
46
46
  data: string;
47
47
  value: bigint;
48
48
  }): Promise<SendResult>;
49
49
  /**
50
- * Replace a pending transaction of this key with the same nonce and fees raised by
51
- * 25 % — the way to speed up an objection, instead of waiting. Refused once the
52
- * nonce is used.
50
+ * Re-sign an AUTHENTIC journal entry with the same nonce and fees raised by 25 % and
51
+ * send it. The caller (actions.ts `speedUp`) has already decided the entry may be
52
+ * re-signed: verified, value 0, its calldata decoded and checked against the chain
53
+ * again. This function only refuses what it can see itself: a used nonce, a target
54
+ * other than the pinned contract, a value other than 0.
53
55
  */
54
- export declare function speedUp(c: ChainCtx, key: UnlockedKey, journal: Journal, txHash: string): Promise<SendResult>;
56
+ export declare function resendWithHigherFee(c: ChainCtx, key: UnlockedKey, journal: KeyedJournal, r: Reservation & {
57
+ signed: NonNullable<Reservation["signed"]>;
58
+ }): Promise<SendResult>;
55
59
  /**
56
60
  * Release reservations whose transaction provably spent nothing: signed, outcome
57
- * unknown, its nonce now used, and no receipt for any of its hashes.
61
+ * unknown, its nonce now used, and no receipt for any of its hashes. Only over an
62
+ * AUTHENTIC entry of an unbroken journal (`verifyForSpending`) — a legacy (0.1.0) line
63
+ * is never released, so an unauthenticated "unknown" line cannot free a reservation.
58
64
  */
59
- export declare function settleUnknown(c: ChainCtx, me: string, journal: Journal): Promise<void>;
65
+ export declare function settleUnknown(c: ChainCtx, me: string, journal: KeyedJournal, v: VerifiedJournal): Promise<void>;
package/dist/chain.js CHANGED
@@ -154,7 +154,7 @@ export async function sendSigned(c, key, journal, reservationId, tx) {
154
154
  const fields = { chainId: c.net.chainId, nonce, to: tx.to, data: tx.data, value: tx.value, gasLimit: pct(gas, 125), ...fees };
155
155
  const signed = await key.signTransaction(fields);
156
156
  const hash = keccak256(signed);
157
- journal.signed(reservationId, hash, nonce, Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "bigint" ? v.toString() : v])));
157
+ journal.signed(reservationId, hash, nonce, fields);
158
158
  try {
159
159
  const answered = await c.rpc.call("eth_sendRawTransaction", [signed]);
160
160
  if (typeof answered === "string" && answered.toLowerCase() !== hash.toLowerCase()) {
@@ -172,34 +172,35 @@ export async function sendSigned(c, key, journal, reservationId, tx) {
172
172
  }
173
173
  }
174
174
  /**
175
- * Replace a pending transaction of this key with the same nonce and fees raised by
176
- * 25 % — the way to speed up an objection, instead of waiting. Refused once the
177
- * nonce is used.
175
+ * Re-sign an AUTHENTIC journal entry with the same nonce and fees raised by 25 % and
176
+ * send it. The caller (actions.ts `speedUp`) has already decided the entry may be
177
+ * re-signed: verified, value 0, its calldata decoded and checked against the chain
178
+ * again. This function only refuses what it can see itself: a used nonce, a target
179
+ * other than the pinned contract, a value other than 0.
178
180
  */
179
- export async function speedUp(c, key, journal, txHash) {
181
+ export async function resendWithHigherFee(c, key, journal, r) {
180
182
  await verifyEndpoint(c.rpc, c.net);
181
- const r = journal.read().find((x) => x.signed && (x.signed.hash.toLowerCase() === txHash.toLowerCase() || x.sentHashes.some((h) => h.toLowerCase() === txHash.toLowerCase())));
182
- if (!r || !r.signed)
183
- refuse("NOT_IN_JOURNAL", "this transaction was not signed by this signer");
184
183
  const used = Number(BigInt(await c.rpc.call("eth_getTransactionCount", [key.address, "latest"])));
185
184
  if (used > r.signed.nonce)
186
185
  refuse("NONCE_USED", "that transaction's nonce is already used — it was mined or replaced");
187
186
  const prev = r.signed.tx;
187
+ if (getAddress(String(prev.to)) !== getAddress(c.net.address))
188
+ refuse("WRONG_TARGET", "the journal entry does not target the pinned contract");
189
+ if (BigInt(prev.value ?? "x") !== BigInt(0))
190
+ refuse("SPEEDUP_REFUSED", "only a transaction that moves no value is replaced by the signer");
188
191
  const fees = await feeFields(c, prev);
189
192
  const fields = {
190
193
  chainId: c.net.chainId,
191
194
  nonce: r.signed.nonce,
192
195
  to: prev.to,
193
196
  data: prev.data,
194
- value: BigInt(prev.value),
197
+ value: BigInt(0),
195
198
  gasLimit: BigInt(prev.gasLimit),
196
199
  ...fees,
197
200
  };
198
- if (getAddress(fields.to) !== getAddress(c.net.address))
199
- refuse("WRONG_TARGET", "the journal entry does not target the pinned contract");
200
201
  const signed = await key.signTransaction(fields);
201
202
  const hash = keccak256(signed);
202
- journal.signed(r.id, hash, r.signed.nonce, Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, typeof v === "bigint" ? v.toString() : v])));
203
+ journal.signed(r.id, hash, r.signed.nonce, fields);
203
204
  try {
204
205
  await c.rpc.call("eth_sendRawTransaction", [signed]);
205
206
  journal.sent(r.id, hash);
@@ -212,10 +213,14 @@ export async function speedUp(c, key, journal, txHash) {
212
213
  }
213
214
  /**
214
215
  * Release reservations whose transaction provably spent nothing: signed, outcome
215
- * unknown, its nonce now used, and no receipt for any of its hashes.
216
+ * unknown, its nonce now used, and no receipt for any of its hashes. Only over an
217
+ * AUTHENTIC entry of an unbroken journal (`verifyForSpending`) — a legacy (0.1.0) line
218
+ * is never released, so an unauthenticated "unknown" line cannot free a reservation.
216
219
  */
217
- export async function settleUnknown(c, me, journal) {
218
- const open = journal.read().filter((r) => r.unknown && !r.released && r.signed);
220
+ export async function settleUnknown(c, me, journal, v) {
221
+ if (v.state !== "intact")
222
+ return;
223
+ const open = v.reservations.filter((r) => !r.legacy && r.unknown && !r.released && r.signed);
219
224
  if (!open.length)
220
225
  return;
221
226
  const used = Number(BigInt(await c.rpc.call("eth_getTransactionCount", [me, "latest"])));
package/dist/checks.d.ts CHANGED
@@ -97,5 +97,7 @@ export declare function checkEscrowAction(action: V3EscrowAction, me: string, e:
97
97
  newDeadline?: bigint | null;
98
98
  proofHash?: string | null;
99
99
  reasonHash?: string | null;
100
+ /** extendDeadline: the policy's longest term (`effectivePolicy`). Absent → the 365-day default, never the contract's five years. */
101
+ maxDeadlineSeconds?: number;
100
102
  }): void;
101
103
  export declare function checkWithdraw(me: string, credit: bigint): void;
package/dist/checks.js CHANGED
@@ -20,6 +20,7 @@ import { ZeroAddress, getAddress, isAddress, verifyTypedData } from "ethers";
20
20
  import { refuse } from "./errors.js";
21
21
  import { ACCEPTANCE_TYPES, ACTION_FN, PER_ESCROW_ACTIONS, V3_LIMITS, acceptanceDomain, canonicalArgs, encodeCall, escrowIdFor, } from "./calldata.js";
22
22
  import { v3ActionBlocker, v3RoleOf } from "./generated/v3-actions.js";
23
+ import { DEFAULT_MAX_DEADLINE_SECONDS, effectivePolicy } from "./policy.js";
23
24
  const lc = (s) => String(s).toLowerCase();
24
25
  function fail(code, reasons) {
25
26
  if (reasons.length)
@@ -152,6 +153,11 @@ export function checkFund(i) {
152
153
  r.push("the deadline has passed on chain");
153
154
  if (f.t.deadline > now + BigInt(V3_LIMITS.MAX_DEADLINE_SECONDS))
154
155
  r.push("the deadline is beyond the contract's maximum duration");
156
+ else {
157
+ const cap = effectivePolicy(pol).maxDeadlineSeconds;
158
+ if (f.t.deadline > now + BigInt(cap))
159
+ r.push(`the deadline is more than ${Math.floor(cap / 86400)} days away — beyond your local policy's maximum (cascrow-signer policy set --max-deadline-days, on a terminal)`);
160
+ }
155
161
  if (f.t.acceptBy < now + BigInt(ACCEPT_BY_MARGIN_SECONDS))
156
162
  r.push("the acceptance has expired on chain (or expires within two minutes)");
157
163
  const signer = acceptanceSigner(i.pinned, id, f.t, f.payeeSig);
@@ -221,6 +227,11 @@ export function checkEscrowAction(action, me, e, chainNowSec, opts) {
221
227
  r.push("newDeadline must be after the current deadline");
222
228
  else if (nd > BigInt(chainNowSec + V3_LIMITS.MAX_DEADLINE_SECONDS))
223
229
  r.push("newDeadline is beyond the contract's maximum duration");
230
+ else {
231
+ const cap = opts.maxDeadlineSeconds ?? DEFAULT_MAX_DEADLINE_SECONDS;
232
+ if (nd > BigInt(chainNowSec + cap))
233
+ r.push(`newDeadline is more than ${Math.floor(cap / 86400)} days away — beyond your local policy's maximum (cascrow-signer policy set --max-deadline-days, on a terminal)`);
234
+ }
224
235
  }
225
236
  if (action === "submitClaim" && !/^0x[0-9a-f]{64}$/.test(lc(opts.proofHash ?? "")))
226
237
  r.push("proofHash must be a 32-byte hex value (the manifestHash of a sealed submission)");
package/dist/cli.js CHANGED
@@ -60,8 +60,11 @@ const HELP = `cascrow-signer ${SIGNER_VERSION} — your MilestoneEscrowV3 key, o
60
60
  Setup
61
61
  init create a key and its encrypted keystore (~/.cascrow/signer)
62
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)
63
+ [--min-review-hours H] [--max-deadline-days D (365)]
64
+ [--payee 0x…]… [--arbiter 0x…]… [--allow-agent-return-funds]
65
+ (terminal only; replaces the whole policy)
66
+ link [--old-home DIR] link this address to your cascrow account (CASCROW_API_KEY);
67
+ replacing a linked wallet needs its keystore: --old-home
65
68
  status
66
69
 
67
70
  As payee As payer
@@ -70,6 +73,7 @@ As payee As payer
70
73
  --proof-hash 0x… | --file PATH release --escrow ID
71
74
  return-funds --escrow ID extend-deadline --escrow ID --new-deadline UNIX
72
75
  claims | watch [--interval S] [--exec CMD] [--webhook URL]
76
+ [--blind-after N (3)] [--once]
73
77
  Either party
74
78
  claim --escrow ID refund --escrow ID withdraw speed-up --tx HASH
75
79
  mcp run as an MCP server on stdio
@@ -126,7 +130,15 @@ async function main() {
126
130
  const ref = { escrowId: one(a, "escrow"), milestoneId: one(a, "milestone") };
127
131
  switch (cmd) {
128
132
  case "status": return print(s.status());
129
- case "link": return print(await s.link());
133
+ case "link": {
134
+ const oldHome = one(a, "old-home");
135
+ return print(await s.link(oldHome
136
+ ? {
137
+ oldHome,
138
+ oldPassphrase: async () => (io.isTTY && io.prompt ? io.prompt("Passphrase of the keystore in --old-home: ", true) : null),
139
+ }
140
+ : {}));
141
+ }
130
142
  case "policy":
131
143
  if (sub === "set") {
132
144
  return print(await s.setPolicy({
@@ -134,8 +146,10 @@ async function main() {
134
146
  dailyMaxXrp: one(a, "daily-xrp") ?? "",
135
147
  unknownPayeePerEscrowMaxXrp: one(a, "unknown-payee-xrp"),
136
148
  minReviewHours: one(a, "min-review-hours") ? Number(one(a, "min-review-hours")) : undefined,
149
+ maxDeadlineDays: one(a, "max-deadline-days") ? Number(one(a, "max-deadline-days")) : undefined,
137
150
  payees: a.flags.payee,
138
151
  arbiters: a.flags.arbiter,
152
+ allowReturnFundsWithoutTerminal: one(a, "allow-agent-return-funds") === "true",
139
153
  }));
140
154
  }
141
155
  return print(await s.policy());
@@ -150,7 +164,14 @@ async function main() {
150
164
  case "return-funds": return print(await s.returnFunds(ref));
151
165
  case "withdraw": return print(await s.withdraw());
152
166
  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" });
167
+ case "watch":
168
+ return s.watch({
169
+ intervalSec: Number(one(a, "interval") ?? 60),
170
+ exec: one(a, "exec"),
171
+ webhook: one(a, "webhook"),
172
+ once: one(a, "once") === "true",
173
+ ...(one(a, "blind-after") ? { blindAfter: Number(one(a, "blind-after")) } : {}),
174
+ });
154
175
  case "speed-up": return print(await s.speedUp(one(a, "tx") ?? ""));
155
176
  case "mcp": return serveStdio(s, SIGNER_VERSION);
156
177
  default: