Skip to content

Commit 95309e6

Browse files
committed
Merge branch '202510_lnurlw': implement LNURL-withdraw
ref #9993
2 parents 2b0cab6 + af67150 commit 95309e6

File tree

13 files changed

+941
-122
lines changed

13 files changed

+941
-122
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import QtQuick
2+
import QtQuick.Layouts
3+
import QtQuick.Controls
4+
import QtQuick.Controls.Material
5+
6+
import org.electrum 1.0
7+
8+
import "controls"
9+
10+
ElDialog {
11+
id: dialog
12+
13+
title: qsTr('LNURL Withdraw request')
14+
iconSource: '../../../icons/link.png'
15+
16+
property Wallet wallet: Daemon.currentWallet
17+
property RequestDetails requestDetails
18+
19+
padding: 0
20+
needsSystemBarPadding: false
21+
22+
property int walletCanReceive: 0
23+
property int providerMinWithdrawable: parseInt(requestDetails.lnurlData['min_withdrawable_sat'])
24+
property int providerMaxWithdrawable: parseInt(requestDetails.lnurlData['max_withdrawable_sat'])
25+
property int effectiveMinWithdrawable: Math.max(providerMinWithdrawable, 1)
26+
property int effectiveMaxWithdrawable: Math.min(providerMaxWithdrawable, walletCanReceive)
27+
property bool insufficientLiquidity: effectiveMinWithdrawable > walletCanReceive
28+
property bool liquidityWarning: providerMaxWithdrawable > walletCanReceive
29+
30+
property bool amountValid: !dialog.insufficientLiquidity &&
31+
amountBtc.textAsSats.satsInt >= dialog.effectiveMinWithdrawable &&
32+
amountBtc.textAsSats.satsInt <= dialog.effectiveMaxWithdrawable
33+
property bool valid: amountValid
34+
35+
Component.onCompleted: {
36+
dialog.walletCanReceive = wallet.lightningCanReceive.satsInt
37+
}
38+
39+
Connections {
40+
// assign walletCanReceive directly to prevent a binding loop
41+
target: wallet
42+
function onLightningCanReceiveChanged() {
43+
if (!requestDetails.busy) {
44+
// don't assign while busy to prevent the view from changing while receiving
45+
// the incoming payment
46+
dialog.walletCanReceive = wallet.lightningCanReceive.satsInt
47+
}
48+
}
49+
}
50+
51+
ColumnLayout {
52+
width: parent.width
53+
54+
GridLayout {
55+
id: rootLayout
56+
columns: 2
57+
58+
Layout.fillWidth: true
59+
Layout.leftMargin: constants.paddingLarge
60+
Layout.rightMargin: constants.paddingLarge
61+
Layout.bottomMargin: constants.paddingLarge
62+
63+
InfoTextArea {
64+
Layout.columnSpan: 2
65+
Layout.fillWidth: true
66+
compact: true
67+
visible: dialog.insufficientLiquidity
68+
text: qsTr('Too little incoming liquidity to satisfy this withdrawal request.')
69+
+ '\n\n'
70+
+ qsTr('Can receive: %1')
71+
.arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit)
72+
+ '\n'
73+
+ qsTr('Minimum withdrawal amount: %1')
74+
.arg(Config.formatSats(dialog.providerMinWithdrawable) + ' ' + Config.baseUnit)
75+
+ '\n\n'
76+
+ qsTr('Do a submarine swap in the \'Channels\' tab to get more incoming liquidity.')
77+
iconStyle: InfoTextArea.IconStyle.Error
78+
}
79+
80+
InfoTextArea {
81+
Layout.columnSpan: 2
82+
Layout.fillWidth: true
83+
compact: true
84+
visible: !dialog.insufficientLiquidity && dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable
85+
text: qsTr('Amount must be between %1 and %2 %3')
86+
.arg(Config.formatSats(dialog.effectiveMinWithdrawable))
87+
.arg(Config.formatSats(dialog.effectiveMaxWithdrawable))
88+
.arg(Config.baseUnit)
89+
}
90+
91+
InfoTextArea {
92+
Layout.columnSpan: 2
93+
Layout.fillWidth: true
94+
compact: true
95+
visible: dialog.liquidityWarning && !dialog.insufficientLiquidity
96+
text: qsTr('The maximum withdrawable amount (%1) is larger than what your channels can receive (%2).')
97+
.arg(Config.formatSats(dialog.providerMaxWithdrawable) + ' ' + Config.baseUnit)
98+
.arg(Config.formatSats(dialog.walletCanReceive) + ' ' + Config.baseUnit)
99+
+ ' '
100+
+ qsTr('You may need to do a submarine swap to increase your incoming liquidity.')
101+
iconStyle: InfoTextArea.IconStyle.Warn
102+
}
103+
104+
Label {
105+
text: qsTr('Provider')
106+
color: Material.accentColor
107+
}
108+
Label {
109+
Layout.fillWidth: true
110+
text: requestDetails.lnurlData['domain']
111+
}
112+
Label {
113+
text: qsTr('Description')
114+
color: Material.accentColor
115+
visible: requestDetails.lnurlData['default_description']
116+
}
117+
Label {
118+
Layout.fillWidth: true
119+
text: requestDetails.lnurlData['default_description']
120+
visible: requestDetails.lnurlData['default_description']
121+
wrapMode: Text.Wrap
122+
}
123+
124+
Label {
125+
text: qsTr('Amount')
126+
color: Material.accentColor
127+
}
128+
129+
RowLayout {
130+
Layout.fillWidth: true
131+
BtcField {
132+
id: amountBtc
133+
Layout.preferredWidth: rootLayout.width / 3
134+
text: Config.formatSatsForEditing(dialog.effectiveMaxWithdrawable)
135+
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
136+
color: Material.foreground // override gray-out on disabled
137+
fiatfield: amountFiat
138+
}
139+
Label {
140+
text: Config.baseUnit
141+
color: Material.accentColor
142+
}
143+
}
144+
145+
Item { visible: Daemon.fx.enabled; Layout.preferredWidth: 1; Layout.preferredHeight: 1 }
146+
147+
RowLayout {
148+
visible: Daemon.fx.enabled
149+
FiatField {
150+
id: amountFiat
151+
Layout.preferredWidth: rootLayout.width / 3
152+
btcfield: amountBtc
153+
enabled: !dialog.insufficientLiquidity && (dialog.providerMinWithdrawable != dialog.providerMaxWithdrawable)
154+
color: Material.foreground
155+
}
156+
Label {
157+
text: Daemon.fx.fiatCurrency
158+
color: Material.accentColor
159+
}
160+
}
161+
}
162+
163+
FlatButton {
164+
Layout.topMargin: constants.paddingLarge
165+
Layout.fillWidth: true
166+
text: qsTr('Withdraw...')
167+
icon.source: '../../icons/confirmed.png'
168+
enabled: valid && !requestDetails.busy
169+
onClicked: {
170+
var satsAmount = amountBtc.textAsSats.satsInt;
171+
requestDetails.lnurlRequestWithdrawal(satsAmount);
172+
dialog.close();
173+
}
174+
}
175+
}
176+
}

