Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9af0a33
feat: extract inbox subscription into dedicated module
AndreaDiazCorreia Aug 13, 2026
1744b58
feat: auto-recover inbox subscription after relay-initiated closures
AndreaDiazCorreia Aug 13, 2026
af4c8d5
feat: add NIP-42 authentication to prevent relay read-gating
AndreaDiazCorreia Aug 13, 2026
a6add01
feat: add inbox subscription watchdog to detect and recover silent fa…
AndreaDiazCorreia Aug 13, 2026
50b27a8
feat: compensate order timeout deadlines for inbox downtime
AndreaDiazCorreia Aug 13, 2026
67ff82a
docs: document inbox subscription lifecycle and recovery mechanisms
AndreaDiazCorreia Aug 13, 2026
86d6918
feat: date first-audit blindness from startup, gate timeouts on confi…
AndreaDiazCorreia Aug 13, 2026
1525b55
docs: refactor timeout compensation from decaying allowance to per-or…
AndreaDiazCorreia Aug 13, 2026
b6dd1a5
feat: defer inbox subscription until notification stream exists to pr…
AndreaDiazCorreia Aug 13, 2026
2e56872
feat: require relay EOSE acknowledgement before counting inbox as hea…
AndreaDiazCorreia Aug 13, 2026
9dccb34
feat: release bonds without slashing after prolonged inbox outage to …
AndreaDiazCorreia Aug 13, 2026
379e819
feat: invalidate relay acknowledgement when re-subscribing to prevent…
AndreaDiazCorreia Aug 13, 2026
9039505
feat: bind relay acknowledgement to websocket session to prevent stal…
AndreaDiazCorreia Aug 21, 2026
4087027
feat: stand down on auth-required and rate-limited closures to let SD…
AndreaDiazCorreia Aug 21, 2026
23aada2
feat: consolidate inbox re-subscribe pacing in InboxHealth to prevent…
AndreaDiazCorreia Aug 21, 2026
71bb1b4
docs: correct inbox subscription origin from main to event loop in do…
AndreaDiazCorreia Aug 21, 2026
ad76e0b
Merge remote-tracking branch 'origin/main' into fix/nostr-inbox-resub…
AndreaDiazCorreia Aug 22, 2026
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
80 changes: 74 additions & 6 deletions docs/EVENT_ROUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ How Nostr events become actions and side effects.
- Source: `src/app.rs:run`
- Steps: POW check → signature verify → recency guard → NIP-59 unwrap → parse `mostro_core::Message` → inner verify → `check_trade_index` → dispatch.

## The Inbox Subscription
- Source: `src/inbox.rs`
- Every trade message reaches Mostro over a single long-lived subscription, created at startup by `main` with a stable id (`InboxSubscription`) so that later frames can be attributed to it. Its filter is p-tagged to the node, restricted to the configured transport's event kind, and carries `limit(0)`: only live traffic is wanted.
Comment thread
AndreaDiazCorreia marked this conversation as resolved.
Outdated
- `run` consumes the whole notification stream, not just events. `ClientNotification::Message` carries the relay control plane and goes to `InboxKeeper`; `ClientNotification::Shutdown` ends the loop.

### Recovering a lost ear
A relay can end a subscription at any time by sending `CLOSED`. The nostr-sdk removes the subscription for almost every reason prefix, and a removed subscription is never re-REQ'd, not even after a reconnect — so without handling, one frame from one relay leaves the daemon running, connected, and unable to receive anything.

Two mechanisms keep the subscription alive:

- `InboxKeeper::on_relay_message` reacts to a `CLOSED` naming the inbox by re-sending the REQ to that relay, paced by a per-relay backoff (immediate first retry, doubling to a five-minute ceiling, cleared when the relay answers with `EOSE`).
- `check_inbox_health`, run every 30 seconds by `job_inbox_watchdog`, audits each connected read relay and re-subscribes any that is no longer serving the inbox. This covers the losses that produce no frame the loop can see: a notification channel that dropped messages under lag, a REQ that failed to go out, a relay added after startup.

Health is judged by the subscription, never by traffic volume: an instance with no trades in flight is legitimately silent.

A relay counts as serving the inbox only once **it** has said so, by answering the REQ with an `EOSE` that the event loop recorded in `InboxHealth`. The SDK's own subscription map is not evidence — it records what Mostro sent, so a relay that holds the connection open and quietly drops the REQ still appears subscribed there, and the daemon would resume order timeouts while deaf. The same rule means a relay re-subscribed during an audit does not count until it answers, which costs one interval before recovery is declared and errs toward keeping the timeout clock frozen slightly longer than strictly needed.

### NIP-42
The daemon and price clients are built with a `SignerAuthenticator` over the node's keys (`src/util.rs:connect_nostr`). Without it a relay that gates reads behind authentication answers the REQ with `CLOSED "auth-required: …"`, which the SDK treats as permanent. The AUTH event is bound to the relay's challenge and URL, so it cannot be replayed elsewhere.

### Messages lost while blind are not recovered
`accept_event` rejects anything whose `created_at` is older than ten seconds. A message sent while the inbox was down is therefore already too old to be accepted by the time the subscription returns, and re-subscribing with `since` instead of `limit(0)` would not change that. Whoever sent it has to send it again.

Because those messages are lost rather than delayed, order timeouts cannot be trusted while the inbox is down — a user who answered on time would look silent. `job_cancel_orders` therefore skips its tick entirely unless an audit has confirmed the daemon is listening (`InboxHealth::is_confirmed_listening`): no slash, no refund, no republish. Startup counts as unconfirmed, since the daemon subscribes before the watchdog's first pass.

Once the inbox recovers, each order is credited the downtime **it** waited through. `InboxHealth` keeps the wall-clock windows during which the node was deaf; `blind_seconds_since(taken_at)` intersects them with the order's own wait. An order already waiting when a relay went quiet is owed all of that outage; one taken after it ended is owed nothing. The query widens its window by `max_blind_seconds` so no eligible order is missed, and the exact per-order figure decides.

The credit has to be per order rather than one global allowance: a single figure either under-credits an order that waited through the whole outage or hands the same credit to one taken long afterwards.

Deferring cannot be unconditional, though. The same pass that slashes a bond is the one that releases it and the one that cancels the seller's hold invoice, so waiting forever on a permanently broken inbox would leave escrows encumbered until CLTV expiry and honest takers' bonds locked indefinitely. After three hours without a confirmed inbox, timed-out orders are unwound anyway — but blamelessly: bonds are released rather than settled (`bond::release_on_timeout_without_slashing`), and the downtime credit is skipped, since by then every waiting order would be owed more than its deadline and nothing would ever be unwound.

## Dispatch
- Router: `src/app.rs:handle_message_action`
- Maps `Action` → module function under `src/app/*`.
Expand All @@ -26,20 +57,57 @@ How Nostr events become actions and side effects.
```mermaid
sequenceDiagram
participant Relay as Nostr Relay
participant Loop as app.rs (run)
participant EventLoop as app.rs (run)
participant Keeper as InboxKeeper
participant Router as handle_message_action
participant Mod as app/*
participant DB as DB
participant LND as LND

Relay-->>Loop: GiftWrap Event
Loop->>Loop: POW + verify + freshness
Loop->>Loop: unwrap + parse Message
Loop->>DB: check_trade_index
Loop->>Router: dispatch(Action)
Relay-->>EventLoop: GiftWrap Event
EventLoop->>EventLoop: POW + verify + freshness
EventLoop->>EventLoop: unwrap + parse Message
EventLoop->>DB: check_trade_index
EventLoop->>Router: dispatch(Action)
Router->>Mod: handler(...)
par side-effects
Mod->>DB: read/write
Mod->>LND: hold/settle/cancel/pay
end

Relay-->>EventLoop: CLOSED (inbox subscription)
EventLoop->>Keeper: on_relay_message
Keeper->>Relay: REQ (same subscription id)
```

The watchdog runs on its own schedule, independently of the loop above:

