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
2 changes: 1 addition & 1 deletion docs/MESSAGE_FLOW_AND_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Used by `dm_helpers::ensure_order_dm_subscription`, startup `fetch_and_replay_st

### Proof-of-work (NIP-13)

Required difficulty comes from kind **38385** tag `pow`. Mostrix uses `nostr_pow_from_instance` and `WrapOptions.pow` on the published event (GiftWrap outer on v1, signed kind-14 on v2). See **[POW_AND_OUTBOUND_EVENTS.md](POW_AND_OUTBOUND_EVENTS.md)**.
Required difficulty comes from kind **38385** tags `pow` and optional `pow_first_contact`. Mostrix uses [`nostr_pow_for_protocol_dm`](../src/util/mostro_info.rs) in [`send_dm`](../src/util/dm_utils/mod.rs) (v2 first-contact actions: `max(pow, pow_first_contact)`). See **[POW_AND_OUTBOUND_EVENTS.md](POW_AND_OUTBOUND_EVENTS.md)**.

## Order Creation Flow

Expand Down
7 changes: 4 additions & 3 deletions docs/POW_AND_OUTBOUND_EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ This document describes how Mostrix applies **NIP-13 proof-of-work** to events i
- **`pow`** — unsigned integer; parsed into [`MostroInstanceInfo.pow`](../src/util/mostro_info.rs) (`Option<u32>`).
- **`protocol_version`** — `"1"` or `"2"`; drives [`transport_from_instance`](../src/util/mostro_info.rs) → [`AppState.transport`](../src/ui/app_state.rs). Missing tag → legacy GiftWrap.
- There is **no** `pow` field in [`Settings`](../src/settings.rs) or in the generated `settings.toml` template.
- **Effective bits** for signing: [`nostr_pow_from_instance`](../src/util/mostro_info.rs) maps `Option<&MostroInstanceInfo>` → `u8` by taking `info.pow`, clamping to `u8::MAX`, and using **0** when info is missing or `pow` is `None`.
- Optional **`pow_first_contact`** on kind **38385** (when the daemon publishes it): parsed into [`MostroInstanceInfo.pow_first_contact`](../src/util/mostro_info.rs). When absent, effective first-contact toll = base `pow`.
- **Effective bits** for signing: [`nostr_pow_from_instance`](../src/util/mostro_info.rs) maps base `pow`; [`nostr_pow_for_protocol_dm`](../src/util/mostro_info.rs) selects per-action bits for [`send_dm`](../src/util/dm_utils/mod.rs).

## 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).
- [`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_from_instance(mostro_instance)` once per send.
- [`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.

At startup (and on reload/reconnect via [`dm_transport_for_mostro`](../src/ui/key_handler/async_tasks.rs)), instance info is fetched **before** the DM listener spawns when relays are reachable.

