Payjoin Sender are now isolated

This commit is contained in:
Konstantin Ullrich 2025-01-27 22:32:14 +01:00
parent 4c4fd8e645
commit 8f4dad9134
No known key found for this signature in database
GPG key ID: 6B3199AD9B3D23B8
45 changed files with 452 additions and 295 deletions

View file

@ -143,7 +143,6 @@ class BitcoinPayjoin {
}
}
Future<PendingBitcoinTransaction> extractPjTx(
Object wallet, String psbtString, Object credentials) async =>
(wallet as BitcoinWallet).psbtToPendingTx(psbtString, credentials);
Future<PendingBitcoinTransaction> extractPjTx(Object wallet, String psbtString) async =>
(wallet as BitcoinWallet).psbtToPendingTx(psbtString);
}

View file

@ -85,7 +85,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
// String sideDerivationPath = derivationPath.substring(0, derivationPath.length - 1) + "1";
// final hd = bitcoin.HDWallet.fromSeed(seedBytes, network: networkType);
payjoinManager = PayjoinManager(PayjoinStorage(payjoinSessionSources: payjoinBox), this);
payjoinManager = PayjoinManager(PayjoinStorage(payjoinBox), this);
walletAddresses = BitcoinWalletAddresses(
walletInfo,
initialAddresses: initialAddresses,
@ -339,43 +339,36 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
@override
Future<PendingTransaction> createTransaction(Object credentials) async {
credentials = credentials as BitcoinTransactionCredentials;
final tx =
super.createTransaction(credentials) as PendingBitcoinTransaction;
// if (credentials.payjoinUri == null) return tx;
//
// final transaction = await buildPsbt(
// utxos: tx.utxos,
// outputs: tx.outputs.map((e) => BitcoinOutput(
// address: BitcoinBaseAddress.fromString(e.scriptPubKey.toAddress()),
// value: e.amount,
// isSilentPayment: e.isSilentPayment,
// isChange: e.isChange,
// )).toList(),
// fee: BigInt.from(tx.fee),
// network: network,
// memo: credentials.outputs.first.memo,
// outputOrdering: BitcoinOrdering.none,
// enableRBF: true,
// publicKeys: tx.publicKeys,
// masterFingerprint: Uint8List(0)
// );
//
// transaction.signWithUTXO(
// tx.utxos
// .map((e) =>
// UtxoWithPrivateKey.fromUtxo(e, tx.inputPrivKeyInfos))
// .toList(), (txDigest, utxo, key, sighash) {
// if (utxo.utxo.isP2tr()) {
// return key.signTapRoot(
// txDigest,
// sighash: sighash,
// tweak: utxo.utxo.isSilentPayment != true,
// );
// } else {
// return key.signInput(txDigest, sigHash: sighash);
// }
// });
final tx = (await super.createTransaction(credentials)) as PendingBitcoinTransaction;
final payjoinUri = credentials.payjoinUri;
if (payjoinUri == null) return tx;
final transaction = await buildPsbt(
utxos: tx.utxos,
outputs: tx.outputs.map((e) => BitcoinOutput(
address: BitcoinBaseAddress.fromString(e.scriptPubKey.toAddress()),
value: e.amount,
isSilentPayment: e.isSilentPayment,
isChange: e.isChange,
)).toList(),
fee: BigInt.from(tx.fee),
network: network,
memo: credentials.outputs.first.memo,
outputOrdering: BitcoinOrdering.none,
enableRBF: true,
publicKeys: tx.publicKeys!,
masterFingerprint: Uint8List(0)
);
final originalPsbt = await signPsbt(base64.encode(transaction.asPsbtV0()), getUtxoWithPrivateKeys());
tx.commitOverride = () async {
final sender =
await payjoinManager.initSender(payjoinUri, originalPsbt, tx.fee);
await payjoinManager.spawnNewSender(sender: sender, pjUrl: payjoinUri);
};
return tx;
}
@ -497,8 +490,25 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
.map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this))
.toList();
Future<PendingBitcoinTransaction> psbtToPendingTx(
String preProcessedPsbt, Object credentials) async {
Future<void> commitPsbt(String finalizedPsbt) {
final psbt = PsbtV2()..deserializeV0(base64.decode(finalizedPsbt));
final btcTx =
BtcTransaction.fromRaw(BytesUtils.toHexString(psbt.extract()));
return PendingBitcoinTransaction(
btcTx,
type,
electrumClient: electrumClient,
amount: 0,
fee: 0,
feeRate: "",
network: network,
hasChange: true,
).commit();
}
Future<PendingBitcoinTransaction> psbtToPendingTx(String preProcessedPsbt) async {
final psbt = PsbtV2()..deserializeV0(base64.decode(preProcessedPsbt));
final inputCount = psbt.getGlobalInputCount();
@ -575,17 +585,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
return base64Encode(psbt.asPsbtV0());
}
Future<String> getPJURl() async {
final payjoinBox = await CakeHive.openBox<PayjoinSession>(PayjoinSession.boxName);
final manager = PayjoinManager(PayjoinStorage(payjoinSessionSources: payjoinBox), this);
final receiver = await manager.initReceiver(walletAddresses.primaryAddress);
manager.spawnNewReceiver(receiver: receiver);
return receiver.pjUriBuilder().build().pjEndpoint();
}
@override
Future<String> signMessage(String message, {String? address = null}) async {
if (walletInfo.isHardwareWallet) {

View file

@ -57,13 +57,12 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
@override
Future<void> init() async {
await super.init();
refreshPayjoinReceiver();
}
Future<void> refreshPayjoinReceiver() async {
Future<void> initPayjoin() async {
currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress);
await payjoinManager.resumeSessions();
await payjoinManager.spawnNewReceiver(receiver: currentPayjoinReceiver!);
}
}

View file

@ -1181,6 +1181,7 @@ abstract class ElectrumWalletBase
isSendAll: estimatedTx.isSendAll,
hasTaprootInputs: hasTaprootInputs,
utxos: estimatedTx.utxos,
publicKeys: estimatedTx.publicKeys
)..addListener((transaction) async {
transactionHistory.addOne(transaction);
if (estimatedTx.spendsSilentPayment) {

View file

@ -5,12 +5,16 @@ import 'dart:typed_data';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cw_bitcoin/bitcoin_wallet.dart';
import 'package:cw_bitcoin/payjoin/payjoin_worker.dart';
import 'package:cw_bitcoin/payjoin/payjoin_receive_worker.dart';
import 'package:cw_bitcoin/payjoin/payjoin_send_worker.dart';
import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart';
import 'package:cw_bitcoin/payjoin/storage.dart';
import 'package:cw_bitcoin/psbt_signer.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:payjoin_flutter/common.dart';
import 'package:payjoin_flutter/receive.dart';
import 'package:payjoin_flutter/uri.dart';
import 'package:payjoin_flutter/send.dart';
import 'package:payjoin_flutter/uri.dart' as PayjoinUri;
class PayjoinManager {
PayjoinManager(this._payjoinStorage, this._wallet);
@ -19,20 +23,133 @@ class PayjoinManager {
final BitcoinWalletBase _wallet;
final Map<String, PayjoinPollerSession> _activePollers = {};
static const List<String> _ohttpRelayUrls = [
static const List<String> ohttpRelayUrls = [
'https://pj.bobspacebkk.com',
'https://ohttp.achow101.com',
];
static Future<PayjoinUri.Url> randomOhttpRelayUrl() => PayjoinUri.Url.fromStr(
ohttpRelayUrls[Random.secure().nextInt(ohttpRelayUrls.length)]);
static const payjoinDirectoryUrl = 'https://payjo.in';
Future<void> resumeSessions() async {
final allSessions = _payjoinStorage.readAllOpenSessions(_wallet.id);
final spawnedSessions = allSessions.map((session) {
if (session.isSenderSession) {
return spawnSender(
sender: Sender.fromJson(session.sender!),
pjUri: session.pjUri!,
);
}
return spawnReceiver(
receiver: Receiver.fromJson(session.receiver!),
);
});
await Future.wait(spawnedSessions);
}
Future<Sender> initSender(
String pjUriString, String originalPsbt, int networkFeesSatPerVb) async {
try {
final pjUri =
(await PayjoinUri.Uri.fromStr(pjUriString)).checkPjSupported();
final minFeeRateSatPerKwu = BigInt.from(networkFeesSatPerVb * 250);
final senderBuilder = await SenderBuilder.fromPsbtAndUri(
psbtBase64: originalPsbt,
pjUri: pjUri,
);
return senderBuilder.buildRecommended(minFeeRate: minFeeRateSatPerKwu);
} catch (e) {
throw Exception('Error initializing Payjoin Sender: $e');
}
}
Future<void> spawnNewSender({
required Sender sender,
required String pjUrl,
bool isTestnet = false,
}) async {
await _payjoinStorage.insertSenderSession(
sender,
pjUrl,
_wallet.id,
);
return spawnSender(
isTestnet: isTestnet,
sender: sender,
pjUri: pjUrl,
);
}
Future<void> spawnSender({
required Sender sender,
required String pjUri,
bool isTestnet = false,
}) async {
final completer = Completer();
final receivePort = ReceivePort();
receivePort.listen((message) async {
print('Sender isolate: $message');
if (message is Map<String, dynamic>) {
try {
switch (message['type']) {
case PayjoinSenderRequestTypes.requestPosted:
//ToDo: Update frontend
return;
case PayjoinSenderRequestTypes.psbtToSign:
final proposalPsbt = message['psbt'] as String;
final utxos = _wallet.getUtxoWithPrivateKeys();
final finalizedPsbt = await _wallet.signPsbt(proposalPsbt, utxos);
_wallet.commitPsbt(finalizedPsbt);
//ToDo: Update frontend
_cleanupSession(pjUri);
await _payjoinStorage.markSenderSessionComplete(pjUri);
completer.complete();
}
} catch (e) {
_cleanupSession(pjUri);
await _payjoinStorage.markReceiverSessionUnrecoverable(pjUri);
completer.completeError(e);
}
} else if (message is PayjoinSessionError) {
_cleanupSession(pjUri);
if (message is UnrecoverableError) {
printV(message.message);
await _payjoinStorage.markReceiverSessionUnrecoverable(pjUri);
}
completer.completeError(message);
}
});
final args = [
receivePort.sendPort,
sender.toJson(),
];
final isolate = await Isolate.spawn(
PayjoinSenderWorker.run,
args,
);
_activePollers[pjUri] = PayjoinPollerSession(isolate, receivePort);
return completer.future;
}
Future<Receiver> initReceiver(String address,
[bool isTestnet = false]) async {
try {
final payjoinDirectory = await Url.fromStr(payjoinDirectoryUrl);
final payjoinDirectory =
await PayjoinUri.Url.fromStr(payjoinDirectoryUrl);
final ohttpKeys = await fetchOhttpKeys(
ohttpRelay: await _randomOhttpRelayUrl(),
final ohttpKeys = await PayjoinUri.fetchOhttpKeys(
ohttpRelay: await randomOhttpRelayUrl(),
payjoinDirectory: payjoinDirectory,
);
@ -41,10 +158,10 @@ class PayjoinManager {
network: isTestnet ? Network.testnet : Network.bitcoin,
directory: payjoinDirectory,
ohttpKeys: ohttpKeys,
ohttpRelay: await _randomOhttpRelayUrl(),
ohttpRelay: await randomOhttpRelayUrl(),
);
} catch (e) {
throw Exception('Error initializing payjoin Receiver: $e');
throw Exception('Error initializing Payjoin Receiver: $e');
}
}
@ -115,18 +232,19 @@ class PayjoinManager {
break;
case PayjoinReceiverRequestTypes.proposalSent:
await _cleanupSession(receiver.id());
_cleanupSession(receiver.id());
await _payjoinStorage.markReceiverSessionComplete(receiver.id());
completer.complete();
}
} catch (e) {
await _cleanupSession(receiver.id());
_cleanupSession(receiver.id());
await _payjoinStorage.markReceiverSessionUnrecoverable(receiver.id());
completer.completeError(e);
}
} else if (message is PayjoinSessionError) {
await _cleanupSession(receiver.id());
_cleanupSession(receiver.id());
if (message is UnrecoverableError) {
printV(message.message);
await _payjoinStorage.markReceiverSessionUnrecoverable(receiver.id());
}
completer.completeError(message);
@ -150,16 +268,16 @@ class PayjoinManager {
return completer.future;
}
void cleanupSessions() {
for (final sessionId in _activePollers.keys) {
_cleanupSession(sessionId);
}
}
Future<void> _cleanupSession(String sessionId) async {
void _cleanupSession(String sessionId) {
_activePollers[sessionId]?.close();
_activePollers.remove(sessionId);
}
// Top-level function to generate random OHTTP relay URL
Future<Url> _randomOhttpRelayUrl() => Url.fromStr(
_ohttpRelayUrls[Random.secure().nextInt(_ohttpRelayUrls.length)],
);
}
class PayjoinPollerSession {

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart';
import 'package:cw_bitcoin/psbt_signer.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:payjoin_flutter/bitcoin_ffi.dart';
@ -19,23 +20,6 @@ enum PayjoinReceiverRequestTypes {
processPsbt;
}
class PayjoinSessionError {
final String message;
const PayjoinSessionError._(this.message);
factory PayjoinSessionError.recoverable(String message) = RecoverableError;
factory PayjoinSessionError.unrecoverable(String message) = UnrecoverableError;
}
class RecoverableError extends PayjoinSessionError {
const RecoverableError(super.message) : super._();
}
class UnrecoverableError extends PayjoinSessionError {
const UnrecoverableError(super.message) : super._();
}
class PayjoinReceiverWorker {
final SendPort sendPort;
final pendingRequests = <String, Completer<dynamic>>{};

View file

@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:cw_bitcoin/payjoin/manager.dart';
import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart';
import 'package:payjoin_flutter/common.dart';
import 'package:payjoin_flutter/send.dart';
import 'package:payjoin_flutter/src/generated/frb_generated.dart' as pj;
enum PayjoinSenderRequestTypes {
requestPosted,
psbtToSign;
}
class PayjoinSenderWorker {
final SendPort sendPort;
final pendingRequests = <String, Completer<dynamic>>{};
PayjoinSenderWorker._(this.sendPort);
static Future<void> run(List<Object> args) async {
await pj.core.init();
final sendPort = args[0] as SendPort;
final senderJson = args[1] as String;
final sender = Sender.fromJson(senderJson);
final worker = PayjoinSenderWorker._(sendPort);
try {
final proposalPsbt = await worker.runSender(sender);
sendPort.send({
'type': PayjoinSenderRequestTypes.psbtToSign,
'psbt': proposalPsbt,
});
} catch (e) {
sendPort.send(e);
}
}
/// Run a payjoin sender (V2 protocol first, fallback to V1).
Future<String> runSender(Sender sender) async {
final httpClient = HttpClient();
try {
return await _runSenderV2(sender, httpClient);
} catch (e) {
if (e is PayjoinException &&
// TODO condition on error type instead of message content
e.message?.contains('parse receiver public key') == true) {
return await _runSenderV1(sender, httpClient);
} else if (e is HttpException) {
throw Exception(PayjoinSessionError.recoverable(e.toString()));
} else {
throw Exception(PayjoinSessionError.unrecoverable(e.toString()));
}
}
}
/// Attempt to send payjoin using the V2 of the protocol.
Future<String> _runSenderV2(Sender sender, HttpClient httpClient) async {
final postRequest = await sender.extractV2(
ohttpProxyUrl: await await PayjoinManager.randomOhttpRelayUrl(),
);
final postResult = await _postRequest(httpClient, postRequest.$1);
final getContext =
await postRequest.$2.processResponse(response: postResult);
sendPort.send({'type': PayjoinSenderRequestTypes.requestPosted});
while (true) {
final getRequest = await getContext.extractReq(
ohttpRelay: await PayjoinManager.randomOhttpRelayUrl(),
);
final getRes = await _postRequest(httpClient, getRequest.$1);
final proposalPsbt = await getContext.processResponse(
response: getRes,
ohttpCtx: getRequest.$2,
);
if (proposalPsbt != null) return proposalPsbt;
}
}
/// Attempt to send payjoin using the V1 of the protocol.
Future<String> _runSenderV1(Sender sender, HttpClient httpClient) async {
try {
final postRequest = await sender.extractV1();
final response = await _postRequest(httpClient, postRequest.$1);
sendPort.send({'type': PayjoinSenderRequestTypes.requestPosted});
return await postRequest.$2.processResponse(response: response);
} catch (e) {
throw PayjoinSessionError.unrecoverable('Send V1 payjoin error: $e');
}
}
Future<List<int>> _postRequest(HttpClient client, Request req) async {
final httpRequest = await client.postUrl(Uri.parse(req.url.asString()));
httpRequest.headers.set('Content-Type', req.contentType);
httpRequest.add(req.body);
final response = await httpRequest.close();
return response.fold<List<int>>(
[],
(previous, element) => previous..addAll(element),
);
}
}

View file

@ -0,0 +1,16 @@
class PayjoinSessionError {
final String message;
const PayjoinSessionError._(this.message);
factory PayjoinSessionError.recoverable(String message) = RecoverableError;
factory PayjoinSessionError.unrecoverable(String message) = UnrecoverableError;
}
class RecoverableError extends PayjoinSessionError {
const RecoverableError(super.message) : super._();
}
class UnrecoverableError extends PayjoinSessionError {
const UnrecoverableError(super.message) : super._();
}

View file

@ -1,194 +1,75 @@
import 'package:cw_core/payjoin_session.dart';
import 'package:hive/hive.dart';
import 'package:payjoin_flutter/receive.dart';
import 'package:payjoin_flutter/send.dart';
class PayjoinStorage {
PayjoinStorage({required Box<PayjoinSession> payjoinSessionSources})
: _payjoinSessionSources = payjoinSessionSources;
PayjoinStorage(this._payjoinSessionSources);
final Box<PayjoinSession> _payjoinSessionSources;
static const String receiverPrefix = 'pj_recv_';
static const String senderPrefix = 'pj_send_';
static const String _receiverPrefix = 'pj_recv_';
static const String _senderPrefix = 'pj_send_';
Future<void> insertReceiverSession(
Receiver receiver,
String walletId,
) async {
final receiverSession =
PayjoinSession(walletId: walletId, receiver: receiver.toJson());
await _payjoinSessionSources.put(
"$receiverPrefix${receiver.id()}", receiverSession);
}
// Future<(RecvSession?, Err?)> readReceiverSession(String sessionId) async {
// try {
// final (jsn, err) =
// await _hiveStorage.getValue(receiverPrefix + sessionId);
// if (err != null) throw err;
// final obj = jsonDecode(jsn!) as Map<String, dynamic>;
// final session = RecvSession.fromJson(obj);
// return (session, null);
// } catch (e) {
// return (
// null,
// Err(
// e.toString(),
// expected: e.toString() == 'No Receiver with id $sessionId',
// )
// );
// }
// }
) =>
_payjoinSessionSources.put(
"$_receiverPrefix${receiver.id()}",
PayjoinSession(
walletId: walletId,
receiver: receiver.toJson(),
),
);
Future<void> markReceiverSessionComplete(String sessionId) async {
final session =
await _payjoinSessionSources.get("$receiverPrefix${sessionId}")!;
final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!;
session.status = "success";
await session.save();
}
Future<void> markReceiverSessionUnrecoverable(String sessionId) async {
final session =
await _payjoinSessionSources.get("$receiverPrefix${sessionId}")!;
final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!;
session.status = "unrecoverable";
await session.save();
}
// Future<(List<RecvSession>, Err?)> readAllReceivers() async {
// //deleteAllSessions();
// try {
// final (allData, err) = await _hiveStorage.getAll();
// if (err != null) return (List<RecvSession>.empty(), err);
//
// final List<RecvSession> receivers = [];
// allData!.forEach((key, value) {
// if (key.startsWith(receiverPrefix)) {
// try {
// final obj = jsonDecode(value) as Map<String, dynamic>;
// receivers.add(RecvSession.fromJson(obj));
// } catch (e) {
// // Skip invalid entries
// debugPrint('Error: $e');
// }
// }
// });
// return (receivers, null);
// } catch (e) {
// return (List<RecvSession>.empty(), Err(e.toString()));
// }
// }
//
// Future<Err?> insertSenderSession(
// Sender sender,
// String pjUrl,
// String walletId,
// bool isTestnet,
// ) async {
// try {
// final sendSession = SendSession(
// isTestnet,
// sender,
// walletId,
// pjUrl,
// PayjoinSessionStatus.pending,
// );
//
// await _hiveStorage.saveValue(
// key: senderPrefix + pjUrl,
// value: jsonEncode(sendSession.toJson()),
// );
// return null;
// } catch (e) {
// return Err(e.toString());
// }
// }
//
// Future<(SendSession?, Err?)> readSenderSession(String pjUrl) async {
// try {
// final (jsn, err) = await _hiveStorage.getValue(senderPrefix + pjUrl);
// if (err != null) throw err;
// final obj = jsonDecode(jsn!) as Map<String, dynamic>;
// final session = SendSession.fromJson(obj);
// return (session, null);
// } catch (e) {
// return (
// null,
// Err(
// e.toString(),
// expected: e.toString() == 'No Sender with id $pjUrl',
// )
// );
// }
// }
//
// Future<Err?> markSenderSessionComplete(String pjUrl) async {
// try {
// final (session, err) = await readSenderSession(pjUrl);
// if (err != null) return err;
//
// final updatedSession = SendSession(
// session!.isTestnet,
// session.sender,
// session.walletId,
// session.pjUri,
// PayjoinSessionStatus.success,
// );
//
// await _hiveStorage.saveValue(
// key: senderPrefix + pjUrl,
// value: jsonEncode(updatedSession.toJson()),
// );
// return null;
// } catch (e) {
// return Err(e.toString());
// }
// }
//
// Future<Err?> markSenderSessionUnrecoverable(String pjUri) async {
// try {
// final (session, err) = await readSenderSession(pjUri);
// if (err != null) return err;
//
// final updatedSession = SendSession(
// session!.isTestnet,
// session.sender,
// session.walletId,
// session.pjUri,
// PayjoinSessionStatus.unrecoverable,
// );
//
// await _hiveStorage.saveValue(
// key: senderPrefix + pjUri,
// value: jsonEncode(updatedSession.toJson()),
// );
// return null;
// } catch (e) {
// return Err(e.toString());
// }
// }
//
// Future<(List<SendSession>, Err?)> readAllSenders() async {
// try {
// final (allData, err) = await _hiveStorage.getAll();
// if (err != null) return (List<SendSession>.empty(), err);
//
// final List<SendSession> senders = [];
// allData!.forEach((key, value) {
// if (key.startsWith(senderPrefix)) {
// try {
// final obj = jsonDecode(value) as Map<String, dynamic>;
// senders.add(SendSession.fromJson(obj));
// } catch (e) {
// // Skip invalid entries
// debugPrint('Error: $e');
// }
// }
// });
// return (senders, null);
// } catch (e) {
// return (List<SendSession>.empty(), Err(e.toString()));
// }
// }
Future<void> insertSenderSession(
Sender sender,
String pjUrl,
String walletId,
) =>
_payjoinSessionSources.put(
"$_senderPrefix$pjUrl",
PayjoinSession(
walletId: walletId,
pjUri: pjUrl,
sender: sender.toJson(),
),
);
Future<void> markSenderSessionComplete(String pjUrl) async {
final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!;
session.status = "success";
await session.save();
}
Future<void> markSenderSessionUnrecoverable(String pjUrl) async {
final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!;
session.status = "unrecoverable";
await session.save();
}
List<PayjoinSession> readAllOpenSessions(String walletId) =>
_payjoinSessionSources.values
.where((session) =>
session.walletId == walletId &&
session.status != "success" &&
session.status != "unrecoverable")
.toList();
}

View file

@ -1,3 +1,4 @@
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:grpc/grpc.dart';
import 'package:cw_bitcoin/exceptions.dart';
import 'package:bitcoin_base/bitcoin_base.dart';
@ -25,6 +26,8 @@ class PendingBitcoinTransaction with PendingTransaction {
this.hasTaprootInputs = false,
this.isMweb = false,
this.utxos = const [],
this.publicKeys,
this.commitOverride,
}) : _listeners = <void Function(ElectrumTransactionInfo transaction)>[];
final WalletType type;
@ -43,6 +46,8 @@ class PendingBitcoinTransaction with PendingTransaction {
String? idOverride;
String? hexOverride;
List<String>? outputAddresses;
final Map<String, PublicKeyWithDerivationPath>? publicKeys;
Future<void> Function()? commitOverride;
@override
String get id => idOverride ?? _tx.txId();
@ -129,6 +134,10 @@ class PendingBitcoinTransaction with PendingTransaction {
@override
Future<void> commit() async {
if (commitOverride != null) {
return commitOverride?.call();
}
if (isMweb) {
await _ltcCommit();
} else {

View file

@ -721,11 +721,20 @@ class CWBitcoin extends Bitcoin {
}
@override
String buildV2PjStr({
required Object receiverWallet
}) {
final wallet = receiverWallet as BitcoinWallet;
return (wallet.walletAddresses as BitcoinWalletAddresses).payjoinEndpoint ?? '';
String getPayjoinEndpoint(Object wallet) {
final _wallet = wallet as BitcoinWallet;
return (_wallet.walletAddresses as BitcoinWalletAddresses).payjoinEndpoint ?? '';
}
@override
void updatePayjoinState(Object wallet, bool value) {
final _wallet = wallet as ElectrumWallet;
if (value) {
(_wallet.walletAddresses as BitcoinWalletAddresses).initPayjoin();
} else {
(_wallet.walletAddresses as BitcoinWalletAddresses).payjoinManager.cleanupSessions();
(_wallet.walletAddresses as BitcoinWalletAddresses).currentPayjoinReceiver = null;
}
}
@override
@ -755,12 +764,10 @@ class CWBitcoin extends Bitcoin {
}
@override
Future<PendingBitcoinTransaction> extractPjTx(
Object wallet, String psbtString, Object credentials) {
Future<PendingBitcoinTransaction> extractPjTx(Object wallet, String psbtString) {
return payjoin.extractPjTx(
wallet,
psbtString,
credentials
psbtString
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart';
import 'package:cake_wallet/entities/fiat_api_mode.dart';
import 'package:cake_wallet/entities/update_haven_rate.dart';
@ -74,6 +75,10 @@ void startCurrentWalletChangeReaction(
_setAutoGenerateSubaddressStatus(wallet, settingsStore);
}
if (wallet.type == WalletType.bitcoin) {
bitcoin!.updatePayjoinState(wallet, settingsStore.usePayjoin);
}
await wallet.connectToNode(node: node);
if (wallet.type == WalletType.nano || wallet.type == WalletType.banano) {
final powNode = settingsStore.getCurrentPowNode(wallet.type);

View file

@ -51,7 +51,7 @@ class PrivacyPage extends BasePage {
),
if (_privacySettingsViewModel.canUsePayjoin)
SettingsSwitcherCell(
title: 'Use Payjoin', // ToDo: localize
title: S.of(context).use_payjoin,
value: _privacySettingsViewModel.usePayjoin,
onValueChange: (BuildContext _, bool value) {
_privacySettingsViewModel.setUsePayjoin(value);

View file

@ -395,7 +395,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
if (wallet.isHardwareWallet) state = IsAwaitingDeviceResponseState();
pendingTransaction = await (pjUri != null ? performPayjoinSend() : wallet.createTransaction(_credentials())); // ToDo: Remove move Payjoin into create tx
pendingTransaction = await wallet.createTransaction(_credentials());
if (provider is ThorChainExchangeProvider) {
final outputCount = pendingTransaction?.outputCount ?? 0;
@ -850,10 +850,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
}
// If a proposal is received, finalize the payjoin
return bitcoin!.extractPjTx(
wallet,
psbt,
_credentials()
);
return bitcoin!.extractPjTx(wallet, psbt);
}
}

View file

@ -172,5 +172,8 @@ abstract class PrivacySettingsViewModelBase with Store {
_settingsStore.useMempoolFeeAPI = value;
@action
void setUsePayjoin(bool value) => _settingsStore.usePayjoin = value;
void setUsePayjoin(bool value) {
_settingsStore.usePayjoin = value;
bitcoin!.updatePayjoinState(_wallet, value);
}
}

View file

@ -284,7 +284,7 @@ abstract class WalletAddressListViewModelBase
case WalletType.haven:
return HavenURI(amount: amount, address: address.address);
case WalletType.bitcoin:
final pjEndpoint = bitcoin!.buildV2PjStr(receiverWallet: wallet);
final pjEndpoint = bitcoin!.getPayjoinEndpoint(wallet);
return BitcoinURI(amount: amount, address: address.address, pjUri: pjEndpoint);
case WalletType.litecoin:
return LitecoinURI(amount: amount, address: address.address);

View file

@ -905,6 +905,7 @@
"use": "التبديل إلى",
"use_card_info_three": "استخدم البطاقة الرقمية عبر الإنترنت أو مع طرق الدفع غير التلامسية.",
"use_card_info_two": "يتم تحويل الأموال إلى الدولار الأمريكي عند الاحتفاظ بها في الحساب المدفوع مسبقًا ، وليس بالعملات الرقمية.",
"use_payjoin": "يستخدم ٪٪٪",
"use_ssl": "استخدم SSL",
"use_suggested": "استخدام المقترح",
"use_testnet": "استخدم testnet",

View file

@ -905,6 +905,7 @@
"use": "Смяна на ",
"use_card_info_three": "Използвайте дигиталната карта онлайн или чрез безконтактен метод на плащане.",
"use_card_info_two": "Средствата се обръщат в USD, когато биват запазени в предплатената карта, а не в дигитална валута.",
"use_payjoin": "Използвайте %%",
"use_ssl": "Използване на SSL",
"use_suggested": "Използване на предложеното",
"use_testnet": "Използвайте TestNet",

View file

@ -905,6 +905,7 @@
"use": "Přepnout na ",
"use_card_info_three": "Použijte tuto digitální kartu online nebo bezkontaktními platebními metodami.",
"use_card_info_two": "Prostředky jsou převedeny na USD, když jsou drženy na předplaceném účtu, nikoliv na digitální měnu.",
"use_payjoin": "Použijte %%TE",
"use_ssl": "Použít SSL",
"use_suggested": "Použít doporučený",
"use_testnet": "Použijte testNet",

View file

@ -907,6 +907,7 @@
"use": "Wechsel zu ",
"use_card_info_three": "Verwenden Sie die digitale Karte online oder mit kontaktlosen Zahlungsmethoden.",
"use_card_info_two": "Guthaben werden auf dem Prepaid-Konto in USD umgerechnet, nicht in digitale Währung.",
"use_payjoin": "Benutze Payjoin",
"use_ssl": "SSL verwenden",
"use_suggested": "Vorgeschlagen verwenden",
"use_testnet": "TESTNET verwenden",
@ -991,4 +992,4 @@
"you_will_get": "Konvertieren zu",
"you_will_send": "Konvertieren von",
"yy": "YY"
}
}

View file

@ -905,6 +905,7 @@
"use": "Switch to ",
"use_card_info_three": "Use the digital card online or with contactless payment methods.",
"use_card_info_two": "Funds are converted to USD when they're held in the prepaid account, not in digital currencies.",
"use_payjoin": "Use Payjoin",
"use_ssl": "Use SSL",
"use_suggested": "Use Suggested",
"use_testnet": "Use Testnet",
@ -988,4 +989,4 @@
"you_will_get": "Convert to",
"you_will_send": "Convert from",
"yy": "YY"
}
}

View file

@ -906,6 +906,7 @@
"use": "Utilizar a ",
"use_card_info_three": "Utiliza la tarjeta digital en línea o con métodos de pago sin contacto.",
"use_card_info_two": "Los fondos se convierten a USD cuando se mantienen en la cuenta prepaga, no en monedas digitales.",
"use_payjoin": "Usar Payjoin",
"use_ssl": "Utiliza SSL",
"use_suggested": "Usar sugerido",
"use_testnet": "Usar TestNet",
@ -989,4 +990,4 @@
"you_will_get": "Convertir a",
"you_will_send": "Convertir de",
"yy": "YY"
}
}

View file

@ -905,6 +905,7 @@
"use": "Changer vers code PIN à ",
"use_card_info_three": "Utilisez la carte numérique en ligne ou avec des méthodes de paiement sans contact.",
"use_card_info_two": "Les fonds sont convertis en USD lorsqu'ils sont détenus sur le compte prépayé, et non en devises numériques.",
"use_payjoin": "Utiliser Payjoin",
"use_ssl": "Utiliser SSL",
"use_suggested": "Suivre la suggestion",
"use_testnet": "Utiliser TestNet",
@ -988,4 +989,4 @@
"you_will_get": "Convertir vers",
"you_will_send": "Convertir depuis",
"yy": "AA"
}
}

View file

@ -907,6 +907,7 @@
"use": "Canja zuwa",
"use_card_info_three": "Yi amfani da katin dijital akan layi ko tare da hanyoyin biyan kuɗi mara lamba.",
"use_card_info_two": "Ana canza kuɗi zuwa dalar Amurka lokacin da ake riƙe su a cikin asusun da aka riga aka biya, ba cikin agogon dijital ba.",
"use_payjoin": "Yi amfani da %%",
"use_ssl": "Yi amfani da SSL",
"use_suggested": "Amfani da Shawarwari",
"use_testnet": "Amfani da gwaji",

View file

@ -907,6 +907,7 @@
"use": "उपयोग ",
"use_card_info_three": "डिजिटल कार्ड का ऑनलाइन या संपर्क रहित भुगतान विधियों के साथ उपयोग करें।",
"use_card_info_two": "डिजिटल मुद्राओं में नहीं, प्रीपेड खाते में रखे जाने पर निधियों को यूएसडी में बदल दिया जाता है।",
"use_payjoin": "उपयोग %%%",
"use_ssl": "उपयोग SSL",
"use_suggested": "सुझाए गए का प्रयोग करें",
"use_testnet": "टेस्टनेट का उपयोग करें",

View file

@ -905,6 +905,7 @@
"use": "Prebaci na",
"use_card_info_three": "Koristite digitalnu karticu online ili s beskontaktnim metodama plaćanja.",
"use_card_info_two": "Sredstva se pretvaraju u USD kada se drže na prepaid računu, a ne u digitalnim valutama.",
"use_payjoin": "Koristite Payjoin",
"use_ssl": "Koristi SSL",
"use_suggested": "Koristite predloženo",
"use_testnet": "Koristite TestNet",
@ -988,4 +989,4 @@
"you_will_get": "Razmijeni u",
"you_will_send": "Razmijeni iz",
"yy": "GG"
}
}

View file

@ -905,6 +905,7 @@
"use": "Փոխեք ",
"use_card_info_three": "Օգտագործեք թվային քարտը առցանց կամ անշփման վճարման մեթոդներով։",
"use_card_info_two": "Միջոցները փոխարկվում են ԱՄՆ դոլար երբ դրանք պահվում են կանխավճարային հաշվեկշռում, ոչ թե թվային արժույթներում։",
"use_payjoin": "Օգտագործեք Payjoin",
"use_ssl": "Օգտագործել SSL",
"use_suggested": "Օգտագործել առաջարկվածը",
"use_testnet": "Օգտագործել Testnet",
@ -988,4 +989,4 @@
"you_will_get": "Ստացեք",
"you_will_send": "Փոխանակեք",
"yy": "ՏՏ"
}
}

View file

@ -908,6 +908,7 @@
"use": "Beralih ke ",
"use_card_info_three": "Gunakan kartu digital secara online atau dengan metode pembayaran tanpa kontak.",
"use_card_info_two": "Dana dikonversi ke USD ketika disimpan dalam akun pra-bayar, bukan dalam mata uang digital.",
"use_payjoin": "Menggunakan Payjoin",
"use_ssl": "Gunakan SSL",
"use_suggested": "Gunakan yang Disarankan",
"use_testnet": "Gunakan TestNet",
@ -991,4 +992,4 @@
"you_will_get": "Konversi ke",
"you_will_send": "Konversi dari",
"yy": "YY"
}
}

View file

@ -907,6 +907,7 @@
"use": "Passa a ",
"use_card_info_three": "Utilizza la carta digitale online o con metodi di pagamento contactless.",
"use_card_info_two": "I fondi vengono convertiti in USD quando sono detenuti nel conto prepagato, non in valute digitali.",
"use_payjoin": "Utilizzo Payjoin",
"use_ssl": "Usa SSL",
"use_suggested": "Usa suggerito",
"use_testnet": "Usa TestNet",
@ -991,4 +992,4 @@
"you_will_get": "Converti a",
"you_will_send": "Conveti da",
"yy": "YY"
}
}