```mermaid
sequenceDiagram
participant Job as job_inbox_watchdog
participant Relay as connected read relays
participant Health as InboxHealth
participant Timeouts as job_cancel_orders

loop every 30s
Job->>Relay: still serving the inbox subscription?
alt not serving it
Job->>Relay: REQ (same subscription id)
end
alt none were serving it
Job->>Health: Blind
else at least one was
Job->>Health: Listening
end
end

loop every 60s
Timeouts->>Health: is_confirmed_listening?
alt not confirmed
Timeouts->>Timeouts: skip the tick
else confirmed
Timeouts->>Health: blind_seconds_since(taken_at) per order
Timeouts->>Timeouts: run, each order credited its own downtime
end
end
```
6 changes: 6 additions & 0 deletions docs/STARTUP_AND_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ Configuration is loaded from `~/.mostro/settings.toml` (template: `settings.tpl.
- `relays` (Vec<String>): List of Nostr relay URLs for event broadcasting
- Default: `['ws://localhost:7000']`
- Note: At least one relay required
- Relays that require NIP-42 authentication are supported: Mostro answers the
challenge with its own key. No configuration is needed.
- A relay that ends Mostro's inbox subscription is re-subscribed
automatically, and a watchdog audits the subscription every 30 seconds. If
no relay is serving it, the log carries `Mostro inbox is BLIND` and order
timeouts are held until it recovers. See `docs/EVENT_ROUTING.md`.

**Lightning** (`src/config/types.rs:27-46`):
- `lnd_cert_file` (String): Path to LND TLS certificate
Expand Down
147 changes: 106 additions & 41 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ use crate::app::trade_pubkey::trade_pubkey_action;
// Core functionality imports
use crate::db::add_new_user;
use crate::db::is_user_present;
use crate::inbox::{InboxKeeper, InboxSubscription};
use crate::lightning::LndConnector;
use crate::util::enqueue_cant_do_msg;
use crate::Result;
Expand Down Expand Up @@ -409,6 +410,11 @@ async fn accept_event(
Some((action, message, unwrapped))
}

/// How long to wait before re-attaching to the notification stream after it
/// ended without a shutdown. Long enough that a persistent failure cannot burn
/// a core, short enough that a transient one costs no meaningful deaf time.
const NOTIFICATION_STREAM_RETRY: std::time::Duration = std::time::Duration::from_secs(1);

/// Shared post-dispatch error handling (identical in both loops). A handler
/// `Err` is downcast to a `MostroError` and turned into the right reply
/// (`manage_errors`) or logged (`warning_msg`); `Ok` is a no-op. Factored out
Expand Down Expand Up @@ -457,37 +463,69 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> {
// gate is meaningless for v1 (gift wraps are signed by throwaway keys).
let pow_first_contact = ctx.settings().mostro.effective_pow_first_contact();
let is_v2 = accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND;
// The inbox identity is derived here rather than passed around (see
// `crate::inbox`).
let subscription = InboxSubscription::new(my_keys.public_key(), accepted_kind);
let mut keeper = InboxKeeper::new(subscription.clone());
let mut subscribed = false;

loop {
let mut notifications = client.notifications();

// The REQ goes out only once this stream exists. A notification
// receiver never sees what was delivered before it was created, so
// subscribing any earlier throws away the relay's EOSE — and every
// event that lands while the rest of the daemon is still booting.
if !subscribed {
subscription.subscribe(client).await?;
subscribed = true;
}
Comment thread
AndreaDiazCorreia marked this conversation as resolved.

while let Some(notification) = notifications.next().await {
if let ClientNotification::Event { event, .. } = notification {
let Some((action, message, unwrapped)) = accept_event(
&ctx,
&event,
my_keys,
pow,
pow_first_contact,
accepted_kind,
is_v2,
)
.await
else {
continue;
};
let result = handle_message_action(
&action,
message.clone(),
&unwrapped,
my_keys,
ln_client,
&ctx,
)
.await;
finalize_dispatch(result, message, unwrapped, &action).await;
match notification {
ClientNotification::Event { event, .. } => {
let Some((action, message, unwrapped)) = accept_event(
&ctx,
&event,
my_keys,
pow,
pow_first_contact,
accepted_kind,
is_v2,
)
.await
else {
continue;
};
let result = handle_message_action(
&action,
message.clone(),
&unwrapped,
my_keys,
ln_client,
&ctx,
)
.await;
finalize_dispatch(result, message, unwrapped, &action).await;
}
ClientNotification::Message { relay_url, message } => {
keeper.on_relay_message(client, &relay_url, &message).await;
}
ClientNotification::Shutdown => return Ok(()),
}
}

// The stream ended without a `Shutdown` frame. That frame can be
// missed — the SDK's notification channel silently drops messages when
// the consumer falls behind — and after a shutdown `notifications()`
// hands back an empty stream, so re-taking it unconditionally spins
// this loop at full tilt. Leave when the client is done, and pace the
// retry otherwise.
if client.is_shutdown() {
return Ok(());
}
tracing::warn!("Nostr notification stream ended without a shutdown; re-attaching");
tokio::time::sleep(NOTIFICATION_STREAM_RETRY).await;
}
}

Expand All @@ -508,30 +546,57 @@ pub async fn run_cashu(ctx: AppContext) -> Result<()> {
let accepted_kind = ctx.settings().mostro.transport.event_kind();
let pow_first_contact = ctx.settings().mostro.effective_pow_first_contact();
let is_v2 = accepted_kind.as_u16() == crate::config::constants::DM_EVENT_KIND;
let subscription = InboxSubscription::new(my_keys.public_key(), accepted_kind);
let mut keeper = InboxKeeper::new(subscription.clone());
let mut subscribed = false;

loop {
let mut notifications = client.notifications();

// Subscribe only once the stream exists — see `run`.
if !subscribed {
subscription.subscribe(client).await?;
subscribed = true;
}

while let Some(notification) = notifications.next().await {
if let ClientNotification::Event { event, .. } = notification {
let Some((action, message, unwrapped)) = accept_event(
&ctx,
&event,
my_keys,
pow,
pow_first_contact,
accepted_kind,
is_v2,
)
.await
else {
continue;
};
let result =
dispatch_cashu(&action, message.clone(), &unwrapped, my_keys, &ctx).await;
finalize_dispatch(result, message, unwrapped, &action).await;
match notification {
ClientNotification::Event { event, .. } => {
let Some((action, message, unwrapped)) = accept_event(
&ctx,
&event,
my_keys,
pow,
pow_first_contact,
accepted_kind,
is_v2,
)
.await
else {
continue;
};
let result =
dispatch_cashu(&action, message.clone(), &unwrapped, my_keys, &ctx).await;
finalize_dispatch(result, message, unwrapped, &action).await;
}
ClientNotification::Message { relay_url, message } => {
keeper.on_relay_message(client, &relay_url, &message).await;
}
ClientNotification::Shutdown => return Ok(()),
}
}

// The stream ended without a `Shutdown` frame. That frame can be
// missed — the SDK's notification channel silently drops messages when
// the consumer falls behind — and after a shutdown `notifications()`
// hands back an empty stream, so re-taking it unconditionally spins
// this loop at full tilt. Leave when the client is done, and pace the
// retry otherwise.
if client.is_shutdown() {
return Ok(());
}
tracing::warn!("Nostr notification stream ended without a shutdown; re-attaching");
tokio::time::sleep(NOTIFICATION_STREAM_RETRY).await;
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/app/bond/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ pub use model::Bond;
pub use payout::{add_bond_invoice_action, run_bond_payout_cycle};
pub use slash::{
apply_bond_resolution, extract_bond_resolution, notify_bond_slashed,
reconcile_stranded_range_maker_bonds, resolve_range_maker_bond_at_close,
resolve_range_maker_bond_at_close_or_warn, slash_or_release_on_timeout,
validate_bond_resolution,
reconcile_stranded_range_maker_bonds, release_on_timeout_without_slashing,
resolve_range_maker_bond_at_close, resolve_range_maker_bond_at_close_or_warn,
slash_or_release_on_timeout, validate_bond_resolution,
};
pub use types::{BondRole, BondSlashReason, BondState};
13 changes: 13 additions & 0 deletions src/app/bond/slash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,19 @@ async fn release_on_timeout(pool: &Pool<Sqlite>, order_id: Uuid, republishes: bo
}
}

/// Resolve a timed-out order's bonds without holding anyone responsible.
///
/// Used when the timeout cannot be attributed to the user: the daemon's Nostr
/// inbox has been unreachable long enough that waiting any longer would keep
/// hold invoices encumbered until CLTV expiry (see `job_cancel_orders`). The
/// order still has to be unwound, but a silence the node could not hear is not
/// evidence of abandonment, so every bond involved is released rather than
/// settled — the republish-vs-cancel distinction is honoured exactly as in
/// [`slash_or_release_on_timeout`].
pub async fn release_on_timeout_without_slashing(pool: &Pool<Sqlite>, order: &Order) {
release_on_timeout(pool, order.id, order_republishes_on_timeout(order)).await;
}

pub async fn slash_or_release_on_timeout<L: SettleLightning + Send>(
pool: &Pool<Sqlite>,
ln_client: &mut L,
Expand Down
Loading