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
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
Manager,
|
|
3
3
|
getEncodedToken,
|
|
4
4
|
normalizeMintUrl,
|
|
5
5
|
} from "@cashu/coco-core";
|
|
6
6
|
import type {
|
|
7
7
|
HistoryEntry,
|
|
8
|
+
ReceiveOperation,
|
|
8
9
|
Logger as CocoLogger,
|
|
9
10
|
Plugin as CocoPlugin,
|
|
10
11
|
} from "@cashu/coco-core";
|
|
@@ -30,7 +31,25 @@ import type {
|
|
|
30
31
|
CocodState,
|
|
31
32
|
NpcAddress,
|
|
32
33
|
NpcUsernameResult,
|
|
34
|
+
WalletCleanupOptions,
|
|
35
|
+
WalletCleanupResult,
|
|
36
|
+
WalletRecoveryProgress,
|
|
33
37
|
} from "./cocod-client";
|
|
38
|
+
import { selectCleanupOperations } from "./cleanup";
|
|
39
|
+
import {
|
|
40
|
+
clearInterruptedReceiveReservations,
|
|
41
|
+
deleteReceiveTokenReservation,
|
|
42
|
+
getReceiveReconcileBackup,
|
|
43
|
+
initReceiveDedupSchema,
|
|
44
|
+
listProcessingReceiveTokens,
|
|
45
|
+
receiveInputFingerprint,
|
|
46
|
+
reconcileExecutingReceives,
|
|
47
|
+
releaseReceiveToken,
|
|
48
|
+
reserveReceiveToken,
|
|
49
|
+
setReceiveReconcileBackup,
|
|
50
|
+
updateReceiveToken,
|
|
51
|
+
type ReceiveReconcileSource,
|
|
52
|
+
} from "./receive-dedup";
|
|
34
53
|
import { cocoLogger, logger } from "../../utils/logger";
|
|
35
54
|
import {
|
|
36
55
|
legacyCocodPidPath,
|
|
@@ -56,12 +75,7 @@ type LegacyCocodFetch = (
|
|
|
56
75
|
|
|
57
76
|
export interface LegacyCocodGuardOptions {
|
|
58
77
|
socketPath?: string;
|
|
59
|
-
pidFilePath?: string;
|
|
60
78
|
pathExists?: (path: string) => boolean;
|
|
61
|
-
readFile?: (path: string) => string;
|
|
62
|
-
isProcessRunning?: (pid: number) => boolean;
|
|
63
|
-
/** PID owned by the caller's already-acquired legacy exclusion lock. */
|
|
64
|
-
ignorePid?: number;
|
|
65
79
|
fetchImpl?: LegacyCocodFetch;
|
|
66
80
|
timeoutMs?: number;
|
|
67
81
|
}
|
|
@@ -69,6 +83,8 @@ export interface LegacyCocodGuardOptions {
|
|
|
69
83
|
export interface LegacyCocodPidClaimOptions {
|
|
70
84
|
pidFilePath?: string;
|
|
71
85
|
pid?: number;
|
|
86
|
+
/** Human-readable lock name used in contention errors. */
|
|
87
|
+
label?: string;
|
|
72
88
|
openExclusive?: (path: string) => number;
|
|
73
89
|
writePid?: (fd: number, pid: number) => void;
|
|
74
90
|
closeFile?: (fd: number) => void;
|
|
@@ -110,6 +126,20 @@ function startupProgress(message: string): void {
|
|
|
110
126
|
console.log(`${STARTUP_LOG_PREFIX} ${message}`);
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
// Set only while the background wallet recovery sweeps are running. While set,
|
|
130
|
+
// coco logger messages that indicate a stalled/failed per-mint check are
|
|
131
|
+
// forwarded to the startup stream (see createCocoLogger).
|
|
132
|
+
let surfacingRecoveryProgress = false;
|
|
133
|
+
|
|
134
|
+
// These fire once per operation when a mint is unreachable or otherwise fails
|
|
135
|
+
// to reconcile. They are the only per-mint signal coco emits, and they happen
|
|
136
|
+
// exactly when recovery is slow.
|
|
137
|
+
const RECOVERY_STALL_MARKERS = new Map<string, string>([
|
|
138
|
+
["SendOperationService\u0000Could not reach mint for recovery, will retry later", "Send recovery: mint unreachable"],
|
|
139
|
+
["ReceiveOperationService\u0000Could not reach mint for receive recovery, will retry later", "Receive recovery: mint unreachable"],
|
|
140
|
+
["MintOperationService\u0000Failed to reconcile stale pending mint operation", "Mint recovery: pending operation check failed"],
|
|
141
|
+
]);
|
|
142
|
+
|
|
113
143
|
const SAFE_COCO_LOG_FIELDS = new Set([
|
|
114
144
|
"module",
|
|
115
145
|
"mintUrl",
|
|
@@ -149,6 +179,19 @@ function createCocoLogger(bindings: Record<string, unknown> = {}): CocoLogger {
|
|
|
149
179
|
// useful without copying wallet material into routstrd's logs. Written to
|
|
150
180
|
// ~/.routstrd/coco-logs/ so wallet-engine noise stays out of the main logs.
|
|
151
181
|
const metadata = safeCocoMetadata([bindings, ...meta]);
|
|
182
|
+
|
|
183
|
+
if (surfacingRecoveryProgress) {
|
|
184
|
+
const module = typeof bindings.module === "string" ? bindings.module : undefined;
|
|
185
|
+
if (module) {
|
|
186
|
+
const stall = RECOVERY_STALL_MARKERS.get(`${module}\u0000${message}`);
|
|
187
|
+
if (stall) {
|
|
188
|
+
const mintUrl =
|
|
189
|
+
typeof metadata.mintUrl === "string" ? metadata.mintUrl : undefined;
|
|
190
|
+
startupProgress(mintUrl ? `${stall} (${mintUrl})` : stall);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
152
195
|
cocoLogger[level](
|
|
153
196
|
`[coco] ${message}`,
|
|
154
197
|
...(Object.keys(metadata).length > 0 ? [metadata] : []),
|
|
@@ -199,13 +242,35 @@ function saveConfig(config: CocodConfig, configFile: string): void {
|
|
|
199
242
|
}
|
|
200
243
|
}
|
|
201
244
|
|
|
245
|
+
export function isZombieProcess(
|
|
246
|
+
pid: number,
|
|
247
|
+
readFile: (path: string) => string = (path) =>
|
|
248
|
+
readFileSync(path, "utf-8"),
|
|
249
|
+
): boolean {
|
|
250
|
+
try {
|
|
251
|
+
// Linux exposes zombie state as the character following the final `)` in
|
|
252
|
+
// /proc/<pid>/stat. Use the final parenthesis because process names may
|
|
253
|
+
// themselves contain spaces or parentheses. Other platforms simply fall
|
|
254
|
+
// back to process.kill(pid, 0) below.
|
|
255
|
+
const stat = readFile(`/proc/${pid}/stat`);
|
|
256
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
257
|
+
return commandEnd >= 0 && stat.charAt(commandEnd + 2) === "Z";
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
202
263
|
function defaultIsProcessRunning(pid: number): boolean {
|
|
203
264
|
try {
|
|
204
265
|
process.kill(pid, 0);
|
|
205
|
-
return true;
|
|
206
266
|
} catch (error) {
|
|
207
267
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
208
268
|
}
|
|
269
|
+
|
|
270
|
+
// kill(pid, 0) also succeeds for dead-but-unreaped processes. Zombies hold
|
|
271
|
+
// no database or socket resources, so treating them as dead allows stale
|
|
272
|
+
// wallet locks to be reclaimed (notably under non-reaping Docker PID 1s).
|
|
273
|
+
return !isZombieProcess(pid);
|
|
209
274
|
}
|
|
210
275
|
|
|
211
276
|
function hasErrorCode(error: unknown, codes: Set<string>): boolean {
|
|
@@ -225,9 +290,15 @@ function hasErrorCode(error: unknown, codes: Set<string>): boolean {
|
|
|
225
290
|
}
|
|
226
291
|
|
|
227
292
|
/**
|
|
228
|
-
* Refuse to open coco.db while
|
|
293
|
+
* Refuse to open coco.db while a daemon answers on legacy cocod's Unix socket.
|
|
229
294
|
* Two independent wallet engines must never operate on the same proof database.
|
|
230
295
|
*
|
|
296
|
+
* cocod.pid is deliberately shared: routstrd writes its own PID there while
|
|
297
|
+
* the in-process wallet is open, fencing old cocod binaries from starting. A
|
|
298
|
+
* live PID in that file therefore cannot identify cocod. Socket responsiveness
|
|
299
|
+
* is the authoritative identity check; the atomic PID-file claim below closes
|
|
300
|
+
* the race when cocod is still starting and has not opened its socket yet.
|
|
301
|
+
*
|
|
231
302
|
* A socket left behind after a crash is safe to ignore only when connecting
|
|
232
303
|
* fails with ENOENT or ECONNREFUSED. Other probe failures are treated as unsafe
|
|
233
304
|
* because they do not prove that cocod has stopped.
|
|
@@ -236,37 +307,7 @@ export async function assertLegacyCocodNotRunning(
|
|
|
236
307
|
options: LegacyCocodGuardOptions = {},
|
|
237
308
|
): Promise<void> {
|
|
238
309
|
const socketPath = options.socketPath || legacyCocodSocketPath();
|
|
239
|
-
const pidFilePath = options.pidFilePath || legacyCocodPidPath();
|
|
240
310
|
const pathExists = options.pathExists || existsSync;
|
|
241
|
-
const readFile = options.readFile || ((path) => readFileSync(path, "utf-8"));
|
|
242
|
-
const isProcessRunning = options.isProcessRunning || defaultIsProcessRunning;
|
|
243
|
-
|
|
244
|
-
const getRunningLegacyPid = (): number | null => {
|
|
245
|
-
if (!pathExists(pidFilePath)) return null;
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
const pid = Number.parseInt(readFile(pidFilePath).trim(), 10);
|
|
249
|
-
return Number.isInteger(pid) &&
|
|
250
|
-
pid > 0 &&
|
|
251
|
-
pid !== options.ignorePid &&
|
|
252
|
-
isProcessRunning(pid)
|
|
253
|
-
? pid
|
|
254
|
-
: null;
|
|
255
|
-
} catch {
|
|
256
|
-
// An unreadable or malformed PID file does not prove that cocod is alive;
|
|
257
|
-
// the socket probe below remains the authoritative fallback.
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
const runningPid = getRunningLegacyPid();
|
|
263
|
-
if (runningPid !== null) {
|
|
264
|
-
throw new Error(
|
|
265
|
-
`Legacy cocod daemon is still running with PID ${runningPid}. ` +
|
|
266
|
-
"Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " +
|
|
267
|
-
`Run 'cocod stop' or 'kill ${runningPid}' and try again.`,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
311
|
|
|
271
312
|
if (!pathExists(socketPath)) return;
|
|
272
313
|
|
|
@@ -281,19 +322,8 @@ export async function assertLegacyCocodNotRunning(
|
|
|
281
322
|
await response.body?.cancel();
|
|
282
323
|
} catch (error) {
|
|
283
324
|
if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (newlyRunningPid === null) {
|
|
287
|
-
logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
throw new Error(
|
|
292
|
-
`Legacy cocod daemon is still running with PID ${newlyRunningPid}. ` +
|
|
293
|
-
"Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " +
|
|
294
|
-
`Run 'cocod stop' or 'kill ${newlyRunningPid}' and try again.`,
|
|
295
|
-
{ cause: error },
|
|
296
|
-
);
|
|
325
|
+
logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
|
|
326
|
+
return;
|
|
297
327
|
}
|
|
298
328
|
|
|
299
329
|
throw new Error(
|
|
@@ -418,12 +448,14 @@ export function claimLegacyCocodPidFile(
|
|
|
418
448
|
return claimPidFile({
|
|
419
449
|
...options,
|
|
420
450
|
pidFilePath: options.pidFilePath || legacyCocodPidPath(),
|
|
451
|
+
label: options.label || "legacy cocod exclusion lock",
|
|
421
452
|
});
|
|
422
453
|
}
|
|
423
454
|
|
|
424
455
|
function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: string }): () => void {
|
|
425
456
|
const pidFilePath = options.pidFilePath;
|
|
426
457
|
const pid = options.pid ?? process.pid;
|
|
458
|
+
const label = options.label || "wallet process lock";
|
|
427
459
|
const openExclusive =
|
|
428
460
|
options.openExclusive || ((path: string) => openSync(path, "wx", 0o600));
|
|
429
461
|
const writePid =
|
|
@@ -449,7 +481,7 @@ function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: strin
|
|
|
449
481
|
stalePid = Number.parseInt(readFile(pidFilePath).trim(), 10);
|
|
450
482
|
} catch {
|
|
451
483
|
throw new Error(
|
|
452
|
-
`Cannot claim the
|
|
484
|
+
`Cannot claim the ${label} at ${pidFilePath}. ` +
|
|
453
485
|
"Another cocod or routstrd process may be starting. Stop it and try again.",
|
|
454
486
|
{ cause: error },
|
|
455
487
|
);
|
|
@@ -460,9 +492,13 @@ function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: strin
|
|
|
460
492
|
stalePid <= 0 ||
|
|
461
493
|
isProcessRunning(stalePid)
|
|
462
494
|
) {
|
|
495
|
+
const ownerMessage =
|
|
496
|
+
Number.isInteger(stalePid) && stalePid > 0
|
|
497
|
+
? `PID ${stalePid} is still running and holds it. ` +
|
|
498
|
+
`Stop that process first ('routstrd stop' or 'kill ${stalePid}').`
|
|
499
|
+
: "Another cocod or routstrd process may be starting. Stop it and try again.";
|
|
463
500
|
throw new Error(
|
|
464
|
-
`Cannot claim the
|
|
465
|
-
"Another cocod or routstrd process may be starting. Stop it and try again.",
|
|
501
|
+
`Cannot claim the ${label} at ${pidFilePath}: ${ownerMessage}`,
|
|
466
502
|
{ cause: error },
|
|
467
503
|
);
|
|
468
504
|
}
|
|
@@ -472,7 +508,7 @@ function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: strin
|
|
|
472
508
|
fd = openExclusive(pidFilePath);
|
|
473
509
|
} catch (retryError) {
|
|
474
510
|
throw new Error(
|
|
475
|
-
`Cannot claim the
|
|
511
|
+
`Cannot claim the ${label} at ${pidFilePath}. ` +
|
|
476
512
|
"Another cocod or routstrd process may be starting. Stop it and try again.",
|
|
477
513
|
{ cause: retryError },
|
|
478
514
|
);
|
|
@@ -493,9 +529,10 @@ function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: strin
|
|
|
493
529
|
}
|
|
494
530
|
|
|
495
531
|
let released = false;
|
|
496
|
-
|
|
532
|
+
const release = () => {
|
|
497
533
|
if (released) return;
|
|
498
534
|
released = true;
|
|
535
|
+
process.removeListener("exit", release);
|
|
499
536
|
|
|
500
537
|
try {
|
|
501
538
|
if (readFile(pidFilePath).trim() === String(pid)) {
|
|
@@ -504,12 +541,37 @@ function claimPidFile(options: LegacyCocodPidClaimOptions & { pidFilePath: strin
|
|
|
504
541
|
} catch (error) {
|
|
505
542
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
506
543
|
logger.warn(
|
|
507
|
-
`Failed to release
|
|
544
|
+
`Failed to release ${label} at ${pidFilePath}:`,
|
|
508
545
|
error,
|
|
509
546
|
);
|
|
510
547
|
}
|
|
511
548
|
}
|
|
512
549
|
};
|
|
550
|
+
|
|
551
|
+
// process.exit() and natural shutdown still run synchronous exit handlers.
|
|
552
|
+
// This prevents migration or startup failures from stranding our PID files.
|
|
553
|
+
process.once("exit", release);
|
|
554
|
+
return release;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Minimal structural view of coco-core's MintOperationService.
|
|
559
|
+
* The service is private on the exported Manager class, so the in-process
|
|
560
|
+
* client reaches it through this narrow cast. Both methods reload the latest
|
|
561
|
+
* persisted row before mutating anything, so a bare operation id is enough.
|
|
562
|
+
*/
|
|
563
|
+
interface MintOperationServiceCleanup {
|
|
564
|
+
failPendingOperation(
|
|
565
|
+
op: { id: string },
|
|
566
|
+
terminalFailure: { reason: string; retryable?: boolean; observedAt: number },
|
|
567
|
+
): Promise<unknown>;
|
|
568
|
+
/**
|
|
569
|
+
* Ask the mint for a pending quote's current state and persist the
|
|
570
|
+
* observation. "waiting" means the mint still reports the quote as unpaid.
|
|
571
|
+
*/
|
|
572
|
+
observePendingOperation(
|
|
573
|
+
operationId: string,
|
|
574
|
+
): Promise<{ category: "waiting" | "ready" | "completed" | "terminal" }>;
|
|
513
575
|
}
|
|
514
576
|
|
|
515
577
|
export interface CreateCocoClientOptions {
|
|
@@ -528,6 +590,337 @@ export interface CreateCocoClientOptions {
|
|
|
528
590
|
npcBaseUrl?: string;
|
|
529
591
|
}
|
|
530
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Build a usable coco Manager without running the blocking recovery sweeps.
|
|
595
|
+
*
|
|
596
|
+
* This replicates `initializeCoco()` up to (but not including) the send/melt/
|
|
597
|
+
* receive/mint recovery passes, so the daemon can serve wallet reads while
|
|
598
|
+
* recovery proceeds in the background.
|
|
599
|
+
*/
|
|
600
|
+
function constructCocoManager(
|
|
601
|
+
repo: SqliteRepositories,
|
|
602
|
+
seed: Uint8Array,
|
|
603
|
+
): Manager {
|
|
604
|
+
return new Manager(repo, async () => seed, createCocoLogger());
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function enableCocoManager(coco: Manager): Promise<void> {
|
|
608
|
+
await coco.initPlugins();
|
|
609
|
+
await coco.reconcileLegacyMintQuotes();
|
|
610
|
+
await coco.enableMintOperationWatcher();
|
|
611
|
+
await coco.enableProofStateWatcher();
|
|
612
|
+
await coco.enableMintOperationProcessor();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Shared wall-clock budget for checking expired mint quotes with their mints
|
|
617
|
+
* during background recovery. coco-core issues mint requests without a
|
|
618
|
+
* timeout, so a hung mint could otherwise stall this phase (and with it the
|
|
619
|
+
* recovery promise that gates value-moving operations) far longer than this.
|
|
620
|
+
*/
|
|
621
|
+
const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
|
|
622
|
+
|
|
623
|
+
/** Rejects when `timeoutMs` elapses before `promise` settles. */
|
|
624
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
625
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
626
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
627
|
+
timer = setTimeout(
|
|
628
|
+
() => reject(new Error("Timed out contacting mint")),
|
|
629
|
+
timeoutMs,
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
633
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Structural subset of coco's Manager used by expired-quote settlement. */
|
|
638
|
+
export interface ExpiredMintQuoteSource {
|
|
639
|
+
ops: {
|
|
640
|
+
mint: {
|
|
641
|
+
listPending(): Promise<
|
|
642
|
+
Array<{
|
|
643
|
+
id: string;
|
|
644
|
+
mintUrl: string;
|
|
645
|
+
quoteId?: string;
|
|
646
|
+
state: string;
|
|
647
|
+
/** Quote expiry in epoch seconds. */
|
|
648
|
+
expiry: number;
|
|
649
|
+
updatedAt: number;
|
|
650
|
+
lastObservedRemoteState?: string;
|
|
651
|
+
}>
|
|
652
|
+
>;
|
|
653
|
+
};
|
|
654
|
+
};
|
|
655
|
+
mintOperationService: MintOperationServiceCleanup;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export interface ExpiredMintSettlement {
|
|
659
|
+
/** Quotes their mint confirmed as UNPAID, failed locally. */
|
|
660
|
+
failed: number;
|
|
661
|
+
/** Quotes observed as PAID/ISSUED, left for mint recovery to finalize. */
|
|
662
|
+
leftForRecovery: number;
|
|
663
|
+
/** Quotes whose mint could not be checked in time, left pending. */
|
|
664
|
+
unobserved: number;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Settle expired pending mint quotes before the mint recovery sweep runs.
|
|
669
|
+
*
|
|
670
|
+
* An expired bolt11 invoice can never be paid again, so a quote the mint
|
|
671
|
+
* still reports as UNPAID is guaranteed never to be issued and is failed
|
|
672
|
+
* locally. That local fail is what keeps coco-core's mint recovery sweep
|
|
673
|
+
* quick: the sweep treats UNPAID as "waiting" and would otherwise re-contact
|
|
674
|
+
* every dead quote's mint on every startup.
|
|
675
|
+
*
|
|
676
|
+
* The observation round is what makes the local fail safe: a quote can have
|
|
677
|
+
* been paid before expiry while the daemon was down, leaving no local
|
|
678
|
+
* observation behind. Failing such a quote without asking the mint would
|
|
679
|
+
* strand the paid funds, because failed operations are skipped by recovery.
|
|
680
|
+
* Asking the mint first closes that hole: PAID/ISSUED quotes are left for
|
|
681
|
+
* the sweep to finalize, and quotes whose mint is unreachable or too slow
|
|
682
|
+
* are left pending so a later startup can still recover them.
|
|
683
|
+
*/
|
|
684
|
+
export async function settleExpiredMintQuotes(
|
|
685
|
+
source: ExpiredMintQuoteSource,
|
|
686
|
+
nowMs: number,
|
|
687
|
+
deadlineMs: number = EXPIRED_MINT_OBSERVATION_DEADLINE_MS,
|
|
688
|
+
): Promise<ExpiredMintSettlement> {
|
|
689
|
+
const pendingMints = await source.ops.mint.listPending();
|
|
690
|
+
const selection = selectCleanupOperations({
|
|
691
|
+
mints: pendingMints,
|
|
692
|
+
sends: [],
|
|
693
|
+
melts: [],
|
|
694
|
+
nowMs,
|
|
695
|
+
minAgeMs: 0,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const settlement: ExpiredMintSettlement = {
|
|
699
|
+
failed: 0,
|
|
700
|
+
leftForRecovery: 0,
|
|
701
|
+
unobserved: 0,
|
|
702
|
+
};
|
|
703
|
+
const candidates = selection.mintsToFail;
|
|
704
|
+
if (candidates.length === 0) return settlement;
|
|
705
|
+
|
|
706
|
+
const startedAt = Date.now();
|
|
707
|
+
for (const op of candidates) {
|
|
708
|
+
const remainingMs = deadlineMs - (Date.now() - startedAt);
|
|
709
|
+
if (remainingMs <= 0) {
|
|
710
|
+
const skipped =
|
|
711
|
+
candidates.length -
|
|
712
|
+
settlement.failed -
|
|
713
|
+
settlement.leftForRecovery -
|
|
714
|
+
settlement.unobserved;
|
|
715
|
+
settlement.unobserved += skipped;
|
|
716
|
+
startupProgress(
|
|
717
|
+
`Expired mint quote check budget exhausted; ${skipped} quote(s) left for mint recovery.`,
|
|
718
|
+
);
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
const result = await withTimeout(
|
|
724
|
+
source.mintOperationService.observePendingOperation(op.id),
|
|
725
|
+
remainingMs,
|
|
726
|
+
);
|
|
727
|
+
if (result.category === "waiting") {
|
|
728
|
+
// The mint confirms the expired quote is still unpaid: it can never
|
|
729
|
+
// be issued now, so failing it locally cannot strand funds.
|
|
730
|
+
await source.mintOperationService.failPendingOperation(
|
|
731
|
+
{ id: op.id },
|
|
732
|
+
{
|
|
733
|
+
reason: "Expired mint quote confirmed unpaid by mint",
|
|
734
|
+
retryable: false,
|
|
735
|
+
observedAt: Date.now(),
|
|
736
|
+
},
|
|
737
|
+
);
|
|
738
|
+
settlement.failed++;
|
|
739
|
+
} else {
|
|
740
|
+
// PAID/ISSUED (or terminally failed) at the mint: normal recovery
|
|
741
|
+
// must see this quote so paid proofs get claimed.
|
|
742
|
+
settlement.leftForRecovery++;
|
|
743
|
+
const observed =
|
|
744
|
+
result.category === "ready"
|
|
745
|
+
? "was paid at the mint"
|
|
746
|
+
: result.category === "completed"
|
|
747
|
+
? "was already issued at the mint"
|
|
748
|
+
: "failed terminally at the mint";
|
|
749
|
+
startupProgress(
|
|
750
|
+
`Expired mint quote ${op.quoteId ?? op.id} at ${op.mintUrl} ${observed}; leaving it for mint recovery.`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
} catch (error) {
|
|
754
|
+
// Mint unreachable, too slow, or the quote unknown to it: leave the
|
|
755
|
+
// operation pending so a later startup can still recover it.
|
|
756
|
+
settlement.unobserved++;
|
|
757
|
+
logger.warn("Could not check expired mint quote; leaving it pending", {
|
|
758
|
+
operationId: op.id,
|
|
759
|
+
mintUrl: op.mintUrl,
|
|
760
|
+
error: error instanceof Error ? error.message : String(error),
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return settlement;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
interface ReceiveRecoveryInternals {
|
|
769
|
+
receiveOperationService: {
|
|
770
|
+
checkProofStatesWithMint(
|
|
771
|
+
mintUrl: string,
|
|
772
|
+
proofs: Array<{ secret: string }>,
|
|
773
|
+
): Promise<Array<{ state: string }>>;
|
|
774
|
+
hasSavedOutputs(operation: unknown): Promise<boolean>;
|
|
775
|
+
markAsRolledBack(operation: unknown, error: string): Promise<unknown>;
|
|
776
|
+
};
|
|
777
|
+
mintAdapter: {
|
|
778
|
+
getCashuMint(mintUrl: string): {
|
|
779
|
+
restore(input: { outputs: Array<{ amount: number; id: string; B_: string }> }): Promise<{
|
|
780
|
+
outputs: Array<{ B_: string }>;
|
|
781
|
+
}>;
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function reconcileDuplicateReceiveOperations(
|
|
787
|
+
coco: Manager,
|
|
788
|
+
repo: SqliteRepositories,
|
|
789
|
+
): Promise<Awaited<ReturnType<typeof reconcileExecutingReceives>>> {
|
|
790
|
+
const internals = coco as unknown as ReceiveRecoveryInternals;
|
|
791
|
+
const unavailableMints = new Set<string>();
|
|
792
|
+
const deadline = Date.now() + 45_000;
|
|
793
|
+
const ensureMintBudget = (mintUrl: string): void => {
|
|
794
|
+
if (Date.now() >= deadline) throw new Error("Receive cleanup time budget exhausted");
|
|
795
|
+
if (unavailableMints.has(mintUrl)) throw new Error("Mint already failed receive cleanup");
|
|
796
|
+
};
|
|
797
|
+
const markMintFailure = (mintUrl: string, error: unknown): never => {
|
|
798
|
+
unavailableMints.add(mintUrl);
|
|
799
|
+
throw error;
|
|
800
|
+
};
|
|
801
|
+
const source: ReceiveReconcileSource = {
|
|
802
|
+
listExecuting: () => repo.receiveOperationRepository.getByState("executing"),
|
|
803
|
+
checkProofStates: async (operation) => {
|
|
804
|
+
ensureMintBudget(operation.mintUrl);
|
|
805
|
+
try {
|
|
806
|
+
return await withTimeout(
|
|
807
|
+
internals.receiveOperationService.checkProofStatesWithMint(
|
|
808
|
+
operation.mintUrl,
|
|
809
|
+
operation.inputProofs,
|
|
810
|
+
),
|
|
811
|
+
Math.min(15_000, Math.max(1, deadline - Date.now())),
|
|
812
|
+
);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
return markMintFailure(operation.mintUrl, error);
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
restoreOutputs: async (mintUrl, outputs) => {
|
|
818
|
+
ensureMintBudget(mintUrl);
|
|
819
|
+
// Probe deterministic outputs in bounded batches. The Cashu restore
|
|
820
|
+
// response echoes only blinded messages with stored signatures, which
|
|
821
|
+
// identifies the owning operation. Coco later performs the real proof
|
|
822
|
+
// recovery for the retained operation.
|
|
823
|
+
const restored: Array<{ B_: string }> = [];
|
|
824
|
+
const mint = internals.mintAdapter.getCashuMint(mintUrl);
|
|
825
|
+
for (let index = 0; index < outputs.length; index += 300) {
|
|
826
|
+
try {
|
|
827
|
+
const response = await withTimeout(
|
|
828
|
+
mint.restore({ outputs: outputs.slice(index, index + 300) }),
|
|
829
|
+
Math.min(15_000, Math.max(1, deadline - Date.now())),
|
|
830
|
+
);
|
|
831
|
+
restored.push(...response.outputs);
|
|
832
|
+
} catch (error) {
|
|
833
|
+
return markMintFailure(mintUrl, error);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return restored;
|
|
837
|
+
},
|
|
838
|
+
hasSavedOutputs: (operation) =>
|
|
839
|
+
internals.receiveOperationService.hasSavedOutputs(operation),
|
|
840
|
+
rollBack: async (operation, reason) => {
|
|
841
|
+
await internals.receiveOperationService.markAsRolledBack(operation, reason);
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
return reconcileExecutingReceives(source);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
interface RecoveryPhaseProgress {
|
|
848
|
+
phase: string;
|
|
849
|
+
failedMintQuotes: number;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Run the wallet recovery sweeps in order, reporting phase changes.
|
|
854
|
+
*
|
|
855
|
+
* Expired mint quotes are settled first: quotes their mint confirms as unpaid
|
|
856
|
+
* are failed locally so `recoverPendingMintOperations()` skips them, while
|
|
857
|
+
* paid/issued and unreachable-mint quotes stay pending for the sweep.
|
|
858
|
+
*/
|
|
859
|
+
async function runWalletRecovery(
|
|
860
|
+
coco: Manager,
|
|
861
|
+
onProgress: (progress: RecoveryPhaseProgress) => void,
|
|
862
|
+
receiveOperationIds?: string[],
|
|
863
|
+
): Promise<void> {
|
|
864
|
+
surfacingRecoveryProgress = true;
|
|
865
|
+
let failedMintQuotes = 0;
|
|
866
|
+
try {
|
|
867
|
+
onProgress({ phase: "Settling expired mint quotes", failedMintQuotes });
|
|
868
|
+
const settlement = await settleExpiredMintQuotes(
|
|
869
|
+
{
|
|
870
|
+
ops: coco.ops,
|
|
871
|
+
mintOperationService: (
|
|
872
|
+
coco as unknown as {
|
|
873
|
+
mintOperationService: MintOperationServiceCleanup;
|
|
874
|
+
}
|
|
875
|
+
).mintOperationService,
|
|
876
|
+
},
|
|
877
|
+
Date.now(),
|
|
878
|
+
);
|
|
879
|
+
failedMintQuotes = settlement.failed;
|
|
880
|
+
if (settlement.leftForRecovery > 0 || settlement.unobserved > 0) {
|
|
881
|
+
startupProgress(
|
|
882
|
+
`Expired mint quotes: ${settlement.failed} failed locally, ` +
|
|
883
|
+
`${settlement.leftForRecovery} paid/issued (kept for recovery), ` +
|
|
884
|
+
`${settlement.unobserved} unverifiable (kept pending).`,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
onProgress({ phase: "Settled expired mint quotes", failedMintQuotes });
|
|
888
|
+
|
|
889
|
+
onProgress({ phase: "Send recovery", failedMintQuotes });
|
|
890
|
+
await coco.ops.send.recovery.run();
|
|
891
|
+
|
|
892
|
+
onProgress({ phase: "Melt recovery", failedMintQuotes });
|
|
893
|
+
await coco.ops.melt.recovery.run();
|
|
894
|
+
|
|
895
|
+
onProgress({ phase: "Receive recovery", failedMintQuotes });
|
|
896
|
+
if (receiveOperationIds) {
|
|
897
|
+
// The pre-check already classified every executing receive by unique
|
|
898
|
+
// input set. Recover only the conclusive retained operations; unresolved
|
|
899
|
+
// groups stay untouched instead of falling back to Coco 1's expensive
|
|
900
|
+
// per-row sweep on this startup.
|
|
901
|
+
for (const operationId of receiveOperationIds) {
|
|
902
|
+
try {
|
|
903
|
+
await withTimeout(coco.ops.receive.refresh(operationId), 15_000);
|
|
904
|
+
} catch (error) {
|
|
905
|
+
logger.warn("Targeted receive recovery did not complete", {
|
|
906
|
+
operationId,
|
|
907
|
+
error: error instanceof Error ? error.message : String(error),
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
} else {
|
|
912
|
+
await coco.ops.receive.recovery.run();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
onProgress({ phase: "Mint recovery", failedMintQuotes });
|
|
916
|
+
await coco.recoverPendingMintOperations();
|
|
917
|
+
|
|
918
|
+
onProgress({ phase: "done", failedMintQuotes });
|
|
919
|
+
} finally {
|
|
920
|
+
surfacingRecoveryProgress = false;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
531
924
|
export async function createCocoClient(
|
|
532
925
|
options: CreateCocoClientOptions = {},
|
|
533
926
|
): Promise<CocodClient> {
|
|
@@ -544,14 +937,14 @@ export async function createCocoClient(
|
|
|
544
937
|
const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL;
|
|
545
938
|
const npcAddressDomain = new URL(npcBaseUrl).host;
|
|
546
939
|
|
|
547
|
-
await assertLegacyCocodNotRunning({
|
|
548
|
-
socketPath: legacySocket,
|
|
549
|
-
pidFilePath: legacyPidFile,
|
|
550
|
-
});
|
|
940
|
+
await assertLegacyCocodNotRunning({ socketPath: legacySocket });
|
|
551
941
|
// The canonical wallet directory is created by initialization/migration.
|
|
552
942
|
// Keep a legacy PID claim as an exclusion fence for old cocod binaries.
|
|
553
943
|
mkdirSync(dirname(legacyPidFile), { recursive: true, mode: 0o700 });
|
|
554
|
-
const releaseWalletPidClaim = claimPidFile({
|
|
944
|
+
const releaseWalletPidClaim = claimPidFile({
|
|
945
|
+
pidFilePath: walletPidFile,
|
|
946
|
+
label: "routstrd wallet lock",
|
|
947
|
+
});
|
|
555
948
|
let releaseLegacyPidClaim: () => void;
|
|
556
949
|
try {
|
|
557
950
|
releaseLegacyPidClaim = claimLegacyCocodPidFile({
|
|
@@ -563,9 +956,26 @@ export async function createCocoClient(
|
|
|
563
956
|
}
|
|
564
957
|
|
|
565
958
|
let database: Database | undefined;
|
|
566
|
-
let coco:
|
|
959
|
+
let coco: Manager | undefined;
|
|
960
|
+
let findFinalizedReceiveSibling: (
|
|
961
|
+
operation: ReceiveOperation | null,
|
|
962
|
+
) => Promise<string | null> = async () => null;
|
|
567
963
|
let walletConfig = loadConfig(configFile);
|
|
568
964
|
|
|
965
|
+
let recoveryPhase = "queued";
|
|
966
|
+
let recoveryFailedMintQuotes = 0;
|
|
967
|
+
let recoveryDone = false;
|
|
968
|
+
let recoveryError: string | undefined;
|
|
969
|
+
const recoveryCounts = {
|
|
970
|
+
pendingSends: 0,
|
|
971
|
+
inflightProofs: 0,
|
|
972
|
+
pendingMints: 0,
|
|
973
|
+
};
|
|
974
|
+
let recoveryResolve: (() => void) | undefined;
|
|
975
|
+
const recoveryPromise = new Promise<void>((resolve) => {
|
|
976
|
+
recoveryResolve = resolve;
|
|
977
|
+
});
|
|
978
|
+
|
|
569
979
|
try {
|
|
570
980
|
startupProgress("Opening Cashu wallet database...");
|
|
571
981
|
|
|
@@ -576,29 +986,125 @@ export async function createCocoClient(
|
|
|
576
986
|
database = new Database(dbPath);
|
|
577
987
|
const repo = new SqliteRepositories({ database });
|
|
578
988
|
await repo.init();
|
|
989
|
+
initReceiveDedupSchema(database);
|
|
990
|
+
const interruptedReservations = clearInterruptedReceiveReservations(database);
|
|
991
|
+
if (interruptedReservations > 0) {
|
|
992
|
+
logger.warn("Cleared interrupted receive reservations with no Coco operation", {
|
|
993
|
+
count: interruptedReservations,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
579
996
|
|
|
580
997
|
const [pendingSends, inflightProofs, pendingMints] = await Promise.all([
|
|
581
998
|
repo.sendOperationRepository.getPending(),
|
|
582
999
|
repo.proofRepository.getInflightProofs(),
|
|
583
1000
|
repo.mintOperationRepository.getPending(),
|
|
584
1001
|
]);
|
|
1002
|
+
recoveryCounts.pendingSends = pendingSends.length;
|
|
1003
|
+
recoveryCounts.inflightProofs = inflightProofs.length;
|
|
1004
|
+
recoveryCounts.pendingMints = pendingMints.length;
|
|
585
1005
|
const recoveryCount =
|
|
586
|
-
pendingSends
|
|
1006
|
+
recoveryCounts.pendingSends +
|
|
1007
|
+
recoveryCounts.inflightProofs +
|
|
1008
|
+
recoveryCounts.pendingMints;
|
|
1009
|
+
|
|
587
1010
|
if (recoveryCount > 0) {
|
|
588
1011
|
startupProgress(
|
|
589
|
-
`Recovering wallet state: ${pendingSends
|
|
590
|
-
`${inflightProofs
|
|
591
|
-
"This may take a few minutes while Cashu mints are contacted.",
|
|
1012
|
+
`Recovering wallet state in background: ${recoveryCounts.pendingSends} pending sends, ` +
|
|
1013
|
+
`${recoveryCounts.inflightProofs} in-flight proofs, ${recoveryCounts.pendingMints} pending mints.`,
|
|
592
1014
|
);
|
|
593
1015
|
} else {
|
|
594
1016
|
startupProgress("Initializing Cashu wallet...");
|
|
595
1017
|
}
|
|
596
1018
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1019
|
+
// Construct Coco so the pre-recovery checker can reuse its mint adapter,
|
|
1020
|
+
// but do not enable watchers/processors until the backup and cleanup finish.
|
|
1021
|
+
coco = constructCocoManager(repo, seed);
|
|
1022
|
+
|
|
1023
|
+
const executingReceives = await repo.receiveOperationRepository.getByState("executing");
|
|
1024
|
+
let receiveRecoveryOperationIds: string[] | undefined;
|
|
1025
|
+
if (executingReceives.length > 0) {
|
|
1026
|
+
startupProgress(
|
|
1027
|
+
`Checking ${executingReceives.length} unfinished Cashu receive operation(s) for duplicates...`,
|
|
1028
|
+
);
|
|
1029
|
+
const recordedBackup = getReceiveReconcileBackup(database);
|
|
1030
|
+
if (!recordedBackup || !existsSync(recordedBackup)) {
|
|
1031
|
+
const backupPath = `${dbPath}.pre-receive-reconcile-${Date.now()}`;
|
|
1032
|
+
database.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`);
|
|
1033
|
+
setReceiveReconcileBackup(database, backupPath);
|
|
1034
|
+
startupProgress(`Created wallet backup before receive cleanup: ${backupPath}`);
|
|
1035
|
+
}
|
|
1036
|
+
const receiveReconcile = await reconcileDuplicateReceiveOperations(coco, repo);
|
|
1037
|
+
receiveRecoveryOperationIds = receiveReconcile.recoveryOperationIds;
|
|
1038
|
+
startupProgress(
|
|
1039
|
+
`Receive cleanup: ${receiveReconcile.executing} operation(s), ` +
|
|
1040
|
+
`${receiveReconcile.uniqueGroups} unique input set(s), ` +
|
|
1041
|
+
`${receiveReconcile.rolledBack} stale duplicate(s) retired, ` +
|
|
1042
|
+
`${receiveReconcile.unresolved} unresolved.`,
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
await enableCocoManager(coco);
|
|
1047
|
+
const openDatabase = database;
|
|
1048
|
+
|
|
1049
|
+
findFinalizedReceiveSibling = async (
|
|
1050
|
+
operation: ReceiveOperation | null,
|
|
1051
|
+
): Promise<string | null> => {
|
|
1052
|
+
if (!operation) return null;
|
|
1053
|
+
const fingerprint = receiveInputFingerprint(operation);
|
|
1054
|
+
const siblings = await repo.receiveOperationRepository.getByMintUrl(operation.mintUrl);
|
|
1055
|
+
return (
|
|
1056
|
+
siblings.find(
|
|
1057
|
+
(candidate) =>
|
|
1058
|
+
candidate.state === "finalized" &&
|
|
1059
|
+
receiveInputFingerprint(candidate) === fingerprint,
|
|
1060
|
+
)?.id ?? null
|
|
1061
|
+
);
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
const syncReceiveReservations = async (): Promise<void> => {
|
|
1065
|
+
for (const reservation of listProcessingReceiveTokens(openDatabase)) {
|
|
1066
|
+
if (!reservation.operationId) continue;
|
|
1067
|
+
try {
|
|
1068
|
+
const operation = await coco!.ops.receive.get(reservation.operationId);
|
|
1069
|
+
if (operation?.state === "finalized") {
|
|
1070
|
+
updateReceiveToken(openDatabase, reservation.tokenHash, {
|
|
1071
|
+
state: "succeeded",
|
|
1072
|
+
operationId: operation.id,
|
|
1073
|
+
});
|
|
1074
|
+
} else if (operation?.state === "rolled_back") {
|
|
1075
|
+
const finalizedSibling = await findFinalizedReceiveSibling(operation);
|
|
1076
|
+
updateReceiveToken(openDatabase, reservation.tokenHash, finalizedSibling
|
|
1077
|
+
? {
|
|
1078
|
+
state: "succeeded",
|
|
1079
|
+
operationId: finalizedSibling,
|
|
1080
|
+
}
|
|
1081
|
+
: {
|
|
1082
|
+
state: "failed",
|
|
1083
|
+
operationId: operation.id,
|
|
1084
|
+
error: operation.error || "Token receive was rolled back",
|
|
1085
|
+
});
|
|
1086
|
+
} else if (operation?.state === "prepared") {
|
|
1087
|
+
// A crash before execute had no mint side effect. Cancel the stale
|
|
1088
|
+
// prepared operation and permit a fresh exact-token attempt.
|
|
1089
|
+
await coco!.ops.receive.cancel(
|
|
1090
|
+
operation.id,
|
|
1091
|
+
"Cancelled interrupted receive before execution",
|
|
1092
|
+
);
|
|
1093
|
+
deleteReceiveTokenReservation(openDatabase, reservation.tokenHash);
|
|
1094
|
+
} else if (!operation) {
|
|
1095
|
+
deleteReceiveTokenReservation(openDatabase, reservation.tokenHash);
|
|
1096
|
+
}
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
// One damaged/stale reservation must never block daemon startup or
|
|
1099
|
+
// make every wallet write fail after recovery.
|
|
1100
|
+
logger.warn("Could not reconcile receive token reservation", {
|
|
1101
|
+
operationId: reservation.operationId,
|
|
1102
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
await syncReceiveReservations();
|
|
602
1108
|
|
|
603
1109
|
const trustedMints = await coco.mint.getAllTrustedMints();
|
|
604
1110
|
const configuredDefault = walletConfig.defaultMintUrl;
|
|
@@ -640,6 +1146,34 @@ export async function createCocoClient(
|
|
|
640
1146
|
}
|
|
641
1147
|
|
|
642
1148
|
startupProgress("Cashu wallet ready.");
|
|
1149
|
+
|
|
1150
|
+
// Recovery runs in the background so the daemon can serve wallet reads
|
|
1151
|
+
// immediately. Value-moving operations await the same promise below.
|
|
1152
|
+
runWalletRecovery(
|
|
1153
|
+
coco,
|
|
1154
|
+
(progress) => {
|
|
1155
|
+
recoveryPhase = progress.phase;
|
|
1156
|
+
recoveryFailedMintQuotes = progress.failedMintQuotes;
|
|
1157
|
+
if (progress.phase !== "done") {
|
|
1158
|
+
startupProgress(`Wallet recovery: ${progress.phase}...`);
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
receiveRecoveryOperationIds,
|
|
1162
|
+
)
|
|
1163
|
+
.then(async () => {
|
|
1164
|
+
await syncReceiveReservations();
|
|
1165
|
+
recoveryDone = true;
|
|
1166
|
+
recoveryPhase = "done";
|
|
1167
|
+
recoveryResolve?.();
|
|
1168
|
+
startupProgress("Wallet recovery complete.");
|
|
1169
|
+
})
|
|
1170
|
+
.catch((error) => {
|
|
1171
|
+
recoveryDone = true;
|
|
1172
|
+
recoveryPhase = "error";
|
|
1173
|
+
recoveryError = error instanceof Error ? error.message : String(error);
|
|
1174
|
+
recoveryResolve?.();
|
|
1175
|
+
startupProgress(`Wallet recovery failed: ${recoveryError}`);
|
|
1176
|
+
});
|
|
643
1177
|
} catch (error) {
|
|
644
1178
|
database?.close();
|
|
645
1179
|
releaseLegacyPidClaim();
|
|
@@ -660,6 +1194,18 @@ export async function createCocoClient(
|
|
|
660
1194
|
};
|
|
661
1195
|
|
|
662
1196
|
let disposed = false;
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Block a value-moving operation until background recovery has settled.
|
|
1200
|
+
* Reads stay ungated so the daemon can report balances/status immediately.
|
|
1201
|
+
*/
|
|
1202
|
+
const waitForRecovery = async (): Promise<void> => {
|
|
1203
|
+
if (!recoveryDone) await recoveryPromise;
|
|
1204
|
+
if (recoveryError) {
|
|
1205
|
+
throw new Error(`Wallet is not ready: ${recoveryError}`);
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
|
|
663
1209
|
return {
|
|
664
1210
|
async ping(): Promise<boolean> {
|
|
665
1211
|
try {
|
|
@@ -671,6 +1217,8 @@ export async function createCocoClient(
|
|
|
671
1217
|
},
|
|
672
1218
|
|
|
673
1219
|
async getStatus(): Promise<CocodState> {
|
|
1220
|
+
if (recoveryError) return "ERROR";
|
|
1221
|
+
if (!recoveryDone) return "RECOVERING";
|
|
674
1222
|
try {
|
|
675
1223
|
await coco.wallet.balances.total();
|
|
676
1224
|
return "UNLOCKED";
|
|
@@ -679,6 +1227,18 @@ export async function createCocoClient(
|
|
|
679
1227
|
}
|
|
680
1228
|
},
|
|
681
1229
|
|
|
1230
|
+
async getRecoveryProgress(): Promise<WalletRecoveryProgress> {
|
|
1231
|
+
return {
|
|
1232
|
+
state: recoveryError ? "ERROR" : recoveryDone ? "UNLOCKED" : "RECOVERING",
|
|
1233
|
+
phase: recoveryPhase,
|
|
1234
|
+
pendingSends: recoveryCounts.pendingSends,
|
|
1235
|
+
inflightProofs: recoveryCounts.inflightProofs,
|
|
1236
|
+
pendingMints: recoveryCounts.pendingMints,
|
|
1237
|
+
failedMintQuotes: recoveryFailedMintQuotes,
|
|
1238
|
+
...(recoveryError ? { error: recoveryError } : {}),
|
|
1239
|
+
};
|
|
1240
|
+
},
|
|
1241
|
+
|
|
682
1242
|
async unlock(_passphrase: string): Promise<string> {
|
|
683
1243
|
// coco-core does not support passphrase locking.
|
|
684
1244
|
// Wallet access is controlled by ~/.routstrd/wallet/config.json.
|
|
@@ -696,11 +1256,135 @@ export async function createCocoClient(
|
|
|
696
1256
|
},
|
|
697
1257
|
|
|
698
1258
|
async receiveCashu(token: string): Promise<string> {
|
|
699
|
-
|
|
700
|
-
|
|
1259
|
+
const reservation = reserveReceiveToken(database, token);
|
|
1260
|
+
if (!reservation.acquired) {
|
|
1261
|
+
if (reservation.existing?.state === "succeeded") {
|
|
1262
|
+
return "Token already received successfully";
|
|
1263
|
+
}
|
|
1264
|
+
if (
|
|
1265
|
+
reservation.existing?.state === "processing" &&
|
|
1266
|
+
reservation.existing.operationId
|
|
1267
|
+
) {
|
|
1268
|
+
// A prior request may have stopped after the mint call became
|
|
1269
|
+
// uncertain. Re-drive that one Coco operation instead of creating a
|
|
1270
|
+
// duplicate. Refresh is idempotent and uses its stored output data.
|
|
1271
|
+
try {
|
|
1272
|
+
const operation = await withTimeout(
|
|
1273
|
+
coco.ops.receive.refresh(reservation.existing.operationId),
|
|
1274
|
+
15_000,
|
|
1275
|
+
);
|
|
1276
|
+
if (operation.state === "finalized") {
|
|
1277
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1278
|
+
state: "succeeded",
|
|
1279
|
+
operationId: operation.id,
|
|
1280
|
+
});
|
|
1281
|
+
return "Token received successfully";
|
|
1282
|
+
}
|
|
1283
|
+
if (operation.state === "rolled_back") {
|
|
1284
|
+
const finalizedSibling = await findFinalizedReceiveSibling(operation);
|
|
1285
|
+
if (finalizedSibling) {
|
|
1286
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1287
|
+
state: "succeeded",
|
|
1288
|
+
operationId: finalizedSibling,
|
|
1289
|
+
});
|
|
1290
|
+
return "Token already received successfully";
|
|
1291
|
+
}
|
|
1292
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1293
|
+
state: "failed",
|
|
1294
|
+
operationId: operation.id,
|
|
1295
|
+
error: operation.error || "Token receive was rolled back",
|
|
1296
|
+
});
|
|
1297
|
+
throw new Error(operation.error || "Token receive was rolled back");
|
|
1298
|
+
}
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
const latest = await coco.ops.receive.get(reservation.existing.operationId);
|
|
1301
|
+
if (latest?.state === "finalized") {
|
|
1302
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1303
|
+
state: "succeeded",
|
|
1304
|
+
operationId: latest.id,
|
|
1305
|
+
});
|
|
1306
|
+
return "Token received successfully";
|
|
1307
|
+
}
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
throw new Error("Token receive is still unresolved");
|
|
1311
|
+
}
|
|
1312
|
+
if (reservation.existing?.state === "processing") {
|
|
1313
|
+
throw new Error("Token receive is already in progress");
|
|
1314
|
+
}
|
|
1315
|
+
throw new Error(reservation.existing?.error || "Token receive previously failed");
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
let preparedOperationId: string | undefined;
|
|
1319
|
+
try {
|
|
1320
|
+
await waitForRecovery();
|
|
1321
|
+
const prepared = await coco.ops.receive.prepare({ token });
|
|
1322
|
+
preparedOperationId = prepared.id;
|
|
1323
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1324
|
+
state: "processing",
|
|
1325
|
+
operationId: prepared.id,
|
|
1326
|
+
});
|
|
1327
|
+
await coco.ops.receive.execute(prepared.id);
|
|
1328
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1329
|
+
state: "succeeded",
|
|
1330
|
+
operationId: prepared.id,
|
|
1331
|
+
});
|
|
1332
|
+
return "Token received successfully";
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1335
|
+
if (preparedOperationId) {
|
|
1336
|
+
let latest: Awaited<ReturnType<typeof coco.ops.receive.get>> = null;
|
|
1337
|
+
try {
|
|
1338
|
+
latest = await coco.ops.receive.get(preparedOperationId);
|
|
1339
|
+
} catch (lookupError) {
|
|
1340
|
+
logger.warn("Could not inspect failed receive operation", {
|
|
1341
|
+
operationId: preparedOperationId,
|
|
1342
|
+
error:
|
|
1343
|
+
lookupError instanceof Error ? lookupError.message : String(lookupError),
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
if (latest?.state === "finalized") {
|
|
1347
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1348
|
+
state: "succeeded",
|
|
1349
|
+
operationId: preparedOperationId,
|
|
1350
|
+
});
|
|
1351
|
+
return "Token received successfully";
|
|
1352
|
+
}
|
|
1353
|
+
if (latest?.state === "executing") {
|
|
1354
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1355
|
+
state: "processing",
|
|
1356
|
+
operationId: preparedOperationId,
|
|
1357
|
+
error: message,
|
|
1358
|
+
});
|
|
1359
|
+
} else if (latest?.state === "rolled_back") {
|
|
1360
|
+
const finalizedSibling = await findFinalizedReceiveSibling(latest);
|
|
1361
|
+
if (finalizedSibling) {
|
|
1362
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1363
|
+
state: "succeeded",
|
|
1364
|
+
operationId: finalizedSibling,
|
|
1365
|
+
});
|
|
1366
|
+
return "Token already received successfully";
|
|
1367
|
+
}
|
|
1368
|
+
updateReceiveToken(database, reservation.tokenHash, {
|
|
1369
|
+
state: "failed",
|
|
1370
|
+
operationId: preparedOperationId,
|
|
1371
|
+
error: latest.error || message,
|
|
1372
|
+
});
|
|
1373
|
+
} else {
|
|
1374
|
+
// A prepared or missing operation had no known mint side effect.
|
|
1375
|
+
deleteReceiveTokenReservation(database, reservation.tokenHash);
|
|
1376
|
+
}
|
|
1377
|
+
} else {
|
|
1378
|
+
// Decode/validation failed before coco created an operation. Do not
|
|
1379
|
+
// permanently reserve malformed input or transient mint-fetch errors.
|
|
1380
|
+
releaseReceiveToken(database, reservation.tokenHash);
|
|
1381
|
+
}
|
|
1382
|
+
throw error;
|
|
1383
|
+
}
|
|
701
1384
|
},
|
|
702
1385
|
|
|
703
1386
|
async receiveBolt11(amount: number, mintUrl?: string): Promise<string> {
|
|
1387
|
+
await waitForRecovery();
|
|
704
1388
|
const targetMint = mintUrl
|
|
705
1389
|
? normalizeMintUrl(mintUrl)
|
|
706
1390
|
: walletConfig.defaultMintUrl;
|
|
@@ -719,6 +1403,7 @@ export async function createCocoClient(
|
|
|
719
1403
|
},
|
|
720
1404
|
|
|
721
1405
|
async sendCashu(amount: number, mintUrl?: string): Promise<string> {
|
|
1406
|
+
await waitForRecovery();
|
|
722
1407
|
const targetMint = mintUrl
|
|
723
1408
|
? normalizeMintUrl(mintUrl)
|
|
724
1409
|
: walletConfig.defaultMintUrl;
|
|
@@ -734,6 +1419,7 @@ export async function createCocoClient(
|
|
|
734
1419
|
},
|
|
735
1420
|
|
|
736
1421
|
async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
|
|
1422
|
+
await waitForRecovery();
|
|
737
1423
|
const targetMint = mintUrl
|
|
738
1424
|
? normalizeMintUrl(mintUrl)
|
|
739
1425
|
: walletConfig.defaultMintUrl;
|
|
@@ -755,6 +1441,7 @@ export async function createCocoClient(
|
|
|
755
1441
|
},
|
|
756
1442
|
|
|
757
1443
|
async addMint(url: string): Promise<string> {
|
|
1444
|
+
await waitForRecovery();
|
|
758
1445
|
const mintUrl = normalizeMintUrl(url);
|
|
759
1446
|
await coco.mint.addMint(mintUrl, { trusted: true });
|
|
760
1447
|
return `Mint ${mintUrl} added successfully`;
|
|
@@ -769,6 +1456,7 @@ export async function createCocoClient(
|
|
|
769
1456
|
},
|
|
770
1457
|
|
|
771
1458
|
async setDefaultMint(url: string): Promise<string> {
|
|
1459
|
+
await waitForRecovery();
|
|
772
1460
|
const mintUrl = normalizeMintUrl(url);
|
|
773
1461
|
const trustedMints = await coco.mint.getAllTrustedMints();
|
|
774
1462
|
if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
|
|
@@ -784,6 +1472,9 @@ export async function createCocoClient(
|
|
|
784
1472
|
if (disposed) return;
|
|
785
1473
|
disposed = true;
|
|
786
1474
|
try {
|
|
1475
|
+
// Let any in-flight recovery settle before closing the database from
|
|
1476
|
+
// underneath it. The recovery promise resolves on success or failure.
|
|
1477
|
+
await recoveryPromise;
|
|
787
1478
|
await coco.dispose();
|
|
788
1479
|
} finally {
|
|
789
1480
|
try {
|
|
@@ -817,6 +1508,7 @@ export async function createCocoClient(
|
|
|
817
1508
|
username: string,
|
|
818
1509
|
confirm?: boolean,
|
|
819
1510
|
): Promise<NpcUsernameResult> {
|
|
1511
|
+
await waitForRecovery();
|
|
820
1512
|
const result = await npcApi().setUsername(username, confirm === true);
|
|
821
1513
|
if (result.success) {
|
|
822
1514
|
return { success: true };
|
|
@@ -829,7 +1521,108 @@ export async function createCocoClient(
|
|
|
829
1521
|
},
|
|
830
1522
|
|
|
831
1523
|
async syncNpc(): Promise<void> {
|
|
1524
|
+
await waitForRecovery();
|
|
832
1525
|
await npcApi().sync();
|
|
833
1526
|
},
|
|
1527
|
+
|
|
1528
|
+
async cleanupStuckOperations(
|
|
1529
|
+
options: WalletCleanupOptions = {},
|
|
1530
|
+
): Promise<WalletCleanupResult> {
|
|
1531
|
+
await waitForRecovery();
|
|
1532
|
+
const minAgeMs = options.minAgeMs ?? 7 * 24 * 60 * 60 * 1000;
|
|
1533
|
+
const dryRun = options.dryRun === true;
|
|
1534
|
+
const nowMs = Date.now();
|
|
1535
|
+
|
|
1536
|
+
const [pendingMints, inFlightSends, preparedMelts] = await Promise.all([
|
|
1537
|
+
coco.ops.mint.listPending(),
|
|
1538
|
+
coco.ops.send.listInFlight(),
|
|
1539
|
+
coco.ops.melt.listPrepared(),
|
|
1540
|
+
]);
|
|
1541
|
+
|
|
1542
|
+
const filteredMints = options.mintUrl
|
|
1543
|
+
? pendingMints.filter((op) => op.mintUrl === options.mintUrl)
|
|
1544
|
+
: pendingMints;
|
|
1545
|
+
const filteredSends = options.mintUrl
|
|
1546
|
+
? inFlightSends.filter((op) => op.mintUrl === options.mintUrl)
|
|
1547
|
+
: inFlightSends;
|
|
1548
|
+
const filteredMelts = options.mintUrl
|
|
1549
|
+
? preparedMelts.filter((op) => op.mintUrl === options.mintUrl)
|
|
1550
|
+
: preparedMelts;
|
|
1551
|
+
|
|
1552
|
+
const selection = selectCleanupOperations({
|
|
1553
|
+
mints: filteredMints,
|
|
1554
|
+
sends: filteredSends,
|
|
1555
|
+
melts: filteredMelts,
|
|
1556
|
+
nowMs,
|
|
1557
|
+
minAgeMs,
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
const errors: WalletCleanupResult["errors"] = [];
|
|
1561
|
+
|
|
1562
|
+
if (!dryRun) {
|
|
1563
|
+
const mintService = (
|
|
1564
|
+
coco as unknown as {
|
|
1565
|
+
mintOperationService: MintOperationServiceCleanup;
|
|
1566
|
+
}
|
|
1567
|
+
).mintOperationService;
|
|
1568
|
+
|
|
1569
|
+
for (const op of selection.mintsToFail) {
|
|
1570
|
+
try {
|
|
1571
|
+
await mintService.failPendingOperation(
|
|
1572
|
+
{ id: op.id },
|
|
1573
|
+
{
|
|
1574
|
+
reason: "Expired unpaid mint quote cleaned up by routstrd",
|
|
1575
|
+
retryable: false,
|
|
1576
|
+
observedAt: nowMs,
|
|
1577
|
+
},
|
|
1578
|
+
);
|
|
1579
|
+
} catch (error) {
|
|
1580
|
+
errors.push({
|
|
1581
|
+
operationId: op.id,
|
|
1582
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
for (const op of selection.sendsToReclaim) {
|
|
1588
|
+
try {
|
|
1589
|
+
await coco.ops.send.reclaim(op.id);
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
errors.push({
|
|
1592
|
+
operationId: op.id,
|
|
1593
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
for (const op of selection.meltsToCancel) {
|
|
1599
|
+
try {
|
|
1600
|
+
await coco.ops.melt.cancel(op.id, "Cancelled by wallet cleanup");
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
errors.push({
|
|
1603
|
+
operationId: op.id,
|
|
1604
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const actedOn =
|
|
1611
|
+
selection.mintsToFail.length +
|
|
1612
|
+
selection.sendsToReclaim.length +
|
|
1613
|
+
selection.meltsToCancel.length;
|
|
1614
|
+
const skipped =
|
|
1615
|
+
filteredMints.length + filteredSends.length + filteredMelts.length -
|
|
1616
|
+
actedOn;
|
|
1617
|
+
|
|
1618
|
+
return {
|
|
1619
|
+
dryRun,
|
|
1620
|
+
failedMintQuotes: selection.mintsToFail.length,
|
|
1621
|
+
reclaimedSends: selection.sendsToReclaim.length,
|
|
1622
|
+
cancelledMelts: selection.meltsToCancel.length,
|
|
1623
|
+
skipped,
|
|
1624
|
+
errors,
|
|
1625
|
+
};
|
|
1626
|
+
},
|
|
834
1627
|
};
|
|
835
1628
|
}
|