routstrd 0.4.11 → 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,
@@ -579,6 +593,96 @@ interface MintOperationServiceCleanup {
579
593
  observePendingOperation(
580
594
  operationId: string,
581
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
+ }
582
686
  }
583
687
 
584
688
  export interface CreateCocoClientOptions {
@@ -629,16 +733,8 @@ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
629
733
 
630
734
  /** Rejects when `timeoutMs` elapses before `promise` settles. */
631
735
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
632
- let timer: ReturnType<typeof setTimeout> | undefined;
633
- const timeout = new Promise<never>((_resolve, reject) => {
634
- timer = setTimeout(
635
- () => reject(new Error("Timed out contacting mint")),
636
- timeoutMs,
637
- );
638
- });
639
- return Promise.race([promise, timeout]).finally(() => {
640
- if (timer !== undefined) clearTimeout(timer);
641
- });
736
+ if (timeoutMs === Infinity) return promise;
737
+ return withRequestTimeout(promise, timeoutMs, "Timed out contacting mint");
642
738
  }
643
739
 
644
740
  /** Structural subset of coco's Manager used by expired-quote settlement. */
@@ -671,6 +767,57 @@ export interface ExpiredMintSettlement {
671
767
  unobserved: number;
672
768
  }
673
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
+
674
821
  /**
675
822
  * Settle expired pending mint quotes before the mint recovery sweep runs.
676
823
  *
@@ -692,6 +839,7 @@ export async function settleExpiredMintQuotes(
692
839
  source: ExpiredMintQuoteSource,
693
840
  nowMs: number,
694
841
  deadlineMs: number = EXPIRED_MINT_OBSERVATION_DEADLINE_MS,
842
+ options: { unreachableMints?: Set<string>; outstanding?: RecoveryWork; shouldStop?: () => boolean } = {},
695
843
  ): Promise<ExpiredMintSettlement> {
696
844
  const pendingMints = await source.ops.mint.listPending();
697
845
  const selection = selectCleanupOperations({
@@ -712,6 +860,25 @@ export async function settleExpiredMintQuotes(
712
860
 
713
861
  const startedAt = Date.now();
714
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
+ }
715
882
  const remainingMs = deadlineMs - (Date.now() - startedAt);
716
883
  if (remainingMs <= 0) {
717
884
  const skipped =
@@ -726,45 +893,38 @@ export async function settleExpiredMintQuotes(
726
893
  break;
727
894
  }
728
895
 
729
- try {
730
- const result = await withTimeout(
731
- source.mintOperationService.observePendingOperation(op.id),
732
- 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.`,
733
916
  );
734
- if (result.category === "waiting") {
735
- // The mint confirms the expired quote is still unpaid: it can never
736
- // be issued now, so failing it locally cannot strand funds.
737
- await source.mintOperationService.failPendingOperation(
738
- { id: op.id },
739
- {
740
- reason: "Expired mint quote confirmed unpaid by mint",
741
- retryable: false,
742
- observedAt: Date.now(),
743
- },
744
- );
745
- settlement.failed++;
746
- } else {
747
- // PAID/ISSUED (or terminally failed) at the mint: normal recovery
748
- // must see this quote so paid proofs get claimed.
749
- settlement.leftForRecovery++;
750
- const observed =
751
- result.category === "ready"
752
- ? "was paid at the mint"
753
- : result.category === "completed"
754
- ? "was already issued at the mint"
755
- : "failed terminally at the mint";
756
- startupProgress(
757
- `Expired mint quote ${op.quoteId ?? op.id} at ${op.mintUrl} ${observed}; leaving it for mint recovery.`,
758
- );
759
- }
760
- } catch (error) {
917
+ } else {
761
918
  // Mint unreachable, too slow, or the quote unknown to it: leave the
762
919
  // operation pending so a later startup can still recover it.
763
920
  settlement.unobserved++;
764
921
  logger.warn("Could not check expired mint quote; leaving it pending", {
765
922
  operationId: op.id,
766
923
  mintUrl: op.mintUrl,
767
- error: error instanceof Error ? error.message : String(error),
924
+ error:
925
+ check.error instanceof Error
926
+ ? check.error.message
927
+ : String(check.error),
768
928
  });
769
929
  }
770
930
  }
@@ -772,6 +932,364 @@ export async function settleExpiredMintQuotes(
772
932
  return settlement;
773
933
  }
774
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
+
775
1293
  const PENDING_MINT_SWEEP_INTERVAL_MS = 15_000;
776
1294
  /** Per-quote wait inside a sweep, so one stalled mint cannot starve the rest. */
