routstrd 0.4.10 → 0.4.12

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,3 +1,5 @@
1
+ import { withTimeout as withRequestTimeout } from "../../utils/with-timeout";
2
+ import { recoveryKey, trackRecovery, drainRecoveryWork, waitForRecoveryWork, createRecoveryDisposer, type RecoveryWork } from "./recovery-work";
1
3
  import {
2
4
  Manager,
3
5
  OperationInProgressError,
@@ -28,15 +30,27 @@ import {
28
30
  import { dirname, join } from "path";
29
31
  import { mnemonicToSeedSync } from "@scure/bip39";
30
32
  import type {
31
- CocodClient,
32
- CocodState,
33
+ WalletClient,
34
+ WalletRuntimeState,
33
35
  NpcAddress,
34
36
  NpcUsernameResult,
35
37
  WalletCleanupOptions,
36
38
  WalletCleanupResult,
37
39
  WalletRecoveryProgress,
38
- } from "./cocod-client";
39
- import { selectCleanupOperations } from "./cleanup";
40
+ } from "./wallet-client";
41
+ import { selectCleanupOperations, summarizeMintCleanup } from "./cleanup";
42
+ import {
43
+ classifyMintQuoteObservation,
44
+ selectMintQuotesForRecovery,
45
+ type MintQuoteRecoveryCandidate,
46
+ } from "./mint-quote-recovery";
47
+ import {
48
+ collectStuckOperations,
49
+ probeMintReachability,
50
+ runTargetedRecovery,
51
+ type SendRecoveryService,
52
+ type StuckOperation,
53
+ } from "./recovery-probe";
40
54
  import {
41
55
  clearInterruptedReceiveReservations,
42
56
  deleteReceiveTokenReservation,
@@ -58,6 +72,9 @@ import {
58
72
  walletDir as defaultWalletDir,
59
73
  walletPidPath as defaultWalletPidPath,
60
74
  } from "./paths";
75
+ import { DEFAULT_MINT_URL, seedTrustedMints } from "./trusted-mints";
76
+
77
+ export { DEFAULT_MINT_URL, DEFAULT_TRUSTED_MINT_URLS } from "./trusted-mints";
61
78
 
62
79
  const NPC_DEFAULT_BASE_URL = "https://npubx.cash";
63
80
 
@@ -117,7 +134,6 @@ interface CocodConfig {
117
134
  }
118
135
 
119
136
  const STARTUP_LOG_PREFIX = "[routstrd:start]";
120
- export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org";
121
137
 
122
138
  function startupProgress(message: string): void {
123
139
  logger.info(message);
@@ -577,6 +593,96 @@ interface MintOperationServiceCleanup {
577
593
  observePendingOperation(
578
594
  operationId: string,
579
595
  ): Promise<{ category: "waiting" | "ready" | "completed" | "terminal" }>;
596
+ /**
597
+ * Acquire coco's per-operation lock for `operationId` and return its release
598
+ * function. coco's execute/finalize/recover paths take the same lock, so
599
+ * holding it across a read-check-write makes the transition atomic with
600
+ * respect to them.
601
+ */
602
+ acquireOperationLock(operationId: string): Promise<() => void>;
603
+ /** Reload a mint operation row, or null when it no longer exists. */
604
+ getOperation(operationId: string): Promise<Record<string, unknown> | null>;
605
+ /**
606
+ * Put a terminally failed operation back into `pending`.
607
+ *
608
+ * coco keeps this private, and it spreads whatever it is handed into the row
609
+ * it writes. The sqlite repository rewrites every column, so callers MUST
610
+ * pass a freshly reloaded full row: a partial object such as `{ id }` would
611
+ * erase `outputDataJson` and make the paid sats unrecoverable.
612
+ */
613
+ transitionToPending(
614
+ op: Record<string, unknown>,
615
+ error?: string,
616
+ ): Promise<unknown>;
617
+ }
618
+
619
+ /**
620
+ * Re-open a terminally failed mint operation so recovery can retry it.
621
+ *
622
+ * Two details make this safe:
623
+ *
624
+ * - The persisted row is reloaded and handed to coco in full. coco spreads
625
+ * whatever it is given and the sqlite repository rewrites every column, so a
626
+ * partial object would be rejected by the NOT NULL schema or, on a more
627
+ * permissive adapter, erase the stored outputs.
628
+ * - The read-check-write runs under coco's per-operation lock, the same lock
629
+ * coco's execute/finalize/recover paths take. Reloading alone only narrows
630
+ * the race: without the lock two concurrent recoveries could both see
631
+ * `failed` and the slower one would clobber a newer state.
632
+ *
633
+ * The lock is fail-fast rather than wait-based: coco's `OperationIdLock.acquire`
634
+ * throws `OperationInProgressError` when the id is already locked. So either
635
+ * this helper holds the lock - and coco's own execute/finalize/recover paths
636
+ * cannot interleave, because acquiring would throw for them too - or it throws
637
+ * and writes nothing. It never waits, and never writes without the lock, which
638
+ * is why a stale `failed` snapshot cannot clobber a newer state.
639
+ *
640
+ * Scope of that lock, in this coco version: `recordPendingObservation` and
641
+ * `failPendingOperation` write without taking it. The justified claim is
642
+ * therefore narrow - a re-open cannot clobber a concurrent executing/recovery
643
+ * pass - not a general guarantee against every watcher write.
644
+ *
645
+ * This is a compatibility shim over private coco internals, so it fails closed:
646
+ * if any of the expected methods are missing it throws before writing. That
647
+ * check only catches removals, not changed behaviour under the same name: it
648
+ * was written against @cashu/coco-core 1.0.1, so any coco bump must re-run the
649
+ * real-Manager and fake-mint integration tests. The long-term fix is an
650
+ * upstream public `reopenFailedOperation(id)` that takes the same lock, reloads
651
+ * the full row, preserves the outputs and emits the usual events.
652
+ */
653
+ export async function reopenFailedMintOperation(
654
+ service: Pick<
655
+ MintOperationServiceCleanup,
656
+ "acquireOperationLock" | "getOperation" | "transitionToPending"
657
+ >,
658
+ operationId: string,
659
+ ): Promise<boolean> {
660
+ for (const method of [
661
+ "acquireOperationLock",
662
+ "getOperation",
663
+ "transitionToPending",
664
+ ] as const) {
665
+ if (typeof service[method] !== "function") {
666
+ throw new Error(
667
+ `coco mintOperationService.${method} is unavailable; refusing to re-open a failed mint operation`,
668
+ );
669
+ }
670
+ }
671
+ const release = await service.acquireOperationLock(operationId);
672
+ try {
673
+ const current = await service.getOperation(operationId);
674
+ if (!current) throw new Error(`Operation ${operationId} not found`);
675
+ if (current.state !== "failed") return false;
676
+ // Clearing the terminal-failure marker keeps the re-opened row from
677
+ // looking terminally failed to readers that inspect it alongside `state`.
678
+ await service.transitionToPending(
679
+ { ...current, terminalFailure: undefined },
680
+ undefined,
681
+ );
682
+ return true;
683
+ } finally {
684
+ release();
685
+ }
580
686
  }
581
687
 
582
688
  export interface CreateCocoClientOptions {
@@ -627,16 +733,8 @@ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
627
733
 
628
734
  /** Rejects when `timeoutMs` elapses before `promise` settles. */
629
735
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
630
- let timer: ReturnType<typeof setTimeout> | undefined;
631
- const timeout = new Promise<never>((_resolve, reject) => {
632
- timer = setTimeout(
633
- () => reject(new Error("Timed out contacting mint")),
634
- timeoutMs,
635
- );
636
- });
637
- return Promise.race([promise, timeout]).finally(() => {
638
- if (timer !== undefined) clearTimeout(timer);
639
- });
736
+ if (timeoutMs === Infinity) return promise;
737
+ return withRequestTimeout(promise, timeoutMs, "Timed out contacting mint");
640
738
  }
641
739
 
642
740
  /** Structural subset of coco's Manager used by expired-quote settlement. */
@@ -669,6 +767,57 @@ export interface ExpiredMintSettlement {
669
767
  unobserved: number;
670
768
  }
671
769
 
770
+ /** Outcome of asking a mint about one expired pending quote. */
771
+ export type ExpiredMintQuoteOutcome =
772
+ | "failed"
773
+ | "leftForRecovery"
774
+ | "unobserved";
775
+
776
+ /**
777
+ * Decide one expired pending quote's fate by asking the mint.
778
+ *
779
+ * Expiry alone does not prove the quote was never paid: the Lightning payment
780
+ * can land just before expiry while the daemon is down, leaving no local
781
+ * observation. A quote the mint still reports UNPAID can never be issued and
782
+ * is safe to fail locally; anything else (PAID/ISSUED, or a mint that cannot
783
+ * answer) stays pending so recovery can still claim the sats.
784
+ */
785
+ export async function failExpiredMintQuoteIfUnpaid(
786
+ mintService: Pick<
787
+ MintOperationServiceCleanup,
788
+ "observePendingOperation" | "failPendingOperation"
789
+ >,
790
+ operationId: string,
791
+ timeoutMs: number,
792
+ ): Promise<{
793
+ outcome: ExpiredMintQuoteOutcome;
794
+ category?: "waiting" | "ready" | "completed" | "terminal";
795
+ error?: unknown;
796
+ }> {
797
+ try {
798
+ const observation = await withTimeout(
799
+ mintService.observePendingOperation(operationId),
800
+ timeoutMs,
801
+ );
802
+ if (observation.category !== "waiting") {
803
+ return { outcome: "leftForRecovery", category: observation.category };
804
+ }
805
+ // The mint confirms the expired quote is still unpaid: it can never be
806
+ // issued now, so failing it locally cannot strand funds.
807
+ await mintService.failPendingOperation(
808
+ { id: operationId },
809
+ {
810
+ reason: "Expired mint quote confirmed unpaid by mint",
811
+ retryable: false,
812
+ observedAt: Date.now(),
813
+ },
814
+ );
815
+ return { outcome: "failed", category: observation.category };
816
+ } catch (error) {
817
+ return { outcome: "unobserved", error };
818
+ }
819
+ }
820
+
672
821
  /**
673
822
  * Settle expired pending mint quotes before the mint recovery sweep runs.
674
823
  *
@@ -690,6 +839,7 @@ export async function settleExpiredMintQuotes(
690
839
  source: ExpiredMintQuoteSource,
691
840
  nowMs: number,
692
841
  deadlineMs: number = EXPIRED_MINT_OBSERVATION_DEADLINE_MS,
842
+ options: { unreachableMints?: Set<string>; outstanding?: RecoveryWork; shouldStop?: () => boolean } = {},
693
843
  ): Promise<ExpiredMintSettlement> {
694
844
  const pendingMints = await source.ops.mint.listPending();
695
845
  const selection = selectCleanupOperations({
@@ -710,6 +860,25 @@ export async function settleExpiredMintQuotes(
710
860
 
711
861
  const startedAt = Date.now();
712
862
  for (const op of candidates) {
863
+ if (options.shouldStop?.() || options.outstanding?.has(recoveryKey("mint", op.id))) {
864
+ settlement.unobserved++; continue;
865
+ }
866
+ // A mint the startup probe already found unreachable cannot answer an
867
+ // observation either; skip it without spending the shared wall-clock
868
+ // budget, leaving the quote pending for a later startup.
869
+ if (options.unreachableMints) {
870
+ let mintUrl = op.mintUrl;
871
+ try {
872
+ mintUrl = normalizeMintUrl(op.mintUrl);
873
+ } catch {
874
+ // Malformed persisted URL: probe keys are raw for those, and the
875
+ // observation below would fail anyway, landing in `unobserved`.
876
+ }
877
+ if (options.unreachableMints.has(mintUrl)) {
878
+ settlement.unobserved++;
879
+ continue;
880
+ }
881
+ }
713
882
  const remainingMs = deadlineMs - (Date.now() - startedAt);
714
883
  if (remainingMs <= 0) {
715
884
  const skipped =
@@ -724,45 +893,38 @@ export async function settleExpiredMintQuotes(
724
893
  break;
725
894
  }
726
895
 
727
- try {
728
- const result = await withTimeout(
729
- source.mintOperationService.observePendingOperation(op.id),
730
- remainingMs,
896
+ // Track the complete observation/failure chain, not just its bounded wait.
897
+ const work = failExpiredMintQuoteIfUnpaid(source.mintOperationService, op.id, Infinity);
898
+ if (options.outstanding) trackRecovery(options.outstanding, recoveryKey("mint", op.id), work);
899
+ const check = await waitForRecoveryWork(work, remainingMs).catch(error => ({
900
+ outcome: "unobserved" as const, error, category: undefined,
901
+ }));
902
+ if (check.outcome === "failed") {
903
+ settlement.failed++;
904
+ } else if (check.outcome === "leftForRecovery") {
905
+ // PAID/ISSUED (or terminally failed) at the mint: normal recovery
906
+ // must see this quote so paid proofs get claimed.
907
+ settlement.leftForRecovery++;
908
+ const observed =
909
+ check.category === "ready"
910
+ ? "was paid at the mint"
911
+ : check.category === "completed"
912
+ ? "was already issued at the mint"
913
+ : "failed terminally at the mint";
914
+ startupProgress(
915
+ `Expired mint quote ${op.quoteId ?? op.id} at ${op.mintUrl} ${observed}; leaving it for mint recovery.`,
731
916
  );
732
- if (result.category === "waiting") {
733
- // The mint confirms the expired quote is still unpaid: it can never
734
- // be issued now, so failing it locally cannot strand funds.
735
- await source.mintOperationService.failPendingOperation(
736
- { id: op.id },
737
- {
738
- reason: "Expired mint quote confirmed unpaid by mint",
739
- retryable: false,
740
- observedAt: Date.now(),
741
- },
742
- );
743
- settlement.failed++;
744
- } else {
745
- // PAID/ISSUED (or terminally failed) at the mint: normal recovery
746
- // must see this quote so paid proofs get claimed.
747
- settlement.leftForRecovery++;
748
- const observed =
749
- result.category === "ready"
750
- ? "was paid at the mint"
751
- : result.category === "completed"
752
- ? "was already issued at the mint"
753
- : "failed terminally at the mint";
754
- startupProgress(
755
- `Expired mint quote ${op.quoteId ?? op.id} at ${op.mintUrl} ${observed}; leaving it for mint recovery.`,
756
- );
757
- }
758
- } catch (error) {
917
+ } else {
759
918
  // Mint unreachable, too slow, or the quote unknown to it: leave the
760
919
  // operation pending so a later startup can still recover it.
761
920
  settlement.unobserved++;
762
921
  logger.warn("Could not check expired mint quote; leaving it pending", {
763
922
  operationId: op.id,
764
923
  mintUrl: op.mintUrl,
765
- error: error instanceof Error ? error.message : String(error),
924
+ error:
925
+ check.error instanceof Error
926
+ ? check.error.message
927
+ : String(check.error),
766
928
  });
767
929
  }
768
930
  }
@@ -770,6 +932,364 @@ export async function settleExpiredMintQuotes(
770
932
  return settlement;
771
933
  }
772
934
 
935
+ /**
936
+ * Source for explicit PAID mint-quote recovery.
937
+ *
938
+ * Unlike the startup sweeps this also accepts caller-supplied operation ids so
939
+ * an operator can target a quote coco already gave up on (state `failed`).
940
+ */
941
+ export interface MintQuoteRecoverySource {
942
+ ops: {
943
+ mint: {
944
+ listPending(): Promise<MintQuoteRecoveryCandidate[]>;
945
+ get(operationId: string): Promise<MintQuoteRecoveryCandidate | null>;
946
+ finalize(operationId: string): Promise<unknown>;
947
+ };
948
+ };
949
+ mintOperationService: Pick<
950
+ MintOperationServiceCleanup,
951
+ "observePendingOperation"
952
+ >;
953
+ /**
954
+ * Re-open a failed operation so it can be recovered; false when it is no
955
+ * longer failed. Implementations must reload the full row (see
956
+ * `reopenFailedMintOperation`).
957
+ */
958
+ reopenFailedOperation(operationId: string): Promise<boolean>;
959
+ }
960
+
961
+ export interface MintQuoteRecoveryOptions {
962
+ shouldStop?: () => boolean;
963
+ /** Target only these operation ids (may include failed operations). */
964
+ operationIds?: string[];
965
+ /** Per-quote budget for observing the mint and finalizing the operation. */
966
+ timeoutMs?: number;
967
+ /**
968
+ * Re-open failed operations instead of skipping them. Only applies to
969
+ * operations named by `operationIds`: coco's pending listing never returns
970
+ * failed operations, so they can only be recovered by explicit id.
971
+ */
972
+ includeFailed?: boolean;
973
+ /**
974
+ * In-flight recovery work keyed by family:id (mint:<operation id>), shared across runs.
975
+ * withTimeout does not cancel the underlying request, so a timed-out quote
976
+ * check or finalize must keep blocking a retry until it actually settles.
977
+ */
978
+ outstanding?: Map<string, Promise<unknown>>;
979
+ }
980
+
981
+ export interface MintQuoteRecoveryResult {
982
+ /** Operations recovery acted on. */
983
+ checked: number;
984
+ /** Operations whose paid sats were minted or restored. */
985
+ recovered: number;
986
+ /** Quotes the mint still reports UNPAID; left pending. */
987
+ waiting: number;
988
+ /**
989
+ * Quotes that ended terminally: the mint can no longer issue them, or coco
990
+ * finalised them without recovering any proofs.
991
+ */
992
+ terminal: number;
993
+ /** Failed operations moved back to pending before checking. */
994
+ reopened: number;
995
+ /**
996
+ * Operations left to a later run: the mint was unreachable, the per-quote
997
+ * budget ran out, or the operation ended in a non-terminal state.
998
+ */
999
+ retryable: number;
1000
+ /** Operations skipped because an earlier recovery of them is still running. */
1001
+ busy: number;
1002
+ errors: Array<{ operationId: string; error: string }>;
1003
+ }
1004
+
1005
+ /**
1006
+ * Run async tasks strictly one after another.
1007
+ *
1008
+ * Used to serialize explicit wallet recovery: two concurrent requests must not
1009
+ * both snapshot the same failed operation, and a retry must not start
1010
+ * underneath work that outlived its timeout. A rejected task never breaks the
1011
+ * chain for the next one.
1012
+ */
1013
+ export function createRunQueue(): (<T>(run: () => Promise<T>) => Promise<T>) & { drain(): Promise<void> } {
1014
+ let tail: Promise<unknown> = Promise.resolve();
1015
+ const enqueue = <T>(run: () => Promise<T>): Promise<T> => {
1016
+ const result = tail.then(run, run);
1017
+ tail = result.then(
1018
+ () => undefined,
1019
+ () => undefined,
1020
+ );
1021
+ return result;
1022
+ };
1023
+ return Object.assign(enqueue, { drain: async () => { await tail; } });
1024
+ }
1025
+
1026
+ /** Per-quote budget for the mint round-trip during explicit recovery. */
1027
+ const MINT_QUOTE_RECOVERY_TIMEOUT_MS = 20_000;
1028
+
1029
+ /**
1030
+ * Bound for the local post-finalize diagnostic read. This is a database
1031
+ * lookup, not a mint round-trip, so it gets its own small budget: the per-op
1032
+ * mint budget is often already spent when finalize throws, and a starved
1033
+ * diagnostic would silently fall back to the generic error message.
1034
+ */
1035
+ const DIAGNOSTIC_LOOKUP_TIMEOUT_MS = 250;
1036
+
1037
+ /**
1038
+ * Recover mint quotes whose sats are PAID at the mint but were never claimed.
1039
+ *
1040
+ * For every target the mint is asked for the current quote state, and only it
1041
+ * decides the outcome: PAID quotes have their stored outputs submitted, ISSUED
1042
+ * quotes have their signatures restored (NUT-09), UNPAID quotes are left
1043
+ * pending, and quotes the mint can no longer issue are reported rather than
1044
+ * silently dropped. Anything the mint cannot answer is retried later.
1045
+ *
1046
+ * `finalize()` does not throw when the mint refuses to issue or when an
1047
+ * already-issued quote's proofs cannot be restored: it returns a terminal
1048
+ * operation instead. Recovery therefore inspects the returned operation's
1049
+ * state and error and only counts a genuine finalized-without-error as
1050
+ * recovered.
1051
+ *
1052
+ * Failed operations are skipped unless `includeFailed` is set, and they can
1053
+ * only be targeted by explicit id because coco's pending listing never returns
1054
+ * them. Re-opening an operation is a mutation, so it happens only here, never
1055
+ * during startup recovery.
1056
+ *
1057
+ * Callers should serialize their own invocations and pass a shared
1058
+ * `outstanding` map: `timeoutMs` bounds the wait but does not cancel the
1059
+ * request behind it, so both a timed-out quote check and a timed-out finalize
1060
+ * keep blocking a retry until they actually settle.
1061
+ */
1062
+ export async function runMintQuoteRecovery(
1063
+ source: MintQuoteRecoverySource,
1064
+ options: MintQuoteRecoveryOptions = {},
1065
+ onProgress?: (message: string) => void,
1066
+ ): Promise<MintQuoteRecoveryResult> {
1067
+ if (options.includeFailed && !options.operationIds?.length) {
1068
+ throw new Error("includeFailed requires explicit operationIds");
1069
+ }
1070
+ const timeoutMs = options.timeoutMs ?? MINT_QUOTE_RECOVERY_TIMEOUT_MS;
1071
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1072
+ throw new Error("timeoutMs must be a positive finite number");
1073
+ }
1074
+ const outstanding =
1075
+ options.outstanding ?? new Map<string, Promise<unknown>>();
1076
+ const result: MintQuoteRecoveryResult = {
1077
+ checked: 0,
1078
+ recovered: 0,
1079
+ waiting: 0,
1080
+ terminal: 0,
1081
+ reopened: 0,
1082
+ retryable: 0,
1083
+ busy: 0,
1084
+ errors: [],
1085
+ };
1086
+ const messageOf = (error: unknown) =>
1087
+ error instanceof Error ? error.message : String(error);
1088
+ /** coco's fail-fast operation lock rejected the call: another holder exists. */
1089
+ const isInProgress = (error: unknown) =>
1090
+ error instanceof Error && error.name === "OperationInProgressError";
1091
+ const track = (operationId: string, work: Promise<unknown>) =>
1092
+ trackRecovery(outstanding, recoveryKey("mint", operationId), work);
1093
+
1094
+ let targets: MintQuoteRecoveryCandidate[];
1095
+ if (options.operationIds && options.operationIds.length > 0) {
1096
+ targets = [];
1097
+ const seen = new Set<string>();
1098
+ for (const operationId of options.operationIds) {
1099
+ if (seen.has(operationId)) continue;
1100
+ seen.add(operationId);
1101
+ try {
1102
+ const op = await source.ops.mint.get(operationId);
1103
+ if (!op) {
1104
+ result.errors.push({ operationId, error: "operation not found" });
1105
+ continue;
1106
+ }
1107
+ targets.push(op);
1108
+ } catch (error) {
1109
+ result.errors.push({ operationId, error: messageOf(error) });
1110
+ }
1111
+ }
1112
+ } else {
1113
+ targets = await source.ops.mint.listPending();
1114
+ }
1115
+
1116
+ const { pending, failed } = selectMintQuotesForRecovery({
1117
+ mints: targets,
1118
+ includeFailed: options.includeFailed === true,
1119
+ });
1120
+
1121
+ for (const op of failed) {
1122
+ if (options.shouldStop?.()) break;
1123
+ const label = `Mint quote ${op.quoteId ?? op.id} at ${op.mintUrl}`;
1124
+ if (outstanding.has(recoveryKey("mint", op.id))) {
1125
+ result.busy++;
1126
+ onProgress?.(`${label}: an earlier recovery is still running; skipped`);
1127
+ continue;
1128
+ }
1129
+ try {
1130
+ if (!(await source.reopenFailedOperation(op.id))) {
1131
+ onProgress?.(`${label}: no longer failed; skipped`);
1132
+ continue;
1133
+ }
1134
+ result.reopened++;
1135
+ onProgress?.(`${label}: re-opened failed operation for recovery`);
1136
+ } catch (error) {
1137
+ // coco's operation lock is fail-fast, so an in-progress error means a
1138
+ // processor or another recovery holds the operation right now.
1139
+ if (isInProgress(error)) {
1140
+ result.busy++;
1141
+ onProgress?.(`${label}: another recovery holds it; skipped`);
1142
+ } else {
1143
+ result.retryable++;
1144
+ onProgress?.(`${label}: could not re-open: ${messageOf(error)}`);
1145
+ }
1146
+ result.errors.push({ operationId: op.id, error: messageOf(error) });
1147
+ continue;
1148
+ }
1149
+ await recoverOne(op);
1150
+ }
1151
+
1152
+ for (const op of pending) {
1153
+ if (options.shouldStop?.()) break;
1154
+ await recoverOne(op);
1155
+ }
1156
+
1157
+ return result;
1158
+
1159
+ async function recoverOne(op: MintQuoteRecoveryCandidate): Promise<void> {
1160
+ const label = `Mint quote ${op.quoteId ?? op.id} at ${op.mintUrl}`;
1161
+ if (outstanding.has(recoveryKey("mint", op.id))) {
1162
+ result.busy++;
1163
+ onProgress?.(`${label}: an earlier recovery is still running; skipped`);
1164
+ return;
1165
+ }
1166
+ result.checked++;
1167
+ // One budget per operation, shared by the mint check and the finalize, so
1168
+ // a slow mint cannot silently double the documented per-quote wait. The
1169
+ // local post-finalize diagnostic read is exempt (DIAGNOSTIC_LOOKUP_TIMEOUT_MS).
1170
+ const deadlineAt = Date.now() + timeoutMs;
1171
+ const remaining = () => Math.max(1, deadlineAt - Date.now());
1172
+
1173
+ if (op.state === "executing") {
1174
+ // A crash mid-mint can leave outputs already signed at the mint;
1175
+ // finalize recovers them instead of minting a second time.
1176
+ await finalizeAndClassify(
1177
+ op.id,
1178
+ label,
1179
+ "recovered interrupted mint",
1180
+ remaining,
1181
+ );
1182
+ return;
1183
+ }
1184
+
1185
+ let observation: {
1186
+ category: "waiting" | "ready" | "completed" | "terminal";
1187
+ };
1188
+ // observePendingOperation is not read-only: it emits quote-state-changed,
1189
+ // persists the observation and can fail a terminal operation. Track it too,
1190
+ // so a timed-out check cannot be retried and then persist a stale read.
1191
+ const check = source.mintOperationService.observePendingOperation(op.id);
1192
+ track(op.id, check);
1193
+ try {
1194
+ observation = await withTimeout(check, remaining());
1195
+ } catch (error) {
1196
+ result.retryable++;
1197
+ result.errors.push({ operationId: op.id, error: messageOf(error) });
1198
+ onProgress?.(`${label}: could not check with mint: ${messageOf(error)}`);
1199
+ return;
1200
+ }
1201
+
1202
+ const decision = classifyMintQuoteObservation(observation.category);
1203
+ if (decision.action === "finalize") {
1204
+ await finalizeAndClassify(
1205
+ op.id,
1206
+ label,
1207
+ decision.observedRemoteState === "PAID"
1208
+ ? `paid, minting proofs (${op.amount} sat)`
1209
+ : `already issued, restoring proofs (${op.amount} sat)`,
1210
+ remaining,
1211
+ );
1212
+ } else if (decision.action === "waiting") {
1213
+ result.waiting++;
1214
+ onProgress?.(`${label}: mint reports UNPAID; left pending`);
1215
+ } else {
1216
+ // coco records the mint's terminal verdict by failing the operation.
1217
+ result.terminal++;
1218
+ onProgress?.(`${label}: mint can no longer issue this quote`);
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Run finalize and classify its result. coco returns a terminal operation
1224
+ * rather than throwing when the mint refuses (for example an expired quote)
1225
+ * or when an already-issued quote's proofs could not be restored, so a
1226
+ * fulfilled promise is not by itself evidence that sats were recovered.
1227
+ */
1228
+ async function finalizeAndClassify(
1229
+ operationId: string,
1230
+ label: string,
1231
+ successMessage: string,
1232
+ remaining: () => number,
1233
+ ): Promise<void> {
1234
+ const work = source.ops.mint.finalize(operationId);
1235
+ track(operationId, work);
1236
+ let terminal: { state?: string; error?: string } | null | undefined;
1237
+ try {
1238
+ terminal = (await withTimeout(work, remaining())) as
1239
+ | { state?: string; error?: string }
1240
+ | null
1241
+ | undefined;
1242
+ } catch (error) {
1243
+ if (isInProgress(error)) {
1244
+ result.busy++;
1245
+ onProgress?.(`${label}: another recovery is working on it; skipped`);
1246
+ } else {
1247
+ result.retryable++;
1248
+ // finalize can throw a generic "remains pending" error after coco has
1249
+ // persisted the actionable mint rejection (for example inactive keyset).
1250
+ const current = await withTimeout(
1251
+ source.ops.mint.get(operationId),
1252
+ Math.max(remaining(), DIAGNOSTIC_LOOKUP_TIMEOUT_MS),
1253
+ ).catch(() => null);
1254
+ const detail = current?.state === "pending" && current.error
1255
+ ? current.error
1256
+ : messageOf(error);
1257
+ result.errors.push({ operationId, error: detail });
1258
+ onProgress?.(`${label}: could not finish recovery: ${detail}`);
1259
+ return;
1260
+ }
1261
+ result.errors.push({ operationId, error: messageOf(error) });
1262
+ return;
1263
+ }
1264
+ if (terminal?.state === "finalized" && !terminal.error) {
1265
+ result.recovered++;
1266
+ onProgress?.(`${label}: ${successMessage}`);
1267
+ return;
1268
+ }
1269
+ if (
1270
+ terminal?.state === "failed" ||
1271
+ (terminal?.state === "finalized" && terminal.error)
1272
+ ) {
1273
+ result.terminal++;
1274
+ const detail =
1275
+ terminal.error ?? `left in state ${terminal.state ?? "unknown"}`;
1276
+ result.errors.push({ operationId, error: detail });
1277
+ onProgress?.(`${label}: not recovered: ${detail}`);
1278
+ return;
1279
+ }
1280
+ // Pending/executing/unknown: coco may still be working on the operation,
1281
+ // so leave it to a later run rather than calling it terminal.
1282
+ result.retryable++;
1283
+ result.errors.push({
1284
+ operationId,
1285
+ error: `left in state ${terminal?.state ?? "unknown"}; will retry`,
1286
+ });
1287
+ onProgress?.(
1288
+ `${label}: still ${terminal?.state ?? "unknown"}; left for a later run`,
1289
+ );
1290
+ }
1291
+ }
1292
+
773
1293
  const PENDING_MINT_SWEEP_INTERVAL_MS = 15_000;
774
1294
  /** Per-quote wait inside a sweep, so one stalled mint cannot starve the rest. */
775
1295
  const PENDING_MINT_CHECK_TIMEOUT_MS = 10_000;
@@ -793,6 +1313,7 @@ export interface PendingMintSweepOptions {
793
1313
  deadlineMs?: number;
794
1314
  checkTimeoutMs?: number;
795
1315
  state?: PendingMintSweepState;
1316
+ shouldStop?: () => boolean;
796
1317
  }
797
1318
 
798
1319
  type PendingMintOutcome = "unreachable" | "other";
@@ -814,21 +1335,21 @@ export async function settlePendingMintQuotes(
814
1335
  const ordered = [...pending.slice(resumeAt), ...pending.slice(0, resumeAt)];
815
1336
  const startedAt = Date.now();
816
1337
  for (const op of ordered) {
1338
+ if (options.shouldStop?.()) break;
817
1339
  const remainingMs = deadlineMs - (Date.now() - startedAt);
818
- if (state.outstanding.has(op.id) || remainingMs <= 0) {
1340
+ if (state.outstanding.has(recoveryKey("mint", op.id)) || remainingMs <= 0) {
819
1341
  unreachable++;
820
1342
  continue;
821
1343
  }
822
1344
  state.after = op.id;
823
- // Report on the refresh itself so a late result is still logged.
1345
+ // Track refresh AND its late-result reporting mutations as one lifetime.
824
1346
  const settled = source.ops.mint
825
1347
  .refresh(op.id)
826
1348
  .then(
827
1349
  (result) => reportPendingMintRefresh(source, op, result, nowMs),
828
1350
  (error) => reportPendingMintRefreshError(source, op, error),
829
- )
830
- .finally(() => state.outstanding.delete(op.id));
831
- state.outstanding.set(op.id, settled);
1351
+ );
1352
+ trackRecovery(state.outstanding, recoveryKey("mint", op.id), settled);
832
1353
  try {
833
1354
  const outcome = await withTimeout(settled, Math.min(checkTimeoutMs, remainingMs));
834
1355
  if (outcome === "unreachable") unreachable++;
@@ -904,16 +1425,16 @@ async function reportPendingMintRefreshError(
904
1425
  * still running after that fails against the closed database and is picked
905
1426
  * up by startup recovery.
906
1427
  */
907
- function startPendingMintSweep(source: PendingMintQuoteSource): () => Promise<void> {
1428
+ function startPendingMintSweep(source: PendingMintQuoteSource, outstanding: RecoveryWork): () => Promise<void> {
908
1429
  let stopped = false;
909
1430
  let timer: ReturnType<typeof setTimeout> | undefined;
910
1431
  let inFlight: Promise<void> = Promise.resolve();
911
1432
  let unreachableBefore = 0;
912
- const state: PendingMintSweepState = { outstanding: new Map() };
1433
+ const state: PendingMintSweepState = { outstanding };
913
1434
 
914
1435
  const tick = async () => {
915
1436
  if (stopped) return;
916
- inFlight = settlePendingMintQuotes(source, Date.now(), { state }).then(
1437
+ inFlight = settlePendingMintQuotes(source, Date.now(), { state, shouldStop: () => stopped }).then(
917
1438
  ({ unreachable }) => {
918
1439
  // Report a mint becoming unreachable, or reachable again, once.
919
1440
  if (unreachable > 0 && unreachableBefore === 0) {
@@ -1029,21 +1550,174 @@ interface RecoveryPhaseProgress {
1029
1550
  failedMintQuotes: number;
1030
1551
  }
1031
1552
 
1553
+ /**
1554
+ * Coco keeps per-operation recovery private on its services; routstrd already
1555
+ * reaches into the Manager the same way for `mintOperationService`. Send is
1556
+ * the only family whose public `refresh()` cannot recover executing ops.
1557
+ */
1558
+ function sendRecoveryServiceOf(coco: Manager): SendRecoveryService {
1559
+ return (coco as unknown as { sendOperationService: SendRecoveryService })
1560
+ .sendOperationService;
1561
+ }
1562
+
1563
+ /** Local-only crash cleanup, run before the degraded gate opens. */
1564
+ export async function cleanupLocalRecoveryState(
1565
+ coco: Manager,
1566
+ repo: SqliteRepositories,
1567
+ ): Promise<void> {
1568
+ // Coco 1.0.1 implements these as local repository/proof operations only.
1569
+ // Keep this version-sensitive bridge together with the send recovery bridge.
1570
+ const services = coco as unknown as Record<string, {
1571
+ recoverInitOperation?(op: unknown): Promise<void>;
1572
+ cleanupOrphanedReservations?(): Promise<number>;
1573
+ } | undefined>;
1574
+ const families = [
1575
+ ["send", repo.sendOperationRepository],
1576
+ ["melt", repo.meltOperationRepository],
1577
+ ["receive", repo.receiveOperationRepository],
1578
+ ["mint", repo.mintOperationRepository],
1579
+ ] as const;
1580
+ // Fail closed the way reopenFailedMintOperation does: a coco bump that
1581
+ // removes or renames these privates must stop recovery with a clear error
1582
+ // before anything is written, not crash halfway through the loop with the
1583
+ // cleanup half-applied.
1584
+ for (const [kind] of families) {
1585
+ if (typeof services[`${kind}OperationService`]?.recoverInitOperation !== "function") {
1586
+ throw new Error(
1587
+ `coco ${kind}OperationService.recoverInitOperation is unavailable; refusing local recovery cleanup`,
1588
+ );
1589
+ }
1590
+ }
1591
+ if (typeof services.sendOperationService?.cleanupOrphanedReservations !== "function") {
1592
+ throw new Error(
1593
+ "coco sendOperationService.cleanupOrphanedReservations is unavailable; refusing local recovery cleanup",
1594
+ );
1595
+ }
1596
+ for (const [kind, repository] of families) {
1597
+ for (const op of await repository.getByState("init")) {
1598
+ await services[`${kind}OperationService`]!.recoverInitOperation!(op);
1599
+ }
1600
+ }
1601
+ await services.sendOperationService!.cleanupOrphanedReservations!();
1602
+ }
1603
+
1604
+ /**
1605
+ * Gate for value-moving wallet operations while startup recovery runs.
1606
+ *
1607
+ * On degraded startup only, publishStuckMints opens the gate for callers
1608
+ * whose target mint has no stuck operations after probing and local cleanup.
1609
+ * A dead mint must not stall spends from a healthy one. On the happy path
1610
+ * all callers wait until the global sweeps finish. Callers
1611
+ * without a target mint, or whose mint has stuck operations, wait for the
1612
+ * full sweep. fail() poisons every caller; reads are never gated.
1613
+ */
1614
+ export interface RecoveryGate {
1615
+ waitForRecovery(mintUrl?: string): Promise<void>;
1616
+ publishStuckMints(mints: Set<string>): void;
1617
+ complete(): void;
1618
+ fail(error: string): void;
1619
+ }
1620
+
1621
+ export function createRecoveryGate(): RecoveryGate {
1622
+ let stuckMints: Set<string> | undefined;
1623
+ let done = false;
1624
+ let error: string | undefined;
1625
+ let mintsResolve: (() => void) | undefined;
1626
+ const mintsPromise = new Promise<void>((resolve) => {
1627
+ mintsResolve = resolve;
1628
+ });
1629
+ let doneResolve: (() => void) | undefined;
1630
+ const donePromise = new Promise<void>((resolve) => {
1631
+ doneResolve = resolve;
1632
+ });
1633
+
1634
+ return {
1635
+ async waitForRecovery(mintUrl?: string): Promise<void> {
1636
+ if (mintUrl) {
1637
+ let normalized: string | undefined;
1638
+ try {
1639
+ normalized = normalizeMintUrl(mintUrl);
1640
+ } catch {
1641
+ // Unparseable URL falls back to the global gate.
1642
+ normalized = undefined;
1643
+ }
1644
+ if (normalized) {
1645
+ await mintsPromise;
1646
+ if (!stuckMints?.has(normalized)) {
1647
+ if (error) throw new Error(`Wallet is not ready: ${error}`);
1648
+ return;
1649
+ }
1650
+ }
1651
+ }
1652
+ if (!done) await donePromise;
1653
+ if (error) throw new Error(`Wallet is not ready: ${error}`);
1654
+ },
1655
+ publishStuckMints(mints: Set<string>): void {
1656
+ if (stuckMints) return;
1657
+ stuckMints = mints;
1658
+ mintsResolve?.();
1659
+ },
1660
+ complete(): void {
1661
+ done = true;
1662
+ mintsResolve?.();
1663
+ doneResolve?.();
1664
+ },
1665
+ fail(message: string): void {
1666
+ done = true;
1667
+ error = message;
1668
+ mintsResolve?.();
1669
+ doneResolve?.();
1670
+ },
1671
+ };
1672
+ }
1673
+
1032
1674
  /**
1033
1675
  * Run the wallet recovery sweeps in order, reporting phase changes.
1034
1676
  *
1035
- * Expired mint quotes are settled first: quotes their mint confirms as unpaid
1677
+ * Probe first, then settle expired mint quotes: quotes confirmed as unpaid
1036
1678
  * are failed locally so `recoverPendingMintOperations()` skips them, while
1037
1679
  * paid/issued and unreachable-mint quotes stay pending for the sweep.
1038
1680
  */
1039
- async function runWalletRecovery(
1681
+ export async function runWalletRecovery(
1040
1682
  coco: Manager,
1041
1683
  onProgress: (progress: RecoveryPhaseProgress) => void,
1042
1684
  receiveOperationIds?: string[],
1685
+ onStuckMintsKnown?: (mints: Set<string>) => void,
1686
+ options: { cleanupLocalState?: () => Promise<void>; fetchImpl?: typeof fetch; outstanding?: RecoveryWork; shouldStop?: () => boolean } = {},
1043
1687
  ): Promise<void> {
1044
1688
  surfacingRecoveryProgress = true;
1045
1689
  let failedMintQuotes = 0;
1046
1690
  try {
1691
+ // Probe every mint that has stuck operations once, FIRST, so a dead mint
1692
+ // costs a single short probe instead of taxing settlement's observation
1693
+ // budget plus a network timeout per operation per sweep. Healthy-mint
1694
+ // operations are recovered per op; dead-mint operations stay parked
1695
+ // exactly as coco's own "will retry later" path would leave them.
1696
+ onProgress({ phase: "Probing mints", failedMintQuotes });
1697
+ const stuckOperations = await collectStuckOperations(coco.ops);
1698
+ const unreachableMints = await probeMintReachability(
1699
+ [...new Set(stuckOperations.map((op) => op.mintUrl))],
1700
+ { fetchImpl: options.fetchImpl },
1701
+ );
1702
+ for (const mintUrl of unreachableMints) {
1703
+ const count = stuckOperations.filter((op) => op.mintUrl === mintUrl).length;
1704
+ startupProgress(
1705
+ `Skipping recovery for unreachable mint: ${mintUrl} (${count} op${count === 1 ? "" : "s"})`,
1706
+ );
1707
+ }
1708
+ const degraded = unreachableMints.size > 0;
1709
+ if (degraded) {
1710
+ // Local-only housekeeping must finish before any new operation is allowed.
1711
+ await options.cleanupLocalState?.();
1712
+ // Global sweeps enumerate fresh state and are unsafe beside live sends.
1713
+ // Only the snapshot-based degraded path may open the per-mint gate.
1714
+ onStuckMintsKnown?.(new Set(stuckOperations.map((op) => op.mintUrl)));
1715
+ }
1716
+
1717
+ // Settlement runs after the gate opens and only spends its observation
1718
+ // budget on mints the probe found reachable; dead-mint quotes stay
1719
+ // pending untouched. It only reads and locally fails long-expired quotes,
1720
+ // so it cannot conflict with live operations the gate just admitted.
1047
1721
  onProgress({ phase: "Settling expired mint quotes", failedMintQuotes });
1048
1722
  const settlement = await settleExpiredMintQuotes(
1049
1723
  {
@@ -1055,6 +1729,8 @@ async function runWalletRecovery(
1055
1729
  ).mintOperationService,
1056
1730
  },
1057
1731
  Date.now(),
1732
+ undefined,
1733
+ { unreachableMints, outstanding: options.outstanding, shouldStop: options.shouldStop },
1058
1734
  );
1059
1735
  failedMintQuotes = settlement.failed;
1060
1736
  if (settlement.leftForRecovery > 0 || settlement.unobserved > 0) {
@@ -1065,22 +1741,48 @@ async function runWalletRecovery(
1065
1741
  );
1066
1742
  }
1067
1743
  onProgress({ phase: "Settled expired mint quotes", failedMintQuotes });
1744
+ const targeted = (kinds: Array<StuckOperation["kind"]>) =>
1745
+ runTargetedRecovery(coco.ops, sendRecoveryServiceOf(coco), {
1746
+ kinds,
1747
+ outstanding: options.outstanding,
1748
+ shouldStop: options.shouldStop,
1749
+ stuckOperations,
1750
+ unreachableMints,
1751
+ });
1068
1752
 
1753
+ // Happy path (every mint reachable) keeps coco's global sweeps: they also
1754
+ // clean up init operations and orphaned proof reservations. Degraded
1755
+ // startup runs that local housekeeping before opening its gate, and only
1756
+ // drives the previously collected snapshot when a dead mint would
1757
+ // otherwise tax every stuck op with a network timeout.
1069
1758
  onProgress({ phase: "Send recovery", failedMintQuotes });
1070
- await coco.ops.send.recovery.run();
1759
+ if (!degraded) await coco.ops.send.recovery.run();
1760
+ else await targeted(["send"]);
1071
1761
 
1072
1762
  onProgress({ phase: "Melt recovery", failedMintQuotes });
1073
- await coco.ops.melt.recovery.run();
1763
+ if (!degraded) await coco.ops.melt.recovery.run();
1764
+ else await targeted(["melt"]);
1074
1765
 
1075
1766
  onProgress({ phase: "Receive recovery", failedMintQuotes });
1076
1767
  if (receiveOperationIds) {
1077
1768
  // The pre-check already classified every executing receive by unique
1078
1769
  // input set. Recover only the conclusive retained operations; unresolved
1079
1770
  // groups stay untouched instead of falling back to Coco 1's expensive
1080
- // per-row sweep on this startup.
1771
+ // per-row sweep on this startup. Operations at mints the probe found
1772
+ // unreachable are skipped rather than costing their 15s timeout each.
1773
+ const mintByOperation = new Map(
1774
+ stuckOperations
1775
+ .filter((op) => op.kind === "receive")
1776
+ .map((op) => [op.id, op.mintUrl]),
1777
+ );
1081
1778
  for (const operationId of receiveOperationIds) {
1779
+ if (options.shouldStop?.()) break;
1780
+ const mintUrl = mintByOperation.get(operationId);
1781
+ if (mintUrl && unreachableMints.has(mintUrl)) continue;
1082
1782
  try {
1083
- await withTimeout(coco.ops.receive.refresh(operationId), 15_000);
1783
+ const work = coco.ops.receive.refresh(operationId);
1784
+ if (options.outstanding) trackRecovery(options.outstanding, recoveryKey("receive", operationId), work);
1785
+ await withTimeout(work, 15_000);
1084
1786
  } catch (error) {
1085
1787
  logger.warn("Targeted receive recovery did not complete", {
1086
1788
  operationId,
@@ -1088,12 +1790,19 @@ async function runWalletRecovery(
1088
1790
  });
1089
1791
  }
1090
1792
  }
1091
- } else {
1793
+ } else if (!degraded) {
1092
1794
  await coco.ops.receive.recovery.run();
1795
+ } else {
1796
+ await targeted(["receive"]);
1093
1797
  }
1094
1798
 
1095
1799
  onProgress({ phase: "Mint recovery", failedMintQuotes });
1096
- await coco.recoverPendingMintOperations();
1800
+ // A settlement wait may have timed out while an unlocked observation
1801
+ // still runs. Never let a fresh global mint sweep observe it again.
1802
+ if (!degraded && ![...(options.outstanding?.keys() ?? [])].some(key => key.startsWith("mint:"))) {
1803
+ await coco.recoverPendingMintOperations();
1804
+ }
1805
+ else await targeted(["mint"]);
1097
1806
 
1098
1807
  onProgress({ phase: "done", failedMintQuotes });
1099
1808
  } finally {
@@ -1103,7 +1812,7 @@ async function runWalletRecovery(
1103
1812
 
1104
1813
  export async function createCocoClient(
1105
1814
  options: CreateCocoClientOptions = {},
1106
- ): Promise<CocodClient> {
1815
+ ): Promise<WalletClient> {
1107
1816
  const configDir = options.walletDir || options.configDir || defaultWalletDir();
1108
1817
  const configFile = join(configDir, "config.json");
1109
1818
  const dbPath = join(configDir, "coco.db");
@@ -1156,6 +1865,10 @@ export async function createCocoClient(
1156
1865
  const recoveryPromise = new Promise<void>((resolve) => {
1157
1866
  recoveryResolve = resolve;
1158
1867
  });
1868
+ const recoveryGate = createRecoveryGate();
1869
+ let disposed = false;
1870
+ const enqueueRecovery = createRunQueue();
1871
+ const recoveryOutstanding: RecoveryWork = new Map();
1159
1872
 
1160
1873
  try {
1161
1874
  startupProgress("Opening Cashu wallet database...");
@@ -1293,10 +2006,23 @@ export async function createCocoClient(
1293
2006
  configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL,
1294
2007
  );
1295
2008
 
1296
- if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) {
1297
- startupProgress(`Adding default mint: ${defaultMintUrl}`);
1298
- await coco.mint.addMint(defaultMintUrl, { trusted: true });
1299
- }
2009
+ // Seeds the mints we ship as trusted. The default mint is strict (see
2010
+ // seedTrustedMints); extra seeds only warn, so an unreachable mint that is
2011
+ // not the default cannot stop the daemon from starting.
2012
+ await seedTrustedMints(
2013
+ {
2014
+ trustedMints: trustedMints.map((mint) => mint.mintUrl),
2015
+ addMint: (mintUrl) => coco!.mint.addMint(mintUrl, { trusted: true }),
2016
+ },
2017
+ defaultMintUrl,
2018
+ {
2019
+ onProgress: startupProgress,
2020
+ onError: (message, error) =>
2021
+ logger.warn(message, {
2022
+ error: error instanceof Error ? error.message : String(error),
2023
+ }),
2024
+ },
2025
+ );
1300
2026
 
1301
2027
  // Persist only after the mint was successfully fetched and trusted. A failed
1302
2028
  // network request must not leave config pointing at an unusable default.
@@ -1340,11 +2066,14 @@ export async function createCocoClient(
1340
2066
  }
1341
2067
  },
1342
2068
  receiveRecoveryOperationIds,
2069
+ (mints) => recoveryGate.publishStuckMints(mints),
2070
+ { cleanupLocalState: () => cleanupLocalRecoveryState(coco!, repo), outstanding: recoveryOutstanding, shouldStop: () => disposed },
1343
2071
  )
1344
2072
  .then(async () => {
1345
2073
  await syncReceiveReservations();
1346
2074
  recoveryDone = true;
1347
2075
  recoveryPhase = "done";
2076
+ recoveryGate.complete();
1348
2077
  recoveryResolve?.();
1349
2078
  startupProgress("Wallet recovery complete.");
1350
2079
  stopPendingMintSweep = startPendingMintSweep({
@@ -1353,12 +2082,13 @@ export async function createCocoClient(
1353
2082
  mintOperationService: (
1354
2083
  coco as unknown as { mintOperationService: MintOperationServiceCleanup }
1355
2084
  ).mintOperationService,
1356
- });
2085
+ }, recoveryOutstanding);
1357
2086
  })
1358
2087
  .catch((error) => {
1359
2088
  recoveryDone = true;
1360
2089
  recoveryPhase = "error";
1361
2090
  recoveryError = error instanceof Error ? error.message : String(error);
2091
+ recoveryGate.fail(recoveryError);
1362
2092
  recoveryResolve?.();
1363
2093
  startupProgress(`Wallet recovery failed: ${recoveryError}`);
1364
2094
  });
@@ -1381,19 +2111,33 @@ export async function createCocoClient(
1381
2111
  return api;
1382
2112
  };
1383
2113
 
1384
- let disposed = false;
2114
+ const assertOpen = () => { if (disposed) throw new Error("Wallet is shutting down"); };
1385
2115
 
1386
- /**
1387
- * Block a value-moving operation until background recovery has settled.
1388
- * Reads stay ungated so the daemon can report balances/status immediately.
1389
- */
1390
- const waitForRecovery = async (): Promise<void> => {
1391
- if (!recoveryDone) await recoveryPromise;
1392
- if (recoveryError) {
1393
- throw new Error(`Wallet is not ready: ${recoveryError}`);
1394
- }
2116
+ // Block a value-moving operation until background recovery has settled for
2117
+ // its target mint (see createRecoveryGate). Reads stay ungated so the
2118
+ // daemon can report balances/status immediately.
2119
+ const waitForRecovery = async (mintUrl?: string): Promise<void> => {
2120
+ assertOpen();
2121
+ await recoveryGate.waitForRecovery(mintUrl);
2122
+ assertOpen();
1395
2123
  };
1396
2124
 
2125
+ const disposeRecovery = createRecoveryDisposer(
2126
+ () => { disposed = true; },
2127
+ async () => {
2128
+ await recoveryPromise;
2129
+ await stopPendingMintSweep?.();
2130
+ await enqueueRecovery.drain();
2131
+ await drainRecoveryWork(recoveryOutstanding);
2132
+ },
2133
+ async () => {
2134
+ await coco.dispose();
2135
+ database.close();
2136
+ releaseLegacyPidClaim();
2137
+ releaseWalletPidClaim();
2138
+ },
2139
+ );
2140
+
1397
2141
  return {
1398
2142
  async ping(): Promise<boolean> {
1399
2143
  try {
@@ -1404,7 +2148,7 @@ export async function createCocoClient(
1404
2148
  }
1405
2149
  },
1406
2150
 
1407
- async getStatus(): Promise<CocodState> {
2151
+ async getStatus(): Promise<WalletRuntimeState> {
1408
2152
  if (recoveryError) return "ERROR";
1409
2153
  if (!recoveryDone) return "RECOVERING";
1410
2154
  try {
@@ -1572,13 +2316,13 @@ export async function createCocoClient(
1572
2316
  },
1573
2317
 
1574
2318
  async receiveBolt11(amount: number, mintUrl?: string) {
1575
- await waitForRecovery();
1576
2319
  const targetMint = mintUrl
1577
2320
  ? normalizeMintUrl(mintUrl)
1578
2321
  : walletConfig.defaultMintUrl;
1579
2322
  if (!targetMint) {
1580
2323
  throw new Error("No trusted mint available for Lightning invoice");
1581
2324
  }
2325
+ await waitForRecovery(targetMint);
1582
2326
  const op = await coco.ops.mint.prepare({
1583
2327
  mintUrl: targetMint,
1584
2328
  amount,
@@ -1604,13 +2348,13 @@ export async function createCocoClient(
1604
2348
  },
1605
2349
 
1606
2350
  async sendCashu(amount: number, mintUrl?: string): Promise<string> {
1607
- await waitForRecovery();
1608
2351
  const targetMint = mintUrl
1609
2352
  ? normalizeMintUrl(mintUrl)
1610
2353
  : walletConfig.defaultMintUrl;
1611
2354
  if (!targetMint) {
1612
2355
  throw new Error("No trusted mint available for sending");
1613
2356
  }
2357
+ await waitForRecovery(targetMint);
1614
2358
  const prepared = await coco.ops.send.prepare({
1615
2359
  mintUrl: targetMint,
1616
2360
  amount,
@@ -1620,13 +2364,13 @@ export async function createCocoClient(
1620
2364
  },
1621
2365
 
1622
2366
  async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
1623
- await waitForRecovery();
1624
2367
  const targetMint = mintUrl
1625
2368
  ? normalizeMintUrl(mintUrl)
1626
2369
  : walletConfig.defaultMintUrl;
1627
2370
  if (!targetMint) {
1628
2371
  throw new Error("No trusted mint available for Lightning payment");
1629
2372
  }
2373
+ await waitForRecovery(targetMint);
1630
2374
  const prepared = await coco.ops.melt.prepare({
1631
2375
  mintUrl: targetMint,
1632
2376
  method: "bolt11",
@@ -1670,28 +2414,20 @@ export async function createCocoClient(
1670
2414
  },
1671
2415
 
1672
2416
  async dispose(): Promise<void> {
1673
- if (disposed) return;
1674
- disposed = true;
1675
- try {
1676
- // Let any in-flight recovery settle before closing the database from
1677
- // underneath it. The recovery promise resolves on success or failure.
1678
- await recoveryPromise;
1679
- await stopPendingMintSweep?.();
1680
- await coco.dispose();
1681
- } finally {
1682
- try {
1683
- database.close();
1684
- } finally {
1685
- releaseLegacyPidClaim();
1686
- releaseWalletPidClaim();
1687
- }
1688
- }
2417
+ await disposeRecovery().catch(error => {
2418
+ logger.warn("Wallet shutdown incomplete; database and ownership retained until recovery settles");
2419
+ throw error;
2420
+ });
1689
2421
  },
1690
2422
 
1691
2423
  async getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]> {
1692
2424
  return coco.history.getPaginatedHistory(offset, limit);
1693
2425
  },
1694
2426
 
2427
+ async getHistoryEntryById(id: string): Promise<HistoryEntry | null> {
2428
+ return coco.history.getHistoryEntryById(id);
2429
+ },
2430
+
1695
2431
  async getNpcAddress(): Promise<NpcAddress> {
1696
2432
  const info = await npcApi().getInfo();
1697
2433
  const name =
@@ -1733,6 +2469,7 @@ export async function createCocoClient(
1733
2469
  await waitForRecovery();
1734
2470
  const minAgeMs = options.minAgeMs ?? 7 * 24 * 60 * 60 * 1000;
1735
2471
  const dryRun = options.dryRun === true;
2472
+ const force = options.force === true;
1736
2473
  const nowMs = Date.now();
1737
2474
 
1738
2475
  const [pendingMints, inFlightSends, preparedMelts] = await Promise.all([
@@ -1760,6 +2497,8 @@ export async function createCocoClient(
1760
2497
  });
1761
2498
 
1762
2499
  const errors: WalletCleanupResult["errors"] = [];
2500
+ let failedMintQuotes = 0;
2501
+ let leftForRecovery = 0;
1763
2502
 
1764
2503
  if (!dryRun) {
1765
2504
  const mintService = (
@@ -1769,20 +2508,49 @@ export async function createCocoClient(
1769
2508
  ).mintOperationService;
1770
2509
 
1771
2510
  for (const op of selection.mintsToFail) {
1772
- try {
1773
- await mintService.failPendingOperation(
1774
- { id: op.id },
1775
- {
1776
- reason: "Expired unpaid mint quote cleaned up by routstrd",
1777
- retryable: false,
1778
- observedAt: nowMs,
1779
- },
1780
- );
1781
- } catch (error) {
1782
- errors.push({
1783
- operationId: op.id,
1784
- error: error instanceof Error ? error.message : String(error),
1785
- });
2511
+ if (force) {
2512
+ // Legacy behaviour: fail the quote locally without asking the mint.
2513
+ try {
2514
+ await mintService.failPendingOperation(
2515
+ { id: op.id },
2516
+ {
2517
+ reason: "Expired mint quote cleaned up by routstrd (forced)",
2518
+ retryable: false,
2519
+ observedAt: nowMs,
2520
+ },
2521
+ );
2522
+ failedMintQuotes++;
2523
+ } catch (error) {
2524
+ errors.push({
2525
+ operationId: op.id,
2526
+ error: error instanceof Error ? error.message : String(error),
2527
+ });
2528
+ }
2529
+ continue;
2530
+ }
2531
+ // Expiry alone does not prove the quote was never paid: the
2532
+ // Lightning payment can land before expiry while the daemon is down.
2533
+ // Confirm UNPAID with the mint before failing, exactly as startup
2534
+ // recovery does; paid quotes are left for recovery to finalize.
2535
+ const check = await failExpiredMintQuoteIfUnpaid(
2536
+ mintService,
2537
+ op.id,
2538
+ EXPIRED_MINT_OBSERVATION_DEADLINE_MS,
2539
+ );
2540
+ if (check.outcome === "failed") {
2541
+ failedMintQuotes++;
2542
+ } else {
2543
+ leftForRecovery++;
2544
+ if (check.outcome === "unobserved") {
2545
+ errors.push({
2546
+ operationId: op.id,
2547
+ error: `could not confirm quote state with mint: ${
2548
+ check.error instanceof Error
2549
+ ? check.error.message
2550
+ : String(check.error)
2551
+ }`,
2552
+ });
2553
+ }
1786
2554
  }
1787
2555
  }
1788
2556
 
@@ -1809,8 +2577,15 @@ export async function createCocoClient(
1809
2577
  }
1810
2578
  }
1811
2579
 
2580
+ const mintSummary = summarizeMintCleanup({
2581
+ dryRun,
2582
+ candidates: selection.mintsToFail.length,
2583
+ failed: failedMintQuotes,
2584
+ leftForRecovery,
2585
+ });
1812
2586
  const actedOn =
1813
- selection.mintsToFail.length +
2587
+ (dryRun ? mintSummary.mintQuoteCandidates : mintSummary.failedMintQuotes) +
2588
+ leftForRecovery +
1814
2589
  selection.sendsToReclaim.length +
1815
2590
  selection.meltsToCancel.length;
1816
2591
  const skipped =
@@ -1819,12 +2594,63 @@ export async function createCocoClient(
1819
2594
 
1820
2595
  return {
1821
2596
  dryRun,
1822
- failedMintQuotes: selection.mintsToFail.length,
2597
+ ...mintSummary,
1823
2598
  reclaimedSends: selection.sendsToReclaim.length,
1824
2599
  cancelledMelts: selection.meltsToCancel.length,
1825
2600
  skipped,
1826
2601
  errors,
1827
2602
  };
1828
2603
  },
2604
+
2605
+ async recoverMintQuotes(options, onProgress) {
2606
+ await waitForRecovery();
2607
+ const service = (
2608
+ coco as unknown as {
2609
+ mintOperationService: MintOperationServiceCleanup;
2610
+ }
2611
+ ).mintOperationService;
2612
+ // Serialize explicit recovery: two concurrent requests must not both
2613
+ // snapshot the same failed operation, and a retry must not start
2614
+ // underneath a finalize that outlived its timeout.
2615
+ return enqueueRecovery(() => {
2616
+ assertOpen();
2617
+ return runMintQuoteRecovery(
2618
+ {
2619
+ ops: coco.ops as unknown as MintQuoteRecoverySource["ops"],
2620
+ mintOperationService: service,
2621
+ reopenFailedOperation: (operationId) =>
2622
+ reopenFailedMintOperation(service, operationId),
2623
+ },
2624
+ { ...options, outstanding: recoveryOutstanding, shouldStop: () => disposed },
2625
+ onProgress,
2626
+ );
2627
+ });
2628
+ },
2629
+
2630
+ async recoverStuckOperations() {
2631
+ await waitForRecovery();
2632
+ // Serialized against explicit mint-quote recovery (and itself) through
2633
+ // the same queue and lifetime tracker, so timed-out passes cannot retry the same
2634
+ // operation. Receive stays startup-only: recovering competing receives
2635
+ // safely requires the startup dedup classification (receive-dedup.ts).
2636
+ // Operations a live execute holds come back as busy via coco's
2637
+ // fail-fast operation lock, never driven underneath it.
2638
+ const result = await enqueueRecovery(() => {
2639
+ assertOpen();
2640
+ return runTargetedRecovery(coco!.ops, sendRecoveryServiceOf(coco!), {
2641
+ kinds: ["send", "melt", "mint"],
2642
+ outstanding: recoveryOutstanding,
2643
+ shouldStop: () => disposed,
2644
+ });
2645
+ });
2646
+ return {
2647
+ timedOut: result.timedOut,
2648
+ attempted: result.attempted,
2649
+ busy: result.busy,
2650
+ skipped: result.skipped,
2651
+ failed: result.failed,
2652
+ skippedMints: Object.fromEntries(result.skippedMints),
2653
+ };
2654
+ },
1829
2655
  };
1830
2656
  }