Skip to main content

mostro_core/
order.rs

1//! Orders and their lifecycle.
2//!
3//! [`Order`] is the database-backed record for a trade between a buyer and a
4//! seller on Mostro. Orders have a [`Kind`] (buy or sell) and a [`Status`]
5//! that evolves through a small state machine as the trade progresses.
6//!
7//! [`SmallOrder`] is a compact, wire-friendly view of an order used when
8//! broadcasting via Nostr or surfacing minimal information to clients.
9
10use crate::prelude::*;
11use nostr_sdk::{PublicKey, Timestamp};
12use serde::{Deserialize, Serialize};
13#[cfg(feature = "sqlx")]
14use sqlx::FromRow;
15#[cfg(feature = "sqlx")]
16use sqlx_crud::SqlxCrud;
17use std::{fmt::Display, str::FromStr};
18use uuid::Uuid;
19use wasm_bindgen::prelude::*;
20
21/// Direction of an order: the maker wants to buy or sell sats.
22#[wasm_bindgen]
23#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
24#[serde(rename_all = "kebab-case")]
25pub enum Kind {
26    /// The maker wants to buy sats in exchange for fiat.
27    Buy,
28    /// The maker wants to sell sats in exchange for fiat.
29    Sell,
30}
31
32impl FromStr for Kind {
33    type Err = ();
34
35    /// Parse a [`Kind`] from `"buy"` or `"sell"` (case-insensitive).
36    ///
37    /// Returns `Err(())` for any other input.
38    fn from_str(kind: &str) -> std::result::Result<Self, Self::Err> {
39        match kind.to_lowercase().as_str() {
40            "buy" => std::result::Result::Ok(Self::Buy),
41            "sell" => std::result::Result::Ok(Self::Sell),
42            _ => Err(()),
43        }
44    }
45}
46
47impl Display for Kind {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Kind::Sell => write!(f, "sell"),
51            Kind::Buy => write!(f, "buy"),
52        }
53    }
54}
55
56/// Lifecycle status of an [`Order`].
57///
58/// Values are serialized in `kebab-case`, matching the representation stored
59/// in the database and sent over the wire.
60#[wasm_bindgen]
61#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
62#[serde(rename_all = "kebab-case")]
63pub enum Status {
64    /// Order is published and available to be taken.
65    Active,
66    /// Order was canceled by the maker or the taker.
67    Canceled,
68    /// Order was canceled by an admin.
69    CanceledByAdmin,
70    /// Order was settled by an admin (solver) after a dispute.
71    SettledByAdmin,
72    /// Order was completed by an admin after a dispute.
73    CompletedByAdmin,
74    /// Order is currently in dispute.
75    Dispute,
76    /// Order expired before being taken or completed.
77    Expired,
78    /// Buyer has marked fiat as sent; waiting for the seller to release.
79    FiatSent,
80    /// Hold invoice has been settled; payment to the buyer is in flight.
81    SettledHoldInvoice,
82    /// Order has been created but not yet published.
83    Pending,
84    /// Trade completed successfully.
85    Success,
86    /// Waiting for the buyer's payout invoice.
87    WaitingBuyerInvoice,
88    /// Waiting for the seller to pay the hold invoice.
89    WaitingPayment,
90    /// Order has been matched to a taker but Mostro is awaiting the taker's
91    /// bond hold-invoice payment before starting the trade flow. Distinct
92    /// from [`Status::Pending`] (advertised, no taker yet) and from
93    /// [`Status::WaitingPayment`] (trade escrow expected from the seller).
94    WaitingTakerBond,
95    /// Both parties agreed to cooperatively cancel the trade.
96    CooperativelyCanceled,
97    /// Order has been taken and the trade is in progress.
98    InProgress,
99}
100
101impl Display for Status {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            Status::Active => write!(f, "active"),
105            Status::Canceled => write!(f, "canceled"),
106            Status::CanceledByAdmin => write!(f, "canceled-by-admin"),
107            Status::SettledByAdmin => write!(f, "settled-by-admin"),
108            Status::CompletedByAdmin => write!(f, "completed-by-admin"),
109            Status::Dispute => write!(f, "dispute"),
110            Status::Expired => write!(f, "expired"),
111            Status::FiatSent => write!(f, "fiat-sent"),
112            Status::SettledHoldInvoice => write!(f, "settled-hold-invoice"),
113            Status::Pending => write!(f, "pending"),
114            Status::Success => write!(f, "success"),
115            Status::WaitingBuyerInvoice => write!(f, "waiting-buyer-invoice"),
116            Status::WaitingPayment => write!(f, "waiting-payment"),
117            Status::WaitingTakerBond => write!(f, "waiting-taker-bond"),
118            Status::CooperativelyCanceled => write!(f, "cooperatively-canceled"),
119            Status::InProgress => write!(f, "in-progress"),
120        }
121    }
122}
123
124impl FromStr for Status {
125    type Err = ();
126    /// Convert a string to a status
127    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
128        match s.to_lowercase().as_str() {
129            "active" => std::result::Result::Ok(Self::Active),
130            "canceled" => std::result::Result::Ok(Self::Canceled),
131            "canceled-by-admin" => std::result::Result::Ok(Self::CanceledByAdmin),
132            "settled-by-admin" => std::result::Result::Ok(Self::SettledByAdmin),
133            "completed-by-admin" => std::result::Result::Ok(Self::CompletedByAdmin),
134            "dispute" => std::result::Result::Ok(Self::Dispute),
135            "expired" => std::result::Result::Ok(Self::Expired),
136            "fiat-sent" => std::result::Result::Ok(Self::FiatSent),
137            "settled-hold-invoice" => std::result::Result::Ok(Self::SettledHoldInvoice),
138            "pending" => std::result::Result::Ok(Self::Pending),
139            "success" => std::result::Result::Ok(Self::Success),
140            "waiting-buyer-invoice" => std::result::Result::Ok(Self::WaitingBuyerInvoice),
141            "waiting-payment" => std::result::Result::Ok(Self::WaitingPayment),
142            "waiting-taker-bond" => std::result::Result::Ok(Self::WaitingTakerBond),
143            "cooperatively-canceled" => std::result::Result::Ok(Self::CooperativelyCanceled),
144            "in-progress" => std::result::Result::Ok(Self::InProgress),
145            _ => Err(()),
146        }
147    }
148}
149/// Persistent representation of a Mostro order.
150///
151/// This is the canonical on-disk record kept by a Mostro node. All fields
152/// are stored so an order can be recomputed / restarted from its row alone;
153/// clients usually work with the lighter [`SmallOrder`] view.
154///
155/// Timestamps are Unix seconds; `hash` / `preimage` refer to the hold
156/// invoice used to lock the seller's funds.
157#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud), external_id)]
158#[derive(Debug, Default, Deserialize, Serialize, Clone)]
159pub struct Order {
160    /// Unique order identifier.
161    pub id: Uuid,
162    /// Order kind ([`Kind::Buy`] or [`Kind::Sell`]), serialized as
163    /// kebab-case.
164    pub kind: String,
165    /// Nostr event id of the order publication.
166    pub event_id: String,
167    /// Payment hash of the seller's hold invoice, once generated.
168    pub hash: Option<String>,
169    /// Preimage revealed when the hold invoice is settled.
170    pub preimage: Option<String>,
171    /// Trade public key of the order creator (maker).
172    pub creator_pubkey: String,
173    /// Trade public key of the party who initiated a cancel, if any.
174    pub cancel_initiator_pubkey: Option<String>,
175    /// Buyer trade public key.
176    pub buyer_pubkey: Option<String>,
177    /// Buyer master identity pubkey. Equal to `buyer_pubkey` when the
178    /// buyer operates in full-privacy mode.
179    pub master_buyer_pubkey: Option<String>,
180    /// Seller trade public key.
181    pub seller_pubkey: Option<String>,
182    /// Seller master identity pubkey. Equal to `seller_pubkey` when the
183    /// seller operates in full-privacy mode.
184    pub master_seller_pubkey: Option<String>,
185    /// Current [`Status`] of the order, serialized as kebab-case.
186    pub status: String,
187    /// `true` if the sats amount was computed from a live market price.
188    pub price_from_api: bool,
189    /// Premium percentage applied on top of the spot price.
190    pub premium: i64,
191    /// Free-form payment method description (e.g. "SEPA,Bank transfer").
192    pub payment_method: String,
193    /// Sats amount. `0` means the amount is computed at take-time from the
194    /// fiat amount and the current market price.
195    pub amount: i64,
196    /// Lower bound of a range order (fiat amount). `None` for fixed orders.
197    pub min_amount: Option<i64>,
198    /// Upper bound of a range order (fiat amount). `None` for fixed orders.
199    pub max_amount: Option<i64>,
200    /// `true` when the buyer has initiated a dispute on this order.
201    pub buyer_dispute: bool,
202    /// `true` when the seller has initiated a dispute on this order.
203    pub seller_dispute: bool,
204    /// `true` when the buyer has initiated a cooperative cancel.
205    pub buyer_cooperativecancel: bool,
206    /// `true` when the seller has initiated a cooperative cancel.
207    pub seller_cooperativecancel: bool,
208    /// Mostro fee charged for this trade, in sats.
209    pub fee: i64,
210    /// Lightning routing fee observed when paying the buyer.
211    pub routing_fee: i64,
212    /// Optional developer-fee portion of `fee`.
213    pub dev_fee: i64,
214    /// `true` once the developer fee has been paid out.
215    pub dev_fee_paid: bool,
216    /// Payment hash of the developer-fee payment, when available.
217    pub dev_fee_payment_hash: Option<String>,
218    /// Fiat currency code (e.g. "EUR", "USD").
219    pub fiat_code: String,
220    /// Fiat amount of the trade.
221    pub fiat_amount: i64,
222    /// Buyer's Lightning payout invoice, once provided.
223    pub buyer_invoice: Option<String>,
224    /// Parent order id for orders derived from a range parent.
225    pub range_parent_id: Option<Uuid>,
226    /// Unix timestamp (seconds) when the hold invoice was locked in.
227    pub invoice_held_at: i64,
228    /// Unix timestamp (seconds) when the order was taken.
229    pub taken_at: i64,
230    /// Unix timestamp (seconds) when the order was created.
231    pub created_at: i64,
232    /// `true` once the buyer has rated the counterpart.
233    pub buyer_sent_rate: bool,
234    /// `true` once the seller has rated the counterpart.
235    pub seller_sent_rate: bool,
236    /// `true` if the latest payment attempt to the buyer failed.
237    pub failed_payment: bool,
238    /// Number of payment attempts performed so far.
239    pub payment_attempts: i64,
240    /// Unix timestamp (seconds) when the order expires automatically.
241    pub expires_at: i64,
242    /// Trade index used by the seller when creating / taking the order.
243    pub trade_index_seller: Option<i64>,
244    /// Trade index used by the buyer when creating / taking the order.
245    pub trade_index_buyer: Option<i64>,
246    /// Trade public key announced by a range-order maker for the next
247    /// trade in the same range.
248    pub next_trade_pubkey: Option<String>,
249    /// Trade index announced by a range-order maker for the next trade.
250    pub next_trade_index: Option<i64>,
251}
252
253impl From<SmallOrder> for Order {
254    fn from(small_order: SmallOrder) -> Self {
255        Self {
256            id: Uuid::new_v4(),
257            // order will be overwritten with the real one before publishing
258            kind: small_order
259                .kind
260                .map_or_else(|| Kind::Buy.to_string(), |k| k.to_string()),
261            status: small_order
262                .status
263                .map_or_else(|| Status::Active.to_string(), |s| s.to_string()),
264            amount: small_order.amount,
265            fiat_code: small_order.fiat_code,
266            min_amount: small_order.min_amount,
267            max_amount: small_order.max_amount,
268            fiat_amount: small_order.fiat_amount,
269            payment_method: small_order.payment_method,
270            premium: small_order.premium,
271            event_id: String::new(),
272            creator_pubkey: String::new(),
273            price_from_api: false,
274            fee: 0,
275            routing_fee: 0,
276            dev_fee: 0,
277            dev_fee_paid: false,
278            dev_fee_payment_hash: None,
279            invoice_held_at: 0,
280            taken_at: 0,
281            created_at: small_order.created_at.unwrap_or(0),
282            expires_at: small_order.expires_at.unwrap_or(0),
283            payment_attempts: 0,
284            ..Default::default()
285        }
286    }
287}
288
289impl Order {
290    /// Build a [`SmallOrder`] suitable for broadcasting as a new order event.
291    ///
292    /// Copies the tradable fields (amounts, payment method, premium, etc.)
293    /// from `self`. Trade pubkeys are left unset because a new order is
294    /// published before a counterpart is assigned.
295    pub fn as_new_order(&self) -> SmallOrder {
296        SmallOrder::new(
297            Some(self.id),
298            Some(Kind::from_str(&self.kind).unwrap()),
299            Some(Status::from_str(&self.status).unwrap()),
300            self.amount,
301            self.fiat_code.clone(),
302            self.min_amount,
303            self.max_amount,
304            self.fiat_amount,
305            self.payment_method.clone(),
306            self.premium,
307            None,
308            None,
309            self.buyer_invoice.clone(),
310            Some(self.created_at),
311            Some(self.expires_at),
312        )
313    }
314    /// Parse the order kind from the string-encoded field.
315    ///
316    /// Returns [`ServiceError::InvalidOrderKind`] when `self.kind` does not
317    /// match a known [`Kind`] variant.
318    pub fn get_order_kind(&self) -> Result<Kind, ServiceError> {
319        if let Ok(kind) = Kind::from_str(&self.kind) {
320            Ok(kind)
321        } else {
322            Err(ServiceError::InvalidOrderKind)
323        }
324    }
325
326    /// Parse the order status from the string-encoded field.
327    ///
328    /// Returns [`ServiceError::InvalidOrderStatus`] when `self.status` does
329    /// not match a known [`Status`] variant.
330    pub fn get_order_status(&self) -> Result<Status, ServiceError> {
331        if let Ok(status) = Status::from_str(&self.status) {
332            Ok(status)
333        } else {
334            Err(ServiceError::InvalidOrderStatus)
335        }
336    }
337
338    /// Check that the order is currently in a specific [`Status`].
339    ///
340    /// Returns `Ok(())` on match and [`CantDoReason::InvalidOrderStatus`]
341    /// either on mismatch or when the stored status cannot be parsed.
342    pub fn check_status(&self, status: Status) -> Result<(), CantDoReason> {
343        match Status::from_str(&self.status) {
344            Ok(s) => match s == status {
345                true => Ok(()),
346                false => Err(CantDoReason::InvalidOrderStatus),
347            },
348            Err(_) => Err(CantDoReason::InvalidOrderStatus),
349        }
350    }
351
352    /// Assert that the order is a [`Kind::Buy`] order.
353    pub fn is_buy_order(&self) -> Result<(), CantDoReason> {
354        if self.kind != Kind::Buy.to_string() {
355            return Err(CantDoReason::InvalidOrderKind);
356        }
357        Ok(())
358    }
359    /// Assert that the order is a [`Kind::Sell`] order.
360    pub fn is_sell_order(&self) -> Result<(), CantDoReason> {
361        if self.kind != Kind::Sell.to_string() {
362            return Err(CantDoReason::InvalidOrderKind);
363        }
364        Ok(())
365    }
366
367    /// Assert that `sender` is the maker (creator) of the order.
368    ///
369    /// Returns [`CantDoReason::InvalidPubkey`] when the pubkeys differ.
370    pub fn sent_from_maker(&self, sender: PublicKey) -> Result<(), CantDoReason> {
371        let sender = sender.to_string();
372        if self.creator_pubkey != sender {
373            return Err(CantDoReason::InvalidPubkey);
374        }
375        Ok(())
376    }
377
378    /// Assert that `sender` is **not** the maker of the order.
379    ///
380    /// Returns [`CantDoReason::InvalidPubkey`] when `sender` matches
381    /// `self.creator_pubkey`.
382    pub fn not_sent_from_maker(&self, sender: PublicKey) -> Result<(), CantDoReason> {
383        let sender = sender.to_string();
384        if self.creator_pubkey == sender {
385            return Err(CantDoReason::InvalidPubkey);
386        }
387        Ok(())
388    }
389
390    /// Parse the maker's public key as a Nostr [`PublicKey`].
391    pub fn get_creator_pubkey(&self) -> Result<PublicKey, ServiceError> {
392        match PublicKey::from_str(self.creator_pubkey.as_ref()) {
393            Ok(pk) => Ok(pk),
394            Err(_) => Err(ServiceError::InvalidPubkey),
395        }
396    }
397
398    /// Parse the buyer trade public key.
399    ///
400    /// Returns [`ServiceError::InvalidPubkey`] when the field is absent or
401    /// cannot be parsed.
402    pub fn get_buyer_pubkey(&self) -> Result<PublicKey, ServiceError> {
403        if let Some(pk) = self.buyer_pubkey.as_ref() {
404            PublicKey::from_str(pk).map_err(|_| ServiceError::InvalidPubkey)
405        } else {
406            Err(ServiceError::InvalidPubkey)
407        }
408    }
409    /// Parse the seller trade public key.
410    ///
411    /// Returns [`ServiceError::InvalidPubkey`] when the field is absent or
412    /// cannot be parsed.
413    pub fn get_seller_pubkey(&self) -> Result<PublicKey, ServiceError> {
414        if let Some(pk) = self.seller_pubkey.as_ref() {
415            PublicKey::from_str(pk).map_err(|_| ServiceError::InvalidPubkey)
416        } else {
417            Err(ServiceError::InvalidPubkey)
418        }
419    }
420    /// Parse the buyer master identity public key.
421    pub fn get_master_buyer_pubkey(&self) -> Result<PublicKey, ServiceError> {
422        if let Some(pk) = self.master_buyer_pubkey.as_ref() {
423            PublicKey::from_str(pk).map_err(|_| ServiceError::InvalidPubkey)
424        } else {
425            Err(ServiceError::InvalidPubkey)
426        }
427    }
428    /// Parse the seller master identity public key.
429    pub fn get_master_seller_pubkey(&self) -> Result<PublicKey, ServiceError> {
430        if let Some(pk) = self.master_seller_pubkey.as_ref() {
431            PublicKey::from_str(pk).map_err(|_| ServiceError::InvalidPubkey)
432        } else {
433            Err(ServiceError::InvalidPubkey)
434        }
435    }
436
437    /// `true` when both `min_amount` and `max_amount` are set, i.e. this is
438    /// a range order.
439    pub fn is_range_order(&self) -> bool {
440        self.min_amount.is_some() && self.max_amount.is_some()
441    }
442
443    /// Increment the payment-failure counter.
444    ///
445    /// On the first failure, sets [`Self::failed_payment`] to `true` and
446    /// [`Self::payment_attempts`] to `1`. On subsequent failures the counter
447    /// is bumped, capped at `retries_number`.
448    pub fn count_failed_payment(&mut self, retries_number: i64) {
449        if !self.failed_payment {
450            self.failed_payment = true;
451            self.payment_attempts = 1;
452        } else if self.payment_attempts < retries_number {
453            self.payment_attempts += 1;
454        }
455    }
456
457    /// `true` when `amount == 0`, meaning the sats amount is not fixed and
458    /// will be computed from the fiat amount and market price.
459    pub fn has_no_amount(&self) -> bool {
460        self.amount == 0
461    }
462
463    /// Set [`Self::taken_at`] to the current Unix timestamp.
464    pub fn set_timestamp_now(&mut self) {
465        self.taken_at = Timestamp::now().as_secs() as i64
466    }
467
468    /// Compare the trade pubkeys against the master pubkeys to detect which
469    /// sides of the trade are operating in full privacy mode.
470    ///
471    /// Returns a `(buyer_normal_idkey, seller_normal_idkey)` tuple. Each
472    /// value is `Some(master_pubkey)` when that side is running in normal
473    /// mode (trade key differs from master key, so the user is willing to
474    /// associate the trade with its reputation); `None` when the side is in
475    /// full privacy mode.
476    pub fn is_full_privacy_order(&self) -> Result<(Option<String>, Option<String>), ServiceError> {
477        let (mut normal_buyer_idkey, mut normal_seller_idkey) = (None, None);
478
479        // Get master pubkeys to get users data from db
480        let master_buyer_pubkey = self.get_master_buyer_pubkey().ok();
481        let master_seller_pubkey = self.get_master_seller_pubkey().ok();
482
483        // Check if the buyer is in full privacy mode
484        if self.buyer_pubkey != master_buyer_pubkey.map(|pk| pk.to_string()) {
485            normal_buyer_idkey = master_buyer_pubkey.map(|pk| pk.to_string());
486        }
487
488        // Check if the seller is in full privacy mode
489        if self.seller_pubkey != master_seller_pubkey.map(|pk| pk.to_string()) {
490            normal_seller_idkey = master_seller_pubkey.map(|pk| pk.to_string());
491        }
492
493        Ok((normal_buyer_idkey, normal_seller_idkey))
494    }
495    /// Mark the order as in dispute and record which side initiated it.
496    ///
497    /// When `is_buyer_dispute` is `true` the buyer flag is set, otherwise
498    /// the seller flag. The order status is then transitioned to
499    /// [`Status::Dispute`]. Returns
500    /// [`CantDoReason::DisputeCreationError`] when the appropriate flag was
501    /// already set (avoids registering the same dispute twice).
502    pub fn setup_dispute(&mut self, is_buyer_dispute: bool) -> Result<(), CantDoReason> {
503        // Get the opposite dispute status
504        let is_seller_dispute = !is_buyer_dispute;
505
506        // Update dispute flags based on who initiated
507        let mut update_seller_dispute = false;
508        let mut update_buyer_dispute = false;
509
510        if is_seller_dispute && !self.seller_dispute {
511            update_seller_dispute = true;
512            self.seller_dispute = update_seller_dispute;
513        } else if is_buyer_dispute && !self.buyer_dispute {
514            update_buyer_dispute = true;
515            self.buyer_dispute = update_buyer_dispute;
516        };
517        // Set the status to dispute
518        self.status = Status::Dispute.to_string();
519
520        // Update the database with dispute information
521        // Save the dispute to DB
522        if !update_buyer_dispute && !update_seller_dispute {
523            return Err(CantDoReason::DisputeCreationError);
524        }
525
526        Ok(())
527    }
528}
529
530/// Compact, wire-friendly view of an order.
531///
532/// `SmallOrder` carries the fields needed to publish a new order or to show
533/// a listing entry to a client, without the bookkeeping fields kept in
534/// [`Order`] (hold invoice hash, fees, dispute flags, etc.). It is the shape
535/// used by [`Payload::Order`] and siblings.
536///
537/// Unknown fields are rejected at deserialization time (`deny_unknown_fields`).
538#[derive(Debug, Default, Deserialize, Serialize, Clone)]
539#[serde(deny_unknown_fields)]
540pub struct SmallOrder {
541    /// Order id. `None` for orders that have not been persisted yet.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub id: Option<Uuid>,
544    /// Order kind.
545    pub kind: Option<Kind>,
546    /// Current status.
547    pub status: Option<Status>,
548    /// Sats amount. `0` when the sats amount is derived from the fiat
549    /// amount and live market price.
550    pub amount: i64,
551    /// Fiat currency code (e.g. "EUR").
552    pub fiat_code: String,
553    /// Lower bound of a range order (fiat amount).
554    pub min_amount: Option<i64>,
555    /// Upper bound of a range order (fiat amount).
556    pub max_amount: Option<i64>,
557    /// Fiat amount of the trade.
558    pub fiat_amount: i64,
559    /// Free-form payment method description.
560    pub payment_method: String,
561    /// Premium percentage applied on top of the spot price.
562    pub premium: i64,
563    /// Buyer trade public key, when known.
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub buyer_trade_pubkey: Option<String>,
566    /// Seller trade public key, when known.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub seller_trade_pubkey: Option<String>,
569    /// Buyer's Lightning payout invoice, when already provided.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub buyer_invoice: Option<String>,
572    /// Unix timestamp (seconds) when the order was created.
573    pub created_at: Option<i64>,
574    /// Unix timestamp (seconds) when the order expires automatically.
575    pub expires_at: Option<i64>,
576}
577
578#[allow(dead_code)]
579impl SmallOrder {
580    /// Construct a new [`SmallOrder`] from all of its fields.
581    #[allow(clippy::too_many_arguments)]
582    pub fn new(
583        id: Option<Uuid>,
584        kind: Option<Kind>,
585        status: Option<Status>,
586        amount: i64,
587        fiat_code: String,
588        min_amount: Option<i64>,
589        max_amount: Option<i64>,
590        fiat_amount: i64,
591        payment_method: String,
592        premium: i64,
593        buyer_trade_pubkey: Option<String>,
594        seller_trade_pubkey: Option<String>,
595        buyer_invoice: Option<String>,
596        created_at: Option<i64>,
597        expires_at: Option<i64>,
598    ) -> Self {
599        Self {
600            id,
601            kind,
602            status,
603            amount,
604            fiat_code,
605            min_amount,
606            max_amount,
607            fiat_amount,
608            payment_method,
609            premium,
610            buyer_trade_pubkey,
611            seller_trade_pubkey,
612            buyer_invoice,
613            created_at,
614            expires_at,
615        }
616    }
617    /// Parse a [`SmallOrder`] from its JSON representation.
618    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
619        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
620    }
621
622    /// Serialize the order to a JSON string.
623    pub fn as_json(&self) -> Result<String, ServiceError> {
624        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
625    }
626
627    /// Return the sats amount as a string, or the literal `"Market price"`
628    /// when the amount is `0` (to be computed at take-time).
629    pub fn sats_amount(&self) -> String {
630        if self.amount == 0 {
631            "Market price".to_string()
632        } else {
633            self.amount.to_string()
634        }
635    }
636    /// Assert that the fiat amount is strictly positive.
637    ///
638    /// Returns [`CantDoReason::InvalidAmount`] otherwise.
639    pub fn check_fiat_amount(&self) -> Result<(), CantDoReason> {
640        if self.fiat_amount <= 0 {
641            return Err(CantDoReason::InvalidAmount);
642        }
643        Ok(())
644    }
645
646    /// Assert that the sats amount is non-negative.
647    ///
648    /// A value of `0` is explicitly accepted because it signals that the
649    /// sats amount will be derived from the fiat amount and the market
650    /// price at take-time. Returns [`CantDoReason::InvalidAmount`] when the
651    /// amount is negative.
652    pub fn check_amount(&self) -> Result<(), CantDoReason> {
653        if self.amount < 0 {
654            return Err(CantDoReason::InvalidAmount);
655        }
656        Ok(())
657    }
658
659    /// Reject orders that set both `amount` and `premium` at the same time.
660    ///
661    /// A premium only makes sense when the sats amount is market-priced;
662    /// combining a fixed sats amount with a premium is ambiguous and
663    /// returns [`CantDoReason::InvalidParameters`].
664    pub fn check_zero_amount_with_premium(&self) -> Result<(), CantDoReason> {
665        let premium = (self.premium != 0).then_some(self.premium);
666        let sats_amount = (self.amount != 0).then_some(self.amount);
667
668        if premium.is_some() && sats_amount.is_some() {
669            return Err(CantDoReason::InvalidParameters);
670        }
671        Ok(())
672    }
673
674    /// Validate the bounds of a range order and push them into `amounts`.
675    ///
676    /// When both `min_amount` and `max_amount` are set, they must be
677    /// non-negative, `min < max`, and `amount` must be `0` (range orders
678    /// cannot fix the sats amount). On success, `amounts` is cleared and
679    /// replaced with `[min, max]`. On failure returns
680    /// [`CantDoReason::InvalidAmount`].
681    pub fn check_range_order_limits(&self, amounts: &mut Vec<i64>) -> Result<(), CantDoReason> {
682        // Check if the min and max amount are valid and update the vector
683        if let (Some(min), Some(max)) = (self.min_amount, self.max_amount) {
684            if min < 0 || max < 0 {
685                return Err(CantDoReason::InvalidAmount);
686            }
687            if min >= max {
688                return Err(CantDoReason::InvalidAmount);
689            }
690            if self.amount != 0 {
691                return Err(CantDoReason::InvalidAmount);
692            }
693            amounts.clear();
694            amounts.push(min);
695            amounts.push(max);
696        }
697        Ok(())
698    }
699
700    /// Verify that the order's fiat code appears in the list of accepted
701    /// currencies.
702    ///
703    /// An empty allowlist disables the check (every currency is accepted).
704    /// Returns [`CantDoReason::InvalidFiatCurrency`] when the currency is
705    /// not allowed.
706    pub fn check_fiat_currency(
707        &self,
708        fiat_currencies_accepted: &[String],
709    ) -> Result<(), CantDoReason> {
710        if !fiat_currencies_accepted.contains(&self.fiat_code)
711            && !fiat_currencies_accepted.is_empty()
712        {
713            return Err(CantDoReason::InvalidFiatCurrency);
714        }
715        Ok(())
716    }
717}
718
719impl From<Order> for SmallOrder {
720    fn from(order: Order) -> Self {
721        let id = Some(order.id);
722        let kind = Kind::from_str(&order.kind).unwrap();
723        let status = Status::from_str(&order.status).unwrap();
724        let amount = order.amount;
725        let fiat_code = order.fiat_code.clone();
726        let min_amount = order.min_amount;
727        let max_amount = order.max_amount;
728        let fiat_amount = order.fiat_amount;
729        let payment_method = order.payment_method.clone();
730        let premium = order.premium;
731        let buyer_trade_pubkey = order.buyer_pubkey.clone();
732        let seller_trade_pubkey = order.seller_pubkey.clone();
733        let buyer_invoice = order.buyer_invoice.clone();
734
735        Self {
736            id,
737            kind: Some(kind),
738            status: Some(status),
739            amount,
740            fiat_code,
741            min_amount,
742            max_amount,
743            fiat_amount,
744            payment_method,
745            premium,
746            buyer_trade_pubkey,
747            seller_trade_pubkey,
748            buyer_invoice,
749            created_at: Some(order.created_at),
750            expires_at: Some(order.expires_at),
751        }
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use crate::error::CantDoReason;
759    use nostr_sdk::Keys;
760    use uuid::uuid;
761
762    #[test]
763    fn test_status_string() {
764        assert_eq!(Status::Active.to_string(), "active");
765        assert_eq!(Status::CompletedByAdmin.to_string(), "completed-by-admin");
766        assert_eq!(Status::FiatSent.to_string(), "fiat-sent");
767        assert_ne!(Status::Pending.to_string(), "Pending");
768    }
769
770    #[test]
771    fn test_status_waiting_taker_bond_roundtrip() {
772        assert_eq!(Status::WaitingTakerBond.to_string(), "waiting-taker-bond");
773        assert_eq!(
774            Status::from_str("waiting-taker-bond").unwrap(),
775            Status::WaitingTakerBond
776        );
777        // serde representation must match the string form.
778        let json = serde_json::to_string(&Status::WaitingTakerBond).unwrap();
779        assert_eq!(json, "\"waiting-taker-bond\"");
780        let back: Status = serde_json::from_str(&json).unwrap();
781        assert_eq!(back, Status::WaitingTakerBond);
782    }
783
784    #[test]
785    fn test_kind_string() {
786        assert_ne!(Kind::Sell.to_string(), "active");
787        assert_eq!(Kind::Sell.to_string(), "sell");
788        assert_eq!(Kind::Buy.to_string(), "buy");
789        assert_ne!(Kind::Buy.to_string(), "active");
790    }
791
792    #[test]
793    fn test_order_message() {
794        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
795        let payment_methods = "SEPA,Bank transfer".to_string();
796        let payload = Payload::Order(SmallOrder::new(
797            Some(uuid),
798            Some(Kind::Sell),
799            Some(Status::Pending),
800            100,
801            "eur".to_string(),
802            None,
803            None,
804            100,
805            payment_methods,
806            1,
807            None,
808            None,
809            None,
810            Some(1627371434),
811            None,
812        ));
813
814        let test_message = Message::Order(MessageKind::new(
815            Some(uuid),
816            Some(1),
817            Some(2),
818            Action::NewOrder,
819            Some(payload),
820        ));
821        let test_message_json = test_message.as_json().unwrap();
822        let sample_message = r#"{"order":{"version":1,"id":"308e1272-d5f4-47e6-bd97-3504baea9c23","request_id":1,"trade_index":2,"action":"new-order","payload":{"order":{"id":"308e1272-d5f4-47e6-bd97-3504baea9c23","kind":"sell","status":"pending","amount":100,"fiat_code":"eur","fiat_amount":100,"payment_method":"SEPA,Bank transfer","premium":1,"created_at":1627371434}}}}"#;
823        let message = Message::from_json(sample_message).unwrap();
824        assert!(message.verify());
825        let message_json = message.as_json().unwrap();
826        assert_eq!(message_json, test_message_json);
827    }
828
829    #[test]
830    fn test_payment_request_payload_message() {
831        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
832        let test_message = Message::Order(MessageKind::new(
833            Some(uuid),
834            Some(1),
835            Some(3),
836            Action::PayInvoice,
837            Some(Payload::PaymentRequest(
838                Some(SmallOrder::new(
839                    Some(uuid),
840                    Some(Kind::Sell),
841                    Some(Status::WaitingPayment),
842                    100,
843                    "eur".to_string(),
844                    None,
845                    None,
846                    100,
847                    "Face to face".to_string(),
848                    1,
849                    None,
850                    None,
851                    None,
852                    Some(1627371434),
853                    None,
854                )),
855                "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string(),
856                None,
857            )),
858        ));
859        let sample_message = r#"{"order":{"version":1,"id":"308e1272-d5f4-47e6-bd97-3504baea9c23","request_id":1,"trade_index":3,"action":"pay-invoice","payload":{"payment_request":[{"id":"308e1272-d5f4-47e6-bd97-3504baea9c23","kind":"sell","status":"waiting-payment","amount":100,"fiat_code":"eur","fiat_amount":100,"payment_method":"Face to face","premium":1,"created_at":1627371434},"lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e",null]}}}"#;
860        let message = Message::from_json(sample_message).unwrap();
861        assert!(message.verify());
862        let message_json = message.as_json().unwrap();
863        let test_message_json = test_message.as_json().unwrap();
864        assert_eq!(message_json, test_message_json);
865    }
866
867    #[test]
868    fn test_message_payload_signature() {
869        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
870        let peer = Peer::new(
871            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
872            None,
873        );
874        let payload = Payload::Peer(peer);
875        let test_message = Message::Order(MessageKind::new(
876            Some(uuid),
877            Some(1),
878            Some(2),
879            Action::FiatSentOk,
880            Some(payload),
881        ));
882        assert!(test_message.verify());
883        let test_message_json = test_message.as_json().unwrap();
884        // Message should be signed with the trade keys
885        let trade_keys =
886            Keys::parse("110e43647eae221ab1da33ddc17fd6ff423f2b2f49d809b9ffa40794a2ab996c")
887                .unwrap();
888        let sig = Message::sign(test_message_json.clone(), &trade_keys);
889
890        assert!(Message::verify_signature(
891            test_message_json,
892            trade_keys.public_key(),
893            sig
894        ));
895    }
896
897    #[test]
898    fn test_cant_do_message_serialization() {
899        // Test all CantDoReason variants
900        let reasons = vec![
901            CantDoReason::InvalidSignature,
902            CantDoReason::InvalidTradeIndex,
903            CantDoReason::InvalidAmount,
904            CantDoReason::InvalidInvoice,
905            CantDoReason::InvalidPaymentRequest,
906            CantDoReason::InvalidPeer,
907            CantDoReason::InvalidRating,
908            CantDoReason::InvalidTextMessage,
909            CantDoReason::InvalidOrderStatus,
910            CantDoReason::InvalidPubkey,
911            CantDoReason::InvalidParameters,
912            CantDoReason::OrderAlreadyCanceled,
913            CantDoReason::CantCreateUser,
914            CantDoReason::IsNotYourOrder,
915            CantDoReason::NotAllowedByStatus,
916            CantDoReason::OutOfRangeFiatAmount,
917            CantDoReason::OutOfRangeSatsAmount,
918            CantDoReason::IsNotYourDispute,
919            CantDoReason::NotFound,
920            CantDoReason::InvalidFiatCurrency,
921            CantDoReason::TooManyRequests,
922        ];
923
924        for reason in reasons {
925            let cant_do = Message::CantDo(MessageKind::new(
926                None,
927                None,
928                None,
929                Action::CantDo,
930                Some(Payload::CantDo(Some(reason.clone()))),
931            ));
932            let message = Message::from_json(&cant_do.as_json().unwrap()).unwrap();
933            assert!(message.verify());
934            assert_eq!(message.as_json().unwrap(), cant_do.as_json().unwrap());
935        }
936
937        // Test None case
938        let cant_do = Message::CantDo(MessageKind::new(
939            None,
940            None,
941            None,
942            Action::CantDo,
943            Some(Payload::CantDo(None)),
944        ));
945        let message = Message::from_json(&cant_do.as_json().unwrap()).unwrap();
946        assert!(message.verify());
947        assert_eq!(message.as_json().unwrap(), cant_do.as_json().unwrap());
948    }
949
950    // === check_fiat_amount tests ===
951
952    #[test]
953    fn test_check_fiat_amount_valid() {
954        // id, kind, status, amount, fiat_code, min_amount, max_amount, fiat_amount, payment_method, premium, buyer_pubkey, seller_pubkey, buyer_invoice, created_at, expires_at
955        let order = SmallOrder::new(
956            None,
957            None,
958            None,
959            100,
960            "VES".to_string(),
961            None,
962            None,
963            500,
964            "Bank".to_string(),
965            1,
966            None,
967            None,
968            None,
969            None,
970            None,
971        );
972        assert!(order.check_fiat_amount().is_ok());
973    }
974
975    #[test]
976    fn test_check_fiat_amount_zero() {
977        let order = SmallOrder::new(
978            None,
979            None,
980            None,
981            100,
982            "VES".to_string(),
983            None,
984            None,
985            0,
986            "Bank".to_string(),
987            1,
988            None,
989            None,
990            None,
991            None,
992            None,
993        );
994        let result = order.check_fiat_amount();
995        assert!(result.is_err());
996        assert_eq!(result.unwrap_err(), CantDoReason::InvalidAmount);
997    }
998
999    #[test]
1000    fn test_check_fiat_amount_negative() {
1001        let order = SmallOrder::new(
1002            None,
1003            None,
1004            None,
1005            100,
1006            "VES".to_string(),
1007            None,
1008            None,
1009            -100,
1010            "Bank".to_string(),
1011            1,
1012            None,
1013            None,
1014            None,
1015            None,
1016            None,
1017        );
1018        let result = order.check_fiat_amount();
1019        assert!(result.is_err());
1020        assert_eq!(result.unwrap_err(), CantDoReason::InvalidAmount);
1021    }
1022
1023    // === check_amount tests ===
1024
1025    #[test]
1026    fn test_check_amount_valid() {
1027        // amount = 100000 (positive, valid sats)
1028        let order = SmallOrder::new(
1029            None,
1030            None,
1031            None,
1032            100000,
1033            "VES".to_string(),
1034            None,
1035            None,
1036            500,
1037            "Bank".to_string(),
1038            0,
1039            None,
1040            None,
1041            None,
1042            None,
1043            None,
1044        );
1045        assert!(order.check_amount().is_ok());
1046    }
1047
1048    #[test]
1049    fn test_check_amount_zero() {
1050        // amount = 0 is valid (seller sets exact sats amount)
1051        let order = SmallOrder::new(
1052            None,
1053            None,
1054            None,
1055            0,
1056            "VES".to_string(),
1057            None,
1058            None,
1059            500,
1060            "Bank".to_string(),
1061            0,
1062            None,
1063            None,
1064            None,
1065            None,
1066            None,
1067        );
1068        assert!(order.check_amount().is_ok());
1069    }
1070
1071    #[test]
1072    fn test_check_amount_negative() {
1073        // amount = -1000 (negative, invalid)
1074        let order = SmallOrder::new(
1075            None,
1076            None,
1077            None,
1078            -1000,
1079            "VES".to_string(),
1080            None,
1081            None,
1082            500,
1083            "Bank".to_string(),
1084            0,
1085            None,
1086            None,
1087            None,
1088            None,
1089            None,
1090        );
1091        let result = order.check_amount();
1092        assert!(result.is_err());
1093        assert_eq!(result.unwrap_err(), CantDoReason::InvalidAmount);
1094    }
1095}