777
1295
  const PENDING_MINT_CHECK_TIMEOUT_MS = 10_000;
@@ -795,6 +1313,7 @@ export interface PendingMintSweepOptions {
795
1313
  deadlineMs?: number;
796
1314
  checkTimeoutMs?: number;
797
1315
  state?: PendingMintSweepState;
1316
+ shouldStop?: () => boolean;
798
1317
  }
799
1318
 
800
1319
  type PendingMintOutcome = "unreachable" | "other";
@@ -816,21 +1335,21 @@ export async function settlePendingMintQuotes(
816
1335
  const ordered = [...pending.slice(resumeAt), ...pending.slice(0, resumeAt)];
817
1336
  const startedAt = Date.now();
818
1337
  for (const op of ordered) {
1338
+ if (options.shouldStop?.()) break;
819
1339
  const remainingMs = deadlineMs - (Date.now() - startedAt);
820
- if (state.outstanding.has(op.id) || remainingMs <= 0) {
1340
+ if (state.outstanding.has(recoveryKey("mint", op.id)) || remainingMs <= 0) {
821
1341
  unreachable++;
822
1342
  continue;
823
1343
  }
824
1344
  state.after = op.id;
825
- // Report on the refresh itself so a late result is still logged.
1345
+ // Track refresh AND its late-result reporting mutations as one lifetime.
826
1346
  const settled = source.ops.mint
827
1347
  .refresh(op.id)
828
1348
  .then(
829
1349
  (result) => reportPendingMintRefresh(source, op, result, nowMs),
830
1350
  (error) => reportPendingMintRefreshError(source, op, error),
831
- )
832
- .finally(() => state.outstanding.delete(op.id));
833
- state.outstanding.set(op.id, settled);
1351
+ );
1352
+ trackRecovery(state.outstanding, recoveryKey("mint", op.id), settled);
834
1353
  try {
835
1354
  const outcome = await withTimeout(settled, Math.min(checkTimeoutMs, remainingMs));
836
1355
  if (outcome === "unreachable") unreachable++;
@@ -906,16 +1425,16 @@ async function reportPendingMintRefreshError(
906
1425
  * still running after that fails against the closed database and is picked
907
1426
  * up by startup recovery.
908
1427
  */
909
- function startPendingMintSweep(source: PendingMintQuoteSource): () => Promise<void> {
1428
+ function startPendingMintSweep(source: PendingMintQuoteSource, outstanding: RecoveryWork): () => Promise<void> {
910
1429
  let stopped = false;
911
1430
  let timer: ReturnType<typeof setTimeout> | undefined;
912
1431
  let inFlight: Promise<void> = Promise.resolve();
913
1432
  let unreachableBefore = 0;
914
- const state: PendingMintSweepState = { outstanding: new Map() };
1433
+ const state: PendingMintSweepState = { outstanding };
915
1434
 
916
1435
  const tick = async () => {
917
1436
  if (stopped) return;
918
- inFlight = settlePendingMintQuotes(source, Date.now(), { state }).then(
1437
+ inFlight = settlePendingMintQuotes(source, Date.now(), { state, shouldStop: () => stopped }).then(
919
1438
  ({ unreachable }) => {
920
1439
  // Report a mint becoming unreachable, or reachable again, once.
921
1440
  if (unreachable > 0 && unreachableBefore === 0) {
@@ -1031,21 +1550,174 @@ interface RecoveryPhaseProgress {
1031
1550
  failedMintQuotes: number;
1032
1551
  }
1033
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
+
1034
1674
  /**
1035
1675
  * Run the wallet recovery sweeps in order, reporting phase changes.
1036
1676
  *
1037
- * Expired mint quotes are settled first: quotes their mint confirms as unpaid
1677
+ * Probe first, then settle expired mint quotes: quotes confirmed as unpaid
1038
1678
  * are failed locally so `recoverPendingMintOperations()` skips them, while
1039
1679
  * paid/issued and unreachable-mint quotes stay pending for the sweep.
1040
1680
  */
1041
- async function runWalletRecovery(
1681
+ export async function runWalletRecovery(
1042
1682
  coco: Manager,
1043
1683
  onProgress: (progress: RecoveryPhaseProgress) => void,
1044
1684
  receiveOperationIds?: string[],
1685
+ onStuckMintsKnown?: (mints: Set<string>) => void,
1686
+ options: { cleanupLocalState?: () => Promise<void>; fetchImpl?: typeof fetch; outstanding?: RecoveryWork; shouldStop?: () => boolean } = {},
1045
1687
  ): Promise<void> {
1046
1688
  surfacingRecoveryProgress = true;
1047
1689
  let failedMintQuotes = 0;
1048
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.
1049
1721
  onProgress({ phase: "Settling expired mint quotes", failedMintQuotes });
1050
1722
  const settlement = await settleExpiredMintQuotes(
1051
1723
  {
@@ -1057,6 +1729,8 @@ async function runWalletRecovery(
1057
1729
  ).mintOperationService,
1058
1730
  },
1059
1731
  Date.now(),
1732
+ undefined,
1733
+ { unreachableMints, outstanding: options.outstanding, shouldStop: options.shouldStop },
1060
1734
  );
1061
1735
  failedMintQuotes = settlement.failed;
1062
1736
  if (settlement.leftForRecovery > 0 || settlement.unobserved > 0) {
@@ -1067,22 +1741,48 @@ async function runWalletRecovery(
1067
1741
  );
1068
1742
  }
1069
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
+ });
1070
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.
1071
1758
  onProgress({ phase: "Send recovery", failedMintQuotes });
1072
- await coco.ops.send.recovery.run();
1759
+ if (!degraded) await coco.ops.send.recovery.run();
1760
+ else await targeted(["send"]);
1073
1761
 
1074
1762
  onProgress({ phase: "Melt recovery", failedMintQuotes });
1075
- await coco.ops.melt.recovery.run();
1763
+ if (!degraded) await coco.ops.melt.recovery.run();
1764
+ else await targeted(["melt"]);
1076
1765
 
1077
1766
  onProgress({ phase: "Receive recovery", failedMintQuotes });
1078
1767
  if (receiveOperationIds) {
1079
1768
  // The pre-check already classified every executing receive by unique
1080
1769
  // input set. Recover only the conclusive retained operations; unresolved
1081
1770
  // groups stay untouched instead of falling back to Coco 1's expensive
1082
- // 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
+ );
1083
1778
  for (const operationId of receiveOperationIds) {
1779
+ if (options.shouldStop?.()) break;
1780
+ const mintUrl = mintByOperation.get(operationId);
1781
+ if (mintUrl && unreachableMints.has(mintUrl)) continue;
1084
1782
  try {
1085
- 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);
1086
1786
  } catch (error) {
1087
1787
  logger.warn("Targeted receive recovery did not complete", {
1088
1788
  operationId,
@@ -1090,12 +1790,19 @@ async function runWalletRecovery(
1090
1790
  });
1091
1791
  }
1092
1792
  }
1093
- } else {
1793
+ } else if (!degraded) {
1094
1794
  await coco.ops.receive.recovery.run();
1795
+ } else {
1796
+ await targeted(["receive"]);
1095
1797
  }
1096
1798
 
1097
1799
  onProgress({ phase: "Mint recovery", failedMintQuotes });
1098
- 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"]);
1099
1806
 
1100
1807
  onProgress({ phase: "done", failedMintQuotes });
1101
1808
  } finally {
@@ -1105,7 +1812,7 @@ async function runWalletRecovery(
1105
1812
 
1106
1813
  export async function createCocoClient(
1107
1814
  options: CreateCocoClientOptions = {},
1108
- ): Promise<CocodClient> {
1815
+ ): Promise<WalletClient> {
1109
1816
  const configDir = options.walletDir || options.configDir || defaultWalletDir();
1110
1817
  const configFile = join(configDir, "config.json");
1111
1818
  const dbPath = join(configDir, "coco.db");
@@ -1158,6 +1865,10 @@ export async function createCocoClient(
1158
1865
  const recoveryPromise = new Promise<void>((resolve) => {
1159
1866
  recoveryResolve = resolve;
1160
1867
  });
1868
+ const recoveryGate = createRecoveryGate();
1869
+ let disposed = false;
1870
+ const enqueueRecovery = createRunQueue();
1871
+ const recoveryOutstanding: RecoveryWork = new Map();
1161
1872
 
1162
1873
  try {
1163
1874
  startupProgress("Opening Cashu wallet database...");
@@ -1355,11 +2066,14 @@ export async function createCocoClient(
1355
2066
  }
1356
2067
  },
1357
2068
  receiveRecoveryOperationIds,
2069
+ (mints) => recoveryGate.publishStuckMints(mints),
2070
+ { cleanupLocalState: () => cleanupLocalRecoveryState(coco!, repo), outstanding: recoveryOutstanding, shouldStop: () => disposed },
1358
2071
  )
1359
2072
  .then(async () => {
1360
2073
  await syncReceiveReservations();
1361
2074
  recoveryDone = true;
1362
2075
  recoveryPhase = "done";
2076
+ recoveryGate.complete();
1363
2077
  recoveryResolve?.();
1364
2078
  startupProgress("Wallet recovery complete.");
1365
2079
  stopPendingMintSweep = startPendingMintSweep({
@@ -1368,12 +2082,13 @@ export async function createCocoClient(
1368
2082
  mintOperationService: (
1369
2083
  coco as unknown as { mintOperationService: MintOperationServiceCleanup }
1370
2084
  ).mintOperationService,
1371
- });
2085
+ }, recoveryOutstanding);
1372
2086
  })
1373
2087
  .catch((error) => {
1374
2088
  recoveryDone = true;
1375
2089
  recoveryPhase = "error";
1376
2090
  recoveryError = error instanceof Error ? error.message : String(error);
2091
+ recoveryGate.fail(recoveryError);
1377
2092
  recoveryResolve?.();
1378
2093
  startupProgress(`Wallet recovery failed: ${recoveryError}`);
1379
2094
  });
@@ -1396,19 +2111,33 @@ export async function createCocoClient(
1396
2111
  return api;
1397
2112
  };
1398
2113
 
1399
- let disposed = false;
2114
+ const assertOpen = () => { if (disposed) throw new Error("Wallet is shutting down"); };
1400
2115
 
1401
- /**
1402
- * Block a value-moving operation until background recovery has settled.
1403
- * Reads stay ungated so the daemon can report balances/status immediately.
1404
- */
1405
- const waitForRecovery = async (): Promise<void> => {
1406
- if (!recoveryDone) await recoveryPromise;
1407
- if (recoveryError) {
1408
- throw new Error(`Wallet is not ready: ${recoveryError}`);
1409
- }
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();
1410
2123
  };
1411
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
+
1412
2141
  return {
1413
2142
  async ping(): Promise<boolean> {
1414
2143
  try {
@@ -1419,7 +2148,7 @@ export async function createCocoClient(
1419
2148
  }
1420
2149
  },
1421
2150
 
1422
- async getStatus(): Promise<CocodState> {
2151
+ async getStatus(): Promise<WalletRuntimeState> {
1423
2152
  if (recoveryError) return "ERROR";
1424
2153
  if (!recoveryDone) return "RECOVERING";
1425
2154
  try {
@@ -1587,13 +2316,13 @@ export async function createCocoClient(
1587
2316
  },
1588
2317
 
1589
2318
  async receiveBolt11(amount: number, mintUrl?: string) {
1590
- await waitForRecovery();
1591
2319
  const targetMint = mintUrl
1592
2320
  ? normalizeMintUrl(mintUrl)
1593
2321
  : walletConfig.defaultMintUrl;
1594
2322
  if (!targetMint) {
1595
2323
  throw new Error("No trusted mint available for Lightning invoice");
1596
2324
  }
2325
+ await waitForRecovery(targetMint);
1597
2326
  const op = await coco.ops.mint.prepare({
1598
2327
  mintUrl: targetMint,
1599
2328
  amount,
@@ -1619,13 +2348,13 @@ export async function createCocoClient(
1619
2348
  },
1620
2349
 
1621
2350
  async sendCashu(amount: number, mintUrl?: string): Promise<string> {
1622
- await waitForRecovery();
1623
2351
  const targetMint = mintUrl
1624
2352
  ? normalizeMintUrl(mintUrl)
1625
2353
  : walletConfig.defaultMintUrl;
1626
2354
  if (!targetMint) {
1627
2355
  throw new Error("No trusted mint available for sending");
1628
2356
  }
2357
+ await waitForRecovery(targetMint);
1629
2358
  const prepared = await coco.ops.send.prepare({
1630
2359
  mintUrl: targetMint,
1631
2360
  amount,
@@ -1635,13 +2364,13 @@ export async function createCocoClient(
1635
2364
  },
1636
2365
 
1637
2366
  async sendBolt11(invoice: string, mintUrl?: string): Promise<string> {
1638
- await waitForRecovery();
1639
2367
  const targetMint = mintUrl
1640
2368
  ? normalizeMintUrl(mintUrl)
1641
2369
  : walletConfig.defaultMintUrl;
1642
2370
  if (!targetMint) {
1643
2371
  throw new Error("No trusted mint available for Lightning payment");
1644
2372
  }
2373
+ await waitForRecovery(targetMint);
1645
2374
  const prepared = await coco.ops.melt.prepare({
1646
2375
  mintUrl: targetMint,
1647
2376
  method: "bolt11",
@@ -1685,28 +2414,20 @@ export async function createCocoClient(
1685
2414
  },
1686
2415
 
1687
2416
  async dispose(): Promise<void> {
1688
- if (disposed) return;
1689
- disposed = true;
1690
- try {
1691
- // Let any in-flight recovery settle before closing the database from
1692
- // underneath it. The recovery promise resolves on success or failure.
1693
- await recoveryPromise;
1694
- await stopPendingMintSweep?.();
1695
- await coco.dispose();
1696
- } finally {
1697
- try {
1698
- database.close();
1699
- } finally {
1700
- releaseLegacyPidClaim();
1701
- releaseWalletPidClaim();
1702
- }
1703
- }
2417
+ await disposeRecovery().catch(error => {
2418
+ logger.warn("Wallet shutdown incomplete; database and ownership retained until recovery settles");
2419
+ throw error;
2420
+ });
1704
2421
  },
1705
2422
 
1706
2423
  async getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]> {
1707
2424
  return coco.history.getPaginatedHistory(offset, limit);
1708
2425
  },
1709
2426
 
2427
+ async getHistoryEntryById(id: string): Promise<HistoryEntry | null> {
2428
+ return coco.history.getHistoryEntryById(id);
2429
+ },
2430
+
1710
2431
  async getNpcAddress(): Promise<NpcAddress> {
1711
2432
  const info = await npcApi().getInfo();
1712
2433
  const name =
@@ -1748,6 +2469,7 @@ export async function createCocoClient(
1748
2469
  await waitForRecovery();
1749
2470
  const minAgeMs = options.minAgeMs ?? 7 * 24 * 60 * 60 * 1000;
1750
2471
  const dryRun = options.dryRun === true;
2472
+ const force = options.force === true;
1751
2473
  const nowMs = Date.now();
1752
2474
 
1753
2475
  const [pendingMints, inFlightSends, preparedMelts] = await Promise.all([
@@ -1775,6 +2497,8 @@ export async function createCocoClient(
1775
2497
  });
1776
2498
 
1777
2499
  const errors: WalletCleanupResult["errors"] = [];
2500
+ let failedMintQuotes = 0;
2501
+ let leftForRecovery = 0;
1778
2502
 
1779
2503
  if (!dryRun) {
1780
2504
  const mintService = (
@@ -1784,20 +2508,49 @@ export async function createCocoClient(
1784
2508
  ).mintOperationService;
1785
2509
 
1786
2510
  for (const op of selection.mintsToFail) {
1787
- try {
1788
- await mintService.failPendingOperation(
1789
- { id: op.id },
1790
- {
1791
- reason: "Expired unpaid mint quote cleaned up by routstrd",
1792
- retryable: false,
1793
- observedAt: nowMs,
1794
- },
1795
- );
1796
- } catch (error) {
1797
- errors.push({
1798
- operationId: op.id,
1799
- error: error instanceof Error ? error.message : String(error),
1800
- });
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
+ }
1801
2554
  }
1802
2555
  }
1803
2556
 
@@ -1824,8 +2577,15 @@ export async function createCocoClient(
1824
2577
  }
1825
2578
  }
1826
2579
 
2580
+ const mintSummary = summarizeMintCleanup({
2581
+ dryRun,
2582
+ candidates: selection.mintsToFail.length,
2583
+ failed: failedMintQuotes,
2584
+ leftForRecovery,
2585
+ });
1827
2586
  const actedOn =
1828
- selection.mintsToFail.length +
2587
+ (dryRun ? mintSummary.mintQuoteCandidates : mintSummary.failedMintQuotes) +
2588
+ leftForRecovery +
1829
2589
  selection.sendsToReclaim.length +
1830
2590
  selection.meltsToCancel.length;
1831
2591
  const skipped =
@@ -1834,12 +2594,63 @@ export async function createCocoClient(
1834
2594
 
1835
2595
  return {
1836
2596
  dryRun,
1837
- failedMintQuotes: selection.mintsToFail.length,
2597
+ ...mintSummary,
1838
2598
  reclaimedSends: selection.sendsToReclaim.length,
1839
2599
  cancelledMelts: selection.meltsToCancel.length,
1840
2600
  skipped,
1841
2601
  errors,
1842
2602
  };
1843
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
+ },
1844
2655
  };
1845
2656
  }