Files
routstrd/src
redshift f5bf6d9f1c feat(wallet): recover PAID mint quotes that were paid but never issued
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.
2026-09-30 23:00:10 +08:00
..
2026-03-19 16:25:57 +00:00