Skip to main content

mostro_core/
nip59.rs

1//! NIP-59 GiftWrap transport for Mostro messages.
2//!
3//! Every message exchanged with a Mostro node travels through the same
4//! pipeline:
5//!
6//! ```text
7//! Message -> JSON((Message, Option<Signature>)) -> Rumor -> Seal -> GiftWrap
8//! ```
9//!
10//! Mostro splits signing across two keys: a long-lived **identity key**
11//! signs the seal (and encrypts it to the receiver), while a per-trade
12//! **trade key** authors the rumor and produces the inner tuple signature.
13//! This deliberately breaks NIP-59's "rumor author == seal signer"
14//! convention that `nostr` 0.45 enforces via `SenderMismatch` in
15//! `nip59::extract_rumor`, so the unwrap path does its own NIP-44 +
16//! signature verification instead of calling that helper. Seals are built
17//! with `GiftWrapSealBuilder` (the 0.45 replacement for
18//! `EventBuilder::seal`).
19//!
20//! The module centralizes wrap/unwrap so clients do not need to reimplement
21//! NIP-59 glue themselves. It does not manage relays, subscriptions,
22//! waiters or persistence — the returned `Event` is ready to publish, and
23//! the caller decides how to do so.
24
25use std::str::FromStr;
26
27use crate::message::{Action, Message, Payload};
28use crate::prelude::{CantDoReason, MostroError, ServiceError};
29use nostr::nips::nip44;
30use nostr::nips::nip59::GiftWrapSealBuilder;
31use nostr_sdk::prelude::*;
32
33/// NIP-59-compatible random timestamp tweak range (0..2 days).
34/// Mirrored locally: `RANGE_RANDOM_TIMESTAMP_TWEAK` is private in nostr 0.45.
35const RANGE_RANDOM_TIMESTAMP_TWEAK_SECS: u64 = 172_800;
36
37/// Options controlling how a Mostro message is wrapped.
38#[derive(Debug, Clone)]
39pub struct WrapOptions {
40    /// NIP-13 proof-of-work difficulty applied to the outer GiftWrap event.
41    pub pow: u8,
42    /// Optional expiration tag for the outer GiftWrap event.
43    pub expiration: Option<Timestamp>,
44    /// When true the inner rumor content is `(Message, Some(Signature))`,
45    /// with the signature produced from the JSON of `Message` using
46    /// `trade_keys`. When false the content is `(Message, None)`. Traffic
47    /// to a Mostro node always uses `true`.
48    pub signed: bool,
49}
50
51impl Default for WrapOptions {
52    fn default() -> Self {
53        Self {
54            pow: 0,
55            expiration: None,
56            signed: true,
57        }
58    }
59}
60
61/// A Mostro message recovered from an incoming GiftWrap, plus metadata from
62/// the outer envelopes.
63#[derive(Debug, Clone)]
64pub struct UnwrappedMessage {
65    /// The logical Mostro message carried inside the rumor.
66    pub message: Message,
67    /// Signature of the JSON-serialized `Message`, produced with the sender's
68    /// trade keys. Present only when the sender set `signed = true`.
69    pub signature: Option<Signature>,
70    /// Rumor author — the sender's trade public key.
71    pub sender: PublicKey,
72    /// Seal signer — the sender's long-lived identity public key. In
73    /// full-privacy mode (where the client reuses its trade key as identity)
74    /// this equals `sender`.
75    pub identity: PublicKey,
76    /// Rumor `created_at` timestamp.
77    pub created_at: Timestamp,
78}
79
80/// Build a GiftWrap event (`kind: 1059`) ready to be published to a relay.
81///
82/// * `message` — the Mostro message to send.
83/// * `identity_keys` — long-lived identity keys. Sign the seal (kind 13)
84///   and encrypt it to `receiver` via NIP-44 (`GiftWrapSealBuilder` in
85///   nostr 0.45). Callers that want the "full privacy" mode (no stable
86///   identity, no reputation) should pass the same value as `trade_keys`.
87/// * `trade_keys` — per-trade keys. Author of the rumor (kind 1) and
88///   signer of the inner tuple signature when `opts.signed == true`.
89/// * `receiver` — the Mostro node public key.
90/// * `opts` — wrap options (PoW, expiration, signed). Outer PoW uses
91///   `UnsignedEvent::mine` when `pow > 0`; gift-wrap `created_at` is
92///   blurred with the local NIP-59-compatible tweak helper.
93pub async fn wrap_message(
94    message: &Message,
95    identity_keys: &Keys,
96    trade_keys: &Keys,
97    receiver: PublicKey,
98    opts: WrapOptions,
99) -> Result<Event, MostroError> {
100    let message_json = message.as_json().map_err(MostroError::MostroInternalErr)?;
101
102    let content = if opts.signed {
103        let sig = Message::sign(message_json, trade_keys);
104        serde_json::to_string(&(message, Some(sig.to_string())))
105            .map_err(|_| MostroError::MostroInternalErr(ServiceError::MessageSerializationError))?
106    } else {
107        serde_json::to_string(&(message, Option::<String>::None))
108            .map_err(|_| MostroError::MostroInternalErr(ServiceError::MessageSerializationError))?
109    };
110
111    // PoW only applies to the outer GiftWrap (per WrapOptions docs); the
112    // rumor is encrypted inside the seal and never published on its own,
113    // so mining its event id would burn CPU for nothing.
114    let mut rumor =
115        EventBuilder::new(Kind::TextNote, content).finalize_unsigned(trade_keys.public_key());
116
117    // The rumor id must be set before sealing: `GiftWrapSealBuilder` in nostr
118    // 0.45 encrypts the rumor JSON before ensuring its id, so an unset id is
119    // silently dropped from the wrap (nostrdevkit/nostr#1443). Cheap no-op
120    // once fixed upstream — keep it as a guard, the id was already lost twice.
121    rumor.ensure_id();
122
123    // Seal is encrypted and signed with identity_keys so the receiver can
124    // decrypt it via (receiver_secret, seal.pubkey) — this keeps seal.pubkey
125    // consistent with the encryption key, while leaving rumor.pubkey free to
126    // carry the per-trade key (the mismatch standard NIP-59 rejects).
127    let seal: Event = GiftWrapSealBuilder::new(rumor, receiver)
128        .finalize(identity_keys)
129        .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?;
130
131    gift_wrap_from_seal_with_pow(&seal, receiver, opts.pow, opts.expiration)
132}
133
134/// Wrap an already built Seal into a NIP-59 GiftWrap with optional PoW and
135/// expiration. The outer event is signed with a freshly generated ephemeral
136/// key and carries a mandatory `p` tag pointing at `receiver`.
137///
138/// PoW (`pow > 0`) is applied with `UnsignedEvent::mine(&SingleThreadPow, …)`
139/// before `finalize`; `created_at` uses the local [`tweaked_timestamp`] helper
140/// (nostr 0.45 made `Timestamp::tweaked` / the NIP-59 range private).
141fn gift_wrap_from_seal_with_pow(
142    seal: &Event,
143    receiver: PublicKey,
144    pow: u8,
145    expiration: Option<Timestamp>,
146) -> Result<Event, MostroError> {
147    if seal.kind != Kind::Seal {
148        return Err(MostroError::MostroInternalErr(
149            ServiceError::UnexpectedError("expected Seal kind".to_string()),
150        ));
151    }
152
153    let ephemeral = Keys::generate();
154    let encrypted = nip44::encrypt(
155        ephemeral.secret_key(),
156        &receiver,
157        seal.as_json(),
158        nip44::Version::default(),
159    )
160    .map_err(|e| MostroError::MostroInternalErr(ServiceError::EncryptionError(e.to_string())))?;
161
162    let mut tags: Vec<Tag> = Vec::new();
163    if let Some(exp) = expiration {
164        tags.push(Tag::expiration(exp));
165    }
166    tags.push(Tag::public_key(receiver));
167
168    let unsigned = EventBuilder::new(Kind::GiftWrap, encrypted)
169        .tags(tags)
170        .custom_created_at(tweaked_timestamp())
171        .finalize_unsigned(ephemeral.public_key());
172
173    let unsigned = match core::num::NonZeroU8::new(pow) {
174        Some(pow) => unsigned
175            .mine(&SingleThreadPow, pow)
176            .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?,
177        None => unsigned,
178    };
179
180    unsigned
181        .finalize(&ephemeral)
182        .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))
183}
184
185/// Subtract a random offset in `0..RANGE_RANDOM_TIMESTAMP_TWEAK_SECS` from now.
186fn tweaked_timestamp() -> Timestamp {
187    let now = Timestamp::now().as_secs();
188    let entropy = Keys::generate();
189    let bytes = entropy.secret_key().to_secret_bytes();
190    let tweak = u64::from_le_bytes(bytes[0..8].try_into().expect("8 bytes"))
191        % RANGE_RANDOM_TIMESTAMP_TWEAK_SECS;
192    Timestamp::from_secs(now.saturating_sub(tweak))
193}
194
195/// Try to open an incoming GiftWrap with the given `receiver_keys`.
196///
197/// Returns `Ok(None)` only when the outer NIP-44 layer could not be
198/// decrypted with `receiver_keys` — the canonical "not addressed to me"
199/// signal, so callers can try multiple candidate keys without treating
200/// each miss as fatal. Every other failure (corrupted seal, malformed
201/// rumor JSON, invalid signatures, etc.) yields `Err` so callers can tell
202/// "not mine" apart from "broken".
203///
204/// Does **not** enforce `seal.pubkey == rumor.pubkey`: Mostro signs the
205/// seal with the identity key and authors the rumor with the per-trade
206/// key, so the two legitimately differ.
207pub async fn unwrap_message(
208    event: &Event,
209    receiver_keys: &Keys,
210) -> Result<Option<UnwrappedMessage>, MostroError> {
211    if event.kind != Kind::GiftWrap {
212        return Err(MostroError::MostroInternalErr(
213            ServiceError::UnexpectedError("event is not a GiftWrap".to_string()),
214        ));
215    }
216
217    // Decrypt outer GiftWrap using (receiver_secret, ephemeral_pub).
218    // Failure here is the "not addressed to me" signal.
219    let seal_json = match nip44::decrypt(receiver_keys.secret_key(), &event.pubkey, &event.content)
220    {
221        Ok(s) => s,
222        Err(_) => return Ok(None),
223    };
224
225    let seal: Event = Event::from_json(&seal_json).map_err(|e| {
226        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
227            "malformed seal JSON: {e}"
228        )))
229    })?;
230
231    if seal.kind != Kind::Seal {
232        return Err(MostroError::MostroInternalErr(
233            ServiceError::UnexpectedError("inner event is not a Seal".to_string()),
234        ));
235    }
236
237    seal.verify_signature().then_some(()).ok_or_else(|| {
238        MostroError::MostroInternalErr(ServiceError::NostrError(
239            "invalid seal signature".to_string(),
240        ))
241    })?;
242
243    // Decrypt the seal content using (receiver_secret, seal.pubkey). In
244    // Mostro, seal.pubkey is the sender's identity key, which is also the
245    // key that performed the NIP-44 encryption in `wrap_message`.
246    let rumor_json = nip44::decrypt(receiver_keys.secret_key(), &seal.pubkey, &seal.content)
247        .map_err(|e| {
248            MostroError::MostroInternalErr(ServiceError::DecryptionError(e.to_string()))
249        })?;
250
251    let rumor: UnsignedEvent = UnsignedEvent::from_json(&rumor_json).map_err(|e| {
252        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
253            "malformed rumor JSON: {e}"
254        )))
255    })?;
256
257    if rumor.kind != Kind::TextNote {
258        return Err(MostroError::MostroInternalErr(
259            ServiceError::UnexpectedError("rumor is not a TextNote".to_string()),
260        ));
261    }
262
263    let (message, sig_str): (Message, Option<String>) = serde_json::from_str(&rumor.content)
264        .map_err(|_| MostroError::MostroInternalErr(ServiceError::MessageSerializationError))?;
265
266    let signature = match sig_str {
267        Some(s) => {
268            let sig = Signature::from_str(&s).map_err(|e| {
269                MostroError::MostroInternalErr(ServiceError::UnexpectedError(format!(
270                    "malformed rumor signature: {e}"
271                )))
272            })?;
273            let message_json = message.as_json().map_err(MostroError::MostroInternalErr)?;
274            if !Message::verify_signature(message_json, rumor.pubkey, sig) {
275                return Err(MostroError::MostroInternalErr(
276                    ServiceError::UnexpectedError(
277                        "rumor signature does not verify against sender".to_string(),
278                    ),
279                ));
280            }
281            Some(sig)
282        }
283        None => None,
284    };
285
286    Ok(Some(UnwrappedMessage {
287        message,
288        signature,
289        sender: rumor.pubkey,
290        identity: seal.pubkey,
291        created_at: rumor.created_at,
292    }))
293}
294
295/// Validate a response received from a Mostro node.
296///
297/// * Returns `Err(MostroCantDo(reason))` when the payload is `CantDo`.
298/// * Returns `Err(MostroInternalErr(...))` when `expected_request_id` is
299///   provided and the inner message carries a different id, or no id at all
300///   on an action that requires one.
301/// * Otherwise returns `Ok(())`.
302///
303/// The allow-list of actions that may arrive without a `request_id` (server
304/// push messages such as state transitions, DMs, payment failures, etc.) is
305/// intentionally kept on the caller side, because the exact set depends on
306/// the client flow; this function only enforces the universal rules.
307pub fn validate_response(
308    message: &Message,
309    expected_request_id: Option<u64>,
310) -> Result<(), MostroError> {
311    let inner = message.get_inner_message_kind();
312
313    if let Some(Payload::CantDo(reason)) = &inner.payload {
314        return Err(MostroError::MostroCantDo(
315            reason.clone().unwrap_or(CantDoReason::InvalidAction),
316        ));
317    }
318
319    if let Some(expected) = expected_request_id {
320        match inner.request_id {
321            Some(got) if got == expected => {}
322            Some(_) => {
323                return Err(MostroError::MostroInternalErr(
324                    ServiceError::UnexpectedError("mismatched request_id".to_string()),
325                ));
326            }
327            None => {
328                if !action_accepts_missing_request_id(&inner.action) {
329                    return Err(MostroError::MostroInternalErr(
330                        ServiceError::UnexpectedError(
331                            "missing request_id on a response that requires one".to_string(),
332                        ),
333                    ));
334                }
335            }
336        }
337    }
338
339    Ok(())
340}
341
342/// Actions that may legitimately arrive without a `request_id` even when the
343/// caller was waiting on one (unsolicited server-initiated events).
344fn action_accepts_missing_request_id(action: &Action) -> bool {
345    matches!(
346        action,
347        Action::BuyerTookOrder
348            | Action::HoldInvoicePaymentAccepted
349            | Action::HoldInvoicePaymentSettled
350            | Action::HoldInvoicePaymentCanceled
351            | Action::WaitingSellerToPay
352            | Action::WaitingBuyerInvoice
353            | Action::BuyerInvoiceAccepted
354            | Action::PurchaseCompleted
355            | Action::Released
356            | Action::FiatSentOk
357            | Action::Canceled
358            | Action::CooperativeCancelInitiatedByPeer
359            | Action::CooperativeCancelAccepted
360            | Action::DisputeInitiatedByPeer
361            | Action::AdminSettled
362            | Action::AdminCanceled
363            | Action::AdminTookDispute
364            | Action::PaymentFailed
365            | Action::InvoiceUpdated
366            | Action::Rate
367            | Action::RateReceived
368            | Action::SendDm
369            | Action::BondSlashed
370            | Action::CashuEscrowLocked
371            | Action::CashuPmSignature
372    )
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::message::{Action, MessageKind, Payload};
379    use uuid::uuid;
380
381    fn sample_order_message(request_id: Option<u64>) -> Message {
382        let peer = crate::message::Peer::new(
383            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
384            None,
385        );
386        Message::Order(MessageKind::new(
387            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
388            request_id,
389            Some(1),
390            Action::FiatSentOk,
391            Some(Payload::Peer(peer)),
392        ))
393    }
394
395    #[tokio::test]
396    async fn wrap_then_unwrap_roundtrip() {
397        let identity_keys = Keys::generate();
398        let trade_keys = Keys::generate();
399        let receiver_keys = Keys::generate();
400
401        let message = sample_order_message(Some(42));
402
403        let wrapped = wrap_message(
404            &message,
405            &identity_keys,
406            &trade_keys,
407            receiver_keys.public_key(),
408            WrapOptions::default(),
409        )
410        .await
411        .expect("wrap");
412
413        assert_eq!(wrapped.kind, Kind::GiftWrap);
414        assert!(wrapped
415            .tags
416            .iter()
417            .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p")));
418
419        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
420            .await
421            .expect("unwrap result")
422            .expect("unwrap some");
423
424        assert_eq!(unwrapped.sender, trade_keys.public_key());
425        assert_eq!(unwrapped.identity, identity_keys.public_key());
426        assert_eq!(
427            unwrapped.message.as_json().unwrap(),
428            message.as_json().unwrap()
429        );
430        assert!(unwrapped.signature.is_some());
431    }
432
433    // Guards the `ensure_id()` call in `wrap_message`: our own unwrap
434    // tolerates an id-less rumor, but strict NIP-59 clients reject it,
435    // so the wire format must always carry the id.
436    #[tokio::test]
437    async fn rumor_id_serialized_inside_wrap() {
438        let identity_keys = Keys::generate();
439        let trade_keys = Keys::generate();
440        let receiver_keys = Keys::generate();
441
442        let wrapped = wrap_message(
443            &sample_order_message(Some(42)),
444            &identity_keys,
445            &trade_keys,
446            receiver_keys.public_key(),
447            WrapOptions::default(),
448        )
449        .await
450        .expect("wrap");
451
452        // Unwrap by hand to inspect the raw rumor JSON as a receiver sees it.
453        let seal_json = nip44::decrypt(
454            receiver_keys.secret_key(),
455            &wrapped.pubkey,
456            &wrapped.content,
457        )
458        .expect("decrypt wrap");
459        let seal: Event = Event::from_json(&seal_json).expect("parse seal");
460        let rumor_json = nip44::decrypt(receiver_keys.secret_key(), &seal.pubkey, &seal.content)
461            .expect("decrypt seal");
462
463        let rumor: UnsignedEvent = UnsignedEvent::from_json(&rumor_json).expect("parse rumor");
464        assert_eq!(rumor.id, Some(rumor.compute_id()));
465    }
466
467    #[tokio::test]
468    async fn full_privacy_mode_identity_equals_sender() {
469        // Caller that opts out of reputation passes trade_keys as identity.
470        let trade_keys = Keys::generate();
471        let receiver_keys = Keys::generate();
472
473        let wrapped = wrap_message(
474            &sample_order_message(Some(1)),
475            &trade_keys,
476            &trade_keys,
477            receiver_keys.public_key(),
478            WrapOptions::default(),
479        )
480        .await
481        .expect("wrap");
482
483        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
484            .await
485            .expect("unwrap")
486            .expect("some");
487
488        assert_eq!(unwrapped.sender, trade_keys.public_key());
489        assert_eq!(unwrapped.identity, trade_keys.public_key());
490    }
491
492    #[tokio::test]
493    async fn unwrap_with_corrupted_seal_returns_err() {
494        let receiver_keys = Keys::generate();
495        let ephemeral = Keys::generate();
496
497        // GiftWrap whose outer ciphertext decrypts successfully but yields
498        // a string that is not a valid seal Event JSON. Must surface as
499        // Err, not be silently absorbed as Ok(None).
500        let encrypted = nip44::encrypt(
501            ephemeral.secret_key(),
502            &receiver_keys.public_key(),
503            "not a seal",
504            nip44::Version::default(),
505        )
506        .expect("encrypt");
507
508        let corrupted = EventBuilder::new(Kind::GiftWrap, encrypted)
509            .tags([Tag::public_key(receiver_keys.public_key())])
510            .finalize(&ephemeral)
511            .expect("sign");
512
513        let result = unwrap_message(&corrupted, &receiver_keys).await;
514        assert!(
515            matches!(result, Err(MostroError::MostroInternalErr(_))),
516            "expected Err for corrupted gift wrap, got {result:?}",
517        );
518    }
519
520    // Build a GiftWrap by hand with a custom inner rumor tuple so tests
521    // can inject a malformed or wrong-signature payload that `wrap_message`
522    // would never emit.
523    async fn wrap_with_raw_inner(
524        identity_keys: &Keys,
525        trade_keys: &Keys,
526        receiver: PublicKey,
527        inner: (&Message, Option<String>),
528    ) -> Event {
529        let content = serde_json::to_string(&inner).unwrap();
530        let mut rumor =
531            EventBuilder::new(Kind::TextNote, content).finalize_unsigned(trade_keys.public_key());
532        // Mirror wrap_message so test wraps stay representative of production
533        // ones (see the rumor id note there).
534        rumor.ensure_id();
535        let seal = GiftWrapSealBuilder::new(rumor, receiver)
536            .finalize(identity_keys)
537            .unwrap();
538        gift_wrap_from_seal_with_pow(&seal, receiver, 0, None).unwrap()
539    }
540
541    #[tokio::test]
542    async fn unwrap_with_malformed_signature_errors() {
543        let identity_keys = Keys::generate();
544        let trade_keys = Keys::generate();
545        let receiver_keys = Keys::generate();
546        let msg = sample_order_message(Some(1));
547
548        let wrapped = wrap_with_raw_inner(
549            &identity_keys,
550            &trade_keys,
551            receiver_keys.public_key(),
552            (&msg, Some("not-a-hex-signature".to_string())),
553        )
554        .await;
555
556        let result = unwrap_message(&wrapped, &receiver_keys).await;
557        assert!(
558            matches!(result, Err(MostroError::MostroInternalErr(_))),
559            "malformed signature must surface as Err, got {result:?}",
560        );
561    }
562
563    #[tokio::test]
564    async fn unwrap_with_signature_for_other_content_errors() {
565        let identity_keys = Keys::generate();
566        let trade_keys = Keys::generate();
567        let receiver_keys = Keys::generate();
568        let msg = sample_order_message(Some(1));
569        // Well-formed signature, but over a completely different payload.
570        let bogus = Message::sign("not the real message".to_string(), &trade_keys);
571
572        let wrapped = wrap_with_raw_inner(
573            &identity_keys,
574            &trade_keys,
575            receiver_keys.public_key(),
576            (&msg, Some(bogus.to_string())),
577        )
578        .await;
579
580        let result = unwrap_message(&wrapped, &receiver_keys).await;
581        assert!(
582            matches!(result, Err(MostroError::MostroInternalErr(_))),
583            "non-verifying signature must surface as Err, got {result:?}",
584        );
585    }
586
587    #[tokio::test]
588    async fn unwrap_with_wrong_receiver_keys_returns_none() {
589        let identity_keys = Keys::generate();
590        let trade_keys = Keys::generate();
591        let receiver_keys = Keys::generate();
592        let stranger_keys = Keys::generate();
593
594        let wrapped = wrap_message(
595            &sample_order_message(Some(1)),
596            &identity_keys,
597            &trade_keys,
598            receiver_keys.public_key(),
599            WrapOptions::default(),
600        )
601        .await
602        .expect("wrap");
603
604        let result = unwrap_message(&wrapped, &stranger_keys)
605            .await
606            .expect("call should not error");
607        assert!(result.is_none());
608    }
609
610    #[tokio::test]
611    async fn signature_is_verifiable_with_trade_pubkey() {
612        let identity_keys = Keys::generate();
613        let trade_keys = Keys::generate();
614        let receiver_keys = Keys::generate();
615        let message = sample_order_message(Some(7));
616
617        let wrapped = wrap_message(
618            &message,
619            &identity_keys,
620            &trade_keys,
621            receiver_keys.public_key(),
622            WrapOptions::default(),
623        )
624        .await
625        .unwrap();
626
627        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
628            .await
629            .unwrap()
630            .unwrap();
631
632        let sig = unwrapped.signature.expect("signed");
633        let json = unwrapped.message.as_json().unwrap();
634        assert!(Message::verify_signature(
635            json,
636            trade_keys.public_key(),
637            sig
638        ));
639    }
640
641    #[tokio::test]
642    async fn unsigned_wrap_has_no_signature() {
643        let identity_keys = Keys::generate();
644        let trade_keys = Keys::generate();
645        let receiver_keys = Keys::generate();
646
647        let wrapped = wrap_message(
648            &sample_order_message(Some(3)),
649            &identity_keys,
650            &trade_keys,
651            receiver_keys.public_key(),
652            WrapOptions {
653                signed: false,
654                ..WrapOptions::default()
655            },
656        )
657        .await
658        .expect("wrap");
659
660        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
661            .await
662            .unwrap()
663            .unwrap();
664        assert!(unwrapped.signature.is_none());
665    }
666
667    #[tokio::test]
668    async fn expiration_tag_is_set_when_provided() {
669        let identity_keys = Keys::generate();
670        let trade_keys = Keys::generate();
671        let receiver_keys = Keys::generate();
672        let exp = Timestamp::from_secs(Timestamp::now().as_secs() + 3600);
673
674        let wrapped = wrap_message(
675            &sample_order_message(Some(1)),
676            &identity_keys,
677            &trade_keys,
678            receiver_keys.public_key(),
679            WrapOptions {
680                expiration: Some(exp),
681                ..WrapOptions::default()
682            },
683        )
684        .await
685        .expect("wrap");
686
687        let has_expiration = wrapped
688            .tags
689            .iter()
690            .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("expiration"));
691        assert!(has_expiration);
692    }
693
694    #[test]
695    fn validate_response_cant_do_short_circuits() {
696        let msg = Message::cant_do(
697            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
698            Some(5),
699            Some(Payload::CantDo(Some(CantDoReason::NotAuthorized))),
700        );
701        let err = validate_response(&msg, Some(5)).unwrap_err();
702        match err {
703            MostroError::MostroCantDo(CantDoReason::NotAuthorized) => {}
704            _ => panic!("expected CantDo(NotAuthorized)"),
705        }
706    }
707
708    #[test]
709    fn validate_response_request_id_match() {
710        let msg = sample_order_message(Some(9));
711        validate_response(&msg, Some(9)).unwrap();
712    }
713
714    #[test]
715    fn validate_response_request_id_mismatch_errors() {
716        let msg = sample_order_message(Some(9));
717        let err = validate_response(&msg, Some(10)).unwrap_err();
718        assert!(matches!(err, MostroError::MostroInternalErr(_)));
719    }
720
721    #[test]
722    fn validate_response_allows_unsolicited_actions_without_request_id() {
723        let msg = Message::Order(MessageKind::new(
724            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
725            None,
726            None,
727            Action::BuyerTookOrder,
728            None,
729        ));
730        validate_response(&msg, Some(1)).unwrap();
731    }
732
733    #[test]
734    fn validate_response_with_no_expected_id_is_ok() {
735        let msg = sample_order_message(None);
736        validate_response(&msg, None).unwrap();
737    }
738
739    #[test]
740    fn validate_response_allows_cashu_server_events_without_request_id() {
741        // Both Cashu notifications are server-originated and may arrive while
742        // the client is still waiting on an earlier request_id.
743        for action in [Action::CashuEscrowLocked, Action::CashuPmSignature] {
744            let msg = Message::Order(MessageKind::new(
745                Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
746                None,
747                None,
748                action,
749                None,
750            ));
751            validate_response(&msg, Some(1)).unwrap();
752        }
753    }
754}