1 #!/usr/bin/env python3
2 # Copyright (c) 2023-present The Limenka developers
3 # Distributed under the MIT software license, see the accompanying
4 # file COPYING or http://www.opensource.org/licenses/mit-license.php.
5 """Test for assumeutxo wallet related behavior.
6 See feature_assumeutxo.py for background.
7 8 ## Possible test improvements
9 10 - TODO: test loading a wallet (backup) on a pruned node
11 12 """
13 from test_framework.address import address_to_scriptpubkey
14 from test_framework.descriptors import descsum_create
15 from test_framework.test_framework import LimenkaTestFramework
16 from test_framework.messages import COIN
17 from test_framework.util import (
18 assert_equal,
19 assert_raises_rpc_error,
20 ensure_for,
21 )
22 from test_framework.wallet import MiniWallet
23 from test_framework.wallet_util import get_generate_key
24 25 START_HEIGHT = 199
26 SNAPSHOT_BASE_HEIGHT = 299
27 FINAL_HEIGHT = 399
28 29 30 class AssumeutxoTest(LimenkaTestFramework):
31 def skip_test_if_missing_module(self):
32 self.skip_if_no_wallet()
33 34 def add_options(self, parser):
35 self.add_wallet_options(parser, legacy=False)
36 37 def set_test_params(self):
38 """Use the pregenerated, deterministic chain up to height 199."""
39 self.num_nodes = 3
40 self.rpc_timeout = 120
41 self.extra_args = [
42 [],
43 [],
44 [],
45 ]
46 47 def setup_network(self):
48 """Start with the nodes disconnected so that one can generate a snapshot
49 including blocks the other hasn't yet seen."""
50 self.add_nodes(3)
51 self.start_nodes(extra_args=self.extra_args)
52 53 def import_descriptor(self, node, wallet_name, key, timestamp):
54 import_request = [{"desc": descsum_create("pkh(" + key.pubkey + ")"),
55 "timestamp": timestamp,
56 "label": "Descriptor import test"}]
57 wrpc = node.get_wallet_rpc(wallet_name)
58 return wrpc.importdescriptors(import_request)
59 60 def run_test(self):
61 """
62 Bring up two (disconnected) nodes, mine some new blocks on the first,
63 and generate a UTXO snapshot.
64 65 Load the snapshot into the second, ensure it syncs to tip and completes
66 background validation when connected to the first.
67 """
68 n0 = self.nodes[0]
69 n1 = self.nodes[1]
70 n2 = self.nodes[2]
71 72 self.mini_wallet = MiniWallet(n0)
73 74 # Mock time for a deterministic chain
75 for n in self.nodes:
76 n.setmocktime(n.getblockheader(n.getbestblockhash())['time'])
77 78 # Create a wallet that we will create a backup for later (at snapshot height)
79 n0.createwallet('w')
80 w = n0.get_wallet_rpc("w")
81 w_address = w.getnewaddress()
82 83 # Create another wallet and backup now (before snapshot height)
84 n0.createwallet('w2')
85 w2 = n0.get_wallet_rpc("w2")
86 w2_address = w2.getnewaddress()
87 w2.backupwallet("backup_w2.dat")
88 89 # Generate a series of blocks that `n0` will have in the snapshot,
90 # but that n1 doesn't yet see. In order for the snapshot to activate,
91 # though, we have to ferry over the new headers to n1 so that it
92 # isn't waiting forever to see the header of the snapshot's base block
93 # while disconnected from n0.
94 for i in range(100):
95 if i % 3 == 0:
96 self.mini_wallet.send_self_transfer(from_node=n0)
97 self.generate(n0, nblocks=1, sync_fun=self.no_op)
98 newblock = n0.getblock(n0.getbestblockhash(), 0)
99 100 # make n1 aware of the new header, but don't give it the block.
101 n1.submitheader(newblock)
102 n2.submitheader(newblock)
103 104 # Ensure everyone is seeing the same headers.
105 for n in self.nodes:
106 assert_equal(n.getblockchaininfo()[
107 "headers"], SNAPSHOT_BASE_HEIGHT)
108 109 # This backup is created at the snapshot height, so it's
110 # not part of the background sync anymore
111 w.backupwallet("backup_w.dat")
112 113 self.log.info("-- Testing assumeutxo")
114 115 assert_equal(n0.getblockcount(), SNAPSHOT_BASE_HEIGHT)
116 assert_equal(n1.getblockcount(), START_HEIGHT)
117 118 self.log.info(
119 f"Creating a UTXO snapshot at height {SNAPSHOT_BASE_HEIGHT}")
120 dump_output = n0.dumptxoutset('utxos.dat', "latest")
121 122 assert_equal(
123 dump_output['txoutset_hash'],
124 "a4bf3407ccb2cc0145c49ebba8fa91199f8a3903daf0883875941497d2493c27")
125 assert_equal(dump_output["nchaintx"], 334)
126 assert_equal(n0.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
127 128 # Mine more blocks on top of the snapshot that n1 hasn't yet seen. This
129 # will allow us to test n1's sync-to-tip on top of a snapshot.
130 w_skp = address_to_scriptpubkey(w_address)
131 w2_skp = address_to_scriptpubkey(w2_address)
132 for i in range(100):
133 if i % 3 == 0:
134 self.mini_wallet.send_to(from_node=n0, scriptPubKey=w_skp, amount=1 * COIN)
135 self.mini_wallet.send_to(from_node=n0, scriptPubKey=w2_skp, amount=10 * COIN)
136 self.generate(n0, nblocks=1, sync_fun=self.no_op)
137 138 assert_equal(n0.getblockcount(), FINAL_HEIGHT)
139 assert_equal(n1.getblockcount(), START_HEIGHT)
140 assert_equal(n2.getblockcount(), START_HEIGHT)
141 142 assert_equal(n0.getblockchaininfo()["blocks"], FINAL_HEIGHT)
143 144 self.log.info(
145 f"Loading snapshot into second node from {dump_output['path']}")
146 loaded = n1.loadtxoutset(dump_output['path'])
147 assert_equal(loaded['coins_loaded'], SNAPSHOT_BASE_HEIGHT)
148 assert_equal(loaded['base_height'], SNAPSHOT_BASE_HEIGHT)
149 150 normal, snapshot = n1.getchainstates()["chainstates"]
151 assert_equal(normal['blocks'], START_HEIGHT)
152 assert_equal(normal.get('snapshot_blockhash'), None)
153 assert_equal(normal['validated'], True)
154 assert_equal(snapshot['blocks'], SNAPSHOT_BASE_HEIGHT)
155 assert_equal(snapshot['snapshot_blockhash'], dump_output['base_hash'])
156 assert_equal(snapshot['validated'], False)
157 158 assert_equal(n1.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
159 160 self.log.info("Backup from the snapshot height can be loaded during background sync")
161 n1.restorewallet("w", "backup_w.dat")
162 # Balance of w wallet is still still 0 because n1 has not synced yet
163 assert_equal(n1.getbalance(), 0)
164 165 self.log.info("Backup from before the snapshot height can't be loaded during background sync")
166 assert_raises_rpc_error(-4, "Wallet loading failed. Error loading wallet. Wallet requires blocks to be downloaded, and software does not currently support loading wallets while blocks are being downloaded out of order when using assumeutxo snapshots. Wallet should be able to load successfully after node sync reaches height 299", n1.restorewallet, "w2", "backup_w2.dat")
167 168 self.log.info("Test loading descriptors during background sync")
169 wallet_name = "w1"
170 n1.createwallet(wallet_name, disable_private_keys=True)
171 key = get_generate_key()
172 time = n1.getblockchaininfo()['time']
173 timestamp = 0
174 expected_error_message = f"Rescan failed for descriptor with timestamp {timestamp}. There was an error reading a block from time {time}, which is after or within 7200 seconds of key creation, and could contain transactions pertaining to the desc. As a result, transactions and coins using this desc may not appear in the wallet. This error is likely caused by an in-progress assumeutxo background sync. Check logs or getchainstates RPC for assumeutxo background sync progress and try again later."
175 result = self.import_descriptor(n1, wallet_name, key, timestamp)
176 assert_equal(result[0]['error']['code'], -1)
177 assert_equal(result[0]['error']['message'], expected_error_message)
178 179 self.log.info("Test that rescanning blocks from before the snapshot fails when blocks are not available from the background sync yet")
180 w1 = n1.get_wallet_rpc(wallet_name)
181 assert_raises_rpc_error(-1, "Failed to rescan unavailable blocks likely due to an in-progress assumeutxo background sync. Check logs or getchainstates RPC for assumeutxo background sync progress and try again later.", w1.rescanblockchain, 100)
182 183 PAUSE_HEIGHT = FINAL_HEIGHT - 40
184 185 self.log.info("Restarting node to stop at height %d", PAUSE_HEIGHT)
186 self.restart_node(1, extra_args=[
187 f"-stopatheight={PAUSE_HEIGHT}", *self.extra_args[1]])
188 189 # Finally connect the nodes and let them sync.
190 #
191 # Set `wait_for_connect=False` to avoid a race between performing connection
192 # assertions and the -stopatheight tripping.
193 self.connect_nodes(0, 1, wait_for_connect=False)
194 195 n1.wait_until_stopped(timeout=5)
196 197 self.log.info(
198 "Restarted node before snapshot validation completed, reloading...")
199 self.restart_node(1, extra_args=self.extra_args[1])
200 201 # TODO: inspect state of e.g. the wallet before reconnecting
202 self.connect_nodes(0, 1)
203 204 self.log.info(
205 f"Ensuring snapshot chain syncs to tip. ({FINAL_HEIGHT})")
206 self.wait_until(lambda: n1.getchainstates()[
207 'chainstates'][-1]['blocks'] == FINAL_HEIGHT)
208 self.sync_blocks(nodes=(n0, n1))
209 210 self.log.info("Ensuring background validation completes")
211 self.wait_until(lambda: len(n1.getchainstates()['chainstates']) == 1)
212 213 self.log.info("Ensuring wallet can be restored from a backup that was created before the snapshot height")
214 n1.restorewallet("w2", "backup_w2.dat")
215 # Check balance of w2 wallet
216 assert_equal(n1.getbalance(), 340)
217 218 # Check balance of w wallet after node is synced
219 n1.loadwallet("w")
220 w = n1.get_wallet_rpc("w")
221 assert_equal(w.getbalance(), 34)
222 223 self.log.info("Check balance of a wallet that is active during snapshot completion")
224 n2.restorewallet("w", "backup_w.dat")
225 loaded = n2.loadtxoutset(dump_output['path'])
226 self.connect_nodes(0, 2)
227 self.wait_until(lambda: len(n2.getchainstates()['chainstates']) == 1)
228 ensure_for(duration=1, f=lambda: (n2.getbalance() == 34))
229 230 self.log.info("Ensuring descriptors can be loaded after background sync")
231 n1.loadwallet(wallet_name)
232 result = self.import_descriptor(n1, wallet_name, key, timestamp)
233 assert_equal(result[0]['success'], True)
234 235 236 if __name__ == '__main__':
237 AssumeutxoTest(__file__).main()
238