From 290d87d9f6144b42e7f94071e915c3196cbbfa55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:11:43 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P --- .../NotificationFeedFilterModeOverrideTest.kt | 3 +- .../ThreadDualAxisChartAssemblerTest.kt | 3 +- .../vitorpamplona/amethyst/model/Account.kt | 6 +- .../model/accountsCache/AccountCacheState.kt | 6 +- .../model/nip60Cashu/CashuWalletState.kt | 76 ++++++++++-------- .../ui/screen/loggedIn/AccountViewModel.kt | 6 +- .../amethyst/cli/CashuContext.kt | 3 +- .../commons/cashu/CashuWalletReader.kt | 0 .../commons/cashu/ops/CashuWalletOps.kt | 13 ++-- .../amethyst/commons/util/ConcurrentSet.kt | 3 + .../commons/util/ConcurrentSetTest.kt | 12 +++ .../commons/util/ConcurrentSet.ios.kt | 2 + .../commons/util/ConcurrentSet.jvmAndroid.kt | 3 + .../nip60Cashu/mintApi/CashuMintOperations.kt | 0 .../mintApi/CashuMintUrlValidator.kt | 0 .../nip60Cashu/mintApi/MintHttpClient.kt | 54 ++++--------- .../nip60Cashu/mintApi/MintHttpTransport.kt | 44 +++++++++++ .../nip60Cashu/mintApi/OkHttpMintTransport.kt | 78 +++++++++++++++++++ 18 files changed, 224 insertions(+), 88 deletions(-) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt (99%) rename quartz/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt (100%) rename quartz/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt (100%) rename quartz/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt (80%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpTransport.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/OkHttpMintTransport.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt index abd2af80fd..f9ba49639c 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/NotificationFeedFilterModeOverrideTest.kt @@ -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.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, - okHttpClientForMoney = { OkHttpClient() }, + cashuMintTransport = OkHttpMintTransport { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = client, diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt index dfc3608ef6..65161ac998 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ThreadDualAxisChartAssemblerTest.kt @@ -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.Loading) }, nwcFilterAssembler = { NWCPaymentFilterAssembler(client) }, cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) }, - okHttpClientForMoney = { OkHttpClient() }, + cashuMintTransport = OkHttpMintTransport { OkHttpClient() }, otsResolverBuilder = { EmptyOtsResolverBuilder.build() }, cache = LocalCache, client = client, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1c35b0e0e6..4d76325fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, 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, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 8d84a0e37e..ba3ede60a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -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, 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 1a97ee66c1..a651c7dc34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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>, private val dmRelaysFlow: StateFlow>, 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() - private val historyEvents = ConcurrentHashMap() - private val quoteEvents = ConcurrentHashMap() - private val nutzapEvents = ConcurrentHashMap() + private val tokenEvents = ConcurrentMap() + private val historyEvents = ConcurrentMap() + private val quoteEvents = ConcurrentMap() + private val nutzapEvents = ConcurrentMap() /** * 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() + private val recommendationEvents = ConcurrentMap() /** NIP-44 decryption cache for token contents, keyed by event id. */ - private val tokenContents = ConcurrentHashMap() + private val tokenContents = ConcurrentMap() 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() + private val sessionRedeemedNutzaps = ConcurrentSet() /** * 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() + private val sessionUnredeemableNutzaps = ConcurrentSet() // ============================================================ // 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() + val collected = ConcurrentMap() 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 { @@ -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() - 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 39c38d6ff6..2f6a75f954 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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(), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt index 0ac9e65b23..fad27a6af2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/CashuContext.kt @@ -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 }, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletReader.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt similarity index 99% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index f40c21c608..ce72493a66 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -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() + private val opsCache = ConcurrentMap() 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.kt index 519a5e1aa3..d3b28ebcce 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.kt @@ -50,4 +50,7 @@ expect class ConcurrentSet() { fun clear() val size: Int + + /** A point-in-time copy of the elements — safe to iterate while others add and remove. */ + fun snapshot(): Set } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSetTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSetTest.kt index f6e2543476..23eaf36ebc 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSetTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSetTest.kt @@ -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() + 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()) + } } diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.ios.kt index 5fc7921153..25cfb1295c 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.ios.kt @@ -36,4 +36,6 @@ actual class ConcurrentSet { actual fun clear() = lock.withLock { set.clear() } actual val size: Int get() = lock.withLock { set.size } + + actual fun snapshot(): Set = lock.withLock { set.toHashSet() } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.jvmAndroid.kt index e91b7196f9..f8948e12f8 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ConcurrentSet.jvmAndroid.kt @@ -35,4 +35,7 @@ actual class ConcurrentSet { 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 = set.toHashSet() } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt similarity index 100% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintOperations.kt diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt similarity index 100% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/CashuMintUrlValidator.kt diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt similarity index 80% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt index c6cdd37c4e..9b754c2277 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpClient.kt @@ -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("/v1/info") - cachedInfo = fresh to System.currentTimeMillis() + cachedInfo = fresh to TimeUtils.nowMillis() return fresh } @@ -128,41 +127,20 @@ class MintHttpClient( private suspend inline fun 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(body) - } + val resp = transport.get(baseUrl + path) + if (!resp.isSuccessful) throw decodeError(resp.status, resp.body) + json.decodeFromString(resp.body) } private suspend inline fun post( path: String, body: T, - serializer: kotlinx.serialization.KSerializer, + serializer: KSerializer, ): 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(text) - } + val resp = transport.postJson(baseUrl + path, json.encodeToString(serializer, body)) + if (!resp.isSuccessful) throw decodeError(resp.status, resp.body) + json.decodeFromString(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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpTransport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpTransport.kt new file mode 100644 index 0000000000..b4580029e3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/MintHttpTransport.kt @@ -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 +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/OkHttpMintTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/OkHttpMintTransport.kt new file mode 100644 index 0000000000..dd2a32db67 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip60Cashu/mintApi/OkHttpMintTransport.kt @@ -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))