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    /// Serialized Cashu token locked 1-of-1 to `P_M`, carrying the Mostro fee
604    /// the seller funds alongside the escrow. `None` when the node charges no
605    /// fee. Omitted from the wire form when absent, so proofs without a fee
606    /// serialize exactly as before.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub fee_token: Option<String>,
609}
610
611impl CashuLockProof {
612    /// Create a new [`CashuLockProof`].
613    pub fn new(
614        token: String,
615        mint_url: String,
616        buyer_pubkey: String,
617        seller_pubkey: String,
618        mostro_pubkey: String,
619    ) -> Self {
620        Self {
621            token,
622            mint_url,
623            buyer_pubkey,
624            seller_pubkey,
625            mostro_pubkey,
626            fee_token: None,
627        }
628    }
629
630    /// Attach the seller-funded fee token (a Cashu token locked to `P_M`),
631    /// returning the updated proof.
632    pub fn with_fee_token(mut self, fee_token: String) -> Self {
633        self.fee_token = Some(fee_token);
634        self
635    }
636
637    /// Parse a [`CashuLockProof`] from its JSON representation.
638    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
639        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
640    }
641
642    /// Serialize the lock proof to a JSON string.
643    pub fn as_json(&self) -> Result<String, ServiceError> {
644        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
645    }
646}
647
648/// Mostro's `P_M` signature for a single escrowed proof.
649///
650/// Under NUT-11 SIG_INPUTS each input proof carries its own witness with a
651/// signature over that proof's own secret, so a Cashu token split across
652/// several denominations needs one signature per proof. The dispute winner
653/// matches each signature to its proof by `secret` when populating the
654/// per-proof witnesses and assembling the mint swap. See
655/// [`Payload::CashuSignatures`].
656#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
657pub struct CashuProofSignature {
658    /// NUT-11 secret of the proof this signature applies to, exactly as it
659    /// appears in the escrowed token. Used to match the signature to its
660    /// proof.
661    pub secret: String,
662    /// Mostro's `P_M` signature (hex) over `secret`, to be inserted into that
663    /// proof's NUT-11 witness.
664    pub signature: String,
665}
666
667impl CashuProofSignature {
668    /// Create a new [`CashuProofSignature`].
669    pub fn new(secret: String, signature: String) -> Self {
670        Self { secret, signature }
671    }
672}
673
674/// Typed payload attached to a [`MessageKind`].
675///
676/// Each variant corresponds to a set of [`Action`] values that can legally
677/// carry it (see [`MessageKind::verify`]). Serialized in `snake_case` so
678/// that the variant name is the JSON discriminator.
679#[derive(Debug, Deserialize, Serialize, Clone)]
680#[serde(rename_all = "snake_case")]
681pub enum Payload {
682    /// A compact representation of an order used by [`Action::NewOrder`].
683    Order(SmallOrder),
684    /// Lightning payment request plus optional amount override.
685    ///
686    /// Used by [`Action::PayInvoice`], [`Action::PayBondInvoice`],
687    /// [`Action::AddInvoice`], [`Action::AddBondInvoice`] and
688    /// [`Action::TakeSell`]. The [`SmallOrder`]
689    /// carries the matching order when relevant; the `String` is a BOLT-11
690    /// invoice.
691    PaymentRequest(Option<SmallOrder>, String, Option<Amount>),
692    /// Free-form text message used by DMs.
693    TextMessage(String),
694    /// Peer disclosure (trade pubkey and optional reputation).
695    Peer(Peer),
696    /// Rating value the user wants to attach to a completed trade.
697    RatingUser(u8),
698    /// Raw amount in satoshis (for actions that accept an amount override).
699    Amount(Amount),
700    /// Dispute context: the dispute id plus optional
701    /// [`SolverDisputeInfo`] bundle sent to solvers.
702    Dispute(Uuid, Option<SolverDisputeInfo>),
703    /// Reason carried by a [`Action::CantDo`] response.
704    CantDo(Option<CantDoReason>),
705    /// Next trade key and index announced by the maker of a range order
706    /// when it emits [`Action::Release`] or [`Action::FiatSent`].
707    NextTrade(String, u32),
708    /// Retry configuration surfaced by [`Action::PaymentFailed`].
709    PaymentFailed(PaymentFailedInfo),
710    /// Payload returned by the server on a session restore.
711    RestoreData(RestoreSessionInfo),
712    /// Vector of order ids (lightweight listing).
713    Ids(Vec<Uuid>),
714    /// Vector of [`SmallOrder`] values (full listing).
715    Orders(Vec<SmallOrder>),
716    /// Slash decisions carried by [`Action::AdminSettle`] /
717    /// [`Action::AdminCancel`]. See [`BondResolution`].
718    BondResolution(BondResolution),
719    /// Outbound bond payout invoice request carried by
720    /// [`Action::AddBondInvoice`] (Mostro → counterparty). The reply
721    /// direction (counterparty → Mostro) keeps using
722    /// [`Payload::PaymentRequest`] with the actual bolt11. See
723    /// [`BondPayoutRequest`].
724    BondPayoutRequest(BondPayoutRequest),
725    /// Cashu 2-of-3 multisig escrow lock submitted on
726    /// [`Action::AddCashuEscrow`] (seller → Mostro). See [`CashuLockProof`].
727    CashuLockProof(CashuLockProof),
728    /// Mostro's NUT-11 P2PK signatures over the escrowed proofs, one entry
729    /// per proof. Carried by [`Action::CashuPmSignature`] when Mostro delivers
730    /// its `P_M` signatures to a dispute winner. A token split across several
731    /// denominations contains multiple proofs, and SIG_INPUTS requires each to
732    /// be signed independently. See [`CashuProofSignature`].
733    CashuSignatures(Vec<CashuProofSignature>),
734}
735
736#[allow(dead_code)]
737impl MessageKind {
738    /// Build a new [`MessageKind`] stamped with the current protocol
739    /// version (`PROTOCOL_VER`).
740    pub fn new(
741        id: Option<Uuid>,
742        request_id: Option<u64>,
743        trade_index: Option<i64>,
744        action: Action,
745        payload: Option<Payload>,
746    ) -> Self {
747        Self {
748            version: PROTOCOL_VER,
749            request_id,
750            trade_index,
751            id,
752            action,
753            payload,
754        }
755    }
756    /// Parse a [`MessageKind`] from its JSON representation.
757    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
758        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
759    }
760    /// Serialize the [`MessageKind`] to a JSON string.
761    pub fn as_json(&self) -> Result<String, ServiceError> {
762        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
763    }
764
765    /// Return a clone of the [`Action`] carried by this message.
766    pub fn get_action(&self) -> Action {
767        self.action.clone()
768    }
769
770    /// Extract the `(next_trade_pubkey, next_trade_index)` pair from a
771    /// [`Payload::NextTrade`] payload.
772    ///
773    /// Returns `Ok(None)` when there is no payload at all and
774    /// [`ServiceError::InvalidPayload`] when the payload is present but of
775    /// a different variant.
776    pub fn get_next_trade_key(&self) -> Result<Option<(String, u32)>, ServiceError> {
777        match &self.payload {
778            Some(Payload::NextTrade(key, index)) => Ok(Some((key.to_string(), *index))),
779            None => Ok(None),
780            _ => Err(ServiceError::InvalidPayload),
781        }
782    }
783
784    /// Extract the rating value from a [`Payload::RatingUser`] payload,
785    /// validating it against
786    /// [`MIN_RATING`]`..=`[`MAX_RATING`].
787    ///
788    /// Returns [`ServiceError::InvalidRating`] when the payload shape is
789    /// wrong and [`ServiceError::InvalidRatingValue`] when the value is out
790    /// of range.
791    pub fn get_rating(&self) -> Result<u8, ServiceError> {
792        if let Some(Payload::RatingUser(v)) = self.payload.to_owned() {
793            if !(MIN_RATING..=MAX_RATING).contains(&v) {
794                return Err(ServiceError::InvalidRatingValue);
795            }
796            Ok(v)
797        } else {
798            Err(ServiceError::InvalidRating)
799        }
800    }
801
802    /// Check that the payload, id and trade index are consistent with the
803    /// action carried by this message.
804    ///
805    /// Returns `true` when the combination is well-formed and `false`
806    /// otherwise; Mostro uses this method to reject malformed requests
807    /// before processing them.
808    pub fn verify(&self) -> bool {
809        match &self.action {
810            Action::NewOrder => matches!(&self.payload, Some(Payload::Order(_))),
811            Action::PayInvoice | Action::PayBondInvoice | Action::AddInvoice => {
812                if self.id.is_none() {
813                    return false;
814                }
815                matches!(&self.payload, Some(Payload::PaymentRequest(_, _, _)))
816            }
817            Action::AddBondInvoice => {
818                if self.id.is_none() {
819                    return false;
820                }
821                // Two valid shapes:
822                //   - `BondPayoutRequest` for the outbound direction
823                //     (Mostro → counterparty, "send me a bolt11").
824                //   - `PaymentRequest` for the inbound reply
825                //     (counterparty → Mostro, "here is the bolt11").
826                matches!(
827                    &self.payload,
828                    Some(Payload::BondPayoutRequest(_)) | Some(Payload::PaymentRequest(_, _, _))
829                )
830            }
831            Action::AdminSettle | Action::AdminCancel => {
832                if self.id.is_none() {
833                    return false;
834                }
835                matches!(&self.payload, None | Some(Payload::BondResolution(_)))
836            }
837            Action::AddCashuEscrow => {
838                if self.id.is_none() {
839                    return false;
840                }
841                matches!(&self.payload, Some(Payload::CashuLockProof(_)))
842            }
843            Action::CashuPmSignature => {
844                if self.id.is_none() {
845                    return false;
846                }
847                matches!(&self.payload, Some(Payload::CashuSignatures(sigs)) if !sigs.is_empty())
848            }
849            Action::TakeSell
850            | Action::TakeBuy
851            | Action::FiatSent
852            | Action::FiatSentOk
853            | Action::Release
854            | Action::Released
855            | Action::Dispute
856            | Action::AdminCanceled
857            | Action::AdminSettled
858            | Action::Rate
859            | Action::RateReceived
860            | Action::AdminTakeDispute
861            | Action::AdminTookDispute
862            | Action::DisputeInitiatedByYou
863            | Action::DisputeInitiatedByPeer
864            | Action::WaitingBuyerInvoice
865            | Action::PurchaseCompleted
866            | Action::BondPayoutCompleted
867            | Action::BondSlashed
868            | Action::HoldInvoicePaymentAccepted
869            | Action::HoldInvoicePaymentSettled
870            | Action::HoldInvoicePaymentCanceled
871            | Action::WaitingSellerToPay
872            | Action::BuyerTookOrder
873            | Action::BuyerInvoiceAccepted
874            | Action::BondInvoiceAccepted
875            | Action::CooperativeCancelInitiatedByYou
876            | Action::CooperativeCancelInitiatedByPeer
877            | Action::CooperativeCancelAccepted
878            | Action::Cancel
879            | Action::InvoiceUpdated
880            | Action::AdminAddSolver
881            | Action::SendDm
882            | Action::TradePubkey
883            | Action::CashuEscrowLocked
884            | Action::Canceled => {
885                if self.id.is_none() {
886                    return false;
887                }
888                !matches!(
889                    &self.payload,
890                    Some(Payload::BondResolution(_)) | Some(Payload::BondPayoutRequest(_))
891                )
892            }
893            Action::LastTradeIndex | Action::RestoreSession => self.payload.is_none(),
894            Action::PaymentFailed => {
895                if self.id.is_none() {
896                    return false;
897                }
898                matches!(&self.payload, Some(Payload::PaymentFailed(_)))
899            }
900            Action::RateUser => {
901                matches!(&self.payload, Some(Payload::RatingUser(_)))
902            }
903            Action::CantDo => {
904                matches!(&self.payload, Some(Payload::CantDo(_)))
905            }
906            Action::Orders => {
907                matches!(
908                    &self.payload,
909                    Some(Payload::Ids(_)) | Some(Payload::Orders(_))
910                )
911            }
912        }
913    }
914
915    /// Return the [`SmallOrder`] carried by a [`Action::NewOrder`] message.
916    ///
917    /// Yields `None` if the action is not `NewOrder` or the payload is of a
918    /// different variant.
919    pub fn get_order(&self) -> Option<&SmallOrder> {
920        if self.action != Action::NewOrder {
921            return None;
922        }
923        match &self.payload {
924            Some(Payload::Order(o)) => Some(o),
925            _ => None,
926        }
927    }
928
929    /// Return the Lightning payment request embedded in a message.
930    ///
931    /// Valid only for [`Action::TakeSell`], [`Action::AddInvoice`],
932    /// [`Action::AddBondInvoice`] and [`Action::NewOrder`]. For `NewOrder`,
933    /// the invoice is read from the [`SmallOrder::buyer_invoice`] field.
934    /// Returns `None` otherwise.
935    pub fn get_payment_request(&self) -> Option<String> {
936        if self.action != Action::TakeSell
937            && self.action != Action::AddInvoice
938            && self.action != Action::AddBondInvoice
939            && self.action != Action::NewOrder
940        {
941            return None;
942        }
943        match &self.payload {
944            Some(Payload::PaymentRequest(_, pr, _)) => Some(pr.to_owned()),
945            Some(Payload::Order(ord)) => ord.buyer_invoice.to_owned(),
946            _ => None,
947        }
948    }
949
950    /// Return the amount override embedded in a [`Action::TakeSell`] or
951    /// [`Action::TakeBuy`] message, either from a [`Payload::Amount`] or
952    /// from the third element of a [`Payload::PaymentRequest`].
953    pub fn get_amount(&self) -> Option<Amount> {
954        if self.action != Action::TakeSell && self.action != Action::TakeBuy {
955            return None;
956        }
957        match &self.payload {
958            Some(Payload::PaymentRequest(_, _, amount)) => *amount,
959            Some(Payload::Amount(amount)) => Some(*amount),
960            _ => None,
961        }
962    }
963
964    /// Borrow the optional payload.
965    pub fn get_payload(&self) -> Option<&Payload> {
966        self.payload.as_ref()
967    }
968
969    /// Return `(true, index)` when the message carries a trade index,
970    /// `(false, 0)` otherwise.
971    pub fn has_trade_index(&self) -> (bool, i64) {
972        if let Some(index) = self.trade_index {
973            return (true, index);
974        }
975        (false, 0)
976    }
977
978    /// Return the trade index carried by the message, or `0` when absent.
979    pub fn trade_index(&self) -> i64 {
980        if let Some(index) = self.trade_index {
981            return index;
982        }
983        0
984    }
985}
986
987#[cfg(test)]
988mod test {
989    use crate::message::{
990        Action, BondPayoutRequest, CashuLockProof, CashuProofSignature, Message, MessageKind,
991        Payload, Peer,
992    };
993    use crate::order::SmallOrder;
994    use crate::user::UserInfo;
995    use nostr_sdk::Keys;
996    use uuid::uuid;
997
998    #[test]
999    fn test_peer_with_reputation() {
1000        // Test creating a Peer with reputation information
1001        let reputation = UserInfo {
1002            rating: 4.5,
1003            reviews: 10,
1004            operating_days: 30,
1005        };
1006        let peer = Peer::new(
1007            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1008            Some(reputation.clone()),
1009        );
1010
1011        // Assert the fields are set correctly
1012        assert_eq!(
1013            peer.pubkey,
1014            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
1015        );
1016        assert!(peer.reputation.is_some());
1017        let peer_reputation = peer.reputation.clone().unwrap();
1018        assert_eq!(peer_reputation.rating, 4.5);
1019        assert_eq!(peer_reputation.reviews, 10);
1020        assert_eq!(peer_reputation.operating_days, 30);
1021
1022        // Test JSON serialization and deserialization
1023        let json = peer.as_json().unwrap();
1024        let deserialized_peer = Peer::from_json(&json).unwrap();
1025        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
1026        assert!(deserialized_peer.reputation.is_some());
1027        let deserialized_reputation = deserialized_peer.reputation.unwrap();
1028        assert_eq!(deserialized_reputation.rating, 4.5);
1029        assert_eq!(deserialized_reputation.reviews, 10);
1030        assert_eq!(deserialized_reputation.operating_days, 30);
1031    }
1032
1033    #[test]
1034    fn test_peer_without_reputation() {
1035        // Test creating a Peer without reputation information
1036        let peer = Peer::new(
1037            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1038            None,
1039        );
1040
1041        // Assert the reputation field is None
1042        assert_eq!(
1043            peer.pubkey,
1044            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
1045        );
1046        assert!(peer.reputation.is_none());
1047
1048        // Test JSON serialization and deserialization
1049        let json = peer.as_json().unwrap();
1050        let deserialized_peer = Peer::from_json(&json).unwrap();
1051        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
1052        assert!(deserialized_peer.reputation.is_none());
1053    }
1054
1055    #[test]
1056    fn test_peer_in_message() {
1057        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1058
1059        // Test with reputation
1060        let reputation = UserInfo {
1061            rating: 4.5,
1062            reviews: 10,
1063            operating_days: 30,
1064        };
1065        let peer_with_reputation = Peer::new(
1066            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1067            Some(reputation),
1068        );
1069        let payload_with_reputation = Payload::Peer(peer_with_reputation);
1070        let message_with_reputation = Message::Order(MessageKind::new(
1071            Some(uuid),
1072            Some(1),
1073            Some(2),
1074            Action::FiatSentOk,
1075            Some(payload_with_reputation),
1076        ));
1077
1078        // Verify message with reputation
1079        assert!(message_with_reputation.verify());
1080        let message_json = message_with_reputation.as_json().unwrap();
1081        let deserialized_message = Message::from_json(&message_json).unwrap();
1082        assert!(deserialized_message.verify());
1083
1084        // Test without reputation
1085        let peer_without_reputation = Peer::new(
1086            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1087            None,
1088        );
1089        let payload_without_reputation = Payload::Peer(peer_without_reputation);
1090        let message_without_reputation = Message::Order(MessageKind::new(
1091            Some(uuid),
1092            Some(1),
1093            Some(2),
1094            Action::FiatSentOk,
1095            Some(payload_without_reputation),
1096        ));
1097
1098        // Verify message without reputation
1099        assert!(message_without_reputation.verify());
1100        let message_json = message_without_reputation.as_json().unwrap();
1101        let deserialized_message = Message::from_json(&message_json).unwrap();
1102        assert!(deserialized_message.verify());
1103    }
1104
1105    #[test]
1106    fn test_bond_payout_request_payload_verifies_on_add_bond_invoice() {
1107        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1108        let order = SmallOrder {
1109            id: Some(order_id),
1110            kind: None,
1111            status: None,
1112            amount: 500,
1113            fiat_code: "USD".to_string(),
1114            min_amount: None,
1115            max_amount: None,
1116            fiat_amount: 0,
1117            payment_method: "lightning".to_string(),
1118            premium: 0,
1119            buyer_trade_pubkey: None,
1120            seller_trade_pubkey: None,
1121            buyer_invoice: None,
1122            created_at: None,
1123            expires_at: None,
1124        };
1125        let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1126            order,
1127            slashed_at: 1_734_000_000,
1128        });
1129        let kind = MessageKind::new(
1130            Some(order_id),
1131            None,
1132            None,
1133            Action::AddBondInvoice,
1134            Some(payload),
1135        );
1136        assert!(
1137            kind.verify(),
1138            "BondPayoutRequest must verify on AddBondInvoice"
1139        );
1140
1141        // Round-trip through JSON to catch a serde-rename mismatch on the
1142        // new snake_case discriminator.
1143        let m = Message::Order(kind);
1144        let json = m.as_json().unwrap();
1145        assert!(json.contains("bond_payout_request"));
1146        let back = Message::from_json(&json).unwrap();
1147        assert!(back.verify());
1148    }
1149
1150    #[test]
1151    fn test_bond_payout_request_payload_rejected_on_wrong_action() {
1152        // BondPayoutRequest on any action other than AddBondInvoice must
1153        // fail verification — the new variant is opt-in per action.
1154        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1155        let order = SmallOrder {
1156            id: Some(order_id),
1157            kind: None,
1158            status: None,
1159            amount: 500,
1160            fiat_code: "USD".to_string(),
1161            min_amount: None,
1162            max_amount: None,
1163            fiat_amount: 0,
1164            payment_method: "lightning".to_string(),
1165            premium: 0,
1166            buyer_trade_pubkey: None,
1167            seller_trade_pubkey: None,
1168            buyer_invoice: None,
1169            created_at: None,
1170            expires_at: None,
1171        };
1172
1173        // Compile-time exhaustiveness guard: adding a new Action variant
1174        // forces this match to be updated, which in turn forces a
1175        // decision about whether the new variant belongs in
1176        // `other_actions` below.
1177        let _exhaustive: fn(Action) = |a| match a {
1178            Action::AddBondInvoice => {}
1179            Action::NewOrder
1180            | Action::TakeSell
1181            | Action::TakeBuy
1182            | Action::PayInvoice
1183            | Action::PayBondInvoice
1184            | Action::FiatSent
1185            | Action::FiatSentOk
1186            | Action::Release
1187            | Action::Released
1188            | Action::Cancel
1189            | Action::Canceled
1190            | Action::CooperativeCancelInitiatedByYou
1191            | Action::CooperativeCancelInitiatedByPeer
1192            | Action::DisputeInitiatedByYou
1193            | Action::DisputeInitiatedByPeer
1194            | Action::CooperativeCancelAccepted
1195            | Action::BuyerInvoiceAccepted
1196            | Action::BondInvoiceAccepted
1197            | Action::PurchaseCompleted
1198            | Action::BondPayoutCompleted
1199            | Action::BondSlashed
1200            | Action::HoldInvoicePaymentAccepted
1201            | Action::HoldInvoicePaymentSettled
1202            | Action::HoldInvoicePaymentCanceled
1203            | Action::WaitingSellerToPay
1204            | Action::WaitingBuyerInvoice
1205            | Action::AddInvoice
1206            | Action::BuyerTookOrder
1207            | Action::Rate
1208            | Action::RateUser
1209            | Action::RateReceived
1210            | Action::CantDo
1211            | Action::Dispute
1212            | Action::AdminCancel
1213            | Action::AdminCanceled
1214            | Action::AdminSettle
1215            | Action::AdminSettled
1216            | Action::AdminAddSolver
1217            | Action::AdminTakeDispute
1218            | Action::AdminTookDispute
1219            | Action::PaymentFailed
1220            | Action::InvoiceUpdated
1221            | Action::SendDm
1222            | Action::TradePubkey
1223            | Action::RestoreSession
1224            | Action::LastTradeIndex
1225            | Action::AddCashuEscrow
1226            | Action::CashuEscrowLocked
1227            | Action::CashuPmSignature
1228            | Action::Orders => {}
1229        };
1230
1231        let other_actions: &[Action] = &[
1232            Action::NewOrder,
1233            Action::TakeSell,
1234            Action::TakeBuy,
1235            Action::PayInvoice,
1236            Action::PayBondInvoice,
1237            Action::FiatSent,
1238            Action::FiatSentOk,
1239            Action::Release,
1240            Action::Released,
1241            Action::Cancel,
1242            Action::Canceled,
1243            Action::CooperativeCancelInitiatedByYou,
1244            Action::CooperativeCancelInitiatedByPeer,
1245            Action::DisputeInitiatedByYou,
1246            Action::DisputeInitiatedByPeer,
1247            Action::CooperativeCancelAccepted,
1248            Action::BuyerInvoiceAccepted,
1249            Action::BondInvoiceAccepted,
1250            Action::PurchaseCompleted,
1251            Action::BondPayoutCompleted,
1252            Action::BondSlashed,
1253            Action::HoldInvoicePaymentAccepted,
1254            Action::HoldInvoicePaymentSettled,
1255            Action::HoldInvoicePaymentCanceled,
1256            Action::WaitingSellerToPay,
1257            Action::WaitingBuyerInvoice,
1258            Action::AddInvoice,
1259            Action::BuyerTookOrder,
1260            Action::Rate,
1261            Action::RateUser,
1262            Action::RateReceived,
1263            Action::CantDo,
1264            Action::Dispute,
1265            Action::AdminCancel,
1266            Action::AdminCanceled,
1267            Action::AdminSettle,
1268            Action::AdminSettled,
1269            Action::AdminAddSolver,
1270            Action::AdminTakeDispute,
1271            Action::AdminTookDispute,
1272            Action::PaymentFailed,
1273            Action::InvoiceUpdated,
1274            Action::SendDm,
1275            Action::TradePubkey,
1276            Action::RestoreSession,
1277            Action::LastTradeIndex,
1278            Action::Orders,
1279            Action::AddCashuEscrow,
1280            Action::CashuEscrowLocked,
1281            Action::CashuPmSignature,
1282        ];
1283
1284        for action in other_actions {
1285            let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1286                order: order.clone(),
1287                slashed_at: 0,
1288            });
1289            let kind = MessageKind::new(Some(order_id), None, None, action.clone(), Some(payload));
1290            assert!(
1291                !kind.verify(),
1292                "BondPayoutRequest must be rejected on {action:?}"
1293            );
1294        }
1295    }
1296
1297    #[test]
1298    fn test_bond_payout_ack_actions_verify_and_wire_format() {
1299        use crate::message::BondResolution;
1300
1301        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1302
1303        // SmallOrder whose `amount` is the counterparty share carried by
1304        // these Mostro → winner bond-payout acknowledgements.
1305        let order = || SmallOrder {
1306            id: Some(order_id),
1307            kind: None,
1308            status: None,
1309            amount: 500,
1310            fiat_code: "USD".to_string(),
1311            min_amount: None,
1312            max_amount: None,
1313            fiat_amount: 0,
1314            payment_method: "lightning".to_string(),
1315            premium: 0,
1316            buyer_trade_pubkey: None,
1317            seller_trade_pubkey: None,
1318            buyer_invoice: None,
1319            created_at: None,
1320            expires_at: None,
1321        };
1322
1323        // These bond notifications carry Payload::Order (BondInvoiceAccepted
1324        // / BondPayoutCompleted are the duals of BuyerInvoiceAccepted /
1325        // PurchaseCompleted; BondSlashed informs the slashed user): id
1326        // required, Payload::Order accepted, and the bond request/resolution
1327        // payloads rejected.
1328        for (action, discriminator) in [
1329            (Action::BondInvoiceAccepted, "bond-invoice-accepted"),
1330            (Action::BondPayoutCompleted, "bond-payout-completed"),
1331            (Action::BondSlashed, "bond-slashed"),
1332        ] {
1333            // id set + Payload::Order verifies.
1334            let ok = Message::Order(MessageKind::new(
1335                Some(order_id),
1336                Some(1),
1337                Some(2),
1338                action.clone(),
1339                Some(Payload::Order(order())),
1340            ));
1341            assert!(ok.verify(), "{action:?} + Order should verify");
1342
1343            // Missing id is invalid.
1344            let no_id = Message::Order(MessageKind::new(
1345                None,
1346                Some(1),
1347                Some(2),
1348                action.clone(),
1349                Some(Payload::Order(order())),
1350            ));
1351            assert!(!no_id.verify(), "{action:?} without id must be rejected");
1352
1353            // BondResolution payload is rejected on these outbound acks.
1354            let with_resolution = Message::Order(MessageKind::new(
1355                Some(order_id),
1356                Some(1),
1357                Some(2),
1358                action.clone(),
1359                Some(Payload::BondResolution(BondResolution {
1360                    slash_seller: true,
1361                    slash_buyer: false,
1362                })),
1363            ));
1364            assert!(
1365                !with_resolution.verify(),
1366                "{action:?} + BondResolution must be rejected"
1367            );
1368
1369            // BondPayoutRequest payload is rejected too.
1370            let with_request = Message::Order(MessageKind::new(
1371                Some(order_id),
1372                Some(1),
1373                Some(2),
1374                action.clone(),
1375                Some(Payload::BondPayoutRequest(BondPayoutRequest {
1376                    order: order(),
1377                    slashed_at: 0,
1378                })),
1379            ));
1380            assert!(
1381                !with_request.verify(),
1382                "{action:?} + BondPayoutRequest must be rejected"
1383            );
1384
1385            // Wire format uses the kebab-case discriminator and round-trips.
1386            let json = ok.as_json().unwrap();
1387            assert!(
1388                json.contains(&format!("\"action\":\"{discriminator}\"")),
1389                "expected kebab-case discriminator {discriminator}, got: {json}"
1390            );
1391            let decoded = Message::from_json(&json).unwrap();
1392            assert!(decoded.verify());
1393            assert_eq!(decoded.inner_action(), Some(action));
1394        }
1395    }
1396
1397    #[test]
1398    fn test_payment_failed_payload() {
1399        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1400
1401        // Test PaymentFailedInfo serialization and deserialization
1402        let payment_failed_info = crate::message::PaymentFailedInfo {
1403            payment_attempts: 3,
1404            payment_retries_interval: 60,
1405        };
1406
1407        let payload = Payload::PaymentFailed(payment_failed_info);
1408        let message = Message::Order(MessageKind::new(
1409            Some(uuid),
1410            Some(1),
1411            Some(2),
1412            Action::PaymentFailed,
1413            Some(payload),
1414        ));
1415
1416        // Verify message validation
1417        assert!(message.verify());
1418
1419        // Test JSON serialization
1420        let message_json = message.as_json().unwrap();
1421
1422        // Test deserialization
1423        let deserialized_message = Message::from_json(&message_json).unwrap();
1424        assert!(deserialized_message.verify());
1425
1426        // Verify the payload contains correct values
1427        if let Message::Order(kind) = deserialized_message {
1428            if let Some(Payload::PaymentFailed(info)) = kind.payload {
1429                assert_eq!(info.payment_attempts, 3);
1430                assert_eq!(info.payment_retries_interval, 60);
1431            } else {
1432                panic!("Expected PaymentFailed payload");
1433            }
1434        } else {
1435            panic!("Expected Order message");
1436        }
1437    }
1438
1439    #[test]
1440    fn test_message_payload_signature() {
1441        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1442        let peer = Peer::new(
1443            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1444            None, // Add None for the reputation parameter
1445        );
1446        let payload = Payload::Peer(peer);
1447        let test_message = Message::Order(MessageKind::new(
1448            Some(uuid),
1449            Some(1),
1450            Some(2),
1451            Action::FiatSentOk,
1452            Some(payload),
1453        ));
1454        assert!(test_message.verify());
1455        let test_message_json = test_message.as_json().unwrap();
1456        // Message should be signed with the trade keys
1457        let trade_keys =
1458            Keys::parse("110e43647eae221ab1da33ddc17fd6ff423f2b2f49d809b9ffa40794a2ab996c")
1459                .unwrap();
1460        let sig = Message::sign(test_message_json.clone(), &trade_keys);
1461
1462        assert!(Message::verify_signature(
1463            test_message_json,
1464            trade_keys.public_key(),
1465            sig
1466        ));
1467    }
1468
1469    #[test]
1470    fn test_restore_session_message() {
1471        // Test RestoreSession request (payload = None)
1472        let restore_request_message = Message::Restore(MessageKind::new(
1473            None,
1474            None,
1475            None,
1476            Action::RestoreSession,
1477            None,
1478        ));
1479
1480        // Verify message validation
1481        assert!(restore_request_message.verify());
1482        assert_eq!(
1483            restore_request_message.inner_action(),
1484            Some(Action::RestoreSession)
1485        );
1486
1487        // Test JSON serialization and deserialization for RestoreRequest
1488        let message_json = restore_request_message.as_json().unwrap();
1489        let deserialized_message = Message::from_json(&message_json).unwrap();
1490        assert!(deserialized_message.verify());
1491        assert_eq!(
1492            deserialized_message.inner_action(),
1493            Some(Action::RestoreSession)
1494        );
1495
1496        // Test RestoreSession with RestoreData payload
1497        let restored_orders = vec![
1498            crate::message::RestoredOrdersInfo {
1499                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1500                trade_index: 1,
1501                status: "active".to_string(),
1502            },
1503            crate::message::RestoredOrdersInfo {
1504                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1505                trade_index: 2,
1506                status: "success".to_string(),
1507            },
1508        ];
1509
1510        let restored_disputes = vec![
1511            crate::message::RestoredDisputesInfo {
1512                dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1513                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1514                trade_index: 1,
1515                status: "initiated".to_string(),
1516                initiator: Some(crate::message::DisputeInitiator::Buyer),
1517                solver_pubkey: None,
1518            },
1519            crate::message::RestoredDisputesInfo {
1520                dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1521                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1522                trade_index: 2,
1523                status: "in-progress".to_string(),
1524                initiator: None,
1525                solver_pubkey: Some(
1526                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1527                ),
1528            },
1529            crate::message::RestoredDisputesInfo {
1530                dispute_id: uuid!("708e1272-d5f4-47e6-bd97-3504baea9c27"),
1531                order_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1532                trade_index: 3,
1533                status: "initiated".to_string(),
1534                initiator: Some(crate::message::DisputeInitiator::Seller),
1535                solver_pubkey: None,
1536            },
1537        ];
1538
1539        let restore_session_info = crate::message::RestoreSessionInfo {
1540            restore_orders: restored_orders.clone(),
1541            restore_disputes: restored_disputes.clone(),
1542        };
1543
1544        let restore_data_payload = Payload::RestoreData(restore_session_info);
1545        let restore_data_message = Message::Restore(MessageKind::new(
1546            None,
1547            None,
1548            None,
1549            Action::RestoreSession,
1550            Some(restore_data_payload),
1551        ));
1552
1553        // With new logic, any payload for RestoreSession is invalid (must be None)
1554        assert!(!restore_data_message.verify());
1555
1556        // Verify serialization/deserialization of RestoreData payload with all initiator cases
1557        let message_json = restore_data_message.as_json().unwrap();
1558        let deserialized_restore_message = Message::from_json(&message_json).unwrap();
1559
1560        if let Message::Restore(kind) = deserialized_restore_message {
1561            if let Some(Payload::RestoreData(session_info)) = kind.payload {
1562                assert_eq!(session_info.restore_disputes.len(), 3);
1563                assert_eq!(
1564                    session_info.restore_disputes[0].initiator,
1565                    Some(crate::message::DisputeInitiator::Buyer)
1566                );
1567                assert!(session_info.restore_disputes[0].solver_pubkey.is_none());
1568                assert_eq!(session_info.restore_disputes[1].initiator, None);
1569                assert_eq!(
1570                    session_info.restore_disputes[1].solver_pubkey,
1571                    Some(
1572                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1573                            .to_string()
1574                    )
1575                );
1576                assert_eq!(
1577                    session_info.restore_disputes[2].initiator,
1578                    Some(crate::message::DisputeInitiator::Seller)
1579                );
1580                assert!(session_info.restore_disputes[2].solver_pubkey.is_none());
1581            } else {
1582                panic!("Expected RestoreData payload");
1583            }
1584        } else {
1585            panic!("Expected Restore message");
1586        }
1587    }
1588
1589    #[test]
1590    fn test_restore_session_message_validation() {
1591        // Test that RestoreSession action accepts only payload=None or RestoreData
1592        let restore_request_message = Message::Restore(MessageKind::new(
1593            None,
1594            None,
1595            None,
1596            Action::RestoreSession,
1597            None, // Missing payload
1598        ));
1599
1600        // Verify restore request message
1601        assert!(restore_request_message.verify());
1602
1603        // Test with wrong payload type
1604        let wrong_payload = Payload::TextMessage("wrong payload".to_string());
1605        let wrong_message = Message::Restore(MessageKind::new(
1606            None,
1607            None,
1608            None,
1609            Action::RestoreSession,
1610            Some(wrong_payload),
1611        ));
1612
1613        // Should fail validation because RestoreSession only accepts None
1614        assert!(!wrong_message.verify());
1615
1616        // With new logic, presence of id/request_id/trade_index is allowed
1617        let with_id = Message::Restore(MessageKind::new(
1618            Some(uuid!("00000000-0000-0000-0000-000000000001")),
1619            None,
1620            None,
1621            Action::RestoreSession,
1622            None,
1623        ));
1624        assert!(with_id.verify());
1625
1626        let with_request_id = Message::Restore(MessageKind::new(
1627            None,
1628            Some(42),
1629            None,
1630            Action::RestoreSession,
1631            None,
1632        ));
1633        assert!(with_request_id.verify());
1634
1635        let with_trade_index = Message::Restore(MessageKind::new(
1636            None,
1637            None,
1638            Some(7),
1639            Action::RestoreSession,
1640            None,
1641        ));
1642        assert!(with_trade_index.verify());
1643    }
1644
1645    #[test]
1646    fn test_restore_session_message_constructor() {
1647        // Test the new_restore constructor
1648        let restore_request_message = Message::new_restore(None);
1649
1650        assert!(matches!(restore_request_message, Message::Restore(_)));
1651        assert!(restore_request_message.verify());
1652        assert_eq!(
1653            restore_request_message.inner_action(),
1654            Some(Action::RestoreSession)
1655        );
1656
1657        // Test with RestoreData payload should be invalid now
1658        let restore_session_info = crate::message::RestoreSessionInfo {
1659            restore_orders: vec![],
1660            restore_disputes: vec![],
1661        };
1662        let restore_data_message =
1663            Message::new_restore(Some(Payload::RestoreData(restore_session_info)));
1664
1665        assert!(matches!(restore_data_message, Message::Restore(_)));
1666        assert!(!restore_data_message.verify());
1667    }
1668
1669    #[test]
1670    fn test_last_trade_index_valid_message() {
1671        let kind = MessageKind::new(None, None, Some(7), Action::LastTradeIndex, None);
1672        let msg = Message::Restore(kind);
1673
1674        assert!(msg.verify());
1675
1676        // roundtrip
1677        let json = msg.as_json().unwrap();
1678        let decoded = Message::from_json(&json).unwrap();
1679        assert!(decoded.verify());
1680
1681        // ensure the trade index is propagated
1682        let inner = decoded.get_inner_message_kind();
1683        assert_eq!(inner.trade_index(), 7);
1684        assert_eq!(inner.has_trade_index(), (true, 7));
1685    }
1686
1687    #[test]
1688    fn test_last_trade_index_without_id_is_valid() {
1689        // With new logic, id is not required; only payload must be None
1690        let kind = MessageKind::new(None, None, Some(5), Action::LastTradeIndex, None);
1691        let msg = Message::Restore(kind);
1692        assert!(msg.verify());
1693    }
1694
1695    #[test]
1696    fn test_last_trade_index_with_payload_fails_validation() {
1697        // LastTradeIndex does not accept payload
1698        let kind = MessageKind::new(
1699            None,
1700            None,
1701            Some(3),
1702            Action::LastTradeIndex,
1703            Some(Payload::TextMessage("ignored".to_string())),
1704        );
1705        let msg = Message::Restore(kind);
1706        assert!(!msg.verify());
1707    }
1708
1709    #[test]
1710    fn test_bond_resolution_admin_actions_accept_payload_or_none() {
1711        use crate::message::BondResolution;
1712
1713        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1714
1715        for action in [Action::AdminSettle, Action::AdminCancel] {
1716            let with_resolution = Message::Order(MessageKind::new(
1717                Some(uuid),
1718                Some(1),
1719                Some(2),
1720                action.clone(),
1721                Some(Payload::BondResolution(BondResolution {
1722                    slash_seller: true,
1723                    slash_buyer: false,
1724                })),
1725            ));
1726            assert!(
1727                with_resolution.verify(),
1728                "{action:?} + BondResolution should verify"
1729            );
1730
1731            let without_payload = Message::Order(MessageKind::new(
1732                Some(uuid),
1733                Some(1),
1734                Some(2),
1735                action.clone(),
1736                None,
1737            ));
1738            assert!(without_payload.verify(), "{action:?} + None should verify");
1739
1740            // Wrong payload type must be rejected for these admin actions.
1741            let wrong = Message::Order(MessageKind::new(
1742                Some(uuid),
1743                Some(1),
1744                Some(2),
1745                action.clone(),
1746                Some(Payload::TextMessage("nope".to_string())),
1747            ));
1748            assert!(!wrong.verify(), "{action:?} + TextMessage must be rejected");
1749
1750            // Missing id is still invalid.
1751            let no_id = Message::Order(MessageKind::new(
1752                None,
1753                Some(1),
1754                Some(2),
1755                action,
1756                Some(Payload::BondResolution(BondResolution {
1757                    slash_seller: false,
1758                    slash_buyer: false,
1759                })),
1760            ));
1761            assert!(!no_id.verify(), "admin action without id must be rejected");
1762        }
1763    }
1764
1765    #[test]
1766    fn test_bond_resolution_rejected_on_non_admin_actions() {
1767        use crate::message::BondResolution;
1768
1769        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1770        let payload = Payload::BondResolution(BondResolution {
1771            slash_seller: true,
1772            slash_buyer: true,
1773        });
1774
1775        // Every Action except AdminSettle / AdminCancel must reject a
1776        // BondResolution payload. Listed explicitly (no strum) so that adding
1777        // a new Action variant forces a compile-error reminder here.
1778        for action in [
1779            Action::NewOrder,
1780            Action::TakeSell,
1781            Action::TakeBuy,
1782            Action::PayInvoice,
1783            Action::PayBondInvoice,
1784            Action::FiatSent,
1785            Action::FiatSentOk,
1786            Action::Release,
1787            Action::Released,
1788            Action::Cancel,
1789            Action::Canceled,
1790            Action::CooperativeCancelInitiatedByYou,
1791            Action::CooperativeCancelInitiatedByPeer,
1792            Action::DisputeInitiatedByYou,
1793            Action::DisputeInitiatedByPeer,
1794            Action::CooperativeCancelAccepted,
1795            Action::BuyerInvoiceAccepted,
1796            Action::BondInvoiceAccepted,
1797            Action::PurchaseCompleted,
1798            Action::BondPayoutCompleted,
1799            Action::BondSlashed,
1800            Action::HoldInvoicePaymentAccepted,
1801            Action::HoldInvoicePaymentSettled,
1802            Action::HoldInvoicePaymentCanceled,
1803            Action::WaitingSellerToPay,
1804            Action::WaitingBuyerInvoice,
1805            Action::AddInvoice,
1806            Action::AddBondInvoice,
1807            Action::BuyerTookOrder,
1808            Action::Rate,
1809            Action::RateUser,
1810            Action::RateReceived,
1811            Action::CantDo,
1812            Action::Dispute,
1813            Action::AdminCanceled,
1814            Action::AdminSettled,
1815            Action::AdminAddSolver,
1816            Action::AdminTakeDispute,
1817            Action::AdminTookDispute,
1818            Action::PaymentFailed,
1819            Action::InvoiceUpdated,
1820            Action::SendDm,
1821            Action::TradePubkey,
1822            Action::RestoreSession,
1823            Action::LastTradeIndex,
1824            Action::Orders,
1825        ] {
1826            let msg = Message::Order(MessageKind::new(
1827                Some(uuid),
1828                Some(1),
1829                Some(2),
1830                action.clone(),
1831                Some(payload.clone()),
1832            ));
1833            assert!(
1834                !msg.verify(),
1835                "{action:?} must reject BondResolution payload"
1836            );
1837        }
1838    }
1839
1840    #[test]
1841    fn test_bond_resolution_wire_format() {
1842        use crate::message::BondResolution;
1843
1844        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1845        let msg = Message::Order(MessageKind::new(
1846            Some(uuid),
1847            None,
1848            None,
1849            Action::AdminCancel,
1850            Some(Payload::BondResolution(BondResolution {
1851                slash_seller: true,
1852                slash_buyer: false,
1853            })),
1854        ));
1855
1856        let json = msg.as_json().unwrap();
1857        // Variant discriminator must be the snake_case `bond_resolution`.
1858        assert!(
1859            json.contains("\"bond_resolution\""),
1860            "expected snake_case discriminator, got: {json}"
1861        );
1862        assert!(json.contains("\"slash_seller\":true"));
1863        assert!(json.contains("\"slash_buyer\":false"));
1864
1865        // Roundtrip preserves the variant.
1866        let decoded = Message::from_json(&json).unwrap();
1867        assert!(decoded.verify());
1868        if let Message::Order(kind) = decoded {
1869            match kind.payload {
1870                Some(Payload::BondResolution(b)) => {
1871                    assert!(b.slash_seller);
1872                    assert!(!b.slash_buyer);
1873                }
1874                other => panic!("expected BondResolution payload, got {other:?}"),
1875            }
1876        } else {
1877            panic!("expected Order message");
1878        }
1879    }
1880
1881    #[test]
1882    fn test_bond_resolution_legacy_null_payload() {
1883        // payload = null on AdminSettle/AdminCancel must keep verifying so
1884        // pre-BondResolution clients keep working (interpreted as "no slash"
1885        // by the server).
1886        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1887        let json = format!(
1888            r#"{{"order":{{"version":1,"id":"{uuid}","action":"admin-cancel","payload":null}}}}"#
1889        );
1890        let msg = Message::from_json(&json).unwrap();
1891        assert!(msg.verify());
1892    }
1893
1894    #[test]
1895    fn test_pay_bond_invoice_wire_format_and_verify() {
1896        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1897        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1898
1899        let msg = Message::Order(MessageKind::new(
1900            Some(uuid),
1901            Some(1),
1902            Some(2),
1903            Action::PayBondInvoice,
1904            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1905        ));
1906        assert!(msg.verify());
1907
1908        // Wire format must use the kebab-case discriminator.
1909        let json = msg.as_json().unwrap();
1910        assert!(
1911            json.contains("\"action\":\"pay-bond-invoice\""),
1912            "expected kebab-case discriminator, got: {json}"
1913        );
1914
1915        // Roundtrip preserves the variant.
1916        let decoded = Message::from_json(&json).unwrap();
1917        assert!(decoded.verify());
1918        assert!(matches!(
1919            decoded.inner_action(),
1920            Some(Action::PayBondInvoice)
1921        ));
1922
1923        // Same id / payload constraints as PayInvoice: missing id is invalid.
1924        let no_id = Message::Order(MessageKind::new(
1925            None,
1926            Some(1),
1927            Some(2),
1928            Action::PayBondInvoice,
1929            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1930        ));
1931        assert!(!no_id.verify());
1932
1933        // Wrong payload shape is rejected.
1934        let wrong_payload = Message::Order(MessageKind::new(
1935            Some(uuid),
1936            Some(1),
1937            Some(2),
1938            Action::PayBondInvoice,
1939            Some(Payload::TextMessage("nope".to_string())),
1940        ));
1941        assert!(!wrong_payload.verify());
1942
1943        // Missing payload is rejected (PaymentRequest is required).
1944        let no_payload = Message::Order(MessageKind::new(
1945            Some(uuid),
1946            Some(1),
1947            Some(2),
1948            Action::PayBondInvoice,
1949            None,
1950        ));
1951        assert!(!no_payload.verify());
1952    }
1953
1954    #[test]
1955    fn test_add_bond_invoice_wire_format_and_verify() {
1956        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1957        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
1958
1959        let msg = Message::Order(MessageKind::new(
1960            Some(uuid),
1961            Some(1),
1962            Some(2),
1963            Action::AddBondInvoice,
1964            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1965        ));
1966        assert!(msg.verify());
1967
1968        // Wire format must use the kebab-case discriminator.
1969        let json = msg.as_json().unwrap();
1970        assert!(
1971            json.contains("\"action\":\"add-bond-invoice\""),
1972            "expected kebab-case discriminator, got: {json}"
1973        );
1974
1975        // Roundtrip preserves the variant.
1976        let decoded = Message::from_json(&json).unwrap();
1977        assert!(decoded.verify());
1978        assert!(matches!(
1979            decoded.inner_action(),
1980            Some(Action::AddBondInvoice)
1981        ));
1982
1983        // Same id / payload constraints as AddInvoice: missing id is invalid.
1984        let no_id = Message::Order(MessageKind::new(
1985            None,
1986            Some(1),
1987            Some(2),
1988            Action::AddBondInvoice,
1989            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
1990        ));
1991        assert!(!no_id.verify());
1992
1993        // Wrong payload shape is rejected.
1994        let wrong_payload = Message::Order(MessageKind::new(
1995            Some(uuid),
1996            Some(1),
1997            Some(2),
1998            Action::AddBondInvoice,
1999            Some(Payload::TextMessage("nope".to_string())),
2000        ));
2001        assert!(!wrong_payload.verify());
2002
2003        // Missing payload is rejected (PaymentRequest is required).
2004        let no_payload = Message::Order(MessageKind::new(
2005            Some(uuid),
2006            Some(1),
2007            Some(2),
2008            Action::AddBondInvoice,
2009            None,
2010        ));
2011        assert!(!no_payload.verify());
2012
2013        // get_payment_request must surface the bolt11 for AddBondInvoice.
2014        if let Message::Order(kind) = &msg {
2015            assert_eq!(kind.get_payment_request(), Some(bolt11));
2016        } else {
2017            panic!("expected Message::Order");
2018        }
2019    }
2020
2021    #[test]
2022    fn test_restored_dispute_helper_serialization_roundtrip() {
2023        use crate::message::RestoredDisputeHelper;
2024
2025        let helper = RestoredDisputeHelper {
2026            dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
2027            order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
2028            dispute_status: "initiated".to_string(),
2029            master_buyer_pubkey: Some("npub1buyerkey".to_string()),
2030            master_seller_pubkey: Some("npub1sellerkey".to_string()),
2031            trade_index_buyer: Some(1),
2032            trade_index_seller: Some(2),
2033            buyer_dispute: true,
2034            seller_dispute: false,
2035            solver_pubkey: None,
2036        };
2037
2038        let json = serde_json::to_string(&helper).unwrap();
2039        let deserialized: RestoredDisputeHelper = serde_json::from_str(&json).unwrap();
2040
2041        assert_eq!(deserialized.dispute_id, helper.dispute_id);
2042        assert_eq!(deserialized.order_id, helper.order_id);
2043        assert_eq!(deserialized.dispute_status, helper.dispute_status);
2044        assert_eq!(deserialized.master_buyer_pubkey, helper.master_buyer_pubkey);
2045        assert_eq!(
2046            deserialized.master_seller_pubkey,
2047            helper.master_seller_pubkey
2048        );
2049        assert_eq!(deserialized.trade_index_buyer, helper.trade_index_buyer);
2050        assert_eq!(deserialized.trade_index_seller, helper.trade_index_seller);
2051        assert_eq!(deserialized.buyer_dispute, helper.buyer_dispute);
2052        assert_eq!(deserialized.seller_dispute, helper.seller_dispute);
2053        assert_eq!(deserialized.solver_pubkey, helper.solver_pubkey);
2054
2055        let helper_seller_dispute = RestoredDisputeHelper {
2056            dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
2057            order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
2058            dispute_status: "in-progress".to_string(),
2059            master_buyer_pubkey: None,
2060            master_seller_pubkey: None,
2061            trade_index_buyer: None,
2062            trade_index_seller: None,
2063            buyer_dispute: false,
2064            seller_dispute: true,
2065            solver_pubkey: Some(
2066                "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
2067            ),
2068        };
2069
2070        let json_seller = serde_json::to_string(&helper_seller_dispute).unwrap();
2071        let deserialized_seller: RestoredDisputeHelper =
2072            serde_json::from_str(&json_seller).unwrap();
2073
2074        assert_eq!(
2075            deserialized_seller.dispute_id,
2076            helper_seller_dispute.dispute_id
2077        );
2078        assert_eq!(deserialized_seller.order_id, helper_seller_dispute.order_id);
2079        assert_eq!(
2080            deserialized_seller.dispute_status,
2081            helper_seller_dispute.dispute_status
2082        );
2083        assert_eq!(deserialized_seller.master_buyer_pubkey, None);
2084        assert_eq!(deserialized_seller.master_seller_pubkey, None);
2085        assert_eq!(deserialized_seller.trade_index_buyer, None);
2086        assert_eq!(deserialized_seller.trade_index_seller, None);
2087        assert!(!deserialized_seller.buyer_dispute);
2088        assert!(deserialized_seller.seller_dispute);
2089        assert_eq!(
2090            deserialized_seller.solver_pubkey,
2091            helper_seller_dispute.solver_pubkey
2092        );
2093    }
2094
2095    fn sample_lock_proof() -> CashuLockProof {
2096        CashuLockProof::new(
2097            "cashuAeyJ0b2tlbiI6dGVzdA".to_string(),
2098            "https://mint.example".to_string(),
2099            "02b_buyer".to_string(),
2100            "02s_seller".to_string(),
2101            "02m_mostro".to_string(),
2102        )
2103    }
2104
2105    #[test]
2106    fn test_cashu_lock_proof_json_round_trip() {
2107        let proof = sample_lock_proof();
2108        let json = proof.as_json().unwrap();
2109        let back = CashuLockProof::from_json(&json).unwrap();
2110        assert_eq!(back, proof);
2111    }
2112
2113    #[test]
2114    fn test_cashu_lock_proof_fee_token_round_trip() {
2115        let proof = sample_lock_proof().with_fee_token("cashuAfee".to_string());
2116        let json = proof.as_json().unwrap();
2117        assert!(json.contains("fee_token"));
2118        let back = CashuLockProof::from_json(&json).unwrap();
2119        assert_eq!(back, proof);
2120        assert_eq!(back.fee_token.as_deref(), Some("cashuAfee"));
2121    }
2122
2123    #[test]
2124    fn test_cashu_lock_proof_without_fee_token_is_omitted_and_defaults_to_none() {
2125        // A proof with no fee serializes without the key (wire-compatible with
2126        // clients that predate the field) and parses back to `None`.
2127        let proof = sample_lock_proof();
2128        assert_eq!(proof.fee_token, None);
2129        let json = proof.as_json().unwrap();
2130        assert!(!json.contains("fee_token"));
2131        let back = CashuLockProof::from_json(&json).unwrap();
2132        assert_eq!(back.fee_token, None);
2133    }
2134
2135    #[test]
2136    fn test_add_cashu_escrow_verifies_with_lock_proof() {
2137        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2138        let payload = Payload::CashuLockProof(sample_lock_proof());
2139        let kind = MessageKind::new(
2140            Some(order_id),
2141            None,
2142            None,
2143            Action::AddCashuEscrow,
2144            Some(payload),
2145        );
2146        assert!(
2147            kind.verify(),
2148            "CashuLockProof must verify on AddCashuEscrow"
2149        );
2150
2151        // Round-trip through JSON to catch a serde-rename mismatch on the
2152        // new snake_case discriminator.
2153        let json = Message::Order(kind).as_json().unwrap();
2154        assert!(json.contains("cashu_lock_proof"));
2155        assert!(Message::from_json(&json).unwrap().verify());
2156    }
2157
2158    #[test]
2159    fn test_add_cashu_escrow_requires_id_and_right_payload() {
2160        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2161
2162        // Missing id ⇒ invalid.
2163        let no_id = MessageKind::new(
2164            None,
2165            None,
2166            None,
2167            Action::AddCashuEscrow,
2168            Some(Payload::CashuLockProof(sample_lock_proof())),
2169        );
2170        assert!(
2171            !no_id.verify(),
2172            "AddCashuEscrow without id must be rejected"
2173        );
2174
2175        // Wrong payload ⇒ invalid.
2176        let wrong_payload = MessageKind::new(
2177            Some(order_id),
2178            None,
2179            None,
2180            Action::AddCashuEscrow,
2181            Some(Payload::TextMessage("not a lock proof".to_string())),
2182        );
2183        assert!(
2184            !wrong_payload.verify(),
2185            "AddCashuEscrow with non-lock-proof payload must be rejected"
2186        );
2187    }
2188
2189    #[test]
2190    fn test_cashu_pm_signature_verifies_with_signatures() {
2191        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2192        let kind = MessageKind::new(
2193            Some(order_id),
2194            None,
2195            None,
2196            Action::CashuPmSignature,
2197            Some(Payload::CashuSignatures(vec![
2198                CashuProofSignature::new("secret-0".to_string(), "deadbeef".to_string()),
2199                CashuProofSignature::new("secret-1".to_string(), "c0ffee".to_string()),
2200            ])),
2201        );
2202        assert!(
2203            kind.verify(),
2204            "CashuSignatures must verify on CashuPmSignature"
2205        );
2206
2207        let json = Message::Order(kind).as_json().unwrap();
2208        assert!(json.contains("cashu_signatures"));
2209        assert!(Message::from_json(&json).unwrap().verify());
2210
2211        // Wrong payload ⇒ invalid.
2212        let wrong = MessageKind::new(Some(order_id), None, None, Action::CashuPmSignature, None);
2213        assert!(
2214            !wrong.verify(),
2215            "CashuPmSignature without a signature payload must be rejected"
2216        );
2217
2218        // Empty signature set ⇒ invalid: a multi-proof escrow needs one
2219        // signature per proof, so an empty collection cannot assemble a swap.
2220        let empty = MessageKind::new(
2221            Some(order_id),
2222            None,
2223            None,
2224            Action::CashuPmSignature,
2225            Some(Payload::CashuSignatures(vec![])),
2226        );
2227        assert!(
2228            !empty.verify(),
2229            "CashuPmSignature with an empty signature set must be rejected"
2230        );
2231    }
2232
2233    #[test]
2234    fn test_cashu_escrow_locked_is_informational() {
2235        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2236
2237        // Informational ack with an id and no payload verifies.
2238        let ok = MessageKind::new(Some(order_id), None, None, Action::CashuEscrowLocked, None);
2239        assert!(ok.verify(), "CashuEscrowLocked with id must verify");
2240
2241        // Missing id ⇒ invalid.
2242        let no_id = MessageKind::new(None, None, None, Action::CashuEscrowLocked, None);
2243        assert!(
2244            !no_id.verify(),
2245            "CashuEscrowLocked without id must be rejected"
2246        );
2247    }
2248}