routstrd 0.4.0 → 0.4.2
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/README.md +5 -0
- package/bun.lock +316 -2
- package/dist/daemon/index.js +70789 -54681
- package/dist/index.js +729 -183
- package/package.json +2 -2
- package/src/cli.ts +258 -38
- package/src/daemon/config-store.ts +79 -4
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/models.test.ts +81 -0
- package/src/daemon/models.ts +18 -2
- 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.test.ts +191 -30
- package/src/daemon/wallet/coco-client.ts +867 -74
- package/src/daemon/wallet/cocod-client.ts +49 -1
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/migration.test.ts +30 -6
- package/src/daemon/wallet/migration.ts +30 -85
- 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/pi.ts +17 -1
- package/src/start-daemon.ts +21 -1
- package/src/tui/usage/data.ts +13 -2
- package/src/tui/usage/render.ts +8 -0
- package/src/utils/config.ts +3 -1
- package/tests/daemon/config-store.perms.test.ts +55 -0
- package/tests/daemon/config-store.scenario.ts +113 -0
- package/tests/integrations/hermes.test.ts +42 -5
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
classifyWalletMigration,
|
|
7
|
+
type WalletMigrationClass,
|
|
8
|
+
} from "./wallet-state";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wallet diagnostics for the cocod → routstrd wallet-directory migration.
|
|
12
|
+
*
|
|
13
|
+
* The migration in `migration.ts` refuses to choose between two wallets when
|
|
14
|
+
* both `~/.routstrd/wallet` and `~/.cocod` contain different data. These
|
|
15
|
+
* helpers summarize each side with privacy-safe, decision-relevant signals
|
|
16
|
+
* (mnemonic fingerprint, timestamps, and database proof/amount summaries) so a
|
|
17
|
+
* user can tell which wallet is the real one without us ever printing secrets.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type WalletRole = "canonical" | "legacy";
|
|
21
|
+
|
|
22
|
+
export type WalletSummary = Record<string, unknown[]>;
|
|
23
|
+
|
|
24
|
+
/** Queries shared with the migration snapshot verifier. */
|
|
25
|
+
export const SUMMARY_QUERIES: Record<string, string> = {
|
|
26
|
+
proofs:
|
|
27
|
+
"SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
|
|
28
|
+
counters:
|
|
29
|
+
"SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
|
|
30
|
+
mintOperations:
|
|
31
|
+
"SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state",
|
|
32
|
+
sendOperations:
|
|
33
|
+
"SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state",
|
|
34
|
+
meltOperations:
|
|
35
|
+
"SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state",
|
|
36
|
+
mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify a database is healthy and return a wallet summary. Throws on corrupt
|
|
41
|
+
* databases because the migration must never proceed over them.
|
|
42
|
+
*/
|
|
43
|
+
export function verifyDatabase(database: Database, label: string): WalletSummary {
|
|
44
|
+
const checks = database.query("PRAGMA quick_check").values() as unknown[][];
|
|
45
|
+
if (checks.length !== 1 || checks[0]?.[0] !== "ok") {
|
|
46
|
+
throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tables = new Set(
|
|
50
|
+
(database
|
|
51
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
52
|
+
.values() as string[][]).map(([name]) => name),
|
|
53
|
+
);
|
|
54
|
+
const summary: WalletSummary = {};
|
|
55
|
+
for (const [name, query] of Object.entries(SUMMARY_QUERIES)) {
|
|
56
|
+
const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1];
|
|
57
|
+
summary[name] = table && tables.has(table) ? database.query(query).all() : [];
|
|
58
|
+
}
|
|
59
|
+
return summary;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ProofSummaryRow {
|
|
63
|
+
mintUrl: string;
|
|
64
|
+
state: string;
|
|
65
|
+
count: number;
|
|
66
|
+
amount: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DbDiagnosticSummary {
|
|
70
|
+
proofs: ProofSummaryRow[];
|
|
71
|
+
mints: { mintUrl: string; trusted: number }[];
|
|
72
|
+
totalProofs: number;
|
|
73
|
+
totalAmount: number;
|
|
74
|
+
amountByState: Record<string, number>;
|
|
75
|
+
distinctMints: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WalletConfigDiagnostic {
|
|
79
|
+
exists: boolean;
|
|
80
|
+
path: string;
|
|
81
|
+
mtimeMs?: number;
|
|
82
|
+
error?: string;
|
|
83
|
+
fingerprint?: string;
|
|
84
|
+
encrypted?: boolean;
|
|
85
|
+
createdAt?: string;
|
|
86
|
+
version?: number;
|
|
87
|
+
defaultMintUrl?: string;
|
|
88
|
+
hasMnemonic?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface WalletDbDiagnostic {
|
|
92
|
+
exists: boolean;
|
|
93
|
+
path: string;
|
|
94
|
+
mtimeMs?: number;
|
|
95
|
+
sizeBytes?: number;
|
|
96
|
+
error?: string;
|
|
97
|
+
summary?: DbDiagnosticSummary;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface WalletDiagnostic {
|
|
101
|
+
dir: string;
|
|
102
|
+
role: WalletRole;
|
|
103
|
+
config: WalletConfigDiagnostic;
|
|
104
|
+
db: WalletDbDiagnostic;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A one-way, truncated fingerprint of a mnemonic. Never returns the mnemonic. */
|
|
108
|
+
export function mnemonicFingerprint(mnemonic: string): string {
|
|
109
|
+
const normalized = mnemonic.trim().split(/\s+/).join(" ");
|
|
110
|
+
const hex = createHash("sha256").update(normalized).digest("hex");
|
|
111
|
+
return hex.slice(0, 16).match(/.{1,4}/g)?.join("-") ?? hex.slice(0, 16);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read-only DB summary; never throws, degrades to an error string. */
|
|
115
|
+
export function summarizeDbReadonly(
|
|
116
|
+
dbPath: string,
|
|
117
|
+
): { summary?: DbDiagnosticSummary; error?: string } {
|
|
118
|
+
let database: Database | undefined;
|
|
119
|
+
try {
|
|
120
|
+
database = new Database(dbPath, { readonly: true });
|
|
121
|
+
const raw = verifyDatabase(database, "Wallet database");
|
|
122
|
+
|
|
123
|
+
const proofs = (raw.proofs ?? []) as ProofSummaryRow[];
|
|
124
|
+
const mints = (raw.mints ?? []) as { mintUrl: string; trusted: number }[];
|
|
125
|
+
const amountByState: Record<string, number> = {};
|
|
126
|
+
let totalProofs = 0;
|
|
127
|
+
let totalAmount = 0;
|
|
128
|
+
for (const row of proofs) {
|
|
129
|
+
const count = typeof row.count === "number" ? row.count : 0;
|
|
130
|
+
const amount = typeof row.amount === "number" ? row.amount : 0;
|
|
131
|
+
totalProofs += count;
|
|
132
|
+
totalAmount += amount;
|
|
133
|
+
const state = row.state || "unknown";
|
|
134
|
+
amountByState[state] = (amountByState[state] ?? 0) + amount;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
summary: {
|
|
139
|
+
proofs,
|
|
140
|
+
mints,
|
|
141
|
+
totalProofs,
|
|
142
|
+
totalAmount,
|
|
143
|
+
amountByState,
|
|
144
|
+
distinctMints: new Set(proofs.map((row) => row.mintUrl).filter(Boolean)).size,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
149
|
+
} finally {
|
|
150
|
+
database?.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function summarizeConfig(configPath: string): WalletConfigDiagnostic {
|
|
155
|
+
const result: WalletConfigDiagnostic = { exists: false, path: configPath };
|
|
156
|
+
try {
|
|
157
|
+
if (!existsSync(configPath)) return result;
|
|
158
|
+
result.exists = true;
|
|
159
|
+
result.mtimeMs = statSync(configPath).mtimeMs;
|
|
160
|
+
|
|
161
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
|
162
|
+
result.encrypted = parsed.encrypted === true;
|
|
163
|
+
if (typeof parsed.createdAt === "string") result.createdAt = parsed.createdAt;
|
|
164
|
+
if (typeof parsed.version === "number") result.version = parsed.version;
|
|
165
|
+
if (typeof parsed.defaultMintUrl === "string") {
|
|
166
|
+
result.defaultMintUrl = parsed.defaultMintUrl;
|
|
167
|
+
}
|
|
168
|
+
if (typeof parsed.mnemonic === "string") {
|
|
169
|
+
result.hasMnemonic = true;
|
|
170
|
+
if (!result.encrypted) result.fingerprint = mnemonicFingerprint(parsed.mnemonic);
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function summarizeDb(dbPath: string): WalletDbDiagnostic {
|
|
179
|
+
const result: WalletDbDiagnostic = { exists: false, path: dbPath };
|
|
180
|
+
try {
|
|
181
|
+
if (!existsSync(dbPath)) return result;
|
|
182
|
+
result.exists = true;
|
|
183
|
+
const stats = statSync(dbPath);
|
|
184
|
+
result.mtimeMs = stats.mtimeMs;
|
|
185
|
+
result.sizeBytes = stats.size;
|
|
186
|
+
|
|
187
|
+
const { summary, error } = summarizeDbReadonly(dbPath);
|
|
188
|
+
if (summary) result.summary = summary;
|
|
189
|
+
if (error) result.error = error;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function summarizeWalletDirectory(
|
|
197
|
+
dir: string,
|
|
198
|
+
role: WalletRole,
|
|
199
|
+
): WalletDiagnostic {
|
|
200
|
+
return {
|
|
201
|
+
dir,
|
|
202
|
+
role,
|
|
203
|
+
config: summarizeConfig(join(dir, "config.json")),
|
|
204
|
+
db: summarizeDb(join(dir, "coco.db")),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export class WalletMigrationConflictError extends Error {
|
|
209
|
+
readonly target: WalletDiagnostic;
|
|
210
|
+
readonly source: WalletDiagnostic;
|
|
211
|
+
|
|
212
|
+
constructor(target: WalletDiagnostic, source: WalletDiagnostic) {
|
|
213
|
+
super(renderWalletConflict(target, source));
|
|
214
|
+
this.name = "WalletMigrationConflictError";
|
|
215
|
+
this.target = target;
|
|
216
|
+
this.source = source;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function timeAgo(ms?: number): string | undefined {
|
|
221
|
+
if (ms === undefined || !Number.isFinite(ms)) return undefined;
|
|
222
|
+
const elapsed = Date.now() - ms;
|
|
223
|
+
if (elapsed < 0) return undefined;
|
|
224
|
+
const seconds = Math.floor(elapsed / 1000);
|
|
225
|
+
if (seconds < 5) return "just now";
|
|
226
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
227
|
+
const minutes = Math.floor(seconds / 60);
|
|
228
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
229
|
+
const hours = Math.floor(minutes / 60);
|
|
230
|
+
if (hours < 48) return `${hours}h ago`;
|
|
231
|
+
const days = Math.floor(hours / 24);
|
|
232
|
+
if (days < 60) return `${days}d ago`;
|
|
233
|
+
const months = Math.floor(days / 30);
|
|
234
|
+
if (months < 24) return `${months}mo ago`;
|
|
235
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function modifiedDescription(config: WalletConfigDiagnostic): string {
|
|
239
|
+
if (config.createdAt) return `created ${config.createdAt}`;
|
|
240
|
+
const ago = timeAgo(config.mtimeMs);
|
|
241
|
+
if (ago) return `modified ${ago}`;
|
|
242
|
+
return "modified time unknown";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function roleLabel(role: WalletRole): string {
|
|
246
|
+
return role === "canonical" ? "current routstrd wallet" : "legacy cocod wallet";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function configLine(config: WalletConfigDiagnostic): string {
|
|
250
|
+
if (!config.exists) return "config.json absent";
|
|
251
|
+
if (config.error) return `config.json present (unreadable: ${config.error})`;
|
|
252
|
+
const parts: string[] = ["config.json present"];
|
|
253
|
+
if (config.fingerprint) parts.push(`fingerprint ${config.fingerprint}`);
|
|
254
|
+
else if (config.encrypted) parts.push("encrypted (mnemonic not readable)");
|
|
255
|
+
else parts.push("no mnemonic found");
|
|
256
|
+
parts.push(modifiedDescription(config));
|
|
257
|
+
if (config.defaultMintUrl) parts.push(`default mint ${config.defaultMintUrl}`);
|
|
258
|
+
return parts.join(" · ");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function formatNumber(value: number): string {
|
|
262
|
+
return value.toLocaleString("en-US");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function amountByStateLine(summary: DbDiagnosticSummary): string {
|
|
266
|
+
const parts = Object.entries(summary.amountByState).filter(([, amount]) => amount > 0);
|
|
267
|
+
if (parts.length === 0) return "0 sats";
|
|
268
|
+
return parts.map(([state, amount]) => `${state}: ${formatNumber(amount)} sats`).join(", ");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function dbLine(db: WalletDbDiagnostic): string {
|
|
272
|
+
if (!db.exists) return "coco.db absent";
|
|
273
|
+
if (db.error && !db.summary) return `coco.db present (unreadable: ${db.error})`;
|
|
274
|
+
const parts: string[] = ["coco.db present"];
|
|
275
|
+
if (db.summary) {
|
|
276
|
+
const { totalProofs, mints, distinctMints } = db.summary;
|
|
277
|
+
// The mints table is the wallet's mint registry; fall back to mints seen
|
|
278
|
+
// in proofs for databases old enough to lack the registry table.
|
|
279
|
+
const mintCount = mints.length > 0 ? mints.length : distinctMints;
|
|
280
|
+
parts.push(`${formatNumber(totalProofs)} proof${totalProofs === 1 ? "" : "s"}`);
|
|
281
|
+
parts.push(amountByStateLine(db.summary));
|
|
282
|
+
parts.push(`${mintCount} mint${mintCount === 1 ? "" : "s"}`);
|
|
283
|
+
}
|
|
284
|
+
return parts.join(" · ");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function block(diag: WalletDiagnostic, letter: string): string {
|
|
288
|
+
return [
|
|
289
|
+
` ${letter}) ${diag.dir} (${roleLabel(diag.role)})`,
|
|
290
|
+
` ${configLine(diag.config)}`,
|
|
291
|
+
` ${dbLine(diag.db)}`,
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function moveAsideCommands(target: WalletDiagnostic, source: WalletDiagnostic): string {
|
|
296
|
+
return [
|
|
297
|
+
" • If B is your wallet (keep the legacy wallet):",
|
|
298
|
+
` mv "${target.dir}" "${target.dir}.old"`,
|
|
299
|
+
"",
|
|
300
|
+
" • If A is your wallet (keep the normal routstrd wallet):",
|
|
301
|
+
` mv "${source.dir}" "${source.dir}.old"`,
|
|
302
|
+
].join("\n");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function renderResolutionSteps(
|
|
306
|
+
target: WalletDiagnostic,
|
|
307
|
+
source: WalletDiagnostic,
|
|
308
|
+
): string {
|
|
309
|
+
return [
|
|
310
|
+
"To resolve, decide which wallet is yours, move the other one aside, and run",
|
|
311
|
+
"'routstrd onboard' (or 'routstrd start') again. Stop the daemon first:",
|
|
312
|
+
"",
|
|
313
|
+
" routstrd stop",
|
|
314
|
+
"",
|
|
315
|
+
"Then:",
|
|
316
|
+
"",
|
|
317
|
+
moveAsideCommands(target, source),
|
|
318
|
+
].join("\n");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function renderWalletConflict(
|
|
322
|
+
target: WalletDiagnostic,
|
|
323
|
+
source: WalletDiagnostic,
|
|
324
|
+
): string {
|
|
325
|
+
return [
|
|
326
|
+
"Cannot migrate wallet: two different wallets were found, and routstrd will",
|
|
327
|
+
"not choose a mnemonic or merge databases automatically — the wrong choice",
|
|
328
|
+
"could lose access to your funds.",
|
|
329
|
+
"",
|
|
330
|
+
block(target, "A"),
|
|
331
|
+
block(source, "B"),
|
|
332
|
+
"",
|
|
333
|
+
"No files were changed.",
|
|
334
|
+
"",
|
|
335
|
+
renderResolutionSteps(target, source),
|
|
336
|
+
"",
|
|
337
|
+
"For a full comparison and safe cleanup, run: routstrd wallet doctor",
|
|
338
|
+
].join("\n");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface WalletVerdict {
|
|
342
|
+
/** One-line human verdict for the doctor report. */
|
|
343
|
+
text: string;
|
|
344
|
+
/** Whether the mv-aside resolution steps apply to this state. */
|
|
345
|
+
showResolution: boolean;
|
|
346
|
+
/** Whether startup would (or may) refuse to migrate in this state. */
|
|
347
|
+
conflict: boolean;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function conflictVerdict(
|
|
351
|
+
target: WalletDiagnostic,
|
|
352
|
+
source: WalletDiagnostic,
|
|
353
|
+
): WalletVerdict {
|
|
354
|
+
const unreadable =
|
|
355
|
+
!!target.config.error ||
|
|
356
|
+
!!source.config.error ||
|
|
357
|
+
!!target.db.error ||
|
|
358
|
+
!!source.db.error;
|
|
359
|
+
if (unreadable) {
|
|
360
|
+
return {
|
|
361
|
+
text: "Verdict: both wallets exist but one or both could not be fully read; startup will refuse to migrate. See the details above.",
|
|
362
|
+
showResolution: true,
|
|
363
|
+
conflict: true,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (target.config.fingerprint && source.config.fingerprint) {
|
|
367
|
+
if (target.config.fingerprint === source.config.fingerprint) {
|
|
368
|
+
return {
|
|
369
|
+
text: "Verdict: both wallets share the same mnemonic but the files differ, so startup still refuses. Keep the wallet with your funds and move the other aside.",
|
|
370
|
+
showResolution: true,
|
|
371
|
+
conflict: true,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
text: "Verdict: the two wallets have DIFFERENT mnemonics. Startup will refuse to migrate.",
|
|
376
|
+
showResolution: true,
|
|
377
|
+
conflict: true,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
text: "Verdict: both wallets exist but their mnemonics cannot be compared (encrypted or missing). Startup will refuse to migrate unless the files are identical.",
|
|
382
|
+
showResolution: true,
|
|
383
|
+
conflict: true,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function verdictFromClassification(
|
|
388
|
+
target: WalletDiagnostic,
|
|
389
|
+
source: WalletDiagnostic,
|
|
390
|
+
classification: WalletMigrationClass,
|
|
391
|
+
): WalletVerdict {
|
|
392
|
+
switch (classification.kind) {
|
|
393
|
+
case "fresh":
|
|
394
|
+
return {
|
|
395
|
+
text: "Verdict: no wallet found in either location (fresh install).",
|
|
396
|
+
showResolution: false,
|
|
397
|
+
conflict: false,
|
|
398
|
+
};
|
|
399
|
+
case "already-current":
|
|
400
|
+
return {
|
|
401
|
+
text: "Verdict: the current routstrd wallet is authoritative; no migration needed.",
|
|
402
|
+
showResolution: false,
|
|
403
|
+
conflict: false,
|
|
404
|
+
};
|
|
405
|
+
case "migrate":
|
|
406
|
+
return {
|
|
407
|
+
text: "Verdict: only the legacy cocod wallet exists; it will be migrated on next startup.",
|
|
408
|
+
showResolution: false,
|
|
409
|
+
conflict: false,
|
|
410
|
+
};
|
|
411
|
+
case "conflict":
|
|
412
|
+
return conflictVerdict(target, source);
|
|
413
|
+
case "database-only":
|
|
414
|
+
return {
|
|
415
|
+
text: "Verdict: a wallet is incomplete (coco.db without config.json). Startup will refuse to migrate. See the details above.",
|
|
416
|
+
showResolution: false,
|
|
417
|
+
conflict: true,
|
|
418
|
+
};
|
|
419
|
+
case "orphaned-sidecars":
|
|
420
|
+
return {
|
|
421
|
+
text: "Verdict: the legacy wallet has SQLite sidecar files without coco.db. Startup will refuse to migrate. Restore the matching database first.",
|
|
422
|
+
showResolution: false,
|
|
423
|
+
conflict: true,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Map the shared migration classification onto a human doctor verdict. */
|
|
429
|
+
export function diagnoseWallets(
|
|
430
|
+
target: WalletDiagnostic,
|
|
431
|
+
source: WalletDiagnostic,
|
|
432
|
+
): WalletVerdict {
|
|
433
|
+
let classification: WalletMigrationClass;
|
|
434
|
+
try {
|
|
435
|
+
classification = classifyWalletMigration(target.dir, source.dir);
|
|
436
|
+
} catch {
|
|
437
|
+
// classifyWalletMigration does raw reads (filesEqual) that can throw on
|
|
438
|
+
// unreadable or concurrently-removed files. The doctor exists to diagnose
|
|
439
|
+
// broken states, so it must never crash on one. Fail safe: treat the
|
|
440
|
+
// state as a conflict — startup hits the same read error and refuses.
|
|
441
|
+
if (target.config.error || source.config.error || target.db.error || source.db.error) {
|
|
442
|
+
return conflictVerdict(target, source);
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
text: "Verdict: the wallets could not be fully compared (read error); startup will refuse to migrate. See the details above.",
|
|
446
|
+
showResolution: true,
|
|
447
|
+
conflict: true,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return verdictFromClassification(target, source, classification);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function renderWalletDoctor(
|
|
454
|
+
target: WalletDiagnostic,
|
|
455
|
+
source: WalletDiagnostic,
|
|
456
|
+
): string {
|
|
457
|
+
const verdict = diagnoseWallets(target, source);
|
|
458
|
+
const lines = [
|
|
459
|
+
"Routstr wallet diagnostic",
|
|
460
|
+
"========================",
|
|
461
|
+
"",
|
|
462
|
+
block(target, "A"),
|
|
463
|
+
block(source, "B"),
|
|
464
|
+
"",
|
|
465
|
+
verdict.text,
|
|
466
|
+
];
|
|
467
|
+
if (verdict.showResolution) {
|
|
468
|
+
lines.push("", renderResolutionSteps(target, source));
|
|
469
|
+
}
|
|
470
|
+
return lines.join("\n");
|
|
471
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { tmpdir } from "os";
|
|
14
14
|
import { join } from "path";
|
|
15
15
|
import { migrateLegacyWallet } from "./migration";
|
|
16
|
+
import { WalletMigrationConflictError } from "./diagnostics";
|
|
16
17
|
|
|
17
18
|
const roots: string[] = [];
|
|
18
19
|
function root(): string {
|
|
@@ -135,13 +136,36 @@ describe("migrateLegacyWallet", () => {
|
|
|
135
136
|
const walletDir = join(base, ".routstrd", "wallet");
|
|
136
137
|
mkdirSync(legacyDir, { recursive: true });
|
|
137
138
|
mkdirSync(walletDir, { recursive: true });
|
|
138
|
-
writeFileSync(
|
|
139
|
-
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(legacyDir, "config.json"),
|
|
141
|
+
JSON.stringify({ mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" }),
|
|
142
|
+
);
|
|
143
|
+
writeFileSync(
|
|
144
|
+
join(walletDir, "config.json"),
|
|
145
|
+
JSON.stringify({ mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow" }),
|
|
146
|
+
);
|
|
140
147
|
|
|
141
|
-
|
|
142
|
-
|
|
148
|
+
let thrown: unknown;
|
|
149
|
+
try {
|
|
150
|
+
await migrateLegacyWallet({ walletDir, legacyDir });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
thrown = error;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
expect(thrown).toBeInstanceOf(WalletMigrationConflictError);
|
|
156
|
+
const err = thrown as WalletMigrationConflictError;
|
|
157
|
+
expect(err.target.dir).toBe(walletDir);
|
|
158
|
+
expect(err.source.dir).toBe(legacyDir);
|
|
159
|
+
expect(err.message).toContain("two different wallets were found");
|
|
160
|
+
expect(err.message).toContain(walletDir);
|
|
161
|
+
expect(err.message).toContain(legacyDir);
|
|
162
|
+
expect(err.message).not.toContain("abandon");
|
|
163
|
+
expect(err.message).not.toContain("legal winner");
|
|
164
|
+
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toContain(
|
|
165
|
+
"legal winner",
|
|
166
|
+
);
|
|
167
|
+
expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toContain(
|
|
168
|
+
"abandon abandon",
|
|
143
169
|
);
|
|
144
|
-
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe("current");
|
|
145
|
-
expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toBe("legacy");
|
|
146
170
|
});
|
|
147
171
|
});
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
copyFileSync,
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
6
|
renameSync,
|
|
8
7
|
rmSync,
|
|
9
8
|
statSync,
|
|
@@ -11,6 +10,13 @@ import {
|
|
|
11
10
|
import { Database } from "bun:sqlite";
|
|
12
11
|
import { basename, dirname, join } from "path";
|
|
13
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";
|
|
14
20
|
|
|
15
21
|
export type WalletMigrationResult =
|
|
16
22
|
| { status: "fresh" }
|
|
@@ -26,60 +32,10 @@ export interface WalletMigrationOptions {
|
|
|
26
32
|
acquireLegacyLock?: () => (() => void) | Promise<() => void>;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
type WalletState = "absent" | "database-only" | "initialized";
|
|
30
|
-
|
|
31
|
-
function state(configPath: string, dbPath: string): WalletState {
|
|
32
|
-
const hasConfig = existsSync(configPath);
|
|
33
|
-
const hasDb = existsSync(dbPath);
|
|
34
|
-
if (hasConfig) return "initialized";
|
|
35
|
-
if (hasDb) return "database-only";
|
|
36
|
-
return "absent";
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function filesEqual(left: string, right: string): boolean {
|
|
40
|
-
if (!existsSync(left) || !existsSync(right)) return false;
|
|
41
|
-
if (statSync(left).size !== statSync(right).size) return false;
|
|
42
|
-
return readFileSync(left).equals(readFileSync(right));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
35
|
function sqlString(value: string): string {
|
|
46
36
|
return `'${value.replaceAll("'", "''")}'`;
|
|
47
37
|
}
|
|
48
38
|
|
|
49
|
-
type WalletSummary = Record<string, unknown[]>;
|
|
50
|
-
const SUMMARY_QUERIES: Record<string, string> = {
|
|
51
|
-
proofs:
|
|
52
|
-
"SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
|
|
53
|
-
counters:
|
|
54
|
-
"SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
|
|
55
|
-
mintOperations:
|
|
56
|
-
"SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state",
|
|
57
|
-
sendOperations:
|
|
58
|
-
"SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state",
|
|
59
|
-
meltOperations:
|
|
60
|
-
"SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state",
|
|
61
|
-
mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl",
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
function verifyDatabase(database: Database, label: string): WalletSummary {
|
|
65
|
-
const checks = database.query("PRAGMA quick_check").values() as unknown[][];
|
|
66
|
-
if (checks.length !== 1 || checks[0]?.[0] !== "ok") {
|
|
67
|
-
throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const tables = new Set(
|
|
71
|
-
(database
|
|
72
|
-
.query("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
73
|
-
.values() as string[][]).map(([name]) => name),
|
|
74
|
-
);
|
|
75
|
-
const summary: WalletSummary = {};
|
|
76
|
-
for (const [name, query] of Object.entries(SUMMARY_QUERIES)) {
|
|
77
|
-
const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1];
|
|
78
|
-
summary[name] = table && tables.has(table) ? database.query(query).all() : [];
|
|
79
|
-
}
|
|
80
|
-
return summary;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
39
|
/** Write a standalone SQLite snapshot containing committed WAL frames. */
|
|
84
40
|
function snapshotDatabase(sourcePath: string, destinationPath: string): void {
|
|
85
41
|
const source = new Database(sourcePath);
|
|
@@ -114,45 +70,34 @@ export async function migrateLegacyWallet(
|
|
|
114
70
|
): Promise<WalletMigrationResult> {
|
|
115
71
|
const targetDir = options.walletDir || walletDir();
|
|
116
72
|
const sourceDir = options.legacyDir || legacyCocodDir();
|
|
117
|
-
const targetConfig = join(targetDir, "config.json");
|
|
118
|
-
const targetDb = join(targetDir, "coco.db");
|
|
119
73
|
const sourceConfig = join(sourceDir, "config.json");
|
|
120
74
|
const sourceDb = join(sourceDir, "coco.db");
|
|
121
75
|
const sourceWal = `${sourceDb}-wal`;
|
|
122
76
|
const sourceShm = `${sourceDb}-shm`;
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
"Restore the matching main database before migration.",
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
if (targetState === "database-only" || sourceState === "database-only") {
|
|
150
|
-
throw new Error(
|
|
151
|
-
`Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` +
|
|
152
|
-
"A coco.db without config.json cannot be opened; restore the matching config first.",
|
|
153
|
-
);
|
|
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
|
+
);
|
|
154
100
|
}
|
|
155
|
-
if (sourceState === "absent") return { status: "fresh" };
|
|
156
101
|
|
|
157
102
|
await options.assertLegacyStopped?.();
|
|
158
103
|
const releaseLegacyLock = await options.acquireLegacyLock?.();
|