View file

@ -906,6 +906,7 @@
"use": "使用する ",
"use_card_info_three": "デジタルカードをオンラインまたは非接触型決済方法で使用してください。",
"use_card_info_two": "デジタル通貨ではなく、プリペイドアカウントで保持されている場合、資金は米ドルに変換されます。",
"use_payjoin": "使用 ",
"use_ssl": "SSLを使用する",
"use_suggested": "推奨を使用",
"use_testnet": "テストネットを使用します",

View file

@ -906,6 +906,7 @@
"use": "사용하다 ",
"use_card_info_three": "디지털 카드를 온라인 또는 비접촉식 결제 수단으로 사용하십시오.",
"use_card_info_two": "디지털 화폐가 아닌 선불 계정에 보유하면 자금이 USD로 변환됩니다.",
"use_payjoin": "사용 Payjoin",
"use_ssl": "SSL 사용",
"use_suggested": "추천 사용",
"use_testnet": "TestNet을 사용하십시오",
@ -990,4 +991,4 @@
"you_will_send": "다음에서 변환",
"YY": "YY",
"yy": "YY"
}
}

View file

@ -905,6 +905,7 @@
"use": "သို့ပြောင်းပါ။",
"use_card_info_three": "ဒစ်ဂျစ်တယ်ကတ်ကို အွန်လိုင်း သို့မဟုတ် ထိတွေ့မှုမဲ့ ငွေပေးချေမှုနည်းလမ်းများဖြင့် အသုံးပြုပါ။",
"use_card_info_two": "ဒစ်ဂျစ်တယ်ငွေကြေးများဖြင့်မဟုတ်ဘဲ ကြိုတင်ငွေပေးချေသည့်အကောင့်တွင် သိမ်းထားသည့်အခါ ရန်ပုံငွေများကို USD သို့ ပြောင်းလဲပါသည်။",
"use_payjoin": "%% Chair ကိုအသုံးပြုပါ",
"use_ssl": "SSL ကိုသုံးပါ။",
"use_suggested": "အကြံပြုထားသည်ကို အသုံးပြုပါ။",
"use_testnet": "testnet ကိုသုံးပါ",

