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/link.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link this key's address to the cascrow account — the signed challenge
|
|
3
|
+
* `POST /api/user/wallet` requires.
|
|
4
|
+
*
|
|
5
|
+
* A signature over arbitrary text a server hands you can be anything (a
|
|
6
|
+
* permit, a login elsewhere). So the challenge is signed only if it is EXACTLY
|
|
7
|
+
* the known sentence with the nonce that the challenge token itself carries —
|
|
8
|
+
* checked here, before `signMessage` is reached. And an account that already has
|
|
9
|
+
* a different wallet linked is never re-pointed without a person confirming it
|
|
10
|
+
* on a terminal: on V3 the linked wallet is where an acceptance must come from,
|
|
11
|
+
* and a silent re-point breaks every open offer (design Rev. 10, step 5).
|
|
12
|
+
*/
|
|
13
|
+
import { getAddress, isAddress } from "ethers";
|
|
14
|
+
import { refuse } from "./errors.js";
|
|
15
|
+
export const CHALLENGE_PREFIX = "Sign this message to prove wallet ownership for Cascrow: ";
|
|
16
|
+
/** The nonce the challenge token carries, read from its payload (the server verifies the token; we only read it). */
|
|
17
|
+
export function challengeNonce(token) {
|
|
18
|
+
const parts = String(token).split(".");
|
|
19
|
+
if (parts.length !== 3)
|
|
20
|
+
refuse("CHALLENGE_MISMATCH", "the challenge token is not a JWT");
|
|
21
|
+
let payload;
|
|
22
|
+
try {
|
|
23
|
+
payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
refuse("CHALLENGE_MISMATCH", "the challenge token's payload does not parse");
|
|
27
|
+
}
|
|
28
|
+
if (payload.purpose !== "wallet-ownership")
|
|
29
|
+
refuse("CHALLENGE_MISMATCH", "the challenge token is not a wallet-ownership challenge");
|
|
30
|
+
if (typeof payload.nonce !== "string" || !/^[0-9a-f]{32}$/.test(payload.nonce))
|
|
31
|
+
refuse("CHALLENGE_MISMATCH", "the challenge token carries no valid nonce");
|
|
32
|
+
return payload.nonce;
|
|
33
|
+
}
|
|
34
|
+
/** Refuses anything but the exact challenge sentence for this token's nonce. */
|
|
35
|
+
export function checkChallenge(message, token) {
|
|
36
|
+
if (typeof message !== "string" || typeof token !== "string")
|
|
37
|
+
refuse("CHALLENGE_MISMATCH", "the challenge is incomplete");
|
|
38
|
+
const nonce = challengeNonce(token);
|
|
39
|
+
if (message !== `${CHALLENGE_PREFIX}${nonce}`)
|
|
40
|
+
refuse("CHALLENGE_MISMATCH", "the text to sign is not the wallet-ownership challenge — nothing was signed");
|
|
41
|
+
return message;
|
|
42
|
+
}
|
|
43
|
+
export async function link(ctx) {
|
|
44
|
+
const key = await ctx.unlock();
|
|
45
|
+
const me = getAddress(key.address);
|
|
46
|
+
const current = (await ctx.remote.me()).walletAddress;
|
|
47
|
+
const known = typeof current === "string" || current === null;
|
|
48
|
+
if (typeof current === "string" && isAddress(current) && getAddress(current) === me) {
|
|
49
|
+
return { linked: true, walletAddress: me, changed: false, message: "This key's address is already the account's wallet." };
|
|
50
|
+
}
|
|
51
|
+
if (!known || current !== null) {
|
|
52
|
+
const what = known ? `replaces the linked wallet ${String(current)}` : "may replace a wallet already linked to this account (cascrow did not say which)";
|
|
53
|
+
if (!ctx.io.isTTY || !ctx.io.confirm) {
|
|
54
|
+
refuse("REPOINT_NEEDS_TTY", `linking ${me} ${what}; that is only done after confirming on a terminal: cascrow-signer link`);
|
|
55
|
+
}
|
|
56
|
+
if (!(await ctx.io.confirm(`Linking ${me} ${what}. Open offers and acceptances naming the old wallet will fail. Continue?`))) {
|
|
57
|
+
refuse("CANCELLED", "not linked");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const ch = await ctx.remote.walletChallenge();
|
|
61
|
+
const message = checkChallenge(ch.message, ch.token);
|
|
62
|
+
const signature = await key.signMessage(message);
|
|
63
|
+
await ctx.remote.linkWallet(me, signature, ch.token);
|
|
64
|
+
return { linked: true, walletAddress: me, changed: true };
|
|
65
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Signer } from "./api.js";
|
|
2
|
+
export declare const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
3
|
+
/** Dispatch one tool call. Exported for tests: every result an agent sees comes from here. */
|
|
4
|
+
export declare function handleToolCall(s: Signer, name: string, args: Record<string, unknown>): Promise<unknown>;
|
|
5
|
+
export declare function answer(s: Signer, msg: {
|
|
6
|
+
id?: unknown;
|
|
7
|
+
method?: string;
|
|
8
|
+
params?: Record<string, unknown>;
|
|
9
|
+
}, version: string): Promise<unknown | null>;
|
|
10
|
+
export declare function serveStdio(s: Signer, version: string): void;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signer as an MCP server over stdio — a small JSON-RPC 2.0 loop, written
|
|
3
|
+
* out here instead of pulling in the MCP SDK, so this package keeps exactly one
|
|
4
|
+
* runtime dependency (design Rev. 10, finding 10).
|
|
5
|
+
*
|
|
6
|
+
* Protocol surface: `initialize`, `notifications/initialized`, `ping`,
|
|
7
|
+
* `tools/list`, `tools/call`. Messages are newline-delimited JSON on stdin and
|
|
8
|
+
* stdout; diagnostics and alarms go to stderr. The process is never a TTY for
|
|
9
|
+
* the policy (policy.ts): a model cannot raise its own limits through a tool.
|
|
10
|
+
*/
|
|
11
|
+
import { createInterface } from "node:readline";
|
|
12
|
+
import { SIGNER_TOOLS } from "./tools.js";
|
|
13
|
+
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
14
|
+
const str = (v) => (typeof v === "string" && v.length ? v : undefined);
|
|
15
|
+
const strList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string") : undefined);
|
|
16
|
+
/** Dispatch one tool call. Exported for tests: every result an agent sees comes from here. */
|
|
17
|
+
export async function handleToolCall(s, name, args) {
|
|
18
|
+
const ref = { escrowId: str(args.escrowId), milestoneId: str(args.milestoneId) };
|
|
19
|
+
switch (name) {
|
|
20
|
+
case "cascrow_signer_status": return s.status();
|
|
21
|
+
case "cascrow_signer_link": return s.link();
|
|
22
|
+
case "cascrow_signer_accept": return s.accept({ milestoneId: String(args.milestoneId ?? ""), minAmountXrp: str(args.minAmountXrp) });
|
|
23
|
+
case "cascrow_signer_fund": return s.fund({ milestoneId: String(args.milestoneId ?? ""), maxAmountXrp: str(args.maxAmountXrp) });
|
|
24
|
+
case "cascrow_signer_submit_claim": return s.submitClaim({ ...ref, proofHash: str(args.proofHash), evidence: { files: strList(args.files), texts: strList(args.texts) } });
|
|
25
|
+
case "cascrow_signer_object": return s.object({ ...ref, reason: str(args.reason) });
|
|
26
|
+
case "cascrow_signer_release": return s.release(ref);
|
|
27
|
+
case "cascrow_signer_extend_deadline": return s.extendDeadline({ ...ref, newDeadline: str(args.newDeadline) });
|
|
28
|
+
case "cascrow_signer_claim": return s.claim(ref);
|
|
29
|
+
case "cascrow_signer_refund": return s.refund(ref);
|
|
30
|
+
case "cascrow_signer_return_funds": return s.returnFunds(ref);
|
|
31
|
+
case "cascrow_signer_withdraw": return s.withdraw();
|
|
32
|
+
case "cascrow_signer_onchain_claims": return s.onchainClaims();
|
|
33
|
+
case "cascrow_signer_speed_up": return s.speedUp(String(args.txHash ?? ""));
|
|
34
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const json = (v) => JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x), 2);
|
|
38
|
+
export async function answer(s, msg, version) {
|
|
39
|
+
const id = msg.id ?? null;
|
|
40
|
+
const reply = (result) => ({ jsonrpc: "2.0", id, result });
|
|
41
|
+
switch (msg.method) {
|
|
42
|
+
case "initialize":
|
|
43
|
+
return reply({ protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: "cascrow-signer", version } });
|
|
44
|
+
case "notifications/initialized":
|
|
45
|
+
return null;
|
|
46
|
+
case "ping":
|
|
47
|
+
return reply({});
|
|
48
|
+
case "tools/list":
|
|
49
|
+
return reply({ tools: SIGNER_TOOLS });
|
|
50
|
+
case "tools/call": {
|
|
51
|
+
const name = String(msg.params?.name ?? "");
|
|
52
|
+
const args = (msg.params?.arguments ?? {});
|
|
53
|
+
try {
|
|
54
|
+
return reply({ content: [{ type: "text", text: json(await handleToolCall(s, name, args)) }] });
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
return reply({ content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : "unknown error"}` }], isError: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
default:
|
|
61
|
+
return id === null ? null : { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${String(msg.method)}` } };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function serveStdio(s, version) {
|
|
65
|
+
const rl = createInterface({ input: process.stdin });
|
|
66
|
+
rl.on("line", async (line) => {
|
|
67
|
+
if (!line.trim())
|
|
68
|
+
return;
|
|
69
|
+
let msg;
|
|
70
|
+
try {
|
|
71
|
+
msg = JSON.parse(line);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }) + "\n");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const out = await answer(s, msg, version);
|
|
78
|
+
if (out)
|
|
79
|
+
process.stdout.write(JSON.stringify(out) + "\n");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
type DeploymentRow = {
|
|
2
|
+
network: string;
|
|
3
|
+
address: string | null;
|
|
4
|
+
deployBlock: number | null;
|
|
5
|
+
runtimeCodeHash: string | null;
|
|
6
|
+
};
|
|
7
|
+
export declare const DEPLOYMENTS: Record<string, DeploymentRow | string>;
|
|
8
|
+
export type SignerNetworkName = "testnet" | "mainnet";
|
|
9
|
+
/** Chain ID and public RPC per network — the same values as EVM_NETWORKS in the app. */
|
|
10
|
+
export declare const PUBLIC_NETWORKS: Readonly<Record<SignerNetworkName, {
|
|
11
|
+
chainId: number;
|
|
12
|
+
rpcUrl: string;
|
|
13
|
+
}>>;
|
|
14
|
+
export type SignerNetwork = {
|
|
15
|
+
name: SignerNetworkName;
|
|
16
|
+
chainId: number;
|
|
17
|
+
rpcUrl: string;
|
|
18
|
+
address: string;
|
|
19
|
+
deployBlock: number;
|
|
20
|
+
runtimeCodeHash: string;
|
|
21
|
+
overridden: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** Testnet only: a deployment the table does not (yet) name. Every field that is set replaces the pinned one. */
|
|
24
|
+
export type TestnetOverride = {
|
|
25
|
+
rpcUrl?: string;
|
|
26
|
+
address?: string;
|
|
27
|
+
deployBlock?: number;
|
|
28
|
+
runtimeCodeHash?: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function resolveNetwork(name: string, override?: TestnetOverride | null, warn?: (line: string) => void): SignerNetwork;
|
|
31
|
+
export declare class RpcError extends Error {
|
|
32
|
+
readonly rpcCode: number | null;
|
|
33
|
+
readonly data: unknown;
|
|
34
|
+
constructor(message: string, rpcCode: number | null, data?: unknown);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A JSON-RPC client bound to ONE URL. There is no way to send through it to
|
|
38
|
+
* anywhere else — the send path of the whole package goes through `call`, and
|
|
39
|
+
* the URL is the network's.
|
|
40
|
+
*/
|
|
41
|
+
export declare class Rpc {
|
|
42
|
+
private readonly timeoutMs;
|
|
43
|
+
readonly url: string;
|
|
44
|
+
private id;
|
|
45
|
+
constructor(url: string, timeoutMs?: number);
|
|
46
|
+
call<T = unknown>(method: string, params?: unknown[]): Promise<T>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The RPC is on this chain, and the contract at the pinned address is the pinned
|
|
50
|
+
* code. Run before every send and before every chain read a decision rests on.
|
|
51
|
+
*/
|
|
52
|
+
export declare function verifyEndpoint(rpc: Rpc, net: SignerNetwork): Promise<void>;
|
|
53
|
+
export {};
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which chain, which RPC, which contract — pinned in the package, never taken
|
|
3
|
+
* from cascrow's API.
|
|
4
|
+
*
|
|
5
|
+
* The deployment table is a byte copy of the app's
|
|
6
|
+
* src/lib/abi/milestone-escrow-v3.deployments.json (scripts/sync-signer-artifacts.mjs;
|
|
7
|
+
* signer-artifact-parity.test.ts). A network without an address, a deploy block
|
|
8
|
+
* and a runtime code hash is REFUSED: a `fund()` with value sent to an address
|
|
9
|
+
* with no contract behind it is a plain transfer, and the XRP is gone.
|
|
10
|
+
*
|
|
11
|
+
* The RPC is the PUBLIC endpoint of the network (the same constants as the app's
|
|
12
|
+
* src/lib/evm-network.ts; parity-tested). Every transaction goes there and only
|
|
13
|
+
* there — never to cascrow, which neither sends nor relays (rule I9). An override
|
|
14
|
+
* exists for testnet only, and it is announced on stderr every time it is used.
|
|
15
|
+
*/
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { getAddress, isAddress, keccak256 } from "ethers";
|
|
18
|
+
import { refuse } from "./errors.js";
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
export const DEPLOYMENTS = require("./generated/milestone-escrow-v3.deployments.json");
|
|
21
|
+
/** Chain ID and public RPC per network — the same values as EVM_NETWORKS in the app. */
|
|
22
|
+
export const PUBLIC_NETWORKS = Object.freeze({
|
|
23
|
+
testnet: Object.freeze({ chainId: 1449000, rpcUrl: "https://rpc.testnet.xrplevm.org" }),
|
|
24
|
+
mainnet: Object.freeze({ chainId: 1440000, rpcUrl: "https://rpc.xrplevm.org" }),
|
|
25
|
+
});
|
|
26
|
+
const HASH = /^0x[0-9a-f]{64}$/;
|
|
27
|
+
export function resolveNetwork(name, override, warn = () => { }) {
|
|
28
|
+
if (name !== "testnet" && name !== "mainnet")
|
|
29
|
+
refuse("NETWORK_UNKNOWN", `network must be "testnet" or "mainnet" (got ${JSON.stringify(name)})`);
|
|
30
|
+
const pub = PUBLIC_NETWORKS[name];
|
|
31
|
+
const row = DEPLOYMENTS[String(pub.chainId)];
|
|
32
|
+
const pinned = row && typeof row === "object" ? row : null;
|
|
33
|
+
const hasOverride = !!override && Object.values(override).some((v) => v !== undefined && v !== "");
|
|
34
|
+
if (hasOverride && name !== "testnet") {
|
|
35
|
+
refuse("OVERRIDE_MAINNET", "a deployment override is accepted on testnet only — on mainnet the pinned table is the only source");
|
|
36
|
+
}
|
|
37
|
+
const address = (hasOverride && override.address) || pinned?.address || null;
|
|
38
|
+
const deployBlock = hasOverride && override.deployBlock !== undefined ? override.deployBlock : pinned?.deployBlock ?? null;
|
|
39
|
+
const codeHash = ((hasOverride && override.runtimeCodeHash) || pinned?.runtimeCodeHash || "").toLowerCase();
|
|
40
|
+
const rpcUrl = (hasOverride && override.rpcUrl) || pub.rpcUrl;
|
|
41
|
+
if (!address || !isAddress(address) || deployBlock === null || !Number.isSafeInteger(deployBlock) || deployBlock < 0 || !HASH.test(codeHash)) {
|
|
42
|
+
refuse("NETWORK_NOT_DEPLOYED", `MilestoneEscrowV3 has no pinned deployment on ${name} (chain ${pub.chainId}) in this version of cascrow-signer. ` +
|
|
43
|
+
"The signer refuses to send to an address it cannot verify. Upgrade cascrow-signer once the contract is deployed.");
|
|
44
|
+
}
|
|
45
|
+
if (hasOverride) {
|
|
46
|
+
warn(`WARNING: using a TESTNET deployment override (contract ${getAddress(address)}, rpc ${rpcUrl}). ` +
|
|
47
|
+
"It is not the pinned deployment — only use it against a contract you deployed or verified yourself.");
|
|
48
|
+
}
|
|
49
|
+
return { name, chainId: pub.chainId, rpcUrl, address: getAddress(address), deployBlock, runtimeCodeHash: codeHash, overridden: hasOverride };
|
|
50
|
+
}
|
|
51
|
+
export class RpcError extends Error {
|
|
52
|
+
rpcCode;
|
|
53
|
+
data;
|
|
54
|
+
constructor(message, rpcCode, data) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "RpcError";
|
|
57
|
+
this.rpcCode = rpcCode;
|
|
58
|
+
this.data = data;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A JSON-RPC client bound to ONE URL. There is no way to send through it to
|
|
63
|
+
* anywhere else — the send path of the whole package goes through `call`, and
|
|
64
|
+
* the URL is the network's.
|
|
65
|
+
*/
|
|
66
|
+
export class Rpc {
|
|
67
|
+
timeoutMs;
|
|
68
|
+
url;
|
|
69
|
+
id = 0;
|
|
70
|
+
constructor(url, timeoutMs = 20_000) {
|
|
71
|
+
this.timeoutMs = timeoutMs;
|
|
72
|
+
this.url = url;
|
|
73
|
+
}
|
|
74
|
+
async call(method, params = []) {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
77
|
+
let text;
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(this.url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++this.id, method, params }),
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
});
|
|
85
|
+
text = await res.text();
|
|
86
|
+
if (!res.ok)
|
|
87
|
+
throw new RpcError(`RPC answered HTTP ${res.status} to ${method}`, null);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
if (e instanceof RpcError)
|
|
91
|
+
throw e;
|
|
92
|
+
throw new RpcError(`RPC ${method} failed: ${e instanceof Error && e.name === "AbortError" ? "timeout" : "network error"}`, null);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
let body;
|
|
98
|
+
try {
|
|
99
|
+
body = JSON.parse(text);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new RpcError(`RPC ${method} answered with non-JSON`, null);
|
|
103
|
+
}
|
|
104
|
+
if (body.error)
|
|
105
|
+
throw new RpcError(`RPC ${method}: ${String(body.error.message ?? "error").slice(0, 200)}`, body.error.code ?? null, body.error.data);
|
|
106
|
+
return body.result;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The RPC is on this chain, and the contract at the pinned address is the pinned
|
|
111
|
+
* code. Run before every send and before every chain read a decision rests on.
|
|
112
|
+
*/
|
|
113
|
+
export async function verifyEndpoint(rpc, net) {
|
|
114
|
+
const chainId = await rpc.call("eth_chainId");
|
|
115
|
+
if (typeof chainId !== "string" || !/^0x[0-9a-fA-F]+$/.test(chainId) || BigInt(chainId) !== BigInt(net.chainId)) {
|
|
116
|
+
refuse("CHAIN_MISMATCH", `the RPC answered chain ID ${String(chainId)}, expected ${net.chainId} (${net.name}) — nothing was signed`);
|
|
117
|
+
}
|
|
118
|
+
const code = await rpc.call("eth_getCode", [net.address, "latest"]);
|
|
119
|
+
if (typeof code !== "string" || code.length <= 2 || keccak256(code).toLowerCase() !== net.runtimeCodeHash) {
|
|
120
|
+
refuse("CODE_HASH_MISMATCH", `the code at ${net.address} is not the pinned MilestoneEscrowV3 runtime code — nothing was signed`);
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const POLICY_FILE = "policy.json";
|
|
2
|
+
/** The floor below which no local minimum review period may be set: 24 h. */
|
|
3
|
+
export declare const MIN_REVIEW_FLOOR_SECONDS: number;
|
|
4
|
+
export type Policy = {
|
|
5
|
+
v: 1;
|
|
6
|
+
/** The keystore address this policy belongs to (lowercase). */
|
|
7
|
+
address: string;
|
|
8
|
+
perEscrowMaxWei: string;
|
|
9
|
+
unknownPayeePerEscrowMaxWei: string;
|
|
10
|
+
dailyMaxWei: string;
|
|
11
|
+
minReviewSeconds: number;
|
|
12
|
+
/** Payees confirmed on a TTY (lowercase). */
|
|
13
|
+
payees: string[];
|
|
14
|
+
/** Arbiters the owner accepts (lowercase). Empty = only escrows without an arbiter. */
|
|
15
|
+
arbiters: string[];
|
|
16
|
+
};
|
|
17
|
+
export type PolicyInput = {
|
|
18
|
+
perEscrowMaxXrp: string;
|
|
19
|
+
unknownPayeePerEscrowMaxXrp?: string;
|
|
20
|
+
dailyMaxXrp: string;
|
|
21
|
+
minReviewHours?: number;
|
|
22
|
+
payees?: string[];
|
|
23
|
+
arbiters?: string[];
|
|
24
|
+
};
|
|
25
|
+
export declare function buildPolicy(address: string, input: PolicyInput): Policy;
|
|
26
|
+
/** The policy, or a refusal: missing, tampered, or bound to a different key. */
|
|
27
|
+
export declare function loadPolicy(home: string, macKey: Buffer, address: string): Policy;
|
|
28
|
+
/** Write the policy. Only from a terminal: an agent driving the MCP tools must not be able to raise its own limits. */
|
|
29
|
+
export declare function savePolicy(home: string, macKey: Buffer, policy: Policy, io: {
|
|
30
|
+
isTTY: boolean;
|
|
31
|
+
}): void;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The payer's local limits, and the counterparty/arbiter allowlists.
|
|
3
|
+
*
|
|
4
|
+
* Limits are in XRP and stored in WEI — never in dollars: the chain moves XRP,
|
|
5
|
+
* and a limit in a currency the signer cannot observe is a limit on a number the
|
|
6
|
+
* server supplies. The file carries an HMAC under a key derived from the
|
|
7
|
+
* keystore's private key (keystore.ts `macKey`), so a policy edited by hand, or
|
|
8
|
+
* by a process that can write the directory but not unlock the key, is REFUSED
|
|
9
|
+
* rather than obeyed. Changing it takes `cascrow-signer policy set` on a TTY.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS DOES NOT PROTECT AGAINST, stated plainly (SECURITY.md): a process
|
|
12
|
+
* that can read the passphrase can unlock the key, recompute the MAC and write
|
|
13
|
+
* any policy it likes. The policy stops a confused or prompt-injected agent that
|
|
14
|
+
* drives the MCP tools; it does not stop one that owns the machine.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { getAddress, isAddress, parseEther } from "ethers";
|
|
19
|
+
import { refuse } from "./errors.js";
|
|
20
|
+
import { ensureHome, macMatches, macOf, readPrivateFile, replacePrivateFile } from "./files.js";
|
|
21
|
+
import { V3_LIMITS } from "./calldata.js";
|
|
22
|
+
export const POLICY_FILE = "policy.json";
|
|
23
|
+
/** The floor below which no local minimum review period may be set: 24 h. */
|
|
24
|
+
export const MIN_REVIEW_FLOOR_SECONDS = 24 * 3600;
|
|
25
|
+
function xrp(label, v) {
|
|
26
|
+
if (!/^\d+(\.\d{1,18})?$/.test(v.trim()))
|
|
27
|
+
refuse("POLICY_INVALID", `${label} must be a decimal XRP amount`);
|
|
28
|
+
return parseEther(v.trim());
|
|
29
|
+
}
|
|
30
|
+
function addrList(label, list) {
|
|
31
|
+
const out = new Set();
|
|
32
|
+
for (const a of list ?? []) {
|
|
33
|
+
if (!isAddress(a))
|
|
34
|
+
refuse("POLICY_INVALID", `${label}: ${a} is not an address`);
|
|
35
|
+
out.add(getAddress(a).toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
return [...out].sort();
|
|
38
|
+
}
|
|
39
|
+
export function buildPolicy(address, input) {
|
|
40
|
+
const per = xrp("perEscrowMaxXrp", input.perEscrowMaxXrp);
|
|
41
|
+
const unknown = input.unknownPayeePerEscrowMaxXrp !== undefined ? xrp("unknownPayeePerEscrowMaxXrp", input.unknownPayeePerEscrowMaxXrp) : per / BigInt(10);
|
|
42
|
+
const daily = xrp("dailyMaxXrp", input.dailyMaxXrp);
|
|
43
|
+
if (unknown > per)
|
|
44
|
+
refuse("POLICY_INVALID", "the limit for an unknown payee cannot exceed the per-escrow limit");
|
|
45
|
+
if (per > daily)
|
|
46
|
+
refuse("POLICY_INVALID", "the per-escrow limit cannot exceed the daily limit");
|
|
47
|
+
const minReviewSeconds = Math.round((input.minReviewHours ?? 72) * 3600);
|
|
48
|
+
if (!(minReviewSeconds >= MIN_REVIEW_FLOOR_SECONDS))
|
|
49
|
+
refuse("POLICY_INVALID", "the minimum review period cannot be below 24 hours");
|
|
50
|
+
if (minReviewSeconds > V3_LIMITS.MAX_REVIEW_SECONDS)
|
|
51
|
+
refuse("POLICY_INVALID", "the minimum review period exceeds the contract's maximum");
|
|
52
|
+
return {
|
|
53
|
+
v: 1,
|
|
54
|
+
address: getAddress(address).toLowerCase(),
|
|
55
|
+
perEscrowMaxWei: per.toString(),
|
|
56
|
+
unknownPayeePerEscrowMaxWei: unknown.toString(),
|
|
57
|
+
dailyMaxWei: daily.toString(),
|
|
58
|
+
minReviewSeconds,
|
|
59
|
+
payees: addrList("payees", input.payees),
|
|
60
|
+
arbiters: addrList("arbiters", input.arbiters),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function file(home) {
|
|
64
|
+
return path.join(home, POLICY_FILE);
|
|
65
|
+
}
|
|
66
|
+
/** The policy, or a refusal: missing, tampered, or bound to a different key. */
|
|
67
|
+
export function loadPolicy(home, macKey, address) {
|
|
68
|
+
ensureHome(home);
|
|
69
|
+
const f = file(home);
|
|
70
|
+
if (!fs.existsSync(f))
|
|
71
|
+
refuse("POLICY_MISSING", "no local policy — set limits first on a terminal: cascrow-signer policy set");
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(readPrivateFile(f));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
refuse("POLICY_TAMPERED", "the local policy file is not valid JSON — the signer refuses to guess limits");
|
|
78
|
+
}
|
|
79
|
+
if (!parsed.policy || !macMatches(macKey, parsed.policy, parsed.mac)) {
|
|
80
|
+
refuse("POLICY_TAMPERED", "the local policy file was changed outside `cascrow-signer policy set` — the signer refuses to use it");
|
|
81
|
+
}
|
|
82
|
+
if (parsed.policy.address !== address.toLowerCase())
|
|
83
|
+
refuse("POLICY_TAMPERED", "the local policy belongs to a different key");
|
|
84
|
+
return parsed.policy;
|
|
85
|
+
}
|
|
86
|
+
/** Write the policy. Only from a terminal: an agent driving the MCP tools must not be able to raise its own limits. */
|
|
87
|
+
export function savePolicy(home, macKey, policy, io) {
|
|
88
|
+
if (!io.isTTY)
|
|
89
|
+
refuse("POLICY_TTY_REQUIRED", "the policy can only be changed on an interactive terminal: cascrow-signer policy set");
|
|
90
|
+
ensureHome(home);
|
|
91
|
+
replacePrivateFile(file(home), JSON.stringify({ policy, mac: macOf(macKey, policy) }, null, 2));
|
|
92
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type RemoteConfig = {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
apiKey: string;
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
};
|
|
6
|
+
export declare class RemoteError extends Error {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly code: string | null;
|
|
9
|
+
readonly body: Record<string, unknown>;
|
|
10
|
+
constructor(message: string, status: number, code: string | null, body: Record<string, unknown>);
|
|
11
|
+
}
|
|
12
|
+
export declare class Remote {
|
|
13
|
+
private readonly cfg;
|
|
14
|
+
constructor(cfg: RemoteConfig);
|
|
15
|
+
get configured(): boolean;
|
|
16
|
+
private call;
|
|
17
|
+
prepare(q: {
|
|
18
|
+
milestoneId: string;
|
|
19
|
+
action: string;
|
|
20
|
+
proofHash?: string;
|
|
21
|
+
reasonHash?: string;
|
|
22
|
+
newDeadline?: string;
|
|
23
|
+
}): Promise<Record<string, unknown>>;
|
|
24
|
+
acceptance(milestoneId: string): Promise<Record<string, unknown>>;
|
|
25
|
+
submitAcceptance(offerId: string, signature: string): Promise<Record<string, unknown>>;
|
|
26
|
+
walletChallenge(): Promise<Record<string, unknown>>;
|
|
27
|
+
linkWallet(walletAddress: string, signature: string, token: string): Promise<Record<string, unknown>>;
|
|
28
|
+
me(): Promise<Record<string, unknown>>;
|
|
29
|
+
/** A transaction HASH as a hint to re-read the chain. Never a signed transaction. */
|
|
30
|
+
reportTx(milestoneId: string, txHash: string): Promise<Record<string, unknown>>;
|
|
31
|
+
}
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signer's calls to cascrow — the complete list, in one place, so what
|
|
3
|
+
* leaves this machine for cascrow can be read in one screen:
|
|
4
|
+
*
|
|
5
|
+
* GET /api/escrow/v3/prepare a prepared transaction (a cross-check; fund needs it)
|
|
6
|
+
* GET /api/escrow/v3/acceptance the payer's frozen offer, for the payee to check and sign
|
|
7
|
+
* POST /api/escrow/v3/acceptance { offerId, signature } — the payee's acceptance signature
|
|
8
|
+
* GET /api/user/wallet/challenge the wallet-link challenge
|
|
9
|
+
* POST /api/user/wallet { walletAddress, signature, token } — the link signature
|
|
10
|
+
* GET /api/agent/me which wallet the account has linked now
|
|
11
|
+
* POST /api/escrow/v3/sync { milestoneId, txHash } — a HASH, best effort, never a transaction
|
|
12
|
+
*
|
|
13
|
+
* Two signatures ever reach cascrow: the wallet-link signature and the payee's
|
|
14
|
+
* acceptance signature. Never a transaction, signed or not; never a key,
|
|
15
|
+
* keystore or passphrase. signer-canary.test.ts records every request and holds
|
|
16
|
+
* that.
|
|
17
|
+
*/
|
|
18
|
+
import { refuse } from "./errors.js";
|
|
19
|
+
export class RemoteError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
code;
|
|
22
|
+
body;
|
|
23
|
+
constructor(message, status, code, body) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "RemoteError";
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.body = body;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class Remote {
|
|
32
|
+
cfg;
|
|
33
|
+
constructor(cfg) {
|
|
34
|
+
this.cfg = cfg;
|
|
35
|
+
}
|
|
36
|
+
get configured() {
|
|
37
|
+
return !!this.cfg.apiKey;
|
|
38
|
+
}
|
|
39
|
+
async call(method, path, body) {
|
|
40
|
+
if (!this.cfg.apiKey)
|
|
41
|
+
refuse("API_KEY_REQUIRED", "CASCROW_API_KEY is not set — this step needs your cascrow account");
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs ?? 30_000);
|
|
44
|
+
let status = 0;
|
|
45
|
+
let text = "";
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`${this.cfg.baseUrl.replace(/\/+$/, "")}${path}`, {
|
|
48
|
+
method,
|
|
49
|
+
headers: { Authorization: `Bearer ${this.cfg.apiKey}`, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
50
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
status = res.status;
|
|
54
|
+
text = await res.text();
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
throw new RemoteError(`cascrow did not answer ${path} (${e instanceof Error && e.name === "AbortError" ? "timeout" : "network error"})`, 0, "API_UNREACHABLE", {});
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
let data = {};
|
|
63
|
+
try {
|
|
64
|
+
data = text ? JSON.parse(text) : {};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new RemoteError(`cascrow answered HTTP ${status} with non-JSON`, status, "UPSTREAM_NON_JSON", {});
|
|
68
|
+
}
|
|
69
|
+
if (status < 200 || status >= 300) {
|
|
70
|
+
const code = typeof data.code === "string" ? data.code : null;
|
|
71
|
+
const msg = typeof data.error === "string" ? data.error : `HTTP ${status}`;
|
|
72
|
+
throw new RemoteError(`${msg}${code ? ` [${code}]` : ""}`, status, code, data);
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
prepare(q) {
|
|
77
|
+
const p = new URLSearchParams();
|
|
78
|
+
for (const [k, v] of Object.entries(q))
|
|
79
|
+
if (v !== undefined)
|
|
80
|
+
p.set(k, v);
|
|
81
|
+
return this.call("GET", `/api/escrow/v3/prepare?${p.toString()}`);
|
|
82
|
+
}
|
|
83
|
+
acceptance(milestoneId) {
|
|
84
|
+
return this.call("GET", `/api/escrow/v3/acceptance?milestoneId=${encodeURIComponent(milestoneId)}`);
|
|
85
|
+
}
|
|
86
|
+
submitAcceptance(offerId, signature) {
|
|
87
|
+
return this.call("POST", "/api/escrow/v3/acceptance", { offerId, signature });
|
|
88
|
+
}
|
|
89
|
+
walletChallenge() {
|
|
90
|
+
return this.call("GET", "/api/user/wallet/challenge");
|
|
91
|
+
}
|
|
92
|
+
linkWallet(walletAddress, signature, token) {
|
|
93
|
+
return this.call("POST", "/api/user/wallet", { walletAddress, signature, token });
|
|
94
|
+
}
|
|
95
|
+
me() {
|
|
96
|
+
return this.call("GET", "/api/agent/me");
|
|
97
|
+
}
|
|
98
|
+
/** A transaction HASH as a hint to re-read the chain. Never a signed transaction. */
|
|
99
|
+
reportTx(milestoneId, txHash) {
|
|
100
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash))
|
|
101
|
+
refuse("NOT_A_HASH", "only a 32-byte transaction hash is ever reported");
|
|
102
|
+
return this.call("POST", "/api/escrow/v3/sync", { milestoneId, txHash });
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP tools of the LOCAL signer. Every name carries the `cascrow_signer_`
|
|
3
|
+
* namespace, and none of them exists on cascrow's hosted MCP server or in the
|
|
4
|
+
* cascrow-mcp package: `LOCAL_ONLY_TOOLS` ∩ hosted tools = ∅
|
|
5
|
+
* (signer-tool-parity.test.ts). A tool that signs must never be answerable by a
|
|
6
|
+
* server that holds no key — a same-named hosted tool would make "which server
|
|
7
|
+
* answered?" decide whether money moved.
|
|
8
|
+
*
|
|
9
|
+
* No input names a key, secret, passphrase, mnemonic or seed: the passphrase
|
|
10
|
+
* comes from the environment of the process (CASCROW_KEYSTORE_PASSPHRASE_FILE),
|
|
11
|
+
* never from a model.
|
|
12
|
+
*/
|
|
13
|
+
export type ToolDef = {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
inputSchema: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
export declare const SIGNER_TOOLS: readonly ToolDef[];
|
|
19
|
+
/** The tools only this package offers — disjoint from the hosted cascrow MCP tools. */
|
|
20
|
+
export declare const LOCAL_ONLY_TOOLS: readonly string[];
|