Skip to content

feat(bolt12): add BOLT12 offer payout support via LNDK#720

Open
QaidVoid wants to merge 1 commit into
MostroP2P:mainfrom
QaidVoid:feat/bolt12-lndk
Open

feat(bolt12): add BOLT12 offer payout support via LNDK#720
QaidVoid wants to merge 1 commit into
MostroP2P:mainfrom
QaidVoid:feat/bolt12-lndk

Conversation

@QaidVoid
Copy link
Copy Markdown

@QaidVoid QaidVoid commented Apr 26, 2026

Related Issue

Partial fix for #703. BIP-353 DNS resolution will follow as a separate PR.

Summary

Lets buyers submit a BOLT12 offer (lno1…) as their payout destination. Mostro routes the payout through an optional LNDK daemon running alongside LND. Opt-in; default-off behavior is byte-identical to current Mostro.

Motivation

BOLT12 closes UX gaps in BOLT11: offers are reusable (no per-retry invoice round-trip) and receiver identity is hidden via blinded paths. Mostro's escrow flow keeps using BOLT11 hold invoices (BOLT12 has no hold-invoice equivalent), so this PR only touches the buyer-payout step — the one place where the destination is chosen by an external party.

LNDK is the standard sidecar for BOLT12 with LND today: LDK implements offers, LND provides routing and onion-message forwarding, LNDK glues them together. Avoids forking LND or maintaining a parallel routing stack.

Architecture

Buyer  ---- Nostr ---->  Mostro  ----gRPC---->  LNDK  ----gRPC---->  LND
(sends lno1…)             |                      |                    |
                          +--BOLT11/LNURL path---|------gRPC--------->+
                                                 |
                                                 +- onion messaging -+

do_payment becomes a thin dispatch over a new classify() that routes BOLT12 offers to do_payment_bolt12 (LNDK) and everything else to a renamed do_payment_lnd whose body is byte-for-byte identical to the prior do_payment. pay_offer_validated() does a defensive GetInvoice → validate → PayInvoice because LNDK's one-shot PayOffer doesn't verify the fetched invoice's amount or expiry. The LNDK client is threaded through AppContext and AdminServiceImpl so dispute resolution and admin settle/cancel can pay BOLT12 buyers as well.

Defensive checks in validate_offer reject non-BTC currency, mismatched fixed amounts, elapsed expiry, Quantity::Bounded(n > 1), zero-amount orders, and any offer received while lndk_enabled = false. Wire-level fee tolerance is sent as absolute msats (PayInvoiceRequest.fee_limit), not LNDK's whole-percent field, so a small max_routing_fee doesn't get rounded up by ~10×.

Configuration

All new fields live in the existing [lightning] block. Older settings.toml files parse unchanged via struct-level #[serde(default)]. Operator setup (LND build tags, custom-message flags, LNDK install, baked-macaroon recipe, troubleshooting) lives in docs/LNDK_SETUP.md. lndk_tls_domain lets remote LNDK deployments override the default localhost SNI; lndk_min_invoice_expiry (default 60s) keeps the freshness floor appropriate for short-lived BOLT12 invoices.

Backward compatibility

  • Default-off; no LNDK process required.
  • BOLT11 / LNURL / LN-Address paths byte-identical.
  • Pre-fetched BOLT12 invoices (lni1…) rejected at validation rather than failing later.
  • Older settings.toml files parse unchanged.

Scope and limitations

Deferred to follow-ups: BIP-353 (separate PR), BOLT12 offer creation (dev-fee receipt still uses BOLT11), BOLT12-aware retries (today shares the BOLT11 retry loop), seller→Mostro escrow leg (BOLT12 has no hold-invoice equivalent).

Test plan

Automated

  • cargo test: 251 passed, 0 failed.
  • cargo clippy --all-targets --all-features: clean.
  • cargo fmt --check: clean.
  • Unit coverage for offers (classify shapes, validate_offer rejection cases including Quantity::Bounded(n>1) and zero-amount orders, validate_fetched_invoice amount / expiry / sanity bounds).

Regtest topology

  • bitcoind regtest.
  • Two LND v0.20.1-beta nodes (lnd, lnd2) built with peersrpc signrpc walletrpc and started with --protocol.custom-message=513 --protocol.custom-nodeann=39 --protocol.custom-init=39.
  • LNDK v0.3.0 sidecar on each LND (:7000, :7001).
  • CLN (Core Lightning) as a third node, peered with both LNDs and used as the BOLT12 offer issuer.
  • Channels: lnd2 → lnd 500k sats, cln → lnd 500k sats, lnd2 ↔ cln peered for the BOLT12 reply path.

LNDK wire-format check

LNDK v0.3.0 was triggered with the exact wire format Mostro now sends — absolute msat fee_limit and payer_note: None. From the LNDK server log:

INFO lndk::server - Received a request: PayOfferRequest {
  ...,
  payer_note: None,
  fee_limit: Some(200),
  fee_limit_percent: None
}
TRACE lndk::offers::lnd_requests - Peer already known while connecting as introduction node: <CLN pubkey>
INFO lndk::onion_messenger - Sending outgoing onion message to <CLN pubkey>.

LNDK parsed the request, looked up the introduction node, and emitted the onion message — the new PayInvoiceRequest.fee_limit (msat) + payer_note: None encoding is wire-compatible with LNDK v0.3.0. mostrod boots cleanly against the same LNDK (LNDK connector initialized for BOLT12 offer payouts) with no panics, and resubscribes pending orders without errors.

End-to-end Mostro flow on the same topology

  1. BOLT12 payout. Maker creates a buy order with buyer_invoice = lno1... issued by CLN. Hold-invoice flow runs unchanged. On release, mostrod calls LNDK::GetInvoice → validate → PayInvoice. After lnd2 was peered with CLN (so the BOLT12 reply path through it works), the retry loop converged with BOLT12 offer paid, preimage=0cad817d…. Channel-level proof: lnd → cln shifted by exactly 100,000 sats.
  2. BOLT11 regression. Maker creates a buy order with payout = a freshly-minted lnbcrt500u… invoice on the seller LND. release triggers do_payment_lnd. mostrod logs Order…: Invoice with hash: b21a252e… paid! and transitions to success. No BOLT12 path touched.

Summary by CodeRabbit

  • New Features

    • Experimental support for paying BOLT12 offers via an optional LNDK payout path, including offer validation, invoice fetching, and payout handling.
    • New config options to enable/disable LNDK, set gRPC endpoint/TLS/macaron, timeouts, expiry and fee limits.
  • Documentation

    • Added LNDK setup guide and updated startup/config docs describing BOLT12/LNDK configuration and troubleshooting.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aa30bce7-4b2d-455b-89b9-6a982094b1e0

📥 Commits

