fix(clink): run offer/debit payer crypto off the Main thread

StrictMode flagged the offer round-trip (ephemeral keygen, JSON serialization,
NIP-44 encrypt/decrypt, signing) running on the UI thread, because
ClinkOfferPreview launches it from a Compose (Main) scope. Wrap the heavy work
in withContext(Dispatchers.IO) in both ClinkOfferPayer.requestInvoice and
ClinkDebitPayer.payInvoice/requestBudget so the payers are main-safe regardless
of caller dispatcher.

https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
This commit is contained in:
Claude
2026-06-10 21:40:53 +00:00
parent 5aab19e8da
commit f2cce3dc87
2 changed files with 57 additions and 44 deletions
@@ -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
@@ -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<OfferEvent>()
val subId = "clink-offer-${request.id}"
val filters: Map<NormalizedRelayUrl, List<Filter>> = relays.associateWith { listOf(client.responseFilter(request.id)) }
val reply = CompletableDeferred<OfferEvent>()
val subId = "clink-offer-${request.id}"
val filters: Map<NormalizedRelayUrl, List<Filter>> = relays.associateWith { listOf(client.responseFilter(request.id)) }
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<Filter>?,
) {
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)
}
}
}