mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
A mint quote can be PAID at the mint while its local coco operation is still pending (the Lightning payment landed before expiry while the daemon was down) or even terminally failed. routstrd could strand those sats in two ways: - `routstrd wallet cleanup` failed expired pending quotes locally without asking the mint, so a quote paid before its invoice expired was marked failed and then skipped by every recovery sweep. - nothing re-issued a paid-but-unissued quote: coco's refresh turns the mint's expired-quote error into a terminal failure, and NUT-09 restore returns nothing when amount_issued is still 0. Changes: - add `failExpiredMintQuoteIfUnpaid`, the single mint-confirmed decision now used by both startup recovery and `wallet cleanup`. Cleanup gains `--force` for the old local-fail behaviour and reports `leftForRecovery`. - add `runMintQuoteRecovery` and pure selection/classification helpers in `mint-quote-recovery.ts`, exposed as `routstrd wallet recover` and `POST /wallet/recover`. It asks the mint per quote: PAID is minted from the operation's stored outputs, ISSUED restores proofs, UNPAID is left pending, and quotes the mint can no longer issue are reported instead of dropped. Failed operations are re-opened only by explicit operation id (`--include-failed`), since coco's pending listing never returns them, and a named failed operation is judged by the mint rather than a stale local observation. - add `createRunQueue` so explicit recovery requests are serialized, and a shared `outstanding` map so a quote check or finalize that outlives its timeout keeps blocking a retry until it settles. coco details the implementation is careful about: - `ops.mint.finalize` does not throw when the mint refuses (expired quote) or when an already-issued quote's proofs cannot be restored: it returns a terminal operation. Recovery inspects the returned state and error, counts only finalized-without-error as recovered, reserves `terminal` for failed/finalized-with-error, and leaves anything still pending to a later run. - `transitionToPending` spreads whatever it is handed and the sqlite repository rewrites every column, so `reopenFailedMintOperation` reloads and passes the full persisted row, and holds coco's per-operation lock across the read-check-write. That lock is fail-fast (it throws `OperationInProgressError` rather than waiting), so a re-open either holds the lock - blocking coco's own execute/finalize/recover paths, which share it - or writes nothing. Scope is narrow: `recordPendingObservation` and `failPendingOperation` write without that lock, so the claim is only that a re-open cannot clobber a concurrent executing/recovery pass. This shims private coco internals and is written against @cashu/coco-core 1.0.1, so it fails closed on a missing method and the integration tests must be re-run on any coco bump; changed behaviour under an unchanged name is not detectable by the guard alone. Dependency reproducibility and an upstream public locked reopen API remain release considerations. Known interop gap (not a supported path): NUT-09 allows `null` entries in the positional `signatures` array for unsigned outputs, but cashu-ts 3.7.1, which coco depends on, dereferences every entry while normalising amounts and so throws. A null-containing restore response therefore credits nothing and the operation is retried later. The fake mint keeps a switch for that shape and an explicit regression pins the current behaviour; this should be tracked upstream. Filtering the fixture to signed outputs for the success-path scenarios does not claim null responses are handled. Tests: - `mint-quote-recovery.manager.test.ts`: the production helper against a real Manager + sqlite service - full-row preservation, the fail-fast lock (including refusing to write while a processor holds it), and the `mint-op:pending` event. - `mint-quote-recovery.fake-mint.test.ts` + `testing/fake-mint.ts`: a real Manager over HTTP against an in-process mint signing with genuine secp256k1 blind signatures. Covers expired-but-PAID issuance with the operation's own blinded outputs (once), ISSUED restore without double credit, a mint refusal (20007) as terminal without credit, an issued-but-unrestorable quote as terminal without credit, the null-signature interop gap, a locked operation counted as busy, in-flight issuance not minted twice, a hung check tracked until it drains, coexistence with coco's own operation watcher/processor, and the composed failed -> re-open -> PAID issue -> spendable path with a stale UNPAID observation recovered by explicit id. - `createRunQueue` unit tests, adapter-backed helper tests, fail-closed behaviour, and the classify/select unit tests.
164 lines
5.4 KiB
TypeScript
164 lines
5.4 KiB
TypeScript
/**
|
|
* Re-opening a failed mint operation is the one genuinely destructive step of
|
|
* PAID-quote recovery, because coco's private `transitionToPending` spreads
|
|
* whatever it is handed and `SqliteMintOperationRepository.update` rewrites
|
|
* every column. A partial object such as `{ id }` is therefore rejected by the
|
|
* NOT NULL schema, and on a more permissive adapter would overwrite `quoteId`,
|
|
* `amount`, `request`, `lastObservedRemoteState` and `outputDataJson` with NULL,
|
|
* destroying the material needed to claim the paid sats.
|
|
*
|
|
* These tests drive the production `reopenFailedMintOperation` helper against a
|
|
* real coco sqlite repository through adapter-backed getOperation and
|
|
* transitionToPending implementations, so a regression at the helper/service
|
|
* boundary is caught rather than a mock standing in for it.
|
|
*/
|
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
import { Database } from "bun:sqlite";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { SqliteRepositories } from "@cashu/coco-sqlite-bun";
|
|
import { reopenFailedMintOperation } from "./coco-client";
|
|
|
|
const OUTPUT_DATA = [
|
|
{
|
|
blindedMessage: { amount: "210000", id: "00deadbeef", B_: "02deadbeef" },
|
|
blindingFactor: "1234567890",
|
|
secret: "aabbccdd",
|
|
},
|
|
];
|
|
|
|
function failedRow(state = "failed") {
|
|
return {
|
|
id: "op-1",
|
|
mintUrl: "https://mint.example.com",
|
|
quoteId: "quote-1",
|
|
state,
|
|
createdAt: 1_000,
|
|
updatedAt: 2_000,
|
|
error: state === "failed" ? "expired" : undefined,
|
|
method: "bolt11",
|
|
methodData: { method: "bolt11", data: {} },
|
|
amount: 210_000,
|
|
unit: "sat",
|
|
request: "lnbc1example",
|
|
expiry: 1_800_000_000,
|
|
pubkey: undefined,
|
|
lastObservedRemoteState: "PAID",
|
|
lastObservedRemoteStateAt: 3_000,
|
|
terminalFailure:
|
|
state === "failed" ? { reason: "expired", observedAt: 3_000 } : undefined,
|
|
outputData: OUTPUT_DATA,
|
|
};
|
|
}
|
|
|
|
describe("reopenFailedMintOperation against real coco sqlite", () => {
|
|
let dir: string | undefined;
|
|
let database: Database | undefined;
|
|
|
|
afterEach(() => {
|
|
database?.close();
|
|
database = undefined;
|
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
dir = undefined;
|
|
});
|
|
|
|
async function repos() {
|
|
dir = mkdtempSync(join(tmpdir(), "routstrd-reopen-"));
|
|
database = new Database(join(dir, "coco.db"));
|
|
const repositories = new SqliteRepositories({ database });
|
|
await repositories.init();
|
|
return repositories;
|
|
}
|
|
|
|
function readRow(repositories: SqliteRepositories, id: string) {
|
|
return repositories.mintOperationRepository.getById(id) as unknown as Promise<
|
|
Record<string, unknown> | null
|
|
>;
|
|
}
|
|
|
|
/**
|
|
* Adapter-backed stand-in for the private coco service methods the helper
|
|
* uses: getOperation reads and transitionToPending mirrors coco's
|
|
* spread-and-update implementation.
|
|
*/
|
|
function serviceOver(repositories: SqliteRepositories) {
|
|
return {
|
|
acquireOperationLock: async (_id: string) => () => {},
|
|
getOperation: (id: string) => readRow(repositories, id),
|
|
transitionToPending: async (
|
|
op: Record<string, unknown>,
|
|
error?: string,
|
|
) => {
|
|
await repositories.mintOperationRepository.update({
|
|
...op,
|
|
state: "pending",
|
|
error,
|
|
} as never);
|
|
},
|
|
};
|
|
}
|
|
|
|
it("re-opens a failed row without losing quote metadata or stored outputs", async () => {
|
|
const repositories = await repos();
|
|
await repositories.mintOperationRepository.create(
|
|
failedRow() as never,
|
|
);
|
|
|
|
const reopened = await reopenFailedMintOperation(
|
|
serviceOver(repositories),
|
|
"op-1",
|
|
);
|
|
|
|
expect(reopened).toBe(true);
|
|
const row = await readRow(repositories, "op-1");
|
|
expect(row?.state).toBe("pending");
|
|
expect(row?.quoteId).toBe("quote-1");
|
|
expect(row?.amount).toBe(210_000);
|
|
expect(row?.unit).toBe("sat");
|
|
expect(row?.request).toBe("lnbc1example");
|
|
expect(row?.lastObservedRemoteState).toBe("PAID");
|
|
expect(row?.outputData).toEqual(OUTPUT_DATA);
|
|
expect(row?.terminalFailure ?? undefined).toBeUndefined();
|
|
});
|
|
|
|
it("is a no-op when the operation is no longer failed", async () => {
|
|
const repositories = await repos();
|
|
await repositories.mintOperationRepository.create(
|
|
failedRow("finalized") as never,
|
|
);
|
|
|
|
const reopened = await reopenFailedMintOperation(
|
|
serviceOver(repositories),
|
|
"op-1",
|
|
);
|
|
|
|
expect(reopened).toBe(false);
|
|
const row = await readRow(repositories, "op-1");
|
|
expect(row?.state).toBe("finalized");
|
|
expect(row?.outputData).toEqual(OUTPUT_DATA);
|
|
});
|
|
|
|
it("rejects a partial row, which is why the helper reloads in full", async () => {
|
|
// Locks in the reason for reloading. If a future coco version accepts
|
|
// partial updates this fails, and the helper can be simplified rather than
|
|
// silently losing paid sats.
|
|
const repositories = await repos();
|
|
await repositories.mintOperationRepository.create(
|
|
failedRow() as never,
|
|
);
|
|
|
|
await expect(
|
|
repositories.mintOperationRepository.update({
|
|
id: "op-1",
|
|
state: "pending",
|
|
updatedAt: Date.now(),
|
|
} as never),
|
|
).rejects.toThrow();
|
|
|
|
const unchanged = await readRow(repositories, "op-1");
|
|
expect(unchanged?.state).toBe("failed");
|
|
expect(unchanged?.outputData).toEqual(OUTPUT_DATA);
|
|
});
|
|
});
|