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) + } + } +}