fix(clink): harden payer round-trips + two review findings

From a correctness review of the CLINK branch:
- ClinkOfferPayer/ClinkDebitPayer now catch decrypt/parse failures from
  parseResponse and treat an undecryptable reply as no response (return null)
  instead of throwing. An uncaught SerializationException/NIP-44 failure
  escaped launchSigner (which only catches signer exceptions), hanging the UI:
  the offer card stuck on 'Requesting…', the DVM status stuck, the budget toast
  never shown, and a split zap silently cancelling sibling payments.
- ClinkOfferPreview now renders the active (possibly moved) offer's price/type,
  not the original pointer's, after an Expired-or-Moved redirect.
- WalletViewModel.setDefaultWallet only updates local state when the persist
  actually succeeds, so the default star can't diverge from the stored value.

:amethyst compiles.
This commit is contained in:
Claude
2026-06-10 03:13:54 +00:00
parent 084c7be23a
commit a481fa5bea
4 changed files with 30 additions and 6 deletions
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
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.withTimeoutOrNull
@@ -115,7 +116,15 @@ object ClinkDebitPayer {
return try {
account.client.publish(request, relays)
val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null
client.parseResponse(response)
// Treat an undecryptable/malformed reply as no usable response rather than
// throwing — callers only handle null, and an uncaught decode error would
// leave the calling UI hung (spinner stuck, no toast, sibling zaps cancelled).
try {
client.parseResponse(response)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
} finally {
account.client.unsubscribe(subId)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
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.withTimeoutOrNull
@@ -82,7 +83,16 @@ object ClinkOfferPayer {
return try {
account.client.publish(request, relays)
val response = withTimeoutOrNull(timeoutMs) { reply.await() } ?: return null
client.parseResponse(response)
// 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)
}
@@ -145,10 +145,12 @@ fun ClinkOfferPreview(
// FIXED offers display their preset price; SPONTANEOUS offers (and the default
// when the pointer omits a price type) require the payer to enter an amount.
val effectiveType = offer.priceType ?: OfferPriceType.SPONTANEOUS
// Reflect the pointer actually being charged (which may have changed if the
// service redirected us to a replacement noffer via "Expired or Moved").
val effectiveType = activeOffer.priceType ?: OfferPriceType.SPONTANEOUS
if (effectiveType == OfferPriceType.FIXED) {
offer.price?.let {
activeOffer.price?.let {
Text(
text = "$it ${stringRes(id = R.string.sats)}",
fontSize = 25.sp,
@@ -287,8 +287,11 @@ class WalletViewModel : ViewModel() {
fun setDefaultWallet(walletId: String) {
val acc = account ?: return
acc.settings.setDefaultPaymentSource(walletId)
_defaultWalletId.value = walletId
// Only reflect the change locally if it actually persisted (the id must exist
// in one of the lists); otherwise the star and the stored default would diverge.
if (acc.settings.setDefaultPaymentSource(walletId)) {
_defaultWalletId.value = walletId
}
}
fun removeWallet(walletId: String) {