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