Skip to content

Add support for sending to human-readable names (BIP 353) #528

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ panic = 'abort' # Abort on panic
default = []

[dependencies]
lightning = { version = "0.1.0", features = ["std"] }
lightning = { version = "0.1.0", features = ["std", "dnssec"] }
lightning-types = { version = "0.2.0" }
lightning-invoice = { version = "0.33.0", features = ["std"] }
lightning-net-tokio = { version = "0.1.0" }
21 changes: 19 additions & 2 deletions bindings/ldk_node.udl
Original file line number Diff line number Diff line change
@@ -13,6 +13,11 @@ dictionary Config {
u64 probing_liquidity_limit_multiplier;
AnchorChannelsConfig? anchor_channels_config;
SendingParameters? sending_parameters;
HumanReadableNamesConfig? hrn_config;
};

dictionary HumanReadableNamesConfig {
sequence<PublicKey> dns_resolvers_node_ids;
};

dictionary AnchorChannelsConfig {
@@ -197,6 +202,8 @@ interface Bolt12Payment {
[Throws=NodeError]
PaymentId send_using_amount([ByRef]Offer offer, u64 amount_msat, u64? quantity, string? payer_note);
[Throws=NodeError]
PaymentId send_to_human_readable_name([ByRef]HumanReadableName hrn, u64 amount_msat);
[Throws=NodeError]
Offer receive(u64 amount_msat, [ByRef]string description, u32? expiry_secs, u64? quantity);
[Throws=NodeError]
Offer receive_variable_amount([ByRef]string description, u32? expiry_secs);
@@ -248,6 +255,13 @@ interface LSPS1Liquidity {
LSPS1OrderStatus check_order_status(OrderId order_id);
};

interface HumanReadableName {
[Throws=NodeError, Name=from_encoded]
constructor([ByRef] string encoded);
string user();
string domain();
};

[Error]
enum NodeError {
"AlreadyRunning",
@@ -302,6 +316,8 @@ enum NodeError {
"InsufficientFunds",
"LiquiditySourceUnavailable",
"LiquidityFeeTooHigh",
"HrnParsingFailed",
"DnsResolversUnavailable",
};

dictionary NodeStatus {
@@ -337,6 +353,7 @@ enum BuildError {
"WalletSetupFailed",
"LoggerSetupFailed",
"NetworkMismatch",
"DnsResolversUnavailable",
};

[Trait]
@@ -402,7 +419,7 @@ interface PaymentKind {
Onchain(Txid txid, ConfirmationStatus status);
Bolt11(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret);
Bolt11Jit(PaymentHash hash, PaymentPreimage? preimage, PaymentSecret? secret, u64? counterparty_skimmed_fee_msat, LSPFeeLimits lsp_fee_limits);
Bolt12Offer(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, OfferId offer_id, UntrustedString? payer_note, u64? quantity);
Bolt12Offer(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, OfferId? offer_id, UntrustedString? payer_note, u64? quantity);
Bolt12Refund(PaymentHash? hash, PaymentPreimage? preimage, PaymentSecret? secret, UntrustedString? payer_note, u64? quantity);
Spontaneous(PaymentHash hash, PaymentPreimage? preimage);
};
@@ -854,4 +871,4 @@ typedef string NodeAlias;
typedef string OrderId;

[Custom]
typedef string DateTime;
typedef string DateTime;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Not sure why this change here was included. Please revert.

13 changes: 13 additions & 0 deletions src/builder.rs
Original file line number Diff line number Diff line change
@@ -174,6 +174,10 @@ pub enum BuildError {
LoggerSetupFailed,
/// The given network does not match the node's previously configured network.
NetworkMismatch,
/// The [`dns_resolvers_node_ids`] provided for HRN resolution is empty.
///
/// [`dns_resolvers_node_ids`]: crate::config::HumanReadableNamesConfig::dns_resolvers_node_ids
DnsResolversUnavailable,
}

impl fmt::Display for BuildError {
@@ -201,6 +205,9 @@ impl fmt::Display for BuildError {
Self::NetworkMismatch => {
write!(f, "Given network does not match the node's previously configured network.")
},
Self::DnsResolversUnavailable => {
write!(f, "The DNS resolvers provided for HRN resolution is empty.")
},
}
}
}
@@ -1492,6 +1499,12 @@ fn build_with_store_internal(
},
};

if let Some(hrn_config) = &config.hrn_config {
if hrn_config.dns_resolvers_node_ids.is_empty() {
return Err(BuildError::DnsResolversUnavailable);
}
};

let (stop_sender, _) = tokio::sync::watch::channel(());
let (event_handling_stopped_sender, _) = tokio::sync::watch::channel(());

20 changes: 20 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -103,6 +103,7 @@ pub const WALLET_KEYS_SEED_LEN: usize = 64;
/// | `log_level` | Debug |
/// | `anchor_channels_config` | Some(..) |
/// | `sending_parameters` | None |
/// | `hrn_config` | None |
///
/// See [`AnchorChannelsConfig`] and [`SendingParameters`] for more information regarding their
/// respective default values.
@@ -167,6 +168,10 @@ pub struct Config {
/// **Note:** If unset, default parameters will be used, and you will be able to override the
/// parameters on a per-payment basis in the corresponding method calls.
pub sending_parameters: Option<SendingParameters>,
/// Configuration options for Human-Readable Names ([BIP 353]).
///
/// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki
pub hrn_config: Option<HumanReadableNamesConfig>,
}

impl Default for Config {
@@ -181,10 +186,24 @@ impl Default for Config {
anchor_channels_config: Some(AnchorChannelsConfig::default()),
sending_parameters: None,
node_alias: None,
hrn_config: None,
}
}
}

/// Configuration options for Human-Readable Names ([BIP 353]).
///
/// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki
#[derive(Debug, Clone)]
pub struct HumanReadableNamesConfig {
/// The DNS resolvers to be used for resolving Human-Readable Names.
///
/// If not empty, the values set will be used as DNS resolvers when sending to HRNs.
///
/// **Note:** If empty, payments to HRNs will fail.
pub dns_resolvers_node_ids: Vec<PublicKey>,
}

/// Configuration options pertaining to 'Anchor' channels, i.e., channels for which the
/// `option_anchors_zero_fee_htlc_tx` channel type is negotiated.
///
@@ -306,6 +325,7 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig {
let mut user_config = UserConfig::default();
user_config.channel_handshake_limits.force_announced_channel_preference = false;
user_config.manually_accept_inbound_channels = true;
user_config.manually_handle_bolt12_invoices = true;
user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx =
config.anchor_channels_config.is_some();

10 changes: 10 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -120,6 +120,10 @@ pub enum Error {
LiquiditySourceUnavailable,
/// The given operation failed due to the LSP's required opening fee being too high.
LiquidityFeeTooHigh,
/// Parsing a Human-Readable Name has failed.
HrnParsingFailed,
/// The given operation failed due to DNS resolvers not being configured.
DnsResolversUnavailable,
}

impl fmt::Display for Error {
@@ -193,6 +197,12 @@ impl fmt::Display for Error {
Self::LiquidityFeeTooHigh => {
write!(f, "The given operation failed due to the LSP's required opening fee being too high.")
},
Self::HrnParsingFailed => {
write!(f, "Failed to parse a human-readable name.")
},
Self::DnsResolversUnavailable => {
write!(f, "The given operation failed due to DNS resolvers not being configured.")
},
}
}
}
22 changes: 19 additions & 3 deletions src/event.rs
Original file line number Diff line number Diff line change
@@ -742,7 +742,7 @@ where
hash: Some(payment_hash),
preimage: payment_preimage,
secret: Some(payment_secret),
offer_id,
offer_id: Some(offer_id),
payer_note,
quantity,
};
@@ -1417,8 +1417,24 @@ where
);
}
},
LdkEvent::InvoiceReceived { .. } => {
debug_assert!(false, "We currently don't handle BOLT12 invoices manually, so this event should never be emitted.");
LdkEvent::InvoiceReceived { payment_id, invoice, context, responder: _ } => {
let update = PaymentDetailsUpdate {
hash: Some(Some(invoice.payment_hash())),
quantity: invoice.quantity(),
..PaymentDetailsUpdate::new(payment_id)
};

match self.payment_store.update(&update) {
Ok(_) => {},
Err(e) => {
log_error!(self.logger, "Failed to access payment store: {}", e);
return Err(ReplayEvent());
},
};

let _ = self
.channel_manager
.send_payment_for_bolt12_invoice(&invoice, context.as_ref());
Comment on lines +1435 to +1437
Copy link
Author

@chuksys chuksys Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't handle the error here because I'm not sure if we want to return a ReplayEvent Error when we get a Bolt12PaymentError::UnexpectedInvoice or Bolt12PaymentError::DuplicateInvoice - Plus since the success or failure of the payment, triggers Event::PaymentSent or Event::PaymentFailed (which are already properly handled), I'm thinking this might be the way to go. Please let me know what you think.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, true, but we should at the very least log the error here I think.

},
LdkEvent::ConnectionNeeded { node_id, addresses } => {
let runtime_lock = self.runtime.read().unwrap();
57 changes: 56 additions & 1 deletion src/ffi/types.rs
Original file line number Diff line number Diff line change
@@ -12,7 +12,7 @@

pub use crate::config::{
default_config, AnchorChannelsConfig, BackgroundSyncConfig, ElectrumSyncConfig,
EsploraSyncConfig, MaxDustHTLCExposure,
EsploraSyncConfig, HumanReadableNamesConfig, MaxDustHTLCExposure,
};
pub use crate::graph::{ChannelInfo, ChannelUpdateInfo, NodeAnnouncementInfo, NodeInfo};
pub use crate::liquidity::{LSPS1OrderStatus, LSPS2ServiceConfig, OnchainPaymentInfo, PaymentInfo};
@@ -36,6 +36,8 @@ pub use lightning_invoice::{Description, SignedRawBolt11Invoice};
pub use lightning_liquidity::lsps1::msgs::ChannelInfo as ChannelOrderInfo;
pub use lightning_liquidity::lsps1::msgs::{OrderId, OrderParameters, PaymentState};

pub use lightning::onion_message::dns_resolution::HumanReadableName as LdkHumanReadableName;

pub use bitcoin::{Address, BlockHash, FeeRate, Network, OutPoint, Txid};

pub use bip39::Mnemonic;
@@ -1117,6 +1119,59 @@ impl UniffiCustomTypeConverter for DateTime {
}
}

pub struct HumanReadableName {
pub(crate) inner: LdkHumanReadableName,
}

impl HumanReadableName {
/// Returns the underlying HumanReadableName [`LdkHumanReadableName`]
pub fn into_inner(&self) -> LdkHumanReadableName {
self.inner.clone()
}

pub fn from_encoded(encoded: &str) -> Result<Self, Error> {
let hrn = match LdkHumanReadableName::from_encoded(encoded) {
Ok(hrn) => Ok(hrn),
Err(_) => Err(Error::HrnParsingFailed),
}?;

Ok(Self { inner: hrn })
}

pub fn user(&self) -> String {
self.inner.user().to_string()
}

pub fn domain(&self) -> String {
self.inner.domain().to_string()
}
}

impl From<LdkHumanReadableName> for HumanReadableName {
fn from(ldk_hrn: LdkHumanReadableName) -> Self {
HumanReadableName { inner: ldk_hrn }
}
}

impl From<HumanReadableName> for LdkHumanReadableName {
fn from(wrapper: HumanReadableName) -> Self {
wrapper.into_inner()
}
}

impl Deref for HumanReadableName {
type Target = LdkHumanReadableName;
fn deref(&self) -> &Self::Target {
&self.inner
}
}

impl AsRef<LdkHumanReadableName> for HumanReadableName {
fn as_ref(&self) -> &LdkHumanReadableName {
self.deref()
}
}

#[cfg(test)]
mod tests {
use std::{
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -872,6 +872,7 @@ impl Node {
Arc::clone(&self.channel_manager),
Arc::clone(&self.payment_store),
Arc::clone(&self.logger),
Arc::clone(&self.config),
)
}

@@ -885,6 +886,7 @@ impl Node {
Arc::clone(&self.channel_manager),
Arc::clone(&self.payment_store),
Arc::clone(&self.logger),
Arc::clone(&self.config),
))
}

