diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index d6f4c2f95f..a15ba45683 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -32,6 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** @@ -60,10 +62,13 @@ object ClinkDebitPayer { bolt11: String, amountSats: Long? = null, timeoutMs: Long = DEFAULT_TIMEOUT_MS, - ): DebitResponse? { - val client = clientFor(pointer, account) ?: return null - return sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs) - } + ): DebitResponse? = + // Off the Main thread: building the request signs + NIP-44 encrypts, and callers reach + // this from Compose (Main) scopes (offer card, lightning-address row). See ClinkOfferPayer. + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.payInvoice(bolt11, amountSats), timeoutMs) + } /** * Asks the wallet to authorize a spending budget. Omit [frequency] for a one-time @@ -75,10 +80,11 @@ object ClinkDebitPayer { amountSats: Long, frequency: DebitFrequency? = null, timeoutMs: Long = DEFAULT_TIMEOUT_MS, - ): DebitResponse? { - val client = clientFor(pointer, account) ?: return null - return sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) - } + ): DebitResponse? = + withContext(Dispatchers.IO) { + val client = clientFor(pointer, account) ?: return@withContext null + sendAndAwait(account, client, client.requestBudget(amountSats, frequency), timeoutMs) + } // Debits sign with the persistent account identity (unlike offer requests, which use a // throwaway key — see ClinkOfferPayer): the service must see one stable app identity so a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index cce3b7356e..a835db93e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** @@ -60,49 +62,54 @@ object ClinkOfferPayer { val relays = offer.relays.toSet() if (relays.isEmpty()) return null - // Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker - // News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this - // key and decrypted with it), so there is no reason to expose the user's real identity - // to every offer service they pay. Payer identity, when needed, travels in the request - // body (payer_data / a signed zap request), not the transport key. - val ephemeralSigner = NostrSignerInternal(KeyPair()) - val client = OfferClient(offer, ephemeralSigner) - val request = client.requestInvoice(amountSats = amountSats) + // Keep the round-trip off the Main thread: the ephemeral keygen, JSON serialization, + // NIP-44 encryption and signing are CPU/crypto-heavy, and callers reach this from a + // Compose (Main) scope. StrictMode flags any of it running on the UI thread. + return withContext(Dispatchers.IO) { + // Sign the request with a fresh throwaway key, like the reference SDK/Zeus/Stacker + // News do: an offer round-trip is self-contained (the reply is NIP-44'd back to this + // key and decrypted with it), so there is no reason to expose the user's real identity + // to every offer service they pay. Payer identity, when needed, travels in the request + // body (payer_data / a signed zap request), not the transport key. + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val client = OfferClient(offer, ephemeralSigner) + val request = client.requestInvoice(amountSats = amountSats) - val reply = CompletableDeferred() - val subId = "clink-offer-${request.id}" - val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } + val reply = CompletableDeferred() + val subId = "clink-offer-${request.id}" + val filters: Map> = relays.associateWith { listOf(client.responseFilter(request.id)) } - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) { - reply.complete(event) + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is OfferEvent && event.requestId() == request.id && !reply.isCompleted) { + reply.complete(event) + } } } - } - account.client.subscribe(subId, filters, listener) - return try { - account.client.publish(request, relays) - val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null - // A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON - // from a buggy or hostile relay) is treated as no usable response rather than - // thrown — callers only handle null, and an uncaught decode error would hang - // the UI (the Pay button stuck on "Requesting…"). + account.client.subscribe(subId, filters, listener) try { - client.parseResponse(response) - } catch (e: Exception) { - if (e is CancellationException) throw e - null + account.client.publish(request, relays) + val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return@withContext null + // A reply that can't be decrypted/parsed (corrupt ciphertext, malformed JSON + // from a buggy or hostile relay) is treated as no usable response rather than + // thrown — callers only handle null, and an uncaught decode error would hang + // the UI (the Pay button stuck on "Requesting…"). + try { + client.parseResponse(response) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } finally { + account.client.unsubscribe(subId) } - } finally { - account.client.unsubscribe(subId) } } }