routstrd 0.3.11 → 0.4.1
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/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/IMPLEMENTATION.md +253 -0
- package/README.md +46 -0
- package/SECURITY.md +23 -0
- package/SKILL.md +13 -3
- package/bun.lock +68 -41
- package/dist/daemon/index.js +48837 -11383
- package/dist/index.js +5027 -421
- package/package.json +8 -3
- package/src/cli.test.ts +55 -0
- package/src/cli.ts +601 -217
- package/src/daemon/args.ts +8 -1
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/models.ts +119 -39
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
- package/src/daemon/wallet/coco-client.test.ts +669 -0
- package/src/daemon/wallet/coco-client.ts +1628 -0
- package/src/daemon/wallet/cocod-client.ts +135 -3
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +171 -0
- package/src/daemon/wallet/migration.ts +154 -0
- package/src/daemon/wallet/paths.ts +33 -0
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/integrations/claudecode.ts +2 -1
- package/src/integrations/hermes.ts +46 -6
- package/src/integrations/openclaw.ts +3 -2
- package/src/integrations/opencode.ts +3 -2
- package/src/integrations/pi.ts +3 -2
- package/src/integrations/registry.ts +7 -5
- package/src/start-daemon.ts +189 -23
- package/src/utils/clients.ts +21 -0
- package/src/utils/config.ts +11 -0
- package/src/utils/daemon-client.ts +104 -25
- package/src/utils/logger.ts +42 -28
- package/tests/integrations/hermes.test.ts +42 -5
- package/tests/utils/daemon-client.test.ts +61 -0
- package/tests/wallet/short-keyset-token.test.ts +106 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure selection helpers for the wallet cleanup command.
|
|
3
|
+
*
|
|
4
|
+
* These helpers decide *which* stuck operations are safe to clear. The actual
|
|
5
|
+
* state transitions are applied by the in-process coco wallet client so that
|
|
6
|
+
* coco-core's operation services emit their normal events and release proof
|
|
7
|
+
* reservations. Keeping the selection logic here makes it easy to unit test
|
|
8
|
+
* without a wallet database or network access.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface MintCleanupCandidate {
|
|
12
|
+
id: string;
|
|
13
|
+
state: string;
|
|
14
|
+
/** Quote expiry in epoch seconds. `0` means unknown/not applicable. */
|
|
15
|
+
expiry: number;
|
|
16
|
+
/** Last update time in epoch milliseconds. */
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
/**
|
|
19
|
+
* Last quote state observed from the mint (e.g. "PAID", "ISSUED").
|
|
20
|
+
* Unknown/undefined means no terminal observation has been recorded.
|
|
21
|
+
*/
|
|
22
|
+
lastObservedRemoteState?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NonMintCleanupCandidate {
|
|
26
|
+
id: string;
|
|
27
|
+
state: string;
|
|
28
|
+
/** Last update time in epoch milliseconds. */
|
|
29
|
+
updatedAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CleanupSelectionOptions<
|
|
33
|
+
TMint extends MintCleanupCandidate,
|
|
34
|
+
TSend extends NonMintCleanupCandidate,
|
|
35
|
+
TMelt extends NonMintCleanupCandidate,
|
|
36
|
+
> {
|
|
37
|
+
mints: TMint[];
|
|
38
|
+
sends: TSend[];
|
|
39
|
+
melts: TMelt[];
|
|
40
|
+
nowMs: number;
|
|
41
|
+
minAgeMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CleanupSelection<
|
|
45
|
+
TMint extends MintCleanupCandidate,
|
|
46
|
+
TSend extends NonMintCleanupCandidate,
|
|
47
|
+
TMelt extends NonMintCleanupCandidate,
|
|
48
|
+
> {
|
|
49
|
+
mintsToFail: TMint[];
|
|
50
|
+
sendsToReclaim: TSend[];
|
|
51
|
+
meltsToCancel: TMelt[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Select stuck operations that are old enough to be considered for clearing.
|
|
56
|
+
*
|
|
57
|
+
* - Pending mint quotes are candidates once their bolt11 quote has expired
|
|
58
|
+
* (an expired Lightning invoice can never be paid again) and the mint has
|
|
59
|
+
* not already reported it as PAID/ISSUED. A paid-but-unfinalized quote
|
|
60
|
+
* still has claimable proofs, so it must go through normal recovery instead
|
|
61
|
+
* of being failed locally.
|
|
62
|
+
*
|
|
63
|
+
* Note that expiry alone does not prove a quote was never paid: the payment
|
|
64
|
+
* can have happened before expiry while the daemon was down, leaving no
|
|
65
|
+
* local observation. Callers that fail quotes automatically at startup must
|
|
66
|
+
* therefore confirm UNPAID with the mint first (see
|
|
67
|
+
* settleExpiredMintQuotes in coco-client.ts); only the explicit,
|
|
68
|
+
* user-invoked cleanup command may fail candidates purely locally.
|
|
69
|
+
* - Pending sends are reclaimed (rolled back) only when they are older than
|
|
70
|
+
* `minAgeMs`, so we never roll back a token that a receiver might still
|
|
71
|
+
* legitimately claim.
|
|
72
|
+
* - Prepared melts are cancelled under the same age guard.
|
|
73
|
+
*/
|
|
74
|
+
export function selectCleanupOperations<
|
|
75
|
+
TMint extends MintCleanupCandidate,
|
|
76
|
+
TSend extends NonMintCleanupCandidate,
|
|
77
|
+
TMelt extends NonMintCleanupCandidate,
|
|
78
|
+
>(
|
|
79
|
+
options: CleanupSelectionOptions<TMint, TSend, TMelt>,
|
|
80
|
+
): CleanupSelection<TMint, TSend, TMelt> {
|
|
81
|
+
const { mints, sends, melts, nowMs, minAgeMs } = options;
|
|
82
|
+
|
|
83
|
+
// Expired quotes can never be paid again, but may have been paid before
|
|
84
|
+
// expiry without a local observation: this is a candidate set, and whether
|
|
85
|
+
// failing is safe without a mint round-trip depends on the caller (above).
|
|
86
|
+
const mintsToFail = mints.filter(
|
|
87
|
+
(op) =>
|
|
88
|
+
op.state === "pending" &&
|
|
89
|
+
op.expiry > 0 &&
|
|
90
|
+
op.expiry * 1000 <= nowMs &&
|
|
91
|
+
op.lastObservedRemoteState !== "PAID" &&
|
|
92
|
+
op.lastObservedRemoteState !== "ISSUED",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const sendsToReclaim = sends.filter(
|
|
96
|
+
(op) => op.state === "pending" && nowMs - op.updatedAt >= minAgeMs,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const meltsToCancel = melts.filter(
|
|
100
|
+
(op) => op.state === "prepared" && nowMs - op.updatedAt >= minAgeMs,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return { mintsToFail, sendsToReclaim, meltsToCancel };
|
|
104
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
// The real NPCPlugin opens a websocket to npubx.cash once the wallet is ready.
|
|
7
|
+
// Mock the module before importing coco-client so tests never touch the network.
|
|
8
|
+
const npcState = {
|
|
9
|
+
info: { name: "alice" as string | null, pubkey: "ab".repeat(32) },
|
|
10
|
+
usernameResult: { success: true } as
|
|
11
|
+
| { success: true }
|
|
12
|
+
| { success: false; pr: { amount: number; mints: string[] } },
|
|
13
|
+
setUsernameCalls: [] as Array<{ username: string; attemptPayment?: boolean }>,
|
|
14
|
+
syncCalls: 0,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
class MockNPCPlugin {
|
|
18
|
+
readonly name = "npc";
|
|
19
|
+
readonly required = [] as const;
|
|
20
|
+
private readonly api = {
|
|
21
|
+
getInfo: async () => npcState.info,
|
|
22
|
+
setUsername: async (username: string, attemptPayment?: boolean) => {
|
|
23
|
+
npcState.setUsernameCalls.push({ username, attemptPayment });
|
|
24
|
+
return npcState.usernameResult;
|
|
25
|
+
},
|
|
26
|
+
sync: async () => {
|
|
27
|
+
npcState.syncCalls += 1;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
onInit(ctx: { registerExtension: (key: string, api: unknown) => void }) {
|
|
32
|
+
ctx.registerExtension("npc", this.api);
|
|
33
|
+
return () => {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
mock.module("coco-cashu-plugin-npc", () => ({
|
|
38
|
+
NPCPlugin: MockNPCPlugin,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const { createCocoClient } = await import("./coco-client");
|
|
42
|
+
const { createCocodClient, CocodHttpError } = await import("./cocod-client");
|
|
43
|
+
|
|
44
|
+
const TEST_MNEMONIC =
|
|
45
|
+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
46
|
+
|
|
47
|
+
const tempDirs: string[] = [];
|
|
48
|
+
|
|
49
|
+
function makeWalletDir(): string {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-npc-test-"));
|
|
51
|
+
tempDirs.push(dir);
|
|
52
|
+
const walletDir = join(dir, "wallet");
|
|
53
|
+
mkdirSync(walletDir, { recursive: true });
|
|
54
|
+
writeFileSync(
|
|
55
|
+
join(walletDir, "config.json"),
|
|
56
|
+
JSON.stringify({ version: 1, mnemonic: TEST_MNEMONIC, encrypted: false }),
|
|
57
|
+
);
|
|
58
|
+
return walletDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function testClientOptions(walletDir = makeWalletDir()) {
|
|
62
|
+
return {
|
|
63
|
+
walletDir,
|
|
64
|
+
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
|
65
|
+
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resetNpcState(): void {
|
|
70
|
+
npcState.info = { name: "alice", pubkey: "ab".repeat(32) };
|
|
71
|
+
npcState.usernameResult = { success: true };
|
|
72
|
+
npcState.setUsernameCalls = [];
|
|
73
|
+
npcState.syncCalls = 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
resetNpcState();
|
|
78
|
+
for (const dir of tempDirs.splice(0)) {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("in-process coco client NPC integration", () => {
|
|
84
|
+
it("registers the plugin and exposes the wallet's NPC lightning address", async () => {
|
|
85
|
+
const client = await createCocoClient(testClientOptions());
|
|
86
|
+
try {
|
|
87
|
+
const info = await client.getNpcAddress();
|
|
88
|
+
expect(info).toEqual({
|
|
89
|
+
address: "alice@npubx.cash",
|
|
90
|
+
name: "alice",
|
|
91
|
+
pubkey: "ab".repeat(32),
|
|
92
|
+
});
|
|
93
|
+
} finally {
|
|
94
|
+
await client.dispose?.();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to the npub address when no username is set", async () => {
|
|
99
|
+
npcState.info = { name: null, pubkey: "ab".repeat(32) };
|
|
100
|
+
const client = await createCocoClient(testClientOptions());
|
|
101
|
+
try {
|
|
102
|
+
const info = await client.getNpcAddress();
|
|
103
|
+
expect(info.address).toStartWith("npub1");
|
|
104
|
+
expect(info.address).toEndWith("@npubx.cash");
|
|
105
|
+
expect(info.name).toBeUndefined();
|
|
106
|
+
expect(info.pubkey).toBe("ab".repeat(32));
|
|
107
|
+
} finally {
|
|
108
|
+
await client.dispose?.();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("maps payment-required username claims without paying", async () => {
|
|
113
|
+
npcState.usernameResult = {
|
|
114
|
+
success: false,
|
|
115
|
+
pr: { amount: 21, mints: ["https://mint.example.com"] },
|
|
116
|
+
};
|
|
117
|
+
const client = await createCocoClient(testClientOptions());
|
|
118
|
+
try {
|
|
119
|
+
const result = await client.setNpcUsername("bob");
|
|
120
|
+
expect(result).toEqual({
|
|
121
|
+
success: false,
|
|
122
|
+
paymentRequest: { amount: 21, mints: ["https://mint.example.com"] },
|
|
123
|
+
});
|
|
124
|
+
expect(npcState.setUsernameCalls).toEqual([
|
|
125
|
+
{ username: "bob", attemptPayment: false },
|
|
126
|
+
]);
|
|
127
|
+
} finally {
|
|
128
|
+
await client.dispose?.();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("confirms the claim fee payment when requested", async () => {
|
|
133
|
+
const client = await createCocoClient(testClientOptions());
|
|
134
|
+
try {
|
|
135
|
+
const result = await client.setNpcUsername("bob", true);
|
|
136
|
+
expect(result).toEqual({ success: true });
|
|
137
|
+
expect(npcState.setUsernameCalls).toEqual([
|
|
138
|
+
{ username: "bob", attemptPayment: true },
|
|
139
|
+
]);
|
|
140
|
+
} finally {
|
|
141
|
+
await client.dispose?.();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("triggers a manual quote sync", async () => {
|
|
146
|
+
const client = await createCocoClient(testClientOptions());
|
|
147
|
+
try {
|
|
148
|
+
await client.syncNpc();
|
|
149
|
+
expect(npcState.syncCalls).toBe(1);
|
|
150
|
+
} finally {
|
|
151
|
+
await client.dispose?.();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects NPC calls with a clear error when the plugin is disabled", async () => {
|
|
156
|
+
const client = await createCocoClient({
|
|
157
|
+
...testClientOptions(),
|
|
158
|
+
enableNpc: false,
|
|
159
|
+
});
|
|
160
|
+
try {
|
|
161
|
+
await expect(client.getNpcAddress()).rejects.toThrow(
|
|
162
|
+
"NPC plugin is not enabled",
|
|
163
|
+
);
|
|
164
|
+
await expect(client.syncNpc()).rejects.toThrow(
|
|
165
|
+
"NPC plugin is not enabled",
|
|
166
|
+
);
|
|
167
|
+
} finally {
|
|
168
|
+
await client.dispose?.();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("legacy cocod client NPC passthroughs", () => {
|
|
174
|
+
type FetchImpl = (
|
|
175
|
+
input: string | URL | Request,
|
|
176
|
+
init?: RequestInit & { unix: string },
|
|
177
|
+
) => Promise<Response>;
|
|
178
|
+
|
|
179
|
+
function legacyClient(handler: (path: string, body?: unknown) => Response) {
|
|
180
|
+
const requests: Array<{ path: string; body?: unknown }> = [];
|
|
181
|
+
const fetchImpl: FetchImpl = async (input, init) => {
|
|
182
|
+
const path = String(input).replace("http://localhost", "");
|
|
183
|
+
const body =
|
|
184
|
+
typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
|
185
|
+
if (path !== "/ping") requests.push({ path, body });
|
|
186
|
+
if (path === "/ping") {
|
|
187
|
+
return new Response(JSON.stringify({ output: "pong" }));
|
|
188
|
+
}
|
|
189
|
+
return handler(path, body);
|
|
190
|
+
};
|
|
191
|
+
const client = createCocodClient({
|
|
192
|
+
socketPath: "/tmp/routstrd-test-legacy-npc/cocod.sock",
|
|
193
|
+
fetchImpl,
|
|
194
|
+
});
|
|
195
|
+
return { client, requests };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it("fetches and parses a username NPC address", async () => {
|
|
199
|
+
const { client, requests } = legacyClient(
|
|
200
|
+
() => new Response(JSON.stringify({ output: "alice@npubx.cash" })),
|
|
201
|
+
);
|
|
202
|
+
const info = await client.getNpcAddress();
|
|
203
|
+
expect(info).toEqual({ address: "alice@npubx.cash", name: "alice" });
|
|
204
|
+
expect(requests[0]?.path).toBe("/npc/address");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("omits the name for npub fallback addresses", async () => {
|
|
208
|
+
const { client } = legacyClient(
|
|
209
|
+
() =>
|
|
210
|
+
new Response(JSON.stringify({ output: "npub1abc123@npubx.cash" })),
|
|
211
|
+
);
|
|
212
|
+
const info = await client.getNpcAddress();
|
|
213
|
+
expect(info).toEqual({ address: "npub1abc123@npubx.cash" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("posts username claims with the confirm flag", async () => {
|
|
217
|
+
const { client, requests } = legacyClient(
|
|
218
|
+
() => new Response(JSON.stringify({ output: { success: true } })),
|
|
219
|
+
);
|
|
220
|
+
const result = await client.setNpcUsername("bob", true);
|
|
221
|
+
expect(result).toEqual({ success: true });
|
|
222
|
+
expect(requests[0]).toEqual({
|
|
223
|
+
path: "/npc/username",
|
|
224
|
+
body: { username: "bob", confirm: true },
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("preserves the 402 payment-required error from cocod", async () => {
|
|
229
|
+
const { client } = legacyClient(
|
|
230
|
+
() =>
|
|
231
|
+
new Response(
|
|
232
|
+
JSON.stringify({ error: "Payment required to set username" }),
|
|
233
|
+
{ status: 402 },
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
try {
|
|
237
|
+
await client.setNpcUsername("bob");
|
|
238
|
+
expect.unreachable("should have thrown");
|
|
239
|
+
} catch (error) {
|
|
240
|
+
expect(error).toBeInstanceOf(CocodHttpError);
|
|
241
|
+
expect((error as InstanceType<typeof CocodHttpError>).status).toBe(402);
|
|
242
|
+
expect((error as Error).message).toContain("Payment required");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("reports manual sync as unsupported", async () => {
|
|
247
|
+
const { client } = legacyClient(
|
|
248
|
+
() => new Response(JSON.stringify({ output: {} })),
|
|
249
|
+
);
|
|
250
|
+
await expect(client.syncNpc()).rejects.toThrow("not supported");
|
|
251
|
+
});
|
|
252
|
+
});
|