Skip to content

feat: add configurable route hints to BOLT11 receive APIs#2

Draft
ajaysehwal wants to merge 2 commits into
ZeusLN:zeusfrom
ajaysehwal:feat/route-hints
Draft

feat: add configurable route hints to BOLT11 receive APIs#2
ajaysehwal wants to merge 2 commits into
ZeusLN:zeusfrom
ajaysehwal:feat/route-hints

Conversation

@ajaysehwal

@ajaysehwal ajaysehwal commented Jul 15, 2026

Copy link
Copy Markdown

Summary

  • Add RouteHintsMode (None / Automatic / Custom) for BOLT11 invoice creation
  • Add receive_with_route_hints and receive_variable_amount_with_route_hints (Rust + UniFFI)
  • Existing receive* APIs stay on Automatic

Custom takes up to 3 UserChannelIds and builds hints from ready channels that have an inbound SCID and forwarding info. Unusable channels are skipped;

@ajaysehwal
ajaysehwal marked this pull request as ready for review July 15, 2026 08:43
@ajaysehwal
ajaysehwal changed the base branch from v0.7.0-zeus-bimodal to zeus July 15, 2026 09:19
@ajaysehwal
ajaysehwal marked this pull request as draft July 15, 2026 12:12
@ajaysehwal
ajaysehwal marked this pull request as ready for review July 16, 2026 08:53

@kaloudis kaloudis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the branch at e70a3c6. The core mechanics check out: claimability parameters (payment secret registration, MIN_FINAL_CLTV_EXPIRY_DELTA, currency, MPP feature, signature recovery) match what ChannelManager expects per the pinned rust-lightning, cargo check passes with and without --all-features, and uniffi bindgen generates valid Python/Kotlin/Swift bindings including the None variant.

Five inline comments below. Minor notes not worth separate threads:

  • The PR description is stale: it still says unusable channels are skipped, but e70a3c6 made them fail fast.
  • Custom-hint validation runs after create_inbound_payment*. No state leaks (inbound payments are stateless), but validating the hints first is the cleaner fail-fast order and skips pointless key derivation on the error path.
  • sha256::Hash::from_slice(&payment_hash.0) on a fixed [u8; 32] can never fail — sha256::Hash::from_byte_array(payment_hash.0) removes the dead error branch.
  • The *_for_hash receive variants got no route-hints counterpart even though receive_inner now supports the combination — worth deciding whether that asymmetry is intentional before this API ships.

Comment thread src/payment/bolt11.rs

/// Controls which route hints are included when creating a BOLT11 invoice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteHintsMode {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Invalid mode/ids combinations are representable and silently accepted. custom_route_hint_user_channel_ids is only read in the Custom branch: Automatic or None plus Some(ids) silently ignores the ids (no error, and no doc stating they only apply to Custom), while Custom plus a null list falls through unwrap_or_default() into Error::InvalidChannelId, pointing the caller at their channel ids instead of the missing argument.

A data-carrying enum makes both mismatches unrepresentable and deletes the second parameter from every signature:

pub enum RouteHints {
	None,
	Automatic,
	Custom(Vec<UserChannelId>),
}

UniFFI handles this via [Enum] interface — this UDL already uses that shape with sequence fields (e.g. Event::PaymentReceived(... sequence<CustomTlvRecord> custom_records)).

Comment thread src/payment/bolt11.rs
)
}

fn create_bolt11_invoice_with_route_hints(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This adds a second in-repo invoice-construction path that will drift from LDK's. Automatic invoices go through ChannelManager::create_bolt11_invoice; None/Custom now go through this hand-rolled builder (itself near-verbatim from lsps2_create_jit_invoice in liquidity.rs). Any upstream change to LDK's invoice construction — new feature bits, CLTV buffer changes, payment metadata — lands only in the Automatic arm, and the modes diverge silently.

Since this repo already pins ZeusLN/rust-lightning (lsps7-for-ldk-node-close-fix, currently 2 commits off upstream), the cleaner fix is a small additive field on Bolt11InvoiceParameters in the fork, e.g. route_hints_override: Option<Vec<RouteHint>> (with Some(vec![]) meaning no hints). All three modes then stay on LDK's single audited construction-and-signing path, and this whole function disappears. That would also resolve the signing and error-granularity comments below.

Comment thread src/payment/bolt11.rs

let invoice = invoice_builder
.build_signed(|hash| {
Secp256k1::new()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This regresses invoice signing from the NodeSigner abstraction to raw key extraction. On the base branch every invoice is signed inside create_bolt11_invoice via node_signer.sign_invoice(&raw_invoice, Recipient::Node); this path instead pulls the raw node secret and signs with a fresh Secp256k1 context. WalletKeysManager already implements sign_invoice (delegating to the inner KeysManager and its cached secp context), so the LDK-idiomatic form works here as-is:

let raw_invoice = invoice_builder.build_raw().map_err(...)?;
let signature = self.keys_manager.sign_invoice(&raw_invoice, Recipient::Node);
let invoice = raw_invoice
	.sign(|_| signature)
	.map(|signed| Bolt11Invoice::from_signed(signed))...

(The raw-key pattern here matches liquidity.rs's JIT path inherited from upstream, so it's an established in-repo idiom — but there's no reason to add a second such site when the signer abstraction is already wired up.)

Comment thread src/payment/bolt11.rs
}
}

fn build_custom_route_hints_from_channels(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All six failure causes in this function return Error::InvalidChannelId, with no logging and an undocumented cap. Empty list, more than MAX_CUSTOM_ROUTE_HINTS ids (even when every id is valid), unknown id, not-ready channel, missing scid, and missing forwarding info all render as "The given channel ID is invalid." Elsewhere in this file coarse errors are consistently paired with a log_error! naming the actual cause; this helper logs nothing, so neither callers nor operators can tell a caller bug from a transient state.

Two related items:

  • The 3-id cap is currently stated only in the PR description — it should be on the receive_*_with_route_hints doc comments and in the UDL.
  • Duplicate ids aren't deduped: [x, x, x] yields three identical hints, while [x, x, y, z] (three distinct channels) is rejected on length. Harmless for payers, but inconsistent — worth deduping before the length check.

Comment thread src/payment/bolt11.rs
.or(channel.short_channel_id)
.ok_or(Error::InvalidChannelId)?;

let forwarding_info = channel

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A transient state is reported as a permanently-invalid channel id. A channel can be is_channel_ready while counterparty.forwarding_info is still None until the peer's first channel_update is processed — LDK's own hint selection treats this as skip-with-trace ("Ignoring channel for invoice route hints", invoice_utils.rs), and it has a test for exactly this state. Failing fast is the right call for Custom mode (silently dropping a requested hint would be worse), but the caller gets InvalidChannelId for a valid id in a retryable state, indistinguishable from a typo. The same applies to the missing-scid and not-ready checks above.

Since Error is an exhaustive enum (adding a variant is a breaking change for exhaustive matchers and the FFI surface), the minimum fix is a distinct log_error! per cause so the states are at least distinguishable in logs — see the error-granularity comment on this function.

@ajaysehwal
ajaysehwal marked this pull request as draft July 23, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants