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