mirror of
https://github.com/cake-tech/cake_wallet.git
synced 2025-06-28 12:29:51 +00:00
implement-payjoin (#1949)
* Initial Payjoin * Initial Payjoin * More payjoin stuff * Minor fixes * Minor fixes * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Fix minor bug causes by data inconsistency in the btc utxos * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Initial Payjoin * Initial Payjoin * More payjoin stuff * Minor fixes * Minor fixes * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Fix minor bug causes by data inconsistency in the btc utxos * Minor cleanup * Minor cleanup * Minor cleanup * Minor cleanup * Fix Rebase issues * Move PJ Receiver to isolate * Add Payjoin Setting * Payjoin Sender are now isolated * Added Payjoin sessions to tx overview. Fix Fee issue with payjoin * Clean up code * Fix taproot for payjoin * Fix CI Errors * Add Payjoin UI elements and details page * Add Payjoin UI elements and details page * Fix Translations * feat: Detect Payjoin URIs in pasted text and show to the User sending Payjoin * feat: rename pjUri to payjoinURI for more code clarity * Update res/values/strings_pl.arb Co-authored-by: cyan <cyjan@mrcyjanek.net> * Update cw_bitcoin/lib/payjoin/manager.dart Co-authored-by: cyan <cyjan@mrcyjanek.net> * Update cw_bitcoin/lib/payjoin/manager.dart Co-authored-by: cyan <cyjan@mrcyjanek.net> * feat: Disable Payjoin per default * feat: Disable Payjoin fully if disabled or no Inputs available * feat: Resume Payjoin if app comes back to foreground * chore: Revert overly aggressive code formats * feat: show correct Payjoin amount for receivers * feat: Improved payjoin status * feat: Show payjoin errors on payjoin details screen * deps: update flutter to 3.27.4 * feat: Revert localisations * bug: Remove duplicate transaction id on payjoin details * style: remove double await in payjoin sender * refactor(cw_bitcoin): Refactor method signatures and convert constructor to factory * refactor(cw_bitcoin): Refactor wallet service and PSBT signer for cleaner code Removed unnecessary `CakeHive` dependency and refactored `BitcoinWallet` initialization to use `payjoinSessionSource`. Improved code readability in `PsbtSigner` by reformatting lines and simplifying constructor methods for `UtxoWithPrivateKey`. * fix: Resume Payjoin Sessions and load PJUri after sleep * feat: Add "Copy Payjoin URL button" to receive screen * fix: Add "Payjoin enabled"-Box below QR Code on the receive screen * fix: Set payjoin_enabled color to black independent of the theme * refactor: Payjoin session management and cleanup unused code. --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com> Co-authored-by: cyan <cyjan@mrcyjanek.net>
This commit is contained in:
parent
4a08e18f00
commit
82e3ebf4fa
84 changed files with 2622 additions and 198 deletions
|
@ -2,22 +2,36 @@ import 'package:bitcoin_base/bitcoin_base.dart';
|
|||
|
||||
String addressFromOutputScript(Script script, BasedUtxoNetwork network) {
|
||||
try {
|
||||
switch (script.getAddressType()) {
|
||||
case P2pkhAddressType.p2pkh:
|
||||
return P2pkhAddress.fromScriptPubkey(script: script).toAddress(network);
|
||||
case P2shAddressType.p2pkInP2sh:
|
||||
return P2shAddress.fromScriptPubkey(script: script).toAddress(network);
|
||||
case SegwitAddresType.p2wpkh:
|
||||
return P2wpkhAddress.fromScriptPubkey(script: script).toAddress(network);
|
||||
case P2shAddressType.p2pkhInP2sh:
|
||||
return P2shAddress.fromScriptPubkey(script: script).toAddress(network);
|
||||
case SegwitAddresType.p2wsh:
|
||||
return P2wshAddress.fromScriptPubkey(script: script).toAddress(network);
|
||||
case SegwitAddresType.p2tr:
|
||||
return P2trAddress.fromScriptPubkey(script: script).toAddress(network);
|
||||
default:
|
||||
}
|
||||
return addressFromScript(script, network).toAddress(network);
|
||||
} catch (_) {}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
BitcoinBaseAddress addressFromScript(Script script,
|
||||
[BasedUtxoNetwork network = BitcoinNetwork.mainnet]) {
|
||||
final addressType = script.getAddressType();
|
||||
if (addressType == null) {
|
||||
throw ArgumentError("Invalid script");
|
||||
}
|
||||
|
||||
switch (addressType) {
|
||||
case P2pkhAddressType.p2pkh:
|
||||
return P2pkhAddress.fromScriptPubkey(
|
||||
script: script, network: BitcoinNetwork.mainnet);
|
||||
case P2shAddressType.p2pkhInP2sh:
|
||||
return P2shAddress.fromScriptPubkey(
|
||||
script: script, network: BitcoinNetwork.mainnet);
|
||||
case SegwitAddresType.p2wpkh:
|
||||
return P2wpkhAddress.fromScriptPubkey(
|
||||
script: script, network: BitcoinNetwork.mainnet);
|
||||
case SegwitAddresType.p2wsh:
|
||||
return P2wshAddress.fromScriptPubkey(
|
||||
script: script, network: BitcoinNetwork.mainnet);
|
||||
case SegwitAddresType.p2tr:
|
||||
return P2trAddress.fromScriptPubkey(
|
||||
script: script, network: BitcoinNetwork.mainnet);
|
||||
}
|
||||
|
||||
throw ArgumentError("Invalid script");
|
||||
}
|
||||
|
|
|
@ -3,11 +3,17 @@ import 'package:cw_core/output_info.dart';
|
|||
import 'package:cw_core/unspent_coin_type.dart';
|
||||
|
||||
class BitcoinTransactionCredentials {
|
||||
BitcoinTransactionCredentials(this.outputs,
|
||||
{required this.priority, this.feeRate, this.coinTypeToSpendFrom = UnspentCoinType.any});
|
||||
BitcoinTransactionCredentials(
|
||||
this.outputs, {
|
||||
required this.priority,
|
||||
this.feeRate,
|
||||
this.coinTypeToSpendFrom = UnspentCoinType.any,
|
||||
this.payjoinUri,
|
||||
});
|
||||
|
||||
final List<OutputInfo> outputs;
|
||||
final BitcoinTransactionPriority? priority;
|
||||
final int? feeRate;
|
||||
final UnspentCoinType coinTypeToSpendFrom;
|
||||
final String? payjoinUri;
|
||||
}
|
||||
|
|
|
@ -3,22 +3,33 @@ import 'dart:convert';
|
|||
import 'package:bip39/bip39.dart' as bip39;
|
||||
import 'package:bitcoin_base/bitcoin_base.dart';
|
||||
import 'package:blockchain_utils/blockchain_utils.dart';
|
||||
import 'package:cw_bitcoin/address_from_output.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_address_record.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_mnemonic.dart';
|
||||
import 'package:cw_bitcoin/psbt_transaction_builder.dart';
|
||||
import 'package:cw_core/encryption_file_utils.dart';
|
||||
import 'package:cw_bitcoin/electrum_derivations.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart';
|
||||
import 'package:cw_bitcoin/electrum_balance.dart';
|
||||
import 'package:cw_bitcoin/electrum_derivations.dart';
|
||||
import 'package:cw_bitcoin/electrum_wallet.dart';
|
||||
import 'package:cw_bitcoin/electrum_wallet_snapshot.dart';
|
||||
import 'package:cw_bitcoin/payjoin/manager.dart';
|
||||
import 'package:cw_bitcoin/payjoin/storage.dart';
|
||||
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
|
||||
import 'package:cw_bitcoin/psbt/signer.dart';
|
||||
import 'package:cw_bitcoin/psbt/transaction_builder.dart';
|
||||
import 'package:cw_bitcoin/psbt/v0_deserialize.dart';
|
||||
import 'package:cw_bitcoin/psbt/v0_finalizer.dart';
|
||||
import 'package:cw_core/crypto_currency.dart';
|
||||
import 'package:cw_core/encryption_file_utils.dart';
|
||||
import 'package:cw_core/payjoin_session.dart';
|
||||
import 'package:cw_core/pending_transaction.dart';
|
||||
import 'package:cw_core/unspent_coins_info.dart';
|
||||
import 'package:cw_core/wallet_info.dart';
|
||||
import 'package:cw_core/wallet_keys_file.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:ledger_bitcoin/ledger_bitcoin.dart';
|
||||
import 'package:ledger_bitcoin/psbt.dart';
|
||||
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
|
@ -31,6 +42,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
required String password,
|
||||
required WalletInfo walletInfo,
|
||||
required Box<UnspentCoinsInfo> unspentCoinsInfo,
|
||||
required Box<PayjoinSession> payjoinBox,
|
||||
required EncryptionFileUtils encryptionFileUtils,
|
||||
Uint8List? seedBytes,
|
||||
String? mnemonic,
|
||||
|
@ -71,20 +83,21 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
// String derivationPath = walletInfo.derivationInfo!.derivationPath!;
|
||||
// String sideDerivationPath = derivationPath.substring(0, derivationPath.length - 1) + "1";
|
||||
// final hd = bitcoin.HDWallet.fromSeed(seedBytes, network: networkType);
|
||||
walletAddresses = BitcoinWalletAddresses(
|
||||
walletInfo,
|
||||
initialAddresses: initialAddresses,
|
||||
initialRegularAddressIndex: initialRegularAddressIndex,
|
||||
initialChangeAddressIndex: initialChangeAddressIndex,
|
||||
initialSilentAddresses: initialSilentAddresses,
|
||||
initialSilentAddressIndex: initialSilentAddressIndex,
|
||||
mainHd: hd,
|
||||
sideHd: accountHD.childKey(Bip32KeyIndex(1)),
|
||||
network: networkParam ?? network,
|
||||
masterHd:
|
||||
seedBytes != null ? Bip32Slip10Secp256k1.fromSeed(seedBytes) : null,
|
||||
isHardwareWallet: walletInfo.isHardwareWallet,
|
||||
);
|
||||
|
||||
payjoinManager = PayjoinManager(PayjoinStorage(payjoinBox), this);
|
||||
walletAddresses = BitcoinWalletAddresses(walletInfo,
|
||||
initialAddresses: initialAddresses,
|
||||
initialRegularAddressIndex: initialRegularAddressIndex,
|
||||
initialChangeAddressIndex: initialChangeAddressIndex,
|
||||
initialSilentAddresses: initialSilentAddresses,
|
||||
initialSilentAddressIndex: initialSilentAddressIndex,
|
||||
mainHd: hd,
|
||||
sideHd: accountHD.childKey(Bip32KeyIndex(1)),
|
||||
network: networkParam ?? network,
|
||||
masterHd:
|
||||
seedBytes != null ? Bip32Slip10Secp256k1.fromSeed(seedBytes) : null,
|
||||
isHardwareWallet: walletInfo.isHardwareWallet,
|
||||
payjoinManager: payjoinManager);
|
||||
|
||||
autorun((_) {
|
||||
this.walletAddresses.isEnabledAutoGenerateSubaddress =
|
||||
|
@ -100,6 +113,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
required String password,
|
||||
required WalletInfo walletInfo,
|
||||
required Box<UnspentCoinsInfo> unspentCoinsInfo,
|
||||
required Box<PayjoinSession> payjoinBox,
|
||||
required EncryptionFileUtils encryptionFileUtils,
|
||||
String? passphrase,
|
||||
String? addressPageType,
|
||||
|
@ -122,9 +136,11 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
break;
|
||||
case DerivationType.electrum:
|
||||
default:
|
||||
seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? "");
|
||||
seedBytes =
|
||||
await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? "");
|
||||
break;
|
||||
}
|
||||
|
||||
return BitcoinWallet(
|
||||
mnemonic: mnemonic,
|
||||
passphrase: passphrase ?? "",
|
||||
|
@ -141,6 +157,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
initialChangeAddressIndex: initialChangeAddressIndex,
|
||||
addressPageType: addressPageType,
|
||||
networkParam: network,
|
||||
payjoinBox: payjoinBox,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -148,6 +165,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
required String name,
|
||||
required WalletInfo walletInfo,
|
||||
required Box<UnspentCoinsInfo> unspentCoinsInfo,
|
||||
required Box<PayjoinSession> payjoinBox,
|
||||
required String password,
|
||||
required EncryptionFileUtils encryptionFileUtils,
|
||||
required bool alwaysScan,
|
||||
|
@ -204,7 +222,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
if (mnemonic != null) {
|
||||
switch (walletInfo.derivationInfo!.derivationType) {
|
||||
case DerivationType.electrum:
|
||||
seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? "");
|
||||
seedBytes =
|
||||
await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? "");
|
||||
break;
|
||||
case DerivationType.bip39:
|
||||
default:
|
||||
|
@ -217,24 +236,24 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
}
|
||||
|
||||
return BitcoinWallet(
|
||||
mnemonic: mnemonic,
|
||||
xpub: keysData.xPub,
|
||||
password: password,
|
||||
passphrase: passphrase,
|
||||
walletInfo: walletInfo,
|
||||
unspentCoinsInfo: unspentCoinsInfo,
|
||||
initialAddresses: snp?.addresses,
|
||||
initialSilentAddresses: snp?.silentAddresses,
|
||||
initialSilentAddressIndex: snp?.silentAddressIndex ?? 0,
|
||||
initialBalance: snp?.balance,
|
||||
encryptionFileUtils: encryptionFileUtils,
|
||||
seedBytes: seedBytes,
|
||||
initialRegularAddressIndex: snp?.regularAddressIndex,
|
||||
initialChangeAddressIndex: snp?.changeAddressIndex,
|
||||
addressPageType: snp?.addressPageType,
|
||||
networkParam: network,
|
||||
alwaysScan: alwaysScan,
|
||||
);
|
||||
mnemonic: mnemonic,
|
||||
xpub: keysData.xPub,
|
||||
password: password,
|
||||
passphrase: passphrase,
|
||||
walletInfo: walletInfo,
|
||||
unspentCoinsInfo: unspentCoinsInfo,
|
||||
initialAddresses: snp?.addresses,
|
||||
initialSilentAddresses: snp?.silentAddresses,
|
||||
initialSilentAddressIndex: snp?.silentAddressIndex ?? 0,
|
||||
initialBalance: snp?.balance,
|
||||
encryptionFileUtils: encryptionFileUtils,
|
||||
seedBytes: seedBytes,
|
||||
initialRegularAddressIndex: snp?.regularAddressIndex,
|
||||
initialChangeAddressIndex: snp?.changeAddressIndex,
|
||||
addressPageType: snp?.addressPageType,
|
||||
networkParam: network,
|
||||
alwaysScan: alwaysScan,
|
||||
payjoinBox: payjoinBox);
|
||||
}
|
||||
|
||||
LedgerConnection? _ledgerConnection;
|
||||
|
@ -247,20 +266,25 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
derivationPath: walletInfo.derivationInfo!.derivationPath!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BtcTransaction> buildHardwareWalletTransaction({
|
||||
late final PayjoinManager payjoinManager;
|
||||
|
||||
bool get isPayjoinAvailable => unspentCoinsInfo.values
|
||||
.where((element) =>
|
||||
element.walletId == id && element.isSending && !element.isFrozen)
|
||||
.isNotEmpty;
|
||||
|
||||
Future<PsbtV2> buildPsbt({
|
||||
required List<BitcoinBaseOutput> outputs,
|
||||
required BigInt fee,
|
||||
required BasedUtxoNetwork network,
|
||||
required List<UtxoWithAddress> utxos,
|
||||
required Map<String, PublicKeyWithDerivationPath> publicKeys,
|
||||
required Uint8List masterFingerprint,
|
||||
String? memo,
|
||||
bool enableRBF = false,
|
||||
BitcoinOrdering inputOrdering = BitcoinOrdering.bip69,
|
||||
BitcoinOrdering outputOrdering = BitcoinOrdering.bip69,
|
||||
}) async {
|
||||
final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint();
|
||||
|
||||
final psbtReadyInputs = <PSBTReadyUtxoWithAddress>[];
|
||||
for (final utxo in utxos) {
|
||||
final rawTx =
|
||||
|
@ -278,13 +302,128 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store {
|
|||
));
|
||||
}
|
||||
|
||||
final psbt = PSBTTransactionBuild(
|
||||
inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF);
|
||||
return PSBTTransactionBuild(
|
||||
inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF)
|
||||
.psbt;
|
||||
}
|
||||
|
||||
final rawHex = await _bitcoinLedgerApp!.signPsbt(psbt: psbt.psbt);
|
||||
@override
|
||||
Future<BtcTransaction> buildHardwareWalletTransaction({
|
||||
required List<BitcoinBaseOutput> outputs,
|
||||
required BigInt fee,
|
||||
required BasedUtxoNetwork network,
|
||||
required List<UtxoWithAddress> utxos,
|
||||
required Map<String, PublicKeyWithDerivationPath> publicKeys,
|
||||
String? memo,
|
||||
bool enableRBF = false,
|
||||
BitcoinOrdering inputOrdering = BitcoinOrdering.bip69,
|
||||
BitcoinOrdering outputOrdering = BitcoinOrdering.bip69,
|
||||
}) async {
|
||||
final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint();
|
||||
|
||||
final psbt = await buildPsbt(
|
||||
outputs: outputs,
|
||||
fee: fee,
|
||||
network: network,
|
||||
utxos: utxos,
|
||||
publicKeys: publicKeys,
|
||||
masterFingerprint: masterFingerprint,
|
||||
memo: memo,
|
||||
enableRBF: enableRBF,
|
||||
inputOrdering: inputOrdering,
|
||||
outputOrdering: outputOrdering,
|
||||
);
|
||||
|
||||
final rawHex = await _bitcoinLedgerApp!.signPsbt(psbt: psbt);
|
||||
return BtcTransaction.fromRaw(BytesUtils.toHexString(rawHex));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PendingTransaction> createTransaction(Object credentials) async {
|
||||
credentials = credentials as BitcoinTransactionCredentials;
|
||||
|
||||
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: addressFromScript(e.scriptPubKey),
|
||||
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, int.parse(tx.feeRate));
|
||||
payjoinManager.spawnNewSender(
|
||||
sender: sender, pjUrl: payjoinUri, amount: BigInt.from(tx.amount));
|
||||
};
|
||||
|
||||
return tx;
|
||||
}
|
||||
|
||||
List<UtxoWithPrivateKey> getUtxoWithPrivateKeys() => unspentCoins
|
||||
.where((e) => (e.isSending && !e.isFrozen))
|
||||
.map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this))
|
||||
.toList();
|
||||
|
||||
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<String> signPsbt(
|
||||
String preProcessedPsbt, List<UtxoWithPrivateKey> utxos) async {
|
||||
final psbt = PsbtV2()..deserializeV0(base64Decode(preProcessedPsbt));
|
||||
|
||||
await psbt.signWithUTXO(utxos, (txDigest, utxo, key, sighash) {
|
||||
return utxo.utxo.isP2tr()
|
||||
? key.signTapRoot(
|
||||
txDigest,
|
||||
sighash: sighash,
|
||||
tweak: utxo.utxo.isSilentPayment != true,
|
||||
)
|
||||
: key.signInput(txDigest, sigHash: sighash);
|
||||
}, (txId, vout) async {
|
||||
final txHex = await electrumClient.getTransactionHex(hash: txId);
|
||||
final output = BtcTransaction.fromRaw(txHex).outputs[vout];
|
||||
return TaprootAmountScriptPair(output.amount, output.scriptPubKey);
|
||||
});
|
||||
|
||||
psbt.finalizeV0();
|
||||
return base64Encode(psbt.asPsbtV0());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> signMessage(String message, {String? address = null}) async {
|
||||
if (walletInfo.isHardwareWallet) {
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import 'package:bitcoin_base/bitcoin_base.dart';
|
||||
import 'package:blockchain_utils/bip/bip/bip32/bip32.dart';
|
||||
import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
|
||||
import 'package:cw_bitcoin/payjoin/manager.dart';
|
||||
import 'package:cw_bitcoin/utils.dart';
|
||||
import 'package:cw_core/unspent_coin_type.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:cw_core/wallet_info.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
import 'package:payjoin_flutter/receive.dart' as payjoin;
|
||||
|
||||
part 'bitcoin_wallet_addresses.g.dart';
|
||||
|
||||
|
@ -17,6 +20,7 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
|
|||
required super.sideHd,
|
||||
required super.network,
|
||||
required super.isHardwareWallet,
|
||||
required this.payjoinManager,
|
||||
super.initialAddresses,
|
||||
super.initialRegularAddressIndex,
|
||||
super.initialChangeAddressIndex,
|
||||
|
@ -25,6 +29,15 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
|
|||
super.masterHd,
|
||||
}) : super(walletInfo);
|
||||
|
||||
final PayjoinManager payjoinManager;
|
||||
|
||||
@observable
|
||||
payjoin.Receiver? currentPayjoinReceiver;
|
||||
|
||||
@computed
|
||||
String? get payjoinEndpoint =>
|
||||
currentPayjoinReceiver?.pjUriBuilder().build().pjEndpoint();
|
||||
|
||||
@override
|
||||
String getAddress(
|
||||
{required int index,
|
||||
|
@ -45,4 +58,17 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S
|
|||
|
||||
return generateP2WPKHAddress(hd: hd, index: index, network: network);
|
||||
}
|
||||
|
||||
Future<void> initPayjoin() async {
|
||||
currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress);
|
||||
|
||||
payjoinManager.resumeSessions();
|
||||
}
|
||||
|
||||
Future<void> newPayjoinReceiver() async {
|
||||
currentPayjoinReceiver = await payjoinManager.initReceiver(primaryAddress);
|
||||
|
||||
printV("Initializing new Payjoin Receiver");
|
||||
payjoinManager.spawnNewReceiver(receiver: currentPayjoinReceiver!);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart';
|
|||
import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart';
|
||||
import 'package:cw_core/encryption_file_utils.dart';
|
||||
import 'package:cw_core/payjoin_session.dart';
|
||||
import 'package:cw_core/unspent_coins_info.dart';
|
||||
import 'package:cw_core/wallet_base.dart';
|
||||
import 'package:cw_core/wallet_service.dart';
|
||||
|
@ -21,10 +22,12 @@ class BitcoinWalletService extends WalletService<
|
|||
BitcoinRestoreWalletFromSeedCredentials,
|
||||
BitcoinRestoreWalletFromWIFCredentials,
|
||||
BitcoinRestoreWalletFromHardware> {
|
||||
BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource, this.alwaysScan, this.isDirect);
|
||||
BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource,
|
||||
this.payjoinSessionSource, this.alwaysScan, this.isDirect);
|
||||
|
||||
final Box<WalletInfo> walletInfoSource;
|
||||
final Box<UnspentCoinsInfo> unspentCoinsInfoSource;
|
||||
final Box<PayjoinSession> payjoinSessionSource;
|
||||
final bool alwaysScan;
|
||||
final bool isDirect;
|
||||
|
||||
|
@ -55,6 +58,7 @@ class BitcoinWalletService extends WalletService<
|
|||
passphrase: credentials.passphrase,
|
||||
walletInfo: credentials.walletInfo!,
|
||||
unspentCoinsInfo: unspentCoinsInfoSource,
|
||||
payjoinBox: payjoinSessionSource,
|
||||
network: network,
|
||||
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
|
||||
);
|
||||
|
@ -79,6 +83,7 @@ class BitcoinWalletService extends WalletService<
|
|||
name: name,
|
||||
walletInfo: walletInfo,
|
||||
unspentCoinsInfo: unspentCoinsInfoSource,
|
||||
payjoinBox: payjoinSessionSource,
|
||||
alwaysScan: alwaysScan,
|
||||
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
|
||||
);
|
||||
|
@ -92,6 +97,7 @@ class BitcoinWalletService extends WalletService<
|
|||
name: name,
|
||||
walletInfo: walletInfo,
|
||||
unspentCoinsInfo: unspentCoinsInfoSource,
|
||||
payjoinBox: payjoinSessionSource,
|
||||
alwaysScan: alwaysScan,
|
||||
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
|
||||
);
|
||||
|
@ -126,6 +132,7 @@ class BitcoinWalletService extends WalletService<
|
|||
name: currentName,
|
||||
walletInfo: currentWalletInfo,
|
||||
unspentCoinsInfo: unspentCoinsInfoSource,
|
||||
payjoinBox: payjoinSessionSource,
|
||||
alwaysScan: alwaysScan,
|
||||
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
|
||||
);
|
||||
|
@ -147,7 +154,6 @@ class BitcoinWalletService extends WalletService<
|
|||
credentials.walletInfo?.network = network.value;
|
||||
credentials.walletInfo?.derivationInfo?.derivationPath =
|
||||
credentials.hwAccountData.derivationPath;
|
||||
|
||||
final wallet = await BitcoinWallet(
|
||||
password: credentials.password!,
|
||||
xpub: credentials.hwAccountData.xpub,
|
||||
|
@ -155,6 +161,7 @@ class BitcoinWalletService extends WalletService<
|
|||
unspentCoinsInfo: unspentCoinsInfoSource,
|
||||
networkParam: network,
|
||||
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
|
||||
payjoinBox: payjoinSessionSource,
|
||||
);
|
||||
await wallet.save();
|
||||
await wallet.init();
|
||||
|
@ -182,6 +189,7 @@ class BitcoinWalletService extends WalletService<
|
|||
mnemonic: credentials.mnemonic,
|
||||
walletInfo: credentials.walletInfo!,
|
||||
unspentCoinsInfo: unspentCoinsInfoSource,
|
||||
payjoinBox: payjoinSessionSource,
|
||||
network: network,
|
||||
encryptionFileUtils: encryptionFileUtilsFor(isDirect),
|
||||
);
|
||||
|
|
|
@ -1188,6 +1188,7 @@ abstract class ElectrumWalletBase
|
|||
isSendAll: estimatedTx.isSendAll,
|
||||
hasTaprootInputs: hasTaprootInputs,
|
||||
utxos: estimatedTx.utxos,
|
||||
publicKeys: estimatedTx.publicKeys
|
||||
)..addListener((transaction) async {
|
||||
transactionHistory.addOne(transaction);
|
||||
if (estimatedTx.spendsSilentPayment) {
|
||||
|
@ -1965,6 +1966,11 @@ abstract class ElectrumWalletBase
|
|||
}
|
||||
}
|
||||
|
||||
bool isMine(Script script) {
|
||||
final derivedAddress = addressFromOutputScript(script, network);
|
||||
return addressesSet.contains(derivedAddress);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
|
||||
try {
|
||||
|
|
298
cw_bitcoin/lib/payjoin/manager.dart
Normal file
298
cw_bitcoin/lib/payjoin/manager.dart
Normal file
|
@ -0,0 +1,298 @@
|
|||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bitcoin_base/bitcoin_base.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_wallet.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_wallet_addresses.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_bitcoin/psbt/utils.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/send.dart';
|
||||
import 'package:payjoin_flutter/uri.dart' as PayjoinUri;
|
||||
|
||||
class PayjoinManager {
|
||||
PayjoinManager(this._payjoinStorage, this._wallet);
|
||||
|
||||
final PayjoinStorage _payjoinStorage;
|
||||
final BitcoinWalletBase _wallet;
|
||||
final Map<String, PayjoinPollerSession> _activePollers = {};
|
||||
|
||||
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) {
|
||||
printV("Resuming Payjoin Sender Session ${session.pjUri!}");
|
||||
return _spawnSender(
|
||||
sender: Sender.fromJson(session.sender!),
|
||||
pjUri: session.pjUri!,
|
||||
);
|
||||
}
|
||||
final receiver = Receiver.fromJson(session.receiver!);
|
||||
printV("Resuming Payjoin Receiver Session ${receiver.id()}");
|
||||
return _spawnReceiver(receiver: receiver);
|
||||
});
|
||||
|
||||
printV("Resumed ${spawnedSessions.length} Payjoin Sessions");
|
||||
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,
|
||||
required BigInt amount,
|
||||
bool isTestnet = false,
|
||||
}) async {
|
||||
final pjUri = Uri.parse(pjUrl).queryParameters['pj']!;
|
||||
await _payjoinStorage.insertSenderSession(
|
||||
sender, pjUri, _wallet.id, amount);
|
||||
|
||||
return _spawnSender(isTestnet: isTestnet, sender: sender, pjUri: pjUri);
|
||||
}
|
||||
|
||||
Future<void> _spawnSender({
|
||||
required Sender sender,
|
||||
required String pjUri,
|
||||
bool isTestnet = false,
|
||||
}) async {
|
||||
final completer = Completer();
|
||||
final receivePort = ReceivePort();
|
||||
|
||||
receivePort.listen((message) async {
|
||||
if (message is Map<String, dynamic>) {
|
||||
try {
|
||||
switch (message['type'] as PayjoinSenderRequestTypes) {
|
||||
case PayjoinSenderRequestTypes.requestPosted:
|
||||
return;
|
||||
case PayjoinSenderRequestTypes.psbtToSign:
|
||||
final proposalPsbt = message['psbt'] as String;
|
||||
final utxos = _wallet.getUtxoWithPrivateKeys();
|
||||
final finalizedPsbt = await _wallet.signPsbt(proposalPsbt, utxos);
|
||||
final txId = getTxIdFromPsbtV0(finalizedPsbt);
|
||||
_wallet.commitPsbt(finalizedPsbt);
|
||||
|
||||
_cleanupSession(pjUri);
|
||||
await _payjoinStorage.markSenderSessionComplete(pjUri, txId);
|
||||
completer.complete();
|
||||
}
|
||||
} catch (e) {
|
||||
_cleanupSession(pjUri);
|
||||
printV(e);
|
||||
await _payjoinStorage.markSenderSessionUnrecoverable(pjUri);
|
||||
completer.completeError(e);
|
||||
}
|
||||
} else if (message is PayjoinSessionError) {
|
||||
_cleanupSession(pjUri);
|
||||
if (message is UnrecoverableError) {
|
||||
printV(message.message);
|
||||
await _payjoinStorage.markSenderSessionUnrecoverable(pjUri);
|
||||
completer.complete();
|
||||
} else if (message is RecoverableError) {
|
||||
completer.complete();
|
||||
} else {
|
||||
completer.completeError(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final isolate = await Isolate.spawn(
|
||||
PayjoinSenderWorker.run,
|
||||
[receivePort.sendPort, sender.toJson(), pjUri],
|
||||
);
|
||||
|
||||
_activePollers[pjUri] = PayjoinPollerSession(isolate, receivePort);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<Receiver> initReceiver(String address,
|
||||
[bool isTestnet = false]) async {
|
||||
try {
|
||||
final payjoinDirectory =
|
||||
await PayjoinUri.Url.fromStr(payjoinDirectoryUrl);
|
||||
|
||||
final ohttpKeys = await PayjoinUri.fetchOhttpKeys(
|
||||
ohttpRelay: await randomOhttpRelayUrl(),
|
||||
payjoinDirectory: payjoinDirectory,
|
||||
);
|
||||
|
||||
final receiver = await Receiver.create(
|
||||
address: address,
|
||||
network: isTestnet ? Network.testnet : Network.bitcoin,
|
||||
directory: payjoinDirectory,
|
||||
ohttpKeys: ohttpKeys,
|
||||
ohttpRelay: await randomOhttpRelayUrl(),
|
||||
);
|
||||
|
||||
await _payjoinStorage.insertReceiverSession(receiver, _wallet.id);
|
||||
|
||||
return receiver;
|
||||
} catch (e) {
|
||||
throw Exception('Error initializing Payjoin Receiver: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> spawnNewReceiver({
|
||||
required Receiver receiver,
|
||||
bool isTestnet = false,
|
||||
}) async {
|
||||
await _payjoinStorage.insertReceiverSession(receiver, _wallet.id);
|
||||
return _spawnReceiver(isTestnet: isTestnet, receiver: receiver);
|
||||
}
|
||||
|
||||
Future<void> _spawnReceiver({
|
||||
required Receiver receiver,
|
||||
bool isTestnet = false,
|
||||
}) async {
|
||||
final completer = Completer();
|
||||
final receivePort = ReceivePort();
|
||||
|
||||
SendPort? mainToIsolateSendPort;
|
||||
List<UtxoWithPrivateKey> utxos = [];
|
||||
String rawAmount = '0';
|
||||
|
||||
receivePort.listen((message) async {
|
||||
if (message is Map<String, dynamic>) {
|
||||
try {
|
||||
switch (message['type'] as PayjoinReceiverRequestTypes) {
|
||||
case PayjoinReceiverRequestTypes.processOriginalTx:
|
||||
final tx = message['tx'] as String;
|
||||
rawAmount = getOutputAmountFromTx(tx, _wallet);
|
||||
break;
|
||||
case PayjoinReceiverRequestTypes.checkIsOwned:
|
||||
(_wallet.walletAddresses as BitcoinWalletAddresses).newPayjoinReceiver();
|
||||
_payjoinStorage.markReceiverSessionInProgress(receiver.id());
|
||||
|
||||
final inputScript = message['input_script'] as Uint8List;
|
||||
final isOwned =
|
||||
_wallet.isMine(Script.fromRaw(byteData: inputScript));
|
||||
mainToIsolateSendPort?.send({
|
||||
'requestId': message['requestId'],
|
||||
'result': isOwned,
|
||||
});
|
||||
break;
|
||||
|
||||
case PayjoinReceiverRequestTypes.checkIsReceiverOutput:
|
||||
final outputScript = message['output_script'] as Uint8List;
|
||||
final isReceiverOutput =
|
||||
_wallet.isMine(Script.fromRaw(byteData: outputScript));
|
||||
mainToIsolateSendPort?.send({
|
||||
'requestId': message['requestId'],
|
||||
'result': isReceiverOutput,
|
||||
});
|
||||
break;
|
||||
|
||||
case PayjoinReceiverRequestTypes.getCandidateInputs:
|
||||
utxos = _wallet.getUtxoWithPrivateKeys();
|
||||
mainToIsolateSendPort?.send({
|
||||
'requestId': message['requestId'],
|
||||
'result': utxos,
|
||||
});
|
||||
break;
|
||||
|
||||
case PayjoinReceiverRequestTypes.processPsbt:
|
||||
final psbt = message['psbt'] as String;
|
||||
final signedPsbt = await _wallet.signPsbt(psbt, utxos);
|
||||
mainToIsolateSendPort?.send({
|
||||
'requestId': message['requestId'],
|
||||
'result': signedPsbt,
|
||||
});
|
||||
break;
|
||||
|
||||
case PayjoinReceiverRequestTypes.proposalSent:
|
||||
_cleanupSession(receiver.id());
|
||||
final psbt = message['psbt'] as String;
|
||||
await _payjoinStorage.markReceiverSessionComplete(
|
||||
receiver.id(), getTxIdFromPsbtV0(psbt), rawAmount);
|
||||
completer.complete();
|
||||
}
|
||||
} catch (e) {
|
||||
_cleanupSession(receiver.id());
|
||||
await _payjoinStorage.markReceiverSessionUnrecoverable(
|
||||
receiver.id(), e.toString());
|
||||
completer.completeError(e);
|
||||
}
|
||||
} else if (message is PayjoinSessionError) {
|
||||
_cleanupSession(receiver.id());
|
||||
if (message is UnrecoverableError) {
|
||||
await _payjoinStorage.markReceiverSessionUnrecoverable(
|
||||
receiver.id(), message.message);
|
||||
completer.complete();
|
||||
} else if (message is RecoverableError) {
|
||||
completer.complete();
|
||||
} else {
|
||||
completer.completeError(message);
|
||||
}
|
||||
} else if (message is SendPort) {
|
||||
mainToIsolateSendPort = message;
|
||||
}
|
||||
});
|
||||
|
||||
final isolate = await Isolate.spawn(
|
||||
PayjoinReceiverWorker.run,
|
||||
[receivePort.sendPort, receiver.toJson()],
|
||||
);
|
||||
|
||||
_activePollers[receiver.id()] = PayjoinPollerSession(isolate, receivePort);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void cleanupSessions() {
|
||||
final sessionIds = _activePollers.keys.toList();
|
||||
for (final sessionId in sessionIds) {
|
||||
_cleanupSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanupSession(String sessionId) {
|
||||
_activePollers[sessionId]?.close();
|
||||
_activePollers.remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
class PayjoinPollerSession {
|
||||
final Isolate isolate;
|
||||
final ReceivePort port;
|
||||
|
||||
PayjoinPollerSession(this.isolate, this.port);
|
||||
|
||||
void close() {
|
||||
isolate.kill();
|
||||
port.close();
|
||||
}
|
||||
}
|
219
cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart
Normal file
219
cw_bitcoin/lib/payjoin/payjoin_receive_worker.dart
Normal file
|
@ -0,0 +1,219 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:blockchain_utils/blockchain_utils.dart';
|
||||
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:http/http.dart' as http;
|
||||
import 'package:payjoin_flutter/bitcoin_ffi.dart';
|
||||
import 'package:payjoin_flutter/common.dart';
|
||||
import 'package:payjoin_flutter/receive.dart';
|
||||
import 'package:payjoin_flutter/src/generated/frb_generated.dart' as pj;
|
||||
|
||||
enum PayjoinReceiverRequestTypes {
|
||||
processOriginalTx,
|
||||
proposalSent,
|
||||
getCandidateInputs,
|
||||
checkIsOwned,
|
||||
checkIsReceiverOutput,
|
||||
processPsbt;
|
||||
}
|
||||
|
||||
class PayjoinReceiverWorker {
|
||||
final SendPort sendPort;
|
||||
final pendingRequests = <String, Completer<dynamic>>{};
|
||||
|
||||
PayjoinReceiverWorker._(this.sendPort);
|
||||
|
||||
static Future<void> run(List<Object> args) async {
|
||||
await pj.core.init();
|
||||
|
||||
final sendPort = args[0] as SendPort;
|
||||
final receiverJson = args[1] as String;
|
||||
|
||||
final worker = PayjoinReceiverWorker._(sendPort);
|
||||
final receivePort = ReceivePort();
|
||||
|
||||
sendPort.send(receivePort.sendPort);
|
||||
receivePort.listen(worker.handleMessage);
|
||||
|
||||
try {
|
||||
final httpClient = http.Client();
|
||||
final receiver = Receiver.fromJson(receiverJson);
|
||||
|
||||
final uncheckedProposal =
|
||||
await worker.receiveUncheckedProposal(httpClient, receiver);
|
||||
|
||||
final originalTx = await uncheckedProposal.extractTxToScheduleBroadcast();
|
||||
sendPort.send({
|
||||
'type': PayjoinReceiverRequestTypes.processOriginalTx,
|
||||
'tx': BytesUtils.toHexString(originalTx),
|
||||
});
|
||||
|
||||
final payjoinProposal = await worker.processPayjoinProposal(
|
||||
uncheckedProposal,
|
||||
);
|
||||
final psbt = await worker.sendFinalProposal(httpClient, payjoinProposal);
|
||||
sendPort.send({
|
||||
'type': PayjoinReceiverRequestTypes.proposalSent,
|
||||
'psbt': psbt,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e is HttpException ||
|
||||
(e is http.ClientException &&
|
||||
e.message.contains("Software caused connection abort"))) {
|
||||
sendPort.send(PayjoinSessionError.recoverable(e.toString()));
|
||||
} else {
|
||||
sendPort.send(PayjoinSessionError.unrecoverable(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void handleMessage(dynamic message) async {
|
||||
if (message is Map<String, dynamic>) {
|
||||
final requestId = message['requestId'] as String?;
|
||||
if (requestId != null && pendingRequests.containsKey(requestId)) {
|
||||
pendingRequests[requestId]!.complete(message['result']);
|
||||
pendingRequests.remove(requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> _sendRequest(PayjoinReceiverRequestTypes type,
|
||||
[Map<String, dynamic> data = const {}]) async {
|
||||
final completer = Completer<dynamic>();
|
||||
final requestId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
pendingRequests[requestId] = completer;
|
||||
|
||||
sendPort.send({
|
||||
...data,
|
||||
'type': type,
|
||||
'requestId': requestId,
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<UncheckedProposal> receiveUncheckedProposal(
|
||||
http.Client httpClient, Receiver session) async {
|
||||
while (true) {
|
||||
printV("Polling for Proposal (${session.id()})");
|
||||
final extractReq = await session.extractReq();
|
||||
final request = extractReq.$1;
|
||||
|
||||
final url = Uri.parse(request.url.asString());
|
||||
final httpRequest = await httpClient.post(url,
|
||||
headers: {'Content-Type': request.contentType}, body: request.body);
|
||||
|
||||
final proposal = await session.processRes(
|
||||
body: httpRequest.bodyBytes, ctx: extractReq.$2);
|
||||
if (proposal != null) return proposal;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> sendFinalProposal(
|
||||
http.Client httpClient, PayjoinProposal finalProposal) async {
|
||||
final req = await finalProposal.extractV2Req();
|
||||
final proposalReq = req.$1;
|
||||
final proposalCtx = req.$2;
|
||||
|
||||
final request = await httpClient.post(
|
||||
Uri.parse(proposalReq.url.asString()),
|
||||
headers: {"Content-Type": proposalReq.contentType},
|
||||
body: proposalReq.body,
|
||||
);
|
||||
|
||||
await finalProposal.processRes(
|
||||
res: request.bodyBytes,
|
||||
ohttpContext: proposalCtx,
|
||||
);
|
||||
|
||||
return await finalProposal.psbt();
|
||||
}
|
||||
|
||||
Future<PayjoinProposal> processPayjoinProposal(
|
||||
UncheckedProposal proposal) async {
|
||||
await proposal.extractTxToScheduleBroadcast();
|
||||
// TODO Handle this. send to the main port on a timer?
|
||||
|
||||
try {
|
||||
// Receive Check 1: can broadcast
|
||||
final pj1 = await proposal.assumeInteractiveReceiver();
|
||||
|
||||
// Receive Check 2: original PSBT has no receiver-owned inputs
|
||||
final pj2 = await pj1.checkInputsNotOwned(
|
||||
isOwned: (inputScript) async {
|
||||
final result = await _sendRequest(
|
||||
PayjoinReceiverRequestTypes.checkIsOwned,
|
||||
{'input_script': inputScript},
|
||||
);
|
||||
return result as bool;
|
||||
},
|
||||
);
|
||||
// Receive Check 3: sender inputs have not been seen before (prevent probing attacks)
|
||||
final pj3 = await pj2.checkNoInputsSeenBefore(isKnown: (input) => false);
|
||||
|
||||
// Identify receiver outputs
|
||||
final pj4 = await pj3.identifyReceiverOutputs(
|
||||
isReceiverOutput: (outputScript) async {
|
||||
final result = await _sendRequest(
|
||||
PayjoinReceiverRequestTypes.checkIsReceiverOutput,
|
||||
{'output_script': outputScript},
|
||||
);
|
||||
return result as bool;
|
||||
},
|
||||
);
|
||||
final pj5 = await pj4.commitOutputs();
|
||||
|
||||
final listUnspent =
|
||||
await _sendRequest(PayjoinReceiverRequestTypes.getCandidateInputs);
|
||||
final unspent = listUnspent as List<UtxoWithPrivateKey>;
|
||||
if (unspent.isEmpty) throw Exception('No unspent outputs available');
|
||||
|
||||
final selectedUtxo = await _inputPairFromUtxo(unspent[0]);
|
||||
final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]);
|
||||
final pj7 = await pj6.commitInputs();
|
||||
|
||||
// Finalize proposal
|
||||
final payjoinProposal = await pj7.finalizeProposal(
|
||||
processPsbt: (String psbt) async {
|
||||
final result = await _sendRequest(
|
||||
PayjoinReceiverRequestTypes.processPsbt, {'psbt': psbt});
|
||||
return result as String;
|
||||
},
|
||||
// TODO set maxFeeRateSatPerVb
|
||||
maxFeeRateSatPerVb: BigInt.from(10000),
|
||||
);
|
||||
return payjoinProposal;
|
||||
} catch (e) {
|
||||
printV('Error occurred while finalizing proposal: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<InputPair> _inputPairFromUtxo(UtxoWithPrivateKey utxo) async {
|
||||
final txout = TxOut(
|
||||
value: utxo.utxo.value,
|
||||
scriptPubkey: Uint8List.fromList(
|
||||
utxo.ownerDetails.address.toScriptPubKey().toBytes()),
|
||||
);
|
||||
|
||||
final psbtin =
|
||||
PsbtInput(witnessUtxo: txout, redeemScript: null, witnessScript: null);
|
||||
|
||||
final previousOutput =
|
||||
OutPoint(txid: utxo.utxo.txHash, vout: utxo.utxo.vout);
|
||||
|
||||
final txin = TxIn(
|
||||
previousOutput: previousOutput,
|
||||
scriptSig: await Script.newInstance(rawOutputScript: []),
|
||||
witness: [],
|
||||
sequence: 0,
|
||||
);
|
||||
|
||||
return InputPair.newInstance(txin, psbtin);
|
||||
}
|
||||
}
|
119
cw_bitcoin/lib/payjoin/payjoin_send_worker.dart
Normal file
119
cw_bitcoin/lib/payjoin/payjoin_send_worker.dart
Normal file
|
@ -0,0 +1,119 @@
|
|||
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:cw_core/utils/print_verbose.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
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>>{};
|
||||
final String pjUrl;
|
||||
|
||||
PayjoinSenderWorker._(this.sendPort, this.pjUrl);
|
||||
|
||||
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 pjUrl = args[2] as String;
|
||||
|
||||
final sender = Sender.fromJson(senderJson);
|
||||
final worker = PayjoinSenderWorker._(sendPort, pjUrl);
|
||||
|
||||
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 = http.Client();
|
||||
|
||||
try {
|
||||
return await _runSenderV2(sender, httpClient);
|
||||
} catch (e) {
|
||||
printV(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) {
|
||||
printV(e);
|
||||
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, http.Client httpClient) async {
|
||||
try {
|
||||
final postRequest = await sender.extractV2(
|
||||
ohttpProxyUrl: await PayjoinManager.randomOhttpRelayUrl(),
|
||||
);
|
||||
|
||||
final postResult = await _postRequest(httpClient, postRequest.$1);
|
||||
final getContext =
|
||||
await postRequest.$2.processResponse(response: postResult);
|
||||
|
||||
sendPort.send({'type': PayjoinSenderRequestTypes.requestPosted, "pj": pjUrl});
|
||||
|
||||
while (true) {
|
||||
printV('Polling V2 Proposal Request (${pjUrl})');
|
||||
|
||||
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,
|
||||
);
|
||||
printV("$proposalPsbt");
|
||||
if (proposalPsbt != null) return proposalPsbt;
|
||||
}
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to send payjoin using the V1 of the protocol.
|
||||
Future<String> _runSenderV1(Sender sender, http.Client 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(http.Client client, Request req) async {
|
||||
final httpRequest = await client.post(Uri.parse(req.url.asString()),
|
||||
headers: {'Content-Type': req.contentType}, body: req.body);
|
||||
|
||||
return httpRequest.bodyBytes;
|
||||
}
|
||||
}
|
16
cw_bitcoin/lib/payjoin/payjoin_session_errors.dart
Normal file
16
cw_bitcoin/lib/payjoin/payjoin_session_errors.dart
Normal 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._();
|
||||
}
|
95
cw_bitcoin/lib/payjoin/storage.dart
Normal file
95
cw_bitcoin/lib/payjoin/storage.dart
Normal file
|
@ -0,0 +1,95 @@
|
|||
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(this._payjoinSessionSources);
|
||||
|
||||
final Box<PayjoinSession> _payjoinSessionSources;
|
||||
|
||||
static const String _receiverPrefix = 'pj_recv_';
|
||||
static const String _senderPrefix = 'pj_send_';
|
||||
|
||||
Future<void> insertReceiverSession(
|
||||
Receiver receiver,
|
||||
String walletId,
|
||||
) =>
|
||||
_payjoinSessionSources.put(
|
||||
"$_receiverPrefix${receiver.id()}",
|
||||
PayjoinSession(
|
||||
walletId: walletId,
|
||||
receiver: receiver.toJson(),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> markReceiverSessionComplete(
|
||||
String sessionId, String txId, String amount) async {
|
||||
final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!;
|
||||
|
||||
session.status = PayjoinSessionStatus.success.name;
|
||||
session.txId = txId;
|
||||
session.rawAmount = amount;
|
||||
await session.save();
|
||||
}
|
||||
|
||||
Future<void> markReceiverSessionUnrecoverable(
|
||||
String sessionId, String reason) async {
|
||||
final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!;
|
||||
|
||||
session.status = PayjoinSessionStatus.unrecoverable.name;
|
||||
session.error = reason;
|
||||
await session.save();
|
||||
}
|
||||
|
||||
Future<void> markReceiverSessionInProgress(String sessionId) async {
|
||||
final session = _payjoinSessionSources.get("$_receiverPrefix${sessionId}")!;
|
||||
|
||||
session.status = PayjoinSessionStatus.inProgress.name;
|
||||
session.inProgressSince = DateTime.now();
|
||||
await session.save();
|
||||
}
|
||||
|
||||
Future<void> insertSenderSession(
|
||||
Sender sender,
|
||||
String pjUrl,
|
||||
String walletId,
|
||||
BigInt amount,
|
||||
) =>
|
||||
_payjoinSessionSources.put(
|
||||
"$_senderPrefix$pjUrl",
|
||||
PayjoinSession(
|
||||
walletId: walletId,
|
||||
pjUri: pjUrl,
|
||||
sender: sender.toJson(),
|
||||
status: PayjoinSessionStatus.inProgress.name,
|
||||
inProgressSince: DateTime.now(),
|
||||
rawAmount: amount.toString(),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> markSenderSessionComplete(String pjUrl, String txId) async {
|
||||
final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!;
|
||||
|
||||
session.status = PayjoinSessionStatus.success.name;
|
||||
session.txId = txId;
|
||||
await session.save();
|
||||
}
|
||||
|
||||
Future<void> markSenderSessionUnrecoverable(String pjUrl) async {
|
||||
final session = _payjoinSessionSources.get("$_senderPrefix$pjUrl")!;
|
||||
|
||||
session.status = PayjoinSessionStatus.unrecoverable.name;
|
||||
await session.save();
|
||||
}
|
||||
|
||||
List<PayjoinSession> readAllOpenSessions(String walletId) =>
|
||||
_payjoinSessionSources.values
|
||||
.where((session) =>
|
||||
session.walletId == walletId &&
|
||||
![
|
||||
PayjoinSessionStatus.success.name,
|
||||
PayjoinSessionStatus.unrecoverable.name
|
||||
].contains(session.status))
|
||||
.toList();
|
||||
}
|
|
@ -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 {
|
||||
|
|
263
cw_bitcoin/lib/psbt/signer.dart
Normal file
263
cw_bitcoin/lib/psbt/signer.dart
Normal file
|
@ -0,0 +1,263 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bitcoin_base/bitcoin_base.dart';
|
||||
import 'package:blockchain_utils/blockchain_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_address_record.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_unspent.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_wallet.dart';
|
||||
import 'package:cw_bitcoin/utils.dart';
|
||||
import 'package:ledger_bitcoin/psbt.dart';
|
||||
import 'package:ledger_bitcoin/src/utils/buffer_writer.dart';
|
||||
|
||||
extension PsbtSigner on PsbtV2 {
|
||||
Uint8List extractUnsignedTX({bool getSegwit = true}) {
|
||||
final tx = BufferWriter()..writeUInt32(getGlobalTxVersion());
|
||||
|
||||
final isSegwit = getInputWitnessUtxo(0) != null;
|
||||
if (isSegwit && getSegwit) {
|
||||
tx.writeSlice(Uint8List.fromList([0, 1]));
|
||||
}
|
||||
|
||||
final inputCount = getGlobalInputCount();
|
||||
tx.writeVarInt(inputCount);
|
||||
|
||||
for (var i = 0; i < inputCount; i++) {
|
||||
tx
|
||||
..writeSlice(getInputPreviousTxid(i))
|
||||
..writeUInt32(getInputOutputIndex(i))
|
||||
..writeVarSlice(Uint8List(0))
|
||||
..writeUInt32(getInputSequence(i));
|
||||
}
|
||||
|
||||
final outputCount = getGlobalOutputCount();
|
||||
tx.writeVarInt(outputCount);
|
||||
for (var i = 0; i < outputCount; i++) {
|
||||
tx.writeUInt64(getOutputAmount(i));
|
||||
tx.writeVarSlice(getOutputScript(i));
|
||||
}
|
||||
tx.writeUInt32(getGlobalFallbackLocktime() ?? 0);
|
||||
return tx.buffer();
|
||||
}
|
||||
|
||||
Future<void> signWithUTXO(
|
||||
List<UtxoWithPrivateKey> utxos, UTXOSignerCallBack signer,
|
||||
[UTXOGetterCallBack? getTaprootPair]) async {
|
||||
final raw = BytesUtils.toHexString(extractUnsignedTX(getSegwit: false));
|
||||
final tx = BtcTransaction.fromRaw(raw);
|
||||
|
||||
/// when the transaction is taproot and we must use getTaproot transaction
|
||||
/// digest we need all of inputs amounts and owner script pub keys
|
||||
List<BigInt> taprootAmounts = [];
|
||||
List<Script> taprootScripts = [];
|
||||
|
||||
if (utxos.any((e) => e.utxo.isP2tr())) {
|
||||
for (final input in tx.inputs) {
|
||||
final utxo = utxos.firstWhereOrNull(
|
||||
(u) => u.utxo.txHash == input.txId && u.utxo.vout == input.txIndex);
|
||||
|
||||
if (utxo == null) {
|
||||
final trPair = await getTaprootPair!.call(input.txId, input.txIndex);
|
||||
taprootAmounts.add(trPair.value);
|
||||
taprootScripts.add(trPair.script);
|
||||
continue;
|
||||
}
|
||||
taprootAmounts.add(utxo.utxo.value);
|
||||
taprootScripts.add(_findLockingScript(utxo, true));
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < tx.inputs.length; i++) {
|
||||
final utxo = utxos.firstWhereOrNull((e) =>
|
||||
e.utxo.txHash == tx.inputs[i].txId &&
|
||||
e.utxo.vout == tx.inputs[i].txIndex); // ToDo: More robust verify
|
||||
if (utxo == null) continue;
|
||||
|
||||
/// We receive the owner's ScriptPubKey
|
||||
final script = _findLockingScript(utxo, false);
|
||||
|
||||
final int sighash = utxo.utxo.isP2tr()
|
||||
? BitcoinOpCodeConst.TAPROOT_SIGHASH_ALL
|
||||
: BitcoinOpCodeConst.SIGHASH_ALL;
|
||||
|
||||
/// We generate transaction digest for current input
|
||||
final digest = _generateTransactionDigest(
|
||||
script, i, utxo.utxo, tx, taprootAmounts, taprootScripts);
|
||||
|
||||
/// now we need sign the transaction digest
|
||||
final sig = signer(digest, utxo, utxo.privateKey, sighash);
|
||||
|
||||
if (utxo.utxo.isP2tr()) {
|
||||
setInputTapKeySig(i, Uint8List.fromList(BytesUtils.fromHexString(sig)));
|
||||
} else {
|
||||
setInputPartialSig(
|
||||
i,
|
||||
Uint8List.fromList(BytesUtils.fromHexString(utxo.public().toHex())),
|
||||
Uint8List.fromList(BytesUtils.fromHexString(sig)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<int> _generateTransactionDigest(
|
||||
Script scriptPubKeys,
|
||||
int input,
|
||||
BitcoinUtxo utxo,
|
||||
BtcTransaction transaction,
|
||||
List<BigInt> taprootAmounts,
|
||||
List<Script> tapRootPubKeys) {
|
||||
if (utxo.isSegwit()) {
|
||||
if (utxo.isP2tr()) {
|
||||
return transaction.getTransactionTaprootDigset(
|
||||
txIndex: input,
|
||||
scriptPubKeys: tapRootPubKeys,
|
||||
amounts: taprootAmounts,
|
||||
);
|
||||
}
|
||||
return transaction.getTransactionSegwitDigit(
|
||||
txInIndex: input, script: scriptPubKeys, amount: utxo.value);
|
||||
}
|
||||
return transaction.getTransactionDigest(
|
||||
txInIndex: input, script: scriptPubKeys);
|
||||
}
|
||||
|
||||
Script _findLockingScript(UtxoWithAddress utxo, bool isTaproot) {
|
||||
if (utxo.isMultiSig()) {
|
||||
throw Exception("MultiSig is not supported yet");
|
||||
}
|
||||
|
||||
final senderPub = utxo.public();
|
||||
switch (utxo.utxo.scriptType) {
|
||||
case PubKeyAddressType.p2pk:
|
||||
return senderPub.toRedeemScript();
|
||||
case SegwitAddresType.p2wsh:
|
||||
if (isTaproot) {
|
||||
return senderPub.toP2wshAddress().toScriptPubKey();
|
||||
}
|
||||
return senderPub.toP2wshRedeemScript();
|
||||
case P2pkhAddressType.p2pkh:
|
||||
return senderPub.toP2pkhAddress().toScriptPubKey();
|
||||
case SegwitAddresType.p2wpkh:
|
||||
if (isTaproot) {
|
||||
return senderPub.toP2wpkhAddress().toScriptPubKey();
|
||||
}
|
||||
return senderPub.toP2pkhAddress().toScriptPubKey();
|
||||
case SegwitAddresType.p2tr:
|
||||
return senderPub
|
||||
.toTaprootAddress(tweak: utxo.utxo.isSilentPayment != true)
|
||||
.toScriptPubKey();
|
||||
case SegwitAddresType.mweb:
|
||||
return Script(script: []);
|
||||
case P2shAddressType.p2pkhInP2sh:
|
||||
if (isTaproot) {
|
||||
return senderPub.toP2pkhInP2sh().toScriptPubKey();
|
||||
}
|
||||
return senderPub.toP2pkhAddress().toScriptPubKey();
|
||||
case P2shAddressType.p2wpkhInP2sh:
|
||||
if (isTaproot) {
|
||||
return senderPub.toP2wpkhInP2sh().toScriptPubKey();
|
||||
}
|
||||
return senderPub.toP2pkhAddress().toScriptPubKey();
|
||||
case P2shAddressType.p2wshInP2sh:
|
||||
if (isTaproot) {
|
||||
return senderPub.toP2wshInP2sh().toScriptPubKey();
|
||||
}
|
||||
return senderPub.toP2wshRedeemScript();
|
||||
case P2shAddressType.p2pkInP2sh:
|
||||
if (isTaproot) {
|
||||
return senderPub.toP2pkInP2sh().toScriptPubKey();
|
||||
}
|
||||
return senderPub.toRedeemScript();
|
||||
}
|
||||
throw Exception("invalid bitcoin address type");
|
||||
}
|
||||
}
|
||||
|
||||
typedef UTXOSignerCallBack = String Function(List<int> trDigest,
|
||||
UtxoWithAddress utxo, ECPrivate privateKey, int sighash);
|
||||
|
||||
typedef UTXOGetterCallBack = Future<TaprootAmountScriptPair> Function(
|
||||
String txId, int vout);
|
||||
|
||||
class TaprootAmountScriptPair {
|
||||
final BigInt value;
|
||||
final Script script;
|
||||
|
||||
const TaprootAmountScriptPair(this.value, this.script);
|
||||
}
|
||||
|
||||
class UtxoWithPrivateKey extends UtxoWithAddress {
|
||||
final ECPrivate privateKey;
|
||||
|
||||
UtxoWithPrivateKey({
|
||||
required super.utxo,
|
||||
required super.ownerDetails,
|
||||
required this.privateKey,
|
||||
});
|
||||
|
||||
factory UtxoWithPrivateKey.fromUtxo(
|
||||
UtxoWithAddress input, List<ECPrivateInfo> inputPrivateKeyInfos) {
|
||||
ECPrivateInfo? key;
|
||||
|
||||
if (inputPrivateKeyInfos.isEmpty) {
|
||||
throw Exception("No private keys generated.");
|
||||
} else {
|
||||
key = inputPrivateKeyInfos.firstWhereOrNull((element) {
|
||||
final elemPubkey = element.privkey.getPublic().toHex();
|
||||
if (elemPubkey == input.public().toHex()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (key == null) {
|
||||
throw Exception("${input.utxo.txHash} No Key found");
|
||||
}
|
||||
|
||||
return UtxoWithPrivateKey(
|
||||
utxo: input.utxo,
|
||||
ownerDetails: input.ownerDetails,
|
||||
privateKey: key.privkey);
|
||||
}
|
||||
|
||||
factory UtxoWithPrivateKey.fromUnspent(
|
||||
BitcoinUnspent input, BitcoinWalletBase wallet) {
|
||||
final address =
|
||||
RegexUtils.addressTypeFromStr(input.address, BitcoinNetwork.mainnet);
|
||||
|
||||
final newHd =
|
||||
input.bitcoinAddressRecord.isHidden ? wallet.sideHd : wallet.hd;
|
||||
|
||||
ECPrivate privkey;
|
||||
if (input.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
|
||||
final unspentAddress =
|
||||
input.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord;
|
||||
privkey = wallet.walletAddresses.silentAddress!.b_spend.tweakAdd(
|
||||
BigintUtils.fromBytes(
|
||||
BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
privkey = generateECPrivate(
|
||||
hd: newHd,
|
||||
index: input.bitcoinAddressRecord.index,
|
||||
network: BitcoinNetwork.mainnet);
|
||||
}
|
||||
|
||||
return UtxoWithPrivateKey(
|
||||
utxo: BitcoinUtxo(
|
||||
txHash: input.hash,
|
||||
value: BigInt.from(input.value),
|
||||
vout: input.vout,
|
||||
scriptType: input.bitcoinAddressRecord.type,
|
||||
isSilentPayment:
|
||||
input.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord,
|
||||
),
|
||||
ownerDetails: UtxoAddressDetails(
|
||||
publicKey: privkey.getPublic().toHex(),
|
||||
address: address,
|
||||
),
|
||||
privateKey: privkey);
|
||||
}
|
||||
}
|
41
cw_bitcoin/lib/psbt/utils.dart
Normal file
41
cw_bitcoin/lib/psbt/utils.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:bitcoin_base/bitcoin_base.dart';
|
||||
import 'package:blockchain_utils/blockchain_utils.dart';
|
||||
import 'package:cw_bitcoin/bitcoin_wallet.dart';
|
||||
import 'package:cw_bitcoin/psbt/v0_deserialize.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:ledger_bitcoin/psbt.dart';
|
||||
|
||||
String getTxIdFromPsbtV0(String psbt) {
|
||||
final psbtV2 = PsbtV2()..deserializeV0(base64.decode(psbt));
|
||||
|
||||
return BtcTransaction.fromRaw(
|
||||
BytesUtils.toHexString(psbtV2.extractUnsignedTX(false)))
|
||||
.txId();
|
||||
}
|
||||
|
||||
String getOutputAmountFromPsbt(String psbtV0, BitcoinWalletBase wallet) {
|
||||
printV(psbtV0);
|
||||
final psbt = PsbtV2()..deserializeV0(base64.decode(psbtV0));
|
||||
int amount = 0;
|
||||
for (var i = 0; i < psbt.getGlobalOutputCount(); i++) {
|
||||
final script = psbt.getOutputScript(i);
|
||||
if (wallet.isMine(Script.fromRaw(byteData: script))) {
|
||||
amount += psbt.getOutputAmount(i);
|
||||
}
|
||||
}
|
||||
return amount.toString();
|
||||
}
|
||||
|
||||
String getOutputAmountFromTx(String originalTx, BitcoinWalletBase wallet) {
|
||||
final tx = BtcTransaction.fromRaw(originalTx);
|
||||
BigInt amount = BigInt.zero;
|
||||
for (final output in tx.outputs) {
|
||||
if (wallet.isMine(output.scriptPubKey)) {
|
||||
amount += output.amount;
|
||||
}
|
||||
}
|
||||
printV(amount);
|
||||
return amount.toString();
|
||||
}
|
52
cw_bitcoin/lib/psbt/v0_deserialize.dart
Normal file
52
cw_bitcoin/lib/psbt/v0_deserialize.dart
Normal file
|
@ -0,0 +1,52 @@
|
|||
import 'package:bitcoin_base/bitcoin_base.dart';
|
||||
import 'package:blockchain_utils/blockchain_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:ledger_bitcoin/psbt.dart';
|
||||
import 'package:ledger_bitcoin/src/psbt/map_extension.dart';
|
||||
import 'package:ledger_bitcoin/src/utils/buffer_reader.dart';
|
||||
import 'package:ledger_bitcoin/src/utils/uint8list_extension.dart' as ext;
|
||||
|
||||
extension PsbtSigner on PsbtV2 {
|
||||
|
||||
void deserializeV0(Uint8List psbt) {
|
||||
final bufferReader = BufferReader(psbt);
|
||||
if (!listEquals(bufferReader.readSlice(5), Uint8List.fromList([0x70, 0x73, 0x62, 0x74, 0xff]))) {
|
||||
throw Exception("Invalid magic bytes");
|
||||
}
|
||||
while (_readKeyPair(globalMap, bufferReader)) {}
|
||||
|
||||
final tx = BtcTransaction.fromRaw(BytesUtils.toHexString(globalMap['00']!));
|
||||
|
||||
setGlobalInputCount(tx.inputs.length);
|
||||
setGlobalOutputCount(tx.outputs.length);
|
||||
setGlobalTxVersion(Uint8List.fromList(tx.version).readUint32LE(0));
|
||||
|
||||
for (var i = 0; i < getGlobalInputCount(); i++) {
|
||||
inputMaps.insert(i, <String, Uint8List>{});
|
||||
while (_readKeyPair(inputMaps[i], bufferReader)) {}
|
||||
final input = tx.inputs[i];
|
||||
setInputOutputIndex(i, input.txIndex);
|
||||
setInputPreviousTxId(i, Uint8List.fromList(BytesUtils.fromHexString(input.txId).reversed.toList()));
|
||||
setInputSequence(i, Uint8List.fromList(input.sequence).readUint32LE(0));
|
||||
}
|
||||
for (var i = 0; i < getGlobalOutputCount(); i++) {
|
||||
outputMaps.insert(i, <String, Uint8List>{});
|
||||
while (_readKeyPair(outputMaps[i], bufferReader)) {}
|
||||
final output = tx.outputs[i];
|
||||
setOutputAmount(i, output.amount.toInt());
|
||||
setOutputScript(i, Uint8List.fromList(output.scriptPubKey.toBytes()));
|
||||
}
|
||||
}
|
||||
|
||||
bool _readKeyPair(Map<String, Uint8List> map, BufferReader bufferReader) {
|
||||
final keyLen = bufferReader.readVarInt();
|
||||
if (keyLen == 0) return false;
|
||||
|
||||
final keyType = bufferReader.readUInt8();
|
||||
final keyData = bufferReader.readSlice(keyLen - 1);
|
||||
final value = bufferReader.readVarSlice();
|
||||
|
||||
map.set(keyType, keyData, value);
|
||||
return true;
|
||||
}
|
||||
}
|
143
cw_bitcoin/lib/psbt/v0_finalizer.dart
Normal file
143
cw_bitcoin/lib/psbt/v0_finalizer.dart
Normal file
|
@ -0,0 +1,143 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:ledger_bitcoin/src/psbt/constants.dart";
|
||||
import "package:ledger_bitcoin/src/psbt/psbtv2.dart";
|
||||
import "package:ledger_bitcoin/src/utils/buffer_writer.dart";
|
||||
|
||||
/// This roughly implements the "input finalizer" role of BIP370 (PSBTv2
|
||||
/// https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki). However
|
||||
/// the role is documented in BIP174 (PSBTv0
|
||||
/// https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki).
|
||||
///
|
||||
/// Verify that all inputs have a signature, and set inputFinalScriptwitness
|
||||
/// and/or inputFinalScriptSig depending on the type of the spent outputs. Clean
|
||||
/// fields that aren't useful anymore, partial signatures, redeem script and
|
||||
/// derivation paths.
|
||||
///
|
||||
/// @param psbt The psbt with all signatures added as partial sigs, either
|
||||
/// through PSBT_IN_PARTIAL_SIG or PSBT_IN_TAP_KEY_SIG
|
||||
extension InputFinalizer on PsbtV2 {
|
||||
void finalizeV0() {
|
||||
|
||||
// First check that each input has a signature
|
||||
for (var i = 0; i < getGlobalInputCount(); i++) {
|
||||
if (_isFinalized(i)) continue;
|
||||
|
||||
final legacyPubkeys = getInputKeyDatas(i, PSBTIn.partialSig);
|
||||
final taprootSig = getInputTapKeySig(i);
|
||||
if (legacyPubkeys.isEmpty && taprootSig == null) {
|
||||
continue;
|
||||
// throw Exception('No signature for input $i present');
|
||||
}
|
||||
if (legacyPubkeys.isNotEmpty) {
|
||||
if (legacyPubkeys.length > 1) {
|
||||
throw Exception(
|
||||
'Expected exactly one signature, got ${legacyPubkeys.length}');
|
||||
}
|
||||
if (taprootSig != null) {
|
||||
throw Exception('Both taproot and non-taproot signatures present.');
|
||||
}
|
||||
|
||||
final isSegwitV0 = getInputWitnessUtxo(i) != null;
|
||||
final redeemScript = getInputRedeemScript(i);
|
||||
final isWrappedSegwit = redeemScript != null;
|
||||
final signature = getInputPartialSig(i, legacyPubkeys[0]);
|
||||
if (signature == null) {
|
||||
throw Exception('Expected partial signature for input $i');
|
||||
}
|
||||
if (isSegwitV0) {
|
||||
final witnessBuf = BufferWriter()
|
||||
..writeVarInt(2)
|
||||
..writeVarInt(signature.length)
|
||||
..writeSlice(signature)
|
||||
..writeVarInt(legacyPubkeys[0].length)
|
||||
..writeSlice(legacyPubkeys[0]);
|
||||
setInputFinalScriptwitness(i, witnessBuf.buffer());
|
||||
if (isWrappedSegwit) {
|
||||
if (redeemScript.isEmpty) {
|
||||
throw Exception(
|
||||
"Expected non-empty redeemscript. Can't finalize intput $i");
|
||||
}
|
||||
final scriptSigBuf = BufferWriter()
|
||||
..writeUInt8(redeemScript.length) // Push redeemScript length
|
||||
..writeSlice(redeemScript);
|
||||
setInputFinalScriptsig(i, scriptSigBuf.buffer());
|
||||
}
|
||||
} else {
|
||||
// Legacy input
|
||||
final scriptSig = BufferWriter();
|
||||
_writePush(scriptSig, signature);
|
||||
_writePush(scriptSig, legacyPubkeys[0]);
|
||||
setInputFinalScriptsig(i, scriptSig.buffer());
|
||||
}
|
||||
} else {
|
||||
// Taproot input
|
||||
final signature = getInputTapKeySig(i);
|
||||
if (signature == null) {
|
||||
throw Exception("No taproot signature found");
|
||||
}
|
||||
if (signature.length != 64 && signature.length != 65) {
|
||||
throw Exception("Unexpected length of schnorr signature.");
|
||||
}
|
||||
final witnessBuf = BufferWriter()
|
||||
..writeVarInt(1)
|
||||
..writeVarSlice(signature);
|
||||
setInputFinalScriptwitness(i, witnessBuf.buffer());
|
||||
}
|
||||
clearFinalizedInput(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes fields that are no longer neccesary from the psbt.
|
||||
///
|
||||
/// Note, the spec doesn't say anything about removing ouput fields
|
||||
/// like PSBT_OUT_BIP32_DERIVATION_PATH and others, so we keep them
|
||||
/// without actually knowing why. I think we should remove them too.
|
||||
void clearFinalizedInput(int inputIndex) {
|
||||
final keyTypes = [
|
||||
PSBTIn.bip32Derivation,
|
||||
PSBTIn.partialSig,
|
||||
PSBTIn.tapBip32Derivation,
|
||||
PSBTIn.tapKeySig,
|
||||
];
|
||||
final witnessUtxoAvailable = getInputWitnessUtxo(inputIndex) != null;
|
||||
final nonWitnessUtxoAvailable = getInputNonWitnessUtxo(inputIndex) != null;
|
||||
if (witnessUtxoAvailable && nonWitnessUtxoAvailable) {
|
||||
// Remove NON_WITNESS_UTXO for segwit v0 as it's only needed while signing.
|
||||
// Segwit v1 doesn't have NON_WITNESS_UTXO set.
|
||||
// See https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki#cite_note-7
|
||||
keyTypes.add(PSBTIn.nonWitnessUTXO);
|
||||
}
|
||||
deleteInputEntries(inputIndex, keyTypes);
|
||||
}
|
||||
|
||||
/// Writes a script push operation to buf, which looks different
|
||||
/// depending on the size of the data. See
|
||||
/// https://en.bitcoin.it/wiki/Script#finalants
|
||||
///
|
||||
/// [buf] the BufferWriter to write to
|
||||
/// [data] the Buffer to be pushed.
|
||||
void _writePush(BufferWriter buf, Uint8List data) {
|
||||
if (data.length <= 75) {
|
||||
buf.writeUInt8(data.length);
|
||||
} else if (data.length <= 256) {
|
||||
buf.writeUInt8(76);
|
||||
buf.writeUInt8(data.length);
|
||||
} else if (data.length <= 256 * 256) {
|
||||
buf.writeUInt8(77);
|
||||
final b = ByteData(2)..setUint16(0, data.length, Endian.little);
|
||||
buf.writeSlice(b.buffer.asUint8List());
|
||||
}
|
||||
buf.writeSlice(data);
|
||||
}
|
||||
|
||||
bool _isFinalized(int i) {
|
||||
if (getInputFinalScriptsig(i) != null) return true;
|
||||
try {
|
||||
getInputFinalScriptwitness(i);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -125,6 +125,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
build_cli_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_cli_annotations
|
||||
sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -377,6 +385,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
flutter_rust_bridge:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_rust_bridge
|
||||
sha256: "3292ad6085552987b8b3b9a7e5805567f4013372d302736b702801acb001ee00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
@ -395,6 +411,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -559,7 +583,7 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/ledger-bitcoin"
|
||||
ref: HEAD
|
||||
ref: trunk
|
||||
resolved-ref: e93254f3ff3f996fb91f65a1e7ceffb9f510b4c8
|
||||
url: "https://github.com/cake-tech/ledger-flutter-plus-plugins"
|
||||
source: git
|
||||
|
@ -726,6 +750,15 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
payjoin_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "6a3eb32fb9467ac12e7b75d3de47de4ca44fd88c"
|
||||
resolved-ref: "6a3eb32fb9467ac12e7b75d3de47de4ca44fd88c"
|
||||
url: "https://github.com/konstantinullrich/payjoin-flutter"
|
||||
source: git
|
||||
version: "0.21.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -940,6 +973,14 @@ packages:
|
|||
url: "https://github.com/cake-tech/sp_scanner"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1036,6 +1077,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1118,4 +1167,4 @@ packages:
|
|||
version: "2.2.2"
|
||||
sdks:
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
flutter: ">=3.27.4"
|
||||
|
|
|
@ -40,11 +40,16 @@ dependencies:
|
|||
bech32:
|
||||
git:
|
||||
url: https://github.com/cake-tech/bech32.git
|
||||
payjoin_flutter:
|
||||
git:
|
||||
url: https://github.com/konstantinullrich/payjoin-flutter
|
||||
ref: 6a3eb32fb9467ac12e7b75d3de47de4ca44fd88c #cake-v1
|
||||
ledger_flutter_plus: ^1.4.1
|
||||
ledger_bitcoin:
|
||||
git:
|
||||
url: https://github.com/cake-tech/ledger-flutter-plus-plugins
|
||||
path: packages/ledger-bitcoin
|
||||
ref: trunk
|
||||
ledger_litecoin:
|
||||
git:
|
||||
url: https://github.com/cake-tech/ledger-flutter-plus-plugins
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue