Skip to content
Closed
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
dbaf6a0
lnmsg: add new primitive type `bip340sig`, add tlv merkle root calcul…
accumulator May 28, 2024
378faa5
lnmsg: validate point primitive field
f321x Feb 10, 2026
74a843b
lnmsg: fix parsing of nested complex types where the leaf objects
accumulator Apr 20, 2026
c2aca2e
test_lnmsg: add bolt 12 merkle tree test vector
f321x Apr 21, 2026
f9437e8
lnwire: add bolt12 types to onion_wire
accumulator Jul 8, 2024
735a3e2
onion_message: factor out payinfo creation from get_blinded_paths_to_me
f321x May 6, 2026
17f6e86
onion_message: handle missing remote update
f321x May 7, 2026
a6a2e5d
onion_message: use trampoline nodes for onion messages
f321x May 7, 2026
fe1278a
onion_message: pathfinding: ignore amount contraints
f321x May 7, 2026
6cad3c7
onion_messages: filter correct feature when creating paths
f321x May 13, 2026
943f8ba
lnonion: support payment path blinding
accumulator Nov 11, 2025
3f94381
lnworker: add ONION_MESSAGE and ROUTE_BLINDING features to LNWALLET_F…
accumulator Dec 5, 2025
cd9e908
onion_message: perform a direct peer connection for sending request
accumulator Nov 24, 2025
163cae5
lnpeer: add send_onion_message method
f321x May 4, 2026
f4b49ab
PaymentIdentifier: _do_finalize: don't catch all exc
f321x Mar 23, 2026
e6cd28e
lnonion: introduce BlindedPath dataclasses
f321x Apr 14, 2026
f5f5959
bolt12: add BOLT12 classes, encode/decode
f321x Apr 14, 2026
101356e
tests: add bolt12 string formatting test vector
f321x Feb 10, 2026
fb52cae
tests: add bolt12 offer test vector
f321x Feb 12, 2026
4826382
commands: add decode_bolt12 command
accumulator Nov 5, 2025
a51ffbb
bolt12/lnwallet: implement requesting and handling invoices
f321x Apr 15, 2026
35c3cd6
test_lnwallet: add unittests for bolt12 invoice flow
f321x Apr 16, 2026
28aba5c
invoices: make `Invoice` handle bolt 12 invoices
f321x Apr 14, 2026
1c006dd
payment_identifier: initial support for bolt12 offers
accumulator May 28, 2024
2d3e545
tests: test_payment_identifier: test bolt12 pi
f321x Feb 19, 2026
ea304b1
bolt12: route blinding test
accumulator Mar 9, 2025
87df622
lnonion: implement hops data assembly for blinded payments
accumulator May 7, 2026
15acf23
lnutil, lnonion: add RoutingInfo abstractions
f321x Apr 14, 2026
9e4e6f8
LNWallet/bolt12: implement sending blinded payments
f321x Apr 14, 2026
640d872
add unit test: blinded payment onion
ecdsa Oct 7, 2025
3989cc2
bolt12/lnworker: implement creating offer
accumulator Oct 8, 2025
10a1c5e
test_lnwallet: unittest offer creation
f321x Apr 16, 2026
d45a9dc
bolt12: handle 'invoice_request' onion message payloads
f321x Oct 8, 2025
0f2eae0
test_lnwallet: test bolt12 invoice request handling
f321x Apr 16, 2026
312a560
test_bolt12: unittest Bolt12InvoiceError
f321x Apr 17, 2026
735afb7
wallet: return bech32 encoded bolt12 invoice in export_invoice()
accumulator Nov 13, 2025
aab052d
add regtest: tests.regtest.TestLightningABC.test_bolt12
ecdsa Dec 6, 2025
0159be8
lnutil: add blinded path method to PaymentFeeBudget
f321x Mar 17, 2026
5c9aa41
trampoline: change invoice_features tlv from u64 to bytes
f321x Mar 18, 2026
6ddd51f
trampoline: add blinded payment support
f321x Apr 1, 2026
923ec98
qt: initial support for bolt12 offers
accumulator Apr 1, 2026
ec7c65e
qml: initial support for bolt12 offers
accumulator Apr 1, 2026
fde12bf
wip: exchange rates
f321x May 13, 2026
17ef453
fix(lnpeer): include path_key in processed-onion cache key
m0wer May 27, 2026
e50836b
feat(wallet-db): bump seed_version to 72 for bolt12 blinded htlcs
m0wer May 27, 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
743 changes: 743 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 @@ -2229,6 +2300,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 @@ -2272,11 +2345,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 Exception('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.
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 @@ -107,6 +111,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 +451,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