1
0

Merge pull request #10321 from accumulator/qml_msat_precision

qml millisat precision fix
This commit is contained in:
ghost43
2025-11-27 15:46:34 +00:00
committed by GitHub
13 changed files with 200 additions and 79 deletions

View File

@@ -181,7 +181,9 @@ ElDialog {
font.pixelSize: constants.fontSizeXLarge
font.family: FixedFont
font.bold: true
text: Config.formatSats(invoice.amount, false)
text: invoice.invoiceType == Invoice.LightningInvoice
? Config.formatMilliSats(invoice.amount, false)
: Config.formatSats(invoice.amount, false)
}
Label {
@@ -223,12 +225,13 @@ ElDialog {
Layout.preferredWidth: amountFontMetrics.advanceWidth('0') * 14 + leftPadding + rightPadding
fiatfield: amountFiat
readOnly: amountMax.checked
msatPrecision: invoice.invoiceType == Invoice.LightningInvoice
color: readOnly
? Material.accentColor
: Material.foreground
onTextAsSatsChanged: {
if (!amountMax.checked)
invoice.amountOverride.satsInt = textAsSats.satsInt
invoice.amountOverride.copyFrom(textAsSats)
}
Connections {
target: invoice.amountOverride

View File

@@ -7,12 +7,13 @@ TextField {
id: amount
required property TextField fiatfield
property bool msatPrecision: false
font.family: FixedFont
placeholderText: qsTr('Amount')
inputMethodHints: Qt.ImhDigitsOnly
validator: RegularExpressionValidator {
regularExpression: Config.btcAmountRegex
regularExpression: msatPrecision ? Config.btcAmountRegexMsat : Config.btcAmountRegex
}
property Amount textAsSats

View File

@@ -94,14 +94,22 @@ class QEConfig(AuthMixin, QObject):
@pyqtProperty('QRegularExpression', notify=baseUnitChanged)
def btcAmountRegex(self):
return self._btcAmountRegex()
@pyqtProperty('QRegularExpression', notify=baseUnitChanged)
def btcAmountRegexMsat(self):
return self._btcAmountRegex(3)
def _btcAmountRegex(self, extra_precision: int = 0):
decimal_point = base_unit_name_to_decimal_point(self.config.get_base_unit())
max_digits_before_dp = (
len(str(TOTAL_COIN_SUPPLY_LIMIT_IN_BTC))
+ (base_unit_name_to_decimal_point("BTC") - decimal_point))
exp = '[0-9]{0,%d}' % max_digits_before_dp
exp = '^[0-9]{0,%d}' % max_digits_before_dp
decimal_point += extra_precision
if decimal_point > 0:
exp += '\\.'
exp += '[0-9]{0,%d}' % decimal_point
exp += '(\\.[0-9]{0,%d})?' % decimal_point
exp += '$'
return QRegularExpression(exp)
thousandsSeparatorChanged = pyqtSignal()
@@ -358,13 +366,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,18 +374,13 @@ 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)
def satsToUnits(self, satoshis):
return satoshis / pow(10, self.config.decimal_point)
return satoshis / pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)

View File

@@ -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):
@@ -395,7 +383,7 @@ class QEInvoice(QObject, QtEventListener):
if self.amount.isEmpty:
if self.amountOverride.isEmpty:
raise Exception('can not pay 0 amount')
amount_msat = self.amountOverride.satsInt * 1000
amount_msat = self.amountOverride.msatsInt
self._wallet.pay_lightning_invoice(self._effectiveInvoice, amount_msat)

View File

@@ -90,6 +90,7 @@ class QEAmount(QObject):
self._is_max = False
self.valueChanged.emit()
@pyqtSlot('QVariant')
def copyFrom(self, amount):
if not amount:
self._logger.warning('copyFrom with None argument. assuming 0') # TODO

View File

@@ -1005,7 +1005,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener):
return self.config.format_fee_rate(fee_rate)
def get_decimal_point(self):
return self.config.get_decimal_point()
return self.config.BTC_AMOUNTS_DECIMAL_POINT
def base_unit(self):
return self.config.get_base_unit()

View File

