Lightning Operations
June 16, 2026 · View on GitHub
Core interactions with LND via fedimint_tonic_lnd.
Connector
- Source:
src/lightning/mod.rs - Type:
LndConnector { client: Client } - Construct:
LndConnector::new()using host, cert, macaroon from settings.
Hold Invoices
- Create:
create_hold_invoice(description, amount)→(AddHoldInvoiceResp, preimage, hash). - Subscribe:
subscribe_invoice(r_hash, sender)streamsInvoiceStateupdates. - Settle:
settle_hold_invoice(preimage). - Cancel:
cancel_hold_invoice(hash).
Outgoing Payments
send_payment(invoice, amount, sender)- Validates invoice amount or supplies
amt. - Caps max fee: 1% for amounts ≤1000 sats;
Settings::get_mostro().max_routing_feefor larger amounts. - Streams router updates to caller.
- Validates invoice amount or supplies
Node Status
get_node_info(); mapped toLnStatusand stored inconfig::LN_STATUS.
Invoice Validation
Source: src/lightning/invoice.rs
The invoice validation module provides comprehensive validation for Lightning invoices, Lightning Addresses, and LNURL-pay requests.
Functions
decode_invoice
pub fn decode_invoice(payment_request: &str) -> Result<Bolt11Invoice, MostroError>
Decodes a BOLT11 invoice string into a structured invoice object.
Parameters:
payment_request: BOLT11 invoice string
Returns: Bolt11Invoice or MostroError::InvalidInvoice
Entry: src/lightning/invoice.rs:33
is_valid_invoice
pub async fn is_valid_invoice(
amount: i64,
fee: i64,
payment_request: &str,
) -> Result<String, MostroError>
Comprehensive validation supporting:
- BOLT11 Lightning invoices
- Lightning Addresses (user@domain.com)
- LNURL-pay requests
Validation checks:
- Invoice format and decoding
- Amount matching (with fee deduction):
expected_amount = amount - fee - Minimum payment amount enforcement (
mostro_settings.min_payment_amount) - Expiration validation (
invoice.is_expired()) - Expiration window compliance:
expires_at > now + invoice_expiration_window - Lightning Address resolution via LNURL
- LNURL callback with amount parameter
Entry: src/lightning/invoice.rs:181
Error cases:
InvalidInvoice: Decoding fails or format invalidWrongAmountError: Invoice amount doesn't match expected (after fee deduction)MinAmountError: Amount below minimum thresholdInvoiceExpiredError: Invoice already expiredExpirationWindowTooShort: Expires before required window
Payment Retry System
Source: src/scheduler.rs:172 (job_retry_failed_payments)
Failed outgoing payments are automatically retried via the scheduler.
Configuration
Settings (src/config/types.rs:28-46):
pub struct LightningSettings {
// ... other fields ...
pub payment_attempts: u32, // Max retry attempts (default: 3)
pub payment_retries_interval: u32, // Seconds between retries (default: 60)
}
Retry Job
Function: job_retry_failed_payments()
- Queries database for failed payments with retry attempts remaining
- Respects
payment_attemptslimit - Scheduled at
payment_retries_intervalfrequency - Automatically invokes
send_payment()for each retry - Updates payment status and attempt count in database
Workflow
- Payment fails initially → marked as failed in DB
- Scheduler runs retry job every N seconds (payment_retries_interval)
- Job finds failed payments with
attempts < payment_attempts - Invokes send_payment() again
- Increments attempt counter
- Continues until success or max attempts reached
Payment Error Handling
Pre-flight Checks (lines 183-201):
Before attempting payment, send_payment() uses track_payment_v2 to detect duplicate attempts:
// Check if payment was previously attempted
match ln_client.router().track_payment_v2(track_req).await {
Ok(_) => {
error!("Aborting paying invoice with hash {hash} to buyer");
return Err(MostroError::TrackError);
}
Err(_) => {
// Payment not found, safe to proceed
}
}
Amount Validation (lines 210-220):
if let Some(amt_msat) = invoice.amount_milli_satoshis() {
let invoice_amount_sats = amt_msat / 1000;
if invoice_amount_sats != amount as u64 * 1000 {
error!("Aborting paying invoice with wrong amount to buyer");
return Err(MostroError::WrongAmountError);
}
}
Zero-Amount Invoice Handling (lines 222-228):
If invoice has no amount, the amt field is populated in SendPaymentRequest:
if invoice.amount_milli_satoshis().is_none() {
req.amt = amount;
}
Fee Limit Enforcement:
let max_fee = match amount.cmp(&1000) {
// For small amounts, use 1% but ensure minimum of 10 sats
Ordering::Less | Ordering::Equal => (amount as f64 * 0.01).max(10.0),
Ordering::Greater => amount as f64 * mostro_settings.max_routing_fee,
};
req.fee_limit_sat = max_fee as i64;
Timeout: 60 seconds (line 205)
Node Information
get_node_info
pub async fn get_node_info(&mut self) -> Result<GetInfoResponse, MostroError>
Retrieves LND node information including:
- Node version
- Public key
- Node alias
- Active chains (e.g., bitcoin mainnet/testnet)
- Network information
- Block height sync status
Entry: src/lightning/mod.rs:260
Usage: Called during startup to populate config::LN_STATUS (src/main.rs:86)
Anti-Abuse Bond Operations
The optional anti-abuse bond ([anti_abuse_bond], off by default) puts a
second hold invoice on a trade, owned by the maker and/or taker. It is
released on normal completion and on cancels before a waiting-state
timeout; it is slashed only on an explicit solver BondResolution
directive or a waiting-state timeout (when slash_on_waiting_timeout = true). Full design: docs/ANTI_ABUSE_BOND.md. This section is the
operator runbook.
Where the state lives
Every bond is one row in the bonds table (src/app/bond/db.rs,
model.rs). Inspect it directly:
SELECT id, order_id, role, state, amount_sats,
parent_bond_id, child_order_id, slashed_share_sats,
node_share_sats, slashed_reason,
payout_attempts, invoice_request_attempts, slashed_at
FROM bonds ORDER BY created_at;
state (string-backed, src/app/bond/types.rs) walks:
requested → locked ─┬→ released (happy / cancel before timeout)
└→ pending-payout ─┬→ slashed (counterparty paid their share)
├→ forfeited (counterparty never claimed in window)
└→ failed (send_payment exhausted)
pending-payout— a slash already fired. The bond HTLC was settled (claimed into Mostro's wallet) at slash time; the scheduler is now driving the counterparty payout. The split is frozen here:node_share_satsis the node's retained share,amount_sats - node_share_satsis owed to the winning counterparty.slashed— terminal success; the counterparty share was paid.forfeited— designed-in long-stop: the counterparty never sent a payout invoice withinpayout_claim_window_days. The node keepsamount_satsin full. No operator action needed.failed—send_paymentexhaustedpayout_max_retriesagainst a delivered invoice. User-recoverable while inside the claim window: a freshAction::AddBondInvoicefrom the recipient flips the row back topending-payout. Only past the window does it need operator attention (see below).slashed_reasonislost-dispute(solver directive) ortimeout(waiting-state timeout). A cancel before the timeout is never a slash.
For range-order maker bonds the parent row stays locked while child
rows (parent_bond_id set, child_order_id = the taken slice) carry the
proportional per-slice slashes; the single settle happens at range close.
Scheduler jobs
Run from src/scheduler.rs (see run_jobs):
job_process_bond_payouts— drives everypending-payoutrow: requests a payout bolt11 from the winner (Action::AddBondInvoice, cadenced bypayout_invoice_window_seconds), runssend_payment, retries up topayout_max_retries, and reconciles against LND on entry so a daemon restart never double-pays.job_reconcile_stranded_maker_bonds— settles and distributes a range maker bond at range close (per-slice counterparty shares + maker refund); the 5-minute sweep is the backstop if the inline close failed.
Reading what happened in the logs
Bond transitions log through tracing (bond payout: … lines in
src/app/bond/payout.rs, plus slash/release lines in flow.rs /
slash.rs). To follow a solver decision, look for the BondResolution
on the inbound admin-settle / admin-cancel message — its wire shape is:
{ "order": { "version": 1, "id": "<order-id>", "action": "admin-cancel",
"payload": { "bond_resolution": { "slash_seller": true, "slash_buyer": false } } } }
slash_seller / slash_buyer are resolved to a maker- or taker-bond row
by the order kind (sell → maker is seller; buy → maker is buyer). A
payload: null (or absent) means release both bonds — no slash. A
slash directed at a side with no locked bond is rejected with
CantDo(InvalidPayload) and the trade resolution does not run.
Resolving a failed bond manually
A failed row means the bond was slashed (sats are already in Mostro's
wallet), but Mostro could not route the counterparty's share and the
claim window has since elapsed, so the auto-recovery path no longer
re-arms it. There is no slash to undo and no funds at risk on the
counterparty's side — the value is held by the node. To make the
counterparty whole, pay them out-of-band (the amount owed is
amount_sats - node_share_sats) and keep the row as the audit record.
Before the window elapses, prefer the built-in path: have the
counterparty resend their payout invoice, which flips the row back to
pending-payout automatically.
Public exposure
The node advertises its bond policy in the kind-38385 info event
(src/nip33.rs::info_to_tags) so clients can warn users before they
trade: bond_enabled (always emitted), and when enabled bond_apply_to,
bond_amount_pct, bond_base_amount_sats, bond_slash_on_waiting_timeout,
bond_slash_node_share_pct, and bond_payout_claim_window_days.
Diagrams
flowchart TD A[Create Hold Invoice] --> B[Subscribe Single Invoice] B -->|Settled| C[Settle Hold] B -->|Cancel/Expire| D[Cancel Hold] E[Send Payment] --> F[Track/Stream Updates]