Reviewing files that changed from the base of the PR and between d37ac14 and 55dc7e9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • Cargo.toml
  • build.rs
  • docs/LNDK_SETUP.md
  • docs/STARTUP_AND_CONFIG.md
  • proto/lndkrpc.proto
  • settings.tpl.toml
  • src/app/context.rs
  • src/app/release.rs
  • src/config/types.rs
  • src/config/wizard.rs
  • src/lightning/invoice.rs
  • src/lightning/lndk.rs
  • src/lightning/mod.rs
  • src/lightning/offers.rs
  • src/main.rs
  • src/rpc/server.rs
  • src/rpc/service.rs
✅ Files skipped from review due to trivial changes (2)
  • settings.tpl.toml
  • docs/STARTUP_AND_CONFIG.md
🚧 Files skipped from review as they are similar to previous changes (13)
  • build.rs
  • Cargo.toml
  • src/config/wizard.rs
  • src/lightning/lndk.rs
  • src/main.rs
  • src/lightning/invoice.rs
  • src/lightning/mod.rs
  • src/rpc/service.rs
  • src/rpc/server.rs
  • proto/lndkrpc.proto
  • src/app/context.rs
  • docs/LNDK_SETUP.md
  • src/app/release.rs

Walkthrough

Adds experimental BOLT12 offer payouts via an LNDK gRPC integration: proto schema, build change, new crates/config, offer classification and validation, LndkConnector client, payment routing and refactor, context/RPC wiring, startup init/feature checks, docs, and tests.

Changes

BOLT12 Offer Payouts via LNDK

Layer / File(s) Summary
Protobuf Schemas & Build
proto/lndkrpc.proto, build.rs
Defines LNDK Offers gRPC service with GetInvoice/PayInvoice and supporting messages; updates build to compile the new proto.
Dependencies & Config Schema
Cargo.toml, src/config/types.rs
Adds lightning and hex crates and enables tls-ring on tonic; extends LightningSettings with LNDK fields and serde defaults.
Docs & Templates
docs/LNDK_SETUP.md, docs/STARTUP_AND_CONFIG.md, settings.tpl.toml
Operator guide and startup/config docs for experimental LNDK/BOLT12 settings; settings template entries added.
Invoice Classification
src/lightning/offers.rs
Adds InvoiceFormat enum and classify() for Bolt11/Bolt12 offers/invoices, LN Address, LNURL, and kind_tag() mapping; includes unit tests.
BOLT12 Offer & Invoice Validation
src/lightning/offers.rs
validate_offer() enforces LNDK enablement, BTC denomination, amount pinning, expiry/quantity checks; validate_fetched_invoice() enforces amount/expiry constraints and sanity bounds.
Invoice Validation Integration
src/lightning/invoice.rs
Refactors is_valid_invoice() to use classify() and dispatch to format-specific validators; rejects pre-fetched BOLT12 invoices; Unknown falls back to prior loose checks.
LNDK Client Implementation
src/lightning/lndk.rs
Implements LndkConnector: TLS channel setup, macaroon injection, new_from_settings(), and pay_offer_validated() performing GetInvoice→validate→PayInvoice with computed fee limits and error mapping.
Module Exports
src/lightning/mod.rs
Publishes lndk and offers submodules.
AppContext Injection
src/app/context.rs
Adds optional lndk: Option<LndkConnector> field, updates constructor and accessor, and adjusts test builder.
RPC Server & AdminService Wiring
src/rpc/server.rs, src/rpc/service.rs
Wires optional LndkConnector into RpcServer::start and AdminServiceImpl::new; admin helpers pass lndk into AppContext::new.
Payment Routing & Release Logic
src/app/release.rs
do_payment() classifies buyer_invoice: Bolt12 offers routed to do_payment_bolt12() (LNDK path) or others to do_payment_lnd(); Bolt12 payouts spawn background tasks and handle LNDK availability.
Initialization
src/main.rs
Initializes optional LndkConnector from settings; aborts startup if explicitly enabled but init fails; performs best-effort LND onion-message feature check and warns if missing; passes connector to RPC and AppContext.
Operator Documentation
docs/LNDK_SETUP.md
Detailed setup, LND prerequisites/startup flags, LNDK install/macaroon guidance, config fields, validation/payout flow, limitations, troubleshooting, and disable procedure.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • MostroP2P/mostro#499: Modifies invoice validation (is_valid_invoice) and related helpers — closely related to classification/validation changes.
  • MostroP2P/mostro#512: Changes protobuf/build steps — related to adding and compiling new proto files.
  • MostroP2P/mostro#431: Prior lightning/invoice refactor and Cargo changes touching similar areas.

Suggested reviewers

  • Catrya
  • grunch

Poem

🐰 I nibbled bytes under moonlit code tonight,
BOLT12 offers gleamed, a tiny, curious light.
Fetch, check, then pay — macaroon snug and neat,
Preimages hop back with a jubilant tweet.
Hooray — small hops make the network complete!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(bolt12): add BOLT12 offer payout support via LNDK' clearly and concisely summarizes the main change: adding BOLT12 offer payout support via LNDK integration.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/util.rs (1)

1244-1286: ⚠️ Potential issue | 🔴 Critical

Store the buyer's original address in buyer_invoice, not the resolved BIP-353 offer; re-resolve at payout time.

The code resolves BIP-353 addresses to BOLT12 offers and stores the resolved offer in order.buyer_invoice (see order.rs:96-100), then uses that stored offer directly at payout time in do_payment_bolt12 (release.rs:476, do_payment_bolt12 at release.rs:579) without re-resolution. This defeats BIP-353's offer rotation: if the issuer rotates or expires the offer between order creation and payout, the stored offer becomes stale and payment fails.

Instead, preserve the original user@domain address in buyer_invoice and call resolve_bip353 again at payout time (in do_payment before routing to do_payment_bolt12) to fetch the current offer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util.rs` around lines 1244 - 1286, The current logic in util.rs resolves
BIP-353 addresses via crate::lightning::bip353::resolve_bip353 and then stores
the resolved BOLT12 offer in order.buyer_invoice; instead, keep the original
user@domain string in order.buyer_invoice (do not replace it with the resolved
offer) and only use resolve_bip353 for validation (is_valid_invoice) or
temporary checks here; then, remove usage of the stored offer in
do_payment_bolt12 and instead call resolve_bip353 again from do_payment (before
routing to do_payment_bolt12) to fetch the live BOLT12 offer at payout time so
offer rotation/expiration is respected. Ensure resolve_bip353, is_valid_invoice,
order.buyer_invoice, do_payment, and do_payment_bolt12 are the referenced
symbols to locate changes.
🧹 Nitpick comments (15)
build.rs (1)

4-7: Optional: emit cargo:rerun-if-changed for the proto inputs.

Currently build.rs has no rerun-if-changed directive for the .proto files, so edits to proto/lndkrpc.proto (or proto/admin.proto) won't trigger a rebuild during local development. Adding directives keeps generated Tonic types in sync with the schema.

♻️ Suggested addition
     tonic_prost_build::configure()
         .protoc_arg("--experimental_allow_proto3_optional")
         .compile_protos(&["proto/admin.proto", "proto/lndkrpc.proto"], &["proto"])
         .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
+
+    println!("cargo:rerun-if-changed=proto/admin.proto");
+    println!("cargo:rerun-if-changed=proto/lndkrpc.proto");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.rs` around lines 4 - 7, Add cargo rebuild triggers for the proto inputs
so Cargo reruns the build script when proto files change: in the build script
where tonic_prost_build::configure() and .compile_protos(...) are invoked, emit
println!("cargo:rerun-if-changed=proto/lndkrpc.proto") and
println!("cargo:rerun-if-changed=proto/admin.proto") (optionally also for the
proto directory) before calling compile_protos to ensure generated Tonic code is
regenerated on proto edits.
src/util.rs (1)

