From 33b5329ddfa43fed33b6576949b0dc091d6aa72e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 16:30:09 +0000 Subject: [PATCH] fix: wrap NWC response callbacks in a coroutine (408 -> 393 errors) The NWC response/timeout callbacks are invoked by the relay dispatcher, so they cannot become suspend - the requirement would cascade into LocalCache.consume. Their bodies can open a coroutine though, which is what lets them resolve a Compose string. WalletViewModel went 22 -> 1 on this alone (8 callbacks, plus updateWalletInfo's transform lambda made suspend). Same shape applied to ZapPaymentHandler (3) and V4VPaymentHandler (4) using account.scope, which both already had. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L --- .../amethyst/service/V4VPaymentHandler.kt | 21 +- .../amethyst/service/ZapPaymentHandler.kt | 36 +-- .../screen/loggedIn/wallet/WalletViewModel.kt | 236 ++++++++++-------- 3 files changed, 162 insertions(+), 131 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index b6a904c17d..5abe34e891 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.podcasts.PodcastValueShare import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -170,12 +171,16 @@ class V4VPaymentHandler( account.zaps.sendNwcRequest( request = request, onResponse = { response: Response? -> - response.nwcFailureDetail(context)?.let { detail -> - onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail) + account.scope.launch { + response.nwcFailureDetail(context)?.let { detail -> + onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail) + } } }, onTimeout = { - onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context)) + account.scope.launch { + onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context)) + } }, ) } @@ -264,12 +269,16 @@ class V4VPaymentHandler( bolt11 = payable.invoice, zappedNote = zappedNote, onResponse = { response -> - response.nwcFailureDetail(context)?.let { detail -> - onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail) + account.scope.launch { + response.nwcFailureDetail(context)?.let { detail -> + onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail) + } } }, onTimeout = { - onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context)) + account.scope.launch { + onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context)) + } }, ) done++ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index dd3eed4f7e..3c3cc689e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -486,21 +486,25 @@ class ZapPaymentHandler( comment = payable.message, ), onResponse = { response -> - progress.step() - response.nwcFailureDetail(context)?.let { detail -> - onError( - loadStringRes(Res.string.error_dialog_pay_invoice_error), - loadStringRes(Res.string.wallet_connect_pay_invoice_error_error, detail), - payable.info.user, - ) + account.scope.launch { + progress.step() + response.nwcFailureDetail(context)?.let { detail -> + onError( + loadStringRes(Res.string.error_dialog_pay_invoice_error), + loadStringRes(Res.string.wallet_connect_pay_invoice_error_error, detail), + payable.info.user, + ) + } } }, onTimeout = { - onError( - loadStringRes(Res.string.error_dialog_pay_invoice_error), - nwcTimeoutMessage(context), - payable.info.user, - ) + account.scope.launch { + onError( + loadStringRes(Res.string.error_dialog_pay_invoice_error), + nwcTimeoutMessage(context), + payable.info.user, + ) + } }, ) @@ -596,9 +600,11 @@ class ZapPaymentHandler( } }, onTimeout = { - // No response callback will fire, so account for the settlement step here. - reportBolt12Error(Res.string.bolt12_payment_failed, nwcTimeoutMessage(context)) - progress.step() + account.scope.launch { + // No response callback will fire, so account for the settlement step here. + reportBolt12Error(Res.string.bolt12_payment_failed, nwcTimeoutMessage(context)) + progress.step() + } }, onProcessed = { progress.step() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 13b6be5231..dd2a4840b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -435,21 +435,23 @@ class WalletViewModel : ViewModel() { updateWalletInfo(walletId) { it.copy(isLoading = true, error = null) } try { acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> - when (response) { - is GetBalanceSuccessResponse -> { - val sats = (response.result?.balance ?: 0L) / 1000L - updateWalletInfo(walletId) { it.copy(balanceSats = sats, isLoading = false) } - } - - is NwcErrorResponse -> { - updateWalletInfo(walletId) { - it.copy(error = response.error?.message ?: text(Res.string.wallet_balance_request_failed), isLoading = false) + viewModelScope.launch { + when (response) { + is GetBalanceSuccessResponse -> { + val sats = (response.result?.balance ?: 0L) / 1000L + updateWalletInfo(walletId) { it.copy(balanceSats = sats, isLoading = false) } } - } - else -> { - updateWalletInfo(walletId) { - it.copy(error = unreadableResponseError(response), isLoading = false) + is NwcErrorResponse -> { + updateWalletInfo(walletId) { + it.copy(error = response.error?.message ?: text(Res.string.wallet_balance_request_failed), isLoading = false) + } + } + + else -> { + updateWalletInfo(walletId) { + it.copy(error = unreadableResponseError(response), isLoading = false) + } } } } @@ -466,12 +468,14 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { try { acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> - when (response) { - is GetInfoSuccessResponse -> { - updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) } - } + viewModelScope.launch { + when (response) { + is GetInfoSuccessResponse -> { + updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) } + } - else -> {} + else -> {} + } } } } catch (_: Exception) { @@ -481,7 +485,7 @@ class WalletViewModel : ViewModel() { private suspend fun updateWalletInfo( walletId: String, - transform: (WalletInfo) -> WalletInfo, + transform: suspend (WalletInfo) -> WalletInfo, ) { walletInfoMap.value = walletInfoMap.value.toMutableMap().apply { @@ -508,23 +512,25 @@ class WalletViewModel : ViewModel() { try { requestId = acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response -> - timeoutJob.cancel() - when (response) { - is GetBalanceSuccessResponse -> { - _balanceSats.value = (response.result?.balance ?: 0L) / 1000L - updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) } - _error.value = null - } + viewModelScope.launch { + timeoutJob.cancel() + when (response) { + is GetBalanceSuccessResponse -> { + _balanceSats.value = (response.result?.balance ?: 0L) / 1000L + updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) } + _error.value = null + } - is NwcErrorResponse -> { - _error.value = response.error?.message ?: text(Res.string.wallet_balance_request_failed) - } + is NwcErrorResponse -> { + _error.value = response.error?.message ?: text(Res.string.wallet_balance_request_failed) + } - else -> { - _error.value = unreadableResponseError(response) + else -> { + _error.value = unreadableResponseError(response) + } } + _isLoading.value = false } - _isLoading.value = false } } catch (e: Exception) { timeoutJob.cancel() @@ -541,13 +547,15 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { try { acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response -> - when (response) { - is GetInfoSuccessResponse -> { - _walletAlias.value = response.result?.alias - updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) } - } + viewModelScope.launch { + when (response) { + is GetInfoSuccessResponse -> { + _walletAlias.value = response.result?.alias + updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) } + } - else -> {} + else -> {} + } } } } catch (_: Exception) { @@ -576,30 +584,32 @@ class WalletViewModel : ViewModel() { unpaid = false, ), ) { response -> - timeoutJob.cancel() - when (response) { - is ListTransactionsSuccessResponse -> { - val txs = response.result?.transactions ?: emptyList() - allTransactions.value = txs - val totalCount = response.result?.total_count - _hasMoreTransactions.value = - if (totalCount != null) { - txs.size < totalCount - } else { - txs.size >= pageSize - } - _error.value = null - } + viewModelScope.launch { + timeoutJob.cancel() + when (response) { + is ListTransactionsSuccessResponse -> { + val txs = response.result?.transactions ?: emptyList() + allTransactions.value = txs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + txs.size < totalCount + } else { + txs.size >= pageSize + } + _error.value = null + } - is NwcErrorResponse -> { - _error.value = response.error?.message ?: text(Res.string.wallet_transactions_load_failed) - } + is NwcErrorResponse -> { + _error.value = response.error?.message ?: text(Res.string.wallet_transactions_load_failed) + } - else -> { - _error.value = unreadableResponseError(response) + else -> { + _error.value = unreadableResponseError(response) + } } + _isLoading.value = false } - _isLoading.value = false } } catch (e: Exception) { timeoutJob.cancel() @@ -630,30 +640,32 @@ class WalletViewModel : ViewModel() { unpaid = false, ), ) { response -> - timeoutJob.cancel() - when (response) { - is ListTransactionsSuccessResponse -> { - val newTxs = response.result?.transactions ?: emptyList() - allTransactions.value += newTxs - val totalCount = response.result?.total_count - _hasMoreTransactions.value = - if (totalCount != null) { - allTransactions.value.size < totalCount - } else { - newTxs.size >= pageSize - } - _error.value = null - } + viewModelScope.launch { + timeoutJob.cancel() + when (response) { + is ListTransactionsSuccessResponse -> { + val newTxs = response.result?.transactions ?: emptyList() + allTransactions.value += newTxs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + allTransactions.value.size < totalCount + } else { + newTxs.size >= pageSize + } + _error.value = null + } - is NwcErrorResponse -> { - _error.value = response.error?.message ?: text(Res.string.wallet_transactions_load_more_failed) - } + is NwcErrorResponse -> { + _error.value = response.error?.message ?: text(Res.string.wallet_transactions_load_more_failed) + } - else -> { - _error.value = unreadableResponseError(response) + else -> { + _error.value = unreadableResponseError(response) + } } + _isLoadingMore.value = false } - _isLoadingMore.value = false } } catch (e: Exception) { timeoutJob.cancel() @@ -671,24 +683,26 @@ class WalletViewModel : ViewModel() { _sendState.value = SendState.Sending try { acc.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response -> - when (response) { - is PayInvoiceSuccessResponse -> { - _sendState.value = SendState.Success(response.result?.preimage) - fetchBalance() - } + viewModelScope.launch { + when (response) { + is PayInvoiceSuccessResponse -> { + _sendState.value = SendState.Success(response.result?.preimage) + fetchBalance() + } - is IErrorResponseLike -> { - // Both PayInvoiceErrorResponse (method-specific, kept for - // back-compat) and NwcErrorResponse (generic) reduce to the - // same user-visible "payment failed" message. - _sendState.value = - SendState.Error( - response.errorMessage() ?: text(Res.string.send_payment_failed), - ) - } + is IErrorResponseLike -> { + // Both PayInvoiceErrorResponse (method-specific, kept for + // back-compat) and NwcErrorResponse (generic) reduce to the + // same user-visible "payment failed" message. + _sendState.value = + SendState.Error( + response.errorMessage() ?: text(Res.string.send_payment_failed), + ) + } - else -> { - _sendState.value = SendState.Error(text(Res.string.wallet_connect_unreadable_response_error)) + else -> { + _sendState.value = SendState.Error(text(Res.string.wallet_connect_unreadable_response_error)) + } } } } @@ -715,25 +729,27 @@ class WalletViewModel : ViewModel() { description = description, ), ) { response -> - when (response) { - is MakeInvoiceSuccessResponse -> { - val invoice = response.result?.invoice - if (invoice != null) { - _receiveState.value = ReceiveState.Created(invoice, amountSats) - } else { - _receiveState.value = ReceiveState.Error(text(Res.string.wallet_no_invoice_returned)) + viewModelScope.launch { + when (response) { + is MakeInvoiceSuccessResponse -> { + val invoice = response.result?.invoice + if (invoice != null) { + _receiveState.value = ReceiveState.Created(invoice, amountSats) + } else { + _receiveState.value = ReceiveState.Error(text(Res.string.wallet_no_invoice_returned)) + } } - } - is NwcErrorResponse -> { - _receiveState.value = - ReceiveState.Error( - response.error?.message ?: text(Res.string.wallet_invoice_creation_failed), - ) - } + is NwcErrorResponse -> { + _receiveState.value = + ReceiveState.Error( + response.error?.message ?: text(Res.string.wallet_invoice_creation_failed), + ) + } - else -> { - _receiveState.value = ReceiveState.Error(text(Res.string.wallet_connect_unreadable_response_error)) + else -> { + _receiveState.value = ReceiveState.Error(text(Res.string.wallet_connect_unreadable_response_error)) + } } } }