util.format_satoshis: floating-point paranoia
What changed, and why it matters
This commit tightens how Electrum converts numbers into displayed bitcoin amounts. It removes the ability to pass plain strings into a key formatting helper, and routes integer and float inputs through a safer Decimal conversion to avoid tiny rounding errors that can appear when computers handle decimal numbers. The change also makes one fee calculation explicitly use Decimal arithmetic. The patch is defensive and does not by itself fix a known exploitable bug, but it reduces the risk of incorrect amounts being shown or used in fee calculations.
Treat as a defensive hardening commit. Review whether any remaining float-based monetary paths should be migrated to Decimal, as suggested by the TODO. No immediate incident response is indicated, but downstream users relying on format_satoshis with string inputs will need to update their code.
Security signals we found
Floating-point/Decimal conversion hardening in monetary formatting functions
Removal of str input type from format_satoshis to reduce unexpected parsing paths
Explicit Decimal conversion in fee-per-byte calculation
Added unit test demonstrating correct rounding behavior for float input
TODO comment suggesting future hard-fail on float inputs for money values
Evidence from the diff
The patch modifies util.format_satoshis and util.format_satoshis_plain to convert inputs via to_decimal() before formatting. to_decimal() now returns Decimal(x) for ints instead of Decimal(str(x)), and the type signature of commands.format_satoshis drops str from the accepted input types. wallet.py’s fee-per-byte calculation is changed from fee / size to Decimal(fee) / size. A test is added showing that format_satoshis(41754.681) now rounds to ‘0.00041755’. The commit message frames this as ‘floating-point paranoia’, i.e. a hardening change rather than a fix for a reported vulnerability.
Changed components
electrum/util.py:format_satoshiselectrum/util.py:format_satoshis_plainelectrum/util.py:to_decimalelectrum/commands.py:format_satoshiselectrum/wallet.py:Abstract_Wallet.get_history_itemtests/test_commands.pyInspect captured patch +11 / −4
diff --git a/electrum/commands.py b/electrum/commands.py
index 5b1688c..a886197 100644
--- a/electrum/commands.py
+++ b/electrum/commands.py
@@ -101,7 +101,7 @@ def satoshis(amount):
return int(COIN*to_decimal(amount)) if amount is not None else None
-def format_satoshis(x: Union[str, float, int, Decimal, None]) -> Optional[str]:
+def format_satoshis(x: Union[float, int, Decimal, None]) -> Optional[str]:
"""
input: satoshis as a Number
output: str formatted as bitcoin amount
diff --git a/electrum/util.py b/electrum/util.py
index 915f7ce..0a57b68 100644
--- a/electrum/util.py
+++ b/electrum/util.py
@@ -234,6 +234,8 @@ def to_decimal(x: Union[str, float, int, Decimal]) -> Decimal:
# Decimal('41754.681')
if isinstance(x, Decimal):
return x
+ if isinstance(x, int):
+ return Decimal(x)
return Decimal(str(x))
@@ -800,8 +802,10 @@ def format_satoshis_plain(
if is_max_allowed and parse_max_spend(x):
return f'max({x})'
assert isinstance(x, (int, float, Decimal)), f"{x!r} should be a number"
+ # TODO(ghost43) just hard-fail if x is a float. do we even use floats for money anywhere?
+ x = to_decimal(x)
scale_factor = pow(10, decimal_point)
- return "{:.8f}".format(Decimal(x) / scale_factor).rstrip('0').rstrip('.')
+ return "{:.8f}".format(x / scale_factor).rstrip('0').rstrip('.')
# Check that Decimal precision is sufficient.
@@ -833,8 +837,10 @@ def format_satoshis(
if parse_max_spend(x):
return f'max({x})'
assert isinstance(x, (int, float, Decimal)), f"{x!r} should be a number"
+ # TODO(ghost43) just hard-fail if x is a float. do we even use floats for money anywhere?
+ x = to_decimal(x)
# lose redundant precision
- x = Decimal(x).quantize(Decimal(10) ** (-precision))
+ x = x.quantize(Decimal(10) ** (-precision))
# format string
overall_precision = decimal_point + precision # max digits after final decimal point
decimal_format = "." + str(overall_precision) if overall_precision > 0 else ""
diff --git a/electrum/wallet.py b/electrum/wallet.py
index 894c3a9..e28778a 100644
--- a/electrum/wallet.py
+++ b/electrum/wallet.py
@@ -1759,7 +1759,7 @@ class Abstract_Wallet(ABC, Logger, EventListener):
fee = self.adb.get_tx_fee(tx_hash)
if fee is not None:
size = tx.estimated_size()
- fee_per_byte = fee / size
+ fee_per_byte = Decimal(fee) / size
extra.append(format_fee_satoshis(fee_per_byte) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VB}")
if fee is not None and height in (TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED) \
and self.network and self.network.has_fee_mempool():
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 2d88431..f1169ec 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -185,6 +185,7 @@ class TestCommands(ElectrumTestCase):
self.assertEqual(format_satoshis(Decimal(123.456)), "0.00000123")
self.assertEqual(format_satoshis(Decimal(123.5)), "0.00000124")
self.assertEqual(format_satoshis(Decimal(123.789)), "0.00000124")
+ self.assertEqual(format_satoshis(41754.681), "0.00041755")
class TestCommandsTestnet(ElectrumTestCase):
Why this scored 46/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.