The class TxBatcher handles the creation, broadcast and replacement
of replaceable transactions. Callers (LNWatcher, SwapManager) use
methods add_payment_output and add_sweep_info. Transactions
created by TxBatcher may combine sweeps and outgoing payments.
Transactions created by TxBatcher will have their fee bumped
automatically (this was only the case for sweeps before).
TxBatcher manages several TxBatches. TxBatches are created
dynamically when needed.
The GUI does not touch txbatcher transactions:
- wallet.get_candidates_for_batching excludes txbatcher
transactions
- RBF dialogs do not work with txbatcher transactions
wallet:
- instead of reading config variables, make_unsigned_transaction
takes new parameters: base_tx, send_change_to_lighting
tests:
- unit tests in test_txbatcher.py (replaces test_sswaps.py)
- force all regtests to use MPP, so that we sweep transactions
with several HTLCs. This forces the payment manager to aggregate
first-stage HTLC tx inputs. second-stage are not batched for now.
217 lines
11 KiB
Python
217 lines
11 KiB
Python
import unittest
|
|
import logging
|
|
from unittest import mock
|
|
import asyncio
|
|
|
|
from electrum import storage, bitcoin, keystore, wallet
|
|
from electrum import Transaction
|
|
from electrum import SimpleConfig
|
|
from electrum import util
|
|
from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_LOCAL
|
|
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput, TxOutpoint
|
|
from electrum.logging import console_stderr_handler, Logger
|
|
from electrum.submarine_swaps import SwapManager, SwapData
|
|
from electrum.lnsweep import SweepInfo
|
|
from electrum.fee_policy import FeeTimeEstimates
|
|
|
|
from . import ElectrumTestCase
|
|
from .test_wallet_vertical import WalletIntegrityHelper
|
|
|
|
class MockNetwork(Logger):
|
|
|
|
def __init__(self, config):
|
|
self.config = config
|
|
self.fee_estimates = FeeTimeEstimates()
|
|
self.asyncio_loop = util.get_asyncio_loop()
|
|
self.interface = None
|
|
self.relay_fee = 1000
|
|
self.wallets = []
|
|
self._tx_event = asyncio.Event()
|
|
|
|
def get_local_height(self):
|
|
return 42
|
|
|
|
def blockchain(self):
|
|
class BlockchainMock:
|
|
def is_tip_stale(self):
|
|
return True
|
|
return BlockchainMock()
|
|
|
|
async def try_broadcasting(self, tx, name):
|
|
for w in self.wallets:
|
|
w.adb.receive_tx_callback(tx, TX_HEIGHT_UNCONFIRMED)
|
|
|
|
self._tx_event.set()
|
|
self._tx = tx
|
|
self._tx_event.clear()
|
|
return tx.txid()
|
|
|
|
async def next_tx(self):
|
|
await self._tx_event.wait()
|
|
return self._tx
|
|
|
|
|
|
WALLET_SEED = 'cause carbon luggage air humble mistake melt paper supreme sense gravity void'
|
|
FUNDING_TX = '020000000001021798e10f8b7220c57ea0d605316a52453ca9b3eed99996b5b7bdf4699548bb520000000000fdffffff277d82678d238ca45dd3490ac9fbb49272f0980b093b9197ff70ec8eb082cfb00100000000fdffffff028c360100000000001600147a9bfd90821be827275023849dd91ee80d494957a08601000000000016001476efaaa243327bf3a2c0f5380cb3914099448cec024730440220354b2a74f5ac039cca3618f7ff98229d243b89ac40550c8b027894f2c5cb88ff022064cb5ab1539b4c5367c2e01a8362e0aa12c2732bc8d08c3fce6eab9e56b7fe19012103e0a1499cb3d8047492c60466722c435dfbcffae8da9b83e758fbd203d12728f502473044022073cef8b0cfb093aed5b8eaacbb58c2fa6a69405a8e266cd65e76b726c9151d7602204d5820b23ab96acc57c272aac96d94740a20a6b89c016aa5aed7c06d1e6b9100012102f09e50a265c6a0dcf7c87153ea73d7b12a0fbe9d7d0bbec5db626b2402c1e85c02fa2400'
|
|
SWAP_FUNDING_TX = "01000000000101500e9d67647481864edfb020b5c45e1c40d90f06b0130f9faed1a5149c6d26450000000000ffffffff0226080300000000002200205059c44bf57534303ab8f090f06b7bde58f5d2522440247a1ff6b41bdca9348df312c20100000000160014021d4f3b17921d790e1c022367a5bb078ce4deb402483045022100d41331089a2031396a1db8e4dec6dda9cacefe1288644b92f8e08a23325aa19b02204159230691601f7d726e4e6e0b7124d3377620f400d699a01095f0b0a09ee26a012102d60315c72c0cefd41c6d07883c20b88be3fc37aac7912f0052722a95de0de71600000000"
|
|
SWAP_CLAIM_TX = "02000000000101f9db8580febd5c0f85b6f1576c83f7739109e3a2d772743e3217e9537fea7e890000000000fdffffff017005030000000000160014b113a47f3718da3fd161339a6681c150fef2cfe30347304402206736066ce15d34eed20951a9d974a100a72dc034f9c878769ddf27f9a584dcb1022042b14d627b8e8465a3a129bb43c0bd8369f49bbcf473879b9a477263655f1f930120f1939b5723155713855d7ebea6e174f77d41d669269e7f138856c3de190e7a366a8201208763a914d7a62ef0270960fe23f0f351b28caadab62c21838821030bfd61153816df786036ea293edce851d3a4b9f4a1c66bdc1a17f00ffef3d6b167750334ef24b1752102fc8128f17f9e666ea281c702171ab16c1dd2a4337b71f08970f5aa10c608a93268ac00000000"
|
|
|
|
|
|
class TestTxBatcher(ElectrumTestCase):
|
|
|
|
TESTNET = True
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
console_stderr_handler.setLevel(logging.DEBUG)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.config = SimpleConfig({'electrum_path': self.electrum_path})
|
|
self.fee_policy_descriptor = 'feerate:5000'
|
|
|
|
async def asyncSetUp(self):
|
|
await super().asyncSetUp()
|
|
self.network = MockNetwork(self.config)
|
|
|
|
def create_standard_wallet_from_seed(self, seed_words, *, config=None, gap_limit=2):
|
|
if config is None:
|
|
config = self.config
|
|
ks = keystore.from_seed(seed_words, passphrase='', for_multisig=False)
|
|
return WalletIntegrityHelper.create_standard_wallet(ks, gap_limit=gap_limit, config=config)
|
|
|
|
@mock.patch.object(wallet.Abstract_Wallet, 'save_db')
|
|
async def test_batch_payments(self, mock_save_db):
|
|
# output 1: tx1(o1) ---------------
|
|
# \
|
|
# output 2: tx1'(o1,o2) ----> tx2(tx1|o2)
|
|
#
|
|
# tx1 is broadcast, and replaced by tx1'
|
|
# tx1 gets mined
|
|
# txbatcher creates a new transaction tx2, child of tx1
|
|
#
|
|
OUTGOING_ADDRESS = 'tb1q7rl9cxr85962ztnsze089zs8ycv52hk43f3m9n'
|
|
# create wallet
|
|
wallet = self.create_standard_wallet_from_seed(WALLET_SEED)
|
|
wallet.start_network(self.network)
|
|
wallet.txbatcher.SLEEP_INTERVAL = 0.01
|
|
self.network.wallets.append(wallet)
|
|
# fund wallet
|
|
funding_tx = Transaction(FUNDING_TX)
|
|
await self.network.try_broadcasting(funding_tx, 'funding')
|
|
assert wallet.adb.get_transaction(funding_tx.txid()) is not None
|
|
self.logger.info(f'wallet balance {wallet.get_balance()}')
|
|
# payment 1 -> tx1(output1)
|
|
output1 = PartialTxOutput.from_address_and_value(OUTGOING_ADDRESS, 10_000)
|
|
wallet.txbatcher.add_payment_output('default', output1, self.fee_policy_descriptor)
|
|
tx1 = await self.network.next_tx()
|
|
assert output1 in tx1.outputs()
|
|
# payment 2 -> tx2(output1, output2)
|
|
output2 = PartialTxOutput.from_address_and_value(OUTGOING_ADDRESS, 20_000)
|
|
wallet.txbatcher.add_payment_output('default', output2, self.fee_policy_descriptor)
|
|
tx1_prime = await self.network.next_tx()
|
|
assert wallet.adb.get_transaction(tx1_prime.txid()) is not None
|
|
assert len(tx1_prime.outputs()) == 3
|
|
assert output1 in tx1_prime.outputs()
|
|
assert output2 in tx1_prime.outputs()
|
|
# tx1 gets confirmed, tx2 gets removed
|
|
wallet.adb.receive_tx_callback(tx1, 1)
|
|
tx_mined_status = wallet.adb.get_tx_height(tx1.txid())
|
|
wallet.adb.add_verified_tx(tx1.txid(), tx_mined_status._replace(conf=1))
|
|
assert wallet.adb.get_transaction(tx1.txid()) is not None
|
|
assert wallet.adb.get_transaction(tx1_prime.txid()) is None
|
|
# txbatcher creates tx2
|
|
tx2 = await self.network.next_tx()
|
|
assert output1 in tx1.outputs()
|
|
assert output2 in tx2.outputs()
|
|
# check that tx2 is child of tx1
|
|
assert len(tx2.inputs()) == 1
|
|
assert tx2.inputs()[0].prevout.txid.hex() == tx1.txid()
|
|
|
|
|
|
@mock.patch.object(wallet.Abstract_Wallet, 'save_db')
|
|
async def test_rbf_batching__cannot_batch_as_would_need_to_use_ismine_outputs_of_basetx(self, mock_save_db):
|
|
"""Wallet history contains unconf tx1 that spends all its coins to two ismine outputs,
|
|
one 'recv' address (20k sats) and one 'change' (80k sats).
|
|
The user tries to create tx2, that pays an invoice for 90k sats.
|
|
The tx batcher fails to batch, and should create a child transaction
|
|
"""
|
|
wallet = self.create_standard_wallet_from_seed(WALLET_SEED)
|
|
wallet.start_network(self.network)
|
|
wallet.txbatcher.SLEEP_INTERVAL = 0.01
|
|
wallet.txbatcher.RETRY_DELAY = 0.60
|
|
self.network.wallets.append(wallet)
|
|
|
|
# fund wallet
|
|
funding_tx = Transaction(FUNDING_TX)
|
|
await self.network.try_broadcasting(funding_tx, 'funding')
|
|
assert wallet.adb.get_transaction(funding_tx.txid()) is not None
|
|
self.logger.info(f'wallet balance1 {wallet.get_balance()}')
|
|
|
|
# to_self_payment tx1
|
|
output1 = PartialTxOutput.from_address_and_value("tb1qyfnv3y866ufedugxxxfksyratv4pz3h78g9dad", 20_000)
|
|
wallet.txbatcher.add_payment_output('default', output1, self.fee_policy_descriptor)
|
|
toself_tx = await self.network.next_tx()
|
|
assert len(toself_tx.outputs()) == 2
|
|
assert output1 in toself_tx.outputs()
|
|
|
|
# outgoing payment tx2
|
|
output2 = PartialTxOutput.from_address_and_value("tb1qkfn0fude7z789uys2u7sf80kd4805zpvs3na0h", 90_000)
|
|
wallet.txbatcher.add_payment_output('default', output2, self.fee_policy_descriptor)
|
|
tx2 = await self.network.next_tx()
|
|
assert len(tx2.outputs()) == 2
|
|
assert output2 in tx2.outputs()
|
|
|
|
|
|
@mock.patch.object(wallet.Abstract_Wallet, 'save_db')
|
|
async def test_sweep_from_submarine_swap(self, mock_save_db):
|
|
self.maxDiff = None
|
|
# create wallet
|
|
wallet = self.create_standard_wallet_from_seed(WALLET_SEED)
|
|
wallet.start_network(self.network)
|
|
wallet.txbatcher.SLEEP_INTERVAL = 0.01
|
|
self.network.wallets.append(wallet)
|
|
# add swap data
|
|
swap_data = SwapData(
|
|
is_reverse=True,
|
|
locktime=2420532,
|
|
onchain_amount=198694,
|
|
lightning_amount=200000,
|
|
redeem_script=bytes.fromhex('8201208763a914d7a62ef0270960fe23f0f351b28caadab62c21838821030bfd61153816df786036ea293edce851d3a4b9f4a1c66bdc1a17f00ffef3d6b167750334ef24b1752102fc8128f17f9e666ea281c702171ab16c1dd2a4337b71f08970f5aa10c608a93268ac'),
|
|
preimage=bytes.fromhex('f1939b5723155713855d7ebea6e174f77d41d669269e7f138856c3de190e7a36'),
|
|
prepay_hash=None,
|
|
privkey=bytes.fromhex('58fd0018a9a2737d1d6b81d380df96bf0c858473a9592015508a270a7c9b1d8d'),
|
|
lockup_address='tb1q2pvugjl4w56rqw4c7zg0q6mmmev0t5jjy3qzg7sl766phh9fxjxsrtl77t',
|
|
receive_address='tb1ql0adrj58g88xgz375yct63rclhv29hv03u0mel',
|
|
funding_txid='897eea7f53e917323e7472d7a2e3099173f7836c57f1b6850f5cbdfe8085dbf9',
|
|
spending_txid=None,
|
|
is_redeemed=False,
|
|
)
|
|
wallet.adb.db.transactions[swap_data.funding_txid] = Transaction(SWAP_FUNDING_TX)
|
|
txin = PartialTxInput(
|
|
prevout=TxOutpoint(txid=bytes.fromhex(swap_data.funding_txid), out_idx=0),
|
|
)
|
|
txin._trusted_value_sats = swap_data.onchain_amount
|
|
txin, locktime = SwapManager.create_claim_txin(txin=txin, swap=swap_data)
|
|
sweep_info = SweepInfo(
|
|
txin=txin,
|
|
csv_delay=0,
|
|
cltv_abs=locktime,
|
|
txout=None,
|
|
name='swap claim',
|
|
can_be_batched=True,
|
|
)
|
|
wallet.txbatcher.add_sweep_input('swaps', sweep_info, self.fee_policy_descriptor)
|
|
tx = await self.network.next_tx()
|
|
txid = tx.txid()
|
|
self.assertEqual(SWAP_CLAIM_TX, str(tx))
|
|
# add a new payment, reusing the same input
|
|
# this tests that txin.make_witness() can be called more than once
|
|
output1 = PartialTxOutput.from_address_and_value("tb1qyfnv3y866ufedugxxxfksyratv4pz3h78g9dad", 20_000)
|
|
wallet.txbatcher.add_payment_output('swaps', output1, self.fee_policy_descriptor)
|
|
new_tx = await self.network.next_tx()
|
|
# check that we batched with previous tx
|
|
assert new_tx.inputs()[0].prevout == tx.inputs()[0].prevout == txin.prevout
|
|
assert output1 in new_tx.outputs()
|