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