Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
03bebd8
lnonion: support payment path blinding
accumulator Nov 11, 2025
5753992
lnworker: add ONION_MESSAGE and ROUTE_BLINDING features to LNWALLET_F…
accumulator Dec 5, 2025
a0273fe
onion_message: perform a direct peer connection for sending request
accumulator Nov 24, 2025
26acdf8
lnpeer: add send_onion_message method
f321x May 4, 2026
2cc7ff2
PaymentIdentifier: _do_finalize: don't catch all exc
f321x Mar 23, 2026
730f689
lnonion: introduce BlindedPath dataclasses
f321x Apr 14, 2026
1a90310
bolt12: add BOLT12 classes, encode/decode
f321x Apr 14, 2026
3074664
tests: add bolt12 string formatting test vector
f321x Feb 10, 2026
4174a71
tests: add bolt12 offer test vector
f321x Feb 12, 2026
5628920
commands: add decode_bolt12 command
accumulator Nov 5, 2025
f05a16e
bolt12/lnwallet: implement requesting and handling invoices
f321x Apr 15, 2026
d6b97e6
test_lnwallet: add unittests for bolt12 invoice flow
f321x Apr 16, 2026
eeca23e
invoices: make `Invoice` handle bolt 12 invoices
f321x Apr 14, 2026
e5e6e86
payment_identifier: initial support for bolt12 offers
accumulator May 28, 2024
d181a8c
tests: test_payment_identifier: test bolt12 pi
f321x Feb 19, 2026
60b7a3c
bolt12: route blinding test
accumulator Mar 9, 2025
cf0a8d5
lnonion: implement hops data assembly for blinded payments
accumulator May 7, 2026
f3bb35e
lnutil, lnonion: add RoutingInfo abstractions
f321x Apr 14, 2026
2c9cd44
lnutil: add final cltv delta offset
f321x Jun 4, 2026
f228a14
LNWallet/bolt12: implement sending blinded payments
f321x Apr 14, 2026
38708ff
add unit test: blinded payment onion
ecdsa Oct 7, 2025
ff07091
bolt12/lnworker: implement creating offer
accumulator Oct 8, 2025
e7e7c32
test_lnwallet: unittest offer creation
f321x Apr 16, 2026
4db33a1
bolt12: handle 'invoice_request' onion message payloads
f321x Oct 8, 2025
34498b6
test_lnwallet: test bolt12 invoice request handling
f321x Apr 16, 2026
9b6712a
wallet: return bech32 encoded bolt12 invoice in export_invoice()
accumulator Nov 13, 2025
053f3c2
add regtest: tests.regtest.TestLightningABC.test_bolt12
ecdsa Dec 6, 2025
c997c7f
lnutil: add blinded path method to PaymentFeeBudget
f321x Mar 17, 2026
9a2799a
trampoline: change invoice_features tlv from u64 to bytes
f321x Mar 18, 2026
d6994ee
trampoline: add blinded payment support
f321x Apr 1, 2026
124fec3
tests: test_lnrouter: add trampoline route construction tests
f321x Jun 1, 2026
31d6987
qt: initial support for bolt12 offers
accumulator Apr 1, 2026
b924ee1
qml: initial support for bolt12 offers
accumulator Apr 1, 2026
5419870
wip: qml: bolt12: implement GUI for creating offer
f321x Jun 8, 2026
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
747 changes: 747 additions & 0 deletions electrum/bolt12.py

Large diffs are not rendered by default.

95 changes: 92 additions & 3 deletions electrum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import io
import sys
import datetime
import dataclasses
import time
import argparse
import json
Expand All @@ -43,15 +44,18 @@

import electrum_ecc as ecc

from . import util
from . import util, bolt12
from .bolt12 import BOLT12Invoice, BOLT12Offer, BOLT12InvoiceRequest
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 .lnonion import BlindedPath
from .segwit_addr import 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 @@ -1384,6 +1388,73 @@ 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,
payer_note: Optional[str] = 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
arg:str:payer_note:Text note for the recipient
"""
amount_msat = satoshis(amount) * 1000 if amount else None
bolt12_offer = bolt12.BOLT12Offer.decode(offer)
offer_amount_msat = bolt12_offer.offer_amount
if offer_amount_msat and amount_msat and offer_amount_msat != amount_msat:
raise ValueError(f"{amount_msat=} different than {offer_amount_msat=}")
send_amount = offer_amount_msat or amount_msat
if not send_amount:
raise ValueError(f"Missing amount to send: {amount_msat=}")
lnworker = wallet.lnworker
bolt12_invoice, invoice_tlv = await lnworker.request_bolt12_invoice(
bolt12_offer,
amount_msat=send_amount,
payer_note=payer_note,
)
invoice_bech32 = bolt12.bolt12_tlv_bytes_to_bech32(invoice_tlv, bolt12.BOLT12Invoice)
invoice = Invoice.from_bech32(invoice_bech32)
success, log = await lnworker.pay_invoice(invoice)
return {
'success': success,
'log': [x.formatted_tuple() for x in log]
}

@command('wnl')
async def add_lightning_offer(
self,
amount: Optional[Decimal] = None,
description: Optional[str] = None,
relative_expiry: Optional[int] = None,
issuer_name: Optional[str] = None,
allow_unblinded: bool = False,
wallet: Abstract_Wallet = None
):
"""Create a bolt12 offer.

