mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user