v31.1-roots.1 · Commit 6: [PATCH 06/17] feat(wallet): preserve Roots signing and spend controls on v31
test/functional/wallet_sweepprivkeys.py
Before: /dev/null · After: test/functional/wallet_sweepprivkeys.py
File metadata
new file mode 100644 index 0000000000000000000000000000000000000000..ea319bb3e407131f63ff45ea7d4fc7b6a87a7cbc
Download original patch · Release notes
Diff rows 1–232 of 232. Line numbers refer to the original before/after files.
+ Added− RemovedUnmarked lines are context.
Scroll the code area horizontally to read long lines. Before is on the left; after is on the right. Unified view fits narrow screens more easily.
test/functional/wallet_sweepprivkeys.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Exercise transient-key sweep preview and broadcast without import."""
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from test_framework.address import key_to_p2pkh, key_to_p2sh_p2wpkh, key_to_p2wpkh
|
| 7 |
+
from test_framework.descriptors import descsum_create
|
| 8 |
+
from test_framework.test_framework import BitcoinTestFramework
|
| 9 |
+
from test_framework.util import assert_equal, assert_raises_rpc_error
|
| 10 |
+
from test_framework.wallet_util import generate_keypair
|
| 11 |
+
|
| 12 |
+
COIN = Decimal(100_000_000)
|
| 13 |
+
|
| 14 |
+
class SweepPrivKeysTest(BitcoinTestFramework):
|
| 15 |
+
def set_test_params(self):
|
| 16 |
+
self.num_nodes = 2
|
| 17 |
+
self.extra_args = [[], []]
|
| 18 |
+
|
| 19 |
+
def skip_test_if_missing_module(self):
|
| 20 |
+
self.skip_if_no_wallet()
|
| 21 |
+
|
| 22 |
+
def run_test(self):
|
| 23 |
+
node, miner = self.nodes
|
| 24 |
+
wallet = node.get_wallet_rpc(node.listwallets()[0])
|
| 25 |
+
destination = wallet.getnewaddress()
|
| 26 |
+
wif, pubkey = generate_keypair(wif=True)
|
| 27 |
+
uncompressed_wif, uncompressed_pubkey = generate_keypair(compressed=False, wif=True)
|
| 28 |
+
race_wif, race_pubkey = generate_keypair(wif=True)
|
| 29 |
+
empty_wif, _ = generate_keypair(wif=True)
|
| 30 |
+
self.generate(miner, 101)
|
| 31 |
+
assert_raises_rpc_error(-6, 'No spendable UTXOs found', wallet.sweepprivkeys, [empty_wif], destination)
|
| 32 |
+
for address in (key_to_p2pkh(pubkey), key_to_p2wpkh(pubkey), key_to_p2sh_p2wpkh(pubkey), key_to_p2pkh(uncompressed_pubkey)):
|
| 33 |
+
miner.sendtoaddress(address, 1)
|
| 34 |
+
race_address = key_to_p2pkh(race_pubkey)
|
| 35 |
+
race_txid = miner.sendtoaddress(race_address, 1)
|
| 36 |
+
self.generate(miner, 1)
|
| 37 |
+
race_tx = miner.decoderawtransaction(miner.gettransaction(race_txid)['hex'])
|
| 38 |
+
race_vout = next(vout for vout in race_tx['vout'] if vout['scriptPubKey'].get('address') == race_address)
|
| 39 |
+
race_prevout = {'txid': race_txid, 'vout': race_vout['n'], 'scriptPubKey': race_vout['scriptPubKey']['hex'], 'amount': race_vout['value']}
|
| 40 |
+
race_raw = miner.createrawtransaction([race_prevout], {miner.getnewaddress(): Decimal('0.99990000')})
|
| 41 |
+
race_signed = miner.signrawtransactionwithkey(race_raw, [race_wif], [race_prevout])
|
| 42 |
+
assert_equal(race_signed['complete'], True)
|
| 43 |
+
miner.sendrawtransaction(race_signed['hex'])
|
| 44 |
+
race_block = self.generate(miner, 1)[0]
|
| 45 |
+
self.sync_blocks()
|
| 46 |
+
assert_raises_rpc_error(-6, 'No spendable UTXOs found', wallet.sweepprivkeys, [race_wif], destination)
|
| 47 |
+
node.invalidateblock(race_block)
|
| 48 |
+
mempool_before_preview = wallet.getrawmempool()
|
| 49 |
+
stale_preview = wallet.sweepprivkeys([race_wif], destination)
|
| 50 |
+
assert_equal(stale_preview['broadcast'], False)
|
| 51 |
+
assert_equal(stale_preview['inputs'], 1)
|
| 52 |
+
assert_equal(wallet.getrawmempool(), mempool_before_preview)
|
| 53 |
+
node.reconsiderblock(race_block)
|
| 54 |
+
assert_raises_rpc_error(-6, 'No spendable UTXOs found', wallet.sweepprivkeys, [race_wif], destination)
|
| 55 |
+
conflict_wif, conflict_pubkey = generate_keypair(wif=True)
|
| 56 |
+
conflict_address = key_to_p2pkh(conflict_pubkey)
|
| 57 |
+
conflict_txid = miner.sendtoaddress(conflict_address, 1)
|
| 58 |
+
self.generate(miner, 1)
|
| 59 |
+
self.sync_blocks()
|
| 60 |
+
conflict_tx = miner.decoderawtransaction(miner.gettransaction(conflict_txid)['hex'])
|
| 61 |
+
conflict_vout = next(v for v in conflict_tx['vout'] if v['scriptPubKey'].get('address') == conflict_address)
|
| 62 |
+
prevout = {'txid': conflict_txid, 'vout': conflict_vout['n'], 'scriptPubKey': conflict_vout['scriptPubKey']['hex'], 'amount': conflict_vout['value']}
|
| 63 |
+
raw = miner.createrawtransaction([prevout], {miner.getnewaddress(): Decimal('0.99990000')})
|
| 64 |
+
competing = miner.signrawtransactionwithkey(raw, [conflict_wif], [prevout])
|
| 65 |
+
conflict_id = miner.sendrawtransaction(competing['hex'])
|
| 66 |
+
self.sync_mempools()
|
| 67 |
+
rejected_state = wallet.listdescriptors(), wallet.listlabels(), wallet.getwalletinfo()['keypoolsize']
|
| 68 |
+
assert_raises_rpc_error(-4, 'Sweep transaction broadcast failed: insufficient fee, rejecting replacement', wallet.sweepprivkeys, [conflict_wif], destination, True)
|
| 69 |
+
assert_equal(wallet.getrawmempool(), [conflict_id])
|
| 70 |
+
assert_equal((wallet.listdescriptors(), wallet.listlabels(), wallet.getwalletinfo()['keypoolsize']), rejected_state)
|
| 71 |
+
assert_equal(wallet.getaddressinfo(conflict_address)['ismine'], False)
|
| 72 |
+
descriptors = wallet.listdescriptors()
|
| 73 |
+
labels = wallet.listlabels()
|
| 74 |
+
keypoolsize = wallet.getwalletinfo()['keypoolsize']
|
| 75 |
+
|
| 76 |
+
assert_raises_rpc_error(-5, 'Invalid private key', wallet.sweepprivkeys, ['invalid'], destination)
|
| 77 |
+
assert_raises_rpc_error(-5, 'Invalid private key', wallet.sweepprivkeys, ['KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn'], destination)
|
| 78 |
+
assert_raises_rpc_error(-8, 'Duplicate private key', wallet.sweepprivkeys, [wif, wif], destination)
|
| 79 |
+
assert_raises_rpc_error(-4, 'Destination is not controlled', wallet.sweepprivkeys, [wif], miner.getnewaddress())
|
| 80 |
+
node.createwallet(wallet_name='other')
|
| 81 |
+
other = node.get_wallet_rpc('other')
|
| 82 |
+
assert_raises_rpc_error(-4, 'Destination is not controlled', wallet.sweepprivkeys, [wif], other.getnewaddress())
|
| 83 |
+
node.createwallet(wallet_name='watch', disable_private_keys=True)
|
| 84 |
+
watch = node.get_wallet_rpc('watch')
|
| 85 |
+
watch.importdescriptors([{'desc': descsum_create(f"wpkh({wallet.getaddressinfo(destination)['pubkey']})"), 'timestamp': 'now'}])
|
| 86 |
+
assert_raises_rpc_error(-4, 'Destination is not controlled', watch.sweepprivkeys, [wif], destination)
|
| 87 |
+
|
| 88 |
+
# Native descriptor import rejects a wholly public-only descriptor in a
|
| 89 |
+
# local-key wallet; it must not turn into an eligible sweep destination.
|
| 90 |
+
_, public_only_pubkey = generate_keypair(wif=True)
|
| 91 |
+
public_only_destination = key_to_p2wpkh(public_only_pubkey)
|
| 92 |
+
imported = wallet.importdescriptors([{'desc': descsum_create(f'wpkh({public_only_pubkey.hex()})'), 'timestamp': 'now'}])
|
| 93 |
+
assert_equal(imported[0]['success'], False)
|
| 94 |
+
assert_equal(imported[0]['error']['message'], 'Cannot import descriptor without private keys to a wallet with private keys enabled')
|
| 95 |
+
assert_equal(wallet.getaddressinfo(public_only_destination)['ismine'], False)
|
| 96 |
+
assert_raises_rpc_error(-4, 'Destination is not controlled', wallet.sweepprivkeys, [wif], public_only_destination)
|
| 97 |
+
|
| 98 |
+
# Ownership alone includes partial descriptors. Require a satisfiable
|
| 99 |
+
# local signature threshold, while accepting a sufficient subset.
|
| 100 |
+
local1, _ = generate_keypair(wif=True)
|
| 101 |
+
local2, _ = generate_keypair(wif=True)
|
| 102 |
+
_, foreign = generate_keypair(wif=True)
|
| 103 |
+
partial_destinations = []
|
| 104 |
+
for keys, sufficient in (([local1, foreign.hex()], False), ([local1, local2, foreign.hex()], True)):
|
| 105 |
+
descriptor = descsum_create(f'wsh(multi(2,{",".join(keys)}))')
|
| 106 |
+
assert_equal(wallet.importdescriptors([{'desc': descriptor, 'timestamp': 'now'}])[0]['success'], True)
|
| 107 |
+
partial_destination = node.deriveaddresses(node.getdescriptorinfo(descriptor)['descriptor'])[0]
|
| 108 |
+
assert_equal(wallet.getaddressinfo(partial_destination)['ismine'], True)
|
| 109 |
+
partial_destinations.append((partial_destination, sufficient))
|
| 110 |
+
if sufficient:
|
| 111 |
+
assert_equal(wallet.sweepprivkeys([wif], partial_destination)['broadcast'], False)
|
| 112 |
+
else:
|
| 113 |
+
assert_raises_rpc_error(-4, 'Destination is not controlled', wallet.sweepprivkeys, [wif], partial_destination)
|
| 114 |
+
|
| 115 |
+
self.restart_node(0, extra_args=['-fallbackfee=0.00001000'])
|
| 116 |
+
self.connect_nodes(0, 1)
|
| 117 |
+
node = self.nodes[0]
|
| 118 |
+
wallet = node.get_wallet_rpc(node.listwallets()[0])
|
| 119 |
+
self.sync_blocks()
|
| 120 |
+
# Loading may refresh descriptor metadata; compare the same loaded state
|
| 121 |
+
# immediately before and after the operation to test no-key-import.
|
| 122 |
+
descriptors = wallet.listdescriptors()
|
| 123 |
+
labels = wallet.listlabels()
|
| 124 |
+
keypoolsize = wallet.getwalletinfo()['keypoolsize']
|
| 125 |
+
preview = wallet.sweepprivkeys(privkeys=[wif, uncompressed_wif], destination=destination)
|
| 126 |
+
assert_equal(preview['broadcast'], False)
|
| 127 |
+
assert_equal(preview['inputs'], 4)
|
| 128 |
+
decoded = node.decoderawtransaction(preview['hex'])
|
| 129 |
+
required_fee = Decimal(decoded['vsize']) / COIN
|
| 130 |
+
assert preview['fee'] >= required_fee
|
| 131 |
+
assert preview['fee'] - required_fee <= Decimal('0.00000001')
|
| 132 |
+
assert_equal(preview['amount'] - sum(output['value'] for output in decoded['vout']), preview['fee'])
|
| 133 |
+
assert_equal(wallet.listdescriptors(), descriptors)
|
| 134 |
+
assert_equal(wallet.listlabels(), labels)
|
| 135 |
+
assert_equal(wallet.getwalletinfo()['keypoolsize'], keypoolsize)
|
| 136 |
+
assert_equal(wallet.getaddressinfo(key_to_p2pkh(pubkey))['ismine'], False)
|
| 137 |
+
assert_equal(wallet.getrawmempool(), [conflict_id])
|
| 138 |
+
|
| 139 |
+
wallet.encryptwallet('pass')
|
| 140 |
+
for partial_destination, sufficient in partial_destinations:
|
| 141 |
+
if sufficient:
|
| 142 |
+
assert_equal(wallet.sweepprivkeys([wif], partial_destination)['broadcast'], False)
|
| 143 |
+
else:
|
| 144 |
+
assert_raises_rpc_error(-4, 'Destination is not controlled', wallet.sweepprivkeys, [wif], partial_destination)
|
| 145 |
+
assert_equal(wallet.getwalletinfo()['unlocked_until'], 0)
|
| 146 |
+
sent = wallet.sweepprivkeys([wif, uncompressed_wif], destination, True)
|
| 147 |
+
assert_equal(sent['broadcast'], True)
|
| 148 |
+
assert_equal(sent['txid'], preview['txid'])
|
| 149 |
+
assert sent['txid'] in wallet.getrawmempool()
|
| 150 |
+
rejected_descriptors = wallet.listdescriptors()
|
| 151 |
+
rejected_labels = wallet.listlabels()
|
| 152 |
+
rejected_keypoolsize = wallet.getwalletinfo()['keypoolsize']
|
| 153 |
+
duplicate = wallet.sweepprivkeys([wif, uncompressed_wif], destination, True)
|
| 154 |
+
assert_equal(duplicate['txid'], sent['txid'])
|
| 155 |
+
assert_equal(set(wallet.getrawmempool()), {conflict_id, sent['txid']})
|
| 156 |
+
assert_equal(wallet.listdescriptors(), rejected_descriptors)
|
| 157 |
+
assert_equal(wallet.listlabels(), rejected_labels)
|
| 158 |
+
assert_equal(wallet.getwalletinfo()['keypoolsize'], rejected_keypoolsize)
|
| 159 |
+
assert_equal(wallet.getaddressinfo(key_to_p2pkh(pubkey))['ismine'], False)
|
| 160 |
+
self.sync_mempools()
|
| 161 |
+
self.generate(miner, 1)
|
| 162 |
+
self.sync_blocks()
|
| 163 |
+
assert_raises_rpc_error(-6, 'No spendable UTXOs found', wallet.sweepprivkeys, [wif, uncompressed_wif], destination)
|
| 164 |
+
|
| 165 |
+
small_wif, small_pubkey = generate_keypair(wif=True)
|
| 166 |
+
miner.sendtoaddress(key_to_p2pkh(small_pubkey), Decimal('0.00010000'))
|
| 167 |
+
self.generate(miner, 1)
|
| 168 |
+
self.sync_blocks()
|
| 169 |
+
wallet_name = wallet.getwalletinfo()['walletname']
|
| 170 |
+
dust_destination = wallet.getnewaddress(address_type='legacy')
|
| 171 |
+
self.restart_node(0, extra_args=['-fallbackfee=0.00052000'])
|
| 172 |
+
self.connect_nodes(0, 1)
|
| 173 |
+
wallet = self.nodes[0].get_wallet_rpc(wallet_name)
|
| 174 |
+
assert_raises_rpc_error(-4, 'Swept output would be dust', wallet.sweepprivkeys, [small_wif], dust_destination)
|
| 175 |
+
self.restart_node(0, extra_args=['-fallbackfee=0.10000000'])
|
| 176 |
+
self.connect_nodes(0, 1)
|
| 177 |
+
wallet = self.nodes[0].get_wallet_rpc(wallet_name)
|
| 178 |
+
assert_raises_rpc_error(-6, 'Fee exceeds swept value', wallet.sweepprivkeys, [small_wif], destination)
|
| 179 |
+
assert wif not in Path(self.options.tmpdir, 'node0', 'regtest', 'debug.log').read_text()
|
| 180 |
+
|
| 181 |
+
# A sweep output is an ordinary wallet UTXO after confirmation. Exercise
|
| 182 |
+
# it through explicit coin control only after the encrypted wallet has
|
| 183 |
+
# restarted and been unloaded/reloaded, so neither lifecycle handling
|
| 184 |
+
# nor a stale sweep state can silently substitute another input.
|
| 185 |
+
lifecycle_wif, lifecycle_pubkey = generate_keypair(wif=True)
|
| 186 |
+
miner.sendtoaddress(key_to_p2wpkh(lifecycle_pubkey), 1)
|
| 187 |
+
self.generate(miner, 1)
|
| 188 |
+
self.sync_blocks()
|
| 189 |
+
lifecycle_preview = wallet.sweepprivkeys([lifecycle_wif], destination)
|
| 190 |
+
lifecycle_sweep = wallet.sweepprivkeys([lifecycle_wif], destination, True)
|
| 191 |
+
assert_equal(lifecycle_sweep['txid'], lifecycle_preview['txid'])
|
| 192 |
+
self.sync_mempools()
|
| 193 |
+
self.generate(miner, 1)
|
| 194 |
+
self.sync_blocks()
|
| 195 |
+
self.wait_until(lambda: any(utxo['txid'] == lifecycle_sweep['txid'] for utxo in wallet.listunspent()))
|
| 196 |
+
lifecycle_utxo = next(utxo for utxo in wallet.listunspent() if utxo['txid'] == lifecycle_sweep['txid'])
|
| 197 |
+
lifecycle_input = {'txid': lifecycle_utxo['txid'], 'vout': lifecycle_utxo['vout']}
|
| 198 |
+
wallet.lockunspent(False, [lifecycle_input])
|
| 199 |
+
assert_equal(wallet.listlockunspent(), [lifecycle_input])
|
| 200 |
+
wallet.lockunspent(True, [lifecycle_input])
|
| 201 |
+
|
| 202 |
+
wallet_name = wallet.getwalletinfo()['walletname']
|
| 203 |
+
self.restart_node(0, expected_stderr='Warning: -fallbackfee is set very high! This is the transaction fee you may pay when fee estimates are not available.')
|
| 204 |
+
wallet = self.nodes[0].get_wallet_rpc(wallet_name)
|
| 205 |
+
assert_equal(wallet.getaddressinfo(key_to_p2pkh(pubkey))['ismine'], False)
|
| 206 |
+
assert wif not in Path(self.options.tmpdir, 'node0', 'regtest', 'debug.log').read_text()
|
| 207 |
+
self.nodes[0].unloadwallet(wallet_name)
|
| 208 |
+
assert wallet_name not in self.nodes[0].listwallets()
|
| 209 |
+
self.nodes[0].loadwallet(wallet_name)
|
| 210 |
+
wallet = self.nodes[0].get_wallet_rpc(wallet_name)
|
| 211 |
+
assert_equal(wallet.getaddressinfo(key_to_p2pkh(pubkey))['ismine'], False)
|
| 212 |
+
wallet.walletpassphrase('pass', 60)
|
| 213 |
+
controlled_send = wallet.send(
|
| 214 |
+
outputs={miner.getnewaddress(): Decimal('0.5')},
|
| 215 |
+
options={
|
| 216 |
+
'inputs': [lifecycle_input],
|
| 217 |
+
'add_inputs': False,
|
| 218 |
+
'fee_rate': 1,
|
| 219 |
+
'replaceable': True,
|
| 220 |
+
},
|
| 221 |
+
)
|
| 222 |
+
assert_equal(controlled_send['complete'], True)
|
| 223 |
+
controlled_tx = self.nodes[0].decoderawtransaction(self.nodes[0].getrawtransaction(controlled_send['txid']))
|
| 224 |
+
assert_equal(len(controlled_tx['vin']), 1)
|
| 225 |
+
assert_equal(controlled_tx['vin'][0]['txid'], lifecycle_input['txid'])
|
| 226 |
+
assert_equal(controlled_tx['vin'][0]['vout'], lifecycle_input['vout'])
|
| 227 |
+
assert controlled_tx['vin'][0]['sequence'] < 0xfffffffe
|
| 228 |
+
assert controlled_send['txid'] in wallet.getrawmempool()
|
| 229 |
+
assert lifecycle_wif not in Path(self.options.tmpdir, 'node0', 'regtest', 'debug.log').read_text()
|
| 230 |
+
|
| 231 |
+
if __name__ == '__main__':
|
| 232 |
+
SweepPrivKeysTest(__file__).main()
|