View file

@ -905,6 +905,7 @@
"use": "Gebruik ",
"use_card_info_three": "Gebruik de digitale kaart online of met contactloze betaalmethoden.",
"use_card_info_two": "Tegoeden worden omgezet naar USD wanneer ze op de prepaid-rekening staan, niet in digitale valuta.",
"use_payjoin": "Gebruik Payjoin",
"use_ssl": "Gebruik SSL",
"use_suggested": "Gebruik aanbevolen",
"use_testnet": "Gebruik testnet",
@ -989,4 +990,4 @@
"you_will_get": "Converteren naar",
"you_will_send": "Converteren van",
"yy": "JJ"
}
}

View file

@ -905,6 +905,7 @@
"use": "Użyj ",
"use_card_info_three": "Użyj cyfrowej karty online lub za pomocą zbliżeniowych metod płatności.",
"use_card_info_two": "Środki są przeliczane na USD, gdy są przechowywane na koncie przedpłaconym, a nie w walutach cyfrowych.",
"use_payjoin": "Używać Payjoin",
"use_ssl": "Użyj SSL",
"use_suggested": "Użyj sugerowane",
"use_testnet": "Użyj testne",
@ -988,4 +989,4 @@
"you_will_get": "Konwertuj na",
"you_will_send": "Konwertuj z",
"yy": "RR"
}
}

