use std::time::Duration;
use mostro_core::message::{Action, Message, Payload};
use nostr_sdk::prelude::*;
use uuid::Uuid;
use crate::chat::shared_key;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct DisputeChatMaterial {
pub buyer_shared_keys: Keys,
pub seller_shared_keys: Keys,
pub buyer_pubkey: String,
pub seller_pubkey: String,
}
impl DisputeChatMaterial {
pub fn buyer_shared_pubkey(&self) -> String {
self.buyer_shared_keys.public_key().to_hex()
}
pub fn seller_shared_pubkey(&self) -> String {
self.seller_shared_keys.public_key().to_hex()
}
}
pub struct TakeFlowParams<'a> {
pub client: &'a Client,
pub serbero_keys: &'a Keys,
pub mostro_pubkey: &'a PublicKey,
pub dispute_id: Uuid,
pub timeout: Duration,
pub poll_interval: Duration,
}
pub async fn run_take_flow(p: TakeFlowParams<'_>) -> Result<DisputeChatMaterial> {
let admin_pubkey = p.serbero_keys.public_key();
let now = Timestamp::now();
let since_window = Timestamp::from_secs(now.as_secs().saturating_sub(7 * 24 * 60 * 60));
let filter = Filter::new()
.kind(Kind::GiftWrap)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
admin_pubkey.to_hex(),
)
.since(since_window);
let sub = p
.client
.subscribe(filter.clone(), None)
.await
.map_err(|e| Error::ChatTransport(format!("failed to subscribe for take-flow: {e}")))?;
let result: Result<DisputeChatMaterial> = async {
let take_msg = Message::new_dispute(
Some(p.dispute_id),
None,
None,
Action::AdminTakeDispute,
None,
);
let msg_json = take_msg
.as_json()
.map_err(|_| Error::ChatTransport("failed to serialize AdminTakeDispute".into()))?;
use nostr_sdk::hashes::Hash as _;
let digest = nostr_sdk::hashes::sha256::Hash::hash(msg_json.as_bytes());
let secp_msg = nostr_sdk::secp256k1::Message::from_digest(digest.to_byte_array());
let sig: Signature = p.serbero_keys.sign_schnorr(&secp_msg);
let content = serde_json::to_string(&(take_msg, Some(sig))).map_err(|e| {
Error::ChatTransport(format!(
"failed to serialize signed AdminTakeDispute tuple: {e}"
))
})?;
let send_out = p
.client
.send_private_msg(*p.mostro_pubkey, content, [])
.await
.map_err(|e| {
Error::ChatTransport(format!("failed to send AdminTakeDispute DM: {e}"))
})?;
tracing::info!(
mostro = %p.mostro_pubkey.to_hex(),
outer_event_id = %send_out.val,
"sent AdminTakeDispute to Mostro"
);
let deadline = tokio::time::Instant::now() + p.timeout;
let timed_out = || {
Error::ChatTransport(
"timed out waiting for AdminTookDispute response from Mostro".into(),
)
};
loop {
let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now())
else {
return Err(timed_out());
};
if remaining.is_zero() {
return Err(timed_out());
}
let fetch_budget = remaining.min(p.poll_interval);
let events = p
.client
.fetch_events(filter.clone(), fetch_budget)
.await
.map_err(|e| {
Error::ChatTransport(format!("fetch_events failed during take-flow: {e}"))
})?;
tracing::trace!(count = events.len(), "take-flow: fetched candidate events");
for wrapped in events.iter() {
let Ok(unwrapped) = p.client.unwrap_gift_wrap(wrapped).await else {
continue;
};
if unwrapped.sender != *p.mostro_pubkey {
continue;
}
let Ok((response, _sig)) =
serde_json::from_str::<(Message, Option<Signature>)>(&unwrapped.rumor.content)
else {
continue;
};
let kind = response.get_inner_message_kind();
if kind.action != Action::AdminTookDispute {
continue;
}
let Some(Payload::Dispute(id, Some(info))) = &kind.payload else {
continue;
};
if *id != p.dispute_id {
continue;
}
return material_from_solver_info(p.serbero_keys, info);
}
let sleep_budget = deadline
.checked_duration_since(tokio::time::Instant::now())
.unwrap_or(Duration::ZERO)
.min(p.poll_interval);
if sleep_budget.is_zero() {
return Err(timed_out());
}
tokio::time::sleep(sleep_budget).await;
}
}
.await;
p.client.unsubscribe(&sub.val).await;
result
}
fn material_from_solver_info(
serbero_keys: &Keys,
info: &mostro_core::dispute::SolverDisputeInfo,
) -> Result<DisputeChatMaterial> {
let buyer_hex = info
.buyer_pubkey
.as_deref()
.ok_or_else(|| Error::ChatTransport("SolverDisputeInfo missing buyer_pubkey".into()))?;
let seller_hex = info
.seller_pubkey
.as_deref()
.ok_or_else(|| Error::ChatTransport("SolverDisputeInfo missing seller_pubkey".into()))?;
let buyer_pk = PublicKey::parse(buyer_hex)
.map_err(|e| Error::ChatTransport(format!("invalid buyer_pubkey: {e}")))?;
let seller_pk = PublicKey::parse(seller_hex)
.map_err(|e| Error::ChatTransport(format!("invalid seller_pubkey: {e}")))?;
if buyer_pk == seller_pk {
return Err(Error::ChatTransport(format!(
"SolverDisputeInfo has identical buyer and seller trade pubkey ({buyer_hex}); \
refusing to start mediation on a malformed dispute"
)));
}
let buyer_shared_keys = shared_key::derive_shared_keys(serbero_keys, &buyer_pk)?;
let seller_shared_keys = shared_key::derive_shared_keys(serbero_keys, &seller_pk)?;
if buyer_shared_keys.secret_key().to_secret_hex()
== seller_shared_keys.secret_key().to_secret_hex()
{
return Err(Error::ChatTransport(
"buyer and seller shared secrets are identical for different trade pubkeys; \
chat would be broken"
.into(),
));
}
Ok(DisputeChatMaterial {
buyer_shared_keys,
seller_shared_keys,
buyer_pubkey: buyer_hex.to_string(),
seller_pubkey: seller_hex.to_string(),
})
}
pub fn load_chat_keys_for_session(
_buyer_shared_pubkey: &str,
_seller_shared_pubkey: &str,
) -> Result<DisputeChatMaterial> {
Err(Error::ChatTransport(
"shared-key secret not persisted; restart-resume requires relay re-fetch \
(deferred to US2+)"
.into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use mostro_core::dispute::SolverDisputeInfo;
fn info(buyer_hex: &str, seller_hex: &str) -> SolverDisputeInfo {
SolverDisputeInfo {
id: Uuid::nil(),
kind: "buy".into(),
status: "in-progress".into(),
hash: None,
preimage: None,
order_previous_status: "active".into(),
initiator_pubkey: buyer_hex.into(),
buyer_pubkey: Some(buyer_hex.into()),
seller_pubkey: Some(seller_hex.into()),
initiator_full_privacy: false,
counterpart_full_privacy: false,
initiator_info: None,
counterpart_info: None,
premium: 0,
payment_method: "".into(),
amount: 0,
fiat_amount: 0,
fee: 0,
routing_fee: 0,
buyer_invoice: None,
invoice_held_at: 0,
taken_at: 0,
created_at: 0,
}
}
#[test]
fn builds_material_from_solver_info() {
let serbero = Keys::generate();
let buyer = Keys::generate();
let seller = Keys::generate();
let material = material_from_solver_info(
&serbero,
&info(&buyer.public_key().to_hex(), &seller.public_key().to_hex()),
)
.unwrap();
assert_eq!(material.buyer_pubkey, buyer.public_key().to_hex());
assert_eq!(material.seller_pubkey, seller.public_key().to_hex());
assert_ne!(
material.buyer_shared_pubkey(),
material.seller_shared_pubkey()
);
}
#[test]
fn errors_when_buyer_pubkey_missing() {
let serbero = Keys::generate();
let seller = Keys::generate();
let mut bad = info("", &seller.public_key().to_hex());
bad.buyer_pubkey = None;
let err = material_from_solver_info(&serbero, &bad).unwrap_err();
match err {
Error::ChatTransport(m) => assert!(m.contains("buyer_pubkey")),
other => panic!("expected ChatTransport error, got {other}"),
}
}
#[test]
fn errors_when_buyer_and_seller_trade_pubkeys_are_identical() {
let serbero = Keys::generate();
let shared = Keys::generate();
let hex = shared.public_key().to_hex();
let err = material_from_solver_info(&serbero, &info(&hex, &hex)).unwrap_err();
match err {
Error::ChatTransport(m) => {
assert!(
m.contains("identical buyer and seller"),
"unexpected error text: {m}"
);
}
other => panic!("expected ChatTransport, got {other}"),
}
}
#[test]
fn errors_when_seller_pubkey_malformed() {
let serbero = Keys::generate();
let buyer = Keys::generate();
let bad = info(&buyer.public_key().to_hex(), "not-a-pubkey");
let err = material_from_solver_info(&serbero, &bad).unwrap_err();
assert!(
matches!(err, Error::ChatTransport(_)),
"expected ChatTransport, got {err}"
);
}
#[test]
fn load_chat_keys_for_session_documents_the_us2_limitation() {
let err = load_chat_keys_for_session("buyer-shared-pk", "seller-shared-pk").unwrap_err();
match err {
Error::ChatTransport(msg) => {
assert!(
msg.contains("not persisted"),
"error message should name the key-lifecycle limitation: {msg}"
);
}
other => panic!("expected ChatTransport, got {other}"),
}
}
}