Skip to content
Open
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
134 changes: 134 additions & 0 deletions rust/src/api/disputes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use tokio::sync::{broadcast, RwLock};
use tokio::sync::broadcast::error::RecvError;

use crate::api::types::{Dispute, DisputeResolution, DisputeStatus};
use crate::db::Storage;

// ── Dispute store ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -296,6 +297,7 @@ pub async fn handle_admin_took_dispute(trade_id: String, admin_pubkey: String) -
})
.await;
log::info!("[disputes] created record for peer-opened dispute trade={trade_id}");
persist_admin_pubkey(&trade_id, &admin_pubkey_for_key).await;
return derive_admin_shared_key(&trade_id, &admin_pubkey_for_key).await;
}

Expand Down Expand Up @@ -325,9 +327,36 @@ pub async fn handle_admin_took_dispute(trade_id: String, admin_pubkey: String) -
})
.await?;

persist_admin_pubkey(&trade_id, &admin_pubkey_for_key).await;
derive_admin_shared_key(&trade_id, &admin_pubkey_for_key).await
}

/// Persist the solver pubkey so the dispute chat survives a restart.
///
/// Deliberately narrow: the dispute record stays in memory (its status and
/// resolution come back from daemon events), but this pubkey arrives exactly
/// once and cannot be re-derived. Without it, a restart mid-dispute leaves the
/// party unable to reach the solver at all.
///
/// Best-effort — a storage failure must not undo an already-applied dispute
/// update, and the live listener keeps working for this session.
async fn persist_admin_pubkey(order_id: &str, admin_pubkey_hex: &str) {
let Some(db) = crate::db::app_db::db() else {
log::warn!("[disputes] no store — solver pubkey will not survive a restart");
return;
Comment on lines +452 to +454

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Provide a web persistence path for solver keys

On Flutter web, lib/main.dart explicitly skips initDb, so app_db::db() is always None and every new persistence attempt returns here without writing. Even if the IndexedDB settings store were initialized, its list_trades() currently always returns an empty list, leaving rehydration with no enumeration source. Consequently, browser reloads still lose the solver pubkey and dispute chat despite this feature; web needs an initialized store plus an enumeration strategy that does not depend on unimplemented trade persistence.

Useful? React with 👍 / 👎.

};
if let Err(e) = db
.set_setting(
&crate::db::settings_keys::dispute_admin(order_id),
admin_pubkey_hex,
)
.await
{
log::warn!("[disputes] could not persist solver pubkey for {order_id}: {e}");
}
}


/// Derive the dispute-chat keys for `trade_id` and start listening.
///
/// Both sides ECDH their trade key against the solver's pubkey and split the
Expand Down Expand Up @@ -382,7 +411,69 @@ async fn derive_admin_shared_key(trade_id: &str, admin_pubkey_hex: &str) -> Resu
/// would stay invisible for the rest of the process. Idempotent — the
/// per-channel single-owner guard makes a spawn for an already-listening
/// dispute a no-op.
/// Rebuild dispute records for orders with a persisted solver pubkey.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update the dispute contract for restart persistence

This introduces externally observable persistence and restart-rehydration behavior for get_dispute and submit_evidence, but specs/004-mostro-p2p-client/contracts/disputes.md remains unchanged and documents only the in-process lifecycle. Add the persisted solver-key and rehydration semantics, including terminal-state handling and platform limitations, to the matching contract as required for behavior changes.

AGENTS.md reference: AGENTS.md:L75-L75

Useful? React with 👍 / 👎.

