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.
Files changed (45) hide show
  1. package/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
  2. package/IMPLEMENTATION.md +253 -0
  3. package/README.md +46 -0
  4. package/SECURITY.md +23 -0
  5. package/SKILL.md +13 -3
  6. package/bun.lock +68 -41
  7. package/dist/daemon/index.js +48837 -11383
  8. package/dist/index.js +5027 -421
  9. package/package.json +8 -3
  10. package/src/cli.test.ts +55 -0
  11. package/src/cli.ts +601 -217
  12. package/src/daemon/args.ts +8 -1
  13. package/src/daemon/fatal-error.test.ts +104 -0
  14. package/src/daemon/fatal-error.ts +48 -0
  15. package/src/daemon/models.ts +119 -39
  16. package/src/daemon/wallet/auto-refill.ts +4 -6
  17. package/src/daemon/wallet/cleanup.test.ts +168 -0
  18. package/src/daemon/wallet/cleanup.ts +104 -0
  19. package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
  20. package/src/daemon/wallet/coco-client.test.ts +669 -0
  21. package/src/daemon/wallet/coco-client.ts +1628 -0
  22. package/src/daemon/wallet/cocod-client.ts +135 -3
  23. package/src/daemon/wallet/diagnostics.test.ts +378 -0
  24. package/src/daemon/wallet/diagnostics.ts +471 -0
  25. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  26. package/src/daemon/wallet/migration.test.ts +171 -0
  27. package/src/daemon/wallet/migration.ts +154 -0
  28. package/src/daemon/wallet/paths.ts +33 -0
  29. package/src/daemon/wallet/receive-dedup.test.ts +238 -0
  30. package/src/daemon/wallet/receive-dedup.ts +384 -0
  31. package/src/daemon/wallet/wallet-state.ts +75 -0
  32. package/src/integrations/claudecode.ts +2 -1
  33. package/src/integrations/hermes.ts +46 -6
  34. package/src/integrations/openclaw.ts +3 -2
  35. package/src/integrations/opencode.ts +3 -2
  36. package/src/integrations/pi.ts +3 -2
  37. package/src/integrations/registry.ts +7 -5
  38. package/src/start-daemon.ts +189 -23
  39. package/src/utils/clients.ts +21 -0
  40. package/src/utils/config.ts +11 -0
  41. package/src/utils/daemon-client.ts +104 -25
  42. package/src/utils/logger.ts +42 -28
  43. package/tests/integrations/hermes.test.ts +42 -5
  44. package/tests/utils/daemon-client.test.ts +61 -0
  45. package/tests/wallet/short-keyset-token.test.ts +106 -0
