diff --git a/src/daemon/wallet/doctor.test.ts b/src/daemon/wallet/doctor.test.ts new file mode 100644 index 0000000..da390bb --- /dev/null +++ b/src/daemon/wallet/doctor.test.ts @@ -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 { + 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 { + 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 { + 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", + ); + }); +}); diff --git a/src/daemon/wallet/doctor.ts b/src/daemon/wallet/doctor.ts new file mode 100644 index 0000000..5a04f76 --- /dev/null +++ b/src/daemon/wallet/doctor.ts @@ -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( + 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( + 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; +} + +/** + * 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"; +}