routstrd 0.4.0 → 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.
@@ -1,10 +1,11 @@
1
1
  import {
2
- initializeCoco,
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 the legacy cocod daemon owns its Unix socket.
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
- // Recheck after the failed probe in case cocod started concurrently.
285
- const newlyRunningPid = getRunningLegacyPid();
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 wallet process lock at ${pidFilePath}. ` +
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 wallet process lock at ${pidFilePath}. ` +
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 wallet process lock at ${pidFilePath}. ` +
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
- return () => {
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 wallet process lock at ${pidFilePath}:`,
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({ pidFilePath: walletPidFile });
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: Awaited<ReturnType<typeof initializeCoco>> | undefined;
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.length + inflightProofs.length + pendingMints.length;
1006
+ recoveryCounts.pendingSends +
1007
+ recoveryCounts.inflightProofs +
1008
+ recoveryCounts.pendingMints;
1009
+
587
1010
  if (recoveryCount > 0) {
588
1011
  startupProgress(
589
- `Recovering wallet state: ${pendingSends.length} pending sends, ` +
590
- `${inflightProofs.length} in-flight proofs, ${pendingMints.length} pending mints. ` +
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
- coco = await initializeCoco({
598
- repo,
599
- seedGetter: async () => seed,
600
- logger: createCocoLogger(),
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
- await coco.wallet.receive(token);
700
- return "Token received successfully";
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
  }