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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +35 -0
- package/README.md +130 -0
- package/SECURITY.md +71 -0
- package/dist/acceptance.d.ts +70 -0
- package/dist/acceptance.js +169 -0
- package/dist/actions.d.ts +60 -0
- package/dist/actions.js +319 -0
- package/dist/api.d.ts +83 -0
- package/dist/api.js +105 -0
- package/dist/book.d.ts +26 -0
- package/dist/book.js +62 -0
- package/dist/calldata.d.ts +62 -0
- package/dist/calldata.js +121 -0
- package/dist/chain.d.ts +59 -0
- package/dist/chain.js +233 -0
- package/dist/checks.d.ts +101 -0
- package/dist/checks.js +236 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +163 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.js +50 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +17 -0
- package/dist/files.d.ts +13 -0
- package/dist/files.js +78 -0
- package/dist/generated/milestone-escrow-v3.deployments.json +15 -0
- package/dist/generated/milestone-escrow-v3.json +1179 -0
- package/dist/generated/v3-actions.d.ts +49 -0
- package/dist/generated/v3-actions.js +102 -0
- package/dist/generated/v3-proof-manifest.d.ts +19 -0
- package/dist/generated/v3-proof-manifest.js +79 -0
- package/dist/journal.d.ts +41 -0
- package/dist/journal.js +186 -0
- package/dist/keystore.d.ts +30 -0
- package/dist/keystore.js +119 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.js +65 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +81 -0
- package/dist/network.d.ts +53 -0
- package/dist/network.js +122 -0
- package/dist/policy.d.ts +31 -0
- package/dist/policy.js +92 -0
- package/dist/remote.d.ts +31 -0
- package/dist/remote.js +104 -0
- package/dist/tools.d.ts +20 -0
- package/dist/tools.js +112 -0
- package/dist/watch.d.ts +35 -0
- package/dist/watch.js +129 -0
- package/package.json +64 -0
package/dist/actions.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One function per escrow action. Each one: unlock the key, read what it needs
|
|
3
|
+
* from the CHAIN, build the calldata itself from the pinned ABI, run the checks
|
|
4
|
+
* (checks.ts), then — inside the journal lock — reserve, sign, send to the
|
|
5
|
+
* pinned public RPC, and finally report the hash to cascrow, best effort.
|
|
6
|
+
*
|
|
7
|
+
* `fund` needs cascrow: the payee's acceptance signature and the frozen terms
|
|
8
|
+
* come from `prepare?action=fund`. `submitClaim` needs it too, for one fact only:
|
|
9
|
+
* that the proof hash is a submission cascrow SEALED for the milestone (a claim
|
|
10
|
+
* naming anything else has no verdict — post-review G2). Every other action is
|
|
11
|
+
* built from the escrow ID and the chain alone; `prepare` is fetched only as a
|
|
12
|
+
* cross-check, and an UNREACHABLE cascrow never stops a payer from objecting,
|
|
13
|
+
* releasing or refunding (design Rev. 10, "Nach der Finanzierung ohne Server").
|
|
14
|
+
* A cascrow that ANSWERS with a refusal (a 4xx with its code) is surfaced as that
|
|
15
|
+
* refusal, never read as "down" (post-review G1/H2).
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import { getAddress, keccak256, parseEther, sha256, toUtf8Bytes } from "ethers";
|
|
19
|
+
import { SignerError, refuse } from "./errors.js";
|
|
20
|
+
import { PER_ESCROW_ACTIONS, encodeCall } from "./calldata.js";
|
|
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";
|
|
25
|
+
import { Book } from "./book.js";
|
|
26
|
+
import { RemoteError } from "./remote.js";
|
|
27
|
+
import { verifyEndpoint } from "./network.js";
|
|
28
|
+
import { manifestHash, contentHashFor } from "./generated/v3-proof-manifest.js";
|
|
29
|
+
function chainCtx(ctx) {
|
|
30
|
+
return { rpc: ctx.rpc, net: ctx.net };
|
|
31
|
+
}
|
|
32
|
+
async function report(ctx, milestoneId, txHash, notes) {
|
|
33
|
+
if (!milestoneId || !ctx.remote.configured) {
|
|
34
|
+
notes.push("sent, not reported to cascrow (no milestone or no API key) — cascrow's indexer reads it from the chain anyway");
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await ctx.remote.reportTx(milestoneId, txHash);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
notes.push("sent, not reported to cascrow — its indexer reads the chain within minutes; do NOT send again");
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function fund(ctx, input) {
|
|
47
|
+
if (!input.milestoneId)
|
|
48
|
+
refuse("INPUT", "milestoneId is required");
|
|
49
|
+
const maxAmountWei = input.maxAmountXrp ? parseEther(input.maxAmountXrp) : null;
|
|
50
|
+
const key = await ctx.unlock();
|
|
51
|
+
const me = key.address;
|
|
52
|
+
let policy = loadPolicy(ctx.home, key.macKey, me);
|
|
53
|
+
const book = new Book(ctx.home, key.macKey);
|
|
54
|
+
const notes = [];
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = await ctx.remote.prepare({ milestoneId: input.milestoneId, action: "fund" });
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (isPrepareRefusal(e)) {
|
|
61
|
+
const detail = refusalDetail(e.body);
|
|
62
|
+
throw new SignerError(e.code ?? "PREPARE_REFUSED", `cascrow refused to prepare fund: ${typeof e.body.error === "string" && e.body.error ? e.body.error : `HTTP ${e.status}`}${detail ? ` — ${detail}` : ""}. Nothing was signed`);
|
|
63
|
+
}
|
|
64
|
+
refuse("PREPARE_UNAVAILABLE", `funding needs the payee's acceptance from cascrow, and prepare failed: ${e instanceof Error ? e.message : "error"}`);
|
|
65
|
+
}
|
|
66
|
+
const p = checkPreparedEnvelope(raw, "fund", ctx.net, me);
|
|
67
|
+
const terms = fundTermsFrom(p);
|
|
68
|
+
// First contact with a payee: only a person at a terminal adds it to the book.
|
|
69
|
+
if (!policy.payees.includes(terms.t.payee.toLowerCase()) && ctx.io.isTTY && ctx.io.confirm) {
|
|
70
|
+
const ok = await ctx.io.confirm(`Payee ${terms.t.payee} is not in your counterparty book. Add it (raises the limit to your per-escrow limit)?`);
|
|
71
|
+
if (ok) {
|
|
72
|
+
policy = { ...policy, payees: [...policy.payees, terms.t.payee.toLowerCase()].sort() };
|
|
73
|
+
savePolicy(ctx.home, key.macKey, policy, ctx.io);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const c = chainCtx(ctx);
|
|
77
|
+
return withLock(ctx.home, async () => {
|
|
78
|
+
await verifyEndpoint(ctx.rpc, ctx.net);
|
|
79
|
+
const head = await latestBlock(c);
|
|
80
|
+
const journalPresent = ctx.journal.exists();
|
|
81
|
+
const fromJournal = ctx.journal.daySum(head.timestamp);
|
|
82
|
+
let fromChain;
|
|
83
|
+
try {
|
|
84
|
+
fromChain = await fundedByMeLastDay(c, me, head);
|
|
85
|
+
}
|
|
86
|
+
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");
|
|
89
|
+
}
|
|
90
|
+
notes.push(`could not cross-check today's total against the chain (${e instanceof Error ? e.message : "error"}); used the local journal`);
|
|
91
|
+
fromChain = new Map();
|
|
92
|
+
}
|
|
93
|
+
let day = fromJournal.sumWei;
|
|
94
|
+
for (const [id, amt] of fromChain)
|
|
95
|
+
if (!fromJournal.escrowIds.has(id))
|
|
96
|
+
day += amt;
|
|
97
|
+
if (!journalPresent)
|
|
98
|
+
notes.push("the spending journal was missing; today's total was rebuilt from Funded events on chain");
|
|
99
|
+
try {
|
|
100
|
+
await settleUnknown(c, me, ctx.journal);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* unknown outcomes keep counting — the safe direction */
|
|
104
|
+
}
|
|
105
|
+
const slot = await readEscrow(c, p.escrowId);
|
|
106
|
+
const { payeeKnown } = checkFund({ prepared: p, terms, me, pinned: ctx.net, policy, daySumWei: day, chainNowSec: head.timestamp, slotStatus: slot.status, maxAmountWei });
|
|
107
|
+
if (!payeeKnown)
|
|
108
|
+
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);
|
|
112
|
+
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
|
+
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 });
|
|
115
|
+
if (sent.note)
|
|
116
|
+
notes.push(sent.note);
|
|
117
|
+
const reported = await report(ctx, input.milestoneId, sent.txHash, notes);
|
|
118
|
+
return {
|
|
119
|
+
action: "fund",
|
|
120
|
+
escrowId: p.escrowId,
|
|
121
|
+
txHash: sent.txHash,
|
|
122
|
+
sent: sent.sent,
|
|
123
|
+
reported,
|
|
124
|
+
from: me,
|
|
125
|
+
amountWei: terms.t.amount.toString(),
|
|
126
|
+
payee: terms.t.payee,
|
|
127
|
+
notes,
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The manifest hash of evidence the payee holds locally, computed HERE with the
|
|
133
|
+
* one manifest encoding. A submission is what ONE evidence-door request sealed —
|
|
134
|
+
* today one file, one text or one GitHub item — so the hash of several items only
|
|
135
|
+
* matches a submission that sealed exactly those items. It is never trusted on
|
|
136
|
+
* its own: `submitClaim` sends it only after cascrow's prepare confirms it is a
|
|
137
|
+
* sealed submission of the milestone (post-review G2).
|
|
138
|
+
*/
|
|
139
|
+
export function localProofHash(milestoneId, ev) {
|
|
140
|
+
const items = [];
|
|
141
|
+
for (const f of ev.files ?? [])
|
|
142
|
+
items.push({ kind: "file", sha256: sha256(fs.readFileSync(f)) });
|
|
143
|
+
for (const t of ev.texts ?? [])
|
|
144
|
+
items.push({ kind: "text", text: t });
|
|
145
|
+
for (const g of ev.github ?? [])
|
|
146
|
+
items.push({ kind: "github", commitSha: g.commitSha, diffSha256: g.diffSha256 });
|
|
147
|
+
if (!items.length)
|
|
148
|
+
refuse("INPUT", "submitClaim needs proofHash (the manifestHash the evidence door returned) or the evidence file(s) to hash locally");
|
|
149
|
+
return manifestHash(milestoneId, items.map(contentHashFor));
|
|
150
|
+
}
|
|
151
|
+
const hasEvidence = (ev) => !!ev && ((ev.files?.length ?? 0) + (ev.texts?.length ?? 0) + (ev.github?.length ?? 0)) > 0;
|
|
152
|
+
/**
|
|
153
|
+
* The fields of a refusal body a recipient can act on, as one line. The same
|
|
154
|
+
* fields every cascrow client appends (post-review G5).
|
|
155
|
+
*/
|
|
156
|
+
export function refusalDetail(body) {
|
|
157
|
+
const out = [];
|
|
158
|
+
if (typeof body.remedy === "string" && body.remedy)
|
|
159
|
+
out.push(body.remedy);
|
|
160
|
+
if (Array.isArray(body.allowed))
|
|
161
|
+
out.push(`allowed now: ${body.allowed.length ? body.allowed.map(String).join(", ") : "nothing"}`);
|
|
162
|
+
if (typeof body.objectionDeadline === "string")
|
|
163
|
+
out.push(`objection deadline: ${body.objectionDeadline}`);
|
|
164
|
+
if (typeof body.offerExpiresAt === "string")
|
|
165
|
+
out.push(`offer expires: ${body.offerExpiresAt}`);
|
|
166
|
+
return out.join(" — ");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Status codes that are an answer about cascrow's REACHABILITY for this caller,
|
|
170
|
+
* not about the transaction: a bad API key, a rate limit, a timeout. They fall
|
|
171
|
+
* through like an unreachable server, so a rate limit can never cost a payer an
|
|
172
|
+
* objection window. Every other 4xx is cascrow's refusal and is surfaced.
|
|
173
|
+
*/
|
|
174
|
+
const NOT_A_VERDICT_4XX = new Set([401, 408, 429]);
|
|
175
|
+
/** True when prepare's failure is cascrow saying no (a verdict), not cascrow being unavailable. */
|
|
176
|
+
export function isPrepareRefusal(e) {
|
|
177
|
+
return e instanceof RemoteError && e.status >= 400 && e.status < 500 && !NOT_A_VERDICT_4XX.has(e.status);
|
|
178
|
+
}
|
|
179
|
+
/** The book, read for a NON-book-bound action: a broken book is a loud note, never an abort (post-review H1). */
|
|
180
|
+
function readBookLoosely(ctx, book, notes) {
|
|
181
|
+
try {
|
|
182
|
+
return book.read();
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
if (!(e instanceof SignerError))
|
|
186
|
+
throw e;
|
|
187
|
+
const n = `WARNING: the local escrow book could not be used (${e.message}). It was ignored for this action, which is checked against the chain alone; ` +
|
|
188
|
+
"release and extendDeadline stay refused until the book is restored.";
|
|
189
|
+
notes.push(n);
|
|
190
|
+
ctx.io.err(n);
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export async function escrowAction(ctx, action, input) {
|
|
195
|
+
const key = await ctx.unlock();
|
|
196
|
+
const me = key.address;
|
|
197
|
+
const book = new Book(ctx.home, key.macKey);
|
|
198
|
+
const bookBound = BOOK_BOUND_ACTIONS.has(action);
|
|
199
|
+
const notes = [];
|
|
200
|
+
const c = chainCtx(ctx);
|
|
201
|
+
// Arguments, computed locally.
|
|
202
|
+
const reasonHash = action === "object" ? (input.reason ? keccak256(toUtf8Bytes(input.reason)) : refuse("INPUT", "object needs a reason")) : null;
|
|
203
|
+
const newDeadline = action === "extendDeadline" ? (input.newDeadline !== undefined ? BigInt(input.newDeadline) : refuse("INPUT", "extendDeadline needs newDeadline")) : null;
|
|
204
|
+
let proofHash = null;
|
|
205
|
+
if (action === "submitClaim") {
|
|
206
|
+
if (!input.milestoneId)
|
|
207
|
+
refuse("INPUT", "submitClaim needs the milestoneId the submission is bound to");
|
|
208
|
+
const given = input.proofHash?.trim().toLowerCase() ?? null;
|
|
209
|
+
if (given !== null && !/^0x[0-9a-f]{64}$/.test(given))
|
|
210
|
+
refuse("INPUT", "proofHash must be 0x + 64 hex characters (the manifestHash the evidence door returned)");
|
|
211
|
+
const local = hasEvidence(input.evidence) ? localProofHash(input.milestoneId, input.evidence).toLowerCase() : null;
|
|
212
|
+
if (given && local && given !== local)
|
|
213
|
+
refuse("INPUT", "the evidence given hashes to a different manifest than proofHash — pass one of them");
|
|
214
|
+
proofHash = given ?? local ?? refuse("INPUT", "submitClaim needs proofHash (the manifestHash the evidence door returned) or the evidence file(s) to hash locally");
|
|
215
|
+
// A claim names evidence on chain for good. Only a submission cascrow SEALED has a
|
|
216
|
+
// verdict; a claim with any other hash gets no recommendation and invites an
|
|
217
|
+
// objection. So prepare must confirm it — no confirmation, no claim (G2).
|
|
218
|
+
if (!ctx.remote.configured)
|
|
219
|
+
refuse("API_KEY_REQUIRED", "submitClaim needs CASCROW_API_KEY: cascrow must confirm the proof hash is a sealed submission of this milestone before it goes on chain");
|
|
220
|
+
}
|
|
221
|
+
// Cross-check with cascrow if it answers. Required for submitClaim (above), optional
|
|
222
|
+
// for every other action: an unreachable cascrow never stops one — but when cascrow
|
|
223
|
+
// ANSWERS with a refusal, that answer is surfaced, never read as "down" (G1/H2).
|
|
224
|
+
let prepared = null;
|
|
225
|
+
if (input.milestoneId && ctx.remote.configured) {
|
|
226
|
+
try {
|
|
227
|
+
const raw = await ctx.remote.prepare({
|
|
228
|
+
milestoneId: input.milestoneId,
|
|
229
|
+
action,
|
|
230
|
+
...(proofHash ? { proofHash } : {}),
|
|
231
|
+
...(reasonHash ? { reasonHash } : {}),
|
|
232
|
+
...(newDeadline !== null ? { newDeadline: newDeadline.toString() } : {}),
|
|
233
|
+
});
|
|
234
|
+
prepared = checkPreparedEnvelope(raw, action, ctx.net, me);
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
if (e instanceof SignerError)
|
|
238
|
+
throw e; // prepare answered, and it is wrong: abort
|
|
239
|
+
if (isPrepareRefusal(e)) {
|
|
240
|
+
const msg = typeof e.body.error === "string" && e.body.error ? e.body.error : `HTTP ${e.status}`;
|
|
241
|
+
const detail = refusalDetail(e.body);
|
|
242
|
+
throw new SignerError(e.code ?? "PREPARE_REFUSED", `cascrow refused to prepare ${action}: ${msg}${detail ? ` — ${detail}` : ""}. Nothing was signed`);
|
|
243
|
+
}
|
|
244
|
+
if (action === "submitClaim") {
|
|
245
|
+
refuse("PREPARE_UNAVAILABLE", `submitClaim needs cascrow to confirm the proof hash is a sealed submission of this milestone, and prepare failed: ${e instanceof Error ? e.message : "error"}. Nothing was signed; retry`);
|
|
246
|
+
}
|
|
247
|
+
notes.push(`cascrow's prepare was not available (${e instanceof Error ? e.message : "error"}) — the transaction was built from the chain alone`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (action === "submitClaim" && !prepared)
|
|
251
|
+
refuse("PREPARE_UNAVAILABLE", "submitClaim needs cascrow's confirmation of the proof hash; nothing was signed");
|
|
252
|
+
// Only a book-bound action reads the book strictly (a broken book refuses it); any
|
|
253
|
+
// other action reads it, if at all, loosely — to find an escrow ID or a milestone.
|
|
254
|
+
const lookup = () => (bookBound ? book.read() : readBookLoosely(ctx, book, notes));
|
|
255
|
+
let escrowId = input.escrowId?.toLowerCase() ?? null;
|
|
256
|
+
if (!escrowId && input.milestoneId) {
|
|
257
|
+
escrowId = lookup().find((e) => e.milestoneId === input.milestoneId && e.chainId === ctx.net.chainId && e.contract === ctx.net.address.toLowerCase())?.escrowId ?? null;
|
|
258
|
+
}
|
|
259
|
+
if (!escrowId && prepared)
|
|
260
|
+
escrowId = prepared.escrowId.toLowerCase();
|
|
261
|
+
if (!escrowId || !/^0x[0-9a-f]{64}$/.test(escrowId))
|
|
262
|
+
refuse("INPUT", "no escrowId: pass it, or a milestoneId this signer has in its book");
|
|
263
|
+
if (prepared && prepared.escrowId.toLowerCase() !== escrowId)
|
|
264
|
+
refuse("PREPARE_DISAGREES", "prepare names a different escrow than the one asked for");
|
|
265
|
+
const args = action === "object" ? [escrowId, reasonHash] :
|
|
266
|
+
action === "extendDeadline" ? [escrowId, newDeadline.toString()] :
|
|
267
|
+
action === "submitClaim" ? [escrowId, proofHash] :
|
|
268
|
+
[escrowId];
|
|
269
|
+
const data = encodeCall(action, args);
|
|
270
|
+
if (prepared)
|
|
271
|
+
checkMatchesLocal({ to: ctx.net.address, data, value: BigInt(0) }, prepared);
|
|
272
|
+
return withLock(ctx.home, async () => {
|
|
273
|
+
await verifyEndpoint(ctx.rpc, ctx.net);
|
|
274
|
+
const head = await latestBlock(c);
|
|
275
|
+
const e = await readEscrow(c, escrowId);
|
|
276
|
+
const findIn = (entries) => entries.find((x) => x.escrowId === escrowId && x.chainId === ctx.net.chainId && x.contract === ctx.net.address.toLowerCase()) ?? null;
|
|
277
|
+
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) });
|
|
281
|
+
if (sent.note)
|
|
282
|
+
notes.push(sent.note);
|
|
283
|
+
const milestoneId = input.milestoneId ?? entry?.milestoneId ?? null;
|
|
284
|
+
const reported = await report(ctx, milestoneId, sent.txHash, notes);
|
|
285
|
+
return {
|
|
286
|
+
action,
|
|
287
|
+
escrowId,
|
|
288
|
+
txHash: sent.txHash,
|
|
289
|
+
sent: sent.sent,
|
|
290
|
+
reported,
|
|
291
|
+
from: me,
|
|
292
|
+
...(proofHash ? { proofHash } : {}),
|
|
293
|
+
...(reasonHash ? { reasonHash } : {}),
|
|
294
|
+
notes,
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/** withdraw(own account, native XRP): pays this key's credit to this key. No escrow, no cascrow. */
|
|
299
|
+
export async function withdraw(ctx) {
|
|
300
|
+
const key = await ctx.unlock();
|
|
301
|
+
const me = getAddress(key.address);
|
|
302
|
+
const c = chainCtx(ctx);
|
|
303
|
+
const notes = [];
|
|
304
|
+
return withLock(ctx.home, async () => {
|
|
305
|
+
await verifyEndpoint(ctx.rpc, ctx.net);
|
|
306
|
+
const head = await latestBlock(c);
|
|
307
|
+
const credit = await readCredit(c, me);
|
|
308
|
+
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);
|
|
312
|
+
const data = encodeCall("withdraw", [me, "0x0000000000000000000000000000000000000000"]);
|
|
313
|
+
const sent = await sendSigned(c, key, ctx.journal, rid, { to: ctx.net.address, data, value: BigInt(0) });
|
|
314
|
+
if (sent.note)
|
|
315
|
+
notes.push(sent.note);
|
|
316
|
+
return { action: "withdraw", escrowId: "", txHash: sent.txHash, sent: sent.sent, reported: false, from: me, creditWei: credit.toString(), notes };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
export { PER_ESCROW_ACTIONS };
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { SignerContext } from "./context.js";
|
|
2
|
+
import type { Io, SignerConfig } from "./context.js";
|
|
3
|
+
import type { PolicyInput } from "./policy.js";
|
|
4
|
+
import type { EscrowActionInput, FundInput } from "./actions.js";
|
|
5
|
+
import type { AcceptInput } from "./acceptance.js";
|
|
6
|
+
import type { WatchOptions } from "./watch.js";
|
|
7
|
+
export { SignerError } from "./errors.js";
|
|
8
|
+
export { SignerContext } from "./context.js";
|
|
9
|
+
export type { Io, SignerConfig } from "./context.js";
|
|
10
|
+
export { LOCAL_ONLY_TOOLS } from "./tools.js";
|
|
11
|
+
export { checkAcceptance } from "./acceptance.js";
|
|
12
|
+
export { checkChallenge, CHALLENGE_PREFIX } from "./link.js";
|
|
13
|
+
export { localProofHash } from "./actions.js";
|
|
14
|
+
export { resolveNetwork, PUBLIC_NETWORKS } from "./network.js";
|
|
15
|
+
export declare const SIGNER_VERSION = "0.1.0";
|
|
16
|
+
/** Create the keystore and the spending journal. Does not need a network or cascrow. */
|
|
17
|
+
export declare function initSigner(home: string, passphrase: string, opts?: {
|
|
18
|
+
scryptN?: number;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
address: string;
|
|
21
|
+
home: string;
|
|
22
|
+
}>;
|
|
23
|
+
type Env = Record<string, string | undefined>;
|
|
24
|
+
export declare function configFromEnv(env: Env, io: Io, overrides?: Partial<SignerConfig>): SignerConfig;
|
|
25
|
+
export declare function createSigner(cfg: SignerConfig): {
|
|
26
|
+
ctx: SignerContext;
|
|
27
|
+
address: () => string;
|
|
28
|
+
link: () => Promise<{
|
|
29
|
+
linked: boolean;
|
|
30
|
+
walletAddress: string;
|
|
31
|
+
changed: boolean;
|
|
32
|
+
message: string;
|
|
33
|
+
} | {
|
|
34
|
+
linked: boolean;
|
|
35
|
+
walletAddress: string;
|
|
36
|
+
changed: boolean;
|
|
37
|
+
message?: undefined;
|
|
38
|
+
}>;
|
|
39
|
+
accept: (i: AcceptInput) => Promise<{
|
|
40
|
+
accepted: boolean;
|
|
41
|
+
escrowId: string;
|
|
42
|
+
payer: string;
|
|
43
|
+
amountWei: string;
|
|
44
|
+
acceptBy: string;
|
|
45
|
+
deadline: string;
|
|
46
|
+
reviewPeriod: number;
|
|
47
|
+
arbiter: string;
|
|
48
|
+
server: {
|
|
49
|
+
escrowId: {} | null;
|
|
50
|
+
acceptBy: {} | null;
|
|
51
|
+
};
|
|
52
|
+
note: string;
|
|
53
|
+
}>;
|
|
54
|
+
fund: (i: FundInput) => Promise<import("./actions.js").ActionResult>;
|
|
55
|
+
submitClaim: (i: EscrowActionInput) => Promise<import("./actions.js").ActionResult>;
|
|
56
|
+
claim: (i: EscrowActionInput) => Promise<import("./actions.js").ActionResult>;
|
|
57
|
+
release: (i: EscrowActionInput) => Promise<import("./actions.js").ActionResult>;
|
|
58
|
+
object: (i: EscrowActionInput) => Promise<import("./actions.js").ActionResult>;
|
|
59
|
+
refund: (i: EscrowActionInput) => Promise<import("./actions.js").ActionResult>;
|
|
60
|
+
extendDeadline: (i: EscrowActionInput) => Promise<import("./actions.js").ActionResult>;
|
|
61
|
+
returnFunds: (i: EscrowActionInput) => Promise<import("./actions.js").ActionResult>;
|
|
62
|
+
withdraw: () => Promise<import("./actions.js").ActionResult>;
|
|
63
|
+
onchainClaims: () => Promise<{
|
|
64
|
+
address: string;
|
|
65
|
+
claims: import("./watch.js").OnchainClaim[];
|
|
66
|
+
escrowsChecked: number;
|
|
67
|
+
}>;
|
|
68
|
+
watch: (o?: WatchOptions) => Promise<void>;
|
|
69
|
+
speedUp: (txHash: string) => Promise<import("./chain.js").SendResult>;
|
|
70
|
+
policy: () => Promise<import("./policy.js").Policy>;
|
|
71
|
+
setPolicy: (input: PolicyInput) => Promise<import("./policy.js").Policy>;
|
|
72
|
+
status: () => {
|
|
73
|
+
address: string;
|
|
74
|
+
network: import("./network.js").SignerNetworkName;
|
|
75
|
+
chainId: number;
|
|
76
|
+
contract: string;
|
|
77
|
+
rpcUrl: string;
|
|
78
|
+
overridden: boolean;
|
|
79
|
+
home: string;
|
|
80
|
+
version: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
export type Signer = ReturnType<typeof createSigner>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cascrow-signer — the programmatic API.
|
|
3
|
+
*
|
|
4
|
+
* Everything the CLI and the MCP server do goes through here. The key lives in
|
|
5
|
+
* an encrypted keystore on this machine (keystore.ts) and never leaves it; the
|
|
6
|
+
* only things sent to cascrow are listed in remote.ts, and every transaction goes
|
|
7
|
+
* to the pinned public RPC of the network (network.ts).
|
|
8
|
+
*
|
|
9
|
+
* import { createSigner, configFromEnv } from "cascrow-signer";
|
|
10
|
+
* const s = createSigner(configFromEnv(process.env, io));
|
|
11
|
+
* await s.object({ escrowId, reason: "The delivery does not meet criterion 2." });
|
|
12
|
+
*/
|
|
13
|
+
import { refuse } from "./errors.js";
|
|
14
|
+
import { SignerContext } from "./context.js";
|
|
15
|
+
import { createKeystore } from "./keystore.js";
|
|
16
|
+
import { Journal } from "./journal.js";
|
|
17
|
+
import { buildPolicy, loadPolicy, savePolicy } from "./policy.js";
|
|
18
|
+
import { escrowAction, fund, withdraw } from "./actions.js";
|
|
19
|
+
import { accept } from "./acceptance.js";
|
|
20
|
+
import { link } from "./link.js";
|
|
21
|
+
import { onchainClaims, watch } from "./watch.js";
|
|
22
|
+
import { speedUp } from "./chain.js";
|
|
23
|
+
import { withLock } from "./journal.js";
|
|
24
|
+
import { ensureHome, signerHome } from "./files.js";
|
|
25
|
+
export { SignerError } from "./errors.js";
|
|
26
|
+
export { SignerContext } from "./context.js";
|
|
27
|
+
export { LOCAL_ONLY_TOOLS } from "./tools.js";
|
|
28
|
+
export { checkAcceptance } from "./acceptance.js";
|
|
29
|
+
export { checkChallenge, CHALLENGE_PREFIX } from "./link.js";
|
|
30
|
+
export { localProofHash } from "./actions.js";
|
|
31
|
+
export { resolveNetwork, PUBLIC_NETWORKS } from "./network.js";
|
|
32
|
+
export const SIGNER_VERSION = "0.1.0";
|
|
33
|
+
/** Create the keystore and the spending journal. Does not need a network or cascrow. */
|
|
34
|
+
export async function initSigner(home, passphrase, opts = {}) {
|
|
35
|
+
ensureHome(home);
|
|
36
|
+
const { address } = await createKeystore(home, passphrase, opts);
|
|
37
|
+
new Journal(home).create();
|
|
38
|
+
return { address, home };
|
|
39
|
+
}
|
|
40
|
+
export function configFromEnv(env, io, overrides = {}) {
|
|
41
|
+
const network = (overrides.network ?? env.CASCROW_SIGNER_NETWORK ?? "").trim();
|
|
42
|
+
if (network !== "testnet" && network !== "mainnet") {
|
|
43
|
+
refuse("NETWORK_REQUIRED", "name the network: --network testnet|mainnet or CASCROW_SIGNER_NETWORK (the signer never guesses a chain)");
|
|
44
|
+
}
|
|
45
|
+
const o = {
|
|
46
|
+
rpcUrl: env.CASCROW_SIGNER_TESTNET_RPC_URL || undefined,
|
|
47
|
+
address: env.CASCROW_SIGNER_TESTNET_CONTRACT || undefined,
|
|
48
|
+
deployBlock: env.CASCROW_SIGNER_TESTNET_DEPLOY_BLOCK ? Number(env.CASCROW_SIGNER_TESTNET_DEPLOY_BLOCK) : undefined,
|
|
49
|
+
runtimeCodeHash: env.CASCROW_SIGNER_TESTNET_CODE_HASH || undefined,
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
home: overrides.home ?? signerHome(env),
|
|
53
|
+
network,
|
|
54
|
+
override: overrides.override ?? o,
|
|
55
|
+
apiBaseUrl: overrides.apiBaseUrl ?? env.CASCROW_BASE_URL ?? "https://cascrow.com",
|
|
56
|
+
apiKey: overrides.apiKey ?? env.CASCROW_API_KEY ?? "",
|
|
57
|
+
io,
|
|
58
|
+
...(overrides.passphrase ? { passphrase: overrides.passphrase } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function createSigner(cfg) {
|
|
62
|
+
const ctx = new SignerContext(cfg);
|
|
63
|
+
const act = (a) => (i) => escrowAction(ctx, a, i);
|
|
64
|
+
return {
|
|
65
|
+
ctx,
|
|
66
|
+
address: () => ctx.address(),
|
|
67
|
+
link: () => link(ctx),
|
|
68
|
+
accept: (i) => accept(ctx, i),
|
|
69
|
+
fund: (i) => fund(ctx, i),
|
|
70
|
+
submitClaim: act("submitClaim"),
|
|
71
|
+
claim: act("claim"),
|
|
72
|
+
release: act("release"),
|
|
73
|
+
object: act("object"),
|
|
74
|
+
refund: act("refund"),
|
|
75
|
+
extendDeadline: act("extendDeadline"),
|
|
76
|
+
returnFunds: act("returnFunds"),
|
|
77
|
+
withdraw: () => withdraw(ctx),
|
|
78
|
+
onchainClaims: () => onchainClaims(ctx),
|
|
79
|
+
watch: (o) => watch(ctx, o),
|
|
80
|
+
speedUp: async (txHash) => {
|
|
81
|
+
const key = await ctx.unlock();
|
|
82
|
+
return withLock(ctx.home, () => speedUp({ rpc: ctx.rpc, net: ctx.net }, key, ctx.journal, txHash));
|
|
83
|
+
},
|
|
84
|
+
policy: async () => {
|
|
85
|
+
const key = await ctx.unlock();
|
|
86
|
+
return loadPolicy(ctx.home, key.macKey, key.address);
|
|
87
|
+
},
|
|
88
|
+
setPolicy: async (input) => {
|
|
89
|
+
const key = await ctx.unlock();
|
|
90
|
+
const p = buildPolicy(key.address, input);
|
|
91
|
+
savePolicy(ctx.home, key.macKey, p, ctx.io);
|
|
92
|
+
return p;
|
|
93
|
+
},
|
|
94
|
+
status: () => ({
|
|
95
|
+
address: ctx.address(),
|
|
96
|
+
network: ctx.net.name,
|
|
97
|
+
chainId: ctx.net.chainId,
|
|
98
|
+
contract: ctx.net.address,
|
|
99
|
+
rpcUrl: ctx.net.rpcUrl,
|
|
100
|
+
overridden: ctx.net.overridden,
|
|
101
|
+
home: ctx.home,
|
|
102
|
+
version: SIGNER_VERSION,
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
package/dist/book.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const BOOK_FILE = "escrows.json";
|
|
2
|
+
export type BookEntry = {
|
|
3
|
+
escrowId: string;
|
|
4
|
+
role: "payer" | "payee";
|
|
5
|
+
milestoneId: string | null;
|
|
6
|
+
payer: string;
|
|
7
|
+
payee: string;
|
|
8
|
+
amountWei: string;
|
|
9
|
+
chainId: number;
|
|
10
|
+
contract: string;
|
|
11
|
+
at: number;
|
|
12
|
+
};
|
|
13
|
+
export declare class Book {
|
|
14
|
+
private readonly home;
|
|
15
|
+
private readonly macKey;
|
|
16
|
+
private readonly file;
|
|
17
|
+
constructor(home: string, macKey: Buffer);
|
|
18
|
+
read(): BookEntry[];
|
|
19
|
+
find(escrowId: string, chainId: number, contract: string): BookEntry | null;
|
|
20
|
+
add(e: Omit<BookEntry, "escrowId" | "payer" | "payee" | "contract"> & {
|
|
21
|
+
escrowId: string;
|
|
22
|
+
payer: string;
|
|
23
|
+
payee: string;
|
|
24
|
+
contract: string;
|
|
25
|
+
}): void;
|
|
26
|
+
}
|
package/dist/book.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local escrow book: every escrow this key funded (as payer) or accepted (as
|
|
3
|
+
* payee), written at the moment it signed — never learned from cascrow.
|
|
4
|
+
*
|
|
5
|
+
* `release`, `object` and `extendDeadline` are only signed for an escrow in the
|
|
6
|
+
* book whose ON-CHAIN payer is this key and whose on-chain payee is the book's.
|
|
7
|
+
* The watcher (watch.ts) reads its escrows from here and from the chain, so a
|
|
8
|
+
* server that "forgets" an escrow cannot hide a claim from its payer.
|
|
9
|
+
*
|
|
10
|
+
* MAC'd like the policy (same derived key), so an entry cannot be slipped in by a
|
|
11
|
+
* process that can write the directory but not unlock the key.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { getAddress } from "ethers";
|
|
16
|
+
import { refuse } from "./errors.js";
|
|
17
|
+
import { ensureHome, macMatches, macOf, readPrivateFile, replacePrivateFile } from "./files.js";
|
|
18
|
+
export const BOOK_FILE = "escrows.json";
|
|
19
|
+
export class Book {
|
|
20
|
+
home;
|
|
21
|
+
macKey;
|
|
22
|
+
file;
|
|
23
|
+
constructor(home, macKey) {
|
|
24
|
+
this.home = home;
|
|
25
|
+
this.macKey = macKey;
|
|
26
|
+
this.file = path.join(home, BOOK_FILE);
|
|
27
|
+
}
|
|
28
|
+
read() {
|
|
29
|
+
ensureHome(this.home);
|
|
30
|
+
if (!fs.existsSync(this.file))
|
|
31
|
+
return [];
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(readPrivateFile(this.file));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
refuse("BOOK_TAMPERED", "the local escrow book is not valid JSON");
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(parsed.entries) || !macMatches(this.macKey, parsed.entries, parsed.mac)) {
|
|
40
|
+
refuse("BOOK_TAMPERED", "the local escrow book was changed outside the signer — it is not used");
|
|
41
|
+
}
|
|
42
|
+
return parsed.entries;
|
|
43
|
+
}
|
|
44
|
+
find(escrowId, chainId, contract) {
|
|
45
|
+
const id = escrowId.toLowerCase();
|
|
46
|
+
return this.read().find((e) => e.escrowId === id && e.chainId === chainId && e.contract === contract.toLowerCase()) ?? null;
|
|
47
|
+
}
|
|
48
|
+
add(e) {
|
|
49
|
+
const entries = this.read();
|
|
50
|
+
const norm = {
|
|
51
|
+
...e,
|
|
52
|
+
escrowId: e.escrowId.toLowerCase(),
|
|
53
|
+
payer: getAddress(e.payer).toLowerCase(),
|
|
54
|
+
payee: getAddress(e.payee).toLowerCase(),
|
|
55
|
+
contract: getAddress(e.contract).toLowerCase(),
|
|
56
|
+
};
|
|
57
|
+
if (entries.some((x) => x.escrowId === norm.escrowId && x.chainId === norm.chainId && x.contract === norm.contract))
|
|
58
|
+
return;
|
|
59
|
+
entries.push(norm);
|
|
60
|
+
replacePrivateFile(this.file, JSON.stringify({ entries, mac: macOf(this.macKey, entries) }, null, 2));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Interface } from "ethers";
|
|
2
|
+
import type { TypedDataDomain, TypedDataField } from "ethers";
|
|
3
|
+
export declare const ESCROW_V3_INTERFACE: Interface;
|
|
4
|
+
export declare const V3_PHASE_NAMES: readonly string[];
|
|
5
|
+
export declare const V3_LIMITS: Readonly<{
|
|
6
|
+
MIN_REVIEW_SECONDS: number;
|
|
7
|
+
MAX_REVIEW_SECONDS: number;
|
|
8
|
+
MIN_ARBITRATION_SECONDS: number;
|
|
9
|
+
MAX_ARBITRATION_SECONDS: number;
|
|
10
|
+
MAX_DEADLINE_SECONDS: number;
|
|
11
|
+
MAX_CLAIMS: number;
|
|
12
|
+
}>;
|
|
13
|
+
export declare const ACCEPTANCE_TYPES: Record<string, TypedDataField[]>;
|
|
14
|
+
export declare const ACCEPTANCE_PRIMARY_TYPE: string;
|
|
15
|
+
/** keccak256(abi.encode(payer, ref, salt)) — the contract's `escrowIdFor`. */
|
|
16
|
+
export declare function escrowIdFor(payer: string, ref: string, salt: string): string;
|
|
17
|
+
/** The domain a payee signs under — the PINNED chain and contract, never the server's. */
|
|
18
|
+
export declare function acceptanceDomain(chainId: number, verifyingContract: string): TypedDataDomain;
|
|
19
|
+
/** The contract function each action calls. `resolve` is the arbiter's and is not offered. */
|
|
20
|
+
export declare const ACTION_FN: Readonly<{
|
|
21
|
+
readonly fund: "fund";
|
|
22
|
+
readonly submitClaim: "submitClaim";
|
|
23
|
+
readonly claim: "claim";
|
|
24
|
+
readonly release: "release";
|
|
25
|
+
readonly object: "object";
|
|
26
|
+
readonly refund: "refund";
|
|
27
|
+
readonly extendDeadline: "extendDeadline";
|
|
28
|
+
readonly returnFunds: "returnFunds";
|
|
29
|
+
readonly withdraw: "withdraw";
|
|
30
|
+
}>;
|
|
31
|
+
export type SignerAction = keyof typeof ACTION_FN;
|
|
32
|
+
export declare const SIGNER_ACTIONS: readonly ("object" | "fund" | "submitClaim" | "claim" | "release" | "refund" | "extendDeadline" | "returnFunds" | "withdraw")[];
|
|
33
|
+
/** Actions whose first argument is the escrow ID. */
|
|
34
|
+
export declare const PER_ESCROW_ACTIONS: ReadonlySet<SignerAction>;
|
|
35
|
+
/** Canonical form of `args` for `fn`, whatever shape (ethers Result, strings, bigints) they come in. */
|
|
36
|
+
export declare function canonicalArgs(fn: string, args: readonly unknown[]): unknown[];
|
|
37
|
+
/** Calldata for `fn(args)`, encoded from the pinned ABI. */
|
|
38
|
+
export declare function encodeCall(fn: string, args: readonly unknown[]): string;
|
|
39
|
+
/** Decode calldata into its function name and canonical arguments. */
|
|
40
|
+
export declare function decodeCall(data: string): {
|
|
41
|
+
fn: string;
|
|
42
|
+
args: unknown[];
|
|
43
|
+
};
|
|
44
|
+
export declare function sameCanonical(a: unknown, b: unknown): boolean;
|
|
45
|
+
export declare const ZERO_ADDRESS: string;
|
|
46
|
+
/** The escrow as `getEscrow` returns it, with numbers as bigint/number. */
|
|
47
|
+
export type ChainEscrow = {
|
|
48
|
+
payer: string;
|
|
49
|
+
deadline: number;
|
|
50
|
+
reviewPeriod: number;
|
|
51
|
+
payee: string;
|
|
52
|
+
claimedAt: number;
|
|
53
|
+
claimCount: number;
|
|
54
|
+
objected: boolean;
|
|
55
|
+
status: number;
|
|
56
|
+
arbiter: string;
|
|
57
|
+
objectedAt: number;
|
|
58
|
+
arbitrationPeriod: number;
|
|
59
|
+
token: string;
|
|
60
|
+
amount: bigint;
|
|
61
|
+
};
|
|
62
|
+
export declare function decodeGetEscrow(result: string): ChainEscrow;
|