refactor: move NUT-13 counters to DataStore, hoisting the reservation

Step 6, built as option 2: the counter reservation moves up to the
suspend layer so secret derivation stays pure, rather than making
SecretFactory.nextSecrets suspend and dragging RandomSecretFactory — a
function that does nothing but generate randomness — along with it.

SecretFactory splits in two:

  suspend fun reserve(keysetId, count): SecretReservation
  fun derive(reservation, count): List<DerivedSecret>

reserve() is the durability boundary and the only part that touches
storage. derive() is pure: same reservation, same secrets, no
suspension. nextSecrets() remains as the convenience that does both.
CashuMintOperations.secretOutputsFor now calls them separately, so the
write that stands between a crash and a reused counter is visible at the
layer that can await it, instead of hidden inside derivation.

RandomSecretFactory reserves nothing. DeterministicSecretFactory reserves
nothing either when it has no seed yet, since it will fall back to random
and burning counters for secrets never derived from them is waste. If the
seed disappears between reserving and deriving, that batch falls back to
random and the reserved range goes unused — harmless, because counters
only ever move forward and an unused one is never replayed.

Storage: CashuKeysetCounterStore becomes suspend, and the Android
implementation moves to DataStore as DataStoreCashuCounterStore in
commons, so desktop gets one too rather than having none. Read and write
sit inside a single `edit`, which is what the previous @Synchronized was
for: two concurrent mints cannot observe the same starting index.
DataStore's edit suspends until its write lands and swaps the file
atomically — the same guarantee SharedPreferences.edit(commit = true)
gave, paid at a suspension rather than a blocked thread.

Both older layers still feed in and neither can move a counter backwards:
the per-account SharedPreferences file is copied in full on first read,
inside the same atomic write that records the copy happened, so a crash
cannot leave the marker set with the counters missing; and the older
AccountSettings.cashuKeysetCounters map is still applied per keyset
through seedIfMissing.

21 tests where there were none. This path decides whether ecash is
spendable and had no coverage at all while it was on SharedPreferences.
The ones that matter most: 25 concurrent reservations carve strictly
disjoint ranges, a reservation survives a reopen, seedIfMissing never
moves a counter backwards, the legacy migration carries every counter and
does not rewind reservations made after it ran, and a host with no store
wired fails loudly instead of answering 0.

