Skip to main content

melin_server/
request_decoder.rs

1//! Trading-side [`RequestDecoder`] implementation.
2//!
3//! Owns the bytes -> `melin_protocol::Request` -> `TradingEvent`
4//! pipeline. Hides the wire enum behind the [`RequestDecoder`] trait
5//! so the server runtime never needs to pattern-match on
6//! application-shaped variants.
7
8use melin_app::auth::Permission;
9use melin_app::decoder::{Decoded, RequestDecoder as RequestDecoderTrait};
10use melin_protocol::codec;
11use melin_protocol::message::Request;
12use melin_trading::trading_event::TradingEvent;
13use melin_wire_protocol::error::ProtocolError;
14
15/// Decoder for the trading wire protocol.
16///
17/// Zero-sized. The runtime owns an `Arc<dyn RequestDecoder<...>>`;
18/// constructing one is `Arc::new(RequestDecoder)`.
19#[derive(Debug, Clone, Copy)]
20pub struct RequestDecoder;
21
22impl RequestDecoderTrait for RequestDecoder {
23    type Event = TradingEvent;
24
25    fn decode(&self, bytes: &[u8], permission: Permission) -> Decoded<TradingEvent> {
26        let (request_seq, request) = match codec::decode_request(bytes) {
27            Ok(pair) => pair,
28            Err(e) => return Decoded::DecodeError(protocol_error_reason(&e)),
29        };
30
31        if should_filter(&request) {
32            return Decoded::Filter;
33        }
34
35        if let Err(reason) = check_permission(&request, permission) {
36            return Decoded::PermissionDenied(reason);
37        }
38
39        Decoded::Permitted {
40            request_seq,
41            event: to_trading_event(&request),
42        }
43    }
44}
45
46/// Collapse a typed `ProtocolError` into the static reason carried by
47/// `Decoded::DecodeError`. The reader's debug log surfaces this
48/// reason; a misbehaving client gets diagnosed without exposing the
49/// full error chain.
50#[inline]
51fn protocol_error_reason(e: &ProtocolError) -> &'static str {
52    match e {
53        ProtocolError::Truncated => "truncated frame",
54        ProtocolError::UnknownTag(_) => "unknown variant tag",
55        ProtocolError::InvalidField(_) => "invalid field",
56        ProtocolError::MessageTooLarge(_) => "message too large",
57        ProtocolError::Io(_) => "io error",
58    }
59}
60
61/// Transport-level frames the runtime never publishes to the
62/// pipeline: heartbeats, post-auth handshakes, subscription control.
63#[inline]
64fn should_filter(request: &Request) -> bool {
65    matches!(
66        request,
67        Request::Heartbeat | Request::ChallengeResponse { .. } | Request::Subscribe { .. }
68    )
69}
70
71/// Permission model — separation of duties:
72/// - Operator: exchange configuration (instruments, risk, circuit breakers, fees, EOD, stats)
73/// - Custodian: fund management (deposit, withdraw)
74/// - Trader: order submission and cancellation
75/// - ReadOnly: rejected here for anything that isn't filtered out above
76///   (heartbeats / handshakes / subscribe never reach this function;
77///   any other request from a ReadOnly connection falls into the final
78///   clause and is denied for lacking `can_trade`).
79#[inline]
80fn check_permission(request: &Request, permission: Permission) -> Result<(), &'static str> {
81    if request.requires_operator() && !permission.is_operator() {
82        return Err("non-operator attempted operator command");
83    }
84    if request.is_fund_management() && !permission.can_manage_funds() {
85        return Err("non-custodian attempted fund management");
86    }
87    if !request.requires_operator() && !request.is_fund_management() && !permission.can_trade() {
88        return Err("connection lacks trading permission");
89    }
90    Ok(())
91}
92
93/// Per-variant `Request -> TradingEvent` mapping. Caller must have
94/// filtered transport-level frames first; this panics on heartbeats /
95/// post-auth handshakes / subscribe frames.
96#[inline]
97fn to_trading_event(request: &Request) -> TradingEvent {
98    match *request {
99        Request::SubmitOrder { symbol, order } => TradingEvent::SubmitOrder { symbol, order },
100        Request::CancelOrder {
101            symbol,
102            account,
103            order_id,
104        } => TradingEvent::CancelOrder {
105            symbol,
106            account,
107            order_id,
108        },
109        Request::CancelAll { account } => TradingEvent::CancelAll { account },
110        Request::AddInstrument { spec } => TradingEvent::AddInstrument { spec },
111        Request::Deposit {
112            account,
113            currency,
114            amount,
115        } => TradingEvent::Deposit {
116            account,
117            currency,
118            amount,
119        },
120        Request::Withdraw {
121            account,
122            currency,
123            amount,
124        } => TradingEvent::Withdraw {
125            account,
126            currency,
127            amount,
128        },
129        Request::SetRiskLimits { symbol, limits } => TradingEvent::SetRiskLimits { symbol, limits },
130        Request::SetCircuitBreaker { symbol, config } => {
131            TradingEvent::SetCircuitBreaker { symbol, config }
132        }
133        Request::CancelReplace {
134            symbol,
135            account,
136            order_id,
137            new_price,
138            new_quantity,
139        } => TradingEvent::CancelReplace {
140            symbol,
141            account,
142            order_id,
143            new_price,
144            new_quantity,
145        },
146        Request::SetFeeSchedule { symbol, schedule } => {
147            TradingEvent::SetFeeSchedule { symbol, schedule }
148        }
149        Request::QueryStats => TradingEvent::QueryStats,
150        Request::QueryPosition { account } => TradingEvent::QueryPosition { account },
151        Request::QueryRequestSeq => TradingEvent::QueryRequestSeq,
152        Request::EndOfDay => TradingEvent::EndOfDay,
153        Request::DisableInstrument { symbol } => TradingEvent::DisableInstrument { symbol },
154        Request::EnableInstrument { symbol } => TradingEvent::EnableInstrument { symbol },
155        Request::RemoveInstrument { symbol } => TradingEvent::RemoveInstrument { symbol },
156        Request::Heartbeat | Request::ChallengeResponse { .. } | Request::Subscribe { .. } => {
157            unreachable!("filtered before to_trading_event")
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::num::NonZeroU64;
166
167    use melin_app::AppEvent;
168    use melin_types::types::*;
169
170    /// Wire-encode a Request into the byte form the decoder expects
171    /// (seq + tag + payload, with the framing length-prefix already
172    /// stripped — same shape `codec::decode_request` consumes).
173    fn encode(request: &Request, seq: u64) -> Vec<u8> {
174        let mut buf = vec![0u8; 256];
175        let total = codec::encode_request(request, seq, &mut buf).unwrap();
176        buf[4..total].to_vec()
177    }
178
179    fn order() -> Order {
180        Order {
181            id: OrderId(1),
182            account: AccountId(1),
183            side: Side::Buy,
184            order_type: OrderType::Market,
185            quantity: Quantity(NonZeroU64::new(10).unwrap()),
186            time_in_force: TimeInForce::GTC,
187            stp: SelfTradeProtection::Allow,
188            expiry_ns: 0,
189        }
190    }
191
192    #[test]
193    fn heartbeat_is_filtered() {
194        let bytes = encode(&Request::Heartbeat, 0);
195        assert!(matches!(
196            RequestDecoder.decode(&bytes, Permission::Trader),
197            Decoded::Filter
198        ));
199    }
200
201    #[test]
202    fn subscribe_is_filtered() {
203        let bytes = encode(
204            &Request::Subscribe {
205                symbols: [Symbol(0); 8],
206                count: 0,
207            },
208            0,
209        );
210        assert!(matches!(
211            RequestDecoder.decode(&bytes, Permission::Trader),
212            Decoded::Filter
213        ));
214    }
215
216    #[test]
217    fn challenge_response_is_filtered() {
218        let bytes = encode(
219            &Request::ChallengeResponse {
220                signature: [0u8; 64],
221                public_key: [0u8; 32],
222            },
223            0,
224        );
225        assert!(matches!(
226            RequestDecoder.decode(&bytes, Permission::Trader),
227            Decoded::Filter
228        ));
229    }
230
231    #[test]
232    fn submit_order_as_trader_is_permitted() {
233        let bytes = encode(
234            &Request::SubmitOrder {
235                symbol: Symbol(1),
236                order: order(),
237            },
238            42,
239        );
240        match RequestDecoder.decode(&bytes, Permission::Trader) {
241            Decoded::Permitted { request_seq, event } => {
242                assert_eq!(request_seq, 42);
243                assert!(matches!(event, TradingEvent::SubmitOrder { .. }));
244                // Trading-side query taxonomy: order submission is not a query.
245                assert!(!event.is_query());
246            }
247            other => panic!("expected Permitted, got {:?}", debug_variant(&other)),
248        }
249    }
250
251    #[test]
252    fn submit_order_as_readonly_is_denied() {
253        let bytes = encode(
254            &Request::SubmitOrder {
255                symbol: Symbol(1),
256                order: order(),
257            },
258            0,
259        );
260        assert!(matches!(
261            RequestDecoder.decode(&bytes, Permission::ReadOnly),
262            Decoded::PermissionDenied(_)
263        ));
264    }
265
266    #[test]
267    fn add_instrument_as_operator_is_permitted() {
268        let bytes = encode(
269            &Request::AddInstrument {
270                spec: InstrumentSpec {
271                    symbol: Symbol(1),
272                    base: CurrencyId(1),
273                    quote: CurrencyId(2),
274                },
275            },
276            7,
277        );
278        assert!(matches!(
279            RequestDecoder.decode(&bytes, Permission::Operator),
280            Decoded::Permitted { .. }
281        ));
282    }
283
284    #[test]
285    fn add_instrument_as_trader_is_denied() {
286        let bytes = encode(
287            &Request::AddInstrument {
288                spec: InstrumentSpec {
289                    symbol: Symbol(1),
290                    base: CurrencyId(1),
291                    quote: CurrencyId(2),
292                },
293            },
294            0,
295        );
296        assert!(matches!(
297            RequestDecoder.decode(&bytes, Permission::Trader),
298            Decoded::PermissionDenied(_)
299        ));
300    }
301
302    #[test]
303    fn deposit_as_custodian_is_permitted() {
304        let bytes = encode(
305            &Request::Deposit {
306                account: AccountId(1),
307                currency: CurrencyId(1),
308                amount: 100,
309            },
310            3,
311        );
312        assert!(matches!(
313            RequestDecoder.decode(&bytes, Permission::Custodian),
314            Decoded::Permitted { .. }
315        ));
316    }
317
318    #[test]
319    fn deposit_as_trader_is_denied() {
320        let bytes = encode(
321            &Request::Deposit {
322                account: AccountId(1),
323                currency: CurrencyId(1),
324                amount: 100,
325            },
326            0,
327        );
328        assert!(matches!(
329            RequestDecoder.decode(&bytes, Permission::Trader),
330            Decoded::PermissionDenied(_)
331        ));
332    }
333
334    #[test]
335    fn query_stats_is_permitted_and_flagged() {
336        // QueryStats is an operator-only request — see
337        // `Request::requires_operator`.
338        let bytes = encode(&Request::QueryStats, 1);
339        match RequestDecoder.decode(&bytes, Permission::Operator) {
340            Decoded::Permitted { event, .. } => {
341                assert!(matches!(event, TradingEvent::QueryStats));
342                assert!(event.is_query());
343            }
344            other => panic!("expected Permitted, got {:?}", debug_variant(&other)),
345        }
346    }
347
348    #[test]
349    fn malformed_frame_yields_decode_error() {
350        // Empty bytes can't even fit the request_seq prefix.
351        assert!(matches!(
352            RequestDecoder.decode(&[], Permission::Trader),
353            Decoded::DecodeError(_)
354        ));
355    }
356
357    fn debug_variant<E: AppEvent>(d: &Decoded<E>) -> &'static str {
358        match d {
359            Decoded::Filter => "Filter",
360            Decoded::Permitted { .. } => "Permitted",
361            Decoded::PermissionDenied(_) => "PermissionDenied",
362            Decoded::DecodeError(_) => "DecodeError",
363        }
364    }
365
366    // ------------------------------------------------------------------
367    // Per-variant `Request -> TradingEvent` mapping checks.
368    //
369    // The trait tests above only assert `matches!(event,
370    // TradingEvent::Foo { .. })`; these go one level deeper and
371    // confirm the field-by-field mapping for every variant we
372    // currently translate. They call `to_trading_event` directly
373    // because asserting "the SubmitOrder symbol came through as
374    // Symbol(1)" doesn't need a wire round-trip.
375    // ------------------------------------------------------------------
376
377    fn full_order(id: u64, account: u32, side: Side) -> Order {
378        Order {
379            id: OrderId(id),
380            account: AccountId(account),
381            side,
382            order_type: OrderType::Limit {
383                price: Price(NonZeroU64::new(100).unwrap()),
384                post_only: false,
385            },
386            quantity: Quantity(NonZeroU64::new(10).unwrap()),
387            time_in_force: TimeInForce::GTC,
388            stp: SelfTradeProtection::CancelNewest,
389            expiry_ns: 0,
390        }
391    }
392
393    #[test]
394    fn maps_submit_order() {
395        let req = Request::SubmitOrder {
396            symbol: Symbol(1),
397            order: full_order(1, 1, Side::Buy),
398        };
399        assert!(matches!(
400            to_trading_event(&req),
401            TradingEvent::SubmitOrder { symbol, .. } if symbol == Symbol(1)
402        ));
403    }
404
405    #[test]
406    fn maps_cancel_order() {
407        let req = Request::CancelOrder {
408            symbol: Symbol(2),
409            account: AccountId(5),
410            order_id: OrderId(42),
411        };
412        assert!(matches!(
413            to_trading_event(&req),
414            TradingEvent::CancelOrder { symbol, account, order_id }
415                if symbol == Symbol(2) && account == AccountId(5) && order_id == OrderId(42)
416        ));
417    }
418
419    #[test]
420    fn maps_cancel_all() {
421        let req = Request::CancelAll {
422            account: AccountId(7),
423        };
424        assert!(matches!(
425            to_trading_event(&req),
426            TradingEvent::CancelAll { account } if account == AccountId(7)
427        ));
428    }
429
430    #[test]
431    fn maps_deposit() {
432        let req = Request::Deposit {
433            account: AccountId(1),
434            currency: CurrencyId(2),
435            amount: 1000,
436        };
437        assert!(matches!(
438            to_trading_event(&req),
439            TradingEvent::Deposit { account, currency, amount }
440                if account == AccountId(1) && currency == CurrencyId(2) && amount == 1000
441        ));
442    }
443
444    #[test]
445    fn maps_add_instrument() {
446        let spec = InstrumentSpec {
447            symbol: Symbol(10),
448            base: CurrencyId(1),
449            quote: CurrencyId(2),
450        };
451        let req = Request::AddInstrument { spec };
452        assert!(matches!(
453            to_trading_event(&req),
454            TradingEvent::AddInstrument { spec: s } if s.symbol == Symbol(10)
455        ));
456    }
457
458    #[test]
459    fn maps_cancel_replace() {
460        let req = Request::CancelReplace {
461            symbol: Symbol(1),
462            account: AccountId(1),
463            order_id: OrderId(5),
464            new_price: Price(NonZeroU64::new(200).unwrap()),
465            new_quantity: Quantity(NonZeroU64::new(50).unwrap()),
466        };
467        assert!(matches!(
468            to_trading_event(&req),
469            TradingEvent::CancelReplace { order_id, .. } if order_id == OrderId(5)
470        ));
471    }
472
473    #[test]
474    fn maps_set_risk_limits() {
475        let req = Request::SetRiskLimits {
476            symbol: Symbol(1),
477            limits: RiskLimits::default(),
478        };
479        assert!(matches!(
480            to_trading_event(&req),
481            TradingEvent::SetRiskLimits { symbol, .. } if symbol == Symbol(1)
482        ));
483    }
484
485    #[test]
486    fn maps_set_circuit_breaker() {
487        let req = Request::SetCircuitBreaker {
488            symbol: Symbol(1),
489            config: CircuitBreakerConfig::default(),
490        };
491        assert!(matches!(
492            to_trading_event(&req),
493            TradingEvent::SetCircuitBreaker { symbol, .. } if symbol == Symbol(1)
494        ));
495    }
496
497    #[test]
498    fn maps_set_fee_schedule() {
499        let req = Request::SetFeeSchedule {
500            symbol: Symbol(3),
501            schedule: FeeSchedule::default(),
502        };
503        assert!(matches!(
504            to_trading_event(&req),
505            TradingEvent::SetFeeSchedule { symbol, .. } if symbol == Symbol(3)
506        ));
507    }
508
509    #[test]
510    fn maps_query_stats() {
511        assert!(matches!(
512            to_trading_event(&Request::QueryStats),
513            TradingEvent::QueryStats
514        ));
515    }
516
517    #[test]
518    #[should_panic(expected = "filtered before to_trading_event")]
519    fn heartbeat_panics_if_not_filtered() {
520        to_trading_event(&Request::Heartbeat);
521    }
522
523    #[test]
524    #[should_panic(expected = "filtered before to_trading_event")]
525    fn challenge_response_panics_if_not_filtered() {
526        to_trading_event(&Request::ChallengeResponse {
527            signature: [0u8; 64],
528            public_key: [0u8; 32],
529        });
530    }
531}