Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d23fe87
segwit_addr: bech32 decode without checksum option
accumulator May 28, 2024
e56aa89
lnwire: add bolt12 types to onion_wire
accumulator Jul 8, 2024
17b82eb
lnmsg: add new primitive type `bip340sig`, add tlv merkle root calcul…
accumulator May 28, 2024
fdd5ce1
bolt12: add encode/decode functions and tests, request_invoice, invoi…
accumulator Nov 14, 2024
c0b5a3c
payment_identifier: initial support for bolt12 offers
accumulator May 28, 2024
5eb7e49
bolt12: route blinding test
accumulator Mar 9, 2025
9119dd8
wip: temporarily piggy-back bolt12 invoices in Invoice.lightning_invo…
accumulator Jun 25, 2025
58f20ff
onion_message: factor out get_blinded_paths_to_me from get_blinded_re…
accumulator Oct 8, 2025
9613135
bolt12: pass bolt12_invoice along lnworker, lnpeer,
accumulator Feb 20, 2025
7ccd2f5
add unit test: blinded payment onion
ecdsa Oct 7, 2025
e89bd3b
move blinding_privkey from onion_message to lnonion
accumulator Dec 1, 2025
4af21bd
lnmsg: fix parsing of nested complex types where the leaf objects
accumulator Nov 14, 2025
c2e77fd
bolt12: add initial cli commands add_offer, get_offer and list_offers
accumulator Oct 8, 2025
6d66062
bolt12: handle 'invoice_request' onion message payloads,
accumulator Oct 8, 2025
a06ec4f
lnutil: add blinded path feature flag
accumulator May 9, 2024
972dd99
lnpeer: update_add_htlc: take blinding into account for HTLCs arrivin…
accumulator Nov 11, 2025
774bb0e
wallet: return bech32 encoded bolt12 invoice in export_invoice()
accumulator Nov 13, 2025
c80926d
lnutil: define LN_FEATURES_ASSUMED according to BOLT9 and add LnFeatu…
accumulator Nov 18, 2025
cca9578
bolt12: check invoice features
accumulator Nov 18, 2025
95f54f7
onion_message: verify LNPeerAddr returned as hint in NoRouteFound
accumulator Nov 13, 2025
59c9748
onion_message: let caller specify considered channels for blinded paths.
accumulator Nov 18, 2025
dbea3c5
bolt12: create invoices with blinded paths suitable to receive paymen…
accumulator Nov 18, 2025
e955a8c
onion_message: iterate blinded paths for onion message requests
accumulator Nov 20, 2025
c09fafe
bolt12: pass invoice_expiry to verify_request_and_create_invoice(),
accumulator Nov 19, 2025
eace956
commands: add issuer option to offer
accumulator Nov 18, 2025
33cc28c
bolt12: validate constraint offer in invreq
accumulator Nov 21, 2025
e0fa80d
bolt12: send invoice_error reply when Bolt12InvoiceError is raised
accumulator Nov 21, 2025
9c97da4
bolt12: tests verify_request_and_create_invoice()
accumulator Nov 21, 2025
12c3b23
bolt12: use specific exception for invreq validation,
accumulator Nov 24, 2025
12bc4c4
onion_message: perform a direct peer connection for sending request, …
accumulator Nov 24, 2025
33a24fd
payment_identifier: bolt12 request_invoice exceptions other than Time…
accumulator Nov 26, 2025
1066546
onion_message: raise specific exceptions if blinded path could not be…
accumulator Nov 26, 2025
ca0cfdf
qt: initial support for bolt12 offers
accumulator Oct 8, 2025
637cdee
qml: initial support for bolt12 offers
accumulator Jul 16, 2024
0945395
remove is_onionmessage from process_onion_message, make it implicit o…
accumulator Dec 1, 2025
2901ecd
commands: add decode_bolt12 command
accumulator Nov 5, 2025
ff39002
payment_identifier: properly lock amount once resolved to invoice,
accumulator Dec 1, 2025
ff2cf3d
qml: reset _key in QEInvoice on clear(), validate bolt12 recipients
accumulator Dec 1, 2025
7994821
bolt12: persist offers
accumulator Dec 2, 2025
8be3cb6
lnutil: add to_tlv_bytes() to LnFeatures
accumulator Dec 4, 2025
09bb5fb
bolt12: generate unique path_id for each blinded path,
accumulator Dec 3, 2025
d2af0a5
lnworker: add ONION_MESSAGE and ROUTE_BLINDING features to LNWALLET_F…
accumulator Dec 5, 2025
e9dff74
add regtest: tests.regtest.TestLightningABC.test_bolt12
ecdsa Dec 6, 2025
d5359f3
lnworker: don't raise on non-existing id when delete_offer
accumulator Dec 15, 2025
7d60511
lnonion: support payment path blinding
accumulator Dec 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
479 changes: 479 additions & 0 deletions electrum/bolt12.py

Large diffs are not rendered by default.

108 changes: 106 additions & 2 deletions electrum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,16 @@

