refactor(cashu): mint client and wallet ops to commonMain over a transport

The Cashu stack under Account (CashuWalletState -> commons CashuWalletOps
-> quartz CashuMintOperations/MintHttpClient) was jvmAndroid because the
mint client was written on OkHttp.

quartz:
- MintHttpTransport (commonMain) is the HTTP the client needs: GET and a
  JSON POST, returning status + body text. OkHttpMintTransport (jvmAndroid)
  implements it with the per-URL OkHttp factory it used before, and a
  same-named MintHttpClient(..., okHttpClient) factory keeps the app's
  MeltProcessor, the CLI mint commands and the tests unchanged.
- MintHttpClient, CashuMintOperations and CashuMintUrlValidator move to
  commonMain. The client keeps withContext(Dispatchers.IO) around each call
  and its decode, uses TimeUtils.nowMillis() and kotlin.concurrent.Volatile.

commons:
- CashuWalletOps and CashuWalletReader move to commonMain. CashuWalletOps
  takes a mintTransport instead of an OkHttp factory; its per-mint cache is
  quartz ConcurrentMap (same atomic getOrPut).
- ConcurrentSet gains snapshot() (jvmAndroid: copy of the key-set view; iOS:
  copy under its lock), with a commonTest.

app:
- CashuWalletState's maps are quartz ConcurrentMap / commons ConcurrentSet;
  every iteration reads a snapshot(). put(...) == null ("was it new") is
  putIfAbsent(...) == null: an event with the same id is the same event.
- Account takes cashuMintTransport instead of okHttpClientForMoney (Cashu
  was its only user); AccountCacheState wraps its money client in
  OkHttpMintTransport. The CLI's CashuContext does the same.

