From e5c38866cac97cd0e3ba86e81311362b08a05144 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 31 Jul 2026 08:52:13 -0400 Subject: [PATCH] fix(01-19): embed route hints in invoice creation for private channels 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. --- core/archipelago/src/api/rpc/lnd/wallet.rs | 45 ++++++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index df0bab3b..445adaa5 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -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!({