///
/// Trades are the enumeration source, so no key-prefix scan is needed: each
/// persisted trade is asked whether it has a stored solver. Records already in
/// memory win — they are at least as fresh as storage.
///
/// Status is `InReview`: a stored solver means one took the dispute, and any
/// later resolution arrives as a daemon event. Restoring the record is what
/// lets `submit_evidence` work again after a restart, since it refuses without
/// one.
async fn rehydrate_disputes_from_storage() {
let Some(db) = crate::db::app_db::db() else {
return;
};
let trades = match db.list_trades().await {
Ok(t) => t,
Err(e) => {
log::warn!("[disputes] rehydrate: list_trades failed: {e}");
return;
}
};

for trade in trades {
let order_id = trade.order.id.clone();
if dispute_store().get(&order_id).await.is_some() {
continue;
}
let admin_hex = match db
.get_setting(&crate::db::settings_keys::dispute_admin(&order_id))
.await
{
Ok(Some(hex)) => hex,
Ok(None) => continue,
Err(e) => {
log::warn!("[disputes] rehydrate: reading solver for {order_id}: {e}");
continue;
}
};

dispute_store()
.upsert(Dispute {
id: uuid::Uuid::new_v4().to_string(),
trade_id: order_id.clone(),
status: DisputeStatus::InReview,
Comment on lines +622 to +626

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip terminal trades during dispute rehydration

When a stored trade has already reached SettledByAdmin or CanceledByAdmin, list_trades() still returns it and the solver setting is never removed, so this unconditionally resurrects the dispute as InReview on every restart. The daemon dispatcher in orders.rs routes those terminal actions only through trade-status synchronization and never calls the dispute resolution handlers, leaving the restored dispute active indefinitely, starting an unnecessary listener and allowing further evidence submission. Filter terminal trade statuses here or clear the persisted solver key when resolving.

Useful? React with 👍 / 👎.

initiated_by_me: false,
reason: None,
admin_pubkey: Some(admin_hex),
resolution: None,
opened_at: unix_now(),
resolved_at: None,
is_read: true,
})
.await;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
log::info!("[disputes] rehydrated dispute record order={order_id}");
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

pub(crate) async fn resubscribe_active_dispute_chats() {
// A restart leaves the in-memory store empty, so the loop below would find
// nothing to re-arm. Refill it from the persisted solver pubkeys first —
// that is the one piece of a dispute that cannot be re-derived.
rehydrate_disputes_from_storage().await;

for dispute in dispute_store().all().await {
if dispute.status != DisputeStatus::InReview {
continue;
Expand Down Expand Up @@ -562,6 +653,49 @@ mod tests {
assert!(err.to_string().contains("EvidenceEmpty"));
}

#[tokio::test]
async fn the_solver_pubkey_outlives_the_in_memory_record() {
// The dispute record is in-memory by design, so a restart drops it.
// The solver pubkey must not go with it: it arrives once, in
// admin-took-dispute, and without it the chat keys cannot be derived
// again — the party would be left unable to reach the solver.
let path = std::env::temp_dir()
.join(format!("mostro_dispute_kv_{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let db = crate::db::sqlite::SqliteStorage::open(path.to_str().unwrap())
.await
.unwrap();

let order_id = "order-dispute-1";
let admin_pk = "0000000000000000000000000000000000000000000000000000000000000003";
let key = crate::db::settings_keys::dispute_admin(order_id);

assert_eq!(db.get_setting(&key).await.unwrap(), None);

db.set_setting(&key, admin_pk).await.unwrap();

// Reopen: this is the restart the persistence exists for.
drop(db);
let db = crate::db::sqlite::SqliteStorage::open(path.to_str().unwrap())
.await
.unwrap();
assert_eq!(db.get_setting(&key).await.unwrap().as_deref(), Some(admin_pk));

drop(db);
let _ = std::fs::remove_file(&path);
}

#[test]
fn each_order_gets_its_own_solver_key() {
// One party can have several disputed orders, each with its own solver.
assert_ne!(
crate::db::settings_keys::dispute_admin("order-a"),
crate::db::settings_keys::dispute_admin("order-b")
);
assert!(crate::db::settings_keys::dispute_admin("order-a")
.starts_with(crate::db::settings_keys::DISPUTE_ADMIN_PREFIX));
}

#[tokio::test]
async fn a_peer_opened_dispute_still_records_the_solver() {
// The party that did not open the dispute has no local record when
Expand Down