View file

@ -907,6 +907,7 @@
"use": "Use PIN de ",
"use_card_info_three": "Use o cartão digital online ou com métodos de pagamento sem contato.",
"use_card_info_two": "Os fundos são convertidos para USD quando mantidos na conta pré-paga, não em moedas digitais.",
"use_payjoin": "Usar Payjoin",
"use_ssl": "Use SSL",
"use_suggested": "Uso sugerido",
"use_testnet": "Use testNet",
@ -991,4 +992,4 @@
"you_will_get": "Converter para",
"you_will_send": "Converter de",
"yy": "aa"
}
}

View file

@ -906,6 +906,7 @@
"use": "Использовать ",
"use_card_info_three": "Используйте цифровую карту онлайн или с помощью бесконтактных способов оплаты.",
"use_card_info_two": "Средства конвертируются в доллары США, когда они хранятся на предоплаченном счете, а не в цифровых валютах.",
"use_payjoin": "Использовать %%%",
"use_ssl": "Использовать SSL",
"use_suggested": "Использовать предложенный",
"use_testnet": "Используйте Testnet",

View file

@ -905,6 +905,7 @@
"use": "สลับไปที่ ",
"use_card_info_three": "ใช้บัตรดิจิตอลออนไลน์หรือผ่านวิธีการชำระเงินแบบไม่ต้องใช้บัตรกระดาษ",
"use_card_info_two": "เงินจะถูกแปลงค่าเป็นดอลลาร์สหรัฐเมื่อถือไว้ในบัญชีสำรองเงิน ไม่ใช่สกุลเงินดิจิตอล",
"use_payjoin": "ใช้ %%%",
"use_ssl": "ใช้ SSL",
"use_suggested": "ใช้ที่แนะนำ",
"use_testnet": "ใช้ testnet",

