Skip to content
Bitcoin Roots

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()