Skip to main content

mostro_core/
dispute.rs

1//! Dispute representation and lifecycle states.
2//!
3//! A [`Dispute`] is opened when one of the counterparts of a trade asks
4//! Mostro to involve a solver. The dispute moves through a small state
5//! machine described by [`Status`] until it is either settled, refunded or
6//! released.
7//!
8//! [`SolverDisputeInfo`] is the payload surfaced to solvers with all the
9//! trade details they need to render the dispute in their UI without
10//! loading additional data.
11
12use crate::{order::Order, user::User, user::UserInfo};
13use chrono::Utc;
14use nostr_sdk::Timestamp;
15use serde::{Deserialize, Serialize};
16#[cfg(feature = "sqlx")]
17use sqlx::{FromRow, Type};
18#[cfg(feature = "sqlx")]
19use sqlx_crud::SqlxCrud;
20use std::{fmt::Display, str::FromStr};
21use uuid::Uuid;
22
23/// Lifecycle status of a [`Dispute`].
24#[cfg_attr(feature = "sqlx", derive(Type))]
25#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
26#[serde(rename_all = "kebab-case")]
27pub enum Status {
28    /// Dispute has been initiated and is waiting to be taken by a solver.
29    #[default]
30    Initiated,
31    /// A solver has taken the dispute and is working on it.
32    InProgress,
33    /// Admin/solver canceled the trade and refunded the seller.
34    SellerRefunded,
35    /// Admin/solver settled the seller's hold invoice and initiated payment
36    /// to the buyer.
37    Settled,
38    /// The seller released the funds before the dispute was resolved.
39    Released,
40}
41
42impl Display for Status {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Status::Initiated => write!(f, "initiated"),
46            Status::InProgress => write!(f, "in-progress"),
47            Status::SellerRefunded => write!(f, "seller-refunded"),
48            Status::Settled => write!(f, "settled"),
49            Status::Released => write!(f, "released"),
50        }
51    }
52}
53
54impl FromStr for Status {
55    type Err = ();
56
57    /// Parse a [`Status`] from its kebab-case string representation.
58    ///
59    /// Returns `Err(())` if `s` does not match a known variant.
60    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
61        match s {
62            "initiated" => std::result::Result::Ok(Self::Initiated),
63            "in-progress" => std::result::Result::Ok(Self::InProgress),
64            "seller-refunded" => std::result::Result::Ok(Self::SellerRefunded),
65            "settled" => std::result::Result::Ok(Self::Settled),
66            "released" => std::result::Result::Ok(Self::Released),
67            _ => Err(()),
68        }
69    }
70}
71
72/// Database representation of a dispute.
73///
74/// Disputes are always bound to a parent [`Order`]; `order_previous_status`
75/// preserves the status the order had before the dispute was filed so that
76/// it can be restored if the dispute is dismissed.
77#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud), external_id)]
78#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Eq)]
79pub struct Dispute {
80    /// Unique identifier for the dispute.
81    pub id: Uuid,
82    /// Id of the order the dispute is attached to.
83    pub order_id: Uuid,
84    /// Current [`Status`] of the dispute, serialized as kebab-case.
85    pub status: String,
86    /// The status the underlying order had before the dispute was opened.
87    pub order_previous_status: String,
88    /// Public key of the solver that has taken the dispute, if any.
89    pub solver_pubkey: Option<String>,
90    /// Unix timestamp (seconds) when the dispute was created.
91    pub created_at: i64,
92    /// Unix timestamp (seconds) when the dispute was taken by a solver.
93    /// `0` when it has not been taken yet.
94    pub taken_at: i64,
95}
96
97/// Extended dispute view for solvers.
98///
99/// Bundles the [`Dispute`] together with the key fields of its parent
100/// [`Order`] so a solver UI can render everything needed without additional
101/// database lookups, while still respecting the `full privacy` setting of
102/// each counterpart.
103#[derive(Debug, Default, Deserialize, Serialize, Clone)]
104pub struct SolverDisputeInfo {
105    /// Order id the dispute is attached to.
106    pub id: Uuid,
107    /// Order kind (`buy` or `sell`), serialized as kebab-case.
108    pub kind: String,
109    /// Order status at the time the dispute view was built.
110    pub status: String,
111    /// Payment hash of the hold invoice.
112    pub hash: Option<String>,
113    /// Preimage revealed once the hold invoice is settled.
114    pub preimage: Option<String>,
115    /// Status the order had immediately before the dispute was opened.
116    pub order_previous_status: String,
117    /// Trade public key of the dispute initiator.
118    pub initiator_pubkey: String,
119    /// Buyer's trade public key, if available.
120    pub buyer_pubkey: Option<String>,
121    /// Seller's trade public key, if available.
122    pub seller_pubkey: Option<String>,
123    /// `true` when the initiator is operating in full privacy mode, hiding
124    /// its reputation.
125    pub initiator_full_privacy: bool,
126    /// `true` when the counterpart is operating in full privacy mode.
127    pub counterpart_full_privacy: bool,
128    /// Reputation snapshot of the initiator, when privacy allows it.
129    pub initiator_info: Option<UserInfo>,
130    /// Reputation snapshot of the counterpart, when privacy allows it.
131    pub counterpart_info: Option<UserInfo>,
132    /// Premium percentage applied to the order price.
133    pub premium: i64,
134    /// Payment method agreed upon for the fiat leg.
135    pub payment_method: String,
136    /// Sats amount of the trade.
137    pub amount: i64,
138    /// Fiat amount of the trade.
139    pub fiat_amount: i64,
140    /// Mostro fee charged for the trade.
141    pub fee: i64,
142    /// Lightning routing fee paid when settling the trade.
143    pub routing_fee: i64,
144    /// Buyer's Lightning invoice, if already provided.
145    pub buyer_invoice: Option<String>,
146    /// Unix timestamp (seconds) when the hold invoice was locked in.
147    pub invoice_held_at: i64,
148    /// Unix timestamp (seconds) when the order was taken.
149    pub taken_at: i64,
150    /// Unix timestamp (seconds) when the order was created.
151    pub created_at: i64,
152}
153
154impl SolverDisputeInfo {
155    /// Build a [`SolverDisputeInfo`] from an order, its dispute and the
156    /// optional [`User`] records of both counterparts.
157    ///
158    /// When a [`User`] is provided, the corresponding privacy flag is set to
159    /// `false` and a [`UserInfo`] snapshot is included (rating, reviews,
160    /// operating days computed from `created_at`). When a [`User`] is `None`,
161    /// the party is considered to be operating in full privacy mode.
162    pub fn new(
163        order: &Order,
164        dispute: &Dispute,
165        initiator_tradekey: String,
166        counterpart: Option<User>,
167        initiator: Option<User>,
168    ) -> Self {
169        // Get initiator and counterpart info if not in full privacy mode
170        let mut initiator_info = None;
171        let mut counterpart_info = None;
172        let mut initiator_full_privacy = true;
173        let mut counterpart_full_privacy = true;
174
175        if let Some(initiator) = initiator {
176            let now = Timestamp::now();
177            let initiator_operating_days = (now.as_secs() - initiator.created_at as u64) / 86400;
178            initiator_info = Some(UserInfo {
179                rating: initiator.total_rating,
180                reviews: initiator.total_reviews,
181                operating_days: initiator_operating_days,
182            });
183            initiator_full_privacy = false;
184        }
185        if let Some(counterpart) = counterpart {
186            let now = Timestamp::now();
187            let couterpart_operating_days = (now.as_secs() - counterpart.created_at as u64) / 86400;
188            counterpart_info = Some(UserInfo {
189                rating: counterpart.total_rating,
190                reviews: counterpart.total_reviews,
191                operating_days: couterpart_operating_days,
192            });
193            counterpart_full_privacy = false;
194        }
195
196        Self {
197            id: order.id,
198            kind: order.kind.clone(),
199            status: order.status.clone(),
200            hash: order.hash.clone(),
201            preimage: order.preimage.clone(),
202            order_previous_status: dispute.order_previous_status.clone(),
203            initiator_pubkey: initiator_tradekey,
204            buyer_pubkey: order.buyer_pubkey.clone(),
205            seller_pubkey: order.seller_pubkey.clone(),
206            initiator_full_privacy,
207            counterpart_full_privacy,
208            counterpart_info,
209            initiator_info,
210            premium: order.premium,
211            payment_method: order.payment_method.clone(),
212            amount: order.amount,
213            fiat_amount: order.fiat_amount,
214            fee: order.fee,
215            routing_fee: order.routing_fee,
216            buyer_invoice: order.buyer_invoice.clone(),
217            invoice_held_at: order.invoice_held_at,
218            taken_at: order.taken_at,
219            created_at: order.created_at,
220        }
221    }
222}
223
224impl Dispute {
225    /// Create a new dispute for an order.
226    ///
227    /// The dispute starts in [`Status::Initiated`] with a fresh UUID, the
228    /// current timestamp as `created_at`, no solver assigned and `taken_at`
229    /// set to `0`. `order_status` should be the current status of the order
230    /// at the moment the dispute is filed; it is preserved in
231    /// `order_previous_status`.
232    pub fn new(order_id: Uuid, order_status: String) -> Self {
233        Self {
234            id: Uuid::new_v4(),
235            order_id,
236            status: Status::Initiated.to_string(),
237            order_previous_status: order_status,
238            solver_pubkey: None,
239            created_at: Utc::now().timestamp(),
240            taken_at: 0,
241        }
242    }
243}