swaps: add sanity check to reverse swap mining fee
What changed, and why it matters
This commit tightens how Electrum handles reverse submarine swaps (a way to move funds from Lightning back to on-chain Bitcoin). Previously, the swap code relied on the current provider fee stored in memory, which could change between the moment the user saw a quote and the moment the swap was executed. The patch makes the GUI and command-line pass the exact mining fee the user was shown into the swap logic, and adds a sanity check that the provider cannot charge more than twice that shown fee. This reduces the risk of a malicious or misconfigured swap provider surprising the user with unexpectedly high fees right before the swap completes.
Treat this as a hardening/fix commit for the reverse swap fee path. Review whether the 2x multiplier is an acceptable trust boundary, and consider adding user-visible confirmation of the exact server mining fee before swap execution. No immediate incident response is indicated by the diff alone.
Security signals we found
fee-invoice amount now bounded by user-visible mining fee instead of live provider state
GUI stops auto-updating offer/fee display after user input to prevent bait-and-switch fee changes
new server_mining_fee_sat parameter committed through CLI, QML, Qt, and swap manager
check uses 'greater than 2x' threshold, leaving a tolerance window
commit message explicitly notes trust assumption: server is trusted with max fee pre-payment amount
Evidence from the diff
The change threads a new server_mining_fee_sat parameter through reverse_swap flows in commands.py, the QML and Qt GUIs, and submarine_swaps.py. The fee the user saw (last_server_mining_fee_sat / sm.mining_fee at quote time) is now committed to and used in the fee-invoice validation instead of self.mining_fee. The validation now rejects a fee invoice whose amount is greater than server_mining_fee_sat * 2. The Qt dialog also stops refreshing offer/fee data once the user has entered an amount, preventing a last-second UI update from silently changing the fee the user agreed to. The commit message frames this as a ‘sanity check’ rather than a full security boundary.
Changed components
electrum/submarine_swaps.pyelectrum/gui/qt/swap_dialog.pyelectrum/gui/qml/qeswaphelper.pyelectrum/commands.pyInspect captured patch +29 / −8
diff --git a/electrum/commands.py b/electrum/commands.py
index 2a004db..73f8ce3 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -1975,7 +1975,7 @@ class Commands(Logger):
arg:decimal_or_dryrun:onchain_amount:Amount to be sent, in BTC. Set it to 'dryrun' to receive a value
"""
sm = wallet.lnworker.swap_manager
- with sm.create_transport() as transport:
+ async with sm.create_transport() as transport:
await sm.is_initialized.wait()
if lightning_amount == 'dryrun':
onchain_amount_sat = satoshis(onchain_amount)
@@ -2002,37 +2002,46 @@ class Commands(Logger):
}
@command('wnpl')
- async def reverse_swap(self, lightning_amount, onchain_amount, password=None, wallet: Abstract_Wallet = None):
+ async def reverse_swap(self, lightning_amount, onchain_amount, provider_mining_fee, password=None, wallet: Abstract_Wallet = None):
"""
Reverse submarine swap: send on Lightning, receive on-chain
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
+ arg:decimal_or_dryrun:provider_mining_fee:Mining fee required by the swap provider, in BTC. Set it to 'dryrun' to receive a value
"""
sm = wallet.lnworker.swap_manager
- with sm.create_transport() as transport:
+ async with sm.create_transport() as transport:
await sm.is_initialized.wait()
if onchain_amount == 'dryrun':
lightning_amount_sat = satoshis(lightning_amount)
onchain_amount_sat = sm.get_recv_amount(lightning_amount_sat, is_reverse=True)
+ assert provider_mining_fee == "dryrun", f"Cannot use {provider_mining_fee=} in dryrun. Set it to 'dryrun'."
+ provider_mining_fee = 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)
+ assert provider_mining_fee == "dryrun", f"Cannot use {provider_mining_fee=} in dryrun. Set it to 'dryrun'."
+ provider_mining_fee = sm.mining_fee
funding_txid = None
else:
lightning_amount_sat = satoshis(lightning_amount)
claim_fee = sm.get_fee_for_txbatcher()
onchain_amount_sat = satoshis(onchain_amount) + claim_fee
+ assert provider_mining_fee != "dryrun", "Provide the 'provder_mining_fee' obtained from the dryrun."
+ provider_mining_fee = satoshis(provider_mining_fee)
funding_txid = await wallet.lnworker.swap_manager.reverse_swap(
transport=transport,
lightning_amount_sat=lightning_amount_sat,
expected_onchain_amount_sat=onchain_amount_sat,
+ server_mining_fee_sat=provider_mining_fee,
)
return {
'funding_txid': funding_txid,
'lightning_amount': format_satoshis(lightning_amount_sat),
'onchain_amount': format_satoshis(onchain_amount_sat),
+ 'provider_mining_fee': format_satoshis(provider_mining_fee)
}
@command('n')
diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py
index 80dede7..0084426 100644
--- a/electrum/gui/qml/qeswaphelper.py
+++ b/electrum/gui/qml/qeswaphelper.py
@@ -719,6 +719,7 @@ class QESwapHelper(AuthMixin, QObject, QtEventListener):
transport=self.swap_transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + swap_manager.get_fee_for_txbatcher(),
+ server_mining_fee_sat=self.serverMiningfee.satsInt,
)
try: # swaphelper might be destroyed at this point
if txid:
diff --git a/electrum/gui/qt/swap_dialog.py b/electrum/gui/qt/swap_dialog.py
index fbf1c8e..0120284 100644
--- a/electrum/gui/qt/swap_dialog.py
+++ b/electrum/gui/qt/swap_dialog.py
@@ -94,6 +94,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
self.swap_limits_label = QLabel()
self.fee_label = QLabel()
self.server_fee_label = QLabel()
+ self.last_server_mining_fee_sat = None
h = QGridLayout()
h.addWidget(self.description_label, 0, 0, 1, 3)
h.addWidget(self.toggle_button, 0, 3)
@@ -153,7 +154,12 @@ class SwapDialog(WindowModalDialog, QtEventListener):
@qt_event_listener
def on_event_swap_offers_changed(self, recent_offers: Sequence['SwapOffer']):
self.set_server_button_text(len(recent_offers))
- self.update()
+ if not self.ok_button.isEnabled():
+ # only update the dialog with the new offer if the user hasn't entered an amount yet.
+ # if the user has already entered an amount we prefer the swap to fail due to outdated
+ # fees than the possibility of a swap happening with fees the user hasn't seen
+ # due to an update happening just before the user initiated the swap
+ self.update()
def set_server_button_text(self, offer_count: int):
button_text = f' {offer_count} ' + (_('providers') if offer_count != 1 else _('provider'))
@@ -282,8 +288,8 @@ class SwapDialog(WindowModalDialog, QtEventListener):
f"{self.window.format_amount(max_swap_limit)} {w_base_unit}")
self.swap_limits_label.setText(swap_limit_str)
self.swap_limits_label.repaint() # macOS hack for #6269
- server_mining_fee = sm.mining_fee
- server_fee_str = '%.2f'%sm.percentage + '% + ' + self.window.format_amount(server_mining_fee) + ' ' + w_base_unit
+ self.last_server_mining_fee_sat = sm.mining_fee
+ server_fee_str = '%.2f'%sm.percentage + '% + ' + self.window.format_amount(sm.mining_fee) + ' ' + w_base_unit
self.server_fee_label.setText(server_fee_str)
self.server_fee_label.repaint() # macOS hack for #6269
self.needs_tx_update = True
@@ -332,6 +338,7 @@ class SwapDialog(WindowModalDialog, QtEventListener):
transport=transport,
lightning_amount_sat=lightning_amount,
expected_onchain_amount_sat=onchain_amount + self.swap_manager.get_fee_for_txbatcher(),
+ server_mining_fee_sat=self.last_server_mining_fee_sat,
)
try:
# we must not leave the context, so we use run_couroutine_dialog
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index fcd6cd6..97561d5 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -904,6 +904,7 @@ class SwapManager(Logger):
transport: 'SwapServerTransport',
lightning_amount_sat: int,
expected_onchain_amount_sat: int,
+ server_mining_fee_sat: int,
channels: Optional[Sequence['Channel']] = None,
) -> Optional[str]:
"""send on Lightning, receive on-chain
@@ -920,6 +921,9 @@ class SwapManager(Logger):
- Server fulfills HTLC using preimage.
Note: expected_onchain_amount_sat is BEFORE deducting the on-chain claim tx fee.
+ Note: server_mining_fee_sat is passed as argument instead of accessing self.mining_fee to ensure
+ the mining fees the user sees in the GUI are also the values used for the checks performed here.
+ We commit to server_mining_fee_sat as it limits the max fee pre-payment amt, which the server is trusted with.
"""
assert self.network
assert self.lnwatcher
@@ -977,16 +981,16 @@ class SwapManager(Logger):
invoice_amount = int(lnaddr.get_amount_sat())
if lnaddr.paymenthash != payment_hash:
raise Exception("rswap check failed: inconsistent RHASH and invoice")
- # check that the lightning amount is what we requested
if fee_invoice:
fee_lnaddr = self.lnworker._check_bolt11_invoice(fee_invoice)
- if fee_lnaddr.get_amount_sat() > self.mining_fee * 2:
+ if fee_lnaddr.get_amount_sat() > server_mining_fee_sat * 2:
raise SwapServerError(_("Mining fee requested by swap-server larger "
"than what was announced in their offer."))
invoice_amount += fee_lnaddr.get_amount_sat()
prepay_hash = fee_lnaddr.paymenthash
else:
prepay_hash = None
+ # check that the lightning amount is what we requested
if int(invoice_amount) != lightning_amount_sat:
raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) "
f"not what we requested ({lightning_amount_sat})")
Why this scored 59/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.