Skip to main content

mostro_core/
message.rs

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