Skip to main content

mostro_core/
message.rs

1//! Protocol message envelope exchanged between clients and a Mostro node.
2//!
3//! The top-level type is [`Message`], a tagged union that carries a
4//! [`MessageKind`] together with a discriminator (order, dispute, DM, rate,
5//! can't-do, restore). [`MessageKind`] holds the shared fields present on
6//! every request/response: protocol version, optional identifier, trade
7//! index, [`Action`] and [`Payload`].
8//!
9//! In transit, messages are serialized to JSON, optionally signed with the
10//! sender's trade keys using [`Message::sign`], and wrapped in a NIP-59
11//! envelope by [`crate::nip59::wrap_message`].
12
13use crate::prelude::*;
14use bitcoin::hashes::sha256::Hash as Sha256Hash;
15use bitcoin::hashes::Hash;
16use bitcoin::key::Secp256k1;
17use bitcoin::secp256k1::Message as BitcoinMessage;
18use nostr_sdk::prelude::*;
19#[cfg(feature = "sqlx")]
20use sqlx::FromRow;
21#[cfg(feature = "sqlx")]
22use sqlx_crud::SqlxCrud;
23
24use std::fmt;
25use uuid::Uuid;
26
27/// Identity of a counterpart in a trade.
28///
29/// `Peer` bundles the counterpart's trade public key with an optional
30/// [`UserInfo`] snapshot so it can be embedded into messages that need to
31/// surface reputation (for example the peer disclosure sent with
32/// [`Action::FiatSentOk`]).
33#[derive(Debug, Deserialize, Serialize, Clone)]
34pub struct Peer {
35    /// Trade public key of the peer (hex or npub).
36    pub pubkey: String,
37    /// Optional reputation snapshot. Absent when the peer operates in full
38    /// privacy mode.
39    pub reputation: Option<UserInfo>,
40}
41
42impl Peer {
43    /// Create a new [`Peer`].
44    pub fn new(pubkey: String, reputation: Option<UserInfo>) -> Self {
45        Self { pubkey, reputation }
46    }
47
48    /// Parse a [`Peer`] from its JSON representation.
49    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
50        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
51    }
52
53    /// Serialize the peer to a JSON string.
54    pub fn as_json(&self) -> Result<String, ServiceError> {
55        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
56    }
57}
58
59/// Discriminator describing the verb of a Mostro message.
60///
61/// `Action` values are serialized in `kebab-case`. Each action has its own
62/// expected [`Payload`] shape — see [`MessageKind::verify`] for the full
63/// matrix.
64#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
65#[serde(rename_all = "kebab-case")]
66pub enum Action {
67    /// Publish a new order. Payload: [`Payload::Order`].
68    NewOrder,
69    /// Take an existing `sell` order. Payload: optional
70    /// [`Payload::PaymentRequest`] or [`Payload::Amount`].
71    TakeSell,
72    /// Take an existing `buy` order. Payload: optional [`Payload::Amount`].
73    TakeBuy,
74    /// Request the taker to pay a Lightning invoice.
75    /// Payload: [`Payload::PaymentRequest`].
76    PayInvoice,
77    /// Mostro delivers the bolt11 hold invoice that the taker must pay as
78    /// their anti-abuse bond. Same payload shape and direction as
79    /// [`Action::PayInvoice`] (Mostro → user); only the discriminator
80    /// differs so clients can tell the bond invoice apart from the trade
81    /// hold invoice that follows.
82    /// Payload: [`Payload::PaymentRequest`].
83    PayBondInvoice,
84    /// Buyer notifies Mostro that fiat was sent.
85    FiatSent,
86    /// Mostro acknowledges the fiat-sent notification to the seller.
87    FiatSentOk,
88    /// Seller releases the hold invoice funds.
89    Release,
90    /// Mostro confirms that the funds have been released.
91    Released,
92    /// Cancel an order.
93    Cancel,
94    /// Mostro confirms that the order was canceled.
95    Canceled,
96    /// Local side started a cooperative cancel.
97    CooperativeCancelInitiatedByYou,
98    /// Remote side started a cooperative cancel.
99    CooperativeCancelInitiatedByPeer,
100    /// Local side opened a dispute.
101    DisputeInitiatedByYou,
102    /// Remote side opened a dispute.
103    DisputeInitiatedByPeer,
104    /// Both sides agreed on the cooperative cancel.
105    CooperativeCancelAccepted,
106    /// Mostro accepted the buyer's payout invoice.
107    BuyerInvoiceAccepted,
108    /// Trade completed successfully.
109    PurchaseCompleted,
110    /// Mostro saw the hold-invoice payment accepted by the node.
111    HoldInvoicePaymentAccepted,
112    /// Mostro saw the hold-invoice payment settled.
113    HoldInvoicePaymentSettled,
114    /// Mostro saw the hold-invoice payment canceled.
115    HoldInvoicePaymentCanceled,
116    /// Informational: waiting for the seller to pay the hold invoice.
117    WaitingSellerToPay,
118    /// Informational: waiting for the buyer's payout invoice.
119    WaitingBuyerInvoice,
120    /// Buyer sends/updates its payout invoice.
121    /// Payload: [`Payload::PaymentRequest`].
122    AddInvoice,
123    /// Taker sends a Lightning invoice that Mostro must pay out as the
124    /// taker's anti-abuse bond. Same payload shape and direction as
125    /// [`Action::AddInvoice`] (user → Mostro); only the discriminator
126    /// differs so Mostro can tell a bond-payout invoice apart from a
127    /// buyer's trade payout invoice.
128    /// Payload: [`Payload::PaymentRequest`].
129    AddBondInvoice,
130    /// Informational: a buyer has taken a sell order.
131    BuyerTookOrder,
132    /// Server-initiated rating request.
133    Rate,
134    /// Client-initiated rate. Payload: [`Payload::RatingUser`].
135    RateUser,
136    /// Acknowledgement of a received rating.
137    RateReceived,
138    /// Mostro returns a structured refusal. Payload: [`Payload::CantDo`].
139    CantDo,
140    /// Client-initiated dispute.
141    Dispute,
142    /// Admin cancels a trade.
143    AdminCancel,
144    /// Admin cancel acknowledged.
145    AdminCanceled,
146    /// Admin settles the hold invoice.
147    AdminSettle,
148    /// Admin settle acknowledged.
149    AdminSettled,
150    /// Admin registers a new dispute solver.
151    AdminAddSolver,
152    /// Solver takes a dispute.
153    AdminTakeDispute,
154    /// Solver took the dispute acknowledged.
155    AdminTookDispute,
156    /// Notification that a Lightning payment failed.
157    /// Payload: [`Payload::PaymentFailed`].
158    PaymentFailed,
159    /// Invoice associated with the order was updated.
160    InvoiceUpdated,
161    /// Direct message between users. Payload: [`Payload::TextMessage`].
162    SendDm,
163    /// Disclosure of a counterpart's trade pubkey. Payload: [`Payload::Peer`].
164    TradePubkey,
165    /// Client asks Mostro to restore its session state. Payload must be `None`.
166    RestoreSession,
167    /// Client asks Mostro for its last known trade index. Payload must be
168    /// `None`.
169    LastTradeIndex,
170    /// Listing of orders in response to a query.
171    /// Payload: [`Payload::Ids`] or [`Payload::Orders`].
172    Orders,
173}
174
175impl fmt::Display for Action {
176    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
177        write!(f, "{self:?}")
178    }
179}
180
181/// Top-level Mostro message exchanged between users and Mostro.
182///
183/// `Message` is a tagged union: every variant carries the shared
184/// [`MessageKind`] body, while the variant itself tells the receiver which
185/// channel the message belongs to (orders, disputes, DMs, rating, can't-do,
186/// session restore). Serializes as `kebab-case` JSON.
187#[derive(Debug, Clone, Deserialize, Serialize)]
188#[serde(rename_all = "kebab-case")]
189pub enum Message {
190    /// Order-channel message.
191    Order(MessageKind),
192    /// Dispute-channel message.
193    Dispute(MessageKind),
194    /// "Can't do" response returned by the Mostro node.
195    CantDo(MessageKind),
196    /// Rating message (server-initiated rate request or client rate).
197    Rate(MessageKind),
198    /// Direct message between users.
199    Dm(MessageKind),
200    /// Session restore request/response.
201    Restore(MessageKind),
202}
203
204impl Message {
205    /// Build a new `Message::Order` wrapping a freshly constructed
206    /// [`MessageKind`].
207    pub fn new_order(
208        id: Option<Uuid>,
209        request_id: Option<u64>,
210        trade_index: Option<i64>,
211        action: Action,
212        payload: Option<Payload>,
213    ) -> Self {
214        let kind = MessageKind::new(id, request_id, trade_index, action, payload);
215        Self::Order(kind)
216    }
217
218    /// Build a new `Message::Dispute` wrapping a freshly constructed
219    /// [`MessageKind`].
220    pub fn new_dispute(
221        id: Option<Uuid>,
222        request_id: Option<u64>,
223        trade_index: Option<i64>,
224        action: Action,
225        payload: Option<Payload>,
226    ) -> Self {
227        let kind = MessageKind::new(id, request_id, trade_index, action, payload);
228
229        Self::Dispute(kind)
230    }
231
232    /// Build a new `Message::Restore` with [`Action::RestoreSession`].
233    ///
234    /// According to [`MessageKind::verify`], the payload for a restore
235    /// request must be `None`. Any other payload yields an invalid message.
236    pub fn new_restore(payload: Option<Payload>) -> Self {
237        let kind = MessageKind::new(None, None, None, Action::RestoreSession, payload);
238        Self::Restore(kind)
239    }
240
241    /// Build a new `Message::CantDo` message (a structured refusal sent by
242    /// Mostro when a request cannot be fulfilled).
243    pub fn cant_do(id: Option<Uuid>, request_id: Option<u64>, payload: Option<Payload>) -> Self {
244        let kind = MessageKind::new(id, request_id, None, Action::CantDo, payload);
245
246        Self::CantDo(kind)
247    }
248
249    /// Build a new `Message::Dm` carrying a direct message between users.
250    pub fn new_dm(
251        id: Option<Uuid>,
252        request_id: Option<u64>,
253        action: Action,
254        payload: Option<Payload>,
255    ) -> Self {
256        let kind = MessageKind::new(id, request_id, None, action, payload);
257
258        Self::Dm(kind)
259    }
260
261    /// Parse a [`Message`] from its JSON representation.
262    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
263        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
264    }
265
266    /// Serialize the message to a JSON string.
267    pub fn as_json(&self) -> Result<String, ServiceError> {
268        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
269    }
270
271    /// Borrow the inner [`MessageKind`] regardless of the variant.
272    pub fn get_inner_message_kind(&self) -> &MessageKind {
273        match self {
274            Message::Dispute(k)
275            | Message::Order(k)
276            | Message::CantDo(k)
277            | Message::Rate(k)
278            | Message::Dm(k)
279            | Message::Restore(k) => k,
280        }
281    }
282
283    /// Return the [`Action`] of the inner [`MessageKind`].
284    ///
285    /// Always returns `Some` for the current variant set; the `Option` is
286    /// kept for API stability.
287    pub fn inner_action(&self) -> Option<Action> {
288        match self {
289            Message::Dispute(a)
290            | Message::Order(a)
291            | Message::CantDo(a)
292            | Message::Rate(a)
293            | Message::Dm(a)
294            | Message::Restore(a) => Some(a.get_action()),
295        }
296    }
297
298    /// Validate that the inner [`MessageKind`] is consistent with its
299    /// [`Action`]. Delegates to [`MessageKind::verify`].
300    pub fn verify(&self) -> bool {
301        match self {
302            Message::Order(m)
303            | Message::Dispute(m)
304            | Message::CantDo(m)
305            | Message::Rate(m)
306            | Message::Dm(m)
307            | Message::Restore(m) => m.verify(),
308        }
309    }
310
311    /// Produce a Schnorr signature over the SHA-256 digest of `message`
312    /// using `keys`.
313    ///
314    /// This is the signature embedded in the rumor tuple when
315    /// [`crate::nip59::wrap_message`] is called with
316    /// [`WrapOptions::signed`](crate::nip59::WrapOptions::signed) set to
317    /// `true`. It binds a message to the sender's trade keys without
318    /// relying on the outer Nostr event signature.
319    pub fn sign(message: String, keys: &Keys) -> Signature {
320        let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes());
321        let hash = hash.to_byte_array();
322        let message: BitcoinMessage = BitcoinMessage::from_digest(hash);
323
324        keys.sign_schnorr(&message)
325    }
326
327    /// Verify a signature previously produced by [`Message::sign`].
328    ///
329    /// Returns `true` when `sig` is a valid Schnorr signature of the
330    /// SHA-256 digest of `message` under `pubkey`, `false` otherwise
331    /// (including when `pubkey` has no x-only representation).
332    pub fn verify_signature(message: String, pubkey: PublicKey, sig: Signature) -> bool {
333        // Create payload hash
334        let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes());
335        let hash = hash.to_byte_array();
336        let message: BitcoinMessage = BitcoinMessage::from_digest(hash);
337
338        // Create a verification-only context for better performance
339        let secp = Secp256k1::verification_only();
340        // Verify signature
341        if let Ok(xonlykey) = pubkey.xonly() {
342            xonlykey.verify(&secp, &message, &sig).is_ok()
343        } else {
344            false
345        }
346    }
347}
348
349/// Body shared by every [`Message`] variant.
350///
351/// All Mostro protocol messages share this envelope: a protocol version,
352/// an optional client-chosen request id for correlation, a trade index used
353/// to enforce strictly increasing sequences per user, an optional
354/// order/dispute id, an [`Action`] and an optional [`Payload`].
355#[derive(Debug, Clone, Deserialize, Serialize)]
356pub struct MessageKind {
357    /// Mostro protocol version. Set to
358    /// `PROTOCOL_VER` by [`MessageKind::new`].
359    pub version: u8,
360    /// Client-chosen correlation id, echoed back on responses so the client
361    /// can match them to in-flight requests.
362    pub request_id: Option<u64>,
363    /// Trade index attached to this message. Must be strictly greater than
364    /// the last trade index Mostro has seen for the sender.
365    pub trade_index: Option<i64>,
366    /// Optional target identifier (usually the id of an [`crate::order::Order`]
367    /// or [`crate::dispute::Dispute`]).
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub id: Option<Uuid>,
370    /// Verb of the message.
371    pub action: Action,
372    /// Payload attached to the action. The allowed shape for a given action
373    /// is enforced by [`MessageKind::verify`].
374    pub payload: Option<Payload>,
375}
376
377/// Alias for a signed integer amount in satoshis.
378type Amount = i64;
379
380/// Retry configuration for a failed Lightning payment.
381///
382/// Sent inside a [`Payload::PaymentFailed`] so the client knows how many
383/// retries to expect and how long to wait between them.
384#[derive(Debug, Deserialize, Serialize, Clone)]
385pub struct PaymentFailedInfo {
386    /// Maximum number of payment attempts Mostro will perform.
387    pub payment_attempts: u32,
388    /// Delay in seconds between two retry attempts.
389    pub payment_retries_interval: u32,
390}
391
392/// Row-mapper used by `mostrod` when fetching metadata for session restore.
393///
394/// Not intended as a general-purpose order representation — field names are
395/// chosen to match the SQL `SELECT` aliases used by the server query.
396#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
397#[derive(Debug, Deserialize, Serialize, Clone)]
398pub struct RestoredOrderHelper {
399    /// Order id.
400    pub id: Uuid,
401    /// Order status, serialized as kebab-case.
402    pub status: String,
403    /// Master identity pubkey of the buyer, if any.
404    pub master_buyer_pubkey: Option<String>,
405    /// Master identity pubkey of the seller, if any.
406    pub master_seller_pubkey: Option<String>,
407    /// Trade index the buyer used on this order.
408    pub trade_index_buyer: Option<i64>,
409    /// Trade index the seller used on this order.
410    pub trade_index_seller: Option<i64>,
411}
412
413/// Row-mapper used by `mostrod` when fetching disputes for session restore.
414///
415/// Field names are chosen to match the SQL `SELECT` aliases in the restore
416/// query (in particular `status` is aliased as `dispute_status`).
417#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
418#[derive(Debug, Deserialize, Serialize, Clone)]
419pub struct RestoredDisputeHelper {
420    /// Dispute id.
421    pub dispute_id: Uuid,
422    /// Order id the dispute is attached to.
423    pub order_id: Uuid,
424    /// Dispute status, serialized as kebab-case.
425    pub dispute_status: String,
426    /// Master identity pubkey of the buyer, if any.
427    pub master_buyer_pubkey: Option<String>,
428    /// Master identity pubkey of the seller, if any.
429    pub master_seller_pubkey: Option<String>,
430    /// Trade index the buyer used on the parent order.
431    pub trade_index_buyer: Option<i64>,
432    /// Trade index the seller used on the parent order.
433    pub trade_index_seller: Option<i64>,
434    /// Whether the buyer has initiated a dispute for this order.
435    /// Combined with [`Self::seller_dispute`] to derive
436    /// [`RestoredDisputesInfo::initiator`].
437    pub buyer_dispute: bool,
438    /// Whether the seller has initiated a dispute for this order.
439    /// Combined with [`Self::buyer_dispute`] to derive
440    /// [`RestoredDisputesInfo::initiator`].
441    pub seller_dispute: bool,
442    /// Public key of the solver assigned to the dispute, `None` if no
443    /// solver has taken it.
444    pub solver_pubkey: Option<String>,
445}
446
447/// Minimal per-order information returned to a client on session restore.
448#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
449#[derive(Debug, Deserialize, Serialize, Clone)]
450pub struct RestoredOrdersInfo {
451    /// Id of the order.
452    pub order_id: Uuid,
453    /// Trade index of the order as seen by the requesting user.
454    pub trade_index: i64,
455    /// Current status of the order, serialized as kebab-case.
456    pub status: String,
457}
458
459/// Identifies which party of an order opened a dispute.
460#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
461#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
462#[serde(rename_all = "lowercase")]
463#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT", rename_all = "lowercase"))]
464pub enum DisputeInitiator {
465    /// The buyer opened the dispute.
466    Buyer,
467    /// The seller opened the dispute.
468    Seller,
469}
470
471/// Minimal per-dispute information returned to a client on session restore.
472#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
473#[derive(Debug, Deserialize, Serialize, Clone)]
474pub struct RestoredDisputesInfo {
475    /// Id of the dispute.
476    pub dispute_id: Uuid,
477    /// Id of the order the dispute is attached to.
478    pub order_id: Uuid,
479    /// Trade index of the dispute as seen by the requesting user.
480    pub trade_index: i64,
481    /// Current status of the dispute, serialized as kebab-case.
482    pub status: String,
483    /// Who initiated the dispute: [`DisputeInitiator::Buyer`],
484    /// [`DisputeInitiator::Seller`], or `None` when unknown.
485    pub initiator: Option<DisputeInitiator>,
486    /// Public key of the solver assigned to the dispute, `None` if no
487    /// solver has taken it yet.
488    pub solver_pubkey: Option<String>,
489}
490
491/// Bundle of orders and disputes returned on a session restore.
492///
493/// Carried inside [`Payload::RestoreData`]. The server typically sends this
494/// struct in the response to a [`Action::RestoreSession`] request.
495#[derive(Debug, Deserialize, Serialize, Clone, Default)]
496pub struct RestoreSessionInfo {
497    /// Orders associated with the requesting user.
498    #[serde(rename = "orders")]
499    pub restore_orders: Vec<RestoredOrdersInfo>,
500    /// Disputes associated with the requesting user.
501    #[serde(rename = "disputes")]
502    pub restore_disputes: Vec<RestoredDisputesInfo>,
503}
504
505/// Bond resolution carried by [`Action::AdminSettle`] /
506/// [`Action::AdminCancel`].
507///
508/// Lets the solver express slash decisions independently from the trade
509/// outcome (settle vs cancel). Absent payload (`null`) ⇒ neither bond is
510/// slashed (release-by-default, coherent with the "when in doubt, release"
511/// invariant).
512#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Default)]
513pub struct BondResolution {
514    /// Slash the seller's bond (if posted).
515    pub slash_seller: bool,
516    /// Slash the buyer's bond (if posted).
517    pub slash_buyer: bool,
518}
519
520/// Typed payload attached to a [`MessageKind`].
521///
522/// Each variant corresponds to a set of [`Action`] values that can legally
523/// carry it (see [`MessageKind::verify`]). Serialized in `snake_case` so
524/// that the variant name is the JSON discriminator.
525#[derive(Debug, Deserialize, Serialize, Clone)]
526#[serde(rename_all = "snake_case")]
527pub enum Payload {
528    /// A compact representation of an order used by [`Action::NewOrder`].
529    Order(SmallOrder),
530    /// Lightning payment request plus optional amount override.
531    ///
532    /// Used by [`Action::PayInvoice`], [`Action::PayBondInvoice`],
533    /// [`Action::AddInvoice`], [`Action::AddBondInvoice`] and
534    /// [`Action::TakeSell`]. The [`SmallOrder`]
535    /// carries the matching order when relevant; the `String` is a BOLT-11
536    /// invoice.
537    PaymentRequest(Option<SmallOrder>, String, Option<Amount>),
538    /// Free-form text message used by DMs.
539    TextMessage(String),
540    /// Peer disclosure (trade pubkey and optional reputation).
541    Peer(Peer),
542    /// Rating value the user wants to attach to a completed trade.
543    RatingUser(u8),
544    /// Raw amount in satoshis (for actions that accept an amount override).
545    Amount(Amount),
546    /// Dispute context: the dispute id plus optional
547    /// [`SolverDisputeInfo`] bundle sent to solvers.
548    Dispute(Uuid, Option<SolverDisputeInfo>),
549    /// Reason carried by a [`Action::CantDo`] response.
550    CantDo(Option<CantDoReason>),
551    /// Next trade key and index announced by the maker of a range order
552    /// when it emits [`Action::Release`] or [`Action::FiatSent`].
553    NextTrade(String, u32),
554    /// Retry configuration surfaced by [`Action::PaymentFailed`].
555    PaymentFailed(PaymentFailedInfo),
556    /// Payload returned by the server on a session restore.
557    RestoreData(RestoreSessionInfo),
558    /// Vector of order ids (lightweight listing).
559    Ids(Vec<Uuid>),
560    /// Vector of [`SmallOrder`] values (full listing).
561    Orders(Vec<SmallOrder>),
562    /// Slash decisions carried by [`Action::AdminSettle`] /
563    /// [`Action::AdminCancel`]. See [`BondResolution`].
564    BondResolution(BondResolution),
565}
566
567#[allow(dead_code)]
568impl MessageKind {
569    /// Build a new [`MessageKind`] stamped with the current protocol
570    /// version (`PROTOCOL_VER`).
571    pub fn new(
572        id: Option<Uuid>,
573        request_id: Option<u64>,
574        trade_index: Option<i64>,
575        action: Action,
576        payload: Option<Payload>,
577    ) -> Self {
578        Self {
579            version: PROTOCOL_VER,
580            request_id,
581            trade_index,
582            id,
583            action,
584            payload,
585        }
586    }
587    /// Parse a [`MessageKind`] from its JSON representation.
588    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
589        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
590    }
591    /// Serialize the [`MessageKind`] to a JSON string.
592    pub fn as_json(&self) -> Result<String, ServiceError> {
593        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
594    }
595
596    /// Return a clone of the [`Action`] carried by this message.
597    pub fn get_action(&self) -> Action {
598        self.action.clone()
599    }
600
601    /// Extract the `(next_trade_pubkey, next_trade_index)` pair from a
602    /// [`Payload::NextTrade`] payload.
603    ///
604    /// Returns `Ok(None)` when there is no payload at all and
605    /// [`ServiceError::InvalidPayload`] when the payload is present but of
606    /// a different variant.
607    pub fn get_next_trade_key(&self) -> Result<Option<(String, u32)>, ServiceError> {
608        match &self.payload {
609            Some(Payload::NextTrade(key, index)) => Ok(Some((key.to_string(), *index))),
610            None => Ok(None),
611            _ => Err(ServiceError::InvalidPayload),
612        }
613    }
614
615    /// Extract the rating value from a [`Payload::RatingUser`] payload,
616    /// validating it against
617    /// [`MIN_RATING`]`..=`[`MAX_RATING`].
618    ///
619    /// Returns [`ServiceError::InvalidRating`] when the payload shape is
620    /// wrong and [`ServiceError::InvalidRatingValue`] when the value is out
621    /// of range.
622    pub fn get_rating(&self) -> Result<u8, ServiceError> {
623        if let Some(Payload::RatingUser(v)) = self.payload.to_owned() {
624            if !(MIN_RATING..=MAX_RATING).contains(&v) {
625                return Err(ServiceError::InvalidRatingValue);
626            }
627            Ok(v)
628        } else {
629            Err(ServiceError::InvalidRating)
630        }
631    }
632
633    /// Check that the payload, id and trade index are consistent with the
634    /// action carried by this message.
635    ///
636    /// Returns `true` when the combination is well-formed and `false`
637    /// otherwise; Mostro uses this method to reject malformed requests
638    /// before processing them.
639    pub fn verify(&self) -> bool {
640        match &self.action {
641            Action::NewOrder => matches!(&self.payload, Some(Payload::Order(_))),
642            Action::PayInvoice
643            | Action::PayBondInvoice
644            | Action::AddInvoice
645            | Action::AddBondInvoice => {
646                if self.id.is_none() {
647                    return false;
648                }
649                matches!(&self.payload, Some(Payload::PaymentRequest(_, _, _)))
650            }
651            Action::AdminSettle | Action::AdminCancel => {
652                if self.id.is_none() {
653                    return false;
654                }
655                matches!(&self.payload, None | Some(Payload::BondResolution(_)))
656            }
657            Action::TakeSell
658            | Action::TakeBuy
659            | Action::FiatSent
660            | Action::FiatSentOk
661            | Action::Release
662            | Action::Released
663            | Action::Dispute
664            | Action::AdminCanceled
665            | Action::AdminSettled
666            | Action::Rate
667            | Action::RateReceived
668            | Action::AdminTakeDispute
669            | Action::AdminTookDispute
670            | Action::DisputeInitiatedByYou
671            | Action::DisputeInitiatedByPeer
672            | Action::WaitingBuyerInvoice
673            | Action::PurchaseCompleted
674            | Action::HoldInvoicePaymentAccepted
675            | Action::HoldInvoicePaymentSettled
676            | Action::HoldInvoicePaymentCanceled
677            | Action::WaitingSellerToPay
678            | Action::BuyerTookOrder
679            | Action::BuyerInvoiceAccepted
680            | Action::CooperativeCancelInitiatedByYou
681            | Action::CooperativeCancelInitiatedByPeer
682            | Action::CooperativeCancelAccepted
683            | Action::Cancel
684            | Action::InvoiceUpdated
685            | Action::AdminAddSolver
686            | Action::SendDm
687            | Action::TradePubkey
688            | Action::Canceled => {
689                if self.id.is_none() {
690                    return false;
691                }
692                !matches!(&self.payload, Some(Payload::BondResolution(_)))
693            }
694            Action::LastTradeIndex | Action::RestoreSession => self.payload.is_none(),
695            Action::PaymentFailed => {
696                if self.id.is_none() {
697                    return false;
698                }
699                matches!(&self.payload, Some(Payload::PaymentFailed(_)))
700            }
701            Action::RateUser => {
702                matches!(&self.payload, Some(Payload::RatingUser(_)))
703            }
704            Action::CantDo => {
705                matches!(&self.payload, Some(Payload::CantDo(_)))
706            }
707            Action::Orders => {
708                matches!(
709                    &self.payload,
710                    Some(Payload::Ids(_)) | Some(Payload::Orders(_))
711                )
712            }
713        }
714    }
715
716    /// Return the [`SmallOrder`] carried by a [`Action::NewOrder`] message.
717    ///
718    /// Yields `None` if the action is not `NewOrder` or the payload is of a
719    /// different variant.
720    pub fn get_order(&self) -> Option<&SmallOrder> {
721        if self.action != Action::NewOrder {
722            return None;
723        }
724        match &self.payload {
725            Some(Payload::Order(o)) => Some(o),
726            _ => None,
727        }
728    }
729
730    /// Return the Lightning payment request embedded in a message.
731    ///
732    /// Valid only for [`Action::TakeSell`], [`Action::AddInvoice`],
733    /// [`Action::AddBondInvoice`] and [`Action::NewOrder`]. For `NewOrder`,
734    /// the invoice is read from the [`SmallOrder::buyer_invoice`] field.
735    /// Returns `None` otherwise.
736    pub fn get_payment_request(&self) -> Option<String> {
737        if self.action != Action::TakeSell
738            && self.action != Action::AddInvoice
739            && self.action != Action::AddBondInvoice
740            && self.action != Action::NewOrder
741        {
742            return None;
743        }
744        match &self.payload {
745            Some(Payload::PaymentRequest(_, pr, _)) => Some(pr.to_owned()),
746            Some(Payload::Order(ord)) => ord.buyer_invoice.to_owned(),
747            _ => None,
748        }
749    }
750
751    /// Return the amount override embedded in a [`Action::TakeSell`] or
752    /// [`Action::TakeBuy`] message, either from a [`Payload::Amount`] or
753    /// from the third element of a [`Payload::PaymentRequest`].
754    pub fn get_amount(&self) -> Option<Amount> {
755        if self.action != Action::TakeSell && self.action != Action::TakeBuy {
756            return None;
757        }
758        match &self.payload {
759            Some(Payload::PaymentRequest(_, _, amount)) => *amount,
760            Some(Payload::Amount(amount)) => Some(*amount),
761            _ => None,
762        }
763    }
764
765    /// Borrow the optional payload.
766    pub fn get_payload(&self) -> Option<&Payload> {
767        self.payload.as_ref()
768    }
769
770    /// Return `(true, index)` when the message carries a trade index,
771    /// `(false, 0)` otherwise.
772    pub fn has_trade_index(&self) -> (bool, i64) {
773        if let Some(index) = self.trade_index {
774            return (true, index);
775        }
776        (false, 0)
777    }
778
779    /// Return the trade index carried by the message, or `0` when absent.
780    pub fn trade_index(&self) -> i64 {
781        if let Some(index) = self.trade_index {
782            return index;
783        }
784        0
785    }
786}
787
788#[cfg(test)]
789mod test {
790    use crate::message::{Action, Message, MessageKind, Payload, Peer};
791    use crate::user::UserInfo;
792    use nostr_sdk::Keys;
793    use uuid::uuid;
794
795    #[test]
796    fn test_peer_with_reputation() {
797        // Test creating a Peer with reputation information
798        let reputation = UserInfo {
799            rating: 4.5,
800            reviews: 10,
801            operating_days: 30,
802        };
803        let peer = Peer::new(
804            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
805            Some(reputation.clone()),
806        );
807
808        // Assert the fields are set correctly
809        assert_eq!(
810            peer.pubkey,
811            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
812        );
813        assert!(peer.reputation.is_some());
814        let peer_reputation = peer.reputation.clone().unwrap();
815        assert_eq!(peer_reputation.rating, 4.5);
816        assert_eq!(peer_reputation.reviews, 10);
817        assert_eq!(peer_reputation.operating_days, 30);
818
819        // Test JSON serialization and deserialization
820        let json = peer.as_json().unwrap();
821        let deserialized_peer = Peer::from_json(&json).unwrap();
822        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
823        assert!(deserialized_peer.reputation.is_some());
824        let deserialized_reputation = deserialized_peer.reputation.unwrap();
825        assert_eq!(deserialized_reputation.rating, 4.5);
826        assert_eq!(deserialized_reputation.reviews, 10);
827        assert_eq!(deserialized_reputation.operating_days, 30);
828    }
829
830    #[test]
831    fn test_peer_without_reputation() {
832        // Test creating a Peer without reputation information
833        let peer = Peer::new(
834            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
835            None,
836        );
837
838        // Assert the reputation field is None
839        assert_eq!(
840            peer.pubkey,
841            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
842        );
843        assert!(peer.reputation.is_none());
844
845        // Test JSON serialization and deserialization
846        let json = peer.as_json().unwrap();
847        let deserialized_peer = Peer::from_json(&json).unwrap();
848        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
849        assert!(deserialized_peer.reputation.is_none());
850    }
851
852    #[test]
853    fn test_peer_in_message() {
854        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
855
856        // Test with reputation
857        let reputation = UserInfo {
858            rating: 4.5,
859            reviews: 10,
860            operating_days: 30,
861        };
862        let peer_with_reputation = Peer::new(
863            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
864            Some(reputation),
865        );
866        let payload_with_reputation = Payload::Peer(peer_with_reputation);
867        let message_with_reputation = Message::Order(MessageKind::new(
868            Some(uuid),
869            Some(1),
870            Some(2),
871            Action::FiatSentOk,
872            Some(payload_with_reputation),
873        ));
874
875        // Verify message with reputation
876        assert!(message_with_reputation.verify());
877        let message_json = message_with_reputation.as_json().unwrap();
878        let deserialized_message = Message::from_json(&message_json).unwrap();
879        assert!(deserialized_message.verify());
880
881        // Test without reputation
882        let peer_without_reputation = Peer::new(
883            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
884            None,
885        );
886        let payload_without_reputation = Payload::Peer(peer_without_reputation);
887        let message_without_reputation = Message::Order(MessageKind::new(
888            Some(uuid),
889            Some(1),
890            Some(2),
891            Action::FiatSentOk,
892            Some(payload_without_reputation),
893        ));
894
895        // Verify message without reputation
896        assert!(message_without_reputation.verify());
897        let message_json = message_without_reputation.as_json().unwrap();
898        let deserialized_message = Message::from_json(&message_json).unwrap();
899        assert!(deserialized_message.verify());
900    }
901
902    #[test]
903    fn test_payment_failed_payload() {
904        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
905
906        // Test PaymentFailedInfo serialization and deserialization
907        let payment_failed_info = crate::message::PaymentFailedInfo {
908            payment_attempts: 3,
909            payment_retries_interval: 60,
910        };
911
912        let payload = Payload::PaymentFailed(payment_failed_info);
913        let message = Message::Order(MessageKind::new(
914            Some(uuid),
915            Some(1),
916            Some(2),
917            Action::PaymentFailed,
918            Some(payload),
919        ));
920
921        // Verify message validation
922        assert!(message.verify());
923
924        // Test JSON serialization
925        let message_json = message.as_json().unwrap();
926
927        // Test deserialization
928        let deserialized_message = Message::from_json(&message_json).unwrap();
929        assert!(deserialized_message.verify());
930
931        // Verify the payload contains correct values
932        if let Message::Order(kind) = deserialized_message {
933            if let Some(Payload::PaymentFailed(info)) = kind.payload {
934                assert_eq!(info.payment_attempts, 3);
935                assert_eq!(info.payment_retries_interval, 60);
936            } else {
937                panic!("Expected PaymentFailed payload");
938            }
939        } else {
940            panic!("Expected Order message");
941        }
942    }
943
944    #[test]
945    fn test_message_payload_signature() {
946        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
947        let peer = Peer::new(
948            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
949            None, // Add None for the reputation parameter
950        );
951        let payload = Payload::Peer(peer);
952        let test_message = Message::Order(MessageKind::new(
953            Some(uuid),
954            Some(1),
955            Some(2),
956            Action::FiatSentOk,
957            Some(payload),
958        ));
959        assert!(test_message.verify());
960        let test_message_json = test_message.as_json().unwrap();
961        // Message should be signed with the trade keys
962        let trade_keys =
963            Keys::parse("110e43647eae221ab1da33ddc17fd6ff423f2b2f49d809b9ffa40794a2ab996c")
964                .unwrap();
965        let sig = Message::sign(test_message_json.clone(), &trade_keys);
966
967        assert!(Message::verify_signature(
968            test_message_json,
969            trade_keys.public_key(),
970            sig
971        ));
972    }
973
974    #[test]
975    fn test_restore_session_message() {
976        // Test RestoreSession request (payload = None)
977        let restore_request_message = Message::Restore(MessageKind::new(
978            None,
979            None,
980            None,
981            Action::RestoreSession,
982            None,
983        ));
984
985        // Verify message validation
986        assert!(restore_request_message.verify());
987        assert_eq!(
988            restore_request_message.inner_action(),
989            Some(Action::RestoreSession)
990        );
991
992        // Test JSON serialization and deserialization for RestoreRequest
993        let message_json = restore_request_message.as_json().unwrap();
994        let deserialized_message = Message::from_json(&message_json).unwrap();
995        assert!(deserialized_message.verify());
996        assert_eq!(
997            deserialized_message.inner_action(),
998            Some(Action::RestoreSession)
999        );
1000
1001        // Test RestoreSession with RestoreData payload
1002        let restored_orders = vec![
1003            crate::message::RestoredOrdersInfo {
1004                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1005                trade_index: 1,
1006                status: "active".to_string(),
1007            },
1008            crate::message::RestoredOrdersInfo {
1009                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1010                trade_index: 2,
1011                status: "success".to_string(),
1012            },
1013        ];
1014
1015        let restored_disputes = vec![
1016            crate::message::RestoredDisputesInfo {
1017                dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1018                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1019                trade_index: 1,
1020                status: "initiated".to_string(),
1021                initiator: Some(crate::message::DisputeInitiator::Buyer),
1022                solver_pubkey: None,
1023            },
1024            crate::message::RestoredDisputesInfo {
1025                dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1026                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1027                trade_index: 2,
1028                status: "in-progress".to_string(),
1029                initiator: None,
1030                solver_pubkey: Some(
1031                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1032                ),
1033            },
1034            crate::message::RestoredDisputesInfo {
1035                dispute_id: uuid!("708e1272-d5f4-47e6-bd97-3504baea9c27"),
1036                order_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1037                trade_index: 3,
1038                status: "initiated".to_string(),
1039                initiator: Some(crate::message::DisputeInitiator::Seller),
1040                solver_pubkey: None,
1041            },
1042        ];
1043
1044        let restore_session_info = crate::message::RestoreSessionInfo {
1045            restore_orders: restored_orders.clone(),
1046            restore_disputes: restored_disputes.clone(),
1047        };
1048
1049        let restore_data_payload = Payload::RestoreData(restore_session_info);
1050        let restore_data_message = Message::Restore(MessageKind::new(
1051            None,
1052            None,
1053            None,
1054            Action::RestoreSession,
1055            Some(restore_data_payload),
1056        ));
1057
1058        // With new logic, any payload for RestoreSession is invalid (must be None)
1059        assert!(!restore_data_message.verify());
1060
1061        // Verify serialization/deserialization of RestoreData payload with all initiator cases
1062        let message_json = restore_data_message.as_json().unwrap();
1063        let deserialized_restore_message = Message::from_json(&message_json).unwrap();
1064
1065        if let Message::Restore(kind) = deserialized_restore_message {
1066            if let Some(Payload::RestoreData(session_info)) = kind.payload {
1067                assert_eq!(session_info.restore_disputes.len(), 3);
1068                assert_eq!(
1069                    session_info.restore_disputes[0].initiator,
1070                    Some(crate::message::DisputeInitiator::Buyer)
1071                );
1072                assert!(session_info.restore_disputes[0].solver_pubkey.is_none());
1073                assert_eq!(session_info.restore_disputes[1].initiator, None);
1074                assert_eq!(
1075                    session_info.restore_disputes[1].solver_pubkey,
1076                    Some(
1077                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1078                            .to_string()
1079                    )
1080                );
1081                assert_eq!(
1082                    session_info.restore_disputes[2].initiator,
1083                    Some(crate::message::DisputeInitiator::Seller)
1084                );
1085                assert!(session_info.restore_disputes[2].solver_pubkey.is_none());
1086            } else {
1087                panic!("Expected RestoreData payload");
1088            }
1089        } else {
1090            panic!("Expected Restore message");
1091        }
1092    }
1093
1094    #[test]
1095    fn test_restore_session_message_validation() {
1096        // Test that RestoreSession action accepts only payload=None or RestoreData
1097        let restore_request_message = Message::Restore(MessageKind::new(
1098            None,
1099            None,
1100            None,
1101            Action::RestoreSession,
1102            None, // Missing payload
1103        ));
1104
1105        // Verify restore request message
1106        assert!(restore_request_message.verify());
1107
1108        // Test with wrong payload type
1109        let wrong_payload = Payload::TextMessage("wrong payload".to_string());
1110        let wrong_message = Message::Restore(MessageKind::new(
1111            None,
1112            None,
1113            None,
1114            Action::RestoreSession,
1115            Some(wrong_payload),
1116        ));
1117
1118        // Should fail validation because RestoreSession only accepts None
1119        assert!(!wrong_message.verify());
1120
1121        // With new logic, presence of id/request_id/trade_index is allowed
1122        let with_id = Message::Restore(MessageKind::new(
1123            Some(uuid!("00000000-0000-0000-0000-000000000001")),
1124            None,
1125            None,
1126            Action::RestoreSession,
1127            None,
1128        ));
1129        assert!(with_id.verify());
1130
1131        let with_request_id = Message::Restore(MessageKind::new(
1132            None,
1133            Some(42),
1134            None,
1135            Action::RestoreSession,
1136            None,
1137        ));
1138        assert!(with_request_id.verify());
1139
1140        let with_trade_index = Message::Restore(MessageKind::new(
1141            None,
1142            None,
1143            Some(7),
1144            Action::RestoreSession,
1145            None,
1146        ));
1147        assert!(with_trade_index.verify());
1148    }
1149
1150    #[test]
1151    fn test_restore_session_message_constructor() {
1152        // Test the new_restore constructor
1153        let restore_request_message = Message::new_restore(None);
1154
1155        assert!(matches!(restore_request_message, Message::Restore(_)));
1156        assert!(restore_request_message.verify());
1157        assert_eq!(
1158            restore_request_message.inner_action(),
1159            Some(Action::RestoreSession)
1160        );
1161
1162        // Test with RestoreData payload should be invalid now
1163        let restore_session_info = crate::message::RestoreSessionInfo {
1164            restore_orders: vec![],
1165            restore_disputes: vec![],
1166        };
1167        let restore_data_message =
1168            Message::new_restore(Some(Payload::RestoreData(restore_session_info)));
1169
1170        assert!(matches!(restore_data_message, Message::Restore(_)));
1171        assert!(!restore_data_message.verify());
1172    }
1173
1174    #[test]
1175    fn test_last_trade_index_valid_message() {
1176        let kind = MessageKind::new(None, None, Some(7), Action::LastTradeIndex, None);
1177        let msg = Message::Restore(kind);
1178
1179        assert!(msg.verify());
1180
1181        // roundtrip
1182        let json = msg.as_json().unwrap();
1183        let decoded = Message::from_json(&json).unwrap();
1184        assert!(decoded.verify());
1185
1186        // ensure the trade index is propagated
1187        let inner = decoded.get_inner_message_kind();
1188        assert_eq!(inner.trade_index(), 7);
1189        assert_eq!(inner.has_trade_index(), (true, 7));
1190    }
1191
1192    #[test]
1193    fn test_last_trade_index_without_id_is_valid() {
1194        // With new logic, id is not required; only payload must be None
1195        let kind = MessageKind::new(None, None, Some(5), Action::LastTradeIndex, None);
1196        let msg = Message::Restore(kind);
1197        assert!(msg.verify());
1198    }
1199
1200    #[test]
1201    fn test_last_trade_index_with_payload_fails_validation() {
1202        // LastTradeIndex does not accept payload
1203        let kind = MessageKind::new(
1204            None,
1205            None,
1206            Some(3),
1207            Action::LastTradeIndex,
1208            Some(Payload::TextMessage("ignored".to_string())),
1209        );
1210        let msg = Message::Restore(kind);
1211        assert!(!msg.verify());
1212    }
1213
1214    #[test]
1215    fn test_bond_resolution_admin_actions_accept_payload_or_none() {
1216        use crate::message::BondResolution;
1217
1218        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1219
1220        for action in [Action::AdminSettle, Action::AdminCancel] {
1221            let with_resolution = Message::Order(MessageKind::new(
1222                Some(uuid),
1223                Some(1),
1224                Some(2),
1225                action.clone(),
1226                Some(Payload::BondResolution(BondResolution {
1227                    slash_seller: true,
1228                    slash_buyer: false,
1229                })),
1230            ));
1231            assert!(
1232                with_resolution.verify(),
1233                "{action:?} + BondResolution should verify"
1234            );
1235
1236            let without_payload = Message::Order(MessageKind::new(
1237                Some(uuid),
1238                Some(1),
1239                Some(2),
1240                action.clone(),
1241                None,
1242            ));
1243            assert!(without_payload.verify(), "{action:?} + None should verify");
1244
1245            // Wrong payload type must be rejected for these admin actions.
1246            let wrong = Message::Order(MessageKind::new(
1247                Some(uuid),
1248                Some(1),
1249                Some(2),
1250                action.clone(),
1251                Some(Payload::TextMessage("nope".to_string())),
1252            ));
1253            assert!(!wrong.verify(), "{action:?} + TextMessage must be rejected");
1254
1255            // Missing id is still invalid.
1256            let no_id = Message::Order(MessageKind::new(
1257                None,
1258                Some(1),
1259                Some(2),
1260                action,
1261                Some(Payload::BondResolution(BondResolution {
1262                    slash_seller: false,
1263                    slash_buyer: false,
1264                })),
1265            ));
1266            assert!(!no_id.verify(), "admin action without id must be rejected");
1267        }
1268    }
1269
1270    #[test]
1271    fn test_bond_resolution_rejected_on_non_admin_actions() {
1272        use crate::message::BondResolution;
1273
1274        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1275        let payload = Payload::BondResolution(BondResolution {
1276            slash_seller: true,
1277            slash_buyer: true,
1278        });
1279
1280        // Every Action except AdminSettle / AdminCancel must reject a
1281        // BondResolution payload. Listed explicitly (no strum) so that adding
1282        // a new Action variant forces a compile-error reminder here.
1283        for action in [
1284            Action::NewOrder,
1285            Action::TakeSell,
1286            Action::TakeBuy,
1287            Action::PayInvoice,
1288            Action::PayBondInvoice,
1289            Action::FiatSent,
1290            Action::FiatSentOk,
1291            Action::Release,
1292            Action::Released,
1293            Action::Cancel,
1294            Action::Canceled,
1295            Action::CooperativeCancelInitiatedByYou,
1296            Action::CooperativeCancelInitiatedByPeer,
1297            Action::DisputeInitiatedByYou,
1298            Action::DisputeInitiatedByPeer,
1299            Action::CooperativeCancelAccepted,
1300            Action::BuyerInvoiceAccepted,
1301            Action::PurchaseCompleted,
1302            Action::HoldInvoicePaymentAccepted,
1303            Action::HoldInvoicePaymentSettled,
1304            Action::HoldInvoicePaymentCanceled,
1305            Action::WaitingSellerToPay,
1306            Action::WaitingBuyerInvoice,
1307            Action::AddInvoice,
1308            Action::AddBondInvoice,
1309            Action::BuyerTookOrder,
1310            Action::Rate,
1311            Action::RateUser,
1312            Action::RateReceived,
1313            Action::CantDo,
1314            Action::Dispute,
1315            Action::AdminCanceled,
1316            Action::AdminSettled,
1317            Action::AdminAddSolver,
1318            Action::AdminTakeDispute,
1319            Action::AdminTookDispute,
1320            Action::PaymentFailed,
1321            Action::InvoiceUpdated,
1322            Action::SendDm,
1323            Action::TradePubkey,
1324            Action::RestoreSession,
1325            Action::LastTradeIndex,
1326            Action::Orders,
1327        ] {
1328            let msg = Message::Order(MessageKind::new(
1329                Some(uuid),
1330                Some(1),
1331                Some(2),
1332                action.clone(),
1333                Some(payload.clone()),
1334            ));
1335            assert!(
1336                !msg.verify(),
1337                "{action:?} must reject BondResolution payload"
1338            );
1339        }
1340    }
1341
1342    #[test]
1343    fn test_bond_resolution_wire_format() {
1344        use crate::message::BondResolution;
1345
1346        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1347        let msg = Message::Order(MessageKind::new(
1348            Some(uuid),
1349            None,
1350            None,
1351            Action::AdminCancel,
1352            Some(Payload::BondResolution(BondResolution {
1353                slash_seller: true,
1354                slash_buyer: false,
1355            })),
1356        ));
1357
1358        let json = msg.as_json().unwrap();
1359        // Variant discriminator must be the snake_case `bond_resolution`.
1360        assert!(
1361            json.contains("\"bond_resolution\""),
1362            "expected snake_case discriminator, got: {json}"
1363        );
1364        assert!(json.contains("\"slash_seller\":true"));
1365        assert!(json.contains("\"slash_buyer\":false"));
1366
1367        // Roundtrip preserves the variant.
1368        let decoded = Message::from_json(&json).unwrap();
1369        assert!(decoded.verify());
1370        if let Message::Order(kind) = decoded {
1371            match kind.payload {
1372                Some(Payload::BondResolution(b)) => {
1373                    assert!(b.slash_seller);
1374                    assert!(!b.slash_buyer);
1375                }
1376                other => panic!("expected BondResolution payload, got {other:?}"),
1377            }
1378        } else {
1379            panic!("expected Order message");
1380        }
1381    }
1382
1383    #[test]
1384    fn test_bond_resolution_legacy_null_payload() {
1385        // payload = null on AdminSettle/AdminCancel must keep verifying so
1386        // pre-BondResolution clients keep working (interpreted as "no slash"
1387        // by the server).
1388        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1389        let json = format!(
1390            r#"{{"order":{{"version":1,"id":"{uuid}","action":"admin-cancel","payload":null}}}}"#
1391        );
1392        let msg = Message::from_json(&json).unwrap();
1393        assert!(msg.verify());
1394    }
1395
1396    #[test]
1397    fn test_pay_bond_invoice_wire_format_and_verify() {
1398        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1399        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1400
1401        let msg = Message::Order(MessageKind::new(
1402            Some(uuid),
1403            Some(1),
1404            Some(2),
1405            Action::PayBondInvoice,
1406            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1407        ));
1408        assert!(msg.verify());
1409
1410        // Wire format must use the kebab-case discriminator.
1411        let json = msg.as_json().unwrap();
1412        assert!(
1413            json.contains("\"action\":\"pay-bond-invoice\""),
1414            "expected kebab-case discriminator, got: {json}"
1415        );
1416
1417        // Roundtrip preserves the variant.
1418        let decoded = Message::from_json(&json).unwrap();
1419        assert!(decoded.verify());
1420        assert!(matches!(
1421            decoded.inner_action(),
1422            Some(Action::PayBondInvoice)
1423        ));
1424
1425        // Same id / payload constraints as PayInvoice: missing id is invalid.
1426        let no_id = Message::Order(MessageKind::new(
1427            None,
1428            Some(1),
1429            Some(2),
1430            Action::PayBondInvoice,
1431            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1432        ));
1433        assert!(!no_id.verify());
1434
1435        // Wrong payload shape is rejected.
1436        let wrong_payload = Message::Order(MessageKind::new(
1437            Some(uuid),
1438            Some(1),
1439            Some(2),
1440            Action::PayBondInvoice,
1441            Some(Payload::TextMessage("nope".to_string())),
1442        ));
1443        assert!(!wrong_payload.verify());
1444
1445        // Missing payload is rejected (PaymentRequest is required).
1446        let no_payload = Message::Order(MessageKind::new(
1447            Some(uuid),
1448            Some(1),
1449            Some(2),
1450            Action::PayBondInvoice,
1451            None,
1452        ));
1453        assert!(!no_payload.verify());
1454    }
1455
1456    #[test]
1457    fn test_add_bond_invoice_wire_format_and_verify() {
1458        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1459        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1460
1461        let msg = Message::Order(MessageKind::new(
1462            Some(uuid),
1463            Some(1),
1464            Some(2),
1465            Action::AddBondInvoice,
1466            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1467        ));
1468        assert!(msg.verify());
1469
1470        // Wire format must use the kebab-case discriminator.
1471        let json = msg.as_json().unwrap();
1472        assert!(
1473            json.contains("\"action\":\"add-bond-invoice\""),
1474            "expected kebab-case discriminator, got: {json}"
1475        );
1476
1477        // Roundtrip preserves the variant.
1478        let decoded = Message::from_json(&json).unwrap();
1479        assert!(decoded.verify());
1480        assert!(matches!(
1481            decoded.inner_action(),
1482            Some(Action::AddBondInvoice)
1483        ));
1484
1485        // Same id / payload constraints as AddInvoice: missing id is invalid.
1486        let no_id = Message::Order(MessageKind::new(
1487            None,
1488            Some(1),
1489            Some(2),
1490            Action::AddBondInvoice,
1491            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1492        ));
1493        assert!(!no_id.verify());
1494
1495        // Wrong payload shape is rejected.
1496        let wrong_payload = Message::Order(MessageKind::new(
1497            Some(uuid),
1498            Some(1),
1499            Some(2),
1500            Action::AddBondInvoice,
1501            Some(Payload::TextMessage("nope".to_string())),
1502        ));
1503        assert!(!wrong_payload.verify());
1504
1505        // Missing payload is rejected (PaymentRequest is required).
1506        let no_payload = Message::Order(MessageKind::new(
1507            Some(uuid),
1508            Some(1),
1509            Some(2),
1510            Action::AddBondInvoice,
1511            None,
1512        ));
1513        assert!(!no_payload.verify());
1514
1515        // get_payment_request must surface the bolt11 for AddBondInvoice.
1516        if let Message::Order(kind) = &msg {
1517            assert_eq!(kind.get_payment_request(), Some(bolt11));
1518        } else {
1519            panic!("expected Message::Order");
1520        }
1521    }
1522
1523    #[test]
1524    fn test_restored_dispute_helper_serialization_roundtrip() {
1525        use crate::message::RestoredDisputeHelper;
1526
1527        let helper = RestoredDisputeHelper {
1528            dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1529            order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1530            dispute_status: "initiated".to_string(),
1531            master_buyer_pubkey: Some("npub1buyerkey".to_string()),
1532            master_seller_pubkey: Some("npub1sellerkey".to_string()),
1533            trade_index_buyer: Some(1),
1534            trade_index_seller: Some(2),
1535            buyer_dispute: true,
1536            seller_dispute: false,
1537            solver_pubkey: None,
1538        };
1539
1540        let json = serde_json::to_string(&helper).unwrap();
1541        let deserialized: RestoredDisputeHelper = serde_json::from_str(&json).unwrap();
1542
1543        assert_eq!(deserialized.dispute_id, helper.dispute_id);
1544        assert_eq!(deserialized.order_id, helper.order_id);
1545        assert_eq!(deserialized.dispute_status, helper.dispute_status);
1546        assert_eq!(deserialized.master_buyer_pubkey, helper.master_buyer_pubkey);
1547        assert_eq!(
1548            deserialized.master_seller_pubkey,
1549            helper.master_seller_pubkey
1550        );
1551        assert_eq!(deserialized.trade_index_buyer, helper.trade_index_buyer);
1552        assert_eq!(deserialized.trade_index_seller, helper.trade_index_seller);
1553        assert_eq!(deserialized.buyer_dispute, helper.buyer_dispute);
1554        assert_eq!(deserialized.seller_dispute, helper.seller_dispute);
1555        assert_eq!(deserialized.solver_pubkey, helper.solver_pubkey);
1556
1557        let helper_seller_dispute = RestoredDisputeHelper {
1558            dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1559            order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1560            dispute_status: "in-progress".to_string(),
1561            master_buyer_pubkey: None,
1562            master_seller_pubkey: None,
1563            trade_index_buyer: None,
1564            trade_index_seller: None,
1565            buyer_dispute: false,
1566            seller_dispute: true,
1567            solver_pubkey: Some(
1568                "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1569            ),
1570        };
1571
1572        let json_seller = serde_json::to_string(&helper_seller_dispute).unwrap();
1573        let deserialized_seller: RestoredDisputeHelper =
1574            serde_json::from_str(&json_seller).unwrap();
1575
1576        assert_eq!(
1577            deserialized_seller.dispute_id,
1578            helper_seller_dispute.dispute_id
1579        );
1580        assert_eq!(deserialized_seller.order_id, helper_seller_dispute.order_id);
1581        assert_eq!(
1582            deserialized_seller.dispute_status,
1583            helper_seller_dispute.dispute_status
1584        );
1585        assert_eq!(deserialized_seller.master_buyer_pubkey, None);
1586        assert_eq!(deserialized_seller.master_seller_pubkey, None);
1587        assert_eq!(deserialized_seller.trade_index_buyer, None);
1588        assert_eq!(deserialized_seller.trade_index_seller, None);
1589        assert!(!deserialized_seller.buyer_dispute);
1590        assert!(deserialized_seller.seller_dispute);
1591        assert_eq!(
1592            deserialized_seller.solver_pubkey,
1593            helper_seller_dispute.solver_pubkey
1594        );
1595    }
1596}