commands: add delete_channel(_backup) cli commands, analog to functionality already present in the GUIs
What changed, and why it matters
This commit adds two new command-line commands to Electrum, 'delete_channel' and 'delete_channel_backup', which already existed in the graphical user interface. It simply exposes existing wallet functionality through the command line, with the same safety checks. There is no indication this is a security fix or introduces a security issue.
No security action required; this is a routine feature addition exposing existing GUI functionality via CLI.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch adds two new @command-decorated async methods in electrum/commands.py: delete_channel() and delete_channel_backup(). Both delegate to wallet.lnworker.remove_channel() / remove_channel_backup() after validating the channel exists, is the correct type (backup vs. non-backup), and that can_be_deleted() returns True. Unit tests mock lnworker and verify the validation logic. The functionality mirrors existing GUI behavior and does not change any security-sensitive code paths.
Changed components
electrum/commands.pytests/test_commands.pyInspect captured patch +129 / −1
diff --git a/electrum/commands.py b/electrum/commands.py
index a3776ed..37686f9 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -1934,6 +1934,42 @@ class Commands(Logger):
coro = wallet.lnworker.force_close_channel(chan_id) if force else wallet.lnworker.close_channel(chan_id)
return await coro
+ @command('wpl')
+ async def delete_channel(self, channel_point, password=None, wallet: Abstract_Wallet = None):
+ """
+ Delete a lightning channel (only if channel-open funding has expired, or channel is in REDEEMED state)
+
+ arg:str:channel_point:channel point
+ """
+ txid, index = channel_point.split(':')
+ chan_id, _ = channel_id_from_funding_tx(txid, int(index))
+ if chan_id not in wallet.lnworker.channels:
+ raise UserFacingException(f'Unknown channel {channel_point}')
+ chan = wallet.lnworker.channels[chan_id]
+ if chan.is_backup():
+ raise UserFacingException(f'{channel_point} is a channel backup, use delete_channel_backup instead')
+ if not chan.can_be_deleted():
+ raise UserFacingException(f'Cannot delete channel {channel_point}')
+ wallet.lnworker.remove_channel(chan_id)
+
+ @command('wpl')
+ async def delete_channel_backup(self, channel_point, password=None, wallet: Abstract_Wallet = None):
+ """
+ Delete a lightning channel backup (only if imported, or channel is in REDEEMED state)
+
+ arg:str:channel_point:channel point
+ """
+ txid, index = channel_point.split(':')
+ chan_id, _ = channel_id_from_funding_tx(txid, int(index))
+ if chan_id not in wallet.lnworker.channel_backups:
+ raise UserFacingException(f'Unknown channel backup {channel_point}')
+ chan = wallet.lnworker.channel_backups[chan_id]
+ if not chan.is_backup():
+ raise UserFacingException(f'{channel_point} is not a channel backup, use delete_channel instead')
+ if not chan.can_be_deleted():
+ raise UserFacingException(f'Cannot delete channel backup {channel_point}')
+ wallet.lnworker.remove_channel_backup(chan_id)
+
@command('wnpl')
async def request_force_close(self, channel_point, connection_string=None, password=None, wallet: Abstract_Wallet = None):
"""
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 6514afe..8180146 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -11,7 +11,7 @@ import shutil
import electrum
from electrum.commands import Commands, eval_bool
from electrum import storage, wallet
-from electrum.lnutil import RECEIVED
+from electrum.lnutil import RECEIVED, channel_id_from_funding_tx
from electrum.lnworker import RecvMPPResolution
from electrum.wallet import Abstract_Wallet
from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED
@@ -768,3 +768,95 @@ class TestCommandsTestnet(ElectrumTestCase):
result = await cmds.add_peer(connection_string=connection_string, wallet=w)
assert called == 2
self.assertTrue(result)
+
+ # arbitrary funding outpoint
+ _CHANNEL_POINT = 'ede61d39e501d65ccf34e6300da439419c43393f793bb9a8a4b06b2d0d80a8a0:0'
+
+ @staticmethod
+ def _mock_channel(*, is_backup: bool, can_be_deleted: bool) -> mock.Mock:
+ chan = mock.Mock()
+ chan.is_backup.return_value = is_backup
+ chan.can_be_deleted.return_value = can_be_deleted
+ return chan
+
+ @staticmethod
+ def mock_lnworker(w, channels=None, channel_backups=None):
+ w.lnworker = mock.Mock()
+ w.lnworker.channels = channels if channels else {}
+ w.lnworker.channel_backups = channel_backups if channel_backups else {}
+
+ @classmethod
+ def _chan_id_for(cls, channel_point: str) -> bytes:
+ txid, index = channel_point.split(':')
+ chan_id, _ = channel_id_from_funding_tx(txid, int(index))
+ return chan_id
+
+ async def test_delete_channel(self):
+ w = restore_wallet_from_text__for_unittest(
+ 'disagree rug lemon bean unaware square alone beach tennis exhibit fix mimic',
+ path=None,
+ config=self.config)['wallet']
+ cmds = Commands(config=self.config)
+
+ # no such channel
+ self.mock_lnworker(w)
+
+ with self.assertRaises(UserFacingException):
+ result = await cmds.delete_channel(self._CHANNEL_POINT, wallet=w)
+
+ # can't delete
+ chan_id = self._chan_id_for(self._CHANNEL_POINT)
+ chan = self._mock_channel(is_backup=False, can_be_deleted=False)
+ self.mock_lnworker(w, {chan_id: chan})
+ with self.assertRaises(UserFacingException):
+ result = await cmds.delete_channel(self._CHANNEL_POINT, wallet=w)
+
+ # is backup
+ chan_id = self._chan_id_for(self._CHANNEL_POINT)
+ chan = self._mock_channel(is_backup=True, can_be_deleted=True)
+ self.mock_lnworker(w, {chan_id: chan})
+ with self.assertRaises(UserFacingException):
+ result = await cmds.delete_channel(self._CHANNEL_POINT, wallet=w)
+
+ chan_id = self._chan_id_for(self._CHANNEL_POINT)
+ chan = self._mock_channel(is_backup=False, can_be_deleted=True)
+
+ self.mock_lnworker(w, {chan_id: chan})
+ result = await cmds.delete_channel(self._CHANNEL_POINT, wallet=w)
+ self.assertIsNone(result)
+ w.lnworker.remove_channel.assert_called_once_with(chan_id)
+
+ async def test_delete_channel_backup(self):
+ w = restore_wallet_from_text__for_unittest(
+ 'disagree rug lemon bean unaware square alone beach tennis exhibit fix mimic',
+ path=None,
+ config=self.config)['wallet']
+ cmds = Commands(config=self.config)
+
+ # no such channel
+ self.mock_lnworker(w)
+
+ with self.assertRaises(UserFacingException):
+ result = await cmds.delete_channel_backup(self._CHANNEL_POINT, wallet=w)
+
+ # can't delete
+ chan_id = self._chan_id_for(self._CHANNEL_POINT)
+ chan = self._mock_channel(is_backup=True, can_be_deleted=False)
+ self.mock_lnworker(w, None, {chan_id: chan})
+ with self.assertRaises(UserFacingException):
+ result = await cmds.delete_channel_backup(self._CHANNEL_POINT, wallet=w)
+
+ # is not backup
+ chan_id = self._chan_id_for(self._CHANNEL_POINT)
+ chan = self._mock_channel(is_backup=False, can_be_deleted=True)
+ self.mock_lnworker(w, None, {chan_id: chan})
+ with self.assertRaises(UserFacingException):
+ result = await cmds.delete_channel_backup(self._CHANNEL_POINT, wallet=w)
+
+ chan_id = self._chan_id_for(self._CHANNEL_POINT)
+ chan = self._mock_channel(is_backup=True, can_be_deleted=True)
+
+ self.mock_lnworker(w, None, {chan_id: chan})
+ result = await cmds.delete_channel_backup(self._CHANNEL_POINT, wallet=w)
+ self.assertIsNone(result)
+ w.lnworker.remove_channel_backup.assert_called_once_with(chan_id)
Why this scored 15/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.