View file

@ -905,6 +905,7 @@
"use": "Lumipat sa ",
"use_card_info_three": "Gamitin ang digital card online o sa mga paraan ng pagbabayad na walang contact.",
"use_card_info_two": "Ang mga pondo ay na-convert sa USD kapag hawak sa prepaid account, hindi sa mga digital na pera.",
"use_payjoin": "Gumamit ng Payjoin",
"use_ssl": "Gumamit ng SSL",
"use_suggested": "Gumamit ng iminungkahing",
"use_testnet": "Gumamit ng testnet",
@ -988,4 +989,4 @@
"you_will_get": "I-convert sa",
"you_will_send": "I-convert mula sa",
"yy": "YY"
}
}

View file

@ -905,6 +905,7 @@
"use": "Şuna geç: ",
"use_card_info_three": "Dijital kartı çevrimiçi olarak veya temassız ödeme yöntemleriyle kullanın.",
"use_card_info_two": "Paralar, dijital para birimlerinde değil, ön ödemeli hesapta tutulduğunda USD'ye dönüştürülür.",
"use_payjoin": "Kullanmak Payjoin",
"use_ssl": "SSL kullan",
"use_suggested": "Önerileni Kullan",
"use_testnet": "TestNet kullanın",
@ -988,4 +989,4 @@
"you_will_get": "Biçimine dönüştür:",
"you_will_send": "Biçiminden dönüştür:",
"yy": "YY"
}
}

View file

@ -906,6 +906,7 @@
"use": "Використати ",
"use_card_info_three": "Використовуйте цифрову картку онлайн або за допомогою безконтактних методів оплати.",
"use_card_info_two": "Кошти конвертуються в долари США, якщо вони зберігаються на передплаченому рахунку, а не в цифрових валютах.",
"use_payjoin": "Використовуйте %%",
"use_ssl": "Використати SSL",
"use_suggested": "Використати запропоноване",
"use_testnet": "Використовуйте тестову мережу",

View file

@ -907,6 +907,7 @@
"use": "تبدیل کرنا",
"use_card_info_three": "ڈیجیٹل کارڈ آن لائن یا کنٹیکٹ لیس ادائیگی کے طریقوں کے ساتھ استعمال کریں۔",
"use_card_info_two": "رقوم کو امریکی ڈالر میں تبدیل کیا جاتا ہے جب پری پیڈ اکاؤنٹ میں رکھا جاتا ہے، ڈیجیٹل کرنسیوں میں نہیں۔",
"use_payjoin": "٪٪٪ کا استعمال کریں",
"use_ssl": "SSL استعمال کریں۔",
"use_suggested": "تجویز کردہ استعمال کریں۔",
"use_testnet": "ٹیسٹ نیٹ استعمال کریں",

