Cw 649 rbf improvements opt in (#1772)

* initial commit * revert changes * allow adding inputs to RBF transactions * address review comments[skip ci] --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

Serhii committed Nov 28, 2024 at 02:42 UTC 4ca50b5e6329dd5be066396ccb2e5a4c0483df53
1 file changed +113 -32
cw_bitcoin/lib/electrum_wallet.dart
+113 -32
@@ -1503,14 +1503,21 @@ abstract class ElectrumWalletBase
1503 final bundle = await getTransactionExpanded(hash: txId);
1504 final outputs = bundle.originalTransaction.outputs;
1505
1506 - final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden);
1506 + final ownAddresses = walletAddresses.allAddresses.map((addr) => addr.address).toSet();
1507
1508 - // look for a change address in the outputs
1509 - final changeOutput = outputs.firstWhereOrNull((output) => changeAddresses.any(
1510 - (element) => element.address == addressFromOutputScript(output.scriptPubKey, network)));
1508 + final receiverAmount = outputs
1509 + .where((output) => !ownAddresses.contains(addressFromOutputScript(output.scriptPubKey, network)))
1510 + .fold<int>(0, (sum, output) => sum + output.amount.toInt());
1511
1512 - var allInputsAmount = 0;
1512 + if (receiverAmount == 0) {
1513 + throw Exception("Receiver output not found.");
1514 + }
1515 +
1516 + final availableInputs = unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList();
1517 + int totalBalance = availableInputs.fold<int>(
1518 + 0, (previousValue, element) => previousValue + element.value.toInt());
1519
1520 + int allInputsAmount = 0;
1521 for (int i = 0; i < bundle.originalTransaction.inputs.length; i++) {
1522 final input = bundle.originalTransaction.inputs[i];
1523 final inputTransaction = bundle.ins[i];
@@ -1521,12 +1528,10 @@ abstract class ElectrumWalletBase
1528
1529 int totalOutAmount = bundle.originalTransaction.outputs
1530 .fold<int>(0, (previousValue, element) => previousValue + element.amount.toInt());
1524 -
1531 var currentFee = allInputsAmount - totalOutAmount;
1532
1533 int remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee;
1528 -
1529 - return changeOutput != null && changeOutput.amount.toInt() - remainingFee >= 0;
1534 + return totalBalance - receiverAmount - remainingFee >= _dustAmount;
1535 }
1536
1537 Future<PendingBitcoinTransaction> replaceByFee(String hash, int newFee) async {
@@ -1534,12 +1539,13 @@ abstract class ElectrumWalletBase
1539 final bundle = await getTransactionExpanded(hash: hash);
1540
1541 final utxos = <UtxoWithAddress>[];
1542 + final outputs = <BitcoinOutput>[];
1543 List<ECPrivate> privateKeys = [];
1544
1545 var allInputsAmount = 0;
1546 String? memo;
1547
1542 - // Add inputs
1548 + // Add original inputs
1549 for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) {
1550 final input = bundle.originalTransaction.inputs[i];
1551 final inputTransaction = bundle.ins[i];
@@ -1549,8 +1555,7 @@ abstract class ElectrumWalletBase
1555 allInputsAmount += outTransaction.amount.toInt();
1556
1557 final addressRecord =
1552 - walletAddresses.allAddresses.firstWhere((element) => element.address == address);
1553 -
1558 + walletAddresses.allAddresses.firstWhere((element) => element.address == address);
1559 final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network);
1560 final privkey = generateECPrivate(
1561 hd: addressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd,
@@ -1568,15 +1573,13 @@ abstract class ElectrumWalletBase
1573 scriptType: _getScriptType(btcAddress),
1574 ),
1575 ownerDetails:
1571 - UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: btcAddress),
1576 + UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: btcAddress),
1577 ),
1578 );
1579 }
1580
1576 - // Create a list of available outputs
1577 - final outputs = <BitcoinOutput>[];
1581 + // Add original outputs
1582 for (final out in bundle.originalTransaction.outputs) {
1579 - // Check if the script contains OP_RETURN
1583 final script = out.scriptPubKey.script;
1584 if (script.contains('OP_RETURN') && memo == null) {
1585 final index = script.indexOf('OP_RETURN');
@@ -1598,7 +1601,7 @@ abstract class ElectrumWalletBase
1601
1602 // Calculate the total amount and fees
1603 int totalOutAmount =
1601 - outputs.fold<int>(0, (previousValue, output) => previousValue + output.value.toInt());
1604 + outputs.fold<int>(0, (previousValue, output) => previousValue + output.value.toInt());
1605 int currentFee = allInputsAmount - totalOutAmount;
1606 int remainingFee = newFee - currentFee;
1607
@@ -1606,17 +1609,95 @@ abstract class ElectrumWalletBase
1609 throw Exception("New fee must be higher than the current fee.");
1610 }
1611
1609 - // Deduct Remaining Fee from Main Outputs
1612 + // Deduct fee from change outputs first, if possible
1613 if (remainingFee > 0) {
1614 + final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden);
1615 for (int i = outputs.length - 1; i >= 0; i--) {
1612 - int outputAmount = outputs[i].value.toInt();
1616 + final output = outputs[i];
1617 + final isChange = changeAddresses
1618 + .any((element) => element.address == output.address.toAddress(network));
1619 +
1620 + if (isChange) {
1621 + int outputAmount = output.value.toInt();
1622 + if (outputAmount > _dustAmount) {
1623 + int deduction = (outputAmount - _dustAmount >= remainingFee)
1624 + ? remainingFee
1625 + : outputAmount - _dustAmount;
1626 + outputs[i] = BitcoinOutput(
1627 + address: output.address, value: BigInt.from(outputAmount - deduction));
1628 + remainingFee -= deduction;
1629 +
1630 + if (remainingFee <= 0) break;
1631 + }
1632 + }
1633 + }
1634 + }
1635 +
1636 + // If still not enough, add UTXOs until the fee is covered
1637 + if (remainingFee > 0) {
1638 + final unusedUtxos = unspentCoins
1639 + .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0)
1640 + .toList();
1641 +
1642 + for (final utxo in unusedUtxos) {
1643 + final address = RegexUtils.addressTypeFromStr(utxo.address, network);
1644 + final privkey = generateECPrivate(
1645 + hd: utxo.bitcoinAddressRecord.isHidden
1646 + ? walletAddresses.sideHd
1647 + : walletAddresses.mainHd,
1648 + index: utxo.bitcoinAddressRecord.index,
1649 + network: network,
1650 + );
1651 + privateKeys.add(privkey);
1652 +
1653 + utxos.add(UtxoWithAddress(
1654 + utxo: BitcoinUtxo(
1655 + txHash: utxo.hash,
1656 + value: BigInt.from(utxo.value),
1657 + vout: utxo.vout,
1658 + scriptType: _getScriptType(address)),
1659 + ownerDetails:
1660 + UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address),
1661 + ));
1662 +
1663 + allInputsAmount += utxo.value;
1664 + remainingFee -= utxo.value;
1665 +
1666 + if (remainingFee < 0) {
1667 + final changeOutput = outputs.firstWhereOrNull((output) => walletAddresses.allAddresses
1668 + .any((addr) => addr.address == output.address.toAddress(network)));
1669 + if (changeOutput != null) {
1670 + final newValue = changeOutput.value.toInt() + (-remainingFee);
1671 + outputs[outputs.indexOf(changeOutput)] =
1672 + BitcoinOutput(address: changeOutput.address, value: BigInt.from(newValue));
1673 + } else {
1674 + final changeAddress = await walletAddresses.getChangeAddress();
1675 + outputs.add(BitcoinOutput(
1676 + address: RegexUtils.addressTypeFromStr(changeAddress.address, network),
1677 + value: BigInt.from(-remainingFee)));
1678 + }
1679 +
1680 + remainingFee = 0;
1681 + break;
1682 + }
1683 +
1684 + if (remainingFee <= 0) break;
1685 + }
1686 + }
1687 +
1688 + // Deduct from the receiver's output if remaining fee is still greater than 0
1689 + if (remainingFee > 0) {
1690 + for (int i = 0; i < outputs.length; i++) {
1691 + final output = outputs[i];
1692 + int outputAmount = output.value.toInt();
1693
1694 if (outputAmount > _dustAmount) {
1695 int deduction = (outputAmount - _dustAmount >= remainingFee)
1696 ? remainingFee
1697 : outputAmount - _dustAmount;
1698 +
1699 outputs[i] = BitcoinOutput(
1619 - address: outputs[i].address, value: BigInt.from(outputAmount - deduction));
1700 + address: output.address, value: BigInt.from(outputAmount - deduction));
1701 remainingFee -= deduction;
1702
1703 if (remainingFee <= 0) break;
@@ -1633,11 +1714,11 @@ abstract class ElectrumWalletBase
1714 final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden);
1715 final List<BitcoinOutput> changeOutputs = outputs
1716 .where((output) => changeAddresses
1636 - .any((element) => element.address == output.address.toAddress(network)))
1717 + .any((element) => element.address == output.address.toAddress(network)))
1718 .toList();
1719
1720 int totalChangeAmount =
1640 - changeOutputs.fold<int>(0, (sum, output) => sum + output.value.toInt());
1721 + changeOutputs.fold<int>(0, (sum, output) => sum + output.value.toInt());
1722
1723 // The final amount that the receiver will receive
1724 int sendingAmount = allInputsAmount - newFee - totalChangeAmount;
@@ -1654,8 +1735,7 @@ abstract class ElectrumWalletBase
1735
1736 final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) {
1737 final key =
1657 - privateKeys.firstWhereOrNull((element) => element.getPublic().toHex() == publicKey);
1658 -
1738 + privateKeys.firstWhereOrNull((element) => element.getPublic().toHex() == publicKey);
1739 if (key == null) {
1740 throw Exception("Cannot find private key");
1741 }
@@ -1665,6 +1745,7 @@ abstract class ElectrumWalletBase
1745 } else {
1746 return key.signInput(txDigest, sigHash: sighash);
1747 }
1748 +
1749 });
1750
1751 return PendingBitcoinTransaction(
@@ -1677,16 +1758,16 @@ abstract class ElectrumWalletBase
1758 hasChange: changeOutputs.isNotEmpty,
1759 feeRate: newFee.toString(),
1760 )..addListener((transaction) async {
1680 - transactionHistory.transactions.values.forEach((tx) {
1681 - if (tx.id == hash) {
1682 - tx.isReplaced = true;
1683 - tx.isPending = false;
1684 - transactionHistory.addOne(tx);
1685 - }
1686 - });
1687 - transactionHistory.addOne(transaction);
1688 - await updateBalance();
1761 + transactionHistory.transactions.values.forEach((tx) {
1762 + if (tx.id == hash) {
1763 + tx.isReplaced = true;
1764 + tx.isPending = false;
1765 + transactionHistory.addOne(tx);
1766 + }
1767 });
1768 + transactionHistory.addOne(transaction);
1769 + await updateBalance();
1770 + });
1771 } catch (e) {
1772 throw e;
1773 }