fix(01-19): embed route hints in invoice creation for private channels
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled

handle_lnd_createinvoice posted to LND's /v1/invoices with only value/memo,
so LND defaulted private to false and returned invoices with empty
route_hints. Any node whose only channels are private/unannounced was
unpayable through the wallet UI's Receive flow. Diagnosed on
archy-x250-mad2, whose only channel (to Olympus by ZEUS) is private with
~40.8k sats usable inbound; three wallet-UI invoices never received an
HTLC.

Audited every other invoice-creation call site and found a second one with
the identical omission: create_invoice, the seller-side/peer-file paid-
content flow (content.rs -> handler for paid downloads). Same bug, same
fix, wider blast radius than the one-node report suggested -- paid-file
sales were unreceivable on private-channel nodes too.

Extracted both call sites' invoice_body construction into one shared
build_invoice_request_body() that sets private: true unconditionally, and
added a unit test pinning that field so neither site can silently drift
back to false. private:true is harmless on nodes with public channels --
LND still prefers a direct public route and the hint is just an unused
alternate path.
This commit is contained in:
archipelago 2026-07-31 08:52:13 -04:00
parent 050a87d2dd
commit e5c38866ca

View File

@ -327,10 +327,10 @@ impl RpcHandler {
}
let (client, macaroon_hex) = self.lnd_client().await?;
let invoice_body = serde_json::json!({
"value": amount_sats.to_string(),
"memo": memo,
});
// See `build_invoice_request_body` for why `private: true` is
// unconditional — this is the seller-side/peer-file invoice path,
// the twin of the bug fixed in `handle_lnd_createinvoice` below.
let invoice_body = build_invoice_request_body(amount_sats, memo);
// LND's REST endpoint can briefly drop/reset connections under load
// (swap pressure, just-restarted, TLS handshake races), which used to
// hard-fail the buy-file invoice with an opaque 503. Retry on a
@ -551,10 +551,10 @@ impl RpcHandler {
let (client, macaroon_hex) = self.lnd_client().await?;
let invoice_body = serde_json::json!({
"value": amount_sats.to_string(),
"memo": memo,
});
// See `build_invoice_request_body` for why `private: true` is
// unconditional. This is the wallet UI's Receive flow / the
// `lnd.createinvoice` RPC — the bug diagnosed on archy-x250-mad2.
let invoice_body = build_invoice_request_body(amount_sats, memo);
let resp = match client
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
@ -1105,10 +1105,39 @@ fn classify_lnd_address_error(message: &str) -> &'static str {
}
}
/// Build the LND `/v1/invoices` POST body used to mint a Lightning invoice.
///
/// `private: true` is unconditional and must stay that way: it tells LND to
/// embed a route hint for every private/unannounced channel this node
/// holds, which is the only way a payment can find its way in over such a
/// channel. It is harmless when the node also has public channels — LND
/// still routes directly over a public channel when it can, and the hint is
/// simply an unused alternate path.
///
/// Shared by both invoice-creation call sites (`create_invoice`, the
/// seller-side/peer-file flow, and `handle_lnd_createinvoice`, the wallet
/// UI's Receive flow / `lnd.createinvoice` RPC) so a single test pins the
/// field for both and neither can silently drift back to `false`.
fn build_invoice_request_body(amount_sats: i64, memo: &str) -> serde_json::Value {
serde_json::json!({
"value": amount_sats.to_string(),
"memo": memo,
"private": true,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invoice_request_body_always_sets_private_true() {
let body = build_invoice_request_body(1_234, "test memo");
assert_eq!(body["private"], serde_json::json!(true));
assert_eq!(body["value"], serde_json::json!("1234"));
assert_eq!(body["memo"], serde_json::json!("test memo"));
}
#[test]
fn lnd_error_message_prefers_message_field() {
let body = serde_json::json!({