import electrum_ecc as ecc

from . import util
from . import util, bolt12
from .lnmsg import OnionWireSerializer
from .lnworker import LN_P2P_NETWORK_TIMEOUT
from .logging import Logger
from .onion_message import create_blinded_path, send_onion_message_to
from .segwit_addr import bech32_encode, Encoding, convertbits, INVALID_BECH32
from .submarine_swaps import NostrTransport
from .util import (
bfh, json_decode, json_normalize, is_hash256_str, is_hex_str, to_bytes, parse_max_spend, to_decimal,
UserFacingException, InvalidPassword
UserFacingException, InvalidPassword, json_encode
)
from . import bitcoin
from .bitcoin import is_address, hash_160, COIN
Expand Down Expand Up @@ -1375,6 +1376,93 @@ async def add_request(self, amount, memo='', expiry=3600, lightning=False, force
req = wallet.get_request(key)
return wallet.export_request(req)

@command('wnl')
async def pay_bolt12_offer(
self,
offer: str,
amount: Optional[Decimal] = None,
wallet: Abstract_Wallet = None
):
"""Retrieve an invoice from a bolt12 offer, and pay that invoice

arg:str:offer:bolt-12 offer (bech32)
arg:decimal:amount:Amount to send
"""
amount_msat = satoshis(amount) * 1000 if amount else None
bolt12_offer = bolt12.decode_offer(offer)
offer_amount = bolt12_offer.get('offer_amount')
offer_amount_msat = offer_amount.get('amount')
if amount_msat and offer_amount_msat:
assert amount_msat == offer_amount_msat
lnworker = wallet.lnworker
bolt12_invoice, bolt12_invoice_tlv = await bolt12.request_invoice(lnworker, bolt12_offer, amount_msat or offer_amount_msat)
invoice = Invoice.from_bolt12_invoice_tlv(bolt12_invoice_tlv)
success, log = await lnworker.pay_invoice(invoice)
return {
'success': success,
'log': [x.formatted_tuple() for x in log]
}

Comment thread
accumulator marked this conversation as resolved.
@command('wnl')
async def add_offer(
self,
amount: Optional[Decimal] = None,
memo: Optional[str] = '',
expiry: Optional[int] = 3600,
issuer: Optional[str] = None,
wallet: Abstract_Wallet = None
):
"""Create a bolt12 offer.

arg:decimal:amount:Requested amount (in btc)
arg:str:memo:Description of the request
arg:int:expiry:Time in seconds.
arg:str:issuer:Issuer string
"""
amount_msat = satoshis(amount) * 1000 if amount else None
expiry = int(expiry) if expiry else None
key = wallet.lnworker.create_offer(amount_msat=amount_msat, memo=memo, expiry=expiry, issuer=issuer)
offer = wallet.lnworker.get_offer(key)

return {
'id': key.hex(),
'offer': offer.offer_bech32
}

@command('wl')
async def get_offer(self, offer_id, wallet: Abstract_Wallet = None):
"""
retrieve bolt12 offer
arg:str:offer_id:the offer id
"""
id_ = bfh(offer_id)
offer = wallet.lnworker.get_offer(id_)
return {
'id': offer_id,
'offer': offer.offer_bech32
} if offer else {}

@command('wl')
async def delete_offer(self, offer_id, wallet: Abstract_Wallet = None):
"""
delete bolt12 offer
arg:str:offer_id:the offer id
"""
wallet.lnworker.delete_offer(bfh(offer_id))

@command('wl')
async def list_offers(self, wallet: Abstract_Wallet = None):
"""
list bolt12 offers
"""
result = []
for offer_id, offer in wallet.lnworker.offers.items():
result.append({
'id': offer_id.hex(),
'offer': offer.offer_bech32
})
return result

@command('wnl')
async def add_hold_invoice(
self,
Expand Down Expand Up @@ -2214,6 +2302,22 @@ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet:

return encoded_blinded_path.hex()

@command('')
async def decode_bolt12(self, bech32: str):
"""Decode bolt12 object

arg:str:bech32:invoice or offer
"""
dec = bolt12.bech32_decode(bech32, ignore_long_length=True, with_checksum=False)
if dec == INVALID_BECH32:
raise Exception('invalid bech32')
d = {
'lni': bolt12.decode_invoice,
'lno': bolt12.decode_offer,
'lnr': bolt12.decode_invoice_request,
}[dec.hrp](bech32)
return json_encode(d)


def plugin_command(s, plugin_name):
"""Decorator to register a cli command inside a plugin. To be used within a commands.py file
Expand Down
Binary file added electrum/gui/icons/bolt12.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
146 changes: 146 additions & 0 deletions electrum/gui/qml/components/Bolt12OfferDialog.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material

import org.electrum 1.0

import "controls"

ElDialog {
id: dialog

title: qsTr('BOLT12 Offer')
iconSource: '../../../icons/bolt12.png'

property InvoiceParser invoiceParser

padding: 0

property bool commentValid: true // TODO?
property bool amountValid: amountBtc.textAsSats.satsInt > 0
property bool valid: commentValid && amountValid

ColumnLayout {
width: parent.width

spacing: 0

GridLayout {
id: rootLayout
columns: 2

Layout.fillWidth: true
Layout.leftMargin: constants.paddingLarge
Layout.rightMargin: constants.paddingLarge
Layout.bottomMargin: constants.paddingLarge

// qml quirk; first cells cannot colspan without messing up the grid width
Item { Layout.fillWidth: true; Layout.preferredWidth: 1; Layout.preferredHeight: 1 }
Item { Layout.fillWidth: true; Layout.preferredWidth: 1; Layout.preferredHeight: 1 }

Label {
Layout.columnSpan: 2
text: qsTr('Issuer')
color: Material.accentColor
visible: 'issuer' in invoiceParser.offerData
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: 'issuer' in invoiceParser.offerData
Label {
width: parent.width
wrapMode: Text.Wrap
text: invoiceParser.offerData['issuer']
}
}
Label {
Layout.columnSpan: 2
Layout.fillWidth: true
text: qsTr('Description')
color: Material.accentColor
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
Label {
width: parent.width
text: invoiceParser.offerData['description']
wrapMode: Text.Wrap
}
}
Label {
Layout.columnSpan: 2
text: qsTr('Amount')
color: Material.accentColor
}

RowLayout {
Layout.columnSpan: 2
Layout.fillWidth: true
BtcField {
id: amountBtc
Layout.preferredWidth: rootLayout.width /3
text: 'amount' in invoiceParser.offerData
? Config.formatSatsForEditing(invoiceParser.offerData['amount']/1000)
: ''
readOnly: 'amount' in invoiceParser.offerData
color: Material.foreground // override gray-out on disabled
fiatfield: amountFiat
onTextAsSatsChanged: {
invoiceParser.amountOverride = textAsSats
}
}
Label {
text: Config.baseUnit
color: Material.accentColor
}
}

RowLayout {
Layout.columnSpan: 2
visible: Daemon.fx.enabled
FiatField {
id: amountFiat
Layout.preferredWidth: rootLayout.width / 3
btcfield: amountBtc
readOnly: btcfield.readOnly
}
Label {
text: Daemon.fx.fiatCurrency
color: Material.accentColor
}
}

Label {
Layout.columnSpan: 2
text: qsTr('Note')
color: Material.accentColor
}
ElTextArea {
id: note
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.minimumHeight: 100
wrapMode: TextEdit.Wrap
placeholderText: qsTr('Enter an (optional) message for the receiver')
// TODO: max 100 chars is arbitrary, not sure what the max size is
color: text.length > 100 ? constants.colorError : Material.foreground
}
}

FlatButton {
Layout.topMargin: constants.paddingLarge
Layout.fillWidth: true
text: qsTr('Pay')
icon.source: '../../icons/confirmed.png'
enabled: valid
onClicked: {
invoiceParser.requestInvoiceFromOffer(note.text)
dialog.close()
}
}
}

}
60 changes: 59 additions & 1 deletion electrum/gui/qml/components/InvoiceDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ ElDialog {
signal doPay
signal invoiceAmountChanged

title: invoice.invoiceType == Invoice.OnchainInvoice ? qsTr('On-chain Invoice') : qsTr('Lightning Invoice')
title: invoice.invoiceType == Invoice.OnchainInvoice
? qsTr('On-chain Invoice')
: invoice.lnprops.is_bolt12
? qsTr('BOLT12 Invoice')
: qsTr('Lightning Invoice')
iconSource: Qt.resolvedUrl('../../icons/tab_send.png')

padding: 0
Expand Down Expand Up @@ -106,6 +110,30 @@ ElDialog {
}
}

Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
text: qsTr('Issuer')
visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != ''
color: Material.accentColor
}

TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true

visible: 'issuer' in invoice.lnprops && invoice.lnprops.issuer != ''
leftPadding: constants.paddingMedium

Label {
text: 'issuer' in invoice.lnprops ? invoice.lnprops.issuer : ''
width: parent.width
font.pixelSize: constants.fontSizeXLarge
wrapMode: Text.Wrap
elide: Text.ElideRight
}
}

Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
Expand Down Expand Up @@ -420,6 +448,36 @@ ElDialog {
}
}

Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
visible: 'blinded_paths' in invoice.lnprops && invoice.lnprops.blinded_paths.length
text: qsTr('Blinded paths')
color: Material.accentColor
}

Repeater {
visible: 'blinded_paths' in invoice.lnprops && invoice.lnprops.blinded_paths.length
model: invoice.lnprops.blinded_paths

TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true

RowLayout {
width: parent.width

Label {
Layout.fillWidth: true
text: qsTr('via %1 (%2 hops)')
.arg(modelData.first_node)
.arg(modelData.path_length)
wrapMode: Text.Wrap
}
}
}
}

Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
Expand Down
Loading