Skip to content

Commit

Permalink
Send payjoin in main thread
Browse files Browse the repository at this point in the history
Sends payjoin in place using the build, send, confirm flow.

There are 2 hacks to get this to work that I'm sure must be worked out.

1. BIP21 Uris are mangled by dart's Uri struct since BIP21 and form URI
   encoding are different specifications. This requires some reencoding
   of bip21 values before the sender gets initialized.

   In the future, the bip21 implementation here must be made spec
   compliant, perhaps by depending on the rust crate so as to use the
   same types in bdk, payjoin-flutter, etc.

2. TransactionDetails used to display historical data are missing. BDK
   defines a TransactionDetails type which is now deprecated in BDK 1.0
   which includes details like 'address', 'sent' amount, and 'received'
   amount. This PR makes no attempt to follow this paradigm.

   Somehow historical data must be populated so that history is
   accurately populated and the confirm screen and confirmed screen
   displays correct information.
  • Loading branch information
DanGould authored and ethicnology committed Dec 19, 2024
1 parent d4a4091 commit 02ea89a
Show file tree
Hide file tree
Showing 12 changed files with 277 additions and 6 deletions.
108 changes: 108 additions & 0 deletions lib/_pkg/payjoin/manager.dart
Original file line number Diff line number Diff line change
@@ -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<Sender> 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<String?> 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<int>,
);
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<int>,
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<String> _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<int>);
return proposalPsbt;
} catch (e) {
throw Exception('Send V1 payjoin error: $e');
}
}

/// Take a Request from the payjoin sender and post it over OHTTP.
Future<Response<dynamic>> _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,
);
}
}
9 changes: 8 additions & 1 deletion lib/_pkg/wallet/bdk/sensitive_create.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 22 additions & 2 deletions lib/_pkg/wallet/bdk/transaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -595,15 +595,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,
Expand All @@ -627,6 +628,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,
Expand Down
48 changes: 48 additions & 0 deletions lib/_pkg/wallet/transaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions lib/locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -259,6 +260,10 @@ Future _setupBlocs() async {
),
);

locator.registerSingleton<PayjoinManager>(
PayjoinManager(),
);

locator.registerSingleton<NetworkFeesCubit>(
NetworkFeesCubit(
hiveStorage: locator<HiveStorage>(),
Expand Down
64 changes: 62 additions & 2 deletions lib/send/bloc/send_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,6 +35,7 @@ class SendCubit extends Cubit<SendState> {
required bool openScanner,
required HomeCubit homeCubit,
required bool defaultRBF,
required PayjoinManager payjoinManager,
required SwapBoltz swapBoltz,
required CreateSwapCubit swapCubit,
bool oneWallet = true,
Expand All @@ -44,6 +46,7 @@ class SendCubit extends Cubit<SendState> {
_walletTx = walletTx,
_fileStorage = fileStorage,
_barcode = barcode,
_payjoinManager = payjoinManager,
_swapBoltz = swapBoltz,
_swapCubit = swapCubit,
super(
Expand All @@ -66,6 +69,7 @@ class SendCubit extends Cubit<SendState> {
final Barcode _barcode;
final FileStorage _fileStorage;
final WalletTx _walletTx;
final PayjoinManager _payjoinManager;
final SwapBoltz _swapBoltz;

final NetworkCubit _networkCubit;
Expand Down Expand Up @@ -135,7 +139,13 @@ class SendCubit extends Cubit<SendState> {
}
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(
Expand Down Expand Up @@ -920,6 +930,44 @@ class SendCubit extends Cubit<SendState> {
}
}

Future<void> 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<void> 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<void> baseLayerSend() async {
if (state.selectedWalletBloc == null) return;
emit(state.copyWith(sending: true, errSending: ''));
Expand Down Expand Up @@ -1124,7 +1172,15 @@ class SendCubit extends Cubit<SendState> {
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<WalletBloc>().state.wallet;
Expand All @@ -1146,6 +1202,10 @@ class SendCubit extends Cubit<SendState> {
}

if (!isLn) {
if (state.payjoinSender != null) {
await payjoinSend(wallet);
return;
}
baseLayerSend();
return;
}
Expand Down
6 changes: 5 additions & 1 deletion lib/send/bloc/send_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<UTXO> selectedUtxos,
@Default('') String errAddresses,
Expand Down Expand Up @@ -254,7 +256,9 @@ class SendState with _$SendState {
? 'Generate PSBT'
: signed
? sending
? 'Broadcasting'
? payjoinSender != null
? 'Payjoining'
: 'Broadcasting'
: 'Confirm'
: sending
? 'Building Tx'
Expand Down
Loading

0 comments on commit 02ea89a

Please sign in to comment.