120 changes: 113 additions & 7 deletions src/payment/bolt12.rs
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
//!
//! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md
use crate::config::LDK_PAYMENT_RETRY_TIMEOUT;
use crate::config::{Config, LDK_PAYMENT_RETRY_TIMEOUT};
use crate::error::Error;
use crate::ffi::{maybe_deref, maybe_wrap};
use crate::logger::{log_error, log_info, LdkLogger, Logger};
@@ -19,6 +19,7 @@ use crate::types::{ChannelManager, PaymentStore};
use lightning::ln::channelmanager::{PaymentId, Retry};
use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity};
use lightning::offers::parse::Bolt12SemanticError;
use lightning::onion_message::messenger::Destination;
use lightning::util::string::UntrustedString;

use rand::RngCore;
@@ -42,6 +43,11 @@ type Refund = lightning::offers::refund::Refund;
#[cfg(feature = "uniffi")]
type Refund = Arc<crate::ffi::Refund>;

#[cfg(not(feature = "uniffi"))]
type HumanReadableName = lightning::onion_message::dns_resolution::HumanReadableName;
#[cfg(feature = "uniffi")]
type HumanReadableName = Arc<crate::ffi::HumanReadableName>;

/// A payment handler allowing to create and pay [BOLT 12] offers and refunds.
///
/// Should be retrieved by calling [`Node::bolt12_payment`].
@@ -53,15 +59,16 @@ pub struct Bolt12Payment {
channel_manager: Arc<ChannelManager>,
payment_store: Arc<PaymentStore>,
logger: Arc<Logger>,
config: Arc<Config>,
}

