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.
- package/README.md +22 -1
- package/SKILL.md +29 -4
- package/dist/daemon/index.js +93713 -72282
- package/dist/index.js +65080 -43216
- package/package.json +2 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +318 -27
- 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 +921 -110
- 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 +3 -3
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +53 -3
- 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/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,
|
|
@@ -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
|
-
|
|
633
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
2114
|
+
const assertOpen = () => { if (disposed) throw new Error("Wallet is shutting down"); };
|
|
1400
2115
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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<
|
|
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
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|