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            cashu_mint_url: None,
1233        };
1234        let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1235            order,
1236            slashed_at: 1_734_000_000,
1237        });
1238        let kind = MessageKind::new(
1239            Some(order_id),
1240            None,
1241            None,
1242            Action::AddBondInvoice,
1243            Some(payload),
1244        );
1245        assert!(
1246            kind.verify(),
1247            "BondPayoutRequest must verify on AddBondInvoice"
1248        );
1249
1250        // Round-trip through JSON to catch a serde-rename mismatch on the
1251        // new snake_case discriminator.
1252        let m = Message::Order(kind);
1253        let json = m.as_json().unwrap();
1254        assert!(json.contains("bond_payout_request"));
1255        let back = Message::from_json(&json).unwrap();
1256        assert!(back.verify());
1257    }
1258
1259    #[test]
1260    fn test_bond_payout_request_payload_rejected_on_wrong_action() {
1261        // BondPayoutRequest on any action other than AddBondInvoice must
1262        // fail verification — the new variant is opt-in per action.
1263        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1264        let order = SmallOrder {
1265            id: Some(order_id),
1266            kind: None,
1267            status: None,
1268            amount: 500,
1269            fiat_code: "USD".to_string(),
1270            min_amount: None,
1271            max_amount: None,
1272            fiat_amount: 0,
1273            payment_method: "lightning".to_string(),
1274            premium: 0,
1275            buyer_trade_pubkey: None,
1276            seller_trade_pubkey: None,
1277            buyer_invoice: None,
1278            created_at: None,
1279            expires_at: None,
1280            cashu_mint_url: None,
1281        };
1282
1283        // Compile-time exhaustiveness guard: adding a new Action variant
1284        // forces this match to be updated, which in turn forces a
1285        // decision about whether the new variant belongs in
1286        // `other_actions` below.
1287        let _exhaustive: fn(Action) = |a| match a {
1288            Action::AddBondInvoice => {}
1289            Action::NewOrder
1290            | Action::TakeSell
1291            | Action::TakeBuy
1292            | Action::PayInvoice
1293            | Action::PayBondInvoice
1294            | Action::FiatSent
1295            | Action::FiatSentOk
1296            | Action::Release
1297            | Action::Released
1298            | Action::Cancel
1299            | Action::Canceled
1300            | Action::CooperativeCancelInitiatedByYou
1301            | Action::CooperativeCancelInitiatedByPeer
1302            | Action::DisputeInitiatedByYou
1303            | Action::DisputeInitiatedByPeer
1304            | Action::CooperativeCancelAccepted
1305            | Action::BuyerInvoiceAccepted
1306            | Action::BondInvoiceAccepted
1307            | Action::PurchaseCompleted
1308            | Action::BondPayoutCompleted
1309            | Action::BondSlashed
1310            | Action::HoldInvoicePaymentAccepted
1311            | Action::HoldInvoicePaymentSettled
1312            | Action::HoldInvoicePaymentCanceled
1313            | Action::WaitingSellerToPay
1314            | Action::WaitingBuyerInvoice
1315            | Action::AddInvoice
1316            | Action::BuyerTookOrder
1317            | Action::Rate
1318            | Action::RateUser
1319            | Action::RateReceived
1320            | Action::CantDo
1321            | Action::Dispute
1322            | Action::AdminCancel
1323            | Action::AdminCanceled
1324            | Action::AdminSettle
1325            | Action::AdminSettled
1326            | Action::AdminAddSolver
1327            | Action::AdminTakeDispute
1328            | Action::AdminTookDispute
1329            | Action::PaymentFailed
1330            | Action::InvoiceUpdated
1331            | Action::SendDm
1332            | Action::TradePubkey
1333            | Action::RestoreSession
1334            | Action::LastTradeIndex
1335            | Action::AddCashuEscrow
1336            | Action::CashuEscrowLocked
1337            | Action::CashuPmSignature
1338            | Action::DeclarePayer
1339            | Action::PayerDeclared
1340            | Action::PaymentHistory
1341            | Action::ExportReputation
1342            | Action::ReputationExported
1343            | Action::ImportReputation
1344            | Action::ReputationImported
1345            | Action::Orders => {}
1346        };
1347
1348        let other_actions: &[Action] = &[
1349            Action::NewOrder,
1350            Action::TakeSell,
1351            Action::TakeBuy,
1352            Action::PayInvoice,
1353            Action::PayBondInvoice,
1354            Action::FiatSent,
1355            Action::FiatSentOk,
1356            Action::Release,
1357            Action::Released,
1358            Action::Cancel,
1359            Action::Canceled,
1360            Action::CooperativeCancelInitiatedByYou,
1361            Action::CooperativeCancelInitiatedByPeer,
1362            Action::DisputeInitiatedByYou,
1363            Action::DisputeInitiatedByPeer,
1364            Action::CooperativeCancelAccepted,
1365            Action::BuyerInvoiceAccepted,
1366            Action::BondInvoiceAccepted,
1367            Action::PurchaseCompleted,
1368            Action::BondPayoutCompleted,
1369            Action::BondSlashed,
1370            Action::HoldInvoicePaymentAccepted,
1371            Action::HoldInvoicePaymentSettled,
1372            Action::HoldInvoicePaymentCanceled,
1373            Action::WaitingSellerToPay,
1374            Action::WaitingBuyerInvoice,
1375            Action::AddInvoice,
1376            Action::BuyerTookOrder,
1377            Action::Rate,
1378            Action::RateUser,
1379            Action::RateReceived,
1380            Action::CantDo,
1381            Action::Dispute,
1382            Action::AdminCancel,
1383            Action::AdminCanceled,
1384            Action::AdminSettle,
1385            Action::AdminSettled,
1386            Action::AdminAddSolver,
1387            Action::AdminTakeDispute,
1388            Action::AdminTookDispute,
1389            Action::PaymentFailed,
1390            Action::InvoiceUpdated,
1391            Action::SendDm,
1392            Action::TradePubkey,
1393            Action::RestoreSession,
1394            Action::LastTradeIndex,
1395            Action::Orders,
1396            Action::AddCashuEscrow,
1397            Action::CashuEscrowLocked,
1398            Action::CashuPmSignature,
1399            Action::DeclarePayer,
1400            Action::PayerDeclared,
1401            Action::PaymentHistory,
1402            Action::ExportReputation,
1403            Action::ReputationExported,
1404            Action::ImportReputation,
1405            Action::ReputationImported,
1406        ];
1407
1408        for action in other_actions {
1409            let payload = Payload::BondPayoutRequest(BondPayoutRequest {
1410                order: order.clone(),
1411                slashed_at: 0,
1412            });
1413            let kind = MessageKind::new(Some(order_id), None, None, action.clone(), Some(payload));
1414            assert!(
1415                !kind.verify(),
1416                "BondPayoutRequest must be rejected on {action:?}"
1417            );
1418        }
1419    }
1420
1421    #[test]
1422    fn test_payer_payloads_rejected_on_unrelated_actions() {
1423        // PayerDeclaration belongs to DeclarePayer / PayerDeclared and
1424        // PaymentHistory to PaymentHistory; every other action refuses both.
1425        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1426        let unrelated = [
1427            Action::NewOrder,
1428            Action::TakeSell,
1429            Action::TakeBuy,
1430            Action::PayInvoice,
1431            Action::PayBondInvoice,
1432            Action::FiatSent,
1433            Action::FiatSentOk,
1434            Action::Release,
1435            Action::Released,
1436            Action::Cancel,
1437            Action::Canceled,
1438            Action::CooperativeCancelInitiatedByYou,
1439            Action::CooperativeCancelInitiatedByPeer,
1440            Action::DisputeInitiatedByYou,
1441            Action::DisputeInitiatedByPeer,
1442            Action::CooperativeCancelAccepted,
1443            Action::BuyerInvoiceAccepted,
1444            Action::BondInvoiceAccepted,
1445            Action::PurchaseCompleted,
1446            Action::BondPayoutCompleted,
1447            Action::BondSlashed,
1448            Action::HoldInvoicePaymentAccepted,
1449            Action::HoldInvoicePaymentSettled,
1450            Action::HoldInvoicePaymentCanceled,
1451            Action::WaitingSellerToPay,
1452            Action::WaitingBuyerInvoice,
1453            Action::AddInvoice,
1454            Action::BuyerTookOrder,
1455            Action::Rate,
1456            Action::RateUser,
1457            Action::RateReceived,
1458            Action::CantDo,
1459            Action::Dispute,
1460            Action::AdminCancel,
1461            Action::AdminCanceled,
1462            Action::AdminSettle,
1463            Action::AdminSettled,
1464            Action::AdminAddSolver,
1465            Action::AdminTakeDispute,
1466            Action::AdminTookDispute,
1467            Action::PaymentFailed,
1468            Action::InvoiceUpdated,
1469            Action::SendDm,
1470            Action::TradePubkey,
1471            Action::RestoreSession,
1472            Action::LastTradeIndex,
1473            Action::Orders,
1474            Action::AddCashuEscrow,
1475            Action::CashuEscrowLocked,
1476            Action::CashuPmSignature,
1477            Action::AddBondInvoice,
1478        ];
1479        for action in unrelated {
1480            for payload in [
1481                Payload::PayerDeclaration(PayerDeclaration::new("a".repeat(64))),
1482                Payload::PaymentHistory(PaymentHistory::unavailable("a".repeat(64))),
1483            ] {
1484                let kind =
1485                    MessageKind::new(Some(order_id), None, None, action.clone(), Some(payload));
1486                assert!(!kind.verify(), "{action:?} must reject {:?}", kind.payload);
1487            }
1488        }
1489    }
1490
1491    #[test]
1492    fn test_bond_payout_ack_actions_verify_and_wire_format() {
1493        use crate::message::BondResolution;
1494
1495        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1496
1497        // SmallOrder whose `amount` is the counterparty share carried by
1498        // these Mostro → winner bond-payout acknowledgements.
1499        let order = || SmallOrder {
1500            id: Some(order_id),
1501            kind: None,
1502            status: None,
1503            amount: 500,
1504            fiat_code: "USD".to_string(),
1505            min_amount: None,
1506            max_amount: None,
1507            fiat_amount: 0,
1508            payment_method: "lightning".to_string(),
1509            premium: 0,
1510            buyer_trade_pubkey: None,
1511            seller_trade_pubkey: None,
1512            buyer_invoice: None,
1513            created_at: None,
1514            expires_at: None,
1515            cashu_mint_url: None,
1516        };
1517
1518        // These bond notifications carry Payload::Order (BondInvoiceAccepted
1519        // / BondPayoutCompleted are the duals of BuyerInvoiceAccepted /
1520        // PurchaseCompleted; BondSlashed informs the slashed user): id
1521        // required, Payload::Order accepted, and the bond request/resolution
1522        // payloads rejected.
1523        for (action, discriminator) in [
1524            (Action::BondInvoiceAccepted, "bond-invoice-accepted"),
1525            (Action::BondPayoutCompleted, "bond-payout-completed"),
1526            (Action::BondSlashed, "bond-slashed"),
1527        ] {
1528            // id set + Payload::Order verifies.
1529            let ok = Message::Order(MessageKind::new(
1530                Some(order_id),
1531                Some(1),
1532                Some(2),
1533                action.clone(),
1534                Some(Payload::Order(order())),
1535            ));
1536            assert!(ok.verify(), "{action:?} + Order should verify");
1537
1538            // Missing id is invalid.
1539            let no_id = Message::Order(MessageKind::new(
1540                None,
1541                Some(1),
1542                Some(2),
1543                action.clone(),
1544                Some(Payload::Order(order())),
1545            ));
1546            assert!(!no_id.verify(), "{action:?} without id must be rejected");
1547
1548            // BondResolution payload is rejected on these outbound acks.
1549            let with_resolution = Message::Order(MessageKind::new(
1550                Some(order_id),
1551                Some(1),
1552                Some(2),
1553                action.clone(),
1554                Some(Payload::BondResolution(BondResolution {
1555                    slash_seller: true,
1556                    slash_buyer: false,
1557                })),
1558            ));
1559            assert!(
1560                !with_resolution.verify(),
1561                "{action:?} + BondResolution must be rejected"
1562            );
1563
1564            // BondPayoutRequest payload is rejected too.
1565            let with_request = Message::Order(MessageKind::new(
1566                Some(order_id),
1567                Some(1),
1568                Some(2),
1569                action.clone(),
1570                Some(Payload::BondPayoutRequest(BondPayoutRequest {
1571                    order: order(),
1572                    slashed_at: 0,
1573                })),
1574            ));
1575            assert!(
1576                !with_request.verify(),
1577                "{action:?} + BondPayoutRequest must be rejected"
1578            );
1579
1580            // Wire format uses the kebab-case discriminator and round-trips.
1581            let json = ok.as_json().unwrap();
1582            assert!(
1583                json.contains(&format!("\"action\":\"{discriminator}\"")),
1584                "expected kebab-case discriminator {discriminator}, got: {json}"
1585            );
1586            let decoded = Message::from_json(&json).unwrap();
1587            assert!(decoded.verify());
1588            assert_eq!(decoded.inner_action(), Some(action));
1589        }
1590    }
1591
1592    #[test]
1593    fn test_payment_failed_payload() {
1594        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1595
1596        // Test PaymentFailedInfo serialization and deserialization
1597        let payment_failed_info = crate::message::PaymentFailedInfo {
1598            payment_attempts: 3,
1599            payment_retries_interval: 60,
1600        };
1601
1602        let payload = Payload::PaymentFailed(payment_failed_info);
1603        let message = Message::Order(MessageKind::new(
1604            Some(uuid),
1605            Some(1),
1606            Some(2),
1607            Action::PaymentFailed,
1608            Some(payload),
1609        ));
1610
1611        // Verify message validation
1612        assert!(message.verify());
1613
1614        // Test JSON serialization
1615        let message_json = message.as_json().unwrap();
1616
1617        // Test deserialization
1618        let deserialized_message = Message::from_json(&message_json).unwrap();
1619        assert!(deserialized_message.verify());
1620
1621        // Verify the payload contains correct values
1622        if let Message::Order(kind) = deserialized_message {
1623            if let Some(Payload::PaymentFailed(info)) = kind.payload {
1624                assert_eq!(info.payment_attempts, 3);
1625                assert_eq!(info.payment_retries_interval, 60);
1626            } else {
1627                panic!("Expected PaymentFailed payload");
1628            }
1629        } else {
1630            panic!("Expected Order message");
1631        }
1632    }
1633
1634    #[test]
1635    fn test_message_payload_signature() {
1636        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1637        let peer = Peer::new(
1638            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
1639            None, // Add None for the reputation parameter
1640        );
1641        let payload = Payload::Peer(peer);
1642        let test_message = Message::Order(MessageKind::new(
1643            Some(uuid),
1644            Some(1),
1645            Some(2),
1646            Action::FiatSentOk,
1647            Some(payload),
1648        ));
1649        assert!(test_message.verify());
1650        let test_message_json = test_message.as_json().unwrap();
1651        // Message should be signed with the trade keys
1652        let trade_keys =
1653            Keys::parse("110e43647eae221ab1da33ddc17fd6ff423f2b2f49d809b9ffa40794a2ab996c")
1654                .unwrap();
1655        let sig = Message::sign(test_message_json.clone(), &trade_keys);
1656
1657        assert!(Message::verify_signature(
1658            test_message_json,
1659            trade_keys.public_key(),
1660            sig
1661        ));
1662    }
1663
1664    #[test]
1665    fn test_restore_session_message() {
1666        // Test RestoreSession request (payload = None)
1667        let restore_request_message = Message::Restore(MessageKind::new(
1668            None,
1669            None,
1670            None,
1671            Action::RestoreSession,
1672            None,
1673        ));
1674
1675        // Verify message validation
1676        assert!(restore_request_message.verify());
1677        assert_eq!(
1678            restore_request_message.inner_action(),
1679            Some(Action::RestoreSession)
1680        );
1681
1682        // Test JSON serialization and deserialization for RestoreRequest
1683        let message_json = restore_request_message.as_json().unwrap();
1684        let deserialized_message = Message::from_json(&message_json).unwrap();
1685        assert!(deserialized_message.verify());
1686        assert_eq!(
1687            deserialized_message.inner_action(),
1688            Some(Action::RestoreSession)
1689        );
1690
1691        // Test RestoreSession with RestoreData payload
1692        let restored_orders = vec![
1693            crate::message::RestoredOrdersInfo {
1694                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1695                trade_index: 1,
1696                status: "active".to_string(),
1697                counterparty_trade_pubkey: Some(
1698                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1699                ),
1700            },
1701            crate::message::RestoredOrdersInfo {
1702                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1703                trade_index: 2,
1704                status: "success".to_string(),
1705                counterparty_trade_pubkey: None,
1706            },
1707        ];
1708
1709        let restored_disputes = vec![
1710            crate::message::RestoredDisputesInfo {
1711                dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1712                order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
1713                trade_index: 1,
1714                status: "initiated".to_string(),
1715                initiator: Some(crate::message::DisputeInitiator::Buyer),
1716                solver_pubkey: None,
1717            },
1718            crate::message::RestoredDisputesInfo {
1719                dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
1720                order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
1721                trade_index: 2,
1722                status: "in-progress".to_string(),
1723                initiator: None,
1724                solver_pubkey: Some(
1725                    "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
1726                ),
1727            },
1728            crate::message::RestoredDisputesInfo {
1729                dispute_id: uuid!("708e1272-d5f4-47e6-bd97-3504baea9c27"),
1730                order_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
1731                trade_index: 3,
1732                status: "initiated".to_string(),
1733                initiator: Some(crate::message::DisputeInitiator::Seller),
1734                solver_pubkey: None,
1735            },
1736        ];
1737
1738        let restore_session_info = crate::message::RestoreSessionInfo {
1739            restore_orders: restored_orders.clone(),
1740            restore_disputes: restored_disputes.clone(),
1741        };
1742
1743        let restore_data_payload = Payload::RestoreData(restore_session_info);
1744        let restore_data_message = Message::Restore(MessageKind::new(
1745            None,
1746            None,
1747            None,
1748            Action::RestoreSession,
1749            Some(restore_data_payload),
1750        ));
1751
1752        // With new logic, any payload for RestoreSession is invalid (must be None)
1753        assert!(!restore_data_message.verify());
1754
1755        // Verify serialization/deserialization of RestoreData payload with all initiator cases
1756        let message_json = restore_data_message.as_json().unwrap();
1757        let deserialized_restore_message = Message::from_json(&message_json).unwrap();
1758
1759        if let Message::Restore(kind) = deserialized_restore_message {
1760            if let Some(Payload::RestoreData(session_info)) = kind.payload {
1761                assert_eq!(session_info.restore_disputes.len(), 3);
1762                assert_eq!(
1763                    session_info.restore_disputes[0].initiator,
1764                    Some(crate::message::DisputeInitiator::Buyer)
1765                );
1766                assert!(session_info.restore_disputes[0].solver_pubkey.is_none());
1767                assert_eq!(session_info.restore_disputes[1].initiator, None);
1768                assert_eq!(
1769                    session_info.restore_disputes[1].solver_pubkey,
1770                    Some(
1771                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1772                            .to_string()
1773                    )
1774                );
1775                assert_eq!(
1776                    session_info.restore_disputes[2].initiator,
1777                    Some(crate::message::DisputeInitiator::Seller)
1778                );
1779                assert!(session_info.restore_disputes[2].solver_pubkey.is_none());
1780                assert_eq!(
1781                    session_info.restore_orders[0].counterparty_trade_pubkey,
1782                    Some(
1783                        "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
1784                            .to_string()
1785                    )
1786                );
1787                assert!(session_info.restore_orders[1]
1788                    .counterparty_trade_pubkey
1789                    .is_none());
1790            } else {
1791                panic!("Expected RestoreData payload");
1792            }
1793        } else {
1794            panic!("Expected Restore message");
1795        }
1796    }
1797
1798    /// A daemon that predates `counterparty_trade_pubkey` sends the payload
1799    /// without it; a client built against this version must still parse it.
1800    #[test]
1801    fn restored_order_without_counterparty_pubkey_still_parses() {
1802        let json = r#"{
1803            "order_id": "308e1272-d5f4-47e6-bd97-3504baea9c23",
1804            "trade_index": 1,
1805            "status": "active"
1806        }"#;
1807
1808        let order: crate::message::RestoredOrdersInfo = serde_json::from_str(json).unwrap();
1809
1810        assert_eq!(order.trade_index, 1);
1811        assert_eq!(order.status, "active");
1812        assert!(order.counterparty_trade_pubkey.is_none());
1813    }
1814
1815    #[test]
1816    fn test_restore_session_message_validation() {
1817        // Test that RestoreSession action accepts only payload=None or RestoreData
1818        let restore_request_message = Message::Restore(MessageKind::new(
1819            None,
1820            None,
1821            None,
1822            Action::RestoreSession,
1823            None, // Missing payload
1824        ));
1825
1826        // Verify restore request message
1827        assert!(restore_request_message.verify());
1828
1829        // Test with wrong payload type
1830        let wrong_payload = Payload::TextMessage("wrong payload".to_string());
1831        let wrong_message = Message::Restore(MessageKind::new(
1832            None,
1833            None,
1834            None,
1835            Action::RestoreSession,
1836            Some(wrong_payload),
1837        ));
1838
1839        // Should fail validation because RestoreSession only accepts None
1840        assert!(!wrong_message.verify());
1841
1842        // With new logic, presence of id/request_id/trade_index is allowed
1843        let with_id = Message::Restore(MessageKind::new(
1844            Some(uuid!("00000000-0000-0000-0000-000000000001")),
1845            None,
1846            None,
1847            Action::RestoreSession,
1848            None,
1849        ));
1850        assert!(with_id.verify());
1851
1852        let with_request_id = Message::Restore(MessageKind::new(
1853            None,
1854            Some(42),
1855            None,
1856            Action::RestoreSession,
1857            None,
1858        ));
1859        assert!(with_request_id.verify());
1860
1861        let with_trade_index = Message::Restore(MessageKind::new(
1862            None,
1863            None,
1864            Some(7),
1865            Action::RestoreSession,
1866            None,
1867        ));
1868        assert!(with_trade_index.verify());
1869    }
1870
1871    #[test]
1872    fn test_restore_session_message_constructor() {
1873        // Test the new_restore constructor
1874        let restore_request_message = Message::new_restore(None);
1875
1876        assert!(matches!(restore_request_message, Message::Restore(_)));
1877        assert!(restore_request_message.verify());
1878        assert_eq!(
1879            restore_request_message.inner_action(),
1880            Some(Action::RestoreSession)
1881        );
1882
1883        // Test with RestoreData payload should be invalid now
1884        let restore_session_info = crate::message::RestoreSessionInfo {
1885            restore_orders: vec![],
1886            restore_disputes: vec![],
1887        };
1888        let restore_data_message =
1889            Message::new_restore(Some(Payload::RestoreData(restore_session_info)));
1890
1891        assert!(matches!(restore_data_message, Message::Restore(_)));
1892        assert!(!restore_data_message.verify());
1893    }
1894
1895    #[test]
1896    fn test_last_trade_index_valid_message() {
1897        let kind = MessageKind::new(None, None, Some(7), Action::LastTradeIndex, None);
1898        let msg = Message::Restore(kind);
1899
1900        assert!(msg.verify());
1901
1902        // roundtrip
1903        let json = msg.as_json().unwrap();
1904        let decoded = Message::from_json(&json).unwrap();
1905        assert!(decoded.verify());
1906
1907        // ensure the trade index is propagated
1908        let inner = decoded.get_inner_message_kind();
1909        assert_eq!(inner.trade_index(), 7);
1910        assert_eq!(inner.has_trade_index(), (true, 7));
1911    }
1912
1913    #[test]
1914    fn test_last_trade_index_without_id_is_valid() {
1915        // With new logic, id is not required; only payload must be None
1916        let kind = MessageKind::new(None, None, Some(5), Action::LastTradeIndex, None);
1917        let msg = Message::Restore(kind);
1918        assert!(msg.verify());
1919    }
1920
1921    #[test]
1922    fn test_last_trade_index_with_payload_fails_validation() {
1923        // LastTradeIndex does not accept payload
1924        let kind = MessageKind::new(
1925            None,
1926            None,
1927            Some(3),
1928            Action::LastTradeIndex,
1929            Some(Payload::TextMessage("ignored".to_string())),
1930        );
1931        let msg = Message::Restore(kind);
1932        assert!(!msg.verify());
1933    }
1934
1935    #[test]
1936    fn test_bond_resolution_admin_actions_accept_payload_or_none() {
1937        use crate::message::BondResolution;
1938
1939        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1940
1941        for action in [Action::AdminSettle, Action::AdminCancel] {
1942            let with_resolution = Message::Order(MessageKind::new(
1943                Some(uuid),
1944                Some(1),
1945                Some(2),
1946                action.clone(),
1947                Some(Payload::BondResolution(BondResolution {
1948                    slash_seller: true,
1949                    slash_buyer: false,
1950                })),
1951            ));
1952            assert!(
1953                with_resolution.verify(),
1954                "{action:?} + BondResolution should verify"
1955            );
1956
1957            let without_payload = Message::Order(MessageKind::new(
1958                Some(uuid),
1959                Some(1),
1960                Some(2),
1961                action.clone(),
1962                None,
1963            ));
1964            assert!(without_payload.verify(), "{action:?} + None should verify");
1965
1966            // Wrong payload type must be rejected for these admin actions.
1967            let wrong = Message::Order(MessageKind::new(
1968                Some(uuid),
1969                Some(1),
1970                Some(2),
1971                action.clone(),
1972                Some(Payload::TextMessage("nope".to_string())),
1973            ));
1974            assert!(!wrong.verify(), "{action:?} + TextMessage must be rejected");
1975
1976            // Missing id is still invalid.
1977            let no_id = Message::Order(MessageKind::new(
1978                None,
1979                Some(1),
1980                Some(2),
1981                action,
1982                Some(Payload::BondResolution(BondResolution {
1983                    slash_seller: false,
1984                    slash_buyer: false,
1985                })),
1986            ));
1987            assert!(!no_id.verify(), "admin action without id must be rejected");
1988        }
1989    }
1990
1991    #[test]
1992    fn test_bond_resolution_rejected_on_non_admin_actions() {
1993        use crate::message::BondResolution;
1994
1995        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
1996        let payload = Payload::BondResolution(BondResolution {
1997            slash_seller: true,
1998            slash_buyer: true,
1999        });
2000
2001        // Every Action except AdminSettle / AdminCancel must reject a
2002        // BondResolution payload. Listed explicitly (no strum) so that adding
2003        // a new Action variant forces a compile-error reminder here.
2004        for action in [
2005            Action::NewOrder,
2006            Action::TakeSell,
2007            Action::TakeBuy,
2008            Action::PayInvoice,
2009            Action::PayBondInvoice,
2010            Action::FiatSent,
2011            Action::FiatSentOk,
2012            Action::Release,
2013            Action::Released,
2014            Action::Cancel,
2015            Action::Canceled,
2016            Action::CooperativeCancelInitiatedByYou,
2017            Action::CooperativeCancelInitiatedByPeer,
2018            Action::DisputeInitiatedByYou,
2019            Action::DisputeInitiatedByPeer,
2020            Action::CooperativeCancelAccepted,
2021            Action::BuyerInvoiceAccepted,
2022            Action::BondInvoiceAccepted,
2023            Action::PurchaseCompleted,
2024            Action::BondPayoutCompleted,
2025            Action::BondSlashed,
2026            Action::HoldInvoicePaymentAccepted,
2027            Action::HoldInvoicePaymentSettled,
2028            Action::HoldInvoicePaymentCanceled,
2029            Action::WaitingSellerToPay,
2030            Action::WaitingBuyerInvoice,
2031            Action::AddInvoice,
2032            Action::AddBondInvoice,
2033            Action::BuyerTookOrder,
2034            Action::Rate,
2035            Action::RateUser,
2036            Action::RateReceived,
2037            Action::CantDo,
2038            Action::Dispute,
2039            Action::AdminCanceled,
2040            Action::AdminSettled,
2041            Action::AdminAddSolver,
2042            Action::AdminTakeDispute,
2043            Action::AdminTookDispute,
2044            Action::PaymentFailed,
2045            Action::InvoiceUpdated,
2046            Action::SendDm,
2047            Action::TradePubkey,
2048            Action::RestoreSession,
2049            Action::LastTradeIndex,
2050            Action::Orders,
2051        ] {
2052            let msg = Message::Order(MessageKind::new(
2053                Some(uuid),
2054                Some(1),
2055                Some(2),
2056                action.clone(),
2057                Some(payload.clone()),
2058            ));
2059            assert!(
2060                !msg.verify(),
2061                "{action:?} must reject BondResolution payload"
2062            );
2063        }
2064    }
2065
2066    #[test]
2067    fn test_bond_resolution_wire_format() {
2068        use crate::message::BondResolution;
2069
2070        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2071        let msg = Message::Order(MessageKind::new(
2072            Some(uuid),
2073            None,
2074            None,
2075            Action::AdminCancel,
2076            Some(Payload::BondResolution(BondResolution {
2077                slash_seller: true,
2078                slash_buyer: false,
2079            })),
2080        ));
2081
2082        let json = msg.as_json().unwrap();
2083        // Variant discriminator must be the snake_case `bond_resolution`.
2084        assert!(
2085            json.contains("\"bond_resolution\""),
2086            "expected snake_case discriminator, got: {json}"
2087        );
2088        assert!(json.contains("\"slash_seller\":true"));
2089        assert!(json.contains("\"slash_buyer\":false"));
2090
2091        // Roundtrip preserves the variant.
2092        let decoded = Message::from_json(&json).unwrap();
2093        assert!(decoded.verify());
2094        if let Message::Order(kind) = decoded {
2095            match kind.payload {
2096                Some(Payload::BondResolution(b)) => {
2097                    assert!(b.slash_seller);
2098                    assert!(!b.slash_buyer);
2099                }
2100                other => panic!("expected BondResolution payload, got {other:?}"),
2101            }
2102        } else {
2103            panic!("expected Order message");
2104        }
2105    }
2106
2107    #[test]
2108    fn test_bond_resolution_legacy_null_payload() {
2109        // payload = null on AdminSettle/AdminCancel must keep verifying so
2110        // pre-BondResolution clients keep working (interpreted as "no slash"
2111        // by the server).
2112        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2113        let json = format!(
2114            r#"{{"order":{{"version":1,"id":"{uuid}","action":"admin-cancel","payload":null}}}}"#
2115        );
2116        let msg = Message::from_json(&json).unwrap();
2117        assert!(msg.verify());
2118    }
2119
2120    #[test]
2121    fn test_pay_bond_invoice_wire_format_and_verify() {
2122        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2123        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
2124
2125        let msg = Message::Order(MessageKind::new(
2126            Some(uuid),
2127            Some(1),
2128            Some(2),
2129            Action::PayBondInvoice,
2130            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2131        ));
2132        assert!(msg.verify());
2133
2134        // Wire format must use the kebab-case discriminator.
2135        let json = msg.as_json().unwrap();
2136        assert!(
2137            json.contains("\"action\":\"pay-bond-invoice\""),
2138            "expected kebab-case discriminator, got: {json}"
2139        );
2140
2141        // Roundtrip preserves the variant.
2142        let decoded = Message::from_json(&json).unwrap();
2143        assert!(decoded.verify());
2144        assert!(matches!(
2145            decoded.inner_action(),
2146            Some(Action::PayBondInvoice)
2147        ));
2148
2149        // Same id / payload constraints as PayInvoice: missing id is invalid.
2150        let no_id = Message::Order(MessageKind::new(
2151            None,
2152            Some(1),
2153            Some(2),
2154            Action::PayBondInvoice,
2155            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2156        ));
2157        assert!(!no_id.verify());
2158
2159        // Wrong payload shape is rejected.
2160        let wrong_payload = Message::Order(MessageKind::new(
2161            Some(uuid),
2162            Some(1),
2163            Some(2),
2164            Action::PayBondInvoice,
2165            Some(Payload::TextMessage("nope".to_string())),
2166        ));
2167        assert!(!wrong_payload.verify());
2168
2169        // Missing payload is rejected (PaymentRequest is required).
2170        let no_payload = Message::Order(MessageKind::new(
2171            Some(uuid),
2172            Some(1),
2173            Some(2),
2174            Action::PayBondInvoice,
2175            None,
2176        ));
2177        assert!(!no_payload.verify());
2178    }
2179
2180    #[test]
2181    fn test_add_bond_invoice_wire_format_and_verify() {
2182        let uuid = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2183        let bolt11 = "lnbcrt78510n1pj59wmepp50677g8tffdqa2p8882y0x6newny5vtz0hjuyngdwv226nanv4uzsdqqcqzzsxqyz5vqsp5skn973360gp4yhlpmefwvul5hs58lkkl3u3ujvt57elmp4zugp4q9qyyssqw4nzlr72w28k4waycf27qvgzc9sp79sqlw83j56txltz4va44j7jda23ydcujj9y5k6k0rn5ms84w8wmcmcyk5g3mhpqepf7envhdccp72nz6e".to_string();
2184
2185        let msg = Message::Order(MessageKind::new(
2186            Some(uuid),
2187            Some(1),
2188            Some(2),
2189            Action::AddBondInvoice,
2190            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2191        ));
2192        assert!(msg.verify());
2193
2194        // Wire format must use the kebab-case discriminator.
2195        let json = msg.as_json().unwrap();
2196        assert!(
2197            json.contains("\"action\":\"add-bond-invoice\""),
2198            "expected kebab-case discriminator, got: {json}"
2199        );
2200
2201        // Roundtrip preserves the variant.
2202        let decoded = Message::from_json(&json).unwrap();
2203        assert!(decoded.verify());
2204        assert!(matches!(
2205            decoded.inner_action(),
2206            Some(Action::AddBondInvoice)
2207        ));
2208
2209        // Same id / payload constraints as AddInvoice: missing id is invalid.
2210        let no_id = Message::Order(MessageKind::new(
2211            None,
2212            Some(1),
2213            Some(2),
2214            Action::AddBondInvoice,
2215            Some(Payload::PaymentRequest(None, bolt11.clone(), None)),
2216        ));
2217        assert!(!no_id.verify());
2218
2219        // Wrong payload shape is rejected.
2220        let wrong_payload = Message::Order(MessageKind::new(
2221            Some(uuid),
2222            Some(1),
2223            Some(2),
2224            Action::AddBondInvoice,
2225            Some(Payload::TextMessage("nope".to_string())),
2226        ));
2227        assert!(!wrong_payload.verify());
2228
2229        // Missing payload is rejected (PaymentRequest is required).
2230        let no_payload = Message::Order(MessageKind::new(
2231            Some(uuid),
2232            Some(1),
2233            Some(2),
2234            Action::AddBondInvoice,
2235            None,
2236        ));
2237        assert!(!no_payload.verify());
2238
2239        // get_payment_request must surface the bolt11 for AddBondInvoice.
2240        if let Message::Order(kind) = &msg {
2241            assert_eq!(kind.get_payment_request(), Some(bolt11));
2242        } else {
2243            panic!("expected Message::Order");
2244        }
2245    }
2246
2247    #[test]
2248    fn test_restored_dispute_helper_serialization_roundtrip() {
2249        use crate::message::RestoredDisputeHelper;
2250
2251        let helper = RestoredDisputeHelper {
2252            dispute_id: uuid!("508e1272-d5f4-47e6-bd97-3504baea9c25"),
2253            order_id: uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23"),
2254            dispute_status: "initiated".to_string(),
2255            master_buyer_pubkey: Some("npub1buyerkey".to_string()),
2256            master_seller_pubkey: Some("npub1sellerkey".to_string()),
2257            trade_index_buyer: Some(1),
2258            trade_index_seller: Some(2),
2259            buyer_dispute: true,
2260            seller_dispute: false,
2261            solver_pubkey: None,
2262        };
2263
2264        let json = serde_json::to_string(&helper).unwrap();
2265        let deserialized: RestoredDisputeHelper = serde_json::from_str(&json).unwrap();
2266
2267        assert_eq!(deserialized.dispute_id, helper.dispute_id);
2268        assert_eq!(deserialized.order_id, helper.order_id);
2269        assert_eq!(deserialized.dispute_status, helper.dispute_status);
2270        assert_eq!(deserialized.master_buyer_pubkey, helper.master_buyer_pubkey);
2271        assert_eq!(
2272            deserialized.master_seller_pubkey,
2273            helper.master_seller_pubkey
2274        );
2275        assert_eq!(deserialized.trade_index_buyer, helper.trade_index_buyer);
2276        assert_eq!(deserialized.trade_index_seller, helper.trade_index_seller);
2277        assert_eq!(deserialized.buyer_dispute, helper.buyer_dispute);
2278        assert_eq!(deserialized.seller_dispute, helper.seller_dispute);
2279        assert_eq!(deserialized.solver_pubkey, helper.solver_pubkey);
2280
2281        let helper_seller_dispute = RestoredDisputeHelper {
2282            dispute_id: uuid!("608e1272-d5f4-47e6-bd97-3504baea9c26"),
2283            order_id: uuid!("408e1272-d5f4-47e6-bd97-3504baea9c24"),
2284            dispute_status: "in-progress".to_string(),
2285            master_buyer_pubkey: None,
2286            master_seller_pubkey: None,
2287            trade_index_buyer: None,
2288            trade_index_seller: None,
2289            buyer_dispute: false,
2290            seller_dispute: true,
2291            solver_pubkey: Some(
2292                "aabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344".to_string(),
2293            ),
2294        };
2295
2296        let json_seller = serde_json::to_string(&helper_seller_dispute).unwrap();
2297        let deserialized_seller: RestoredDisputeHelper =
2298            serde_json::from_str(&json_seller).unwrap();
2299
2300        assert_eq!(
2301            deserialized_seller.dispute_id,
2302            helper_seller_dispute.dispute_id
2303        );
2304        assert_eq!(deserialized_seller.order_id, helper_seller_dispute.order_id);
2305        assert_eq!(
2306            deserialized_seller.dispute_status,
2307            helper_seller_dispute.dispute_status
2308        );
2309        assert_eq!(deserialized_seller.master_buyer_pubkey, None);
2310        assert_eq!(deserialized_seller.master_seller_pubkey, None);
2311        assert_eq!(deserialized_seller.trade_index_buyer, None);
2312        assert_eq!(deserialized_seller.trade_index_seller, None);
2313        assert!(!deserialized_seller.buyer_dispute);
2314        assert!(deserialized_seller.seller_dispute);
2315        assert_eq!(
2316            deserialized_seller.solver_pubkey,
2317            helper_seller_dispute.solver_pubkey
2318        );
2319    }
2320
2321    fn sample_lock_proof() -> CashuLockProof {
2322        CashuLockProof::new(
2323            "cashuAeyJ0b2tlbiI6dGVzdA".to_string(),
2324            "https://mint.example".to_string(),
2325            "02b_buyer".to_string(),
2326            "02s_seller".to_string(),
2327            "02m_mostro".to_string(),
2328        )
2329    }
2330
2331    #[test]
2332    fn test_cashu_lock_proof_json_round_trip() {
2333        let proof = sample_lock_proof();
2334        let json = proof.as_json().unwrap();
2335        let back = CashuLockProof::from_json(&json).unwrap();
2336        assert_eq!(back, proof);
2337    }
2338
2339    #[test]
2340    fn test_cashu_lock_proof_fee_token_round_trip() {
2341        let proof = sample_lock_proof().with_fee_token("cashuAfee".to_string());
2342        let json = proof.as_json().unwrap();
2343        assert!(json.contains("fee_token"));
2344        let back = CashuLockProof::from_json(&json).unwrap();
2345        assert_eq!(back, proof);
2346        assert_eq!(back.fee_token.as_deref(), Some("cashuAfee"));
2347    }
2348
2349    #[test]
2350    fn test_cashu_lock_proof_without_fee_token_is_omitted_and_defaults_to_none() {
2351        // A proof with no fee serializes without the key (wire-compatible with
2352        // clients that predate the field) and parses back to `None`.
2353        let proof = sample_lock_proof();
2354        assert_eq!(proof.fee_token, None);
2355        let json = proof.as_json().unwrap();
2356        assert!(!json.contains("fee_token"));
2357        let back = CashuLockProof::from_json(&json).unwrap();
2358        assert_eq!(back.fee_token, None);
2359    }
2360
2361    #[test]
2362    fn test_add_cashu_escrow_verifies_with_lock_proof() {
2363        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2364        let payload = Payload::CashuLockProof(sample_lock_proof());
2365        let kind = MessageKind::new(
2366            Some(order_id),
2367            None,
2368            None,
2369            Action::AddCashuEscrow,
2370            Some(payload),
2371        );
2372        assert!(
2373            kind.verify(),
2374            "CashuLockProof must verify on AddCashuEscrow"
2375        );
2376
2377        // Round-trip through JSON to catch a serde-rename mismatch on the
2378        // new snake_case discriminator.
2379        let json = Message::Order(kind).as_json().unwrap();
2380        assert!(json.contains("cashu_lock_proof"));
2381        assert!(Message::from_json(&json).unwrap().verify());
2382    }
2383
2384    #[test]
2385    fn test_add_cashu_escrow_requires_id_and_right_payload() {
2386        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2387
2388        // Missing id ⇒ invalid.
2389        let no_id = MessageKind::new(
2390            None,
2391            None,
2392            None,
2393            Action::AddCashuEscrow,
2394            Some(Payload::CashuLockProof(sample_lock_proof())),
2395        );
2396        assert!(
2397            !no_id.verify(),
2398            "AddCashuEscrow without id must be rejected"
2399        );
2400
2401        // Wrong payload ⇒ invalid.
2402        let wrong_payload = MessageKind::new(
2403            Some(order_id),
2404            None,
2405            None,
2406            Action::AddCashuEscrow,
2407            Some(Payload::TextMessage("not a lock proof".to_string())),
2408        );
2409        assert!(
2410            !wrong_payload.verify(),
2411            "AddCashuEscrow with non-lock-proof payload must be rejected"
2412        );
2413    }
2414
2415    #[test]
2416    fn test_cashu_pm_signature_verifies_with_signatures() {
2417        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2418        let kind = MessageKind::new(
2419            Some(order_id),
2420            None,
2421            None,
2422            Action::CashuPmSignature,
2423            Some(Payload::CashuSignatures(vec![
2424                CashuProofSignature::new("secret-0".to_string(), "deadbeef".to_string()),
2425                CashuProofSignature::new("secret-1".to_string(), "c0ffee".to_string()),
2426            ])),
2427        );
2428        assert!(
2429            kind.verify(),
2430            "CashuSignatures must verify on CashuPmSignature"
2431        );
2432
2433        let json = Message::Order(kind).as_json().unwrap();
2434        assert!(json.contains("cashu_signatures"));
2435        assert!(Message::from_json(&json).unwrap().verify());
2436
2437        // Wrong payload ⇒ invalid.
2438        let wrong = MessageKind::new(Some(order_id), None, None, Action::CashuPmSignature, None);
2439        assert!(
2440            !wrong.verify(),
2441            "CashuPmSignature without a signature payload must be rejected"
2442        );
2443
2444        // Empty signature set ⇒ invalid: a multi-proof escrow needs one
2445        // signature per proof, so an empty collection cannot assemble a swap.
2446        let empty = MessageKind::new(
2447            Some(order_id),
2448            None,
2449            None,
2450            Action::CashuPmSignature,
2451            Some(Payload::CashuSignatures(vec![])),
2452        );
2453        assert!(
2454            !empty.verify(),
2455            "CashuPmSignature with an empty signature set must be rejected"
2456        );
2457    }
2458
2459    #[test]
2460    fn test_cashu_escrow_locked_is_informational() {
2461        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2462
2463        // Informational ack with an id and no payload verifies.
2464        let ok = MessageKind::new(Some(order_id), None, None, Action::CashuEscrowLocked, None);
2465        assert!(ok.verify(), "CashuEscrowLocked with id must verify");
2466
2467        // Missing id ⇒ invalid.
2468        let no_id = MessageKind::new(None, None, None, Action::CashuEscrowLocked, None);
2469        assert!(
2470            !no_id.verify(),
2471            "CashuEscrowLocked without id must be rejected"
2472        );
2473    }
2474
2475    fn sample_declaration() -> PayerDeclaration {
2476        PayerDeclaration::new(payment_hash("EU|SEPA|DE89370400440532013000|ALICE SMITH"))
2477    }
2478
2479    fn sample_history() -> PaymentHistory {
2480        PaymentHistory {
2481            payment_hash: sample_declaration().payment_hash,
2482            buyer_mode: BuyerMode::Reputation,
2483            successful_trades: 47,
2484            distinct_counterparties: 29,
2485            experienced_counterparties: 11,
2486            first_success_at: Some(1_762_128_000),
2487            last_success_at: Some(1_787_654_321),
2488        }
2489    }
2490
2491    #[test]
2492    fn test_payer_history_actions_use_kebab_case_wire_names() {
2493        for (action, wire) in [
2494            (Action::DeclarePayer, "\"declare-payer\""),
2495            (Action::PayerDeclared, "\"payer-declared\""),
2496            (Action::PaymentHistory, "\"payment-history\""),
2497        ] {
2498            assert_eq!(serde_json::to_string(&action).unwrap(), wire);
2499            assert_eq!(serde_json::from_str::<Action>(wire).unwrap(), action);
2500        }
2501    }
2502
2503    #[test]
2504    fn test_declare_payer_requires_id_and_declaration() {
2505        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2506        let decl = Some(Payload::PayerDeclaration(sample_declaration()));
2507
2508        let ok = MessageKind::new(
2509            Some(order_id),
2510            Some(1),
2511            Some(7),
2512            Action::DeclarePayer,
2513            decl.clone(),
2514        );
2515        assert!(
2516            ok.verify(),
2517            "DeclarePayer with id and declaration must verify"
2518        );
2519
2520        let no_id = MessageKind::new(None, None, None, Action::DeclarePayer, decl);
2521        assert!(!no_id.verify(), "DeclarePayer without id must be rejected");
2522
2523        let no_payload = MessageKind::new(Some(order_id), None, None, Action::DeclarePayer, None);
2524        assert!(
2525            !no_payload.verify(),
2526            "DeclarePayer without payload must be rejected"
2527        );
2528
2529        let wrong = MessageKind::new(
2530            Some(order_id),
2531            None,
2532            None,
2533            Action::DeclarePayer,
2534            Some(Payload::TextMessage(
2535                "plaintext must never be sent".to_string(),
2536            )),
2537        );
2538        assert!(
2539            !wrong.verify(),
2540            "DeclarePayer with a non-declaration payload must be rejected"
2541        );
2542    }
2543
2544    #[test]
2545    fn test_payer_declared_requires_id_and_declaration() {
2546        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2547        let decl = Some(Payload::PayerDeclaration(sample_declaration()));
2548
2549        let ok = MessageKind::new(
2550            Some(order_id),
2551            None,
2552            None,
2553            Action::PayerDeclared,
2554            decl.clone(),
2555        );
2556        assert!(
2557            ok.verify(),
2558            "PayerDeclared with id and declaration must verify"
2559        );
2560
2561        let no_id = MessageKind::new(None, None, None, Action::PayerDeclared, decl);
2562        assert!(!no_id.verify(), "PayerDeclared without id must be rejected");
2563
2564        let no_payload = MessageKind::new(Some(order_id), None, None, Action::PayerDeclared, None);
2565        assert!(
2566            !no_payload.verify(),
2567            "PayerDeclared without payload must be rejected"
2568        );
2569
2570        let wrong = MessageKind::new(
2571            Some(order_id),
2572            None,
2573            None,
2574            Action::PayerDeclared,
2575            Some(Payload::PaymentHistory(sample_history())),
2576        );
2577        assert!(
2578            !wrong.verify(),
2579            "PayerDeclared with a history payload must be rejected"
2580        );
2581    }
2582
2583    #[test]
2584    fn test_payment_history_accepts_query_and_reply_shapes() {
2585        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2586
2587        // Seller → Mostro query: no payload.
2588        let query = MessageKind::new(Some(order_id), Some(3), None, Action::PaymentHistory, None);
2589        assert!(
2590            query.verify(),
2591            "PaymentHistory query (no payload) must verify"
2592        );
2593
2594        // Mostro → seller push / reply: the aggregate history.
2595        let reply = MessageKind::new(
2596            Some(order_id),
2597            None,
2598            None,
2599            Action::PaymentHistory,
2600            Some(Payload::PaymentHistory(sample_history())),
2601        );
2602        assert!(reply.verify(), "PaymentHistory reply must verify");
2603
2604        let no_id = MessageKind::new(None, None, None, Action::PaymentHistory, None);
2605        assert!(
2606            !no_id.verify(),
2607            "PaymentHistory without id must be rejected"
2608        );
2609
2610        let wrong = MessageKind::new(
2611            Some(order_id),
2612            None,
2613            None,
2614            Action::PaymentHistory,
2615            Some(Payload::PayerDeclaration(sample_declaration())),
2616        );
2617        assert!(
2618            !wrong.verify(),
2619            "PaymentHistory with a declaration payload must be rejected"
2620        );
2621    }
2622
2623    #[test]
2624    fn test_payer_payloads_round_trip_with_snake_case_keys() {
2625        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2626
2627        let declare = Message::Order(MessageKind::new(
2628            Some(order_id),
2629            Some(981_231),
2630            Some(7),
2631            Action::DeclarePayer,
2632            Some(Payload::PayerDeclaration(sample_declaration())),
2633        ));
2634        let json = declare.as_json().unwrap();
2635        assert!(json.contains("\"action\":\"declare-payer\""));
2636        assert!(json.contains("\"payer_declaration\":{\"payment_hash\":"));
2637        assert!(Message::from_json(&json).unwrap().verify());
2638
2639        let push = Message::Order(MessageKind::new(
2640            Some(order_id),
2641            None,
2642            None,
2643            Action::PaymentHistory,
2644            Some(Payload::PaymentHistory(sample_history())),
2645        ));
2646        let json = push.as_json().unwrap();
2647        assert!(json.contains("\"payment_history\":{"));
2648        assert!(json.contains("\"buyer_mode\":\"reputation\""));
2649        assert!(json.contains("\"experienced_counterparties\":11"));
2650        let back = Message::from_json(&json).unwrap();
2651        assert!(back.verify());
2652        match back.get_inner_message_kind().payload.clone() {
2653            Some(Payload::PaymentHistory(h)) => assert_eq!(h, sample_history()),
2654            other => panic!("expected a PaymentHistory payload, got {other:?}"),
2655        }
2656    }
2657
2658    #[test]
2659    fn test_payment_history_parses_the_protocol_wire_example() {
2660        // The full-privacy example from the protocol chapter, verbatim apart
2661        // from the elided hash.
2662        let hash = sample_declaration().payment_hash;
2663        let json = format!(
2664            r#"{{"payment_history": {{"payment_hash": "{hash}", "buyer_mode": "full_privacy",
2665              "successful_trades": 0, "distinct_counterparties": 0,
2666              "experienced_counterparties": 0,
2667              "first_success_at": null, "last_success_at": null}}}}"#
2668        );
2669        let payload: Payload = serde_json::from_str(&json).unwrap();
2670        match payload {
2671            Payload::PaymentHistory(h) => assert_eq!(h, PaymentHistory::unavailable(hash)),
2672            other => panic!("expected a PaymentHistory payload, got {other:?}"),
2673        }
2674    }
2675
2676    /// The wire shapes the protocol's reputation chapter documents: the
2677    /// `order` wrapper, no `id`, kebab-case actions and snake_case payloads.
2678    #[test]
2679    fn reputation_messages_have_the_documented_wire_shape() {
2680        let export = Message::new_order(
2681            None,
2682            Some(4126),
2683            None,
2684            Action::ExportReputation,
2685            Some(Payload::ReputationExportRequest(
2686                super::ReputationExportRequest {
2687                    destination: "ab".repeat(32),
2688                    rebind: None,
2689                },
2690            )),
2691        );
2692        let json: serde_json::Value = serde_json::from_str(&export.as_json().unwrap()).unwrap();
2693        assert_eq!(
2694            json,
2695            serde_json::json!({ "order": {
2696                "version": 2,
2697                "request_id": 4126,
2698                "trade_index": null,
2699                "action": "export-reputation",
2700                "payload": { "reputation_export_request": {
2701                    "destination": "ab".repeat(32),
2702                    "rebind": null,
2703                }},
2704            }})
2705        );
2706
2707        for (action, wire) in [
2708            (Action::ReputationExported, "reputation-exported"),
2709            (Action::ImportReputation, "import-reputation"),
2710        ] {
2711            let message = Message::new_order(
2712                None,
2713                None,
2714                None,
2715                action,
2716                Some(Payload::ReputationAttestation("{}".into())),
2717            );
2718            let json: serde_json::Value =
2719                serde_json::from_str(&message.as_json().unwrap()).unwrap();
2720            assert_eq!(json["order"]["action"], wire);
2721            assert_eq!(json["order"]["payload"]["reputation_attestation"], "{}");
2722        }
2723
2724        let imported = Message::new_order(None, None, None, Action::ReputationImported, None);
2725        let json: serde_json::Value = serde_json::from_str(&imported.as_json().unwrap()).unwrap();
2726        assert_eq!(json["order"]["action"], "reputation-imported");
2727        assert!(json["order"]["payload"].is_null());
2728    }
2729
2730    #[test]
2731    fn an_export_request_without_rebind_deserialises() {
2732        let json = r#"{"order":{"version":2,"action":"export-reputation","payload":{"reputation_export_request":{"destination":"ab"}}}}"#;
2733        let message = Message::from_json(json).unwrap();
2734        let kind = message.get_inner_message_kind();
2735        match &kind.payload {
2736            Some(Payload::ReputationExportRequest(request)) => assert_eq!(request.rebind, None),
2737            other => panic!("unexpected payload {other:?}"),
2738        }
2739        assert!(kind.verify());
2740    }
2741
2742    #[test]
2743    fn reputation_actions_verify_only_without_an_id_and_with_their_payload() {
2744        let order_id = uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23");
2745        let request = Payload::ReputationExportRequest(super::ReputationExportRequest {
2746            destination: "ab".repeat(32),
2747            rebind: None,
2748        });
2749        let attestation = Payload::ReputationAttestation("{}".into());
2750        let cases = [
2751            (Action::ExportReputation, Some(request.clone()), true),
2752            (Action::ExportReputation, Some(attestation.clone()), false),
2753            (Action::ExportReputation, None, false),
2754            (Action::ReputationExported, Some(attestation.clone()), true),
2755            (Action::ReputationExported, None, false),
2756            (Action::ImportReputation, Some(attestation.clone()), true),
2757            (Action::ImportReputation, Some(request.clone()), false),
2758            (Action::ReputationImported, None, true),
2759            (Action::ReputationImported, Some(attestation.clone()), false),
2760        ];
2761        for (action, payload, ok) in cases {
2762            let without_id = MessageKind::new(None, None, None, action.clone(), payload.clone());
2763            assert_eq!(without_id.verify(), ok, "{action:?} {payload:?}");
2764            let with_id = MessageKind::new(Some(order_id), None, None, action.clone(), payload);
2765            assert!(!with_id.verify(), "{action:?} must not carry an id");
2766        }
2767    }
2768
2769    /// `PROTOCOL_VER` stays 2: the reputation actions are request/response
2770    /// over encrypted direct messages, so an old client never sends them and
2771    /// is never sent their replies, and a new client only sends them to a
2772    /// node whose info event advertises them. Nothing about them is
2773    /// broadcast. A new `cant-do` reason reaching an old client degrades to
2774    /// `Unknown` through `CantDoReason`'s catch-all.
2775    #[test]
2776    fn reputation_messages_keep_protocol_version_2() {
2777        for action in [
2778            Action::ExportReputation,
2779            Action::ReputationExported,
2780            Action::ImportReputation,
2781            Action::ReputationImported,
2782        ] {
2783            assert_eq!(MessageKind::new(None, None, None, action, None).version, 2);
2784        }
2785    }
2786}