@@ -97,7 +97,7 @@ class SettingsDialog(QDialog, QtEventListener):
nz_label = HelpLabel.from_configvar(self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT)
nz = QSpinBox()
nz.setMinimum(0)
nz.setMaximum(self.config.decimal_point)
nz.setMaximum(self.config.BTC_AMOUNTS_DECIMAL_POINT)
nz.setValue(self.config.num_zeros)
if not self.config.cv.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT.is_modifiable():
for w in [nz, nz_label]: w.setEnabled(False)
@@ -205,7 +205,7 @@ class SettingsDialog(QDialog, QtEventListener):
if self.config.get_base_unit() == unit_result:
return
self.config.set_base_unit(unit_result)
nz.setMaximum(self.config.decimal_point)
nz.setMaximum(self.config.BTC_AMOUNTS_DECIMAL_POINT)
self.app.refresh_tabs_signal.emit()
self.app.update_status_signal.emit()
self.app.refresh_amount_edits_signal.emit()

View File

@@ -615,7 +615,7 @@ class ElectrumGui(BaseElectrumGui, EventListener):
x = Decimal(text)
except Exception:
return None
power = pow(10, self.config.get_decimal_point())
power = pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)
return int(power * x)
def read_invoice(self):

View File

@@ -557,7 +557,7 @@ class PaymentIdentifier(Logger):
raise Exception("Amount is empty")
if parse_max_spend(x):
return x
p = pow(10, self.config.get_decimal_point())
p = pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT)
try:
return int(p * Decimal(x))
except InvalidOperation:

View File

