qml: refactor invoice amount checks, msat precision for lightning. add qeconfig unit tests for conversion methods.
What changed, and why it matters
This commit fixes a precision bug in Electrum's mobile/QML interface when handling very small Lightning payments. Previously, amounts were rounded to whole satoshis too early, which could cause the app to reject valid Lightning invoices that require millisecond-precision amounts, or to miscalculate whether the user has enough balance. The change also cleans up duplicate balance-check code and adds unit tests.
Review the refactored check_can_pay_amount logic for off-by-1000 errors and ensure msat/satoshi comparisons remain consistent across all Lightning payment flows. Run the new unit tests and consider additional integration tests for Lightning invoice payment edge cases.
Security signals we found
Amount precision loss in payment path could lead to incorrect payment validation
Lightning invoice minimum-amount check previously used satoshi-rounded value, potentially allowing underpayment or over-rejection
Refactoring consolidates duplicated balance/can-pay logic, reducing risk of inconsistent validation
New unit tests added for amount conversion edge cases
Evidence from the diff
The patch refactors invoice amount validation in electrum/gui/qml/qeinvoice.py to operate on msatsInt for Lightning invoices instead of satsInt, and consolidates the can-pay/userinfo logic into a single check_can_pay_amount method. In electrum/gui/qml/qeconfig.py, unitsToSats now populates both amount_sat and amount_msat fields with appropriate precision (BTC_AMOUNTS_DECIMAL_POINT and +3 for msats), removing the old max_precision fallthrough that truncated sub-satoshi precision. New unit tests verify the conversion behavior at various decimal-point settings.
Changed components
electrum/gui/qml/qeconfig.pyelectrum/gui/qml/qeinvoice.pytests/test_qml_qeconfig.pyInspect captured patch +93 / −51
diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py
index caa1509..979565e 100644
--- a/electrum/gui/qml/qeconfig.py
+++ b/electrum/gui/qml/qeconfig.py
@@ -358,13 +358,6 @@ class QEConfig(AuthMixin, QObject):
else:
return self.config.format_amount(msats/1000, precision=precision)
- # TODO delegate all this to config.py/util.py
- def decimal_point(self):
- return self.config.BTC_AMOUNTS_DECIMAL_POINT
-
- def max_precision(self):
- return self.decimal_point() + 0 # self.extra_precision
-
@pyqtSlot(str, result=QEAmount)
def unitsToSats(self, unitAmount):
self._amount = QEAmount()
@@ -373,16 +366,11 @@ class QEConfig(AuthMixin, QObject):
except Exception:
return self._amount
- # scale it to max allowed precision, make it an int
- max_prec_amount = int(pow(10, self.max_precision()) * x)
- # if the max precision is simply what unit conversion allows, just return
- if self.max_precision() == self.decimal_point():
- self._amount = QEAmount(amount_sat=max_prec_amount)
- return self._amount
- self._logger.debug('fallthrough')
- # otherwise, scale it back to the expected unit
- #amount = Decimal(max_prec_amount) / Decimal(pow(10, self.max_precision()-self.decimal_point()))
- #return int(amount) #Decimal(amount) if not self.is_int else int(amount)
+ sat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT
+ msat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT + 3
+ sat_max_prec_amount = int(pow(10, sat_max_precision) * x)
+ msat_max_prec_amount = int(pow(10, msat_max_precision) * x)
+ self._amount = QEAmount(amount_sat=sat_max_prec_amount, amount_msat=msat_max_prec_amount)
return self._amount
@pyqtSlot('quint64', result=float)
diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py
index acb80bb..bedc1c6 100644
--- a/electrum/gui/qml/qeinvoice.py
+++ b/electrum/gui/qml/qeinvoice.py
@@ -1,7 +1,7 @@
import copy
import threading
from enum import IntEnum
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, Tuple
from urllib.parse import urlparse
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QTimer
@@ -327,23 +327,10 @@ class QEInvoice(QObject, QtEventListener):
PR_UNKNOWN: _('Invoice has unknown status'),
}[_status]
- if self.invoiceType == QEInvoice.Type.LightningInvoice:
- if status in [PR_UNPAID, PR_FAILED]:
- if self.get_max_spendable_lightning() >= amount.satsInt:
- lnaddr = self._effectiveInvoice._lnaddr
- if lnaddr.amount and amount.satsInt < lnaddr.amount * COIN:
- self.userinfo = _('Cannot pay less than the amount specified in the invoice')
- elif not self.address or self.get_max_spendable_onchain() < amount.satsInt:
- # TODO: for onchain: validate address? subtract fee?
- self.userinfo = _('Insufficient balance')
- else:
- self.userinfo = userinfo_for_invoice_status(status)
- elif self.invoiceType == QEInvoice.Type.OnchainInvoice:
- if status in [PR_UNPAID, PR_FAILED]:
- if not ((amount.isMax and self.get_max_spendable_onchain() > 0) or (self.get_max_spendable_onchain() >= amount.satsInt)):
- self.userinfo = _('Insufficient balance')
- else:
- self.userinfo = userinfo_for_invoice_status(status)
+ if status in [PR_UNPAID, PR_FAILED]:
+ x, self.userinfo = self.check_can_pay_amount(amount)
+ else:
+ self.userinfo = userinfo_for_invoice_status(status)
def determine_can_pay(self):
self.canPay = False
@@ -364,24 +351,25 @@ class QEInvoice(QObject, QtEventListener):
if amount.isEmpty and status == PR_UNPAID: # unspecified amount
return
+ if status in [PR_UNPAID, PR_FAILED]:
+ self.canPay, x = self.check_can_pay_amount(amount)
+
+ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[bool, Optional[str]]:
+ assert self.status in [PR_UNPAID, PR_FAILED]
if self.invoiceType == QEInvoice.Type.LightningInvoice:
- if status in [PR_UNPAID, PR_FAILED]:
- if self.get_max_spendable_lightning() >= amount.satsInt:
- lnaddr = self._effectiveInvoice._lnaddr
- if not (lnaddr.amount and amount.satsInt < lnaddr.amount * COIN):
- self.canPay = True
- elif self.address and self.get_max_spendable_onchain() > amount.satsInt:
- # TODO: validate address?
- # TODO: subtract fee?
- self.canPay = True
+ if self.get_max_spendable_lightning() * 1000 >= amount.msatsInt:
+ lnaddr = self._effectiveInvoice._lnaddr
+ if lnaddr.amount and amount.msatsInt < lnaddr.amount * COIN * 1000:
+ return False, _('Cannot pay less than the amount specified in the invoice')
+ else:
+ return True, None
+ elif self.address and self.get_max_spendable_onchain() > amount.satsInt:
+ return True, None
elif self.invoiceType == QEInvoice.Type.OnchainInvoice:
- if status in [PR_UNPAID, PR_FAILED]:
- if amount.isMax and self.get_max_spendable_onchain() > 0:
- # TODO: dust limit?
- self.canPay = True
- elif self.get_max_spendable_onchain() >= amount.satsInt:
- # TODO: subtract fee?
- self.canPay = True
+ if (amount.isMax and self.get_max_spendable_onchain() > 0) or (self.get_max_spendable_onchain() >= amount.satsInt):
+ return True, None
+
+ return False, _('Insufficient balance')
@pyqtSlot()
def payLightningInvoice(self):
diff --git a/tests/test_qml_qeconfig.py b/tests/test_qml_qeconfig.py
new file mode 100644
index 0000000..d76c543
--- /dev/null
+++ b/tests/test_qml_qeconfig.py
@@ -0,0 +1,66 @@
+from electrum import SimpleConfig
+from electrum.gui.qml.qeconfig import QEConfig
+from tests.qt_util import QETestCase, qt_test
+
+
+class TestConfig(QETestCase):
+ @classmethod
+ def setUpClass(cls):
+ QEConfig(SimpleConfig())
+
+ def setUp(self):
+ super().setUp()
+ self.q: QEConfig = QEConfig.instance
+ # raise Exception() # NOTE: exceptions in setUp() will block the test
+
+ @qt_test
+ def test_satstounits(self):
+ self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 5
+ self.assertEqual(self.q.satsToUnits(100_000), 1.0)
+ self.assertEqual(self.q.satsToUnits(1), 0.00001)
+ self.assertEqual(self.q.satsToUnits(0.001), 0.00000001)
+
+ @qt_test
+ def test_unitstosats(self):
+ qa = self.q.unitsToSats('')
+ self.assertTrue(qa.isEmpty)
+ qa = self.q.unitsToSats('0')
+ self.assertTrue(qa.isEmpty)
+ qa = self.q.unitsToSats('0.000')
+ self.assertTrue(qa.isEmpty)
+
+ self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 5
+
+ qa = self.q.unitsToSats('1')
+ self.assertFalse(qa.isEmpty)
+ self.assertEqual(qa.satsInt, 100_000)
+ self.assertEqual(qa.msatsInt, 100_000_000)
+
+ qa = self.q.unitsToSats('1.001')
+ self.assertFalse(qa.isEmpty)
+ self.assertEqual(qa.satsInt, 100_100)
+ self.assertEqual(qa.msatsInt, 100_100_000)
+
+ qa = self.q.unitsToSats('1.000001')
+ self.assertFalse(qa.isEmpty)
+ self.assertEqual(qa.satsInt, 100_000)
+ self.assertEqual(qa.msatsInt, 100_000_100)
+
+ self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 0
+
+ qa = self.q.unitsToSats('1.001')
+ self.assertFalse(qa.isEmpty)
+ self.assertEqual(qa.satsInt, 1)
+ self.assertEqual(qa.msatsInt, 1001)
+
+ qa = self.q.unitsToSats('1.0001') # outside msat precision
+ self.assertFalse(qa.isEmpty)
+ self.assertEqual(qa.satsInt, 1)
+ self.assertEqual(qa.msatsInt, 1000)
+
+ self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 8
+
+ qa = self.q.unitsToSats('0.00000001001')
+ self.assertFalse(qa.isEmpty)
+ self.assertEqual(qa.satsInt, 1)
+ self.assertEqual(qa.msatsInt, 1001)
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.