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/// Outbound side of the bond payout invoice request carried by
521/// [`Action::AddBondInvoice`] (Mostro → winning counterparty).
522///
523/// Asks the recipient for a bolt11 sized at `order.amount` (= the
524/// counterparty share of a slashed bond) and ships the slash anchor
525/// `slashed_at` so the client can compute the forfeit deadline as
526/// `slashed_at + bond_payout_claim_window_days * 86_400` — accurate even
527/// when the message lands days late because the recipient or their relay
528/// was offline.
529///
530/// The reply (counterparty → Mostro) reuses [`Payload::PaymentRequest`]
531/// with the actual bolt11 in the second tuple slot, so the two
532/// directions of the same action are wire-distinguishable by payload
533/// shape rather than by message ordering.
534#[derive(Debug, Deserialize, Serialize, Clone)]
535pub struct BondPayoutRequest {
536    /// Order context (id, kind, `amount` = counterparty share in sats,
537    /// fiat metadata, etc.). Same [`SmallOrder`] shape the client
538    /// already renders for other order-bearing actions.
539    pub order: SmallOrder,
540    /// Unix timestamp (seconds, UTC) at which Mostro recorded the slash
541    /// decision. Frozen at the `Locked → PendingPayout` CAS and shipped
542    /// verbatim on every cadence retry of this request — clients can
543    /// rely on it as a fixed anchor.
544    pub slashed_at: i64,
545}
546
547/// Typed payload attached to a [`MessageKind`].
548///
549/// Each variant corresponds to a set of [`Action`] values that can legally
550/// carry it (see [`MessageKind::verify`]). Serialized in `snake_case` so
551/// that the variant name is the JSON discriminator.
552#[derive(Debug, Deserialize, Serialize, Clone)]
553#[serde(rename_all = "snake_case")]
554pub enum Payload {
555    /// A compact representation of an order used by [`Action::NewOrder`].
556    Order(SmallOrder),
557    /// Lightning payment request plus optional amount override.
558    ///
559    /// Used by [`Action::PayInvoice`], [`Action::PayBondInvoice`],
560    /// [`Action::AddInvoice`], [`Action::AddBondInvoice`] and
561    /// [`Action::TakeSell`]. The [`SmallOrder`]
562    /// carries the matching order when relevant; the `String` is a BOLT-11
563    /// invoice.
564    PaymentRequest(Option<SmallOrder>, String, Option<Amount>),
565    /// Free-form text message used by DMs.
566    TextMessage(String),
567    /// Peer disclosure (trade pubkey and optional reputation).
568    Peer(Peer),
569    /// Rating value the user wants to attach to a completed trade.
570    RatingUser(u8),
571    /// Raw amount in satoshis (for actions that accept an amount override).
572    Amount(Amount),
573    /// Dispute context: the dispute id plus optional
574    /// [`SolverDisputeInfo`] bundle sent to solvers.
575    Dispute(Uuid, Option<SolverDisputeInfo>),
576    /// Reason carried by a [`Action::CantDo`] response.
577    CantDo(Option<CantDoReason>),
578    /// Next trade key and index announced by the maker of a range order
579    /// when it emits [`Action::Release`] or [`Action::FiatSent`].
580    NextTrade(String, u32),
581    /// Retry configuration surfaced by [`Action::PaymentFailed`].
582    PaymentFailed(PaymentFailedInfo),
583    /// Payload returned by the server on a session restore.
584    RestoreData(RestoreSessionInfo),
585    /// Vector of order ids (lightweight listing).
586    Ids(Vec<Uuid>),
587    /// Vector of [`SmallOrder`] values (full listing).
588    Orders(Vec<SmallOrder>),
589    /// Slash decisions carried by [`Action::AdminSettle`] /
590    /// [`Action::AdminCancel`]. See [`BondResolution`].
591    BondResolution(BondResolution),
592    /// Outbound bond payout invoice request carried by
593    /// [`Action::AddBondInvoice`] (Mostro → counterparty). The reply
594    /// direction (counterparty → Mostro) keeps using
595    /// [`Payload::PaymentRequest`] with the actual bolt11. See
596    /// [`BondPayoutRequest`].
597    BondPayoutRequest(BondPayoutRequest),
598}
599
600#[allow(dead_code)]
601impl MessageKind {
602    /// Build a new [`MessageKind`] stamped with the current protocol
603    /// version (`PROTOCOL_VER`).
604    pub fn new(
605        id: Option<Uuid>,
606        request_id: Option<u64>,
607        trade_index: Option<i64>,
608        action: Action,
609        payload: Option<Payload>,
610    ) -> Self {
611        Self {
612            version: PROTOCOL_VER,
613            request_id,
614            trade_index,
615            id,
616            action,
617            payload,
618        }
619    }
620    /// Parse a [`MessageKind`] from its JSON representation.
621    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
622        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
623    }
624    /// Serialize the [`MessageKind`] to a JSON string.
625    pub fn as_json(&self) -> Result<String, ServiceError> {
626        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
627    }
628
629    /// Return a clone of the [`Action`] carried by this message.
630    pub fn get_action(&self) -> Action {
631        self.action.clone()
632    }
633
634    /// Extract the `(next_trade_pubkey, next_trade_index)` pair from a
635    /// [`Payload::NextTrade`] payload.
636    ///
637    /// Returns `Ok(None)` when there is no payload at all and
638    /// [`ServiceError::InvalidPayload`] when the payload is present but of
639    /// a different variant.
640    pub fn get_next_trade_key(&self) -> Result<Option<(String, u32)>, ServiceError> {
641        match &self.payload {
642            Some(Payload::NextTrade(key, index)) => Ok(Some((key.to_string(), *index))),
643            None => Ok(None),
644            _ => Err(ServiceError::InvalidPayload),
645        }
646    }
647
648    /// Extract the rating value from a [`Payload::RatingUser`] payload,
649    /// validating it against
650    /// [`MIN_RATING`]`..=`[`MAX_RATING`].
651    ///
652    /// Returns [`ServiceError::InvalidRating`] when the payload shape is
653    /// wrong and [`ServiceError::InvalidRatingValue`] when the value is out
654    /// of range.
655    pub fn get_rating(&self) -> Result<u8, ServiceError> {
656        if let Some(Payload::RatingUser(v)) = self.payload.to_owned() {
657            if !(MIN_RATING..=MAX_RATING).contains(&v) {
658                return Err(ServiceError::InvalidRatingValue);
659            }
660            Ok(v)
661        } else {
662            Err(ServiceError::InvalidRating)
663        }
664    }
665
666    /// Check that the payload, id and trade index are consistent with the
667    /// action carried by this message.
668    ///
669    /// Returns `true` when the combination is well-formed and `false`
670    /// otherwise; Mostro uses this method to reject malformed requests
671    /// before processing them.
672    pub fn verify(&self) -> bool {
673        match &self.action {
674            Action::NewOrder => matches!(&self.payload, Some(Payload::Order(_))),
675            Action::PayInvoice | Action::PayBondInvoice | Action::AddInvoice => {
676                if self.id.is_none() {
677                    return false;
678                }
679                matches!(&self.payload, Some(Payload::PaymentRequest(_, _, _)))
680            }
681            Action::AddBondInvoice => {
682                if self.id.is_none() {
683                    return false;
684                }
685                // Two valid shapes:
686                //   - `BondPayoutRequest` for the outbound direction
687                //     (Mostro → counterparty, "send me a bolt11").
688                //   - `PaymentRequest` for the inbound reply
689                //     (counterparty → Mostro, "here is the bolt11").
690                matches!(
691                    &self.payload,
692                    Some(Payload::BondPayoutRequest(_)) | Some(Payload::PaymentRequest(_, _, _))
693                )
694            }
695            Action::AdminSettle | Action::AdminCancel => {
696                if self.id.is_none() {
697                    return false;
698                }
699                matches!(&self.payload, None | Some(Payload::BondResolution(_)))
700            }
701            Action::TakeSell
702            | Action::TakeBuy
703            | Action::FiatSent
704            | Action::FiatSentOk
705            | Action::Release
706            | Action::Released
707            | Action::Dispute
708            | Action::AdminCanceled
709            | Action::AdminSettled
710            | Action::Rate
711            | Action::RateReceived
712            | Action::AdminTakeDispute
713            | Action::AdminTookDispute
714            | Action::DisputeInitiatedByYou
715            | Action::DisputeInitiatedByPeer
716            | Action::WaitingBuyerInvoice
717            | Action::PurchaseCompleted
718            | Action::HoldInvoicePaymentAccepted
719            | Action::HoldInvoicePaymentSettled
720            | Action::HoldInvoicePaymentCanceled
721            | Action::WaitingSellerToPay
722            | Action::BuyerTookOrder
723            | Action::BuyerInvoiceAccepted
724            | Action::CooperativeCancelInitiatedByYou
725            | Action::CooperativeCancelInitiatedByPeer
726            | Action::CooperativeCancelAccepted
727            | Action::Cancel
728            | Action::InvoiceUpdated
729            | Action::AdminAddSolver
730            | Action::SendDm
731            | Action::TradePubkey
732            | Action::Canceled => {
733                if self.id.is_none() {
734                    return false;
735                }
736                !matches!(
737                    &self.payload,
738                    Some(Payload::BondResolution(_)) | Some(Payload::BondPayoutRequest(_))
739                )
740            }
741            Action::LastTradeIndex | Action::RestoreSession => self.payload.is_none(),
742            Action::PaymentFailed => {
743                if self.id.is_none() {
744                    return false;
745                }
746                matches!(&self.payload, Some(Payload::PaymentFailed(_)))
747            }
748            Action::RateUser => {
749                matches!(&self.payload, Some(Payload::RatingUser(_)))
750            }
751            Action::CantDo => {
752                matches!(&self.payload, Some(Payload::CantDo(_)))
753            }
754            Action::Orders => {
755                matches!(
756                    &self.payload,
757                    Some(Payload::Ids(_)) | Some(Payload::Orders(_))
758                )
759            }
760        }
761    }
762
763    /// Return the [`SmallOrder`] carried by a [`Action::NewOrder`] message.
764    ///
765    /// Yields `None` if the action is not `NewOrder` or the payload is of a
766    /// different variant.
767    pub fn get_order(&self) -> Option<&SmallOrder> {
768        if self.action != Action::NewOrder {
769            return None;
770        }
771        match &self.payload {
772            Some(Payload::Order(o)) => Some(o),
773            _ => None,
774        }
775    }
776
777    /// Return the Lightning payment request embedded in a message.
778    ///
779    /// Valid only for [`Action::TakeSell`], [`Action::AddInvoice`],
780    /// [`Action::AddBondInvoice`] and [`Action::NewOrder`]. For `NewOrder`,
781    /// the invoice is read from the [`SmallOrder::buyer_invoice`] field.
782    /// Returns `None` otherwise.
783    pub fn get_payment_request(&self) -> Option<String> {
784        if self.action != Action::TakeSell
785            && self.action != Action::AddInvoice
786            && self.action != Action::AddBondInvoice
787            && self.action != Action::NewOrder
788        {
789            return None;
790        }
791        match &self.payload {
792            Some(Payload::PaymentRequest(_, pr, _)) => Some(pr.to_owned()),
793            Some(Payload::Order(ord)) => ord.buyer_invoice.to_owned(),
794            _ => None,
795        }
796    }
797
798    /// Return the amount override embedded in a [`Action::TakeSell`] or
799    /// [`Action::TakeBuy`] message, either from a [`Payload::Amount`] or
800    /// from the third element of a [`Payload::PaymentRequest`].
801    pub fn get_amount(&self) -> Option<Amount> {
802        if self.action != Action::TakeSell && self.action != Action::TakeBuy {
803            return None;
804        }
805        match &self.payload {
806            Some(Payload::PaymentRequest(_, _, amount)) => *amount,
807            Some(Payload::Amount(amount)) => Some(*amount),
808            _ => None,
809        }
810    }
811
812    /// Borrow the optional payload.
813    pub fn get_payload(&self) -> Option<&Payload> {
814        self.payload.as_ref()
815    }
816
817    /// Return `(true, index)` when the message carries a trade index,
818    /// `(false, 0)` otherwise.
819    pub fn has_trade_index(&self) -> (bool, i64) {
820        if let Some(index) = self.trade_index {
821            return (true, index);
822        }
823        (false, 0)
824    }
825
826    /// Return the trade index carried by the message, or `0` when absent.
827    pub fn trade_index(&self) -> i64 {
828        if let Some(index) = self.trade_index {
829            return index;
830        }
831        0
832    }
833}
834
835#[cfg(test)]
836mod test {
837    use crate::message::{Action, BondPayoutRequest, Message, MessageKind, Payload, Peer};
838    use crate::order::SmallOrder;
839    use crate::user::UserInfo;
840    use nostr_sdk::Keys;
841    use uuid::uuid;
842
843    #[test]
844    fn test_peer_with_reputation() {
845        // Test creating a Peer with reputation information
846        let reputation = UserInfo {
847            rating: 4.5,
848            reviews: 10,
849            operating_days: 30,
850        };
851        let peer = Peer::new(
852            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
853            Some(reputation.clone()),
854        );
855
856        // Assert the fields are set correctly
857        assert_eq!(
858            peer.pubkey,
859            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
860        );
861        assert!(peer.reputation.is_some());
862        let peer_reputation = peer.reputation.clone().unwrap();
863        assert_eq!(peer_reputation.rating, 4.5);
864        assert_eq!(peer_reputation.reviews, 10);
865        assert_eq!(peer_reputation.operating_days, 30);
866
867        // Test JSON serialization and deserialization
868        let json = peer.as_json().unwrap();
869        let deserialized_peer = Peer::from_json(&json).unwrap();
870        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
871        assert!(deserialized_peer.reputation.is_some());
872        let deserialized_reputation = deserialized_peer.reputation.unwrap();
873        assert_eq!(deserialized_reputation.rating, 4.5);
874        assert_eq!(deserialized_reputation.reviews, 10);
875        assert_eq!(deserialized_reputation.operating_days, 30);
876    }
877
878    #[test]
879    fn test_peer_without_reputation() {
880        // Test creating a Peer without reputation information
881        let peer = Peer::new(
882            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
883            None,
884        );
885
886        // Assert the reputation field is None
887        assert_eq!(
888            peer.pubkey,
889            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
890        );
891        assert!(peer.reputation.is_none());
892
893        // Test JSON serialization and deserialization
894        let json = peer.as_json().unwrap();
895        let deserialized_peer = Peer::from_json(&json).unwrap();
896        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
897        assert!(deserialized_peer.reputation.is_none());
898    }
899
900    #[test]
901    fn test_peer_in_message() {
902        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
903
904        // Test with reputation
905        let reputation = UserInfo {
906            rating: 4.5,
907            reviews: 10,
908            operating_days: 30,
909        };
910        let peer_with_reputation = Peer::new(
911            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
912            Some(reputation),
913        );
914        let payload_with_reputation = Payload::Peer(peer_with_reputation);
915        let message_with_reputation = Message::Order(MessageKind::new(
916            Some(uuid),
917            Some(1),
918            Some(2),
919            Action::FiatSentOk,
920            Some(payload_with_reputation),
921        ));
922
923        // Verify message with reputation
924        assert!(message_with_reputation.verify());
925        let message_json = message_with_reputation.as_json().unwrap();
926        let deserialized_message = Message::from_json(&message_json).unwrap();
927        assert!(deserialized_message.verify());
928
929        // Test without reputation
930        let peer_without_reputation = Peer::new(
931            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
932            None,
933        );
934        let payload_without_reputation = Payload::Peer(peer_without_reputation);
935        let message_without_reputation = Message::Order(MessageKind::new(
936            Some(uuid),
937            Some(1),
938            Some(2),
939            Action::FiatSentOk,
940            Some(payload_without_reputation),
941        ));
942
943        // Verify message without reputation
944        assert!(message_without_reputation.verify());
945        let message_json = message_without_reputation.as_json().unwrap();
946        let deserialized_message = Message::from_json(&message_json).unwrap();
947        assert!(deserialized_message.verify());
948    }
949
950    #[test]
951    fn test_bond_payout_request_payload_verifies_on_add_bond_invoice() {
952        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
953        let order = SmallOrder {
954            id: Some(order_id),
955            kind: None,
956            status: None,
957            amount: 500,
958            fiat_code: "USD".to_string(),
959            min_amount: None,
960            max_amount: None,
961            fiat_amount: 0,
962            payment_method: "lightning".to_string(),
963            premium: 0,
964            buyer_trade_pubkey: None,
965            seller_trade_pubkey: None,
966            buyer_invoice: None,
967            created_at: None,
968            expires_at: None,
969        };
970        let payload = Payload::BondPayoutRequest(BondPayoutRequest {
971            order,
972            slashed_at: 1_734_000_000,
973        });
974        let kind = MessageKind::new(
975            Some(order_id),
976            None,
977            None,
978            Action::AddBondInvoice,
979            Some(payload),
980        );
981        assert!(
982            kind.verify(),
983            "BondPayoutRequest must verify on AddBondInvoice"
984        );
985
986        // Round-trip through JSON to catch a serde-rename mismatch on the
987        // new snake_case discriminator.
988        let m = Message::Order(kind);
989        let json = m.as_json().unwrap();
990        assert!(json.contains("bond_payout_request"));
991        let back = Message::from_json(&json).unwrap();
992        assert!(back.verify());
993    }
994
995    #[test]
996    fn test_bond_payout_request_payload_rejected_on_wrong_action() {
997        // BondPayoutRequest on any action other than AddBondInvoice must
998        // fail verification — the new variant is opt-in per action.
999        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1000        let order = SmallOrder {
1001            id: Some(order_id),
1002            kind: None,
1003            status: None,
1004            amount: 500,
1005            fiat_code: "USD".to_string(),
1006            min_amount: None,
1007            max_amount: None,
1008            fiat_amount: 0,
1009            payment_method: "lightning".to_string(),
1010            premium: 0,
1011            buyer_trade_pubkey: None,
1012            seller_trade_pubkey: None,
1013            buyer_invoice: None,
1014            created_at: None,
1015            expires_at: None,
1016        };
1017
1018        // Compile-time exhaustiveness guard: adding a new Action variant
1019        // forces this match to be updated, which in turn forces a
1020        // decision about whether the new variant belongs in
1021        // `other_actions` below.
1022        let _exhaustive: fn(Action) = |a| match a {
1023            Action::AddBondInvoice => {}
1024            Action::NewOrder
1025            | Action::TakeSell
1026            | Action::TakeBuy
1027            | Action::PayInvoice
1028            | Action::PayBondInvoice
1029            | Action::FiatSent
1030            | Action::FiatSentOk
1031            | Action::Release
1032            | Action::Released
1033            | Action::Cancel
1034            | Action::Canceled
1035            | Action::CooperativeCancelInitiatedByYou
1036            | Action::CooperativeCancelInitiatedByPeer
1037            | Action::DisputeInitiatedByYou
1038            | Action::DisputeInitiatedByPeer
1039            | Action::CooperativeCancelAccepted
1040            | Action::BuyerInvoiceAccepted
1041            | Action::PurchaseCompleted
1042            | Action::HoldInvoicePaymentAccepted
1043            | Action::HoldInvoicePaymentSettled
1044            | Action::HoldInvoicePaymentCanceled
1045            | Action::WaitingSellerToPay
1046            | Action::WaitingBuyerInvoice
1047            | Action::AddInvoice
1048            | Action::BuyerTookOrder
1049            | Action::Rate
1050            | Action::RateUser
1051            | Action::RateReceived
1052            | Action::CantDo
1053            | Action::Dispute
1054            | Action::AdminCancel
1055            | Action::AdminCanceled
1056            | Action::AdminSettle
1057            | Action::AdminSettled
1058            | Action::AdminAddSolver
1059            | Action::AdminTakeDispute
1060            | Action::AdminTookDispute
1061            | Action::PaymentFailed
1062            | Action::InvoiceUpdated
1063            | Action::SendDm
1064            | Action::TradePubkey
1065            | Action::RestoreSession
1066            | Action::LastTradeIndex
1067            | Action::Orders => {}
1068        };
1069
1070        let other_actions: &[Action] = &[
1071            Action::NewOrder,
1072            Action::TakeSell,
1073            Action::TakeBuy,
1074            Action::PayInvoice,
1075            Action::PayBondInvoice,
1076            Action::FiatSent,
1077            Action::FiatSentOk,
1078            Action::Release,
1079            Action::Released,
1080            Action::Cancel,
1081            Action::Canceled,
1082            Action::CooperativeCancelInitiatedByYou,
1083            Action::CooperativeCancelInitiatedByPeer,
1084            Action::DisputeInitiatedByYou,
1085            Action::DisputeInitiatedByPeer,
1086            Action::CooperativeCancelAccepted,
1087            Action::BuyerInvoiceAccepted,
1088            Action::PurchaseCompleted,
1089            Action::HoldInvoicePaymentAccepted,
1090            Action::HoldInvoicePaymentSettled,
1091            Action::HoldInvoicePaymentCanceled,
1092            Action::WaitingSellerToPay,
1093            Action::WaitingBuyerInvoice,
1094            Action::AddInvoice,
1095            Action::BuyerTookOrder,
1096            Action::Rate,
1097            Action::RateUser,
1098            Action::RateReceived,
1099            Action::CantDo,
1100            Action::Dispute,
1101            Action::AdminCancel,
1102            Action::AdminCanceled,
1103            Action::AdminSettle,
1104            Action::AdminSettled,
1105            Action::AdminAddSolver,
1106            Action::AdminTakeDispute,
1107            Action::AdminTookDispute,
1108            Action::PaymentFailed,
1109            Action::InvoiceUpdated,
1110            Action::SendDm,
1111            Action::TradePubkey,
1112            Action::RestoreSession,
1113            Action::LastTradeIndex,
1114            Action::Orders,
1115        ];
1116
1117        for action in other_actions {
1118            let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1119                order: order.clone(),
1120                slashed_at: 0,
1121            });
1122            let kind = MessageKind::new(Some(order_id), None, None, action.clone(), Some(payload));
1123            assert!(
1124                !kind.verify(),
1125                "BondPayoutRequest must be rejected on {action:?}"
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn test_payment_failed_payload() {
1132        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1133
1134        // Test PaymentFailedInfo serialization and deserialization
1135        let payment_failed_info = crate::message::PaymentFailedInfo {
1136            payment_attempts: 3,
1137            payment_retries_interval: 60,
1138        };
1139
1140        let payload = Payload::PaymentFailed(payment_failed_info);
1141        let message = Message::Order(MessageKind::new(
1142            Some(uuid),
1143            Some(1),
1144            Some(2),
1145            Action::PaymentFailed,
1146            Some(payload),
1147        ));
1148
1149        // Verify message validation
1150        assert!(message.verify());
1151
1152        // Test JSON serialization
1153        let message_json = message.as_json().unwrap();
1154
1155        // Test deserialization
1156        let deserialized_message = Message::from_json(&message_json).unwrap();
1157        assert!(deserialized_message.verify());
1158
1159        // Verify the payload contains correct values
1160        if let Message::Order(kind) = deserialized_message {
1161            if let Some(Payload::PaymentFailed(info)) = kind.payload {
1162                assert_eq!(info.payment_attempts, 3);
1163                assert_eq!(info.payment_retries_interval, 60);
1164            } else {
1165                panic!("Expected PaymentFailed payload");
1166            }
1167        } else {
1168            panic!("Expected Order message");
1169        }
1170    }
1171
1172    #[test]
1173    fn test_message_payload_signature() {
1174        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1175        let peer = Peer::new(
1176            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1177            None, // Add None for the reputation parameter
1178        );
1179        let payload = Payload::Peer(peer);
1180        let test_message = Message::Order(MessageKind::new(
1181            Some(uuid),
1182            Some(1),
1183            Some(2),
1184            Action::FiatSentOk,
1185            Some(payload),
1186        ));
1187        assert!(test_message.verify());
1188        let test_message_json = test_message.as_json().unwrap();
1189        // Message should be signed with the trade keys
1190        let trade_keys =
1191            Keys::parse("110e43647eae221ab1da33ddc17fd6ff423f2b2f49d809b9ffa40794a2ab996c")
1192                .unwrap();
1193        let sig = Message::sign(test_message_json.clone(), &trade_keys);
1194
1195        assert!(Message::verify_signature(
1196            test_message_json,
1197            trade_keys.public_key(),
1198            sig
1199        ));
1200    }
1201
1202    #[test]
1203    fn test_restore_session_message() {
1204        // Test RestoreSession request (payload = None)
1205        let restore_request_message = Message::Restore(MessageKind::new(
1206            None,
1207            None,
1208            None,
1209            Action::RestoreSession,
1210            None,
1211        ));
1212
1213        // Verify message validation
1214        assert!(restore_request_message.verify());
1215        assert_eq!(
1216            restore_request_message.inner_action(),
1217            Some(Action::RestoreSession)
1218        );
1219
1220        // Test JSON serialization and deserialization for RestoreRequest
1221        let message_json = restore_request_message.as_json().unwrap();
1222        let deserialized_message = Message::from_json(&message_json).unwrap();
1223        assert!(deserialized_message.verify());
1224        assert_eq!(
1225            deserialized_message.inner_action(),
1226            Some(Action::RestoreSession)
1227        );
1228
1229        // Test RestoreSession with RestoreData payload
1230        let restored_orders = vec![
1231            crate::message::RestoredOrdersInfo {
1232                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1233                trade_index: 1,
1234                status: "active".to_string(),
1235            },
1236            crate::message::RestoredOrdersInfo {
1237                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1238                trade_index: 2,
1239                status: "success".to_string(),
1240            },
1241        ];
1242
1243        let restored_disputes = vec![
1244            crate::message::RestoredDisputesInfo {
1245                dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1246                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1247                trade_index: 1,
1248                status: "initiated".to_string(),
1249                initiator: Some(crate::message::DisputeInitiator::Buyer),
1250                solver_pubkey: None,
1251            },
1252            crate::message::RestoredDisputesInfo {
1253                dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1254                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1255                trade_index: 2,
1256                status: "in-progress".to_string(),
1257                initiator: None,
1258                solver_pubkey: Some(
1259                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1260                ),
1261            },
1262            crate::message::RestoredDisputesInfo {
1263                dispute_id: uuid!("708e1272-d5f4-47e6-bd97-3504baea9c27"),
1264                order_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1265                trade_index: 3,
1266                status: "initiated".to_string(),
1267                initiator: Some(crate::message::DisputeInitiator::Seller),
1268                solver_pubkey: None,
1269            },
1270        ];
1271
1272        let restore_session_info = crate::message::RestoreSessionInfo {
1273            restore_orders: restored_orders.clone(),
1274            restore_disputes: restored_disputes.clone(),
1275        };
1276
1277        let restore_data_payload = Payload::RestoreData(restore_session_info);
1278        let restore_data_message = Message::Restore(MessageKind::new(
1279            None,
1280            None,
1281            None,
1282            Action::RestoreSession,
1283            Some(restore_data_payload),
1284        ));
1285
1286        // With new logic, any payload for RestoreSession is invalid (must be None)
1287        assert!(!restore_data_message.verify());
1288
1289        // Verify serialization/deserialization of RestoreData payload with all initiator cases
1290        let message_json = restore_data_message.as_json().unwrap();
1291        let deserialized_restore_message = Message::from_json(&message_json).unwrap();
1292
1293        if let Message::Restore(kind) = deserialized_restore_message {
1294            if let Some(Payload::RestoreData(session_info)) = kind.payload {
1295                assert_eq!(session_info.restore_disputes.len(), 3);
1296                assert_eq!(
1297                    session_info.restore_disputes[0].initiator,
1298                    Some(crate::message::DisputeInitiator::Buyer)
1299                );
1300                assert!(session_info.restore_disputes[0].solver_pubkey.is_none());
1301                assert_eq!(session_info.restore_disputes[1].initiator, None);
1302                assert_eq!(
1303                    session_info.restore_disputes[1].solver_pubkey,
1304                    Some(
1305                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1306                            .to_string()
1307                    )
1308                );
1309                assert_eq!(
1310                    session_info.restore_disputes[2].initiator,
1311                    Some(crate::message::DisputeInitiator::Seller)
1312                );
1313                assert!(session_info.restore_disputes[2].solver_pubkey.is_none());
1314            } else {
1315                panic!("Expected RestoreData payload");
1316            }
1317        } else {
1318            panic!("Expected Restore message");
1319        }
1320    }
1321
1322    #[test]
1323    fn test_restore_session_message_validation() {
1324        // Test that RestoreSession action accepts only payload=None or RestoreData
1325        let restore_request_message = Message::Restore(MessageKind::new(
1326            None,
1327            None,
1328            None,
1329            Action::RestoreSession,
1330            None, // Missing payload
1331        ));
1332
1333        // Verify restore request message
1334        assert!(restore_request_message.verify());
1335
1336        // Test with wrong payload type
1337        let wrong_payload = Payload::TextMessage("wrong payload".to_string());
1338        let wrong_message = Message::Restore(MessageKind::new(
1339            None,
1340            None,
1341            None,
1342            Action::RestoreSession,
1343            Some(wrong_payload),
1344        ));
1345
1346        // Should fail validation because RestoreSession only accepts None
1347        assert!(!wrong_message.verify());
1348
1349        // With new logic, presence of id/request_id/trade_index is allowed
1350        let with_id = Message::Restore(MessageKind::new(
1351            Some(uuid!("00000000-0000-0000-0000-000000000001")),
1352            None,
1353            None,
1354            Action::RestoreSession,
1355            None,
1356        ));
1357        assert!(with_id.verify());
1358
1359        let with_request_id = Message::Restore(MessageKind::new(
1360            None,
1361            Some(42),
1362            None,
1363            Action::RestoreSession,
1364            None,
1365        ));
1366        assert!(with_request_id.verify());
1367
1368        let with_trade_index = Message::Restore(MessageKind::new(
1369            None,
1370            None,
1371            Some(7),
1372            Action::RestoreSession,
1373            None,
1374        ));
1375        assert!(with_trade_index.verify());
1376    }
1377
1378    #[test]
1379    fn test_restore_session_message_constructor() {
1380        // Test the new_restore constructor
1381        let restore_request_message = Message::new_restore(None);
1382
1383        assert!(matches!(restore_request_message, Message::Restore(_)));
1384        assert!(restore_request_message.verify());
1385        assert_eq!(
1386            restore_request_message.inner_action(),
1387            Some(Action::RestoreSession)
1388        );
1389
1390        // Test with RestoreData payload should be invalid now
1391        let restore_session_info = crate::message::RestoreSessionInfo {
1392            restore_orders: vec![],
1393            restore_disputes: vec![],
1394        };
1395        let restore_data_message =
1396            Message::new_restore(Some(Payload::RestoreData(restore_session_info)));
1397
1398        assert!(matches!(restore_data_message, Message::Restore(_)));
1399        assert!(!restore_data_message.verify());
1400    }
1401
1402    #[test]
1403    fn test_last_trade_index_valid_message() {
1404        let kind = MessageKind::new(None, None, Some(7), Action::LastTradeIndex, None);
1405        let msg = Message::Restore(kind);
1406
1407        assert!(msg.verify());
1408
1409        // roundtrip
1410        let json = msg.as_json().unwrap();
1411        let decoded = Message::from_json(&json).unwrap();
1412        assert!(decoded.verify());
1413
1414        // ensure the trade index is propagated
1415        let inner = decoded.get_inner_message_kind();
1416        assert_eq!(inner.trade_index(), 7);
1417        assert_eq!(inner.has_trade_index(), (true, 7));
1418    }
1419
1420    #[test]
1421    fn test_last_trade_index_without_id_is_valid() {
1422        // With new logic, id is not required; only payload must be None
1423        let kind = MessageKind::new(None, None, Some(5), Action::LastTradeIndex, None);
1424        let msg = Message::Restore(kind);
1425        assert!(msg.verify());
1426    }
1427
1428    #[test]
1429    fn test_last_trade_index_with_payload_fails_validation() {
1430        // LastTradeIndex does not accept payload
1431        let kind = MessageKind::new(
1432            None,
1433            None,
1434            Some(3),
1435            Action::LastTradeIndex,
1436            Some(Payload::TextMessage("ignored".to_string())),
1437        );
1438        let msg = Message::Restore(kind);
1439        assert!(!msg.verify());
1440    }
1441
1442    #[test]
1443    fn test_bond_resolution_admin_actions_accept_payload_or_none() {
1444        use crate::message::BondResolution;
1445
1446        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1447
1448        for action in [Action::AdminSettle, Action::AdminCancel] {
1449            let with_resolution = Message::Order(MessageKind::new(
1450                Some(uuid),
1451                Some(1),
1452                Some(2),
1453                action.clone(),
1454                Some(Payload::BondResolution(BondResolution {
1455                    slash_seller: true,
1456                    slash_buyer: false,
1457                })),
1458            ));
1459            assert!(
1460                with_resolution.verify(),
1461                "{action:?} + BondResolution should verify"
1462            );
1463
1464            let without_payload = Message::Order(MessageKind::new(
1465                Some(uuid),
1466                Some(1),
1467                Some(2),
1468                action.clone(),
1469                None,
1470            ));
1471            assert!(without_payload.verify(), "{action:?} + None should verify");
1472
1473            // Wrong payload type must be rejected for these admin actions.
1474            let wrong = Message::Order(MessageKind::new(
1475                Some(uuid),
1476                Some(1),
1477                Some(2),
1478                action.clone(),
1479                Some(Payload::TextMessage("nope".to_string())),
1480            ));
1481            assert!(!wrong.verify(), "{action:?} + TextMessage must be rejected");
1482
1483            // Missing id is still invalid.
1484            let no_id = Message::Order(MessageKind::new(
1485                None,
1486                Some(1),
1487                Some(2),
1488                action,
1489                Some(Payload::BondResolution(BondResolution {
1490                    slash_seller: false,
1491                    slash_buyer: false,
1492                })),
1493            ));
1494            assert!(!no_id.verify(), "admin action without id must be rejected");
1495        }
1496    }
1497
1498    #[test]
1499    fn test_bond_resolution_rejected_on_non_admin_actions() {
1500        use crate::message::BondResolution;
1501
1502        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1503        let payload = Payload::BondResolution(BondResolution {
1504            slash_seller: true,
1505            slash_buyer: true,
1506        });
1507
1508        // Every Action except AdminSettle / AdminCancel must reject a
1509        // BondResolution payload. Listed explicitly (no strum) so that adding
1510        // a new Action variant forces a compile-error reminder here.
1511        for action in [
1512            Action::NewOrder,
1513            Action::TakeSell,
1514            Action::TakeBuy,
1515            Action::PayInvoice,
1516            Action::PayBondInvoice,
1517            Action::FiatSent,
1518            Action::FiatSentOk,
1519            Action::Release,
1520            Action::Released,
1521            Action::Cancel,
1522            Action::Canceled,
1523            Action::CooperativeCancelInitiatedByYou,
1524            Action::CooperativeCancelInitiatedByPeer,
1525            Action::DisputeInitiatedByYou,
1526            Action::DisputeInitiatedByPeer,
1527            Action::CooperativeCancelAccepted,
1528            Action::BuyerInvoiceAccepted,
1529            Action::PurchaseCompleted,
1530            Action::HoldInvoicePaymentAccepted,
1531            Action::HoldInvoicePaymentSettled,
1532            Action::HoldInvoicePaymentCanceled,
1533            Action::WaitingSellerToPay,
1534            Action::WaitingBuyerInvoice,
1535            Action::AddInvoice,
1536            Action::AddBondInvoice,
1537            Action::BuyerTookOrder,
1538            Action::Rate,
1539            Action::RateUser,
1540            Action::RateReceived,
1541            Action::CantDo,
1542            Action::Dispute,
1543            Action::AdminCanceled,
1544            Action::AdminSettled,
1545            Action::AdminAddSolver,
1546            Action::AdminTakeDispute,
1547            Action::AdminTookDispute,
1548            Action::PaymentFailed,
1549            Action::InvoiceUpdated,
1550            Action::SendDm,
1551            Action::TradePubkey,
1552            Action::RestoreSession,
1553            Action::LastTradeIndex,
1554            Action::Orders,
1555        ] {
1556            let msg = Message::Order(MessageKind::new(
1557                Some(uuid),
1558                Some(1),
1559                Some(2),
1560                action.clone(),
1561                Some(payload.clone()),
1562            ));
1563            assert!(
1564                !msg.verify(),
1565                "{action:?} must reject BondResolution payload"
1566            );
1567        }
1568    }
1569
1570    #[test]
1571    fn test_bond_resolution_wire_format() {
1572        use crate::message::BondResolution;
1573
1574        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1575        let msg = Message::Order(MessageKind::new(
1576            Some(uuid),
1577            None,
1578            None,
1579            Action::AdminCancel,
1580            Some(Payload::BondResolution(BondResolution {
1581                slash_seller: true,
1582                slash_buyer: false,
1583            })),
1584        ));
1585
1586        let json = msg.as_json().unwrap();
1587        // Variant discriminator must be the snake_case `bond_resolution`.
1588        assert!(
1589            json.contains("\"bond_resolution\""),
1590            "expected snake_case discriminator, got: {json}"
1591        );
1592        assert!(json.contains("\"slash_seller\":true"));
1593        assert!(json.contains("\"slash_buyer\":false"));
1594
1595        // Roundtrip preserves the variant.
1596        let decoded = Message::from_json(&json).unwrap();
1597        assert!(decoded.verify());
1598        if let Message::Order(kind) = decoded {
1599            match kind.payload {
1600                Some(Payload::BondResolution(b)) => {
1601                    assert!(b.slash_seller);
1602                    assert!(!b.slash_buyer);
1603                }
1604                other => panic!("expected BondResolution payload, got {other:?}"),
1605            }
1606        } else {
1607            panic!("expected Order message");
1608        }
1609    }
1610
1611    #[test]
1612    fn test_bond_resolution_legacy_null_payload() {
1613        // payload = null on AdminSettle/AdminCancel must keep verifying so
1614        // pre-BondResolution clients keep working (interpreted as "no slash"
1615        // by the server).
1616        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1617        let json = format!(
1618            r#"{{"order":{{"version":1,"id":"{uuid}","action":"admin-cancel","payload":null}}}}"#
1619        );
1620        let msg = Message::from_json(&json).unwrap();
1621        assert!(msg.verify());
1622    }
1623
1624    #[test]
1625    fn test_pay_bond_invoice_wire_format_and_verify() {
1626        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1627        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1628
1629        let msg = Message::Order(MessageKind::new(
1630            Some(uuid),
1631            Some(1),
1632            Some(2),
1633            Action::PayBondInvoice,
1634            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1635        ));
1636        assert!(msg.verify());
1637
1638        // Wire format must use the kebab-case discriminator.
1639        let json = msg.as_json().unwrap();
1640        assert!(
1641            json.contains("\"action\":\"pay-bond-invoice\""),
1642            "expected kebab-case discriminator, got: {json}"
1643        );
1644
1645        // Roundtrip preserves the variant.
1646        let decoded = Message::from_json(&json).unwrap();
1647        assert!(decoded.verify());
1648        assert!(matches!(
1649            decoded.inner_action(),
1650            Some(Action::PayBondInvoice)
1651        ));
1652
1653        // Same id / payload constraints as PayInvoice: missing id is invalid.
1654        let no_id = Message::Order(MessageKind::new(
1655            None,
1656            Some(1),
1657            Some(2),
1658            Action::PayBondInvoice,
1659            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1660        ));
1661        assert!(!no_id.verify());
1662
1663        // Wrong payload shape is rejected.
1664        let wrong_payload = Message::Order(MessageKind::new(
1665            Some(uuid),
1666            Some(1),
1667            Some(2),
1668            Action::PayBondInvoice,
1669            Some(Payload::TextMessage("nope".to_string())),
1670        ));
1671        assert!(!wrong_payload.verify());
1672
1673        // Missing payload is rejected (PaymentRequest is required).
1674        let no_payload = Message::Order(MessageKind::new(
1675            Some(uuid),
1676            Some(1),
1677            Some(2),
1678            Action::PayBondInvoice,
1679            None,
1680        ));
1681        assert!(!no_payload.verify());
1682    }
1683
1684    #[test]
1685    fn test_add_bond_invoice_wire_format_and_verify() {
1686        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1687        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1688
1689        let msg = Message::Order(MessageKind::new(
1690            Some(uuid),
1691            Some(1),
1692            Some(2),
1693            Action::AddBondInvoice,
1694            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1695        ));
1696        assert!(msg.verify());
1697
1698        // Wire format must use the kebab-case discriminator.
1699        let json = msg.as_json().unwrap();
1700        assert!(
1701            json.contains("\"action\":\"add-bond-invoice\""),
1702            "expected kebab-case discriminator, got: {json}"
1703        );
1704
1705        // Roundtrip preserves the variant.
1706        let decoded = Message::from_json(&json).unwrap();
1707        assert!(decoded.verify());
1708        assert!(matches!(
1709            decoded.inner_action(),
1710            Some(Action::AddBondInvoice)
1711        ));
1712
1713        // Same id / payload constraints as AddInvoice: missing id is invalid.
1714        let no_id = Message::Order(MessageKind::new(
1715            None,
1716            Some(1),
1717            Some(2),
1718            Action::AddBondInvoice,
1719            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1720        ));
1721        assert!(!no_id.verify());
1722
1723        // Wrong payload shape is rejected.
1724        let wrong_payload = Message::Order(MessageKind::new(
1725            Some(uuid),
1726            Some(1),
1727            Some(2),
1728            Action::AddBondInvoice,
1729            Some(Payload::TextMessage("nope".to_string())),
1730        ));
1731        assert!(!wrong_payload.verify());
1732
1733        // Missing payload is rejected (PaymentRequest is required).
1734        let no_payload = Message::Order(MessageKind::new(
1735            Some(uuid),
1736            Some(1),
1737            Some(2),
1738            Action::AddBondInvoice,
1739            None,
1740        ));
1741        assert!(!no_payload.verify());
1742
1743        // get_payment_request must surface the bolt11 for AddBondInvoice.
1744        if let Message::Order(kind) = &msg {
1745            assert_eq!(kind.get_payment_request(), Some(bolt11));
1746        } else {
1747            panic!("expected Message::Order");
1748        }
1749    }
1750
1751    #[test]
1752    fn test_restored_dispute_helper_serialization_roundtrip() {
1753        use crate::message::RestoredDisputeHelper;
1754
1755        let helper = RestoredDisputeHelper {
1756            dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1757            order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1758            dispute_status: "initiated".to_string(),
1759            master_buyer_pubkey: Some("npub1buyerkey".to_string()),
1760            master_seller_pubkey: Some("npub1sellerkey".to_string()),
1761            trade_index_buyer: Some(1),
1762            trade_index_seller: Some(2),
1763            buyer_dispute: true,
1764            seller_dispute: false,
1765            solver_pubkey: None,
1766        };
1767
1768        let json = serde_json::to_string(&helper).unwrap();
1769        let deserialized: RestoredDisputeHelper = serde_json::from_str(&json).unwrap();
1770
1771        assert_eq!(deserialized.dispute_id, helper.dispute_id);
1772        assert_eq!(deserialized.order_id, helper.order_id);
1773        assert_eq!(deserialized.dispute_status, helper.dispute_status);
1774        assert_eq!(deserialized.master_buyer_pubkey, helper.master_buyer_pubkey);
1775        assert_eq!(
1776            deserialized.master_seller_pubkey,
1777            helper.master_seller_pubkey
1778        );
1779        assert_eq!(deserialized.trade_index_buyer, helper.trade_index_buyer);
1780        assert_eq!(deserialized.trade_index_seller, helper.trade_index_seller);
1781        assert_eq!(deserialized.buyer_dispute, helper.buyer_dispute);
1782        assert_eq!(deserialized.seller_dispute, helper.seller_dispute);
1783        assert_eq!(deserialized.solver_pubkey, helper.solver_pubkey);
1784
1785        let helper_seller_dispute = RestoredDisputeHelper {
1786            dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1787            order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1788            dispute_status: "in-progress".to_string(),
1789            master_buyer_pubkey: None,
1790            master_seller_pubkey: None,
1791            trade_index_buyer: None,
1792            trade_index_seller: None,
1793            buyer_dispute: false,
1794            seller_dispute: true,
1795            solver_pubkey: Some(
1796                "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1797            ),
1798        };
1799
1800        let json_seller = serde_json::to_string(&helper_seller_dispute).unwrap();
1801        let deserialized_seller: RestoredDisputeHelper =
1802            serde_json::from_str(&json_seller).unwrap();
1803
1804        assert_eq!(
1805            deserialized_seller.dispute_id,
1806            helper_seller_dispute.dispute_id
1807        );
1808        assert_eq!(deserialized_seller.order_id, helper_seller_dispute.order_id);
1809        assert_eq!(
1810            deserialized_seller.dispute_status,
1811            helper_seller_dispute.dispute_status
1812        );
1813        assert_eq!(deserialized_seller.master_buyer_pubkey, None);
1814        assert_eq!(deserialized_seller.master_seller_pubkey, None);
1815        assert_eq!(deserialized_seller.trade_index_buyer, None);
1816        assert_eq!(deserialized_seller.trade_index_seller, None);
1817        assert!(!deserialized_seller.buyer_dispute);
1818        assert!(deserialized_seller.seller_dispute);
1819        assert_eq!(
1820            deserialized_seller.solver_pubkey,
1821            helper_seller_dispute.solver_pubkey
1822        );
1823    }
1824}