@@ -518,7 +518,7 @@ class BitBox02Client(HardwareClientBase):
format_unit = bitbox02.btc.BTCSignInitRequest.FormatUnit.DEFAULT
# Base unit is configured to be "sat":
if self.config.get_decimal_point() == 0:
if self.config.BTC_AMOUNTS_DECIMAL_POINT == 0:
format_unit = bitbox02.btc.BTCSignInitRequest.FormatUnit.SAT
sigs = self.bitbox02_device.btc_sign(

View File

@@ -324,11 +324,11 @@ class TrezorPlugin(HW_PluginBase):
raise ValueError('unexpected txin type: {}'.format(electrum_txin_type))
def get_trezor_amount_unit(self):
if self.config.decimal_point == 0:
if self.config.BTC_AMOUNTS_DECIMAL_POINT == 0:
return AmountUnit.SATOSHI
elif self.config.decimal_point == 2:
elif self.config.BTC_AMOUNTS_DECIMAL_POINT == 2:
return AmountUnit.MICROBITCOIN
elif self.config.decimal_point == 5:
elif self.config.BTC_AMOUNTS_DECIMAL_POINT == 5:
return AmountUnit.MILLIBITCOIN
else:
return AmountUnit.BITCOIN

View File

@@ -217,12 +217,10 @@ class SimpleConfig(Logger):
self._check_dependent_keys()
# units and formatting
# FIXME is this duplication (dp, nz, post_sat, thou_sep) due to performance reasons??
self.decimal_point = self.BTC_AMOUNTS_DECIMAL_POINT
try:
decimal_point_to_base_unit_name(self.decimal_point)
decimal_point_to_base_unit_name(self.BTC_AMOUNTS_DECIMAL_POINT)
except UnknownBaseUnit:
self.decimal_point = DECIMAL_POINT_DEFAULT
self.BTC_AMOUNTS_DECIMAL_POINT = DECIMAL_POINT_DEFAULT
self.num_zeros = self.BTC_AMOUNTS_FORCE_NZEROS_AFTER_DECIMAL_POINT
self.amt_precision_post_satoshi = self.BTC_AMOUNTS_PREC_POST_SAT
self.amt_add_thousands_sep = self.BTC_AMOUNTS_ADD_THOUSANDS_SEP
@@ -530,7 +528,7 @@ class SimpleConfig(Logger):
return format_satoshis(
amount_sat,
num_zeros=self.num_zeros,
decimal_point=self.decimal_point,
decimal_point=self.BTC_AMOUNTS_DECIMAL_POINT,
is_diff=is_diff,
whitespaces=whitespaces,
precision=precision,
@@ -545,15 +543,11 @@ class SimpleConfig(Logger):
return format_fee_satoshis(fee_rate/1000, num_zeros=self.num_zeros) + f" {util.UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE}"
def get_base_unit(self):
return decimal_point_to_base_unit_name(self.decimal_point)
return decimal_point_to_base_unit_name(self.BTC_AMOUNTS_DECIMAL_POINT)
def set_base_unit(self, unit):
assert unit in base_units.keys()
self.decimal_point = base_unit_name_to_decimal_point(unit)
self.BTC_AMOUNTS_DECIMAL_POINT = self.decimal_point
def get_decimal_point(self):
return self.decimal_point
self.BTC_AMOUNTS_DECIMAL_POINT = base_unit_name_to_decimal_point(unit)
def get_nostr_relays(self) -> Sequence[str]:
relays = []

138
tests/test_qml_qeconfig.py Normal file
View File

@@ -0,0 +1,138 @@
from typing import TYPE_CHECKING
from electrum import SimpleConfig
from electrum.gui.qml.qeconfig import QEConfig
from tests.qt_util import QETestCase, qt_test
if TYPE_CHECKING:
from PyQt6.QtCore import QRegularExpression
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)
@qt_test
def test_btc_amount_regexes(self):
self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 8
a: 'QRegularExpression' = self.q.btcAmountRegex
b: 'QRegularExpression' = self.q.btcAmountRegexMsat
self.assertTrue(a.isValid())
self.assertTrue(b.isValid())
self.assertTrue(a.match('1').hasMatch())
self.assertTrue(a.match('1.').hasMatch())
self.assertTrue(a.match('1.00000000').hasMatch())
self.assertFalse(a.match('1.000000000').hasMatch())
self.assertTrue(a.match('21000000').hasMatch())
self.assertFalse(a.match('121000000').hasMatch())
self.assertTrue(b.match('1').hasMatch())
self.assertTrue(b.match('1.').hasMatch())
self.assertTrue(b.match('1.00000000').hasMatch())
self.assertTrue(b.match('1.00000000000').hasMatch())
self.assertFalse(b.match('1.000000000000').hasMatch())
self.assertTrue(b.match('21000000').hasMatch())
self.assertFalse(b.match('121000000').hasMatch())
self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 5
a: 'QRegularExpression' = self.q.btcAmountRegex
b: 'QRegularExpression' = self.q.btcAmountRegexMsat
self.assertTrue(a.isValid())
self.assertTrue(b.isValid())
self.assertTrue(a.match('1').hasMatch())
self.assertTrue(a.match('1.').hasMatch())
self.assertTrue(a.match('1.00000').hasMatch())
self.assertFalse(a.match('1.000000').hasMatch())
self.assertTrue(a.match('21000000000').hasMatch())
self.assertFalse(a.match('121000000000').hasMatch())
self.assertTrue(b.match('1').hasMatch())
self.assertTrue(b.match('1.').hasMatch())
self.assertTrue(b.match('1.0000000').hasMatch())
self.assertTrue(b.match('1.00000000').hasMatch())
self.assertFalse(b.match('1.000000000000').hasMatch())
self.assertTrue(b.match('21000000000').hasMatch())
self.assertFalse(b.match('121000000000').hasMatch())
self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 0
a: 'QRegularExpression' = self.q.btcAmountRegex
b: 'QRegularExpression' = self.q.btcAmountRegexMsat
self.assertTrue(a.isValid())
self.assertTrue(b.isValid())
self.assertTrue(a.match('1').hasMatch())
self.assertFalse(a.match('1.').hasMatch())
self.assertTrue(a.match('2100000000000000').hasMatch())
self.assertFalse(a.match('12100000000000000').hasMatch())
self.assertTrue(b.match('1').hasMatch())
self.assertTrue(b.match('1.').hasMatch())
self.assertTrue(b.match('1.000').hasMatch())
self.assertFalse(b.match('1.0000').hasMatch())
self.assertTrue(b.match('2100000000000000').hasMatch())
self.assertFalse(b.match('12100000000000000').hasMatch())