impl Bolt12Payment {
pub(crate) fn new(
runtime: Arc<RwLock<Option<Arc<tokio::runtime::Runtime>>>>,
channel_manager: Arc<ChannelManager>, payment_store: Arc<PaymentStore>,
logger: Arc<Logger>,
logger: Arc<Logger>, config: Arc<Config>,
) -> Self {
Self { runtime, channel_manager, payment_store, logger }
Self { runtime, channel_manager, payment_store, logger, config }
}

/// Send a payment given an offer.
@@ -118,7 +125,7 @@ impl Bolt12Payment {
hash: None,
preimage: None,
secret: None,
offer_id: offer.id(),
offer_id: Some(offer.id()),
payer_note: payer_note.map(UntrustedString),
quantity,
};
@@ -143,7 +150,7 @@ impl Bolt12Payment {
hash: None,
preimage: None,
secret: None,
offer_id: offer.id(),
offer_id: Some(offer.id()),
payer_note: payer_note.map(UntrustedString),
quantity,
};
@@ -225,7 +232,7 @@ impl Bolt12Payment {
hash: None,
preimage: None,
secret: None,
offer_id: offer.id(),
offer_id: Some(offer.id()),
payer_note: payer_note.map(UntrustedString),
quantity,
};
@@ -250,7 +257,7 @@ impl Bolt12Payment {
hash: None,
preimage: None,
secret: None,
offer_id: offer.id(),
offer_id: Some(offer.id()),
payer_note: payer_note.map(UntrustedString),
quantity,
};
@@ -270,6 +277,105 @@ impl Bolt12Payment {
}
}

