Merge pull request #10922 from accumulator/fix_use_change_config_sweeps
What changed, and why it matters
This commit fixes a bug in the Electrum Bitcoin wallet where, if the user had disabled change addresses, funds from 'sweep' transactions (such as recovering Lightning channel funds or claiming submarine swaps) could accidentally be sent back to an address controlled by someone else instead of to the user's own wallet. The fix forces the creation of a wallet-owned change address whenever a sweep is involved, even when change addresses are normally disabled.
Users running Electrum with Lightning or submarine swaps enabled, especially those who have disabled change addresses, should upgrade to a version containing this fix. Reviewers should verify that the new change address is always generated from the wallet's own keypool and that the is_mine check correctly covers all sweep input types.
Security signals we found
Funds could be sent to a non-ismine address when use_change is disabled
Affected flows include Lightning channel sweeps and submarine swap claims
Coin chooser fallback to first input address is unsafe for sweep inputs
Fix explicitly creates a wallet-owned change output for sweep transactions
Regression tests verify change output is ismine for sweep-only and mixed sweep/payment batches
Evidence from the diff
The patch renames get_new_sweep_address_for_channel() to get_new_sweep_address() and updates callers in lnchannel.py and lnworker.py. In wallet.py, before coin selection, it adds a check: if no change address exists (e.g., because use_change is disabled) and the transaction has no wallet-owned outputs or any input is not ismine (sweep inputs), it explicitly adds a new sweep change address. This prevents coin chooser fallback behavior that sends change back to the first input’s address, which is unsafe when that input is a sweep from a non-wallet-controlled address. Regression tests are added in tests/test_txbatcher.py.
Changed components
electrum/wallet.pyelectrum/lnchannel.pyelectrum/lnworker.pytests/test_txbatcher.pyInspect captured patch +73 / −4
### electrum/lnchannel.py
@@ -723,7 +723,7 @@ def is_frozen_for_receiving(self) -> bool:
return False
def get_sweep_address(self) -> str:
- return self.lnworker.wallet.get_new_sweep_address_for_channel()
+ return self.lnworker.wallet.get_new_sweep_address()
def has_anchors(self) -> Optional[bool]:
return None
@@ -1017,7 +1017,7 @@ def remove_zeroconf_flag(self) -> None:
def get_sweep_address(self) -> str:
# TODO: in case of unilateral close with pending HTLCs, this address will be reused
if self.has_anchors():
- addr = self.lnworker.wallet.get_new_sweep_address_for_channel()
+ addr = self.lnworker.wallet.get_new_sweep_address()
elif self.is_static_remotekey_enabled():
our_payment_pubkey = self.config[LOCAL].payment_basepoint.pubkey
addr = make_commitment_output_to_remote_address(our_payment_pubkey, has_anchors=self.has_anchors())
### electrum/lnworker.py
@@ -1726,7 +1726,7 @@ def make_local_config_for_new_channel(
assert self.config.TEST_LN_OPEN_SRK_CHANNELS
wallet = self.wallet
assert wallet.txin_type == 'p2wpkh'
- addr = wallet.get_new_sweep_address_for_channel()
+ addr = wallet.get_new_sweep_address()
static_payment_key = None
static_remotekey = bytes.fromhex(wallet.get_public_key(addr))
### electrum/wallet.py
@@ -1896,7 +1896,11 @@ def get_single_change_address_for_new_transaction(
return addrs[0]
return None
- def get_new_sweep_address_for_channel(self) -> str:
+ def get_new_sweep_address(self) -> str:
+ """Returns an ismine address to sweep funds to.
+ NOTE: this ignores the 'use_change' setting, as the funds we are sweeping are not
+ in the wallet yet, so there is no "sending address" we could send them back to.
+ """
addrs = self._get_change_addresses_we_can_use_now(allow_reuse=True)
if addrs:
return addrs[0]
@@ -2071,6 +2075,13 @@ def fee_estimator(size: Union[int, float, Decimal]) -> int:
# even if the option use multiple change outputs is enabled there should be only
# one change address if there are 0 txos as this is a sweep tx, or if we want to swap change to ln
change_addrs = change_addrs[0:1]
+ if not change_addrs:
+ # We have no change address, e.g. because 'use_change' is disabled. The coin chooser
+ # then sends the change back to the address of the first input, which is only sane if
+ # all inputs are ismine. That is not the case when sweeping (e.g. a lightning ctx
+ # output or a swap claim output), and not guaranteed when batching sweeps with payments.
+ if len(txo) == 0 or not all(self.is_mine(self.adb.get_txin_address(txin)) for txin in txi):
+ change_addrs = [self.get_new_sweep_address()]
tx = coin_chooser.make_tx(
coins=coins,
inputs=txi,
### tests/test_txbatcher.py
@@ -251,6 +251,64 @@ async def test_sweep_from_submarine_swap(self, mock_save_db):
assert new_tx.inputs()[0].prevout == tx.inputs()[0].prevout == txin.prevout
assert output1 in new_tx.outputs()
+ @mock.patch.object(wallet.Abstract_Wallet, 'save_db')
+ async def test_sweep_with_use_change_disabled(self, mock_save_db):
+ """A sweep tx has no txo of its own, so it needs a change output, even if the
+ 'use_change' option is disabled. Also, the change of a batch must never be sent
+ back to the address of a sweep input, as that address is not ismine.
+ """
+ wallet = self._create_wallet()
+ wallet.use_change = False
+ wallet.adb.db.transactions[SWAPDATA.funding_txid] = tx = Transaction(SWAP_FUNDING_TX)
+ wallet.adb.receive_tx_callback(tx, tx_height=1)
+ tx_mined_status = wallet.adb.get_tx_height(tx.txid())
+ wallet.adb.add_verified_tx(tx.txid(), dataclasses.replace(tx_mined_status, conf=1))
+ # sweep-only batch: the swept funds end up in the (forced) change output
+ wallet.txbatcher.add_sweep_input('default', SWAP_SWEEP_INFO)
+ tx = await self.network.next_tx()
+ self.assertEqual(1, len(tx.outputs()))
+ self.assertTrue(wallet.is_mine(tx.outputs()[0].address))
+ # add a payment to the batch. the batch now has a txo, but the change still must
+ # not be sent to the address of the sweep input
+ output1 = PartialTxOutput.from_address_and_value("tb1qyfnv3y866ufedugxxxfksyratv4pz3h78g9dad", 20_000)
+ wallet.txbatcher.add_payment_output('default', output1)
+ new_tx = await self.network.next_tx()
+ self.assertIn(output1, new_tx.outputs())
+ self.assertEqual(2, len(new_tx.outputs()))
+ for txout in new_tx.outputs():
+ if txout == output1:
+ continue
+ self.assertTrue(wallet.is_mine(txout.address), f'change sent to {txout.address}')
+
+ @mock.patch.object(wallet.Abstract_Wallet, 'save_db')
+ async def test_batch_sweep_and_payment_with_use_change_disabled(self, mock_save_db):
+ """A batch that combines a sweep input with a payment output must not send its
+ change back to the address of the sweep input, as that address is not ismine.
+ """
+ wallet = self._create_wallet()
+ wallet.use_change = False
+ wallet.txbatcher.SLEEP_INTERVAL = 100 # we drive the batch manually
+ # fund wallet
+ funding_tx = Transaction(WALLET_DATA['funding_tx'])
+ await self.network.try_broadcasting(funding_tx, 'funding')
+ await self.network.next_tx()
+ # add the swap funding tx
+ wallet.adb.db.transactions[SWAPDATA.funding_txid] = tx = Transaction(SWAP_FUNDING_TX)
+ wallet.adb.receive_tx_callback(tx, tx_height=1)
+ tx_mined_status = wallet.adb.get_tx_height(tx.txid())
+ wallet.adb.add_verified_tx(tx.txid(), dataclasses.replace(tx_mined_status, conf=1))
+ # the batch contains both a sweep input and a payment output
+ wallet.txbatcher.add_sweep_input('default', SWAP_SWEEP_INFO)
+ output1 = PartialTxOutput.from_address_and_value("tb1qyfnv3y866ufedugxxxfksyratv4pz3h78g9dad", 20_000)
+ wallet.txbatcher.add_payment_output('default', output1)
+ tx = wallet.txbatcher.tx_batches['default'].create_next_transaction(None)
+ self.assertIn(output1, tx.outputs())
+ self.assertEqual(2, len(tx.outputs()))
+ for txout in tx.outputs():
+ if txout == output1:
+ continue
+ self.assertTrue(wallet.is_mine(txout.address), f'change sent to {txout.address}')
+
async def test_to_sweep_after_anchor_sweep_conditions(self):
# create wallet
wallet = self._create_wallet()Why this scored 64/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.