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