@@ -0,0 +1,154 @@
1
+ import {
2
+ chmodSync,
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ statSync,
9
+ } from "fs";
10
+ import { Database } from "bun:sqlite";
11
+ import { basename, dirname, join } from "path";
12
+ import { legacyCocodDir, walletDir } from "./paths";
13
+ import {
14
+ summarizeWalletDirectory,
15
+ verifyDatabase,
16
+ WalletMigrationConflictError,
17
+ type WalletSummary,
18
+ } from "./diagnostics";
19
+ import { classifyWalletMigration } from "./wallet-state";
20
+
21
+ export type WalletMigrationResult =
22
+ | { status: "fresh" }
23
+ | { status: "already-current" }
24
+ | { status: "migrated"; from: string; to: string; cleanupWarnings: string[] };
25
+
26
+ export interface WalletMigrationOptions {
27
+ walletDir?: string;
28
+ legacyDir?: string;
29
+ /** Called after state validation and before legacy files are copied. */
30
+ assertLegacyStopped?: () => void | Promise<void>;
31
+ /** Claims the legacy cocod exclusion lock for the duration of the copy. */
32
+ acquireLegacyLock?: () => (() => void) | Promise<() => void>;
33
+ }
34
+
35
+ function sqlString(value: string): string {
36
+ return `'${value.replaceAll("'", "''")}'`;
37
+ }
38
+
39
+ /** Write a standalone SQLite snapshot containing committed WAL frames. */
40
+ function snapshotDatabase(sourcePath: string, destinationPath: string): void {
41
+ const source = new Database(sourcePath);
42
+ let sourceSummary: WalletSummary;
43
+ try {
44
+ sourceSummary = verifyDatabase(source, "Legacy wallet database");
45
+ source.exec(`VACUUM INTO ${sqlString(destinationPath)}`);
46
+ } finally {
47
+ source.close();
48
+ }
49
+
50
+ const destination = new Database(destinationPath, { readonly: true });
51
+ try {
52
+ const destinationSummary = verifyDatabase(destination, "Staged wallet database");
53
+ if (JSON.stringify(sourceSummary) !== JSON.stringify(destinationSummary)) {
54
+ throw new Error(
55
+ "Staged wallet database does not contain the same proofs, counters, operations, and mints as the legacy database.",
56
+ );
57
+ }
58
+ } finally {
59
+ destination.close();
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Copy a legacy cocod wallet into the canonical routstrd wallet directory.
65
+ * The staging directory is renamed atomically, so the canonical path is never
66
+ * exposed with only one of its two required files.
67
+ */
68
+ export async function migrateLegacyWallet(
69
+ options: WalletMigrationOptions = {},
70
+ ): Promise<WalletMigrationResult> {
71
+ const targetDir = options.walletDir || walletDir();
72
+ const sourceDir = options.legacyDir || legacyCocodDir();
73
+ const sourceConfig = join(sourceDir, "config.json");
74
+ const sourceDb = join(sourceDir, "coco.db");
75
+ const sourceWal = `${sourceDb}-wal`;
76
+ const sourceShm = `${sourceDb}-shm`;
77
+ const classification = classifyWalletMigration(targetDir, sourceDir);
78
+ switch (classification.kind) {
79
+ case "fresh":
80
+ return { status: "fresh" };
81
+ case "already-current":
82
+ return { status: "already-current" };
83
+ case "migrate":
84
+ break;
85
+ case "conflict":
86
+ throw new WalletMigrationConflictError(
87
+ summarizeWalletDirectory(targetDir, "canonical"),
88
+ summarizeWalletDirectory(sourceDir, "legacy"),
89
+ );
90
+ case "orphaned-sidecars":
91
+ throw new Error(
92
+ `Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` +
93
+ "Restore the matching main database before migration.",
94
+ );
95
+ case "database-only":
96
+ throw new Error(
97
+ `Cannot migrate wallet: ${targetDir} is ${classification.targetState} and ${sourceDir} is ${classification.sourceState}. ` +
98
+ "A coco.db without config.json cannot be opened; restore the matching config first.",
99
+ );
100
+ }
101
+
102
+ await options.assertLegacyStopped?.();
103
+ const releaseLegacyLock = await options.acquireLegacyLock?.();
104
+ const stagingDir = join(
105
+ dirname(targetDir),
106
+ `.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`,
107
+ );
108
+
109
+ try {
110
+ // Recheck after claiming the exclusion lock to close the probe/claim race.
111
+ await options.assertLegacyStopped?.();
112
+ mkdirSync(dirname(targetDir), { recursive: true, mode: 0o700 });
113
+ mkdirSync(stagingDir, { mode: 0o700 });
114
+ const stagedConfig = join(stagingDir, "config.json");
115
+ const stagedDb = join(stagingDir, "coco.db");
116
+ copyFileSync(sourceConfig, stagedConfig);
117
+ chmodSync(stagedConfig, 0o600);
118
+ const hasSourceDb = existsSync(sourceDb);
119
+ if (hasSourceDb) {
120
+ // A SQLite WAL is part of the logical database. Raw-copying coco.db can
121
+ // silently omit proofs and counters that have not yet been checkpointed.
122
+ snapshotDatabase(sourceDb, stagedDb);
123
+ chmodSync(stagedDb, 0o600);
124
+ }
125
+
126
+ if (statSync(stagedConfig).size !== statSync(sourceConfig).size) {
127
+ throw new Error("Cannot migrate wallet: staged config failed size validation.");
128
+ }
129
+
130
+ renameSync(stagingDir, targetDir);
131
+ } catch (error) {
132
+ rmSync(stagingDir, { recursive: true, force: true });
133
+ throw error;
134
+ } finally {
135
+ releaseLegacyLock?.();
136
+ }
137
+
138
+ // Preserve the complete legacy SQLite file set for manual recovery instead
139
+ // of deleting the only rollback copy immediately after migration.
140
+ const cleanupWarnings: string[] = [];
141
+ const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync);
142
+ if (sourceFiles.length > 0) {
143
+ const archiveDir = join(sourceDir, `wallet-migrated-${Date.now()}`);
144
+ try {
145
+ mkdirSync(archiveDir, { mode: 0o700 });
146
+ for (const path of sourceFiles) renameSync(path, join(archiveDir, basename(path)));
147
+ } catch (error) {
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ cleanupWarnings.push(`Could not archive legacy wallet files: ${message}`);
150
+ }
151
+ }
152
+
153
+ return { status: "migrated", from: sourceDir, to: targetDir, cleanupWarnings };
154
+ }
@@ -0,0 +1,33 @@
1
+ import { join } from "path";
2
+
3
+ function homeDir(): string {
4
+ return process.env.HOME || process.env.USERPROFILE || "";
5
+ }
6
+
7
+ /** Root directory for routstrd state. Evaluated lazily so env overrides work in tests. */
8
+ export function routstrdConfigDir(): string {
9
+ return process.env.ROUTSTRD_DIR || join(homeDir(), ".routstrd");
10
+ }
11
+
12
+ /** Canonical directory for the in-process Cashu wallet. */
13
+ export function walletDir(): string {
14
+ return process.env.ROUTSTRD_WALLET_DIR || join(routstrdConfigDir(), "wallet");
15
+ }
16
+
17
+ /** Lock owned by an in-process routstrd wallet instance. */
18
+ export function walletPidPath(): string {
19
+ return process.env.ROUTSTRD_WALLET_PID || join(walletDir(), "wallet.pid");
20
+ }
21
+
22
+ /** Legacy external cocod directory. This is not the routstrd wallet directory. */
23
+ export function legacyCocodDir(): string {
24
+ return process.env.COCOD_DIR || join(homeDir(), ".cocod");
25
+ }
26
+
27
+ export function legacyCocodSocketPath(): string {
28
+ return process.env.COCOD_SOCKET || join(legacyCocodDir(), "cocod.sock");
29
+ }
30
+
31
+ export function legacyCocodPidPath(): string {
32
+ return process.env.COCOD_PID || join(legacyCocodDir(), "cocod.pid");
33
+ }
@@ -0,0 +1,238 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { ReceiveOperation } from "@cashu/coco-core";
3
+ import { Database } from "bun:sqlite";
4
+ import {
5
+ clearInterruptedReceiveReservations,
6
+ deleteReceiveTokenReservation,
7
+ groupExecutingReceives,
8
+ hashReceiveToken,
9
+ initReceiveDedupSchema,
10
+ listProcessingReceiveTokens,
11
+ reconcileExecutingReceives,
12
+ reserveReceiveToken,
13
+ updateReceiveToken,
14
+ type ReceiveReconcileSource,
15
+ } from "./receive-dedup";
16
+
17
+ function operation(
18
+ id: string,
19
+ secret: string,
20
+ outputB: string,
21
+ createdAt = 1,
22
+ ): Extract<ReceiveOperation, { state: "executing" }> {
23
+ return {
24
+ id,
25
+ mintUrl: "https://mint.example",
26
+ unit: "sat",
27
+ amount: 1,
28
+ state: "executing",
29
+ createdAt,
30
+ updatedAt: createdAt,
31
+ fee: 0,
32
+ inputProofs: [{ amount: 1, id: "keyset", secret, C: "02" }],
33
+ outputData: {
34
+ keep: [
35
+ {
36
+ blindedMessage: { amount: 1, id: "keyset", B_: outputB },
37
+ blindingFactor: "1",
38
+ secret: "00",
39
+ },
40
+ ],
41
+ send: [],
42
+ },
43
+ };
44
+ }
45
+
46
+ function source(options: {
47
+ operations: ReceiveOperation[];
48
+ states?: string[];
49
+ restored?: string[];
50
+ saved?: string[];
51
+ }) {
52
+ const rolledBack: Array<{ id: string; reason: string }> = [];
53
+ let stateChecks = 0;
54
+ let restoreChecks = 0;
55
+ const reconcileSource: ReceiveReconcileSource = {
56
+ listExecuting: async () => options.operations,
57
+ checkProofStates: async (op) => {
58
+ stateChecks++;
59
+ return (options.states ?? op.inputProofs.map(() => "SPENT")).map((state) => ({ state }));
60
+ },
61
+ restoreOutputs: async (_mintUrl, outputs) => {
62
+ restoreChecks++;
63
+ const restored = new Set(options.restored ?? []);
64
+ return outputs.filter((output) => restored.has(output.B_));
65
+ },
66
+ hasSavedOutputs: async (op) => (options.saved ?? []).includes(op.id),
67
+ rollBack: async (op, reason) => {
68
+ rolledBack.push({ id: op.id, reason });
69
+ },
70
+ };
71
+ return {
72
+ source: reconcileSource,
73
+ rolledBack,
74
+ stateChecks: () => stateChecks,
75
+ restoreChecks: () => restoreChecks,
76
+ };
77
+ }
78
+
79
+ describe("receive token hashing and reservation", () => {
80
+ it("hashes the exact encoded token deterministically", () => {
81
+ expect(hashReceiveToken("cashuA")).toBe(hashReceiveToken("cashuA"));
82
+ expect(hashReceiveToken("cashuA")).not.toBe(hashReceiveToken("cashuB"));
83
+ });
84
+
85
+ it("atomically reserves an exact token once", () => {
86
+ const database = new Database(":memory:");
87
+ initReceiveDedupSchema(database);
88
+ const first = reserveReceiveToken(database, "cashuA");
89
+ const second = reserveReceiveToken(database, "cashuA");
90
+ expect(first.acquired).toBe(true);
91
+ expect(second).toMatchObject({
92
+ acquired: false,
93
+ existing: { state: "processing", operationId: null },
94
+ });
95
+ database.close();
96
+ });
97
+
98
+ it("persists successful status and releases retryable reservations", () => {
99
+ const database = new Database(":memory:");
100
+ initReceiveDedupSchema(database);
101
+ const reservation = reserveReceiveToken(database, "cashuA");
102
+ updateReceiveToken(database, reservation.tokenHash, {
103
+ state: "succeeded",
104
+ operationId: "receive-1",
105
+ });
106
+ expect(reserveReceiveToken(database, "cashuA").existing).toMatchObject({
107
+ state: "succeeded",
108
+ operationId: "receive-1",
109
+ });
110
+ deleteReceiveTokenReservation(database, reservation.tokenHash);
111
+ expect(reserveReceiveToken(database, "cashuA").acquired).toBe(true);
112
+ database.close();
113
+ });
114
+
115
+ it("clears only interrupted reservations without Coco operation ids", () => {
116
+ const database = new Database(":memory:");
117
+ initReceiveDedupSchema(database);
118
+ const orphan = reserveReceiveToken(database, "orphan");
119
+ const linked = reserveReceiveToken(database, "linked");
120
+ updateReceiveToken(database, linked.tokenHash, {
121
+ state: "processing",
122
+ operationId: "receive-1",
123
+ });
124
+ expect(clearInterruptedReceiveReservations(database)).toBe(1);
125
+ expect(listProcessingReceiveTokens(database)).toEqual([
126
+ expect.objectContaining({ tokenHash: linked.tokenHash, operationId: "receive-1" }),
127
+ ]);
128
+ expect(reserveReceiveToken(database, "orphan").tokenHash).toBe(orphan.tokenHash);
129
+ database.close();
130
+ });
131
+ });
132
+
133
+ describe("legacy receive grouping", () => {
134
+ it("groups identical input proofs independent of operation id", () => {
135
+ const groups = groupExecutingReceives([
136
+ operation("one", "same", "B1"),
137
+ operation("two", "same", "B2"),
138
+ operation("three", "different", "B3"),
139
+ ]);
140
+ expect(groups.map((group) => group.map((op) => op.id))).toEqual([
141
+ ["one", "two"],
142
+ ["three"],
143
+ ]);
144
+ });
145
+
146
+ it("checks an unspent duplicate group once and retains one operation", async () => {
147
+ const mock = source({
148
+ operations: [operation("one", "same", "B1", 1), operation("two", "same", "B2", 2)],
149
+ states: ["UNSPENT"],
150
+ });
151
+ const result = await reconcileExecutingReceives(mock.source);
152
+ expect(mock.stateChecks()).toBe(1);
153
+ expect(mock.restoreChecks()).toBe(0);
154
+ expect(mock.rolledBack.map((entry) => entry.id)).toEqual(["two"]);
155
+ expect(result).toMatchObject({ rolledBack: 1, retained: 1, unresolved: 0 });
156
+ });
157
+
158
+ it("rolls back all spent duplicates when restore finds no outputs", async () => {
159
+ const mock = source({
160
+ operations: [operation("one", "same", "B1"), operation("two", "same", "B2")],
161
+ states: ["SPENT"],
162
+ restored: [],
163
+ });
164
+ const result = await reconcileExecutingReceives(mock.source);
165
+ expect(mock.stateChecks()).toBe(1);
166
+ expect(mock.restoreChecks()).toBe(1);
167
+ expect(mock.rolledBack.map((entry) => entry.id)).toEqual(["one", "two"]);
168
+ expect(result.rolledBack).toBe(2);
169
+ });
170
+
171
+ it("retains the operation whose output the mint can restore", async () => {
172
+ const mock = source({
173
+ operations: [operation("one", "same", "B1"), operation("two", "same", "B2")],
174
+ states: ["SPENT"],
175
+ restored: ["B2"],
176
+ });
177
+ const result = await reconcileExecutingReceives(mock.source);
178
+ expect(mock.rolledBack.map((entry) => entry.id)).toEqual(["one"]);
179
+ expect(result).toMatchObject({ rolledBack: 1, retained: 1 });
180
+ });
181
+
182
+ it("leaves a group untouched when the mint returns incomplete states", async () => {
183
+ const first = operation("one", "same", "B1");
184
+ first.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
185
+ const second = operation("two", "same", "B2");
186
+ second.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
187
+ const mock = source({
188
+ operations: [first, second],
189
+ states: ["SPENT"],
190
+ });
191
+ const result = await reconcileExecutingReceives(mock.source);
192
+ expect(mock.rolledBack).toEqual([]);
193
+ expect(mock.restoreChecks()).toBe(0);
194
+ expect(result.unresolved).toBe(2);
195
+ });
196
+
197
+ it("leaves a group untouched when proof states are mixed", async () => {
198
+ const first = operation("one", "same", "B1");
199
+ first.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
200
+ const second = operation("two", "same", "B2");
201
+ second.inputProofs.push({ amount: 1, id: "keyset", secret: "other", C: "03" });
202
+ const mock = source({
203
+ operations: [first, second],
204
+ states: ["SPENT", "UNSPENT"],
205
+ });
206
+ const result = await reconcileExecutingReceives(mock.source);
207
+ expect(mock.rolledBack).toEqual([]);
208
+ expect(result.unresolved).toBe(2);
209
+ });
210
+
211
+ it("scales mint checks with unique input groups rather than row count", async () => {
212
+ const operations: ReceiveOperation[] = [];
213
+ for (let group = 0; group < 47; group++) {
214
+ const repeats = group < 44 ? 41 : 40; // 1,924 rows total
215
+ for (let index = 0; index < repeats; index++) {
216
+ operations.push(
217
+ operation(
218
+ `operation-${group}-${index}`,
219
+ `secret-${group}`,
220
+ `B-${group}-${index}`,
221
+ index,
222
+ ),
223
+ );
224
+ }
225
+ }
226
+ expect(operations).toHaveLength(1924);
227
+ const mock = source({ operations, states: ["SPENT"], restored: [] });
228
+ const result = await reconcileExecutingReceives(mock.source);
229
+ expect(mock.stateChecks()).toBe(47);
230
+ expect(mock.restoreChecks()).toBe(47);
231
+ expect(result).toMatchObject({
232
+ executing: 1924,
233
+ uniqueGroups: 47,
234
+ rolledBack: 1924,
235
+ unresolved: 0,
236
+ });
237
+ });
238
+ });