Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/MESSAGE_FLOW_AND_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ Mostrix uses Nostr transports for two distinct purposes:
Mostro daemons advertise wire format on the **instance status** event (kind **38385**):

- Tag **`protocol_version`**: `"1"` → GiftWrap, `"2"` → NIP-44 direct messages.
- Mostrix parses this into [`MostroInstanceInfo.protocol_version`](../src/util/mostro_info.rs) and resolves [`Transport`](../src/util/mod.rs) with [`transport_from_instance`](../src/util/mostro_info.rs).
- [`AppState.transport`](../src/ui/app_state.rs) is kept in sync whenever instance info updates ([`set_mostro_info`](../src/ui/app_state.rs)).
- Mostrix parses this into [`MostroInstanceInfo.protocol_version`](../src/util/mostro_info.rs) and resolves [`Transport`](../src/util/mod.rs) with [`transport_from_instance`](../src/util/mostro_info.rs). Intake authenticates the kind-38385 event client-side ([`fetch_mostro_instance_info`](../src/util/mostro_info.rs) / MOSTRO-075).
- [`AppState.transport`](../src/ui/app_state.rs) is kept in sync when instance info updates ([`set_mostro_info`](../src/ui/app_state.rs); older `created_at` than the cache is ignored).
- The **Mostro Info** tab displays protocol version and resolved wire transport.

**Outbound send (implemented):** [`send_dm`](../src/util/dm_utils/mod.rs) uses `transport_from_instance` + [`wrap_message_with`](../src/util/mod.rs); v2 adds default NIP-40 expiration (30 days) when `expiration` is `None`.
Expand Down
4 changes: 2 additions & 2 deletions docs/POW_AND_OUTBOUND_EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ This document describes how Mostrix applies **NIP-13 proof-of-work** to events i

## Cached instance info at runtime

- [`AppState.mostro_info`](../src/ui/app_state.rs) holds the latest fetched `MostroInstanceInfo`.
- [`AppState.transport`](../src/ui/app_state.rs) mirrors resolved [`Transport`](../src/util/mod.rs). Updated via [`set_mostro_info`](../src/ui/app_state.rs).
- [`AppState.mostro_info`](../src/ui/app_state.rs) holds the latest **client-authenticated** `MostroInstanceInfo` (see [`fetch_mostro_instance_info`](../src/util/mostro_info.rs)).
- [`AppState.transport`](../src/ui/app_state.rs) mirrors resolved [`Transport`](../src/util/mod.rs). Updated via [`set_mostro_info`](../src/ui/app_state.rs) (stale `created_at` ignored).
- [`EnterKeyContext`](../src/ui/key_handler/mod.rs) threads `mostro_info` into async work without re-fetching per message.
- [`send_dm`](../src/util/dm_utils/mod.rs) takes `mostro_instance: Option<&MostroInstanceInfo>` and computes `pow = nostr_pow_for_protocol_dm(mostro_instance, action)` once per send.

Expand Down
5 changes: 3 additions & 2 deletions docs/STARTUP_AND_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,10 @@ Proof-of-work for published events is taken from the Mostro instance status even

Background and manual refresh (Mostro Info tab → Enter) fetch the daemon status event and update UI state:

- **`AppState.mostro_info`**: parsed tags (`pow`, `bond_enabled`, `protocol_version`, LND metadata, …) — see [`mostro_info_from_tags`](../src/util/mostro_info.rs).
- **`AppState.transport`**: resolved wire transport for **protocol DMs** via [`transport_from_instance`](../src/util/mostro_info.rs). Updated through [`AppState.set_mostro_info`](../src/ui/app_state.rs) (startup await, main loop `MostroInfoFetchResult`, reconnect, invalid-pubkey clear).
- **`AppState.mostro_info`**: parsed tags (`pow`, `bond_enabled`, `protocol_version`, LND metadata, …) via [`mostro_info_from_authenticated_event`](../src/util/mostro_info.rs) after client-side auth (tag-only parsing: [`mostro_info_from_tags`](../src/util/mostro_info.rs)).
- **`AppState.transport`**: resolved wire transport for **protocol DMs** via [`transport_from_instance`](../src/util/mostro_info.rs). Updated through [`AppState.set_mostro_info`](../src/ui/app_state.rs) (startup await, main loop `MostroInfoFetchResult`, reconnect, invalid-pubkey clear; ignores older `created_at` than the cache).
- **Startup**: when relays are reachable, [`run_post_terminal_startup`](../src/startup.rs) **awaits** [`fetch_mostro_instance_info`](../src/util/mostro_info.rs) before spawning the DM listener so the first subscription uses the correct transport (v1 GiftWrap or v2 kind 14). On fetch failure or offline boot, transport defaults to GiftWrap.
- **Auth (MOSTRO-075)**: relay `.author()` filters are not trusted. [`fetch_mostro_instance_info`](../src/util/mostro_info.rs) re-checks `event.pubkey`, the `d` tag, and `Event::verify` via [`instance_info_event_is_authentic`](../src/util/mostro_info.rs) / [`select_authentic_instance_info_event`](../src/util/mostro_info.rs) before apply. [`MostroInstanceInfoFetch::Rejected`](../src/util/mostro_info.rs) / `NotFound` preserve the cached revision (no `set_mostro_info(None)`); relay fetch failures use [`MostroInfoFetchResult::FetchFailed`](../src/ui/orders.rs) (same). Explicit clears stay on hard [`MostroInfoFetchResult::Err`](../src/ui/orders.rs) (invalid settings/pubkey).

