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    /// Mostro accepted the winning counterparty's bond-payout invoice.
109    /// Bond dual of [`Action::BuyerInvoiceAccepted`] (Mostro → winner):
110    /// the payout bolt11 was received and the payment is now pending, so
111    /// the client can stop prompting the user for an invoice.
112    /// Payload: [`Payload::Order`] (amount = counterparty share).
113    BondInvoiceAccepted,
114    /// Trade completed successfully.
115    PurchaseCompleted,
116    /// Mostro paid out a slashed bond's counterparty share successfully.
117    /// Bond dual of [`Action::PurchaseCompleted`] (Mostro → winner): the
118    /// `send_payment` to the winner's bolt11 succeeded and the bond is
119    /// now settled, so the client can close the claim.
120    /// Payload: [`Payload::Order`] (amount = counterparty share).
121    BondPayoutCompleted,
122    /// Mostro notifies a user that their anti-abuse bond was slashed for
123    /// letting a waiting-state timeout elapse (Mostro → slashed user). The
124    /// bond's hold invoice has been settled and the row moved to
125    /// `pending-payout`; the user keeps no claim over the forfeited sats.
126    /// Informational only — the slashed user receives this alongside the
127    /// `Action::Canceled` for the order itself.
128    /// Payload: [`Payload::Order`] (amount = slashed bond amount).
129    BondSlashed,
130    /// Mostro saw the hold-invoice payment accepted by the node.
131    HoldInvoicePaymentAccepted,
132    /// Mostro saw the hold-invoice payment settled.
133    HoldInvoicePaymentSettled,
134    /// Mostro saw the hold-invoice payment canceled.
135    HoldInvoicePaymentCanceled,
136    /// Informational: waiting for the seller to pay the hold invoice.
137    WaitingSellerToPay,
138    /// Informational: waiting for the buyer's payout invoice.
139    WaitingBuyerInvoice,
140    /// Buyer sends/updates its payout invoice.
141    /// Payload: [`Payload::PaymentRequest`].
142    AddInvoice,
143    /// Taker sends a Lightning invoice that Mostro must pay out as the
144    /// taker's anti-abuse bond. Same payload shape and direction as
145    /// [`Action::AddInvoice`] (user → Mostro); only the discriminator
146    /// differs so Mostro can tell a bond-payout invoice apart from a
147    /// buyer's trade payout invoice.
148    /// Payload: [`Payload::PaymentRequest`].
149    AddBondInvoice,
150    /// Informational: a buyer has taken a sell order.
151    BuyerTookOrder,
152    /// Server-initiated rating request.
153    Rate,
154    /// Client-initiated rate. Payload: [`Payload::RatingUser`].
155    RateUser,
156    /// Acknowledgement of a received rating.
157    RateReceived,
158    /// Mostro returns a structured refusal. Payload: [`Payload::CantDo`].
159    CantDo,
160    /// Client-initiated dispute.
161    Dispute,
162    /// Admin cancels a trade.
163    AdminCancel,
164    /// Admin cancel acknowledged.
165    AdminCanceled,
166    /// Admin settles the hold invoice.
167    AdminSettle,
168    /// Admin settle acknowledged.
169    AdminSettled,
170    /// Admin registers a new dispute solver.
171    AdminAddSolver,
172    /// Solver takes a dispute.
173    AdminTakeDispute,
174    /// Solver took the dispute acknowledged.
175    AdminTookDispute,
176    /// Notification that a Lightning payment failed.
177    /// Payload: [`Payload::PaymentFailed`].
178    PaymentFailed,
179    /// Invoice associated with the order was updated.
180    InvoiceUpdated,
181    /// Direct message between users. Payload: [`Payload::TextMessage`].
182    SendDm,
183    /// Disclosure of a counterpart's trade pubkey. Payload: [`Payload::Peer`].
184    TradePubkey,
185    /// Client asks Mostro to restore its session state. Payload must be `None`.
186    RestoreSession,
187    /// Client asks Mostro for its last known trade index. Payload must be
188    /// `None`.
189    LastTradeIndex,
190    /// Listing of orders in response to a query.
191    /// Payload: [`Payload::Ids`] or [`Payload::Orders`].
192    Orders,
193}
194
195impl fmt::Display for Action {
196    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
197        write!(f, "{self:?}")
198    }
199}
200
201/// Top-level Mostro message exchanged between users and Mostro.
202///
203/// `Message` is a tagged union: every variant carries the shared
204/// [`MessageKind`] body, while the variant itself tells the receiver which
205/// channel the message belongs to (orders, disputes, DMs, rating, can't-do,
206/// session restore). Serializes as `kebab-case` JSON.
207#[derive(Debug, Clone, Deserialize, Serialize)]
208#[serde(rename_all = "kebab-case")]
209pub enum Message {
210    /// Order-channel message.
211    Order(MessageKind),
212    /// Dispute-channel message.
213    Dispute(MessageKind),
214    /// "Can't do" response returned by the Mostro node.
215    CantDo(MessageKind),
216    /// Rating message (server-initiated rate request or client rate).
217    Rate(MessageKind),
218    /// Direct message between users.
219    Dm(MessageKind),
220    /// Session restore request/response.
221    Restore(MessageKind),
222}
223
224impl Message {
225    /// Build a new `Message::Order` wrapping a freshly constructed
226    /// [`MessageKind`].
227    pub fn new_order(
228        id: Option<Uuid>,
229        request_id: Option<u64>,
230        trade_index: Option<i64>,
231        action: Action,
232        payload: Option<Payload>,
233    ) -> Self {
234        let kind = MessageKind::new(id, request_id, trade_index, action, payload);
235        Self::Order(kind)
236    }
237
238    /// Build a new `Message::Dispute` wrapping a freshly constructed
239    /// [`MessageKind`].
240    pub fn new_dispute(
241        id: Option<Uuid>,
242        request_id: Option<u64>,
243        trade_index: Option<i64>,
244        action: Action,
245        payload: Option<Payload>,
246    ) -> Self {
247        let kind = MessageKind::new(id, request_id, trade_index, action, payload);
248
249        Self::Dispute(kind)
250    }
251
252    /// Build a new `Message::Restore` with [`Action::RestoreSession`].
253    ///
254    /// According to [`MessageKind::verify`], the payload for a restore
255    /// request must be `None`. Any other payload yields an invalid message.
256    pub fn new_restore(payload: Option<Payload>) -> Self {
257        let kind = MessageKind::new(None, None, None, Action::RestoreSession, payload);
258        Self::Restore(kind)
259    }
260
261    /// Build a new `Message::CantDo` message (a structured refusal sent by
262    /// Mostro when a request cannot be fulfilled).
263    pub fn cant_do(id: Option<Uuid>, request_id: Option<u64>, payload: Option<Payload>) -> Self {
264        let kind = MessageKind::new(id, request_id, None, Action::CantDo, payload);
265
266        Self::CantDo(kind)
267    }
268
269    /// Build a new `Message::Dm` carrying a direct message between users.
270    pub fn new_dm(
271        id: Option<Uuid>,
272        request_id: Option<u64>,
273        action: Action,
274        payload: Option<Payload>,
275    ) -> Self {
276        let kind = MessageKind::new(id, request_id, None, action, payload);
277
278        Self::Dm(kind)
279    }
280
281    /// Parse a [`Message`] from its JSON representation.
282    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
283        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
284    }
285
286    /// Serialize the message to a JSON string.
287    pub fn as_json(&self) -> Result<String, ServiceError> {
288        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
289    }
290
291    /// Borrow the inner [`MessageKind`] regardless of the variant.
292    pub fn get_inner_message_kind(&self) -> &MessageKind {
293        match self {
294            Message::Dispute(k)
295            | Message::Order(k)
296            | Message::CantDo(k)
297            | Message::Rate(k)
298            | Message::Dm(k)
299            | Message::Restore(k) => k,
300        }
301    }
302
303    /// Return the [`Action`] of the inner [`MessageKind`].
304    ///
305    /// Always returns `Some` for the current variant set; the `Option` is
306    /// kept for API stability.
307    pub fn inner_action(&self) -> Option<Action> {
308        match self {
309            Message::Dispute(a)
310            | Message::Order(a)
311            | Message::CantDo(a)
312            | Message::Rate(a)
313            | Message::Dm(a)
314            | Message::Restore(a) => Some(a.get_action()),
315        }
316    }
317
318    /// Validate that the inner [`MessageKind`] is consistent with its
319    /// [`Action`]. Delegates to [`MessageKind::verify`].
320    pub fn verify(&self) -> bool {
321        match self {
322            Message::Order(m)
323            | Message::Dispute(m)
324            | Message::CantDo(m)
325            | Message::Rate(m)
326            | Message::Dm(m)
327            | Message::Restore(m) => m.verify(),
328        }
329    }
330
331    /// Produce a Schnorr signature over the SHA-256 digest of `message`
332    /// using `keys`.
333    ///
334    /// This is the signature embedded in the rumor tuple when
335    /// [`crate::nip59::wrap_message`] is called with
336    /// [`WrapOptions::signed`](crate::nip59::WrapOptions::signed) set to
337    /// `true`. It binds a message to the sender's trade keys without
338    /// relying on the outer Nostr event signature.
339    pub fn sign(message: String, keys: &Keys) -> Signature {
340        let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes());
341        let hash = hash.to_byte_array();
342        let message: BitcoinMessage = BitcoinMessage::from_digest(hash);
343
344        keys.sign_schnorr(&message)
345    }
346
347    /// Verify a signature previously produced by [`Message::sign`].
348    ///
349    /// Returns `true` when `sig` is a valid Schnorr signature of the
350    /// SHA-256 digest of `message` under `pubkey`, `false` otherwise
351    /// (including when `pubkey` has no x-only representation).
352    pub fn verify_signature(message: String, pubkey: PublicKey, sig: Signature) -> bool {
353        // Create payload hash
354        let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes());
355        let hash = hash.to_byte_array();
356        let message: BitcoinMessage = BitcoinMessage::from_digest(hash);
357
358        // Create a verification-only context for better performance
359        let secp = Secp256k1::verification_only();
360        // Verify signature
361        if let Ok(xonlykey) = pubkey.xonly() {
362            xonlykey.verify(&secp, &message, &sig).is_ok()
363        } else {
364            false
365        }
366    }
367}
368
369/// Body shared by every [`Message`] variant.
370///
371/// All Mostro protocol messages share this envelope: a protocol version,
372/// an optional client-chosen request id for correlation, a trade index used
373/// to enforce strictly increasing sequences per user, an optional
374/// order/dispute id, an [`Action`] and an optional [`Payload`].
375#[derive(Debug, Clone, Deserialize, Serialize)]
376pub struct MessageKind {
377    /// Mostro protocol version. Set to
378    /// `PROTOCOL_VER` by [`MessageKind::new`].
379    pub version: u8,
380    /// Client-chosen correlation id, echoed back on responses so the client
381    /// can match them to in-flight requests.
382    pub request_id: Option<u64>,
383    /// Trade index attached to this message. Must be strictly greater than
384    /// the last trade index Mostro has seen for the sender.
385    pub trade_index: Option<i64>,
386    /// Optional target identifier (usually the id of an [`crate::order::Order`]
387    /// or [`crate::dispute::Dispute`]).
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub id: Option<Uuid>,
390    /// Verb of the message.
391    pub action: Action,
392    /// Payload attached to the action. The allowed shape for a given action
393    /// is enforced by [`MessageKind::verify`].
394    pub payload: Option<Payload>,
395}
396
397/// Alias for a signed integer amount in satoshis.
398type Amount = i64;
399
400/// Retry configuration for a failed Lightning payment.
401///
402/// Sent inside a [`Payload::PaymentFailed`] so the client knows how many
403/// retries to expect and how long to wait between them.
404#[derive(Debug, Deserialize, Serialize, Clone)]
405pub struct PaymentFailedInfo {
406    /// Maximum number of payment attempts Mostro will perform.
407    pub payment_attempts: u32,
408    /// Delay in seconds between two retry attempts.
409    pub payment_retries_interval: u32,
410}
411
412/// Row-mapper used by `mostrod` when fetching metadata for session restore.
413///
414/// Not intended as a general-purpose order representation — field names are
415/// chosen to match the SQL `SELECT` aliases used by the server query.
416#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
417#[derive(Debug, Deserialize, Serialize, Clone)]
418pub struct RestoredOrderHelper {
419    /// Order id.
420    pub id: Uuid,
421    /// Order status, serialized as kebab-case.
422    pub status: String,
423    /// Master identity pubkey of the buyer, if any.
424    pub master_buyer_pubkey: Option<String>,
425    /// Master identity pubkey of the seller, if any.
426    pub master_seller_pubkey: Option<String>,
427    /// Trade index the buyer used on this order.
428    pub trade_index_buyer: Option<i64>,
429    /// Trade index the seller used on this order.
430    pub trade_index_seller: Option<i64>,
431}
432
433/// Row-mapper used by `mostrod` when fetching disputes for session restore.
434///
435/// Field names are chosen to match the SQL `SELECT` aliases in the restore
436/// query (in particular `status` is aliased as `dispute_status`).
437#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
438#[derive(Debug, Deserialize, Serialize, Clone)]
439pub struct RestoredDisputeHelper {
440    /// Dispute id.
441    pub dispute_id: Uuid,
442    /// Order id the dispute is attached to.
443    pub order_id: Uuid,
444    /// Dispute status, serialized as kebab-case.
445    pub dispute_status: String,
446    /// Master identity pubkey of the buyer, if any.
447    pub master_buyer_pubkey: Option<String>,
448    /// Master identity pubkey of the seller, if any.
449    pub master_seller_pubkey: Option<String>,
450    /// Trade index the buyer used on the parent order.
451    pub trade_index_buyer: Option<i64>,
452    /// Trade index the seller used on the parent order.
453    pub trade_index_seller: Option<i64>,
454    /// Whether the buyer has initiated a dispute for this order.
455    /// Combined with [`Self::seller_dispute`] to derive
456    /// [`RestoredDisputesInfo::initiator`].
457    pub buyer_dispute: bool,
458    /// Whether the seller has initiated a dispute for this order.
459    /// Combined with [`Self::buyer_dispute`] to derive
460    /// [`RestoredDisputesInfo::initiator`].
461    pub seller_dispute: bool,
462    /// Public key of the solver assigned to the dispute, `None` if no
463    /// solver has taken it.
464    pub solver_pubkey: Option<String>,
465}
466
467/// Minimal per-order information returned to a client on session restore.
468#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
469#[derive(Debug, Deserialize, Serialize, Clone)]
470pub struct RestoredOrdersInfo {
471    /// Id of the order.
472    pub order_id: Uuid,
473    /// Trade index of the order as seen by the requesting user.
474    pub trade_index: i64,
475    /// Current status of the order, serialized as kebab-case.
476    pub status: String,
477}
478
479/// Identifies which party of an order opened a dispute.
480#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
481#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
482#[serde(rename_all = "lowercase")]
483#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT", rename_all = "lowercase"))]
484pub enum DisputeInitiator {
485    /// The buyer opened the dispute.
486    Buyer,
487    /// The seller opened the dispute.
488    Seller,
489}
490
491/// Minimal per-dispute information returned to a client on session restore.
492#[cfg_attr(feature = "sqlx", derive(FromRow, SqlxCrud))]
493#[derive(Debug, Deserialize, Serialize, Clone)]
494pub struct RestoredDisputesInfo {
495    /// Id of the dispute.
496    pub dispute_id: Uuid,
497    /// Id of the order the dispute is attached to.
498    pub order_id: Uuid,
499    /// Trade index of the dispute as seen by the requesting user.
500    pub trade_index: i64,
501    /// Current status of the dispute, serialized as kebab-case.
502    pub status: String,
503    /// Who initiated the dispute: [`DisputeInitiator::Buyer`],
504    /// [`DisputeInitiator::Seller`], or `None` when unknown.
505    pub initiator: Option<DisputeInitiator>,
506    /// Public key of the solver assigned to the dispute, `None` if no
507    /// solver has taken it yet.
508    pub solver_pubkey: Option<String>,
509}
510
511/// Bundle of orders and disputes returned on a session restore.
512///
513/// Carried inside [`Payload::RestoreData`]. The server typically sends this
514/// struct in the response to a [`Action::RestoreSession`] request.
515#[derive(Debug, Deserialize, Serialize, Clone, Default)]
516pub struct RestoreSessionInfo {
517    /// Orders associated with the requesting user.
518    #[serde(rename = "orders")]
519    pub restore_orders: Vec<RestoredOrdersInfo>,
520    /// Disputes associated with the requesting user.
521    #[serde(rename = "disputes")]
522    pub restore_disputes: Vec<RestoredDisputesInfo>,
523}
524
525/// Bond resolution carried by [`Action::AdminSettle`] /
526/// [`Action::AdminCancel`].
527///
528/// Lets the solver express slash decisions independently from the trade
529/// outcome (settle vs cancel). Absent payload (`null`) ⇒ neither bond is
530/// slashed (release-by-default, coherent with the "when in doubt, release"
531/// invariant).
532#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Default)]
533pub struct BondResolution {
534    /// Slash the seller's bond (if posted).
535    pub slash_seller: bool,
536    /// Slash the buyer's bond (if posted).
537    pub slash_buyer: bool,
538}
539
540/// Outbound side of the bond payout invoice request carried by
541/// [`Action::AddBondInvoice`] (Mostro → winning counterparty).
542///
543/// Asks the recipient for a bolt11 sized at `order.amount` (= the
544/// counterparty share of a slashed bond) and ships the slash anchor
545/// `slashed_at` so the client can compute the forfeit deadline as
546/// `slashed_at + bond_payout_claim_window_days * 86_400` — accurate even
547/// when the message lands days late because the recipient or their relay
548/// was offline.
549///
550/// The reply (counterparty → Mostro) reuses [`Payload::PaymentRequest`]
551/// with the actual bolt11 in the second tuple slot, so the two
552/// directions of the same action are wire-distinguishable by payload
553/// shape rather than by message ordering.
554#[derive(Debug, Deserialize, Serialize, Clone)]
555pub struct BondPayoutRequest {
556    /// Order context (id, kind, `amount` = counterparty share in sats,
557    /// fiat metadata, etc.). Same [`SmallOrder`] shape the client
558    /// already renders for other order-bearing actions.
559    pub order: SmallOrder,
560    /// Unix timestamp (seconds, UTC) at which Mostro recorded the slash
561    /// decision. Frozen at the `Locked → PendingPayout` CAS and shipped
562    /// verbatim on every cadence retry of this request — clients can
563    /// rely on it as a fixed anchor.
564    pub slashed_at: i64,
565}
566
567/// Typed payload attached to a [`MessageKind`].
568///
569/// Each variant corresponds to a set of [`Action`] values that can legally
570/// carry it (see [`MessageKind::verify`]). Serialized in `snake_case` so
571/// that the variant name is the JSON discriminator.
572#[derive(Debug, Deserialize, Serialize, Clone)]
573#[serde(rename_all = "snake_case")]
574pub enum Payload {
575    /// A compact representation of an order used by [`Action::NewOrder`].
576    Order(SmallOrder),
577    /// Lightning payment request plus optional amount override.
578    ///
579    /// Used by [`Action::PayInvoice`], [`Action::PayBondInvoice`],
580    /// [`Action::AddInvoice`], [`Action::AddBondInvoice`] and
581    /// [`Action::TakeSell`]. The [`SmallOrder`]
582    /// carries the matching order when relevant; the `String` is a BOLT-11
583    /// invoice.
584    PaymentRequest(Option<SmallOrder>, String, Option<Amount>),
585    /// Free-form text message used by DMs.
586    TextMessage(String),
587    /// Peer disclosure (trade pubkey and optional reputation).
588    Peer(Peer),
589    /// Rating value the user wants to attach to a completed trade.
590    RatingUser(u8),
591    /// Raw amount in satoshis (for actions that accept an amount override).
592    Amount(Amount),
593    /// Dispute context: the dispute id plus optional
594    /// [`SolverDisputeInfo`] bundle sent to solvers.
595    Dispute(Uuid, Option<SolverDisputeInfo>),
596    /// Reason carried by a [`Action::CantDo`] response.
597    CantDo(Option<CantDoReason>),
598    /// Next trade key and index announced by the maker of a range order
599    /// when it emits [`Action::Release`] or [`Action::FiatSent`].
600    NextTrade(String, u32),
601    /// Retry configuration surfaced by [`Action::PaymentFailed`].
602    PaymentFailed(PaymentFailedInfo),
603    /// Payload returned by the server on a session restore.
604    RestoreData(RestoreSessionInfo),
605    /// Vector of order ids (lightweight listing).
606    Ids(Vec<Uuid>),
607    /// Vector of [`SmallOrder`] values (full listing).
608    Orders(Vec<SmallOrder>),
609    /// Slash decisions carried by [`Action::AdminSettle`] /
610    /// [`Action::AdminCancel`]. See [`BondResolution`].
611    BondResolution(BondResolution),
612    /// Outbound bond payout invoice request carried by
613    /// [`Action::AddBondInvoice`] (Mostro → counterparty). The reply
614    /// direction (counterparty → Mostro) keeps using
615    /// [`Payload::PaymentRequest`] with the actual bolt11. See
616    /// [`BondPayoutRequest`].
617    BondPayoutRequest(BondPayoutRequest),
618}
619
620#[allow(dead_code)]
621impl MessageKind {
622    /// Build a new [`MessageKind`] stamped with the current protocol
623    /// version (`PROTOCOL_VER`).
624    pub fn new(
625        id: Option<Uuid>,
626        request_id: Option<u64>,
627        trade_index: Option<i64>,
628        action: Action,
629        payload: Option<Payload>,
630    ) -> Self {
631        Self {
632            version: PROTOCOL_VER,
633            request_id,
634            trade_index,
635            id,
636            action,
637            payload,
638        }
639    }
640    /// Parse a [`MessageKind`] from its JSON representation.
641    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
642        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
643    }
644    /// Serialize the [`MessageKind`] to a JSON string.
645    pub fn as_json(&self) -> Result<String, ServiceError> {
646        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
647    }
648
649    /// Return a clone of the [`Action`] carried by this message.
650    pub fn get_action(&self) -> Action {
651        self.action.clone()
652    }
653
654    /// Extract the `(next_trade_pubkey, next_trade_index)` pair from a
655    /// [`Payload::NextTrade`] payload.
656    ///
657    /// Returns `Ok(None)` when there is no payload at all and
658    /// [`ServiceError::InvalidPayload`] when the payload is present but of
659    /// a different variant.
660    pub fn get_next_trade_key(&self) -> Result<Option<(String, u32)>, ServiceError> {
661        match &self.payload {
662            Some(Payload::NextTrade(key, index)) => Ok(Some((key.to_string(), *index))),
663            None => Ok(None),
664            _ => Err(ServiceError::InvalidPayload),
665        }
666    }
667
668    /// Extract the rating value from a [`Payload::RatingUser`] payload,
669    /// validating it against
670    /// [`MIN_RATING`]`..=`[`MAX_RATING`].
671    ///
672    /// Returns [`ServiceError::InvalidRating`] when the payload shape is
673    /// wrong and [`ServiceError::InvalidRatingValue`] when the value is out
674    /// of range.
675    pub fn get_rating(&self) -> Result<u8, ServiceError> {
676        if let Some(Payload::RatingUser(v)) = self.payload.to_owned() {
677            if !(MIN_RATING..=MAX_RATING).contains(&v) {
678                return Err(ServiceError::InvalidRatingValue);
679            }
680            Ok(v)
681        } else {
682            Err(ServiceError::InvalidRating)
683        }
684    }
685
686    /// Check that the payload, id and trade index are consistent with the
687    /// action carried by this message.
688    ///
689    /// Returns `true` when the combination is well-formed and `false`
690    /// otherwise; Mostro uses this method to reject malformed requests
691    /// before processing them.
692    pub fn verify(&self) -> bool {
693        match &self.action {
694            Action::NewOrder => matches!(&self.payload, Some(Payload::Order(_))),
695            Action::PayInvoice | Action::PayBondInvoice | Action::AddInvoice => {
696                if self.id.is_none() {
697                    return false;
698                }
699                matches!(&self.payload, Some(Payload::PaymentRequest(_, _, _)))
700            }
701            Action::AddBondInvoice => {
702                if self.id.is_none() {
703                    return false;
704                }
705                // Two valid shapes:
706                //   - `BondPayoutRequest` for the outbound direction
707                //     (Mostro → counterparty, "send me a bolt11").
708                //   - `PaymentRequest` for the inbound reply
709                //     (counterparty → Mostro, "here is the bolt11").
710                matches!(
711                    &self.payload,
712                    Some(Payload::BondPayoutRequest(_)) | Some(Payload::PaymentRequest(_, _, _))
713                )
714            }
715            Action::AdminSettle | Action::AdminCancel => {
716                if self.id.is_none() {
717                    return false;
718                }
719                matches!(&self.payload, None | Some(Payload::BondResolution(_)))
720            }
721            Action::TakeSell
722            | Action::TakeBuy
723            | Action::FiatSent
724            | Action::FiatSentOk
725            | Action::Release
726            | Action::Released
727            | Action::Dispute
728            | Action::AdminCanceled
729            | Action::AdminSettled
730            | Action::Rate
731            | Action::RateReceived
732            | Action::AdminTakeDispute
733            | Action::AdminTookDispute
734            | Action::DisputeInitiatedByYou
735            | Action::DisputeInitiatedByPeer
736            | Action::WaitingBuyerInvoice
737            | Action::PurchaseCompleted
738            | Action::BondPayoutCompleted
739            | Action::BondSlashed
740            | Action::HoldInvoicePaymentAccepted
741            | Action::HoldInvoicePaymentSettled
742            | Action::HoldInvoicePaymentCanceled
743            | Action::WaitingSellerToPay
744            | Action::BuyerTookOrder
745            | Action::BuyerInvoiceAccepted
746            | Action::BondInvoiceAccepted
747            | Action::CooperativeCancelInitiatedByYou
748            | Action::CooperativeCancelInitiatedByPeer
749            | Action::CooperativeCancelAccepted
750            | Action::Cancel
751            | Action::InvoiceUpdated
752            | Action::AdminAddSolver
753            | Action::SendDm
754            | Action::TradePubkey
755            | Action::Canceled => {
756                if self.id.is_none() {
757                    return false;
758                }
759                !matches!(
760                    &self.payload,
761                    Some(Payload::BondResolution(_)) | Some(Payload::BondPayoutRequest(_))
762                )
763            }
764            Action::LastTradeIndex | Action::RestoreSession => self.payload.is_none(),
765            Action::PaymentFailed => {
766                if self.id.is_none() {
767                    return false;
768                }
769                matches!(&self.payload, Some(Payload::PaymentFailed(_)))
770            }
771            Action::RateUser => {
772                matches!(&self.payload, Some(Payload::RatingUser(_)))
773            }
774            Action::CantDo => {
775                matches!(&self.payload, Some(Payload::CantDo(_)))
776            }
777            Action::Orders => {
778                matches!(
779                    &self.payload,
780                    Some(Payload::Ids(_)) | Some(Payload::Orders(_))
781                )
782            }
783        }
784    }
785
786    /// Return the [`SmallOrder`] carried by a [`Action::NewOrder`] message.
787    ///
788    /// Yields `None` if the action is not `NewOrder` or the payload is of a
789    /// different variant.
790    pub fn get_order(&self) -> Option<&SmallOrder> {
791        if self.action != Action::NewOrder {
792            return None;
793        }
794        match &self.payload {
795            Some(Payload::Order(o)) => Some(o),
796            _ => None,
797        }
798    }
799
800    /// Return the Lightning payment request embedded in a message.
801    ///
802    /// Valid only for [`Action::TakeSell`], [`Action::AddInvoice`],
803    /// [`Action::AddBondInvoice`] and [`Action::NewOrder`]. For `NewOrder`,
804    /// the invoice is read from the [`SmallOrder::buyer_invoice`] field.
805    /// Returns `None` otherwise.
806    pub fn get_payment_request(&self) -> Option<String> {
807        if self.action != Action::TakeSell
808            && self.action != Action::AddInvoice
809            && self.action != Action::AddBondInvoice
810            && self.action != Action::NewOrder
811        {
812            return None;
813        }
814        match &self.payload {
815            Some(Payload::PaymentRequest(_, pr, _)) => Some(pr.to_owned()),
816            Some(Payload::Order(ord)) => ord.buyer_invoice.to_owned(),
817            _ => None,
818        }
819    }
820
821    /// Return the amount override embedded in a [`Action::TakeSell`] or
822    /// [`Action::TakeBuy`] message, either from a [`Payload::Amount`] or
823    /// from the third element of a [`Payload::PaymentRequest`].
824    pub fn get_amount(&self) -> Option<Amount> {
825        if self.action != Action::TakeSell && self.action != Action::TakeBuy {
826            return None;
827        }
828        match &self.payload {
829            Some(Payload::PaymentRequest(_, _, amount)) => *amount,
830            Some(Payload::Amount(amount)) => Some(*amount),
831            _ => None,
832        }
833    }
834
835    /// Borrow the optional payload.
836    pub fn get_payload(&self) -> Option<&Payload> {
837        self.payload.as_ref()
838    }
839
840    /// Return `(true, index)` when the message carries a trade index,
841    /// `(false, 0)` otherwise.
842    pub fn has_trade_index(&self) -> (bool, i64) {
843        if let Some(index) = self.trade_index {
844            return (true, index);
845        }
846        (false, 0)
847    }
848
849    /// Return the trade index carried by the message, or `0` when absent.
850    pub fn trade_index(&self) -> i64 {
851        if let Some(index) = self.trade_index {
852            return index;
853        }
854        0
855    }
856}
857
858#[cfg(test)]
859mod test {
860    use crate::message::{Action, BondPayoutRequest, Message, MessageKind, Payload, Peer};
861    use crate::order::SmallOrder;
862    use crate::user::UserInfo;
863    use nostr_sdk::Keys;
864    use uuid::uuid;
865
866    #[test]
867    fn test_peer_with_reputation() {
868        // Test creating a Peer with reputation information
869        let reputation = UserInfo {
870            rating: 4.5,
871            reviews: 10,
872            operating_days: 30,
873        };
874        let peer = Peer::new(
875            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
876            Some(reputation.clone()),
877        );
878
879        // Assert the fields are set correctly
880        assert_eq!(
881            peer.pubkey,
882            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
883        );
884        assert!(peer.reputation.is_some());
885        let peer_reputation = peer.reputation.clone().unwrap();
886        assert_eq!(peer_reputation.rating, 4.5);
887        assert_eq!(peer_reputation.reviews, 10);
888        assert_eq!(peer_reputation.operating_days, 30);
889
890        // Test JSON serialization and deserialization
891        let json = peer.as_json().unwrap();
892        let deserialized_peer = Peer::from_json(&json).unwrap();
893        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
894        assert!(deserialized_peer.reputation.is_some());
895        let deserialized_reputation = deserialized_peer.reputation.unwrap();
896        assert_eq!(deserialized_reputation.rating, 4.5);
897        assert_eq!(deserialized_reputation.reviews, 10);
898        assert_eq!(deserialized_reputation.operating_days, 30);
899    }
900
901    #[test]
902    fn test_peer_without_reputation() {
903        // Test creating a Peer without reputation information
904        let peer = Peer::new(
905            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
906            None,
907        );
908
909        // Assert the reputation field is None
910        assert_eq!(
911            peer.pubkey,
912            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
913        );
914        assert!(peer.reputation.is_none());
915
916        // Test JSON serialization and deserialization
917        let json = peer.as_json().unwrap();
918        let deserialized_peer = Peer::from_json(&json).unwrap();
919        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
920        assert!(deserialized_peer.reputation.is_none());
921    }
922
923    #[test]
924    fn test_peer_in_message() {
925        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
926
927        // Test with reputation
928        let reputation = UserInfo {
929            rating: 4.5,
930            reviews: 10,
931            operating_days: 30,
932        };
933        let peer_with_reputation = Peer::new(
934            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
935            Some(reputation),
936        );
937        let payload_with_reputation = Payload::Peer(peer_with_reputation);
938        let message_with_reputation = Message::Order(MessageKind::new(
939            Some(uuid),
940            Some(1),
941            Some(2),
942            Action::FiatSentOk,
943            Some(payload_with_reputation),
944        ));
945
946        // Verify message with reputation
947        assert!(message_with_reputation.verify());
948        let message_json = message_with_reputation.as_json().unwrap();
949        let deserialized_message = Message::from_json(&message_json).unwrap();
950        assert!(deserialized_message.verify());
951
952        // Test without reputation
953        let peer_without_reputation = Peer::new(
954            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
955            None,
956        );
957        let payload_without_reputation = Payload::Peer(peer_without_reputation);
958        let message_without_reputation = Message::Order(MessageKind::new(
959            Some(uuid),
960            Some(1),
961            Some(2),
962            Action::FiatSentOk,
963            Some(payload_without_reputation),
964        ));
965
966        // Verify message without reputation
967        assert!(message_without_reputation.verify());
968        let message_json = message_without_reputation.as_json().unwrap();
969        let deserialized_message = Message::from_json(&message_json).unwrap();
970        assert!(deserialized_message.verify());
971    }
972
973    #[test]
974    fn test_bond_payout_request_payload_verifies_on_add_bond_invoice() {
975        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
976        let order = SmallOrder {
977            id: Some(order_id),
978            kind: None,
979            status: None,
980            amount: 500,
981            fiat_code: "USD".to_string(),
982            min_amount: None,
983            max_amount: None,
984            fiat_amount: 0,
985            payment_method: "lightning".to_string(),
986            premium: 0,
987            buyer_trade_pubkey: None,
988            seller_trade_pubkey: None,
989            buyer_invoice: None,
990            created_at: None,
991            expires_at: None,
992        };
993        let payload = Payload::BondPayoutRequest(BondPayoutRequest {
994            order,
995            slashed_at: 1_734_000_000,
996        });
997        let kind = MessageKind::new(
998            Some(order_id),
999            None,
1000            None,
1001            Action::AddBondInvoice,
1002            Some(payload),
1003        );
1004        assert!(
1005            kind.verify(),
1006            "BondPayoutRequest must verify on AddBondInvoice"
1007        );
1008
1009        // Round-trip through JSON to catch a serde-rename mismatch on the
1010        // new snake_case discriminator.
1011        let m = Message::Order(kind);
1012        let json = m.as_json().unwrap();
1013        assert!(json.contains("bond_payout_request"));
1014        let back = Message::from_json(&json).unwrap();
1015        assert!(back.verify());
1016    }
1017
1018    #[test]
1019    fn test_bond_payout_request_payload_rejected_on_wrong_action() {
1020        // BondPayoutRequest on any action other than AddBondInvoice must
1021        // fail verification — the new variant is opt-in per action.
1022        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1023        let order = SmallOrder {
1024            id: Some(order_id),
1025            kind: None,
1026            status: None,
1027            amount: 500,
1028            fiat_code: "USD".to_string(),
1029            min_amount: None,
1030            max_amount: None,
1031            fiat_amount: 0,
1032            payment_method: "lightning".to_string(),
1033            premium: 0,
1034            buyer_trade_pubkey: None,
1035            seller_trade_pubkey: None,
1036            buyer_invoice: None,
1037            created_at: None,
1038            expires_at: None,
1039        };
1040
1041        // Compile-time exhaustiveness guard: adding a new Action variant
1042        // forces this match to be updated, which in turn forces a
1043        // decision about whether the new variant belongs in
1044        // `other_actions` below.
1045        let _exhaustive: fn(Action) = |a| match a {
1046            Action::AddBondInvoice => {}
1047            Action::NewOrder
1048            | Action::TakeSell
1049            | Action::TakeBuy
1050            | Action::PayInvoice
1051            | Action::PayBondInvoice
1052            | Action::FiatSent
1053            | Action::FiatSentOk
1054            | Action::Release
1055            | Action::Released
1056            | Action::Cancel
1057            | Action::Canceled
1058            | Action::CooperativeCancelInitiatedByYou
1059            | Action::CooperativeCancelInitiatedByPeer
1060            | Action::DisputeInitiatedByYou
1061            | Action::DisputeInitiatedByPeer
1062            | Action::CooperativeCancelAccepted
1063            | Action::BuyerInvoiceAccepted
1064            | Action::BondInvoiceAccepted
1065            | Action::PurchaseCompleted
1066            | Action::BondPayoutCompleted
1067            | Action::BondSlashed
1068            | Action::HoldInvoicePaymentAccepted
1069            | Action::HoldInvoicePaymentSettled
1070            | Action::HoldInvoicePaymentCanceled
1071            | Action::WaitingSellerToPay
1072            | Action::WaitingBuyerInvoice
1073            | Action::AddInvoice
1074            | Action::BuyerTookOrder
1075            | Action::Rate
1076            | Action::RateUser
1077            | Action::RateReceived
1078            | Action::CantDo
1079            | Action::Dispute
1080            | Action::AdminCancel
1081            | Action::AdminCanceled
1082            | Action::AdminSettle
1083            | Action::AdminSettled
1084            | Action::AdminAddSolver
1085            | Action::AdminTakeDispute
1086            | Action::AdminTookDispute
1087            | Action::PaymentFailed
1088            | Action::InvoiceUpdated
1089            | Action::SendDm
1090            | Action::TradePubkey
1091            | Action::RestoreSession
1092            | Action::LastTradeIndex
1093            | Action::Orders => {}
1094        };
1095
1096        let other_actions: &[Action] = &[
1097            Action::NewOrder,
1098            Action::TakeSell,
1099            Action::TakeBuy,
1100            Action::PayInvoice,
1101            Action::PayBondInvoice,
1102            Action::FiatSent,
1103            Action::FiatSentOk,
1104            Action::Release,
1105            Action::Released,
1106            Action::Cancel,
1107            Action::Canceled,
1108            Action::CooperativeCancelInitiatedByYou,
1109            Action::CooperativeCancelInitiatedByPeer,
1110            Action::DisputeInitiatedByYou,
1111            Action::DisputeInitiatedByPeer,
1112            Action::CooperativeCancelAccepted,
1113            Action::BuyerInvoiceAccepted,
1114            Action::BondInvoiceAccepted,
1115            Action::PurchaseCompleted,
1116            Action::BondPayoutCompleted,
1117            Action::BondSlashed,
1118            Action::HoldInvoicePaymentAccepted,
1119            Action::HoldInvoicePaymentSettled,
1120            Action::HoldInvoicePaymentCanceled,
1121            Action::WaitingSellerToPay,
1122            Action::WaitingBuyerInvoice,
1123            Action::AddInvoice,
1124            Action::BuyerTookOrder,
1125            Action::Rate,
1126            Action::RateUser,
1127            Action::RateReceived,
1128            Action::CantDo,
1129            Action::Dispute,
1130            Action::AdminCancel,
1131            Action::AdminCanceled,
1132            Action::AdminSettle,
1133            Action::AdminSettled,
1134            Action::AdminAddSolver,
1135            Action::AdminTakeDispute,
1136            Action::AdminTookDispute,
1137            Action::PaymentFailed,
1138            Action::InvoiceUpdated,
1139            Action::SendDm,
1140            Action::TradePubkey,
1141            Action::RestoreSession,
1142            Action::LastTradeIndex,
1143            Action::Orders,
1144        ];
1145
1146        for action in other_actions {
1147            let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1148                order: order.clone(),
1149                slashed_at: 0,
1150            });
1151            let kind = MessageKind::new(Some(order_id), None, None, action.clone(), Some(payload));
1152            assert!(
1153                !kind.verify(),
1154                "BondPayoutRequest must be rejected on {action:?}"
1155            );
1156        }
1157    }
1158
1159    #[test]
1160    fn test_bond_payout_ack_actions_verify_and_wire_format() {
1161        use crate::message::BondResolution;
1162
1163        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1164
1165        // SmallOrder whose `amount` is the counterparty share carried by
1166        // these Mostro → winner bond-payout acknowledgements.
1167        let order = || SmallOrder {
1168            id: Some(order_id),
1169            kind: None,
1170            status: None,
1171            amount: 500,
1172            fiat_code: "USD".to_string(),
1173            min_amount: None,
1174            max_amount: None,
1175            fiat_amount: 0,
1176            payment_method: "lightning".to_string(),
1177            premium: 0,
1178            buyer_trade_pubkey: None,
1179            seller_trade_pubkey: None,
1180            buyer_invoice: None,
1181            created_at: None,
1182            expires_at: None,
1183        };
1184
1185        // These bond notifications carry Payload::Order (BondInvoiceAccepted
1186        // / BondPayoutCompleted are the duals of BuyerInvoiceAccepted /
1187        // PurchaseCompleted; BondSlashed informs the slashed user): id
1188        // required, Payload::Order accepted, and the bond request/resolution
1189        // payloads rejected.
1190        for (action, discriminator) in [
1191            (Action::BondInvoiceAccepted, "bond-invoice-accepted"),
1192            (Action::BondPayoutCompleted, "bond-payout-completed"),
1193            (Action::BondSlashed, "bond-slashed"),
1194        ] {
1195            // id set + Payload::Order verifies.
1196            let ok = Message::Order(MessageKind::new(
1197                Some(order_id),
1198                Some(1),
1199                Some(2),
1200                action.clone(),
1201                Some(Payload::Order(order())),
1202            ));
1203            assert!(ok.verify(), "{action:?} + Order should verify");
1204
1205            // Missing id is invalid.
1206            let no_id = Message::Order(MessageKind::new(
1207                None,
1208                Some(1),
1209                Some(2),
1210                action.clone(),
1211                Some(Payload::Order(order())),
1212            ));
1213            assert!(!no_id.verify(), "{action:?} without id must be rejected");
1214
1215            // BondResolution payload is rejected on these outbound acks.
1216            let with_resolution = Message::Order(MessageKind::new(
1217                Some(order_id),
1218                Some(1),
1219                Some(2),
1220                action.clone(),
1221                Some(Payload::BondResolution(BondResolution {
1222                    slash_seller: true,
1223                    slash_buyer: false,
1224                })),
1225            ));
1226            assert!(
1227                !with_resolution.verify(),
1228                "{action:?} + BondResolution must be rejected"
1229            );
1230
1231            // BondPayoutRequest payload is rejected too.
1232            let with_request = Message::Order(MessageKind::new(
1233                Some(order_id),
1234                Some(1),
1235                Some(2),
1236                action.clone(),
1237                Some(Payload::BondPayoutRequest(BondPayoutRequest {
1238                    order: order(),
1239                    slashed_at: 0,
1240                })),
1241            ));
1242            assert!(
1243                !with_request.verify(),
1244                "{action:?} + BondPayoutRequest must be rejected"
1245            );
1246
1247            // Wire format uses the kebab-case discriminator and round-trips.
1248            let json = ok.as_json().unwrap();
1249            assert!(
1250                json.contains(&format!("\"action\":\"{discriminator}\"")),
1251                "expected kebab-case discriminator {discriminator}, got: {json}"
1252            );
1253            let decoded = Message::from_json(&json).unwrap();
1254            assert!(decoded.verify());
1255            assert_eq!(decoded.inner_action(), Some(action));
1256        }
1257    }
1258
1259    #[test]
1260    fn test_payment_failed_payload() {
1261        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1262
1263        // Test PaymentFailedInfo serialization and deserialization
1264        let payment_failed_info = crate::message::PaymentFailedInfo {
1265            payment_attempts: 3,
1266            payment_retries_interval: 60,
1267        };
1268
1269        let payload = Payload::PaymentFailed(payment_failed_info);
1270        let message = Message::Order(MessageKind::new(
1271            Some(uuid),
1272            Some(1),
1273            Some(2),
1274            Action::PaymentFailed,
1275            Some(payload),
1276        ));
1277
1278        // Verify message validation
1279        assert!(message.verify());
1280
1281        // Test JSON serialization
1282        let message_json = message.as_json().unwrap();
1283
1284        // Test deserialization
1285        let deserialized_message = Message::from_json(&message_json).unwrap();
1286        assert!(deserialized_message.verify());
1287
1288        // Verify the payload contains correct values
1289        if let Message::Order(kind) = deserialized_message {
1290            if let Some(Payload::PaymentFailed(info)) = kind.payload {
1291                assert_eq!(info.payment_attempts, 3);
1292                assert_eq!(info.payment_retries_interval, 60);
1293            } else {
1294                panic!("Expected PaymentFailed payload");
1295            }
1296        } else {
1297            panic!("Expected Order message");
1298        }
1299    }
1300
1301    #[test]
1302    fn test_message_payload_signature() {
1303        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1304        let peer = Peer::new(
1305            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1306            None, // Add None for the reputation parameter
1307        );
1308        let payload = Payload::Peer(peer);
1309        let test_message = Message::Order(MessageKind::new(
1310            Some(uuid),
1311            Some(1),
1312            Some(2),
1313            Action::FiatSentOk,
1314            Some(payload),
1315        ));
1316        assert!(test_message.verify());
1317        let test_message_json = test_message.as_json().unwrap();
1318        // Message should be signed with the trade keys
1319        let trade_keys =
1320            Keys::parse("110e43647eae221ab1da33ddc17fd6ff423f2b2f49d809b9ffa40794a2ab996c")
1321                .unwrap();
1322        let sig = Message::sign(test_message_json.clone(), &trade_keys);
1323
1324        assert!(Message::verify_signature(
1325            test_message_json,
1326            trade_keys.public_key(),
1327            sig
1328        ));
1329    }
1330
1331    #[test]
1332    fn test_restore_session_message() {
1333        // Test RestoreSession request (payload = None)
1334        let restore_request_message = Message::Restore(MessageKind::new(
1335            None,
1336            None,
1337            None,
1338            Action::RestoreSession,
1339            None,
1340        ));
1341
1342        // Verify message validation
1343        assert!(restore_request_message.verify());
1344        assert_eq!(
1345            restore_request_message.inner_action(),
1346            Some(Action::RestoreSession)
1347        );
1348
1349        // Test JSON serialization and deserialization for RestoreRequest
1350        let message_json = restore_request_message.as_json().unwrap();
1351        let deserialized_message = Message::from_json(&message_json).unwrap();
1352        assert!(deserialized_message.verify());
1353        assert_eq!(
1354            deserialized_message.inner_action(),
1355            Some(Action::RestoreSession)
1356        );
1357
1358        // Test RestoreSession with RestoreData payload
1359        let restored_orders = vec![
1360            crate::message::RestoredOrdersInfo {
1361                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1362                trade_index: 1,
1363                status: "active".to_string(),
1364            },
1365            crate::message::RestoredOrdersInfo {
1366                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1367                trade_index: 2,
1368                status: "success".to_string(),
1369            },
1370        ];
1371
1372        let restored_disputes = vec![
1373            crate::message::RestoredDisputesInfo {
1374                dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1375                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1376                trade_index: 1,
1377                status: "initiated".to_string(),
1378                initiator: Some(crate::message::DisputeInitiator::Buyer),
1379                solver_pubkey: None,
1380            },
1381            crate::message::RestoredDisputesInfo {
1382                dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1383                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1384                trade_index: 2,
1385                status: "in-progress".to_string(),
1386                initiator: None,
1387                solver_pubkey: Some(
1388                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1389                ),
1390            },
1391            crate::message::RestoredDisputesInfo {
1392                dispute_id: uuid!("708e1272-d5f4-47e6-bd97-3504baea9c27"),
1393                order_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1394                trade_index: 3,
1395                status: "initiated".to_string(),
1396                initiator: Some(crate::message::DisputeInitiator::Seller),
1397                solver_pubkey: None,
1398            },
1399        ];
1400
1401        let restore_session_info = crate::message::RestoreSessionInfo {
1402            restore_orders: restored_orders.clone(),
1403            restore_disputes: restored_disputes.clone(),
1404        };
1405
1406        let restore_data_payload = Payload::RestoreData(restore_session_info);
1407        let restore_data_message = Message::Restore(MessageKind::new(
1408            None,
1409            None,
1410            None,
1411            Action::RestoreSession,
1412            Some(restore_data_payload),
1413        ));
1414
1415        // With new logic, any payload for RestoreSession is invalid (must be None)
1416        assert!(!restore_data_message.verify());
1417
1418        // Verify serialization/deserialization of RestoreData payload with all initiator cases
1419        let message_json = restore_data_message.as_json().unwrap();
1420        let deserialized_restore_message = Message::from_json(&message_json).unwrap();
1421
1422        if let Message::Restore(kind) = deserialized_restore_message {
1423            if let Some(Payload::RestoreData(session_info)) = kind.payload {
1424                assert_eq!(session_info.restore_disputes.len(), 3);
1425                assert_eq!(
1426                    session_info.restore_disputes[0].initiator,
1427                    Some(crate::message::DisputeInitiator::Buyer)
1428                );
1429                assert!(session_info.restore_disputes[0].solver_pubkey.is_none());
1430                assert_eq!(session_info.restore_disputes[1].initiator, None);
1431                assert_eq!(
1432                    session_info.restore_disputes[1].solver_pubkey,
1433                    Some(
1434                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1435                            .to_string()
1436                    )
1437                );
1438                assert_eq!(
1439                    session_info.restore_disputes[2].initiator,
1440                    Some(crate::message::DisputeInitiator::Seller)
1441                );
1442                assert!(session_info.restore_disputes[2].solver_pubkey.is_none());
1443            } else {
1444                panic!("Expected RestoreData payload");
1445            }
1446        } else {
1447            panic!("Expected Restore message");
1448        }
1449    }
1450
1451    #[test]
1452    fn test_restore_session_message_validation() {
1453        // Test that RestoreSession action accepts only payload=None or RestoreData
1454        let restore_request_message = Message::Restore(MessageKind::new(
1455            None,
1456            None,
1457            None,
1458            Action::RestoreSession,
1459            None, // Missing payload
1460        ));
1461
1462        // Verify restore request message
1463        assert!(restore_request_message.verify());
1464
1465        // Test with wrong payload type
1466        let wrong_payload = Payload::TextMessage("wrong payload".to_string());
1467        let wrong_message = Message::Restore(MessageKind::new(
1468            None,
1469            None,
1470            None,
1471            Action::RestoreSession,
1472            Some(wrong_payload),
1473        ));
1474
1475        // Should fail validation because RestoreSession only accepts None
1476        assert!(!wrong_message.verify());
1477
1478        // With new logic, presence of id/request_id/trade_index is allowed
1479        let with_id = Message::Restore(MessageKind::new(
1480            Some(uuid!("00000000-0000-0000-0000-000000000001")),
1481            None,
1482            None,
1483            Action::RestoreSession,
1484            None,
1485        ));
1486        assert!(with_id.verify());
1487
1488        let with_request_id = Message::Restore(MessageKind::new(
1489            None,
1490            Some(42),
1491            None,
1492            Action::RestoreSession,
1493            None,
1494        ));
1495        assert!(with_request_id.verify());
1496
1497        let with_trade_index = Message::Restore(MessageKind::new(
1498            None,
1499            None,
1500            Some(7),
1501            Action::RestoreSession,
1502            None,
1503        ));
1504        assert!(with_trade_index.verify());
1505    }
1506
1507    #[test]
1508    fn test_restore_session_message_constructor() {
1509        // Test the new_restore constructor
1510        let restore_request_message = Message::new_restore(None);
1511
1512        assert!(matches!(restore_request_message, Message::Restore(_)));
1513        assert!(restore_request_message.verify());
1514        assert_eq!(
1515            restore_request_message.inner_action(),
1516            Some(Action::RestoreSession)
1517        );
1518
1519        // Test with RestoreData payload should be invalid now
1520        let restore_session_info = crate::message::RestoreSessionInfo {
1521            restore_orders: vec![],
1522            restore_disputes: vec![],
1523        };
1524        let restore_data_message =
1525            Message::new_restore(Some(Payload::RestoreData(restore_session_info)));
1526
1527        assert!(matches!(restore_data_message, Message::Restore(_)));
1528        assert!(!restore_data_message.verify());
1529    }
1530
1531    #[test]
1532    fn test_last_trade_index_valid_message() {
1533        let kind = MessageKind::new(None, None, Some(7), Action::LastTradeIndex, None);
1534        let msg = Message::Restore(kind);
1535
1536        assert!(msg.verify());
1537
1538        // roundtrip
1539        let json = msg.as_json().unwrap();
1540        let decoded = Message::from_json(&json).unwrap();
1541        assert!(decoded.verify());
1542
1543        // ensure the trade index is propagated
1544        let inner = decoded.get_inner_message_kind();
1545        assert_eq!(inner.trade_index(), 7);
1546        assert_eq!(inner.has_trade_index(), (true, 7));
1547    }
1548
1549    #[test]
1550    fn test_last_trade_index_without_id_is_valid() {
1551        // With new logic, id is not required; only payload must be None
1552        let kind = MessageKind::new(None, None, Some(5), Action::LastTradeIndex, None);
1553        let msg = Message::Restore(kind);
1554        assert!(msg.verify());
1555    }
1556
1557    #[test]
1558    fn test_last_trade_index_with_payload_fails_validation() {
1559        // LastTradeIndex does not accept payload
1560        let kind = MessageKind::new(
1561            None,
1562            None,
1563            Some(3),
1564            Action::LastTradeIndex,
1565            Some(Payload::TextMessage("ignored".to_string())),
1566        );
1567        let msg = Message::Restore(kind);
1568        assert!(!msg.verify());
1569    }
1570
1571    #[test]
1572    fn test_bond_resolution_admin_actions_accept_payload_or_none() {
1573        use crate::message::BondResolution;
1574
1575        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1576
1577        for action in [Action::AdminSettle, Action::AdminCancel] {
1578            let with_resolution = Message::Order(MessageKind::new(
1579                Some(uuid),
1580                Some(1),
1581                Some(2),
1582                action.clone(),
1583                Some(Payload::BondResolution(BondResolution {
1584                    slash_seller: true,
1585                    slash_buyer: false,
1586                })),
1587            ));
1588            assert!(
1589                with_resolution.verify(),
1590                "{action:?} + BondResolution should verify"
1591            );
1592
1593            let without_payload = Message::Order(MessageKind::new(
1594                Some(uuid),
1595                Some(1),
1596                Some(2),
1597                action.clone(),
1598                None,
1599            ));
1600            assert!(without_payload.verify(), "{action:?} + None should verify");
1601
1602            // Wrong payload type must be rejected for these admin actions.
1603            let wrong = Message::Order(MessageKind::new(
1604                Some(uuid),
1605                Some(1),
1606                Some(2),
1607                action.clone(),
1608                Some(Payload::TextMessage("nope".to_string())),
1609            ));
1610            assert!(!wrong.verify(), "{action:?} + TextMessage must be rejected");
1611
1612            // Missing id is still invalid.
1613            let no_id = Message::Order(MessageKind::new(
1614                None,
1615                Some(1),
1616                Some(2),
1617                action,
1618                Some(Payload::BondResolution(BondResolution {
1619                    slash_seller: false,
1620                    slash_buyer: false,
1621                })),
1622            ));
1623            assert!(!no_id.verify(), "admin action without id must be rejected");
1624        }
1625    }
1626
1627    #[test]
1628    fn test_bond_resolution_rejected_on_non_admin_actions() {
1629        use crate::message::BondResolution;
1630
1631        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1632        let payload = Payload::BondResolution(BondResolution {
1633            slash_seller: true,
1634            slash_buyer: true,
1635        });
1636
1637        // Every Action except AdminSettle / AdminCancel must reject a
1638        // BondResolution payload. Listed explicitly (no strum) so that adding
1639        // a new Action variant forces a compile-error reminder here.
1640        for action in [
1641            Action::NewOrder,
1642            Action::TakeSell,
1643            Action::TakeBuy,
1644            Action::PayInvoice,
1645            Action::PayBondInvoice,
1646            Action::FiatSent,
1647            Action::FiatSentOk,
1648            Action::Release,
1649            Action::Released,
1650            Action::Cancel,
1651            Action::Canceled,
1652            Action::CooperativeCancelInitiatedByYou,
1653            Action::CooperativeCancelInitiatedByPeer,
1654            Action::DisputeInitiatedByYou,
1655            Action::DisputeInitiatedByPeer,
1656            Action::CooperativeCancelAccepted,
1657            Action::BuyerInvoiceAccepted,
1658            Action::BondInvoiceAccepted,
1659            Action::PurchaseCompleted,
1660            Action::BondPayoutCompleted,
1661            Action::BondSlashed,
1662            Action::HoldInvoicePaymentAccepted,
1663            Action::HoldInvoicePaymentSettled,
1664            Action::HoldInvoicePaymentCanceled,
1665            Action::WaitingSellerToPay,
1666            Action::WaitingBuyerInvoice,
1667            Action::AddInvoice,
1668            Action::AddBondInvoice,
1669            Action::BuyerTookOrder,
1670            Action::Rate,
1671            Action::RateUser,
1672            Action::RateReceived,
1673            Action::CantDo,
1674            Action::Dispute,
1675            Action::AdminCanceled,
1676            Action::AdminSettled,
1677            Action::AdminAddSolver,
1678            Action::AdminTakeDispute,
1679            Action::AdminTookDispute,
1680            Action::PaymentFailed,
1681            Action::InvoiceUpdated,
1682            Action::SendDm,
1683            Action::TradePubkey,
1684            Action::RestoreSession,
1685            Action::LastTradeIndex,
1686            Action::Orders,
1687        ] {
1688            let msg = Message::Order(MessageKind::new(
1689                Some(uuid),
1690                Some(1),
1691                Some(2),
1692                action.clone(),
1693                Some(payload.clone()),
1694            ));
1695            assert!(
1696                !msg.verify(),
1697                "{action:?} must reject BondResolution payload"
1698            );
1699        }
1700    }
1701
1702    #[test]
1703    fn test_bond_resolution_wire_format() {
1704        use crate::message::BondResolution;
1705
1706        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1707        let msg = Message::Order(MessageKind::new(
1708            Some(uuid),
1709            None,
1710            None,
1711            Action::AdminCancel,
1712            Some(Payload::BondResolution(BondResolution {
1713                slash_seller: true,
1714                slash_buyer: false,
1715            })),
1716        ));
1717
1718        let json = msg.as_json().unwrap();
1719        // Variant discriminator must be the snake_case `bond_resolution`.
1720        assert!(
1721            json.contains("\"bond_resolution\""),
1722            "expected snake_case discriminator, got: {json}"
1723        );
1724        assert!(json.contains("\"slash_seller\":true"));
1725        assert!(json.contains("\"slash_buyer\":false"));
1726
1727        // Roundtrip preserves the variant.
1728        let decoded = Message::from_json(&json).unwrap();
1729        assert!(decoded.verify());
1730        if let Message::Order(kind) = decoded {
1731            match kind.payload {
1732                Some(Payload::BondResolution(b)) => {
1733                    assert!(b.slash_seller);
1734                    assert!(!b.slash_buyer);
1735                }
1736                other => panic!("expected BondResolution payload, got {other:?}"),
1737            }
1738        } else {
1739            panic!("expected Order message");
1740        }
1741    }
1742
1743    #[test]
1744    fn test_bond_resolution_legacy_null_payload() {
1745        // payload = null on AdminSettle/AdminCancel must keep verifying so
1746        // pre-BondResolution clients keep working (interpreted as "no slash"
1747        // by the server).
1748        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1749        let json = format!(
1750            r#"{{"order":{{"version":1,"id":"{uuid}","action":"admin-cancel","payload":null}}}}"#
1751        );
1752        let msg = Message::from_json(&json).unwrap();
1753        assert!(msg.verify());
1754    }
1755
1756    #[test]
1757    fn test_pay_bond_invoice_wire_format_and_verify() {
1758        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1759        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1760
1761        let msg = Message::Order(MessageKind::new(
1762            Some(uuid),
1763            Some(1),
1764            Some(2),
1765            Action::PayBondInvoice,
1766            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1767        ));
1768        assert!(msg.verify());
1769
1770        // Wire format must use the kebab-case discriminator.
1771        let json = msg.as_json().unwrap();
1772        assert!(
1773            json.contains("\"action\":\"pay-bond-invoice\""),
1774            "expected kebab-case discriminator, got: {json}"
1775        );
1776
1777        // Roundtrip preserves the variant.
1778        let decoded = Message::from_json(&json).unwrap();
1779        assert!(decoded.verify());
1780        assert!(matches!(
1781            decoded.inner_action(),
1782            Some(Action::PayBondInvoice)
1783        ));
1784
1785        // Same id / payload constraints as PayInvoice: missing id is invalid.
1786        let no_id = Message::Order(MessageKind::new(
1787            None,
1788            Some(1),
1789            Some(2),
1790            Action::PayBondInvoice,
1791            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1792        ));
1793        assert!(!no_id.verify());
1794
1795        // Wrong payload shape is rejected.
1796        let wrong_payload = Message::Order(MessageKind::new(
1797            Some(uuid),
1798            Some(1),
1799            Some(2),
1800            Action::PayBondInvoice,
1801            Some(Payload::TextMessage("nope".to_string())),
1802        ));
1803        assert!(!wrong_payload.verify());
1804
1805        // Missing payload is rejected (PaymentRequest is required).
1806        let no_payload = Message::Order(MessageKind::new(
1807            Some(uuid),
1808            Some(1),
1809            Some(2),
1810            Action::PayBondInvoice,
1811            None,
1812        ));
1813        assert!(!no_payload.verify());
1814    }
1815
1816    #[test]
1817    fn test_add_bond_invoice_wire_format_and_verify() {
1818        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1819        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1820
1821        let msg = Message::Order(MessageKind::new(
1822            Some(uuid),
1823            Some(1),
1824            Some(2),
1825            Action::AddBondInvoice,
1826            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1827        ));
1828        assert!(msg.verify());
1829
1830        // Wire format must use the kebab-case discriminator.
1831        let json = msg.as_json().unwrap();
1832        assert!(
1833            json.contains("\"action\":\"add-bond-invoice\""),
1834            "expected kebab-case discriminator, got: {json}"
1835        );
1836
1837        // Roundtrip preserves the variant.
1838        let decoded = Message::from_json(&json).unwrap();
1839        assert!(decoded.verify());
1840        assert!(matches!(
1841            decoded.inner_action(),
1842            Some(Action::AddBondInvoice)
1843        ));
1844
1845        // Same id / payload constraints as AddInvoice: missing id is invalid.
1846        let no_id = Message::Order(MessageKind::new(
1847            None,
1848            Some(1),
1849            Some(2),
1850            Action::AddBondInvoice,
1851            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1852        ));
1853        assert!(!no_id.verify());
1854
1855        // Wrong payload shape is rejected.
1856        let wrong_payload = Message::Order(MessageKind::new(
1857            Some(uuid),
1858            Some(1),
1859            Some(2),
1860            Action::AddBondInvoice,
1861            Some(Payload::TextMessage("nope".to_string())),
1862        ));
1863        assert!(!wrong_payload.verify());
1864
1865        // Missing payload is rejected (PaymentRequest is required).
1866        let no_payload = Message::Order(MessageKind::new(
1867            Some(uuid),
1868            Some(1),
1869            Some(2),
1870            Action::AddBondInvoice,
1871            None,
1872        ));
1873        assert!(!no_payload.verify());
1874
1875        // get_payment_request must surface the bolt11 for AddBondInvoice.
1876        if let Message::Order(kind) = &msg {
1877            assert_eq!(kind.get_payment_request(), Some(bolt11));
1878        } else {
1879            panic!("expected Message::Order");
1880        }
1881    }
1882
1883    #[test]
1884    fn test_restored_dispute_helper_serialization_roundtrip() {
1885        use crate::message::RestoredDisputeHelper;
1886
1887        let helper = RestoredDisputeHelper {
1888            dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1889            order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1890            dispute_status: "initiated".to_string(),
1891            master_buyer_pubkey: Some("npub1buyerkey".to_string()),
1892            master_seller_pubkey: Some("npub1sellerkey".to_string()),
1893            trade_index_buyer: Some(1),
1894            trade_index_seller: Some(2),
1895            buyer_dispute: true,
1896            seller_dispute: false,
1897            solver_pubkey: None,
1898        };
1899
1900        let json = serde_json::to_string(&helper).unwrap();
1901        let deserialized: RestoredDisputeHelper = serde_json::from_str(&json).unwrap();
1902
1903        assert_eq!(deserialized.dispute_id, helper.dispute_id);
1904        assert_eq!(deserialized.order_id, helper.order_id);
1905        assert_eq!(deserialized.dispute_status, helper.dispute_status);
1906        assert_eq!(deserialized.master_buyer_pubkey, helper.master_buyer_pubkey);
1907        assert_eq!(
1908            deserialized.master_seller_pubkey,
1909            helper.master_seller_pubkey
1910        );
1911        assert_eq!(deserialized.trade_index_buyer, helper.trade_index_buyer);
1912        assert_eq!(deserialized.trade_index_seller, helper.trade_index_seller);
1913        assert_eq!(deserialized.buyer_dispute, helper.buyer_dispute);
1914        assert_eq!(deserialized.seller_dispute, helper.seller_dispute);
1915        assert_eq!(deserialized.solver_pubkey, helper.solver_pubkey);
1916
1917        let helper_seller_dispute = RestoredDisputeHelper {
1918            dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1919            order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1920            dispute_status: "in-progress".to_string(),
1921            master_buyer_pubkey: None,
1922            master_seller_pubkey: None,
1923            trade_index_buyer: None,
1924            trade_index_seller: None,
1925            buyer_dispute: false,
1926            seller_dispute: true,
1927            solver_pubkey: Some(
1928                "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1929            ),
1930        };
1931
1932        let json_seller = serde_json::to_string(&helper_seller_dispute).unwrap();
1933        let deserialized_seller: RestoredDisputeHelper =
1934            serde_json::from_str(&json_seller).unwrap();
1935
1936        assert_eq!(
1937            deserialized_seller.dispute_id,
1938            helper_seller_dispute.dispute_id
1939        );
1940        assert_eq!(deserialized_seller.order_id, helper_seller_dispute.order_id);
1941        assert_eq!(
1942            deserialized_seller.dispute_status,
1943            helper_seller_dispute.dispute_status
1944        );
1945        assert_eq!(deserialized_seller.master_buyer_pubkey, None);
1946        assert_eq!(deserialized_seller.master_seller_pubkey, None);
1947        assert_eq!(deserialized_seller.trade_index_buyer, None);
1948        assert_eq!(deserialized_seller.trade_index_seller, None);
1949        assert!(!deserialized_seller.buyer_dispute);
1950        assert!(deserialized_seller.seller_dispute);
1951        assert_eq!(
1952            deserialized_seller.solver_pubkey,
1953            helper_seller_dispute.solver_pubkey
1954        );
1955    }
1956}