Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"] }
Expand Down
2 changes: 1 addition & 1 deletion build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
203 changes: 203 additions & 0 deletions docs/LNDK_SETUP.md
Original file line number Diff line number Diff line change
@@ -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

```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add language specifiers to fenced code blocks (MD040).

Static analysis (markdownlint MD040) flagged two fenced blocks without a language. As per coding guidelines: "Add a language specifier to every fenced code block in documentation to comply with markdownlint MD040".

📝 Proposed fix
-```
+```text
 Buyer  ---- Nostr ---->  Mostro  ----gRPC---->  LNDK  ----gRPC---->  LND
 …
-```
+```ini
 [protocol]
 protocol.custom-message=513
 protocol.custom-nodeann=39
 protocol.custom-init=39

Also applies to: 46-46

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 13-13: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

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

In `@docs/LNDK_SETUP.md` at 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.

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
<https://github.com/lndk-org/lndk#setting-up-lndk>. 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.
13 changes: 13 additions & 0 deletions docs/STARTUP_AND_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>): 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:*
Expand Down
Loading