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