Not verified here: no mint was contacted. The durability and concurrency
properties are covered by tests, but an end-to-end mint/melt against a
real mint is still worth doing before release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
Claude
2026-09-23 19:50:58 +00:00
parent e4ebb0c728
commit 1397bc6f81
10 changed files with 658 additions and 125 deletions
@@ -1221,7 +1221,8 @@ 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 synchronously before returning — see [CashuKeysetCounterStore].
* Persisted before returning — see [CashuKeysetCounterStore]. Suspends
* because that write is what stands between a crash and a reused counter.
*
* One-time migration: when this keyset has a non-zero value in the
* legacy [cashuKeysetCounters] map (from a build that persisted
@@ -1229,7 +1230,7 @@ class AccountSettings(
* still at zero, the legacy value is copied over before we reserve
* so an upgrade doesn't reset the counter.
*/
fun reserveCashuCounters(
suspend fun reserveCashuCounters(
keysetId: String,
count: Int,
): Long {
@@ -1238,12 +1239,12 @@ class AccountSettings(
}
/** Inspect the next counter for [keysetId] without consuming any. */
fun peekCashuCounter(keysetId: String): Long {
suspend fun peekCashuCounter(keysetId: String): Long {
migrateLegacyCashuCounter(keysetId)
return cashuCounters.peek(keysetId)
}
private fun migrateLegacyCashuCounter(keysetId: String) {
private suspend fun migrateLegacyCashuCounter(keysetId: String) {
val legacy = cashuKeysetCounters[keysetId] ?: return
cashuCounters.seedIfMissing(keysetId, legacy)
}
@@ -20,110 +20,64 @@
*/
package com.vitorpamplona.amethyst.model.nip60Cashu
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.longPreferencesKey
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.cashu.DataStoreCashuCounterStore
import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
import com.vitorpamplona.quartz.utils.cache.LargeCache
import okio.Path.Companion.toOkioPath
import java.io.File
/**
* 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.
* Android's per-account NUT-13 counter store: the shared
* [DataStoreCashuCounterStore] over a file in the app's data directory.
*
* # Why a separate store
* Two older layers feed into it, and neither may move a counter backwards:
*
* 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.
* - `cashu_prefs_<npub>` SharedPreferences, copied in full on first read by
* [CopyOnceMigration]. The copy happens inside the same atomic DataStore
* write that records it happened, so a crash cannot leave the marker set
* with the counters missing. It is a copy, not a move: the old file stays
* intact, so a rolled-back build still finds its counters.
* - `AccountSettings.cashuKeysetCounters`, an older in-settings map, still
* applied per keyset through `seedIfMissing` on every read.
*
* 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 (OOM, signer dialog dismiss,
* unexpected process death) 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.
* Losing a counter here means restarting a keyset at zero and reusing
* indices, which costs real ecash — so nothing on this path is best-effort.
*/
class CashuPreferences(
private val prefs: SharedPreferences,
) : CashuKeysetCounterStore {
/** Inspect the next free counter for [keysetId] without advancing it. */
@Synchronized
override fun peek(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
object CashuPreferences {
private const val LEGACY_FILE_PREFIX = "cashu_prefs_"
private val stores = LargeCache<String, CashuKeysetCounterStore>()
/**
* 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.
* Per-account instance, cached: DataStore refuses two live instances over
* one file, and a second instance would defeat the single-writer
* serialisation that `reserve` depends on.
*/
@Synchronized
@SuppressLint("ApplySharedPref")
override fun reserve(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = peek(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")
override fun seedIfMissing(
keysetId: String,
legacyValue: Long,
) {
if (legacyValue <= 0L) return
val current = peek(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 {
fun forAccount(npub: String): CashuKeysetCounterStore =
stores.getOrCreate(npub) {
val context = Amethyst.instance.appContext
val prefs = context.getSharedPreferences("$FILE_PREFIX$npub", Context.MODE_PRIVATE)
return CashuPreferences(prefs)
DataStoreCashuCounterStore(
PreferenceDataStoreFactory.createWithPath(
migrations = listOf(legacyMigration(context, npub)),
produceFile = { File(context.filesDir, "datastore/cashu_$npub.preferences_pb").toOkioPath() },
),
)
}
private fun legacyMigration(
context: Context,
npub: String,
) = CopyOnceMigration("migrated.cashuCounters") { out ->
val legacy = context.getSharedPreferences("$LEGACY_FILE_PREFIX$npub", Context.MODE_PRIVATE)
legacy.all.forEach { (key, value) ->
if (key.startsWith(DataStoreCashuCounterStore.COUNTER_PREFIX) && value is Long) {
out[longPreferencesKey(key)] = value
}
}
}
}
@@ -54,9 +54,9 @@ class FileCashuKeysetCounterStore(
Persisted()
}
override fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L }
override suspend fun peek(keysetId: String): Long = synchronized(lock) { load().keyset_counters[keysetId] ?: 0L }
override fun reserve(
override suspend fun reserve(
keysetId: String,
count: Int,
): Long =
@@ -27,7 +27,8 @@ package com.vitorpamplona.amethyst.commons.cashu
* monotonically increasing counter. Reusing a counter makes the mint reply
* `outputs already signed`, so every reservation MUST be persisted **before**
* the blinded outputs hit the mint. Implementations therefore make
* [reserve] atomic and durable.
* [reserve] atomic and durable. They suspend because durable storage on
* every target this runs on is a suspending API.
*
* - Android backs this with `AccountSettings` / `CashuPreferences`.
* - `amy` backs this with `~/.amy/<account>/cashu.json`.
@@ -37,13 +38,13 @@ package com.vitorpamplona.amethyst.commons.cashu
*/
interface CashuKeysetCounterStore {
/** The next counter for [keysetId] without advancing it (0 if unseen). */
fun peek(keysetId: String): Long
suspend fun peek(keysetId: String): Long
/**
* Atomically reserve [count] consecutive counters for [keysetId] and
* return the first reserved index. Persists before returning.
*/
fun reserve(
suspend fun reserve(
keysetId: String,
count: Int,
): Long
@@ -55,7 +56,7 @@ interface CashuKeysetCounterStore {
* kept whatever the backing store; a host whose store can write the value in
* one atomic op should override it.
*/
fun seedIfMissing(
suspend fun seedIfMissing(
keysetId: String,
legacyValue: Long,
) {
@@ -76,9 +77,9 @@ interface CashuKeysetCounterStore {
object UnavailableCashuKeysetCounterStore : CashuKeysetCounterStore {
private fun fail(): Nothing = error("No durable NUT-13 counter store is wired for this account; refusing to reuse counters.")
override fun peek(keysetId: String): Long = fail()
override suspend fun peek(keysetId: String): Long = fail()
override fun reserve(
override suspend fun reserve(
keysetId: String,
count: Int,
): Long = fail()
@@ -0,0 +1,101 @@
/*
* 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 androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import okio.IOException
/**
* DataStore-backed NUT-13 counter store, shared by every front end that has one.
*
* # Why the counters are not secret
*
* They are indices, not key material: they derive nothing without the wallet
* seed, carry no value, and a leak would at most reveal how many proofs the
* wallet has minted at each keyset.
*
* # Why every write is awaited
*
* Reusing a counter makes the mint reply `outputs already signed` and strands
* the proofs, so [reserve] must reach disk before the secrets derived from it
* reach the mint. DataStore's `edit` suspends until its write completes and
* swaps the file atomically — the same guarantee
* `SharedPreferences.edit(commit = true)` gave, paid at a suspension rather
* than a blocked thread.
*
* Read and write live inside one `edit`, so two concurrent mints cannot
* observe the same starting index; that is what the previous implementation's
* `@Synchronized` was for.
*/
class DataStoreCashuCounterStore(
private val store: DataStore<Preferences>,
) : CashuKeysetCounterStore {
companion object {
const val COUNTER_PREFIX = "counter_"
fun counterKey(keysetId: String) = longPreferencesKey(COUNTER_PREFIX + keysetId)
}
private suspend fun read(): Preferences =
store.data
.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
.first()
override suspend fun peek(keysetId: String): Long = read()[counterKey(keysetId)] ?: 0L
override suspend fun reserve(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
var first = 0L
store.edit { prefs ->
val key = counterKey(keysetId)
first = prefs[key] ?: 0L
prefs[key] = first + count.toLong()
}
return first
}
/**
* Carry a counter forward from an older store, never backwards.
*
* Writes the value directly in one atomic edit rather than advancing
* through [reserve], and compares inside that edit so a concurrent
* reservation cannot be undone by a stale read.
*/
override suspend fun seedIfMissing(
keysetId: String,
legacyValue: Long,
) {
if (legacyValue <= 0L) return
store.edit { prefs ->
val key = counterKey(keysetId)
if ((prefs[key] ?: 0L) < legacyValue) prefs[key] = legacyValue
}
}
}
@@ -119,14 +119,14 @@ class CashuWalletOps(
* it. Used to rewind the restore window in [completeMintFromLightning]
* recovery. Default is 0 (no persistent counter store).
*/
private val peekCashuCounter: (keysetId: String) -> Long = { 0L },
private val peekCashuCounter: suspend (keysetId: String) -> Long = { 0L },
/**
* Atomically reserve [count] consecutive NUT-13 counters and
* return the first reserved index. Used by the recovery path to
* advance past slots the mint confirmed in use. Default is a
* no-op for tests / random-only callers.
*/
private val reserveCashuCounters: (keysetId: String, count: Int) -> Long = { _, _ -> 0L },
private val reserveCashuCounters: suspend (keysetId: String, count: Int) -> Long = { _, _ -> 0L },
) {
private val opsCache = ConcurrentHashMap<String, CashuMintOperations>()
@@ -0,0 +1,254 @@
/*
* 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 androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey
import com.vitorpamplona.amethyst.commons.model.preferences.CopyOnceMigration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.job
import kotlinx.coroutines.test.runTest
import okio.Path.Companion.toOkioPath
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* NUT-13 counters decide whether ecash is spendable.
*
* A counter handed out twice under the same (seed, keyset) derives the same
* blinded secret twice; the mint answers `outputs already signed` and the
* proofs are stranded. These tests exist because that path had no coverage at
* all while it was backed by SharedPreferences.
*/
class DataStoreCashuCounterStoreTest {
@get:Rule
val folder = TemporaryFolder()
private var seq = 0
/**
* Closes a store's scope and waits for it.
*
* DataStore refuses two live instances over one file and only releases it
* once the owning job has actually finished, so a bare cancel() races the
* next open.
*/
private suspend fun CoroutineScope.release() {
coroutineContext.job.cancelAndJoin()
}
private fun raw(
file: File = File(folder.root, "cashu_${seq++}.preferences_pb"),
scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
migrations: List<DataMigration<Preferences>> = emptyList(),
): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(
scope = scope,
migrations = migrations,
produceFile = { file.toOkioPath() },
)
@Test
fun anUnseenKeysetStartsAtZero() =
runTest {
assertEquals(0L, DataStoreCashuCounterStore(raw()).peek("keyset1"))
}
@Test
fun peekDoesNotAdvance() =
runTest {
val store = DataStoreCashuCounterStore(raw())
store.peek("keyset1")
store.peek("keyset1")
assertEquals(0L, store.reserve("keyset1", 1))
}
@Test
fun reserveReturnsTheFirstIndexAndAdvancesByCount() =
runTest {
val store = DataStoreCashuCounterStore(raw())
assertEquals(0L, store.reserve("keyset1", 3))
assertEquals(3L, store.peek("keyset1"))
assertEquals(3L, store.reserve("keyset1", 2))
assertEquals(5L, store.peek("keyset1"))
}
@Test
fun keysetsAdvanceIndependently() =
runTest {
val store = DataStoreCashuCounterStore(raw())
store.reserve("keyset1", 5)
assertEquals(0L, store.reserve("keyset2", 1))
}
@Test
fun aNonPositiveReservationIsRejected() =
runTest {
val store = DataStoreCashuCounterStore(raw())
// Not assertThrows: a nested runTest would wrap the failure.
val thrown = runCatching { store.reserve("keyset1", 0) }.exceptionOrNull()
assertTrue("expected IllegalArgumentException, got $thrown", thrown is IllegalArgumentException)
}
/**
* The property everything else rests on: no index is ever handed out twice.
* Concurrent reservations must carve up disjoint ranges.
*/
@Test
fun concurrentReservationsNeverOverlap() =
runTest {
val store = DataStoreCashuCounterStore(raw())
val batch = 4
val workers = 25
val firsts =
(1..workers)
.map { async(Dispatchers.IO) { store.reserve("keyset1", batch) } }
.awaitAll()
val handedOut = firsts.flatMap { first -> (0 until batch).map { first + it } }
assertEquals("every index handed out exactly once", handedOut.size, handedOut.toSet().size)
assertEquals("the counter accounts for all of them", (workers * batch).toLong(), store.peek("keyset1"))
}
/** A reserved counter must survive the process that reserved it. */
@Test
fun reservationsSurviveAReopen() =
runTest {
val file = File(folder.root, "persist.preferences_pb")
val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
DataStoreCashuCounterStore(raw(file, firstScope)).reserve("keyset1", 7)
firstScope.release()
val secondScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
assertEquals(7L, DataStoreCashuCounterStore(raw(file, secondScope)).peek("keyset1"))
secondScope.release()
}
// ── seeding from older stores ─────────────────────────────────────
@Test
fun seedIfMissingCarriesALegacyValueForward() =
runTest {
val store = DataStoreCashuCounterStore(raw())
store.seedIfMissing("keyset1", 42L)
assertEquals(42L, store.peek("keyset1"))
}
/** Never backwards: a legacy value below the current one must be ignored. */
@Test
fun seedIfMissingNeverMovesACounterBackwards() =
runTest {
val store = DataStoreCashuCounterStore(raw())
store.reserve("keyset1", 100)
store.seedIfMissing("keyset1", 5L)
assertEquals(100L, store.peek("keyset1"))
}
@Test
fun seedIfMissingIgnoresNonPositiveValues() =
runTest {
val store = DataStoreCashuCounterStore(raw())
store.reserve("keyset1", 3)
store.seedIfMissing("keyset1", 0L)
store.seedIfMissing("keyset1", -1L)
assertEquals(3L, store.peek("keyset1"))
}
/**
* The SharedPreferences -> DataStore migration. A counter lost here
* restarts a keyset at zero and reuses every index it already spent.
*/
@Test
fun theLegacyMigrationCarriesEveryCounter() =
runTest {
val legacy =
mapOf(
"counter_keysetA" to 17L,
"counter_keysetB" to 4L,
)
val migration =
CopyOnceMigration("migrated.cashuCounters") { out ->
legacy.forEach { (key, value) -> out[longPreferencesKey(key)] = value }
}
val store = DataStoreCashuCounterStore(raw(migrations = listOf(migration)))
assertEquals(17L, store.peek("keysetA"))
assertEquals(4L, store.peek("keysetB"))
assertEquals("the next reservation continues, never replays", 17L, store.reserve("keysetA", 1))
}
/** The migration must not re-run and rewind counters spent since it ran. */
@Test
fun theLegacyMigrationDoesNotRewindLaterReservations() =
runTest {
val file = File(folder.root, "once.preferences_pb")
val migration = { CopyOnceMigration("migrated.cashuCounters") { out -> out[longPreferencesKey("counter_keysetA")] = 10L } }
val firstScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
DataStoreCashuCounterStore(raw(file, firstScope, listOf(migration()))).reserve("keysetA", 5)
firstScope.release()
val secondScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val reopened = DataStoreCashuCounterStore(raw(file, secondScope, listOf(migration())))
assertEquals(15L, reopened.peek("keysetA"))
assertTrue("a later reservation is past everything spent", reopened.reserve("keysetA", 1) >= 15L)
secondScope.release()
}
/** A host that wired no store must fail loudly rather than answer 0. */
@Test
fun theUnavailableStoreRefusesToAnswer() =
runTest {
val onPeek = runCatching { UnavailableCashuKeysetCounterStore.peek("keyset1") }.exceptionOrNull()
val onReserve = runCatching { UnavailableCashuKeysetCounterStore.reserve("keyset1", 1) }.exceptionOrNull()
assertTrue("peek must refuse, got $onPeek", onPeek is IllegalStateException)
assertTrue("reserve must refuse, got $onReserve", onReserve is IllegalStateException)
}
}
@@ -44,6 +44,18 @@ data class DerivedSecret(
override fun hashCode(): Int = 31 * secretHex.hashCode() + blindingFactor.contentHashCode()
}
/**
* Where a batch of deterministic secrets starts.
*
* [firstCounter] is null when the secrets will be random — either because the
* factory is [RandomSecretFactory], or because a [DeterministicSecretFactory]
* had no seed available when it reserved.
*/
data class SecretReservation(
val keysetId: String,
val firstCounter: Long?,
)
/**
* Strategy for producing the (secret, r) pairs that go into BDHKE blind
* messages. Two impls today:
@@ -61,21 +73,48 @@ data class DerivedSecret(
*/
interface SecretFactory {
/**
* Mint [count] (secret, r) pairs for use on the specified keyset.
* Reserve whatever durable state the next [count] secrets need, and return
* where they start.
*
* Batched on purpose: the deterministic implementation reserves a
* contiguous counter range via a single atomic critical section on
* `AccountSettings.reserveCashuCounters`. Calling one-at-a-time
* inside `splitAmounts(amount).map { ... }` would take the lock N
* times per mint — wasteful for both contention and disk writes.
* Suspends because that is the durability boundary: a NUT-13 counter MUST
* reach disk before any secret derived from it reaches a mint, or a crash
* mid-mint replays the counter on the next launch and the mint answers
* `outputs already signed`.
*
* Separate from [derive] so that derivation stays pure and synchronous.
* Reserving is the only part that touches storage, and only a
* deterministic factory does so at all.
*/
fun nextSecrets(
suspend fun reserve(
keysetId: String,
count: Int,
): SecretReservation
/**
* Derive [count] (secret, r) pairs from an already-reserved position.
*
* Pure: no storage, no suspension, same output for the same reservation.
*/
fun derive(
reservation: SecretReservation,
count: Int,
): List<DerivedSecret>
/**
* Reserve and derive in one step.
*
* Batched on purpose: the deterministic implementation reserves a
* contiguous counter range in a single atomic write. Calling one-at-a-time
* inside `splitAmounts(amount).map { ... }` would take the lock N times per
* mint — wasteful for both contention and disk writes.
*/
suspend fun nextSecrets(
keysetId: String,
count: Int,
): List<DerivedSecret> = derive(reserve(keysetId, count), count)
/** Convenience for ops that need a single output. */
fun nextSecret(keysetId: String): DerivedSecret = nextSecrets(keysetId, 1).first()
suspend fun nextSecret(keysetId: String): DerivedSecret = nextSecrets(keysetId, 1).first()
}
/**
@@ -85,9 +124,15 @@ interface SecretFactory {
* pre-dates the NUT-13 wiring).
*/
object RandomSecretFactory : SecretFactory {
override fun nextSecrets(
/** Nothing to reserve: random secrets keep no durable state. */
override suspend fun reserve(
keysetId: String,
count: Int,
): SecretReservation = SecretReservation(keysetId, null)
override fun derive(
reservation: SecretReservation,
count: Int,
): List<DerivedSecret> {
require(count > 0) { "Must request at least one secret" }
return List(count) {
@@ -126,29 +171,51 @@ class DeterministicSecretFactory(
private val seedProvider: () -> ByteArray?,
/**
* Atomically reserves [count] consecutive counters for a keyset and
* returns the FIRST one — the factory then derives at indices
* returns the FIRST one — derivation then runs at indices
* `[returned .. returned+count)`. Persisting in one shot avoids the
* lock-N-times-per-mint waste of the old per-secret API.
* lock-N-times-per-mint waste of a per-secret API.
*
* Suspends: it must reach disk before the secrets are used.
* `AccountSettings.reserveCashuCounters(keysetId, count)` is the
* canonical implementation.
*/
private val reserveCounters: (keysetId: String, count: Int) -> Long,
private val reserveCounters: suspend (keysetId: String, count: Int) -> Long,
private val fallback: SecretFactory = RandomSecretFactory,
) : SecretFactory {
override fun nextSecrets(
/**
* With no seed yet — the wallet has not decrypted its kind:17375 — this
* reserves nothing and reports a random batch, so counters are not burned
* for secrets that will not be derived from them.
*/
override suspend fun reserve(
keysetId: String,
count: Int,
): SecretReservation {
require(count > 0) { "Must request at least one secret" }
seedProvider() ?: return SecretReservation(keysetId, null)
return SecretReservation(keysetId, reserveCounters(keysetId, count))
}
override fun derive(
reservation: SecretReservation,
count: Int,
): List<DerivedSecret> {
require(count > 0) { "Must request at least one secret" }
val seed = seedProvider() ?: return fallback.nextSecrets(keysetId, count)
val first = reserveCounters(keysetId, count)
val first = reservation.firstCounter ?: return fallback.derive(reservation, count)
// Re-read rather than capturing the seed in the reservation, which
// would carry it through a public data class. If the seed vanished
// between reserving and deriving, this batch falls back to random and
// the reserved counters go unused — harmless, because counters only
// ever move forward and an unused one is never replayed.
val seed = seedProvider() ?: return fallback.derive(SecretReservation(reservation.keysetId, null), count)
return List(count) { offset ->
val counter = first + offset
// CashuDeterministic.secretBytes returns the raw 32 bytes;
// the hex form is what BDHKE/proof storage actually use.
val secretHex = CashuDeterministic.secretBytes(seed, keysetId, counter).toHexKey()
val r = CashuDeterministic.blindingFactor(seed, keysetId, counter)
val secretHex = CashuDeterministic.secretBytes(seed, reservation.keysetId, counter).toHexKey()
val r = CashuDeterministic.blindingFactor(seed, reservation.keysetId, counter)
DerivedSecret(secretHex, r)
}
}
@@ -0,0 +1,150 @@
/*
* 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.quartz.nip60Cashu.mintApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The reserve/derive split.
*
* Reserving is the durability boundary — it suspends and must persist a NUT-13
* counter before any secret derived from it reaches a mint. Deriving is pure.
* Keeping them apart is what lets [RandomSecretFactory] stay free of storage
* and lets the mint layer see where the write happens.
*/
class SecretFactoryTest {
private val seed = ByteArray(64) { it.toByte() }
// Keyset ids are hex by NUT-02, and NUT-13 derivation decodes them, so a
// placeholder like "keyset1" is rejected before any secret is produced.
private val keysetA = "009a1f293253e41e"
private val keysetB = "00ad268c4d1f5826"
@Test
fun randomFactoryReservesNothing() =
runTest {
val reservation = RandomSecretFactory.reserve(keysetA, 3)
assertNull(reservation.firstCounter, "random secrets keep no durable state")
assertEquals(keysetA, reservation.keysetId)
}
@Test
fun randomFactoryDerivesDistinctSecrets() =
runTest {
val secrets = RandomSecretFactory.nextSecrets(keysetA, 4)
assertEquals(4, secrets.size)
assertEquals(4, secrets.map { it.secretHex }.toSet().size, "random secrets must not repeat")
}
@Test
fun deterministicFactoryReservesTheWholeBatchOnce() =
runTest {
val calls = mutableListOf<Pair<String, Int>>()
val factory =
DeterministicSecretFactory(
seedProvider = { seed },
reserveCounters = { keysetId, count ->
calls.add(keysetId to count)
10L
},
)
val reservation = factory.reserve(keysetA, 5)
assertEquals(listOf(keysetA to 5), calls, "one reservation for the batch, not one per secret")
assertEquals(10L, reservation.firstCounter)
}
/**
* With no seed the factory falls back to random, so reserving would burn
* counters for secrets that are never derived from them.
*/
@Test
fun noSeedMeansNoCountersBurned() =
runTest {
var reserved = false
val factory =
DeterministicSecretFactory(
seedProvider = { null },
reserveCounters = { _, _ ->
reserved = true
0L
},
)
val reservation = factory.reserve(keysetA, 3)
assertTrue(!reserved, "the counter store must not be touched")
assertNull(reservation.firstCounter)
}
/** Derivation is pure: the same reservation yields the same secrets. */
@Test
fun deriveIsDeterministicForAReservation() =
runTest {
val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 7L })
val reservation = SecretReservation(keysetA, 7L)
assertEquals(factory.derive(reservation, 3), factory.derive(reservation, 3))
}
/** Consecutive counters must give different secrets, or a reused index would be harmless — it is not. */
@Test
fun eachCounterInABatchDerivesADifferentSecret() =
runTest {
val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L })
val secrets = factory.derive(SecretReservation(keysetA, 0L), 4)
assertEquals(4, secrets.map { it.secretHex }.toSet().size)
}
/** NUT-13 derivation is keyset-aware: the same counter on another keyset is a different secret. */
@Test
fun theSameCounterOnAnotherKeysetDerivesADifferentSecret() =
runTest {
val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L })
val onA = factory.derive(SecretReservation(keysetA, 0L), 1).first()
val onB = factory.derive(SecretReservation(keysetB, 0L), 1).first()
assertNotEquals(onA.secretHex, onB.secretHex)
}
/** A reservation carrying no counter derives random secrets, whatever the factory. */
@Test
fun aCounterlessReservationFallsBackToRandom() =
runTest {
val factory = DeterministicSecretFactory(seedProvider = { seed }, reserveCounters = { _, _ -> 0L })
val first = factory.derive(SecretReservation(keysetA, null), 2)
val second = factory.derive(SecretReservation(keysetA, null), 2)
assertNotEquals(first.map { it.secretHex }, second.map { it.secretHex }, "random, so not reproducible")
}
}
@@ -723,7 +723,7 @@ class CashuMintOperations(
*/
private suspend fun fetchInputFeePpkByKeyset(): Map<String, Long> = client.keysets().keysets.associate { it.id to (it.inputFeePpk ?: 0L) }
private fun createBlindedOutputs(
private suspend fun createBlindedOutputs(
amount: Long,
keyset: KeysetDto,
): List<BlindOutput> = secretOutputsFor(splitAmounts(amount), keyset)
@@ -735,7 +735,7 @@ class CashuMintOperations(
* [SecretFactory.nextSecret] per amount instead would take the
* @Synchronized lock + dirty `AccountSettings.saveable` N times.
*/
private fun secretOutputsFor(
private suspend fun secretOutputsFor(
amounts: List<Long>,
keyset: KeysetDto,
): List<BlindOutput> {
@@ -744,7 +744,12 @@ class CashuMintOperations(
// [secretFactory] decides whether those bytes are pure-random or
// NUT-13-derived from a wallet seed; either way the on-wire shape
// is identical so the mint can't tell which scheme we're using.
val derived = secretFactory.nextSecrets(keyset.id, amounts.size)
// Two steps on purpose. reserve() suspends and persists the NUT-13
// counter; derive() is pure. Keeping them apart means the durability
// boundary is visible here, at the only layer that can await it, rather
// than hidden inside secret derivation.
val reservation = secretFactory.reserve(keyset.id, amounts.size)
val derived = secretFactory.derive(reservation, amounts.size)
return amounts.mapIndexed { i, amount ->
val pair = derived[i]
val bTick = Bdhke.blind(pair.secretHex.encodeToByteArray(), pair.blindingFactor)