fix(cashu): NUT-13 counters move to a synchronous per-account store

"Mint error HTTP 400: outputs already signed" started hitting every
send-token / send-LN after the wallet had crashed once. Root cause:
AccountSettings.reserveCashuCounters wrote the counter advance to
the same MutableStateFlow that drives the global settings save —
debounced by 1000 ms before the disk write fires.

The race window is exactly the time between "we ask the mint to
sign" and "the mint replies": ~200 ms. Any crash in that window
(ART JIT crash on Android 15+, signer dialog dismiss, OOM) loses
the counter advance even though the mint has already persisted its
side. Next reservation pulls the same slot, derives the same
deterministic blinded message, mint rejects with 10002.

Move the counter to a dedicated CashuPreferences SharedPreferences
file per account, written with `commit = true` so every reserve()
is durable BEFORE returning. AccountSettings.reserveCashuCounters
now delegates; a one-time migration on first read seeds the new
store from the legacy cashuKeysetCounters map so users on the old
build don't reset to zero. Plain (non-encrypted) prefs because
counters aren't secret — they don't carry value and aren't the seed.

Per Vitor's suggestion: the file is sized for the broader "Cashu
state that needs its own store" idea; today it only holds counters,
but the structure is in place for the kind:17375 / kind:10019 backups
to move out of the debounced settings path too if we later want.
This commit is contained in:
Claude
2026-05-27 23:03:32 +00:00
parent 23ea046d06
commit ec44b001fc
2 changed files with 164 additions and 14 deletions
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
@@ -844,28 +846,48 @@ class AccountSettings(
}
/**
* Reserve [count] consecutive NUT-13 counters for [keysetId], returning
* the first one. Caller derives `(secret, r)` from `(seed, keysetId, i)`
* for `i in [returned .. returned+count-1]`. Persisted immediately so
* a crash mid-mint doesn't reuse the same counter on next launch.
*
* Synchronized to make the read-modify-write atomic — two coroutines
* minting concurrently must each get their own counter range.
* NUT-13 keyset counters live in [CashuPreferences], a dedicated
* SharedPreferences file with synchronous (`commit = true`) writes.
* AccountSettings goes through a 1-second debounce on its own save
* path; the cashu counter cannot tolerate that window because the
* mint persists signed (keyset, blind_message) pairs the moment it
* sees them, so any local lag → "outputs already signed" on retry.
* See [CashuPreferences] for the full rationale.
*/
private val cashuPrefs: CashuPreferences by lazy {
CashuPreferences.forAccount(keyPair.pubKey.toNpub())
}
/**
* Reserve [count] consecutive NUT-13 counters for [keysetId],
* returning the first one. Caller derives `(secret, r)` from
* `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`.
* Persisted synchronously before returning — see [CashuPreferences].
*
* One-time migration: when this keyset has a non-zero value in the
* legacy [cashuKeysetCounters] map (from a build that persisted
* counters inside AccountSettings) and the dedicated store is
* still at zero, the legacy value is copied over before we reserve
* so an upgrade doesn't reset the counter.
*/
@Synchronized
fun reserveCashuCounters(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = cashuKeysetCounters[keysetId] ?: 0L
cashuKeysetCounters[keysetId] = current + count.toLong()
saveAccountSettings()
return current
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.reserveCounters(keysetId, count)
}
/** Inspect the next counter for [keysetId] without consuming any. */
fun peekCashuCounter(keysetId: String): Long = cashuKeysetCounters[keysetId] ?: 0L
fun peekCashuCounter(keysetId: String): Long {
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.peekCounter(keysetId)
}
private fun migrateLegacyCashuCounter(keysetId: String) {
val legacy = cashuKeysetCounters[keysetId] ?: return
cashuPrefs.seedCounterIfMissing(keysetId, legacy)
}
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
@@ -0,0 +1,128 @@
/*
* 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.model.nip60Cashu
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
/**
* Per-account Cashu state that needs durable, synchronous persistence —
* separate from [com.vitorpamplona.amethyst.model.AccountSettings] which
* batches writes through a 1-second debounced StateFlow.
*
* # Why a separate store
*
* The NUT-13 keyset counter is the critical bit. Every mint / swap /
* melt reserves counter slots, derives deterministic blinded outputs at
* those slots, sends them to the mint, and the mint signs them. The
* mint persists which (keyset, blind_message) pairs it has ever signed;
* a second request to sign the same blind_message returns HTTP 400
* "outputs already signed". So once the wallet hands a counter to the
* mint, the local counter advance MUST survive a crash — otherwise the
* next reservation pulls the same slot and the mint rejects it.
*
* The default settings save path debounces writes by 1000 ms, which is
* exactly the race window between "we asked the mint to sign" and "the
* mint replied". A crash inside that window (ART JIT crash on Android
* 15+, signer dialog dismiss, OOM, etc.) loses the counter advance and
* makes the wallet unusable. This store writes via `commit = true` so
* each reservation is durable before the function returns.
*
* # Layout
*
* One SharedPreferences file per account, named
* `cashu_prefs_<npub>.xml`. Keys are flat:
* - `counter_<keysetId>` → Long, the next free NUT-13 counter
*
* Plain (non-encrypted) prefs because keyset counters aren't secret —
* they're not the seed, they don't carry value, and a leak would only
* tell an attacker how many proofs the wallet has minted at each
* keyset (a privacy signal at most).
*
* # Migration
*
* Older builds stored counters inside `AccountSettings.cashuKeysetCounters`.
* On first read of a given keyset, callers should pre-seed the store
* from the legacy map (one-time copy) so an upgrade doesn't reset the
* counter to zero. See `AccountSettings.migrateCashuCountersTo` for
* the helper.
*/
class CashuPreferences(
private val prefs: SharedPreferences,
) {
/** Inspect the next free counter for [keysetId] without advancing it. */
@Synchronized
fun peekCounter(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
/**
* Atomically reserve [count] consecutive NUT-13 counters for
* [keysetId] and return the first reserved index. The write is
* forced to disk with `commit = true` BEFORE returning — see the
* class header for why this isn't optional.
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun reserveCounters(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = peekCounter(keysetId)
val next = current + count.toLong()
prefs.edit(commit = true) { putLong(counterKey(keysetId), next) }
return current
}
/**
* Seed [keysetId]'s counter from a legacy value found in
* [AccountSettings.cashuKeysetCounters]. No-op when the store
* already has a value at or above [legacyValue] — never moves the
* counter backwards. Called once at wallet load to carry forward
* pre-migration state.
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun seedCounterIfMissing(
keysetId: String,
legacyValue: Long,
) {
if (legacyValue <= 0L) return
val current = peekCounter(keysetId)
if (current >= legacyValue) return
prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) }
}
companion object {
private const val FILE_PREFIX = "cashu_prefs_"
private fun counterKey(keysetId: String) = "counter_$keysetId"
/** Per-account instance. [npub] keys the on-disk file so each account is isolated. */
fun forAccount(npub: String): CashuPreferences {
val context = Amethyst.instance.appContext
val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE)
return CashuPreferences(prefs)
}
}
}