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