Skip to content

Commit

Permalink
Send payjoin in main thread
Browse files Browse the repository at this point in the history
  • Loading branch information
DanGould committed Dec 19, 2024
1 parent 35ff891 commit 8eb953a
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 9 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 @@ -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,
Expand All @@ -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,
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 @@ -250,7 +252,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 8eb953a

Please sign in to comment.