/// Send a payment to an offer resolved from a Human-Readable Name ([BIP 353]).
///
/// Paying to Human-Readable Names makes it more intuitive to make payments for offers
/// as users can simply send payments to HRNs such as `user@example.com`.
///
/// This can be used to pay so-called "zero-amount" offers, i.e., an offer that leaves the
/// amount paid to be determined by the user.
///
/// If `dns_resolvers_node_ids` in [`Config.hrn_config`] is empty, this operation will fail.
///
/// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki
pub fn send_to_human_readable_name(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we can add a test case to check that this works somehow? Or would we need to wait for #436 to be able to create an end-to-end test?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can intercept the InvoiceReceived event or have an event that is emitted after the offer is retrieved, I think we can have at least a test case built around any of these events.

I think the end-to-end test would be the ideal, eventually.

&self, hrn: &HumanReadableName, amount_msat: u64,
) -> Result<PaymentId, Error> {
let hrn = maybe_deref(hrn);
let rt_lock = self.runtime.read().unwrap();
if rt_lock.is_none() {
return Err(Error::NotRunning);
}

let mut random_bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut random_bytes);
let payment_id = PaymentId(random_bytes);
let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT);
let max_total_routing_fee_msat = None;

let destinations: Vec<Destination> = match &self.config.hrn_config {
Some(hrn_config) => Ok(hrn_config
.dns_resolvers_node_ids
.iter()
.map(|node_id| Destination::Node(*node_id))
.collect()),
None => Err(Error::DnsResolversUnavailable),
}?;

