From 8eb953a9d471515a051b885f9770000e2da82c6f Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 19 Dec 2024 00:45:40 -0500 Subject: [PATCH] Send payjoin in main thread --- lib/_pkg/payjoin/manager.dart | 108 ++++++++++++++++++++++ lib/_pkg/wallet/bdk/sensitive_create.dart | 9 +- lib/_pkg/wallet/bdk/transaction.dart | 24 ++++- lib/_pkg/wallet/transaction.dart | 48 ++++++++++ lib/locator.dart | 5 + lib/send/bloc/send_cubit.dart | 64 ++++++++++++- lib/send/bloc/send_state.dart | 6 +- lib/send/send_page.dart | 2 + lib/swap/swap_page.dart | 2 + lib/transaction/bump_fees.dart | 2 + pubspec.lock | 15 ++- pubspec.yaml | 4 + 12 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 lib/_pkg/payjoin/manager.dart diff --git a/lib/_pkg/payjoin/manager.dart b/lib/_pkg/payjoin/manager.dart new file mode 100644 index 00000000..218b52d0 --- /dev/null +++ b/lib/_pkg/payjoin/manager.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/send.dart'; +import 'package:payjoin_flutter/uri.dart' as pj_uri; + +class PayjoinManager { + Future initSender( + String pjUriString, + int networkFees, + String originalPsbt, + ) async { + try { + // TODO this is a super ugly hack because of ugliness in the bip21 module. + // Fix that and get rid of this. + final pjSubstring = pjUriString.substring(pjUriString.indexOf('pj=') + 3); + final capitalizedPjSubstring = pjSubstring.toUpperCase(); + final pjUriStringWithCapitalizedPj = + pjUriString.substring(0, pjUriString.indexOf('pj=') + 3) + + capitalizedPjSubstring; + // This should already be done before letting payjoin be enabled for sending + final pjUri = (await pj_uri.Uri.fromStr(pjUriStringWithCapitalizedPj)) + .checkPjSupported(); + final minFeeRateSatPerKwu = BigInt.from(networkFees * 250); + final senderBuilder = await SenderBuilder.fromPsbtAndUri( + psbtBase64: originalPsbt, + pjUri: pjUri, + ); + final sender = await senderBuilder.buildRecommended( + minFeeRate: minFeeRateSatPerKwu, + ); + return sender; + } catch (e) { + throw Exception('Error initializing payjoin Sender: $e'); + } + } + + /// Sends a payjoin using the v2 protocol given an initialized Sender. + /// V2 protocol first attempts a v2 request, but if one cannot be extracted + /// from the given bitcoin URI, it will attempt to send a v1 request. + Future runPayjoinSender(Sender sender) async { + final ohttpProxyUrl = + await pj_uri.Url.fromStr('https://pj.bobspacebkk.com'); + Request postReq; + V2PostContext postReqCtx; + final dio = Dio(); + + try { + final result = await sender.extractV2(ohttpProxyUrl: ohttpProxyUrl); + postReq = result.$1; + postReqCtx = result.$2; + } catch (e) { + // extract v2 failed, attempt to send v1 + return await _runPayjoinSenderV1(sender, dio); + } + + try { + final postRes = await _postRequest(dio, postReq); + final getCtx = await postReqCtx.processResponse( + response: postRes.data as List, + ); + while (true) { + try { + final (getRequest, getReqCtx) = await getCtx.extractReq( + ohttpRelay: ohttpProxyUrl, + ); + final getRes = await _postRequest(dio, getRequest); + return await getCtx.processResponse( + response: getRes.data as List, + ohttpCtx: getReqCtx, + ); + } catch (e) { + // loop + } + } + } catch (e) { + throw Exception('Error polling payjoin sender: $e'); + } + } + + // Attempt to send a payjoin using the v1 protocol as fallback. + Future _runPayjoinSenderV1(Sender sender, Dio dio) async { + try { + final (req, v1Ctx) = await sender.extractV1(); + final response = await _postRequest(dio, req); + final proposalPsbt = + await v1Ctx.processResponse(response: response.data as List); + return proposalPsbt; + } catch (e) { + throw Exception('Send V1 payjoin error: $e'); + } + } + + /// Take a Request from the payjoin sender and post it over OHTTP. + Future> _postRequest(Dio dio, Request req) async { + return await dio.post( + req.url.asString(), + options: Options( + headers: { + 'Content-Type': req.contentType, + }, + responseType: ResponseType.bytes, + ), + data: req.body, + ); + } +} diff --git a/lib/_pkg/wallet/bdk/sensitive_create.dart b/lib/_pkg/wallet/bdk/sensitive_create.dart index 2cedba6f..30e9fb7c 100644 --- a/lib/_pkg/wallet/bdk/sensitive_create.dart +++ b/lib/_pkg/wallet/bdk/sensitive_create.dart @@ -7,6 +7,7 @@ import 'package:bb_mobile/_pkg/wallet/create.dart'; import 'package:bb_mobile/_pkg/wallet/repository/wallets.dart'; import 'package:bb_mobile/_pkg/wallet/utils.dart'; import 'package:bdk_flutter/bdk_flutter.dart' as bdk; +import 'package:path_provider/path_provider.dart'; class BDKSensitiveCreate { BDKSensitiveCreate({ @@ -449,7 +450,13 @@ class BDKSensitiveCreate { ); } - const dbConfig = bdk.DatabaseConfig.memory(); + final appDocDir = await getApplicationDocumentsDirectory(); + final String dbDir = + '${appDocDir.path}/${wallet.getWalletStorageString()}'; + + final dbConfig = bdk.DatabaseConfig.sqlite( + config: bdk.SqliteDbConfiguration(path: dbDir), + ); final bdkWallet = await bdk.Wallet.create( descriptor: external, diff --git a/lib/_pkg/wallet/bdk/transaction.dart b/lib/_pkg/wallet/bdk/transaction.dart index 095fe3b8..8c48259e 100644 --- a/lib/_pkg/wallet/bdk/transaction.dart +++ b/lib/_pkg/wallet/bdk/transaction.dart @@ -591,15 +591,16 @@ class BDKTransactions { // required bdk.Blockchain blockchain, required bdk.Wallet bdkWallet, // required String address, + bool trustWitnessUtxo = false, }) async { try { final psbtStruct = await bdk.PartiallySignedTransaction.fromString(psbt); final tx = psbtStruct.extractTx(); final _ = await bdkWallet.sign( psbt: psbtStruct, - signOptions: const bdk.SignOptions( + signOptions: bdk.SignOptions( // multiSig: false, - trustWitnessUtxo: false, + trustWitnessUtxo: trustWitnessUtxo, allowAllSighashes: false, removePartialSigs: true, tryFinalize: true, @@ -623,6 +624,25 @@ class BDKTransactions { } } + /// Broadcast a PSBT and return the txid + Future<(String?, Err?)> broadcastPsbt({ + required String psbt, + required bdk.Blockchain blockchain, + }) async { + // FIXME does this need to handle Transaction model accounting? + try { + final psbtStruct = await bdk.PartiallySignedTransaction.fromString(psbt); + final tx = psbtStruct.extractTx(); + + await blockchain.broadcast(transaction: tx); + final txid = psbtStruct.txid(); + + return (txid, null); + } on Exception catch (e) { + return (null, Err(e.message)); + } + } + Future<((Wallet, String)?, Err?)> broadcastTxWithWallet({ required String psbt, required bdk.Blockchain blockchain, diff --git a/lib/_pkg/wallet/transaction.dart b/lib/_pkg/wallet/transaction.dart index ddd6be85..eb42569d 100644 --- a/lib/_pkg/wallet/transaction.dart +++ b/lib/_pkg/wallet/transaction.dart @@ -224,6 +224,54 @@ class WalletTx implements IWalletTransactions { } } + Future<(String?, Err?)> signAndBroadcastPsbt({ + required String psbt, + // required bdk.Blockchain blockchain, + required Wallet walletStruct, + // required String address, + }) async { + // FIXME should this be broken up into two functions? Is "And" a smell? + try { + final (bdkWallet, errWallet) = + _walletsRepository.getBdkWallet(walletStruct.id); + if (errWallet != null) throw errWallet; + final (seed, errSeed) = await _walletSensitiveStorageRepository.readSeed( + fingerprintIndex: walletStruct.getRelatedSeedStorageString(), + ); + if (errSeed != null) throw errSeed; + final (bdkSignerWallet, errSigner) = + await _bdkSensitiveCreate.loadPrivateBdkWallet( + walletStruct, + seed!, + ); + if (errSigner != null) throw errSigner; + + final (signedTx, errSign) = await _bdkTransactions.signTx( + psbt: psbt, + bdkWallet: bdkSignerWallet!, + trustWitnessUtxo: true, + ); + if (errSign != null) throw errSign; + final (blockchain, errNetwork) = _networkRepository.bdkBlockchain; + if (errNetwork != null) throw errNetwork; + final (txid, errBroadcast) = await _bdkTransactions.broadcastPsbt( + psbt: psbt, + blockchain: blockchain!, + ); + if (errBroadcast != null) throw errBroadcast; + return (txid, null); + } catch (e) { + return ( + null, + Err( + e.toString(), + title: 'Error occurred while signing transaction', + solution: 'Please try again.', + ), + ); + } + } + Future<(Wallet, Err?)> addUnsignedTxToWallet({ required Transaction transaction, required Wallet wallet, diff --git a/lib/locator.dart b/lib/locator.dart index 6fae3552..81b3fa92 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -9,6 +9,7 @@ import 'package:bb_mobile/_pkg/launcher.dart'; import 'package:bb_mobile/_pkg/logger.dart'; import 'package:bb_mobile/_pkg/mempool_api.dart'; import 'package:bb_mobile/_pkg/nfc.dart'; +import 'package:bb_mobile/_pkg/payjoin/manager.dart'; import 'package:bb_mobile/_pkg/storage/hive.dart'; import 'package:bb_mobile/_pkg/storage/secure_storage.dart'; import 'package:bb_mobile/_pkg/storage/storage.dart'; @@ -259,6 +260,10 @@ Future _setupBlocs() async { ), ); + locator.registerSingleton( + PayjoinManager(), + ); + locator.registerSingleton( NetworkFeesCubit( hiveStorage: locator(), diff --git a/lib/send/bloc/send_cubit.dart b/lib/send/bloc/send_cubit.dart index 9f723da0..b2c05531 100644 --- a/lib/send/bloc/send_cubit.dart +++ b/lib/send/bloc/send_cubit.dart @@ -7,6 +7,7 @@ import 'package:bb_mobile/_pkg/barcode.dart'; import 'package:bb_mobile/_pkg/boltz/swap.dart'; import 'package:bb_mobile/_pkg/consts/configs.dart'; import 'package:bb_mobile/_pkg/file_storage.dart'; +import 'package:bb_mobile/_pkg/payjoin/manager.dart'; import 'package:bb_mobile/_pkg/wallet/bip21.dart'; import 'package:bb_mobile/_pkg/wallet/transaction.dart'; import 'package:bb_mobile/currency/bloc/currency_cubit.dart'; @@ -34,6 +35,7 @@ class SendCubit extends Cubit { required bool openScanner, required HomeCubit homeCubit, required bool defaultRBF, + required PayjoinManager payjoinManager, required SwapBoltz swapBoltz, required CreateSwapCubit swapCubit, bool oneWallet = true, @@ -44,6 +46,7 @@ class SendCubit extends Cubit { _walletTx = walletTx, _fileStorage = fileStorage, _barcode = barcode, + _payjoinManager = payjoinManager, _swapBoltz = swapBoltz, _swapCubit = swapCubit, super( @@ -66,6 +69,7 @@ class SendCubit extends Cubit { final Barcode _barcode; final FileStorage _fileStorage; final WalletTx _walletTx; + final PayjoinManager _payjoinManager; final SwapBoltz _swapBoltz; final NetworkCubit _networkCubit; @@ -135,7 +139,13 @@ class SendCubit extends Cubit { } final pjParam = bip21Obj.options['pj'] as String?; if (pjParam != null) { - emit(state.copyWith(payjoinEndpoint: Uri.parse(pjParam))); + // FIXME: this is an ugly hack because of ugliness in the bip21 module. + // Dart's URI encoding is not the same as the one used by the bip21 module. + final parsedPjParam = Uri.parse(pjParam); + final partialEncodedPjParam = + parsedPjParam.toString().replaceAll('#', '%23'); + final encodedPjParam = partialEncodedPjParam.replaceAll('%20', '+'); + emit(state.copyWith(payjoinEndpoint: Uri.parse(encodedPjParam))); } case AddressNetwork.bip21Liquid: final bip21Obj = bip21.decode( @@ -920,6 +930,44 @@ class SendCubit extends Cubit { } } + Future payjoinBuild({ + required int networkFees, + required String originalPsbt, + required Wallet wallet, + }) async { + // TODO Serialize raw bip21 input instead of this monstrosity + final pjUriString = + 'bitcoin:${state.address}?amount=${_currencyCubit.state.amount / 100000000}&label=${Uri.encodeComponent(state.note)}&pj=${state.payjoinEndpoint!}&pjos=0'; + final sender = await _payjoinManager.initSender( + pjUriString, networkFees, originalPsbt); + emit(state.copyWith(payjoinSender: sender)); + } + + Future payjoinSend(Wallet wallet) async { + if (state.selectedWalletBloc == null) return; + if (state.payjoinSender == null) return; + + // TODO copy originalPsbt.extractTx() to state.tx + // emit(state.copyWith(tx: originalPsbtTxWithId)); + emit(state.copyWith(sending: true, sent: false)); + final proposalPsbt = await _payjoinManager.runPayjoinSender( + state.payjoinSender!, + ); + final (txid, errBroadcast) = await _walletTx.signAndBroadcastPsbt( + walletStruct: wallet, + psbt: proposalPsbt!, + ); + if (errBroadcast != null) { + emit(state.copyWith(errSending: errBroadcast.toString(), sending: false)); + return; + } + + Future.delayed(150.ms); + state.selectedWalletBloc!.add(SyncWallet()); + + emit(state.copyWith(sending: false, sent: true)); + } + Future baseLayerSend() async { if (state.selectedWalletBloc == null) return; emit(state.copyWith(sending: true, errSending: '')); @@ -1124,7 +1172,15 @@ class SendCubit extends Cubit { if (!state.signed) { if (!isLn) { final fees = _networkFeesCubit.state.selectedOrFirst(false); - baseLayerBuild(networkFees: fees); + await baseLayerBuild(networkFees: fees); + if (state.payjoinEndpoint != null) { + await payjoinBuild( + networkFees: fees, + originalPsbt: state.psbtSigned!, + wallet: wallet, + ); + return; + } return; } // context.read().state.wallet; @@ -1146,6 +1202,10 @@ class SendCubit extends Cubit { } if (!isLn) { + if (state.payjoinSender != null) { + await payjoinSend(wallet); + return; + } baseLayerSend(); return; } diff --git a/lib/send/bloc/send_state.dart b/lib/send/bloc/send_state.dart index d74adbc8..a0c5dc61 100644 --- a/lib/send/bloc/send_state.dart +++ b/lib/send/bloc/send_state.dart @@ -8,6 +8,7 @@ import 'package:bb_mobile/_pkg/utils.dart'; import 'package:bb_mobile/wallet/bloc/wallet_bloc.dart'; import 'package:bdk_flutter/bdk_flutter.dart' as bdk; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:payjoin_flutter/send.dart'; part 'send_state.freezed.dart'; @@ -38,6 +39,7 @@ class SendState with _$SendState { @Default(false) bool downloaded, @Default(false) bool disableRBF, Uri? payjoinEndpoint, + Sender? payjoinSender, @Default(false) bool sendAllCoin, @Default([]) List selectedUtxos, @Default('') String errAddresses, @@ -250,7 +252,9 @@ class SendState with _$SendState { ? 'Generate PSBT' : signed ? sending - ? 'Broadcasting' + ? payjoinSender != null + ? 'Payjoining' + : 'Broadcasting' : 'Confirm' : sending ? 'Building Tx' diff --git a/lib/send/send_page.dart b/lib/send/send_page.dart index d6f16396..c4964ff5 100644 --- a/lib/send/send_page.dart +++ b/lib/send/send_page.dart @@ -4,6 +4,7 @@ import 'package:bb_mobile/_pkg/bull_bitcoin_api.dart'; import 'package:bb_mobile/_pkg/clipboard.dart'; import 'package:bb_mobile/_pkg/file_storage.dart'; import 'package:bb_mobile/_pkg/mempool_api.dart'; +import 'package:bb_mobile/_pkg/payjoin/manager.dart'; import 'package:bb_mobile/_pkg/storage/hive.dart'; import 'package:bb_mobile/_pkg/wallet/repository/sensitive_storage.dart'; import 'package:bb_mobile/_pkg/wallet/transaction.dart'; @@ -97,6 +98,7 @@ class _SendPageState extends State { networkCubit: locator(), networkFeesCubit: locator(), homeCubit: locator(), + payjoinManager: locator(), swapBoltz: locator(), currencyCubit: currency, openScanner: widget.openScanner, diff --git a/lib/swap/swap_page.dart b/lib/swap/swap_page.dart index 52282dc3..0b293a16 100644 --- a/lib/swap/swap_page.dart +++ b/lib/swap/swap_page.dart @@ -4,6 +4,7 @@ import 'package:bb_mobile/_pkg/boltz/swap.dart'; import 'package:bb_mobile/_pkg/bull_bitcoin_api.dart'; import 'package:bb_mobile/_pkg/file_storage.dart'; import 'package:bb_mobile/_pkg/mempool_api.dart'; +import 'package:bb_mobile/_pkg/payjoin/manager.dart'; import 'package:bb_mobile/_pkg/storage/hive.dart'; import 'package:bb_mobile/_pkg/wallet/repository/sensitive_storage.dart'; import 'package:bb_mobile/_pkg/wallet/transaction.dart'; @@ -77,6 +78,7 @@ class _SwapPageState extends State { networkCubit: locator(), networkFeesCubit: locator(), homeCubit: locator(), + payjoinManager: locator(), swapBoltz: locator(), currencyCubit: currency, openScanner: false, diff --git a/lib/transaction/bump_fees.dart b/lib/transaction/bump_fees.dart index 22d07881..0818b709 100644 --- a/lib/transaction/bump_fees.dart +++ b/lib/transaction/bump_fees.dart @@ -5,6 +5,7 @@ import 'package:bb_mobile/_pkg/bull_bitcoin_api.dart'; import 'package:bb_mobile/_pkg/file_storage.dart'; import 'package:bb_mobile/_pkg/launcher.dart'; import 'package:bb_mobile/_pkg/mempool_api.dart'; +import 'package:bb_mobile/_pkg/payjoin/manager.dart'; import 'package:bb_mobile/_pkg/storage/hive.dart'; import 'package:bb_mobile/_pkg/wallet/address.dart'; import 'package:bb_mobile/_pkg/wallet/bdk/sensitive_create.dart'; @@ -164,6 +165,7 @@ class _BumpFeesPageState extends State { networkCubit: locator(), networkFeesCubit: locator(), homeCubit: locator(), + payjoinManager: locator(), swapBoltz: locator(), currencyCubit: currency, openScanner: false, diff --git a/pubspec.lock b/pubspec.lock index dfdd72fd..254e0d50 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -256,7 +256,7 @@ packages: source: hosted version: "1.18.0" convert: - dependency: transitive + dependency: "direct main" description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 @@ -280,7 +280,7 @@ packages: source: hosted version: "0.3.4+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" @@ -644,7 +644,7 @@ packages: source: hosted version: "2.5.7" freezed_annotation: - dependency: transitive + dependency: "direct main" description: name: freezed_annotation sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 @@ -1062,6 +1062,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + payjoin_flutter: + dependency: "direct main" + description: + path: "." + ref: "0124ef669eb3b8e130e4584a534e9eb51a06bdec" + resolved-ref: "0124ef669eb3b8e130e4584a534e9eb51a06bdec" + url: "https://github.com/LtbLightning/payjoin-flutter" + source: git + version: "0.21.0" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5ab14725..308a621c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,10 @@ dependencies: modal_bottom_sheet: ^3.0.0-pre font_awesome_flutter: ^10.3.0 path_provider: ^2.0.15 + payjoin_flutter: + git: + url: https://github.com/LtbLightning/payjoin-flutter + ref: 0124ef669eb3b8e130e4584a534e9eb51a06bdec carousel_slider: ^4.2.1 qr_flutter: ^4.1.0 flutter_translate: ^4.0.3