Skip to main content

mostro_core/
order.rs

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