Skip to content

Commit 6e8cddb

Browse files
committed
test: Test for exportwatchonlywallet
1 parent 7775c54 commit 6e8cddb

File tree

2 files changed

+267
-0
lines changed

2 files changed

+267
-0
lines changed

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@
162162
'wallet_fast_rescan.py',
163163
'wallet_gethdkeys.py',
164164
'wallet_createwalletdescriptor.py',
165+
'wallet_exported_watchonly.py',
165166
'interface_zmq.py',
166167
'rpc_invalid_address_message.py',
167168
'rpc_validateaddress.py',
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2025-present The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or https://www.opensource.org/licenses/mit-license.php.
5+
6+
import os
7+
8+
from test_framework.descriptors import descsum_create
9+
from test_framework.key import H_POINT
10+
from test_framework.test_framework import BitcoinTestFramework
11+
from test_framework.util import (
12+
assert_equal,
13+
assert_not_equal,
14+
assert_raises_rpc_error,
15+
)
16+
from test_framework.wallet_util import generate_keypair
17+
18+
KEYPOOL_SIZE = 10
19+
20+
class WalletExportedWatchOnly(BitcoinTestFramework):
21+
def set_test_params(self):
22+
self.setup_clean_chain = True
23+
self.num_nodes = 2
24+
self.extra_args = [[], [f"-keypool={KEYPOOL_SIZE}"]]
25+
26+
def setup_network(self):
27+
# Setup the nodes but don't connect them to each other
28+
self.setup_nodes()
29+
30+
def skip_test_if_missing_module(self):
31+
self.skip_if_no_wallet()
32+
33+
def export_and_restore(self, wallet, export_name):
34+
export_path = os.path.join(self.export_path, f"{export_name}.dat")
35+
res = wallet.exportwatchonlywallet(export_path)
36+
assert_equal(res["exported_file"], export_path)
37+
self.online.restorewallet(export_name, res["exported_file"])
38+
return self.online.get_wallet_rpc(export_name)
39+
40+
def test_basic_export(self):
41+
self.log.info("Test basic watchonly wallet export")
42+
self.offline.createwallet("basic")
43+
offline_wallet = self.offline.get_wallet_rpc("basic")
44+
45+
# Bad RPC args
46+
assert_raises_rpc_error(-4, "Error: Export ", offline_wallet.exportwatchonlywallet, "")
47+
assert_raises_rpc_error(-4, "Error: Export destination '.' already exists", offline_wallet.exportwatchonlywallet, ".")
48+
assert_raises_rpc_error(-4, f"Error: Export destination '{self.export_path}' already exists", offline_wallet.exportwatchonlywallet, self.export_path)
49+
50+
# Export the watchonly wallet file and load onto online node
51+
online_wallet = self.export_and_restore(offline_wallet, "basic_watchonly")
52+
53+
# Exporting watchonly from a watchonly also works
54+
online_wallet2 = self.export_and_restore(online_wallet, "basic_watchonly2")
55+
56+
# Verify that the wallets have the same descriptors
57+
addr = offline_wallet.getnewaddress()
58+
assert_equal(addr, online_wallet.getnewaddress())
59+
assert_equal(addr, online_wallet2.getnewaddress())
60+
assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet.listdescriptors()["descriptors"])
61+
assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet2.listdescriptors()["descriptors"])
62+
63+
# Verify that online wallet cannot spend, but offline can
64+
self.funder.sendtoaddress(online_wallet.getnewaddress(), 10)
65+
self.generate(self.online, 1, sync_fun=self.no_op)
66+
assert_equal(online_wallet.getbalances()["mine"]["trusted"], 10)
67+
assert_equal(offline_wallet.getbalances()["mine"]["trusted"], 0)
68+
funds_addr = self.funder.getnewaddress()
69+
send_res = online_wallet.send([{funds_addr: 5}])
70+
assert_equal(send_res["complete"], False)
71+
assert "psbt" in send_res
72+
signed_psbt = offline_wallet.walletprocesspsbt(send_res["psbt"])["psbt"]
73+
finalized = self.online.finalizepsbt(signed_psbt)["hex"]
74+
self.online.sendrawtransaction(finalized)
75+
76+
# Verify that the change address is known to both wallets
77+
dec_tx = self.online.decoderawtransaction(finalized)
78+
for txout in dec_tx["vout"]:
79+
if txout["scriptPubKey"]["address"] == funds_addr:
80+
continue
81+
assert_equal(online_wallet.getaddressinfo(txout["scriptPubKey"]["address"])["ismine"], True)
82+
assert_equal(offline_wallet.getaddressinfo(txout["scriptPubKey"]["address"])["ismine"], True)
83+
84+
self.generate(self.online, 1, sync_fun=self.no_op)
85+
offline_wallet.unloadwallet()
86+
online_wallet.unloadwallet()
87+
88+
def test_export_with_address_book(self):
89+
self.log.info("Test all address book entries appear in the exported wallet")
90+
self.offline.createwallet("addrbook")
91+
offline_wallet = self.offline.get_wallet_rpc("addrbook")
92+
93+
# Create some address book entries
94+
receive_addr = offline_wallet.getnewaddress(label="addrbook_receive")
95+
send_addr = self.funder.getnewaddress()
96+
offline_wallet.setlabel(send_addr, "addrbook_send") # Sets purpose "send"
97+
98+
# Export the watchonly wallet file and load onto online node
99+
online_wallet = self.export_and_restore(offline_wallet, "addrbook_watchonly")
100+
101+
# Verify the labels are in both wallets
102+
for wallet in [online_wallet, offline_wallet]:
103+
for purpose in ["receive", "send"]:
104+
label = f"addrbook_{purpose}"
105+
assert_equal(wallet.listlabels(purpose), [label])
106+
addr = send_addr if purpose == "send" else receive_addr
107+
assert_equal(wallet.getaddressesbylabel(label), {addr: {"purpose": purpose}})
108+
109+
offline_wallet.unloadwallet()
110+
online_wallet.unloadwallet()
111+
112+
def test_export_with_txs_and_locked_coins(self):
113+
self.log.info("Test all transactions and locked coins appear in the exported wallet")
114+
self.offline.createwallet("txs")
115+
offline_wallet = self.offline.get_wallet_rpc("txs")
116+
117+
# In order to make transactions in the offline wallet, briefly connect offline to online
118+
self.connect_nodes(self.offline.index, self.online.index)
119+
txids = [self.funder.sendtoaddress(offline_wallet.getnewaddress("funds"), i) for i in range(1, 4)]
120+
self.generate(self.online, 1)
121+
self.disconnect_nodes(self.offline.index ,self.online.index)
122+
123+
# lock some coins
124+
persistent_lock = [{"txid": txids[0], "vout": 0}]
125+
temp_lock = [{"txid": txids[1], "vout": 0}]
126+
offline_wallet.lockunspent(unlock=False, transactions=persistent_lock, persistent=True)
127+
offline_wallet.lockunspent(unlock=False, transactions=temp_lock, persistent=False)
128+
129+
# Export the watchonly wallet file and load onto online node
130+
online_wallet = self.export_and_restore(offline_wallet, "txs_watchonly")
131+
132+
# Verify the transactions are in both wallets
133+
for txid in txids:
134+
assert_equal(online_wallet.gettransaction(txid), offline_wallet.gettransaction(txid))
135+
136+
# Verify that the persistent locked coin is locked in both wallets
137+
assert_equal(online_wallet.listlockunspent(), persistent_lock)
138+
assert_equal(sorted(offline_wallet.listlockunspent(), key=lambda x: x["txid"]), sorted(persistent_lock + temp_lock, key=lambda x: x["txid"]))
139+
140+
offline_wallet.unloadwallet()
141+
online_wallet.unloadwallet()
142+
143+
def test_export_imported_descriptors(self):
144+
self.log.info("Test imported descriptors are exported to the watchonly wallet")
145+
self.offline.createwallet("imports")
146+
offline_wallet = self.offline.get_wallet_rpc("imports")
147+
148+
import_res = offline_wallet.importdescriptors(
149+
[
150+
# A single key, non-ranged
151+
{"desc": descsum_create(f"pkh({generate_keypair(wif=True)[0]})"), "timestamp": "now"},
152+
# hardened derivation
153+
{"desc": descsum_create("sh(wpkh(tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/0'/*'))"), "timestamp": "now", "active": True},
154+
# multisig
155+
{"desc": descsum_create("wsh(multi(1,tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/*,tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/*))"), "timestamp": "now", "active": True, "internal": True},
156+
# taproot multi scripts
157+
{"desc": descsum_create(f"tr({H_POINT},{{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/*),pk(tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/0h/*)}})"), "timestamp": "now", "active": True},
158+
# miniscript
159+
{"desc": descsum_create(f"tr({H_POINT},or_b(pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/1/2/*),s:pk(tprv8ZgxMBicQKsPeuVhWwi6wuMQGfPKi9Li5GtX35jVNknACgqe3CY4g5xgkfDDJcmtF7o1QnxWDRYw4H5P26PXq7sbcUkEqeR4fg3Kxp2tigg/1h/2/*)))"), "timestamp": "now", "active": True, "internal": True},
160+
]
161+
)
162+
assert_equal(all([r["success"] for r in import_res]), True)
163+
164+
# Export the watchonly wallet file and load onto online node
165+
online_wallet = self.export_and_restore(offline_wallet, "imports_watchonly")
166+
167+
# Verify public descriptors are the same
168+
assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet.listdescriptors()["descriptors"])
169+
170+
# Verify all the addresses are the same
171+
for address_type in ["legacy", "p2sh-segwit", "bech32", "bech32m"]:
172+
for internal in [False, True]:
173+
if internal:
174+
addr = offline_wallet.getrawchangeaddress(address_type=address_type)
175+
assert_equal(addr, online_wallet.getrawchangeaddress(address_type=address_type))
176+
else:
177+
addr = offline_wallet.getnewaddress(address_type=address_type)
178+
assert_equal(addr, online_wallet.getnewaddress(address_type=address_type))
179+
self.funder.sendtoaddress(addr, 1)
180+
self.generate(self.online, 1, sync_fun=self.no_op)
181+
182+
# The hardened derivation should have KEYPOOL_SIZE - 1 remaining addresses
183+
for _ in range(KEYPOOL_SIZE - 1):
184+
online_wallet.getnewaddress(address_type="p2sh-segwit")
185+
assert_raises_rpc_error(-12, "No addresses available", online_wallet.getnewaddress, address_type="p2sh-segwit")
186+
187+
# Verify that the offline wallet can sign and send
188+
send_res = online_wallet.sendall([self.funder.getnewaddress()])
189+
assert_equal(send_res["complete"], False)
190+
assert "psbt" in send_res
191+
signed_psbt = offline_wallet.walletprocesspsbt(send_res["psbt"])["psbt"]
192+
finalized = self.online.finalizepsbt(signed_psbt)["hex"]
193+
self.online.sendrawtransaction(finalized)
194+
195+
self.generate(self.online, 1, sync_fun=self.no_op)
196+
offline_wallet.unloadwallet()
197+
online_wallet.unloadwallet()
198+
199+
def test_avoid_reuse(self):
200+
self.log.info("Test that the avoid reuse flag appears in the exported wallet")
201+
self.offline.createwallet(wallet_name="avoidreuse", avoid_reuse=True)
202+
offline_wallet = self.offline.get_wallet_rpc("avoidreuse")
203+
assert_equal(offline_wallet.getwalletinfo()["avoid_reuse"], True)
204+
205+
# The avoid_reuse flag also sets some specific address book entries to track reused addresses
206+
# In order for these to be set, a few transactions need to be made, so briefly connect offline to online
207+
self.connect_nodes(self.offline.index, self.online.index)
208+
addr = offline_wallet.getnewaddress()
209+
self.funder.sendtoaddress(addr, 1)
210+
self.generate(self.online, 1)
211+
# Spend funds in order to mark addr as previously spent
212+
offline_wallet.sendall([self.funder.getnewaddress()])
213+
self.funder.sendtoaddress(addr, 1)
214+
self.generate(self.online, 1)
215+
assert_equal(offline_wallet.listunspent(addresses=[addr])[0]["reused"], True)
216+
self.disconnect_nodes(self.offline.index ,self.online.index)
217+
218+
# Export the watchonly wallet file and load onto online node
219+
online_wallet = self.export_and_restore(offline_wallet, "avoidreuse_watchonly")
220+
221+
# check avoid_reuse is still set
222+
assert_equal(online_wallet.getwalletinfo()["avoid_reuse"], True)
223+
assert_equal(online_wallet.listunspent(addresses=[addr])[0]["reused"], True)
224+
225+
offline_wallet.unloadwallet()
226+
online_wallet.unloadwallet()
227+
228+
def test_encrypted_wallet(self):
229+
self.log.info("Test that a watchonly wallet can be exported from a locked wallet")
230+
self.offline.createwallet(wallet_name="encrypted", passphrase="pass")
231+
offline_wallet = self.offline.get_wallet_rpc("encrypted")
232+
assert_equal(offline_wallet.getwalletinfo()["unlocked_until"], 0)
233+
234+
# Export the watchonly wallet file and load onto online node
235+
online_wallet = self.export_and_restore(offline_wallet, "encrypted_watchonly")
236+
237+
# watchonly wallet does not have encryption because it doesn't have private keys
238+
assert "unlocked_until" not in online_wallet.getwalletinfo()
239+
# But it still has all of the public descriptors
240+
assert_equal(offline_wallet.listdescriptors()["descriptors"], online_wallet.listdescriptors()["descriptors"])
241+
242+
offline_wallet.unloadwallet()
243+
online_wallet.unloadwallet()
244+
245+
def run_test(self):
246+
self.online = self.nodes[0]
247+
self.offline = self.nodes[1]
248+
self.funder = self.online.get_wallet_rpc(self.default_wallet_name)
249+
self.export_path = os.path.join(self.options.tmpdir, "exported_wallets")
250+
os.makedirs(self.export_path, exist_ok=True)
251+
252+
# Mine some blocks, and verify disconnected
253+
self.generate(self.online, 101, sync_fun=self.no_op)
254+
assert_not_equal(self.online.getbestblockhash(), self.offline.getbestblockhash())
255+
assert_equal(self.online.getblockcount(), 101)
256+
assert_equal(self.offline.getblockcount(), 0)
257+
258+
self.test_basic_export()
259+
self.test_export_with_address_book()
260+
self.test_export_with_txs_and_locked_coins()
261+
self.test_export_imported_descriptors()
262+
self.test_avoid_reuse()
263+
self.test_encrypted_wallet()
264+
265+
if __name__ == '__main__':
266+
WalletExportedWatchOnly(__file__).main()

0 commit comments

Comments
 (0)