Files
bitcoin/test/functional/wallet_multiwallet.py
David Gumberg 0d1301b47a test: functional: drop rmtree usage and add lint check
`shutil.rmtree` is dangerous because it recursively deletes. There are
not likely to be any issues with it's current uses, but it is possible
that some of the assumptions being made now won't always be true, e.g.
about what some of the variables being passed to `rmtree` represent.

For some remaining uses of rmtree that can't be avoided for now, use
`cleanup_dir` which asserts that the recursively deleted folder is a
child of the the `tmpdir` of the test run. Otherwise,
`tempfile.TemporaryDirectory` should be used which does it's own
deleting on being garbage collected, or old fashioned unlinking and
rmdir in the case of directories with known contents.
2026-03-24 16:06:35 -07:00

464 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) 2017-present The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test multiwallet.
Verify that a bitcoind node can load multiple wallet files
"""
from threading import Thread
import os
import platform
import shutil
import stat
from test_framework.authproxy import JSONRPCException
from test_framework.blocktools import COINBASE_MATURITY
from test_framework.test_framework import BitcoinTestFramework
from test_framework.test_node import ErrorMatch
from test_framework.util import (
assert_equal,
assert_raises_rpc_error,
ensure_for,
get_rpc_proxy,
)
got_loading_error = False
def test_load_unload(node, name):
global got_loading_error
while True:
if got_loading_error:
return
try:
node.loadwallet(name)
node.unloadwallet(name)
except JSONRPCException as e:
if e.error['code'] == -4 and 'Wallet already loading' in e.error['message']:
got_loading_error = True
return
def data_dir(node, *p):
return os.path.join(node.chain_path, *p)
def wallet_dir(node, *p):
return data_dir(node, 'wallets', *p)
def get_wallet(node, name):
return node.get_wallet_rpc(name)
class MultiWalletTest(BitcoinTestFramework):
def set_test_params(self):
self.setup_clean_chain = True
self.num_nodes = 2
self.rpc_timeout = 120
self.extra_args = [["-nowallet"], []]
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def wallet_file(self, node, name):
if name == self.default_wallet_name:
return wallet_dir(node, self.default_wallet_name, self.wallet_data_filename)
if os.path.isdir(wallet_dir(node, name)):
return wallet_dir(node, name, "wallet.dat")
return wallet_dir(node, name)
def run_test(self):
self.check_chmod = True
self.check_symlinks = True
if platform.system() == 'Windows':
# Additional context:
# - chmod: Posix has one user per file while Windows has an ACL approach
# - symlinks: GCC 13 has FIXME notes for symlinks under Windows:
# https://gcc.gnu.org/git/?p=gcc.git;a=blob;f=libstdc%2B%2B-v3/src/filesystem/ops-common.h;h=ba377905a2e90f7baf30c900b090f1f732397e08;hb=refs/heads/releases/gcc-13#l124
self.log.warning('Skipping chmod+symlink checks on Windows: '
'chmod works differently due to how access rights work and '
'symlink behavior with regard to the standard library is non-standard on cross-built binaries.')
self.check_chmod = False
self.check_symlinks = False
elif os.geteuid() == 0:
self.log.warning('Skipping checks involving chmod as they require non-root permissions.')
self.check_chmod = False
node = self.nodes[0]
assert_equal(node.listwalletdir(), {'wallets': [{'name': self.default_wallet_name, "warnings": []}]})
# check wallet.dat is created
self.stop_nodes()
assert_equal(os.path.isfile(wallet_dir(node, self.default_wallet_name, self.wallet_data_filename)), True)
self.test_scanning_main_dir_access(node)
empty_wallet, empty_created_wallet, wallet_names, in_wallet_dir = self.test_mixed_wallets(node)
self.test_scanning_sub_dir(node, in_wallet_dir)
self.test_scanning_symlink_levels(node, in_wallet_dir)
self.test_init(node, wallet_names)
self.test_balances_and_fees(node, wallet_names, in_wallet_dir)
w1, w2 = self.test_loading(node, wallet_names)
self.test_creation(node, in_wallet_dir)
self.test_unloading(node, in_wallet_dir, w1, w2)
self.test_backup_and_restore(node, wallet_names, empty_wallet, empty_created_wallet)
self.test_lock_file_closed(node)
def test_scanning_main_dir_access(self, node):
if not self.check_chmod:
return
self.log.info("Verify warning is emitted when failing to scan the wallets directory")
self.start_node(0)
with node.assert_debug_log(unexpected_msgs=['Error scanning directory entries under'], expected_msgs=[]):
result = node.listwalletdir()
assert_equal(result, {'wallets': [{'name': 'default_wallet', 'warnings': []}]})
os.chmod(data_dir(node, 'wallets'), 0)
with node.assert_debug_log(expected_msgs=['Error scanning directory entries under']):
result = node.listwalletdir()
assert_equal(result, {'wallets': []})
self.stop_node(0)
# Restore permissions
os.chmod(data_dir(node, 'wallets'), stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
def test_mixed_wallets(self, node):
self.log.info("Test mixed wallets")
# create symlink to verify wallet directory path can be referenced
# through symlink
os.mkdir(wallet_dir(node, 'w7'))
os.symlink('w7', wallet_dir(node, 'w7_symlink'))
if self.check_symlinks:
os.symlink('..', wallet_dir(node, 'recursive_dir_symlink'))
# rename wallet.dat to make sure plain wallet file paths (as opposed to
# directory paths) can be loaded
# create another dummy wallet for use in testing backups later
self.start_node(0)
node.createwallet("empty")
node.createwallet("plain")
node.createwallet("created")
self.stop_nodes()
empty_wallet = os.path.join(self.options.tmpdir, 'empty.dat')
os.rename(self.wallet_file(node, "empty"), empty_wallet)
os.rmdir(wallet_dir(node, "empty"))
empty_created_wallet = os.path.join(self.options.tmpdir, 'empty.created.dat')
os.rename(wallet_dir(node, "created", self.wallet_data_filename), empty_created_wallet)
os.rmdir(wallet_dir(node, "created"))
os.rename(self.wallet_file(node, "plain"), wallet_dir(node, "w8"))
os.rmdir(wallet_dir(node, "plain"))
# restart node with a mix of wallet names:
# w1, w2, w3 - to verify new wallets created when non-existing paths specified
# w - to verify wallet name matching works when one wallet path is prefix of another
# sub/w5 - to verify relative wallet path is created correctly
# extern/w6 - to verify absolute wallet path is created correctly
# w7_symlink - to verify symlinked wallet path is initialized correctly
# w8 - to verify existing wallet file is loaded correctly. Not tested for SQLite wallets as this is a deprecated BDB behavior.
# '' - to verify default wallet file is created correctly
to_create = ['w1', 'w2', 'w3', 'w', 'sub/w5', 'w7_symlink']
in_wallet_dir = [w.replace('/', os.path.sep) for w in to_create] # Wallets in the wallet dir
in_wallet_dir.append('w7') # w7 is not loaded or created, but will be listed by listwalletdir because w7_symlink
to_create.append(os.path.join(self.options.tmpdir, 'extern/w6')) # External, not in the wallet dir, so we need to avoid adding it to in_wallet_dir
to_load = [self.default_wallet_name]
wallet_names = to_create + to_load # Wallet names loaded in the wallet
in_wallet_dir += to_load # The loaded wallets are also in the wallet dir
self.start_node(0)
for wallet_name in to_create:
node.createwallet(wallet_name)
for wallet_name in to_load:
node.loadwallet(wallet_name)
return empty_wallet, empty_created_wallet, wallet_names, in_wallet_dir
def test_scanning_sub_dir(self, node, in_wallet_dir):
if not self.check_chmod:
return
self.log.info("Test scanning for sub directories")
# Baseline, no errors.
with node.assert_debug_log(expected_msgs=[], unexpected_msgs=["Error while scanning wallet dir"]):
walletlist = node.listwalletdir()['wallets']
assert_equal(sorted(map(lambda w: w['name'], walletlist)), sorted(in_wallet_dir))
# "Permission denied" error.
os.mkdir(wallet_dir(node, 'no_access'))
os.chmod(wallet_dir(node, 'no_access'), 0)
with node.assert_debug_log(expected_msgs=["Error while scanning wallet dir"]):
walletlist = node.listwalletdir()['wallets']
# Need to ensure access is restored for cleanup
os.chmod(wallet_dir(node, 'no_access'), stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
# Verify that we no longer emit errors after restoring permissions
with node.assert_debug_log(expected_msgs=[], unexpected_msgs=["Error while scanning wallet dir"]):
walletlist = node.listwalletdir()['wallets']
assert_equal(sorted(map(lambda w: w['name'], walletlist)), sorted(in_wallet_dir))
def test_scanning_symlink_levels(self, node, in_wallet_dir):
if not self.check_symlinks:
return
self.log.info("Test for errors from too many levels of symbolic links")
os.mkdir(wallet_dir(node, 'self_walletdat_symlink'))
os.symlink('wallet.dat', wallet_dir(node, 'self_walletdat_symlink/wallet.dat'))
with node.assert_debug_log(expected_msgs=["Error while scanning wallet dir"]):
walletlist = node.listwalletdir()['wallets']
assert_equal(sorted(map(lambda w: w['name'], walletlist)), sorted(in_wallet_dir))
def test_init(self, node, wallet_names):
self.log.info("Test initialization")
assert_equal(set(node.listwallets()), set(wallet_names))
# check that all requested wallets were created
self.stop_node(0)
for wallet_name in wallet_names:
assert_equal(os.path.isfile(self.wallet_file(node, wallet_name)), True)
node.assert_start_raises_init_error(['-walletdir=wallets'], 'Error: Specified -walletdir "wallets" does not exist')
node.assert_start_raises_init_error(['-walletdir=wallets'], 'Error: Specified -walletdir "wallets" is a relative path', cwd=data_dir(node))
node.assert_start_raises_init_error(['-walletdir=debug.log'], 'Error: Specified -walletdir "debug.log" is not a directory', cwd=data_dir(node))
self.start_node(0, ['-wallet=w1', '-wallet=w1'])
self.stop_node(0, 'Warning: Ignoring duplicate -wallet w1.')
# should not initialize if wallet file is a symlink
if self.check_symlinks:
os.symlink('w8', wallet_dir(node, 'w8_symlink'))
node.assert_start_raises_init_error(['-wallet=w8_symlink'], r'Error: Invalid -wallet path \'w8_symlink\'\. .*', match=ErrorMatch.FULL_REGEX)
# should not initialize if the specified walletdir does not exist
node.assert_start_raises_init_error(['-walletdir=bad'], 'Error: Specified -walletdir "bad" does not exist')
# should not initialize if the specified walletdir is not a directory
not_a_dir = wallet_dir(node, 'notadir')
open(not_a_dir, 'a').close()
node.assert_start_raises_init_error(['-walletdir=' + not_a_dir], 'Error: Specified -walletdir "' + not_a_dir + '" is not a directory')
# if wallets/ doesn't exist, datadir should be the default wallet dir
wallet_dir2 = data_dir(node, 'walletdir')
os.rename(wallet_dir(node), wallet_dir2)
self.start_node(0)
node.createwallet("w4")
node.createwallet("w5")
assert_equal(set(node.listwallets()), {"w4", "w5"})
w5 = get_wallet(node, "w5")
self.generatetoaddress(node, nblocks=1, address=w5.getnewaddress(), sync_fun=self.no_op)
# now if wallets/ exists again, but the rootdir is specified as the walletdir, w4 and w5 should still be loaded
os.rename(wallet_dir2, wallet_dir(node))
self.restart_node(0, ['-nowallet', '-walletdir=' + data_dir(node)])
node.loadwallet("w4")
node.loadwallet("w5")
assert_equal(set(node.listwallets()), {"w4", "w5"})
w5 = get_wallet(node, "w5")
assert_equal(w5.getbalances()["mine"]["immature"], 50)
competing_wallet_dir = os.path.join(self.options.tmpdir, 'competing_walletdir')
os.mkdir(competing_wallet_dir)
self.restart_node(0, ['-nowallet', '-walletdir=' + competing_wallet_dir])
node.createwallet(self.default_wallet_name)
exp_stderr = f"Error: SQLiteDatabase: Unable to obtain an exclusive lock on the database, is it being used by another instance of {self.config['environment']['CLIENT_NAME']}?"
self.nodes[1].assert_start_raises_init_error(['-walletdir=' + competing_wallet_dir], exp_stderr, match=ErrorMatch.PARTIAL_REGEX)
def test_balances_and_fees(self, node, wallet_names, in_wallet_dir):
self.log.info("Test balances and fees")
self.restart_node(0)
for wallet_name in wallet_names:
node.loadwallet(wallet_name)
assert_equal(sorted(map(lambda w: w['name'], node.listwalletdir()['wallets'])), sorted(in_wallet_dir))
wallets = [get_wallet(node, w) for w in wallet_names]
wallet_bad = get_wallet(node, "bad")
# check wallet names and balances
self.generatetoaddress(node, nblocks=1, address=wallets[0].getnewaddress(), sync_fun=self.no_op)
for wallet_name, wallet in zip(wallet_names, wallets):
info = wallet.getwalletinfo()
assert_equal(wallet.getbalances()["mine"]["immature"], 50 if wallet is wallets[0] else 0)
assert_equal(info['walletname'], wallet_name)
# accessing invalid wallet fails
assert_raises_rpc_error(-18, "Requested wallet does not exist or is not loaded", wallet_bad.getwalletinfo)
# accessing wallet RPC without using wallet endpoint fails
assert_raises_rpc_error(-19, "Multiple wallets are loaded. Please select which wallet", node.getwalletinfo)
w1, w2, w3, w4, *_ = wallets
self.generatetoaddress(node, nblocks=COINBASE_MATURITY + 1, address=w1.getnewaddress(), sync_fun=self.no_op)
assert_equal(w1.getbalance(), 100)
assert_equal(w2.getbalance(), 0)
assert_equal(w3.getbalance(), 0)
assert_equal(w4.getbalance(), 0)
w1.sendtoaddress(w2.getnewaddress(), 1)
w1.sendtoaddress(w3.getnewaddress(), 2)
w1.sendtoaddress(w4.getnewaddress(), 3)
self.generatetoaddress(node, nblocks=1, address=w1.getnewaddress(), sync_fun=self.no_op)
assert_equal(w2.getbalance(), 1)
assert_equal(w3.getbalance(), 2)
assert_equal(w4.getbalance(), 3)
batch = w1.batch([w1.getblockchaininfo.get_request(), w1.getwalletinfo.get_request()])
assert_equal(batch[0]["result"]["chain"], self.chain)
assert_equal(batch[1]["result"]["walletname"], "w1")
def test_loading(self, node, wallet_names):
self.log.info("Test dynamic wallet loading")
self.restart_node(0, ['-nowallet'])
assert_equal(node.listwallets(), [])
assert_raises_rpc_error(-18, "No wallet is loaded. Load a wallet using loadwallet or create a new one with createwallet. (Note: A default wallet is no longer automatically created)", node.getwalletinfo)
self.log.info("Load first wallet")
loadwallet_name = node.loadwallet(wallet_names[0])
assert_equal(loadwallet_name['name'], wallet_names[0])
assert_equal(node.listwallets(), wallet_names[0:1])
node.getwalletinfo()
w1 = get_wallet(node, wallet_names[0])
w1.getwalletinfo()
self.log.info("Load second wallet")
loadwallet_name = node.loadwallet(wallet_names[1])
assert_equal(loadwallet_name['name'], wallet_names[1])
assert_equal(node.listwallets(), wallet_names[0:2])
assert_raises_rpc_error(-19, "Multiple wallets are loaded. Please select which wallet", node.getwalletinfo)
w2 = get_wallet(node, wallet_names[1])
w2.getwalletinfo()
self.log.info("Concurrent wallet loading")
threads = []
for _ in range(3):
n = node.cli if self.options.usecli else get_rpc_proxy(node.url, 1, timeout=600, coveragedir=node.coverage_dir)
t = Thread(target=test_load_unload, args=(n, wallet_names[2]))
t.start()
threads.append(t)
for t in threads:
t.join()
global got_loading_error
assert_equal(got_loading_error, True)
self.log.info("Load remaining wallets")
for wallet_name in wallet_names[2:]:
loadwallet_name = node.loadwallet(wallet_name)
assert_equal(loadwallet_name['name'], wallet_name)
assert_equal(set(node.listwallets()), set(wallet_names))
# Fail to load if wallet doesn't exist
path = wallet_dir(node, "wallets")
assert_raises_rpc_error(-18, "Wallet file verification failed. Failed to load database path '{}'. Path does not exist.".format(path), node.loadwallet, 'wallets')
# Fail to load duplicate wallets
assert_raises_rpc_error(-35, "Wallet \"w1\" is already loaded.", node.loadwallet, wallet_names[0])
# Fail to load if wallet file is a symlink
if self.check_symlinks:
assert_raises_rpc_error(-4, "Wallet file verification failed. Invalid -wallet path 'w8_symlink'", node.loadwallet, 'w8_symlink')
# Fail to load if a directory is specified that doesn't contain a wallet
os.mkdir(wallet_dir(node, 'empty_wallet_dir'))
path = wallet_dir(node, "empty_wallet_dir")
assert_raises_rpc_error(-18, "Wallet file verification failed. Failed to load database path '{}'. Data is not in recognized format.".format(path), node.loadwallet, 'empty_wallet_dir')
return w1, w2
def test_creation(self, node, in_wallet_dir):
self.log.info("Test dynamic wallet creation")
# should raise rpc error if wallet path can't be created
err_code = -4
assert_raises_rpc_error(err_code, "Wallet file verification failed. ", node.createwallet, "w8/bad")
# Fail to create a wallet if it already exists.
path = wallet_dir(node, "w2")
assert_raises_rpc_error(-4, "Failed to create database path '{}'. Database already exists.".format(path), node.createwallet, 'w2')
# Successfully create a wallet with a new name
loadwallet_name = node.createwallet('w9')
in_wallet_dir.append('w9')
assert_equal(loadwallet_name['name'], 'w9')
w9 = get_wallet(node, 'w9')
assert_equal(w9.getwalletinfo()['walletname'], 'w9')
assert 'w9' in node.listwallets()
# Successfully create a wallet using a full path
new_wallet_dir = os.path.join(self.options.tmpdir, 'new_walletdir')
new_wallet_name = os.path.join(new_wallet_dir, 'w10')
loadwallet_name = node.createwallet(new_wallet_name)
assert_equal(loadwallet_name['name'], new_wallet_name)
w10 = get_wallet(node, new_wallet_name)
assert_equal(w10.getwalletinfo()['walletname'], new_wallet_name)
assert new_wallet_name in node.listwallets()
def test_unloading(self, node, in_wallet_dir, w1, w2):
self.log.info("Test dynamic wallet unloading")
# Test `unloadwallet` errors
assert_raises_rpc_error(-8, "Either the RPC endpoint wallet or the wallet name parameter must be provided", node.unloadwallet)
assert_raises_rpc_error(-18, "Requested wallet does not exist or is not loaded", node.unloadwallet, "dummy")
assert_raises_rpc_error(-18, "Requested wallet does not exist or is not loaded", get_wallet(node, "dummy").unloadwallet)
assert_raises_rpc_error(-8, "The RPC endpoint wallet and the wallet name parameter specify different wallets", w1.unloadwallet, "w2"),
# Successfully unload the specified wallet name
node.unloadwallet("w1")
assert 'w1' not in node.listwallets()
# Unload w1 again, this time providing the wallet name twice
node.loadwallet("w1")
assert 'w1' in node.listwallets()
w1.unloadwallet("w1")
assert 'w1' not in node.listwallets()
# Successfully unload the wallet referenced by the request endpoint
# Also ensure unload works during walletpassphrase timeout
w2.encryptwallet('test')
w2.walletpassphrase('test', 1)
w2.unloadwallet()
ensure_for(duration=1.1, f=lambda: 'w2' not in node.listwallets())
# Successfully unload all wallets
for wallet_name in node.listwallets():
node.unloadwallet(wallet_name)
assert_equal(node.listwallets(), [])
assert_raises_rpc_error(-18, "No wallet is loaded. Load a wallet using loadwallet or create a new one with createwallet. (Note: A default wallet is no longer automatically created)", node.getwalletinfo)
# Successfully load a previously unloaded wallet
node.loadwallet('w1')
assert_equal(node.listwallets(), ['w1'])
assert_equal(w1.getwalletinfo()['walletname'], 'w1')
assert_equal(sorted(map(lambda w: w['name'], node.listwalletdir()['wallets'])), sorted(in_wallet_dir))
def test_backup_and_restore(self, node, wallet_names, empty_wallet, empty_created_wallet):
self.log.info("Test wallet backup and restore")
self.restart_node(0, ['-nowallet'])
for wallet_name in wallet_names:
node.loadwallet(wallet_name)
for wallet_name in wallet_names:
rpc = get_wallet(node, wallet_name)
addr = rpc.getnewaddress()
backup = os.path.join(self.options.tmpdir, 'backup.dat')
if os.path.exists(backup):
os.unlink(backup)
rpc.backupwallet(backup)
node.unloadwallet(wallet_name)
shutil.copyfile(empty_created_wallet if wallet_name == self.default_wallet_name else empty_wallet, self.wallet_file(node, wallet_name))
node.loadwallet(wallet_name)
assert_equal(rpc.getaddressinfo(addr)['ismine'], False)
node.unloadwallet(wallet_name)
shutil.copyfile(backup, self.wallet_file(node, wallet_name))
node.loadwallet(wallet_name)
assert_equal(rpc.getaddressinfo(addr)['ismine'], True)
def test_lock_file_closed(self, node):
self.log.info("Test wallet lock file is closed")
self.start_node(1)
wallet = os.path.join(self.options.tmpdir, 'my_wallet')
node.createwallet(wallet)
assert_raises_rpc_error(-4, "Unable to obtain an exclusive lock", self.nodes[1].loadwallet, wallet)
node.unloadwallet(wallet)
self.nodes[1].loadwallet(wallet)
if __name__ == '__main__':
MultiWalletTest(__file__).main()