diff --git a/electrum/commands.py b/electrum/commands.py index 301fc2eb903e..35ef31492f93 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1380,7 +1380,7 @@ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force else: addr = None expiry = int(expiry) if expiry else None - key = wallet.create_request(amount, memo, expiry, addr) + key = wallet.create_request(amount_sat=amount, message=memo, exp_delay=expiry, address=addr) req = wallet.get_request(key) return wallet.export_request(req) diff --git a/electrum/gui/qml/components/BalanceDetails.qml b/electrum/gui/qml/components/BalanceDetails.qml index 49b92a462200..7311f66eab68 100644 --- a/electrum/gui/qml/components/BalanceDetails.qml +++ b/electrum/gui/qml/components/BalanceDetails.qml @@ -214,7 +214,7 @@ Pane { Layout.preferredWidth: 1 text: qsTr('Lightning swap'); visible: Daemon.currentWallet.isLightning - enabled: Daemon.currentWallet.lightningCanSend.satsInt > 0 || Daemon.currentWallet.lightningCanReceive.satInt > 0 + enabled: !Daemon.currentWallet.lightningCanSend.isEmpty || !Daemon.currentWallet.lightningCanReceive.isEmpty icon.source: Qt.resolvedUrl('../../icons/update.png') onClicked: app.startSwap() } @@ -224,7 +224,7 @@ Pane { Layout.preferredWidth: 1 text: qsTr('Open Channel') visible: Daemon.currentWallet.isLightning - enabled: Daemon.currentWallet.confirmedBalance.satsInt > 0 + enabled: !Daemon.currentWallet.confirmedBalance.isEmpty onClicked: { var dialog = openChannelDialog.createObject(rootItem) dialog.open() diff --git a/electrum/gui/qml/components/Channels.qml b/electrum/gui/qml/components/Channels.qml index 4369c8fbca8a..35555c268486 100644 --- a/electrum/gui/qml/components/Channels.qml +++ b/electrum/gui/qml/components/Channels.qml @@ -125,8 +125,8 @@ Pane { Layout.fillWidth: true Layout.preferredWidth: 1 text: qsTr('Swap'); - enabled: Daemon.currentWallet.lightningCanSend.satsInt > 0 || - (Daemon.currentWallet.lightningCanReceive.satsInt > 0 && Daemon.currentWallet.confirmedBalance.satsInt > 0) + enabled: !Daemon.currentWallet.lightningCanSend.isEmpty || + (!Daemon.currentWallet.lightningCanReceive.isEmpty && !Daemon.currentWallet.confirmedBalance.isEmpty) icon.source: Qt.resolvedUrl('../../icons/update.png') onClicked: app.startSwap() } @@ -134,7 +134,7 @@ Pane { FlatButton { Layout.fillWidth: true Layout.preferredWidth: 1 - enabled: Daemon.currentWallet.canHaveLightning && Daemon.currentWallet.confirmedBalance.satsInt > 0 + enabled: Daemon.currentWallet.canHaveLightning && !Daemon.currentWallet.confirmedBalance.isEmpty text: qsTr('Open Channel') onClicked: { if (Daemon.currentWallet.channelModel.count == 0) { diff --git a/electrum/gui/qml/components/InvoiceDialog.qml b/electrum/gui/qml/components/InvoiceDialog.qml index 51f41de5a745..09020c36dc92 100644 --- a/electrum/gui/qml/components/InvoiceDialog.qml +++ b/electrum/gui/qml/components/InvoiceDialog.qml @@ -229,16 +229,16 @@ ElDialog { color: readOnly ? Material.accentColor : Material.foreground - onTextAsSatsChanged: { + onValueChanged: { if (!amountMax.checked) invoice.amountOverride.copyFrom(textAsSats) } Connections { target: invoice.amountOverride - function onSatsIntChanged() { - console.log('amountOverride satsIntChanged, sats=' + invoice.amountOverride.satsInt) + function onValueChanged() { + console.log('amountOverride valueChanged, sats=' + invoice.amountOverride.satsStr) if (amountMax.checked) // amountOverride updated by max amount estimate - amountBtc.text = Config.formatSatsForEditing(invoice.amountOverride.satsInt) + amountBtc.text = Config.formatSatsForEditing(invoice.amountOverride) } } } @@ -469,7 +469,7 @@ ElDialog { enabled: !invoice.isSaved && invoice.canSave onClicked: { if (invoice.amount.isEmpty) { - invoice.amountOverride = Config.unitsToSats(amountBtc.text) + invoice.amountOverride = Config.baseunitStrToAmount(amountBtc.text) if (amountMax.checked) invoice.amountOverride.isMax = true } @@ -487,7 +487,7 @@ ElDialog { enabled: invoice.invoiceType != Invoice.Invalid && invoice.canPay onClicked: { if (invoice.amount.isEmpty) { - invoice.amountOverride = Config.unitsToSats(amountBtc.text) + invoice.amountOverride = Config.baseunitStrToAmount(amountBtc.text) if (amountMax.checked) invoice.amountOverride.isMax = true } diff --git a/electrum/gui/qml/components/LightningPaymentDetails.qml b/electrum/gui/qml/components/LightningPaymentDetails.qml index 2edbacd900fb..7a00b5548a87 100644 --- a/electrum/gui/qml/components/LightningPaymentDetails.qml +++ b/electrum/gui/qml/components/LightningPaymentDetails.qml @@ -52,7 +52,7 @@ Pane { } Label { - text: lnpaymentdetails.amount.msatsInt > 0 + text: lnpaymentdetails.amount.positive ? qsTr('Amount received') : qsTr('Amount sent') color: Material.accentColor @@ -64,13 +64,13 @@ Pane { } Label { - visible: lnpaymentdetails.amount.msatsInt < 0 + visible: !lnpaymentdetails.amount.positive text: qsTr('Transaction fee') color: Material.accentColor } FormattedAmount { - visible: lnpaymentdetails.amount.msatsInt < 0 + visible: !lnpaymentdetails.amount.positive amount: lnpaymentdetails.fee timestamp: lnpaymentdetails.timestamp } diff --git a/electrum/gui/qml/components/LnurlPayRequestDialog.qml b/electrum/gui/qml/components/LnurlPayRequestDialog.qml index 338dc08d8c04..abc45cfa375b 100644 --- a/electrum/gui/qml/components/LnurlPayRequestDialog.qml +++ b/electrum/gui/qml/components/LnurlPayRequestDialog.qml @@ -19,10 +19,15 @@ ElDialog { needsSystemBarPadding: false property bool commentValid: comment.text.length <= invoiceParser.lnurlData['comment_allowed'] - property bool amountValid: amountBtc.textAsSats.satsInt >= parseInt(invoiceParser.lnurlData['min_sendable_sat']) - && amountBtc.textAsSats.satsInt <= parseInt(invoiceParser.lnurlData['max_sendable_sat']) + property bool amountValid: false property bool valid: commentValid && amountValid + function isValidAmount() { + return amountBtc.textAsSats.gte(invoiceParser.lnurlData['min_sendable_msat']) + && amountBtc.textAsSats.lte(invoiceParser.lnurlData['max_sendable_msat']) + && amountBtc.textAsSats.lte(invoiceParser.wallet.lightningCanSend) + } + ColumnLayout { width: parent.width @@ -41,8 +46,11 @@ ElDialog { Layout.columnSpan: 2 Layout.fillWidth: true compact: true - visible: invoiceParser.lnurlData['min_sendable_sat'] != invoiceParser.lnurlData['max_sendable_sat'] - text: qsTr('Amount must be between %1 and %2 %3').arg(Config.formatSats(invoiceParser.lnurlData['min_sendable_sat'])).arg(Config.formatSats(invoiceParser.lnurlData['max_sendable_sat'])).arg(Config.baseUnit) + visible: !invoiceParser.lnurlData['min_sendable_msat'].eq(invoiceParser.lnurlData['max_sendable_msat']) + text: qsTr('Amount must be between %1 and %2 %3') + .arg(Config.formatMilliSats(invoiceParser.lnurlData['min_sendable_msat'])) + .arg(Config.formatMilliSats(invoiceParser.lnurlData['max_sendable_msat'])) + .arg(Config.baseUnit) } Label { @@ -73,12 +81,14 @@ ElDialog { BtcField { id: amountBtc Layout.preferredWidth: rootLayout.width /3 - text: Config.formatSatsForEditing(invoiceParser.lnurlData['min_sendable_sat']) - enabled: invoiceParser.lnurlData['min_sendable_sat'] != invoiceParser.lnurlData['max_sendable_sat'] + text: Config.formatMilliSatsForEditing(invoiceParser.lnurlData['min_sendable_msat']) + enabled: !invoiceParser.lnurlData['min_sendable_msat'].eq(invoiceParser.lnurlData['max_sendable_msat']) color: Material.foreground // override gray-out on disabled fiatfield: amountFiat - onTextAsSatsChanged: { + msatPrecision: true + onValueChanged: { invoiceParser.amountOverride = textAsSats + dialog.amountValid = isValidAmount() } } Label { diff --git a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml index d5a84c6f1641..1b211fae5cb0 100644 --- a/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml +++ b/electrum/gui/qml/components/LnurlWithdrawRequestDialog.qml @@ -13,41 +13,65 @@ ElDialog { title: qsTr('LNURL Withdraw request') iconSource: '../../../icons/link.png' - property Wallet wallet: Daemon.currentWallet property RequestDetails requestDetails + property Amount onemsat: Amount { Component.onCompleted: { msatsInt = 1 } } padding: 0 needsSystemBarPadding: false - property int walletCanReceive: 0 - property int providerMinWithdrawable: parseInt(requestDetails.lnurlData['min_withdrawable_sat']) - property int providerMaxWithdrawable: parseInt(requestDetails.lnurlData['max_withdrawable_sat']) - property int effectiveMinWithdrawable: Math.max(providerMinWithdrawable, 1) - property int effectiveMaxWithdrawable: Math.min(providerMaxWithdrawable, walletCanReceive) - property bool insufficientLiquidity: effectiveMinWithdrawable > walletCanReceive - property bool liquidityWarning: providerMaxWithdrawable > walletCanReceive - - property bool amountValid: !dialog.insufficientLiquidity && - amountBtc.textAsSats.satsInt >= dialog.effectiveMinWithdrawable && - amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable + property var walletCanReceive: Amount { + onValueChanged: console.log('wallet can receive ' + msatsStr) + } + property var providerMinWithdrawable: requestDetails.lnurlData['min_withdrawable_msat'] + property var providerMaxWithdrawable: requestDetails.lnurlData['max_withdrawable_msat'] + property var effectiveMinWithdrawable: onemsat.max(providerMinWithdrawable, onemsat) + property var effectiveMaxWithdrawable: onemsat.min(providerMaxWithdrawable, requestDetails.wallet.lightningCanReceive) + property bool insufficientLiquidity: effectiveMinWithdrawable.gt(requestDetails.wallet.lightningCanReceive) + property bool liquidityWarning: providerMaxWithdrawable.gt(walletCanReceive) + property bool fixedAmount: false + + property bool amountValid: isValidAmount() property bool valid: amountValid Component.onCompleted: { - dialog.walletCanReceive = wallet.lightningCanReceive.satsInt + updateLimits() + } + + function isValidAmount() { + return !dialog.insufficientLiquidity + && amountBtc.textAsSats.gte(dialog.effectiveMinWithdrawable) + && amountBtc.textAsSats.lte(dialog.effectiveMaxWithdrawable) + } + + function updateLimits() { + dialog.walletCanReceive.copyFrom(requestDetails.wallet.lightningCanReceive) + dialog.effectiveMaxWithdrawable = onemsat.min(dialog.providerMaxWithdrawable, requestDetails.wallet.lightningCanReceive) + dialog.insufficientLiquidity = dialog.effectiveMinWithdrawable.gt(requestDetails.wallet.lightningCanReceive) + dialog.liquidityWarning = dialog.providerMaxWithdrawable.gt(requestDetails.wallet.lightningCanReceive) + dialog.fixedAmount = dialog.providerMinWithdrawable.eq(dialog.providerMaxWithdrawable) + dialog.amountValid = isValidAmount() } Connections { // assign walletCanReceive directly to prevent a binding loop - target: wallet + target: requestDetails.wallet function onLightningCanReceiveChanged() { if (!requestDetails.busy) { // don't assign while busy to prevent the view from changing while receiving // the incoming payment - dialog.walletCanReceive = wallet.lightningCanReceive.satsInt + console.log('UPDATING') + updateLimits() } } } + Connections { + target: amountBtc + function onValueChanged() { + dialog.amountValid = isValidAmount() + } + } + ColumnLayout { width: parent.width @@ -68,10 +92,10 @@ ElDialog { text: qsTr('Too little incoming liquidity to satisfy this withdrawal request.') + '\n\n' + qsTr('Can receive: %1') - .arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit) + .arg(Config.formatMilliSats(dialog.walletCanReceive) + ' ' + Config.baseUnit) + '\n' + qsTr('Minimum withdrawal amount: %1') - .arg(Config.formatSats(dialog.providerMinWithdrawable) + ' ' + Config.baseUnit) + .arg(Config.formatMilliSats(dialog.providerMinWithdrawable) + ' ' + Config.baseUnit) + '\n\n' + qsTr('Do a submarine swap in the \'Channels\' tab to get more incoming liquidity.') iconStyle: InfoTextArea.IconStyle.Error @@ -81,11 +105,11 @@ ElDialog { Layout.columnSpan: 2 Layout.fillWidth: true compact: true - visible: !dialog.insufficientLiquidity && dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable + visible: !dialog.insufficientLiquidity && !dialog.fixedAmount text: qsTr('Amount must be between %1 and %2 %3') - .arg(Config.formatSats(dialog.effectiveMinWithdrawable)) - .arg(Config.formatSats(dialog.effectiveMaxWithdrawable)) - .arg(Config.baseUnit) + .arg(Config.formatMilliSats(dialog.effectiveMinWithdrawable)) + .arg(Config.formatMilliSats(dialog.effectiveMaxWithdrawable)) + .arg(Config.baseUnit) } InfoTextArea { @@ -94,8 +118,8 @@ ElDialog { compact: true visible: dialog.liquidityWarning && !dialog.insufficientLiquidity text: qsTr('The maximum withdrawable amount (%1) is larger than what your channels can receive (%2).') - .arg(Config.formatSats(dialog.providerMaxWithdrawable) + ' ' + Config.baseUnit) - .arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit) + .arg(Config.formatMilliSats(dialog.providerMaxWithdrawable) + ' ' + Config.baseUnit) + .arg(Config.formatMilliSats(dialog.walletCanReceive) + ' ' + Config.baseUnit) + ' ' + qsTr('You may need to do a submarine swap to increase your incoming liquidity.') iconStyle: InfoTextArea.IconStyle.Warn @@ -131,10 +155,11 @@ ElDialog { BtcField { id: amountBtc Layout.preferredWidth: rootLayout.width / 3 - text: Config.formatSatsForEditing(dialog.effectiveMaxWithdrawable) - enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable) + text: Config.formatMilliSatsForEditing(dialog.effectiveMaxWithdrawable) + enabled: !dialog.insufficientLiquidity && !dialog.fixedAmount color: Material.foreground // override gray-out on disabled fiatfield: amountFiat + msatPrecision: true } Label { text: Config.baseUnit @@ -150,7 +175,7 @@ ElDialog { id: amountFiat Layout.preferredWidth: rootLayout.width / 3 btcfield: amountBtc - enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable) + enabled: !dialog.insufficientLiquidity && !dialog.fixedAmount color: Material.foreground } Label { @@ -167,8 +192,7 @@ ElDialog { icon.source: '../../icons/confirmed.png' enabled: valid && !requestDetails.busy onClicked: { - var satsAmount = amountBtc.textAsSats.satsInt; - requestDetails.lnurlRequestWithdrawal(satsAmount); + requestDetails.lnurlRequestWithdrawal(amountBtc.textAsSats); dialog.close(); } } diff --git a/electrum/gui/qml/components/OpenChannelDialog.qml b/electrum/gui/qml/components/OpenChannelDialog.qml index 97d709b52ca0..5fe49dfec7f7 100644 --- a/electrum/gui/qml/components/OpenChannelDialog.qml +++ b/electrum/gui/qml/components/OpenChannelDialog.qml @@ -170,7 +170,7 @@ ElDialog { id: amountBtc fiatfield: amountFiat Layout.preferredWidth: amountFontMetrics.advanceWidth('0') * 14 + leftPadding + rightPadding - onTextAsSatsChanged: { + onValueChanged: { if (!is_max.checked) channelopener.amount = amountBtc.textAsSats } @@ -181,9 +181,9 @@ 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) + amountBtc.text = Config.formatSatsForEditing(channelopener.amount) } } } diff --git a/electrum/gui/qml/components/ReceiveDetailsDialog.qml b/electrum/gui/qml/components/ReceiveDetailsDialog.qml index c29df76b95a1..908dfd829f1a 100644 --- a/electrum/gui/qml/components/ReceiveDetailsDialog.qml +++ b/electrum/gui/qml/components/ReceiveDetailsDialog.qml @@ -58,6 +58,7 @@ ElDialog { id: amountBtc fiatfield: amountFiat Layout.fillWidth: true + msatPrecision: true } Label { @@ -103,22 +104,22 @@ ElDialog { Layout.preferredWidth: 1 text: qsTr('Onchain') icon.source: '../../icons/bitcoin.png' + enabled: !amountBtc.textAsSats.hasMsatPrecision onClicked: { dialog.isLightning = false; doAccept() } } FlatButton { Layout.fillWidth: true Layout.preferredWidth: 1 - enabled: Daemon.currentWallet.isLightning && (Daemon.currentWallet.lightningCanReceive.satsInt - > amountBtc.textAsSats.satsInt || Daemon.currentWallet.canGetZeroconfChannel) + enabled: Daemon.currentWallet.isLightning && (Daemon.currentWallet.lightningCanReceive.gt(amountBtc.textAsSats) + || Daemon.currentWallet.canGetZeroconfChannel) text: qsTr('Lightning') icon.source: '../../icons/lightning.png' onClicked: { - if (Daemon.currentWallet.lightningCanReceive.satsInt > amountBtc.textAsSats.satsInt) { + if (Daemon.currentWallet.lightningCanReceive.gt(amountBtc.textAsSats)) { // can receive on existing channel dialog.isLightning = true doAccept() - } else if (Daemon.currentWallet.canGetZeroconfChannel && amountBtc.textAsSats.satsInt - >= Daemon.currentWallet.minChannelFunding.satsInt) { + } else if (Daemon.currentWallet.canGetZeroconfChannel && amountBtc.textAsSats.gte(Daemon.currentWallet.minChannelFunding)) { // ask for confirmation of zeroconf channel to prevent fee surprise var confirmdialog = app.messageDialog.createObject(dialog, { title: qsTr('Confirm just-in-time channel'), @@ -138,7 +139,7 @@ ElDialog { title: qsTr("Amount too low"), text: [qsTr("You don't have channels with enough inbound liquidity to receive this payment."), qsTr("Request at least %1 to open a channel just-in-time.").arg( - Config.formatSats(Daemon.currentWallet.minChannelFunding.satsInt, true))].join(' ') + Config.formatSats(Daemon.currentWallet.minChannelFunding, true))].join(' ') }) confirmdialog.open() } diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml index 42f462a2ef53..504a29482b96 100644 --- a/electrum/gui/qml/components/TxDetails.qml +++ b/electrum/gui/qml/components/TxDetails.qml @@ -106,8 +106,8 @@ Pane { Label { Layout.preferredWidth: 1 Layout.fillWidth: true - visible: !txdetails.isUnrelated && txdetails.amount.satsInt != 0 - text: txdetails.amount.satsInt > 0 + visible: !txdetails.isUnrelated && !txdetails.amount.isEmpty + text: txdetails.amount.positive ? qsTr('Amount received onchain') : qsTr('Amount sent onchain') color: Material.accentColor @@ -117,15 +117,15 @@ Pane { FormattedAmount { Layout.preferredWidth: 1 Layout.fillWidth: true - visible: !txdetails.isUnrelated && txdetails.amount.satsInt != 0 + visible: !txdetails.isUnrelated && !txdetails.amount.isEmpty amount: txdetails.amount timestamp: txdetails.timestamp } Label { Layout.fillWidth: true - visible: !txdetails.isUnrelated && txdetails.lnAmount.satsInt != 0 - text: txdetails.lnAmount.satsInt > 0 + visible: !txdetails.isUnrelated && !txdetails.lnAmount.isEmpty + text: txdetails.lnAmount.positive ? qsTr('Amount received in channels') : qsTr('Amount withdrawn from channels') color: Material.accentColor @@ -133,7 +133,7 @@ Pane { } FormattedAmount { - visible: !txdetails.isUnrelated && txdetails.lnAmount.satsInt != 0 + visible: !txdetails.isUnrelated && !txdetails.lnAmount.isEmpty Layout.preferredWidth: 1 Layout.fillWidth: true amount: txdetails.lnAmount.isEmpty ? txdetails.amount : txdetails.lnAmount diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index c39cda5ed030..6067d558d483 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -131,7 +131,7 @@ Item { } function createRequest(lightning, reuse_address) { - var qamt = Config.unitsToSats(_request_amount) + var qamt = Config.baseunitStrToAmount(_request_amount) Daemon.currentWallet.createRequest(qamt, _request_description, _request_expiry, lightning, reuse_address) } @@ -540,9 +540,9 @@ Item { if (invoice.invoiceType == Invoice.LightningInvoice && invoice.address) { // ln invoice with fallback var amountToSend = invoice.amountOverride.isEmpty - ? invoice.amount.satsInt - : invoice.amountOverride.satsInt - if (amountToSend > Daemon.currentWallet.lightningCanSend.satsInt) { + ? invoice.amount + : invoice.amountOverride + if (amountToSend.gt(Daemon.currentWallet.lightningCanSend)) { lninvoiceButPayOnchain = true } } diff --git a/electrum/gui/qml/components/controls/AddressDelegate.qml b/electrum/gui/qml/components/controls/AddressDelegate.qml index ebd88015a00a..fa242923d4c2 100644 --- a/electrum/gui/qml/components/controls/AddressDelegate.qml +++ b/electrum/gui/qml/components/controls/AddressDelegate.qml @@ -46,7 +46,7 @@ ItemDelegate { color: model.held ? constants.colorAddressFrozen : model.numtx > 0 - ? model.balance.satsInt == 0 + ? model.balance.isEmpty ? constants.colorAddressUsed : constants.colorAddressUsedWithBalance : model.type == 'change' @@ -68,12 +68,12 @@ ItemDelegate { Label { font.family: FixedFont text: Config.formatSats(model.balance, false) - visible: model.balance.satsInt != 0 + visible: !model.balance.isEmpty } Label { color: Material.accentColor text: Config.baseUnit + ',' - visible: model.balance.satsInt != 0 + visible: !model.balance.isEmpty } Label { text: model.numtx diff --git a/electrum/gui/qml/components/controls/BtcField.qml b/electrum/gui/qml/components/controls/BtcField.qml index a42624356b9d..0921a3a5b0e4 100644 --- a/electrum/gui/qml/components/controls/BtcField.qml +++ b/electrum/gui/qml/components/controls/BtcField.qml @@ -4,11 +4,13 @@ import QtQuick.Controls import org.electrum 1.0 TextField { - id: amount + id: root required property TextField fiatfield property bool msatPrecision: false + signal valueChanged + font.family: FixedFont placeholderText: qsTr('Amount') inputMethodHints: Qt.ImhDigitsOnly @@ -16,22 +18,26 @@ TextField { regularExpression: msatPrecision ? Config.btcAmountRegexMsat : Config.btcAmountRegex } - property Amount textAsSats + property Amount textAsSats: Amount { + // propagate on parent + onValueChanged: root.valueChanged() + } + onTextChanged: { - textAsSats = Config.unitsToSats(amount.text) + textAsSats.copyFrom(Config.baseunitStrToAmount(root.text)) if (fiatfield.activeFocus) return - fiatfield.text = text == '' ? '' : Daemon.fx.fiatValue(amount.textAsSats) + fiatfield.text = text == '' ? '' : Daemon.fx.fiatValue(root.textAsSats) } Connections { target: Config function onBaseUnitChanged() { - amount.text = amount.textAsSats.satsInt != 0 - ? Config.satsToUnits(amount.textAsSats) + root.text = !root.textAsSats.isEmpty + ? Config.amountToBaseunitStr(root.textAsSats) : '' } } - Component.onCompleted: amount.textChanged() + Component.onCompleted: root.textChanged() } diff --git a/electrum/gui/qml/components/controls/ChannelBar.qml b/electrum/gui/qml/components/controls/ChannelBar.qml index 8635356f7c61..47cfa0c22e5e 100644 --- a/electrum/gui/qml/components/controls/ChannelBar.qml +++ b/electrum/gui/qml/components/controls/ChannelBar.qml @@ -22,24 +22,24 @@ Item { } function do_update() { - var cap = capacity.satsInt * 1000 + var cap = capacity.satsInt var twocap = cap * 2 - l1.width = width * (cap - localCapacity.msatsInt) / twocap + l1.width = width * (cap - localCapacity.satsInt) / twocap if (frozenForSending) { - l2.width = width * localCapacity.msatsInt / twocap + l2.width = width * localCapacity.satsInt / twocap l3.width = 0 } else { - l2.width = width * (localCapacity.msatsInt - canSend.msatsInt) / twocap - l3.width = width * canSend.msatsInt / twocap + l2.width = width * (localCapacity.satsInt - canSend.satsInt) / twocap + l3.width = width * canSend.satsInt / twocap } if (frozenForReceiving) { r3.width = 0 - r2.width = width * remoteCapacity.msatsInt / twocap + r2.width = width * remoteCapacity.satsInt / twocap } else { - r3.width = width * canReceive.msatsInt / twocap - r2.width = width * (remoteCapacity.msatsInt - canReceive.msatsInt) / twocap + r3.width = width * canReceive.satsInt / twocap + r2.width = width * (remoteCapacity.satsInt - canReceive.satsInt) / twocap } - r1.width = width * (cap - remoteCapacity.msatsInt) / twocap + r1.width = width * (cap - remoteCapacity.satsInt) / twocap } onWidthChanged: update() @@ -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 { diff --git a/electrum/gui/qml/components/controls/CoinDelegate.qml b/electrum/gui/qml/components/controls/CoinDelegate.qml index 7f823b794dbc..45910f881cf5 100644 --- a/electrum/gui/qml/components/controls/CoinDelegate.qml +++ b/electrum/gui/qml/components/controls/CoinDelegate.qml @@ -69,14 +69,14 @@ ItemDelegate { horizontalAlignment: Text.AlignRight font.family: FixedFont text: Config.formatSats(model.amount, false) - visible: model.amount.satsInt != 0 + visible: !model.amount.isEmpty } Label { Layout.minimumWidth: implicitWidth Layout.preferredWidth: implicitWidth color: Material.accentColor text: Config.baseUnit - visible: model.amount.satsInt != 0 + visible: !model.amount.isEmpty } } diff --git a/electrum/gui/qml/components/controls/FiatField.qml b/electrum/gui/qml/components/controls/FiatField.qml index 750aa9024eba..90cb27296d3e 100644 --- a/electrum/gui/qml/components/controls/FiatField.qml +++ b/electrum/gui/qml/components/controls/FiatField.qml @@ -19,7 +19,7 @@ TextField { if (amountFiat.activeFocus) btcfield.text = text == '' ? '' - : Config.satsToUnits(Daemon.fx.satoshiValue(amountFiat.text)) + : Config.amountToBaseunitStr(Daemon.fx.satoshiValue(amountFiat.text)) } Connections { @@ -27,7 +27,7 @@ TextField { function onQuotesUpdated() { amountFiat.text = btcfield.text == '' ? '' - : Daemon.fx.fiatValue(Config.unitsToSats(btcfield.text)) + : Daemon.fx.fiatValue(Config.baseunitStrToAmount(btcfield.text)) } } diff --git a/electrum/gui/qml/components/controls/FormattedAmount.qml b/electrum/gui/qml/components/controls/FormattedAmount.qml index 80d6d778a066..dde8d86a3cc6 100644 --- a/electrum/gui/qml/components/controls/FormattedAmount.qml +++ b/electrum/gui/qml/components/controls/FormattedAmount.qml @@ -26,7 +26,7 @@ GridLayout { } Label { visible: valid - text: amount.msatsInt != 0 ? Config.formatMilliSats(amount) : Config.formatSats(amount) + text: Config.formatMilliSats(amount) font.family: FixedFont } Label { diff --git a/electrum/gui/qml/components/controls/HistoryItemDelegate.qml b/electrum/gui/qml/components/controls/HistoryItemDelegate.qml index 3491a2a9adb6..3c6e45ef62aa 100644 --- a/electrum/gui/qml/components/controls/HistoryItemDelegate.qml +++ b/electrum/gui/qml/components/controls/HistoryItemDelegate.qml @@ -84,10 +84,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.positive ? constants.colorCredit : constants.colorDebit function updateText() { - text = Config.formatSats(model.value) + text = Config.formatMilliSats(model.value) } Component.onCompleted: updateText() } diff --git a/electrum/gui/qml/components/controls/InvoiceDelegate.qml b/electrum/gui/qml/components/controls/InvoiceDelegate.qml index bc053b07ba8c..3ea645bfa301 100644 --- a/electrum/gui/qml/components/controls/InvoiceDelegate.qml +++ b/electrum/gui/qml/components/controls/InvoiceDelegate.qml @@ -77,7 +77,7 @@ ItemDelegate { ? '' : model.amount.isMax ? 'MAX' - : Config.formatSats(model.amount) + : Config.formatMilliSats(model.amount) font.pixelSize: constants.fontSizeMedium font.family: FixedFont } @@ -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 { diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index d4ca6b9d5aa0..d9623eed913c 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -360,11 +360,28 @@ def formatSatsForEditing(self, satoshis): add_thousands_sep=False, ) + @pyqtSlot('qint64', result=str) + @pyqtSlot(QEAmount, result=str) + def formatMilliSatsForEditing(self, satoshis): + if isinstance(satoshis, QEAmount): + satoshis = Decimal(satoshis.msatsInt) / 1000 + elif isinstance(satoshis, int): + satoshis = Decimal(satoshis) / 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) @pyqtSlot(QEAmount, bool, result=str) def formatSats(self, satoshis, with_unit=False): + if satoshis is None: + return '' if isinstance(satoshis, QEAmount): satoshis = satoshis.satsInt if with_unit: @@ -372,32 +389,47 @@ 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 amount is None: + return '' + 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): + def baseunitStrToAmount(self, unitAmount): self._amount = QEAmount() try: x = Decimal(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) - def satsToUnits(self, satoshis): - return satoshis / pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT) + @pyqtSlot('quint64', result=str) + @pyqtSlot(QEAmount, result=str) + def amountToBaseunitStr(self, amount) -> str: + assert isinstance(amount, (QEAmount, int)) + if isinstance(amount, QEAmount): + satoshis = Decimal(amount.msatsInt) / 1000 + elif isinstance(amount, int): + satoshis = Decimal(amount) + msat_max_precision = self.config.BTC_AMOUNTS_DECIMAL_POINT + 3 + unit_str = self.config.format_amount(satoshis, precision=msat_max_precision, add_thousands_sep=False) + return unit_str.rstrip('.') diff --git a/electrum/gui/qml/qefx.py b/electrum/gui/qml/qefx.py index 8225a3a62fed..9cd0ab785cbc 100644 --- a/electrum/gui/qml/qefx.py +++ b/electrum/gui/qml/qefx.py @@ -115,7 +115,7 @@ def enabled(self, enable): def fiatValue(self, satoshis, plain=True): rate = self.fx.exchange_rate() if isinstance(satoshis, QEAmount): - satoshis = satoshis.msatsInt / 1000 if satoshis.msatsInt != 0 else satoshis.satsInt + satoshis = Decimal(satoshis.msatsInt) / 1000 else: try: sd = Decimal(satoshis) @@ -132,7 +132,7 @@ def fiatValue(self, satoshis, plain=True): @pyqtSlot(QEAmount, str, bool, result=str) def fiatValueHistoric(self, satoshis, timestamp, plain=True): if isinstance(satoshis, QEAmount): - satoshis = satoshis.msatsInt / 1000 if satoshis.msatsInt != 0 else satoshis.satsInt + satoshis = Decimal(satoshis.msatsInt) / 1000 else: try: sd = Decimal(satoshis) @@ -151,21 +151,18 @@ def fiatValueHistoric(self, satoshis, timestamp, plain=True): else: return self.fx.historical_value_str(satoshis, dt) - @pyqtSlot(str, result=str) - @pyqtSlot(str, bool, result=str) - def satoshiValue(self, fiat, plain=True): + @pyqtSlot(str, result='QVariant') + def satoshiValue(self, fiat): + self._amount = QEAmount() rate = self.fx.exchange_rate() try: fd = Decimal(fiat) except Exception: return '' v = fd / Decimal(rate) * COIN - if v.is_nan(): - return '' - if plain: - return str(v.to_integral_value()) - else: - return self.config.format_amount(v) + if not v.is_nan(): + self._amount.satsInt = v.to_integral_value() + return self._amount @pyqtSlot(str, result=bool) def isRecent(self, timestamp): diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 395f4c785ccf..24c246dc3933 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -1,5 +1,6 @@ import copy import threading +from decimal import Decimal from enum import IntEnum from typing import Optional, Dict, Any, Tuple from urllib.parse import urlparse @@ -17,15 +18,13 @@ from electrum.lnurl import LNURL6Data from electrum.bitcoin import COIN, address_to_script from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType -from electrum.network import Network -from electrum.util import event_listener +from electrum.util import event_listener, InvoiceError from electrum.gui.common_qt.util import QtEventListener from .qetypes import QEAmount from .qewallet import QEWallet from .util import status_update_timer_interval -from ...util import InvoiceError class QEInvoice(QObject, QtEventListener): @@ -375,11 +374,8 @@ def check_can_pay_amount(self, amount: QEAmount) -> Tuple[bool, Optional[str]]: @pyqtSlot() def payLightningInvoice(self): - if not self.canPay: - raise Exception('can not pay invoice, canPay is false') - - if self.invoiceType != QEInvoice.Type.LightningInvoice: - raise Exception('payLightningInvoice can only pay lightning invoices') + assert self.canPay, 'can not pay invoice, canPay is false' + assert self.invoiceType == QEInvoice.Type.LightningInvoice, 'can only pay lightning invoices' amount_msat = None if self.amount.isEmpty: @@ -389,10 +385,10 @@ def payLightningInvoice(self): self._wallet.pay_lightning_invoice(self._effectiveInvoice, amount_msat) - def get_max_spendable_onchain(self): + def get_max_spendable_onchain(self) -> int: return self._wallet.wallet.get_spendable_balance_sat() - def get_max_spendable_lightning(self): + def get_max_spendable_lightning(self) -> int | Decimal: return self._wallet.wallet.lnworker.num_sats_can_send() if self._wallet.wallet.lnworker else 0 @pyqtSlot() @@ -590,8 +586,8 @@ def on_lnurl_pay(self, lnurldata: LNURL6Data): self._lnurlData = { 'domain': urlparse(lnurldata.callback_url).netloc, 'callback_url': lnurldata.callback_url, - 'min_sendable_sat': lnurldata.min_sendable_sat, - 'max_sendable_sat': lnurldata.max_sendable_sat, + 'min_sendable_msat': QEAmount(amount_msat=lnurldata.min_sendable_msat), + 'max_sendable_msat': QEAmount(amount_msat=lnurldata.max_sendable_msat), 'metadata_plaintext': lnurldata.metadata_plaintext, 'comment_allowed': lnurldata.comment_allowed, } @@ -606,7 +602,7 @@ def lnurlGetInvoice(self, comment=None): assert self.invoiceType == QEInvoice.Type.LNURLPayRequest self._logger.debug(f'{repr(self._lnurlData)}') - amount = self.amountOverride.satsInt + amount = Decimal(self.amountOverride.msatsInt) / 1000 if self._lnurlData['comment_allowed'] == 0: comment = None @@ -621,19 +617,19 @@ def on_finished(pi): else: self.lnurlError.emit('lnurl', pi.get_error()) else: - self.on_lnurl_invoice(self.amountOverride.satsInt, pi.bolt11) + self.on_lnurl_invoice(self.amountOverride.msatsInt, pi.bolt11) self._busy = True self.busyChanged.emit() self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished) - def on_lnurl_invoice(self, orig_amount, invoice): + def on_lnurl_invoice(self, orig_amount_msat, invoice): self._logger.debug('on_lnurl_invoice') self._logger.debug(f'{repr(invoice)}') # assure no shenanigans with the bolt11 invoice we get back - if orig_amount * 1000 != invoice.amount_msat: # TODO msat precision can cause trouble here + if orig_amount_msat != invoice.amount_msat: raise Exception('Unexpected amount in invoice, differs from lnurl-pay specified amount') self.amountOverride = QEAmount() @@ -653,7 +649,7 @@ def saveInvoice(self) -> bool: if self.invoiceType == QEInvoice.Type.OnchainInvoice and self.amountOverride.isMax: self._effectiveInvoice.set_amount_msat('!') else: - self._effectiveInvoice.set_amount_msat(self.amountOverride.satsInt * 1000) + self._effectiveInvoice.set_amount_msat(self.amountOverride.msatsInt) except InvoiceError as e: self.invoiceCreateError.emit('validation', str(e)) return False diff --git a/electrum/gui/qml/qerequestdetails.py b/electrum/gui/qml/qerequestdetails.py index ababb00f7da8..009692d75a8e 100644 --- a/electrum/gui/qml/qerequestdetails.py +++ b/electrum/gui/qml/qerequestdetails.py @@ -146,7 +146,7 @@ def bolt11(self): wallet = self._wallet.wallet if not wallet.lnworker: return '' - amount_sat = self._req.get_amount_sat() or 0 if self._req else 0 + amount_sat = self._req.get_amount_sat_msat_precision() or 0 if self._req else 0 can_receive = wallet.lnworker.num_sats_can_receive() will_req_zeroconf = wallet.lnworker.receive_requires_jit_channel(amount_msat=amount_sat*1000) if self._req and ((can_receive > 0 and amount_sat <= can_receive) @@ -217,8 +217,8 @@ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None: self._lnurlData = { 'domain': urlparse(lnurldata.callback_url).netloc, 'callback_url': lnurldata.callback_url, - 'min_withdrawable_sat': lnurldata.min_withdrawable_sat, - 'max_withdrawable_sat': lnurldata.max_withdrawable_sat, + 'min_withdrawable_msat': QEAmount(amount_msat=lnurldata.min_withdrawable_msat), + 'max_withdrawable_msat': QEAmount(amount_msat=lnurldata.max_withdrawable_msat), 'default_description': lnurldata.default_description, 'k1': lnurldata.k1, } @@ -226,14 +226,14 @@ def fromResolvedPaymentIdentifier(self, resolved_pi: PaymentIdentifier) -> None: else: raise NotImplementedError("Cannot request withdrawal for this payment identifier type") - @pyqtSlot(int) - def lnurlRequestWithdrawal(self, amount_sat: int) -> None: + @pyqtSlot(QEAmount) + def lnurlRequestWithdrawal(self, amount: QEAmount) -> None: assert self._lnurlData self._logger.debug(f'requesting lnurlw: {repr(self._lnurlData)}') try: key = self._wallet.wallet.create_request( - amount_sat=amount_sat, + amount_msat=amount.msatsInt, message=self._lnurlData.get('default_description', ''), exp_delay=120, address=None, diff --git a/electrum/gui/qml/qetransactionlistmodel.py b/electrum/gui/qml/qetransactionlistmodel.py index 293e60ee3670..ca8d748342f7 100644 --- a/electrum/gui/qml/qetransactionlistmodel.py +++ b/electrum/gui/qml/qetransactionlistmodel.py @@ -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']) diff --git a/electrum/gui/qml/qetypes.py b/electrum/gui/qml/qetypes.py index abcb445dfe81..2e8afda04580 100644 --- a/electrum/gui/qml/qetypes.py +++ b/electrum/gui/qml/qetypes.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject from electrum.logging import get_logger @@ -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 @@ -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, parent=None, *, amount_sat: int = None, amount_msat: int = None, is_max: bool = False, from_invoice=None): 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) @@ -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): @@ -81,31 +100,97 @@ 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) -> bool: + return not (self._amount_msat == self._sat_to_msat(self._msat_to_sat(self._amount_msat))) + + @pyqtProperty(bool, notify=valueChanged) + def positive(self) -> bool: + return self.isEmpty or self.isMax or self.msatsInt >= 0 @pyqtSlot() def clear(self): - self._amount_sat = 0 self._amount_msat = 0 self._is_max = False self.valueChanged.emit() @pyqtSlot('QVariant') - def copyFrom(self, amount): + def copyFrom(self, amount: 'QEAmount|None'): 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() + + @pyqtSlot('QVariant', result=bool) + def lt(self, other: 'QEAmount|None') -> bool: + if other is None: + other = QEAmount() + assert isinstance(other, QEAmount) + if self.isMax or other.isMax: + return False + if self.isEmpty and not other.isEmpty: + return True + return self.msatsInt < other.msatsInt + + @pyqtSlot('QVariant', result=bool) + def lte(self, other: 'QEAmount|None') -> bool: + return self.lt(other) or self == other + + @pyqtSlot('QVariant', result=bool) + def gt(self, other: 'QEAmount|None') -> bool: + if other is None: + other = QEAmount() + assert isinstance(other, QEAmount) + if self.isMax or other.isMax: + return False + if self.isEmpty and not other.isEmpty: + return False + return self.msatsInt > other.msatsInt + + @pyqtSlot('QVariant', result=bool) + def gte(self, other: 'QEAmount|None') -> bool: + return self.gt(other) or self == other + + @pyqtSlot('QVariant', result=bool) + def eq(self, other: 'QEAmount|None') -> bool: + return self == other + + @pyqtSlot('QVariant', 'QVariant', result='QVariant') + def max(self, one: 'QEAmount|None', two: 'QEAmount|None'): + if one is None: + one = QEAmount() + if two is None: + two = QEAmount() + assert isinstance(one, QEAmount) + assert isinstance(two, QEAmount) + return one if one.gt(two) else two + + @pyqtSlot('QVariant', 'QVariant', result='QVariant') + def min(self, one: 'QEAmount|None', two: 'QEAmount|None'): + if one is None: + one = QEAmount() + if two is None: + two = QEAmount() + assert isinstance(one, QEAmount) + assert isinstance(two, QEAmount) + return one if one.lt(two) else two 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 @@ -113,10 +198,10 @@ 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"" + return f"" class QEBytes(QObject): diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 2b7b3034888c..8e1081a9b228 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -475,31 +475,31 @@ def confirmedBalance(self): @pyqtProperty(QEAmount, notify=balanceChanged) def lightningBalance(self): if self.isLightning: - self._lightningbalance.satsInt = int(self.wallet.lnworker.get_balance()) + self._lightningbalance.msatsInt = int(self.wallet.lnworker.get_balance() * 1000) return self._lightningbalance @pyqtProperty(QEAmount, notify=balanceChanged) def lightningBalanceFrozen(self): if self.isLightning: - self._lightningbalancefrozen.satsInt = int(self.wallet.lnworker.get_balance(frozen=True)) + self._lightningbalancefrozen.msatsInt = int(self.wallet.lnworker.get_balance(frozen=True) * 1000) return self._lightningbalancefrozen @pyqtProperty(QEAmount, notify=balanceChanged) def totalBalance(self): - total = self.confirmedBalance.satsInt + self.lightningBalance.satsInt - self._totalbalance.satsInt = total + total = self.confirmedBalance.msatsInt + self.lightningBalance.msatsInt + self._totalbalance.msatsInt = total return self._totalbalance @pyqtProperty(QEAmount, notify=balanceChanged) def lightningCanSend(self): if self.isLightning: - self._lightningcansend.satsInt = int(self.wallet.lnworker.num_sats_can_send()) + self._lightningcansend.msatsInt = int(self.wallet.lnworker.num_sats_can_send() * 1000) return self._lightningcansend @pyqtProperty(QEAmount, notify=balanceChanged) def lightningCanReceive(self): if self.isLightning: - self._lightningcanreceive.satsInt = int(self.wallet.lnworker.num_sats_can_receive()) + self._lightningcanreceive.msatsInt = int(self.wallet.lnworker.num_sats_can_receive() * 1000) return self._lightningcanreceive @pyqtProperty(bool, notify=balanceChanged) @@ -687,7 +687,6 @@ def deleteExpiredRequests(self): def createRequest(self, amount: QEAmount, message: str, expiration: int, lightning: bool = False, reuse_address: bool = False): self.deleteExpiredRequests() try: - amount = amount.satsInt if not lightning: addr = self.wallet.get_unused_address() if addr is None: @@ -704,7 +703,10 @@ def createRequest(self, amount: QEAmount, message: str, expiration: int, lightni else: addr = None - key = self.wallet.create_request(amount, message, expiration, addr) + if lightning: + key = self.wallet.create_request(amount_msat=amount.msatsInt, message=message, exp_delay=expiration) + else: + key = self.wallet.create_request(amount_sat=amount.satsInt, message=message, exp_delay=expiration, address=addr) except InvoiceError as e: self.requestCreateError.emit(_('Error creating payment request') + ':\n' + str(e)) return diff --git a/electrum/gui/qt/amountedit.py b/electrum/gui/qt/amountedit.py index 7f12707b112e..4b79fd33e7e3 100644 --- a/electrum/gui/qt/amountedit.py +++ b/electrum/gui/qt/amountedit.py @@ -10,7 +10,8 @@ from .util import char_width_in_lineedit, ColorScheme from electrum.util import (format_satoshis_plain, decimal_point_to_base_unit_name, - FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT, UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE) + FEERATE_PRECISION, quantize_feerate, DECIMAL_POINT, UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE, + to_decimal) from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC _NOT_GIVEN = object() # sentinel value @@ -44,7 +45,7 @@ def sizeHint(self) -> QSize: class AmountEdit(SizedFreezableLineEdit): shortcut = pyqtSignal() - def __init__(self, base_unit, is_int=False, parent=None, *, max_amount=None): + def __init__(self, parent=None, *, base_unit=None, is_int=False, max_amount=None, extra_precision=None): # This seems sufficient for hundred-BTC amounts with 8 decimals width = 16 * char_width_in_lineedit() super().__init__(width=width, parent=parent) @@ -52,14 +53,14 @@ def __init__(self, base_unit, is_int=False, parent=None, *, max_amount=None): self.textChanged.connect(self.numbify) self.is_int = is_int self.is_shortcut = False - self.extra_precision = 0 + self.extra_precision = extra_precision if extra_precision else lambda: 0 self.max_amount = max_amount def decimal_point(self): - return 8 + return 0 def max_precision(self): - return self.decimal_point() + self.extra_precision + return self.decimal_point() + self.extra_precision() def numbify(self): text = self.text().strip() @@ -106,10 +107,18 @@ def get_amount(self) -> Union[None, Decimal, int]: amt = self._get_amount_from_text(str(self.text())) if self.max_amount and amt and amt >= self.max_amount: return self.max_amount - return amt + if amt is None: + return amt + # return as int if no millisats (Decimal otherwise) + return int(amt) if (int(amt) * 1000 == int(amt * 1000)) else amt def _get_text_from_amount(self, amount) -> str: - return "%d" % amount + x = to_decimal(amount) + scale_factor = pow(10, self.decimal_point()) + nfmt = "{:." + str(self.decimal_point() + self.extra_precision()) + "f}" + text = nfmt.format(x / scale_factor).rstrip('0').rstrip('.') + text = text.replace('.', DECIMAL_POINT) + return text def setAmount(self, amount): text = self._get_text_from_amount(amount) @@ -118,12 +127,16 @@ def setAmount(self, amount): class BTCAmountEdit(AmountEdit): - def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=_NOT_GIVEN): + def __init__(self, decimal_point, parent=None, *, is_int=False, max_amount=_NOT_GIVEN, millisat_precision=False): if max_amount is _NOT_GIVEN: max_amount = TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN - AmountEdit.__init__(self, self._base_unit, is_int, parent, max_amount=max_amount) + self.millisat_precision = millisat_precision + AmountEdit.__init__(self, parent, base_unit=self._base_unit, is_int=is_int, max_amount=max_amount, extra_precision=self.extra_precision) self.decimal_point = decimal_point + def extra_precision(self): + return 3 if self.millisat_precision else 0 + def _base_unit(self): return decimal_point_to_base_unit_name(self.decimal_point()) @@ -158,20 +171,35 @@ def setAmount(self, amount_sat): self.setFrozen(self.isFrozen()) # re-apply styling, as it is nuked by setText (?) self.repaint() # macOS hack for #6269 + def setMillisatPrecision(self, millisat_precision: bool): + if self.millisat_precision != millisat_precision: + self.millisat_precision = millisat_precision + self.setAmount(self._get_amount_from_text(self.text())) -class FeerateEdit(BTCAmountEdit): - def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=_NOT_GIVEN): - super().__init__(decimal_point, is_int, parent, max_amount=max_amount) - self.extra_precision = FEERATE_PRECISION +class FeerateEdit(AmountEdit): + def __init__(self, parent=None, *, max_amount=None): + super().__init__(parent, base_unit=lambda: self._base_unit(), max_amount=max_amount) + self.extra_precision = lambda: FEERATE_PRECISION + self.decimal_point = lambda: 0 def _base_unit(self): return UI_UNIT_NAME_FEERATE_SAT_PER_VBYTE def _get_amount_from_text(self, text): - sat_per_byte_amount = super()._get_amount_from_text(text) - return quantize_feerate(sat_per_byte_amount) + return quantize_feerate(super()._get_amount_from_text(text)) def _get_text_from_amount(self, amount): - amount = quantize_feerate(amount) - return super()._get_text_from_amount(amount) + return super()._get_text_from_amount(quantize_feerate(amount)) + + +class FiatAmountEdit(AmountEdit): + def __init__(self, fx=None, parent=None, *, max_amount=None): + super().__init__(parent, base_unit=self._baseunit, max_amount=max_amount, extra_precision=self._extraprecision) + self.fx = fx + + def _baseunit(self): + return self.fx.get_currency() if self.fx else '' + + def _extraprecision(self): + return self.fx.ccy_precision() if self.fx else 0 diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index 990720a3692f..474fdccd08f2 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -250,7 +250,7 @@ def create_fee_controls(self): self.fiat_fee_label.setAmount(0) self.fiat_fee_label.setStyleSheet(ColorScheme.DEFAULT.as_stylesheet()) - self.feerate_e = FeerateEdit(lambda: 0) + self.feerate_e = FeerateEdit() self.feerate_e.textEdited.connect(partial(self.on_fee_or_feerate, self.feerate_e, False)) self.feerate_e.editingFinished.connect(partial(self.on_fee_or_feerate, self.feerate_e, True)) self.update_feerate_label() diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index dd8bfb35d298..0c46fabf71b9 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -115,7 +115,7 @@ def update(self): else: icon_name = 'bitcoin.png' status = self.wallet.get_invoice_status(item) - amount = item.get_amount_sat() + amount = item.get_amount_sat_msat_precision() amount_str = self.main_window.format_amount(amount, whitespaces=True) if amount else "" amount_str_nots = self.main_window.format_amount(amount, whitespaces=True, add_thousands_sep=False) if amount else "" timestamp = item.time or 0 @@ -182,7 +182,7 @@ def create_menu(self, position): copy_menu.addAction(_("Address"), lambda: self.main_window.do_copy(invoice.get_address(), title='Bitcoin Address')) status = wallet.get_invoice_status(invoice) if status == PR_UNPAID: - if bool(invoice.get_amount_sat()): + if bool(invoice.get_amount_msat()): menu.addAction(_("Pay") + "...", lambda: self.send_tab.do_pay_invoice(invoice)) else: menu.addAction(_("Pay") + "...", lambda: self.send_tab.do_edit_invoice(invoice)) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 257add816a37..7f1a3fb6ec79 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -972,7 +972,7 @@ def format_amount( add_thousands_sep=add_thousands_sep, ) - def format_amount_and_units(self, amount_sat, *, timestamp: int = None) -> str: + def format_amount_and_units(self, amount_sat: int | Decimal | None, *, timestamp: int = None) -> str: """Returns string with both bitcoin and fiat amounts, in desired units. E.g. 500_000 -> '0.005 BTC (191.42 EUR)' """ @@ -982,7 +982,7 @@ def format_amount_and_units(self, amount_sat, *, timestamp: int = None) -> str: text += f' ({fiat})' return text - def format_fiat_and_units(self, amount_sat) -> str: + def format_fiat_and_units(self, amount_sat: int | Decimal | None) -> str: """Returns string of FX fiat amount, in desired units. E.g. 500_000 -> '191.42 EUR' """ @@ -1372,7 +1372,7 @@ def on_event_request_status(self, wallet, key, status): if status == PR_PAID: # FIXME notification should only be shown if request was not PAID before msg = _('Payment received') - amount = req.get_amount_sat() + amount = req.get_amount_sat_msat_precision() if amount: msg += ': ' + self.format_amount_and_units(amount) msg += '\n' + req.get_message() @@ -1687,7 +1687,8 @@ def show_lightning_invoice(self, invoice: Invoice): grid.addWidget(QLabel(_("Public Key") + ':'), 0, 0) grid.addWidget(pubkey_e, 0, 1) grid.addWidget(QLabel(_("Amount") + ':'), 1, 0) - amount_str = self.format_amount(invoice.get_amount_sat()) + ' ' + self.base_unit() + amount_sat = invoice.get_amount_sat_msat_precision() + amount_str = self.format_amount(amount_sat) + ' ' + self.base_unit() grid.addWidget(QLabel(amount_str), 1, 1) grid.addWidget(QLabel(_("Description") + ':'), 2, 0) grid.addWidget(QLabel(invoice.message), 2, 1) diff --git a/electrum/gui/qt/receive_tab.py b/electrum/gui/qt/receive_tab.py index 74377b319f6b..dbb019fe21ab 100644 --- a/electrum/gui/qt/receive_tab.py +++ b/electrum/gui/qt/receive_tab.py @@ -14,7 +14,7 @@ from electrum.invoices import pr_expiration_values from electrum.logging import Logger -from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit +from .amountedit import BTCAmountEdit, SizedFreezableLineEdit, FiatAmountEdit from .qrcodewidget import QRCodeWidget from .util import read_QIcon, WWLabel, MessageBoxMixin, MONOSPACE_FONT, get_icon_qrcode @@ -51,11 +51,16 @@ def __init__(self, window: 'ElectrumWindow'): grid.addWidget(QLabel(_('Description')), 0, 0) grid.addWidget(self.receive_message_e, 0, 1, 1, 4) - self.receive_amount_e = BTCAmountEdit(self.window.get_decimal_point) + def amount_e_text_changed(): + amt = self.receive_amount_e.get_amount() + self.create_onchain_invoice_button.setEnabled(amt is None or isinstance(amt, int)) + + self.receive_amount_e = BTCAmountEdit(self.window.get_decimal_point, millisat_precision=self.wallet.has_lightning()) + self.receive_amount_e.textChanged.connect(amount_e_text_changed) grid.addWidget(QLabel(_('Requested amount')), 1, 0) grid.addWidget(self.receive_amount_e, 1, 1) - self.fiat_receive_e = AmountEdit(self.fx.get_currency if self.fx else '') + self.fiat_receive_e = FiatAmountEdit(self.fx) if not self.fx or not self.fx.is_enabled(): self.fiat_receive_e.setVisible(False) grid.addWidget(self.fiat_receive_e, 1, 2, Qt.AlignmentFlag.AlignLeft) @@ -296,7 +301,9 @@ def create_invoice(self, is_lightning: bool): expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS if is_lightning: address = None + amount_msat = int(amount_sat * 1000) if amount_sat is not None else None else: + assert amount_sat is None or isinstance(amount_sat, int), 'no msat precision allowed for onchain' if amount_sat and amount_sat < self.wallet.dust_threshold(): self.show_error(_('Amount too small to be received onchain')) return @@ -307,7 +314,10 @@ def create_invoice(self, is_lightning: bool): # generate even if we cannot receive try: - key = self.wallet.create_request(amount_sat, message, expiry, address) + if is_lightning: + key = self.wallet.create_request(amount_msat=amount_msat, message=message, exp_delay=expiry, address=address) + else: + key = self.wallet.create_request(amount_sat=amount_sat, message=message, exp_delay=expiry, address=address) except InvoiceError as e: self.show_error(_('Error creating payment request') + ':\n' + str(e)) return diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py index 9e66fcc42a71..d983e6124b9f 100644 --- a/electrum/gui/qt/request_list.py +++ b/electrum/gui/qt/request_list.py @@ -143,7 +143,7 @@ def update(self): status = self.wallet.get_invoice_status(req) status_str = req.get_status_str(status) timestamp = req.get_time() - amount = req.get_amount_sat() + amount = req.get_amount_sat_msat_precision() message = req.get_message() date = format_time(timestamp) amount_str = self.main_window.format_amount(amount) if amount else "" diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index cdb97cceb76d..11047c574426 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -29,7 +29,7 @@ from electrum.fee_policy import FeePolicy, FixedFeePolicy from electrum.lnurl import LNURL3Data, request_lnurl_withdraw_callback, LNURLError -from .amountedit import AmountEdit, BTCAmountEdit, SizedFreezableLineEdit +from .amountedit import BTCAmountEdit, SizedFreezableLineEdit, FiatAmountEdit from .paytoedit import InvalidPaymentIdentifier from .util import (WaitingDialog, HelpLabel, MessageBoxMixin, EnterButton, char_width_in_lineedit, get_icon_camera, read_QIcon, ColorScheme, IconLabel, Spinner, Buttons, WWLabel, @@ -68,7 +68,7 @@ def __init__(self, window: 'ElectrumWindow'): grid.setColumnStretch(3, 1) from .paytoedit import PayToEdit - self.amount_e = BTCAmountEdit(self.window.get_decimal_point) + self.amount_e = BTCAmountEdit(self.window.get_decimal_point, millisat_precision=True) self.payto_e = PayToEdit(self) msg = (_("Recipient of the funds.") + "\n\n" @@ -119,7 +119,7 @@ def __init__(self, window: 'ElectrumWindow'): amount_widgets = QHBoxLayout() amount_widgets.addWidget(self.amount_e) - self.fiat_send_e = AmountEdit(self.fx.get_currency if self.fx else '') + self.fiat_send_e = FiatAmountEdit(self.fx) if not self.fx or not self.fx.is_enabled(): self.fiat_send_e.setVisible(False) amount_widgets.addWidget(self.fiat_send_e) @@ -373,6 +373,7 @@ def do_clear(self): w.setToolTip('') for w in [self.save_button, self.send_button]: w.setEnabled(False) + self.amount_e.setMillisatPrecision(True) self.window.update_status() self.paytomany_menu.setChecked(self.payto_e.multiline) self.invoice_error.setText('') @@ -412,6 +413,7 @@ def update_fields(self): pi = self.payto_e.payment_identifier self.clear_button.setEnabled(True) + self.amount_e.setMillisatPrecision(not pi.is_valid() or pi.is_lightning()) if pi.is_multiline(): self.lock_fields(lock_recipient=False, lock_amount=True, lock_max=True, lock_description=False) @@ -552,7 +554,7 @@ def save_pending_invoice(self): self.invoice_list.update() self.pending_invoice = None - def get_amount(self) -> int: + def get_amount(self) -> int | Decimal: # must not be None return self.amount_e.get_amount() or 0 @@ -586,7 +588,7 @@ def pay_multiple_invoices(self, invoices): self.pay_onchain_dialog(outputs) def do_edit_invoice(self, invoice: 'Invoice'): # FIXME broken - assert not bool(invoice.get_amount_sat()) + assert not bool(invoice.get_amount_msat()) text = invoice.lightning_invoice if invoice.is_lightning() else invoice.get_address() self.set_payment_identifier(text) self.amount_e.setFocus() @@ -594,7 +596,7 @@ def do_edit_invoice(self, invoice: 'Invoice'): # FIXME broken self.save_button.setEnabled(False) def do_pay_invoice(self, invoice: 'Invoice'): - if not bool(invoice.get_amount_sat()): + if not bool(invoice.get_amount_msat()): pi = self.payto_e.payment_identifier if pi.type == PaymentIdentifierType.SPK and not pi.spk_is_address: pass @@ -606,7 +608,7 @@ def do_pay_invoice(self, invoice: 'Invoice'): else: self.pay_onchain_dialog(invoice.outputs, invoice=invoice) - def read_amount(self) -> Union[int, str]: + def read_amount(self) -> Union[int, str, Decimal]: amount = '!' if self.max_button.isChecked() else self.get_amount() return amount @@ -661,8 +663,8 @@ def check_payto_line_and_show_errors(self) -> bool: return False # no errors def pay_lightning_invoice(self, invoice: Invoice): - amount_sat = invoice.get_amount_sat() - if amount_sat is None: + amount_msat = invoice.get_amount_msat() + if amount_msat is None: raise Exception("missing amount for LN invoice") # note: lnworker might be None if LN is disabled, # in which case we should still offer the user to pay onchain. @@ -673,6 +675,7 @@ def pay_lightning_invoice(self, invoice: Invoice): can_pay_with_swap = False can_rebalance = False if lnworker: + amount_sat = invoice.get_amount_sat() # lose precision for suggestions, they are not msat based can_pay_with_new_channel = lnworker.suggest_funding_amount(amount_sat, coins=coins) can_pay_with_swap = lnworker.suggest_swap_to_send(amount_sat, coins=coins) rebalance_suggestion = lnworker.suggest_rebalance_to_send(amount_sat) @@ -720,10 +723,9 @@ def pay_lightning_invoice(self, invoice: Invoice): return assert lnworker is not None - # FIXME this is currently lying to user as we truncate to satoshis - amount_msat = invoice.get_amount_msat() + label = QLabel( - _("This will send {} to the recipient").format(self.format_amount_and_units(Decimal(amount_msat)/1000))) + _("This will send {} to the recipient").format(self.format_amount_and_units(invoice.get_amount_sat_msat_precision()))) dialog = WindowModalDialog(self, _("Pay lightning invoice?")) dialog.setMinimumWidth(400) @@ -735,13 +737,16 @@ def pay_lightning_invoice(self, invoice: Invoice): lnfee_hlabel = HelpLabel.from_configvar(self.config.cv.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS) lnfee_hlabel.setText(_('Max routing fee') + ' :') lnfee_map = [500, 1_000, 3_000, 5_000, 10_000, 20_000, 30_000, 50_000] + def lnfee_update_vlabel(fee_val: int): lnfee_vlabel.setText(_("{}% of payment").format(f"{fee_val / 10 ** 4:.2f}")) + def lnfee_slider_moved(): pos = lnfee_slider.sliderPosition() fee_val = lnfee_map[pos] lnfee_update_vlabel(fee_val) self.config.LIGHTNING_PAYMENT_FEE_MAX_MILLIONTHS = fee_val + lnfee_slider = QSlider(Qt.Orientation.Horizontal) lnfee_slider.setRange(0, len(lnfee_map)-1) lnfee_slider.setTracking(True) @@ -767,7 +772,7 @@ def lnfee_slider_moved(): if not dialog.exec(): return self.save_pending_invoice() - coro = lnworker.pay_invoice(invoice, amount_msat=amount_msat) + coro = lnworker.pay_invoice(invoice, amount_msat=invoice.get_amount_msat()) self.window.run_coroutine_from_thread(coro, _('Sending payment')) def broadcast_transaction(self, tx: Transaction, *, invoice: Invoice = None): @@ -893,12 +898,12 @@ def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data): grid.addWidget(desc_text, row, 1, 1, 3) row += 1 - min_amount = max(lnurl_data.min_withdrawable_sat, 1) + min_amount = max(Decimal(lnurl_data.min_withdrawable_msat) / 1000, 1) max_amount = min( - lnurl_data.max_withdrawable_sat, - int(self.wallet.lnworker.num_sats_can_receive()) + Decimal(lnurl_data.max_withdrawable_msat) / 1000, + self.wallet.lnworker.num_sats_can_receive() ) - min_text = self.format_amount_and_units(lnurl_data.min_withdrawable_sat) + min_text = self.format_amount_and_units(Decimal(lnurl_data.min_withdrawable_msat) / 1000) if min_amount > int(self.wallet.lnworker.num_sats_can_receive()): self.show_error("".join([ _("Too little incoming liquidity to satisfy this withdrawal request."), "\n\n", @@ -910,14 +915,14 @@ def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data): ])) return - is_fixed_amount = lnurl_data.min_withdrawable_sat == lnurl_data.max_withdrawable_sat + is_fixed_amount = lnurl_data.min_withdrawable_msat == lnurl_data.max_withdrawable_msat # Range information (only for non-fixed amounts) if not is_fixed_amount: range_label_text = QLabel(_("Range") + ":") range_value = QLabel("{} - {}".format( min_text, - self.format_amount_and_units(lnurl_data.max_withdrawable_sat) + self.format_amount_and_units(Decimal(lnurl_data.max_withdrawable_msat) / 1000) )) grid.addWidget(range_label_text, row, 0) grid.addWidget(range_value, row, 1, 1, 2) @@ -925,7 +930,7 @@ def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data): # Amount section amount_label = QLabel(_("Amount") + ":") - amount_edit = BTCAmountEdit(self.window.get_decimal_point, max_amount=max_amount) + amount_edit = BTCAmountEdit(self.window.get_decimal_point, max_amount=max_amount, millisat_precision=True) amount_edit.setAmount(max_amount) grid.addWidget(amount_label, row, 0) grid.addWidget(amount_edit, row, 1) @@ -943,7 +948,7 @@ def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data): row += 1 # Warning for insufficient liquidity - if lnurl_data.max_withdrawable_sat > int(self.wallet.lnworker.num_sats_can_receive()): + if Decimal(lnurl_data.max_withdrawable_msat) / 1000 > self.wallet.lnworker.num_sats_can_receive(): warning_text = WWLabel( _("The maximum withdrawable amount is larger than what your channels can receive. " "You may need to do a submarine swap to increase your incoming liquidity.") @@ -962,18 +967,19 @@ def request_lnurl_withdraw_dialog(self, lnurl_data: LNURL3Data): # Show dialog and handle result if dialog.exec(): if is_fixed_amount: - amount_sat = lnurl_data.max_withdrawable_sat + amount_msat = lnurl_data.max_withdrawable_msat else: - amount_sat = amount_edit.get_amount() - if not amount_sat or not (min_amount <= int(amount_sat) <= max_amount): - self.show_error(_("Enter a valid amount. You entered: {}").format(amount_sat)) + amount = amount_edit.get_amount() + if not amount or not (min_amount <= amount <= max_amount): + self.show_error(_("Enter a valid amount. You entered: {}").format(amount)) return + amount_msat = int(amount_edit.get_amount() * 1000) else: return try: key = self.wallet.create_request( - amount_sat=amount_sat, + amount_msat=amount_msat, message=lnurl_data.default_description, exp_delay=120, address=None, diff --git a/electrum/gui/text.py b/electrum/gui/text.py index 09fc64c1e577..2973050e4c91 100644 --- a/electrum/gui/text.py +++ b/electrum/gui/text.py @@ -578,7 +578,7 @@ def do_create_request(self, lightning: bool): message = self.str_recv_description expiry = self.config.WALLET_PAYREQ_EXPIRY_SECONDS - key = self.wallet.create_request(amount_sat, message, expiry, address) + key = self.wallet.create_request(amount_sat=amount_sat, message=message, exp_delay=expiry, address=address) self.do_clear_request() self.pos = self.max_pos self.show_request(key) diff --git a/electrum/invoices.py b/electrum/invoices.py index 8f20166cb5ba..111a76c2bbbe 100644 --- a/electrum/invoices.py +++ b/electrum/invoices.py @@ -181,6 +181,17 @@ def get_amount_sat(self) -> Union[int, str, None]: return amount_msat return int(amount_msat // 1000) + def get_amount_sat_msat_precision(self): + """ + Returns a satoshi amount as int if no msat precision, as Decimal if msat precision, or '!' or None. + """ + amount_msat = self.amount_msat + if amount_msat in [None, "!"]: + return amount_msat + amount_sat = Decimal(amount_msat) / 1000 + # return as Decimal if msat precision, else int + return amount_sat if amount_sat != Decimal(amount_msat) // 1000 else amount_msat // 1000 + def set_amount_msat(self, amount_msat: Union[int, str]) -> None: """The GUI uses this to fill the amount for a zero-amount invoice.""" if amount_msat == "!": diff --git a/electrum/lnurl.py b/electrum/lnurl.py index a95bf8bcc633..1709a3f380f5 100644 --- a/electrum/lnurl.py +++ b/electrum/lnurl.py @@ -91,12 +91,13 @@ def _parse_lnurl_response_callback_url(lnurl_response: dict) -> str: # https://github.com/lnurl/luds/blob/227f850b701e9ba893c080103c683273e2feb521/06.md class LNURL6Data(NamedTuple): callback_url: str - max_sendable_sat: int - min_sendable_sat: int + max_sendable_msat: int + min_sendable_msat: int metadata_plaintext: str comment_allowed: int #tag: str = "payRequest" + # withdrawRequest # https://github.com/lnurl/luds/blob/227f850b701e9ba893c080103c683273e2feb521/03.md class LNURL3Data(NamedTuple): @@ -107,10 +108,11 @@ class LNURL3Data(NamedTuple): # A default withdrawal invoice description default_description: str # Min amount the user can withdraw from LN SERVICE, or 0 - min_withdrawable_sat: int + min_withdrawable_msat: int # Max amount the user can withdraw from LN SERVICE, # or equal to minWithdrawable if the user has no choice over the amounts - max_withdrawable_sat: int + max_withdrawable_msat: int + LNURLData = LNURL6Data | LNURL3Data @@ -152,8 +154,8 @@ def _parse_lnurl6_response(lnurl_response: dict) -> LNURL6Data: callback_url = _parse_lnurl_response_callback_url(lnurl_response) # parse lnurl6 "minSendable"/"maxSendable" try: - max_sendable_sat = int(lnurl_response['maxSendable']) // 1000 - min_sendable_sat = int(lnurl_response['minSendable']) // 1000 + max_sendable_msat = int(lnurl_response['maxSendable']) + min_sendable_msat = int(lnurl_response['minSendable']) except Exception as e: raise LNURLError( f"Missing or malformed 'minSendable'/'maxSendable' field in lnurl6 response. {e=!r}") from e @@ -164,8 +166,8 @@ def _parse_lnurl6_response(lnurl_response: dict) -> LNURL6Data: raise LNURLError(f"Malformed 'commentAllowed' field in lnurl6 response. {e=!r}") from e data = LNURL6Data( callback_url=callback_url, - max_sendable_sat=max_sendable_sat, - min_sendable_sat=min_sendable_sat, + max_sendable_msat=max_sendable_msat, + min_sendable_msat=min_sendable_msat, metadata_plaintext=metadata_plaintext, comment_allowed=comment_allowed, ) @@ -179,10 +181,10 @@ def _parse_lnurl3_response(lnurl_response: dict) -> LNURL3Data: raise UntrustedLNURLError(f"Missing k1 value in LNURL3 response: {lnurl_response=}") default_description = lnurl_response.get('defaultDescription', '') try: - min_withdrawable_sat = int(lnurl_response['minWithdrawable'] or 0) // 1000 - max_withdrawable_sat = int(lnurl_response['maxWithdrawable']) // 1000 - assert max_withdrawable_sat >= min_withdrawable_sat, f"Invalid amounts: max < min amount" - assert max_withdrawable_sat > 0, f"Invalid max amount: {max_withdrawable_sat} sat" + min_withdrawable_msat = int(lnurl_response['minWithdrawable'] or 0) + max_withdrawable_msat = int(lnurl_response['maxWithdrawable']) + assert max_withdrawable_msat >= min_withdrawable_msat, f"Invalid amounts: max < min amount" + assert max_withdrawable_msat > 0, f"Invalid max amount: {max_withdrawable_msat} msat" except Exception as e: raise LNURLError( f"Missing or malformed 'minWithdrawable'/'minWithdrawable' field in lnurl3 response. {e=!r}") from e @@ -190,8 +192,8 @@ def _parse_lnurl3_response(lnurl_response: dict) -> LNURL3Data: callback_url=callback_url, k1=k1, default_description=default_description, - min_withdrawable_sat=min_withdrawable_sat, - max_withdrawable_sat=max_withdrawable_sat, + min_withdrawable_msat=min_withdrawable_msat, + max_withdrawable_msat=max_withdrawable_msat, ) @@ -215,6 +217,7 @@ async def try_resolve_lnurlpay(lnurl: Optional[str]) -> Optional[LNURL6Data]: _logger.debug(f"Error resolving lnurl: {request_error!r}") return None + async def request_lnurl_withdraw_callback(callback_url: str, k1: str, bolt_11: str) -> None: assert bolt_11 params = { @@ -226,6 +229,7 @@ async def request_lnurl_withdraw_callback(callback_url: str, k1: str, bolt_11: s params=params ) + async def callback_lnurl(url: str, params: dict) -> dict: """Requests an invoice from a lnurl supporting server.""" if not _is_url_safe_enough_for_lnurl(url): diff --git a/electrum/lnworker.py b/electrum/lnworker.py index e73fb5126adf..7d23133090f0 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1802,7 +1802,7 @@ def get_channel_by_short_id(self, short_channel_id: bytes) -> Optional[Channel]: def can_pay_invoice(self, invoice: Invoice) -> bool: assert invoice.is_lightning() - return (invoice.get_amount_sat() or 0) <= self.num_sats_can_send() + return (invoice.get_amount_sat_msat_precision() or 0) <= self.num_sats_can_send() @log_exceptions async def pay_invoice( @@ -3485,7 +3485,7 @@ async def rebalance_channels(self, chan1: Channel, chan2: Channel, *, amount_msa def can_receive_invoice(self, invoice: BaseInvoice) -> bool: assert invoice.is_lightning() - return (invoice.get_amount_sat() or 0) <= self.num_sats_can_receive() + return (invoice.get_amount_sat_msat_precision() or 0) <= self.num_sats_can_receive() async def close_channel(self, chan_id): chan = self._channels[chan_id] diff --git a/electrum/payment_identifier.py b/electrum/payment_identifier.py index 5883aadfb669..75da254dfe0e 100644 --- a/electrum/payment_identifier.py +++ b/electrum/payment_identifier.py @@ -83,11 +83,11 @@ class PaymentIdentifierType(IntEnum): class FieldsForGUI(NamedTuple): recipient: Optional[str] - amount: Optional[int] + amount: Optional[int | Decimal] description: Optional[str] validated: Optional[bool] comment: Optional[int] - amount_range: Optional[Tuple[int, int]] + amount_range: Optional[Tuple[int | Decimal, int | Decimal]] class PaymentIdentifier(Logger): @@ -165,6 +165,7 @@ def is_onchain(self): return bool(self.bolt11) and bool(self.bolt11.get_address()) if self._type == PaymentIdentifierType.BIP21: return bool(self.bip21.get('address', None)) or (bool(self.bolt11) and bool(self.bolt11.get_address())) + return False def is_multiline(self): return bool(self.multiline_outputs) @@ -176,14 +177,14 @@ def is_amount_locked(self): if self._type == PaymentIdentifierType.BIP21: return bool(self.bip21.get('amount')) elif self._type == PaymentIdentifierType.BOLT11: - return bool(self.bolt11.get_amount_sat()) + return bool(self.bolt11.get_amount_msat()) elif self._type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]: # amount limits known after resolve, might be specific amount or locked to range if self.need_resolve(): return False if self.need_finalize(): - self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_sat}-{self.lnurl_data.max_sendable_sat}') - return not (self.lnurl_data.min_sendable_sat < self.lnurl_data.max_sendable_sat) + self.logger.debug(f'lnurl f {self.lnurl_data.min_sendable_msat}-{self.lnurl_data.max_sendable_msat}') + return not (self.lnurl_data.min_sendable_msat < self.lnurl_data.max_sendable_msat) return True elif self._type == PaymentIdentifierType.MULTILINE: return True @@ -346,7 +347,7 @@ async def _do_resolve(self, *, on_finished: Callable[['PaymentIdentifier'], None def finalize( self, *, - amount_sat: int = 0, + amount_sat: int | Decimal = 0, comment: str = None, on_finished: Callable[['PaymentIdentifier'], None] = None, ): @@ -358,7 +359,7 @@ def finalize( async def _do_finalize( self, *, - amount_sat: int = None, + amount_sat: int | Decimal = None, comment: str = None, on_finished: Callable[['PaymentIdentifier'], None] = None, ): @@ -367,15 +368,18 @@ async def _do_finalize( if not self.lnurl_data: raise Exception("Unexpected missing LNURL data") - if not (self.lnurl_data.min_sendable_sat <= amount_sat <= self.lnurl_data.max_sendable_sat): + amount_msat = amount_sat * 1000 + if not (self.lnurl_data.min_sendable_msat <= amount_msat <= self.lnurl_data.max_sendable_msat): self.error = _('Amount must be between {} and {} sat.').format( - self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) + Decimal(self.lnurl_data.min_sendable_msat) / 1000, + Decimal(self.lnurl_data.max_sendable_msat) / 1000 + ) self.set_state(PaymentIdentifierState.INVALID_AMOUNT) return if self.lnurl_data.comment_allowed == 0: comment = None - params = {'amount': amount_sat * 1000} + params = {'amount': amount_msat} if comment: params['comment'] = comment @@ -388,7 +392,7 @@ async def _do_finalize( bolt11_invoice = invoice_data.get('pr') invoice = Invoice.from_bech32(bolt11_invoice) - if invoice.get_amount_sat() != amount_sat: + if invoice.get_amount_msat() != amount_msat: raise Exception("lnurl returned invoice with wrong amount") # this will change what is returned by get_fields_for_GUI self.bolt11 = invoice @@ -484,7 +488,10 @@ def parse_amount(self, x: str) -> Union[str, int]: return x p = pow(10, self.config.BTC_AMOUNTS_DECIMAL_POINT) try: - return int(p * Decimal(x)) + x = Decimal(x) * p + if int(x) != x: + raise InvalidOperation("no millisat precision allowed") + return int(x) except InvalidOperation: raise Exception("Invalid amount") @@ -530,10 +537,13 @@ def get_fields_for_GUI(self) -> FieldsForGUI: description = self.lnurl_data.metadata_plaintext if self.lnurl_data.comment_allowed: comment = self.lnurl_data.comment_allowed - if self.lnurl_data.min_sendable_sat: - amount = self.lnurl_data.min_sendable_sat - if self.lnurl_data.min_sendable_sat != self.lnurl_data.max_sendable_sat: - amount_range = (self.lnurl_data.min_sendable_sat, self.lnurl_data.max_sendable_sat) + if self.lnurl_data.min_sendable_msat: + amount = Decimal(self.lnurl_data.min_sendable_msat) / 1000 + if self.lnurl_data.min_sendable_msat != self.lnurl_data.max_sendable_msat: + amount_range = ( + Decimal(self.lnurl_data.min_sendable_msat) / 1000, + Decimal(self.lnurl_data.max_sendable_msat) / 1000 + ) elif self.spk: pass @@ -593,7 +603,7 @@ def has_expired(self): def invoice_from_payment_identifier( pi: 'PaymentIdentifier', wallet: 'Abstract_Wallet', - amount_sat: Union[int, str], + amount_sat: Union[int, str, Decimal], message: str = None ) -> Optional[Invoice]: assert pi.state in [PaymentIdentifierState.AVAILABLE,] @@ -607,6 +617,7 @@ def invoice_from_payment_identifier( invoice.set_amount_msat(int(amount_sat * 1000)) return invoice else: + assert isinstance(amount_sat, (str, int)) # disallow fractional sats outputs = pi.get_onchain_outputs(amount_sat) message = pi.bip21.get('message') if pi.bip21 else message return wallet.create_invoice( diff --git a/electrum/wallet.py b/electrum/wallet.py index 29e2e52e2c97..8dcb37aeab91 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -3025,12 +3025,27 @@ def get_bolt11_invoice(self, req: Request) -> str: fallback_address=None) return invoice - def create_request(self, amount_sat: Optional[int], message: Optional[str], exp_delay: Optional[int], address: Optional[str]): + def create_request( + self, + *, + amount_sat: Optional[int] = None, + amount_msat: Optional[int] = None, + message: Optional[str] = None, + exp_delay: Optional[int] = None, + address: Optional[str] = None + ): """ will create a lightning request if address is None """ # for receiving - amount_sat = amount_sat or 0 - assert isinstance(amount_sat, int), f"{amount_sat!r}" - amount_msat = None if not amount_sat else amount_sat * 1000 # amount_sat in [None, 0] implies undefined. + assert amount_sat is None or amount_msat is None, 'both amount_sat and amount_msat are specified' + assert amount_msat is None if address else True, 'onchain request must not pass amount_msat' + if amount_msat is not None: + assert isinstance(amount_msat, int), f"{amount_sat!r}" + elif amount_sat is not None: + assert isinstance(amount_sat, int), f"{amount_msat!r}" + amount_msat = amount_sat * 1000 + else: + amount_msat = None + message = message or '' address = address or None # converts "" to None exp_delay = exp_delay or 0 diff --git a/tests/qml/test_qml_qeconfig.py b/tests/qml/test_qml_qeconfig.py index c6de3156f3d9..63740d11d45f 100644 --- a/tests/qml/test_qml_qeconfig.py +++ b/tests/qml/test_qml_qeconfig.py @@ -2,6 +2,7 @@ from electrum import SimpleConfig from electrum.gui.qml.qeconfig import QEConfig +from electrum.gui.qml.qetypes import QEAmount from .qt_util import QETestCase, qt_test @@ -20,53 +21,54 @@ def setUp(self): # raise Exception() # NOTE: exceptions in setUp() will block the test @qt_test - def test_satstounits(self): + def test_amounttobaseunitstr(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) + self.assertEqual(self.q.amountToBaseunitStr(100_000), '1') + self.assertEqual(self.q.amountToBaseunitStr(1), '0.00001') + amt = QEAmount(amount_msat=1) + self.assertEqual(self.q.amountToBaseunitStr(amt), '0.00000001') @qt_test - def test_unitstosats(self): - qa = self.q.unitsToSats('') + def test_baseunitstrtoamount(self): + qa = self.q.baseunitStrToAmount('') self.assertTrue(qa.isEmpty) - qa = self.q.unitsToSats('0') + qa = self.q.baseunitStrToAmount('0') self.assertTrue(qa.isEmpty) - qa = self.q.unitsToSats('0.000') + qa = self.q.baseunitStrToAmount('0.000') self.assertTrue(qa.isEmpty) self.q.config.BTC_AMOUNTS_DECIMAL_POINT = 5 - qa = self.q.unitsToSats('1') + qa = self.q.baseunitStrToAmount('1') self.assertFalse(qa.isEmpty) self.assertEqual(qa.satsInt, 100_000) self.assertEqual(qa.msatsInt, 100_000_000) - qa = self.q.unitsToSats('1.001') + qa = self.q.baseunitStrToAmount('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') + qa = self.q.baseunitStrToAmount('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') + qa = self.q.baseunitStrToAmount('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 + qa = self.q.baseunitStrToAmount('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') + qa = self.q.baseunitStrToAmount('0.00000001001') self.assertFalse(qa.isEmpty) self.assertEqual(qa.satsInt, 1) self.assertEqual(qa.msatsInt, 1001) diff --git a/tests/qml/test_qml_types.py b/tests/qml/test_qml_types.py index ba14fe115f6e..bdba06ef5298 100644 --- a/tests/qml/test_qml_types.py +++ b/tests/qml/test_qml_types.py @@ -37,6 +37,7 @@ def test_qeamount(self): a.satsInt = 1 self.assertTrue(bool(a_er.received)) self.assertFalse(a.isEmpty) + self.assertEqual(1000, a.msatsInt) self.assertEqual('1', a.satsStr) a_er.clear() @@ -55,10 +56,30 @@ def test_qeamount(self): a.clear() a_er.clear() - a.msatsInt = 1 + a.msatsInt = 1500 self.assertTrue(bool(a_er.received)) self.assertFalse(a.isEmpty) - self.assertEqual('1', a.msatsStr) + self.assertEqual(1, a.satsInt) + self.assertEqual('1500', a.msatsStr) + self.assertTrue(a.hasMsatPrecision) + + b = QEAmount(amount_sat=100) + self.assertFalse(b.isEmpty) + self.assertEqual(100, b.satsInt) + self.assertEqual(100_000, b.msatsInt) + self.assertFalse(b.hasMsatPrecision) + + c = QEAmount(amount_msat=1500) + self.assertFalse(c.isEmpty) + self.assertEqual(1, c.satsInt) + self.assertEqual(1500, c.msatsInt) + self.assertTrue(c.hasMsatPrecision) + + with self.assertRaises(AssertionError): + QEAmount(amount_sat=2, amount_msat=1500) + + with self.assertRaises(AssertionError): + QEAmount(amount_msat=1500, is_max=True) @qt_test def test_qeamount_copy(self): @@ -82,12 +103,14 @@ def test_qeamount_copy(self): t.copyFrom(b) self.assertFalse(t.isEmpty) self.assertEqual(t.satsInt, 1) + self.assertEqual(t.msatsInt, 1000) self.assertEqual(1, len(t_er.received)) t.clear() t_er.clear() t.copyFrom(c) self.assertFalse(t.isEmpty) + self.assertEqual(t.satsInt, 0) self.assertEqual(t.msatsInt, 1) self.assertEqual(1, len(t_er.received)) @@ -116,6 +139,12 @@ def test_qeamount_frominvoice(self): self.assertEqual(10_000_000, a.msatsInt) self.assertFalse(a.isMax) + with self.assertRaises(AssertionError): + QEAmount(amount_msat=amount_sat * 1000, from_invoice=invoice) + + with self.assertRaises(AssertionError): + QEAmount(amount_sat=amount_sat, from_invoice=invoice) + outputs = [PartialTxOutput.from_address_and_value('bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293', '!')] invoice = Invoice( amount_msat='!', @@ -137,3 +166,62 @@ def test_qeamount_frominvoice(self): self.assertEqual(2_000_000, a.satsInt) self.assertEqual(2_000_000_000, a.msatsInt) self.assertFalse(a.isMax) + + @qt_test + def test_lt_gt_eq(self): + a = QEAmount(amount_msat=100) + b = QEAmount(amount_msat=200) + + self.assertTrue(a.lt(b)) + self.assertTrue(b.gt(a)) + self.assertFalse(a.lt(a)) + self.assertTrue(a.lte(b)) + self.assertTrue(b.gte(a)) + self.assertTrue(a.lte(a)) + + c = QEAmount() + + self.assertTrue(a.gt(c)) + self.assertTrue(c.lt(a)) + + d = QEAmount(is_max=True) + + self.assertFalse(d.lt(a)) + self.assertFalse(d.gt(a)) + self.assertFalse(a.lt(d)) + self.assertFalse(a.gt(d)) + + e = QEAmount(amount_msat=200) + self.assertTrue(e.lte(b)) + self.assertTrue(b.lte(e)) + self.assertTrue(e.eq(b)) + + f = QEAmount() + self.assertFalse(f.eq(a)) + self.assertFalse(f.eq(b)) + self.assertTrue(f.eq(c)) + self.assertFalse(f.eq(d)) + + g = QEAmount(is_max=True) + self.assertFalse(g.eq(a)) + self.assertFalse(g.eq(b)) + self.assertFalse(g.eq(c)) + self.assertTrue(g.eq(d)) + + @qt_test + def test_min_max(self): + o = QEAmount() + a = QEAmount(amount_msat=100) + b = QEAmount(amount_msat=200) + c = QEAmount(amount_msat=200) + d = QEAmount() + + self.assertTrue(a.eq(o.min(a, b))) + self.assertTrue(b.eq(o.max(a, b))) + self.assertTrue(b.eq(o.max(b, c))) + self.assertTrue(c.eq(o.max(b, c))) + + self.assertTrue(a.eq(o.max(a, None))) + self.assertTrue(a.eq(o.max(None, a))) + self.assertTrue(d.eq(o.min(a, None))) + self.assertTrue(d.eq(o.min(None, a))) diff --git a/tests/test_invoices.py b/tests/test_invoices.py index 89a7fdac7ffd..8298a3cbe35e 100644 --- a/tests/test_invoices.py +++ b/tests/test_invoices.py @@ -1,5 +1,6 @@ import os import time +from decimal import Decimal from electrum.simple_config import SimpleConfig from electrum.wallet import Standard_Wallet, Abstract_Wallet @@ -240,6 +241,10 @@ async def test_arg_validation(self): invoice.amount_msat = 10**20 with self.assertRaises(InvoiceError): invoice.set_amount_msat(10**20) + with self.assertRaises(InvoiceError): + invoice.amount_msat = Decimal(amount_sat * 1000) + with self.assertRaises(AssertionError): + invoice.set_amount_msat(Decimal(amount_sat * 1000)) with self.assertRaises(InvoiceError): invoice2 = Invoice( amount_msat=10**20, @@ -255,3 +260,29 @@ async def test_arg_validation(self): with self.assertRaises(TypeError): invoice.exp = "asd" + async def test_get_amount_sat_msat_precision(self): + amount_sat = 10_000 + outputs = [PartialTxOutput.from_address_and_value("tb1qmjzmg8nd4z56ar4fpngzsr6euktrhnjg9td385", amount_sat)] + invoice = Invoice( + amount_msat=amount_sat * 1000, + message="mymsg", + time=1692716965, + exp=LN_EXPIRY_NEVER, + outputs=outputs, + height=0, + lightning_invoice=None, + ) + self.assertTrue(isinstance(invoice.get_amount_sat_msat_precision(), int)) + invoice.set_amount_msat(500) + self.assertTrue(isinstance(invoice.get_amount_sat_msat_precision(), Decimal)) + self.assertEqual(invoice.get_amount_sat_msat_precision(), Decimal('0.500')) + invoice.set_amount_msat(1000) + self.assertTrue(isinstance(invoice.get_amount_sat_msat_precision(), int)) + self.assertEqual(invoice.get_amount_sat_msat_precision(), 1) + invoice.set_amount_msat('!') + self.assertTrue(isinstance(invoice.get_amount_sat_msat_precision(), str)) + self.assertEqual(invoice.get_amount_sat_msat_precision(), '!') + with self.assertRaises(AssertionError): + invoice.set_amount_msat(None) # different semantics than just setting the property w validator. + invoice.amount_msat = None + self.assertIsNone(invoice.get_amount_sat_msat_precision()) diff --git a/tests/test_lnurl.py b/tests/test_lnurl.py index 3f3b869f7d59..bc02e0c60266 100644 --- a/tests/test_lnurl.py +++ b/tests/test_lnurl.py @@ -36,8 +36,8 @@ def test_parse_lnurl3_response(self): self.assertEqual('https://service.io/withdraw?sessionid=123', result.callback_url) self.assertEqual('abcdef1234567890', result.k1) self.assertEqual('Withdraw from service', result.default_description) - self.assertEqual(10_000, result.min_withdrawable_sat) - self.assertEqual(100_000, result.max_withdrawable_sat) + self.assertEqual(10_000_000, result.min_withdrawable_msat) + self.assertEqual(100_000_000, result.max_withdrawable_msat) # Test with .onion URL onion_response = { diff --git a/tests/test_payment_identifier.py b/tests/test_payment_identifier.py index 046f5ba11b73..79940b5e22ac 100644 --- a/tests/test_payment_identifier.py +++ b/tests/test_payment_identifier.py @@ -192,8 +192,8 @@ def test_lnurl_pay_resolve(self, mock_request_lnurl): # Mock lnurl-p response mock_lnurl6_data = LNURL6Data( callback_url='https://example.com/lnurl-pay', - max_sendable_sat=1_000_000, - min_sendable_sat=1_000, + max_sendable_msat=1_000_000_000, + min_sendable_msat=1_000_000, metadata_plaintext='Test payment', comment_allowed=100, ) @@ -213,8 +213,8 @@ async def run_resolve(): self.assertTrue(pi.need_finalize()) self.assertIsNotNone(pi.lnurl_data) self.assertTrue(isinstance(pi.lnurl_data, LNURL6Data)) - self.assertEqual(1_000, pi.lnurl_data.min_sendable_sat) - self.assertEqual(1_000_000, pi.lnurl_data.max_sendable_sat) + self.assertEqual(1_000_000, pi.lnurl_data.min_sendable_msat) + self.assertEqual(1_000_000_000, pi.lnurl_data.max_sendable_msat) self.assertEqual('Test payment', pi.lnurl_data.metadata_plaintext) self.assertEqual(100, pi.lnurl_data.comment_allowed) @@ -230,8 +230,8 @@ def test_lnurl_withdraw_resolve(self, mock_request_lnurl): callback_url='https://example.com/lnurl-withdraw', k1='test-k1-value', default_description='Test withdrawal', - min_withdrawable_sat=1_000, - max_withdrawable_sat=500_000, + min_withdrawable_msat=1_000, + max_withdrawable_msat=500_000, ) mock_request_lnurl.return_value = mock_lnurl3_data @@ -249,8 +249,8 @@ async def run_resolve(): self.assertIsNotNone(pi.lnurl_data) self.assertEqual('test-k1-value', pi.lnurl_data.k1) self.assertEqual('Test withdrawal', pi.lnurl_data.default_description) - self.assertEqual(1000, pi.lnurl_data.min_withdrawable_sat) - self.assertEqual(500000, pi.lnurl_data.max_withdrawable_sat) + self.assertEqual(1000, pi.lnurl_data.min_withdrawable_msat) + self.assertEqual(500000, pi.lnurl_data.max_withdrawable_msat) @patch('electrum.payment_identifier.request_lnurl') def test_lnurl_resolve_error(self, mock_request_lnurl): @@ -334,6 +334,13 @@ def test_multiline(self): self.assertEqual(1000, pi.multiline_outputs[0].value) self.assertEqual(0, pi.multiline_outputs[1].value) + pi_str = '\n'.join([ + 'bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293,0.01', + 'bc1q66ex4c3vek4cdmrfjxtssmtguvs3r30pf42jpj,0.000001', # msat precision should invalidate multiline + ]) + pi = PaymentIdentifier(self.wallet, pi_str) + self.assertFalse(pi.is_valid()) + def test_spk(self): address = 'bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293' for pi_str in [