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/CHANGELOG.md +56 -4
- package/MIGRATION.md +36 -1
- package/README.md +52 -14
- package/SECURITY.md +34 -7
- package/dist/acceptance.d.ts +7 -0
- package/dist/acceptance.js +22 -2
- package/dist/actions.d.ts +23 -0
- package/dist/actions.js +180 -26
- package/dist/api.d.ts +19 -4
- package/dist/api.js +19 -11
- package/dist/chain.d.ts +14 -8
- package/dist/chain.js +20 -15
- package/dist/checks.d.ts +2 -0
- package/dist/checks.js +11 -0
- package/dist/cli.js +25 -4
- package/dist/journal.d.ts +76 -9
- package/dist/journal.js +305 -53
- package/dist/keystore.d.ts +6 -1
- package/dist/keystore.js +10 -3
- package/dist/link.d.ts +43 -6
- package/dist/link.js +134 -26
- package/dist/policy.d.ts +36 -0
- package/dist/policy.js +36 -0
- package/dist/remote.d.ts +8 -2
- package/dist/remote.js +15 -8
- package/dist/tools.js +10 -6
- package/dist/watch.d.ts +31 -5
- package/dist/watch.js +152 -63
- package/package.json +1 -1
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
|
-
|
|
81
|
-
const
|
|
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 (!
|
|
88
|
-
refuse("JOURNAL_UNREADABLE",
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
280
|
-
const
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
const rid =
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
37
|
-
new
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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] [--
|
|
64
|
-
|
|
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":
|
|
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":
|
|
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:
|