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