mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+2
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+42
-34
@@ -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
|
||||
|
||||
+4
-2
@@ -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 },
|
||||
|
||||
+7
-6
@@ -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>
|
||||
}
|
||||
|
||||
+12
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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() }
|
||||
}
|
||||
|
||||
+3
@@ -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()
|
||||
}
|
||||
|
||||
+15
-39
@@ -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
|
||||
+44
@@ -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
|
||||
}
|
||||
+78
@@ -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))
|
||||
Reference in New Issue
Block a user