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