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 rumor =
115        EventBuilder::new(Kind::TextNote, content).finalize_unsigned(trade_keys.public_key());
116
117    // Seal is encrypted and signed with identity_keys so the receiver can
118    // decrypt it via (receiver_secret, seal.pubkey) — this keeps seal.pubkey
119    // consistent with the encryption key, while leaving rumor.pubkey free to
120    // carry the per-trade key (the mismatch standard NIP-59 rejects).
121    let seal: Event = GiftWrapSealBuilder::new(rumor, receiver)
122        .finalize(identity_keys)
123        .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?;
124
125    gift_wrap_from_seal_with_pow(&seal, receiver, opts.pow, opts.expiration)
126}
127
128/// Wrap an already built Seal into a NIP-59 GiftWrap with optional PoW and
129/// expiration. The outer event is signed with a freshly generated ephemeral
130/// key and carries a mandatory `p` tag pointing at `receiver`.
131///
132/// PoW (`pow > 0`) is applied with `UnsignedEvent::mine(&SingleThreadPow, …)`
133/// before `finalize`; `created_at` uses the local [`tweaked_timestamp`] helper
134/// (nostr 0.45 made `Timestamp::tweaked` / the NIP-59 range private).
135fn gift_wrap_from_seal_with_pow(
136    seal: &Event,
137    receiver: PublicKey,
138    pow: u8,
139    expiration: Option<Timestamp>,
140) -> Result<Event, MostroError> {
141    if seal.kind != Kind::Seal {
142        return Err(MostroError::MostroInternalErr(
143            ServiceError::UnexpectedError("expected Seal kind".to_string()),
144        ));
145    }
146
147    let ephemeral = Keys::generate();
148    let encrypted = nip44::encrypt(
149        ephemeral.secret_key(),
150        &receiver,
151        seal.as_json(),
152        nip44::Version::default(),
153    )
154    .map_err(|e| MostroError::MostroInternalErr(ServiceError::EncryptionError(e.to_string())))?;
155
156    let mut tags: Vec<Tag> = Vec::new();
157    if let Some(exp) = expiration {
158        tags.push(Tag::expiration(exp));
159    }
160    tags.push(Tag::public_key(receiver));
161
162    let unsigned = EventBuilder::new(Kind::GiftWrap, encrypted)
163        .tags(tags)
164        .custom_created_at(tweaked_timestamp())
165        .finalize_unsigned(ephemeral.public_key());
166
167    let unsigned = match core::num::NonZeroU8::new(pow) {
168        Some(pow) => unsigned
169            .mine(&SingleThreadPow, pow)
170            .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))?,
171        None => unsigned,
172    };
173
174    unsigned
175        .finalize(&ephemeral)
176        .map_err(|e| MostroError::MostroInternalErr(ServiceError::NostrError(e.to_string())))
177}
178
179/// Subtract a random offset in `0..RANGE_RANDOM_TIMESTAMP_TWEAK_SECS` from now.
180fn tweaked_timestamp() -> Timestamp {
181    let now = Timestamp::now().as_secs();
182    let entropy = Keys::generate();
183    let bytes = entropy.secret_key().to_secret_bytes();
184    let tweak = u64::from_le_bytes(bytes[0..8].try_into().expect("8 bytes"))
185        % RANGE_RANDOM_TIMESTAMP_TWEAK_SECS;
186    Timestamp::from_secs(now.saturating_sub(tweak))
187}
188
189/// Try to open an incoming GiftWrap with the given `receiver_keys`.
190///
191/// Returns `Ok(None)` only when the outer NIP-44 layer could not be
192/// decrypted with `receiver_keys` — the canonical "not addressed to me"
193/// signal, so callers can try multiple candidate keys without treating
194/// each miss as fatal. Every other failure (corrupted seal, malformed
195/// rumor JSON, invalid signatures, etc.) yields `Err` so callers can tell
196/// "not mine" apart from "broken".
197///
198/// Does **not** enforce `seal.pubkey == rumor.pubkey`: Mostro signs the
199/// seal with the identity key and authors the rumor with the per-trade
200/// key, so the two legitimately differ.
201pub async fn unwrap_message(
202    event: &Event,
203    receiver_keys: &Keys,
204) -> Result<Option<UnwrappedMessage>, MostroError> {
205    if event.kind != Kind::GiftWrap {
206        return Err(MostroError::MostroInternalErr(
207            ServiceError::UnexpectedError("event is not a GiftWrap".to_string()),
208        ));
209    }
210
211    // Decrypt outer GiftWrap using (receiver_secret, ephemeral_pub).
212    // Failure here is the "not addressed to me" signal.
213    let seal_json = match nip44::decrypt(receiver_keys.secret_key(), &event.pubkey, &event.content)
214    {
215        Ok(s) => s,
216        Err(_) => return Ok(None),
217    };
218
219    let seal: Event = Event::from_json(&seal_json).map_err(|e| {
220        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
221            "malformed seal JSON: {e}"
222        )))
223    })?;
224
225    if seal.kind != Kind::Seal {
226        return Err(MostroError::MostroInternalErr(
227            ServiceError::UnexpectedError("inner event is not a Seal".to_string()),
228        ));
229    }
230
231    seal.verify_signature().then_some(()).ok_or_else(|| {
232        MostroError::MostroInternalErr(ServiceError::NostrError(
233            "invalid seal signature".to_string(),
234        ))
235    })?;
236
237    // Decrypt the seal content using (receiver_secret, seal.pubkey). In
238    // Mostro, seal.pubkey is the sender's identity key, which is also the
239    // key that performed the NIP-44 encryption in `wrap_message`.
240    let rumor_json = nip44::decrypt(receiver_keys.secret_key(), &seal.pubkey, &seal.content)
241        .map_err(|e| {
242            MostroError::MostroInternalErr(ServiceError::DecryptionError(e.to_string()))
243        })?;
244
245    let rumor: UnsignedEvent = UnsignedEvent::from_json(&rumor_json).map_err(|e| {
246        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
247            "malformed rumor JSON: {e}"
248        )))
249    })?;
250
251    if rumor.kind != Kind::TextNote {
252        return Err(MostroError::MostroInternalErr(
253            ServiceError::UnexpectedError("rumor is not a TextNote".to_string()),
254        ));
255    }
256
257    let (message, sig_str): (Message, Option<String>) = serde_json::from_str(&rumor.content)
258        .map_err(|_| MostroError::MostroInternalErr(ServiceError::MessageSerializationError))?;
259
260    let signature = match sig_str {
261        Some(s) => {
262            let sig = Signature::from_str(&s).map_err(|e| {
263                MostroError::MostroInternalErr(ServiceError::UnexpectedError(format!(
264                    "malformed rumor signature: {e}"
265                )))
266            })?;
267            let message_json = message.as_json().map_err(MostroError::MostroInternalErr)?;
268            if !Message::verify_signature(message_json, rumor.pubkey, sig) {
269                return Err(MostroError::MostroInternalErr(
270                    ServiceError::UnexpectedError(
271                        "rumor signature does not verify against sender".to_string(),
272                    ),
273                ));
274            }
275            Some(sig)
276        }
277        None => None,
278    };
279
280    Ok(Some(UnwrappedMessage {
281        message,
282        signature,
283        sender: rumor.pubkey,
284        identity: seal.pubkey,
285        created_at: rumor.created_at,
286    }))
287}
288
289/// Validate a response received from a Mostro node.
290///
291/// * Returns `Err(MostroCantDo(reason))` when the payload is `CantDo`.
292/// * Returns `Err(MostroInternalErr(...))` when `expected_request_id` is
293///   provided and the inner message carries a different id, or no id at all
294///   on an action that requires one.
295/// * Otherwise returns `Ok(())`.
296///
297/// The allow-list of actions that may arrive without a `request_id` (server
298/// push messages such as state transitions, DMs, payment failures, etc.) is
299/// intentionally kept on the caller side, because the exact set depends on
300/// the client flow; this function only enforces the universal rules.
301pub fn validate_response(
302    message: &Message,
303    expected_request_id: Option<u64>,
304) -> Result<(), MostroError> {
305    let inner = message.get_inner_message_kind();
306
307    if let Some(Payload::CantDo(reason)) = &inner.payload {
308        return Err(MostroError::MostroCantDo(
309            reason.clone().unwrap_or(CantDoReason::InvalidAction),
310        ));
311    }
312
313    if let Some(expected) = expected_request_id {
314        match inner.request_id {
315            Some(got) if got == expected => {}
316            Some(_) => {
317                return Err(MostroError::MostroInternalErr(
318                    ServiceError::UnexpectedError("mismatched request_id".to_string()),
319                ));
320            }
321            None => {
322                if !action_accepts_missing_request_id(&inner.action) {
323                    return Err(MostroError::MostroInternalErr(
324                        ServiceError::UnexpectedError(
325                            "missing request_id on a response that requires one".to_string(),
326                        ),
327                    ));
328                }
329            }
330        }
331    }
332
333    Ok(())
334}
335
336/// Actions that may legitimately arrive without a `request_id` even when the
337/// caller was waiting on one (unsolicited server-initiated events).
338fn action_accepts_missing_request_id(action: &Action) -> bool {
339    matches!(
340        action,
341        Action::BuyerTookOrder
342            | Action::HoldInvoicePaymentAccepted
343            | Action::HoldInvoicePaymentSettled
344            | Action::HoldInvoicePaymentCanceled
345            | Action::WaitingSellerToPay
346            | Action::WaitingBuyerInvoice
347            | Action::BuyerInvoiceAccepted
348            | Action::PurchaseCompleted
349            | Action::Released
350            | Action::FiatSentOk
351            | Action::Canceled
352            | Action::CooperativeCancelInitiatedByPeer
353            | Action::CooperativeCancelAccepted
354            | Action::DisputeInitiatedByPeer
355            | Action::AdminSettled
356            | Action::AdminCanceled
357            | Action::AdminTookDispute
358            | Action::PaymentFailed
359            | Action::InvoiceUpdated
360            | Action::Rate
361            | Action::RateReceived
362            | Action::SendDm
363            | Action::BondSlashed
364            | Action::CashuEscrowLocked
365            | Action::CashuPmSignature
366    )
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::message::{Action, MessageKind, Payload};
373    use uuid::uuid;
374
375    fn sample_order_message(request_id: Option<u64>) -> Message {
376        let peer = crate::message::Peer::new(
377            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
378            None,
379        );
380        Message::Order(MessageKind::new(
381            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
382            request_id,
383            Some(1),
384            Action::FiatSentOk,
385            Some(Payload::Peer(peer)),
386        ))
387    }
388
389    #[tokio::test]
390    async fn wrap_then_unwrap_roundtrip() {
391        let identity_keys = Keys::generate();
392        let trade_keys = Keys::generate();
393        let receiver_keys = Keys::generate();
394
395        let message = sample_order_message(Some(42));
396
397        let wrapped = wrap_message(
398            &message,
399            &identity_keys,
400            &trade_keys,
401            receiver_keys.public_key(),
402            WrapOptions::default(),
403        )
404        .await
405        .expect("wrap");
406
407        assert_eq!(wrapped.kind, Kind::GiftWrap);
408        assert!(wrapped
409            .tags
410            .iter()
411            .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p")));
412
413        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
414            .await
415            .expect("unwrap result")
416            .expect("unwrap some");
417
418        assert_eq!(unwrapped.sender, trade_keys.public_key());
419        assert_eq!(unwrapped.identity, identity_keys.public_key());
420        assert_eq!(
421            unwrapped.message.as_json().unwrap(),
422            message.as_json().unwrap()
423        );
424        assert!(unwrapped.signature.is_some());
425    }
426
427    #[tokio::test]
428    async fn full_privacy_mode_identity_equals_sender() {
429        // Caller that opts out of reputation passes trade_keys as identity.
430        let trade_keys = Keys::generate();
431        let receiver_keys = Keys::generate();
432
433        let wrapped = wrap_message(
434            &sample_order_message(Some(1)),
435            &trade_keys,
436            &trade_keys,
437            receiver_keys.public_key(),
438            WrapOptions::default(),
439        )
440        .await
441        .expect("wrap");
442
443        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
444            .await
445            .expect("unwrap")
446            .expect("some");
447
448        assert_eq!(unwrapped.sender, trade_keys.public_key());
449        assert_eq!(unwrapped.identity, trade_keys.public_key());
450    }
451
452    #[tokio::test]
453    async fn unwrap_with_corrupted_seal_returns_err() {
454        let receiver_keys = Keys::generate();
455        let ephemeral = Keys::generate();
456
457        // GiftWrap whose outer ciphertext decrypts successfully but yields
458        // a string that is not a valid seal Event JSON. Must surface as
459        // Err, not be silently absorbed as Ok(None).
460        let encrypted = nip44::encrypt(
461            ephemeral.secret_key(),
462            &receiver_keys.public_key(),
463            "not a seal",
464            nip44::Version::default(),
465        )
466        .expect("encrypt");
467
468        let corrupted = EventBuilder::new(Kind::GiftWrap, encrypted)
469            .tags([Tag::public_key(receiver_keys.public_key())])
470            .finalize(&ephemeral)
471            .expect("sign");
472
473        let result = unwrap_message(&corrupted, &receiver_keys).await;
474        assert!(
475            matches!(result, Err(MostroError::MostroInternalErr(_))),
476            "expected Err for corrupted gift wrap, got {result:?}",
477        );
478    }
479
480    // Build a GiftWrap by hand with a custom inner rumor tuple so tests
481    // can inject a malformed or wrong-signature payload that `wrap_message`
482    // would never emit.
483    async fn wrap_with_raw_inner(
484        identity_keys: &Keys,
485        trade_keys: &Keys,
486        receiver: PublicKey,
487        inner: (&Message, Option<String>),
488    ) -> Event {
489        let content = serde_json::to_string(&inner).unwrap();
490        let rumor =
491            EventBuilder::new(Kind::TextNote, content).finalize_unsigned(trade_keys.public_key());
492        let seal = GiftWrapSealBuilder::new(rumor, receiver)
493            .finalize(identity_keys)
494            .unwrap();
495        gift_wrap_from_seal_with_pow(&seal, receiver, 0, None).unwrap()
496    }
497
498    #[tokio::test]
499    async fn unwrap_with_malformed_signature_errors() {
500        let identity_keys = Keys::generate();
501        let trade_keys = Keys::generate();
502        let receiver_keys = Keys::generate();
503        let msg = sample_order_message(Some(1));
504
505        let wrapped = wrap_with_raw_inner(
506            &identity_keys,
507            &trade_keys,
508            receiver_keys.public_key(),
509            (&msg, Some("not-a-hex-signature".to_string())),
510        )
511        .await;
512
513        let result = unwrap_message(&wrapped, &receiver_keys).await;
514        assert!(
515            matches!(result, Err(MostroError::MostroInternalErr(_))),
516            "malformed signature must surface as Err, got {result:?}",
517        );
518    }
519
520    #[tokio::test]
521    async fn unwrap_with_signature_for_other_content_errors() {
522        let identity_keys = Keys::generate();
523        let trade_keys = Keys::generate();
524        let receiver_keys = Keys::generate();
525        let msg = sample_order_message(Some(1));
526        // Well-formed signature, but over a completely different payload.
527        let bogus = Message::sign("not the real message".to_string(), &trade_keys);
528
529        let wrapped = wrap_with_raw_inner(
530            &identity_keys,
531            &trade_keys,
532            receiver_keys.public_key(),
533            (&msg, Some(bogus.to_string())),
534        )
535        .await;
536
537        let result = unwrap_message(&wrapped, &receiver_keys).await;
538        assert!(
539            matches!(result, Err(MostroError::MostroInternalErr(_))),
540            "non-verifying signature must surface as Err, got {result:?}",
541        );
542    }
543
544    #[tokio::test]
545    async fn unwrap_with_wrong_receiver_keys_returns_none() {
546        let identity_keys = Keys::generate();
547        let trade_keys = Keys::generate();
548        let receiver_keys = Keys::generate();
549        let stranger_keys = Keys::generate();
550
551        let wrapped = wrap_message(
552            &sample_order_message(Some(1)),
553            &identity_keys,
554            &trade_keys,
555            receiver_keys.public_key(),
556            WrapOptions::default(),
557        )
558        .await
559        .expect("wrap");
560
561        let result = unwrap_message(&wrapped, &stranger_keys)
562            .await
563            .expect("call should not error");
564        assert!(result.is_none());
565    }
566
567    #[tokio::test]
568    async fn signature_is_verifiable_with_trade_pubkey() {
569        let identity_keys = Keys::generate();
570        let trade_keys = Keys::generate();
571        let receiver_keys = Keys::generate();
572        let message = sample_order_message(Some(7));
573
574        let wrapped = wrap_message(
575            &message,
576            &identity_keys,
577            &trade_keys,
578            receiver_keys.public_key(),
579            WrapOptions::default(),
580        )
581        .await
582        .unwrap();
583
584        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
585            .await
586            .unwrap()
587            .unwrap();
588
589        let sig = unwrapped.signature.expect("signed");
590        let json = unwrapped.message.as_json().unwrap();
591        assert!(Message::verify_signature(
592            json,
593            trade_keys.public_key(),
594            sig
595        ));
596    }
597
598    #[tokio::test]
599    async fn unsigned_wrap_has_no_signature() {
600        let identity_keys = Keys::generate();
601        let trade_keys = Keys::generate();
602        let receiver_keys = Keys::generate();
603
604        let wrapped = wrap_message(
605            &sample_order_message(Some(3)),
606            &identity_keys,
607            &trade_keys,
608            receiver_keys.public_key(),
609            WrapOptions {
610                signed: false,
611                ..WrapOptions::default()
612            },
613        )
614        .await
615        .expect("wrap");
616
617        let unwrapped = unwrap_message(&wrapped, &receiver_keys)
618            .await
619            .unwrap()
620            .unwrap();
621        assert!(unwrapped.signature.is_none());
622    }
623
624    #[tokio::test]
625    async fn expiration_tag_is_set_when_provided() {
626        let identity_keys = Keys::generate();
627        let trade_keys = Keys::generate();
628        let receiver_keys = Keys::generate();
629        let exp = Timestamp::from_secs(Timestamp::now().as_secs() + 3600);
630
631        let wrapped = wrap_message(
632            &sample_order_message(Some(1)),
633            &identity_keys,
634            &trade_keys,
635            receiver_keys.public_key(),
636            WrapOptions {
637                expiration: Some(exp),
638                ..WrapOptions::default()
639            },
640        )
641        .await
642        .expect("wrap");
643
644        let has_expiration = wrapped
645            .tags
646            .iter()
647            .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("expiration"));
648        assert!(has_expiration);
649    }
650
651    #[test]
652    fn validate_response_cant_do_short_circuits() {
653        let msg = Message::cant_do(
654            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
655            Some(5),
656            Some(Payload::CantDo(Some(CantDoReason::NotAuthorized))),
657        );
658        let err = validate_response(&msg, Some(5)).unwrap_err();
659        match err {
660            MostroError::MostroCantDo(CantDoReason::NotAuthorized) => {}
661            _ => panic!("expected CantDo(NotAuthorized)"),
662        }
663    }
664
665    #[test]
666    fn validate_response_request_id_match() {
667        let msg = sample_order_message(Some(9));
668        validate_response(&msg, Some(9)).unwrap();
669    }
670
671    #[test]
672    fn validate_response_request_id_mismatch_errors() {
673        let msg = sample_order_message(Some(9));
674        let err = validate_response(&msg, Some(10)).unwrap_err();
675        assert!(matches!(err, MostroError::MostroInternalErr(_)));
676    }
677
678    #[test]
679    fn validate_response_allows_unsolicited_actions_without_request_id() {
680        let msg = Message::Order(MessageKind::new(
681            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
682            None,
683            None,
684            Action::BuyerTookOrder,
685            None,
686        ));
687        validate_response(&msg, Some(1)).unwrap();
688    }
689
690    #[test]
691    fn validate_response_with_no_expected_id_is_ok() {
692        let msg = sample_order_message(None);
693        validate_response(&msg, None).unwrap();
694    }
695
696    #[test]
697    fn validate_response_allows_cashu_server_events_without_request_id() {
698        // Both Cashu notifications are server-originated and may arrive while
699        // the client is still waiting on an earlier request_id.
700        for action in [Action::CashuEscrowLocked, Action::CashuPmSignature] {
701            let msg = Message::Order(MessageKind::new(
702                Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
703                None,
704                None,
705                action,
706                None,
707            ));
708            validate_response(&msg, Some(1)).unwrap();
709        }
710    }
711}