Skip to content

Cashu: mint quote lock key is random and unrecoverable; paid quotes are abandoned when the app closes #809

Description

@martinapoltergan

Summary

The bundled NDK Cashu wallet can permanently strand user funds at a mint. A paid
mint quote is abandoned if the app is not open when the Lightning payment lands,
and the key needed to redeem that quote is random and stored only next to the
local quote record — so once local data is lost, the sats are unrecoverable by
anyone, including the mint.

This is not hypothetical. Two users of an SK coordinator hit it in ten days:
263 714 sats (now unrecoverable) and 147 542 sats (possibly still recoverable).
In both cases the coordinator's Lightning payment to mint.minibits.cash
SUCCEEDED with a preimage, and the mint reports the quote as paid / 0 issued.

The Minibits operator asked us directly to report this upstream, and phrased the
two requirements themselves:

  • wallet MUST derive mint quote keys from the seed if it owns them (ie user
    does not provide their own)
  • wallet MUST handle intermittent network and other failures to mint and keep
    request payloads, then retry the mint requests using mint's idempotent cache
    or otherwise provide user the option to retry the minting.

Versions: ndk 0.8.4-dev.8 (bitblik app depends on ndk: ^0.8.4-dev.0).

Defect 1 — the quote lock key is random and can never be re-derived

  • packages/ndk/lib/data_layer/repositories/cashu/cashu_repo_impl.dart:164
    getMintQuote() creates CashuKeypair.generateCashuKeyPair() for every quote
    and sends quoteKey.publicKey to the mint as the NUT-20 pubkey lock.
  • packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart:18
    the private key is Helpers.getSecureRandomHex(32) — pure entropy, unrelated
    to the wallet seed.
  • packages/ndk/lib/domain_layer/entities/cashu/cashu_quote.dart:66
    the keypair is persisted only inside the quote record's JSON.

Because the mint locks issuance to that pubkey, only the holder of that private
key can ever mint the ecash. The seed does not reproduce it — seed restore
returns already-minted proofs, not this key. So if the local quote record is lost
(cleared browser storage, reinstall, restored DB), the paid quote becomes
permanently unredeemable. The mint cannot help either: NUT-20 requires the
signature it no longer has a counterpart for.

This is what makes the failure terminal rather than merely annoying.

Defect 2 — minting is not resumable, which is what strands the quote

  • The whole flow lives in one async generator in
    packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart: it polls for payment
    on CashuConfig.FUNDING_CHECK_INTERVAL (~line 898) and only afterwards calls
    mintTokens() (~line 911).
  • _pendingTransactions (line 92) is an in-memory Set. There is no startup
    scan, and nothing ever revisits a persisted pending quote.
  • Consequently, if the app is closed or the page reloaded while the payment is in
    flight, the stream dies, the payment arrives, and no code path ever requests
    the signatures. The UI offers no retry.

Defect 2 creates the stranded quote; Defect 1 makes it unrecoverable.

Why this matters more than a normal bug

The amounts are not zap-sized. Users reach this state by doing nothing wrong —
closing an app during a payment is ordinary behaviour. From the user's side it
looks exactly like theft by the coordinator or the mint, and the coordinator has
to prove with preimages that the money did leave and did arrive. The mint
operator's own words: "providing half-baked wallet is much worse than providing
none."

Suggested fix

  1. Derive the quote key from the seed. If the wallet owns the key, it must be
    reconstructible: a dedicated derivation path indexed by a quote counter, so
    any restored wallet can re-sign for any quote it ever created. A key supplied
    by the user stays the user's responsibility.
  2. Persist pending mint quotes and resume them. Re-drive them on startup and
    on wallet open, and expose an explicit "retry minting" action. The mint's mint
    request is idempotent/cached, so re-requesting is safe.
  3. Until 1 ships, warn before anything that clears local data while a quote is
    pending, and surface pending quotes in the UI so they are not invisible.

Fix 1 alone would have saved both users: even with no retry button, a
seed-derived key lets the ecash be minted later, by hand if necessary.

Proposed patch

I have implemented fix 1 and the recovery path on a branch and can open a PR:

  • DerivationType.quoteKey(2) added to the existing HMAC-SHA256 KDF, so quote
    keys reuse the scheme already used for NUT-13 secrets and blinding factors,
    with a distinct type byte. Bound to sha256(mintUrl) rather than a keyset id,
    because no keyset is chosen yet when the quote is created.
  • CashuKeyDerivation.deriveQuoteKey({seedBytes, counter, mintUrl}) and
    CashuKeypair.fromPrivateKeyHex().
  • getMintQuote() no longer generates entropy; it takes the derived keypair and
    the counter it came from. The counter is obtained from the existing
    getAndIncrementDerivationCounter under a reserved slot quote-key, which
    cannot collide with a real keyset id since those are always hex.
  • CashuQuote.quoteKeyCounter is persisted; quotes written before this land
    deserialize as -1, so the change is backward compatible.
  • Cashu.recoverQuoteKey({mintUrl, lockedPubkey, maxScan}) walks counters until
    the derived pubkey matches the one the mint locked the quote to. This is what
    turns "local data gone" from fatal into recoverable.

Happy to adjust the derivation message format if you would rather bind it
differently — the important property is only that it is reproducible from the
seed.

Evidence

Available on request (trade ids, quote ids, payment hashes, preimages, and the
mint's own paid / 0 issued export). Withheld here only because this is a public
tracker and the data identifies individual trades.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

  • Status
    In Progress

Relationships

None yet

Development

No branches or pull requests

Issue actions