Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
236 changes: 215 additions & 21 deletions rust/src/api/orders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3398,34 +3398,41 @@ pub async fn get_trade_role(order_id: String) -> Result<Option<crate::api::types
}
}

/// Coerce a wire trade index (`i64`) into a usable counter value, or `None`.
///
/// Trade indexes cross the wire as `i64` (restore payloads, the
/// `LastTradeIndex` reply). A value that is negative or `>= u32::MAX` is not a
/// real trade index: negatives are nonsense, and `u32::MAX` is the reserved
/// terminal index — storing it as the counter would make the next
/// `derive_trade_key` compute `u32::MAX + 1` and overflow (panic in debug, wrap
/// to 0 in release, reissuing index 0 — the exact key-reuse the resync
/// prevents). Such a value is dropped rather than truncated into the counter.
fn sanitize_trade_index(i: i64) -> Option<u32> {
u32::try_from(i).ok().filter(|&v| v < u32::MAX)
}

/// Highest trade-key index across all recovered orders and disputes (#217).
///
/// The counter must be raised to this so the next `derive_trade_key()` cannot
/// hand out an index a recovered trade already owns. Returns `None` when the
/// restore carried no trades (nothing to resync to). Indexes are `i64` on the
/// wire; a value that is negative or beyond `u32::MAX` is not a real trade
/// index, so it is dropped rather than truncated into the counter.
/// restore carried no trades (nothing to resync to).
///
/// NOTE (#328): this is only a *lower bound* of the daemon's real counter —
/// the restore payload lists only non-finalized orders, so a finalized trade
/// holding a higher index is invisible here. `restore_session` sources its
/// resync floor from `last_trade_index()` (authoritative) and falls back to
/// this only when the daemon does not answer.
fn recovered_max_trade_index(
info: &mostro_core::message::RestoreSessionInfo,
) -> Option<u32> {
// A single adapter drops both negatives and any value >= u32::MAX — neither
// is a real trade index, and truncating one into a small u32 could corrupt
// the counter this exists to protect. u32::MAX itself is dropped: it is the
// reserved terminal index, and storing it as the counter would make the next
// derive_trade_key compute u32::MAX + 1 and overflow (panic in debug, wrap to
// 0 in release — reissuing index 0, the exact key-reuse this resync prevents).
// Collapsed into one filter_map so the two conditions can't drift apart.
let all: Vec<i64> = info
.restore_orders
.iter()
.map(|o| o.trade_index)
.chain(info.restore_disputes.iter().map(|d| d.trade_index))
.collect();
let total = all.len();
let valid: Vec<u32> = all
.into_iter()
.filter_map(|i| u32::try_from(i).ok().filter(|&v| v < u32::MAX))
.collect();
let valid: Vec<u32> = all.iter().copied().filter_map(sanitize_trade_index).collect();
// A dropped index is not just an odd value: it means the daemon sent
// something this client's model does not cover, and a silently-lowered
// floor produces a later CantDo(InvalidTradeIndex) with no breadcrumb. Warn
Expand All @@ -3445,6 +3452,140 @@ fn recovered_max_trade_index(
valid.into_iter().max()
}

/// Pick the resync floor from the authoritative daemon counter, falling back to
/// the restore-payload maximum (#328).
///
/// The daemon's `LastTradeIndex` answer (`daemon_counter`) wins whenever it is
/// present — it is the real high-water mark, including finalized trades. The
/// payload maximum is only a lower bound (the restore lists non-finalized
/// orders only) and is used solely when the daemon did not answer. Raising is
/// monotonic downstream (`ensure_trade_key_index_at_least`), so a low daemon
/// answer can never lower the counter.
fn resync_floor(
daemon_counter: Option<u32>,
info: &mostro_core::message::RestoreSessionInfo,
) -> Option<u32> {
daemon_counter.or_else(|| recovered_max_trade_index(info))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Ask the daemon for its authoritative last-known trade index (#328).
///
/// `Action::LastTradeIndex` is account-scoped: the request is signed with the
/// identity keys for BOTH the Seal and the rumor (see
/// `actions::last_trade_index`), so it derives NO trade key and the daemon
/// resolves the account by sender pubkey. The reply is a kind-14 addressed to
/// the identity pubkey, carrying the counter in `MessageKind::trade_index`.
///
/// Returns `Ok(Some(idx))` with the sanitized counter, or `Ok(None)` when the
/// daemon does not answer within the timeout or the reply carries no usable
/// index — the caller then falls back to `recovered_max_trade_index`.
///
/// This is a self-contained request/reply (own subscription + inline wait, like
/// mostro-cli's `wait_for_dm`) rather than a reuse of the per-trade
/// subscription/dispatch path: that path derives its recipient keys from a
/// trade index and is coupled to trade keys, but this reply is encrypted to the
/// identity key.
async fn last_trade_index() -> Result<Option<u32>> {
use nostr_sdk::RelayPoolNotification;
use crate::rt::time::{timeout, Duration};

let identity_keys = crate::api::identity::get_active_keys().await?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Major — publishes an event authored by the master identity key, and bypasses the privacy toggle.

wrap_message_nip44 signs the outer kind-14 with its trade_keys argument, so passing identity_keys for both here makes event.pubkey the user's long-lived identity pubkey, p-tagged to Mostro, on every relay — permanently linking the master key to Mostro. Every other daemon-bound event in this client is authored by an ephemeral trade key.

It isn't required: the daemon resolves the account from event.identity (mostro/src/app/last_trade_index.rs: let requester_pubkey = event.identity.to_string();) and only replies to event.sender. That's the same shape restore_session already uses correctly with a fresh trade key + identity proof.

Suggestion: take the sender_keys restore_session() already derived as the rumor key and get_transport_identity_keys(&sender_keys) as the identity key, and subscribe/filter on that trade pubkey. It's already in the global DM coverage, which also kills the no-matching-p-tag warn this currently triggers.

Also: get_active_keys() skips get_transport_identity_keys(), this codebase's single gate for privacy mode (identity.rs:640-645). Nothing stops restore_session() from running with privacy mode on, so today this signs and publishes with the real identity key even then.

let identity_pk = identity_keys.public_key();
let identity_pk_hex = identity_pk.to_hex();
let mostro_pubkey = nostr_sdk::PublicKey::from_hex(&active_mostro_pubkey())?;

let pool = crate::api::nostr::get_pool()?;
let client = pool.client();

// Grab the notifications receiver BEFORE subscribing so the reply can't
// arrive in the gap between subscribe and the first recv.
let mut rx = client.notifications();

// limit(0): live-only, same rationale as subscribe_daemon_messages — the
// reply is published after we subscribe, and we never want a replayed
// historical LastTradeIndex to resolve this request.
let filter = nostr_sdk::Filter::new()
.kind(nostr_sdk::Kind::PrivateDirectMessage)
.author(mostro_pubkey)
.pubkey(identity_pk)
.limit(0);
if let Err(e) = client.subscribe(filter, None).await {
log::warn!("[orders] last_trade_index subscribe failed: {e}");
return Ok(None);
}

let event_json = actions::last_trade_index(&identity_keys, &mostro_pubkey).await?;
publish_event_json(&event_json).await?;
crate::api::logging::blog_info(
"restore",
"LastTradeIndex published — waiting for daemon".to_string(),
);

// Wait for the reply. 10s matches restore_session's timeout.
let deadline = Duration::from_secs(10);
let start = crate::rt::time::Instant::now();
loop {
let remaining = deadline.saturating_sub(start.elapsed());
if remaining.is_zero() {
break;
}
match timeout(remaining, rx.recv()).await {
Ok(Ok(RelayPoolNotification::Event { event, .. })) => {
if event.kind != nostr_sdk::Kind::PrivateDirectMessage
|| event.pubkey != mostro_pubkey
{
continue;
}
let is_for_us = event.tags.iter().any(|t| {
let s = t.as_slice();
s.first().map(|v| v.as_str()) == Some("p")
&& s.get(1).map(|v| v.as_str()) == Some(identity_pk_hex.as_str())
});
if !is_for_us {
continue;
}
match crate::nostr::transport::unwrap_mostro_message(&identity_keys, &event).await {
Ok(Some(unwrapped)) => {
// Authenticate: the kind-14 author must be the node.
if unwrapped.sender != mostro_pubkey {
continue;
}
let kind = unwrapped.message.get_inner_message_kind();
if kind.action != mostro_core::message::Action::LastTradeIndex {
continue;
}
let idx = kind.trade_index.and_then(sanitize_trade_index);
crate::api::logging::blog_info("restore", format!(
"LastTradeIndex reply: trade_index={:?} -> floor={idx:?}",
kind.trade_index
));
return Ok(idx);
}
Ok(None) => continue,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor — CantDo replies fall through here and cost the full 5s.

The daemon answers this action with MostroCantDo(CantDoReason::NotFound) (user absent) or MostroCantDo(CantDoReason::InvalidTradeIndex) (last_trade_index == 0), echoing the same request_id. is_matching_last_trade_index_reply rejects them on the action check, so the loop continues and burns the whole timeout before falling back.

An early return Ok(None) on Action::CantDo with a matching request_id (with a blog_warn naming the reason) turns a 5s stall into an immediate, diagnosable fallback.

Err(e) => {
log::warn!("[orders] last_trade_index decrypt failed: {e}");
continue;
}
}
}
Ok(Ok(RelayPoolNotification::Shutdown)) => break,
Ok(Err(broadcast::error::RecvError::Lagged(n))) => {
log::warn!("[orders] last_trade_index lagged by {n} messages");
continue;
}
Ok(Err(broadcast::error::RecvError::Closed)) => break,
Err(_) => break, // timeout
Ok(Ok(_)) => continue,
}
}
crate::api::logging::blog_warn(
"restore",
"LastTradeIndex: no usable daemon reply — falling back to restore payload max"
.to_string(),
);
Ok(None)
}

/// Send a `RestoreSession` to the active daemon and return the user's active
/// trades/disputes. Mirrors create_order's send/await, minus the order payload.
///
Expand Down Expand Up @@ -3518,13 +3659,29 @@ pub async fn restore_session() -> Result<mostro_core::message::RestoreSessionInf

match confirmation {
Ok(Ok(DaemonReply::Restored(info))) => {
// #217: raise trade_key_index past every recovered trade before
// returning, so the next derive_trade_key() can't reuse a key a
// recovered trade already owns. Monotonic and idempotent. A persist
// failure fails the restore: an un-resynced counter reopens the
// key-reuse bug this closes, so silent success would be worse than
// a surfaced error the caller can retry.
if let Some(floor) = recovered_max_trade_index(&info) {
// Raise trade_key_index before returning, so the next
// derive_trade_key() can't reuse a key a recovered trade already
// owns. Monotonic and idempotent. A persist failure fails the
// restore: an un-resynced counter reopens the key-reuse bug this
// closes, so silent success would be worse than a surfaced error
// the caller can retry.
//
// #328: the authoritative floor is the daemon's LastTradeIndex
// counter. The restore payload lists only non-finalized orders, so
// recovered_max_trade_index is a lower bound (a finalized trade
// holding a higher index is invisible) — kept only as a fallback
// for when the daemon does not answer.
let daemon_counter = match last_trade_index().await {
Ok(idx) => idx,
Err(e) => {
crate::api::logging::blog_warn("restore", format!(
"LastTradeIndex request errored ({e}); \
falling back to restore payload max"
));
None
}
};
if let Some(floor) = resync_floor(daemon_counter, &info) {
crate::api::identity::ensure_trade_key_index_at_least(floor).await?;
}
Ok(info)
Expand Down Expand Up @@ -3654,6 +3811,43 @@ mod tests {
);
}

// ── #328 sanitize_trade_index / resync_floor ─────────────────────────────
#[test]
fn sanitize_trade_index_drops_negative_and_out_of_range() {
assert_eq!(sanitize_trade_index(0), Some(0));
assert_eq!(sanitize_trade_index(42), Some(42));
assert_eq!(sanitize_trade_index(-1), None);
// u32::MAX is the reserved terminal index (dropped to avoid +1 overflow).
assert_eq!(sanitize_trade_index(i64::from(u32::MAX)), None);
assert_eq!(sanitize_trade_index(i64::from(u32::MAX) - 1), Some(u32::MAX - 1));
assert_eq!(sanitize_trade_index(i64::from(u32::MAX) + 1), None);
}

#[test]
fn resync_floor_prefers_the_daemon_counter_over_the_payload_max() {
// The #328 scenario: order X open at index 1 (the only non-finalized
// trade the restore returns), order Y canceled at index 2. The payload
// max is 1, but the daemon's LastTradeIndex counter is 2 — the real
// high-water mark — and must win, or the first new order collides.
let info = restore_info(vec![1], vec![]);
assert_eq!(resync_floor(Some(2), &info), Some(2));
// The daemon answer wins even when it is lower than the payload max —
// monotonic raising downstream (ensure_trade_key_index_at_least) makes
// this safe, and the daemon is authoritative.
assert_eq!(resync_floor(Some(1), &restore_info(vec![5], vec![])), Some(1));
// ...and even when the payload carried nothing at all.
assert_eq!(resync_floor(Some(7), &restore_info(vec![], vec![])), Some(7));
}

#[test]
fn resync_floor_falls_back_to_payload_max_when_daemon_is_silent() {
// No LastTradeIndex answer (timeout / error): the restore-payload
// maximum is the best available lower bound.
assert_eq!(resync_floor(None, &restore_info(vec![3, 9], vec![4])), Some(9));
// Nothing anywhere -> no resync (None).
assert_eq!(resync_floor(None, &restore_info(vec![], vec![])), None);
}

fn insert_pending_create(key: &str, request_id: u64) -> tokio::sync::oneshot::Receiver<DaemonReply> {
let (tx, rx) = tokio::sync::oneshot::channel::<DaemonReply>();
pending_requests().lock().unwrap().insert(
Expand Down
23 changes: 22 additions & 1 deletion rust/src/mostro/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/// arguments — see `api::identity::get_transport_identity_keys`, which
/// applies the runtime privacy toggle.
use anyhow::Result;
use mostro_core::message::{Action, Message, Payload};
use mostro_core::message::{Action, Message, MessageKind, Payload};
use nostr_sdk::prelude::*;
use uuid::Uuid;

Expand Down Expand Up @@ -419,6 +419,27 @@ pub async fn restore_session(
wrap_message_first_contact(identity_keys, trade_keys, mostro_pubkey, &msg).await
}

/// Build and wrap a `LastTradeIndex` request (#328).
///
/// Account-scoped: `identity_keys` sign BOTH the Seal and the rumor, so the
/// daemon resolves the account by sender pubkey and NO trade key is derived.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doc inaccuracy (mirrored from mostro-cli, but it's wrong there too): the daemon does not resolve the account by the sender pubkey. mostro/src/app/last_trade_index.rs reads

let requester_pubkey = event.identity.to_string(); // account lookup
let trade_key = event.sender;                      // reply address only

event.identity is the proven identity pubkey from the encrypted identity proof (or the rumor author when no proof is attached, i.e. privacy mode). Signing the rumor with the identity key is therefore a choice, not a protocol requirement — see the main review comment on orders.rs:3679.

/// The reply carries the counter in `MessageKind::trade_index`. Payload must be
/// `None` (enforced by mostro-core). Mirrors `mostro-cli`'s
/// `execute_last_trade_index`, which signs with `identity_keys` for both.
pub async fn last_trade_index(
identity_keys: &Keys,
mostro_pubkey: &PublicKey,
) -> Result<String> {
let msg = Message::Restore(MessageKind::new(
None,
None,
None,
Action::LastTradeIndex,
None,
));
wrap_message_first_contact(identity_keys, identity_keys, mostro_pubkey, &msg).await
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading