Skip to content

Commit b9dc6aa

Browse files
authored
Merge pull request #10591 from SomberNight/202604_fix_wallet_mktx_base_tx
wallet: make_unsigned_tx: fix base_tx for GUI simple-send batching
2 parents e96b833 + febe95e commit b9dc6aa

4 files changed

Lines changed: 74 additions & 12 deletions

File tree

electrum/gui/qt/confirm_tx_dialog.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import asyncio
2727
from decimal import Decimal
2828
from functools import partial
29-
from typing import TYPE_CHECKING, Optional, Union
29+
from typing import TYPE_CHECKING, Optional, Union, Sequence
3030
from concurrent.futures import Future
3131
from enum import Enum, auto
3232

@@ -39,7 +39,7 @@
3939
from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates,
4040
get_asyncio_loop, wait_for2, UserFacingException)
4141
from electrum.plugin import run_hook
42-
from electrum.transaction import PartialTransaction, PartialTxOutput
42+
from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction
4343
from electrum.wallet import InternalAddressCorruption
4444
from electrum.bitcoin import DummyAddress
4545
from electrum.fee_policy import FeePolicy, FixedFeePolicy, FeeMethod
@@ -82,7 +82,7 @@ def __init__(
8282
output_value: Union[int, str],
8383
payee_outputs: Optional[list[PartialTxOutput]] = None,
8484
context: TxEditorContext = TxEditorContext.PAYMENT,
85-
batching_candidates=None,
85+
batching_candidates: Sequence[Transaction] = None,
8686
):
8787