1251-1251: Minor: tighten the address heuristic.

pr.contains('@') && !pr.contains(' ') will also match degenerate inputs like "@", "user@", or "@domain" and forward them to resolve_bip353. Resolution will fail and the value will fall through to LNURL — functional, but it adds a wasted DNS round-trip per malformed submission. A cheap LightningAddress::from_str(&pr).is_ok() (already used elsewhere in is_valid_invoice) or a tighter regex would short-circuit clearly invalid inputs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util.rs` at line 1251, The current heuristic `pr.contains('@') &&
!pr.contains(' ')` is too permissive and forwards degenerate inputs to
resolve_bip353; replace it by attempting to parse as a LightningAddress first
(e.g. call LightningAddress::from_str(&pr).is_ok()) or use the stricter
validation used in is_valid_invoice so only well-formed addresses are sent to
resolve_bip353; update the branch that sets `pr` to call
LightningAddress::from_str(&pr).is_ok() (or an equivalent regex) before invoking
resolve_bip353 to avoid unnecessary DNS lookups on invalid inputs.
settings.tpl.toml (1)

28-30: Use neutral / non-mainnet example paths in the template.

The macaroon path defaults to …/chain/bitcoin/mainnet/admin.macaroon while every other LND path in the template (lnd_cert_file, lnd_macaroon_file) uses Polar regtest paths. An operator who copies the template wholesale could end up pointing LNDK at mainnet credentials while LND itself is on regtest, mixing networks. Recommend using a clearly placeholder path or aligning with the existing Polar regtest convention used above.

♻️ Proposed alignment
-lndk_cert_file = "/home/user/.lndk/data/tls-cert.pem"
+lndk_cert_file = "/home/user/.lndk/tls-cert.pem"
 # LND macaroon used by LNDK for payment authorization
-lndk_macaroon_file = "/home/user/.lnd/data/chain/bitcoin/mainnet/admin.macaroon"
+lndk_macaroon_file = "/home/user/.polar/networks/1/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@settings.tpl.toml` around lines 28 - 30, The template uses a mainnet macaroon
path for lndk_macaroon_file which is inconsistent with the other Polar/regtest
example paths and could cause accidental mainnet usage; update the
lndk_macaroon_file value to a neutral placeholder or align it with the Polar
regtest convention used by lnd_cert_file and lnd_macaroon_file (e.g., replace
the /chain/bitcoin/mainnet/... path with a /chain/bitcoin/regtest/... or a
clearly marked placeholder like /path/to/admin.macaroon) so lndk_cert_file,
lndk_macaroon_file and lnd_macaroon_file consistently reference non-mainnet
example locations.
src/main.rs (1)

126-146: Optional: avoid the second get_node_info() round-trip.

get_node_info() is already called at line 127 to populate LN_STATUS (whose response is moved into LnStatus::from_get_info_response). Calling it again at line 146 just to read features is an extra gRPC round-trip on the boot path. You could either persist the original GetInfoResponse for one-shot reuse or extend LnStatus with a features: HashMap<u32, Feature> field so the second call is unnecessary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main.rs` around lines 126 - 146, The code makes a second gRPC round-trip
by calling ln_client.get_node_info() again to check features for LNDK; reuse the
initial GetInfoResponse instead: modify LnStatus::from_get_info_response or
LnStatus to retain the original GetInfoResponse (or a features map) when you
create LN_STATUS from the first ln_client.get_node_info() call, then replace the
second ln_client.get_node_info().await.ok() usage in the lndk_client branch with
a lookup from LN_STATUS (or LnStatus.features) so the extra get_node_info() call
is removed; update references to LnStatus and LN_STATUS accessors accordingly
(keep LndkConnector::new_from_settings, lndk_client logic unchanged).
src/app/context.rs (1)

42-105: LGTM!

Option<LndkConnector> is the right shape — None cleanly disables BOLT12 payouts at every consumer site (do_payment_bolt12 already handles the runtime-None case explicitly).

Optional follow-up: when BOLT12 unit tests land, consider adding a with_lndk(...) method on TestContextBuilder so handler tests can inject a mock connector instead of going through the always-None path.

Also applies to: 250-250

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/context.rs` around lines 42 - 105, Add a with_lndk(...) builder
method to TestContextBuilder so tests can inject a mock LndkConnector instead of
relying on the always-None path; implement TestContextBuilder::with_lndk(self,
lndk: Option<LndkConnector>) -> Self (or accept LndkConnector and wrap in Some)
to set the builder's lndk field and return Self, and update any test setup that
constructs TestContextBuilder to allow passing a mock connector into
AppContext::new via the builder.
src/config/types.rs (1)

205-262: Optional: align Default with #[serde(default = "...")] helpers.

LightningSettings derives Default, so direct Rust construction (LightningSettings::default() — used in test_settings() and elsewhere) yields lndk_grpc_host = "", lndk_fetch_invoice_timeout = 0, and bip353_doh_resolver = "". TOML deserialization with missing fields, on the other hand, uses the default_* helpers and gets the documented values. Today this is benign because lndk_enabled/bip353_enabled default to false, but anyone toggling those flags in a hand-built settings struct will get a 0-second timeout silently.

Consider implementing Default manually (as MostroSettings and RpcSettings already do) so both paths agree.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 205 - 262, LightningSettings derives
Default but that Default yields empty/zero values for fields that have serde
default helpers (lndk_grpc_host, lndk_fetch_invoice_timeout,
bip353_doh_resolver), causing mismatch between LightningSettings::default() and
TOML deserialization; implement a manual impl Default for LightningSettings that
constructs the struct with the same default values used by the serde helpers
(call default_lndk_grpc_host(), default_lndk_fetch_timeout(),
default_bip353_doh_resolver() for the respective fields) and sensible defaults
for the remaining fields (empty strings, zeros, false as appropriate) so tests
like test_settings() and any hand-built settings match deserialized defaults.
src/lightning/bip353.rs (2)

95-122: Minor: parse_bitcoin_uri doesn't enforce the bitcoin: scheme.

