diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index a97845a40..f75fee08b 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; abstract class BaseBitcoinAddressRecord { @@ -84,11 +85,7 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { throw ArgumentError('either scriptHash or network must be provided'); } - try { - this.scriptHash = scriptHash ?? BitcoinAddressUtils.scriptHash(address, network: network!); - } catch (_) { - this.scriptHash = ''; - } + this.scriptHash = scriptHash ?? BitcoinAddressUtils.scriptHash(address, network: network!); } factory BitcoinAddressRecord.fromJSON(String jsonSource) { @@ -211,7 +208,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { } class BitcoinReceivedSPAddressRecord extends BitcoinSilentPaymentAddressRecord { - final ECPrivate spendKey; + final String tweak; BitcoinReceivedSPAddressRecord( super.address, { @@ -220,11 +217,32 @@ class BitcoinReceivedSPAddressRecord extends BitcoinSilentPaymentAddressRecord { super.balance = 0, super.name = '', super.isUsed = false, - required this.spendKey, + required this.tweak, super.addressType = SegwitAddresType.p2tr, super.labelHex, }) : super(isHidden: true); + SilentPaymentOwner getSPWallet( + List silentPaymentsWallets, [ + BasedUtxoNetwork network = BitcoinNetwork.mainnet, + ]) { + final spAddress = silentPaymentsWallets.firstWhere( + (wallet) => wallet.toAddress(network) == this.address, + orElse: () => throw ArgumentError('SP wallet not found'), + ); + + return spAddress; + } + + ECPrivate getSpendKey( + List silentPaymentsWallets, [ + BasedUtxoNetwork network = BitcoinNetwork.mainnet, + ]) { + return getSPWallet(silentPaymentsWallets, network) + .b_spend + .tweakAdd(BigintUtils.fromBytes(BytesUtils.fromHexString(tweak))); + } + factory BitcoinReceivedSPAddressRecord.fromJSON(String jsonSource, {BasedUtxoNetwork? network}) { final decoded = json.decode(jsonSource) as Map; @@ -236,9 +254,7 @@ class BitcoinReceivedSPAddressRecord extends BitcoinSilentPaymentAddressRecord { name: decoded['name'] as String? ?? '', balance: decoded['balance'] as int? ?? 0, labelHex: decoded['label'] as String?, - spendKey: (decoded['spendKey'] as String?) == null - ? ECPrivate.random() - : ECPrivate.fromHex(decoded['spendKey'] as String), + tweak: decoded['tweak'] as String? ?? '', ); } @@ -252,6 +268,6 @@ class BitcoinReceivedSPAddressRecord extends BitcoinSilentPaymentAddressRecord { 'balance': balance, 'type': addressType.toString(), 'labelHex': labelHex, - 'spend_key': spendKey.toString(), + 'tweak': tweak, }); } diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 1846f225c..ea805b168 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -4,9 +4,11 @@ import 'dart:convert'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; +import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/electrum_worker/methods/methods.dart'; +import 'package:cw_bitcoin/exceptions.dart'; +import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/psbt_transaction_builder.dart'; -// import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; @@ -17,15 +19,14 @@ import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; import 'package:cw_core/crypto_currency.dart'; -// import 'package:cw_core/get_height_by_date.dart'; +import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; -// import 'package:cw_core/wallet_type.dart'; -// import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:ledger_bitcoin/ledger_bitcoin.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; @@ -251,7 +252,9 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { final passphrase = keysData.passphrase; if (mnemonic != null) { - for (final derivation in walletInfo.derivations ?? []) { + final derivations = walletInfo.derivations ?? []; + + for (final derivation in derivations) { if (derivation.description?.contains("SP") ?? false) { continue; } @@ -289,16 +292,18 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { hdWallets[CWBitcoinDerivationType.electrum]!; } - switch (walletInfo.derivationInfo?.derivationType) { - case DerivationType.bip39: - seedBytes = await Bip39SeedGenerator.generateFromString(mnemonic, passphrase); - hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes); - break; - case DerivationType.electrum: - default: - seedBytes = await ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase); - hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); - break; + if (derivations.isEmpty) { + switch (walletInfo.derivationInfo?.derivationType) { + case DerivationType.bip39: + seedBytes = await Bip39SeedGenerator.generateFromString(mnemonic, passphrase); + hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes); + break; + case DerivationType.electrum: + default: + seedBytes = await ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase); + hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes); + break; + } } } @@ -914,4 +919,566 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { super.syncStatusReaction(syncStatus); } } + + @override + Future calcFee({ + required List utxos, + required List outputs, + String? memo, + required int feeRate, + List? inputPrivKeyInfos, + List? vinOutpoints, + }) async => + feeRate * + BitcoinTransactionBuilder.estimateTransactionSize( + utxos: utxos, + outputs: outputs, + network: network, + memo: memo, + inputPrivKeyInfos: inputPrivKeyInfos, + vinOutpoints: vinOutpoints, + ); + + @override + TxCreateUtxoDetails createUTXOS({ + required bool sendAll, + bool paysToSilentPayment = false, + int credentialsAmount = 0, + int? inputsCount, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + }) { + List utxos = []; + List vinOutpoints = []; + List inputPrivKeyInfos = []; + final publicKeys = {}; + int allInputsAmount = 0; + bool spendsSilentPayment = false; + bool spendsUnconfirmedTX = false; + + int leftAmount = credentialsAmount; + var availableInputs = unspentCoins.where((utx) { + if (!utx.isSending || utx.isFrozen) { + return false; + } + return true; + }).toList(); + final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); + + for (int i = 0; i < availableInputs.length; i++) { + final utx = availableInputs[i]; + if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; + + if (paysToSilentPayment) { + // Check inputs for shared secret derivation + if (utx.bitcoinAddressRecord.addressType == SegwitAddresType.p2wsh) { + throw BitcoinTransactionSilentPaymentsNotSupported(); + } + } + + allInputsAmount += utx.value; + leftAmount = leftAmount - utx.value; + + final address = RegexUtils.addressTypeFromStr(utx.address, network); + ECPrivate? privkey; + bool? isSilentPayment = false; + + if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { + privkey = (utx.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord).getSpendKey( + (walletAddresses as BitcoinWalletAddresses).silentPaymentWallets, + network, + ); + spendsSilentPayment = true; + isSilentPayment = true; + } else if (!isHardwareWallet) { + final addressRecord = (utx.bitcoinAddressRecord as BitcoinAddressRecord); + final path = addressRecord.derivationInfo.derivationPath + .addElem(Bip32KeyIndex( + BitcoinAddressUtils.getAccountFromChange(addressRecord.isChange), + )) + .addElem(Bip32KeyIndex(addressRecord.index)); + + privkey = ECPrivate.fromBip32(bip32: bip32.derive(path)); + } + + vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout)); + String pubKeyHex; + + if (privkey != null) { + inputPrivKeyInfos.add(ECPrivateInfo( + privkey, + address.type == SegwitAddresType.p2tr, + tweak: !isSilentPayment, + )); + + pubKeyHex = privkey.getPublic().toHex(); + } else { + pubKeyHex = walletAddresses.hdWallet + .childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)) + .publicKey + .toHex(); + } + + if (utx.bitcoinAddressRecord is BitcoinAddressRecord) { + final derivationPath = (utx.bitcoinAddressRecord as BitcoinAddressRecord) + .derivationInfo + .derivationPath + .toString(); + publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); + } + + utxos.add( + UtxoWithAddress( + utxo: BitcoinUtxo( + txHash: utx.hash, + value: BigInt.from(utx.value), + vout: utx.vout, + scriptType: BitcoinAddressUtils.getScriptType(address), + isSilentPayment: isSilentPayment, + ), + ownerDetails: UtxoAddressDetails( + publicKey: pubKeyHex, + address: address, + ), + ), + ); + + // sendAll continues for all inputs + if (!sendAll) { + bool amountIsAcquired = leftAmount <= 0; + if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { + break; + } + } + } + + if (utxos.isEmpty) { + throw BitcoinTransactionNoInputsException(); + } + + return TxCreateUtxoDetails( + availableInputs: availableInputs, + unconfirmedCoins: unconfirmedCoins, + utxos: utxos, + vinOutpoints: vinOutpoints, + inputPrivKeyInfos: inputPrivKeyInfos, + publicKeys: publicKeys, + allInputsAmount: allInputsAmount, + spendsSilentPayment: spendsSilentPayment, + spendsUnconfirmedTX: spendsUnconfirmedTX, + ); + } + + @override + Future estimateSendAllTx( + List outputs, + int feeRate, { + String? memo, + bool hasSilentPayment = false, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + }) async { + final utxoDetails = createUTXOS(sendAll: true, paysToSilentPayment: hasSilentPayment); + + int fee = await calcFee( + utxos: utxoDetails.utxos, + outputs: outputs, + memo: memo, + feeRate: feeRate, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + vinOutpoints: utxoDetails.vinOutpoints, + ); + + if (fee == 0) { + throw BitcoinTransactionNoFeeException(); + } + + // Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change + int amount = utxoDetails.allInputsAmount - fee; + + if (amount <= 0) { + throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); + } + + // Attempting to send less than the dust limit + if (isBelowDust(amount)) { + throw BitcoinTransactionNoDustException(); + } + + if (outputs.length == 1) { + outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount)); + } + + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + isSendAll: true, + hasChange: false, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } + + @override + Future estimateTxForAmount( + int credentialsAmount, + List outputs, + int feeRate, { + List updatedOutputs = const [], + int? inputsCount, + String? memo, + bool? useUnconfirmed, + bool hasSilentPayment = false, + UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + }) async { + // Attempting to send less than the dust limit + if (isBelowDust(credentialsAmount)) { + throw BitcoinTransactionNoDustException(); + } + + final utxoDetails = createUTXOS( + sendAll: false, + credentialsAmount: credentialsAmount, + inputsCount: inputsCount, + paysToSilentPayment: hasSilentPayment, + ); + + final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; + final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX && + utxoDetails.utxos.length == + utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; + + // How much is being spent - how much is being sent + int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount; + + if (amountLeftForChangeAndFee <= 0) { + if (!spendingAllCoins) { + return estimateTxForAmount( + credentialsAmount, + outputs, + feeRate, + updatedOutputs: updatedOutputs, + inputsCount: utxoDetails.utxos.length + 1, + memo: memo, + hasSilentPayment: hasSilentPayment, + ); + } + + throw BitcoinTransactionWrongBalanceException(); + } + + final changeAddress = await walletAddresses.getChangeAddress( + inputs: utxoDetails.availableInputs, + outputs: updatedOutputs, + ); + final address = RegexUtils.addressTypeFromStr(changeAddress.address, network); + updatedOutputs.add(BitcoinOutput( + address: address, + value: BigInt.from(amountLeftForChangeAndFee), + isChange: true, + )); + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(amountLeftForChangeAndFee), + isChange: true, + )); + + // Get Derivation path for change Address since it is needed in Litecoin and BitcoinCash hardware Wallets + final changeDerivationPath = changeAddress.derivationInfo.derivationPath.toString(); + utxoDetails.publicKeys[address.pubKeyHash()] = + PublicKeyWithDerivationPath('', changeDerivationPath); + + // calcFee updates the silent payment outputs to calculate the tx size accounting + // for taproot addresses, but if more inputs are needed to make up for fees, + // the silent payment outputs need to be recalculated for the new inputs + var temp = outputs.map((output) => output).toList(); + int fee = await calcFee( + utxos: utxoDetails.utxos, + // Always take only not updated bitcoin outputs here so for every estimation + // the SP outputs are re-generated to the proper taproot addresses + outputs: temp, + memo: memo, + feeRate: feeRate, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + vinOutpoints: utxoDetails.vinOutpoints, + ); + + updatedOutputs.clear(); + updatedOutputs.addAll(temp); + + if (fee == 0) { + throw BitcoinTransactionNoFeeException(); + } + + int amount = credentialsAmount; + final lastOutput = updatedOutputs.last; + final amountLeftForChange = amountLeftForChangeAndFee - fee; + + if (isBelowDust(amountLeftForChange)) { + // If has change that is lower than dust, will end up with tx rejected by network rules + // so remove the change amount + updatedOutputs.removeLast(); + outputs.removeLast(); + + if (amountLeftForChange < 0) { + if (!spendingAllCoins) { + return estimateTxForAmount( + credentialsAmount, + outputs, + feeRate, + updatedOutputs: updatedOutputs, + inputsCount: utxoDetails.utxos.length + 1, + memo: memo, + useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, + hasSilentPayment: hasSilentPayment, + ); + } else { + throw BitcoinTransactionWrongBalanceException(); + } + } + + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + hasChange: false, + isSendAll: spendingAllCoins, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } else { + // Here, lastOutput already is change, return the amount left without the fee to the user's address. + updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( + address: lastOutput.address, + value: BigInt.from(amountLeftForChange), + isSilentPayment: lastOutput.isSilentPayment, + isChange: true, + ); + outputs[outputs.length - 1] = BitcoinOutput( + address: lastOutput.address, + value: BigInt.from(amountLeftForChange), + isSilentPayment: lastOutput.isSilentPayment, + isChange: true, + ); + + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + hasChange: true, + isSendAll: spendingAllCoins, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } + } + + @override + Future createTransaction(Object credentials) async { + try { + final outputs = []; + final transactionCredentials = credentials as BitcoinTransactionCredentials; + final hasMultiDestination = transactionCredentials.outputs.length > 1; + final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; + final memo = transactionCredentials.outputs.first.memo; + final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; + + int credentialsAmount = 0; + bool hasSilentPayment = false; + + for (final out in transactionCredentials.outputs) { + final outputAmount = out.formattedCryptoAmount!; + + if (!sendAll && isBelowDust(outputAmount)) { + throw BitcoinTransactionNoDustException(); + } + + if (hasMultiDestination) { + if (out.sendAll) { + throw BitcoinTransactionWrongBalanceException(); + } + } + + credentialsAmount += outputAmount; + + final address = RegexUtils.addressTypeFromStr( + out.isParsedAddress ? out.extractedAddress! : out.address, network); + final isSilentPayment = address is SilentPaymentAddress; + + if (isSilentPayment) { + hasSilentPayment = true; + } + + if (sendAll) { + // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(0), + isSilentPayment: isSilentPayment, + )); + } else { + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(outputAmount), + isSilentPayment: isSilentPayment, + )); + } + } + + final feeRateInt = transactionCredentials.feeRate != null + ? transactionCredentials.feeRate! + : feeRate(transactionCredentials.priority!); + + EstimatedTxResult estimatedTx; + final updatedOutputs = outputs + .map((e) => BitcoinOutput( + address: e.address, + value: e.value, + isSilentPayment: e.isSilentPayment, + isChange: e.isChange, + )) + .toList(); + + if (sendAll) { + estimatedTx = await estimateSendAllTx( + updatedOutputs, + feeRateInt, + memo: memo, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } else { + estimatedTx = await estimateTxForAmount( + credentialsAmount, + outputs, + feeRateInt, + updatedOutputs: updatedOutputs, + memo: memo, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } + + if (walletInfo.isHardwareWallet) { + final transaction = await buildHardwareWalletTransaction( + utxos: estimatedTx.utxos, + outputs: updatedOutputs, + publicKeys: estimatedTx.publicKeys, + fee: BigInt.from(estimatedTx.fee), + network: network, + memo: estimatedTx.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: true, + ); + + return PendingBitcoinTransaction( + transaction, + type, + sendWorker: sendWorker, + amount: estimatedTx.amount, + fee: estimatedTx.fee, + feeRate: feeRateInt.toString(), + hasChange: estimatedTx.hasChange, + isSendAll: estimatedTx.isSendAll, + hasTaprootInputs: false, // ToDo: (Konsti) Support Taproot + )..addListener((transaction) async { + transactionHistory.addOne(transaction); + await updateBalance(); + }); + } + + final txb = BitcoinTransactionBuilder( + utxos: estimatedTx.utxos, + outputs: updatedOutputs, + fee: BigInt.from(estimatedTx.fee), + network: network, + memo: estimatedTx.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: !estimatedTx.spendsUnconfirmedTX, + ); + + bool hasTaprootInputs = false; + + final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { + String error = "Cannot find private key."; + + ECPrivateInfo? key; + + if (estimatedTx.inputPrivKeyInfos.isEmpty) { + error += "\nNo private keys generated."; + } else { + error += "\nAddress: ${utxo.ownerDetails.address.toAddress(network)}"; + + try { + key = estimatedTx.inputPrivKeyInfos.firstWhere((element) { + final elemPubkey = element.privkey.getPublic().toHex(); + if (elemPubkey == publicKey) { + return true; + } else { + error += "\nExpected: $publicKey"; + error += "\nPubkey: $elemPubkey"; + return false; + } + }); + } catch (_) { + throw Exception(error); + } + } + + if (key == null) { + throw Exception(error); + } + + if (utxo.utxo.isP2tr()) { + hasTaprootInputs = true; + return key.privkey.signTapRoot( + txDigest, + sighash: sighash, + tweak: utxo.utxo.isSilentPayment != true, + ); + } else { + return key.privkey.signInput(txDigest, sigHash: sighash); + } + }); + + return PendingBitcoinTransaction( + transaction, + type, + sendWorker: sendWorker, + amount: estimatedTx.amount, + fee: estimatedTx.fee, + feeRate: feeRateInt.toString(), + hasChange: estimatedTx.hasChange, + isSendAll: estimatedTx.isSendAll, + hasTaprootInputs: hasTaprootInputs, + utxos: estimatedTx.utxos, + hasSilentPayment: hasSilentPayment, + )..addListener((transaction) async { + transactionHistory.addOne(transaction); + if (estimatedTx.spendsSilentPayment) { + transactionHistory.transactions.values.forEach((tx) { + tx.unspents?.removeWhere( + (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash)); + transactionHistory.addOne(tx); + }); + } + + unspentCoins + .removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); + + await updateBalance(); + }); + } catch (e, s) { + print([e, s]); + throw e; + } + } } diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart deleted file mode 100644 index f15a8d548..000000000 --- a/cw_bitcoin/lib/electrum.dart +++ /dev/null @@ -1,621 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; -import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:flutter/foundation.dart'; -import 'package:rxdart/rxdart.dart'; - -String jsonrpc( - {required String method, - required List params, - required int id, - double version = 2.0}) => - '{"jsonrpc": "$version", "method": "$method", "id": "$id", "params": ${json.encode(params)}}\n'; - -class SocketTask { - SocketTask({required this.isSubscription, this.completer, this.subject}); - - final Completer? completer; - final BehaviorSubject? subject; - final bool isSubscription; -} - -class ElectrumClient { - ElectrumClient() - : _id = 0, - _isConnected = false, - _tasks = {}, - _errors = {}, - unterminatedString = ''; - - static const connectionTimeout = Duration(seconds: 5); - static const aliveTimerDuration = Duration(seconds: 4); - - bool get isConnected => _isConnected; - Socket? socket; - void Function(ConnectionStatus)? onConnectionStatusChange; - int _id; - final Map _tasks; - Map get tasks => _tasks; - final Map _errors; - ConnectionStatus _connectionStatus = ConnectionStatus.disconnected; - bool _isConnected; - Timer? _aliveTimer; - String unterminatedString; - - Uri? uri; - bool? useSSL; - - Future connectToUri(Uri uri, {bool? useSSL}) async { - this.uri = uri; - if (useSSL != null) { - this.useSSL = useSSL; - } - await connect(host: uri.host, port: uri.port); - } - - Future connect({required String host, required int port}) async { - _setConnectionStatus(ConnectionStatus.connecting); - - try { - await socket?.close(); - } catch (_) {} - socket = null; - - try { - if (useSSL == false || (useSSL == null && uri.toString().contains("btc-electrum"))) { - socket = await Socket.connect(host, port, timeout: connectionTimeout); - } else { - socket = await SecureSocket.connect( - host, - port, - timeout: connectionTimeout, - onBadCertificate: (_) => true, - ); - } - } catch (e) { - if (e is HandshakeException) { - useSSL = !(useSSL ?? false); - } - - if (_connectionStatus != ConnectionStatus.connecting) { - _setConnectionStatus(ConnectionStatus.failed); - } - - return; - } - - if (socket == null) { - if (_connectionStatus != ConnectionStatus.connecting) { - _setConnectionStatus(ConnectionStatus.failed); - } - - return; - } - - // use ping to determine actual connection status since we could've just not timed out yet: - // _setConnectionStatus(ConnectionStatus.connected); - - socket!.listen( - (Uint8List event) { - try { - final msg = utf8.decode(event.toList()); - final messagesList = msg.split("\n"); - for (var message in messagesList) { - if (message.isEmpty) { - continue; - } - _parseResponse(message); - } - } catch (e) { - printV("socket.listen: $e"); - } - }, - onError: (Object error) { - final errorMsg = error.toString(); - printV(errorMsg); - unterminatedString = ''; - socket = null; - }, - onDone: () { - printV("SOCKET CLOSED!!!!!"); - unterminatedString = ''; - try { - if (host == socket?.address.host || socket == null) { - _setConnectionStatus(ConnectionStatus.disconnected); - socket?.destroy(); - socket = null; - } - } catch (e) { - printV("onDone: $e"); - } - }, - cancelOnError: true, - ); - - keepAlive(); - } - - void _parseResponse(String message) { - try { - final response = json.decode(message) as Map; - _handleResponse(response); - } on FormatException catch (e) { - final msg = e.message.toLowerCase(); - - if (e.source is String) { - unterminatedString += e.source as String; - } - - if (msg.contains("not a subtype of type")) { - unterminatedString += e.source as String; - return; - } - - if (isJSONStringCorrect(unterminatedString)) { - final response = json.decode(unterminatedString) as Map; - _handleResponse(response); - unterminatedString = ''; - } - } on TypeError catch (e) { - if (!e.toString().contains('Map') && - !e.toString().contains('Map')) { - return; - } - - unterminatedString += message; - - if (isJSONStringCorrect(unterminatedString)) { - final response = json.decode(unterminatedString) as Map; - _handleResponse(response); - // unterminatedString = null; - unterminatedString = ''; - } - } catch (e) { - printV("parse $e"); - } - } - - void keepAlive() { - _aliveTimer?.cancel(); - _aliveTimer = Timer.periodic(aliveTimerDuration, (_) async => ping()); - } - - Future ping() async { - try { - await callWithTimeout(method: 'server.ping'); - _setConnectionStatus(ConnectionStatus.connected); - } catch (_) { - _setConnectionStatus(ConnectionStatus.disconnected); - } - } - - Future> version() => - call(method: 'server.version', params: ["", "1.4"]).then((dynamic result) { - if (result is List) { - return result.map((dynamic val) => val.toString()).toList(); - } - - return []; - }); - - Future> getBalance(String scriptHash) => - call(method: 'blockchain.scripthash.get_balance', params: [scriptHash]) - .then((dynamic result) { - if (result is Map) { - return result; - } - - return {}; - }); - - Future>> getHistory(String scriptHash) => - call(method: 'blockchain.scripthash.get_history', params: [scriptHash]) - .then((dynamic result) { - if (result is List) { - return result.map((dynamic val) { - if (val is Map) { - return val; - } - - return {}; - }).toList(); - } - - return []; - }); - - Future>> getListUnspent(String scriptHash) async { - final result = await call(method: 'blockchain.scripthash.listunspent', params: [scriptHash]); - - if (result is List) { - return result.map((dynamic val) { - if (val is Map) { - return val; - } - - return {}; - }).toList(); - } - - return []; - } - - Future>> getMempool(String scriptHash) => - call(method: 'blockchain.scripthash.get_mempool', params: [scriptHash]) - .then((dynamic result) { - if (result is List) { - return result.map((dynamic val) { - if (val is Map) { - return val; - } - - return {}; - }).toList(); - } - - return []; - }); - - Future getTransaction({required String hash, required bool verbose}) async { - try { - final result = await callWithTimeout( - method: 'blockchain.transaction.get', params: [hash, verbose], timeout: 10000); - return result; - } on RequestFailedTimeoutException catch (_) { - return {}; - } catch (e) { - return {}; - } - } - - Future> getTransactionVerbose({required String hash}) => - getTransaction(hash: hash, verbose: true).then((dynamic result) { - if (result is Map) { - return result; - } - - return {}; - }); - - Future getTransactionHex({required String hash}) => - getTransaction(hash: hash, verbose: false).then((dynamic result) { - if (result is String) { - return result; - } - - return ''; - }); - - Future broadcastTransaction( - {required String transactionRaw, - BasedUtxoNetwork? network, - Function(int)? idCallback}) async => - call( - method: 'blockchain.transaction.broadcast', - params: [transactionRaw], - idCallback: idCallback) - .then((dynamic result) { - if (result is String) { - return result; - } - - return ''; - }); - - Future> getMerkle({required String hash, required int height}) async => - await call(method: 'blockchain.transaction.get_merkle', params: [hash, height]) - as Map; - - Future> getHeader({required int height}) async => - await call(method: 'blockchain.block.get_header', params: [height]) as Map; - - BehaviorSubject? tweaksSubscribe({required int height, required int count}) => - subscribe( - id: 'blockchain.tweaks.subscribe', - method: 'blockchain.tweaks.subscribe', - params: [height, count, true], - ); - - Future tweaksRegister({ - required String secViewKey, - required String pubSpendKey, - List labels = const [], - }) => - call( - method: 'blockchain.tweaks.register', - params: [secViewKey, pubSpendKey, labels], - ); - - Future tweaksErase({required String pubSpendKey}) => call( - method: 'blockchain.tweaks.erase', - params: [pubSpendKey], - ); - - BehaviorSubject? tweaksScan({required String pubSpendKey}) => subscribe( - id: 'blockchain.tweaks.scan', - method: 'blockchain.tweaks.scan', - params: [pubSpendKey], - ); - - Future tweaksGet({required String pubSpendKey}) => call( - method: 'blockchain.tweaks.get', - params: [pubSpendKey], - ); - - Future getTweaks({required int height}) async => - await callWithTimeout(method: 'blockchain.tweaks.subscribe', params: [height, 1, false]); - - Future estimatefee({required int p}) => - call(method: 'blockchain.estimatefee', params: [p]).then((dynamic result) { - if (result is double) { - return result; - } - - if (result is String) { - return double.parse(result); - } - - return 0; - }); - - Future>> feeHistogram() => - call(method: 'mempool.get_fee_histogram').then((dynamic result) { - if (result is List) { - // return result.map((dynamic e) { - // if (e is List) { - // return e.map((dynamic ee) => ee is int ? ee : null).toList(); - // } - - // return null; - // }).toList(); - final histogram = >[]; - for (final e in result) { - if (e is List) { - final eee = []; - for (final ee in e) { - if (ee is int) { - eee.add(ee); - } - } - histogram.add(eee); - } - } - return histogram; - } - - return []; - }); - - // Future> feeRates({BasedUtxoNetwork? network}) async { - // try { - // final topDoubleString = await estimatefee(p: 1); - // final middleDoubleString = await estimatefee(p: 5); - // final bottomDoubleString = await estimatefee(p: 10); - // final top = (stringDoubleToBitcoinAmount(topDoubleString.toString()) / 1000).round(); - // final middle = (stringDoubleToBitcoinAmount(middleDoubleString.toString()) / 1000).round(); - // final bottom = (stringDoubleToBitcoinAmount(bottomDoubleString.toString()) / 1000).round(); - - // return [bottom, middle, top]; - // } catch (_) { - // return []; - // } - // } - - // https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe - // example response: - // { - // "height": 520481, - // "hex": "00000020890208a0ae3a3892aa047c5468725846577cfcd9b512b50000000000000000005dc2b02f2d297a9064ee103036c14d678f9afc7e3d9409cf53fd58b82e938e8ecbeca05a2d2103188ce804c4" - // } - - Future getCurrentBlockChainTip() async { - try { - final result = await callWithTimeout(method: 'blockchain.headers.subscribe'); - if (result is Map) { - return result["height"] as int; - } - return null; - } on RequestFailedTimeoutException catch (_) { - return null; - } catch (e) { - printV("getCurrentBlockChainTip: ${e.toString()}"); - return null; - } - } - - BehaviorSubject? chainTipSubscribe() { - _id += 1; - return subscribe( - id: 'blockchain.headers.subscribe', method: 'blockchain.headers.subscribe'); - } - - BehaviorSubject? scripthashUpdate(String scripthash) { - _id += 1; - return subscribe( - id: 'blockchain.scripthash.subscribe:$scripthash', - method: 'blockchain.scripthash.subscribe', - params: [scripthash]); - } - - BehaviorSubject? subscribe( - {required String id, required String method, List params = const []}) { - try { - if (socket == null) { - return null; - } - final subscription = BehaviorSubject(); - _regisrySubscription(id, subscription); - socket!.write(jsonrpc(method: method, id: _id, params: params)); - - return subscription; - } catch (e) { - printV("subscribe $e"); - return null; - } - } - - Future call( - {required String method, List params = const [], Function(int)? idCallback}) async { - if (socket == null) { - return null; - } - final completer = Completer(); - _id += 1; - final id = _id; - idCallback?.call(id); - _registryTask(id, completer); - socket!.write(jsonrpc(method: method, id: id, params: params)); - - return completer.future; - } - - Future callWithTimeout( - {required String method, List params = const [], int timeout = 5000}) async { - try { - if (socket == null) { - return null; - } - final completer = Completer(); - _id += 1; - final id = _id; - _registryTask(id, completer); - socket!.write(jsonrpc(method: method, id: id, params: params)); - Timer(Duration(milliseconds: timeout), () { - if (!completer.isCompleted) { - completer.completeError(RequestFailedTimeoutException(method, id)); - } - }); - - return completer.future; - } catch (e) { - printV("callWithTimeout $e"); - rethrow; - } - } - - Future close() async { - _aliveTimer?.cancel(); - try { - await socket?.close(); - socket = null; - } catch (_) {} - onConnectionStatusChange = null; - } - - void _registryTask(int id, Completer completer) => - _tasks[id.toString()] = SocketTask(completer: completer, isSubscription: false); - - void _regisrySubscription(String id, BehaviorSubject subject) => - _tasks[id] = SocketTask(subject: subject, isSubscription: true); - - void _finish(String id, Object? data) { - if (_tasks[id] == null) { - return; - } - - if (!(_tasks[id]?.completer?.isCompleted ?? false)) { - _tasks[id]?.completer!.complete(data); - } - - if (!(_tasks[id]?.isSubscription ?? false)) { - _tasks.remove(id); - } else { - _tasks[id]?.subject?.add(data); - } - } - - void _methodHandler({required String method, required Map request}) { - switch (method) { - case 'blockchain.headers.subscribe': - final params = request['params'] as List; - final id = 'blockchain.headers.subscribe'; - - _tasks[id]?.subject?.add(params.last); - break; - case 'blockchain.scripthash.subscribe': - final params = request['params'] as List; - final scripthash = params.first as String?; - final id = 'blockchain.scripthash.subscribe:$scripthash'; - - _tasks[id]?.subject?.add(params.last); - break; - case 'blockchain.headers.subscribe': - final params = request['params'] as List; - _tasks[method]?.subject?.add(params.last); - break; - case 'blockchain.tweaks.subscribe': - case 'blockchain.tweaks.scan': - final params = request['params'] as List; - _tasks[_tasks.keys.first]?.subject?.add(params.last); - break; - default: - break; - } - } - - void _setConnectionStatus(ConnectionStatus status) { - onConnectionStatusChange?.call(status); - _connectionStatus = status; - _isConnected = status == ConnectionStatus.connected; - if (!_isConnected) { - try { - socket?.destroy(); - } catch (_) {} - socket = null; - } - } - - void _handleResponse(Map response) { - final method = response['method']; - final id = response['id'] as String?; - final result = response['result']; - - try { - final error = response['error'] as Map?; - if (error != null) { - final errorMessage = error['message'] as String?; - if (errorMessage != null) { - _errors[id!] = errorMessage; - } - } - } catch (_) {} - - try { - final error = response['error'] as String?; - if (error != null) { - _errors[id!] = error; - } - } catch (_) {} - - if (method is String) { - _methodHandler(method: method, request: response); - return; - } - - if (id != null) { - _finish(id, result); - } - } - - String getErrorMessage(int id) => _errors[id.toString()] ?? ''; -} - -// FIXME: move me -bool isJSONStringCorrect(String source) { - try { - json.decode(source); - return true; - } catch (_) { - return false; - } -} - -class RequestFailedTimeoutException implements Exception { - RequestFailedTimeoutException(this.method, this.id); - - final String method; - final int id; -} diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index 43e462f7c..816f30221 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -62,7 +62,7 @@ class ElectrumTransactionInfo extends TransactionInfo { required bool isPending, bool isReplaced = false, required DateTime date, - required int? time, + int? time, bool? isDateValidated, required int confirmations, String? to, diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 0d4308352..e5a4cade5 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -417,7 +417,6 @@ abstract class ElectrumWalletBase _workerIsolate = await Isolate.spawn(ElectrumWorker.run, receivePort!.sendPort); _workerSubscription = receivePort!.listen((message) { - printV('Main: received message: $message'); if (message is SendPort) { workerSendPort = message; workerSendPort!.send( @@ -439,13 +438,12 @@ abstract class ElectrumWalletBase } } - int get _dustAmount => 546; + int get _dustAmount => 0; - bool _isBelowDust(int amount) => amount <= _dustAmount && network != BitcoinNetwork.testnet; + bool isBelowDust(int amount) => amount <= _dustAmount && network != BitcoinNetwork.testnet; - TxCreateUtxoDetails _createUTXOS({ + TxCreateUtxoDetails createUTXOS({ required bool sendAll, - required bool paysToSilentPayment, int credentialsAmount = 0, int? inputsCount, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, @@ -455,7 +453,6 @@ abstract class ElectrumWalletBase List inputPrivKeyInfos = []; final publicKeys = {}; int allInputsAmount = 0; - bool spendsSilentPayment = false; bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; @@ -483,25 +480,13 @@ abstract class ElectrumWalletBase final utx = availableInputs[i]; if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; - if (paysToSilentPayment) { - // Check inputs for shared secret derivation - if (utx.bitcoinAddressRecord.addressType == SegwitAddresType.p2wsh) { - throw BitcoinTransactionSilentPaymentsNotSupported(); - } - } - allInputsAmount += utx.value; leftAmount = leftAmount - utx.value; final address = RegexUtils.addressTypeFromStr(utx.address, network); ECPrivate? privkey; - bool? isSilentPayment = false; - if (utx.bitcoinAddressRecord is BitcoinReceivedSPAddressRecord) { - privkey = (utx.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord).spendKey; - spendsSilentPayment = true; - isSilentPayment = true; - } else if (!isHardwareWallet) { + if (!isHardwareWallet) { final addressRecord = (utx.bitcoinAddressRecord as BitcoinAddressRecord); final path = addressRecord.derivationInfo.derivationPath .addElem(Bip32KeyIndex( @@ -516,11 +501,7 @@ abstract class ElectrumWalletBase String pubKeyHex; if (privkey != null) { - inputPrivKeyInfos.add(ECPrivateInfo( - privkey, - address.type == SegwitAddresType.p2tr, - tweak: !isSilentPayment, - )); + inputPrivKeyInfos.add(ECPrivateInfo(privkey, address.type == SegwitAddresType.p2tr)); pubKeyHex = privkey.getPublic().toHex(); } else { @@ -545,7 +526,6 @@ abstract class ElectrumWalletBase value: BigInt.from(utx.value), vout: utx.vout, scriptType: BitcoinAddressUtils.getScriptType(address), - isSilentPayment: isSilentPayment, ), ownerDetails: UtxoAddressDetails( publicKey: pubKeyHex, @@ -575,7 +555,6 @@ abstract class ElectrumWalletBase inputPrivKeyInfos: inputPrivKeyInfos, publicKeys: publicKeys, allInputsAmount: allInputsAmount, - spendsSilentPayment: spendsSilentPayment, spendsUnconfirmedTX: spendsUnconfirmedTX, ); } @@ -584,14 +563,9 @@ abstract class ElectrumWalletBase List outputs, int feeRate, { String? memo, - bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { - final utxoDetails = _createUTXOS( - sendAll: true, - paysToSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, - ); + final utxoDetails = createUTXOS(sendAll: true, coinTypeToSpendFrom: coinTypeToSpendFrom); int fee = await calcFee( utxos: utxoDetails.utxos, @@ -612,7 +586,7 @@ abstract class ElectrumWalletBase } // Attempting to send less than the dust limit - if (_isBelowDust(amount)) { + if (isBelowDust(amount)) { throw BitcoinTransactionNoDustException(); } @@ -630,31 +604,27 @@ abstract class ElectrumWalletBase hasChange: false, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, - spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } Future estimateTxForAmount( int credentialsAmount, List outputs, - List updatedOutputs, int feeRate, { int? inputsCount, String? memo, bool? useUnconfirmed, - bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { // Attempting to send less than the dust limit - if (_isBelowDust(credentialsAmount)) { + if (isBelowDust(credentialsAmount)) { throw BitcoinTransactionNoDustException(); } - final utxoDetails = _createUTXOS( + final utxoDetails = createUTXOS( sendAll: false, credentialsAmount: credentialsAmount, inputsCount: inputsCount, - paysToSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); @@ -671,11 +641,9 @@ abstract class ElectrumWalletBase return estimateTxForAmount( credentialsAmount, outputs, - updatedOutputs, feeRate, inputsCount: utxoDetails.utxos.length + 1, memo: memo, - hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } @@ -685,14 +653,9 @@ abstract class ElectrumWalletBase final changeAddress = await walletAddresses.getChangeAddress( inputs: utxoDetails.availableInputs, - outputs: updatedOutputs, + outputs: outputs, ); final address = RegexUtils.addressTypeFromStr(changeAddress.address, network); - updatedOutputs.add(BitcoinOutput( - address: address, - value: BigInt.from(amountLeftForChangeAndFee), - isChange: true, - )); outputs.add(BitcoinOutput( address: address, value: BigInt.from(amountLeftForChangeAndFee), @@ -703,34 +666,25 @@ abstract class ElectrumWalletBase utxoDetails.publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath('', changeDerivationPath); - // calcFee updates the silent payment outputs to calculate the tx size accounting - // for taproot addresses, but if more inputs are needed to make up for fees, - // the silent payment outputs need to be recalculated for the new inputs - var temp = outputs.map((output) => output).toList(); int fee = await calcFee( utxos: utxoDetails.utxos, - // Always take only not updated bitcoin outputs here so for every estimation - // the SP outputs are re-generated to the proper taproot addresses - outputs: temp, + outputs: outputs, memo: memo, feeRate: feeRate, ); - updatedOutputs.clear(); - updatedOutputs.addAll(temp); - if (fee == 0) { throw BitcoinTransactionNoFeeException(); } int amount = credentialsAmount; - final lastOutput = updatedOutputs.last; + final lastOutput = outputs.last; final amountLeftForChange = amountLeftForChangeAndFee - fee; - if (_isBelowDust(amountLeftForChange)) { + if (isBelowDust(amountLeftForChange)) { // If has change that is lower than dust, will end up with tx rejected by network rules // so remove the change amount - updatedOutputs.removeLast(); + outputs.removeLast(); outputs.removeLast(); if (amountLeftForChange < 0) { @@ -738,12 +692,10 @@ abstract class ElectrumWalletBase return estimateTxForAmount( credentialsAmount, outputs, - updatedOutputs, feeRate, inputsCount: utxoDetails.utxos.length + 1, memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, - hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } else { @@ -761,20 +713,12 @@ abstract class ElectrumWalletBase isSendAll: spendingAllCoins, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, - spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } else { // Here, lastOutput already is change, return the amount left without the fee to the user's address. - updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( - address: lastOutput.address, - value: BigInt.from(amountLeftForChange), - isSilentPayment: lastOutput.isSilentPayment, - isChange: true, - ); outputs[outputs.length - 1] = BitcoinOutput( address: lastOutput.address, value: BigInt.from(amountLeftForChange), - isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); @@ -788,7 +732,6 @@ abstract class ElectrumWalletBase isSendAll: spendingAllCoins, memo: memo, spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, - spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } } @@ -818,12 +761,11 @@ abstract class ElectrumWalletBase final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; int credentialsAmount = 0; - bool hasSilentPayment = false; for (final out in transactionCredentials.outputs) { final outputAmount = out.formattedCryptoAmount!; - if (!sendAll && _isBelowDust(outputAmount)) { + if (!sendAll && isBelowDust(outputAmount)) { throw BitcoinTransactionNoDustException(); } @@ -836,25 +778,20 @@ abstract class ElectrumWalletBase credentialsAmount += outputAmount; final address = RegexUtils.addressTypeFromStr( - out.isParsedAddress ? out.extractedAddress! : out.address, network); - final isSilentPayment = address is SilentPaymentAddress; - - if (isSilentPayment) { - hasSilentPayment = true; - } + out.isParsedAddress ? out.extractedAddress! : out.address, + network, + ); if (sendAll) { // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent outputs.add(BitcoinOutput( address: address, value: BigInt.from(0), - isSilentPayment: isSilentPayment, )); } else { outputs.add(BitcoinOutput( address: address, value: BigInt.from(outputAmount), - isSilentPayment: isSilentPayment, )); } } @@ -864,31 +801,19 @@ abstract class ElectrumWalletBase : feeRate(transactionCredentials.priority!); EstimatedTxResult estimatedTx; - final updatedOutputs = outputs - .map((e) => BitcoinOutput( - address: e.address, - value: e.value, - isSilentPayment: e.isSilentPayment, - isChange: e.isChange, - )) - .toList(); - if (sendAll) { estimatedTx = await estimateSendAllTx( - updatedOutputs, + outputs, feeRateInt, memo: memo, - hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } else { estimatedTx = await estimateTxForAmount( credentialsAmount, outputs, - updatedOutputs, feeRateInt, memo: memo, - hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } @@ -896,7 +821,7 @@ abstract class ElectrumWalletBase if (walletInfo.isHardwareWallet) { final transaction = await buildHardwareWalletTransaction( utxos: estimatedTx.utxos, - outputs: updatedOutputs, + outputs: outputs, publicKeys: estimatedTx.publicKeys, fee: BigInt.from(estimatedTx.fee), network: network, @@ -925,7 +850,7 @@ abstract class ElectrumWalletBase if (network is BitcoinCashNetwork) { txb = ForkedTransactionBuilder( utxos: estimatedTx.utxos, - outputs: updatedOutputs, + outputs: outputs, fee: BigInt.from(estimatedTx.fee), network: network, memo: estimatedTx.memo, @@ -935,7 +860,7 @@ abstract class ElectrumWalletBase } else { txb = BitcoinTransactionBuilder( utxos: estimatedTx.utxos, - outputs: updatedOutputs, + outputs: outputs, fee: BigInt.from(estimatedTx.fee), network: network, memo: estimatedTx.memo, @@ -974,11 +899,7 @@ abstract class ElectrumWalletBase if (utxo.utxo.isP2tr()) { hasTaprootInputs = true; - return key.privkey.signTapRoot( - txDigest, - sighash: sighash, - tweak: utxo.utxo.isSilentPayment != true, - ); + return key.privkey.signTapRoot(txDigest, sighash: sighash); } else { return key.privkey.signInput(txDigest, sigHash: sighash); } @@ -997,20 +918,14 @@ abstract class ElectrumWalletBase utxos: estimatedTx.utxos, )..addListener((transaction) async { transactionHistory.addOne(transaction); - if (estimatedTx.spendsSilentPayment) { - transactionHistory.transactions.values.forEach((tx) { - tx.unspents?.removeWhere( - (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash)); - transactionHistory.addOne(tx); - }); - } unspentCoins .removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); await updateBalance(); }); - } catch (e) { + } catch (e, s) { + print([e, s]); throw e; } } @@ -1974,7 +1889,7 @@ class EstimatedTxResult { required this.hasChange, required this.isSendAll, this.memo, - required this.spendsSilentPayment, + this.spendsSilentPayment = false, required this.spendsUnconfirmedTX, }); @@ -1985,7 +1900,6 @@ class EstimatedTxResult { final int amount; final bool spendsSilentPayment; - // final bool sendsToSilentPayment; final bool hasChange; final bool isSendAll; final String? memo; @@ -2018,7 +1932,7 @@ class TxCreateUtxoDetails { required this.inputPrivKeyInfos, required this.publicKeys, required this.allInputsAmount, - required this.spendsSilentPayment, + this.spendsSilentPayment = false, required this.spendsUnconfirmedTX, }); } diff --git a/cw_bitcoin/lib/electrum_worker/electrum_worker.dart b/cw_bitcoin/lib/electrum_worker/electrum_worker.dart index 326fcf64a..758934e03 100644 --- a/cw_bitcoin/lib/electrum_worker/electrum_worker.dart +++ b/cw_bitcoin/lib/electrum_worker/electrum_worker.dart @@ -382,11 +382,20 @@ class ElectrumWorker { } Future _handleBroadcast(ElectrumWorkerBroadcastRequest request) async { + final rpcId = _electrumClient!.id + 1; final txHash = await _electrumClient!.request( ElectrumBroadCastTransaction(transactionRaw: request.transactionRaw), ); - _sendResponse(ElectrumWorkerBroadcastResponse(txHash: txHash, id: request.id)); + if (txHash == null) { + final error = (_electrumClient!.rpc as ElectrumSSLService).getError(rpcId); + + if (error?.message != null) { + return _sendError(ElectrumWorkerBroadcastError(error: error!.message, id: request.id)); + } + } else { + _sendResponse(ElectrumWorkerBroadcastResponse(txHash: txHash, id: request.id)); + } } Future _handleGetTxExpanded(ElectrumWorkerTxExpandedRequest request) async { @@ -586,6 +595,7 @@ class ElectrumWorker { if (scanData.shouldSwitchNodes) { scanningClient = await ElectrumApiProvider.connect( ElectrumTCPService.connect( + // TODO: ssl Uri.parse("tcp://electrs.cakewallet.com:50001"), ), ); @@ -714,7 +724,6 @@ class ElectrumWorker { date: scanData.network == BitcoinNetwork.mainnet ? getDateByBitcoinHeight(tweakHeight) : DateTime.now(), - time: null, confirmations: scanData.chainTip - tweakHeight + 1, unspents: [], isReceivedSilentPayment: true, @@ -736,10 +745,7 @@ class ElectrumWorker { receivingOutputAddress, labelIndex: 1, // TODO: get actual index/label isUsed: true, - // TODO: use right wallet - spendKey: scanData.silentPaymentsWallets.first.b_spend.tweakAdd( - BigintUtils.fromBytes(BytesUtils.fromHexString(t_k)), - ), + tweak: t_k, txCount: 1, balance: amount, ); diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 8f2febb6c..0a23731bc 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -559,7 +559,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { direction: TransactionDirection.incoming, isPending: utxo.height == 0, date: date, - time: null, confirmations: confirmations, inputAddresses: [], outputAddresses: [utxo.outputId], @@ -763,7 +762,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { direction: TransactionDirection.outgoing, isPending: false, date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000), - time: null, confirmations: 1, inputAddresses: inputAddresses.toList(), outputAddresses: [], diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index 2c0763305..9155cbce7 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:cw_bitcoin/electrum_worker/electrum_worker_params.dart'; import 'package:cw_bitcoin/electrum_worker/methods/methods.dart'; import 'package:grpc/grpc.dart'; @@ -24,6 +26,7 @@ class PendingBitcoinTransaction with PendingTransaction { this.hasTaprootInputs = false, this.isMweb = false, this.utxos = const [], + this.hasSilentPayment = false, }) : _listeners = []; final WalletType type; @@ -59,7 +62,7 @@ class PendingBitcoinTransaction with PendingTransaction { List get outputs => _tx.outputs; - bool get hasSilentPayment => _tx.hasSilentPayment; + bool hasSilentPayment; PendingChange? get change { try { @@ -76,41 +79,41 @@ class PendingBitcoinTransaction with PendingTransaction { final List _listeners; Future _commit() async { - int? callId; + final result = await sendWorker( + ElectrumWorkerBroadcastRequest(transactionRaw: hex), + ) as String; - final result = await sendWorker(ElectrumWorkerBroadcastRequest(transactionRaw: hex)) as String; + String? error; + try { + final resultJson = jsonDecode(result) as Map; + error = resultJson["error"] as String; + } catch (_) {} - // if (result.isEmpty) { - // if (callId != null) { - // final error = sendWorker(getErrorMessage(callId!)); + if (error != null) { + if (error.contains("dust")) { + if (hasChange) { + throw BitcoinTransactionCommitFailedDustChange(); + } else if (!isSendAll) { + throw BitcoinTransactionCommitFailedDustOutput(); + } else { + throw BitcoinTransactionCommitFailedDustOutputSendAll(); + } + } - // if (error.contains("dust")) { - // if (hasChange) { - // throw BitcoinTransactionCommitFailedDustChange(); - // } else if (!isSendAll) { - // throw BitcoinTransactionCommitFailedDustOutput(); - // } else { - // throw BitcoinTransactionCommitFailedDustOutputSendAll(); - // } - // } + if (error.contains("bad-txns-vout-negative")) { + throw BitcoinTransactionCommitFailedVoutNegative(); + } - // if (error.contains("bad-txns-vout-negative")) { - // throw BitcoinTransactionCommitFailedVoutNegative(); - // } + if (error.contains("non-BIP68-final")) { + throw BitcoinTransactionCommitFailedBIP68Final(); + } - // if (error.contains("non-BIP68-final")) { - // throw BitcoinTransactionCommitFailedBIP68Final(); - // } + if (error.contains("min fee not met")) { + throw BitcoinTransactionCommitFailedLessThanMin(); + } - // if (error.contains("min fee not met")) { - // throw BitcoinTransactionCommitFailedLessThanMin(); - // } - - // throw BitcoinTransactionCommitFailed(errorMessage: error); - // } - - // throw BitcoinTransactionCommitFailed(); - // } + throw BitcoinTransactionCommitFailed(errorMessage: error); + } } Future _ltcCommit() async { @@ -151,7 +154,6 @@ class PendingBitcoinTransaction with PendingTransaction { inputAddresses: _tx.inputs.map((input) => input.txId).toList(), outputAddresses: outputAddresses, fee: fee, - time: null, ); @override diff --git a/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart b/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart index 340c1eb64..04deffbba 100644 --- a/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart +++ b/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart @@ -1,24 +1,29 @@ +import 'dart:convert'; + +import 'package:cw_bitcoin/electrum_worker/electrum_worker_params.dart'; +import 'package:cw_bitcoin/electrum_worker/methods/methods.dart'; import 'package:cw_bitcoin/exceptions.dart'; import 'package:bitbox/bitbox.dart' as bitbox; import 'package:cw_core/pending_transaction.dart'; -import 'package:cw_bitcoin/electrum.dart'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/wallet_type.dart'; class PendingBitcoinCashTransaction with PendingTransaction { - PendingBitcoinCashTransaction(this._tx, this.type, - {required this.electrumClient, - required this.amount, - required this.fee, - required this.hasChange, - required this.isSendAll}) - : _listeners = []; + PendingBitcoinCashTransaction( + this._tx, + this.type, { + required this.sendWorker, + required this.amount, + required this.fee, + required this.hasChange, + required this.isSendAll, + }) : _listeners = []; final WalletType type; final bitbox.Transaction _tx; - final ElectrumClient electrumClient; + Future Function(ElectrumWorkerRequest) sendWorker; final int amount; final int fee; final bool hasChange; @@ -40,32 +45,40 @@ class PendingBitcoinCashTransaction with PendingTransaction { @override Future commit() async { - int? callId; + final result = await sendWorker( + ElectrumWorkerBroadcastRequest(transactionRaw: hex), + ) as String; - final result = await electrumClient.broadcastTransaction( - transactionRaw: hex, idCallback: (id) => callId = id); + String? error; + try { + final resultJson = jsonDecode(result) as Map; + error = resultJson["error"] as String; + } catch (_) {} - if (result.isEmpty) { - if (callId != null) { - final error = electrumClient.getErrorMessage(callId!); - - if (error.contains("dust")) { - if (hasChange) { - throw BitcoinTransactionCommitFailedDustChange(); - } else if (!isSendAll) { - throw BitcoinTransactionCommitFailedDustOutput(); - } else { - throw BitcoinTransactionCommitFailedDustOutputSendAll(); - } + if (error != null) { + if (error.contains("dust")) { + if (hasChange) { + throw BitcoinTransactionCommitFailedDustChange(); + } else if (!isSendAll) { + throw BitcoinTransactionCommitFailedDustOutput(); + } else { + throw BitcoinTransactionCommitFailedDustOutputSendAll(); } - - if (error.contains("bad-txns-vout-negative")) { - throw BitcoinTransactionCommitFailedVoutNegative(); - } - throw BitcoinTransactionCommitFailed(errorMessage: error); } - throw BitcoinTransactionCommitFailed(); + if (error.contains("bad-txns-vout-negative")) { + throw BitcoinTransactionCommitFailedVoutNegative(); + } + + if (error.contains("non-BIP68-final")) { + throw BitcoinTransactionCommitFailedBIP68Final(); + } + + if (error.contains("min fee not met")) { + throw BitcoinTransactionCommitFailedLessThanMin(); + } + + throw BitcoinTransactionCommitFailed(errorMessage: error); } _listeners.forEach((listener) => listener(transactionInfo())); @@ -86,6 +99,7 @@ class PendingBitcoinCashTransaction with PendingTransaction { fee: fee, isReplaced: false, ); + @override Future commitUR() { throw UnimplementedError(); diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index c28c4110b..dde04bb1c 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -695,7 +695,7 @@ "sent": "Enviada", "service_health_disabled": "O Boletim de Saúde de Serviço está desativado", "service_health_disabled_message": "Esta é a página do Boletim de Saúde de Serviço, você pode ativar esta página em Configurações -> Privacidade", - "set_a_pin": "Defina um pino", + "set_a_pin": "Defina um pin", "settings": "Configurações", "settings_all": "Tudo", "settings_allow_biometrical_authentication": "Permitir autenticação biométrica", @@ -990,4 +990,4 @@ "you_will_get": "Converter para", "you_will_send": "Converter de", "yy": "aa" -} \ No newline at end of file +}