mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
feat(wallet): add pure doctor helpers for wallet health checks
Selection and classification logic for the upcoming wallet doctor health section, kept pure and network-free following the cleanup.ts / mint-quote-recovery.ts pattern: - selectRecentMintQuotes: pending quotes created inside the recent window (default 1h) worth a fresh UNPAID check. - selectPaidUnissuedCandidates: pending/executing ops plus failed ops last seen PAID/ISSUED (or errored before any observation) - the stuck scenario wallet recover remediates. - classifyPaidUnissued: maps a fresh NUT-04 state through recovery's own classifyMintQuoteObservation so doctor and recovery agree, and builds a finding with the matching recover command (--include-failed when the op must be re-opened). - toUnpaidQuote: recent-unpaid finding with age and time-to-expiry. - classifyStuckMelt: prepared (proofs reserved, never attempted), in-flight (payment maybe in progress), and failed-locked (melt failed but input proofs never released) findings with remediation hints. - doctorReportSeverity: ok/warning/critical rollup that will drive the doctor exit code. The doctor is read-only by design: nothing here observes-and-persists, finalizes, or fails operations.
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
classifyPaidUnissued,
|
||||
classifyStuckMelt,
|
||||
doctorReportSeverity,
|
||||
mintQuoteStateToCategory,
|
||||
selectPaidUnissuedCandidates,
|
||||
selectRecentMintQuotes,
|
||||
toUnpaidQuote,
|
||||
type DoctorMeltOperation,
|
||||
type DoctorMintOperation,
|
||||
type WalletDoctorReport,
|
||||
} from "./doctor";
|
||||
|
||||
const NOW_MS = 1_800_000_000_000;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function mintOp(
|
||||
overrides: Partial<DoctorMintOperation> = {},
|
||||
): DoctorMintOperation {
|
||||
return {
|
||||
id: "op-1",
|
||||
mintUrl: "https://mint.example",
|
||||
quoteId: "quote-1",
|
||||
state: "pending",
|
||||
amount: 100,
|
||||
expiry: NOW_MS / 1000 + 600,
|
||||
createdAt: NOW_MS - 10 * 60 * 1000,
|
||||
updatedAt: NOW_MS - 5 * 60 * 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function meltOp(
|
||||
overrides: Partial<DoctorMeltOperation> = {},
|
||||
): DoctorMeltOperation {
|
||||
return {
|
||||
id: "melt-1",
|
||||
mintUrl: "https://mint.example",
|
||||
quoteId: "melt-quote-1",
|
||||
state: "prepared",
|
||||
amount: 2_100,
|
||||
feeReserve: 12,
|
||||
inputProofSecrets: ["secret-a", "secret-b"],
|
||||
createdAt: NOW_MS - 3 * DAY_MS,
|
||||
updatedAt: NOW_MS - 3 * DAY_MS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function report(
|
||||
overrides: Partial<WalletDoctorReport> = {},
|
||||
): WalletDoctorReport {
|
||||
return {
|
||||
generatedAt: NOW_MS,
|
||||
mints: [],
|
||||
unpaidQuotes: [],
|
||||
paidUnissued: [],
|
||||
stuckMelts: [],
|
||||
uncheckedQuotes: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mintQuoteStateToCategory", () => {
|
||||
it("maps NUT-04 states onto coco's pending-check categories", () => {
|
||||
expect(mintQuoteStateToCategory("UNPAID")).toBe("waiting");
|
||||
expect(mintQuoteStateToCategory("PAID")).toBe("ready");
|
||||
expect(mintQuoteStateToCategory("ISSUED")).toBe("completed");
|
||||
});
|
||||
|
||||
it("is case-insensitive and tolerant of whitespace", () => {
|
||||
expect(mintQuoteStateToCategory(" paid ")).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns null for unknown states", () => {
|
||||
expect(mintQuoteStateToCategory("EXPIRED")).toBeNull();
|
||||
expect(mintQuoteStateToCategory("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRecentMintQuotes", () => {
|
||||
it("selects pending quotes created inside the window", () => {
|
||||
const recent = mintOp();
|
||||
const old = mintOp({ id: "op-2", createdAt: NOW_MS - 2 * HOUR_MS });
|
||||
expect(selectRecentMintQuotes([recent, old], NOW_MS)).toEqual([recent]);
|
||||
});
|
||||
|
||||
it("includes executing operations (crash mid-mint)", () => {
|
||||
const executing = mintOp({ state: "executing" });
|
||||
expect(selectRecentMintQuotes([executing], NOW_MS)).toEqual([executing]);
|
||||
});
|
||||
|
||||
it("ignores finalized/failed operations and quotes without a quote id", () => {
|
||||
const finalized = mintOp({ id: "op-2", state: "finalized" });
|
||||
const failed = mintOp({ id: "op-3", state: "failed" });
|
||||
const noQuote = mintOp({ id: "op-4", quoteId: undefined });
|
||||
expect(
|
||||
selectRecentMintQuotes([finalized, failed, noQuote], NOW_MS),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a quote created exactly at the window edge out", () => {
|
||||
const edge = mintOp({ createdAt: NOW_MS - HOUR_MS });
|
||||
expect(selectRecentMintQuotes([edge], NOW_MS)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectPaidUnissuedCandidates", () => {
|
||||
it("selects every pending/executing operation with a quote id", () => {
|
||||
const pending = mintOp();
|
||||
const executing = mintOp({ id: "op-2", state: "executing" });
|
||||
expect(selectPaidUnissuedCandidates([pending, executing])).toEqual([
|
||||
pending,
|
||||
executing,
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects failed operations last seen PAID or ISSUED", () => {
|
||||
const paid = mintOp({ state: "failed", lastObservedRemoteState: "PAID" });
|
||||
const issued = mintOp({
|
||||
id: "op-2",
|
||||
state: "failed",
|
||||
lastObservedRemoteState: "ISSUED",
|
||||
});
|
||||
expect(selectPaidUnissuedCandidates([paid, issued])).toEqual([
|
||||
paid,
|
||||
issued,
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects failed operations that errored before any remote observation", () => {
|
||||
const unobserved = mintOp({ state: "failed", error: "mint unreachable" });
|
||||
expect(selectPaidUnissuedCandidates([unobserved])).toEqual([unobserved]);
|
||||
});
|
||||
|
||||
it("ignores failed quotes last seen UNPAID - they outnumber recoverable ones", () => {
|
||||
const unpaid = mintOp({
|
||||
state: "failed",
|
||||
lastObservedRemoteState: "UNPAID",
|
||||
});
|
||||
expect(selectPaidUnissuedCandidates([unpaid])).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores finalized operations and operations without a quote id", () => {
|
||||
const finalized = mintOp({ state: "finalized" });
|
||||
const noQuote = mintOp({ id: "op-2", quoteId: undefined });
|
||||
expect(selectPaidUnissuedCandidates([finalized, noQuote])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyPaidUnissued", () => {
|
||||
it("flags a pending quote the mint reports PAID", () => {
|
||||
const finding = classifyPaidUnissued(mintOp(), "PAID");
|
||||
expect(finding).toMatchObject({
|
||||
operationId: "op-1",
|
||||
remoteState: "PAID",
|
||||
localState: "pending",
|
||||
remediation: "routstrd wallet recover --op op-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags an issued-but-unfinalized quote", () => {
|
||||
const finding = classifyPaidUnissued(mintOp(), "ISSUED");
|
||||
expect(finding?.remoteState).toBe("ISSUED");
|
||||
});
|
||||
|
||||
it("adds --include-failed for failed operations so recovery re-opens them", () => {
|
||||
const finding = classifyPaidUnissued(
|
||||
mintOp({ state: "failed", lastObservedRemoteState: "PAID" }),
|
||||
"PAID",
|
||||
);
|
||||
expect(finding?.remediation).toBe(
|
||||
"routstrd wallet recover --op op-1 --include-failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces coco's persisted mint rejection when present", () => {
|
||||
const finding = classifyPaidUnissued(
|
||||
mintOp({ error: "keyset id inactive." }),
|
||||
"PAID",
|
||||
);
|
||||
expect(finding?.error).toBe("keyset id inactive.");
|
||||
});
|
||||
|
||||
it("returns null for UNPAID and unknown remote states", () => {
|
||||
expect(classifyPaidUnissued(mintOp(), "UNPAID")).toBeNull();
|
||||
expect(classifyPaidUnissued(mintOp(), "GARBAGE")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toUnpaidQuote", () => {
|
||||
it("reports age and time-to-expiry", () => {
|
||||
const entry = toUnpaidQuote(mintOp(), NOW_MS);
|
||||
expect(entry).toEqual({
|
||||
operationId: "op-1",
|
||||
quoteId: "quote-1",
|
||||
mintUrl: "https://mint.example",
|
||||
amount: 100,
|
||||
ageMs: 10 * 60 * 1000,
|
||||
expiresInMs: 600_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits expiry when unknown and reports a negative drift when expired", () => {
|
||||
expect(toUnpaidQuote(mintOp({ expiry: 0 }), NOW_MS).expiresInMs).toBeUndefined();
|
||||
const expired = toUnpaidQuote(
|
||||
mintOp({ expiry: NOW_MS / 1000 - 60 }),
|
||||
NOW_MS,
|
||||
);
|
||||
expect(expired.expiresInMs).toBe(-60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyStuckMelt", () => {
|
||||
const ctx = { nowMs: NOW_MS };
|
||||
|
||||
it("flags old prepared melts with locked input proofs", () => {
|
||||
const finding = classifyStuckMelt(meltOp(), ctx);
|
||||
expect(finding).toMatchObject({
|
||||
kind: "prepared",
|
||||
lockedSecrets: 2,
|
||||
remediation: "routstrd wallet cleanup",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores freshly prepared melts", () => {
|
||||
const fresh = meltOp({ createdAt: NOW_MS - 1000, updatedAt: NOW_MS - 1000 });
|
||||
expect(classifyStuckMelt(fresh, ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("flags in-flight melts at any age", () => {
|
||||
const pending = meltOp({ state: "pending", updatedAt: NOW_MS - 1000 });
|
||||
const finding = classifyStuckMelt(pending, ctx);
|
||||
expect(finding?.kind).toBe("in-flight");
|
||||
const executing = meltOp({ state: "executing" });
|
||||
expect(classifyStuckMelt(executing, ctx)?.kind).toBe("in-flight");
|
||||
});
|
||||
|
||||
it("flags failed melts whose proofs are still locked", () => {
|
||||
const failed = meltOp({ state: "failed" });
|
||||
const inflight = new Set(["secret-a", "unrelated"]);
|
||||
const finding = classifyStuckMelt(failed, { ...ctx, inflightSecrets: inflight });
|
||||
expect(finding).toMatchObject({ kind: "failed-locked", lockedSecrets: 1 });
|
||||
});
|
||||
|
||||
it("ignores failed melts whose proofs were released", () => {
|
||||
const failed = meltOp({ state: "failed" });
|
||||
expect(
|
||||
classifyStuckMelt(failed, { ...ctx, inflightSecrets: new Set(["other"]) }),
|
||||
).toBeNull();
|
||||
expect(classifyStuckMelt(failed, ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores finalized, rolled-back and init melts", () => {
|
||||
for (const state of ["finalized", "rolled_back", "init"] as const) {
|
||||
expect(classifyStuckMelt(meltOp({ state }), ctx)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctorReportSeverity", () => {
|
||||
it("is ok for an empty healthy report", () => {
|
||||
expect(doctorReportSeverity(report())).toBe("ok");
|
||||
});
|
||||
|
||||
it("is critical when a mint is unreachable", () => {
|
||||
expect(
|
||||
doctorReportSeverity(
|
||||
report({
|
||||
mints: [{ mintUrl: "https://mint.example", reachable: false }],
|
||||
}),
|
||||
),
|
||||
).toBe("critical");
|
||||
});
|
||||
|
||||
it("is critical when paid sats were never issued", () => {
|
||||
const finding = classifyPaidUnissued(mintOp(), "PAID")!;
|
||||
expect(
|
||||
doctorReportSeverity(report({ paidUnissued: [finding] })),
|
||||
).toBe("critical");
|
||||
});
|
||||
|
||||
it("is critical when a failed melt still locks proofs", () => {
|
||||
const stuck = classifyStuckMelt(meltOp({ state: "failed" }), {
|
||||
nowMs: NOW_MS,
|
||||
inflightSecrets: new Set(["secret-a"]),
|
||||
})!;
|
||||
expect(doctorReportSeverity(report({ stuckMelts: [stuck] }))).toBe(
|
||||
"critical",
|
||||
);
|
||||
});
|
||||
|
||||
it("is warning for unpaid quotes, non-critical melts or unchecked quotes", () => {
|
||||
expect(
|
||||
doctorReportSeverity(
|
||||
report({ unpaidQuotes: [toUnpaidQuote(mintOp(), NOW_MS)] }),
|
||||
),
|
||||
).toBe("warning");
|
||||
const prepared = classifyStuckMelt(meltOp(), { nowMs: NOW_MS })!;
|
||||
expect(
|
||||
doctorReportSeverity(report({ stuckMelts: [prepared] })),
|
||||
).toBe("warning");
|
||||
expect(doctorReportSeverity(report({ uncheckedQuotes: 2 }))).toBe(
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Pure selection and classification helpers for `routstrd wallet doctor`.
|
||||
*
|
||||
* The doctor is a READ-ONLY health check. These helpers decide *what* is
|
||||
* worth reporting from operations the wallet already knows about; the live
|
||||
* data gathering (trusted mint list, NUT-06 mint probes, NUT-04 quote state
|
||||
* reads, inflight proof lookups) is wired up by the in-process coco wallet
|
||||
* client. Nothing here - and nothing the client does on behalf of the
|
||||
* doctor - may mutate wallet state: no observe-and-persist, no finalize, no
|
||||
* fail. Remediation is always left to the operator via the recovery and
|
||||
* cleanup commands each finding points to.
|
||||
*
|
||||
* Keeping the decisions here makes them unit testable without a wallet
|
||||
* database or network access.
|
||||
*/
|
||||
|
||||
import {
|
||||
classifyMintQuoteObservation,
|
||||
type PendingMintCheckCategory,
|
||||
} from "./mint-quote-recovery";
|
||||
|
||||
/** How far back "recent" unpaid quotes reach. */
|
||||
export const DOCTOR_RECENT_QUOTE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Prepared melts younger than this are considered normal, not stuck. */
|
||||
export const DOCTOR_STUCK_MELT_MIN_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type DoctorSeverity = "ok" | "warning" | "critical";
|
||||
|
||||
/** Subset of coco's mint operation rows the doctor needs. */
|
||||
export interface DoctorMintOperation {
|
||||
id: string;
|
||||
mintUrl: string;
|
||||
quoteId?: string;
|
||||
/** coco state: init | pending | executing | finalized | failed. */
|
||||
state: string;
|
||||
/** Quote amount in sats. */
|
||||
amount: number;
|
||||
/** Quote expiry in epoch seconds. `0` means unknown/not applicable. */
|
||||
expiry: number;
|
||||
/** Creation time in epoch milliseconds. */
|
||||
createdAt: number;
|
||||
/** Last update time in epoch milliseconds. */
|
||||
updatedAt: number;
|
||||
/** Last quote state observed from the mint (UNPAID, PAID, ISSUED). */
|
||||
lastObservedRemoteState?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Subset of coco's melt operation rows the doctor needs. */
|
||||
export interface DoctorMeltOperation {
|
||||
id: string;
|
||||
mintUrl: string;
|
||||
quoteId?: string;
|
||||
/**
|
||||
* coco state: init | prepared | executing | pending | failed | finalized |
|
||||
* rolling_back | rolled_back.
|
||||
*/
|
||||
state: string;
|
||||
/** Invoice amount in sats. */
|
||||
amount: number;
|
||||
/** Fee reserve locked alongside the amount, in sats. */
|
||||
feeReserve: number;
|
||||
/** Secrets of the input proofs this operation reserved. */
|
||||
inputProofSecrets: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result of probing one mint's NUT-06 info endpoint. */
|
||||
export interface DoctorMintProbe {
|
||||
mintUrl: string;
|
||||
reachable: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A pending quote created recently that the mint still reports UNPAID. */
|
||||
export interface DoctorUnpaidQuote {
|
||||
operationId: string;
|
||||
quoteId?: string;
|
||||
mintUrl: string;
|
||||
amount: number;
|
||||
ageMs: number;
|
||||
/** Milliseconds until the bolt11 quote expires; negative when expired. */
|
||||
expiresInMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A quote the mint reports PAID (claimable) or ISSUED (restorable) while the
|
||||
* local operation never finalized - the stuck scenario `wallet recover`
|
||||
* exists to fix.
|
||||
*/
|
||||
export interface DoctorPaidUnissuedQuote {
|
||||
operationId: string;
|
||||
quoteId?: string;
|
||||
mintUrl: string;
|
||||
amount: number;
|
||||
localState: string;
|
||||
remoteState: "PAID" | "ISSUED";
|
||||
/**
|
||||
* coco's persisted error, when any. A mint rejection such as an inactive
|
||||
* keyset means recovery will keep retrying the stored outputs without
|
||||
* success until the underlying cause is resolved.
|
||||
*/
|
||||
error?: string;
|
||||
/** Suggested remediation command. */
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
/** A melt operation holding (or having leaked) locked proofs. */
|
||||
export interface DoctorStuckMelt {
|
||||
operationId: string;
|
||||
quoteId?: string;
|
||||
mintUrl: string;
|
||||
amount: number;
|
||||
feeReserve: number;
|
||||
ageMs: number;
|
||||
/**
|
||||
* - `prepared`: proofs reserved, payment never attempted.
|
||||
* - `in-flight`: payment may be in flight with the mint.
|
||||
* - `failed-locked`: the melt failed but its input proofs were never
|
||||
* released - the worst case, sats are locked locally.
|
||||
*/
|
||||
kind: "prepared" | "in-flight" | "failed-locked";
|
||||
/** How many of the operation's input proofs are still locked. */
|
||||
lockedSecrets: number;
|
||||
error?: string;
|
||||
/** Suggested remediation command or explanation. */
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
/** Structured result of the doctor's live checks. */
|
||||
export interface WalletDoctorReport {
|
||||
generatedAt: number;
|
||||
/** NUT-06 reachability probe per trusted mint. */
|
||||
mints: DoctorMintProbe[];
|
||||
/** Recent pending quotes the mint still reports UNPAID. */
|
||||
unpaidQuotes: DoctorUnpaidQuote[];
|
||||
/** Quotes paid/issued at the mint but never finalized locally. */
|
||||
paidUnissued: DoctorPaidUnissuedQuote[];
|
||||
/** Melt operations holding locked proofs. */
|
||||
stuckMelts: DoctorStuckMelt[];
|
||||
/** Quotes skipped because the probe budget ran out. */
|
||||
uncheckedQuotes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw NUT-04 quote state onto coco's pending-check category, so the
|
||||
* doctor classifies remote states with the exact same rules as recovery
|
||||
* (see classifyMintQuoteObservation in mint-quote-recovery.ts).
|
||||
*/
|
||||
export function mintQuoteStateToCategory(
|
||||
state: string,
|
||||
): PendingMintCheckCategory | null {
|
||||
switch (state.trim().toUpperCase()) {
|
||||
case "UNPAID":
|
||||
return "waiting";
|
||||
case "PAID":
|
||||
return "ready";
|
||||
case "ISSUED":
|
||||
return "completed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent pending (or executing) mint quotes worth a fresh UNPAID check.
|
||||
*
|
||||
* Only quotes created inside `windowMs` are reported: older pending quotes
|
||||
* are either expired (cleanup's job) or long-forgotten invoices, and listing
|
||||
* them would drown out what the operator actually wants - "did a payment I
|
||||
* just started get stuck?". Operations without a quote id cannot be checked
|
||||
* with the mint and are skipped.
|
||||
*/
|
||||
export function selectRecentMintQuotes<T extends DoctorMintOperation>(
|
||||
ops: T[],
|
||||
nowMs: number,
|
||||
windowMs: number = DOCTOR_RECENT_QUOTE_WINDOW_MS,
|
||||
): T[] {
|
||||
return ops.filter(
|
||||
(op) =>
|
||||
(op.state === "pending" || op.state === "executing") &&
|
||||
typeof op.quoteId === "string" &&
|
||||
op.quoteId.length > 0 &&
|
||||
nowMs - op.createdAt < windowMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations whose remote state could reveal claimable sats.
|
||||
*
|
||||
* Every pending/executing operation qualifies, matching recovery's candidate
|
||||
* rule: only the mint knows whether a quote was paid while the daemon was
|
||||
* down. Failed operations qualify only when the last observation recorded
|
||||
* PAID/ISSUED - failed quotes last seen UNPAID vastly outnumber recoverable
|
||||
* ones, and probing them all would hammer mints for no expected gain. The
|
||||
* one exception is a failed operation with *no* recorded observation and a
|
||||
* persisted error, which failed without ever learning the remote state.
|
||||
*/
|
||||
export function selectPaidUnissuedCandidates<T extends DoctorMintOperation>(
|
||||
ops: T[],
|
||||
): T[] {
|
||||
return ops.filter((op) => {
|
||||
if (!op.quoteId) return false;
|
||||
if (op.state === "pending" || op.state === "executing") return true;
|
||||
if (op.state !== "failed") return false;
|
||||
return (
|
||||
op.lastObservedRemoteState === "PAID" ||
|
||||
op.lastObservedRemoteState === "ISSUED" ||
|
||||
(op.lastObservedRemoteState === undefined && op.error !== undefined)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a paid-but-not-issued finding from a fresh remote quote state, or
|
||||
* return null when the mint's answer means there is nothing to recover.
|
||||
*/
|
||||
export function classifyPaidUnissued(
|
||||
op: DoctorMintOperation,
|
||||
remoteState: string,
|
||||
): DoctorPaidUnissuedQuote | null {
|
||||
const category = mintQuoteStateToCategory(remoteState);
|
||||
if (!category) return null;
|
||||
const decision = classifyMintQuoteObservation(category);
|
||||
if (decision.action !== "finalize") return null;
|
||||
const needsReopen = op.state === "failed";
|
||||
return {
|
||||
operationId: op.id,
|
||||
quoteId: op.quoteId,
|
||||
mintUrl: op.mintUrl,
|
||||
amount: op.amount,
|
||||
localState: op.state,
|
||||
remoteState: decision.observedRemoteState,
|
||||
error: op.error,
|
||||
remediation:
|
||||
`routstrd wallet recover --op ${op.id}` +
|
||||
(needsReopen ? " --include-failed" : ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an unpaid-quote finding for a recently created pending quote. */
|
||||
export function toUnpaidQuote(
|
||||
op: DoctorMintOperation,
|
||||
nowMs: number,
|
||||
): DoctorUnpaidQuote {
|
||||
return {
|
||||
operationId: op.id,
|
||||
quoteId: op.quoteId,
|
||||
mintUrl: op.mintUrl,
|
||||
amount: op.amount,
|
||||
ageMs: Math.max(0, nowMs - op.createdAt),
|
||||
expiresInMs: op.expiry > 0 ? op.expiry * 1000 - nowMs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StuckMeltContext {
|
||||
nowMs: number;
|
||||
/** Prepared melts younger than this are not reported. */
|
||||
minAgeMs?: number;
|
||||
/**
|
||||
* Secrets of proofs currently locked (`inflight` state or reserved via
|
||||
* `usedByOperationId`). Used to detect failed melts whose proofs were
|
||||
* never released.
|
||||
*/
|
||||
inflightSecrets?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one melt operation as a stuck-melt finding, or return null when
|
||||
* the operation is healthy.
|
||||
*
|
||||
* - `prepared` operations reserve their input proofs but never attempted a
|
||||
* payment; past `minAgeMs` the reservation is almost certainly abandoned.
|
||||
* `wallet cleanup` cancels them and releases the proofs.
|
||||
* - `executing`/`pending` operations may have a payment in flight with the
|
||||
* mint; they are reported at any age because their proofs are locked until
|
||||
* the mint answers.
|
||||
* - `failed` operations should have released their proofs. When any input
|
||||
* secret is still locked, the rollback never happened and the sats are
|
||||
* stranded locally until melt recovery re-runs.
|
||||
*/
|
||||
export function classifyStuckMelt(
|
||||
op: DoctorMeltOperation,
|
||||
context: StuckMeltContext,
|
||||
): DoctorStuckMelt | null {
|
||||
const { nowMs, minAgeMs = DOCTOR_STUCK_MELT_MIN_AGE_MS } = context;
|
||||
const ageMs = Math.max(0, nowMs - op.updatedAt);
|
||||
const base = {
|
||||
operationId: op.id,
|
||||
quoteId: op.quoteId,
|
||||
mintUrl: op.mintUrl,
|
||||
amount: op.amount,
|
||||
feeReserve: op.feeReserve,
|
||||
ageMs,
|
||||
error: op.error,
|
||||
};
|
||||
|
||||
if (op.state === "prepared") {
|
||||
if (ageMs < minAgeMs) return null;
|
||||
return {
|
||||
...base,
|
||||
kind: "prepared",
|
||||
lockedSecrets: op.inputProofSecrets.length,
|
||||
remediation: "routstrd wallet cleanup",
|
||||
};
|
||||
}
|
||||
|
||||
if (op.state === "executing" || op.state === "pending") {
|
||||
return {
|
||||
...base,
|
||||
kind: "in-flight",
|
||||
lockedSecrets: op.inputProofSecrets.length,
|
||||
remediation:
|
||||
"payment may be in flight; inspect with routstrd history --json",
|
||||
};
|
||||
}
|
||||
|
||||
if (op.state === "failed") {
|
||||
const locked = context.inflightSecrets
|
||||
? op.inputProofSecrets.filter((secret) =>
|
||||
context.inflightSecrets!.has(secret),
|
||||
)
|
||||
: [];
|
||||
if (locked.length === 0) return null;
|
||||
return {
|
||||
...base,
|
||||
kind: "failed-locked",
|
||||
lockedSecrets: locked.length,
|
||||
remediation:
|
||||
"restart the daemon to re-run melt recovery; if proofs stay locked, report this operation id",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall severity of a doctor report, which drives the CLI exit code.
|
||||
*
|
||||
* Critical means money is provably at risk or a mint cannot be reached at
|
||||
* all; warning means something deserves attention but no sats are stranded.
|
||||
*/
|
||||
export function doctorReportSeverity(
|
||||
report: WalletDoctorReport,
|
||||
): DoctorSeverity {
|
||||
if (report.mints.some((probe) => !probe.reachable)) return "critical";
|
||||
if (report.paidUnissued.length > 0) return "critical";
|
||||
if (report.stuckMelts.some((melt) => melt.kind === "failed-locked")) {
|
||||
return "critical";
|
||||
}
|
||||
if (
|
||||
report.unpaidQuotes.length > 0 ||
|
||||
report.stuckMelts.length > 0 ||
|
||||
report.uncheckedQuotes > 0
|
||||
) {
|
||||
return "warning";
|
||||
}
|
||||
return "ok";
|
||||
}
|
||||
Reference in New Issue
Block a user