mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+108
-23
@@ -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
|
||||
|
||||
+46
-8
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-10
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user