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

Konstantin Ullrich committed Jun 19, 2025 at 18:55 UTC 4b137bc96856e618ba151f62f84c5ac1ea76e3ac
9 files changed +82 -53
cw_bitcoin/lib/bitcoin_wallet.dart
+6
@@ -266,6 +266,12 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
266 derivationPath: walletInfo.derivationInfo!.derivationPath!);
267 }
268
269 + @override
270 + Future<void> close({bool shouldCleanup = false}) async {
271 + payjoinManager.cleanupSessions();
272 + super.close(shouldCleanup: shouldCleanup);
273 + }
274 +
275 late final PayjoinManager payjoinManager;
276
277 bool get isPayjoinAvailable => unspentCoinsInfo.values
cw_bitcoin/lib/bitcoin_wallet_addresses.dart
+15 -8
@@ -59,19 +59,26 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
59
60 @action
61 Future<void> initPayjoin() async {
62 - await payjoinManager.initPayjoin();
63 - currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress);
64 - payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint();
62 + try {
63 + await payjoinManager.initPayjoin();
64 + currentPayjoinReceiver = await payjoinManager.getUnusedReceiver(primaryAddress);
65 + payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint();
66
66 - payjoinManager.resumeSessions();
67 + payjoinManager.resumeSessions();
68 + } catch (e) {
69 + printV(e);
70 + }
71 }
72
73 @action
74 Future<void> newPayjoinReceiver() async {
71 - currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress);
72 - payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint();
75 + try {
76 + currentPayjoinReceiver = await payjoinManager.getUnusedReceiver(primaryAddress);
77 + payjoinEndpoint = (await currentPayjoinReceiver?.pjUri())?.pjEndpoint();
78
74 - printV("Initializing new Payjoin Receiver");
75 - payjoinManager.spawnNewReceiver(receiver: currentPayjoinReceiver!);
79 + payjoinManager.spawnReceiver(receiver: currentPayjoinReceiver!);
80 + } catch (e) {
81 + printV(e);
82 + }
83 }
84 }
cw_bitcoin/lib/payjoin/manager.dart
+34 -33
@@ -53,7 +53,7 @@ class PayjoinManager {
53 }
54 final receiver = Receiver.fromJson(json: session.receiver!);
55 printV("Resuming Payjoin Receiver Session ${receiver.id()}");
56 - return _spawnReceiver(receiver: receiver);
56 + return spawnReceiver(receiver: receiver);
57 });
58
59 printV("Resumed ${spawnedSessions.length} Payjoin Sessions");
@@ -121,15 +121,13 @@ class PayjoinManager {
121 }
122 } catch (e) {
123 _cleanupSession(pjUri);
124 - printV(e);
125 - await _payjoinStorage.markSenderSessionUnrecoverable(pjUri);
126 - completer.completeError(e);
124 + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri, e.toString());
125 + completer.complete();
126 }
127 } else if (message is PayjoinSessionError) {
128 _cleanupSession(pjUri);
129 if (message is UnrecoverableError) {
131 - printV(message.message);
132 - await _payjoinStorage.markSenderSessionUnrecoverable(pjUri);
130 + await _payjoinStorage.markSenderSessionUnrecoverable(pjUri, message.message);
131 completer.complete();
132 } else if (message is RecoverableError) {
133 completer.complete();
@@ -149,42 +147,41 @@ class PayjoinManager {
147 return completer.future;
148 }
149
152 - Future<Receiver> initReceiver(String address,
150 + Future<Receiver> getUnusedReceiver(String address,
151 [bool isTestnet = false]) async {
154 - try {
155 - final ohttpKeys = await PayjoinUri.fetchOhttpKeys(
156 - ohttpRelay: await randomOhttpRelayUrl(),
157 - payjoinDirectory: payjoinDirectoryUrl,
158 - );
152 + final session = _payjoinStorage.getUnusedActiveReceiverSession(_wallet.id);
153
160 - final newReceiver = await NewReceiver.create(
161 - address: address,
162 - network: isTestnet ? Network.testnet : Network.bitcoin,
163 - directory: payjoinDirectoryUrl,
164 - ohttpKeys: ohttpKeys,
165 - );
166 - final persister = PayjoinReceiverPersister.impl();
167 - final receiverToken = await newReceiver.persist(persister: persister);
168 - final receiver =
169 - await Receiver.load(persister: persister, token: receiverToken);
170 -
171 - await _payjoinStorage.insertReceiverSession(receiver, _wallet.id);
154 + if (session != null) {
155 + await PayjoinUri.Url.fromStr(payjoinDirectoryUrl);
156
173 - return receiver;
174 - } catch (e) {
175 - throw Exception('Error initializing Payjoin Receiver: $e');
157 + return Receiver.fromJson(json: session.receiver!);
158 }
159 +
160 + return initReceiver(address);
161 }
162
179 - Future<void> spawnNewReceiver({
180 - required Receiver receiver,
181 - bool isTestnet = false,
182 - }) async {
163 + Future<Receiver> initReceiver(String address, [bool isTestnet = false]) async {
164 + final ohttpKeys = await PayjoinUri.fetchOhttpKeys(
165 + ohttpRelay: await randomOhttpRelayUrl(),
166 + payjoinDirectory: payjoinDirectoryUrl,
167 + );
168 +
169 + final newReceiver = await NewReceiver.create(
170 + address: address,
171 + network: isTestnet ? Network.testnet : Network.bitcoin,
172 + directory: payjoinDirectoryUrl,
173 + ohttpKeys: ohttpKeys,
174 + );
175 + final persister = PayjoinReceiverPersister.impl();
176 + final receiverToken = await newReceiver.persist(persister: persister);
177 + final receiver = await Receiver.load(persister: persister, token: receiverToken);
178 +
179 await _payjoinStorage.insertReceiverSession(receiver, _wallet.id);
184 - return _spawnReceiver(isTestnet: isTestnet, receiver: receiver);
180 +
181 + return receiver;
182 }
183
187 - Future<void> _spawnReceiver({
184 + Future<void> spawnReceiver({
185 required Receiver receiver,
186 bool isTestnet = false,
187 }) async {
@@ -229,6 +226,10 @@ class PayjoinManager {
226
227 case PayjoinReceiverRequestTypes.getCandidateInputs:
228 utxos = _wallet.getUtxoWithPrivateKeys();
229 + if (utxos.isEmpty) {
230 + await _wallet.updateAllUnspents();
231 + utxos = _wallet.getUtxoWithPrivateKeys();
232 + }
233 mainToIsolateSendPort?.send({
234 'requestId': message['requestId'],
235 'result': utxos,
cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart
+1 -1
@@ -174,7 +174,7 @@ class PayjoinReceiverWorker {
174 final listUnspent =
175 await _sendRequest(PayjoinReceiverRequestTypes.getCandidateInputs);
176 final unspent = listUnspent as List<UtxoWithPrivateKey>;
177 - if (unspent.isEmpty) throw Exception('No unspent outputs available');
177 + if (unspent.isEmpty) throw RecoverableError('No unspent outputs available');
178
179 final selectedUtxo = await _inputPairFromUtxo(unspent[0]);
180 final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]);
cw_bitcoin/lib/payjoin/storage.dart
+10 -1
@@ -23,6 +23,14 @@ class PayjoinStorage {
23 ),
24 );
25
26 + PayjoinSession? getUnusedActiveReceiverSession(String walletId) =>
27 + _payjoinSessionSources.values
28 + .where((session) =>
29 + session.walletId == walletId &&
30 + session.status == PayjoinSessionStatus.created.name &&
31 + !session.isSenderSession)
32 + .firstOrNull;
33 +
34 Future<void> markReceiverSessionComplete(
35 String sessionId, String txId, String amount) async {
36 final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!;
@@ -76,10 +84,11 @@ class PayjoinStorage {
84 await session.save();
85 }
86
79 - Future<void> markSenderSessionUnrecoverable(String pjUrl) async {
87 + Future<void> markSenderSessionUnrecoverable(String pjUrl, String reason) async {
88 final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!;
89
90 session.status = PayjoinSessionStatus.unrecoverable.name;
91 + session.error = reason;
92 await session.save();
93 }
94
lib/reactions/on_current_wallet_change.dart
+1 -1
@@ -65,7 +65,7 @@ void startCurrentWalletChangeReaction(
65
66 final node = settingsStore.getCurrentNode(wallet.type);
67
68 - startWalletSyncStatusChangeReaction(wallet, fiatConversionStore);
68 + startWalletSyncStatusChangeReaction(wallet, settingsStore);
69 startCheckConnectionReaction(wallet, settingsStore);
70
71 await Future.delayed(Duration.zero);
lib/reactions/on_wallet_sync_status_change.dart
+10 -2
@@ -1,5 +1,7 @@
1 -import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
1 +import 'package:cake_wallet/bitcoin/bitcoin.dart';
2 +import 'package:cake_wallet/store/settings_store.dart';
3 import 'package:cw_core/utils/print_verbose.dart';
4 +import 'package:cw_core/wallet_type.dart';
5 import 'package:mobx/mobx.dart';
6 import 'package:cw_core/transaction_history.dart';
7 import 'package:cw_core/wallet_base.dart';
@@ -12,7 +14,7 @@ ReactionDisposer? _onWalletSyncStatusChangeReaction;
14
15 void startWalletSyncStatusChangeReaction(
16 WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo> wallet,
15 - FiatConversionStore fiatConversionStore) {
17 + SettingsStore settingsStore) {
18 _onWalletSyncStatusChangeReaction?.reaction.dispose();
19 _onWalletSyncStatusChangeReaction = reaction((_) => wallet.syncStatus, (SyncStatus status) async {
20 try {
@@ -25,6 +27,12 @@ void startWalletSyncStatusChangeReaction(
27 if (status is SyncedSyncStatus || status is FailedSyncStatus) {
28 await WakelockPlus.disable();
29 }
30 +
31 + if (status is SyncedSyncStatus &&
32 + wallet.type == WalletType.bitcoin &&
33 + settingsStore.usePayjoin) {
34 + bitcoin!.resumePayjoinSessions(wallet);
35 + }
36 } catch (e) {
37 printV(e.toString());
38 }
lib/view_model/dashboard/payjoin_transaction_list_item.dart
+2 -3
@@ -20,9 +20,8 @@ class PayjoinTransactionListItem extends ActionListItem {
20 String get status {
21 switch (session.status) {
22 case 'success':
23 - if (transaction?.isPending == true)
24 - return S.current.payjoin_request_awaiting_tx;
25 - return S.current.successful;
23 + if (transaction?.isPending == false) return S.current.successful;
24 + return S.current.payjoin_request_awaiting_tx;
25 case 'inProgress':
26 return S.current.payjoin_request_in_progress;
27 case 'unrecoverable':
lib/view_model/payjoin_details_view_model.dart
+3 -4
@@ -69,7 +69,7 @@ abstract class PayjoinDetailsViewModelBase with Store {
69 title: S.current.error,
70 value: payjoinSession.error!,
71 ),
72 - if (payjoinSession.txId?.isNotEmpty == true)
72 + if (payjoinSession.txId?.isNotEmpty == true && transactionInfo != null)
73 StandartListItem(
74 title: S.current.transaction_details_transaction_id,
75 value: payjoinSession.txId!,
@@ -107,9 +107,8 @@ abstract class PayjoinDetailsViewModelBase with Store {
107 String _getStatusString() {
108 switch (payjoinSession.status) {
109 case 'success':
110 - if (transactionInfo?.isPending == true)
111 - return S.current.payjoin_request_awaiting_tx;
112 - return S.current.successful;
110 + if (transactionInfo?.isPending == false) return S.current.successful;
111 + return S.current.payjoin_request_awaiting_tx;
112 case 'inProgress':
113 return S.current.payjoin_request_in_progress;
114 case 'unrecoverable':