From 4b137bc96856e618ba151f62f84c5ac1ea76e3ac Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Thu, 19 Jun 2025 18:55:41 +0200 Subject: [PATCH] CW-1091-payjoin-error-handeling (#2317) * feat: stop polling payjoin on switch wallet * refactor: improve Payjoin session handling and cleanup unused methods - Replaced `initReceiver` with `getUnusedReceiver` to reuse existing Payjoin sessions. - Streamlined session initialization by removing `spawnNewReceiver`. - Adjusted wallet sync reactions to resume Payjoin sessions when necessary. * fix: Receiver.fromJson correctly handle parameter format in Payjoin manager * fix: try reloading unspents if unspents are empty; No Unpsents available are now recoverable errors * fix: ensure transaction details display only if transactionInfo is available and adjust payjoin success status handling * fix: adjust payjoin success status handling for pending transactions * fix: add error handling for Payjoin initialization and receiver creation [skip-ci] * fix: add unrecoverable error handling for Payjoin sender sessions --- cw_bitcoin/lib/bitcoin_wallet.dart | 6 ++ cw_bitcoin/lib/bitcoin_wallet_addresses.dart | 23 ++++--- cw_bitcoin/lib/payjoin/manager.dart | 67 ++++++++++--------- .../lib/payjoin/payjoin_receive_worker.dart | 2 +- cw_bitcoin/lib/payjoin/storage.dart | 11 ++- lib/reactions/on_current_wallet_change.dart | 2 +- .../on_wallet_sync_status_change.dart | 12 +++- .../payjoin_transaction_list_item.dart | 5 +- .../payjoin_details_view_model.dart | 7 +- 9 files changed, 82 insertions(+), 53 deletions(-) diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index a23b72660..9231022f6 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -266,6 +266,12 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { derivationPath: walletInfo.derivationInfo!.derivationPath!); } + @override + Future close({bool shouldCleanup = false}) async { + payjoinManager.cleanupSessions(); + super.close(shouldCleanup: shouldCleanup); + } + late final PayjoinManager payjoinManager; bool get isPayjoinAvailable => unspentCoinsInfo.values diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index a92c4770f..b33d722ab 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -59,19 +59,26 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S @action Future initPayjoin() async { - await payjoinManager.initPayjoin(); - currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress); - payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint(); + try { + await payjoinManager.initPayjoin(); + currentPayjoinReceiver = await payjoinManager.getUnusedReceiver(primaryAddress); + payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint(); - payjoinManager.resumeSessions(); + payjoinManager.resumeSessions(); + } catch (e) { + printV(e); + } } @action Future newPayjoinReceiver() async { - currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress); - payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint(); + try { + currentPayjoinReceiver = await payjoinManager.getUnusedReceiver(primaryAddress); + payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint(); - printV("Initializing new Payjoin Receiver"); - payjoinManager.spawnNewReceiver(receiver: currentPayjoinReceiver!); + payjoinManager.spawnReceiver(receiver: currentPayjoinReceiver!); + } catch (e) { + printV(e); + } } } diff --git a/cw_bitcoin/lib/payjoin/manager.dart b/cw_bitcoin/lib/payjoin/manager.dart index ad6fef0d8..95a523d89 100644 --- a/cw_bitcoin/lib/payjoin/manager.dart +++ b/cw_bitcoin/lib/payjoin/manager.dart @@ -53,7 +53,7 @@ class PayjoinManager { } final receiver = Receiver.fromJson(json: session.receiver!); printV("Resuming Payjoin Receiver Session ${receiver.id()}"); - return _spawnReceiver(receiver: receiver); + return spawnReceiver(receiver: receiver); }); printV("Resumed ${spawnedSessions.length} Payjoin Sessions"); @@ -121,15 +121,13 @@ class PayjoinManager { } } catch (e) { _cleanupSession(pjUri); - printV(e); - await _payjoinStorage.markSenderSessionUnrecoverable(pjUri); - completer.completeError(e); + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri, e.toString()); + completer.complete(); } } else if (message is PayjoinSessionError) { _cleanupSession(pjUri); if (message is UnrecoverableError) { - printV(message.message); - await _payjoinStorage.markSenderSessionUnrecoverable(pjUri); + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri, message.message); completer.complete(); } else if (message is RecoverableError) { completer.complete(); @@ -149,42 +147,41 @@ class PayjoinManager { return completer.future; } - Future initReceiver(String address, + Future getUnusedReceiver(String address, [bool isTestnet = false]) async { - try { - final ohttpKeys = await PayjoinUri.fetchOhttpKeys( - ohttpRelay: await randomOhttpRelayUrl(), - payjoinDirectory: payjoinDirectoryUrl, - ); + final session = _payjoinStorage.getUnusedActiveReceiverSession(_wallet.id); - final newReceiver = await NewReceiver.create( - address: address, - network: isTestnet ? Network.testnet : Network.bitcoin, - directory: payjoinDirectoryUrl, - ohttpKeys: ohttpKeys, - ); - final persister = PayjoinReceiverPersister.impl(); - final receiverToken = await newReceiver.persist(persister: persister); - final receiver = - await Receiver.load(persister: persister, token: receiverToken); + if (session != null) { + await PayjoinUri.Url.fromStr(payjoinDirectoryUrl); - await _payjoinStorage.insertReceiverSession(receiver, _wallet.id); - - return receiver; - } catch (e) { - throw Exception('Error initializing Payjoin Receiver: $e'); + return Receiver.fromJson(json: session.receiver!); } + + return initReceiver(address); } - Future spawnNewReceiver({ - required Receiver receiver, - bool isTestnet = false, - }) async { + Future initReceiver(String address, [bool isTestnet = false]) async { + final ohttpKeys = await PayjoinUri.fetchOhttpKeys( + ohttpRelay: await randomOhttpRelayUrl(), + payjoinDirectory: payjoinDirectoryUrl, + ); + + final newReceiver = await NewReceiver.create( + address: address, + network: isTestnet ? Network.testnet : Network.bitcoin, + directory: payjoinDirectoryUrl, + ohttpKeys: ohttpKeys, + ); + final persister = PayjoinReceiverPersister.impl(); + final receiverToken = await newReceiver.persist(persister: persister); + final receiver = await Receiver.load(persister: persister, token: receiverToken); + await _payjoinStorage.insertReceiverSession(receiver, _wallet.id); - return _spawnReceiver(isTestnet: isTestnet, receiver: receiver); + + return receiver; } - Future _spawnReceiver({ + Future spawnReceiver({ required Receiver receiver, bool isTestnet = false, }) async { @@ -229,6 +226,10 @@ class PayjoinManager { case PayjoinReceiverRequestTypes.getCandidateInputs: utxos = _wallet.getUtxoWithPrivateKeys(); + if (utxos.isEmpty) { + await _wallet.updateAllUnspents(); + utxos = _wallet.getUtxoWithPrivateKeys(); + } mainToIsolateSendPort?.send({ 'requestId': message['requestId'], 'result': utxos, diff --git a/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart index e86624291..e4cd8a101 100644 --- a/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart +++ b/cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart @@ -174,7 +174,7 @@ class PayjoinReceiverWorker { final listUnspent = await _sendRequest(PayjoinReceiverRequestTypes.getCandidateInputs); final unspent = listUnspent as List; - if (unspent.isEmpty) throw Exception('No unspent outputs available'); + if (unspent.isEmpty) throw RecoverableError('No unspent outputs available'); final selectedUtxo = await _inputPairFromUtxo(unspent[0]); final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]); diff --git a/cw_bitcoin/lib/payjoin/storage.dart b/cw_bitcoin/lib/payjoin/storage.dart index 9c1c83253..5fb9d5716 100644 --- a/cw_bitcoin/lib/payjoin/storage.dart +++ b/cw_bitcoin/lib/payjoin/storage.dart @@ -23,6 +23,14 @@ class PayjoinStorage { ), ); + PayjoinSession? getUnusedActiveReceiverSession(String walletId) => + _payjoinSessionSources.values + .where((session) => + session.walletId == walletId && + session.status == PayjoinSessionStatus.created.name && + !session.isSenderSession) + .firstOrNull; + Future markReceiverSessionComplete( String sessionId, String txId, String amount) async { final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!; @@ -76,10 +84,11 @@ class PayjoinStorage { await session.save(); } - Future markSenderSessionUnrecoverable(String pjUrl) async { + Future markSenderSessionUnrecoverable(String pjUrl, String reason) async { final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!; session.status = PayjoinSessionStatus.unrecoverable.name; + session.error = reason; await session.save(); } diff --git a/lib/reactions/on_current_wallet_change.dart b/lib/reactions/on_current_wallet_change.dart index 6a0be67e9..08f1a25ee 100644 --- a/lib/reactions/on_current_wallet_change.dart +++ b/lib/reactions/on_current_wallet_change.dart @@ -65,7 +65,7 @@ void startCurrentWalletChangeReaction( final node = settingsStore.getCurrentNode(wallet.type); - startWalletSyncStatusChangeReaction(wallet, fiatConversionStore); + startWalletSyncStatusChangeReaction(wallet, settingsStore); startCheckConnectionReaction(wallet, settingsStore); await Future.delayed(Duration.zero); diff --git a/lib/reactions/on_wallet_sync_status_change.dart b/lib/reactions/on_wallet_sync_status_change.dart index bf2e8ed0d..bbf4f44fe 100644 --- a/lib/reactions/on_wallet_sync_status_change.dart +++ b/lib/reactions/on_wallet_sync_status_change.dart @@ -1,5 +1,7 @@ -import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/store/settings_store.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:mobx/mobx.dart'; import 'package:cw_core/transaction_history.dart'; import 'package:cw_core/wallet_base.dart'; @@ -12,7 +14,7 @@ ReactionDisposer? _onWalletSyncStatusChangeReaction; void startWalletSyncStatusChangeReaction( WalletBase, TransactionInfo> wallet, - FiatConversionStore fiatConversionStore) { + SettingsStore settingsStore) { _onWalletSyncStatusChangeReaction?.reaction.dispose(); _onWalletSyncStatusChangeReaction = reaction((_) => wallet.syncStatus, (SyncStatus status) async { try { @@ -25,6 +27,12 @@ void startWalletSyncStatusChangeReaction( if (status is SyncedSyncStatus || status is FailedSyncStatus) { await WakelockPlus.disable(); } + + if (status is SyncedSyncStatus && + wallet.type == WalletType.bitcoin && + settingsStore.usePayjoin) { + bitcoin!.resumePayjoinSessions(wallet); + } } catch (e) { printV(e.toString()); } diff --git a/lib/view_model/dashboard/payjoin_transaction_list_item.dart b/lib/view_model/dashboard/payjoin_transaction_list_item.dart index 93ed094a9..9605ab8fa 100644 --- a/lib/view_model/dashboard/payjoin_transaction_list_item.dart +++ b/lib/view_model/dashboard/payjoin_transaction_list_item.dart @@ -20,9 +20,8 @@ class PayjoinTransactionListItem extends ActionListItem { String get status { switch (session.status) { case 'success': - if (transaction?.isPending == true) - return S.current.payjoin_request_awaiting_tx; - return S.current.successful; + if (transaction?.isPending == false) return S.current.successful; + return S.current.payjoin_request_awaiting_tx; case 'inProgress': return S.current.payjoin_request_in_progress; case 'unrecoverable': diff --git a/lib/view_model/payjoin_details_view_model.dart b/lib/view_model/payjoin_details_view_model.dart index 47e6d2c5d..b19207373 100644 --- a/lib/view_model/payjoin_details_view_model.dart +++ b/lib/view_model/payjoin_details_view_model.dart @@ -69,7 +69,7 @@ abstract class PayjoinDetailsViewModelBase with Store { title: S.current.error, value: payjoinSession.error!, ), - if (payjoinSession.txId?.isNotEmpty == true) + if (payjoinSession.txId?.isNotEmpty == true && transactionInfo != null) StandartListItem( title: S.current.transaction_details_transaction_id, value: payjoinSession.txId!, @@ -107,9 +107,8 @@ abstract class PayjoinDetailsViewModelBase with Store { String _getStatusString() { switch (payjoinSession.status) { case 'success': - if (transactionInfo?.isPending == true) - return S.current.payjoin_request_awaiting_tx; - return S.current.successful; + if (transactionInfo?.isPending == false) return S.current.successful; + return S.current.payjoin_request_awaiting_tx; case 'inProgress': return S.current.payjoin_request_in_progress; case 'unrecoverable':