rbf fixes issues sum utxo and fee calculation (#1625)

* total out amount issue * fix empty inputs and outputs addresses for new tx * fix sum value of utxo not spending * Update configure.dart * Update electrum_wallet.dart * receiving address * review fixes

Serhii committed Aug 23, 2024 at 16:19 UTC 4c2d0613635a1457c2a63ba788f15dae170fcae9
6 files changed +131 -77
cw_bitcoin/lib/electrum_transaction_info.dart
+1 -1
@@ -235,6 +235,6 @@ class ElectrumTransactionInfo extends TransactionInfo {
235 }
236
237 String toString() {
238 - return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, confirmations: $confirmations, to: $to, unspent: $unspents)';
238 + return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses)';
239 }
240 }
cw_bitcoin/lib/electrum_wallet.dart
+82 -47
@@ -132,6 +132,7 @@ abstract class ElectrumWalletBase
132 final String? _mnemonic;
133
134 Bip32Slip10Secp256k1 get hd => accountHD.childKey(Bip32KeyIndex(0));
135 +
136 Bip32Slip10Secp256k1 get sideHd => accountHD.childKey(Bip32KeyIndex(1));
137
138 final EncryptionFileUtils encryptionFileUtils;
@@ -1363,26 +1364,15 @@ abstract class ElectrumWalletBase
1364 }
1365 }
1366
1366 - Future<bool> canReplaceByFee(String hash) async {
1367 - final verboseTransaction = await electrumClient.getTransactionVerbose(hash: hash);
1368 -
1369 - final String? transactionHex;
1370 - int confirmations = 0;
1371 -
1372 - if (verboseTransaction.isEmpty) {
1373 - transactionHex = await electrumClient.getTransactionHex(hash: hash);
1374 - } else {
1375 - confirmations = verboseTransaction['confirmations'] as int? ?? 0;
1376 - transactionHex = verboseTransaction['hex'] as String?;
1377 - }
1378 -
1379 - if (confirmations > 0) return false;
1380 -
1381 - if (transactionHex == null || transactionHex.isEmpty) {
1367 + Future<bool> canReplaceByFee(ElectrumTransactionInfo tx) async {
1368 + try {
1369 + final bundle = await getTransactionExpanded(hash: tx.txHash);
1370 + _updateInputsAndOutputs(tx, bundle);
1371 + if (bundle.confirmations > 0) return false;
1372 + return bundle.originalTransaction.canReplaceByFee;
1373 + } catch (e) {
1374 return false;
1375 }
1384 -
1385 - return BtcTransaction.fromRaw(transactionHex).canReplaceByFee;
1376 }
1377
1378 Future<bool> isChangeSufficientForFee(String txId, int newFee) async {
@@ -1458,47 +1448,59 @@ abstract class ElectrumWalletBase
1448 );
1449 }
1450
1461 - int totalOutAmount = bundle.originalTransaction.outputs
1462 - .fold<int>(0, (previousValue, element) => previousValue + element.amount.toInt());
1451 + // Create a list of available outputs
1452 + final outputs = <BitcoinOutput>[];
1453 + for (final out in bundle.originalTransaction.outputs) {
1454 + final address = addressFromOutputScript(out.scriptPubKey, network);
1455 + final btcAddress = addressTypeFromStr(address, network);
1456 + outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(out.amount.toInt())));
1457 + }
1458
1464 - var currentFee = allInputsAmount - totalOutAmount;
1459 + // Calculate the total amount and fees
1460 + int totalOutAmount =
1461 + outputs.fold<int>(0, (previousValue, output) => previousValue + output.value.toInt());
1462 + int currentFee = allInputsAmount - totalOutAmount;
1463 int remainingFee = newFee - currentFee;
1464
1467 - final outputs = <BitcoinOutput>[];
1465 + if (remainingFee <= 0) {
1466 + throw Exception("New fee must be higher than the current fee.");
1467 + }
1468
1469 - // Add outputs and deduct the fees from it
1470 - for (int i = bundle.originalTransaction.outputs.length - 1; i >= 0; i--) {
1471 - final out = bundle.originalTransaction.outputs[i];
1472 - final address = addressFromOutputScript(out.scriptPubKey, network);
1473 - final btcAddress = addressTypeFromStr(address, network);
1469 + // Deduct Remaining Fee from Main Outputs
1470 + if (remainingFee > 0) {
1471 + for (int i = outputs.length - 1; i >= 0; i--) {
1472 + int outputAmount = outputs[i].value.toInt();
1473
1475 - int newAmount;
1476 - if (out.amount.toInt() >= remainingFee) {
1477 - newAmount = out.amount.toInt() - remainingFee;
1478 - remainingFee = 0;
1474 + if (outputAmount > _dustAmount) {
1475 + int deduction = (outputAmount - _dustAmount >= remainingFee)
1476 + ? remainingFee
1477 + : outputAmount - _dustAmount;
1478 + outputs[i] = BitcoinOutput(
1479 + address: outputs[i].address, value: BigInt.from(outputAmount - deduction));
1480 + remainingFee -= deduction;
1481
1480 - // if new amount of output is less than dust amount, then don't add this output as well
1481 - if (newAmount <= _dustAmount) {
1482 - continue;
1482 + if (remainingFee <= 0) break;
1483 }
1484 - } else {
1485 - remainingFee -= out.amount.toInt();
1486 - continue;
1484 }
1485 + }
1486
1489 - outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(newAmount)));
1487 + // Final check if the remaining fee couldn't be deducted
1488 + if (remainingFee > 0) {
1489 + throw Exception("Not enough funds to cover the fee.");
1490 }
1491
1492 + // Identify all change outputs
1493 final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden);
1494 + final List<BitcoinOutput> changeOutputs = outputs
1495 + .where((output) => changeAddresses
1496 + .any((element) => element.address == output.address.toAddress(network)))
1497 + .toList();
1498
1494 - // look for a change address in the outputs
1495 - final changeOutput = outputs.firstWhereOrNull((output) =>
1496 - changeAddresses.any((element) => element.address == output.address.toAddress(network)));
1499 + int totalChangeAmount =
1500 + changeOutputs.fold<int>(0, (sum, output) => sum + output.value.toInt());
1501
1498 - // deduct the change amount from the output amount
1499 - if (changeOutput != null) {
1500 - totalOutAmount -= changeOutput.value.toInt();
1501 - }
1502 + // The final amount that the receiver will receive
1503 + int sendingAmount = allInputsAmount - newFee - totalChangeAmount;
1504
1505 final txb = BitcoinTransactionBuilder(
1506 utxos: utxos,
@@ -1527,10 +1529,10 @@ abstract class ElectrumWalletBase
1529 transaction,
1530 type,
1531 electrumClient: electrumClient,
1530 - amount: totalOutAmount,
1532 + amount: sendingAmount,
1533 fee: newFee,
1534 network: network,
1533 - hasChange: changeOutput != null,
1535 + hasChange: changeOutputs.isNotEmpty,
1536 feeRate: newFee.toString(),
1537 )..addListener((transaction) async {
1538 transactionHistory.addOne(transaction);
@@ -2026,6 +2028,39 @@ abstract class ElectrumWalletBase
2028 });
2029 }
2030 }
2031 +
2032 + void _updateInputsAndOutputs(ElectrumTransactionInfo tx, ElectrumTransactionBundle bundle) {
2033 + tx.inputAddresses = tx.inputAddresses?.where((address) => address.isNotEmpty).toList();
2034 +
2035 + if (tx.inputAddresses == null ||
2036 + tx.inputAddresses!.isEmpty ||
2037 + tx.outputAddresses == null ||
2038 + tx.outputAddresses!.isEmpty) {
2039 + List<String> inputAddresses = [];
2040 + List<String> outputAddresses = [];
2041 +
2042 + for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) {
2043 + final input = bundle.originalTransaction.inputs[i];
2044 + final inputTransaction = bundle.ins[i];
2045 + final vout = input.txIndex;
2046 + final outTransaction = inputTransaction.outputs[vout];
2047 + final address = addressFromOutputScript(outTransaction.scriptPubKey, network);
2048 +
2049 + if (address.isNotEmpty) inputAddresses.add(address);
2050 + }
2051 +
2052 + for (int i = 0; i < bundle.originalTransaction.outputs.length; i++) {
2053 + final out = bundle.originalTransaction.outputs[i];
2054 + final address = addressFromOutputScript(out.scriptPubKey, network);
2055 +
2056 + if (address.isNotEmpty) outputAddresses.add(address);
2057 + }
2058 + tx.inputAddresses = inputAddresses;
2059 + tx.outputAddresses = outputAddresses;
2060 +
2061 + transactionHistory.addOne(tx);
2062 + }
2063 + }
2064 }
2065
2066 class ScanNode {
lib/bitcoin/cw_bitcoin.dart
+3 -2
@@ -398,9 +398,10 @@ class CWBitcoin extends Bitcoin {
398 }
399
400 @override
401 - Future<bool> canReplaceByFee(Object wallet, String transactionHash) async {
401 + Future<bool> canReplaceByFee(Object wallet, Object transactionInfo) async {
402 final bitcoinWallet = wallet as ElectrumWallet;
403 - return bitcoinWallet.canReplaceByFee(transactionHash);
403 + final tx = transactionInfo as ElectrumTransactionInfo;
404 + return bitcoinWallet.canReplaceByFee(tx);
405 }
406
407 @override
lib/view_model/send/send_view_model.dart
+30 -16
@@ -18,6 +18,7 @@ import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart';
18 import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart';
19 import 'package:cake_wallet/wownero/wownero.dart';
20 import 'package:cw_core/exceptions.dart';
21 +import 'package:cw_core/transaction_info.dart';
22 import 'package:cw_core/transaction_priority.dart';
23 import 'package:cake_wallet/view_model/send/output.dart';
24 import 'package:cake_wallet/view_model/send/send_template_view_model.dart';
@@ -392,25 +393,38 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
393 }
394
395 @action
395 - Future<void> replaceByFee(String txId, String newFee) async {
396 + Future<void> replaceByFee(TransactionInfo tx, String newFee) async {
397 state = IsExecutingState();
398
398 - final isSufficient = await bitcoin!.isChangeSufficientForFee(wallet, txId, newFee);
399 -
400 - if (!isSufficient) {
401 - state = AwaitingConfirmationState(
402 - title: S.current.confirm_fee_deduction,
403 - message: S.current.confirm_fee_deduction_content,
404 - onConfirm: () async {
405 - pendingTransaction = await bitcoin!.replaceByFee(wallet, txId, newFee);
406 - state = ExecutedSuccessfullyState();
407 - },
408 - onCancel: () {
409 - state = FailureState('Insufficient change for fee');
410 - });
411 - } else {
412 - pendingTransaction = await bitcoin!.replaceByFee(wallet, txId, newFee);
399 + try {
400 + final isSufficient = await bitcoin!.isChangeSufficientForFee(wallet, tx.id, newFee);
401 +
402 + if (!isSufficient) {
403 + state = AwaitingConfirmationState(
404 + title: S.current.confirm_fee_deduction,
405 + message: S.current.confirm_fee_deduction_content,
406 + onConfirm: () async => await _executeReplaceByFee(tx, newFee),
407 + onCancel: () => state = FailureState('Insufficient change for fee'));
408 + } else {
409 + await _executeReplaceByFee(tx, newFee);
410 + }
411 + } catch (e) {
412 + state = FailureState(e.toString());
413 + }
414 + }
415 +
416 + Future<void> _executeReplaceByFee(TransactionInfo tx, String newFee) async {
417 +
418 +
419 + clearOutputs();
420 + final output = outputs.first;
421 + output.address = tx.outputAddresses?.first ?? '';
422 +
423 + try {
424 + pendingTransaction = await bitcoin!.replaceByFee(wallet, tx.id, newFee);
425 state = ExecutedSuccessfullyState();
426 + } catch (e) {
427 + state = FailureState(e.toString());
428 }
429 }
430
lib/view_model/transaction_details_view_model.dart
+13 -10
@@ -52,7 +52,7 @@ abstract class TransactionDetailsViewModelBase with Store {
52 case WalletType.bitcoin:
53 _addElectrumListItems(tx, dateFormat);
54 _addBumpFeesListItems(tx);
55 - _checkForRBF();
55 + _checkForRBF(tx);
56 break;
57 case WalletType.litecoin:
58 case WalletType.bitcoinCash:
@@ -349,12 +349,15 @@ abstract class TransactionDetailsViewModelBase with Store {
349
350 void _addBumpFeesListItems(TransactionInfo tx) {
351 transactionPriority = bitcoin!.getBitcoinTransactionPriorityMedium();
352 + final inputsCount = (transactionInfo.inputAddresses?.isEmpty ?? true)
353 + ? 1
354 + : transactionInfo.inputAddresses!.length;
355 + final outputsCount = (transactionInfo.outputAddresses?.isEmpty ?? true)
356 + ? 1
357 + : transactionInfo.outputAddresses!.length;
358
359 newFee = bitcoin!.getFeeAmountForPriority(
354 - wallet,
355 - bitcoin!.getBitcoinTransactionPriorityMedium(),
356 - transactionInfo.inputAddresses?.length ?? 1,
357 - transactionInfo.outputAddresses?.length ?? 1);
360 + wallet, bitcoin!.getBitcoinTransactionPriorityMedium(), inputsCount, outputsCount);
361
362 RBFListItems.add(StandartListItem(title: S.current.old_fee, value: tx.feeFormatted() ?? '0.0'));
363
@@ -383,12 +386,12 @@ abstract class TransactionDetailsViewModelBase with Store {
386 return setNewFee(value: sliderValue, priority: transactionPriority!);
387 }));
388
386 - if (transactionInfo.inputAddresses != null) {
389 + if (transactionInfo.inputAddresses != null && transactionInfo.inputAddresses!.isNotEmpty) {
390 RBFListItems.add(StandardExpandableListItem(
391 title: S.current.inputs, expandableItems: transactionInfo.inputAddresses!));
392 }
393
391 - if (transactionInfo.outputAddresses != null) {
394 + if (transactionInfo.outputAddresses != null && transactionInfo.outputAddresses!.isNotEmpty) {
395 RBFListItems.add(StandardExpandableListItem(
396 title: S.current.outputs, expandableItems: transactionInfo.outputAddresses!));
397 }
@@ -416,10 +419,10 @@ abstract class TransactionDetailsViewModelBase with Store {
419 }
420
421 @action
419 - Future<void> _checkForRBF() async {
422 + Future<void> _checkForRBF(TransactionInfo tx) async {
423 if (wallet.type == WalletType.bitcoin &&
424 transactionInfo.direction == TransactionDirection.outgoing) {
422 - if (await bitcoin!.canReplaceByFee(wallet, transactionInfo.id)) {
425 + if (await bitcoin!.canReplaceByFee(wallet, tx)) {
426 _canReplaceByFee = true;
427 }
428 }
@@ -441,7 +444,7 @@ abstract class TransactionDetailsViewModelBase with Store {
444 return bitcoin!.formatterBitcoinAmountToString(amount: newFee);
445 }
446
444 - void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo.id, newFee);
447 + void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo, newFee,);
448
449 @computed
450 String get pendingTransactionFiatAmountValueFormatted => sendViewModel.isFiatDisabled
tool/configure.dart
+2 -1
@@ -79,6 +79,7 @@ import 'dart:typed_data';
79 import 'package:bitcoin_base/bitcoin_base.dart';
80 import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart';
81 import 'package:cake_wallet/view_model/send/output.dart';
82 +import 'package:cw_bitcoin/electrum_transaction_info.dart';
83 import 'package:cw_core/hardware/hardware_account_data.dart';
84 import 'package:cw_core/node.dart';
85 import 'package:cw_core/output_info.dart';
@@ -204,7 +205,7 @@ abstract class Bitcoin {
205 bool isTestnet(Object wallet);
206
207 Future<PendingTransaction> replaceByFee(Object wallet, String transactionHash, String fee);
207 - Future<bool> canReplaceByFee(Object wallet, String transactionHash);
208 + Future<bool> canReplaceByFee(Object wallet, Object tx);
209 Future<bool> isChangeSufficientForFee(Object wallet, String txId, String newFee);
210 int getFeeAmountForPriority(Object wallet, TransactionPriority priority, int inputsCount, int outputsCount, {int? size});
211 int getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount,