perf: stop the wallet paying per-bundle signer round-trips and per-mint rescans

Audit of the paths the proof backfill makes hot, plus two bugs it makes
reachable.

/v1/checkstate went out unchunked. scrubStaleProofs checks every proof
held at a mint in one call and that set is unbounded — it grows with the
wallet's history, and a client that pages its whole proof set back off
the relays reaches four figures in one sweep. Mints run the same Pydantic
list caps there that they do on /v1/restore, which this file already caps
at 500 for exactly that reason, so the sweep failed with a validation
error at the moment the wallet had the most to reconcile. Chunked, and
the hash-to-curve derivation now happens once per proof instead of twice
(it was computed separately for the request list and the response
lookup — a discarded EC operation per proof, every sweep).

The auto-redeem sweep paid two NIP-44 decrypts of kind:17375 before
checking whether it had anything to redeem, and p2pkPubkeyHex re-decrypts
the same event walletPrivkeyHex just read. That sweep fires from every
relevant cache bundle, so a wallet whose nutzaps were all redeemed months
ago still paid two out-of-process round-trips per bundle on a NIP-46
bunker or a NIP-55 external signer. The candidate filter needs no key, so
it now runs first, and the pubkey is derived from the privkey in hand.

A kind:7375 we cannot decrypt hides money exactly as effectively as one a
relay never delivered, and looked identical to an empty wallet.
recomputeUnspent caches only successes, so failures are retried — but
only when something else marks tokens dirty, which in a quiet wallet may
be never. Failures are now counted and logged, and a forced resync
retries them even when the relay walk found nothing new.

Two quadratic scans that were invisible while truncation kept the entry
list tiny: peekNutzapFunding filtered the whole entry list once per
shared mint, allocating a list each time, from inside a composable
remember (so per rendered note); and cleanupDuplicateProofs compared all
pairs before every Resync. Both are single-pass/indexed now — a superset
of B must share all of B's secrets, so only entries indexed under B's
first secret can cover it.

