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}