Skip to main content

mostro_core/
response.rs

1//! Validation of the responses a client receives from a Mostro node.
2
3use crate::message::{Action, Message, Payload};
4use crate::prelude::{CantDoReason, MostroError, ServiceError};
5
6/// Validate a response received from a Mostro node.
7///
8/// * Returns `Err(MostroCantDo(reason))` when the payload is `CantDo`.
9/// * Returns `Err(MostroInternalErr(...))` when `expected_request_id` is
10///   provided and the inner message carries a different id, or no id at all
11///   on an action that requires one.
12/// * Otherwise returns `Ok(())`.
13///
14/// The allow-list of actions that may arrive without a `request_id` (server
15/// push messages such as state transitions, DMs, payment failures, etc.) is
16/// intentionally kept on the caller side, because the exact set depends on
17/// the client flow; this function only enforces the universal rules.
18pub fn validate_response(
19    message: &Message,
20    expected_request_id: Option<u64>,
21) -> Result<(), MostroError> {
22    let inner = message.get_inner_message_kind();
23
24    if let Some(Payload::CantDo(reason)) = &inner.payload {
25        return Err(MostroError::MostroCantDo(
26            reason.clone().unwrap_or(CantDoReason::InvalidAction),
27        ));
28    }
29
30    if let Some(expected) = expected_request_id {
31        match inner.request_id {
32            Some(got) if got == expected => {}
33            Some(_) => {
34                return Err(MostroError::MostroInternalErr(
35                    ServiceError::UnexpectedError("mismatched request_id".to_string()),
36                ));
37            }
38            None => {
39                if !action_accepts_missing_request_id(&inner.action) {
40                    return Err(MostroError::MostroInternalErr(
41                        ServiceError::UnexpectedError(
42                            "missing request_id on a response that requires one".to_string(),
43                        ),
44                    ));
45                }
46            }
47        }
48    }
49
50    Ok(())
51}
52
53/// Actions that may legitimately arrive without a `request_id` even when the
54/// caller was waiting on one (unsolicited server-initiated events).
55fn action_accepts_missing_request_id(action: &Action) -> bool {
56    matches!(
57        action,
58        Action::BuyerTookOrder
59            | Action::HoldInvoicePaymentAccepted
60            | Action::HoldInvoicePaymentSettled
61            | Action::HoldInvoicePaymentCanceled
62            | Action::WaitingSellerToPay
63            | Action::WaitingBuyerInvoice
64            | Action::BuyerInvoiceAccepted
65            | Action::PurchaseCompleted
66            | Action::Released
67            | Action::FiatSentOk
68            | Action::Canceled
69            | Action::CooperativeCancelInitiatedByPeer
70            | Action::CooperativeCancelAccepted
71            | Action::DisputeInitiatedByPeer
72            | Action::AdminSettled
73            | Action::AdminCanceled
74            | Action::AdminTookDispute
75            | Action::PaymentFailed
76            | Action::InvoiceUpdated
77            | Action::Rate
78            | Action::RateReceived
79            | Action::SendDm
80            | Action::BondSlashed
81            | Action::CashuEscrowLocked
82            | Action::CashuPmSignature
83            | Action::PayerDeclared
84            | Action::PaymentHistory
85    )
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::message::MessageKind;
92    use uuid::uuid;
93
94    fn sample_order_message(request_id: Option<u64>) -> Message {
95        let peer = crate::message::Peer::new(
96            "npub1testjsf0runcqdht5apkfcalajxkf8txdxqqk5kgm0agc38ke4vsfsgzf8".to_string(),
97            None,
98        );
99        Message::Order(MessageKind::new(
100            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
101            request_id,
102            Some(1),
103            Action::FiatSentOk,
104            Some(Payload::Peer(peer)),
105        ))
106    }
107
108    #[test]
109    fn validate_response_cant_do_short_circuits() {
110        let msg = Message::cant_do(
111            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
112            Some(5),
113            Some(Payload::CantDo(Some(CantDoReason::NotAuthorized))),
114        );
115        let err = validate_response(&msg, Some(5)).unwrap_err();
116        match err {
117            MostroError::MostroCantDo(CantDoReason::NotAuthorized) => {}
118            _ => panic!("expected CantDo(NotAuthorized)"),
119        }
120    }
121
122    #[test]
123    fn validate_response_request_id_match() {
124        let msg = sample_order_message(Some(9));
125        validate_response(&msg, Some(9)).unwrap();
126    }
127
128    #[test]
129    fn validate_response_request_id_mismatch_errors() {
130        let msg = sample_order_message(Some(9));
131        let err = validate_response(&msg, Some(10)).unwrap_err();
132        assert!(matches!(err, MostroError::MostroInternalErr(_)));
133    }
134
135    #[test]
136    fn validate_response_allows_unsolicited_actions_without_request_id() {
137        let msg = Message::Order(MessageKind::new(
138            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
139            None,
140            None,
141            Action::BuyerTookOrder,
142            None,
143        ));
144        validate_response(&msg, Some(1)).unwrap();
145    }
146
147    #[test]
148    fn validate_response_with_no_expected_id_is_ok() {
149        let msg = sample_order_message(None);
150        validate_response(&msg, None).unwrap();
151    }
152
153    #[test]
154    fn validate_response_allows_cashu_server_events_without_request_id() {
155        // Both Cashu notifications are server-originated and may arrive while
156        // the client is still waiting on an earlier request_id.
157        for action in [Action::CashuEscrowLocked, Action::CashuPmSignature] {
158            let msg = Message::Order(MessageKind::new(
159                Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
160                None,
161                None,
162                action,
163                None,
164            ));
165            validate_response(&msg, Some(1)).unwrap();
166        }
167    }
168
169    #[test]
170    fn validate_response_allows_payer_history_pushes_without_request_id() {
171        // The seller receives the forwarded `payer-declared` and the
172        // `payment-history` push unsolicited, possibly while waiting on an
173        // unrelated request.
174        for action in [Action::PayerDeclared, Action::PaymentHistory] {
175            let msg = Message::Order(MessageKind::new(
176                Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
177                None,
178                None,
179                action,
180                None,
181            ));
182            validate_response(&msg, Some(1)).unwrap();
183        }
184    }
185
186    #[test]
187    fn validate_response_rejects_declare_payer_echo_without_request_id() {
188        // `declare-payer` is only ever sent by a client; it is never a valid
189        // unsolicited response.
190        let msg = Message::Order(MessageKind::new(
191            Some(uuid!("308e1272-d5f4-47e6-bd97-3504baea9c23")),
192            None,
193            None,
194            Action::DeclarePayer,
195            None,
196        ));
197        assert!(validate_response(&msg, Some(1)).is_err());
198    }
199}