Finally, scanning every keyset made Resync N times slower by
construction: each keyset costs at least three /v1/restore round-trips
with 500-item bodies, so a mint that has rotated ten times turned a
three-request scan into thirty run end to end. The walks are independent
and read-only, so they run three at a time — bounded to stay polite to
the mint's rate limiter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
This commit is contained in:
Claude
2026-08-17 00:09:40 +00:00
parent 3cb5b56ea2
commit 954560666a
3 changed files with 194 additions and 41 deletions
@@ -526,17 +526,23 @@ class CashuWalletState(
}
}
// Page the full proof set back, once, as soon as we know where to ask.
// The live subscription above cannot do this on its own — see
// [resyncProofsFromRelays].
// Page the full proof set back, once, as soon as we know there is a
// wallet and where to ask about it. The live subscription above cannot
// do this on its own — see [resyncProofsFromRelays].
//
// Gated on a wallet existing so an Account object that is only resident
// to decrypt a pushed gift wrap never pages a wallet nobody has: that
// is the same reason the wallet's relay subscription lives in
// CashuWalletEoseManager rather than here.
jobs +=
scope.launch(Dispatchers.IO) {
val relays =
withTimeoutOrNull(BACKFILL_RELAY_WAIT_MS) {
val ready =
withTimeoutOrNull(BACKFILL_READY_WAIT_MS) {
_walletEvent.first { it != null }
outboxRelaysFlow.first { it.isNotEmpty() }
}
if (relays == null) {
Log.w("CashuWallet") { "No outbox relays after ${BACKFILL_RELAY_WAIT_MS}ms; skipping proof backfill" }
if (ready == null) {
Log.d("CashuWallet") { "No wallet + outbox relays within ${BACKFILL_READY_WAIT_MS}ms; skipping proof backfill" }
} else {
resyncProofsFromRelays()
}
@@ -630,13 +636,19 @@ class CashuWalletState(
}
}.onFailure {
Log.w("CashuWallet", "Paged proof backfill failed", it)
}.onSuccess {
// Latch only on a walk that actually completed. A walk that
// blew up (offline at launch, every relay unreachable) has
// proved nothing about what the relays hold, and latching on it
// would leave the wallet showing the truncated balance for the
// rest of the session with no automatic second attempt.
proofBackfillDone = true
}
val fresh = collected.values.filter { it.id !in tokenEvents.keys }
val fresh = collected.values.filter { !tokenEvents.containsKey(it.id) }
Log.i("CashuWallet") {
"Proof backfill over ${relays.size} relay(s): ${collected.size} kind:7375 seen, ${fresh.size} new"
}
proofBackfillDone = true
if (fresh.isNotEmpty()) {
applyEvents(fresh)
@@ -645,6 +657,15 @@ class CashuWalletState(
// number.
runCatching { scrubLocallyStaleProofs() }
.onFailure { Log.w("CashuWallet", "Post-backfill NUT-07 sweep failed", it) }
} else if (undecryptedTokenCount() > 0) {
// Nothing new off the relays, but we are still holding proofs
// we could not read. A decrypt failure hides money exactly as
// effectively as a missing event does, and the retry inside
// recomputeUnspent only fires when some *other* change marks
// the tokens dirty — which, in a wallet that has gone quiet, may
// be never. A user asking for a refresh is asking for that
// retry too.
recomputeUnspent()
}
fresh.size
}
@@ -833,8 +854,10 @@ class CashuWalletState(
private suspend fun recomputeUnspent() {
val all = tokenEvents.values.toList()
// Decrypt anything we haven't seen before; reuse cached TokenContent
// for events we've already decrypted. Decryption failures are
// skipped — the proof set rebuilds the next time a re-key happens.
// for events we've already decrypted. Only successes are cached, so a
// failure is retried on the next recompute rather than being pinned as
// "empty" for the session.
var undecryptable = 0
all.forEach { evt ->
if (!tokenContents.containsKey(evt.id)) {
val content =
@@ -844,7 +867,19 @@ class CashuWalletState(
"Failed to decrypt token ${evt.id.take(8)}: ${it.message}"
}
}.getOrNull()
if (content != null) tokenContents[evt.id] = content
if (content != null) tokenContents[evt.id] = content else undecryptable++
}
}
// A token we cannot decrypt is money we cannot see, and it drops out of
// the balance as silently as a token a relay never delivered. The
// retry above only fires when something else triggers a recompute, so
// say it out loud: with this counter, a wallet reading low because an
// external signer refused N decrypts is diagnosable from a log instead
// of looking identical to a wallet that is genuinely empty.
if (undecryptable > 0) {
Log.w("CashuWallet") {
"$undecryptable of ${all.size} kind:7375 event(s) failed to decrypt — balance excludes them"
}
}
@@ -852,6 +887,9 @@ class CashuWalletState(
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents)
}
/** Token events we hold but have never managed to decrypt. See [recomputeUnspent]. */
private fun undecryptedTokenCount(): Int = tokenEvents.keys.count { it !in tokenContents.keys }
private fun recomputePending() {
// Shared destroyed/expired filter with the headless reader.
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values)
@@ -876,8 +914,14 @@ class CashuWalletState(
private suspend fun redeemPendingNutzapsSerialized() {
if (!redeemMutex.tryLock()) return // a sweep is already in flight
try {
val privkey = walletPrivkeyHex() ?: return
val pubkey = p2pkPubkeyHex() ?: return
// Establish there is work BEFORE touching the signer. This sweep
// fires from every relevant cache bundle, and the two key reads
// below are NIP-44 decrypts of kind:17375 — for a NIP-46 bunker or
// a NIP-55 external signer that is a round-trip out of the process
// (Amber even prompts on some configurations), paid on every bundle
// by a wallet whose nutzaps were all redeemed months ago. Nothing
// above the candidate filter needs a key, so hoist the filter.
if (nutzapEvents.isEmpty()) return
val skipIds = HashSet<HexKey>()
historyEvents.values.forEach { h ->
h.redeemedReferences().forEach { skipIds.add(it.eventId) }
@@ -888,6 +932,17 @@ class CashuWalletState(
val candidates = nutzapEvents.values.filter { it.id !in skipIds }
if (candidates.isEmpty()) return
val privkey = walletPrivkeyHex() ?: return
// Derived from the same key the line above just decrypted — pass it
// in rather than letting p2pkPubkeyHex() decrypt kind:17375 a
// second time for the identical bytes.
val pubkey =
runCatching {
Secp256k1
.pubKeyCompress(Secp256k1.pubkeyCreate(privkey.hexToByteArray()))
.toHexKey()
}.getOrNull() ?: return
for (ev in candidates) {
try {
ops.redeemNutzap(ev, privkey, pubkey)
@@ -1047,11 +1102,26 @@ class CashuWalletState(
val sharedMints = info.mints().map { it.mintUrl }.filter { it in ourMints }
if (sharedMints.isEmpty()) return null
// One pass over the entries, not one per shared mint. This runs inside
// a composable `remember {}` on every zap chip, so it is per rendered
// note — and `_tokenEntries` is no longer the handful of events a
// truncated relay delivery used to leave behind, it is the wallet's
// whole proof set. The old filter-per-mint form was
// O(sharedMints × entries) with a throwaway list allocated per mint.
val entries = _tokenEntries.value
val satsPerMint = HashMap<String, Long>(sharedMints.size)
var totalWalletSats = 0L
entries.forEach { entry ->
val amount = entry.content.totalAmount()
totalWalletSats += amount
val mint = entry.content.mint
if (mint in ourMints) satsPerMint[mint] = (satsPerMint[mint] ?: 0L) + amount
}
var bestMint = sharedMints.first()
var bestMintSats = 0L
for (mint in sharedMints) {
val balance = entries.filter { it.content.mint == mint }.sumOf { it.content.totalAmount() }
val balance = satsPerMint[mint] ?: 0L
if (balance > bestMintSats) {
bestMintSats = balance
bestMint = mint
@@ -1061,7 +1131,7 @@ class CashuWalletState(
return NutzapFunding(
target = NutzapTarget(mintUrl = bestMint, recipientP2pkPubkeyHex = recipientPubkeyHex),
bestSingleMintSats = bestMintSats,
totalWalletSats = entries.sumOf { it.content.totalAmount() },
totalWalletSats = totalWalletSats,
)
}
@@ -1332,11 +1402,26 @@ class CashuWalletState(
entry to entry.content.proofs.mapTo(HashSet()) { it.secret }
}
// Index secret → entries holding it. A superset of B must share every
// one of B's secrets, so the only entries that can possibly cover B are
// the ones indexed under B's first secret — which is a handful, not the
// whole wallet. The previous all-pairs scan was O(entries²) with a
// set-containment test inside; that was invisible while a truncated
// relay delivery kept the wallet at a few entries, and is not once the
// whole proof set is present.
val holdersOfSecret = HashMap<String, MutableList<Pair<TokenEntry, HashSet<String>>>>()
withSecrets.forEach { pair ->
pair.second.forEach { secret ->
holdersOfSecret.getOrPut(secret) { mutableListOf() }.add(pair)
}
}
val redundant = mutableListOf<TokenEntry>()
for ((entry, secrets) in withSecrets) {
if (secrets.isEmpty()) continue
val candidates = holdersOfSecret[secrets.first()] ?: continue
val isRedundant =
withSecrets.any { (other, otherSecrets) ->
candidates.any { (other, otherSecrets) ->
other.event.id != entry.event.id &&
otherSecrets.containsAll(secrets) &&
(
@@ -1697,13 +1782,13 @@ class CashuWalletState(
const val DISCOVERY_TIMEOUT_MS = 8_000L
/**
* How long the startup proof backfill waits for a non-empty outbox
* relay set before giving up. The NIP-65 list is restored from
* AccountSettings almost immediately on a returning launch; this
* window only matters on a first sign-in, where the list has to come
* off the network before we know where the wallet's events live.
* How long the startup proof backfill waits for a wallet event plus a
* non-empty outbox relay set before giving up. Both are restored from
* AccountSettings almost immediately on a returning launch; this window
* only matters on a first sign-in, where they have to come off the
* network before we know there is a wallet and where its events live.
*/
private const val BACKFILL_RELAY_WAIT_MS = 30_000L
private const val BACKFILL_READY_WAIT_MS = 60_000L
/**
* Per-page idle window for the paged proof walk — measured from the
@@ -61,6 +61,11 @@ import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -1154,16 +1159,41 @@ class CashuWalletOps(
val activeKeysetId = mintOps.activeKeyset().id
val keysetIds = mintOps.restorableKeysetIds()
// Scan keysets concurrently, a few at a time. Each keyset's walk costs
// at least `emptyBatchesToStop` /v1/restore round-trips with batch
// bodies up to MAX_RESTORE_REQUEST_ITEMS outputs — so a mint that has
// rotated ten times turns a scan that used to be three requests into
// thirty, and run end to end that is a minute of staring at a spinner
// on mobile. The walks are independent (separate derivation chains,
// read-only at the mint), so the only reason to serialize them is
// politeness to the mint; [RESTORE_KEYSET_CONCURRENCY] keeps that
// while cutting the wall clock by roughly the same factor.
val semaphore = Semaphore(RESTORE_KEYSET_CONCURRENCY)
val results =
coroutineScope {
keysetIds
.map { keysetId ->
async {
semaphore.withPermit {
keysetId to
runCatching {
mintOps.restore(seed = seed, keysetId = keysetId, startCounter = startCounter)
}.onFailure {
// One retired keyset the mint won't serve keys for must not
// sink the recovery of every other keyset at this mint.
Log.w("CashuWalletOps") {
"NUT-09 restore of keyset $keysetId at $mintUrl failed: ${describeMintError(it)}"
}
}.getOrNull()
}
}
}.awaitAll()
}
val recovered = mutableListOf<CashuProof>()
var activeNextCounter = startCounter
for (keysetId in keysetIds) {
val result =
runCatching { mintOps.restore(seed = seed, keysetId = keysetId, startCounter = startCounter) }
.onFailure {
Log.w("CashuWalletOps") {
"NUT-09 restore of keyset $keysetId at $mintUrl failed: ${describeMintError(it)}"
}
}.getOrNull() ?: continue
for ((keysetId, result) in results) {
if (result == null) continue
if (keysetId == activeKeysetId) activeNextCounter = result.nextCounterAfterScan
recovered += result.proofs.map { it.proof }
}
@@ -1292,6 +1322,14 @@ class CashuWalletOps(
* cheap (one /v1/restore batch).
*/
private const val DEFAULT_RESTORE_SCAN_BACK: Long = 32L
/**
* How many of a mint's keysets [scanRecoverableProofs] walks at once.
* Small on purpose: the walks are read-only but each one issues a
* series of large `/v1/restore` bodies, and a recovery is not worth
* tripping a mint's rate limiter over.
*/
private const val RESTORE_KEYSET_CONCURRENCY: Int = 3
}
}
@@ -664,20 +664,42 @@ class CashuMintOperations(
* Used by NUT-09 restore to filter spent proofs out of the recovered
* set before publishing — the mint will sign blind messages whether
* the underlying secret has been spent or not.
*
* Chunked at [MAX_CHECKSTATE_REQUEST_ITEMS] `Y`s per request. The wallet
* sweep ([scrubStaleProofs]) checks every proof it holds at a mint in one
* call, and that set is unbounded — it grows with the wallet's history, and
* a client that pages its whole proof set back off the relays can reach
* four figures in a single sweep. Mints run the same Pydantic list caps on
* `/v1/checkstate` that they do on `/v1/restore`, so an unchunked call
* fails the whole sweep with a validation error at exactly the moment the
* wallet has the most to reconcile.
*
* A proof whose `Y` the mint doesn't echo back is simply absent from the
* result, as before — callers treat "not UNSPENT" and "not present" alike.
*/
suspend fun checkStates(proofs: List<CashuProof>): Map<String, ProofState> {
if (proofs.isEmpty()) return emptyMap()
// NUT-07 keys check requests by `Y` (hash-to-curve of the secret).
val ys = proofs.map { Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey() }
val response = client.checkState(CheckStateRequestDto(ys = ys))
val secretByY =
proofs.associateBy {
Bdhke.hashToCurveCompressed(it.secret.encodeToByteArray()).toHexKey()
// NUT-07 checks by `Y` (hash-to-curve of the secret). That's an EC
// operation per proof, so derive it once and keep both directions from
// the same pass — computing it separately for the request list and for
// the response lookup doubled the curve work on every sweep.
val secretByY = HashMap<String, String>(proofs.size)
val ys = ArrayList<String>(proofs.size)
proofs.forEach { proof ->
val y = Bdhke.hashToCurveCompressed(proof.secret.encodeToByteArray()).toHexKey()
// putIfAbsent: two proofs can legitimately carry the same secret
// (a duplicated kind:7375 the dedup pass hasn't retired yet), and
// they map to the same state anyway.
if (secretByY.putIfAbsent(y, proof.secret) == null) ys.add(y)
}
val out = HashMap<String, ProofState>(secretByY.size)
ys.chunked(MAX_CHECKSTATE_REQUEST_ITEMS).forEach { chunk ->
val response = client.checkState(CheckStateRequestDto(ys = chunk))
for (row in response.states) {
val secret = secretByY[row.y] ?: continue
out[secret] = ProofState.fromWire(row.state)
}
val out = mutableMapOf<String, ProofState>()
for (row in response.states) {
val proof = secretByY[row.y] ?: continue
out[proof.secret] = ProofState.fromWire(row.state)
}
return out
}
@@ -845,6 +867,14 @@ class CashuMintOperations(
*/
const val MAX_RESTORE_REQUEST_ITEMS: Int = 500
/**
* Upper bound on `Y`s per `/v1/checkstate` request body. Same
* reasoning as [MAX_RESTORE_REQUEST_ITEMS], but the response carries
* one small state row per `Y` rather than a full blind signature, so
* there is no doubling to leave headroom for.
*/
const val MAX_CHECKSTATE_REQUEST_ITEMS: Int = 500
/** Re-exported from [splitAmountIntoDenominations] for convenience. */
fun splitAmounts(amount: Long): List<Long> = splitAmountIntoDenominations(amount)