arg:decimal:amount:Requested amount (in btc)
arg:str:description:Description of the request
arg:int:relative_expiry:Time in seconds.
arg:str:issuer_name:Issuer name string
arg:bool:allow_unblinded:Allow revealing node id for unblinded offers
"""
amount_msat = satoshis(amount) * 1000 if amount else None
offer = wallet.lnworker.create_offer(
amount_msat=amount_msat,
description=description,
relative_expiry=relative_expiry,
issuer_name=issuer_name,
allow_unblinded=allow_unblinded,
)

return {
'offer': offer.encode(as_bech32=True)
}

@command('wnl')
async def add_hold_invoice(
self,
Expand Down Expand Up @@ -2265,6 +2336,8 @@ async def send_onion_message(self, node_id_or_blinded_path_hex: str, message: st

node_id_or_blinded_path = bfh(node_id_or_blinded_path_hex)
assert len(node_id_or_blinded_path) >= 33
if len(node_id_or_blinded_path) > 33: # assume blinded path
node_id_or_blinded_path = BlindedPath.decode(node_id_or_blinded_path)

destination_payload = {
'message': {'text': message.encode('utf-8')}
Expand Down Expand Up @@ -2308,11 +2381,27 @@ async def get_blinded_path_via(self, node_id: str, dummy_hops: int = 0, wallet:
fd=blinded_path_fd,
field_type='blinded_path',
count=1,
value=blinded_path)
value=dataclasses.asdict(blinded_path))
encoded_blinded_path = blinded_path_fd.getvalue()

return encoded_blinded_path.hex()

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

arg:str:bech32:Offer, Invoice Request or Invoice
"""
dec = bolt12.bech32_decode(bech32, ignore_long_length=True, with_checksum=False)
if dec == INVALID_BECH32:
raise UserFacingException('invalid bech32')
d = {
'lni': BOLT12Invoice.decode,
'lno': BOLT12Offer.decode,
'lnr': BOLT12InvoiceRequest.decode,
}[dec.hrp](bech32)
return json_encode(d.serialize(with_signature=True))


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.
176 changes: 176 additions & 0 deletions electrum/gui/qml/components/Bolt12OfferDialog.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material

import org.electrum 1.0

import "controls"

ElDialog {
id: dialog

title: qsTr('Lightning Offer')
iconSource: '../../../icons/lightning.png'

property var invoiceParser // type: InvoiceParser

padding: 0

property bool commentValid: note.text.length <= 64
property bool amountValid: amountBtc.textAsSats.satsInt > 0 && amountBtc.textAsSats.satsInt <= Daemon.currentWallet.lightningCanSend.satsInt
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
Layout.topMargin: constants.paddingSmall
text: qsTr('Issuer')
color: Material.accentColor
visible: 'issuer' in invoiceParser.offerData
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: 'issuer' in invoiceParser.offerData
leftPadding: constants.paddingMedium
Label {
width: parent.width
wrapMode: Text.Wrap
elide: Text.ElideRight
font.pixelSize: constants.fontSizeXLarge
text: 'issuer' in invoiceParser.offerData ? invoiceParser.offerData['issuer'] : ''
}
}
Label {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.topMargin: constants.paddingSmall
text: qsTr('Description')
color: Material.accentColor
visible: 'description' in invoiceParser.offerData
}
TextHighlightPane {
Layout.columnSpan: 2
Layout.fillWidth: true
visible: 'description' in invoiceParser.offerData
leftPadding: constants.paddingMedium
Label {
width: parent.width
text: 'description' in invoiceParser.offerData ? invoiceParser.offerData['description'] : ''
wrapMode: Text.Wrap
font.pixelSize: constants.fontSizeXLarge
}
}
Label {
Layout.columnSpan: 2
Layout.topMargin: constants.paddingSmall
text: qsTr('Amount')
color: Material.accentColor
}

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

ColumnLayout {
width: parent.width
spacing: constants.paddingSmall

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

RowLayout {
Layout.fillWidth: true
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
}
}
}
}

RowLayout {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.topMargin: constants.paddingSmall
Label {
text: qsTr('Note')
color: Material.accentColor
}
Item { Layout.fillWidth: true }
Label {
text: note.text.length + '/64'
font.pixelSize: constants.fontSizeSmall
color: commentValid ? constants.mutedForeground : constants.colorError
}
}
ElTextArea {
id: note
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.minimumHeight: 100
wrapMode: TextEdit.Wrap
placeholderText: qsTr('Enter an (optional) message for the receiver')
color: commentValid ? Material.foreground : constants.colorError
}
}

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

}
58 changes: 57 additions & 1 deletion electrum/gui/qml/components/InvoiceDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ 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')
: qsTr('Lightning Invoice')
iconSource: Qt.resolvedUrl('../../icons/tab_send.png')

padding: 0
Expand Down Expand Up @@ -107,6 +109,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 @@ -423,6 +449,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

DialogHighlightPane {
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
Loading