Skip to main content

mostro_core/
message.rs

1//! Protocol message envelope exchanged between clients and a Mostro node.
2//!
3//! The top-level type is [`Message`], a tagged union that carries a
4//! [`MessageKind`] together with a discriminator (order, dispute, DM, rate,
5//! can't-do, restore). [`MessageKind`] holds the shared fields present on
6//! every request/response: protocol version, optional identifier, trade
7//! index, [`Action`] and [`Payload`].
8//!
9//! In transit, messages are serialized to JSON, optionally signed with the
10//! sender's trade keys using [`Message::sign`], and wrapped in a NIP-44
11//! direct event by [`crate::transport::wrap_message_with`].
12
13use crate::prelude::*;
14use bitcoin::hashes::sha256::Hash as Sha256Hash;
15use bitcoin::hashes::Hash;
16use nostr_sdk::prelude::*;
17use secp256k1::schnorr;
18use secp256k1::Secp256k1;
19#[cfg(feature = "sqlx")]
20use sqlx::FromRow;
21
22use std::fmt;
23use uuid::Uuid;
24
25/// Identity of a counterpart in a trade.
26///
27/// `Peer` bundles the counterpart's trade public key with an optional
28/// [`UserInfo`] snapshot so it can be embedded into messages that need to
29/// surface reputation (for example the peer disclosure sent with
30/// [`Action::FiatSentOk`]).
31#[derive(Debug, Deserialize, Serialize, Clone)]
32pub struct Peer {
33    /// Trade public key of the peer (hex or npub).
34    pub pubkey: String,
35    /// Optional reputation snapshot. Absent when the peer operates in full
36    /// privacy mode.
37    pub reputation: Option<UserInfo>,
38}
39
40impl Peer {
41    /// Create a new [`Peer`].
42    pub fn new(pubkey: String, reputation: Option<UserInfo>) -> Self {
43        Self { pubkey, reputation }
44    }
45
46    /// Parse a [`Peer`] from its JSON representation.
47    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
48        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
49    }
50
51    /// Serialize the peer to a JSON string.
52    pub fn as_json(&self) -> Result<String, ServiceError> {
53        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
54    }
55}
56
57/// Discriminator describing the verb of a Mostro message.
58///
59/// `Action` values are serialized in `kebab-case`. Each action has its own
60/// expected [`Payload`] shape — see [`MessageKind::verify`] for the full
61/// matrix.
62#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
63#[serde(rename_all = "kebab-case")]
64pub enum Action {
65    /// Publish a new order. Payload: [`Payload::Order`].
66    NewOrder,
67    /// Take an existing `sell` order. Payload: optional
68    /// [`Payload::PaymentRequest`] or [`Payload::Amount`].
69    TakeSell,
70    /// Take an existing `buy` order. Payload: optional [`Payload::Amount`].
71    TakeBuy,
72    /// Request the taker to pay a Lightning invoice.
73    /// Payload: [`Payload::PaymentRequest`].
74    PayInvoice,
75    /// Mostro delivers the bolt11 hold invoice that the taker must pay as
76    /// their anti-abuse bond. Same payload shape and direction as
77    /// [`Action::PayInvoice`] (Mostro → user); only the discriminator
78    /// differs so clients can tell the bond invoice apart from the trade
79    /// hold invoice that follows.
80    /// Payload: [`Payload::PaymentRequest`].
81    PayBondInvoice,
82    /// Buyer notifies Mostro that fiat was sent.
83    FiatSent,
84    /// Mostro acknowledges the fiat-sent notification to the seller.
85    FiatSentOk,
86    /// Seller releases the hold invoice funds.
87    Release,
88    /// Mostro confirms that the funds have been released.
89    Released,
90    /// Cancel an order.
91    Cancel,
92    /// Mostro confirms that the order was canceled.
93    Canceled,
94    /// Local side started a cooperative cancel.
95    CooperativeCancelInitiatedByYou,
96    /// Remote side started a cooperative cancel.
97    CooperativeCancelInitiatedByPeer,
98    /// Local side opened a dispute.
99    DisputeInitiatedByYou,
100    /// Remote side opened a dispute.
101    DisputeInitiatedByPeer,
102    /// Both sides agreed on the cooperative cancel.
103    CooperativeCancelAccepted,
104    /// Mostro accepted the buyer's payout invoice.
105    BuyerInvoiceAccepted,
106    /// Mostro accepted the winning counterparty's bond-payout invoice.
107    /// Bond dual of [`Action::BuyerInvoiceAccepted`] (Mostro → winner):
108    /// the payout bolt11 was received and the payment is now pending, so
109    /// the client can stop prompting the user for an invoice.
110    /// Payload: [`Payload::Order`] (amount = counterparty share).
111    BondInvoiceAccepted,
112    /// Trade completed successfully.
113    PurchaseCompleted,
114    /// Mostro paid out a slashed bond's counterparty share successfully.
115    /// Bond dual of [`Action::PurchaseCompleted`] (Mostro → winner): the
116    /// `send_payment` to the winner's bolt11 succeeded and the bond is
117    /// now settled, so the client can close the claim.
118    /// Payload: [`Payload::Order`] (amount = counterparty share).
119    BondPayoutCompleted,
120    /// Mostro notifies a user that their anti-abuse bond was slashed for
121    /// letting a waiting-state timeout elapse (Mostro → slashed user). The
122    /// bond's hold invoice has been settled and the row moved to
123    /// `pending-payout`; the user keeps no claim over the forfeited sats.
124    /// Informational only — the slashed user receives this alongside the
125    /// `Action::Canceled` for the order itself.
126    /// Payload: [`Payload::Order`] (amount = slashed bond amount).
127    BondSlashed,
128    /// Mostro saw the hold-invoice payment accepted by the node.
129    HoldInvoicePaymentAccepted,
130    /// Mostro saw the hold-invoice payment settled.
131    HoldInvoicePaymentSettled,
132    /// Mostro saw the hold-invoice payment canceled.
133    HoldInvoicePaymentCanceled,
134    /// Informational: waiting for the seller to pay the hold invoice.
135    WaitingSellerToPay,
136    /// Informational: waiting for the buyer's payout invoice.
137    WaitingBuyerInvoice,
138    /// Buyer sends/updates its payout invoice.
139    /// Payload: [`Payload::PaymentRequest`].
140    AddInvoice,
141    /// Taker sends a Lightning invoice that Mostro must pay out as the
142    /// taker's anti-abuse bond. Same payload shape and direction as
143    /// [`Action::AddInvoice`] (user → Mostro); only the discriminator
144    /// differs so Mostro can tell a bond-payout invoice apart from a
145    /// buyer's trade payout invoice.
146    /// Payload: [`Payload::PaymentRequest`].
147    AddBondInvoice,
148    /// Informational: a buyer has taken a sell order.
149    BuyerTookOrder,
150    /// Server-initiated rating request.
151    Rate,
152    /// Client-initiated rate. Payload: [`Payload::RatingUser`].
153    RateUser,
154    /// Acknowledgement of a received rating.
155    RateReceived,
156    /// Mostro returns a structured refusal. Payload: [`Payload::CantDo`].
157    CantDo,
158    /// Client-initiated dispute.
159    Dispute,
160    /// Admin cancels a trade.
161    AdminCancel,
162    /// Admin cancel acknowledged.
163    AdminCanceled,
164    /// Admin settles the hold invoice.
165    AdminSettle,
166    /// Admin settle acknowledged.
167    AdminSettled,
168    /// Admin registers a new dispute solver.
169    AdminAddSolver,
170    /// Solver takes a dispute.
171    AdminTakeDispute,
172    /// Solver took the dispute acknowledged.
173    AdminTookDispute,
174    /// Notification that a Lightning payment failed.
175    /// Payload: [`Payload::PaymentFailed`].
176    PaymentFailed,
177    /// Invoice associated with the order was updated.
178    InvoiceUpdated,
179    /// Direct message between users. Payload: [`Payload::TextMessage`].
180    SendDm,
181    /// Disclosure of a counterpart's trade pubkey. Payload: [`Payload::Peer`].
182    TradePubkey,
183    /// Client asks Mostro to restore its session state. Payload must be `None`.
184    RestoreSession,
185    /// Client asks Mostro for its last known trade index. Payload must be
186    /// `None`.
187    LastTradeIndex,
188    /// Client reads the reputation this node holds for its own identity key.
189    /// Sent in the `restore` wrapper, requires the identity proof and carries
190    /// no `id`. Request payload: `None`. Response payload:
191    /// [`Payload::UserInfo`] (zeros and no `since` for an unknown identity).
192    UserInfo,
193    /// Listing of orders in response to a query.
194    /// Payload: [`Payload::Ids`] or [`Payload::Orders`].
195    Orders,
196    /// Seller submits a Cashu 2-of-3 multisig locked token as the trade
197    /// escrow, in place of paying a Lightning hold invoice (Cashu escrow
198    /// mode). Direction: seller → Mostro.
199    /// Payload: [`Payload::CashuLockProof`].
200    AddCashuEscrow,
201    /// Mostro confirms it validated the seller's Cashu escrow token (the
202    /// 2-of-3 condition is well-formed and the proofs are unspent at the
203    /// mint) and the trade can proceed. Informational; the daemon never
204    /// takes custody. Direction: Mostro → buyer/seller.
205    CashuEscrowLocked,
206    /// Mostro hands its `P_M` signatures (one per escrowed proof) to the
207    /// dispute winner so they can assemble a valid 2-of-3 swap at the mint.
208    /// Emitted only during dispute resolution, as the Cashu counterpart of
209    /// [`Action::AdminSettled`] / [`Action::AdminCanceled`].
210    /// Direction: Mostro → winner. Payload: [`Payload::CashuSignatures`].
211    CashuPmSignature,
212    /// Buyer commits to the fiat account it will pay from, by hash only.
213    /// Direction: buyer → Mostro. Payload: [`Payload::PayerDeclaration`].
214    DeclarePayer,
215    /// The committed payer hash: Mostro's ack to the buyer and its forward
216    /// to the seller. Direction: Mostro → buyer/seller.
217    /// Payload: [`Payload::PayerDeclaration`].
218    PayerDeclared,
219    /// Aggregate history of the buyer's committed payment account.
220    /// Seller → Mostro query (payload `None`); Mostro → seller push after
221    /// `fiat-sent-ok` or reply to the query (payload
222    /// [`Payload::PaymentHistory`]).
223    PaymentHistory,
224    /// User asks the node to attest their reputation there for a destination
225    /// identity. Acts on the identity the transport proved, never on the
226    /// payload, and requires that identity proof. Carries no `id`.
227    /// Direction: client → issuer. Payload: [`Payload::ReputationExportRequest`].
228    ExportReputation,
229    /// The node's reputation attestation, in reply to
230    /// [`Action::ExportReputation`]. Direction: issuer → client.
231    /// Payload: [`Payload::ReputationAttestation`].
232    ReputationExported,
233    /// User imports a reputation attestation into the node. Requires the
234    /// identity proof; carries no `id`. Direction: client → destination.
235    /// Payload: [`Payload::ReputationAttestation`].
236    ImportReputation,
237    /// The import was recorded and merged, in reply to
238    /// [`Action::ImportReputation`]. Direction: destination → client.
239    /// Payload must be `None`.
240    ReputationImported,
241}
242
243impl fmt::Display for Action {
244    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
245        write!(f, "{self:?}")
246    }
247}
248
249/// Top-level Mostro message exchanged between users and Mostro.
250///
251/// `Message` is a tagged union: every variant carries the shared
252/// [`MessageKind`] body, while the variant itself tells the receiver which
253/// channel the message belongs to (orders, disputes, DMs, rating, can't-do,
254/// session restore). Serializes as `kebab-case` JSON.
255#[derive(Debug, Clone, Deserialize, Serialize)]
256#[serde(rename_all = "kebab-case")]
257pub enum Message {
258    /// Order-channel message.
259    Order(MessageKind),
260    /// Dispute-channel message.
261    Dispute(MessageKind),
262    /// "Can't do" response returned by the Mostro node.
263    CantDo(MessageKind),
264    /// Rating message (server-initiated rate request or client rate).
265    Rate(MessageKind),
266    /// Direct message between users.
267    Dm(MessageKind),
268    /// Session restore request/response.
269    Restore(MessageKind),
270}
271
272impl Message {
273    /// Build a new `Message::Order` wrapping a freshly constructed
274    /// [`MessageKind`].
275    pub fn new_order(
276        id: Option<Uuid>,
277        request_id: Option<u64>,
278        trade_index: Option<i64>,
279        action: Action,
280        payload: Option<Payload>,
281    ) -> Self {
282        let kind = MessageKind::new(id, request_id, trade_index, action, payload);
283        Self::Order(kind)
284    }
285
286    /// Build a new `Message::Dispute` wrapping a freshly constructed
287    /// [`MessageKind`].
288    pub fn new_dispute(
289        id: Option<Uuid>,
290        request_id: Option<u64>,
291        trade_index: Option<i64>,
292        action: Action,
293        payload: Option<Payload>,
294    ) -> Self {
295        let kind = MessageKind::new(id, request_id, trade_index, action, payload);
296
297        Self::Dispute(kind)
298    }
299
300    /// Build a new `Message::Restore` with [`Action::RestoreSession`].
301    ///
302    /// According to [`MessageKind::verify`], the payload for a restore
303    /// request must be `None`. Any other payload yields an invalid message.
304    pub fn new_restore(payload: Option<Payload>) -> Self {
305        let kind = MessageKind::new(None, None, None, Action::RestoreSession, payload);
306        Self::Restore(kind)
307    }
308
309    /// Build a new `Message::CantDo` message (a structured refusal sent by
310    /// Mostro when a request cannot be fulfilled).
311    pub fn cant_do(id: Option<Uuid>, request_id: Option<u64>, payload: Option<Payload>) -> Self {
312        let kind = MessageKind::new(id, request_id, None, Action::CantDo, payload);
313
314        Self::CantDo(kind)
315    }
316
317    /// Build a new `Message::Dm` carrying a direct message between users.
318    pub fn new_dm(
319        id: Option<Uuid>,
320        request_id: Option<u64>,
321        action: Action,
322        payload: Option<Payload>,
323    ) -> Self {
324        let kind = MessageKind::new(id, request_id, None, action, payload);
325
326        Self::Dm(kind)
327    }
328
329    /// Parse a [`Message`] from its JSON representation.
330    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
331        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
332    }
333
334    /// Serialize the message to a JSON string.
335    pub fn as_json(&self) -> Result<String, ServiceError> {
336        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
337    }
338
339    /// Borrow the inner [`MessageKind`] regardless of the variant.
340    pub fn get_inner_message_kind(&self) -> &MessageKind {
341        match self {
342            Message::Dispute(k)
343            | Message::Order(k)
344            | Message::CantDo(k)
345            | Message::Rate(k)
346            | Message::Dm(k)
347            | Message::Restore(k) => k,
348        }
349    }
350
351    /// Return the [`Action`] of the inner [`MessageKind`].
352    ///
353    /// Always returns `Some` for the current variant set; the `Option` is
354    /// kept for API stability.
355    pub fn inner_action(&self) -> Option<Action> {
356        match self {
357            Message::Dispute(a)
358            | Message::Order(a)
359            | Message::CantDo(a)
360            | Message::Rate(a)
361            | Message::Dm(a)
362            | Message::Restore(a) => Some(a.get_action()),
363        }
364    }
365
366    /// Validate that the inner [`MessageKind`] is consistent with its
367    /// [`Action`]. Delegates to [`MessageKind::verify`], and rejects
368    /// [`Action::UserInfo`] outside the `restore` wrapper.
369    pub fn verify(&self) -> bool {
370        if !matches!(self, Message::Restore(_))
371            && self.get_inner_message_kind().action == Action::UserInfo
372        {
373            return false;
374        }
375        match self {
376            Message::Order(m)
377            | Message::Dispute(m)
378            | Message::CantDo(m)
379            | Message::Rate(m)
380            | Message::Dm(m)
381            | Message::Restore(m) => m.verify(),
382        }
383    }
384
385    /// Produce a Schnorr signature over the SHA-256 digest of `message`
386    /// using `keys`.
387    ///
388    /// This is the trade signature embedded in the content tuple when
389    /// [`crate::transport::wrap_message_nip44`] is called with
390    /// [`WrapOptions::signed`](crate::transport::WrapOptions::signed) set to
391    /// `true`. It binds a message to the sender's trade keys without
392    /// relying on the outer Nostr event signature.
393    ///
394    /// Implementation note (nostr 0.45): `Keys::sign_schnorr` takes the
395    /// digest as raw bytes (`AsRef<[u8]>`), not `bitcoin::secp256k1::Message`.
396    pub fn sign(message: String, keys: &Keys) -> Signature {
397        let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes());
398        keys.sign_schnorr(hash.to_byte_array())
399    }
400
401    /// Verify a signature previously produced by [`Message::sign`].
402    ///
403    /// Returns `true` when `sig` is a valid Schnorr signature of the
404    /// SHA-256 digest of `message` under `pubkey`, `false` otherwise
405    /// (including when `pubkey` has no x-only representation).
406    ///
407    /// Uses the same `secp256k1` 0.30 types as `nostr` (via the crate's
408    /// direct `secp256k1` dependency) so verification stays aligned with
409    /// [`Message::sign`].
410    pub fn verify_signature(message: String, pubkey: PublicKey, sig: Signature) -> bool {
411        let hash: Sha256Hash = Sha256Hash::hash(message.as_bytes());
412        let hash = hash.to_byte_array();
413
414        let secp = Secp256k1::verification_only();
415        if let Ok(xonlykey) = pubkey.xonly() {
416            let sig = schnorr::Signature::from_byte_array(*sig.as_bytes());
417            xonlykey.verify(&secp, &hash, &sig).is_ok()
418        } else {
419            false
420        }
421    }
422}
423
424/// Body shared by every [`Message`] variant.
425///
426/// All Mostro protocol messages share this envelope: a protocol version,
427/// an optional client-chosen request id for correlation, a trade index used
428/// to enforce strictly increasing sequences per user, an optional
429/// order/dispute id, an [`Action`] and an optional [`Payload`].
430#[derive(Debug, Clone, Deserialize, Serialize)]
431pub struct MessageKind {
432    /// Mostro protocol version. Set to
433    /// `PROTOCOL_VER` by [`MessageKind::new`].
434    pub version: u8,
435    /// Client-chosen correlation id, echoed back on responses so the client
436    /// can match them to in-flight requests.
437    pub request_id: Option<u64>,
438    /// Trade index attached to this message. Must be strictly greater than
439    /// the last trade index Mostro has seen for the sender.
440    pub trade_index: Option<i64>,
441    /// Optional target identifier (usually the id of an [`crate::order::Order`]
442    /// or [`crate::dispute::Dispute`]).
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub id: Option<Uuid>,
445    /// Verb of the message.
446    pub action: Action,
447    /// Payload attached to the action. The allowed shape for a given action
448    /// is enforced by [`MessageKind::verify`].
449    pub payload: Option<Payload>,
450}
451
452/// Alias for a signed integer amount in satoshis.
453type Amount = i64;
454
455/// Retry configuration for a failed Lightning payment.
456///
457/// Sent inside a [`Payload::PaymentFailed`] so the client knows how many
458/// retries to expect and how long to wait between them.
459#[derive(Debug, Deserialize, Serialize, Clone)]
460pub struct PaymentFailedInfo {
461    /// Maximum number of payment attempts Mostro will perform.
462    pub payment_attempts: u32,
463    /// Delay in seconds between two retry attempts.
464    pub payment_retries_interval: u32,
465}
466
467/// Row-mapper used by `mostrod` when fetching metadata for session restore.
468///
469/// Not intended as a general-purpose order representation — field names are
470/// chosen to match the SQL `SELECT` aliases used by the server query.
471#[cfg_attr(feature = "sqlx", derive(FromRow))]
472#[derive(Debug, Deserialize, Serialize, Clone)]
473pub struct RestoredOrderHelper {
474    /// Order id.
475    pub id: Uuid,
476    /// Order status, serialized as kebab-case.
477    pub status: String,
478    /// Master identity pubkey of the buyer, if any.
479    pub master_buyer_pubkey: Option<String>,
480    /// Master identity pubkey of the seller, if any.
481    pub master_seller_pubkey: Option<String>,
482    /// Trade index the buyer used on this order.
483    pub trade_index_buyer: Option<i64>,
484    /// Trade index the seller used on this order.
485    pub trade_index_seller: Option<i64>,
486}
487
488/// Row-mapper used by `mostrod` when fetching disputes for session restore.
489///
490/// Field names are chosen to match the SQL `SELECT` aliases in the restore
491/// query (in particular `status` is aliased as `dispute_status`).
492#[cfg_attr(feature = "sqlx", derive(FromRow))]
493#[derive(Debug, Deserialize, Serialize, Clone)]
494pub struct RestoredDisputeHelper {
495    /// Dispute id.
496    pub dispute_id: Uuid,
497    /// Order id the dispute is attached to.
498    pub order_id: Uuid,
499    /// Dispute status, serialized as kebab-case.
500    pub dispute_status: String,
501    /// Master identity pubkey of the buyer, if any.
502    pub master_buyer_pubkey: Option<String>,
503    /// Master identity pubkey of the seller, if any.
504    pub master_seller_pubkey: Option<String>,
505    /// Trade index the buyer used on the parent order.
506    pub trade_index_buyer: Option<i64>,
507    /// Trade index the seller used on the parent order.
508    pub trade_index_seller: Option<i64>,
509    /// Whether the buyer has initiated a dispute for this order.
510    /// Combined with [`Self::seller_dispute`] to derive
511    /// [`RestoredDisputesInfo::initiator`].
512    pub buyer_dispute: bool,
513    /// Whether the seller has initiated a dispute for this order.
514    /// Combined with [`Self::buyer_dispute`] to derive
515    /// [`RestoredDisputesInfo::initiator`].
516    pub seller_dispute: bool,
517    /// Public key of the solver assigned to the dispute, `None` if no
518    /// solver has taken it.
519    pub solver_pubkey: Option<String>,
520}
521
522/// Minimal per-order information returned to a client on session restore.
523#[cfg_attr(feature = "sqlx", derive(FromRow))]
524#[derive(Debug, Deserialize, Serialize, Clone)]
525pub struct RestoredOrdersInfo {
526    /// Id of the order.
527    pub order_id: Uuid,
528    /// Trade index of the order as seen by the requesting user.
529    pub trade_index: i64,
530    /// Current status of the order, serialized as kebab-case.
531    pub status: String,
532    /// Trade pubkey of the **other** party on this order, when the order has
533    /// one yet (`None` on an order nobody has taken).
534    ///
535    /// Peer chat is end-to-end between the two trade keys and the daemon
536    /// never sees it, so a client that lost its local database can re-derive
537    /// the conversation keys — the HKDF split of the ECDH shared secret, see
538    /// <https://mostro.network/protocol/chat.html> — only if it learns this
539    /// pubkey again. The trade index above gives back the client's own key;
540    /// this gives back the other half, and with both the client can re-fetch
541    /// its chat history from the relays after a restore.
542    ///
543    /// `#[serde(default)]` so a client built against this version still
544    /// parses the payload of a daemon that predates the field.
545    #[serde(default)]
546    pub counterparty_trade_pubkey: Option<String>,
547}
548
549/// Identifies which party of an order opened a dispute.
550#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
551#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
552#[serde(rename_all = "lowercase")]
553#[cfg_attr(feature = "sqlx", sqlx(type_name = "TEXT", rename_all = "lowercase"))]
554pub enum DisputeInitiator {
555    /// The buyer opened the dispute.
556    Buyer,
557    /// The seller opened the dispute.
558    Seller,
559}
560
561/// Minimal per-dispute information returned to a client on session restore.
562#[cfg_attr(feature = "sqlx", derive(FromRow))]
563#[derive(Debug, Deserialize, Serialize, Clone)]
564pub struct RestoredDisputesInfo {
565    /// Id of the dispute.
566    pub dispute_id: Uuid,
567    /// Id of the order the dispute is attached to.
568    pub order_id: Uuid,
569    /// Trade index of the dispute as seen by the requesting user.
570    pub trade_index: i64,
571    /// Current status of the dispute, serialized as kebab-case.
572    pub status: String,
573    /// Who initiated the dispute: [`DisputeInitiator::Buyer`],
574    /// [`DisputeInitiator::Seller`], or `None` when unknown.
575    pub initiator: Option<DisputeInitiator>,
576    /// Public key of the solver assigned to the dispute, `None` if no
577    /// solver has taken it yet.
578    pub solver_pubkey: Option<String>,
579}
580
581/// Bundle of orders and disputes returned on a session restore.
582///
583/// Carried inside [`Payload::RestoreData`]. The server typically sends this
584/// struct in the response to a [`Action::RestoreSession`] request.
585#[derive(Debug, Deserialize, Serialize, Clone, Default)]
586pub struct RestoreSessionInfo {
587    /// Orders associated with the requesting user.
588    #[serde(rename = "orders")]
589    pub restore_orders: Vec<RestoredOrdersInfo>,
590    /// Disputes associated with the requesting user.
591    #[serde(rename = "disputes")]
592    pub restore_disputes: Vec<RestoredDisputesInfo>,
593}
594
595/// Bond resolution carried by [`Action::AdminSettle`] /
596/// [`Action::AdminCancel`].
597///
598/// Lets the solver express slash decisions independently from the trade
599/// outcome (settle vs cancel). Absent payload (`null`) ⇒ neither bond is
600/// slashed (release-by-default, coherent with the "when in doubt, release"
601/// invariant).
602#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Default)]
603pub struct BondResolution {
604    /// Slash the seller's bond (if posted).
605    pub slash_seller: bool,
606    /// Slash the buyer's bond (if posted).
607    pub slash_buyer: bool,
608}
609
610/// Outbound side of the bond payout invoice request carried by
611/// [`Action::AddBondInvoice`] (Mostro → winning counterparty).
612///
613/// Asks the recipient for a bolt11 sized at `order.amount` (= the
614/// counterparty share of a slashed bond) and ships the slash anchor
615/// `slashed_at` so the client can compute the forfeit deadline as
616/// `slashed_at + bond_payout_claim_window_days * 86_400` — accurate even
617/// when the message lands days late because the recipient or their relay
618/// was offline.
619///
620/// The reply (counterparty → Mostro) reuses [`Payload::PaymentRequest`]
621/// with the actual bolt11 in the second tuple slot, so the two
622/// directions of the same action are wire-distinguishable by payload
623/// shape rather than by message ordering.
624#[derive(Debug, Deserialize, Serialize, Clone)]
625pub struct BondPayoutRequest {
626    /// Order context (id, kind, `amount` = counterparty share in sats,
627    /// fiat metadata, etc.). Same [`SmallOrder`] shape the client
628    /// already renders for other order-bearing actions.
629    pub order: SmallOrder,
630    /// Unix timestamp (seconds, UTC) at which Mostro recorded the slash
631    /// decision. Frozen at the `Locked → PendingPayout` CAS and shipped
632    /// verbatim on every cadence retry of this request — clients can
633    /// rely on it as a fixed anchor.
634    pub slashed_at: i64,
635}
636
637/// Request carried by [`Action::ExportReputation`].
638///
639/// The source account is never named here: the issuer attests the identity
640/// the transport proved.
641#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
642pub struct ReputationExportRequest {
643    /// Identity the attestation will name, as 64 lowercase hex characters.
644    pub destination: String,
645    /// A rebind authorisation serialised as event JSON, signed by the
646    /// identity the account is currently bound to, when `destination` is a
647    /// different one. See
648    /// [`crate::reputation::ReputationRebind`].
649    #[serde(default)]
650    pub rebind: Option<String>,
651}
652
653/// Cashu 2-of-3 multisig escrow lock submitted by the seller.
654///
655/// Carried inside [`Payload::CashuLockProof`] on [`Action::AddCashuEscrow`]
656/// (seller → Mostro). It describes a NUT-11 P2PK token locked to a 2-of-3
657/// spending condition over the buyer (`P_B`), seller (`P_S`) and Mostro
658/// (`P_M`) pubkeys. Mostro validates the condition and confirms the proofs
659/// are unspent at the mint (NUT-07 `checkstate`) without ever taking
660/// custody — it only ever holds one of the three keys.
661#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
662pub struct CashuLockProof {
663    /// Serialized Cashu token (the locked ecash) as submitted by the seller.
664    pub token: String,
665    /// URL of the mint hosting the escrowed proofs. Must match the node's
666    /// configured mint.
667    pub mint_url: String,
668    /// Buyer pubkey embedded in the 2-of-3 condition (`P_B`), hex.
669    pub buyer_pubkey: String,
670    /// Seller pubkey embedded in the 2-of-3 condition (`P_S`), hex.
671    pub seller_pubkey: String,
672    /// Mostro/arbitrator pubkey embedded in the 2-of-3 condition (`P_M`),
673    /// hex.
674    pub mostro_pubkey: String,
675    /// Serialized Cashu token locked 1-of-1 to `P_M`, carrying the Mostro fee
676    /// the seller funds alongside the escrow. `None` when the node charges no
677    /// fee. Omitted from the wire form when absent, so proofs without a fee
678    /// serialize exactly as before.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub fee_token: Option<String>,
681}
682
683impl CashuLockProof {
684    /// Create a new [`CashuLockProof`].
685    pub fn new(
686        token: String,
687        mint_url: String,
688        buyer_pubkey: String,
689        seller_pubkey: String,
690        mostro_pubkey: String,
691    ) -> Self {
692        Self {
693            token,
694            mint_url,
695            buyer_pubkey,
696            seller_pubkey,
697            mostro_pubkey,
698            fee_token: None,
699        }
700    }
701
702    /// Attach the seller-funded fee token (a Cashu token locked to `P_M`),
703    /// returning the updated proof.
704    pub fn with_fee_token(mut self, fee_token: String) -> Self {
705        self.fee_token = Some(fee_token);
706        self
707    }
708
709    /// Parse a [`CashuLockProof`] from its JSON representation.
710    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
711        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
712    }
713
714    /// Serialize the lock proof to a JSON string.
715    pub fn as_json(&self) -> Result<String, ServiceError> {
716        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
717    }
718}
719
720/// Mostro's `P_M` signature for a single escrowed proof.
721///
722/// Under NUT-11 SIG_INPUTS each input proof carries its own witness with a
723/// signature over that proof's own secret, so a Cashu token split across
724/// several denominations needs one signature per proof. The dispute winner
725/// matches each signature to its proof by `secret` when populating the
726/// per-proof witnesses and assembling the mint swap. See
727/// [`Payload::CashuSignatures`].
728#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
729pub struct CashuProofSignature {
730    /// NUT-11 secret of the proof this signature applies to, exactly as it
731    /// appears in the escrowed token. Used to match the signature to its
732    /// proof.
733    pub secret: String,
734    /// Mostro's `P_M` signature (hex) over `secret`, to be inserted into that
735    /// proof's NUT-11 witness.
736    pub signature: String,
737}
738
739impl CashuProofSignature {
740    /// Create a new [`CashuProofSignature`].
741    pub fn new(secret: String, signature: String) -> Self {
742        Self { secret, signature }
743    }
744}
745
746/// Typed payload attached to a [`MessageKind`].
747///
748/// Each variant corresponds to a set of [`Action`] values that can legally
749/// carry it (see [`MessageKind::verify`]). Serialized in `snake_case` so
750/// that the variant name is the JSON discriminator.
751#[derive(Debug, Deserialize, Serialize, Clone)]
752#[serde(rename_all = "snake_case")]
753pub enum Payload {
754    /// A compact representation of an order used by [`Action::NewOrder`].
755    Order(SmallOrder),
756    /// Lightning payment request plus optional amount override.
757    ///
758    /// Used by [`Action::PayInvoice`], [`Action::PayBondInvoice`],
759    /// [`Action::AddInvoice`], [`Action::AddBondInvoice`] and
760    /// [`Action::TakeSell`]. The [`SmallOrder`]
761    /// carries the matching order when relevant; the `String` is a BOLT-11
762    /// invoice.
763    PaymentRequest(Option<SmallOrder>, String, Option<Amount>),
764    /// Free-form text message used by DMs.
765    TextMessage(String),
766    /// Peer disclosure (trade pubkey and optional reputation).
767    Peer(Peer),
768    /// Rating value the user wants to attach to a completed trade.
769    RatingUser(u8),
770    /// Raw amount in satoshis (for actions that accept an amount override).
771    Amount(Amount),
772    /// Dispute context: the dispute id plus optional
773    /// [`SolverDisputeInfo`] bundle sent to solvers.
774    Dispute(Uuid, Option<SolverDisputeInfo>),
775    /// Reason carried by a [`Action::CantDo`] response.
776    CantDo(Option<CantDoReason>),
777    /// Next trade key and index announced by the maker of a range order
778    /// when it emits [`Action::Release`] or [`Action::FiatSent`].
779    NextTrade(String, u32),
780    /// Retry configuration surfaced by [`Action::PaymentFailed`].
781    PaymentFailed(PaymentFailedInfo),
782    /// Payload returned by the server on a session restore.
783    RestoreData(RestoreSessionInfo),
784    /// Vector of order ids (lightweight listing).
785    Ids(Vec<Uuid>),
786    /// Vector of [`SmallOrder`] values (full listing).
787    Orders(Vec<SmallOrder>),
788    /// Slash decisions carried by [`Action::AdminSettle`] /
789    /// [`Action::AdminCancel`]. See [`BondResolution`].
790    BondResolution(BondResolution),
791    /// Outbound bond payout invoice request carried by
792    /// [`Action::AddBondInvoice`] (Mostro → counterparty). The reply
793    /// direction (counterparty → Mostro) keeps using
794    /// [`Payload::PaymentRequest`] with the actual bolt11. See
795    /// [`BondPayoutRequest`].
796    BondPayoutRequest(BondPayoutRequest),
797    /// Cashu 2-of-3 multisig escrow lock submitted on
798    /// [`Action::AddCashuEscrow`] (seller → Mostro). See [`CashuLockProof`].
799    CashuLockProof(CashuLockProof),
800    /// Mostro's NUT-11 P2PK signatures over the escrowed proofs, one entry
801    /// per proof. Carried by [`Action::CashuPmSignature`] when Mostro delivers
802    /// its `P_M` signatures to a dispute winner. A token split across several
803    /// denominations contains multiple proofs, and SIG_INPUTS requires each to
804    /// be signed independently. See [`CashuProofSignature`].
805    CashuSignatures(Vec<CashuProofSignature>),
806    /// Buyer's hash-only commitment to its fiat payer account, carried by
807    /// [`Action::DeclarePayer`] and [`Action::PayerDeclared`]. See
808    /// [`PayerDeclaration`].
809    PayerDeclaration(PayerDeclaration),
810    /// Aggregate history of a `(buyer, payment_hash)` pair, carried by
811    /// [`Action::PaymentHistory`] from Mostro to the seller. See
812    /// [`PaymentHistory`].
813    PaymentHistory(PaymentHistory),
814    /// Destination identity and optional rebind authorisation carried by
815    /// [`Action::ExportReputation`]. See [`ReputationExportRequest`].
816    ReputationExportRequest(ReputationExportRequest),
817    /// A reputation attestation (kind `38388`) serialised as event JSON,
818    /// carried by [`Action::ReputationExported`] and
819    /// [`Action::ImportReputation`]. Parse it with
820    /// [`ReputationAttestation::parse_json`](crate::reputation::ReputationAttestation::parse_json);
821    /// a client forwards it unchanged.
822    ReputationAttestation(String),
823    /// The requester's own reputation on this node, carried by
824    /// [`Action::UserInfo`] from Mostro to the client.
825    UserInfo(UserInfo),
826}
827
828#[allow(dead_code)]
829impl MessageKind {
830    /// Build a new [`MessageKind`] stamped with the current protocol
831    /// version (`PROTOCOL_VER`).
832    pub fn new(
833        id: Option<Uuid>,
834        request_id: Option<u64>,
835        trade_index: Option<i64>,
836        action: Action,
837        payload: Option<Payload>,
838    ) -> Self {
839        Self {
840            version: PROTOCOL_VER,
841            request_id,
842            trade_index,
843            id,
844            action,
845            payload,
846        }
847    }
848    /// Parse a [`MessageKind`] from its JSON representation.
849    pub fn from_json(json: &str) -> Result<Self, ServiceError> {
850        serde_json::from_str(json).map_err(|_| ServiceError::MessageSerializationError)
851    }
852    /// Serialize the [`MessageKind`] to a JSON string.
853    pub fn as_json(&self) -> Result<String, ServiceError> {
854        serde_json::to_string(&self).map_err(|_| ServiceError::MessageSerializationError)
855    }
856
857    /// Return a clone of the [`Action`] carried by this message.
858    pub fn get_action(&self) -> Action {
859        self.action.clone()
860    }
861
862    /// Extract the `(next_trade_pubkey, next_trade_index)` pair from a
863    /// [`Payload::NextTrade`] payload.
864    ///
865    /// Returns `Ok(None)` when there is no payload at all and
866    /// [`ServiceError::InvalidPayload`] when the payload is present but of
867    /// a different variant.
868    pub fn get_next_trade_key(&self) -> Result<Option<(String, u32)>, ServiceError> {
869        match &self.payload {
870            Some(Payload::NextTrade(key, index)) => Ok(Some((key.to_string(), *index))),
871            None => Ok(None),
872            _ => Err(ServiceError::InvalidPayload),
873        }
874    }
875
876    /// Extract the rating value from a [`Payload::RatingUser`] payload,
877    /// validating it against
878    /// [`MIN_RATING`]`..=`[`MAX_RATING`].
879    ///
880    /// Returns [`ServiceError::InvalidRating`] when the payload shape is
881    /// wrong and [`ServiceError::InvalidRatingValue`] when the value is out
882    /// of range.
883    pub fn get_rating(&self) -> Result<u8, ServiceError> {
884        if let Some(Payload::RatingUser(v)) = self.payload.to_owned() {
885            if !(MIN_RATING..=MAX_RATING).contains(&v) {
886                return Err(ServiceError::InvalidRatingValue);
887            }
888            Ok(v)
889        } else {
890            Err(ServiceError::InvalidRating)
891        }
892    }
893
894    /// Check that the payload, id and trade index are consistent with the
895    /// action carried by this message.
896    ///
897    /// Returns `true` when the combination is well-formed and `false`
898    /// otherwise; Mostro uses this method to reject malformed requests
899    /// before processing them.
900    pub fn verify(&self) -> bool {
901        match &self.action {
902            Action::NewOrder => matches!(&self.payload, Some(Payload::Order(_))),
903            Action::PayInvoice | Action::PayBondInvoice | Action::AddInvoice => {
904                if self.id.is_none() {
905                    return false;
906                }
907                matches!(&self.payload, Some(Payload::PaymentRequest(_, _, _)))
908            }
909            Action::AddBondInvoice => {
910                if self.id.is_none() {
911                    return false;
912                }
913                // Two valid shapes:
914                //   - `BondPayoutRequest` for the outbound direction
915                //     (Mostro → counterparty, "send me a bolt11").
916                //   - `PaymentRequest` for the inbound reply
917                //     (counterparty → Mostro, "here is the bolt11").
918                matches!(
919                    &self.payload,
920                    Some(Payload::BondPayoutRequest(_)) | Some(Payload::PaymentRequest(_, _, _))
921                )
922            }
923            Action::AdminSettle | Action::AdminCancel => {
924                if self.id.is_none() {
925                    return false;
926                }
927                matches!(&self.payload, None | Some(Payload::BondResolution(_)))
928            }
929            Action::AddCashuEscrow => {
930                if self.id.is_none() {
931                    return false;
932                }
933                matches!(&self.payload, Some(Payload::CashuLockProof(_)))
934            }
935            Action::CashuPmSignature => {
936                if self.id.is_none() {
937                    return false;
938                }
939                matches!(&self.payload, Some(Payload::CashuSignatures(sigs)) if !sigs.is_empty())
940            }
941            Action::DeclarePayer | Action::PayerDeclared => {
942                if self.id.is_none() {
943                    return false;
944                }
945                matches!(&self.payload, Some(Payload::PayerDeclaration(_)))
946            }
947            Action::PaymentHistory => {
948                if self.id.is_none() {
949                    return false;
950                }
951                // Two valid shapes: the seller's query (no payload) and
952                // Mostro's push / reply (the aggregate history).
953                matches!(&self.payload, None | Some(Payload::PaymentHistory(_)))
954            }
955            Action::TakeSell
956            | Action::TakeBuy
957            | Action::FiatSent
958            | Action::FiatSentOk
959            | Action::Release
960            | Action::Released
961            | Action::Dispute
962            | Action::AdminCanceled
963            | Action::AdminSettled
964            | Action::Rate
965            | Action::RateReceived
966            | Action::AdminTakeDispute
967            | Action::AdminTookDispute
968            | Action::DisputeInitiatedByYou
969            | Action::DisputeInitiatedByPeer
970            | Action::WaitingBuyerInvoice
971            | Action::PurchaseCompleted
972            | Action::BondPayoutCompleted
973            | Action::BondSlashed
974            | Action::HoldInvoicePaymentAccepted
975            | Action::HoldInvoicePaymentSettled
976            | Action::HoldInvoicePaymentCanceled
977            | Action::WaitingSellerToPay
978            | Action::BuyerTookOrder
979            | Action::BuyerInvoiceAccepted
980            | Action::BondInvoiceAccepted
981            | Action::CooperativeCancelInitiatedByYou
982            | Action::CooperativeCancelInitiatedByPeer
983            | Action::CooperativeCancelAccepted
984            | Action::Cancel
985            | Action::InvoiceUpdated
986            | Action::AdminAddSolver
987            | Action::SendDm
988            | Action::TradePubkey
989            | Action::CashuEscrowLocked
990            | Action::Canceled => {
991                if self.id.is_none() {
992                    return false;
993                }
994                // Payloads owned by one action family are refused here.
995                !matches!(
996                    &self.payload,
997                    Some(Payload::BondResolution(_))
998                        | Some(Payload::BondPayoutRequest(_))
999                        | Some(Payload::PayerDeclaration(_))
1000                        | Some(Payload::PaymentHistory(_))
1001                        | Some(Payload::UserInfo(_))
1002                )
1003            }
1004            Action::LastTradeIndex | Action::RestoreSession => self.payload.is_none(),
1005            Action::UserInfo => {
1006                self.id.is_none() && matches!(&self.payload, None | Some(Payload::UserInfo(_)))
1007            }
1008            Action::PaymentFailed => {
1009                if self.id.is_none() {
1010                    return false;
1011                }
1012                matches!(&self.payload, Some(Payload::PaymentFailed(_)))
1013            }
1014            Action::RateUser => {
1015                matches!(&self.payload, Some(Payload::RatingUser(_)))
1016            }
1017            Action::CantDo => {
1018                matches!(&self.payload, Some(Payload::CantDo(_)))
1019            }
1020            Action::Orders => {
1021                matches!(
1022                    &self.payload,
1023                    Some(Payload::Ids(_)) | Some(Payload::Orders(_))
1024                )
1025            }
1026            Action::ExportReputation => {
1027                self.id.is_none()
1028                    && matches!(&self.payload, Some(Payload::ReputationExportRequest(_)))
1029            }
1030            Action::ReputationExported | Action::ImportReputation => {
1031                self.id.is_none()
1032                    && matches!(&self.payload, Some(Payload::ReputationAttestation(_)))
1033            }
1034            Action::ReputationImported => self.id.is_none() && self.payload.is_none(),
1035        }
1036    }
1037
1038    /// Return the [`SmallOrder`] carried by a [`Action::NewOrder`] message.
1039    ///
1040    /// Yields `None` if the action is not `NewOrder` or the payload is of a
1041    /// different variant.
1042    pub fn get_order(&self) -> Option<&SmallOrder> {
1043        if self.action != Action::NewOrder {
1044            return None;
1045        }
1046        match &self.payload {
1047            Some(Payload::Order(o)) => Some(o),
1048            _ => None,
1049        }
1050    }
1051
1052    /// Return the Lightning payment request embedded in a message.
1053    ///
1054    /// Valid only for [`Action::TakeSell`], [`Action::AddInvoice`],
1055    /// [`Action::AddBondInvoice`] and [`Action::NewOrder`]. For `NewOrder`,
1056    /// the invoice is read from the [`SmallOrder::buyer_invoice`] field.
1057    /// Returns `None` otherwise.
1058    pub fn get_payment_request(&self) -> Option<String> {
1059        if self.action != Action::TakeSell
1060            && self.action != Action::AddInvoice
1061            && self.action != Action::AddBondInvoice
1062            && self.action != Action::NewOrder
1063        {
1064            return None;
1065        }
1066        match &self.payload {
1067            Some(Payload::PaymentRequest(_, pr, _)) => Some(pr.to_owned()),
1068            Some(Payload::Order(ord)) => ord.buyer_invoice.to_owned(),
1069            _ => None,
1070        }
1071    }
1072
1073    /// Return the amount override embedded in a [`Action::TakeSell`] or
1074    /// [`Action::TakeBuy`] message, either from a [`Payload::Amount`] or
1075    /// from the third element of a [`Payload::PaymentRequest`].
1076    pub fn get_amount(&self) -> Option<Amount> {
1077        if self.action != Action::TakeSell && self.action != Action::TakeBuy {
1078            return None;
1079        }
1080        match &self.payload {
1081            Some(Payload::PaymentRequest(_, _, amount)) => *amount,
1082            Some(Payload::Amount(amount)) => Some(*amount),
1083            _ => None,
1084        }
1085    }
1086
1087    /// Borrow the optional payload.
1088    pub fn get_payload(&self) -> Option<&Payload> {
1089        self.payload.as_ref()
1090    }
1091
1092    /// Return `(true, index)` when the message carries a trade index,
1093    /// `(false, 0)` otherwise.
1094    pub fn has_trade_index(&self) -> (bool, i64) {
1095        if let Some(index) = self.trade_index {
1096            return (true, index);
1097        }
1098        (false, 0)
1099    }
1100
1101    /// Return the trade index carried by the message, or `0` when absent.
1102    pub fn trade_index(&self) -> i64 {
1103        if let Some(index) = self.trade_index {
1104            return index;
1105        }
1106        0
1107    }
1108}
1109
1110#[cfg(test)]
1111mod test {
1112    use crate::message::{
1113        Action, BondPayoutRequest, CashuLockProof, CashuProofSignature, Message, MessageKind,
1114        Payload, Peer,
1115    };
1116    use crate::order::SmallOrder;
1117    use crate::payer::{payment_hash, BuyerMode, PayerDeclaration, PaymentHistory};
1118    use crate::user::UserInfo;
1119    use nostr_sdk::prelude::Keys;
1120    use uuid::uuid;
1121
1122    #[test]
1123    fn test_peer_with_reputation() {
1124        // Test creating a Peer with reputation information
1125        let reputation = UserInfo {
1126            rating: 4.5,
1127            reviews: 10,
1128            operating_days: 30,
1129            since: None,
1130        };
1131        let peer = Peer::new(
1132            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1133            Some(reputation.clone()),
1134        );
1135
1136        // Assert the fields are set correctly
1137        assert_eq!(
1138            peer.pubkey,
1139            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
1140        );
1141        assert!(peer.reputation.is_some());
1142        let peer_reputation = peer.reputation.clone().unwrap();
1143        assert_eq!(peer_reputation.rating, 4.5);
1144        assert_eq!(peer_reputation.reviews, 10);
1145        assert_eq!(peer_reputation.operating_days, 30);
1146
1147        // Test JSON serialization and deserialization
1148        let json = peer.as_json().unwrap();
1149        let deserialized_peer = Peer::from_json(&json).unwrap();
1150        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
1151        assert!(deserialized_peer.reputation.is_some());
1152        let deserialized_reputation = deserialized_peer.reputation.unwrap();
1153        assert_eq!(deserialized_reputation.rating, 4.5);
1154        assert_eq!(deserialized_reputation.reviews, 10);
1155        assert_eq!(deserialized_reputation.operating_days, 30);
1156    }
1157
1158    #[test]
1159    fn test_peer_without_reputation() {
1160        // Test creating a Peer without reputation information
1161        let peer = Peer::new(
1162            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1163            None,
1164        );
1165
1166        // Assert the reputation field is None
1167        assert_eq!(
1168            peer.pubkey,
1169            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8"
1170        );
1171        assert!(peer.reputation.is_none());
1172
1173        // Test JSON serialization and deserialization
1174        let json = peer.as_json().unwrap();
1175        let deserialized_peer = Peer::from_json(&json).unwrap();
1176        assert_eq!(deserialized_peer.pubkey, peer.pubkey);
1177        assert!(deserialized_peer.reputation.is_none());
1178    }
1179
1180    #[test]
1181    fn test_peer_in_message() {
1182        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1183
1184        // Test with reputation
1185        let reputation = UserInfo {
1186            rating: 4.5,
1187            reviews: 10,
1188            operating_days: 30,
1189            since: None,
1190        };
1191        let peer_with_reputation = Peer::new(
1192            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1193            Some(reputation),
1194        );
1195        let payload_with_reputation = Payload::Peer(peer_with_reputation);
1196        let message_with_reputation = Message::Order(MessageKind::new(
1197            Some(uuid),
1198            Some(1),
1199            Some(2),
1200            Action::FiatSentOk,
1201            Some(payload_with_reputation),
1202        ));
1203
1204        // Verify message with reputation
1205        assert!(message_with_reputation.verify());
1206        let message_json = message_with_reputation.as_json().unwrap();
1207        let deserialized_message = Message::from_json(&message_json).unwrap();
1208        assert!(deserialized_message.verify());
1209
1210        // Test without reputation
1211        let peer_without_reputation = Peer::new(
1212            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1213            None,
1214        );
1215        let payload_without_reputation = Payload::Peer(peer_without_reputation);
1216        let message_without_reputation = Message::Order(MessageKind::new(
1217            Some(uuid),
1218            Some(1),
1219            Some(2),
1220            Action::FiatSentOk,
1221            Some(payload_without_reputation),
1222        ));
1223
1224        // Verify message without reputation
1225        assert!(message_without_reputation.verify());
1226        let message_json = message_without_reputation.as_json().unwrap();
1227        let deserialized_message = Message::from_json(&message_json).unwrap();
1228        assert!(deserialized_message.verify());
1229    }
1230
1231    #[test]
1232    fn test_bond_payout_request_payload_verifies_on_add_bond_invoice() {
1233        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1234        let order = SmallOrder {
1235            id: Some(order_id),
1236            kind: None,
1237            status: None,
1238            amount: 500,
1239            fiat_code: "USD".to_string(),
1240            min_amount: None,
1241            max_amount: None,
1242            fiat_amount: 0,
1243            payment_method: "lightning".to_string(),
1244            premium: 0,
1245            buyer_trade_pubkey: None,
1246            seller_trade_pubkey: None,
1247            buyer_invoice: None,
1248            created_at: None,
1249            expires_at: None,
1250            cashu_mint_url: None,
1251        };
1252        let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1253            order,
1254            slashed_at: 1_734_000_000,
1255        });
1256        let kind = MessageKind::new(
1257            Some(order_id),
1258            None,
1259            None,
1260            Action::AddBondInvoice,
1261            Some(payload),
1262        );
1263        assert!(
1264            kind.verify(),
1265            "BondPayoutRequest must verify on AddBondInvoice"
1266        );
1267
1268        // Round-trip through JSON to catch a serde-rename mismatch on the
1269        // new snake_case discriminator.
1270        let m = Message::Order(kind);
1271        let json = m.as_json().unwrap();
1272        assert!(json.contains("bond_payout_request"));
1273        let back = Message::from_json(&json).unwrap();
1274        assert!(back.verify());
1275    }
1276
1277    #[test]
1278    fn test_bond_payout_request_payload_rejected_on_wrong_action() {
1279        // BondPayoutRequest on any action other than AddBondInvoice must
1280        // fail verification — the new variant is opt-in per action.
1281        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1282        let order = SmallOrder {
1283            id: Some(order_id),
1284            kind: None,
1285            status: None,
1286            amount: 500,
1287            fiat_code: "USD".to_string(),
1288            min_amount: None,
1289            max_amount: None,
1290            fiat_amount: 0,
1291            payment_method: "lightning".to_string(),
1292            premium: 0,
1293            buyer_trade_pubkey: None,
1294            seller_trade_pubkey: None,
1295            buyer_invoice: None,
1296            created_at: None,
1297            expires_at: None,
1298            cashu_mint_url: None,
1299        };
1300
1301        // Compile-time exhaustiveness guard: adding a new Action variant
1302        // forces this match to be updated, which in turn forces a
1303        // decision about whether the new variant belongs in
1304        // `other_actions` below.
1305        let _exhaustive: fn(Action) = |a| match a {
1306            Action::AddBondInvoice => {}
1307            Action::NewOrder
1308            | Action::TakeSell
1309            | Action::TakeBuy
1310            | Action::PayInvoice
1311            | Action::PayBondInvoice
1312            | Action::FiatSent
1313            | Action::FiatSentOk
1314            | Action::Release
1315            | Action::Released
1316            | Action::Cancel
1317            | Action::Canceled
1318            | Action::CooperativeCancelInitiatedByYou
1319            | Action::CooperativeCancelInitiatedByPeer
1320            | Action::DisputeInitiatedByYou
1321            | Action::DisputeInitiatedByPeer
1322            | Action::CooperativeCancelAccepted
1323            | Action::BuyerInvoiceAccepted
1324            | Action::BondInvoiceAccepted
1325            | Action::PurchaseCompleted
1326            | Action::BondPayoutCompleted
1327            | Action::BondSlashed
1328            | Action::HoldInvoicePaymentAccepted
1329            | Action::HoldInvoicePaymentSettled
1330            | Action::HoldInvoicePaymentCanceled
1331            | Action::WaitingSellerToPay
1332            | Action::WaitingBuyerInvoice
1333            | Action::AddInvoice
1334            | Action::BuyerTookOrder
1335            | Action::Rate
1336            | Action::RateUser
1337            | Action::RateReceived
1338            | Action::CantDo
1339            | Action::Dispute
1340            | Action::AdminCancel
1341            | Action::AdminCanceled
1342            | Action::AdminSettle
1343            | Action::AdminSettled
1344            | Action::AdminAddSolver
1345            | Action::AdminTakeDispute
1346            | Action::AdminTookDispute
1347            | Action::PaymentFailed
1348            | Action::InvoiceUpdated
1349            | Action::SendDm
1350            | Action::TradePubkey
1351            | Action::RestoreSession
1352            | Action::LastTradeIndex
1353            | Action::UserInfo
1354            | Action::AddCashuEscrow
1355            | Action::CashuEscrowLocked
1356            | Action::CashuPmSignature
1357            | Action::DeclarePayer
1358            | Action::PayerDeclared
1359            | Action::PaymentHistory
1360            | Action::ExportReputation
1361            | Action::ReputationExported
1362            | Action::ImportReputation
1363            | Action::ReputationImported
1364            | Action::Orders => {}
1365        };
1366
1367        let other_actions: &[Action] = &[
1368            Action::NewOrder,
1369            Action::TakeSell,
1370            Action::TakeBuy,
1371            Action::PayInvoice,
1372            Action::PayBondInvoice,
1373            Action::FiatSent,
1374            Action::FiatSentOk,
1375            Action::Release,
1376            Action::Released,
1377            Action::Cancel,
1378            Action::Canceled,
1379            Action::CooperativeCancelInitiatedByYou,
1380            Action::CooperativeCancelInitiatedByPeer,
1381            Action::DisputeInitiatedByYou,
1382            Action::DisputeInitiatedByPeer,
1383            Action::CooperativeCancelAccepted,
1384            Action::BuyerInvoiceAccepted,
1385            Action::BondInvoiceAccepted,
1386            Action::PurchaseCompleted,
1387            Action::BondPayoutCompleted,
1388            Action::BondSlashed,
1389            Action::HoldInvoicePaymentAccepted,
1390            Action::HoldInvoicePaymentSettled,
1391            Action::HoldInvoicePaymentCanceled,
1392            Action::WaitingSellerToPay,
1393            Action::WaitingBuyerInvoice,
1394            Action::AddInvoice,
1395            Action::BuyerTookOrder,
1396            Action::Rate,
1397            Action::RateUser,
1398            Action::RateReceived,
1399            Action::CantDo,
1400            Action::Dispute,
1401            Action::AdminCancel,
1402            Action::AdminCanceled,
1403            Action::AdminSettle,
1404            Action::AdminSettled,
1405            Action::AdminAddSolver,
1406            Action::AdminTakeDispute,
1407            Action::AdminTookDispute,
1408            Action::PaymentFailed,
1409            Action::InvoiceUpdated,
1410            Action::SendDm,
1411            Action::TradePubkey,
1412            Action::RestoreSession,
1413            Action::LastTradeIndex,
1414            Action::UserInfo,
1415            Action::Orders,
1416            Action::AddCashuEscrow,
1417            Action::CashuEscrowLocked,
1418            Action::CashuPmSignature,
1419            Action::DeclarePayer,
1420            Action::PayerDeclared,
1421            Action::PaymentHistory,
1422            Action::ExportReputation,
1423            Action::ReputationExported,
1424            Action::ImportReputation,
1425            Action::ReputationImported,
1426        ];
1427
1428        for action in other_actions {
1429            let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1430                order: order.clone(),
1431                slashed_at: 0,
1432            });
1433            let kind = MessageKind::new(Some(order_id), None, None, action.clone(), Some(payload));
1434            assert!(
1435                !kind.verify(),
1436                "BondPayoutRequest must be rejected on {action:?}"
1437            );
1438        }
1439    }
1440
1441    #[test]
1442    fn test_payer_payloads_rejected_on_unrelated_actions() {
1443        // PayerDeclaration belongs to DeclarePayer / PayerDeclared and
1444        // PaymentHistory to PaymentHistory; every other action refuses both.
1445        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1446        let unrelated = [
1447            Action::NewOrder,
1448            Action::TakeSell,
1449            Action::TakeBuy,
1450            Action::PayInvoice,
1451            Action::PayBondInvoice,
1452            Action::FiatSent,
1453            Action::FiatSentOk,
1454            Action::Release,
1455            Action::Released,
1456            Action::Cancel,
1457            Action::Canceled,
1458            Action::CooperativeCancelInitiatedByYou,
1459            Action::CooperativeCancelInitiatedByPeer,
1460            Action::DisputeInitiatedByYou,
1461            Action::DisputeInitiatedByPeer,
1462            Action::CooperativeCancelAccepted,
1463            Action::BuyerInvoiceAccepted,
1464            Action::BondInvoiceAccepted,
1465            Action::PurchaseCompleted,
1466            Action::BondPayoutCompleted,
1467            Action::BondSlashed,
1468            Action::HoldInvoicePaymentAccepted,
1469            Action::HoldInvoicePaymentSettled,
1470            Action::HoldInvoicePaymentCanceled,
1471            Action::WaitingSellerToPay,
1472            Action::WaitingBuyerInvoice,
1473            Action::AddInvoice,
1474            Action::BuyerTookOrder,
1475            Action::Rate,
1476            Action::RateUser,
1477            Action::RateReceived,
1478            Action::CantDo,
1479            Action::Dispute,
1480            Action::AdminCancel,
1481            Action::AdminCanceled,
1482            Action::AdminSettle,
1483            Action::AdminSettled,
1484            Action::AdminAddSolver,
1485            Action::AdminTakeDispute,
1486            Action::AdminTookDispute,
1487            Action::PaymentFailed,
1488            Action::InvoiceUpdated,
1489            Action::SendDm,
1490            Action::TradePubkey,
1491            Action::RestoreSession,
1492            Action::LastTradeIndex,
1493            Action::UserInfo,
1494            Action::Orders,
1495            Action::AddCashuEscrow,
1496            Action::CashuEscrowLocked,
1497            Action::CashuPmSignature,
1498            Action::AddBondInvoice,
1499        ];
1500        for action in unrelated {
1501            for payload in [
1502                Payload::PayerDeclaration(PayerDeclaration::new("a".repeat(64))),
1503                Payload::PaymentHistory(PaymentHistory::unavailable("a".repeat(64))),
1504            ] {
1505                let kind =
1506                    MessageKind::new(Some(order_id), None, None, action.clone(), Some(payload));
1507                assert!(!kind.verify(), "{action:?} must reject {:?}", kind.payload);
1508            }
1509        }
1510    }
1511
1512    #[test]
1513    fn test_bond_payout_ack_actions_verify_and_wire_format() {
1514        use crate::message::BondResolution;
1515
1516        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1517
1518        // SmallOrder whose `amount` is the counterparty share carried by
1519        // these Mostro → winner bond-payout acknowledgements.
1520        let order = || SmallOrder {
1521            id: Some(order_id),
1522            kind: None,
1523            status: None,
1524            amount: 500,
1525            fiat_code: "USD".to_string(),
1526            min_amount: None,
1527            max_amount: None,
1528            fiat_amount: 0,
1529            payment_method: "lightning".to_string(),
1530            premium: 0,
1531            buyer_trade_pubkey: None,
1532            seller_trade_pubkey: None,
1533            buyer_invoice: None,
1534            created_at: None,
1535            expires_at: None,
1536            cashu_mint_url: None,
1537        };
1538
1539        // These bond notifications carry Payload::Order (BondInvoiceAccepted
1540        // / BondPayoutCompleted are the duals of BuyerInvoiceAccepted /
1541        // PurchaseCompleted; BondSlashed informs the slashed user): id
1542        // required, Payload::Order accepted, and the bond request/resolution
1543        // payloads rejected.
1544        for (action, discriminator) in [
1545            (Action::BondInvoiceAccepted, "bond-invoice-accepted"),
1546            (Action::BondPayoutCompleted, "bond-payout-completed"),
1547            (Action::BondSlashed, "bond-slashed"),
1548        ] {
1549            // id set + Payload::Order verifies.
1550            let ok = Message::Order(MessageKind::new(
1551                Some(order_id),
1552                Some(1),
1553                Some(2),
1554                action.clone(),
1555                Some(Payload::Order(order())),
1556            ));
1557            assert!(ok.verify(), "{action:?} + Order should verify");
1558
1559            // Missing id is invalid.
1560            let no_id = Message::Order(MessageKind::new(
1561                None,
1562                Some(1),
1563                Some(2),
1564                action.clone(),
1565                Some(Payload::Order(order())),
1566            ));
1567            assert!(!no_id.verify(), "{action:?} without id must be rejected");
1568
1569            // BondResolution payload is rejected on these outbound acks.
1570            let with_resolution = Message::Order(MessageKind::new(
1571                Some(order_id),
1572                Some(1),
1573                Some(2),
1574                action.clone(),
1575                Some(Payload::BondResolution(BondResolution {
1576                    slash_seller: true,
1577                    slash_buyer: false,
1578                })),
1579            ));
1580            assert!(
1581                !with_resolution.verify(),
1582                "{action:?} + BondResolution must be rejected"
1583            );
1584
1585            // BondPayoutRequest payload is rejected too.
1586            let with_request = Message::Order(MessageKind::new(
1587                Some(order_id),
1588                Some(1),
1589                Some(2),
1590                action.clone(),
1591                Some(Payload::BondPayoutRequest(BondPayoutRequest {
1592                    order: order(),
1593                    slashed_at: 0,
1594                })),
1595            ));
1596            assert!(
1597                !with_request.verify(),
1598                "{action:?} + BondPayoutRequest must be rejected"
1599            );
1600
1601            // Wire format uses the kebab-case discriminator and round-trips.
1602            let json = ok.as_json().unwrap();
1603            assert!(
1604                json.contains(&format!("\"action\":\"{discriminator}\"")),
1605                "expected kebab-case discriminator {discriminator}, got: {json}"
1606            );
1607            let decoded = Message::from_json(&json).unwrap();
1608            assert!(decoded.verify());
1609            assert_eq!(decoded.inner_action(), Some(action));
1610        }
1611    }
1612
1613    #[test]
1614    fn test_payment_failed_payload() {
1615        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1616
1617        // Test PaymentFailedInfo serialization and deserialization
1618        let payment_failed_info = crate::message::PaymentFailedInfo {
1619            payment_attempts: 3,
1620            payment_retries_interval: 60,
1621        };
1622
1623        let payload = Payload::PaymentFailed(payment_failed_info);
1624        let message = Message::Order(MessageKind::new(
1625            Some(uuid),
1626            Some(1),
1627            Some(2),
1628            Action::PaymentFailed,
1629            Some(payload),
1630        ));
1631
1632        // Verify message validation
1633        assert!(message.verify());
1634
1635        // Test JSON serialization
1636        let message_json = message.as_json().unwrap();
1637
1638        // Test deserialization
1639        let deserialized_message = Message::from_json(&message_json).unwrap();
1640        assert!(deserialized_message.verify());
1641
1642        // Verify the payload contains correct values
1643        if let Message::Order(kind) = deserialized_message {
1644            if let Some(Payload::PaymentFailed(info)) = kind.payload {
1645                assert_eq!(info.payment_attempts, 3);
1646                assert_eq!(info.payment_retries_interval, 60);
1647            } else {
1648                panic!("Expected PaymentFailed payload");
1649            }
1650        } else {
1651            panic!("Expected Order message");
1652        }
1653    }
1654
1655    #[test]
1656    fn test_message_payload_signature() {
1657        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1658        let peer = Peer::new(
1659            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1660            None, // Add None for the reputation parameter
1661        );
1662        let payload = Payload::Peer(peer);
1663        let test_message = Message::Order(MessageKind::new(
1664            Some(uuid),
1665            Some(1),
1666            Some(2),
1667            Action::FiatSentOk,
1668            Some(payload),
1669        ));
1670        assert!(test_message.verify());
1671        let test_message_json = test_message.as_json().unwrap();
1672        // Message should be signed with the trade keys
1673        let trade_keys =
1674            Keys::parse("110e43647eae221ab1da33ddc17fd6ff423f2b2f49d809b9ffa40794a2ab996c")
1675                .unwrap();
1676        let sig = Message::sign(test_message_json.clone(), &trade_keys);
1677
1678        assert!(Message::verify_signature(
1679            test_message_json,
1680            trade_keys.public_key(),
1681            sig
1682        ));
1683    }
1684
1685    #[test]
1686    fn test_restore_session_message() {
1687        // Test RestoreSession request (payload = None)
1688        let restore_request_message = Message::Restore(MessageKind::new(
1689            None,
1690            None,
1691            None,
1692            Action::RestoreSession,
1693            None,
1694        ));
1695
1696        // Verify message validation
1697        assert!(restore_request_message.verify());
1698        assert_eq!(
1699            restore_request_message.inner_action(),
1700            Some(Action::RestoreSession)
1701        );
1702
1703        // Test JSON serialization and deserialization for RestoreRequest
1704        let message_json = restore_request_message.as_json().unwrap();
1705        let deserialized_message = Message::from_json(&message_json).unwrap();
1706        assert!(deserialized_message.verify());
1707        assert_eq!(
1708            deserialized_message.inner_action(),
1709            Some(Action::RestoreSession)
1710        );
1711
1712        // Test RestoreSession with RestoreData payload
1713        let restored_orders = vec![
1714            crate::message::RestoredOrdersInfo {
1715                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1716                trade_index: 1,
1717                status: "active".to_string(),
1718                counterparty_trade_pubkey: Some(
1719                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1720                ),
1721            },
1722            crate::message::RestoredOrdersInfo {
1723                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1724                trade_index: 2,
1725                status: "success".to_string(),
1726                counterparty_trade_pubkey: None,
1727            },
1728        ];
1729
1730        let restored_disputes = vec![
1731            crate::message::RestoredDisputesInfo {
1732                dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1733                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1734                trade_index: 1,
1735                status: "initiated".to_string(),
1736                initiator: Some(crate::message::DisputeInitiator::Buyer),
1737                solver_pubkey: None,
1738            },
1739            crate::message::RestoredDisputesInfo {
1740                dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1741                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1742                trade_index: 2,
1743                status: "in-progress".to_string(),
1744                initiator: None,
1745                solver_pubkey: Some(
1746                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1747                ),
1748            },
1749            crate::message::RestoredDisputesInfo {
1750                dispute_id: uuid!("708e1272-d5f4-47e6-bd97-3504baea9c27"),
1751                order_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1752                trade_index: 3,
1753                status: "initiated".to_string(),
1754                initiator: Some(crate::message::DisputeInitiator::Seller),
1755                solver_pubkey: None,
1756            },
1757        ];
1758
1759        let restore_session_info = crate::message::RestoreSessionInfo {
1760            restore_orders: restored_orders.clone(),
1761            restore_disputes: restored_disputes.clone(),
1762        };
1763
1764        let restore_data_payload = Payload::RestoreData(restore_session_info);
1765        let restore_data_message = Message::Restore(MessageKind::new(
1766            None,
1767            None,
1768            None,
1769            Action::RestoreSession,
1770            Some(restore_data_payload),
1771        ));
1772
1773        // With new logic, any payload for RestoreSession is invalid (must be None)
1774        assert!(!restore_data_message.verify());
1775
1776        // Verify serialization/deserialization of RestoreData payload with all initiator cases
1777        let message_json = restore_data_message.as_json().unwrap();
1778        let deserialized_restore_message = Message::from_json(&message_json).unwrap();
1779
1780        if let Message::Restore(kind) = deserialized_restore_message {
1781            if let Some(Payload::RestoreData(session_info)) = kind.payload {
1782                assert_eq!(session_info.restore_disputes.len(), 3);
1783                assert_eq!(
1784                    session_info.restore_disputes[0].initiator,
1785                    Some(crate::message::DisputeInitiator::Buyer)
1786                );
1787                assert!(session_info.restore_disputes[0].solver_pubkey.is_none());
1788                assert_eq!(session_info.restore_disputes[1].initiator, None);
1789                assert_eq!(
1790                    session_info.restore_disputes[1].solver_pubkey,
1791                    Some(
1792                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1793                            .to_string()
1794                    )
1795                );
1796                assert_eq!(
1797                    session_info.restore_disputes[2].initiator,
1798                    Some(crate::message::DisputeInitiator::Seller)
1799                );
1800                assert!(session_info.restore_disputes[2].solver_pubkey.is_none());
1801                assert_eq!(
1802                    session_info.restore_orders[0].counterparty_trade_pubkey,
1803                    Some(
1804                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1805                            .to_string()
1806                    )
1807                );
1808                assert!(session_info.restore_orders[1]
1809                    .counterparty_trade_pubkey
1810                    .is_none());
1811            } else {
1812                panic!("Expected RestoreData payload");
1813            }
1814        } else {
1815            panic!("Expected Restore message");
1816        }
1817    }
1818
1819    /// A daemon that predates `counterparty_trade_pubkey` sends the payload
1820    /// without it; a client built against this version must still parse it.
1821    #[test]
1822    fn restored_order_without_counterparty_pubkey_still_parses() {
1823        let json = r#"{
1824            "order_id": "308e1272-d5f4-47e6-bd97-3504baea9c23",
1825            "trade_index": 1,
1826            "status": "active"
1827        }"#;
1828
1829        let order: crate::message::RestoredOrdersInfo = serde_json::from_str(json).unwrap();
1830
1831        assert_eq!(order.trade_index, 1);
1832        assert_eq!(order.status, "active");
1833        assert!(order.counterparty_trade_pubkey.is_none());
1834    }
1835
1836    #[test]
1837    fn test_restore_session_message_validation() {
1838        // Test that RestoreSession action accepts only payload=None or RestoreData
1839        let restore_request_message = Message::Restore(MessageKind::new(
1840            None,
1841            None,
1842            None,
1843            Action::RestoreSession,
1844            None, // Missing payload
1845        ));
1846
1847        // Verify restore request message
1848        assert!(restore_request_message.verify());
1849
1850        // Test with wrong payload type
1851        let wrong_payload = Payload::TextMessage("wrong payload".to_string());
1852        let wrong_message = Message::Restore(MessageKind::new(
1853            None,
1854            None,
1855            None,
1856            Action::RestoreSession,
1857            Some(wrong_payload),
1858        ));
1859
1860        // Should fail validation because RestoreSession only accepts None
1861        assert!(!wrong_message.verify());
1862
1863        // With new logic, presence of id/request_id/trade_index is allowed
1864        let with_id = Message::Restore(MessageKind::new(
1865            Some(uuid!("00000000-0000-0000-0000-000000000001")),
1866            None,
1867            None,
1868            Action::RestoreSession,
1869            None,
1870        ));
1871        assert!(with_id.verify());
1872
1873        let with_request_id = Message::Restore(MessageKind::new(
1874            None,
1875            Some(42),
1876            None,
1877            Action::RestoreSession,
1878            None,
1879        ));
1880        assert!(with_request_id.verify());
1881
1882        let with_trade_index = Message::Restore(MessageKind::new(
1883            None,
1884            None,
1885            Some(7),
1886            Action::RestoreSession,
1887            None,
1888        ));
1889        assert!(with_trade_index.verify());
1890    }
1891
1892    #[test]
1893    fn test_restore_session_message_constructor() {
1894        // Test the new_restore constructor
1895        let restore_request_message = Message::new_restore(None);
1896
1897        assert!(matches!(restore_request_message, Message::Restore(_)));
1898        assert!(restore_request_message.verify());
1899        assert_eq!(
1900            restore_request_message.inner_action(),
1901            Some(Action::RestoreSession)
1902        );
1903
1904        // Test with RestoreData payload should be invalid now
1905        let restore_session_info = crate::message::RestoreSessionInfo {
1906            restore_orders: vec![],
1907            restore_disputes: vec![],
1908        };
1909        let restore_data_message =
1910            Message::new_restore(Some(Payload::RestoreData(restore_session_info)));
1911
1912        assert!(matches!(restore_data_message, Message::Restore(_)));
1913        assert!(!restore_data_message.verify());
1914    }
1915
1916    #[test]
1917    fn test_last_trade_index_valid_message() {
1918        let kind = MessageKind::new(None, None, Some(7), Action::LastTradeIndex, None);
1919        let msg = Message::Restore(kind);
1920
1921        assert!(msg.verify());
1922
1923        // roundtrip
1924        let json = msg.as_json().unwrap();
1925        let decoded = Message::from_json(&json).unwrap();
1926        assert!(decoded.verify());
1927
1928        // ensure the trade index is propagated
1929        let inner = decoded.get_inner_message_kind();
1930        assert_eq!(inner.trade_index(), 7);
1931        assert_eq!(inner.has_trade_index(), (true, 7));
1932    }
1933
1934    #[test]
1935    fn test_last_trade_index_without_id_is_valid() {
1936        // With new logic, id is not required; only payload must be None
1937        let kind = MessageKind::new(None, None, Some(5), Action::LastTradeIndex, None);
1938        let msg = Message::Restore(kind);
1939        assert!(msg.verify());
1940    }
1941
1942    #[test]
1943    fn test_last_trade_index_with_payload_fails_validation() {
1944        // LastTradeIndex does not accept payload
1945        let kind = MessageKind::new(
1946            None,
1947            None,
1948            Some(3),
1949            Action::LastTradeIndex,
1950            Some(Payload::TextMessage("ignored".to_string())),
1951        );
1952        let msg = Message::Restore(kind);
1953        assert!(!msg.verify());
1954    }
1955
1956    #[test]
1957    fn test_user_info_request_wire_format() {
1958        let json =
1959            r#"{"restore":{"version":2,"request_id":123456,"action":"user-info","payload":null}}"#;
1960        let msg = Message::from_json(json).unwrap();
1961        assert!(msg.verify());
1962        let inner = msg.get_inner_message_kind();
1963        assert_eq!(inner.action, Action::UserInfo);
1964        assert_eq!(inner.request_id, Some(123456));
1965        assert!(inner.id.is_none());
1966        assert!(inner.payload.is_none());
1967
1968        let kind = MessageKind::new(None, Some(123456), None, Action::UserInfo, None);
1969        let out = Message::Restore(kind).as_json().unwrap();
1970        assert!(out.contains(r#""action":"user-info""#));
1971    }
1972
1973    #[test]
1974    fn test_user_info_response_wire_format() {
1975        let json = r#"{"restore":{"version":2,"request_id":123456,"action":"user-info","payload":{"user_info":{"rating":4.8,"reviews":23,"operating_days":142,"since":1700784000}}}}"#;
1976        let msg = Message::from_json(json).unwrap();
1977        assert!(msg.verify());
1978        match msg.get_inner_message_kind().payload.as_ref() {
1979            Some(Payload::UserInfo(info)) => {
1980                assert_eq!(info.rating, 4.8);
1981                assert_eq!(info.reviews, 23);
1982                assert_eq!(info.operating_days, 142);
1983                assert_eq!(info.since, Some(1_700_784_000));
1984            }
1985            other => panic!("unexpected payload: {other:?}"),
1986        }
1987        let value: serde_json::Value = serde_json::from_str(&msg.as_json().unwrap()).unwrap();
1988        let expected: serde_json::Value = serde_json::from_str(json).unwrap();
1989        assert_eq!(value["restore"]["action"], expected["restore"]["action"]);
1990        assert_eq!(value["restore"]["payload"], expected["restore"]["payload"]);
1991    }
1992
1993    #[test]
1994    fn test_user_info_response_unknown_identity_omits_since() {
1995        let info = UserInfo {
1996            rating: 0.0,
1997            reviews: 0,
1998            operating_days: 0,
1999            since: None,
2000        };
2001        let kind = MessageKind::new(
2002            None,
2003            None,
2004            None,
2005            Action::UserInfo,
2006            Some(Payload::UserInfo(info)),
2007        );
2008        let msg = Message::Restore(kind);
2009        assert!(msg.verify());
2010        let json = msg.as_json().unwrap();
2011        assert!(json.contains(r#""payload":{"user_info":{"#));
2012        assert!(!json.contains("since"));
2013    }
2014
2015    #[test]
2016    fn test_user_info_rejects_wrong_payload_and_id() {
2017        let wrong_payload = MessageKind::new(
2018            None,
2019            None,
2020            None,
2021            Action::UserInfo,
2022            Some(Payload::Peer(Peer::new("pubkey".to_string(), None))),
2023        );
2024        assert!(!Message::Restore(wrong_payload).verify());
2025
2026        let with_id = MessageKind::new(
2027            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
2028            None,
2029            None,
2030            Action::UserInfo,
2031            None,
2032        );
2033        assert!(!Message::Restore(with_id).verify());
2034
2035        let on_order_action = MessageKind::new(
2036            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
2037            None,
2038            None,
2039            Action::FiatSent,
2040            Some(Payload::UserInfo(UserInfo {
2041                rating: 0.0,
2042                reviews: 0,
2043                operating_days: 0,
2044                since: None,
2045            })),
2046        );
2047        assert!(!on_order_action.verify());
2048    }
2049
2050    #[test]
2051    fn test_user_info_rejected_outside_restore_wrapper() {
2052        let request = MessageKind::new(None, Some(1), None, Action::UserInfo, None);
2053        assert!(Message::Restore(request.clone()).verify());
2054        for msg in [
2055            Message::Order(request.clone()),
2056            Message::Dispute(request.clone()),
2057            Message::CantDo(request.clone()),
2058            Message::Rate(request.clone()),
2059            Message::Dm(request),
2060        ] {
2061            assert!(!msg.verify(), "user-info must be rejected in {msg:?}");
2062        }
2063    }
2064
2065    #[test]
2066    fn test_bond_resolution_admin_actions_accept_payload_or_none() {
2067        use crate::message::BondResolution;
2068
2069        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2070
2071        for action in [Action::AdminSettle, Action::AdminCancel] {
2072            let with_resolution = Message::Order(MessageKind::new(
2073                Some(uuid),
2074                Some(1),
2075                Some(2),
2076                action.clone(),
2077                Some(Payload::BondResolution(BondResolution {
2078                    slash_seller: true,
2079                    slash_buyer: false,
2080                })),
2081            ));
2082            assert!(
2083                with_resolution.verify(),
2084                "{action:?} + BondResolution should verify"
2085            );
2086
2087            let without_payload = Message::Order(MessageKind::new(
2088                Some(uuid),
2089                Some(1),
2090                Some(2),
2091                action.clone(),
2092                None,
2093            ));
2094            assert!(without_payload.verify(), "{action:?} + None should verify");
2095
2096            // Wrong payload type must be rejected for these admin actions.
2097            let wrong = Message::Order(MessageKind::new(
2098                Some(uuid),
2099                Some(1),
2100                Some(2),
2101                action.clone(),
2102                Some(Payload::TextMessage("nope".to_string())),
2103            ));
2104            assert!(!wrong.verify(), "{action:?} + TextMessage must be rejected");
2105
2106            // Missing id is still invalid.
2107            let no_id = Message::Order(MessageKind::new(
2108                None,
2109                Some(1),
2110                Some(2),
2111                action,
2112                Some(Payload::BondResolution(BondResolution {
2113                    slash_seller: false,
2114                    slash_buyer: false,
2115                })),
2116            ));
2117            assert!(!no_id.verify(), "admin action without id must be rejected");
2118        }
2119    }
2120
2121    #[test]
2122    fn test_bond_resolution_rejected_on_non_admin_actions() {
2123        use crate::message::BondResolution;
2124
2125        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2126        let payload = Payload::BondResolution(BondResolution {
2127            slash_seller: true,
2128            slash_buyer: true,
2129        });
2130
2131        // Every Action except AdminSettle / AdminCancel must reject a
2132        // BondResolution payload. Listed explicitly (no strum) so that adding
2133        // a new Action variant forces a compile-error reminder here.
2134        for action in [
2135            Action::NewOrder,
2136            Action::TakeSell,
2137            Action::TakeBuy,
2138            Action::PayInvoice,
2139            Action::PayBondInvoice,
2140            Action::FiatSent,
2141            Action::FiatSentOk,
2142            Action::Release,
2143            Action::Released,
2144            Action::Cancel,
2145            Action::Canceled,
2146            Action::CooperativeCancelInitiatedByYou,
2147            Action::CooperativeCancelInitiatedByPeer,
2148            Action::DisputeInitiatedByYou,
2149            Action::DisputeInitiatedByPeer,
2150            Action::CooperativeCancelAccepted,
2151            Action::BuyerInvoiceAccepted,
2152            Action::BondInvoiceAccepted,
2153            Action::PurchaseCompleted,
2154            Action::BondPayoutCompleted,
2155            Action::BondSlashed,
2156            Action::HoldInvoicePaymentAccepted,
2157            Action::HoldInvoicePaymentSettled,
2158            Action::HoldInvoicePaymentCanceled,
2159            Action::WaitingSellerToPay,
2160            Action::WaitingBuyerInvoice,
2161            Action::AddInvoice,
2162            Action::AddBondInvoice,
2163            Action::BuyerTookOrder,
2164            Action::Rate,
2165            Action::RateUser,
2166            Action::RateReceived,
2167            Action::CantDo,
2168            Action::Dispute,
2169            Action::AdminCanceled,
2170            Action::AdminSettled,
2171            Action::AdminAddSolver,
2172            Action::AdminTakeDispute,
2173            Action::AdminTookDispute,
2174            Action::PaymentFailed,
2175            Action::InvoiceUpdated,
2176            Action::SendDm,
2177            Action::TradePubkey,
2178            Action::RestoreSession,
2179            Action::LastTradeIndex,
2180            Action::UserInfo,
2181            Action::Orders,
2182        ] {
2183            let msg = Message::Order(MessageKind::new(
2184                Some(uuid),
2185                Some(1),
2186                Some(2),
2187                action.clone(),
2188                Some(payload.clone()),
2189            ));
2190            assert!(
2191                !msg.verify(),
2192                "{action:?} must reject BondResolution payload"
2193            );
2194        }
2195    }
2196
2197    #[test]
2198    fn test_bond_resolution_wire_format() {
2199        use crate::message::BondResolution;
2200
2201        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2202        let msg = Message::Order(MessageKind::new(
2203            Some(uuid),
2204            None,
2205            None,
2206            Action::AdminCancel,
2207            Some(Payload::BondResolution(BondResolution {
2208                slash_seller: true,
2209                slash_buyer: false,
2210            })),
2211        ));
2212
2213        let json = msg.as_json().unwrap();
2214        // Variant discriminator must be the snake_case `bond_resolution`.
2215        assert!(
2216            json.contains("\"bond_resolution\""),
2217            "expected snake_case discriminator, got: {json}"
2218        );
2219        assert!(json.contains("\"slash_seller\":true"));
2220        assert!(json.contains("\"slash_buyer\":false"));
2221
2222        // Roundtrip preserves the variant.
2223        let decoded = Message::from_json(&json).unwrap();
2224        assert!(decoded.verify());
2225        if let Message::Order(kind) = decoded {
2226            match kind.payload {
2227                Some(Payload::BondResolution(b)) => {
2228                    assert!(b.slash_seller);
2229                    assert!(!b.slash_buyer);
2230                }
2231                other => panic!("expected BondResolution payload, got {other:?}"),
2232            }
2233        } else {
2234            panic!("expected Order message");
2235        }
2236    }
2237
2238    #[test]
2239    fn test_bond_resolution_legacy_null_payload() {
2240        // payload = null on AdminSettle/AdminCancel must keep verifying so
2241        // pre-BondResolution clients keep working (interpreted as "no slash"
2242        // by the server).
2243        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2244        let json = format!(
2245            r#"{{"order":{{"version":1,"id":"{uuid}","action":"admin-cancel","payload":null}}}}"#
2246        );
2247        let msg = Message::from_json(&json).unwrap();
2248        assert!(msg.verify());
2249    }
2250
2251    #[test]
2252    fn test_pay_bond_invoice_wire_format_and_verify() {
2253        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2254        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
2255
2256        let msg = Message::Order(MessageKind::new(
2257            Some(uuid),
2258            Some(1),
2259            Some(2),
2260            Action::PayBondInvoice,
2261            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2262        ));
2263        assert!(msg.verify());
2264
2265        // Wire format must use the kebab-case discriminator.
2266        let json = msg.as_json().unwrap();
2267        assert!(
2268            json.contains("\"action\":\"pay-bond-invoice\""),
2269            "expected kebab-case discriminator, got: {json}"
2270        );
2271
2272        // Roundtrip preserves the variant.
2273        let decoded = Message::from_json(&json).unwrap();
2274        assert!(decoded.verify());
2275        assert!(matches!(
2276            decoded.inner_action(),
2277            Some(Action::PayBondInvoice)
2278        ));
2279
2280        // Same id / payload constraints as PayInvoice: missing id is invalid.
2281        let no_id = Message::Order(MessageKind::new(
2282            None,
2283            Some(1),
2284            Some(2),
2285            Action::PayBondInvoice,
2286            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2287        ));
2288        assert!(!no_id.verify());
2289
2290        // Wrong payload shape is rejected.
2291        let wrong_payload = Message::Order(MessageKind::new(
2292            Some(uuid),
2293            Some(1),
2294            Some(2),
2295            Action::PayBondInvoice,
2296            Some(Payload::TextMessage("nope".to_string())),
2297        ));
2298        assert!(!wrong_payload.verify());
2299
2300        // Missing payload is rejected (PaymentRequest is required).
2301        let no_payload = Message::Order(MessageKind::new(
2302            Some(uuid),
2303            Some(1),
2304            Some(2),
2305            Action::PayBondInvoice,
2306            None,
2307        ));
2308        assert!(!no_payload.verify());
2309    }
2310
2311    #[test]
2312    fn test_add_bond_invoice_wire_format_and_verify() {
2313        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2314        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
2315
2316        let msg = Message::Order(MessageKind::new(
2317            Some(uuid),
2318            Some(1),
2319            Some(2),
2320            Action::AddBondInvoice,
2321            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2322        ));
2323        assert!(msg.verify());
2324
2325        // Wire format must use the kebab-case discriminator.
2326        let json = msg.as_json().unwrap();
2327        assert!(
2328            json.contains("\"action\":\"add-bond-invoice\""),
2329            "expected kebab-case discriminator, got: {json}"
2330        );
2331
2332        // Roundtrip preserves the variant.
2333        let decoded = Message::from_json(&json).unwrap();
2334        assert!(decoded.verify());
2335        assert!(matches!(
2336            decoded.inner_action(),
2337            Some(Action::AddBondInvoice)
2338        ));
2339
2340        // Same id / payload constraints as AddInvoice: missing id is invalid.
2341        let no_id = Message::Order(MessageKind::new(
2342            None,
2343            Some(1),
2344            Some(2),
2345            Action::AddBondInvoice,
2346            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2347        ));
2348        assert!(!no_id.verify());
2349
2350        // Wrong payload shape is rejected.
2351        let wrong_payload = Message::Order(MessageKind::new(
2352            Some(uuid),
2353            Some(1),
2354            Some(2),
2355            Action::AddBondInvoice,
2356            Some(Payload::TextMessage("nope".to_string())),
2357        ));
2358        assert!(!wrong_payload.verify());
2359
2360        // Missing payload is rejected (PaymentRequest is required).
2361        let no_payload = Message::Order(MessageKind::new(
2362            Some(uuid),
2363            Some(1),
2364            Some(2),
2365            Action::AddBondInvoice,
2366            None,
2367        ));
2368        assert!(!no_payload.verify());
2369
2370        // get_payment_request must surface the bolt11 for AddBondInvoice.
2371        if let Message::Order(kind) = &msg {
2372            assert_eq!(kind.get_payment_request(), Some(bolt11));
2373        } else {
2374            panic!("expected Message::Order");
2375        }
2376    }
2377
2378    #[test]
2379    fn test_restored_dispute_helper_serialization_roundtrip() {
2380        use crate::message::RestoredDisputeHelper;
2381
2382        let helper = RestoredDisputeHelper {
2383            dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
2384            order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
2385            dispute_status: "initiated".to_string(),
2386            master_buyer_pubkey: Some("npub1buyerkey".to_string()),
2387            master_seller_pubkey: Some("npub1sellerkey".to_string()),
2388            trade_index_buyer: Some(1),
2389            trade_index_seller: Some(2),
2390            buyer_dispute: true,
2391            seller_dispute: false,
2392            solver_pubkey: None,
2393        };
2394
2395        let json = serde_json::to_string(&helper).unwrap();
2396        let deserialized: RestoredDisputeHelper = serde_json::from_str(&json).unwrap();
2397
2398        assert_eq!(deserialized.dispute_id, helper.dispute_id);
2399        assert_eq!(deserialized.order_id, helper.order_id);
2400        assert_eq!(deserialized.dispute_status, helper.dispute_status);
2401        assert_eq!(deserialized.master_buyer_pubkey, helper.master_buyer_pubkey);
2402        assert_eq!(
2403            deserialized.master_seller_pubkey,
2404            helper.master_seller_pubkey
2405        );
2406        assert_eq!(deserialized.trade_index_buyer, helper.trade_index_buyer);
2407        assert_eq!(deserialized.trade_index_seller, helper.trade_index_seller);
2408        assert_eq!(deserialized.buyer_dispute, helper.buyer_dispute);
2409        assert_eq!(deserialized.seller_dispute, helper.seller_dispute);
2410        assert_eq!(deserialized.solver_pubkey, helper.solver_pubkey);
2411
2412        let helper_seller_dispute = RestoredDisputeHelper {
2413            dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
2414            order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
2415            dispute_status: "in-progress".to_string(),
2416            master_buyer_pubkey: None,
2417            master_seller_pubkey: None,
2418            trade_index_buyer: None,
2419            trade_index_seller: None,
2420            buyer_dispute: false,
2421            seller_dispute: true,
2422            solver_pubkey: Some(
2423                "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
2424            ),
2425        };
2426
2427        let json_seller = serde_json::to_string(&helper_seller_dispute).unwrap();
2428        let deserialized_seller: RestoredDisputeHelper =
2429            serde_json::from_str(&json_seller).unwrap();
2430
2431        assert_eq!(
2432            deserialized_seller.dispute_id,
2433            helper_seller_dispute.dispute_id
2434        );
2435        assert_eq!(deserialized_seller.order_id, helper_seller_dispute.order_id);
2436        assert_eq!(
2437            deserialized_seller.dispute_status,
2438            helper_seller_dispute.dispute_status
2439        );
2440        assert_eq!(deserialized_seller.master_buyer_pubkey, None);
2441        assert_eq!(deserialized_seller.master_seller_pubkey, None);
2442        assert_eq!(deserialized_seller.trade_index_buyer, None);
2443        assert_eq!(deserialized_seller.trade_index_seller, None);
2444        assert!(!deserialized_seller.buyer_dispute);
2445        assert!(deserialized_seller.seller_dispute);
2446        assert_eq!(
2447            deserialized_seller.solver_pubkey,
2448            helper_seller_dispute.solver_pubkey
2449        );
2450    }
2451
2452    fn sample_lock_proof() -> CashuLockProof {
2453        CashuLockProof::new(
2454            "cashuAeyJ0b2tlbiI6dGVzdA".to_string(),
2455            "https://mint.example".to_string(),
2456            "02b_buyer".to_string(),
2457            "02s_seller".to_string(),
2458            "02m_mostro".to_string(),
2459        )
2460    }
2461
2462    #[test]
2463    fn test_cashu_lock_proof_json_round_trip() {
2464        let proof = sample_lock_proof();
2465        let json = proof.as_json().unwrap();
2466        let back = CashuLockProof::from_json(&json).unwrap();
2467        assert_eq!(back, proof);
2468    }
2469
2470    #[test]
2471    fn test_cashu_lock_proof_fee_token_round_trip() {
2472        let proof = sample_lock_proof().with_fee_token("cashuAfee".to_string());
2473        let json = proof.as_json().unwrap();
2474        assert!(json.contains("fee_token"));
2475        let back = CashuLockProof::from_json(&json).unwrap();
2476        assert_eq!(back, proof);
2477        assert_eq!(back.fee_token.as_deref(), Some("cashuAfee"));
2478    }
2479
2480    #[test]
2481    fn test_cashu_lock_proof_without_fee_token_is_omitted_and_defaults_to_none() {
2482        // A proof with no fee serializes without the key (wire-compatible with
2483        // clients that predate the field) and parses back to `None`.
2484        let proof = sample_lock_proof();
2485        assert_eq!(proof.fee_token, None);
2486        let json = proof.as_json().unwrap();
2487        assert!(!json.contains("fee_token"));
2488        let back = CashuLockProof::from_json(&json).unwrap();
2489        assert_eq!(back.fee_token, None);
2490    }
2491
2492    #[test]
2493    fn test_add_cashu_escrow_verifies_with_lock_proof() {
2494        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2495        let payload = Payload::CashuLockProof(sample_lock_proof());
2496        let kind = MessageKind::new(
2497            Some(order_id),
2498            None,
2499            None,
2500            Action::AddCashuEscrow,
2501            Some(payload),
2502        );
2503        assert!(
2504            kind.verify(),
2505            "CashuLockProof must verify on AddCashuEscrow"
2506        );
2507
2508        // Round-trip through JSON to catch a serde-rename mismatch on the
2509        // new snake_case discriminator.
2510        let json = Message::Order(kind).as_json().unwrap();
2511        assert!(json.contains("cashu_lock_proof"));
2512        assert!(Message::from_json(&json).unwrap().verify());
2513    }
2514
2515    #[test]
2516    fn test_add_cashu_escrow_requires_id_and_right_payload() {
2517        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2518
2519        // Missing id ⇒ invalid.
2520        let no_id = MessageKind::new(
2521            None,
2522            None,
2523            None,
2524            Action::AddCashuEscrow,
2525            Some(Payload::CashuLockProof(sample_lock_proof())),
2526        );
2527        assert!(
2528            !no_id.verify(),
2529            "AddCashuEscrow without id must be rejected"
2530        );
2531
2532        // Wrong payload ⇒ invalid.
2533        let wrong_payload = MessageKind::new(
2534            Some(order_id),
2535            None,
2536            None,
2537            Action::AddCashuEscrow,
2538            Some(Payload::TextMessage("not a lock proof".to_string())),
2539        );
2540        assert!(
2541            !wrong_payload.verify(),
2542            "AddCashuEscrow with non-lock-proof payload must be rejected"
2543        );
2544    }
2545
2546    #[test]
2547    fn test_cashu_pm_signature_verifies_with_signatures() {
2548        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2549        let kind = MessageKind::new(
2550            Some(order_id),
2551            None,
2552            None,
2553            Action::CashuPmSignature,
2554            Some(Payload::CashuSignatures(vec![
2555                CashuProofSignature::new("secret-0".to_string(), "deadbeef".to_string()),
2556                CashuProofSignature::new("secret-1".to_string(), "c0ffee".to_string()),
2557            ])),
2558        );
2559        assert!(
2560            kind.verify(),
2561            "CashuSignatures must verify on CashuPmSignature"
2562        );
2563
2564        let json = Message::Order(kind).as_json().unwrap();
2565        assert!(json.contains("cashu_signatures"));
2566        assert!(Message::from_json(&json).unwrap().verify());
2567
2568        // Wrong payload ⇒ invalid.
2569        let wrong = MessageKind::new(Some(order_id), None, None, Action::CashuPmSignature, None);
2570        assert!(
2571            !wrong.verify(),
2572            "CashuPmSignature without a signature payload must be rejected"
2573        );
2574
2575        // Empty signature set ⇒ invalid: a multi-proof escrow needs one
2576        // signature per proof, so an empty collection cannot assemble a swap.
2577        let empty = MessageKind::new(
2578            Some(order_id),
2579            None,
2580            None,
2581            Action::CashuPmSignature,
2582            Some(Payload::CashuSignatures(vec![])),
2583        );
2584        assert!(
2585            !empty.verify(),
2586            "CashuPmSignature with an empty signature set must be rejected"
2587        );
2588    }
2589
2590    #[test]
2591    fn test_cashu_escrow_locked_is_informational() {
2592        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2593
2594        // Informational ack with an id and no payload verifies.
2595        let ok = MessageKind::new(Some(order_id), None, None, Action::CashuEscrowLocked, None);
2596        assert!(ok.verify(), "CashuEscrowLocked with id must verify");
2597
2598        // Missing id ⇒ invalid.
2599        let no_id = MessageKind::new(None, None, None, Action::CashuEscrowLocked, None);
2600        assert!(
2601            !no_id.verify(),
2602            "CashuEscrowLocked without id must be rejected"
2603        );
2604    }
2605
2606    fn sample_declaration() -> PayerDeclaration {
2607        PayerDeclaration::new(payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH"))
2608    }
2609
2610    fn sample_history() -> PaymentHistory {
2611        PaymentHistory {
2612            payment_hash: sample_declaration().payment_hash,
2613            buyer_mode: BuyerMode::Reputation,
2614            successful_trades: 47,
2615            distinct_counterparties: 29,
2616            experienced_counterparties: 11,
2617            first_success_at: Some(1_762_128_000),
2618            last_success_at: Some(1_787_654_321),
2619        }
2620    }
2621
2622    #[test]
2623    fn test_payer_history_actions_use_kebab_case_wire_names() {
2624        for (action, wire) in [
2625            (Action::DeclarePayer, "\"declare-payer\""),
2626            (Action::PayerDeclared, "\"payer-declared\""),
2627            (Action::PaymentHistory, "\"payment-history\""),
2628        ] {
2629            assert_eq!(serde_json::to_string(&action).unwrap(), wire);
2630            assert_eq!(serde_json::from_str::<Action>(wire).unwrap(), action);
2631        }
2632    }
2633
2634    #[test]
2635    fn test_declare_payer_requires_id_and_declaration() {
2636        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2637        let decl = Some(Payload::PayerDeclaration(sample_declaration()));
2638
2639        let ok = MessageKind::new(
2640            Some(order_id),
2641            Some(1),
2642            Some(7),
2643            Action::DeclarePayer,
2644            decl.clone(),
2645        );
2646        assert!(
2647            ok.verify(),
2648            "DeclarePayer with id and declaration must verify"
2649        );
2650
2651        let no_id = MessageKind::new(None, None, None, Action::DeclarePayer, decl);
2652        assert!(!no_id.verify(), "DeclarePayer without id must be rejected");
2653
2654        let no_payload = MessageKind::new(Some(order_id), None, None, Action::DeclarePayer, None);
2655        assert!(
2656            !no_payload.verify(),
2657            "DeclarePayer without payload must be rejected"
2658        );
2659
2660        let wrong = MessageKind::new(
2661            Some(order_id),
2662            None,
2663            None,
2664            Action::DeclarePayer,
2665            Some(Payload::TextMessage(
2666                "plaintext must never be sent".to_string(),
2667            )),
2668        );
2669        assert!(
2670            !wrong.verify(),
2671            "DeclarePayer with a non-declaration payload must be rejected"
2672        );
2673    }
2674
2675    #[test]
2676    fn test_payer_declared_requires_id_and_declaration() {
2677        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2678        let decl = Some(Payload::PayerDeclaration(sample_declaration()));
2679
2680        let ok = MessageKind::new(
2681            Some(order_id),
2682            None,
2683            None,
2684            Action::PayerDeclared,
2685            decl.clone(),
2686        );
2687        assert!(
2688            ok.verify(),
2689            "PayerDeclared with id and declaration must verify"
2690        );
2691
2692        let no_id = MessageKind::new(None, None, None, Action::PayerDeclared, decl);
2693        assert!(!no_id.verify(), "PayerDeclared without id must be rejected");
2694
2695        let no_payload = MessageKind::new(Some(order_id), None, None, Action::PayerDeclared, None);
2696        assert!(
2697            !no_payload.verify(),
2698            "PayerDeclared without payload must be rejected"
2699        );
2700
2701        let wrong = MessageKind::new(
2702            Some(order_id),
2703            None,
2704            None,
2705            Action::PayerDeclared,
2706            Some(Payload::PaymentHistory(sample_history())),
2707        );
2708        assert!(
2709            !wrong.verify(),
2710            "PayerDeclared with a history payload must be rejected"
2711        );
2712    }
2713
2714    #[test]
2715    fn test_payment_history_accepts_query_and_reply_shapes() {
2716        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2717
2718        // Seller → Mostro query: no payload.
2719        let query = MessageKind::new(Some(order_id), Some(3), None, Action::PaymentHistory, None);
2720        assert!(
2721            query.verify(),
2722            "PaymentHistory query (no payload) must verify"
2723        );
2724
2725        // Mostro → seller push / reply: the aggregate history.
2726        let reply = MessageKind::new(
2727            Some(order_id),
2728            None,
2729            None,
2730            Action::PaymentHistory,
2731            Some(Payload::PaymentHistory(sample_history())),
2732        );
2733        assert!(reply.verify(), "PaymentHistory reply must verify");
2734
2735        let no_id = MessageKind::new(None, None, None, Action::PaymentHistory, None);
2736        assert!(
2737            !no_id.verify(),
2738            "PaymentHistory without id must be rejected"
2739        );
2740
2741        let wrong = MessageKind::new(
2742            Some(order_id),
2743            None,
2744            None,
2745            Action::PaymentHistory,
2746            Some(Payload::PayerDeclaration(sample_declaration())),
2747        );
2748        assert!(
2749            !wrong.verify(),
2750            "PaymentHistory with a declaration payload must be rejected"
2751        );
2752    }
2753
2754    #[test]
2755    fn test_payer_payloads_round_trip_with_snake_case_keys() {
2756        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2757
2758        let declare = Message::Order(MessageKind::new(
2759            Some(order_id),
2760            Some(981_231),
2761            Some(7),
2762            Action::DeclarePayer,
2763            Some(Payload::PayerDeclaration(sample_declaration())),
2764        ));
2765        let json = declare.as_json().unwrap();
2766        assert!(json.contains("\"action\":\"declare-payer\""));
2767        assert!(json.contains("\"payer_declaration\":{\"payment_hash\":"));
2768        assert!(Message::from_json(&json).unwrap().verify());
2769
2770        let push = Message::Order(MessageKind::new(
2771            Some(order_id),
2772            None,
2773            None,
2774            Action::PaymentHistory,
2775            Some(Payload::PaymentHistory(sample_history())),
2776        ));
2777        let json = push.as_json().unwrap();
2778        assert!(json.contains("\"payment_history\":{"));
2779        assert!(json.contains("\"buyer_mode\":\"reputation\""));
2780        assert!(json.contains("\"experienced_counterparties\":11"));
2781        let back = Message::from_json(&json).unwrap();
2782        assert!(back.verify());
2783        match back.get_inner_message_kind().payload.clone() {
2784            Some(Payload::PaymentHistory(h)) => assert_eq!(h, sample_history()),
2785            other => panic!("expected a PaymentHistory payload, got {other:?}"),
2786        }
2787    }
2788
2789    #[test]
2790    fn test_payment_history_parses_the_protocol_wire_example() {
2791        // The full-privacy example from the protocol chapter, verbatim apart
2792        // from the elided hash.
2793        let hash = sample_declaration().payment_hash;
2794        let json = format!(
2795            r#"{{"payment_history": {{"payment_hash": "{hash}", "buyer_mode": "full_privacy",
2796              "successful_trades": 0, "distinct_counterparties": 0,
2797              "experienced_counterparties": 0,
2798              "first_success_at": null, "last_success_at": null}}}}"#
2799        );
2800        let payload: Payload = serde_json::from_str(&json).unwrap();
2801        match payload {
2802            Payload::PaymentHistory(h) => assert_eq!(h, PaymentHistory::unavailable(hash)),
2803            other => panic!("expected a PaymentHistory payload, got {other:?}"),
2804        }
2805    }
2806
2807    /// The wire shapes the protocol's reputation chapter documents: the
2808    /// `order` wrapper, no `id`, kebab-case actions and snake_case payloads.
2809    #[test]
2810    fn reputation_messages_have_the_documented_wire_shape() {
2811        let export = Message::new_order(
2812            None,
2813            Some(4126),
2814            None,
2815            Action::ExportReputation,
2816            Some(Payload::ReputationExportRequest(
2817                super::ReputationExportRequest {
2818                    destination: "ab".repeat(32),
2819                    rebind: None,
2820                },
2821            )),
2822        );
2823        let json: serde_json::Value = serde_json::from_str(&export.as_json().unwrap()).unwrap();
2824        assert_eq!(
2825            json,
2826            serde_json::json!({ "order": {
2827                "version": 2,
2828                "request_id": 4126,
2829                "trade_index": null,
2830                "action": "export-reputation",
2831                "payload": { "reputation_export_request": {
2832                    "destination": "ab".repeat(32),
2833                    "rebind": null,
2834                }},
2835            }})
2836        );
2837
2838        for (action, wire) in [
2839            (Action::ReputationExported, "reputation-exported"),
2840            (Action::ImportReputation, "import-reputation"),
2841        ] {
2842            let message = Message::new_order(
2843                None,
2844                None,
2845                None,
2846                action,
2847                Some(Payload::ReputationAttestation("{}".into())),
2848            );
2849            let json: serde_json::Value =
2850                serde_json::from_str(&message.as_json().unwrap()).unwrap();
2851            assert_eq!(json["order"]["action"], wire);
2852            assert_eq!(json["order"]["payload"]["reputation_attestation"], "{}");
2853        }
2854
2855        let imported = Message::new_order(None, None, None, Action::ReputationImported, None);
2856        let json: serde_json::Value = serde_json::from_str(&imported.as_json().unwrap()).unwrap();
2857        assert_eq!(json["order"]["action"], "reputation-imported");
2858        assert!(json["order"]["payload"].is_null());
2859    }
2860
2861    #[test]
2862    fn an_export_request_without_rebind_deserialises() {
2863        let json = r#"{"order":{"version":2,"action":"export-reputation","payload":{"reputation_export_request":{"destination":"ab"}}}}"#;
2864        let message = Message::from_json(json).unwrap();
2865        let kind = message.get_inner_message_kind();
2866        match &kind.payload {
2867            Some(Payload::ReputationExportRequest(request)) => assert_eq!(request.rebind, None),
2868            other => panic!("unexpected payload {other:?}"),
2869        }
2870        assert!(kind.verify());
2871    }
2872
2873    #[test]
2874    fn reputation_actions_verify_only_without_an_id_and_with_their_payload() {
2875        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2876        let request = Payload::ReputationExportRequest(super::ReputationExportRequest {
2877            destination: "ab".repeat(32),
2878            rebind: None,
2879        });
2880        let attestation = Payload::ReputationAttestation("{}".into());
2881        let cases = [
2882            (Action::ExportReputation, Some(request.clone()), true),
2883            (Action::ExportReputation, Some(attestation.clone()), false),
2884            (Action::ExportReputation, None, false),
2885            (Action::ReputationExported, Some(attestation.clone()), true),
2886            (Action::ReputationExported, None, false),
2887            (Action::ImportReputation, Some(attestation.clone()), true),
2888            (Action::ImportReputation, Some(request.clone()), false),
2889            (Action::ReputationImported, None, true),
2890            (Action::ReputationImported, Some(attestation.clone()), false),
2891        ];
2892        for (action, payload, ok) in cases {
2893            let without_id = MessageKind::new(None, None, None, action.clone(), payload.clone());
2894            assert_eq!(without_id.verify(), ok, "{action:?} {payload:?}");
2895            let with_id = MessageKind::new(Some(order_id), None, None, action.clone(), payload);
2896            assert!(!with_id.verify(), "{action:?} must not carry an id");
2897        }
2898    }
2899
2900    /// `PROTOCOL_VER` stays 2: the reputation actions are request/response
2901    /// over encrypted direct messages, so an old client never sends them and
2902    /// is never sent their replies, and a new client only sends them to a
2903    /// node whose info event advertises them. Nothing about them is
2904    /// broadcast. A new `cant-do` reason reaching an old client degrades to
2905    /// `Unknown` through `CantDoReason`'s catch-all.
2906    #[test]
2907    fn reputation_messages_keep_protocol_version_2() {
2908        for action in [
2909            Action::ExportReputation,
2910            Action::ReputationExported,
2911            Action::ImportReputation,
2912            Action::ReputationImported,
2913        ] {
2914            assert_eq!(MessageKind::new(None, None, None, action, None).version, 2);
2915        }
2916    }
2917}