Merge pull request #10893 from f321x/fix_10891
What changed, and why it matters
This commit fixes a bug in the Electrum Bitcoin wallet that could crash or fail when trying to move funds from a watch-only wallet that only has imported addresses (no private keys). The fix makes the wallet more careful when it doesn't know the exact script type for an address, returning 'unknown' instead of crashing, and only setting a script descriptor when one actually exists. There is no direct evidence this was a security vulnerability exploitable by an attacker; it appears to be a reliability/functional bug fix.
No immediate security action required. Treat as a normal bug fix. Users relying on imported-address watch-only wallets should update to benefit from improved reliability when sweeping or constructing transactions.
Security signals we found
Fixes a runtime exception path in transaction construction for imported watch-only wallets
Adds defensive null-check before assigning script_descriptor
Changes get_txin_type to safely handle missing imported addresses
Adds regression tests for the fixed behavior
Evidence from the diff
The patch addresses issue #10891. In wallet.py, add_input_info now only assigns txin.script_descriptor if get_script_descriptor_for_address returns a non-None value, preventing an AttributeError or downstream failure for imported watch-only addresses. get_txin_type in imported-address wallets now returns ‘unknown’ when the address is not found in the database, instead of raising an exception on None. Tests are added/updated to cover these cases and a new watch-only self-sweep scenario.
Changed components
electrum/wallet.pytests/test_wallet.pytests/test_wallet_vertical.pyInspect captured patch +38 / −10
### electrum/wallet.py
@@ -2698,7 +2698,8 @@ def add_input_info(
is_mine = self._learn_derivation_path_for_address_from_txinout(txin, address)
if not is_mine:
return
- txin.script_descriptor = self.get_script_descriptor_for_address(address)
+ if desc := self.get_script_descriptor_for_address(address):
+ txin.script_descriptor = desc
txin.is_mine = True
self._add_txinout_derivation_info(txin, address, only_der_suffix=only_der_suffix)
txin.block_height = self.adb.get_tx_height(txin.prevout.txid.hex()).height()
@@ -4045,8 +4046,11 @@ def import_private_key(self, key: str, password: Optional[str]) -> str:
else:
raise BitcoinException(str(bad_keys[0][1]))
- def get_txin_type(self, address):
- return self.db.get_imported_address(address).get('type', 'address')
+ def get_txin_type(self, address) -> str:
+ x = self.db.get_imported_address(address)
+ if x is None:
+ return 'unknown'
+ return x.get('type', 'address')
@profiler
def try_detecting_internal_addresses_corruption(self):
### tests/test_wallet.py
@@ -376,9 +376,13 @@ async def test_restore_wallet_from_text_addresses(self):
wallet = d['wallet'] # type: Imported_Wallet
self.assertEqual('bc1q2ccr34wzep58d4239tl3x3734ttle92a8srmuw', wallet.get_receiving_addresses()[0])
self.assertEqual(2, len(wallet.get_receiving_addresses()))
+ # we don't know the script type of an imported address
+ self.assertEqual('address', wallet.get_txin_type('bc1qnp78h78vp92pwdwq5xvh8eprlga5q8gu66960c'))
# also test addr deletion
wallet.delete_address('bc1qnp78h78vp92pwdwq5xvh8eprlga5q8gu66960c')
self.assertEqual(1, len(wallet.get_receiving_addresses()))
+ # querying an address that is not is_mine
+ self.assertEqual('unknown', wallet.get_txin_type('bc1qnp78h78vp92pwdwq5xvh8eprlga5q8gu66960c'))
async def test_restore_wallet_from_text_privkeys(self):
text = 'p2wpkh:L4jkdiXszG26SUYvwwJhzGwg37H2nLhrbip7u6crmgNeJysv5FHL p2wpkh:L24GxnN7NNUAfCXA6hFzB1jt59fYAAiFZMcLaJ2ZSawGpM3uqhb1'
### tests/test_wallet_vertical.py
@@ -12,7 +12,7 @@
from electrum import SimpleConfig
from electrum import util
from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE
-from electrum.wallet import (sweep, Multisig_Wallet, Standard_Wallet, Imported_Wallet,
+from electrum.wallet import (sweep, sweep_preparations, Multisig_Wallet, Standard_Wallet, Imported_Wallet,
Abstract_Wallet, CannotBumpFee, BumpFeeStrategy,
TransactionPotentiallyDangerousException,
TransactionDangerousException,
@@ -2576,13 +2576,33 @@ async def get_transaction(self, txid):
privkeys = ['p2wpkh:cV2BvgtpLNX328m4QrhqycBGA6EkZUFfHM9kKjVXjfyD53uNfC4q',]
network = NetworkMock()
dest_addr = 'tb1qhuy2e45lrdcp9s4ezeptx5kwxcnahzgpar9scc'
- tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(500), locktime=2420010, tx_version=2)
- tx_copy = tx_from_any(tx.serialize())
- self.assertEqual('02000000000101e328aeb4f9dc1b85a2709ce59b0478a15ed9fb5e7f84fb62422f99b8cd6ad7010000000000fdffffff01087e010000000000160014bf08acd69f1b7012c2b91642b352ce3627db89010247304402204993099c4663d92ef4c9a28b3f45a40a6585754fe22ecfdc0a76c43fda7c9d04022006a75e0fd3ad1862d8e81015a71d2a1489ec7a9264e6e63b8fe6bb90c27e799b0121038ca94e7c715152fd89803c2a40a934c7c4035fb87b3cba981cd1e407369cfe312aed2400',
- str(tx_copy))
- self.assertEqual('e02641928e5394332eec0a36c196f1e30e2b8645ebbeef89d6cc27bf237ae548', tx_copy.txid())
- self.assertEqual('b062d2e19880c66b36e80b823c2d00a2769658d1e574ff854dab15efd8fd7da8', tx_copy.wtxid())
+ with self.subTest(msg="simple sweep"):
+ tx = await sweep(privkeys, network=network, to_address=dest_addr, fee_policy=FixedFeePolicy(500), locktime=2420010, tx_version=2)
+
+ tx_copy = tx_from_any(tx.serialize())
+ self.assertEqual('02000000000101e328aeb4f9dc1b85a2709ce59b0478a15ed9fb5e7f84fb62422f99b8cd6ad7010000000000fdffffff01087e010000000000160014bf08acd69f1b7012c2b91642b352ce3627db89010247304402204993099c4663d92ef4c9a28b3f45a40a6585754fe22ecfdc0a76c43fda7c9d04022006a75e0fd3ad1862d8e81015a71d2a1489ec7a9264e6e63b8fe6bb90c27e799b0121038ca94e7c715152fd89803c2a40a934c7c4035fb87b3cba981cd1e407369cfe312aed2400',
+ str(tx_copy))
+ self.assertEqual('e02641928e5394332eec0a36c196f1e30e2b8645ebbeef89d6cc27bf237ae548', tx_copy.txid())
+ self.assertEqual('b062d2e19880c66b36e80b823c2d00a2769658d1e574ff854dab15efd8fd7da8', tx_copy.wtxid())
+
+ with self.subTest(msg="watch-only wallet self-sweep"):
+ # a watch only wallet with imported address must still be able to self-sweep the address (though kind of pointless)
+ # (see https://github.com/spesmilo/electrum/issues/10891)
+ swept_addr = 'tb1q6vu7lmtu6hfg6vvetjh3pwyh82dp83jkm6rf05'
+ wallet = WalletIntegrityHelper.create_imported_wallet(config=self.config, privkeys=False)
+ wallet.import_addresses([swept_addr])
+ self.assertTrue(wallet.is_mine(swept_addr))
+ self.assertIsNone(wallet.get_script_descriptor_for_address(swept_addr))
+ coins, keypairs = await sweep_preparations(privkeys, network=network)
+ tx = wallet.make_unsigned_transaction(
+ coins=coins,
+ outputs=[PartialTxOutput.from_address_and_value(dest_addr, value='!')],
+ fee_policy=FixedFeePolicy(500),
+ is_sweep=True,
+ )
+ tx.sign(keypairs)
+ self.assertTrue(tx.is_complete())
async def test_coinjoin_between_two_p2wpkh_electrum_seeds(self):
wallet1 = WalletIntegrityHelper.create_standard_wallet(Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.