What changed, and why it matters
This commit fixes a crash in Electrum's submarine-swap feature. When a user typed certain amounts (like 20,000 sats) into the swap dialog, a tiny floating-point rounding mismatch made the fee-calculation sanity check fail, returning None where a number was expected and causing a TypeError. The fix changes the fee percentage from a float to a Decimal, which avoids floating-point rounding errors. It is a reliability/bug-fix patch, not a security vulnerability that an attacker can exploit remotely.
Treat as a normal bug-fix commit. No urgent security response is required. If reviewing, verify that all external serialization points cast Decimal to float/string as intended and that no remaining float percentage values enter the swap arithmetic path.
Security signals we found
Floating-point rounding causing a sanity check to fail and raise TypeError
Type confusion between int and NoneType in swap amount calculation
Fix uses Decimal for precise arithmetic on fee percentages
Backwards-compatibility float casts added at network/JSON boundaries
Evidence from the diff
SwapManager.percentage and SwapFees.percentage were previously floats. In get_recv_amount/get_send_amount, the code computes an ‘inverted’ amount and checks abs(send_amount - inverted_send_amount) > 1. With a 0.2 float percentage, the inverse calculation produced a value slightly off due to binary floating-point representation, so the sanity check returned None. The caller then subtracted None from an int, raising TypeError. The patch converts percentage to Decimal throughout, casts it back to float only at JSON-serialization boundaries for backwards compatibility with clients <= 4.7.1, and updates tests to use Decimal literals.
Changed components
electrum/submarine_swaps.pyelectrum/commands.pyelectrum/gui/qml/qeswaphelper.pyelectrum/plugins/swapserver/server.pytests/test_commands.pyInspect captured patch +13 / −13
diff --git a/electrum/commands.py b/electrum/commands.py
index 9ff0eaa..419d1c4 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -2016,7 +2016,7 @@ class Commands(Logger):
result = {}
for offer in offers:
result[offer.server_npub] = {
- "percentage_fee": offer.pairs.percentage,
+ "percentage_fee": float(offer.pairs.percentage),
"max_forward_sat": offer.pairs.max_forward,
"max_reverse_sat": offer.pairs.max_reverse,
"min_amount_sat": offer.pairs.min_amount,
diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py
index 984befb..7475216 100644
--- a/electrum/gui/qml/qeswaphelper.py
+++ b/electrum/gui/qml/qeswaphelper.py
@@ -75,7 +75,7 @@ class QESwapServerNPubListModel(QAbstractListModel):
return {
'npub': x.server_npub,
'server_pubkey': x.server_pubkey,
- 'percentage_fee': x.pairs.percentage,
+ 'percentage_fee': float(x.pairs.percentage),
'mining_fee': x.pairs.mining_fee,
'min_amount': x.pairs.min_amount,
'max_forward_amount': x.pairs.max_forward,
diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py
index b216373..7d645e7 100644
--- a/electrum/plugins/swapserver/server.py
+++ b/electrum/plugins/swapserver/server.py
@@ -95,7 +95,7 @@ class HttpSwapServer(Logger, EventListener):
"minimal": sm._min_amount,
},
"fees": {
- "percentage": sm.percentage,
+ "percentage": float(sm.percentage), # cast to float for <= 4.7.1 backwards compatibility
"minerFees": {
"baseAsset": {
"normal": sm.mining_fee,
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index c320a10..ef55aa6 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -168,7 +168,7 @@ def now():
@attr.s(frozen=True)
class SwapFees:
- percentage = attr.ib(type=int)
+ percentage = attr.ib(type=Decimal)
mining_fee = attr.ib(type=int)
min_amount = attr.ib(type=int)
max_forward = attr.ib(type=int)
@@ -235,7 +235,7 @@ class SwapManager(Logger):
def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'):
Logger.__init__(self)
self.mining_fee = None
- self.percentage = None
+ self.percentage = None # type: Optional[Decimal]
self._min_amount = None
self._max_forward = None
self._max_reverse = None
@@ -1193,7 +1193,7 @@ class SwapManager(Logger):
def server_update_pairs(self) -> None:
""" for server """
- self.percentage = float(self.config.SWAPSERVER_FEE_MILLIONTHS) / 10000 # type: ignore
+ self.percentage = Decimal(self.config.SWAPSERVER_FEE_MILLIONTHS) / 10000 # type: ignore
self._min_amount = MIN_SWAP_AMOUNT_SAT
oc_balance_sat: int = self.wallet.get_spendable_balance_sat()
max_forward: int = min(int(self.lnworker.num_sats_can_receive()), oc_balance_sat, 10000000)
@@ -1259,7 +1259,7 @@ class SwapManager(Logger):
if send_amount is None:
return None
x = Decimal(send_amount)
- percentage = Decimal(self.percentage)
+ percentage = self.percentage
if is_reverse:
if not self.check_invoice_amount(x, is_reverse):
return None
@@ -1289,7 +1289,7 @@ class SwapManager(Logger):
if not recv_amount:
return None
x = Decimal(recv_amount)
- percentage = Decimal(self.percentage)
+ percentage = self.percentage
if is_reverse:
# see/ref:
# https://github.com/BoltzExchange/boltz-backend/blob/e7e2d30f42a5bea3665b164feb85f84c64d86658/lib/service/Service.ts#L928
@@ -1638,7 +1638,7 @@ class HttpTransport(SwapServerTransport):
fees = response['pairs']['BTC/BTC']['fees']
limits = response['pairs']['BTC/BTC']['limits']
pairs = SwapFees(
- percentage=fees['percentage'],
+ percentage=Decimal(str(fees['percentage'])),
mining_fee=fees['minerFees']['baseAsset']['mining_fee'],
min_amount=limits['minimal'],
max_forward=limits['max_forward_amount'],
@@ -1777,7 +1777,7 @@ class NostrTransport(SwapServerTransport):
self.logger.warning(f"not publishing swap offer, no liquidity available: {sm._max_forward=}, {sm._max_reverse=}")
return
offer = {
- 'percentage_fee': sm.percentage,
+ 'percentage_fee': float(sm.percentage), # cast to float for <= 4.7.1 backwards compatibility
'mining_fee': sm.mining_fee,
'min_amount': sm._min_amount,
'max_forward_amount': sm._max_forward,
@@ -1883,7 +1883,7 @@ class NostrTransport(SwapServerTransport):
continue
try:
pairs = SwapFees(
- percentage=content['percentage_fee'],
+ percentage=Decimal(str(content['percentage_fee'])),
mining_fee=content['mining_fee'],
min_amount=content['min_amount'],
max_forward=content['max_forward_amount'],
diff --git a/tests/test_commands.py b/tests/test_commands.py
index dddf293..69b833b 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -678,7 +678,7 @@ class TestCommandsTestnet(ElectrumTestCase):
offer1 = SwapOffer(
pairs=SwapFees(
- percentage=0.5,
+ percentage=Decimal('0.5'),
mining_fee=2000,
min_amount=10000,
max_forward=1000000,
@@ -692,7 +692,7 @@ class TestCommandsTestnet(ElectrumTestCase):
offer2 = SwapOffer(
pairs=SwapFees(
- percentage=1.0,
+ percentage=Decimal('1.0'),
mining_fee=3000,
min_amount=20000,
max_forward=2000000,
Why this scored 32/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.