No behaviour change intended. CashuWalletDiscovery stays in jvmAndroid; the
group does not use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-28 02:11:43 +00:00
parent a87d4ffd6f
commit 290d87d9f6
18 changed files with 224 additions and 88 deletions
@@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
import com.vitorpamplona.quartz.nip60Cashu.mintApi.OkHttpMintTransport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -90,7 +91,7 @@ class NotificationFeedFilterModeOverrideTest {
geolocationFlow = { MutableStateFlow<LocationResult>(LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
cashuMintTransport = OkHttpMintTransport { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = client,
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
import com.vitorpamplona.quartz.nip60Cashu.mintApi.OkHttpMintTransport
import junit.framework.TestCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -80,7 +81,7 @@ class ThreadDualAxisChartAssemblerTest {
geolocationFlow = { MutableStateFlow<LocationResult>(LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
cashuMintTransport = OkHttpMintTransport { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = client,
@@ -299,6 +299,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapTemplateConversion
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpTransport
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
@@ -375,7 +376,8 @@ class Account(
val geolocationFlow: () -> StateFlow<LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
/** How Cashu mint requests go out (the app's money-role HTTP: Tor or proxy per URL). */
val cashuMintTransport: MintHttpTransport,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
val client: INostrClient,
@@ -876,7 +878,7 @@ class Account(
inboxRelaysFlow = notificationRelays.flow,
dmRelaysFlow = dmRelays.flow,
settings = settings,
okHttpClient = okHttpClientForMoney,
mintTransport = cashuMintTransport,
)
/**
@@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import com.vitorpamplona.quartz.nip60Cashu.mintApi.OkHttpMintTransport
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -63,6 +64,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import okio.Path.Companion.toOkioPath
import java.io.File
@@ -70,7 +72,7 @@ class AccountCacheState(
val geolocationFlow: () -> StateFlow<LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val okHttpClientForMoney: (String) -> OkHttpClient,
val contentResolverFn: () -> ContentResolver,
val otsResolverBuilder: () -> OtsResolver,
val cache: LocalCache,
@@ -357,7 +359,7 @@ class AccountCacheState(
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler,
okHttpClientForMoney = okHttpClientForMoney,
cashuMintTransport = OkHttpMintTransport(okHttpClientForMoney),
otsResolverBuilder = otsResolverBuilder,
cache = cache,
client = client,
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters
import com.vitorpamplona.amethyst.commons.util.ConcurrentSet
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -45,6 +46,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpTransport
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -54,6 +56,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -72,8 +75,6 @@ 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
/**
* Account-scoped state holder for the NIP-60 Cashu wallet + NIP-61 nutzaps.
@@ -110,13 +111,13 @@ class CashuWalletState(
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val settings: AccountSettings,
okHttpClient: (String) -> OkHttpClient,
mintTransport: MintHttpTransport,
) {
val ops: CashuWalletOps =
CashuWalletOps(
signer = signer,
publish = ::publishEvent,
okHttpClient = okHttpClient,
mintTransport = mintTransport,
// NUT-13 wiring: the factory closure reads the cached seed at
// mint-op time. cachedSeed is populated by ensureSeed() —
// CashuWalletOps' seedWarmer below calls it before any blind
@@ -152,10 +153,10 @@ class CashuWalletState(
// ============================================================
private var walletEventInternal: CashuWalletEvent? = null
private var nutzapInfoEventInternal: NutzapInfoEvent? = null
private val tokenEvents = ConcurrentHashMap<HexKey, CashuTokenEvent>()
private val historyEvents = ConcurrentHashMap<HexKey, CashuSpendingHistoryEvent>()
private val quoteEvents = ConcurrentHashMap<HexKey, CashuMintQuoteEvent>()
private val nutzapEvents = ConcurrentHashMap<HexKey, NutzapEvent>()
private val tokenEvents = ConcurrentMap<HexKey, CashuTokenEvent>()
private val historyEvents = ConcurrentMap<HexKey, CashuSpendingHistoryEvent>()
private val quoteEvents = ConcurrentMap<HexKey, CashuMintQuoteEvent>()
private val nutzapEvents = ConcurrentMap<HexKey, NutzapEvent>()
/**
* NIP-87 cashu mint recommendations published by this account. Keyed by
@@ -164,10 +165,10 @@ class CashuWalletState(
* mint replaces the older one and we don't show duplicates in the
* Settings screen list.
*/
private val recommendationEvents = ConcurrentHashMap<String, MintRecommendationEvent>()
private val recommendationEvents = ConcurrentMap<String, MintRecommendationEvent>()
/** NIP-44 decryption cache for token contents, keyed by event id. */
private val tokenContents = ConcurrentHashMap<HexKey, TokenContent>()
private val tokenContents = ConcurrentMap<HexKey, TokenContent>()
private val redeemMutex = Mutex()
/**
@@ -180,7 +181,7 @@ class CashuWalletState(
* double-redeem race. The set is process-local; on next launch the
* persisted kind:7376 events rebuild equivalent state.
*/
private val sessionRedeemedNutzaps = ConcurrentHashMap.newKeySet<HexKey>()
private val sessionRedeemedNutzaps = ConcurrentSet<HexKey>()
/**
* Nutzap event ids that failed redemption with a deterministic
@@ -193,7 +194,7 @@ class CashuWalletState(
* process-local — if the user rotates their P2PK key, restarting
* gives the redeem another shot.
*/
private val sessionUnredeemableNutzaps = ConcurrentHashMap.newKeySet<HexKey>()
private val sessionUnredeemableNutzaps = ConcurrentSet<HexKey>()
// ============================================================
// Public flows
@@ -624,7 +625,7 @@ class CashuWalletState(
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>()
val collected = ConcurrentMap<HexKey, CashuTokenEvent>()
runCatching {
client.fetchAllPagesFromPool(
filters = relays.associateWith { filters },
@@ -645,9 +646,9 @@ class CashuWalletState(
proofBackfillDone = true
}
val fresh = collected.values.filter { !tokenEvents.containsKey(it.id) }
val fresh = collected.snapshot().values.filter { tokenEvents[it.id] == null }
Log.i("CashuWallet") {
"Proof backfill over ${relays.size} relay(s): ${collected.size} kind:7375 seen, ${fresh.size} new"
"Proof backfill over ${relays.size} relay(s): ${collected.size()} kind:7375 seen, ${fresh.size} new"
}
if (fresh.isNotEmpty()) {
@@ -720,16 +721,16 @@ class CashuWalletState(
}
}
is CashuTokenEvent -> {
if (tokenEvents.put(event.id, event) == null) dirtyTokens = true
if (tokenEvents.putIfAbsent(event.id, event) == null) dirtyTokens = true
}
is CashuSpendingHistoryEvent -> {
if (historyEvents.put(event.id, event) == null) dirtyHistory = true
if (historyEvents.putIfAbsent(event.id, event) == null) dirtyHistory = true
}
is CashuMintQuoteEvent -> {
if (quoteEvents.put(event.id, event) == null) dirtyQuotes = true
if (quoteEvents.putIfAbsent(event.id, event) == null) dirtyQuotes = true
}
is NutzapEvent -> {
if (nutzapEvents.put(event.id, event) == null) dirtyNutzaps = true
if (nutzapEvents.putIfAbsent(event.id, event) == null) dirtyNutzaps = true
}
is MintRecommendationEvent -> {
// kind:38000 is parameterized-replaceable — keep only the
@@ -776,7 +777,7 @@ class CashuWalletState(
}
if (dirtyTokens) recomputeUnspent()
if (dirtyHistory) {
_history.value = historyEvents.values.sortedByDescending { it.createdAt }
_history.value = historyEvents.snapshot().values.sortedByDescending { it.createdAt }
}
if (dirtyQuotes || dirtyHistory) {
// History gains might mark quotes as fulfilled (via the "destroyed"
@@ -787,7 +788,7 @@ class CashuWalletState(
triggerAutoRedeem()
}
if (dirtyRecommendations) {
_ownRecommendations.value = recommendationEvents.values.sortedByDescending { it.createdAt }
_ownRecommendations.value = recommendationEvents.snapshot().values.sortedByDescending { it.createdAt }
}
}
@@ -819,7 +820,11 @@ class CashuWalletState(
// Recommendations are indexed by dTag, not event id — find by
// matching event.id and drop the entry.
val recoKey =
recommendationEvents.entries.firstOrNull { it.value.id == id }?.key
recommendationEvents
.snapshot()
.entries
.firstOrNull { it.value.id == id }
?.key
if (recoKey != null) {
recommendationEvents.remove(recoKey)
dirtyRecommendations = true
@@ -840,26 +845,26 @@ class CashuWalletState(
settings.clearNutzapInfo()
}
if (dirtyTokens) recomputeUnspent()
if (dirtyHistory) _history.value = historyEvents.values.sortedByDescending { it.createdAt }
if (dirtyHistory) _history.value = historyEvents.snapshot().values.sortedByDescending { it.createdAt }
if (dirtyQuotes || dirtyHistory) recomputePending()
// dirtyNutzaps would trigger UI surfacing for inbound nutzaps; auto-
// redeem already fires from the live-event observer, so no extra
// signal is needed here.
if (dirtyNutzaps) Unit
if (dirtyRecommendations) {
_ownRecommendations.value = recommendationEvents.values.sortedByDescending { it.createdAt }
_ownRecommendations.value = recommendationEvents.snapshot().values.sortedByDescending { it.createdAt }
}
}
private suspend fun recomputeUnspent() {
val all = tokenEvents.values.toList()
val all = tokenEvents.snapshot().values.toList()
// Decrypt anything we haven't seen before; reuse cached TokenContent
// for events we've already decrypted. Only successes are cached, so a
// failure is retried on the next recompute rather than being pinned as
// "empty" for the session.
var undecryptable = 0
all.forEach { evt ->
if (!tokenContents.containsKey(evt.id)) {
if (tokenContents[evt.id] == null) {
val content =
runCatching { evt.tokenContent(signer) }
.onFailure {
@@ -884,15 +889,18 @@ class CashuWalletState(
}
// Shared del-rollover + sort with the headless reader.
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents)
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents.snapshot())
}
/** Token events we hold but have never managed to decrypt. See [recomputeUnspent]. */
private fun undecryptedTokenCount(): Int = tokenEvents.keys.count { it !in tokenContents.keys }
private fun undecryptedTokenCount(): Int {
val decrypted = tokenContents.snapshot()
return tokenEvents.snapshot().keys.count { it !in decrypted }
}
private fun recomputePending() {
// Shared destroyed/expired filter with the headless reader.
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values)
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.snapshot().values, historyEvents.snapshot().values)
}
private fun scanCacheForOwnEvents(): List<Event> {
@@ -921,15 +929,15 @@ class CashuWalletState(
// (Amber even prompts on some configurations), paid on every bundle
// by a wallet whose nutzaps were all redeemed months ago. Nothing
// above the candidate filter needs a key, so hoist the filter.
if (nutzapEvents.isEmpty()) return
if (nutzapEvents.size() == 0) return
val skipIds = HashSet<HexKey>()
historyEvents.values.forEach { h ->
historyEvents.snapshot().values.forEach { h ->
h.redeemedReferences().forEach { skipIds.add(it.eventId) }
}
skipIds.addAll(sessionRedeemedNutzaps)
skipIds.addAll(sessionUnredeemableNutzaps)
skipIds.addAll(sessionRedeemedNutzaps.snapshot())
skipIds.addAll(sessionUnredeemableNutzaps.snapshot())
val candidates = nutzapEvents.values.filter { it.id !in skipIds }
val candidates = nutzapEvents.snapshot().values.filter { it.id !in skipIds }
if (candidates.isEmpty()) return
val privkey = walletPrivkeyHex() ?: return
@@ -215,6 +215,7 @@ import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.OkHttpMintTransport
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.DvmContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip92IMeta.imeta
@@ -253,6 +254,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
/**
* How long the navigation pickers wait for the toggles to stop before publishing. Long enough that a
@@ -3241,7 +3243,7 @@ fun mockAccountViewModel(): AccountViewModel {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuMintDirectoryFilterAssembler(client)
},
okHttpClientForMoney = { okhttp3.OkHttpClient() },
cashuMintTransport = OkHttpMintTransport { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = client,
@@ -3303,7 +3305,7 @@ fun mockVitorAccountViewModel(): AccountViewModel {
com.vitorpamplona.amethyst.commons.relayClient.assemblers
.CashuMintDirectoryFilterAssembler(client)
},
okHttpClientForMoney = { okhttp3.OkHttpClient() },
cashuMintTransport = OkHttpMintTransport { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
cache = LocalCache,
client = EmptyNostrClient(),
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.OkHttpMintTransport
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -79,7 +80,7 @@ class CashuContext(
// keypackage — is the CLI's closest analog, so the wallet lands
// on the same broad relay set the app would use, not just outbox.
publish = { event -> ctx.publish(event, ctx.anyRelays()) },
okHttpClient = { ctx.okhttp },
mintTransport = OkHttpMintTransport { ctx.okhttp },
secretFactory =
DeterministicSecretFactory(
seedProvider = { cachedSeed },
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.DleqProofDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpTransport
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintProtocolException
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState
@@ -61,6 +62,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.redemption.notifySender
import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -70,8 +72,6 @@ import kotlinx.coroutines.sync.withPermit
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.util.concurrent.ConcurrentHashMap
/**
* Wallet-level operations that combine the [CashuMintOperations] HTTP layer
@@ -90,7 +90,8 @@ import java.util.concurrent.ConcurrentHashMap
class CashuWalletOps(
private val signer: NostrSigner,
private val publish: suspend (Event) -> Unit,
private val okHttpClient: (String) -> OkHttpClient,
/** How mint requests go out (OkHttp on Android and the JVM, picking Tor or a proxy per URL). */
private val mintTransport: MintHttpTransport,
/**
* NUT-13 secret strategy. Defaults to random for backwards
* compatibility with tests that don't carry a seed. The wallet state
@@ -129,13 +130,13 @@ class CashuWalletOps(
*/
private val reserveCashuCounters: suspend (keysetId: String, count: Int) -> Long = { _, _ -> 0L },
) {
private val opsCache = ConcurrentHashMap<String, CashuMintOperations>()
private val opsCache = ConcurrentMap<String, CashuMintOperations>()
private fun ops(mintUrl: String): CashuMintOperations =
opsCache.getOrPut(mintUrl.trimEnd('/')) {
// userConfigured: these are the mints of the user's own NIP-60 wallet,
// added deliberately, so a self-hosted mint on the LAN stays usable.
CashuMintOperations(MintHttpClient(mintUrl, userConfigured = true, okHttpClient = okHttpClient), secretFactory)
CashuMintOperations(MintHttpClient(mintUrl, userConfigured = true, transport = mintTransport), secretFactory)
}
/**
@@ -960,7 +961,7 @@ class CashuWalletOps(
* `userConfigured = true`: the URL was typed by the user into the Add-Mint UI,
* so a self-hosted mint on the LAN is a legitimate target here.
*/
suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, userConfigured = true, okHttpClient = okHttpClient).info().name
suspend fun pingMint(mintUrl: String): String? = MintHttpClient(mintUrl, userConfigured = true, transport = mintTransport).info().name
/**
* Fetch the currently-active keyset id for [mintUrl]. Cheap wrapper
@@ -50,4 +50,7 @@ expect class ConcurrentSet<E : Any>() {
fun clear()
val size: Int
/** A point-in-time copy of the elements — safe to iterate while others add and remove. */
fun snapshot(): Set<E>
}
@@ -60,4 +60,16 @@ class ConcurrentSetTest {
assertEquals(0, set.size)
assertFalse(set.contains("a"))
}
@Test
fun `snapshot is a copy that later writes do not change`() {
val set = ConcurrentSet<String>()
set.add("a")
set.add("b")
val snapshot = set.snapshot()
set.add("c")
set.remove("a")
assertEquals(setOf("a", "b"), snapshot)
assertEquals(setOf("b", "c"), set.snapshot())
}
}
@@ -36,4 +36,6 @@ actual class ConcurrentSet<E : Any> {
actual fun clear() = lock.withLock { set.clear() }
actual val size: Int get() = lock.withLock { set.size }
actual fun snapshot(): Set<E> = lock.withLock { set.toHashSet() }
}
@@ -35,4 +35,7 @@ actual class ConcurrentSet<E : Any> {
actual fun clear() = set.clear()
actual val size: Int get() = set.size
// The key-set view iterates weakly consistently, so copying it never throws while others write.
actual fun snapshot(): Set<E> = set.toHashSet()
}
@@ -20,14 +20,13 @@
*/
package com.vitorpamplona.quartz.nip60Cashu.mintApi
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import kotlin.concurrent.Volatile
/**
* Thrown when the mint returns a non-2xx response or an HTTP error.
@@ -53,7 +52,7 @@ class MintProtocolException(
) : RuntimeException(message)
/**
* OkHttp-backed Cashu v1 mint client implementing NUT-00..06 endpoints.
* Cashu v1 mint client implementing NUT-00..06 endpoints, over a [MintHttpTransport].
*
* Each instance is bound to a single mint URL (e.g. `https://mint.example.com`).
* Trailing slashes are stripped on construction.
@@ -70,7 +69,7 @@ class MintProtocolException(
class MintHttpClient(
mintUrl: String,
userConfigured: Boolean = false,
private val okHttpClient: (String) -> OkHttpClient,
private val transport: MintHttpTransport,
) {
private val baseUrl: String = CashuMintUrlValidator.validatedBaseUrl(mintUrl, userConfigured)
@@ -92,13 +91,13 @@ class MintHttpClient(
suspend fun info(force: Boolean = false): MintInfoDto {
if (!force) {
cachedInfo?.let { (info, at) ->
if (System.currentTimeMillis() - at < INFO_CACHE_TTL_MS) {
if (TimeUtils.nowMillis() - at < INFO_CACHE_TTL_MS) {
return info
}
}
}
val fresh = get<MintInfoDto>("/v1/info")
cachedInfo = fresh to System.currentTimeMillis()
cachedInfo = fresh to TimeUtils.nowMillis()
return fresh
}
@@ -128,41 +127,20 @@ class MintHttpClient(
private suspend inline fun <reified R> get(path: String): R =
withContext(Dispatchers.IO) {
val url = baseUrl + path
val client = okHttpClient(url)
val req =
Request
.Builder()
.url(url)
.get()
.build()
client.newCall(req).executeAsync().use { resp ->
val body = resp.body.string()
if (!resp.isSuccessful) throw decodeError(resp.code, body)
json.decodeFromString<R>(body)
}
val resp = transport.get(baseUrl + path)
if (!resp.isSuccessful) throw decodeError(resp.status, resp.body)
json.decodeFromString<R>(resp.body)
}
private suspend inline fun <T, reified R> post(
path: String,
body: T,
serializer: kotlinx.serialization.KSerializer<T>,
serializer: KSerializer<T>,
): R =
withContext(Dispatchers.IO) {
val url = baseUrl + path
val client = okHttpClient(url)
val bodyJson = json.encodeToString(serializer, body)
val req =
Request
.Builder()
.url(url)
.post(bodyJson.toRequestBody(jsonMediaType))
.build()
client.newCall(req).executeAsync().use { resp ->
val text = resp.body.string()
if (!resp.isSuccessful) throw decodeError(resp.code, text)
json.decodeFromString<R>(text)
}
val resp = transport.postJson(baseUrl + path, json.encodeToString(serializer, body))
if (!resp.isSuccessful) throw decodeError(resp.status, resp.body)
json.decodeFromString<R>(resp.body)
}
private fun decodeError(
@@ -187,8 +165,6 @@ class MintHttpClient(
explicitNulls = false
}
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
/**
* Mint info TTL. /v1/info typically changes on the order of
* weeks (mint name, supported NUTs, motd updates). 30 minutes
@@ -0,0 +1,44 @@
/*
* 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
/**
* The HTTP a [MintHttpClient] needs: a GET, and a POST of a JSON body, each returning the status and
* the body as text. Each platform supplies one (OkHttp on Android and the JVM). Implementations
* pick the per-URL client themselves (Tor, proxies), run off the main thread, and throw on a
* transport failure; an HTTP error status is a [MintHttpResponse], not an exception.
*/
interface MintHttpTransport {
suspend fun get(url: String): MintHttpResponse
suspend fun postJson(
url: String,
json: String,
): MintHttpResponse
}
/** One mint response. */
class MintHttpResponse(
val status: Int,
val body: String,
) {
val isSuccessful: Boolean get() = status in 200..299
}
@@ -0,0 +1,78 @@
/*
* 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.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
/** [MintHttpTransport] over OkHttp. [okHttpClient] picks the client for each URL (Tor, proxies). */
class OkHttpMintTransport(
private val okHttpClient: (String) -> OkHttpClient,
) : MintHttpTransport {
override suspend fun get(url: String): MintHttpResponse =
execute(
url,
Request
.Builder()
.url(url)
.get()
.build(),
)
override suspend fun postJson(
url: String,
json: String,
): MintHttpResponse =
execute(
url,
Request
.Builder()
.url(url)
.post(json.toRequestBody(jsonMediaType))
.build(),
)
private suspend fun execute(
url: String,
request: Request,
): MintHttpResponse =
withContext(Dispatchers.IO) {
okHttpClient(url).newCall(request).executeAsync().use { resp ->
MintHttpResponse(resp.code, resp.body.string())
}
}
companion object {
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
}
}
/** A [MintHttpClient] over OkHttp, for callers that hold an OkHttp client factory. */
fun MintHttpClient(
mintUrl: String,
userConfigured: Boolean = false,
okHttpClient: (String) -> OkHttpClient,
): MintHttpClient = MintHttpClient(mintUrl, userConfigured, OkHttpMintTransport(okHttpClient))