electrum/gui/qml/components/SendDialog.qml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ElDialog {
1212
id: dialog
1313

1414
property InvoiceParser invoiceParser
15+
property PIResolver piResolver
1516

1617
signal txFound(data: string)
1718
signal channelBackupFound(data: string)
@@ -36,7 +37,7 @@ ElDialog {
3637
} else if (Daemon.currentWallet.isValidChannelBackup(data)) {
3738
channelBackupFound(data)
3839
} else {
39-
invoiceParser.recipient = data
40+
piResolver.recipient = data
4041
}
4142
}
4243

@@ -57,8 +58,8 @@ ElDialog {
5758
Layout.fillHeight: true
5859

5960
hint: Daemon.currentWallet.isLightning
60-
? qsTr('Scan an Invoice, an Address, an LNURL-pay, a PSBT or a Channel Backup')
61-
: qsTr('Scan an Invoice, an Address, an LNURL-pay or a PSBT')
61+
? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
62+
: qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
6263

6364
onFoundText: (data) => {
6465
dialog.dispatch(data)
@@ -71,7 +72,7 @@ ElDialog {
7172
FlatButton {
7273
Layout.fillWidth: true
7374
Layout.preferredWidth: 1
74-
enabled: !invoiceParser.busy
75+
enabled: !invoiceParser.busy && !piResolver.busy
7576
icon.source: '../../icons/copy_bw.png'
7677
text: qsTr('Paste')
7778
onClicked: {

electrum/gui/qml/components/WalletMainView.qml

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,16 @@ Item {
3636
function openSendDialog() {
3737
// Qt based send dialog if not on android
3838
if (!AppController.isAndroid()) {
39-
_sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser})
39+
_sendDialog = qtSendDialog.createObject(mainView, {invoiceParser: invoiceParser, piResolver: piResolver})
4040
_sendDialog.open()
4141
return
4242
}
4343

4444
// Android based send dialog if on android
4545
var scanner = app.scanDialog.createObject(mainView, {
4646
hint: Daemon.currentWallet.isLightning
47-
? qsTr('Scan an Invoice, an Address, an LNURL-pay, a PSBT or a Channel Backup')
48-
: qsTr('Scan an Invoice, an Address, an LNURL-pay or a PSBT')
47+
? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
48+
: qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
4949
})
5050
scanner.onFoundText.connect(function(data) {
5151
data = data.trim()
@@ -61,7 +61,7 @@ Item {
6161
})
6262
dialog.open()
6363
} else {
64-
invoiceParser.recipient = data
64+
piResolver.recipient = data
6565
}
6666
//scanner.destroy() // TODO
6767
})
@@ -362,7 +362,7 @@ Item {
362362
Layout.preferredWidth: 1
363363
icon.source: '../../icons/tab_send.png'
364364
text: qsTr('Send')
365-
enabled: !invoiceParser.busy
365+
enabled: !invoiceParser.busy && !piResolver.busy && !requestDetails.busy
366366
onClicked: openSendDialog()
367367
onPressAndHold: {
368368
Config.userKnowsPressAndHold = true
@@ -373,6 +373,48 @@ Item {
373373
}
374374
}
375375

376+
PIResolver {
377+
id: piResolver
378+
wallet: Daemon.currentWallet
379+
380+
onResolveError: (code, message) => {
381+
var dialog = app.messageDialog.createObject(app, {
382+
title: qsTr('Error'),
383+
iconSource: Qt.resolvedUrl('../../icons/warning.png'),
384+
text: message
385+
})
386+
dialog.open()
387+
}
388+
389+
onInvoiceResolved: (pi) => {
390+
invoiceParser.fromResolvedPaymentIdentifier(pi)
391+
}
392+
393+
onRequestResolved: (pi) => {
394+
requestDetails.fromResolvedPaymentIdentifier(pi)
395+
}
396+
}
397+
398+
RequestDetails {
399+
id: requestDetails
400+
wallet: Daemon.currentWallet
401+
onNeedsLNURLUserInput: {
402+
closeSendDialog()
403+
var dialog = lnurlWithdrawDialog.createObject(app, {
404+
requestDetails: requestDetails
405+
})
406+
dialog.open()
407+
}
408+
onLnurlError: (code, message) => {
409+
var dialog = app.messageDialog.createObject(app, {
410+
title: qsTr('Error'),
411+
iconSource: Qt.resolvedUrl('../../icons/warning.png'),
412+
text: message
413+
})
414+
dialog.open()
415+
}
416+
}
417+
376418
Invoice {
377419
id: invoice
378420
wallet: Daemon.currentWallet
@@ -420,12 +462,16 @@ Item {
420462
})
421463
dialog.open()
422464
}
423-
424465
onLnurlRetrieved: {
425466
closeSendDialog()
426-
var dialog = lnurlPayDialog.createObject(app, {
427-
invoiceParser: invoiceParser
428-
})
467+
if (invoiceParser.invoiceType === Invoice.Type.LNURLPayRequest) {
468+
var dialog = lnurlPayDialog.createObject(app, {
469+
invoiceParser: invoiceParser
470+
})
471+
} else {
472+
console.log("Unsupported LNURL type:", invoiceParser.invoiceType)
473+
return
474+
}
429475
dialog.open()
430476
}
431477
onLnurlError: (code, message) => {
@@ -451,7 +497,7 @@ Item {
451497
_intentUri = uri
452498
return
453499
}
454-
invoiceParser.recipient = uri
500+
piResolver.recipient = uri
455501
}
456502
}
457503

@@ -460,7 +506,7 @@ Item {
460506
function onWalletLoaded() {
461507
infobanner.hide() // start hidden when switching wallets
462508
if (_intentUri) {
463-
invoiceParser.recipient = _intentUri
509+
piResolver.recipient = _intentUri
464510
_intentUri = ''
465511
}
466512
}
@@ -739,6 +785,16 @@ Item {
739785
}
740786
}
741787

788+
Component {
789+
id: lnurlWithdrawDialog
790+
LnurlWithdrawRequestDialog {
791+
width: parent.width * 0.9
792+
anchors.centerIn: parent
793+
794+
onClosed: destroy()
795+
}
796+
}
797+
742798
Component {
743799
id: otpDialog
744800
OtpDialog {

electrum/gui/qml/qeapp.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .qefx import QEFX
3535
from .qetxfinalizer import QETxFinalizer, QETxRbfFeeBumper, QETxCpfpFeeBumper, QETxCanceller, QETxSweepFinalizer, FeeSlider
3636
from .qeinvoice import QEInvoice, QEInvoiceParser
37+
from .qepiresolver import QEPIResolver
3738
from .qerequestdetails import QERequestDetails
3839
from .qetypes import QEAmount, QEBytes
3940
from .qeaddressdetails import QEAddressDetails
@@ -489,6 +490,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: '
489490
qmlRegisterType(QEQRScanner, 'org.electrum', 1, 0, 'QRScanner')
490491
qmlRegisterType(QEFX, 'org.electrum', 1, 0, 'FX')
491492
qmlRegisterType(QETxFinalizer, 'org.electrum', 1, 0, 'TxFinalizer')
493+
qmlRegisterType(QEPIResolver, 'org.electrum', 1, 0, 'PIResolver')
492494
qmlRegisterType(QEInvoice, 'org.electrum', 1, 0, 'Invoice')
493495
qmlRegisterType(QEInvoiceParser, 'org.electrum', 1, 0, 'InvoiceParser')
494496
qmlRegisterType(QEAddressDetails, 'org.electrum', 1, 0, 'AddressDetails')

0 commit comments

Comments
 (0)