Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions electrum/gui/qml/components/InvoiceDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,8 @@ ElDialog {
}
Connections {
target: invoice.amountOverride
function onSatsIntChanged() {
console.log('amountOverride satsIntChanged, sats=' + invoice.amountOverride.satsInt)
function onValueChanged() {
console.log('amountOverride valueChanged, sats=' + invoice.amountOverride.satsInt)
if (amountMax.checked) // amountOverride updated by max amount estimate
amountBtc.text = Config.formatSatsForEditing(invoice.amountOverride.satsInt)
}
Expand Down
2 changes: 1 addition & 1 deletion electrum/gui/qml/components/OpenChannelDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ ElDialog {

Connections {
target: channelopener.amount
function onSatsIntChanged() {
function onValueChanged() {
if (is_max.checked) // amount updated by max amount estimate
amountBtc.text = Config.formatSatsForEditing(channelopener.amount.satsInt)
}
Expand Down
2 changes: 1 addition & 1 deletion electrum/gui/qml/components/controls/BtcField.qml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ TextField {
Connections {
target: Config
function onBaseUnitChanged() {
amount.text = amount.textAsSats.satsInt != 0
amount.text = amount.textAsSats.msatsInt != 0
? Config.satsToUnits(amount.textAsSats)
: ''
}
Expand Down
10 changes: 5 additions & 5 deletions electrum/gui/qml/components/controls/ChannelBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Item {
}

function do_update() {
var cap = capacity.satsInt * 1000
var cap = capacity.msatsInt
var twocap = cap * 2
l1.width = width * (cap - localCapacity.msatsInt) / twocap
if (frozenForSending) {
Expand All @@ -48,22 +48,22 @@ Item {

Connections {
target: localCapacity
function onMsatsIntChanged() { update() }
function onValueChanged() { update() }
}

Connections {
target: remoteCapacity
function onMsatsIntChanged() { update() }
function onValueChanged() { update() }
}

Connections {
target: canSend
function onMsatsIntChanged() { update() }
function onValueChanged() { update() }
}

Connections {
target: canReceive
function onMsatsIntChanged() { update() }
function onValueChanged() { update() }
}

Rectangle {
Expand Down
2 changes: 1 addition & 1 deletion electrum/gui/qml/components/controls/FormattedAmount.qml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ GridLayout {
}
Label {
visible: valid
text: amount.msatsInt != 0 ? Config.formatMilliSats(amount) : Config.formatSats(amount)
text: Config.formatMilliSats(amount)

@f321x f321x Jun 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passes None to Config.formatMilliSats() and raises an Exception, e.g. when creating a lightning invoice:

  3.70 | I | lnworker.LNWallet.[default_wallet] | creating bolt11 invoice with routing_hints: [('r', [('02c3be1bcedffe1675617fc7ae9e4c43ce0f54cc3e58c48ef51398dc87a78e0ce0', '10052759x15511793x59990', 1000, 1, 144)]), ('r', [('02c3be1bcedffe1675617fc7ae9e4c43ce0f54cc3e58c48ef51398dc87a78e0ce0', '8427041x8597475x4058', 1000, 1, 144)]), ('r', [('02465ed5be53d04fde66c9418ff14a5f2267723810176c9212b722e542dc1afb1b', '16000000x0x18483', 1000, 1, 80)])], sat: 1898
  3.71 | E | gui.qml.qeapp.Exception_Hook | exception caught by crash reporter
Traceback (most recent call last):
  File "/var/home/user/code/code_vm/electrum/electrum/gui/qml/qeconfig.py", line 400, in formatMilliSats
    raise Exception(f"Unknown amount type: {str(type(amount))}")
Exception: Unknown amount type: <class 'NoneType'>
  3.73 | D | gui.qml.qerequestdetails | key=f3cd542ad64f230e65dfe4e7312d3a00394a3ce651ed4a5ab1c8072535c10061

font.family: FixedFont
}
Label {
Expand Down
4 changes: 2 additions & 2 deletions electrum/gui/qml/components/controls/HistoryItemDelegate.qml
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@ Item {
font.pixelSize: constants.fontSizeMedium
Layout.alignment: Qt.AlignRight
font.bold: true
color: model.value.satsInt >= 0 ? constants.colorCredit : constants.colorDebit
color: model.value.msatsInt >= 0 ? constants.colorCredit : constants.colorDebit

function updateText() {
text = Config.formatSats(model.value)
text = Config.formatMilliSats(model.value)
}
Component.onCompleted: updateText()
}
Expand Down
6 changes: 3 additions & 3 deletions electrum/gui/qml/components/controls/InvoiceDelegate.qml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ ItemDelegate {
? ''
: model.amount.isMax
? 'MAX'
: Config.formatSats(model.amount)
: Config.formatMilliSats(model.amount)
font.pixelSize: constants.fontSizeMedium
font.family: FixedFont
}
Expand Down Expand Up @@ -142,10 +142,10 @@ ItemDelegate {
Connections {
target: Config
function onBaseUnitChanged() {
amount.text = model.amount.isEmpty ? '' : Config.formatSats(model.amount)
amount.text = model.amount.isEmpty ? '' : Config.formatMilliSats(model.amount)
}
function onThousandsSeparatorChanged() {
amount.text = model.amount.isEmpty ? '' : Config.formatSats(model.amount)
amount.text = model.amount.isEmpty ? '' : Config.formatMilliSats(model.amount)
}
}
Connections {
Expand Down
36 changes: 28 additions & 8 deletions electrum/gui/qml/qeconfig.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import copy
from decimal import Decimal
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional

from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRegularExpression

Expand Down Expand Up @@ -360,6 +360,21 @@ def formatSatsForEditing(self, satoshis):
add_thousands_sep=False,
)

@pyqtSlot('qint64', result=str)
@pyqtSlot(QEAmount, result=str)
def formatMilliSatsForEditing(self, msatoshis):
if isinstance(msatoshis, QEAmount):
satoshis = Decimal(msatoshis.msatsInt) / 1000
elif isinstance(msatoshis, int):
satoshis = Decimal(msatoshis) / 1000

precision = 3 # config.amt_precision_post_satoshi is not exposed in preferences
return self.config.format_amount(
satoshis,
add_thousands_sep=False,
precision=precision,
)

@pyqtSlot('qint64', result=str)
@pyqtSlot('qint64', bool, result=str)
@pyqtSlot(QEAmount, result=str)
Expand All @@ -372,16 +387,23 @@ def formatSats(self, satoshis, with_unit=False):
else:
return self.config.format_amount(satoshis)

@pyqtSlot('qint64', result=str)
@pyqtSlot('qint64', bool, result=str)
@pyqtSlot(QEAmount, result=str)
@pyqtSlot(QEAmount, bool, result=str)
def formatMilliSats(self, amount, with_unit=False):
assert isinstance(amount, QEAmount), f"unexpected type for amount: {type(amount)}"
msats = amount.msatsInt
if isinstance(amount, QEAmount):
msats = amount.msatsInt
elif isinstance(amount, int):
msats = amount
else:
raise Exception(f"Unknown amount type: {str(type(amount))}")
sats = Decimal(msats) / 1000
precision = 3 # config.amt_precision_post_satoshi is not exposed in preferences
if with_unit:
return self.config.format_amount_and_units(msats/1000, precision=precision)
return self.config.format_amount_and_units(sats, precision=precision)
else:
return self.config.format_amount(msats/1000, precision=precision)
return self.config.format_amount(sats, precision=precision)

@pyqtSlot(str, result=QEAmount)
def unitsToSats(self, unitAmount):
Expand All @@ -391,11 +413,9 @@ def unitsToSats(self, unitAmount):
except Exception:
return self._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)
self._amount = QEAmount(amount_msat=msat_max_prec_amount)
return self._amount

@pyqtSlot('quint64', result=float)
Expand Down
4 changes: 2 additions & 2 deletions electrum/gui/qml/qetransactionlistmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,11 @@ def tx_to_model(self, tx_item):
item['lightning'] = False

if item['lightning']:
item['value'] = QEAmount(amount_sat=item['value'].value, amount_msat=item['amount_msat'])
item['value'] = QEAmount(amount_msat=item['amount_msat'])
item['incoming'] = True if item['amount_msat'] > 0 else False
item['confirmations'] = 0
else:
item['value'] = QEAmount(amount_sat=item['value'].value)
item['value'] = QEAmount(amount_sat=int(item['value'].value))

if 'txid' in item:
tx = self.wallet.db.get_transaction(item['txid'])
Expand Down
73 changes: 50 additions & 23 deletions electrum/gui/qml/qetypes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from decimal import Decimal

from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject

from electrum.logging import get_logger
Expand All @@ -7,7 +9,6 @@
class QEAmount(QObject):
"""Container for bitcoin amounts that can be passed around more
easily between python, QML-property and QML-javascript contexts.
Note: millisat and sat amounts are not synchronized!

QML type 'int' in property definitions is 32 bit signed, so will overflow easily
on (milli)satoshi amounts! 'int' in QML-javascript seems to be larger than 32 bit, and
Expand All @@ -20,32 +21,50 @@ class QEAmount(QObject):

_logger = get_logger(__name__)

def __init__(self, *, amount_sat: int = 0, amount_msat: int = 0, is_max: bool = False, from_invoice=None, parent=None):
valueChanged = pyqtSignal()

def __init__(self, *, amount_sat: int = None, amount_msat: int = None, is_max: bool = False, from_invoice=None, parent=None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default argument change from 0 to None causes the log in satsInt() and msatsInt() to spam a bit when using default QEAmount(). Maybe the log could be removed as it is to be expected that the amount is None sometimes?

E.g. when opening a swap view:

 24.86 | D | gui.qml.qeswaphelper | run_swap_manager
 24.87 | I | submarine_swaps.NostrTransport | starting nostr transport with pubkey: 7992c2f29bf56da19b032e2aca6f785e8413058dd57f1c1482aa2486c4531299
 24.87 | I | submarine_swaps.NostrTransport | nostr relays: ['wss://nostr.einundzwanzig.space', 'wss://relay.primal.net', 'wss://relay.damus.io', 'wss://eu.purplerelay.com', 'wss://ftp.halifax.rwth-aachen.de/nostr', 'wss://relay.getalby.com/v1', 'wss://nostr.mom', 'wss://brb.io', 'wss://nos.lol']
 24.87 | W | gui.qml.qetypes | amount_msat is undefined, returning 0
 24.87 | W | gui.qml.qetypes | amount_msat is undefined, returning 0
 24.87 | W | gui.qml.qetypes | amount_msat is undefined, returning 0
 24.87 | W | gui.qml.qetypes | amount_msat is undefined, returning 0

super().__init__(parent)
self._amount_sat = int(amount_sat) if amount_sat is not None else None
self._amount_msat = int(amount_msat) if amount_msat is not None else None

self._amount_msat = None
if amount_sat is not None:
assert isinstance(amount_sat, int)
self._amount_msat = self._sat_to_msat(amount_sat)
if amount_msat is not None:
assert isinstance(amount_msat, int)
if amount_sat is not None:
assert amount_sat == self._msat_to_sat(amount_msat) # if both defined, assert conversion is as expected
self._amount_msat = amount_msat
if is_max:
assert amount_sat is None and amount_msat is None

self._is_max = is_max
if from_invoice:
assert amount_sat is None and amount_msat is None, 'cannot combine from_invoice and amount_(m)sat'
inv_amt = from_invoice.get_amount_msat()
if inv_amt == '!':
self._is_max = True
elif inv_amt is not None:
self._amount_msat = int(inv_amt)
self._amount_sat = int(from_invoice.get_amount_sat())

valueChanged = pyqtSignal()
def _sat_to_msat(self, amount_sat: int | None) -> int | None:
return amount_sat * 1000 if amount_sat is not None else None

def _msat_to_sat(self, amount_msat: int | None) -> int | None:
return int(Decimal(amount_msat) / 1000) if amount_msat is not None else None

@pyqtProperty('qint64', notify=valueChanged)
def satsInt(self):
if self._amount_sat is None: # should normally be defined when accessing this property
self._logger.warning('amount_sat is undefined, returning 0')
if self._amount_msat is None: # should normally be defined when accessing this property
self._logger.warning('amount_msat is undefined, returning 0')
return 0
return self._amount_sat
return self._msat_to_sat(self._amount_msat)

@satsInt.setter
def satsInt(self, sats):
if self._amount_sat != sats:
self._amount_sat = sats
msats = self._sat_to_msat(sats)
if self._amount_msat != msats:
self._amount_msat = msats
self.valueChanged.emit()

@pyqtProperty('qint64', notify=valueChanged)
Expand All @@ -63,7 +82,7 @@ def msatsInt(self, msats):

@pyqtProperty(str, notify=valueChanged)
def satsStr(self):
return str(self._amount_sat)
return str(self._msat_to_sat(self._amount_msat))

@pyqtProperty(str, notify=valueChanged)
def msatsStr(self):
Expand All @@ -81,11 +100,14 @@ def isMax(self, ismax):

@pyqtProperty(bool, notify=valueChanged)
def isEmpty(self):
return not(self._is_max or self._amount_sat or self._amount_msat)
return not (self._is_max or self._amount_msat)

@pyqtProperty(bool, notify=valueChanged)
def hasMsatPrecision(self):
return not (self._amount_msat == self._sat_to_msat(self._msat_to_sat(self._amount_msat)))

@pyqtSlot()
def clear(self):
self._amount_sat = 0
self._amount_msat = 0
self._is_max = False
self.valueChanged.emit()
Expand All @@ -95,28 +117,33 @@ def copyFrom(self, amount):
if not amount:
self._logger.warning('copyFrom with None argument. assuming 0') # TODO
amount = QEAmount()
self.satsInt = amount.satsInt
self.msatsInt = amount.msatsInt
self.isMax = amount.isMax

changed = False
if self._amount_msat != amount._amount_msat:
self._amount_msat = amount._amount_msat
changed = True
if self._is_max != amount._is_max:
self._is_max = amount._is_max
changed = True
if changed:
self.valueChanged.emit()

def __eq__(self, other):
if isinstance(other, QEAmount):
return self._amount_sat == other._amount_sat and self._amount_msat == other._amount_msat and self._is_max == other._is_max
return self._amount_msat == other._amount_msat and self._is_max == other._is_max
elif isinstance(other, int):
return self._amount_sat == other
elif isinstance(other, str):
return self.satsStr == other
return self._amount_msat == other

return False

def __str__(self):
s = _('Amount')
if self._is_max:
return '%s(MAX)' % s
return '%s(sats=%d, msats=%d)' % (s, self._amount_sat, self._amount_msat)
return '%s(sats=%s, msats=%s)' % (s, str(self._msat_to_sat(self._amount_msat)), str(self._amount_msat))

def __repr__(self):
return f"<QEAmount max={self._is_max} sats={self._amount_sat} msats={self._amount_msat} empty={self.isEmpty}>"
return f"<QEAmount max={self._is_max} sats={self._msat_to_sat(self._amount_msat)} msats={self._amount_msat} empty={self.isEmpty}>"


class QEBytes(QObject):
Expand Down
Loading