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.
- package/README.md +55 -15
- package/SECURITY.md +0 -1
- package/SKILL.md +267 -52
- package/dist/daemon/index.js +93747 -70861
- package/dist/index.js +65958 -42561
- package/package.json +3 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +323 -38
- package/src/daemon/http/request-body.ts +67 -0
- package/src/daemon/http/request-path.ts +39 -0
- package/src/daemon/index.ts +1 -1
- package/src/daemon/models.ts +46 -11
- package/src/daemon/wallet/auto-refill.ts +20 -8
- package/src/daemon/wallet/cleanup.ts +16 -2
- package/src/daemon/wallet/coco-client.ts +941 -115
- package/src/daemon/wallet/index.ts +168 -50
- package/src/daemon/wallet/mint-quote-recovery.ts +119 -0
- package/src/daemon/wallet/recovery-probe.ts +312 -0
- package/src/daemon/wallet/recovery-work.ts +45 -0
- package/src/daemon/wallet/testing/fake-mint.ts +347 -0
- package/src/daemon/wallet/trusted-mints.ts +87 -0
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +189 -34
- package/src/integrations/registry.ts +14 -0
- package/src/tui/usage/render.ts +161 -22
- package/src/utils/config.ts +3 -2
- package/src/utils/cooldowns.ts +134 -0
- package/src/utils/daemon-client.ts +53 -9
- package/src/utils/history.ts +9 -0
- package/src/utils/with-timeout.ts +21 -0
- package/src/TUI refactor.md +0 -113
- package/src/daemon/wallet/cocod-client.ts +0 -505
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
+
WalletClient,
|
|
34
|
+
WalletRuntimeState,
|
|
33
35
|
NpcAddress,
|
|
34
36
|
NpcUsernameResult,
|
|
35
37
|
WalletCleanupOptions,
|
|
36
38
|
WalletCleanupResult,
|
|
37
39
|
WalletRecoveryProgress,
|
|
38
|
-
} from "./
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
-
|
|
2114
|
+
const assertOpen = () => { if (disposed) throw new Error("Wallet is shutting down"); };
|
|
1385
2115
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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<
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|