mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix: recover the whole Cashu balance instead of whatever a relay served
The NIP-60 balance is a pure function of the kind:7375 events the client
holds, and nothing ever checked that it held all of them.
The live wallet subscription sends one REQ per outbox relay with no
`limit`, asking for six kinds at once. Relays answer an unbounded REQ
with their own cap applied to the newest matching events, and kind:7376
history outnumbers the proofs by an order of magnitude on any wallet with
a few hundred transactions — so the proofs that lose that race are the
ones at mints the user hasn't touched recently, which is exactly the
balance they'd forgotten they had. Nothing recovers afterwards: a capped
page and a complete page both just EOSE, and PerUserEoseManager records
that EOSE as the `since` for every later REQ to the relay, so the events
below the cap are never asked for again. The subset is stable across cold
starts and differs per device, which is how one account reads three
different balances on three phones with none of them right.
Page the proof set instead of taking one REQ's word for it: a one-shot
fetchAllPagesFromPool walk over kind:7375 on the outbox relays, run at
startup once the relay list is known and again (forced) when the user
opens the wallet. Only kind:7375 — history and quotes are display-only,
and paging them would multiply the download without moving a balance.
Because a relay that ignores NIP-09 will hand back proofs the mint
already burned, a walk that recovers anything new finishes with the
NUT-07 scrub so the mint, not the relay, decides what is still unspent;
a walk that finds nothing new skips it and costs no mint traffic.
The seed-based recovery that should have been the fallback was blind in
the same direction. NUT-13 derives a counter chain per keyset, and
scanRecoverableProofs only ever scanned the mint's *active* keyset, so
proofs minted before the mint's last rotation sat on a derivation path
nothing walked — the restore reported an empty wallet rather than an
incomplete scan, since a scan that never asks looks like one that found
nothing. It now walks every keyset the mint lists for the unit, active
first, skipping (and logging) any that errors. fetchKeysetById resolved
ids through /v1/keys, which lists active keysets only, so an inactive
keyset was unresolvable even when asked for by id; it now tries NUT-01's
/v1/keys/{id} first and keeps the active-list lookup as fallback.
Counter bookkeeping still tracks the active keyset alone — an inactive
keyset can never receive another mint, so advancing its counter would
protect nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
This commit is contained in:
@@ -709,6 +709,7 @@ class Account(
|
||||
pubKey = signer.pubKey,
|
||||
signer = signer,
|
||||
cache = cache,
|
||||
client = client,
|
||||
scope = scope,
|
||||
outboxRelaysFlow = outboxRelays.flow,
|
||||
inboxRelaysFlow = notificationRelays.flow,
|
||||
|
||||
+145
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -35,6 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
@@ -61,12 +64,14 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -99,6 +104,7 @@ class CashuWalletState(
|
||||
private val pubKey: HexKey,
|
||||
private val signer: NostrSigner,
|
||||
private val cache: LocalCache,
|
||||
private val client: INostrClient,
|
||||
private val scope: CoroutineScope,
|
||||
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
@@ -519,6 +525,129 @@ class CashuWalletState(
|
||||
if (ids.isNotEmpty()) removeEvents(ids)
|
||||
}
|
||||
}
|
||||
|
||||
// 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].
|
||||
jobs +=
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val relays =
|
||||
withTimeoutOrNull(BACKFILL_RELAY_WAIT_MS) {
|
||||
outboxRelaysFlow.first { it.isNotEmpty() }
|
||||
}
|
||||
if (relays == null) {
|
||||
Log.w("CashuWallet") { "No outbox relays after ${BACKFILL_RELAY_WAIT_MS}ms; skipping proof backfill" }
|
||||
} else {
|
||||
resyncProofsFromRelays()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Proof backfill — paging past the relay's REQ cap
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* True once a paged proof walk has completed for this session. Guards the
|
||||
* automatic backfill only; [resyncProofsFromRelays] with `force` ignores it.
|
||||
*/
|
||||
@Volatile private var proofBackfillDone = false
|
||||
|
||||
private val proofBackfillMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Re-download **every** kind:7375 this account ever published, by paging
|
||||
* each outbox relay with `until` cursors, and then reconcile the result
|
||||
* against the mints.
|
||||
*
|
||||
* ### Why this is needed
|
||||
*
|
||||
* [balanceSats] is a pure function of [_tokenEntries], which is a pure
|
||||
* function of the kind:7375 events we happen to hold. Those arrive over the
|
||||
* live wallet subscription, which sends one unbounded REQ per relay. A relay
|
||||
* answers an unbounded REQ with its own cap (NIP-11 `limitation.max_limit`,
|
||||
* or a hard-coded default) applied to the **newest** matching events — and
|
||||
* the same filter also asks for kind:7376 history, which outnumbers the
|
||||
* proofs by an order of magnitude on any wallet with a few hundred
|
||||
* transactions. The proofs that lose that race are the ones at mints the
|
||||
* user has not touched recently, so what drops off the bottom is precisely
|
||||
* the balance the user forgot they had.
|
||||
*
|
||||
* Nothing recovers from it afterwards: a capped page and a complete page
|
||||
* both just EOSE, and [PerUserEoseManager] records that EOSE as the `since`
|
||||
* for every later REQ to that relay, so the events below the cap are never
|
||||
* asked for again. The subset is stable across cold starts (same filter,
|
||||
* same cap, same events) but differs between devices whose relay set,
|
||||
* arrival order or uptime differ — which is why one account can read 39 sat
|
||||
* on one phone, 1443 on another and 2522 on a third, with none of them
|
||||
* being the wallet's actual balance.
|
||||
*
|
||||
* ### What this does
|
||||
*
|
||||
* `fetchAllPagesFromPool` walks each relay backwards page by page until a
|
||||
* page comes back empty, so the cap bounds a page instead of the download.
|
||||
* Events land in [LocalCache] through the client-wide `EventCollector`, but
|
||||
* we also index what we receive directly rather than waiting on the bundled
|
||||
* cache round-trip, so the balance is correct the moment the walk returns.
|
||||
*
|
||||
* ### Why the scrub afterwards
|
||||
*
|
||||
* Spent proofs are retired with a NIP-09 kind:5, and a relay that ignores
|
||||
* deletions will happily hand those kind:7375 events back on a paged walk.
|
||||
* Taken alone, this would trade an under-count for an over-count. So when
|
||||
* the walk actually recovered something, we finish with the NUT-07
|
||||
* [scrubLocallyStaleProofs] sweep: the mint — not the relay — decides which
|
||||
* proofs are still unspent, and anything it calls SPENT is dropped and
|
||||
* re-deleted. The sweep is skipped when the walk found nothing new, so a
|
||||
* steady-state launch costs no mint traffic.
|
||||
*
|
||||
* Returns the number of kind:7375 events the walk delivered that we did not
|
||||
* already hold, or null when it could not run (not started, no relays, or
|
||||
* already done and not forced).
|
||||
*/
|
||||
suspend fun resyncProofsFromRelays(force: Boolean = false): Int? {
|
||||
if (!started) return null
|
||||
if (proofBackfillDone && !force) return null
|
||||
return proofBackfillMutex.withLock {
|
||||
if (proofBackfillDone && !force) return@withLock null
|
||||
val relays = outboxRelaysFlow.value
|
||||
// Don't latch on an empty relay set — the NIP-65 list may simply not
|
||||
// have arrived yet, and the caller retries once it does.
|
||||
if (relays.isEmpty()) return@withLock null
|
||||
|
||||
val filters = cashuProofBackfillFilters(pubKey)
|
||||
// The callback runs on the relay reader thread and must not suspend,
|
||||
// so collect first and index after the walk.
|
||||
val collected = ConcurrentHashMap<HexKey, CashuTokenEvent>()
|
||||
runCatching {
|
||||
client.fetchAllPagesFromPool(
|
||||
filters = relays.associateWith { filters },
|
||||
idleTimeoutMs = BACKFILL_IDLE_TIMEOUT_MS,
|
||||
) { event, _ ->
|
||||
if (event is CashuTokenEvent && event.pubKey == pubKey) {
|
||||
collected.putIfAbsent(event.id, event)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w("CashuWallet", "Paged proof backfill failed", it)
|
||||
}
|
||||
|
||||
val fresh = collected.values.filter { it.id !in tokenEvents.keys }
|
||||
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)
|
||||
// A relay that ignores NIP-09 just handed back proofs the mint
|
||||
// already burned. Let the mint arbitrate before the user sees a
|
||||
// number.
|
||||
runCatching { scrubLocallyStaleProofs() }
|
||||
.onFailure { Log.w("CashuWallet", "Post-backfill NUT-07 sweep failed", it) }
|
||||
}
|
||||
fresh.size
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -1567,6 +1696,22 @@ 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.
|
||||
*/
|
||||
private const val BACKFILL_RELAY_WAIT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* Per-page idle window for the paged proof walk — measured from the
|
||||
* relay's last message, not from the page's start, so a relay actively
|
||||
* streaming a long backlog is never cut off mid-page.
|
||||
*/
|
||||
private const val BACKFILL_IDLE_TIMEOUT_MS = 30_000L
|
||||
|
||||
private const val NOT_STARTED_MESSAGE = "CashuWalletState.start() not called"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-6
@@ -216,16 +216,30 @@ class CashuWalletViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile every mint we hold tokens at against its NUT-07 `/checkstate`
|
||||
* — not just the mint a spend targets. Wired to the wallet screen opening
|
||||
* so a balance auto-redeemed from a mint we never configured (e.g. a
|
||||
* nutzap on a mint not in our kind:10019) still gets its stale proofs
|
||||
* swept. Safe to call repeatedly; no-ops when nothing is stale or the
|
||||
* wallet hasn't started yet.
|
||||
* Bring the wallet's view of its own money up to date, in the two ways it
|
||||
* can be behind.
|
||||
*
|
||||
* First re-page the proof set off the relays: the live subscription takes
|
||||
* whatever one uncapped REQ returns, so proofs older than the relay's cap
|
||||
* are simply absent, and the balance quietly reads low (see
|
||||
* [CashuWalletState.resyncProofsFromRelays]). `force` because the user
|
||||
* opening the wallet is a direct request for a current number, and the
|
||||
* startup walk may have run before the relay list was known.
|
||||
*
|
||||
* Then reconcile every mint we hold tokens at against its NUT-07
|
||||
* `/checkstate` — not just the mint a spend targets — so a balance
|
||||
* auto-redeemed from a mint we never configured (e.g. a nutzap on a mint
|
||||
* not in our kind:10019) still gets its stale proofs swept. Safe to call
|
||||
* repeatedly; no-ops when nothing is stale or the wallet hasn't started.
|
||||
*/
|
||||
fun refresh() {
|
||||
val vm = accountViewModel ?: return
|
||||
vm.launchSigner {
|
||||
try {
|
||||
state.resyncProofsFromRelays(force = true)
|
||||
} catch (e: Exception) {
|
||||
Log.w("CashuWallet", "wallet proof re-page failed", e)
|
||||
}
|
||||
try {
|
||||
state.syncAllMints()
|
||||
} catch (e: Exception) {
|
||||
|
||||
+34
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
|
||||
@@ -134,3 +135,36 @@ fun cashuWalletFilters(
|
||||
|
||||
return ownedSubs + inboundSubs
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter used to **page** the account's whole proof set back from a relay,
|
||||
* as opposed to [cashuWalletFilters], which opens a live subscription.
|
||||
*
|
||||
* The live subscription sends one REQ with no `limit` and takes whatever the
|
||||
* relay decides to give back. Relays cap an unbounded REQ (NIP-11
|
||||
* `limitation.max_limit`, or a hard-coded default) and serve the **newest**
|
||||
* events within that cap, so a wallet whose kind:7375 events are outnumbered
|
||||
* by its kind:7376 history — which is every wallet after a few hundred
|
||||
* transactions — silently receives only a suffix of its proofs. Everything
|
||||
* downstream (balance, per-mint balances, coin selection) is a pure function
|
||||
* of that suffix, which is why two devices on the same account can show two
|
||||
* different balances and neither is right.
|
||||
*
|
||||
* There is no way to detect the truncation from the REQ itself: a capped
|
||||
* response and a complete one both just EOSE. The only fix is to not rely on
|
||||
* one REQ — hand this to `fetchAllPages` / `fetchAllPagesFromPool`, which
|
||||
* walks `until` cursors until a page comes back empty and thereby reaches
|
||||
* events of any age regardless of the cap.
|
||||
*
|
||||
* Scoped to kind:7375 alone. Those are the events that carry money; history,
|
||||
* quotes and recommendations are display-only, and paging them too would
|
||||
* multiply the download for a wallet with a long history without changing a
|
||||
* single balance.
|
||||
*/
|
||||
fun cashuProofBackfillFilters(pubkey: HexKey): List<Filter> =
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(CashuTokenEvent.KIND),
|
||||
authors = listOf(pubkey),
|
||||
),
|
||||
)
|
||||
|
||||
+50
-20
@@ -1111,13 +1111,35 @@ class CashuWalletOps(
|
||||
* funds into the wallet.
|
||||
*
|
||||
* Process:
|
||||
* 1. Fetch the mint's active keyset.
|
||||
* 2. Drive [CashuMintOperations.restore] — scans counters in batches
|
||||
* until enough empty batches in a row signal "no more proofs".
|
||||
* 3. Filter the returned proofs through /v1/checkstate to keep only
|
||||
* 1. List **every** keyset the mint has published for this unit — see
|
||||
* below on why the active one is not enough.
|
||||
* 2. Drive [CashuMintOperations.restore] per keyset — scans counters in
|
||||
* batches until enough empty batches in a row signal "no more proofs".
|
||||
* 3. Filter the pooled proofs through /v1/checkstate to keep only
|
||||
* UNSPENT ones — NUT-09 alone returns even spent proofs.
|
||||
* 4. Drop secrets already present in [existingSecrets].
|
||||
*
|
||||
* ### Every keyset, not just the active one
|
||||
*
|
||||
* NUT-13 derives a separate counter chain per keyset
|
||||
* (`m/129372'/0'/<keyset-id-int>'/<counter>'`), so a proof minted under a
|
||||
* keyset the mint has since rotated out is not reachable from the active
|
||||
* keyset's derivation path at any counter. Scanning only the active keyset
|
||||
* therefore reports "nothing to recover" for exactly the balance a restore
|
||||
* exists to find: the older it is, the more likely its keyset is retired.
|
||||
* That looked like an empty wallet rather than an incomplete scan, because
|
||||
* a scan that never asks and a scan that finds nothing return the same
|
||||
* thing.
|
||||
*
|
||||
* A keyset that errors out (mint won't serve its keys, restore rejected)
|
||||
* is logged and skipped so one bad keyset can't sink the whole recovery.
|
||||
*
|
||||
* [RecoverableProofs.keysetId] and [RecoverableProofs.nextCounterAfterScan]
|
||||
* continue to describe the **active** keyset specifically, because that is
|
||||
* the only chain the wallet will derive new secrets on — an inactive
|
||||
* keyset can never receive another mint, so advancing a counter for it
|
||||
* would protect nothing.
|
||||
*
|
||||
* Since it neither signs, swaps, nor publishes, this is safe to run
|
||||
* speculatively across many candidate wallets/seeds.
|
||||
*/
|
||||
@@ -1129,28 +1151,36 @@ class CashuWalletOps(
|
||||
): RecoverableProofs {
|
||||
seedWarmer()
|
||||
val mintOps = ops(mintUrl)
|
||||
val activeKeyset = mintOps.activeKeyset()
|
||||
val result =
|
||||
mintOps.restore(
|
||||
seed = seed,
|
||||
keysetId = activeKeyset.id,
|
||||
startCounter = startCounter,
|
||||
)
|
||||
if (result.proofs.isEmpty()) {
|
||||
return RecoverableProofs(mintUrl, result.keysetId, emptyList(), result.nextCounterAfterScan)
|
||||
val activeKeysetId = mintOps.activeKeyset().id
|
||||
val keysetIds = mintOps.restorableKeysetIds()
|
||||
|
||||
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
|
||||
if (keysetId == activeKeysetId) activeNextCounter = result.nextCounterAfterScan
|
||||
recovered += result.proofs.map { it.proof }
|
||||
}
|
||||
|
||||
if (recovered.isEmpty()) {
|
||||
return RecoverableProofs(mintUrl, activeKeysetId, emptyList(), activeNextCounter)
|
||||
}
|
||||
|
||||
// /v1/checkstate filters out proofs that were minted but already
|
||||
// melted or sent. Without this, recovered "balance" would include
|
||||
// already-spent proofs that the mint would reject at next swap.
|
||||
val stateMap = mintOps.checkStates(result.proofs.map { it.proof })
|
||||
val stateMap = mintOps.checkStates(recovered)
|
||||
val unspent =
|
||||
result.proofs
|
||||
.filter { recovered ->
|
||||
stateMap[recovered.proof.secret] == ProofState.UNSPENT &&
|
||||
recovered.proof.secret !in existingSecrets
|
||||
}.map { it.proof }
|
||||
return RecoverableProofs(mintUrl, result.keysetId, unspent, result.nextCounterAfterScan)
|
||||
recovered.filter { proof ->
|
||||
stateMap[proof.secret] == ProofState.UNSPENT && proof.secret !in existingSecrets
|
||||
}
|
||||
return RecoverableProofs(mintUrl, activeKeysetId, unspent, activeNextCounter)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.cashu
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuWalletFilters
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
|
||||
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The NIP-60 balance is a pure function of the kind:7375 events the client
|
||||
* holds, and those arrive over a subscription a relay is free to truncate. This
|
||||
* pins both halves of that problem:
|
||||
*
|
||||
* - what a truncated delivery does to the number the user sees, and
|
||||
* - that the backfill filter is shaped so `fetchAllPages` can walk past the
|
||||
* truncation instead of inheriting it.
|
||||
*/
|
||||
class CashuBalanceTruncationTest {
|
||||
private val owner: HexKey = "a".repeat(64)
|
||||
|
||||
/**
|
||||
* Build a kind:7375 whose decrypted content is [content]. The event's own
|
||||
* `content` string is irrelevant here — [CashuWalletReader.computeUnspent]
|
||||
* is handed the already-decrypted map, exactly as the wallet holder does
|
||||
* after its NIP-44 pass.
|
||||
*/
|
||||
private fun tokenEvent(
|
||||
idPrefix: String,
|
||||
createdAt: Long,
|
||||
): CashuTokenEvent =
|
||||
CashuTokenEvent(
|
||||
id = idPrefix.padEnd(64, '0'),
|
||||
pubKey = owner,
|
||||
createdAt = createdAt,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
private fun proofs(
|
||||
keysetId: String,
|
||||
vararg amounts: Long,
|
||||
) = amounts.mapIndexed { i, amount ->
|
||||
CashuProof(id = keysetId, amount = amount, secret = "$keysetId-$i-$amount", c = "02${"0".repeat(64)}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Three mints, funded at different times: the oldest one holds most of the
|
||||
* money and hasn't been touched since. This is the shape of a real wallet —
|
||||
* activity concentrates on the mint you last used.
|
||||
*/
|
||||
private fun wallet(): Pair<List<CashuTokenEvent>, Map<HexKey, TokenContent>> {
|
||||
val oldMint = tokenEvent("aa", createdAt = 1_000)
|
||||
val midMint = tokenEvent("bb", createdAt = 2_000)
|
||||
val hotMint = tokenEvent("cc", createdAt = 3_000)
|
||||
|
||||
val contents =
|
||||
mapOf(
|
||||
oldMint.id to TokenContent(mint = "https://old.mint", proofs = proofs("ks1", 1024L, 459L)),
|
||||
midMint.id to TokenContent(mint = "https://mid.mint", proofs = proofs("ks2", 1000L)),
|
||||
hotMint.id to TokenContent(mint = "https://hot.mint", proofs = proofs("ks3", 32L, 7L)),
|
||||
)
|
||||
return listOf(oldMint, midMint, hotMint) to contents
|
||||
}
|
||||
|
||||
private fun balanceOf(
|
||||
events: List<CashuTokenEvent>,
|
||||
contents: Map<HexKey, TokenContent>,
|
||||
) = CashuWalletReader
|
||||
.computeUnspent(events, contents)
|
||||
.sumOf { it.content.totalAmount() }
|
||||
|
||||
@Test
|
||||
fun `complete delivery reports the whole balance`() {
|
||||
val (events, contents) = wallet()
|
||||
assertEquals(2522L, balanceOf(events, contents))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a relay that serves only the newest events under-reports the balance`() {
|
||||
val (events, contents) = wallet()
|
||||
|
||||
// What a capped REQ returns: the newest N matching events. The proofs
|
||||
// that fall off are the old, untouched mints — precisely the balance
|
||||
// the user forgot they had, which is why the shortfall is large rather
|
||||
// than marginal.
|
||||
val newestOnly = events.sortedByDescending { it.createdAt }.take(1)
|
||||
|
||||
assertEquals(39L, balanceOf(newestOnly, contents))
|
||||
assertNotEquals(
|
||||
"a truncated delivery must not be mistaken for a complete one",
|
||||
balanceOf(events, contents),
|
||||
balanceOf(newestOnly, contents),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each device sees a different number for the same wallet`() {
|
||||
val (events, contents) = wallet()
|
||||
val byAge = events.sortedByDescending { it.createdAt }
|
||||
|
||||
// Same account, same relays, three delivery depths — three balances,
|
||||
// none of which is an error the client can detect locally: every one of
|
||||
// them is a correct sum over an incomplete set.
|
||||
assertEquals(39L, balanceOf(byAge.take(1), contents))
|
||||
assertEquals(1039L, balanceOf(byAge.take(2), contents))
|
||||
assertEquals(2522L, balanceOf(byAge.take(3), contents))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `del rollover still retires spent tokens once everything is delivered`() {
|
||||
val (events, contents) = wallet()
|
||||
val spent = events.first { it.id.startsWith("aa") }
|
||||
val rollover = tokenEvent("dd", createdAt = 4_000)
|
||||
|
||||
val withRollover = events + rollover
|
||||
val contentsWithRollover =
|
||||
contents +
|
||||
(
|
||||
rollover.id to
|
||||
TokenContent(
|
||||
mint = "https://old.mint",
|
||||
proofs = proofs("ks1", 512L),
|
||||
del = listOf(spent.id),
|
||||
)
|
||||
)
|
||||
|
||||
// Backfilling every kind:7375 the account ever published cannot inflate
|
||||
// the balance through superseded events: the `del` chain retires them.
|
||||
assertEquals(1551L, balanceOf(withRollover, contentsWithRollover))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backfill filter asks for proofs only, with no limit for fetchAllPages to inherit`() {
|
||||
val filters = cashuProofBackfillFilters(owner)
|
||||
|
||||
assertEquals(1, filters.size)
|
||||
val filter = filters.single()
|
||||
|
||||
assertEquals(listOf(CashuTokenEvent.KIND), filter.kinds)
|
||||
assertEquals(listOf(owner), filter.authors)
|
||||
|
||||
// fetchAllPages ends the walk on a fulfilled `limit` (End.LIMIT_REACHED)
|
||||
// rather than on a drained page, so a limit here would reintroduce the
|
||||
// very truncation the walk exists to defeat.
|
||||
assertNull("the paged walk must run to exhaustion, not to a limit", filter.limit)
|
||||
|
||||
// Cursors are what make paging work; a preset window would pin the walk
|
||||
// to one slice of history.
|
||||
assertNull(filter.since)
|
||||
assertNull(filter.until)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the live subscription mixes proofs with history — which is what starves them`() {
|
||||
val relay = RelayUrlNormalizer.normalize("wss://relay.example.com")
|
||||
val filters =
|
||||
cashuWalletFilters(
|
||||
CashuWalletQueryState(
|
||||
pubkey = owner,
|
||||
ownEventRelays = setOf(relay),
|
||||
inboxRelays = emptySet(),
|
||||
),
|
||||
since = null,
|
||||
)
|
||||
|
||||
val ownFilter = filters.single { it.filter.authors == listOf(owner) }.filter
|
||||
val kinds = ownFilter.kinds.orEmpty()
|
||||
|
||||
// One REQ carries the proofs and the (far more numerous) history rows.
|
||||
// A cap applied to that combined stream is spent mostly on history, so
|
||||
// this filter alone can never be trusted to deliver the whole proof set
|
||||
// — hence the separate paged backfill.
|
||||
assertTrue(CashuTokenEvent.KIND in kinds)
|
||||
assertTrue(kinds.size > 1)
|
||||
}
|
||||
}
|
||||
+41
@@ -552,7 +552,27 @@ class CashuMintOperations(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one keyset's amount→pubkey map by id, **including keysets the
|
||||
* mint has rotated out of active service**.
|
||||
*
|
||||
* `/v1/keys` returns only the active keysets, so resolving through it
|
||||
* makes every inactive keyset unresolvable — and since a NUT-13 counter
|
||||
* chain is scoped to a keyset id, that silently made a NUT-09 restore of
|
||||
* anything minted before the mint's last rotation impossible: the restore
|
||||
* threw "Mint doesn't expose keyset X", or was never attempted at all.
|
||||
* NUT-01's `/v1/keys/{keyset_id}` is the endpoint that answers for any
|
||||
* keyset the mint has ever published, active or not, so ask that first and
|
||||
* keep the active-list lookup as the fallback for mints that don't serve
|
||||
* the per-id route.
|
||||
*/
|
||||
private suspend fun fetchKeysetById(keysetId: String): KeysetDto {
|
||||
runCatching { client.keysetById(keysetId) }
|
||||
.getOrNull()
|
||||
?.keysets
|
||||
?.firstOrNull { it.id == keysetId }
|
||||
?.let { return it }
|
||||
|
||||
val response = client.activeKeysets()
|
||||
return response.keysets.firstOrNull { it.id == keysetId }
|
||||
?: throw IllegalStateException("Mint doesn't expose keyset $keysetId")
|
||||
@@ -561,6 +581,27 @@ class CashuMintOperations(
|
||||
/** Public surface for callers that need the active keyset (NUT-09 restore driver). */
|
||||
suspend fun activeKeyset(): KeysetDto = fetchKeyset()
|
||||
|
||||
/**
|
||||
* Every keyset id at this mint a NUT-09 restore should walk, for [unit],
|
||||
* active keysets first.
|
||||
*
|
||||
* A restore driver that only scans the *active* keyset finds nothing for a
|
||||
* wallet whose proofs were minted before the mint's last keyset rotation —
|
||||
* which, for a mint that rotates on any schedule at all, is most of an
|
||||
* older wallet's balance. Each keyset carries its own NUT-13 counter chain
|
||||
* (`m/129372'/0'/<keyset-id-int>'/<counter>'`), so proofs under a retired
|
||||
* keyset are simply not on the path the active-only scan derives.
|
||||
*
|
||||
* Active first so a caller that cares about counter bookkeeping (only the
|
||||
* active keyset can receive new mints, so only its counter governs future
|
||||
* derivations) sees it before spending its scan budget elsewhere.
|
||||
*/
|
||||
suspend fun restorableKeysetIds(unit: String = "sat"): List<String> {
|
||||
val summaries = client.keysets().keysets.filter { it.unit == unit }
|
||||
if (summaries.isEmpty()) return listOfNotNull(runCatching { activeKeyset().id }.getOrNull())
|
||||
return summaries.sortedByDescending { it.active }.map { it.id }.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* NUT-12 §3 Carol-side DLEQ verification on a batch of proofs that
|
||||
* arrived from OUTSIDE the wallet's own mint round-trips (an
|
||||
|
||||
Reference in New Issue
Block a user