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
This commit is contained in:
Konstantin Ullrich 2025-06-19 18:55:41 +02:00 committed by GitHub
parent 21d5c51cc9
commit 4b137bc968
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 82 additions and 53 deletions

View file

@ -266,6 +266,12 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
derivationPath: walletInfo.derivationInfo!.derivationPath!);
}
@override
Future<void> close({bool shouldCleanup = false}) async {
payjoinManager.cleanupSessions();
super.close(shouldCleanup: shouldCleanup);
}
late final PayjoinManager payjoinManager;
bool get isPayjoinAvailable => unspentCoinsInfo.values

View file

@ -59,19 +59,26 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
@action
Future<void> 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<void> 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);
}
}
}

View file

@ -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<Receiver> initReceiver(String address,
Future<Receiver> 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<void> spawnNewReceiver({
required Receiver receiver,
bool isTestnet = false,
}) async {
Future<Receiver> 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<void> _spawnReceiver({
Future<void> 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,

View file

@ -174,7 +174,7 @@ class PayjoinReceiverWorker {
final listUnspent =
await _sendRequest(PayjoinReceiverRequestTypes.getCandidateInputs);
final unspent = listUnspent as List<UtxoWithPrivateKey>;
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]);

View file

@ -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<void> markReceiverSessionComplete(
String sessionId, String txId, String amount) async {
final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!;
@ -76,10 +84,11 @@ class PayjoinStorage {
await session.save();
}
Future<void> markSenderSessionUnrecoverable(String pjUrl) async {
Future<void> markSenderSessionUnrecoverable(String pjUrl, String reason) async {
final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!;
session.status = PayjoinSessionStatus.unrecoverable.name;
session.error = reason;
await session.save();
}

View file

@ -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);

View file

@ -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<Balance, TransactionHistoryBase<TransactionInfo>, 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());
}

View file

@ -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':

View file

@ -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':