Displayed on the **Mostro Info** tab: protocol version (`1` / `2` / unknown) and wire transport label (GiftWrap vs NIP-44 direct).

Expand Down
14 changes: 11 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ async fn main() -> Result<(), anyhow::Error> {
match res {
MostroInfoFetchResult::Ok { info, message } => {
let old_transport = app.transport;
app.set_mostro_info(*info);
app.set_mostro_info(Some(*info));
if old_transport != app.transport {
log::warn!(
"Mostro protocol transport changed {:?} -> {:?}; restarting DM listener",
Expand Down Expand Up @@ -689,8 +689,16 @@ async fn main() -> Result<(), anyhow::Error> {
crate::ui::OperationResult::Info(message),
);
}
MostroInfoFetchResult::Applied { info } => {
app.set_mostro_info(*info);
MostroInfoFetchResult::NotFound { message }
| MostroInfoFetchResult::Rejected { message } => {
app.mode = crate::ui::UiMode::operation_result(
crate::ui::OperationResult::Info(message),
);
}
MostroInfoFetchResult::FetchFailed { message } => {
app.mode = crate::ui::UiMode::operation_result(
crate::ui::OperationResult::Error(message),
);
}
MostroInfoFetchResult::Err(e) => {
app.set_mostro_info(None);
Expand Down
11 changes: 6 additions & 5 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,16 +214,17 @@ pub async fn run_post_terminal_startup(
let transport_for_listener = if relays_reachable {
set_startup_phase(phase_tx, "Fetching Mostro instance info…");
match fetch_mostro_instance_info(&client, mostro_pubkey).await {
Ok(info) => {
app.set_mostro_info(info);
Ok(fetch) => {
if let Some(info) = fetch.to_apply() {
app.set_mostro_info(Some(info));
}
app.transport
}
Err(e) => {
log::warn!(
"Failed to fetch Mostro instance info at startup: {e}; defaulting to GiftWrap transport"
"Failed to fetch Mostro instance info at startup: {e}; keeping cached/default transport"
);
app.set_mostro_info(None);
Transport::default()
app.transport
}
}
} else {
Expand Down
60 changes: 59 additions & 1 deletion src/ui/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,11 @@ pub struct AppState {
pub pending_admin_disputes_reload: bool,
/// Cached copy of currencies filter from settings (used for UI-side filtering).
pub currencies_filter: Vec<String>,
/// Cached Mostro instance info (kind 38385 event), if available.
/// Cached Mostro instance info (kind 38385), if available.
/// Populated only from client-authenticated fetches; apply via [`Self::set_mostro_info`].
pub mostro_info: Option<MostroInstanceInfo>,
/// Wire transport resolved from [`Self::mostro_info`] (`protocol_version` tag).
/// Kept in sync by [`Self::set_mostro_info`] (including stale-revision ignore).
pub transport: Transport,
/// Non-blocking overlay shown when relays are unreachable.
pub offline_overlay_message: Option<String>,
Expand Down Expand Up @@ -401,7 +403,22 @@ impl AppState {
}

/// Replace cached instance info and keep [`Self::transport`] in sync.
///
/// Fail-closed on stale revisions: when both the incoming and cached values carry
/// `last_updated`, an older `created_at` is ignored so a lagging or malicious relay
/// cannot roll transport / fee / PoW display back (MOSTRO-075 monotonicity).
/// Explicit `None` still clears (invalid pubkey, hard fetch errors).
pub fn set_mostro_info(&mut self, info: Option<MostroInstanceInfo>) {
if let (Some(new), Some(old)) = (info.as_ref(), self.mostro_info.as_ref()) {
if let (Some(new_ts), Some(old_ts)) = (new.last_updated, old.last_updated) {
if new_ts < old_ts {
log::warn!(
"Ignoring stale Mostro instance info (created_at {new_ts} < cached {old_ts})"
);
return;
}
}
}
self.transport = transport_from_instance(info.as_ref());
self.mostro_info = info;
}
Expand Down Expand Up @@ -477,9 +494,12 @@ impl AppState {
}

#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
use crate::ui::chat::{ChatSender, DisputeChatMessage};
use mostro_core::prelude::Transport;
use nostr_sdk::prelude::Timestamp;

fn dummy_observer_message(content: &str) -> DisputeChatMessage {
DisputeChatMessage {
Expand Down Expand Up @@ -521,4 +541,42 @@ mod tests {
assert!(app.observer_messages.is_empty());
assert!(!app.observer_loading);
}

/// MOSTRO-075: stale created_at must not roll transport back.
#[test]
fn set_mostro_info_rejects_older_created_at() {
let mut app = AppState::new(UserRole::User);
let newer = MostroInstanceInfo {
last_updated: Some(Timestamp::from(2_000)),
protocol_version: Some(2),
..Default::default()
};
app.set_mostro_info(Some(newer));
assert_eq!(app.transport, Transport::Nip44Direct);

let older = MostroInstanceInfo {
last_updated: Some(Timestamp::from(1_000)),
protocol_version: Some(1),
..Default::default()
};
app.set_mostro_info(Some(older));
assert_eq!(app.transport, Transport::Nip44Direct);
assert_eq!(
app.mostro_info.as_ref().and_then(|i| i.protocol_version),
Some(2)
);
}

#[test]
fn set_mostro_info_none_still_clears() {
let mut app = AppState::new(UserRole::User);
app.set_mostro_info(Some(MostroInstanceInfo {
last_updated: Some(Timestamp::from(2_000)),
protocol_version: Some(2),
..Default::default()
}));
app.set_mostro_info(None);
assert!(app.mostro_info.is_none());
assert_eq!(app.transport, Transport::GiftWrap);
}
}
73 changes: 39 additions & 34 deletions src/ui/key_handler/async_tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,23 +236,27 @@ fn clear_runtime_tracking_state_preserve_messages(app: &mut AppState) {
}

/// Fetch instance info for `mostro_pubkey`, refresh [`AppState`] transport, and return it for the DM listener.
///
/// Uses [`fetch_mostro_instance_info`] (client-side author / `d` / signature checks). Apply goes
/// through [`AppState::set_mostro_info`], which ignores older `created_at` than the cache.
async fn dm_transport_for_mostro(
client: &Client,
mostro_pubkey: PublicKey,
app: &mut AppState,
log_context: &str,
) -> Transport {
match fetch_mostro_instance_info(client, mostro_pubkey).await {
Ok(info) => {
app.set_mostro_info(info);
Ok(fetch) => {
if let Some(info) = fetch.to_apply() {
app.set_mostro_info(Some(info));
}
app.transport
}
Err(e) => {
log::warn!(
"{log_context}: failed to fetch Mostro instance info: {e}; defaulting to GiftWrap transport"
"{log_context}: failed to fetch Mostro instance info: {e}; keeping cached transport"
);
app.set_mostro_info(None);
Transport::default()
app.transport
}
}
}
Expand Down Expand Up @@ -1152,60 +1156,61 @@ pub fn spawn_refresh_mostro_info_from_settings_task(
};
let result = fetch_mostro_instance_info(&client, mostro_pubkey).await;
let res = match result {
Ok(Some(info)) => MostroInfoFetchResult::Ok {
info: Box::new(Some(info)),
Ok(crate::util::MostroInstanceInfoFetch::Found(info)) => MostroInfoFetchResult::Ok {
info,
message: "Mostro instance info refreshed from relays.".to_string(),
},
Ok(None) => MostroInfoFetchResult::Ok {
info: Box::new(None),
Ok(crate::util::MostroInstanceInfoFetch::NotFound) => MostroInfoFetchResult::NotFound {
message: "No Mostro instance info event found for the current pubkey.".to_string(),
},
Err(e) => {
MostroInfoFetchResult::Err(format!("Failed to refresh Mostro instance info: {}", e))
Ok(crate::util::MostroInstanceInfoFetch::Rejected { fetched }) => {
MostroInfoFetchResult::Rejected {
message: format!(
"Rejected {fetched} unauthentic instance-info event(s); keeping cached settings."
),
}
}
Err(e) => MostroInfoFetchResult::FetchFailed {
message: format!("Failed to refresh Mostro instance info: {}", e),
},
};
let _ = tx.send(res);
});
}

/// `show_result_toast`: when false (e.g. startup), only [`MostroInfoFetchResult::Applied`] is sent on
/// success and errors are logged without UI.
/// Refresh instance info after the configured Mostro pubkey changes.
///
/// Sends [`MostroInfoFetchResult`] to the main loop (UI toast + optional DM listener respawn).
pub fn spawn_refresh_mostro_info_task(
client: Client,
mostro_pubkey: PublicKey,
tx: UnboundedSender<MostroInfoFetchResult>,
show_result_toast: bool,
) {
tokio::spawn(async move {
let result = fetch_mostro_instance_info(&client, mostro_pubkey).await;
if !show_result_toast {
match &result {
Ok(Some(_)) => {}
Ok(None) => {
log::info!("No Mostro instance info event found for current Mostro pubkey");
}
Err(e) => {
log::warn!("Failed to fetch Mostro instance info: {}", e);
}
}
if let Ok(info) = result {
let _ = tx.send(MostroInfoFetchResult::Applied {
info: Box::new(info),
});
}
return;
}
let res = match result {
Ok(info) => MostroInfoFetchResult::Ok {
info: Box::new(info),
Ok(crate::util::MostroInstanceInfoFetch::Found(info)) => MostroInfoFetchResult::Ok {
info,
message: "Mostro instance info updated.".to_string(),
},
Ok(crate::util::MostroInstanceInfoFetch::NotFound) => MostroInfoFetchResult::NotFound {
message: "No Mostro instance info event found for the current pubkey.".to_string(),
},
Ok(crate::util::MostroInstanceInfoFetch::Rejected { fetched }) => {
MostroInfoFetchResult::Rejected {
message: format!(
"Rejected {fetched} unauthentic instance-info event(s); keeping cached settings."
),
}
}
Err(e) => {
log::warn!(
"Failed to refresh Mostro instance info after pubkey change: {}",
e
);
MostroInfoFetchResult::Err(e.to_string())
MostroInfoFetchResult::FetchFailed {
message: format!("Failed to refresh Mostro instance info: {}", e),
}
}
};
let _ = tx.send(res);
Expand Down
1 change: 0 additions & 1 deletion src/ui/key_handler/enter_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1055,7 +1055,6 @@ fn handle_enter_settings_mode(
ctx.client.clone(),
new_pubkey,
ctx.mostro_info_tx.clone(),
true,
);
app.mode = UiMode::operation_result(OperationResult::Info(
"Fetching Mostro instance info...".to_string(),
Expand Down
14 changes: 9 additions & 5 deletions src/ui/orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,18 @@ pub enum LnAddressVerifyResult {
/// Result of an async Mostro instance info fetch (sent from key handlers to main loop).
#[derive(Clone, Debug)]
pub enum MostroInfoFetchResult {
/// Authentic revision fetched; apply via `set_mostro_info(Some(..))`.
Ok {
info: Box<Option<crate::util::MostroInstanceInfo>>,
info: Box<crate::util::MostroInstanceInfo>,
message: String,
},
/// Startup / background refresh: update `mostro_info` only; do not change mode or show toasts.
Applied {
info: Box<Option<crate::util::MostroInstanceInfo>>,
},
/// No kind-38385 event on relay; cached instance info is unchanged.
NotFound { message: String },
/// Relay returned unauthentic candidates; cached instance info is unchanged.
Rejected { message: String },
/// Relay fetch failed (timeout / unreachable); cached instance info is unchanged.
FetchFailed { message: String },
/// Hard failure (invalid settings / pubkey); clears cached instance info.
Err(String),
}

Expand Down
6 changes: 4 additions & 2 deletions src/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ pub use filters::{
pub use mostro_core::prelude::{unwrap_incoming, wrap_message_with, Transport};
pub use mostro_info::{
fetch_mostro_instance_info, fetch_mostro_instance_info_from_settings, format_instance_info_age,
instance_bonds_enabled, mostro_info_from_tags, nostr_pow_from_instance,
transport_from_instance, MostroInstanceInfo, MOSTRO_INSTANCE_INFO_KIND,
instance_bonds_enabled, instance_info_event_is_authentic, mostro_info_from_authenticated_event,
mostro_info_from_tags, nostr_pow_from_instance, select_authentic_instance_info_event,
transport_from_instance, MostroInstanceInfo, MostroInstanceInfoFetch,
MOSTRO_INSTANCE_INFO_KIND,
};
pub use network::{any_relay_reachable, connect_client_safely};
pub use order_utils::{
Expand Down
Loading