View file

@ -904,6 +904,7 @@
"use": "Chuyển sang",
"use_card_info_three": "Sử dụng thẻ kỹ thuật số trực tuyến hoặc với các phương thức thanh toán không tiếp xúc.",
"use_card_info_two": "Các khoản tiền được chuyển đổi thành USD khi chúng được giữ trong tài khoản trả trước, không phải trong các loại tiền kỹ thuật số.",
"use_payjoin": "Sử dụng Payjoin",
"use_ssl": "Sử dụng SSL",
"use_suggested": "Sử dụng đề xuất",
"use_testnet": "Sử dụng Testnet",
@ -987,4 +988,4 @@
"you_will_get": "Chuyển đổi thành",
"you_will_send": "Chuyển đổi từ",
"yy": "YY"
}
}

View file

@ -906,6 +906,7 @@
"use": "Lo",
"use_card_info_three": "Ẹ lo káàdí ayélujára lórí wẹ́ẹ̀bù tàbí ẹ lò ó lórí àwọn ẹ̀rọ̀ ìrajà tíwọn kò kò.",
"use_card_info_two": "A pààrọ̀ owó sí owó Amẹ́ríkà tó bá wà nínú àkanti t'á ti fikún tẹ́lẹ̀tẹ́lẹ̀. A kò kó owó náà nínú owó ayélujára.",
"use_payjoin": "Lo %%",
"use_ssl": "Lo SSL",
"use_suggested": "Lo àbá",
"use_testnet": "Lo tele",

View file

@ -905,6 +905,7 @@
"use": "切换使用",
"use_card_info_three": "在线使用电子卡或使用非接触式支付方式。",
"use_card_info_two": "预付账户中的资金转换为美元,不是数字货币。",
"use_payjoin": "使用 ",
"use_ssl": "使用SSL",
"use_suggested": "使用建议",
"use_testnet": "使用TestNet",

View file

@ -240,11 +240,12 @@ abstract class Bitcoin {
String? getUnusedMwebAddress(Object wallet);
String? getUnusedSegwitAddress(Object wallet);
String buildV2PjStr({required Object receiverWallet});
void updatePayjoinState(Object wallet, bool state);
String getPayjoinEndpoint(Object wallet);
Future<Sender> buildPayjoinRequest(String originalPsbt, dynamic pjUri, int fee);
Future<String> buildOriginalPsbt(Object wallet, int fee, double amount, Object credentials);
Future<String> requestAndPollV2Proposal(Sender sender);
Future<PendingBitcoinTransaction> extractPjTx(Object wallet, String psbtString, Object credentials);
Future<PendingBitcoinTransaction> extractPjTx(Object wallet, String psbtString);
}
""";