From ec44b001fc13bacd6ef704ea254a26c7f830603c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 23:03:32 +0000 Subject: [PATCH] fix(cashu): NUT-13 counters move to a synchronous per-account store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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. --- .../amethyst/model/AccountSettings.kt | 50 +++++-- .../model/nip60Cashu/CashuPreferences.kt | 128 ++++++++++++++++++ 2 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 1b5319fcdf..f7c8de8f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt new file mode 100644 index 0000000000..4cd8a900db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuPreferences.kt @@ -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_.xml`. Keys are flat: + * - `counter_` → 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) + } + } +}