What changed, and why it matters
This commit adds a safety guard for Electrum's submarine swaps. Before finalizing a swap, the wallet now checks whether the fees/costs look wildly unreasonable (for example, losing more than 25% of the amount, or over 1 million satoshi in absolute terms). If the costs look insane, the wallet stops and shows the user an error instead of proceeding. The change is defensive and does not appear to fix a known exploited bug, but it reduces the risk of a malicious or misconfigured swap server causing the user to lose funds.
No immediate action required. Users and operators should ensure they are running a version that includes this sanity check if they perform submarine swaps. Review whether the chosen thresholds (25% / 15% + 1M sat) are appropriate for your use case.
Security signals we found
New client-side validation of swap costs
Raises user-facing exception to block execution
Catches swap-server error class in UI
Defensive hardening, not a vulnerability patch
Evidence from the diff
A new _sanity_check_swap_costs() method is added to SwapManager. It computes the absolute and relative cost of a swap and raises UserFacingException if the cost exceeds thresholds (ratio > 0.25, or ratio > 0.15 when absolute cost > 1,000,000 sat). The check is invoked in both reverse swaps (request_swap_for_amount) and forward swaps (request_normal_swap). The Qt send tab is updated to catch UserFacingException and display it to the user. This is a client-side sanity check against swap servers quoting abusive terms.
Changed components
electrum/submarine_swaps.pyelectrum/gui/qt/send_tab.pyInspect captured patch +30 / −3
diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py
index e82841b..1550e85 100644
--- a/electrum/gui/qt/send_tab.py
+++ b/electrum/gui/qt/send_tab.py
@@ -13,7 +13,10 @@ from electrum.i18n import _
from electrum.logging import Logger
from electrum.bitcoin import DummyAddress
from electrum.plugin import run_hook
-from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend, UserCancelled, ChoiceItem
+from electrum.util import (
+ NotEnoughFunds, NoDynamicFeeEstimates, parse_max_spend, UserCancelled, ChoiceItem,
+ UserFacingException,
+)
from electrum.invoices import PR_PAID, Invoice, PR_BROADCASTING, PR_BROADCAST
from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput
from electrum.network import TxBroadcastError, BestEffortRequestFailed
@@ -338,7 +341,7 @@ class SendTab(QWidget, MessageBoxMixin, Logger):
coro = sm.request_swap_for_amount(transport=transport, onchain_amount=swap_dummy_output.value)
try:
swap, swap_invoice = self.window.run_coroutine_dialog(coro, _('Requesting swap invoice...'))
- except SwapServerError as e:
+ except (SwapServerError, UserFacingException) as e:
self.show_error(str(e))
return
except UserCancelled:
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index fcd6cd6..d6db458 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -33,7 +33,7 @@ from .transaction import (
from .util import (
log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, ca_path, gen_nostr_ann_pow,
get_nostr_ann_pow_amount, make_aiohttp_proxy_connector, get_running_loop, get_asyncio_loop, wait_for2,
- run_sync_function_on_asyncio_thread, trigger_callback, NoDynamicFeeEstimates
+ run_sync_function_on_asyncio_thread, trigger_callback, NoDynamicFeeEstimates, UserFacingException,
)
from . import lnutil
from .lnutil import hex_to_bytes, REDEEM_AFTER_DOUBLE_SPENT_DELAY, Keypair
@@ -507,6 +507,26 @@ class SwapManager(Logger):
fee_policy = FeePolicy(policy_descriptor)
return fee_policy.estimate_fee(SWAP_TX_SIZE, network=self.network, allow_fallback_to_static_rates=True)
+ def _sanity_check_swap_costs(
+ self,
+ *,
+ incoming_sat: int,
+ outgoing_sat: int,
+ ) -> None:
+ """The user should have already seen the swap amounts, and hence the cost.
+ These are just some last-minute sanity checks that the cost of the swap is not insane.
+ """
+ costs_abs = outgoing_sat - incoming_sat
+ costs_ratio = 1 - incoming_sat / outgoing_sat
+ if costs_abs < 10_000: # "small" amounts are exempt from checks
+ return
+ exc = UserFacingException(_("Total swap costs are insane.") + f"\n({costs_ratio=}, {costs_abs=} sat)")
+ if costs_ratio > 0.25:
+ raise exc
+ if costs_abs > 1_000_000:
+ if costs_ratio > 0.15:
+ raise exc
+
def get_swap(self, payment_hash: bytes) -> Optional[SwapData]:
# for history
swap = self._swaps.get(payment_hash.hex())
@@ -755,6 +775,8 @@ class SwapManager(Logger):
expected_onchain_amount_sat: int,
channels: Optional[Sequence['Channel']] = None,
) -> Tuple[SwapData, str]:
+ self._sanity_check_swap_costs(
+ incoming_sat=lightning_amount_sat, outgoing_sat=expected_onchain_amount_sat)
await self.is_initialized.wait() # add timeout
refund_privkey = os.urandom(32)
refund_pubkey = ECPrivkey(refund_privkey).get_public_key_bytes(compressed=True)
@@ -923,6 +945,8 @@ class SwapManager(Logger):
"""
assert self.network
assert self.lnwatcher
+ self._sanity_check_swap_costs(
+ incoming_sat=expected_onchain_amount_sat, outgoing_sat=lightning_amount_sat)
privkey = os.urandom(32)
our_pubkey = ECPrivkey(privkey).get_public_key_bytes(compressed=True)
preimage = os.urandom(32)
Why this scored 49/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.