diff --git a/Cargo.lock b/Cargo.lock index 7e26631f..3c6285bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,6 +773,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "dnssec-prover" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" + [[package]] name = "dotenvy" version = "0.15.7" @@ -1111,6 +1117,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1546,6 +1558,12 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.10" @@ -1567,6 +1585,23 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lightning" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c90397b635e3ece6b9a723fb470a46cb9b3592f217d72e40540a5fada00289d" +dependencies = [ + "bech32", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice 0.34.0", + "lightning-macros", + "lightning-types 0.3.1", + "possiblyrandom", +] + [[package]] name = "lightning-invoice" version = "0.33.2" @@ -1575,7 +1610,29 @@ checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600" dependencies = [ "bech32", "bitcoin", - "lightning-types", + "lightning-types 0.2.0", +] + +[[package]] +name = "lightning-invoice" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85e5e14bcdb30d746e9785b04f27938292e8944f78f26517e01e91691f6b3f2" +dependencies = [ + "bech32", + "bitcoin", + "lightning-types 0.3.1", +] + +[[package]] +name = "lightning-macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", ] [[package]] @@ -1587,6 +1644,15 @@ dependencies = [ "bitcoin", ] +[[package]] +name = "lightning-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1aac93f22f2c2eac8a0ee83bb1a1ea58673caa2c82847302710b83364d04e6" +dependencies = [ + "bitcoin", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1742,7 +1808,9 @@ dependencies = [ "dirs", "easy-hasher", "fedimint-tonic-lnd", - "lightning-invoice", + "hex", + "lightning", + "lightning-invoice 0.33.2", "lnurl-rs", "mostro-core", "mutants", @@ -2125,6 +2193,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "possiblyrandom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b122a615d72104fb3d8b26523fdf9232cd8ee06949fb37e4ce3ff964d15dffd" +dependencies = [ + "getrandom 0.2.16", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3401,6 +3478,7 @@ dependencies = [ "socket2 0.6.1", "sync_wrapper", "tokio", + "tokio-rustls 0.26.4", "tokio-stream", "tower", "tower-layer", diff --git a/Cargo.toml b/Cargo.toml index 7b2d024f..f4e7579e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ mutants = "0.0.3" # For #[mutants::skip] annotations chrono = "0.4.35" easy-hasher = "2.2.1" lightning-invoice = { version = "0.33.1", features = ["std"] } +lightning = { version = "0.2.2", default-features = false, features = ["std"] } nostr-sdk = { version = "0.44.1", features = ["nip44", "nip59"] } serde = { version = "1.0.210" } toml = "0.9.5" @@ -80,9 +81,10 @@ bitcoin = "0.32.5" dialoguer = "0.11" dirs = "6.0.0" clearscreen = "4.0.1" -tonic = "0.14.2" +tonic = { version = "0.14.2", features = ["tls-ring"] } prost = "0.14.1" tonic-prost = "0.14.1" +hex = "0.4" [dev-dependencies] tokio = { version = "1.47.1", features = ["full", "test-util", "macros"] } diff --git a/build.rs b/build.rs index 818b0fb1..14042fee 100644 --- a/build.rs +++ b/build.rs @@ -3,7 +3,7 @@ fn main() { // Compile protobuf definitions tonic_prost_build::configure() .protoc_arg("--experimental_allow_proto3_optional") - .compile_protos(&["proto/admin.proto"], &["proto"]) + .compile_protos(&["proto/admin.proto", "proto/lndkrpc.proto"], &["proto"]) .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e)); // note: add error checking yourself. diff --git a/docs/LNDK_SETUP.md b/docs/LNDK_SETUP.md new file mode 100644 index 00000000..e252434e --- /dev/null +++ b/docs/LNDK_SETUP.md @@ -0,0 +1,203 @@ +# BOLT12 Offer Payouts via LNDK + +Mostro optionally supports paying buyer payouts to BOLT12 offers (`lno1…`) by +talking to an [LNDK](https://github.com/lndk-org/lndk) daemon that runs +alongside LND. This page documents the operator setup. + +> **Experimental.** BOLT12, LND's onion messaging support, and LNDK itself +> are all still maturing. Leave `lndk_enabled = false` unless you understand +> the risks and want to opt in. + +## Architecture + +``` +Buyer ---- Nostr ----> Mostro ----gRPC----> LNDK ----gRPC----> LND +(sends lno1…) | | | + | | | + +--BOLT11/LNURL path---|------gRPC--------->+ + | + +- onion messaging -+ + (via LND custom + message 513) +``` + +- LND handles everything Mostro always did: hold invoices, routing, channels. +- LNDK is a thin shim that uses LDK's BOLT12 implementation to build + `invoice_request` messages, fetch invoices from offer issuers, and hand + payable invoices back to LND. +- Mostro calls LNDK's gRPC only for the buyer-payout step when + `order.buyer_invoice` is a BOLT12 offer. All other payment paths are + untouched. + +## Prerequisites + +### LND ≥ v0.18.0 with the right build tags + +Build (or install a package built) with the subservers LNDK needs: + +```sh +make install tags="peersrpc signrpc walletrpc" +``` + +### LND startup flags + +LND must advertise and forward onion messages. Add to `lnd.conf`: + +``` +[protocol] +protocol.custom-message=513 +protocol.custom-nodeann=39 +protocol.custom-init=39 +``` + +Or pass them on the command line: + +```sh +lnd --protocol.custom-message=513 \ + --protocol.custom-nodeann=39 \ + --protocol.custom-init=39 +``` + +On startup Mostro checks the LND feature bits and logs a warning if these +flags appear to be missing. + +### Install and run LNDK + +Follow LNDK's own setup guide at +. In short: + +```sh +git clone https://github.com/lndk-org/lndk +cd lndk +cargo run --bin=lndk -- \ + --address=https://127.0.0.1:10009 \ + --cert-path=/path/to/.lnd/tls.cert \ + --macaroon-path=/path/to/.lnd/data/chain/bitcoin/mainnet/admin.macaroon +``` + +By default LNDK writes its self-signed TLS cert to `~/.lndk/data/tls-cert.pem` +and listens on `https://127.0.0.1:7000`. + +### Bake a custom macaroon for Mostro + +Use a minimally scoped macaroon instead of `admin.macaroon`: + +```sh +lncli bakemacaroon --save_to=/path/to/mostro-lndk.macaroon \ + uri:/walletrpc.WalletKit/DeriveKey \ + uri:/signrpc.Signer/SignMessage \ + uri:/lnrpc.Lightning/GetNodeInfo \ + uri:/lnrpc.Lightning/ConnectPeer \ + uri:/lnrpc.Lightning/GetInfo \ + uri:/lnrpc.Lightning/ListPeers \ + uri:/lnrpc.Lightning/GetChanInfo \ + uri:/lnrpc.Lightning/QueryRoutes \ + uri:/routerrpc.Router/SendToRouteV2 \ + uri:/routerrpc.Router/TrackPaymentV2 +``` + +## Mostro configuration + +In `settings.toml`, fill in the `[lightning]` LNDK fields: + +```toml +[lightning] +# ... existing LND settings ... + +lndk_enabled = true +lndk_grpc_host = "https://127.0.0.1:7000" +lndk_cert_file = "/home/mostro/.lndk/data/tls-cert.pem" +lndk_macaroon_file = "/home/mostro/mostro-lndk.macaroon" +lndk_fetch_invoice_timeout = 60 +# lndk_fee_limit_percent = 0.2 # fraction (0.002 = 0.2%). Defaults to mostro.max_routing_fee. +``` + +On startup Mostro will: + +1. Try to dial LNDK. If it cannot (wrong cert, unreachable, bad macaroon), + startup aborts — BOLT12 is opt-in, so silently dropping it is worse than + refusing to start. +2. Log whether LND's onion-message feature bits are advertised. If not, a + loud warning is emitted but startup continues. + +## What Mostro does with an offer + +When a buyer sends a BOLT12 offer string as their payout destination: + +1. **Validation (at `add-invoice` / `take-sell` time).** `is_valid_invoice` + decodes the offer with the `lightning` crate and rejects: + - non-BTC currency offers (e.g. USD); + - offers whose pinned amount disagrees with `order.amount - order.fee`; + - offers whose `absolute_expiry` has already elapsed; + - offers that cannot satisfy a single-item purchase; + - offers received while `lndk_enabled = false`. +2. **Payout (`do_payment`).** Mostro calls LNDK's `GetInvoice` to fetch a + fresh BOLT12 invoice bound to the offer, **re-validates** the fetched + invoice's amount and expiry (LNDK's `PayOffer` shortcut does not), then + calls `PayInvoice`. On success the returned preimage transitions the + order to `Success`. On failure the order enters the normal failed-payment + retry loop. + +## BIP-353 resolution (`user@domain` → BOLT12 offer) + +When `bip353_enabled = true`, Mostro resolves human-readable +`user@domain.tld` payout targets to BOLT12 offers via DNSSEC-validated DNS +TXT records (BIP-353 / `_bitcoin-payment` zone). On a successful resolve, +the original address is replaced with the resolved offer at order creation +time and the BOLT12 payout path described above takes over. On any failure +(no record, DNSSEC fails, malformed URI), Mostro falls back to the LNURL +path so existing Lightning Addresses keep working. + +Configuration: + +```toml +[lightning] +bip353_enabled = true +# Any DoH resolver supporting RFC 8484's JSON API works. Default below. +bip353_doh_resolver = "https://1.1.1.1/dns-query" +# Skip DNSSEC AD-flag check. DANGER: regtest only. +bip353_skip_dnssec = false +``` + +Notes: + +- BIP-353 requires `lndk_enabled = true`; otherwise resolution is skipped + silently because the resolved offer would be unpayable. +- DNSSEC validation is enforced via the resolver's `AD` flag. Disabling + `bip353_skip_dnssec` in production lets DNS-level attackers redirect + payouts. +- Resolution is best-effort: a DoH timeout or non-DNSSEC response is + treated as "no record" so the LNURL path can still serve the request. + +## Limitations + +- **BOLT12 invoices (`lni1…`) as direct inputs are rejected.** Users must + send the offer, not a pre-fetched invoice. +- **Offer creation is not supported.** Mostro does not issue BOLT12 offers + for dev-fee receipt; dev fees still use a BOLT11 destination from config. +- **No background retries for BOLT12 yet.** Offer reusability makes this + trivial to add in a follow-up, but for now BOLT12 payment failures follow + the same retry cadence as BOLT11 and still surface an `AddInvoice` + request to the buyer after the configured retry budget is exhausted. +- **Onion-message network reachability is still maturing.** BOLT12 fetches + can fail in ways BOLT11 does not — check Mostro logs for `lndk + get_invoice:` errors if you see unexpected BOLT12 failures. + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| `LNDK initialization failed: failed to read LNDK TLS cert` | `lndk_cert_file` path wrong or file not readable by the Mostro user | +| `LNDK initialization failed: failed to connect to LNDK` | LNDK daemon not running, or `lndk_grpc_host` mismatch | +| `LNDK initialization failed: TLS config` | Cert file is not a valid PEM certificate | +| Warning: `LND does not advertise onion-message support` | Missing `--protocol.custom-message=513 --protocol.custom-nodeann=39 --protocol.custom-init=39` on LND | +| `lndk get_invoice: ...` errors in logs during payout | Offer issuer unreachable, network has no onion-message route, or the offer expired | +| `BOLT12 invoice amount mismatch` | The offer issuer returned an invoice that does not match the requested amount — defense-in-depth aborted the payment | + +## Disabling BOLT12 + +Set `lndk_enabled = false` and restart. Existing orders whose +`buyer_invoice` is a BOLT12 offer will fail their next payout attempt with +`BOLT12 offer received but LNDK is disabled` and enter the usual retry +loop. Consider waiting until all in-flight BOLT12 orders drain before +flipping the flag. diff --git a/docs/STARTUP_AND_CONFIG.md b/docs/STARTUP_AND_CONFIG.md index e614567c..c7fb3a01 100644 --- a/docs/STARTUP_AND_CONFIG.md +++ b/docs/STARTUP_AND_CONFIG.md @@ -105,6 +105,19 @@ Configuration is loaded from `~/.mostro/settings.toml` (template: `settings.tpl. - `payment_attempts` (u32): Max payment retry attempts (default: 3) - `payment_retries_interval` (u32): Retry interval in seconds (default: 60) +*BOLT12 via LNDK (experimental, opt-in). See `docs/LNDK_SETUP.md`.* +- `lndk_enabled` (bool): Accept BOLT12 offers as buyer payout destinations (default: false) +- `lndk_grpc_host` (String): LNDK gRPC endpoint, must be `https://` (default: `https://127.0.0.1:7000`) +- `lndk_cert_file` (String): Path to LNDK self-signed TLS certificate +- `lndk_macaroon_file` (String): Path to the LND macaroon LNDK uses +- `lndk_fetch_invoice_timeout` (u32): Seconds to wait for the offer issuer's invoice reply (default: 60) +- `lndk_fee_limit_percent` (Option): Fee cap as a fraction; falls back to `mostro.max_routing_fee` + +*BIP-353 DNS resolution. Requires `lndk_enabled = true`.* +- `bip353_enabled` (bool): Resolve `user@domain` payouts to BOLT12 offers via DoH (default: false) +- `bip353_doh_resolver` (String): DoH resolver URL, JSON API (default: `https://1.1.1.1/dns-query`) +- `bip353_skip_dnssec` (bool): Skip the AD-flag DNSSEC check; **regtest only** (default: false) + **Mostro** (`src/config/types.rs:76-108`): *Fee Configuration:* diff --git a/proto/lndkrpc.proto b/proto/lndkrpc.proto new file mode 100644 index 00000000..13b8e82e --- /dev/null +++ b/proto/lndkrpc.proto @@ -0,0 +1,157 @@ +// Vendored from https://github.com/lndk-org/lndk @ v0.3.0. +syntax = "proto3"; +package lndkrpc; + +service Offers { + rpc PayOffer (PayOfferRequest) returns (PayOfferResponse); + rpc GetInvoice (GetInvoiceRequest) returns (GetInvoiceResponse); + rpc DecodeInvoice (DecodeInvoiceRequest) returns (Bolt12InvoiceContents); + rpc PayInvoice (PayInvoiceRequest) returns (PayInvoiceResponse); + rpc CreateOffer (CreateOfferRequest) returns (CreateOfferResponse); +} + +message PayOfferRequest { + string offer = 1; + optional uint64 amount = 2; + optional string payer_note = 3; + optional uint32 response_invoice_timeout = 4; + optional uint32 fee_limit = 5; + optional uint32 fee_limit_percent = 6; +} + +message PayOfferResponse { + string payment_preimage = 2; +} + +message GetInvoiceRequest { + string offer = 1; + optional uint64 amount = 2; + optional string payer_note = 3; + optional uint32 response_invoice_timeout = 4; +} + +message DecodeInvoiceRequest { + string invoice = 1; +} + +message GetInvoiceResponse { + string invoice_hex_str = 1; + Bolt12InvoiceContents invoice_contents = 2; +} + +message PayInvoiceRequest { + string invoice = 1; + optional uint64 amount = 2; + optional uint32 fee_limit = 3; + optional uint32 fee_limit_percent = 4; +} + +message PayInvoiceResponse { + string payment_preimage = 1; +} + +message Bolt12InvoiceContents { + string chain = 1; + optional uint64 quantity = 2; + uint64 amount_msats = 3; + optional string description = 4; + PaymentHash payment_hash = 5; + repeated PaymentPaths payment_paths = 6; + int64 created_at = 7; + uint64 relative_expiry = 8; + PublicKey node_id = 9; + string signature = 10; + repeated FeatureBit features = 11; + optional string payer_note = 12; +} + +message PaymentHash { + bytes hash = 1; +} + +message PublicKey { + bytes key = 1; +} + +message BlindedPayInfo { + uint32 fee_base_msat = 1; + uint32 fee_proportional_millionths = 2; + uint32 cltv_expiry_delta = 3; + uint64 htlc_minimum_msat = 4; + uint64 htlc_maximum_msat = 5; + repeated FeatureBit features = 6; +} + +message BlindedHop { + PublicKey blinded_node_id = 1; + bytes encrypted_payload = 2; +} + +message BlindedPath { + IntroductionNode introduction_node = 1; + PublicKey blinding_point = 2; + repeated BlindedHop blinded_hops = 3; +} + +message PaymentPaths { + BlindedPayInfo blinded_pay_info = 1; + BlindedPath blinded_path = 2; +} + +message IntroductionNode { + optional PublicKey node_id = 1; + optional DirectedShortChannelId directed_short_channel_id = 2; +} + +message DirectedShortChannelId { + Direction direction = 1; + uint64 scid = 2; +} + +enum Direction { + NODE_ONE = 0; + NODE_TWO = 1; +} + +enum FeatureBit { + DATALOSS_PROTECT_REQ = 0; + DATALOSS_PROTECT_OPT = 1; + INITIAL_ROUING_SYNC = 3; + UPFRONT_SHUTDOWN_SCRIPT_REQ = 4; + UPFRONT_SHUTDOWN_SCRIPT_OPT = 5; + GOSSIP_QUERIES_REQ = 6; + GOSSIP_QUERIES_OPT = 7; + TLV_ONION_REQ = 8; + TLV_ONION_OPT = 9; + EXT_GOSSIP_QUERIES_REQ = 10; + EXT_GOSSIP_QUERIES_OPT = 11; + STATIC_REMOTE_KEY_REQ = 12; + STATIC_REMOTE_KEY_OPT = 13; + PAYMENT_ADDR_REQ = 14; + PAYMENT_ADDR_OPT = 15; + MPP_REQ = 16; + MPP_OPT = 17; + WUMBO_CHANNELS_REQ = 18; + WUMBO_CHANNELS_OPT = 19; + ANCHORS_REQ = 20; + ANCHORS_OPT = 21; + ANCHORS_ZERO_FEE_HTLC_REQ = 22; + ANCHORS_ZERO_FEE_HTLC_OPT = 23; + ROUTE_BLINDING_REQUIRED = 24; + ROUTE_BLINDING_OPTIONAL = 25; + AMP_REQ = 30; + AMP_OPT = 31; +} + +message CreateOfferRequest { + optional uint64 amount = 1; + optional string description = 2; + optional string issuer = 3; + optional uint64 quantity = 4; + optional uint64 expiry = 5; +} + + +message CreateOfferResponse { + string offer = 1; +} \ No newline at end of file diff --git a/settings.tpl.toml b/settings.tpl.toml index af9a3574..68ed7472 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -18,6 +18,32 @@ payment_attempts = 3 # Retries interval for failed payments payment_retries_interval = 60 +# --- BOLT12 via LNDK (experimental, optional) --- +# Set to true to accept BOLT12 offers (lno1...) as buyer payout destinations. +# Requires running an LNDK daemon alongside LND. See docs/LNDK_SETUP.md. +lndk_enabled = false +# LNDK gRPC endpoint (must start with https://) +lndk_grpc_host = "https://127.0.0.1:7000" +# TLS certificate file (self-signed, generated by LNDK on first run) +lndk_cert_file = "/home/user/.lndk/data/tls-cert.pem" +# LND macaroon used by LNDK for payment authorization +lndk_macaroon_file = "/home/user/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" +# Timeout for BOLT12 invoice fetch from the offer issuer (seconds) +lndk_fetch_invoice_timeout = 60 +# Fee limit for BOLT12 payments as percent. If unset, falls back to mostro.max_routing_fee. +# lndk_fee_limit_percent = 0.2 + +# --- BIP-353 DNS resolution --- +# Resolves `user@domain` to BOLT12 offers via DNSSEC-validated DNS TXT +# records. Falls back to LNURL if resolution fails. Requires LNDK. +bip353_enabled = false +# DNS-over-HTTPS resolver (must support RFC 8484 JSON API) +bip353_doh_resolver = "https://1.1.1.1/dns-query" + +# Skip DNSSEC validation for BIP-353. DANGER: regtest only — without +# DNSSEC an attacker controlling DNS can redirect payouts. +bip353_skip_dnssec = false + [nostr] nsec_privkey = 'nsec1...' relays = ['ws://localhost:7000'] diff --git a/src/app/context.rs b/src/app/context.rs index 763bf6c8..fa03d00f 100644 --- a/src/app/context.rs +++ b/src/app/context.rs @@ -7,6 +7,7 @@ //! This enables unit testing with mock implementations — see `TestContextBuilder`. use crate::config::settings::Settings; +use crate::lightning::lndk::LndkConnector; use mostro_core::prelude::Message; use nostr_sdk::{Client, Keys, PublicKey}; use sqlx::{Pool, Sqlite}; @@ -38,6 +39,7 @@ pub struct AppContext { settings: Arc, order_msg_queue: OrderMsgQueue, keys: Keys, + lndk: Option, } impl AppContext { @@ -48,6 +50,7 @@ impl AppContext { settings: Arc, order_msg_queue: OrderMsgQueue, keys: Keys, + lndk: Option, ) -> Self { Self { pool, @@ -55,6 +58,7 @@ impl AppContext { settings, order_msg_queue, keys, + lndk, } } @@ -90,6 +94,15 @@ impl AppContext { pub fn keys(&self) -> &Keys { &self.keys } + + /// Optional LNDK client for BOLT12 offer payouts. + /// + /// Returns `None` when `lightning.lndk_enabled = false`, in which case + /// BOLT12 offers are rejected at validation time before ever reaching + /// payout. + pub fn lndk(&self) -> Option<&LndkConnector> { + self.lndk.as_ref() + } } #[cfg(test)] @@ -234,7 +247,7 @@ pub mod test_utils { .expect("TestContextBuilder: invalid nsec_privkey in settings") }); - AppContext::new(pool, nostr_client, settings, order_msg_queue, keys) + AppContext::new(pool, nostr_client, settings, order_msg_queue, keys, None) } /// Build context plus mock handles used for assertions. diff --git a/src/app/order.rs b/src/app/order.rs index 8b10e436..b36cf4ca 100644 --- a/src/app/order.rs +++ b/src/app/order.rs @@ -87,8 +87,18 @@ pub async fn order_action( let request_id = msg.get_inner_message_kind().request_id; if let Some(order) = msg.get_inner_message_kind().get_order() { - // Validate invoice - let _invoice = validate_invoice(&msg, &Order::from(order.clone())).await?; + // Validate invoice AND apply any BIP-353 override so the stored + // buyer_invoice is the resolved BOLT12 offer rather than the raw + // `user@domain` alias. `validate_invoice` returns the (possibly + // rewritten) payment request; shadow `order` with a mutated clone + // so the rest of the flow — `publish_order` and the DB row it + // writes — sees the resolved value. + 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; // Check if fiat currency is accepted let mostro_settings = &ctx.settings().mostro; diff --git a/src/app/release.rs b/src/app/release.rs index 7be71d03..31b7a2fd 100644 --- a/src/app/release.rs +++ b/src/app/release.rs @@ -1,5 +1,7 @@ use crate::app::context::AppContext; use crate::app::dispute::close_dispute_after_user_resolution; +use crate::config::settings::Settings; +use crate::lightning::offers::{classify, InvoiceFormat}; use crate::lightning::LndConnector; use crate::lnurl::resolv_ln_address; use crate::nip33::{new_order_event, order_to_tags}; @@ -445,9 +447,16 @@ async fn handle_child_order( Ok(()) } +/// Pays the buyer their portion of a released order. +/// +/// Classifies `order.buyer_invoice` and routes to either [`do_payment_lnd`] +/// (BOLT11 / LNURL / Lightning Address, via LND) or [`do_payment_bolt12`] +/// (BOLT12 offer, via LNDK). The two paths are kept separate because LND's +/// streaming payment API and LNDK's synchronous RPC have different +/// plumbing and unifying them would only obscure both. pub async fn do_payment( ctx: &AppContext, - mut order: Order, + order: Order, request_id: Option, ) -> Result<(), MostroError> { let payment_request = match order.buyer_invoice.as_ref() { @@ -455,13 +464,35 @@ pub async fn do_payment( _ => return Err(MostroInternalErr(ServiceError::InvoiceInvalidError)), }; - let ln_addr = LightningAddress::from_str(&payment_request); // Calculate buyer's portion after subtracting only the Mostro fee // Dev fee is NOT charged to buyer - it's paid by mostrod from its earnings let amount = (order.amount as u64).saturating_sub(order.fee as u64); if amount == 0 { return Err(MostroInternalErr(ServiceError::InvoiceInvalidError)); } + + match classify(&payment_request) { + InvoiceFormat::Bolt12Offer => { + do_payment_bolt12(ctx, order, payment_request, amount, request_id).await + } + InvoiceFormat::Bolt12Invoice => { + // Pre-fetched BOLT12 invoices are rejected at `validate_invoice` + // time, so reaching here would mean a legacy row. Fail closed. + Err(MostroInternalErr(ServiceError::InvoiceInvalidError)) + } + _ => do_payment_lnd(ctx, order, payment_request, amount, request_id).await, + } +} + +/// Pays via LND directly — BOLT11, LNURL, or Lightning Address. +async fn do_payment_lnd( + ctx: &AppContext, + mut order: Order, + payment_request: String, + amount: u64, + request_id: Option, +) -> Result<(), MostroError> { + let ln_addr = LightningAddress::from_str(&payment_request); let payment_request = if let Ok(addr) = ln_addr { resolv_ln_address(&addr.to_string(), amount) .await @@ -539,6 +570,77 @@ pub async fn do_payment( Ok(()) } +/// Pays a BOLT12 offer via LNDK. +/// +/// Unlike the LND streaming path, LNDK's `GetInvoice` + `PayInvoice` flow +/// is synchronous: one RPC round-trip per step, a preimage (or error) at +/// the end. We spawn the whole sequence on a background task so the +/// caller returns immediately, matching the behavior of the LND path. +async fn do_payment_bolt12( + ctx: &AppContext, + mut order: Order, + offer: String, + amount_sats: u64, + request_id: Option, +) -> Result<(), MostroError> { + let Some(lndk) = ctx.lndk().cloned() else { + // An offer string was stored but LNDK is disabled at payout time. + // This can happen if `lndk_enabled` was flipped off after the order + // was created. Fail the payment and let retries surface the issue. + tracing::error!( + "Order {}: BOLT12 offer in buyer_invoice but LNDK is disabled", + order.id + ); + let _ = check_failure_retries(ctx, &order, request_id).await; + return Err(MostroInternalErr(ServiceError::LnPaymentError( + "BOLT12 offer received but LNDK is disabled".into(), + ))); + }; + + let my_keys = ctx.keys().clone(); + let buyer_pubkey = order.get_buyer_pubkey().map_err(MostroInternalErr)?; + let order_id = order.id; + let ctx_spawn = ctx.clone(); + let min_expiry = Settings::get_ln().invoice_expiration_window as u64; + let amount_msats = amount_sats.saturating_mul(1000); + + tokio::spawn(async move { + let mut lndk = lndk; + let result = lndk + .pay_offer_validated( + &offer, + amount_msats, + Some(format!("mostro:{order_id}")), + min_expiry, + ) + .await; + + match result { + Ok(preimage) => { + info!( + "Order {}: BOLT12 offer paid, preimage={}", + order_id, preimage + ); + let _ = payment_success(&ctx_spawn, &mut order, buyer_pubkey, &my_keys, request_id) + .await; + } + Err(e) => { + info!("Order {}: BOLT12 offer payment failed: {}", order_id, e); + if let Ok(failed_payment) = + check_failure_retries(&ctx_spawn, &order, request_id).await + { + info!( + "Order id {} has {} failed payments retries", + failed_payment.id, failed_payment.payment_attempts + ); + } + } + } + }); + + Ok(()) +} + async fn payment_success( ctx: &AppContext, order: &mut Order, diff --git a/src/config/types.rs b/src/config/types.rs index b3f2b352..a882d3d8 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -203,6 +203,7 @@ pub struct DatabaseSettings { } /// Lightning configuration settings #[derive(Debug, Deserialize, Serialize, Default, Clone)] +#[serde(default)] pub struct LightningSettings { /// LND certificate file path pub lnd_cert_file: String, @@ -220,6 +221,44 @@ pub struct LightningSettings { pub payment_attempts: u32, /// Payment retries interval in seconds pub payment_retries_interval: u32, + /// Enable BOLT12 offer payout via LNDK (experimental) + pub lndk_enabled: bool, + /// LNDK gRPC host (must start with https://) + #[serde(default = "default_lndk_grpc_host")] + pub lndk_grpc_host: String, + /// Path to LNDK TLS certificate (self-signed, generated on first run) + pub lndk_cert_file: String, + /// Path to the LND macaroon LNDK uses for payment authorization + pub lndk_macaroon_file: String, + /// Timeout for BOLT12 invoice fetch from the offer issuer (seconds) + #[serde(default = "default_lndk_fetch_timeout")] + pub lndk_fetch_invoice_timeout: u32, + /// Fee limit for BOLT12 payments as percent. Falls back to mostro.max_routing_fee. + pub lndk_fee_limit_percent: Option, + /// Enable BIP-353 DNS resolution for `user@domain` payment addresses. + /// Resolves via DNS-over-HTTPS to a DNSSEC-validated BOLT12 offer. + /// Falls back to LNURL if resolution fails. Requires LNDK to be enabled. + pub bip353_enabled: bool, + /// DNS-over-HTTPS resolver URL for BIP-353 lookups. + /// Must support the JSON API (RFC 8484). Default: Cloudflare. + #[serde(default = "default_bip353_doh_resolver")] + pub bip353_doh_resolver: String, + /// Skip DNSSEC validation (AD flag check) for BIP-353 lookups. + /// DANGER: only for regtest/dev. An attacker can redirect payments + /// without DNSSEC validation. + pub bip353_skip_dnssec: bool, +} + +fn default_lndk_grpc_host() -> String { + "https://127.0.0.1:7000".to_string() +} + +fn default_lndk_fetch_timeout() -> u32 { + 60 +} + +fn default_bip353_doh_resolver() -> String { + "https://1.1.1.1/dns-query".to_string() } /// Nostr configuration settings #[derive(Debug, Deserialize, Serialize, Default, Clone)] diff --git a/src/config/wizard.rs b/src/config/wizard.rs index fec89490..33885b29 100644 --- a/src/config/wizard.rs +++ b/src/config/wizard.rs @@ -140,6 +140,15 @@ fn prompt_lightning_settings() -> Result { hold_invoice_expiration_window: 300, payment_attempts: 3, payment_retries_interval: 60, + lndk_enabled: false, + lndk_grpc_host: "https://127.0.0.1:7000".to_string(), + lndk_cert_file: String::new(), + lndk_macaroon_file: String::new(), + lndk_fetch_invoice_timeout: 60, + lndk_fee_limit_percent: None, + bip353_enabled: false, + bip353_doh_resolver: "https://1.1.1.1/dns-query".to_string(), + bip353_skip_dnssec: false, }) } diff --git a/src/lightning/bip353.rs b/src/lightning/bip353.rs new file mode 100644 index 00000000..c9febab1 --- /dev/null +++ b/src/lightning/bip353.rs @@ -0,0 +1,251 @@ +//! BIP-353 DNS resolution for `user@domain` payment addresses. +//! +//! Resolves human-readable payment addresses to BOLT12 offers via +//! DNSSEC-validated DNS TXT records. Uses DNS-over-HTTPS (DoH) with +//! a trusted resolver (default: Cloudflare) to get DNSSEC validation +//! without running a local validating resolver. +//! +//! Resolution flow: +//! 1. `user@domain.com` → DNS TXT at `user.user._bitcoin-payment.domain.com` +//! 2. TXT record contains `bitcoin:?lno=lno1...` +//! 3. Extract and return the `lno1...` offer string +//! +//! Falls back gracefully on any failure — the caller should try LNURL +//! next. + +use crate::config::settings::Settings; +use crate::lnurl::HTTP_CLIENT; +use mostro_core::prelude::*; +use serde::Deserialize; + +/// Resolves a BIP-353 `user@domain` address to a BOLT12 offer string. +/// +/// Returns `Ok(Some(offer))` if resolution succeeds with a valid +/// DNSSEC-authenticated offer. Returns `Ok(None)` on any failure so +/// the caller can fall through to LNURL. Only returns `Err` for +/// truly malformed input. +pub async fn resolve_bip353(address: &str) -> Result, MostroError> { + let ln = Settings::get_ln(); + + if !ln.bip353_enabled || !ln.lndk_enabled { + return Ok(None); + } + + let (user, domain) = match address.split_once('@') { + Some(pair) => pair, + None => return Ok(None), + }; + + if user.is_empty() || domain.is_empty() { + return Ok(None); + } + + let dns_name = build_dns_name(user, domain); + let resolver_url = &ln.bip353_doh_resolver; + + let response = match query_doh(resolver_url, &dns_name).await { + Ok(resp) => resp, + Err(e) => { + tracing::warn!("BIP-353 DoH query failed for {address}: {e}"); + return Ok(None); + } + }; + + // DNS status 0 = NOERROR + if response.status != 0 { + tracing::debug!( + "BIP-353 DNS returned status {} for {address}", + response.status + ); + return Ok(None); + } + + // DNSSEC validation via the AD (Authenticated Data) flag + if !response.ad && !ln.bip353_skip_dnssec { + tracing::warn!( + "BIP-353 DNSSEC not validated for {address} (AD=false). \ + Set bip353_skip_dnssec=true to override (regtest only)." + ); + return Ok(None); + } + + // Extract lno offer from TXT records + for answer in &response.answer { + if let Some(offer) = parse_bitcoin_uri(&answer.data) { + if offer.starts_with("lno1") { + tracing::info!("BIP-353 resolved {address} → bolt12 offer"); + return Ok(Some(offer)); + } + } + } + + tracing::debug!("BIP-353 no lno offer found in TXT records for {address}"); + Ok(None) +} + +/// Constructs the BIP-353 DNS name for a given user and domain. +fn build_dns_name(user: &str, domain: &str) -> String { + format!("{user}.user._bitcoin-payment.{domain}") +} + +/// Parses a `bitcoin:` URI and extracts the `lno` query parameter. +/// +/// DNS TXT records may be split across multiple quoted chunks: +/// `"bitcoin:?lno=lno1abc" "def..."` — these are concatenated first. +fn parse_bitcoin_uri(txt: &str) -> Option { + // Concatenate quoted TXT chunks: `"part1" "part2"` → `part1part2` + let concatenated: String = txt + .split('"') + .enumerate() + .filter_map(|(i, s)| if i % 2 == 1 { Some(s) } else { None }) + .collect::(); + + // If no quotes found, use the raw string + let txt = if concatenated.is_empty() { + txt.trim().to_string() + } else { + concatenated + }; + + let query = txt.split_once('?').map(|(_, rest)| rest)?; + + for pair in query.split('&') { + if let Some(("lno", value)) = pair.split_once('=') { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + +// ── DoH (DNS-over-HTTPS) client ──────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct DohResponse { + #[serde(rename = "Status")] + status: u32, + #[serde(rename = "AD", default)] + ad: bool, + #[serde(rename = "Answer", default)] + answer: Vec, +} + +#[derive(Debug, Deserialize)] +struct DohAnswer { + data: String, +} + +/// Queries a DNS-over-HTTPS resolver for TXT records. +async fn query_doh(resolver_url: &str, name: &str) -> Result { + let resp = HTTP_CLIENT + .get(resolver_url) + .query(&[("name", name), ("type", "TXT")]) + .header("Accept", "application/dns-json") + .send() + .await + .map_err(|e| { + MostroInternalErr(ServiceError::LnPaymentError(format!("DoH request: {e}"))) + })?; + + if !resp.status().is_success() { + return Err(MostroInternalErr(ServiceError::LnPaymentError(format!( + "DoH returned HTTP {}", + resp.status() + )))); + } + + resp.json::().await.map_err(|e| { + MostroInternalErr(ServiceError::LnPaymentError(format!( + "DoH response parse: {e}" + ))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dns_name_construction() { + assert_eq!( + build_dns_name("alice", "example.com"), + "alice.user._bitcoin-payment.example.com" + ); + assert_eq!( + build_dns_name("bob", "walletofsatoshi.com"), + "bob.user._bitcoin-payment.walletofsatoshi.com" + ); + } + + #[test] + fn parse_simple_lno() { + let txt = "bitcoin:?lno=lno1pgqfoo"; + assert_eq!(parse_bitcoin_uri(txt), Some("lno1pgqfoo".to_string())); + } + + #[test] + fn parse_lno_with_other_params() { + let txt = "bitcoin:?lno=lno1abc&sp=sp1def"; + assert_eq!(parse_bitcoin_uri(txt), Some("lno1abc".to_string())); + } + + #[test] + fn parse_quoted_txt_record() { + let txt = "\"bitcoin:?lno=lno1xyz\""; + assert_eq!(parse_bitcoin_uri(txt), Some("lno1xyz".to_string())); + } + + #[test] + fn parse_chunked_txt_record() { + // Real-world format: DNS TXT records split across multiple quoted chunks + let txt = "\"bitcoin:?lno=lno1abc\" \"def\""; + assert_eq!(parse_bitcoin_uri(txt), Some("lno1abcdef".to_string())); + } + + #[test] + fn parse_no_lno_param() { + let txt = "bitcoin:?sp=sp1abc"; + assert_eq!(parse_bitcoin_uri(txt), None); + } + + #[test] + fn parse_no_query_string() { + let txt = "bitcoin:bc1qfoo"; + assert_eq!(parse_bitcoin_uri(txt), None); + } + + #[test] + fn parse_empty() { + assert_eq!(parse_bitcoin_uri(""), None); + assert_eq!(parse_bitcoin_uri("\"\""), None); + } + + #[test] + fn doh_response_deserialize() { + let json = r#"{ + "Status": 0, + "AD": true, + "Answer": [ + {"name": "test", "type": 16, "TTL": 300, "data": "\"bitcoin:?lno=lno1test\""} + ] + }"#; + let resp: DohResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, 0); + assert!(resp.ad); + assert_eq!(resp.answer.len(), 1); + assert_eq!( + parse_bitcoin_uri(&resp.answer[0].data), + Some("lno1test".to_string()) + ); + } + + #[test] + fn doh_response_no_ad_flag() { + let json = r#"{"Status": 0, "Answer": []}"#; + let resp: DohResponse = serde_json::from_str(json).unwrap(); + assert!(!resp.ad); + } +} diff --git a/src/lightning/invoice.rs b/src/lightning/invoice.rs index e5c8f27e..0d756ffc 100644 --- a/src/lightning/invoice.rs +++ b/src/lightning/invoice.rs @@ -1,4 +1,5 @@ use crate::config::settings::Settings; +use crate::lightning::offers::{classify, validate_offer, InvoiceFormat}; use crate::lnurl::ln_exists; use chrono::prelude::*; @@ -183,15 +184,39 @@ pub async fn is_valid_invoice( amount: Option, fee: Option, ) -> Result<(), MostroError> { - // Try Lightning address or LNURL first - if LightningAddress::from_str(&payment_request).is_ok() - || LnUrl::from_str(&payment_request).is_ok() - { - return validate_lightning_address(&payment_request).await; + match classify(&payment_request) { + InvoiceFormat::Bolt12Offer => { + let ln_settings = Settings::get_ln(); + validate_offer( + &payment_request, + amount, + fee.unwrap_or(0), + ln_settings.lndk_enabled, + ) + } + InvoiceFormat::Bolt12Invoice => { + // Pre-fetched BOLT12 invoices are not supported — buyers should + // send their offer (`lno1…`) so Mostro can fetch a fresh invoice + // at payout time. Rejecting here gives a clear error rather than + // surfacing a late failure in `do_payment`. + Err(MostroInternalErr(ServiceError::InvoiceInvalidError)) + } + InvoiceFormat::LnAddress | InvoiceFormat::Lnurl => { + validate_lightning_address(&payment_request).await + } + InvoiceFormat::Bolt11 => validate_bolt11_invoice(&payment_request, amount, fee).await, + InvoiceFormat::Unknown => { + // Fall through to the loose detectors for backwards compatibility + // with anything the prefix classifier doesn't recognise. + if LightningAddress::from_str(&payment_request).is_ok() + || LnUrl::from_str(&payment_request).is_ok() + { + validate_lightning_address(&payment_request).await + } else { + validate_bolt11_invoice(&payment_request, amount, fee).await + } + } } - - // Fall back to BOLT11 invoice - validate_bolt11_invoice(&payment_request, amount, fee).await } #[cfg(test)] diff --git a/src/lightning/lndk.rs b/src/lightning/lndk.rs new file mode 100644 index 00000000..63774f79 --- /dev/null +++ b/src/lightning/lndk.rs @@ -0,0 +1,212 @@ +//! LNDK gRPC client for BOLT12 offer payouts. +//! +//! LNDK () is a daemon that sits beside +//! LND and implements BOLT12 using LDK's offers code while delegating +//! Lightning routing and onion message forwarding to LND. Mostro talks to +//! LNDK over its gRPC API to fetch invoices from offers and pay them. +//! +//! Only the buyer-payout path uses this. Hold invoices, seller payments, +//! dev-fee payouts, and LND health checks all continue to use +//! [`super::LndConnector`] directly. +//! +//! # Two-step fetch-then-pay +//! +//! LNDK exposes a convenient `PayOffer` RPC that fetches and pays in one +//! call, but it does not verify the returned invoice's amount or expiry +//! against what the caller asked for. We therefore use `GetInvoice` → +//! defensive validation (see [`super::offers::validate_fetched_invoice`]) → +//! `PayInvoice` so a misbehaving offer issuer cannot slip an over-long or +//! zero-amount invoice past us. + +use crate::config::settings::Settings; +use crate::lightning::offers::validate_fetched_invoice; +use mostro_core::prelude::*; +use tonic::metadata::MetadataValue; +use tonic::transport::{Certificate, Channel, ClientTlsConfig}; +use tonic::{Request, Status}; + +/// Generated from `proto/lndkrpc.proto`. +pub mod lndkrpc { + tonic::include_proto!("lndkrpc"); +} + +use lndkrpc::offers_client::OffersClient; +use lndkrpc::{GetInvoiceRequest, PayInvoiceRequest}; + +/// Client handle for the LNDK `Offers` gRPC service. +/// +/// Wraps a `tonic::Channel` (HTTP/2 multiplexed, cheap to clone) plus the +/// macaroon + fee configuration needed to authenticate each request. +#[derive(Clone)] +pub struct LndkConnector { + client: OffersClient, + macaroon_hex: String, + fetch_timeout_secs: u32, + fee_limit_percent: u32, +} + +impl LndkConnector { + /// Constructs a connector from the current settings. + /// + /// Returns `Ok(None)` when `lightning.lndk_enabled = false`. Returns + /// `Err` when enabled but the cert/macaroon cannot be loaded or the + /// TLS channel cannot be opened; callers should abort startup in that + /// case rather than silently dropping BOLT12 support. + pub async fn new_from_settings() -> Result, MostroError> { + let ln = Settings::get_ln(); + if !ln.lndk_enabled { + return Ok(None); + } + + if ln.lndk_cert_file.is_empty() || ln.lndk_macaroon_file.is_empty() { + return Err(MostroInternalErr(ServiceError::LnNodeError( + "lndk_enabled=true but lndk_cert_file / lndk_macaroon_file are unset".into(), + ))); + } + + let cert = tokio::fs::read(&ln.lndk_cert_file).await.map_err(|e| { + MostroInternalErr(ServiceError::LnNodeError(format!( + "failed to read LNDK TLS cert {}: {}", + ln.lndk_cert_file, e + ))) + })?; + + let macaroon_bytes = tokio::fs::read(&ln.lndk_macaroon_file).await.map_err(|e| { + MostroInternalErr(ServiceError::LnNodeError(format!( + "failed to read LNDK macaroon {}: {}", + ln.lndk_macaroon_file, e + ))) + })?; + let macaroon_hex = hex::encode(&macaroon_bytes); + + // LNDK's self-signed cert uses `localhost` as its subject. The + // configured grpc host may be an IP (127.0.0.1), so we pin the + // domain name explicitly for SNI + verification. + let tls = ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(&cert)) + .domain_name("localhost"); + + let channel = Channel::from_shared(ln.lndk_grpc_host.clone()) + .map_err(|e| { + MostroInternalErr(ServiceError::LnNodeError(format!( + "invalid lndk_grpc_host {}: {}", + ln.lndk_grpc_host, e + ))) + })? + .tls_config(tls) + .map_err(|e| MostroInternalErr(ServiceError::LnNodeError(format!("TLS config: {e}"))))? + .connect() + .await + .map_err(|e| { + MostroInternalErr(ServiceError::LnNodeError(format!( + "failed to connect to LNDK at {}: {}", + ln.lndk_grpc_host, e + ))) + })?; + + let mostro_settings = Settings::get_mostro(); + // LNDK's fee_limit_percent is a whole-number percent (e.g. 2 = 2%). + // `mostro.max_routing_fee` is a fraction (0.002 = 0.2%). Round up to + // ensure we don't accidentally cap fees below the BOLT11 path. + 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; + + Ok(Some(Self { + client: OffersClient::new(channel), + macaroon_hex, + fetch_timeout_secs: ln.lndk_fetch_invoice_timeout, + fee_limit_percent, + })) + } + + /// Pays a BOLT12 offer with a two-step fetch-and-validate flow. + /// + /// Returns the payment preimage (hex string) on success. + pub async fn pay_offer_validated( + &mut self, + offer: &str, + amount_msats: u64, + payer_note: Option, + min_expiry_secs: u64, + ) -> Result { + let mut get_req = Request::new(GetInvoiceRequest { + offer: offer.to_string(), + amount: Some(amount_msats), + payer_note, + response_invoice_timeout: Some(self.fetch_timeout_secs), + }); + self.inject_macaroon(&mut get_req)?; + + let fetched = self + .client + .get_invoice(get_req) + .await + .map_err(|s| map_status(s, "get_invoice"))? + .into_inner(); + + let contents = fetched.invoice_contents.ok_or_else(|| { + MostroInternalErr(ServiceError::LnPaymentError( + "LNDK GetInvoice returned empty invoice_contents".into(), + )) + })?; + + validate_fetched_invoice( + contents.amount_msats, + contents.created_at, + contents.relative_expiry, + amount_msats, + min_expiry_secs, + )?; + + let mut pay_req = Request::new(PayInvoiceRequest { + invoice: fetched.invoice_hex_str, + amount: Some(amount_msats), + fee_limit: None, + fee_limit_percent: Some(self.fee_limit_percent), + }); + self.inject_macaroon(&mut pay_req)?; + + let resp = self + .client + .pay_invoice(pay_req) + .await + .map_err(|s| map_status(s, "pay_invoice"))? + .into_inner(); + + Ok(resp.payment_preimage) + } + + fn inject_macaroon(&self, req: &mut Request) -> Result<(), MostroError> { + let value: MetadataValue<_> = self.macaroon_hex.parse().map_err(|_| { + MostroInternalErr(ServiceError::LnNodeError( + "LNDK macaroon is not valid ASCII hex".into(), + )) + })?; + req.metadata_mut().insert("macaroon", value); + Ok(()) + } +} + +/// Maps a tonic `Status` returned by an LNDK RPC into a Mostro error. +/// +/// LNDK's handler maps `InvalidArgument` to parse / amount / currency +/// failures (i.e. the offer or request is bad) and routes route / +/// payment / peer / timeout failures through `Internal`. We preserve that +/// split so `check_failure_retries` can distinguish "operator misconfig / +/// user error" from "transient network failure". +fn map_status(status: Status, op: &'static str) -> MostroError { + use tonic::Code::*; + match status.code() { + InvalidArgument => MostroInternalErr(ServiceError::InvoiceInvalidError), + Unavailable => MostroInternalErr(ServiceError::LnNodeError(format!( + "lndk {op} unavailable: {}", + status.message() + ))), + _ => MostroInternalErr(ServiceError::LnPaymentError(format!( + "lndk {op}: {}", + status.message() + ))), + } +} diff --git a/src/lightning/mod.rs b/src/lightning/mod.rs index 89c3ece4..1ebcf409 100644 --- a/src/lightning/mod.rs +++ b/src/lightning/mod.rs @@ -1,4 +1,7 @@ +pub mod bip353; pub mod invoice; +pub mod lndk; +pub mod offers; use crate::config::settings::Settings; use crate::lightning::invoice::decode_invoice; diff --git a/src/lightning/offers.rs b/src/lightning/offers.rs new file mode 100644 index 00000000..91db1115 --- /dev/null +++ b/src/lightning/offers.rs @@ -0,0 +1,330 @@ +//! BOLT12 offer and invoice helpers. +//! +//! This module provides format classification for buyer payment requests and +//! semantic validation for BOLT12 offers. It uses the `lightning` crate's +//! `offers` module for pure parsing — no network I/O happens here. +//! +//! Actually paying a BOLT12 offer requires a running LNDK daemon; see +//! [`crate::lightning::lndk`] for the RPC client. + +use lightning::offers::offer::{Amount, Offer, Quantity}; +use mostro_core::prelude::*; +use std::str::FromStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Classification of a buyer-supplied payment request string. +/// +/// Used by [`classify`] and dispatched on in `is_valid_invoice` and +/// `do_payment`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvoiceFormat { + /// A BOLT11 Lightning invoice (`lnbc…` / `lntb…` / `lnbcrt…`). + Bolt11, + /// A BOLT12 offer string (`lno1…`). + Bolt12Offer, + /// A pre-fetched BOLT12 invoice (`lni1…`). Not supported in the first + /// iteration — buyers should send an offer instead. + Bolt12Invoice, + /// A Lightning Address (`user@domain.tld`). + LnAddress, + /// An LNURL-pay request (`lnurl1…`). + Lnurl, + /// Unrecognized format. + Unknown, +} + +/// Classifies a payment request by cheap prefix sniffing. +/// +/// Prefix checks are case-insensitive on the HRP and order-sensitive: BOLT12 +/// prefixes must come before BOLT11 so `lno1…` is not mis-detected as +/// `ln…`-something. +pub fn classify(payment_request: &str) -> InvoiceFormat { + let trimmed = payment_request.trim(); + let lower = trimmed.to_ascii_lowercase(); + + if lower.starts_with("lno1") { + return InvoiceFormat::Bolt12Offer; + } + if lower.starts_with("lni1") { + return InvoiceFormat::Bolt12Invoice; + } + if lower.starts_with("lnbc") + || lower.starts_with("lntb") + || lower.starts_with("lntbs") + || lower.starts_with("lnbcrt") + { + return InvoiceFormat::Bolt11; + } + if lower.starts_with("lnurl") { + return InvoiceFormat::Lnurl; + } + // Lightning Address: "user@host" — very loose check, the real validation + // happens in `lnurl::lightning_address::LightningAddress::from_str`. + if trimmed.contains('@') && !trimmed.contains(' ') { + return InvoiceFormat::LnAddress; + } + InvoiceFormat::Unknown +} + +/// Returns the storage tag written to `orders.buyer_invoice_kind` for a given +/// format. `None` for [`InvoiceFormat::Unknown`] since unknown formats are +/// rejected at validation time. +pub fn kind_tag(fmt: InvoiceFormat) -> Option<&'static str> { + match fmt { + InvoiceFormat::Bolt11 => Some("bolt11"), + InvoiceFormat::Bolt12Offer => Some("bolt12_offer"), + InvoiceFormat::Bolt12Invoice => Some("bolt12_invoice"), + InvoiceFormat::LnAddress => Some("lnaddr"), + InvoiceFormat::Lnurl => Some("lnurl"), + InvoiceFormat::Unknown => None, + } +} + +/// Validates a BOLT12 offer for acceptance as a buyer payout destination. +/// +/// `expected_amount_sats` and `fee_sats` are the order's expected payout +/// amount and Mostro fee; if the offer pins an amount, it must match +/// `expected_amount_sats - fee_sats`. +/// +/// Rejects offers that: +/// - Fail to parse as BOLT12 bech32 +/// - Are denominated in a non-BTC currency +/// - Have a fixed amount that disagrees with the order +/// - Have elapsed their absolute expiry +/// - Cannot satisfy a quantity of 1 +/// +/// When `lndk_enabled` is false, rejects unconditionally — a BOLT12 offer +/// cannot be paid without a running LNDK daemon, so accepting one here would +/// guarantee a payout failure later. +pub fn validate_offer( + offer_str: &str, + expected_amount_sats: Option, + fee_sats: u64, + lndk_enabled: bool, +) -> Result<(), MostroError> { + if !lndk_enabled { + return Err(MostroInternalErr(ServiceError::InvoiceInvalidError)); + } + + let offer = Offer::from_str(offer_str) + .map_err(|_| MostroInternalErr(ServiceError::InvoiceInvalidError))?; + + // Reject non-BTC currency offers — we cannot fetch a satoshi-denominated + // invoice from them. + match offer.amount() { + Some(Amount::Currency { .. }) => { + return Err(MostroInternalErr(ServiceError::InvoiceInvalidError)); + } + Some(Amount::Bitcoin { amount_msats }) => { + if let Some(expected) = expected_amount_sats { + let expected_msats = expected.saturating_sub(fee_sats).saturating_mul(1000); + // A zero-amount order has nothing to compare — this is already + // filtered out at payout time, but be defensive. + if expected_msats != 0 && amount_msats != expected_msats { + return Err(MostroInternalErr(ServiceError::InvoiceInvalidError)); + } + } + } + None => { + // Amount-less offer — LNDK will set the amount at fetch time from + // the caller-supplied `amount_msats`. Nothing to check here. + } + } + + // Reject offers whose absolute expiry has elapsed. + if let Some(expiry) = offer.absolute_expiry() { + if let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) { + if expiry <= now { + return Err(MostroInternalErr(ServiceError::InvoiceInvalidError)); + } + } + } + + // Reject offers that cannot satisfy a single-item purchase. + match offer.supported_quantity() { + Quantity::One | Quantity::Unbounded => {} + Quantity::Bounded(n) => { + if n.get() < 1 { + return Err(MostroInternalErr(ServiceError::InvoiceInvalidError)); + } + } + } + + Ok(()) +} + +/// Post-fetch validation of a BOLT12 invoice returned by LNDK's `GetInvoice`. +/// +/// Defense-in-depth check: LNDK's `PayOffer` does not verify that the fetched +/// invoice's amount matches what the caller asked for, nor that it has a +/// usable expiry window. We call `GetInvoice` first, run these checks on +/// `Bolt12InvoiceContents`, and only then call `PayInvoice`. +/// +/// - `expected_amount_msats` — the amount Mostro asked LNDK to fetch. +/// - `min_expiry_secs` — minimum remaining lifetime before we consider the +/// invoice safe to route. Typically the order's invoice expiration window. +pub fn validate_fetched_invoice( + amount_msats: u64, + created_at_unix: i64, + relative_expiry_secs: u64, + expected_amount_msats: u64, + min_expiry_secs: u64, +) -> Result<(), MostroError> { + if amount_msats != expected_amount_msats { + return Err(MostroInternalErr(ServiceError::LnPaymentError(format!( + "BOLT12 invoice amount mismatch: got {amount_msats} msats, expected {expected_amount_msats}" + )))); + } + + if created_at_unix < 0 { + return Err(MostroInternalErr(ServiceError::LnPaymentError( + "BOLT12 invoice has negative created_at".into(), + ))); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let expires_at = (created_at_unix as u64).saturating_add(relative_expiry_secs); + if expires_at < now.saturating_add(min_expiry_secs) { + return Err(MostroInternalErr(ServiceError::LnPaymentError( + "BOLT12 invoice expired or too close to expiry".into(), + ))); + } + + // Upper bound on relative expiry: an absurdly long window is suspicious. + if Duration::from_secs(relative_expiry_secs) > Duration::from_secs(60 * 60 * 24 * 30) { + return Err(MostroInternalErr(ServiceError::LnPaymentError( + "BOLT12 invoice relative_expiry exceeds sanity bound".into(), + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_bolt12_offer() { + assert_eq!( + classify("lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcgqgn3qzsyvfkx26qkyypvr5hfx60h9w9k934lq8r2n"), + InvoiceFormat::Bolt12Offer + ); + } + + #[test] + fn classify_bolt12_invoice() { + assert_eq!(classify("lni1qqg…"), InvoiceFormat::Bolt12Invoice); + assert_eq!(classify("LNI1QQG…"), InvoiceFormat::Bolt12Invoice); + } + + #[test] + fn classify_bolt11() { + assert_eq!( + classify("lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq"), + InvoiceFormat::Bolt11 + ); + assert_eq!(classify("lnbcrt1p…"), InvoiceFormat::Bolt11); + assert_eq!(classify("lntb1p…"), InvoiceFormat::Bolt11); + } + + #[test] + fn classify_lnurl_and_address() { + assert_eq!(classify("lnurl1dp68gurn8ghj7…"), InvoiceFormat::Lnurl); + assert_eq!(classify("alice@getalby.com"), InvoiceFormat::LnAddress); + } + + #[test] + fn classify_unknown() { + assert_eq!(classify(""), InvoiceFormat::Unknown); + assert_eq!(classify("garbage"), InvoiceFormat::Unknown); + } + + #[test] + fn validate_offer_rejects_when_lndk_disabled() { + // Any string — should bail before parsing. + let err = validate_offer("lno1anything", None, 0, false).unwrap_err(); + assert!(matches!( + err, + MostroInternalErr(ServiceError::InvoiceInvalidError) + )); + } + + #[test] + fn validate_offer_rejects_garbage() { + let err = validate_offer("lno1not_a_real_offer", None, 0, true).unwrap_err(); + assert!(matches!( + err, + MostroInternalErr(ServiceError::InvoiceInvalidError) + )); + } + + #[test] + fn validate_fetched_invoice_amount_mismatch() { + let err = + validate_fetched_invoice(1_000_000, 1_700_000_000, 3600, 500_000, 60).unwrap_err(); + match err { + MostroInternalErr(ServiceError::LnPaymentError(msg)) => { + assert!(msg.contains("amount mismatch")); + } + other => panic!("unexpected error: {:?}", other), + } + } + + #[test] + fn validate_fetched_invoice_expired() { + // Created one hour ago, 60s expiry — well past now. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let err = validate_fetched_invoice(1_000_000, now - 3600, 60, 1_000_000, 60).unwrap_err(); + match err { + MostroInternalErr(ServiceError::LnPaymentError(msg)) => { + assert!(msg.contains("expired")); + } + other => panic!("unexpected error: {:?}", other), + } + } + + #[test] + fn validate_fetched_invoice_accepts_fresh() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + assert!(validate_fetched_invoice(1_000_000, now, 3600, 1_000_000, 60).is_ok()); + } + + #[test] + fn validate_fetched_invoice_rejects_absurd_expiry() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let err = validate_fetched_invoice( + 1_000_000, + now, + 60 * 60 * 24 * 365, // 1 year + 1_000_000, + 60, + ) + .unwrap_err(); + match err { + MostroInternalErr(ServiceError::LnPaymentError(msg)) => { + assert!(msg.contains("sanity bound")); + } + other => panic!("unexpected error: {:?}", other), + } + } + + #[test] + fn kind_tags() { + assert_eq!(kind_tag(InvoiceFormat::Bolt11), Some("bolt11")); + assert_eq!(kind_tag(InvoiceFormat::Bolt12Offer), Some("bolt12_offer")); + assert_eq!(kind_tag(InvoiceFormat::Unknown), None); + } +} diff --git a/src/main.rs b/src/main.rs index 23e7850a..48900bf3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ use crate::config::{ get_db_pool, Settings, DB_POOL, LN_STATUS, MESSAGE_QUEUES, MOSTRO_CONFIG, NOSTR_CLIENT, }; use crate::db::find_held_invoices; +use crate::lightning::lndk::LndkConnector; use crate::lightning::LnStatus; use crate::lightning::LndConnector; use crate::rpc::RpcServer; @@ -129,6 +130,32 @@ async fn main() -> Result<()> { panic!("No connection to LND node - shutting down Mostro!"); }; + // Connect to LNDK for BOLT12 offer payouts (optional, experimental). + // Failure when `lndk_enabled=true` is fatal: the operator explicitly + // asked for BOLT12 support, so silently dropping it would be worse + // than refusing to start. + let lndk_client = LndkConnector::new_from_settings().await.map_err(|e| { + tracing::error!("LNDK initialization failed: {}", e); + e + })?; + if lndk_client.is_some() { + tracing::info!("LNDK connector initialized for BOLT12 offer payouts"); + // Warn loudly if LND does not advertise the onion-message feature + // bit LNDK needs. This catches missing `--protocol.custom-*` flags + // before the first BOLT12 payout fails mysteriously. + let info = ln_client.get_node_info().await.ok(); + if let Some(info) = info { + let has_onion_msg = info.features.keys().any(|bit| *bit == 38 || *bit == 39); + if !has_onion_msg { + tracing::warn!( + "LNDK is enabled but LND does not advertise onion-message support; \ + start lnd with --protocol.custom-message=513 \ + --protocol.custom-nodeann=39 --protocol.custom-init=39" + ); + } + } + } + if let Ok(held_invoices) = find_held_invoices(get_db_pool().as_ref()).await { for invoice in held_invoices.iter() { if let Some(hash) = &invoice.hash { @@ -168,6 +195,7 @@ async fn main() -> Result<()> { settings, MESSAGE_QUEUES.queue_order_msg.clone(), mostro_keys.clone(), + lndk_client, ); // Start scheduler for tasks diff --git a/src/rpc/service.rs b/src/rpc/service.rs index b4fae4f9..ad43b17a 100644 --- a/src/rpc/service.rs +++ b/src/rpc/service.rs @@ -97,6 +97,7 @@ impl AdminServiceImpl { settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + None, ); let mut ln_client = self.ln_client.lock().await; admin_cancel_action(&ctx, msg, &event, &self.keys, &mut ln_client) @@ -152,6 +153,7 @@ impl AdminServiceImpl { settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + None, ); let mut ln_client = self.ln_client.lock().await; admin_settle_action(&ctx, msg, &event, &self.keys, &mut ln_client) @@ -206,6 +208,7 @@ impl AdminServiceImpl { settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + None, ); admin_add_solver_action(&ctx, msg, &event, &self.keys) .await @@ -260,6 +263,7 @@ impl AdminServiceImpl { settings, MESSAGE_QUEUES.queue_order_msg.clone(), self.keys.clone(), + None, ); admin_take_dispute_action(&ctx, msg, &event, &self.keys) .await diff --git a/src/util.rs b/src/util.rs index 1982657d..02e4bc6f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1242,6 +1242,28 @@ pub async fn validate_invoice(msg: &Message, order: &Order) -> Result { + tracing::info!("BIP-353 DNS: {pr} → bolt12 offer"); + offer + } + Ok(None) => pr, + Err(e) => { + tracing::warn!("BIP-353 DNS error for {pr}: {e}"); + pr + } + } + } else { + pr + }; + // Calculate total buyer fees (only Mostro fee) // Dev fee is NOT charged to buyer - it's paid by mostrod from its earnings let total_buyer_fees = order.fee;