From 89b10fe256ff81d6bcb6a8dd584cbe150cd8e47c Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 10 Aug 2026 16:13:37 +0200 Subject: [PATCH 1/2] cli/swaps: fail swap cli on changed fees CLI swaps happen in two steps. First the command is ran as "dryrun" to calculate the expected amounts (considering the feerates), then the swap is initiated with these amounts. If the mining fee estimates increase during the time between those two calls the swap provider might return a lower onchain amount for us, causing the `if onchain_amount < expected_onchain_amount_sat` check in `SwapManager` to fail the swap after a the network round trip with the swap provider. Instead we should fail early before the swap is registered with the provider. Note: the issue can still happen if the fees change in the brief moment during the request, so scripts consuming the CLI should handle failures either way. --- electrum/commands.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 0cdf4576922..e4ea5c0783e 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -2132,7 +2132,8 @@ async def get_submarine_swap_providers(self, query_time=15, wallet: Abstract_Wal @command('wnpl') async def normal_swap(self, onchain_amount, lightning_amount, password=None, wallet: Abstract_Wallet = None): """ - Normal submarine swap: send on-chain BTC, receive on Lightning + Normal submarine swap: send on-chain BTC, receive on Lightning. + Note: fees can change between the dryrun and the following swap request, causing the request to error and require a new dryrun. arg:decimal_or_dryrun:lightning_amount:Amount to be received, in BTC. Set it to 'dryrun' to receive a value arg:decimal_or_dryrun:onchain_amount:Amount to be sent, in BTC. Set it to 'dryrun' to receive a value @@ -2156,6 +2157,13 @@ async def normal_swap(self, onchain_amount, lightning_amount, password=None, wal else: lightning_amount_sat = satoshis(lightning_amount) onchain_amount_sat = satoshis(onchain_amount) + required_onchain_amount_sat = sm.get_send_amount(lightning_amount_sat, is_reverse=False) + # same 1 sat rounding tolerance as in `request_normal_swap()` + if not required_onchain_amount_sat \ + or not onchain_amount_sat - 1 <= required_onchain_amount_sat <= onchain_amount_sat: + raise UserFacingException( + "Swap fees have changed since the dryrun was calculated. Do a new dryrun first." + + f" ({required_onchain_amount_sat} != {onchain_amount_sat} sat)") txid = await wallet.lnworker.swap_manager.normal_swap( transport=transport, lightning_amount_sat=lightning_amount_sat, @@ -2174,7 +2182,8 @@ async def reverse_swap( self, lightning_amount, onchain_amount, prepayment='dryrun', password=None, wallet: Abstract_Wallet = None, ): """ - Reverse submarine swap: send on Lightning, receive on-chain + Reverse submarine swap: send on Lightning, receive on-chain. + Note: fees can change between the dryrun and the following swap request, causing the request to error and require a new dryrun. arg:decimal_or_dryrun:lightning_amount:Amount to be sent, in BTC. Set it to 'dryrun' to receive a value arg:decimal_or_dryrun:onchain_amount:Amount to be received, in BTC. Set it to 'dryrun' to receive a value @@ -2190,32 +2199,38 @@ async def reverse_swap( raise TimeoutError("Could not find configured swap provider. Setup another one. See 'get_submarine_swap_providers'") if onchain_amount == 'dryrun': lightning_amount_sat = satoshis(lightning_amount) - onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True) + onchain_recv_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True) assert prepayment == "dryrun", f"Cannot use {prepayment=} in dryrun. Set it to 'dryrun'." prepayment_sat = 2 * sm.mining_fee funding_txid = None elif lightning_amount == 'dryrun': - onchain_amount_sat = satoshis(onchain_amount) - lightning_amount_sat = sm.get_send_amount(onchain_amount_sat, is_reverse=True) + onchain_recv_amount_sat = satoshis(onchain_amount) + lightning_amount_sat = sm.get_send_amount(onchain_recv_amount_sat, is_reverse=True) assert prepayment == "dryrun", f"Cannot use {prepayment=} in dryrun. Set it to 'dryrun'." prepayment_sat = 2 * sm.mining_fee funding_txid = None else: + assert prepayment != "dryrun", "Provide the 'prepayment' obtained from the dryrun." lightning_amount_sat = satoshis(lightning_amount) + requested_recv_amount_sat = satoshis(onchain_amount) claim_fee = sm.get_fee_for_txbatcher() - onchain_amount_sat = satoshis(onchain_amount) + claim_fee - assert prepayment != "dryrun", "Provide the 'prepayment' obtained from the dryrun." + funding_utxo_value_sat = requested_recv_amount_sat + claim_fee + onchain_recv_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True) + if not onchain_recv_amount_sat or onchain_recv_amount_sat < requested_recv_amount_sat: + raise UserFacingException( + "Swap fees have changed since the dryrun was calculated. Do a new dryrun first." + + f" ({onchain_recv_amount_sat} < {requested_recv_amount_sat} sat)") prepayment_sat = satoshis(prepayment) funding_txid = await wallet.lnworker.swap_manager.reverse_swap( transport=transport, lightning_amount_sat=lightning_amount_sat, - expected_onchain_amount_sat=onchain_amount_sat, + expected_onchain_amount_sat=funding_utxo_value_sat, prepayment_sat=prepayment_sat, ) return { 'funding_txid': funding_txid, 'lightning_amount': format_satoshis(lightning_amount_sat), - 'onchain_amount': format_satoshis(onchain_amount_sat), + 'onchain_amount': format_satoshis(onchain_recv_amount_sat), 'prepayment': format_satoshis(prepayment_sat) } From 43680185789f3212d9910e3b2e7575d8ae058a72 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 17 Aug 2026 11:01:13 +0200 Subject: [PATCH 2/2] cli: break translation import The CLI is intentionally not translated, break the `_()` import to prevent accidental usage. --- electrum/commands.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index e4ea5c0783e..b8187d1a4e6 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -56,7 +56,6 @@ from . import bitcoin from .bitcoin import is_address, hash_160, COIN from .bip32 import BIP32Node -from .i18n import _ from .transaction import ( Transaction, multisig_script, PartialTransaction, PartialTxOutput, tx_from_any, PartialTxInput, TxOutpoint, convert_raw_tx_to_hex @@ -87,6 +86,10 @@ from electrum.lnworker import PaymentInfo +def _(_): # break translation + raise ImportError("The CLI is intentionally always non-localized") + + known_commands = {} # type: Dict[str, Command] @@ -2451,7 +2454,7 @@ def subparser_call(self, parser, namespace, values, option_string=None): parser = self._name_parser_map[parser_name] except KeyError: tup = parser_name, ', '.join(self._name_parser_map) - msg = _('unknown parser {!r} (choices: {})').format(*tup) + msg = 'unknown parser {!r} (choices: {})'.format(*tup) raise ArgumentError(self, msg) # parse all the remaining options into the namespace # store any unrecognized options on the object, so that the top