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
261f949
lnonion: support payment path blinding
accumulator Nov 11, 2025
631c3f9
lnpeer/lnworker: validate and forward blinded path htlcs
accumulator Nov 11, 2025
209aa11
lnpeer: use INVALID_ONION_BLINDING for errors inside path
f321x Jul 3, 2026
29fb667
lnworker: add ROUTE_BLINDING features to LNWALLET_FEATURES
accumulator Dec 5, 2025
078aa51
lnworker: add ONION_MESSAGE features to LNWALLET_FEATURES
f321x Jul 17, 2026
ede5061
lnpeer: add send_onion_message method
f321x May 4, 2026
53d1277
onion_message: only pick trampoline with onion message support
f321x Jul 23, 2026
6ba858c
commands: use `OnionMessageManager.submit_send`
f321x Jul 23, 2026
e39762c
onion_message: perform a direct peer connection for sending request
f321x Nov 24, 2025
0127579
onion message: decrease delay until all dests were tried
f321x Jul 17, 2026
a8cea96
tests: onion_message: fix test_forward
f321x Jul 17, 2026
e1635e1
onion message: trampoline forwarder direct connection
f321x Jul 16, 2026
b89452d
test_onion_message: more onion message unittests
f321x Jul 22, 2026
0fa6fad
PaymentIdentifier: _do_finalize: don't catch all exc
f321x Mar 23, 2026
fd492e6
lnonion: introduce BlindedPath dataclasses
f321x Apr 14, 2026
2254174
bolt12: add BOLT12 classes, encode/decode
f321x Apr 14, 2026
2ff976f
tests: add bolt12 string formatting test vector
f321x Feb 10, 2026
5f26958
tests: add bolt12 offer test vector
f321x Feb 12, 2026
9c4bcd4
commands: add decode_bolt12 command
accumulator Nov 5, 2025
b2af0f0
bolt12/lnwallet: implement requesting and handling invoices
f321x Apr 15, 2026
98b5f2a
test_lnwallet: add unittests for bolt12 invoice flow
f321x Apr 16, 2026
f9dc878
invoices: make `Invoice` handle bolt 12 invoices
f321x Apr 14, 2026
a9e6637
payment_identifier: initial support for bolt12 offers
accumulator May 28, 2024
a338303
tests: test_payment_identifier: test bolt12 pi
f321x Feb 19, 2026
1fefe05
bolt12: route blinding test
accumulator Mar 9, 2025
06cbbd9
lnonion: implement hops data assembly for blinded payments
accumulator May 7, 2026
a28e698
lnutil, lnonion: add RoutingInfo abstractions
f321x Apr 14, 2026
7503913
lnutil: add final cltv delta offset
f321x Jun 4, 2026
dbbab1d
LNWallet/bolt12: implement sending blinded payments
f321x Apr 14, 2026
fb1ac78
add unit test: blinded payment onion
ecdsa Oct 7, 2025
89fbacc
bolt12/lnworker: implement creating offer
accumulator Oct 8, 2025
ffb1d1e
test_lnwallet: unittest offer creation
f321x Apr 16, 2026
115d6e4
bolt12: handle 'invoice_request' onion message payloads
f321x Oct 8, 2025
5ec7a2a
test_lnwallet: test bolt12 invoice request handling
f321x Apr 16, 2026
1522174
wallet: return bech32 encoded bolt12 invoice in export_invoice()
accumulator Nov 13, 2025
e801f99
add regtest: tests.regtest.TestLightningABC.test_bolt12
ecdsa Dec 6, 2025
d556d43
lnutil: add blinded path method to PaymentFeeBudget
f321x Mar 17, 2026
9317bf8
lnonion: also detect final trampoline forwarder by payload keys
f321x Nov 11, 2025
ee94703
trampoline: change invoice_features tlv from u64 to bytes
f321x Mar 18, 2026
c618ebd
trampoline: add blinded payment support
f321x Apr 1, 2026
693384a
tests: test_lnrouter: add trampoline route construction tests
f321x Jun 1, 2026
4871ce1
qt: initial support for bolt12 offers
accumulator Apr 1, 2026
20fb037
qml: initial support for bolt12 offers
accumulator Apr 1, 2026
0f36e56
wip: qml: bolt12: implement GUI for creating offer
f321x Jun 8, 2026
01e5d62
wip: exchange rates
f321x May 13, 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
746 changes: 746 additions & 0 deletions electrum/bolt12.py

Large diffs are not rendered by default.

101 changes: 97 additions & 4 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,13 +2336,19 @@ 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')}
}

try:
send_onion_message_to(wallet.lnworker, node_id_or_blinded_path, destination_payload)
wallet.lnworker.onion_message_manager.submit_send(
payload=destination_payload,
node_id_or_blinded_paths=node_id_or_blinded_path,
).add_done_callback(lambda t: t.cancelled() or t.exception())
# not awaiting task as there won't be a response for 'message' so it would raise Timeout
return {'success': True}
except Exception as e:
msg = str(e)
Expand Down Expand Up @@ -2308,11 +2385,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()
}
}
}

}
Loading