1
0

qml: refactor invoice amount checks, msat precision for lightning.

add qeconfig unit tests for conversion methods.
This commit is contained in:
Sander van Grieken
2025-11-25 12:31:01 +01:00
parent 0f2a41e078
commit 93c9dd9d12
3 changed files with 93 additions and 51 deletions

View File

@@ -358,13 +358,6 @@ class QEConfig(AuthMixin, QObject):
else: else:
return self.config.format_amount(msats/1000, precision=precision) 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) @pyqtSlot(str, result=QEAmount)
def unitsToSats(self, unitAmount): def unitsToSats(self, unitAmount):
self._amount = QEAmount() self._amount = QEAmount()
@@ -373,16 +366,11 @@ class QEConfig(AuthMixin, QObject):
except Exception: except Exception:
return self._amount return self._amount
# scale it to max allowed precision, make it an int sat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT
max_prec_amount = int(pow(10, self.max_precision()) * x) msat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT + 3
# if the max precision is simply what unit conversion allows, just return sat_max_prec_amount = int(pow(10, sat_max_precision) * x)
if self.max_precision() == self.decimal_point(): msat_max_prec_amount = int(pow(10, msat_max_precision) * x)
self._amount = QEAmount(amount_sat=max_prec_amount) self._amount = QEAmount(amount_sat=sat_max_prec_amount, amount_msat=msat_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)
return self._amount return self._amount
@pyqtSlot('quint64', result=float) @pyqtSlot('quint64', result=float)

View File

@@ -1,7 +1,7 @@
import copy import copy
import threading import threading
from enum import IntEnum from enum import IntEnum
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QTimer from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, pyqtEnum, QTimer
@@ -327,23 +327,10 @@ class QEInvoice(QObject, QtEventListener):
PR_UNKNOWN: _('Invoice has unknown status'), PR_UNKNOWN: _('Invoice has unknown status'),
}[_status] }[_status]
if self.invoiceType == QEInvoice.Type.LightningInvoice: if status in [PR_UNPAID, PR_FAILED]:
if status in [PR_UNPAID, PR_FAILED]: x, self.userinfo = self.check_can_pay_amount(amount)
if self.get_max_spendable_lightning() >= amount.satsInt: else:
lnaddr = self._effectiveInvoice._lnaddr self.userinfo = userinfo_for_invoice_status(status)
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)
def determine_can_pay(self): def determine_can_pay(self):
self.canPay = False self.canPay = False
@@ -364,24 +351,25 @@ class QEInvoice(QObject, QtEventListener):
if amount.isEmpty and status == PR_UNPAID: # unspecified amount if amount.isEmpty and status == PR_UNPAID: # unspecified amount
return 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 self.invoiceType == QEInvoice.Type.LightningInvoice:
if status in [PR_UNPAID, PR_FAILED]: if self.get_max_spendable_lightning() * 1000 >= amount.msatsInt:
if self.get_max_spendable_lightning() >= amount.satsInt: lnaddr = self._effectiveInvoice._lnaddr
lnaddr = self._effectiveInvoice._lnaddr if lnaddr.amount and amount.msatsInt < lnaddr.amount * COIN * 1000:
if not (lnaddr.amount and amount.satsInt < lnaddr.amount * COIN): return False, _('Cannot pay less than the amount specified in the invoice')
self.canPay = True else:
elif self.address and self.get_max_spendable_onchain() > amount.satsInt: return True, None
# TODO: validate address? elif self.address and self.get_max_spendable_onchain() > amount.satsInt:
# TODO: subtract fee? return True, None
self.canPay = True
elif self.invoiceType == QEInvoice.Type.OnchainInvoice: elif self.invoiceType == QEInvoice.Type.OnchainInvoice:
if status in [PR_UNPAID, PR_FAILED]: if (amount.isMax and self.get_max_spendable_onchain() > 0) or (self.get_max_spendable_onchain() >= amount.satsInt):
if amount.isMax and self.get_max_spendable_onchain() > 0: return True, None
# TODO: dust limit?
self.canPay = True return False, _('Insufficient balance')
elif self.get_max_spendable_onchain() >= amount.satsInt:
# TODO: subtract fee?
self.canPay = True
@pyqtSlot() @pyqtSlot()
def payLightningInvoice(self): def payLightningInvoice(self):

View File

@@ -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)