8888
WindowModalDialog.__init__(self, window, title=title)
@@ -106,7 +106,7 @@ def __init__(
106106
self.needs_update = False
107107
self.context = context
108108
self.is_preview = False
109-
self._base_tx = None # for batching
109+
self._base_tx = None # type: Optional[Transaction] # for batching
110110
self.batching_candidates = batching_candidates
111111

112112
self.swap_manager = self.wallet.lnworker.swap_manager if self.wallet.has_lightning() else None
@@ -1123,7 +1123,7 @@ def __init__(
11231123
output_value: Union[int, str],
11241124
payee_outputs: Optional[list[PartialTxOutput]] = None,
11251125
context: TxEditorContext = TxEditorContext.PAYMENT,
1126-
batching_candidates=None,
1126+
batching_candidates: Sequence[Transaction] = None,
11271127
):
11281128

11291129
TxEditor.__init__(

electrum/gui/qt/main_window.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1507,7 +1507,7 @@ def confirm_tx_dialog(
15071507
output_value, *,
15081508
payee_outputs: Optional[list[TxOutput]] = None,
15091509
context: TxEditorContext = TxEditorContext.PAYMENT,
1510-
batching_candidates=None,
1510+
batching_candidates: Sequence[Transaction] = None,
15111511
) -> tuple[Optional[PartialTransaction], bool, bool]:
15121512
d = ConfirmTxDialog(
15131513
window=self,

electrum/wallet.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1803,6 +1803,7 @@ def get_candidates_for_batching(
18031803
domain = self.get_addresses()
18041804
for hist_item in self.adb.get_history(domain):
18051805
# tx should not be mined yet
1806+
if hist_item.tx_mined_status.conf is None: continue
18061807
if hist_item.tx_mined_status.conf > 0: continue
18071808
# conservative future proofing of code: only allow known unconfirmed types
18081809
if hist_item.tx_mined_status.height() not in (
@@ -2008,20 +2009,21 @@ def make_unsigned_transaction(
20082009
coin_chooser = coinchooser.get_coin_chooser(self.config)
20092010
# If there is an unconfirmed RBF tx, merge with it
20102011
if base_tx:
2012+
assert base_tx.txid() is not None # pre-segwit and incomplete?
20112013
# make sure we don't try to spend change from the tx-to-be-replaced:
20122014
coins = [c for c in coins if c.prevout.txid.hex() != base_tx.txid()]
20132015
is_local = self.adb.get_tx_height(base_tx.txid()).height() == TX_HEIGHT_LOCAL
2014-
# estimate base tx fee before stripping tx for more accurate estimate
2015-
base_tx_fee = base_tx.get_fee()
2016-
base_feerate = Decimal(base_tx_fee)/base_tx.estimated_size()
2017-
relayfeerate = Decimal(self.relayfee()) / 1000
2018-
original_fee_estimator = fee_estimator
2016+
base_tx_size = base_tx.estimated_size() # estimate before stripping tx for more accurate estimate
20192017
if not isinstance(base_tx, PartialTransaction):
20202018
base_tx = PartialTransaction.from_tx(base_tx)
20212019
base_tx.add_info_from_wallet(self)
20222020
else:
20232021
# don't cast PartialTransaction, because it removes make_witness
20242022
base_tx.remove_signatures()
2023+
base_tx_fee = base_tx.get_fee() # FIXME could be None if some inputs are non-ismine
2024+
base_feerate = Decimal(base_tx_fee) / base_tx_size
2025+
relayfeerate = Decimal(self.relayfee()) / 1000
2026+
original_fee_estimator = fee_estimator
20252027
def fee_estimator(size: Union[int, float, Decimal]) -> int:
20262028
size = Decimal(size)
20272029
lower_bound_relayfee = int(base_tx_fee + round(size * relayfeerate)) if not is_local else 0

tests/test_wallet_vertical.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2159,7 +2159,7 @@ async def _rbf_sufficient_fee_increase_adding_outputs_to_base_tx(self, *, simula
21592159
tx2_feerate_sat_vb = tx2.get_fee() / tx2.estimated_size()
21602160
self.assertGreater(tx2_feerate_sat_vb, tx1_feerate_sat_vb, msg="tx2 feerate lower than tx1")
21612161

2162-
def _create_cause_carbon_wallet(self):
2162+
def _create_cause_carbon_wallet(self) -> tuple[Standard_Wallet, str, str]:
21632163
data = read_test_vector('cause_carbon_wallet.json')
21642164
ks = keystore.from_seed(data['seed'], passphrase='', for_multisig=False)
21652165
wallet = WalletIntegrityHelper.create_standard_wallet(ks, gap_limit=2, gap_limit_for_change=2, config=self.config)
@@ -2356,6 +2356,66 @@ async def test_rbf_batching__merge_duplicate_outputs(self):
23562356
wallet.adb.receive_tx_callback(tx3, tx_height=TX_HEIGHT_UNCONFIRMED)
23572357
self.assertEqual((0, 197_000, 0), wallet.get_balance())
23582358

2359+
async def test_rbf_batching__happypath_using_get_candidates_for_batching(self):
2360+
"""Test GUI simple-send RBF 'batch with' functionality:
2361+
- while there is already an unconfirmed outgoing tx1 in the wallet history,
2362+
- try making another payment, and batch it with tx1.
2363+
"""
2364+
wallet_faucet = self._create_cause_carbon_wallet()[0]
2365+
mywallet = self.create_standard_wallet_from_seed(
2366+
'response era cable net spike again observe dumb wage wonder sail tortoise',
2367+
config=self.config)
2368+
2369+
# fund mywallet with 2 UTXOs
2370+
def fund_mywallet_from_faucet():
2371+
addr = mywallet.get_receiving_address()
2372+
assert not mywallet.adb.is_used(addr)
2373+
funding_tx1 = wallet_faucet.make_unsigned_transaction(
2374+
outputs=[PartialTxOutput.from_address_and_value(addr, 40_000)],
2375+
fee_policy=FixedFeePolicy(200), locktime=4_000_000, rbf=True,
2376+
)
2377+
wallet_faucet.sign_transaction(funding_tx1, password=None)
2378+
wallet_faucet.adb.receive_tx_callback(funding_tx1, tx_height=TX_HEIGHT_UNCONFIRMED)
2379+
mywallet.adb.receive_tx_callback(funding_tx1, tx_height=TX_HEIGHT_UNCONFIRMED)
2380+
assert mywallet.adb.is_used(addr)
2381+
fund_mywallet_from_faucet()
2382+
fund_mywallet_from_faucet()
2383+
2384+
external_addr1 = wallet_faucet.get_receiving_addresses()[0]
2385+
external_addr2 = wallet_faucet.get_receiving_addresses()[1]
2386+
assert external_addr1 and external_addr2 and (external_addr1 != external_addr2)
2387+
2388+
# create outgoing tx1
2389+
output1 = PartialTxOutput.from_address_and_value(external_addr1, 30_000)
2390+
candidates1 = mywallet.get_candidates_for_batching([output1], coins=mywallet.get_spendable_coins(domain=None))
2391+
self.assertEqual(candidates1, [])
2392+
outgoing_tx1 = mywallet.make_unsigned_transaction(
2393+
outputs=[output1],
2394+
fee_policy=FixedFeePolicy(200), locktime=4_000_001, rbf=True,
2395+
)
2396+
mywallet.sign_transaction(outgoing_tx1, password=None)
2397+
mywallet.adb.receive_tx_callback(outgoing_tx1, tx_height=TX_HEIGHT_UNCONFIRMED)
2398+
2399+
# create outgoing tx2, and try to batch it with tx1
2400+
output2 = PartialTxOutput.from_address_and_value(external_addr2, 30_000)
2401+
candidates2 = mywallet.get_candidates_for_batching([output2], coins=mywallet.get_spendable_coins(domain=None))
2402+
self.assertEqual(1, len(candidates2))
2403+
base_tx = candidates2[0]
2404+
self.assertEqual(outgoing_tx1.txid(), base_tx.txid())
2405+
outgoing_tx2 = mywallet.make_unsigned_transaction(
2406+
outputs=[output2],
2407+
fee_policy=FixedFeePolicy(200), locktime=4_000_001, rbf=True,
2408+
base_tx=base_tx,
2409+
)
2410+
mywallet.sign_transaction(outgoing_tx2, password=None)
2411+
mywallet.adb.receive_tx_callback(outgoing_tx2, tx_height=TX_HEIGHT_UNCONFIRMED)
2412+
2413+
self.assertEqual(3, len(outgoing_tx2.outputs()))
2414+
self.assertIn(output1, outgoing_tx2.outputs())
2415+
self.assertIn(output2, outgoing_tx2.outputs())
2416+
self.assertEqual('02000000000102ab99cf51f3e219735c49491a9ecf354517a215a2b462227df7e7624188a7ff830000000000fdffffff417bbd802768510a0c641fc79af6e691a01fd10f37b84162f253b594e5cb87c50100000000fdffffff032c4c0000000000001600144e1b662f616fe134430054e29295ea6e5c18f17330750000000000001600142266c890fad71396f106319368107d5b2a1146fe307500000000000016001476efaaa243327bf3a2c0f5380cb3914099448cec02473044022033f8ddcb07ad7e06cef417208a5244507157cccdd6817029e968ec60a2395add02200720eb6a7c8cea86b470398ce75d69abcc66624a2253b18ea81cd1566eb5132c0121029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7c0247304402205e00156d74bcd85ed26ee9d0bdbd72890656881e25c04b2ac94f1c6b91f1176f02205f94d055e6385b48fbd3ac1a2dc2f57a640f4b33740cd9fb9e0fc20eb0cf5dcb012102c1ed648e71f15643950b444b864ab784b9d0e31e6ca6ec7d849d3dda4d98da0501093d00',
2417+
str(outgoing_tx2))
2418+
23592419
async def test_join_psbts__merge_duplicate_outputs(self):
23602420
"""txos paying to the same address might be merged into a single output with a larger value"""
23612421
rawtx1 = "70736274ff01007102000000019264597cffcce8f0c17b16a02adca7a95ae90f2ea51bd4b4df60c76dfe86686e0000000000fdffffff02400d03000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b6108c0400000000001600144e1b662f616fe134430054e29295ea6e5c18f173c2ad26000001011f20a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa050100de02000000000101013548c9019890e27ce9e58766de05f18ea40ede70751fb6cd7a3a1715ece0a30100000000fdffffff0220a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa05485a080000000000160014bc69f7d82c403a9f35dfb6d1a4531d6b19cab0e3024730440220346b200f21c3024e1d51fb4ecddbdbd68bd24ae7b9dfd501519f6dcbeb7c052402200617e3ce7b0eb308e30caf23894fb0388b68fb1c15dd0681dd13ae5e735f148101210360d0c9ef15b8b6a16912d341ad218a4e4e4e07e9347f4a2dbc7ca8d974f8bc9ec1ad26002206029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7c101f1b48320000008000000000000000000000220203db4846ec1841f48484590e67fcd7d1039f124a04410c5794f38ec8625329ea23101f1b483200000080010000000000000000"

0 commit comments

Comments
 (0)