Skip to main content

mostro_core/
order.rs

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