From 93c9dd9d12aad2ab3324cbd94bff5f75fa5974be Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 25 Nov 2025 12:31:01 +0100 Subject: [PATCH] qml: refactor invoice amount checks, msat precision for lightning. add qeconfig unit tests for conversion methods. --- electrum/gui/qml/qeconfig.py | 22 +++--------- electrum/gui/qml/qeinvoice.py | 56 ++++++++++++----------------- tests/test_qml_qeconfig.py | 66 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 51 deletions(-) create mode 100644 tests/test_qml_qeconfig.py diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index caa150977..979565e5e 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 acb80bb81..bedc1c659 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 000000000..d76c54338 --- /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)