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
- 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.
- 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.
- 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.
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.cashSUCCEEDED 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:
Versions:
ndk 0.8.4-dev.8(bitblik app depends onndk: ^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:164getMintQuote()createsCashuKeypair.generateCashuKeyPair()for every quoteand sends
quoteKey.publicKeyto the mint as the NUT-20pubkeylock.packages/ndk/lib/domain_layer/usecases/cashu/cashu_keypair.dart:18the private key is
Helpers.getSecureRandomHex(32)— pure entropy, unrelatedto the wallet seed.
packages/ndk/lib/domain_layer/entities/cashu/cashu_quote.dart:66the 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
packages/ndk/lib/domain_layer/usecases/cashu/cashu.dart: it polls for paymenton
CashuConfig.FUNDING_CHECK_INTERVAL(~line 898) and only afterwards callsmintTokens()(~line 911)._pendingTransactions(line 92) is an in-memorySet. There is no startupscan, and nothing ever revisits a persisted pending quote.
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
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.
on wallet open, and expose an explicit "retry minting" action. The mint's mint
request is idempotent/cached, so re-requesting is safe.
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 quotekeys 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})andCashuKeypair.fromPrivateKeyHex().getMintQuote()no longer generates entropy; it takes the derived keypair andthe counter it came from. The counter is obtained from the existing
getAndIncrementDerivationCounterunder a reserved slotquote-key, whichcannot collide with a real keyset id since those are always hex.
CashuQuote.quoteKeyCounteris persisted; quotes written before this landdeserialize as
-1, so the change is backward compatible.Cashu.recoverQuoteKey({mintUrl, lockedPubkey, maxScan})walks counters untilthe 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 issuedexport). Withheld here only because this is a publictracker and the data identifies individual trades.