match self.channel_manager.pay_for_offer_from_human_readable_name(
hrn.clone(),
amount_msat,
payment_id,
retry_strategy,
max_total_routing_fee_msat,
destinations,
) {
Ok(()) => {
log_info!(
self.logger,
"Initiated sending {} msats to ₿{}@{}",
amount_msat,
hrn.user(),
hrn.domain()
);
let kind = PaymentKind::Bolt12Offer {
hash: None,
preimage: None,
secret: None,
offer_id: None,
payer_note: None,
quantity: None,
};
let payment = PaymentDetails::new(
payment_id,
kind,
Some(amount_msat),
None,
PaymentDirection::Outbound,
PaymentStatus::Pending,
);
self.payment_store.insert(payment)?;
Ok(payment_id)
},
Err(()) => {
log_error!(
self.logger,
"Failed to send payment to ₿{}@{}",
hrn.user(),
hrn.domain()
);
let kind = PaymentKind::Bolt12Offer {
hash: None,
preimage: None,
secret: None,
offer_id: None,
payer_note: None,
quantity: None,
};
let payment = PaymentDetails::new(
payment_id,
kind,
Some(amount_msat),
None,
PaymentDirection::Outbound,
PaymentStatus::Failed,
);
self.payment_store.insert(payment)?;
Err(Error::PaymentSendingFailed)
},
}
}

pub(crate) fn receive_inner(
&self, amount_msat: u64, description: &str, expiry_secs: Option<u32>, quantity: Option<u64>,
) -> Result<LdkOffer, Error> {
16 changes: 14 additions & 2 deletions src/payment/store.rs
Original file line number Diff line number Diff line change
@@ -404,7 +404,11 @@ pub enum PaymentKind {
/// The secret used by the payment.
secret: Option<PaymentSecret>,
/// The ID of the offer this payment is for.
offer_id: OfferId,
///
/// This will be set to `None` when sending payments to Human-Readable Names ([BIP 353]).
///
/// [BIP 353]: https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki
offer_id: Option<OfferId>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please describe when this field would be set / unset.

/// The payer note for the payment.
///
/// Truncated to [`PAYER_NOTE_LIMIT`] characters.
@@ -470,7 +474,7 @@ impl_writeable_tlv_based_enum!(PaymentKind,
(2, preimage, option),
(3, quantity, option),
(4, secret, option),
(6, offer_id, required),
(6, offer_id, option),
},
(8, Spontaneous) => {
(0, hash, required),
@@ -542,6 +546,7 @@ pub(crate) struct PaymentDetailsUpdate {
pub direction: Option<PaymentDirection>,
pub status: Option<PaymentStatus>,
pub confirmation_status: Option<ConfirmationStatus>,
pub quantity: Option<u64>,
}

impl PaymentDetailsUpdate {
@@ -557,6 +562,7 @@ impl PaymentDetailsUpdate {
direction: None,
status: None,
confirmation_status: None,
quantity: None,
}
}
}
@@ -584,6 +590,11 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
_ => None,
};

let quantity = match value.kind {
PaymentKind::Bolt12Offer { quantity, .. } => quantity,
_ => None,
};

Self {
id: value.id,
hash: Some(hash),
@@ -595,6 +606,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate {
direction: Some(value.direction),
status: Some(value.status),
confirmation_status,
quantity,
}
}
}
8 changes: 4 additions & 4 deletions tests/integration_tests_rust.rs
Original file line number Diff line number Diff line change
@@ -820,7 +820,7 @@ fn simple_bolt12_send_receive() {
} => {
assert!(hash.is_some());
assert!(preimage.is_some());
assert_eq!(offer_id, offer.id());
assert_eq!(offer_id, Some(offer.id()));
assert_eq!(&expected_quantity, qty);
assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0);
//TODO: We should eventually set and assert the secret sender-side, too, but the BOLT12
@@ -841,7 +841,7 @@ fn simple_bolt12_send_receive() {
assert!(hash.is_some());
assert!(preimage.is_some());
assert!(secret.is_some());
assert_eq!(offer_id, offer.id());
assert_eq!(offer_id, Some(offer.id()));
},
_ => {
panic!("Unexpected payment kind");
@@ -886,7 +886,7 @@ fn simple_bolt12_send_receive() {
} => {
assert!(hash.is_some());
assert!(preimage.is_some());
assert_eq!(offer_id, offer.id());
assert_eq!(offer_id, Some(offer.id()));
assert_eq!(&expected_quantity, qty);
assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0);
//TODO: We should eventually set and assert the secret sender-side, too, but the BOLT12
@@ -910,7 +910,7 @@ fn simple_bolt12_send_receive() {
assert!(hash.is_some());
assert!(preimage.is_some());
assert!(secret.is_some());
assert_eq!(offer_id, offer.id());
assert_eq!(offer_id, Some(offer.id()));
},
_ => {
panic!("Unexpected payment kind");