The parser only looks for ? and an lno= parameter, so it would happily extract lno1… from any TXT payload that happens to embed a query string (e.g. a stray https://…?lno=…). For BIP-353 the lookup name is targeted, so the blast radius is small, but a cheap txt.starts_with("bitcoin:") (after dechunking) would make the contract explicit and reject malformed records earlier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/bip353.rs` around lines 95 - 122, The parse_bitcoin_uri
function currently accepts any string with a query containing lno=; after
dechunking/trim you should enforce the BIP-353 scheme by verifying the dechunked
txt starts with "bitcoin:" (e.g., let txt = ...; if !txt.starts_with("bitcoin:")
{ return None; }) before splitting on '?', so malformed or unrelated query
strings are rejected; keep the rest of the logic (query extraction and lno
lookup) unchanged.

27-84: Doc/return-type mismatch: Err is never returned.

The doc comment says "Only returns Err for truly malformed input", but every failure path in the function body returns Ok(None) — there is no path that constructs an Err. Either tighten the signature to Option<String> or drop the Err clause from the docstring (and decide which malformed-input cases should genuinely error out).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/bip353.rs` around lines 27 - 84, The function resolve_bip353
currently has signature pub async fn resolve_bip353(address: &str) ->
Result<Option<String>, MostroError> but never returns Err, so change it to
return Option<String> (pub async fn resolve_bip353(address: &str) ->
Option<String>), update its body to return Some(...) / None as it already does,
remove handling of MostroError, update any call sites to handle Option instead
of Result, and update the doc comment to remove the "Err" clause; alternatively,
if you prefer real error returns, add specific Err returns in resolve_bip353 for
truly malformed input (e.g., when Settings::get_ln() or query_doh fails) and
construct MostroError accordingly—pick one approach and make callers and the
docstring consistent with resolve_bip353 and Settings::get_ln/query_doh usage.
src/app/order.rs (1)

96-101: Minor: avoid double-clone of order.

The block clones order twice — once to materialize an Order for validate_invoice, and again to construct order_with_resolved. You can fold these into a single clone by validating off the already-cloned value.

♻️ Suggested tweak
-        let resolved_invoice = validate_invoice(&msg, &Order::from(order.clone())).await?;
-        let mut order_with_resolved = order.clone();
-        if resolved_invoice.is_some() {
-            order_with_resolved.buyer_invoice = resolved_invoice;
-        }
-        let order = &order_with_resolved;
+        let mut order_with_resolved = order.clone();
+        let resolved_invoice =
+            validate_invoice(&msg, &Order::from(order_with_resolved.clone())).await?;
+        if resolved_invoice.is_some() {
+            order_with_resolved.buyer_invoice = resolved_invoice;
+        }
+        let order = &order_with_resolved;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/order.rs` around lines 96 - 101, Clone `order` only once by creating
a single mutable clone (e.g., let mut order_with_resolved = order.clone()), then
call validate_invoice(&msg, &Order::from(...)) using that cloned value (either
by borrowing or moving into Order::from as the type permits) instead of cloning
again, update order_with_resolved.buyer_invoice with the resolved invoice when
present, and then use &order_with_resolved for the rest; adjust the call sites
around validate_invoice, Order::from, and order_with_resolved to avoid the
second clone.
src/lightning/lndk.rs (3)

163-168: Redundant amount on PayInvoiceRequest for an amount-bearing invoice.

The fetched invoice already pins amount_msats, and validate_fetched_invoice (lines 155–161) has just confirmed it matches amount_msats. Sending amount: Some(amount_msats) again is harmless today but duplicates the source of truth — if LNDK ever treats this field as an override (for amountless invoices) it could mask a desync. Setting it to None here would make the intent clearer and rely solely on the validated invoice contents.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/lndk.rs` around lines 163 - 168, The PayInvoiceRequest
currently sets amount: Some(amount_msats) even though validate_fetched_invoice
already confirmed the fetched invoice (fetched.invoice_hex_str) contains the
expected amount_msats; remove the redundant amount field by setting amount to
None in the PayInvoiceRequest construction so the request relies solely on the
validated invoice contents (update the PayInvoiceRequest used when building
pay_req and ensure validate_fetched_invoice remains the source of truth).

82-87: Hardcoded localhost SNI may fail for non-sidecar LNDK deployments.

This works when LNDK runs on the same host as Mostro (the documented common case), but if an operator points lndk_grpc_host at a remote LNDK whose TLS cert was generated for a different SAN (e.g. its real hostname), the TLS handshake will fail with a confusing "invalid certificate: name not in cert" error. Consider deriving the SNI from lndk_grpc_host (stripping scheme/port) and only falling back to "localhost", or exposing it as a setting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/lndk.rs` around lines 82 - 87, The TLS SNI is currently
hardcoded to "localhost" which breaks remote LNDK deployments; change the
ClientTlsConfig::domain_name call to derive the name from the configured
lndk_grpc_host (parse/strip any scheme and port to get the hostname) and pass
that hostname to domain_name, falling back to "localhost" only if parsing yields
nothing or the host is empty; update the code around
Certificate::from_pem(&cert) / ClientTlsConfig::new() to use the derived SNI or
expose it as a config value.

107-114: The field name lndk_fee_limit_percent is misleading — it stores a fraction, not a percent.

The field is documented in src/config/types.rs as "Fee limit for BOLT12 payments as percent", but the code at line 112–114 treats it as a fraction (multiplying by 100.0 and clamping). The template example lndk_fee_limit_percent = 0.2 and fallback to mostro_settings.max_routing_fee (which is also a fraction) both confirm the actual units are fractional. An operator reading the config name or the field's doc comment would reasonably set lndk_fee_limit_percent = 2 expecting "2%", but this would be interpreted as 200%. Rename to lndk_fee_limit_fraction (matching max_routing_fee naming), or accept a true percent and divide by 100 at the config layer. Also update the doc comment in src/config/types.rs:236 to say "fraction" instead of "percent".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/lndk.rs` around lines 107 - 114, The config field
lndk_fee_limit_percent is misnamed — it stores a fractional value (like
max_routing_fee) but is documented/ named as a percent; rename it to
lndk_fee_limit_fraction in the config type and update its doc comment to say
"fraction" (and update any example/template values to use fractional form), then
update usages in lightning/lndk.rs (replace lndk_fee_limit_percent with
lndk_fee_limit_fraction when building fee_fraction for fee_limit_percent) and
any other places that reference the old symbol; to avoid breaking existing
configs, add a serde alias/rename for backward compatibility (e.g. #[serde(alias
= "lndk_fee_limit_percent")] or serde rename) on the new field.
src/lightning/offers.rs (3)

99-132: Consider rejecting offers whose chain set excludes the active network.

validate_offer checks denomination but not offer.chains(). A buyer can submit a perfectly-parseable BOLT12 offer that only advertises e.g. signet while Mostro/LND/LNDK are on mainnet; that offer will pass validation here and fail later at fetch/pay time, leaving the order in a degraded state. A defensive check against the configured network's chain hash up front would surface the mismatch synchronously, with the same InvoiceInvalidError semantics already used for currency mismatches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/offers.rs` around lines 99 - 132, validate_offer currently
skips checking offer.chains(), so add a defensive check after parsing the Offer
(after Offer::from_str and before the amount match) that the parsed
offer.chains() includes the active network's chain hash and return
MostroInternalErr(ServiceError::InvoiceInvalidError) if it does not; obtain the
active network chain hash from the same config/source used elsewhere in the
module (or add an active_chain_hash parameter to validate_offer if no accessor
exists) so a non-matching offer (e.g., signet vs mainnet) is rejected
synchronously.

51-57: Redundant prefix checks: lntbs and lnbcrt are shadowed.

starts_with("lntb") already matches lntbs…, and starts_with("lnbc") already matches lnbcrt…, so the explicit lntbs / lnbcrt arms are unreachable. All four currently map to Bolt11 so there is no functional bug, but the redundancy is misleading — readers may infer signet/regtest are being treated specially.

♻️ Cleanup
-    if lower.starts_with("lnbc")
-        || lower.starts_with("lntb")
-        || lower.starts_with("lntbs")
-        || lower.starts_with("lnbcrt")
-    {
+    if lower.starts_with("lnbc") || lower.starts_with("lntb") {
         return InvoiceFormat::Bolt11;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/offers.rs` around lines 51 - 57, The prefix checks in the block
that returns InvoiceFormat::Bolt11 are redundant: remove the explicit
starts_with("lntbs") and starts_with("lnbcrt") checks and keep only
starts_with("lnbc") and starts_with("lntb") (the code that builds/uses the lower
variable and returns InvoiceFormat::Bolt11 should remain), so behavior is
unchanged but the unreachable arms are eliminated and readability improved;
update any nearby comment if it suggests special-casing signet/regtest to avoid
confusion.

144-151: Quantity::Bounded(n).get() < 1 is unreachable code.

The lightning crate's Quantity::Bounded variant wraps a NonZeroU64, so n.get() is guaranteed to be ≥ 1. The check will never trigger and can be removed. Simplify the match arm to accept any bounded quantity:

Suggested cleanup
     match offer.supported_quantity() {
-        Quantity::One | Quantity::Unbounded => {}
-        Quantity::Bounded(n) => {
-            if n.get() < 1 {
-                return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
-            }
-        }
+        Quantity::One | Quantity::Unbounded | Quantity::Bounded(_) => {}
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lightning/offers.rs` around lines 144 - 151, The match on
offer.supported_quantity() in offers.rs contains an unreachable check: inside
the Quantity::Bounded(n) arm the code tests if n.get() < 1 and returns an error,
but Quantity::Bounded holds a NonZeroU64 so n.get() is always ≥ 1; remove that
conditional and simply accept the Bounded(n) arm (i.e., drop the n.get() < 1
check and the MostroInternalErr return) so the match becomes a no-op for One,
Unbounded, and Bounded.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/LNDK_SETUP.md`:
- Line 13: Add explicit language specifiers to the two fenced code blocks that
currently have none: change the block containing the ASCII diagram "Buyer  ----
Nostr ---->  Mostro  ----gRPC---->  LNDK  ----gRPC---->  LND …" to start with
```text and change the block containing the configuration snippet beginning with
"[protocol]" to start with ```ini so both blocks satisfy markdownlint MD040.

In `@settings.tpl.toml`:
- Around line 21-46: The template added required-looking keys that will break
existing configs; update LightningSettings in src/config/types.rs to make the
new fields optional by adding #[serde(default)] to lndk_enabled, lndk_cert_file,
lndk_macaroon_file, bip353_enabled, and bip353_skip_dnssec (or alternatively
revert these entries in settings.tpl.toml to commented form like the
[anti_abuse_bond] pattern); ensure the struct field names exactly match those
keys and add sensible Rust defaults if needed, and document the chosen approach
in the changelog.

In `@src/lightning/bip353.rs`:
- Around line 27-84: The logs in resolve_bip353 currently emit the full BIP-353
address (user@domain); change all tracing calls to avoid logging the raw
local-part by only including the domain (domain) and either a short
deterministic hash or redact of the user (e.g. hash of user or
"[redacted_user]") instead of address. Update the tracing::warn/info/debug calls
that currently reference {address} (the DoH failure, DNS status, DNSSEC warning,
resolved bolt12, and no-offer debug messages) to include domain=%domain and
user_hash=%user_hash (or user="[redacted]") so diagnostics keep domain context
but do not expose the plain user identifier; compute user_hash from the local
variable user before logging.

In `@src/util.rs`:
- Around line 1251-1265: The logs in the BIP-353 resolution branch leak the full
user@domain payout address via tracing::info! and tracing::warn!; update the
logging in the block that calls
crate::lightning::bip353::resolve_bip353(&pr).await so it does not emit the raw
pr value—either redact the local-part (log only "@domain"), or log a stable
short hash (e.g., SHA-256 first 8 bytes) of pr, or lower the level to debug;
change the tracing::info! and tracing::warn! calls that reference {pr} to use
the chosen non-identifying representation instead while leaving the rest of the
control flow and returned value (offer or pr) unchanged.

---

Outside diff comments:
In `@src/util.rs`:
- Around line 1244-1286: The current logic in util.rs resolves BIP-353 addresses
via crate::lightning::bip353::resolve_bip353 and then stores the resolved BOLT12
offer in order.buyer_invoice; instead, keep the original user@domain string in
order.buyer_invoice (do not replace it with the resolved offer) and only use
resolve_bip353 for validation (is_valid_invoice) or temporary checks here; then,
remove usage of the stored offer in do_payment_bolt12 and instead call
resolve_bip353 again from do_payment (before routing to do_payment_bolt12) to
fetch the live BOLT12 offer at payout time so offer rotation/expiration is
respected. Ensure resolve_bip353, is_valid_invoice, order.buyer_invoice,
do_payment, and do_payment_bolt12 are the referenced symbols to locate changes.

---

Nitpick comments:
In `@build.rs`:
- Around line 4-7: Add cargo rebuild triggers for the proto inputs so Cargo
reruns the build script when proto files change: in the build script where
tonic_prost_build::configure() and .compile_protos(...) are invoked, emit
println!("cargo:rerun-if-changed=proto/lndkrpc.proto") and
println!("cargo:rerun-if-changed=proto/admin.proto") (optionally also for the
proto directory) before calling compile_protos to ensure generated Tonic code is
regenerated on proto edits.

In `@settings.tpl.toml`:
- Around line 28-30: The template uses a mainnet macaroon path for
lndk_macaroon_file which is inconsistent with the other Polar/regtest example
paths and could cause accidental mainnet usage; update the lndk_macaroon_file
value to a neutral placeholder or align it with the Polar regtest convention
used by lnd_cert_file and lnd_macaroon_file (e.g., replace the
/chain/bitcoin/mainnet/... path with a /chain/bitcoin/regtest/... or a clearly
marked placeholder like /path/to/admin.macaroon) so lndk_cert_file,
lndk_macaroon_file and lnd_macaroon_file consistently reference non-mainnet
example locations.

In `@src/app/context.rs`:
- Around line 42-105: Add a with_lndk(...) builder method to TestContextBuilder
so tests can inject a mock LndkConnector instead of relying on the always-None
path; implement TestContextBuilder::with_lndk(self, lndk: Option<LndkConnector>)
-> Self (or accept LndkConnector and wrap in Some) to set the builder's lndk
field and return Self, and update any test setup that constructs
TestContextBuilder to allow passing a mock connector into AppContext::new via
the builder.

In `@src/app/order.rs`:
- Around line 96-101: Clone `order` only once by creating a single mutable clone
(e.g., let mut order_with_resolved = order.clone()), then call
validate_invoice(&msg, &Order::from(...)) using that cloned value (either by
borrowing or moving into Order::from as the type permits) instead of cloning
again, update order_with_resolved.buyer_invoice with the resolved invoice when
present, and then use &order_with_resolved for the rest; adjust the call sites
around validate_invoice, Order::from, and order_with_resolved to avoid the
second clone.

In `@src/config/types.rs`:
- Around line 205-262: LightningSettings derives Default but that Default yields
empty/zero values for fields that have serde default helpers (lndk_grpc_host,
lndk_fetch_invoice_timeout, bip353_doh_resolver), causing mismatch between
LightningSettings::default() and TOML deserialization; implement a manual impl
Default for LightningSettings that constructs the struct with the same default
values used by the serde helpers (call default_lndk_grpc_host(),
default_lndk_fetch_timeout(), default_bip353_doh_resolver() for the respective
fields) and sensible defaults for the remaining fields (empty strings, zeros,
false as appropriate) so tests like test_settings() and any hand-built settings
match deserialized defaults.

In `@src/lightning/bip353.rs`:
- Around line 95-122: The parse_bitcoin_uri function currently accepts any
string with a query containing lno=; after dechunking/trim you should enforce
the BIP-353 scheme by verifying the dechunked txt starts with "bitcoin:" (e.g.,
let txt = ...; if !txt.starts_with("bitcoin:") { return None; }) before
splitting on '?', so malformed or unrelated query strings are rejected; keep the
rest of the logic (query extraction and lno lookup) unchanged.
- Around line 27-84: The function resolve_bip353 currently has signature pub
async fn resolve_bip353(address: &str) -> Result<Option<String>, MostroError>
but never returns Err, so change it to return Option<String> (pub async fn
resolve_bip353(address: &str) -> Option<String>), update its body to return
Some(...) / None as it already does, remove handling of MostroError, update any
call sites to handle Option instead of Result, and update the doc comment to
remove the "Err" clause; alternatively, if you prefer real error returns, add
specific Err returns in resolve_bip353 for truly malformed input (e.g., when
Settings::get_ln() or query_doh fails) and construct MostroError
accordingly—pick one approach and make callers and the docstring consistent with
resolve_bip353 and Settings::get_ln/query_doh usage.

In `@src/lightning/lndk.rs`:
- Around line 163-168: The PayInvoiceRequest currently sets amount:
Some(amount_msats) even though validate_fetched_invoice already confirmed the
fetched invoice (fetched.invoice_hex_str) contains the expected amount_msats;
remove the redundant amount field by setting amount to None in the
PayInvoiceRequest construction so the request relies solely on the validated
invoice contents (update the PayInvoiceRequest used when building pay_req and
ensure validate_fetched_invoice remains the source of truth).
- Around line 82-87: The TLS SNI is currently hardcoded to "localhost" which
breaks remote LNDK deployments; change the ClientTlsConfig::domain_name call to
derive the name from the configured lndk_grpc_host (parse/strip any scheme and
port to get the hostname) and pass that hostname to domain_name, falling back to
"localhost" only if parsing yields nothing or the host is empty; update the code
around Certificate::from_pem(&cert) / ClientTlsConfig::new() to use the derived
SNI or expose it as a config value.
- Around line 107-114: The config field lndk_fee_limit_percent is misnamed — it
stores a fractional value (like max_routing_fee) but is documented/ named as a
percent; rename it to lndk_fee_limit_fraction in the config type and update its
doc comment to say "fraction" (and update any example/template values to use
fractional form), then update usages in lightning/lndk.rs (replace
lndk_fee_limit_percent with lndk_fee_limit_fraction when building fee_fraction
for fee_limit_percent) and any other places that reference the old symbol; to
avoid breaking existing configs, add a serde alias/rename for backward
compatibility (e.g. #[serde(alias = "lndk_fee_limit_percent")] or serde rename)
on the new field.

In `@src/lightning/offers.rs`:
- Around line 99-132: validate_offer currently skips checking offer.chains(), so
add a defensive check after parsing the Offer (after Offer::from_str and before
the amount match) that the parsed offer.chains() includes the active network's
chain hash and return MostroInternalErr(ServiceError::InvoiceInvalidError) if it
does not; obtain the active network chain hash from the same config/source used
elsewhere in the module (or add an active_chain_hash parameter to validate_offer
if no accessor exists) so a non-matching offer (e.g., signet vs mainnet) is
rejected synchronously.
- Around line 51-57: The prefix checks in the block that returns
InvoiceFormat::Bolt11 are redundant: remove the explicit starts_with("lntbs")
and starts_with("lnbcrt") checks and keep only starts_with("lnbc") and
starts_with("lntb") (the code that builds/uses the lower variable and returns
InvoiceFormat::Bolt11 should remain), so behavior is unchanged but the
unreachable arms are eliminated and readability improved; update any nearby
comment if it suggests special-casing signet/regtest to avoid confusion.
- Around line 144-151: The match on offer.supported_quantity() in offers.rs
contains an unreachable check: inside the Quantity::Bounded(n) arm the code
tests if n.get() < 1 and returns an error, but Quantity::Bounded holds a
NonZeroU64 so n.get() is always ≥ 1; remove that conditional and simply accept
the Bounded(n) arm (i.e., drop the n.get() < 1 check and the MostroInternalErr
return) so the match becomes a no-op for One, Unbounded, and Bounded.

In `@src/main.rs`:
- Around line 126-146: The code makes a second gRPC round-trip by calling
ln_client.get_node_info() again to check features for LNDK; reuse the initial
GetInfoResponse instead: modify LnStatus::from_get_info_response or LnStatus to
retain the original GetInfoResponse (or a features map) when you create
LN_STATUS from the first ln_client.get_node_info() call, then replace the second
ln_client.get_node_info().await.ok() usage in the lndk_client branch with a
lookup from LN_STATUS (or LnStatus.features) so the extra get_node_info() call
is removed; update references to LnStatus and LN_STATUS accessors accordingly
(keep LndkConnector::new_from_settings, lndk_client logic unchanged).

In `@src/util.rs`:
- Line 1251: The current heuristic `pr.contains('@') && !pr.contains(' ')` is
too permissive and forwards degenerate inputs to resolve_bip353; replace it by
attempting to parse as a LightningAddress first (e.g. call
LightningAddress::from_str(&pr).is_ok()) or use the stricter validation used in
is_valid_invoice so only well-formed addresses are sent to resolve_bip353;
update the branch that sets `pr` to call LightningAddress::from_str(&pr).is_ok()
(or an equivalent regex) before invoking resolve_bip353 to avoid unnecessary DNS
lookups on invalid inputs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 03f4e435-f884-4f07-bae3-2cf5d320eb9b

📥 Commits

Reviewing files that changed from the base of the PR and between fb4d229 and 0106b07.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • Cargo.toml
  • build.rs
  • docs/LNDK_SETUP.md
  • docs/STARTUP_AND_CONFIG.md
  • proto/lndkrpc.proto
  • settings.tpl.toml
  • src/app/context.rs
  • src/app/order.rs
  • src/app/release.rs
  • src/config/types.rs
  • src/config/wizard.rs
  • src/lightning/bip353.rs
  • src/lightning/invoice.rs
  • src/lightning/lndk.rs
  • src/lightning/mod.rs
  • src/lightning/offers.rs
  • src/main.rs
  • src/rpc/service.rs
  • src/util.rs

Comment thread docs/LNDK_SETUP.md Outdated
Comment thread settings.tpl.toml
Comment thread src/lightning/bip353.rs Outdated
Comment thread src/util.rs Outdated
Copy link
Copy Markdown
Member

@Catrya Catrya left a comment

Choose a reason for hiding this comment

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

Hi @QaidVoid Thanks for the work on this PR, it’s a great feature. Before moving forward, it should be split into two. This PR touches 20 files, we recommend keeping PRs limited to fewer files and focused on a single feature at a time.
It mixes BOLT12 (via LNDK) and BIP-353 (DoH resolver), which have different dependencies, risks, and operational implications. They shouldn’t be reviewed or shipped as a single unit.
BIP-353 depends on BOLT12, but not the other way around. It makes more sense to land BOLT12 first, and then in other PR add BIP-353.

@Catrya
Copy link
Copy Markdown
Member

Catrya commented May 4, 2026

@QaidVoid I also asked Claude Code (Opus 4.7) to do a deep review, and here’s its feedback, please take this along with CodeRabbit’s review into account when working on the other PRs. Happy to discuss any of these. Thanks again for the work, splitting and addressing the issues above will get this landed cleanly.

Issues found in BOLT12 (PR 1 scope)

Blocking

  • Admin/dispute paths cannot pay BOLT12 buyers. src/rpc/service.rs constructs AppContext with lndk: None in all four admin actions (admin_cancel, admin_settle, admin_add_solver, admin_take_dispute). If admin_settle or a dispute resolution triggers a payout to a buyer whose buyer_invoice is a BOLT12 offer, do_payment_bolt12 will fail with "BOLT12 offer received but LNDK is disabled". Disputes for BOLT12 buyers will get stuck. Either pass the LNDK client through to the RPC layer, or hoist it to a global like MOSTRO_CONFIG.
  • fee_limit_percent calculation inflates fee tolerance ~10×. In src/lightning/lndk.rs: let fee_fraction = ln.lndk_fee_limit_percent.unwrap_or(mostro_settings.max_routing_fee); let fee_limit_percent = ((fee_fraction * 100.0).ceil().max(1.0)) as u32;
  • LNDK expects whole-number percent; max_routing_fee is a fraction. With the typical max_routing_fee = 0.001 (0.1%), this becomes ceil(0.1).max(1.0) = 1 → 1%, a 10× increase versus the BOLT11 path. With 0.0001 (0.01%) the ratio is 100×. Operators will reasonably expect the same max_routing_fee setting to mean the same thing on both paths. Consider using PayInvoiceRequest.fee_limit (absolute msat: ceil(amount_msats * fee_fraction)) instead of fee_limit_percent to preserve precision.
  • Quantity::Bounded(n) if n.get() < 1 is dead code. In validate_offer (src/lightning/offers.rs), NonZeroU64::get() is always ≥ 1, so the branch is unreachable and any Bounded(n) is silently accepted. If an offer requires a minimum quantity > 1, Mostro accepts it and the failure surfaces later as a confusing LNDK GetInvoice error. The intent was probably to reject offers that can't satisfy a single-unit purchase — please rework that match.
  • expected_msats == 0 silently accepts any offer amount. Same function, the if expected_msats != 0 && amount_msats != expected_msats skip lets a zero-amount order accept an offer with an arbitrary fixed amount. Defense-in-depth should error here, not accept.

Serious

  • Hardcoded SNI "localhost". src/lightning/lndk.rs does .domain_name("localhost") regardless of the configured lndk_grpc_host. Remote LNDK deployments are blocked. Please make the TLS domain name configurable (e.g. lndk_tls_domain, default "localhost").
  • payer_note: format!("mostro:{order_id}") leaks correlation to the recipient wallet. The receiver sees mostro:<uuid> and can correlate the order ID (which is publicly known via Nostr) with their wallet. Consider omitting the payer note or using a truncated hash without the mostro: prefix.
  • min_expiry_secs = invoice_expiration_window may be overly strict. BOLT12 invoices are short-lived by design (fetched right before payment). Reusing the 15-minute hold window as the minimum remaining lifetime can reject legitimately fresh invoices. Worth a separate, smaller setting (e.g. bolt12_min_invoice_expiry, default 60s).
  • Vendored proto/lndkrpc.proto pinned at v0.3.0 by hand. If LNDK upstream changes a field, Mostro silently breaks at runtime. At minimum, document the LNDK version requirement loudly in LNDK_SETUP.md. Ideally pull the proto from a tagged release as part of the build.
  • do_payment_bolt12 is fire-and-forget without panic/error visibility. src/lightning/lndk.rs tokio::spawn returns immediately; if the task panics, nothing is reported. This matches the existing LND streaming behavior, so it's consistent — but worth instrumenting (JoinHandle capture, or tracing span on the spawned task).
  • No integration tests for LNDK gRPC. Bugs like the fee_limit_percent one above wouldn't be caught by the current unit tests. The PR mentions a regtest topology was used, but there's nothing automated. For an experimental opt-in feature this is acceptable for now, but worth tracking in a follow-up.

Minor

  • kind_tag(InvoiceFormat::Bolt12Invoice) returns Some("bolt12_invoice") but is_valid_invoice always rejects that variant. Dead path; either remove it or add a comment that it exists for forward compatibility.
  • Onion-message feature bit detection (in main.rs) — checking bits 38/39 advertises onion-message support, but doesn't guarantee the operator actually started LND with --protocol.custom-message=513. The warning is informative only; consider linking the exact config line in the message.
  • Macaroon scope in docs. The setup guide should explicitly recommend a baked macaroon (lncli bakemacaroon) with minimum permissions rather than admin.macaroon. Compromise of LNDK currently means compromise of LND admin.

Issues to address in BIP-353 (PR 2)

Please don't ship these as-is in any future PR; flagging now so they're on the radar:

  • Critical: BIP-353 resolution is one-shot at order creation, defeating offer rotation. In src/util.rs, validate_invoice resolves user@domain to a BOLT12 offer and the resolved offer gets written to order.buyer_invoice. At payout time (potentially hours later), do_payment reads the cached offer and pays it without re-resolving DNS. The whole point of BIP-353 is that the wallet can rotate the offer in DNS between the time the buyer provides their address and payout — caching the offer at order creation eliminates that. The original user@domain should be stored separately (e.g. buyer_invoice_alias) and re-resolved at payout time, with the cached offer or LNURL as fallback.
  • Privacy: full user@domain is logged at info and warn levels. In bip353.rs and the BIP-353 block in src/util.rs::validate_invoice. Any log aggregator (Loki, ELK, Datadog) captures buyer payment identifiers. Please log only the domain or a hashed local-part.
  • Privacy: Cloudflare DoH default exposes payment metadata to a third party. Every resolution sends <user>.user._bitcoin-payment.<domain> from the Mostro node's IP to Cloudflare — i.e. "Mostro is about to pay user@domain". This isn't documented as a privacy trade-off in STARTUP_AND_CONFIG.md. Consider defaulting to a less-centralized option, or at minimum documenting the implication and recommending operators run a local resolver.
  • bip353_skip_dnssec is a footgun with no runtime warning. Without DNSSEC validation, an attacker on the DNS path can redirect payouts. The flag is documented as "regtest only" in a comment, but nothing prints a loud warning at startup if it's enabled in production. At minimum, log a banner when the flag is set.
  • "DNSSEC validated" is trust-the-resolver, not end-to-end. The AD flag comes from the DoH resolver — if the resolver is compromised or the TLS to it is intercepted, the flag is forgeable. The doc should be explicit that Mostro trusts the resolver to have validated DNSSEC, rather than implying end-to-end DNSSEC verification.

@QaidVoid QaidVoid force-pushed the feat/bolt12-lndk branch from 0106b07 to d37ac14 Compare May 10, 2026 13:20
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@settings.tpl.toml`:
- Line 34: The sample setting lndk_macaroon_file uses a mainnet path which is
inconsistent with the surrounding regtest-oriented examples; update the
lndk_macaroon_file example value to a regtest-consistent path (e.g., replace any
".../chain/bitcoin/mainnet/..." with the regtest equivalent) so the template
aligns with other sample values and avoids copy/paste startup errors when using
regtest; locate the lndk_macaroon_file entry in settings.tpl.toml to make this
change.

In `@src/config/types.rs`:
- Around line 206-207: The struct-level #[serde(default)] on LightningSettings
is too broad and lets legacy required fields (lnd_cert_file, lnd_macaroon_file,
lnd_grpc_host) deserialize as empty strings; remove the #[serde(default)] from
the LightningSettings struct and instead add field-level #[serde(default)] only
to the new optional LNDK fields (identify them by name in the struct) so legacy
fields remain required during deserialization while new fields still get
defaults; update the LightningSettings definition to remove the top-level
attribute and annotate only the new optional fields with #[serde(default)].
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2da84603-0836-4959-93ce-27954f53c4ac

📥 Commits

Reviewing files that changed from the base of the PR and between 0106b07 and d37ac14.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • Cargo.toml
  • build.rs
  • docs/LNDK_SETUP.md
  • docs/STARTUP_AND_CONFIG.md
  • proto/lndkrpc.proto
  • settings.tpl.toml
  • src/app/context.rs
  • src/app/release.rs
  • src/config/types.rs
  • src/config/wizard.rs
  • src/lightning/invoice.rs
  • src/lightning/lndk.rs
  • src/lightning/mod.rs
  • src/lightning/offers.rs
  • src/main.rs
  • src/rpc/server.rs
  • src/rpc/service.rs
✅ Files skipped from review due to trivial changes (3)
  • src/lightning/mod.rs
  • docs/STARTUP_AND_CONFIG.md
  • docs/LNDK_SETUP.md
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/main.rs
  • src/lightning/invoice.rs
  • src/app/context.rs
  • build.rs
  • src/lightning/lndk.rs
  • src/lightning/offers.rs
  • src/app/release.rs
  • proto/lndkrpc.proto

Comment thread settings.tpl.toml Outdated
Comment thread src/config/types.rs Outdated
@QaidVoid QaidVoid force-pushed the feat/bolt12-lndk branch from d37ac14 to c807bd1 Compare May 10, 2026 14:00
Accept BOLT12 offers (lno1...) as buyer payout destinations by
routing through an optional LNDK daemon alongside LND. LNDK
implements BOLT12 (invoice_request onion messaging, LDK offer
parsing); LND continues to handle routing.

Opt-in via lightning.lndk_enabled (default false). When disabled,
BOLT12 offers are rejected at validation time. Setup requirements
are in docs/LNDK_SETUP.md.

The BOLT11 path is unchanged. BOLT12 lives in do_payment_bolt12
so LND's streaming payment API and LNDK's synchronous RPC don't
share plumbing. Uses two-step GetInvoice -> validate -> PayInvoice
rather than PayOffer, which does not verify amount or expiry.
@QaidVoid QaidVoid force-pushed the feat/bolt12-lndk branch from c807bd1 to 55dc7e9 Compare May 10, 2026 14:03
@QaidVoid QaidVoid changed the title feat: BOLT12 offer payouts via LNDK and BIP-353 DNS resolution feat(bolt12): add BOLT12 offer payout support via LNDK May 10, 2026
@QaidVoid
Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@QaidVoid
Copy link
Copy Markdown
Author

Hi @Catrya, thanks for the thorough review. I've pushed fixes addressing all the feedback. This PR is now focused only on BOLT12. Ready for another look when you have a moment.

@QaidVoid QaidVoid requested a review from Catrya May 11, 2026 12:21
@grunch
Copy link
Copy Markdown
Member

grunch commented May 11, 2026

@QaidVoid thank you for your valuable contribution, this is a great new feature that Mostro users would appreciate.

Unfortunately this PR is getting too big, we are already in production with mostros around the world and we can't afford to introduce a bug.

When we need to implement features like this one we split it on smaller phases, you can see this example on closed issues on recently we are doing a big and sensitive multiple phases PR to add anti abuse bond (#731)

The spec must indicate all phases and what do each one in detail and update the spec indicating when each one is completed.

Every phase must be backwards compatible if possible, if not we should indicate it in the spec at the beginning to prepare a minor release in the future.

If you have doubts let me know

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.

3 participants