Expand All @@ -27,7 +28,7 @@ At startup (and on reload/reconnect via [`dm_transport_for_mostro`](../src/ui/ke
- **v2 (`Nip44Direct`)**: PoW on the **signed kind-14** event (`WrapOptions.pow`).
- **NIP-40 expiration**: default **now + 30 days** on v2 when the caller passes `expiration: None` (`default_dm_expiration`).

First-contact actions on strict v2 nodes may need higher PoW than instance `pow` (`pow_first_contact` on the daemon — not advertised in 38385 today).
First-contact actions on strict v2 nodes may need higher PoW than instance `pow` (`pow_first_contact` on the daemon). Mostrix applies `max(pow, pow_first_contact)` on v2 for `NewOrder`, `TakeBuy`, and `TakeSell` via [`nostr_pow_for_protocol_dm`](../src/util/mostro_info.rs). When the kind-38385 `pow_first_contact` tag is absent, the effective toll defaults to base `pow` (same as the daemon).

## Chat vs protocol PoW

Expand Down
7 changes: 7 additions & 0 deletions src/ui/tabs/mostro_info_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ fn build_info_lines(info: &MostroInstanceInfo) -> Vec<Line<'static>> {
);
push_opt_f64(&mut lines, "Fee (fraction)", info.fee);
push_opt_u32(&mut lines, "Required PoW", info.pow);
if info.pow_first_contact.is_some() {
push_opt_u32(
&mut lines,
"Required PoW (first contact, v2)",
info.pow_first_contact,
);
}
push_kv(
&mut lines,
"Anti-abuse bonds",
Expand Down
4 changes: 2 additions & 2 deletions src/util/dm_utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use crate::ui::{MessageNotification, OrderMessage};
use crate::util::db_utils::{delete_order_by_id, save_order, update_order_status};
use crate::util::filters::filter_protocol_dm_from_mostro;
use crate::util::mostro_info::{
nostr_pow_from_instance, transport_from_instance, MostroInstanceInfo,
nostr_pow_for_protocol_dm, transport_from_instance, MostroInstanceInfo,
};
use crate::util::order_utils::{
inferred_status_from_trade_action, map_action_to_status, should_apply_status_transition,
Expand Down Expand Up @@ -222,9 +222,9 @@ pub async fn send_dm(
expiration: Option<Timestamp>,
mostro_instance: Option<&MostroInstanceInfo>,
) -> Result<()> {
let pow = nostr_pow_from_instance(mostro_instance);
let message = Message::from_json(&payload)
.map_err(|e| anyhow::anyhow!("Failed to deserialize message: {e}"))?;
let pow = nostr_pow_for_protocol_dm(mostro_instance, &message.get_inner_message_kind().action);
let identity_keys = identity_keys.unwrap_or(trade_keys);
let transport = transport_from_instance(mostro_instance);
let expiration = match (transport, expiration) {
Expand Down
111 changes: 106 additions & 5 deletions src/util/mostro_info.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::settings::load_settings_from_disk;
use crate::util::dm_utils::FETCH_EVENTS_TIMEOUT;
use anyhow::{anyhow, Result};
use mostro_core::prelude::Transport;
use mostro_core::prelude::{Action, Transport};
use nostr_sdk::prelude::*;
use std::str::FromStr;

Expand Down Expand Up @@ -45,6 +45,9 @@ pub struct MostroInstanceInfo {
pub max_orders_per_response: Option<u32>,
pub fee: Option<f64>,
pub pow: Option<u32>,
/// First-contact PoW toll on v2 (kind 38385 tag `pow_first_contact`). When absent, daemon
/// defaults to [`Self::pow`]; Mostrix mirrors that in [`effective_pow_first_contact_from_instance`].
pub pow_first_contact: Option<u32>,
/// Wire transport version from kind-38385 tag `protocol_version` (`"1"` / `"2"`).
pub protocol_version: Option<u8>,
/// Anti-abuse bond feature flag from kind-38385 tag `bond_enabled` (`"true"` / `"false"`).
Expand Down Expand Up @@ -101,13 +104,48 @@ impl MostroInstanceInfo {
}
}

fn clamp_pow_bits(bits: Option<u32>) -> u8 {
bits.map(|n| (n.min(u8::MAX as u32)) as u8).unwrap_or(0)
}

/// NIP-13 difficulty bits for outbound events from cached Mostro instance info (kind 38385 tag `pow`).
/// Returns `0` when info is missing or the tag is absent. Values above `u8::MAX` clamp to 255.
pub fn nostr_pow_from_instance(instance: Option<&MostroInstanceInfo>) -> u8 {
instance
.and_then(|i| i.pow)
.map(|n| (n.min(u8::MAX as u32)) as u8)
.unwrap_or(0)
clamp_pow_bits(instance.and_then(|i| i.pow))
}

/// Effective first-contact PoW bits (mirrors Mostro daemon `effective_pow_first_contact`).
///
/// Uses tag `pow_first_contact` when present; otherwise falls back to base `pow`.
pub fn effective_pow_first_contact_from_instance(instance: Option<&MostroInstanceInfo>) -> u8 {
match instance {
Some(i) => clamp_pow_bits(i.pow_first_contact.or(i.pow)),
None => 0,
}
}

/// Protocol actions that introduce a new trade key to Mostro (v2 spam gate first-contact lane).
pub fn is_v2_first_contact_protocol_action(action: &Action) -> bool {
matches!(
action,
Action::NewOrder | Action::TakeBuy | Action::TakeSell
)
}

/// NIP-13 bits for a protocol DM toward Mostro.
///
/// v1 GiftWrap: always base instance `pow` (daemon spam gate is v2-only).
/// v2 NIP-44: first-contact actions use `max(pow, pow_first_contact)` so new order/take
/// clears the daemon's stiffer toll when operators set `pow_first_contact` above `pow`.
pub fn nostr_pow_for_protocol_dm(instance: Option<&MostroInstanceInfo>, action: &Action) -> u8 {
let base = nostr_pow_from_instance(instance);
if transport_from_instance(instance) == Transport::Nip44Direct
&& is_v2_first_contact_protocol_action(action)
{
base.max(effective_pow_first_contact_from_instance(instance))
} else {
base
}
}

/// Whether the connected Mostro instance has anti-abuse bonds enabled (kind 38385 `bond_enabled`).
Expand Down Expand Up @@ -183,6 +221,9 @@ pub fn mostro_info_from_tags(tags: Tags) -> Result<MostroInstanceInfo> {
"pow" => {
info.pow = MostroInstanceInfo::parse_u32(value);
}
"pow_first_contact" => {
info.pow_first_contact = MostroInstanceInfo::parse_u32(value);
}
"protocol_version" => {
info.protocol_version = MostroInstanceInfo::parse_u8(value);
}
Expand Down Expand Up @@ -464,6 +505,66 @@ mod tests {
);
}

#[test]
fn parse_pow_first_contact_from_tags() {
let mut tags = Tags::new();
tags.push(Tag::parse(["pow", "8"]).unwrap());
tags.push(Tag::parse(["pow_first_contact", "16"]).unwrap());
let result = mostro_info_from_tags(tags).unwrap();
assert_eq!(result.pow, Some(8));
assert_eq!(result.pow_first_contact, Some(16));
}

#[test]
fn effective_pow_first_contact_falls_back_to_base_pow() {
assert_eq!(effective_pow_first_contact_from_instance(None), 0);
assert_eq!(
effective_pow_first_contact_from_instance(Some(&MostroInstanceInfo {
pow: Some(8),
..Default::default()
})),
8
);
assert_eq!(
effective_pow_first_contact_from_instance(Some(&MostroInstanceInfo {
pow: Some(8),
pow_first_contact: Some(16),
..Default::default()
})),
16
);
}

#[test]
fn nostr_pow_for_protocol_dm_v2_first_contact_uses_max_toll() {
let info = MostroInstanceInfo {
pow: Some(8),
pow_first_contact: Some(16),
protocol_version: Some(2),
..Default::default()
};
assert_eq!(
nostr_pow_for_protocol_dm(Some(&info), &Action::NewOrder),
16
);
assert_eq!(nostr_pow_for_protocol_dm(Some(&info), &Action::TakeBuy), 16);
assert_eq!(
nostr_pow_for_protocol_dm(Some(&info), &Action::AddInvoice),
8
);
}

#[test]
fn nostr_pow_for_protocol_dm_v1_ignores_first_contact_toll() {
let info = MostroInstanceInfo {
pow: Some(8),
pow_first_contact: Some(16),
protocol_version: Some(1),
..Default::default()
};
assert_eq!(nostr_pow_for_protocol_dm(Some(&info), &Action::NewOrder), 8);
}

// Fetch tests would require a mock or test double for nostr_sdk::Client
// (e.g. a trait + impl for production and a mock that returns empty events
// or simulates timeout). Not implemented here to avoid refactoring the public API.
Expand Down