Skip to content

Commit 4149589

Browse files
committed
lnurl: implement LNURL-withdraw
adds handling of lnurl-withdraw payment identifiers which allow users to withdraw bitcoin from a service by scanning a qr code or pasting the lnurl-w code as "sending" address.
1 parent 5fad4bf commit 4149589

File tree

9 files changed

+634
-64
lines changed

9 files changed

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

electrum/gui/qml/components/SendDialog.qml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ ElDialog {
5757
Layout.fillHeight: true
5858

5959
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')
60+
? qsTr('Scan an Invoice, an Address, an LNURL, a PSBT or a Channel Backup')
61+
: qsTr('Scan an Invoice, an Address, an LNURL or a PSBT')
6262
onFound: dialog.dispatch(scanData)
6363
}
6464

electrum/gui/qml/components/WalletMainView.qml

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ Item {
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.onFound.connect(function() {
5151
var data = scanner.scanData
@@ -419,9 +419,18 @@ Item {
419419

420420
onLnurlRetrieved: {
421421
closeSendDialog()
422-
var dialog = lnurlPayDialog.createObject(app, {
423-
invoiceParser: invoiceParser
424-
})
422+
if (invoiceParser.invoiceType === Invoice.Type.LNURLPayRequest) {
423+
var dialog = lnurlPayDialog.createObject(app, {
424+
invoiceParser: invoiceParser
425+
})
426+
} else if (invoiceParser.invoiceType === Invoice.Type.LNURLWithdrawRequest) {
427+
var dialog = lnurlWithdrawDialog.createObject(app, {
428+
invoiceParser: invoiceParser
429+
})
430+
} else {
431+
console.log("Unsupported LNURL type:", invoiceParser.invoiceType)
432+
return
433+
}
425434
dialog.open()
426435
}
427436
onLnurlError: (code, message) => {
@@ -735,6 +744,16 @@ Item {
735744
}
736745
}
737746

747+
Component {
748+
id: lnurlWithdrawDialog
749+
LnurlWithdrawRequestDialog {
750+
width: parent.width * 0.9
751+
anchors.centerIn: parent
752+
753+
onClosed: destroy()
754+
}
755+
}
756+
738757
Component {
739758
id: otpDialog
740759
OtpDialog {

electrum/gui/qml/qeinvoice.py

Lines changed: 88 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from electrum.transaction import PartialTxOutput, TxOutput
1616
from electrum.lnutil import format_short_channel_id
17+
from electrum.lnurl import LNURLData, LNURL3Data, LNURL6Data, request_lnurl_withdraw_callback, LNURLError
1718
from electrum.bitcoin import COIN, address_to_script
1819
from electrum.paymentrequest import PaymentRequest
1920
from electrum.payment_identifier import PaymentIdentifier, PaymentIdentifierState, PaymentIdentifierType
@@ -32,6 +33,7 @@ class Type(IntEnum):
3233
OnchainInvoice = 0
3334
LightningInvoice = 1
3435
LNURLPayRequest = 2
36+
LNURLWithdrawRequest = 3
3537

3638
@pyqtEnum
3739
class Status(IntEnum):
@@ -477,7 +479,7 @@ def lnurlData(self):
477479

478480
@pyqtProperty(bool, notify=lnurlRetrieved)
479481
def isLnurlPay(self):
480-
return self._lnurlData is not None
482+
return self._lnurlData is not None and self.invoiceType == QEInvoice.Type.LNURLPayRequest
481483

482484
@pyqtProperty(bool, notify=busyChanged)
483485
def busy(self):
@@ -512,6 +514,12 @@ def setValidLNURLPayRequest(self):
512514
self._effectiveInvoice = None
513515
self.invoiceChanged.emit()
514516

517+
def setValidLNURLWithdrawRequest(self):
518+
self._logger.debug('setValidLNURLWithdrawRequest')
519+
self.setInvoiceType(QEInvoice.Type.LNURLWithdrawRequest)
520+
self._effectiveInvoice = None
521+
self.invoiceChanged.emit()
522+
515523
def create_onchain_invoice(self, outputs, message, payment_request, uri):
516524
return self._wallet.wallet.create_invoice(
517525
outputs=outputs,
@@ -542,7 +550,7 @@ def validateRecipient(self, recipient):
542550
self._pi = PaymentIdentifier(self._wallet.wallet, recipient)
543551
if not self._pi.is_valid() or self._pi.type not in [PaymentIdentifierType.SPK, PaymentIdentifierType.BIP21,
544552
PaymentIdentifierType.BIP70, PaymentIdentifierType.BOLT11,
545-
PaymentIdentifierType.LNURLP,
553+
PaymentIdentifierType.LNURL,
546554
PaymentIdentifierType.EMAILLIKE,
547555
PaymentIdentifierType.DOMAINLIKE]:
548556
self.validationError.emit('unknown', _('Unknown invoice'))
@@ -561,7 +569,11 @@ def _update_from_payment_identifier(self):
561569
self.resolve_pi()
562570
return
563571

564-
if self._pi.type in [PaymentIdentifierType.LNURLP, PaymentIdentifierType.LNADDR]:
572+
if self._pi.type in [
573+
PaymentIdentifierType.LNURLP,
574+
PaymentIdentifierType.LNURLW,
575+
PaymentIdentifierType.LNADDR,
576+
]:
565577
self.on_lnurl(self._pi.lnurl_data)
566578
return
567579

@@ -621,7 +633,7 @@ def on_finished(pi: PaymentIdentifier):
621633
if pi.is_error():
622634
if pi.type in [PaymentIdentifierType.EMAILLIKE, PaymentIdentifierType.DOMAINLIKE]:
623635
msg = _('Could not resolve address')
624-
elif pi.type == PaymentIdentifierType.LNURLP:
636+
elif pi.type == PaymentIdentifierType.LNURL:
625637
msg = _('Could not resolve LNURL') + "\n\n" + pi.get_error()
626638
elif pi.type == PaymentIdentifierType.BIP70:
627639
msg = _('Could not resolve BIP70 payment request: {}').format(pi.error)
@@ -636,26 +648,40 @@ def on_finished(pi: PaymentIdentifier):
636648

637649
self._pi.resolve(on_finished=on_finished)
638650

639-
def on_lnurl(self, lnurldata):
651+
def on_lnurl(self, lnurldata: LNURLData):
640652
self._logger.debug('on_lnurl')
641653
self._logger.debug(f'{repr(lnurldata)}')
642654

643-
self._lnurlData = {
644-
'domain': urlparse(lnurldata.callback_url).netloc,
645-
'callback_url': lnurldata.callback_url,
646-
'min_sendable_sat': lnurldata.min_sendable_sat,
647-
'max_sendable_sat': lnurldata.max_sendable_sat,
648-
'metadata_plaintext': lnurldata.metadata_plaintext,
649-
'comment_allowed': lnurldata.comment_allowed
650-
}
651-
self.setValidLNURLPayRequest()
655+
if isinstance(lnurldata, LNURL6Data):
656+
self._lnurlData = {
657+
'domain': urlparse(lnurldata.callback_url).netloc,
658+
'callback_url': lnurldata.callback_url,
659+
'min_sendable_sat': lnurldata.min_sendable_sat,
660+
'max_sendable_sat': lnurldata.max_sendable_sat,
661+
'metadata_plaintext': lnurldata.metadata_plaintext,
662+
'comment_allowed': lnurldata.comment_allowed,
663+
}
664+
self.setValidLNURLPayRequest()
665+
elif isinstance(lnurldata, LNURL3Data):
666+
self._lnurlData = {
667+
'domain': urlparse(lnurldata.callback_url).netloc,
668+
'callback_url': lnurldata.callback_url,
669+
'min_withdrawable_sat': lnurldata.min_withdrawable_sat,
670+
'max_withdrawable_sat': lnurldata.max_withdrawable_sat,
671+
'default_description': lnurldata.default_description,
672+
'k1': lnurldata.k1,
673+
}
674+
self.setValidLNURLWithdrawRequest()
675+
else:
676+
raise NotImplementedError(f"Invalid lnurl type in on_lnurl {lnurldata=}")
652677
self.lnurlRetrieved.emit()
653678

654679
@pyqtSlot()
655680
@pyqtSlot(str)
656681
def lnurlGetInvoice(self, comment=None):
657682
assert self._lnurlData
658683
assert self._pi.need_finalize()
684+
assert self.invoiceType == QEInvoice.Type.LNURLPayRequest
659685
self._logger.debug(f'{repr(self._lnurlData)}')
660686

661687
amount = self.amountOverride.satsInt
@@ -680,6 +706,54 @@ def on_finished(pi):
680706

681707
self._pi.finalize(amount_sat=amount, comment=comment, on_finished=on_finished)
682708

709+
@pyqtSlot()
710+
def lnurlRequestWithdrawal(self):
711+
assert self._lnurlData
712+
assert self.invoiceType == QEInvoice.Type.LNURLWithdrawRequest
713+
self._logger.debug(f'{repr(self._lnurlData)}')
714+
715+
amount_sat = self.amountOverride.satsInt
716+
717+
try:
718+
key = self.wallet.wallet.create_request(
719+
amount_sat=amount_sat,
720+
message=self._lnurlData.get('default_description', ''),
721+
exp_delay=120,
722+
address=None,
723+
)
724+
req = self.wallet.wallet.get_request(key)
725+
_lnaddr, b11_invoice = self.wallet.wallet.lnworker.get_bolt11_invoice(
726+
payment_hash=req.payment_hash,
727+
amount_msat=req.get_amount_msat(),
728+
message=req.get_message(),
729+
expiry=req.exp,
730+
fallback_address=None
731+
)
732+
except Exception as e:
733+
self._logger.exception('')
734+
self.lnurlError.emit(
735+
'lnurl',
736+
_("Failed to create payment request for withdrawal: {}").format(str(e))
737+
)
738+
return
739+
740+
self._busy = True
741+
self.busyChanged.emit()
742+
743+
coro = request_lnurl_withdraw_callback(
744+
callback_url=self._lnurlData['callback_url'],
745+
k1=self._lnurlData['k1'],
746+
bolt_11=b11_invoice,
747+
)
748+
try:
749+
Network.run_from_another_thread(coro)
750+
except LNURLError as e:
751+
self.lnurlError.emit('lnurl', str(e))
752+
753+
self._busy = False
754+
self.busyChanged.emit()
755+
756+
683757
def on_lnurl_invoice(self, orig_amount, invoice):
684758
self._logger.debug('on_lnurl_invoice')
685759
self._logger.debug(f'{repr(invoice)}')

0 commit comments

Comments
 (0)