Skip to main content

mostro_core/
message.rs

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