Skip to main content

hypercall/rsm/
apply.rs

1//! State machine apply() interface for the unified engine.
2//!
3//! This module provides a deterministic, side-effect free core for the trading engine.
4//! The `apply()` function takes an engine command and returns the resulting events,
5//! enabling:
6//! - Reasoning about correctness as a pure state transition
7//! - Observing "input command -> output events" per request
8//! - Testing determinism and replay
9
10use alloy::primitives::FixedBytes;
11pub use hypercall_engine::command::{
12    AccruePmSettlementInterestCommand, ApplyPmSettlementRepaymentCommand, EngineCommand,
13    JournalPmRecoveryPlanCommand, MarkPmRecoveryActionSubmittedCommand, MarketActionCommand,
14    PmRecoveryActionCommand, PmRecoveryActionKind, PmRecoveryActionResult, PmRecoveryExternalKind,
15    PmRecoveryPlanCommand, PmRecoveryReason, PmRecoveryTrigger, RecordPmVaultDepositCommand,
16    RequestPmVaultWithdrawalCommand, ResolvePmRecoveryActionCommand, RfqExecuteCommand,
17    RfqExecuteLeg, RfqExecuteResult, SetPmSettlementPoolConfigCommand, TickExpiryContext,
18    TickExpiryDueGroup, TickExpiryPendingGroup, TickExpiryPmSettlement, TickExpirySettlementPrice,
19    TickExpiryWalletMarginMode,
20};
21use hypercall_types::EngineMessage;
22use hypercall_types::{OrderUpdateMessage, WalletAddress};
23use rust_decimal::Decimal;
24use serde::{Deserialize, Serialize};
25
26/// Runtime side effects requested by a TickExpiry transition.
27#[derive(Debug, Clone, PartialEq)]
28pub enum ExpiryEffect {
29    UpdateInstrumentStatus {
30        symbols: Vec<String>,
31        status: String,
32    },
33    BatchCancelOrdersForSettlement {
34        order_ids: Vec<i64>,
35        now_ms: u64,
36    },
37    CancelOrphanedOrdersBySymbols {
38        symbols: Vec<String>,
39    },
40    ApplySettlement(ExpirySettlementIntent),
41}
42
43/// Runtime side effects requested by a MarketAction transition.
44#[derive(Debug, Clone)]
45pub enum MarketEffect {
46    SaveMarketAndInstrument {
47        underlying: String,
48        expiry: i64,
49        instrument: hypercall_db::InstrumentRecord,
50    },
51    DeleteMarketAndInstrument {
52        symbol: String,
53    },
54    RegisterSettlement {
55        underlying: String,
56        symbol: String,
57        expiry_ts: i64,
58        twap_window_seconds: u32,
59    },
60}
61
62/// Idempotent durable settlement intent emitted by apply(TickExpiry).
63#[derive(Debug, Clone, PartialEq)]
64pub struct ExpirySettlementIntent {
65    pub wallet: WalletAddress,
66    pub symbol: String,
67    pub position_size: Decimal,
68    pub settlement_price: Decimal,
69    pub settlement_value: Decimal,
70    pub margin_mode: crate::rsm::margin_mode::MarginMode,
71    pub event_ts_ms: i64,
72    pub settlement_entry_price: Option<Decimal>,
73    pub cost_basis: Option<Decimal>,
74    pub net_pnl: Option<Decimal>,
75}
76
77/// Wire-serializable payload for PriceUpdate journal entries.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PriceUpdatePayload {
80    pub underlying: String,
81    pub spot_price: Decimal,
82    pub timestamp_ms: u64,
83}
84
85/// Wire-serializable payload for IvUpdate journal entries.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct IvUpdatePayload {
88    pub underlying: String,
89    pub strike_points: Vec<crate::vol_oracle::vol_surface_cache::VolPoint>,
90    pub timestamp_ms: u64,
91}
92
93/// Wire-serializable payload for TierUpdate journal entries.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct TierUpdatePayload {
96    pub wallet: hypercall_types::WalletAddress,
97    pub margin_mode: crate::rsm::margin_mode::MarginMode,
98    pub tier: String,
99    pub trading_limits: hypercall_types::api_models::TradingLimits,
100}
101
102/// Wire-serializable payload for margin-mode-only TierUpdate NATS commands.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct TierMarginModeUpdatePayload {
105    pub wallet: hypercall_types::WalletAddress,
106    pub margin_mode: crate::rsm::margin_mode::MarginMode,
107}
108
109/// Wire-serializable payload for HypercorePositionUpdate NATS commands.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct HypercorePositionUpdatePayload {
112    pub account: String,
113    pub coin: String,
114    pub size: f64,
115    pub entry_price: f64,
116    pub unrealized_pnl: f64,
117    pub timestamp_ms: u64,
118}
119
120/// Wire-serializable payload for MmpConfigUpdate NATS commands.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct MmpConfigUpdatePayload {
123    pub wallet: hypercall_types::WalletAddress,
124    pub currency: String,
125    pub enabled: bool,
126    pub interval_ms: i64,
127    pub frozen_time_ms: i64,
128    #[serde(default)]
129    pub qty_limit: Option<f64>,
130    #[serde(default)]
131    pub delta_limit: Option<f64>,
132    #[serde(default)]
133    pub vega_limit: Option<f64>,
134}
135
136/// Wire-serializable payload for TradingModeUpdate NATS commands.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct TradingModeUpdatePayload {
139    pub modes: std::collections::HashMap<String, hypercall_types::TradingModes>,
140    pub timestamp_ms: u64,
141}
142
143/// Wire-serializable payload for DepositUpdate cash ledger delta commands.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct DepositUpdatePayload {
146    pub wallet: hypercall_types::WalletAddress,
147    pub amount: Decimal,
148    pub timestamp_ms: u64,
149    pub sequence: u64,
150    pub source_event_hash: FixedBytes<32>,
151}
152
153/// Wire-serializable payload for LiquidationBonusUpdate.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct BalanceCommandPayload {
156    pub wallet: hypercall_types::WalletAddress,
157    pub amount: Decimal,
158    pub balance_after: Decimal,
159    pub timestamp_ms: u64,
160    #[serde(default)]
161    pub sequence: Option<u64>,
162}
163
164/// Wire-serializable payload for ApproveAgent NATS commands.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ApproveAgentPayload {
167    pub wallet: hypercall_types::WalletAddress,
168    pub agent: hypercall_types::WalletAddress,
169    #[serde(default)]
170    pub expires_at_ms: Option<u64>,
171    #[serde(default)]
172    pub nonce: Option<u64>,
173    pub timestamp_ms: u64,
174}
175
176/// Wire-serializable payload for RevokeAgent NATS commands.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct RevokeAgentPayload {
179    pub wallet: hypercall_types::WalletAddress,
180    pub agent: hypercall_types::WalletAddress,
181    #[serde(default)]
182    pub nonce: Option<u64>,
183    pub timestamp_ms: u64,
184}
185
186/// Wire-serializable payload for NonceAdvance NATS commands.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct NonceAdvancePayload {
189    pub wallet: hypercall_types::WalletAddress,
190    pub nonce: u64,
191    pub timestamp_ms: u64,
192}
193
194/// Wire-serializable payload for HypercoreEquityUpdate NATS commands.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct HypercoreEquityUpdatePayload {
197    pub wallet: hypercall_types::WalletAddress,
198    pub account_value: Decimal,
199    pub timestamp_ms: u64,
200}
201
202/// Envelope containing a command with metadata about when it was received.
203#[derive(Debug, Clone)]
204pub struct CommandEnvelope {
205    /// Timestamp when the command was received (milliseconds since epoch)
206    pub received_ts_ms: u64,
207    /// The command to apply
208    pub command: EngineCommand,
209}
210
211impl CommandEnvelope {
212    /// Create a new command envelope
213    pub fn new(received_ts_ms: u64, command: EngineCommand) -> Self {
214        Self {
215            received_ts_ms,
216            command,
217        }
218    }
219
220    /// Get the request_id from the wrapped command
221    pub fn request_id(&self) -> Option<String> {
222        self.command.request_id()
223    }
224}
225
226/// Output from applying a command to the engine state.
227///
228/// Contains all events that should be published as a result of the command,
229/// plus any responses captured from the internal processing pipeline.
230/// The runtime is responsible for actually publishing these events to the event bus
231/// and updating downstream caches.
232#[derive(Debug, Clone, Default)]
233pub struct ApplyOutput {
234    /// Events generated by applying the command.
235    /// These should be published in order after apply() returns.
236    pub events: Vec<EngineMessage>,
237    /// Canonical balance effects reduced into `BalanceLedger` by this command.
238    pub balance_updates: Vec<hypercall_types::BalanceUpdate>,
239    /// Captured order responses from process_order's response_tx.
240    /// For OrderAction commands, this contains the ACK/rejection response(s).
241    /// The journaling layer uses these to determine what to send to the client
242    /// and what to record as the journal response.
243    pub order_responses: Vec<OrderUpdateMessage>,
244    /// Captured market response from handle_market_request's response_tx.
245    pub market_response: Option<hypercall_types::MarketUpdateMessage>,
246    /// Runtime effects requested by TickExpiry after deterministic state transition.
247    pub expiry_effects: Vec<ExpiryEffect>,
248    /// Runtime effects requested by MarketAction after deterministic state transition.
249    pub market_effects: Vec<MarketEffect>,
250    /// RFQ execution plan result. Present only for RfqExecute commands.
251    /// Ok = plan succeeded with fill_id + mmp_updates; events are in `self.events`.
252    /// Err = validation failure; caller should forward to the request sender.
253    pub rfq_plan: Option<Result<RfqPlanOutput, hypercall_runtime_api::RfqExecuteResult>>,
254    /// Durable directive outbox appends produced by accepted directive commands.
255    pub outbox_appends: Vec<crate::directive_outbox::DirectiveOutboxAppend>,
256    /// Rebuildable PM settlement projection effects derived from engine state.
257    pub pm_settlement_effects:
258        Vec<crate::rsm::portfolio_margin::settlement_state::PmSettlementProjectionEffect>,
259    /// Command identity hash for validator RSM batch metadata.
260    #[cfg(feature = "rsm-state")]
261    pub command_identity_hash: [u8; 32],
262}
263
264/// Successful RFQ execution plan output returned from apply().
265#[derive(Debug, Clone)]
266pub struct RfqPlanOutput {
267    pub fill_id: String,
268    pub mmp_updates: Vec<MmpFillUpdate>,
269}
270
271/// Per-leg MMP fill update data for post-journal processing.
272/// Moved here from rfq_handler so it's accessible in ApplyOutput.
273#[derive(Debug, Clone)]
274pub struct MmpFillUpdate {
275    pub qp_wallet: hypercall_types::WalletAddress,
276    pub underlying: String,
277    pub fill: hypercall_types::Fill,
278    pub timestamp_ms: u64,
279}
280
281impl ApplyOutput {
282    /// Create empty output.
283    pub fn new() -> Self {
284        Self::with_capacity(0)
285    }
286
287    /// Create output with pre-allocated capacity
288    pub fn with_capacity(capacity: usize) -> Self {
289        Self {
290            events: Vec::with_capacity(capacity),
291            balance_updates: Vec::new(),
292            order_responses: Vec::new(),
293            market_response: None,
294            expiry_effects: Vec::new(),
295            market_effects: Vec::new(),
296            rfq_plan: None,
297            outbox_appends: Vec::new(),
298            pm_settlement_effects: Vec::new(),
299            #[cfg(feature = "rsm-state")]
300            command_identity_hash: [0u8; 32],
301        }
302    }
303
304    /// Add an event to the output
305    pub fn push(&mut self, event: EngineMessage) {
306        self.events.push(event);
307    }
308
309    /// Check if output has any events or side effects.
310    pub fn is_empty(&self) -> bool {
311        self.events.is_empty()
312            && self.balance_updates.is_empty()
313            && self.order_responses.is_empty()
314            && self.market_response.is_none()
315            && self.expiry_effects.is_empty()
316            && self.market_effects.is_empty()
317            && self.rfq_plan.is_none()
318            && self.outbox_appends.is_empty()
319            && self.pm_settlement_effects.is_empty()
320    }
321
322    /// Get the number of events and side effects.
323    pub fn len(&self) -> usize {
324        self.events.len()
325            + self.balance_updates.len()
326            + self.order_responses.len()
327            + usize::from(self.market_response.is_some())
328            + self.expiry_effects.len()
329            + self.market_effects.len()
330            + usize::from(self.rfq_plan.is_some())
331            + self.outbox_appends.len()
332            + self.pm_settlement_effects.len()
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use hypercall_types::{OrderAction, OrderActionMessage, OrderInfo, TimeInForce};
340    use hypercall_types::{Side, WalletAddress};
341    use rust_decimal_macros::dec;
342    use std::str::FromStr;
343
344    #[test]
345    fn test_engine_command_type() {
346        let wallet = WalletAddress::from_str("0x1234567890123456789012345678901234567890").unwrap();
347        let order_info = OrderInfo {
348            symbol: "BTC-20250131-100000-C".to_string(),
349            price: dec!(0.05),
350            size: dec!(1),
351            side: Side::Buy,
352            tif: TimeInForce::GTC,
353            client_id: None,
354            order_id: None,
355            is_perp: false,
356            underlying: None,
357            reduce_only: None,
358            nonce: None,
359            signature: None,
360            mmp_enabled: false,
361            builder_code_address: None,
362        };
363
364        let msg = OrderActionMessage {
365            timestamp: 1000,
366            info: order_info,
367            action: OrderAction::CreateOrder,
368            wallet,
369            api_wallet_address: None,
370            mmp_triggered: false,
371            request_id: Some("test-request-id".to_string()),
372        };
373
374        let cmd = EngineCommand::OrderAction(msg);
375        assert_eq!(cmd.command_type(), "CreateOrder");
376        assert_eq!(cmd.request_id().as_deref(), Some("test-request-id"));
377    }
378
379    #[test]
380    fn test_command_envelope() {
381        let wallet = WalletAddress::from_str("0x1234567890123456789012345678901234567890").unwrap();
382        let order_info = OrderInfo {
383            symbol: "BTC-20250131-100000-C".to_string(),
384            price: dec!(0.05),
385            size: dec!(1),
386            side: Side::Buy,
387            tif: TimeInForce::GTC,
388            client_id: None,
389            order_id: None,
390            is_perp: false,
391            underlying: None,
392            reduce_only: None,
393            nonce: None,
394            signature: None,
395            mmp_enabled: false,
396            builder_code_address: None,
397        };
398
399        let msg = OrderActionMessage {
400            timestamp: 1000,
401            info: order_info,
402            action: OrderAction::CreateOrder,
403            wallet,
404            api_wallet_address: None,
405            mmp_triggered: false,
406            request_id: Some("envelope-test".to_string()),
407        };
408
409        let envelope = CommandEnvelope::new(1234567890, EngineCommand::OrderAction(msg));
410        assert_eq!(envelope.received_ts_ms, 1234567890);
411        assert_eq!(envelope.request_id().as_deref(), Some("envelope-test"));
412    }
413
414    #[test]
415    fn test_price_update_command_type() {
416        let cmd = EngineCommand::PriceUpdate {
417            underlying: "BTC".to_string(),
418            spot_price: dec!(95000),
419            timestamp_ms: 1700000000000,
420        };
421        assert_eq!(cmd.command_type(), "PriceUpdate");
422        assert_eq!(cmd.request_id(), None);
423        assert_eq!(cmd.wallet(), None);
424        assert_eq!(cmd.symbol(), None);
425        assert_eq!(cmd.order_id(), None);
426    }
427
428    #[test]
429    fn test_price_update_payload_serialization_roundtrip() {
430        let payload = PriceUpdatePayload {
431            underlying: "ETH".to_string(),
432            spot_price: dec!(3200.50),
433            timestamp_ms: 1700000001000,
434        };
435
436        let wire = hypercall_types::serialize_to_wire_bytes(&payload);
437        assert!(!wire.is_empty());
438        assert_eq!(wire[0], hypercall_types::WIRE_FORMAT_VERSION);
439
440        let deserialized: PriceUpdatePayload =
441            rmp_serde::from_slice(&wire[1..]).expect("deserialize PriceUpdatePayload");
442        assert_eq!(deserialized.underlying, "ETH");
443        assert_eq!(deserialized.spot_price, dec!(3200.50));
444        assert_eq!(deserialized.timestamp_ms, 1700000001000);
445    }
446
447    #[test]
448    fn test_iv_update_command_type() {
449        let cmd = EngineCommand::IvUpdate {
450            underlying: "BTC".to_string(),
451            surface: crate::vol_oracle::vol_surface_cache::VolatilitySurface::new(),
452            journal_data: None,
453            timestamp_ms: 1700000000000,
454        };
455        assert_eq!(cmd.command_type(), "IvUpdate");
456        assert_eq!(cmd.request_id(), None);
457    }
458
459    #[test]
460    fn test_iv_update_payload_serialization_roundtrip() {
461        use crate::vol_oracle::vol_surface_cache::VolPoint;
462
463        let payload = IvUpdatePayload {
464            underlying: "BTC".to_string(),
465            strike_points: vec![
466                VolPoint {
467                    strike: 100000.0,
468                    expiry: 1700000000,
469                    iv: 0.65,
470                    timestamp: 1700000000000,
471                },
472                VolPoint {
473                    strike: 110000.0,
474                    expiry: 1700000000,
475                    iv: 0.70,
476                    timestamp: 1700000000000,
477                },
478            ],
479            timestamp_ms: 1700000001000,
480        };
481
482        let wire = hypercall_types::serialize_to_wire_bytes(&payload);
483        assert!(!wire.is_empty());
484
485        let deserialized: IvUpdatePayload =
486            rmp_serde::from_slice(&wire[1..]).expect("deserialize IvUpdatePayload");
487        assert_eq!(deserialized.underlying, "BTC");
488        assert_eq!(deserialized.strike_points.len(), 2);
489        assert_eq!(deserialized.strike_points[0].iv, 0.65);
490        assert_eq!(deserialized.strike_points[1].strike, 110000.0);
491    }
492
493    // =========================================================================
494    // Command identity hash + hash chain tests
495    // =========================================================================
496
497    #[cfg(feature = "rsm-state")]
498    use sha3::{Digest, Keccak256};
499
500    #[cfg(feature = "rsm-state")]
501    fn chain_update(prev: &[u8; 32], cmd: &EngineCommand) -> [u8; 32] {
502        let mut h = Keccak256::new();
503        h.update(prev);
504        h.update(cmd.identity_hash());
505        h.finalize().into()
506    }
507
508    #[cfg(feature = "rsm-state")]
509    #[test]
510    fn test_identity_hash_determinism() {
511        let cmd = EngineCommand::PriceUpdate {
512            underlying: "BTC".to_string(),
513            spot_price: dec!(95000),
514            timestamp_ms: 1000,
515        };
516        assert_eq!(cmd.identity_hash(), cmd.identity_hash());
517    }
518
519    #[cfg(feature = "rsm-state")]
520    #[test]
521    fn test_identity_hash_different_prices_diverge() {
522        let a = EngineCommand::PriceUpdate {
523            underlying: "BTC".to_string(),
524            spot_price: dec!(95000),
525            timestamp_ms: 1000,
526        };
527        let b = EngineCommand::PriceUpdate {
528            underlying: "BTC".to_string(),
529            spot_price: dec!(96000),
530            timestamp_ms: 1000,
531        };
532        assert_ne!(a.identity_hash(), b.identity_hash());
533    }
534
535    #[cfg(feature = "rsm-state")]
536    #[test]
537    fn test_identity_hash_different_iv_surfaces_diverge() {
538        let mut surface_a = crate::vol_oracle::vol_surface_cache::VolatilitySurface::new();
539        surface_a.set_atm_vol(1_700_000_000, 0.65);
540
541        let mut surface_b = crate::vol_oracle::vol_surface_cache::VolatilitySurface::new();
542        surface_b.set_atm_vol(1_700_000_000, 0.70);
543
544        let a = EngineCommand::IvUpdate {
545            underlying: "BTC".to_string(),
546            surface: surface_a,
547            journal_data: None,
548            timestamp_ms: 1000,
549        };
550        let b = EngineCommand::IvUpdate {
551            underlying: "BTC".to_string(),
552            surface: surface_b,
553            journal_data: None,
554            timestamp_ms: 1000,
555        };
556
557        assert_ne!(a.identity_hash(), b.identity_hash());
558    }
559
560    #[cfg(feature = "rsm-state")]
561    #[test]
562    fn test_identity_hash_different_positions_diverge() {
563        let a = EngineCommand::HypercorePositionUpdate {
564            account: "0x1234".to_string(),
565            coin: "BTC".to_string(),
566            size: 1.0,
567            entry_price: 95000.0,
568            unrealized_pnl: 100.0,
569            timestamp_ms: 1000,
570        };
571        let b = EngineCommand::HypercorePositionUpdate {
572            account: "0x1234".to_string(),
573            coin: "BTC".to_string(),
574            size: 2.0,
575            entry_price: 95000.0,
576            unrealized_pnl: 100.0,
577            timestamp_ms: 1000,
578        };
579        assert_ne!(a.identity_hash(), b.identity_hash());
580    }
581
582    #[cfg(feature = "rsm-state")]
583    #[test]
584    fn test_identity_hash_different_rfq_taker_submit_signers_diverge() {
585        let wallet = WalletAddress::from_str("0x1234567890123456789012345678901234567890").unwrap();
586        let signer_a =
587            WalletAddress::from_str("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
588        let signer_b =
589            WalletAddress::from_str("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
590
591        let base = hypercall_runtime_api::RfqExecuteCommand {
592            request_id: "test-req".into(),
593            fill_id: "test-fill".into(),
594            rfq_id: "test-rfq".into(),
595            quote_id: "test-quote".into(),
596            taker_wallet: wallet,
597            qp_wallet: wallet,
598            builder_code_address: None,
599            timestamp_ms: 1,
600            legs: vec![],
601            net_premium: dec!(1),
602            taker_signature: String::new(),
603            qp_signature: String::new(),
604            taker_nonce: Some(1),
605            taker_accept_nonce: None,
606            taker_submit_signer: Some(signer_a),
607            taker_accept_signer: None,
608        };
609
610        let mut changed_signer = base.clone();
611        changed_signer.taker_submit_signer = Some(signer_b);
612
613        assert_ne!(
614            EngineCommand::RfqExecute(base).identity_hash(),
615            EngineCommand::RfqExecute(changed_signer).identity_hash()
616        );
617    }
618
619    #[cfg(feature = "rsm-state")]
620    #[test]
621    fn test_identity_hash_different_rfq_accept_nonces_diverge() {
622        let wallet = WalletAddress::from_str("0x1234567890123456789012345678901234567890").unwrap();
623        let accept_signer_b =
624            WalletAddress::from_str("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap();
625        let base = hypercall_runtime_api::RfqExecuteCommand {
626            request_id: "test-req".into(),
627            fill_id: "test-fill".into(),
628            rfq_id: "test-rfq".into(),
629            quote_id: "test-quote".into(),
630            taker_wallet: wallet,
631            qp_wallet: wallet,
632            builder_code_address: None,
633            timestamp_ms: 1,
634            legs: vec![],
635            net_premium: dec!(1),
636            taker_signature: String::new(),
637            qp_signature: String::new(),
638            taker_nonce: Some(1),
639            taker_accept_nonce: Some(2),
640            taker_submit_signer: Some(wallet),
641            taker_accept_signer: Some(wallet),
642        };
643
644        let mut changed_accept_nonce = base.clone();
645        changed_accept_nonce.taker_accept_nonce = Some(3);
646        let mut changed_accept_signer = base.clone();
647        changed_accept_signer.taker_accept_signer = Some(accept_signer_b);
648
649        assert_ne!(
650            EngineCommand::RfqExecute(base.clone()).identity_hash(),
651            EngineCommand::RfqExecute(changed_accept_nonce).identity_hash()
652        );
653        assert_ne!(
654            EngineCommand::RfqExecute(base).identity_hash(),
655            EngineCommand::RfqExecute(changed_accept_signer).identity_hash()
656        );
657    }
658
659    #[cfg(feature = "rsm-state")]
660    #[test]
661    fn test_identity_hash_different_types_diverge() {
662        let price = EngineCommand::PriceUpdate {
663            underlying: "BTC".to_string(),
664            spot_price: dec!(95000),
665            timestamp_ms: 1000,
666        };
667        let tick = EngineCommand::TickExpiry {
668            now_ms: 1000,
669            context: TickExpiryContext::empty(),
670        };
671        assert_ne!(price.identity_hash(), tick.identity_hash());
672    }
673
674    #[cfg(feature = "rsm-state")]
675    #[test]
676    fn test_hash_chain_ordering_matters() {
677        let a = EngineCommand::PriceUpdate {
678            underlying: "BTC".to_string(),
679            spot_price: dec!(95000),
680            timestamp_ms: 1000,
681        };
682        let b = EngineCommand::TickExpiry {
683            now_ms: 2000,
684            context: TickExpiryContext::empty(),
685        };
686        let root_ab = chain_update(&chain_update(&[0u8; 32], &a), &b);
687        let root_ba = chain_update(&chain_update(&[0u8; 32], &b), &a);
688        assert_ne!(root_ab, root_ba);
689    }
690
691    #[cfg(feature = "rsm-state")]
692    #[test]
693    fn test_hash_chain_long_sequence_determinism() {
694        let mut root_a = [0u8; 32];
695        let mut root_b = [0u8; 32];
696        for i in 0..100u64 {
697            let cmd = EngineCommand::PriceUpdate {
698                underlying: "BTC".to_string(),
699                spot_price: Decimal::from(95000 + i),
700                timestamp_ms: i * 1000,
701            };
702            root_a = chain_update(&root_a, &cmd);
703            root_b = chain_update(&root_b, &cmd);
704        }
705        assert_eq!(root_a, root_b);
706        assert_ne!(root_a, [0u8; 32]);
707    }
708
709    #[cfg(feature = "rsm-state")]
710    #[test]
711    fn test_hash_chain_prev_root_matters() {
712        let cmd = EngineCommand::TickExpiry {
713            now_ms: 1000,
714            context: TickExpiryContext::empty(),
715        };
716        assert_ne!(
717            chain_update(&[0u8; 32], &cmd),
718            chain_update(&[1u8; 32], &cmd)
719        );
720    }
721
722    #[cfg(feature = "rsm-state")]
723    #[test]
724    fn test_identity_hash_all_types_nonzero() {
725        let wallet = WalletAddress::from_str("0x1234567890123456789012345678901234567890").unwrap();
726        let cmds: Vec<EngineCommand> = vec![
727            EngineCommand::TickExpiry {
728                now_ms: 1,
729                context: TickExpiryContext::empty(),
730            },
731            EngineCommand::TickSnapshot { now_ms: 1 },
732            EngineCommand::PriceUpdate {
733                underlying: "BTC".into(),
734                spot_price: dec!(1),
735                timestamp_ms: 1,
736            },
737            EngineCommand::IvUpdate {
738                underlying: "BTC".into(),
739                surface: crate::vol_oracle::vol_surface_cache::VolatilitySurface::new(),
740                journal_data: None,
741                timestamp_ms: 1,
742            },
743            EngineCommand::TierUpdate {
744                wallet,
745                margin_mode: crate::rsm::margin_mode::MarginMode::Portfolio,
746                tier: "tier2".to_string(),
747                trading_limits: hypercall_types::api_models::TradingLimits::default(),
748            },
749            EngineCommand::LegacyTierMarginModeUpdate {
750                wallet,
751                margin_mode: crate::rsm::margin_mode::MarginMode::Standard,
752            },
753            EngineCommand::HypercorePositionUpdate {
754                account: "0x1".into(),
755                coin: "BTC".into(),
756                size: 1.0,
757                entry_price: 1.0,
758                unrealized_pnl: 0.0,
759                timestamp_ms: 1,
760            },
761            EngineCommand::MmpConfigUpdate {
762                wallet,
763                currency: "BTC".into(),
764                enabled: true,
765                interval_ms: 1000,
766                frozen_time_ms: 5000,
767                qty_limit: None,
768                delta_limit: None,
769                vega_limit: None,
770            },
771            EngineCommand::RfqExecute(hypercall_runtime_api::RfqExecuteCommand {
772                request_id: "test-req".into(),
773                fill_id: "test-fill".into(),
774                rfq_id: "test-rfq".into(),
775                quote_id: "test-quote".into(),
776                taker_wallet: wallet,
777                qp_wallet: wallet,
778                builder_code_address: None,
779                timestamp_ms: 1,
780                legs: vec![],
781                net_premium: dec!(1),
782                taker_signature: String::new(),
783                qp_signature: String::new(),
784                taker_nonce: None,
785                taker_accept_nonce: None,
786                taker_submit_signer: None,
787                taker_accept_signer: None,
788            }),
789            EngineCommand::DepositUpdate {
790                wallet,
791                amount: dec!(1),
792                timestamp_ms: 1,
793                sequence: Some(1),
794                source_event_hash: FixedBytes::from([1; 32]),
795            },
796            EngineCommand::LiquidationBonusUpdate {
797                wallet,
798                amount: dec!(1),
799                balance_after: dec!(3),
800                timestamp_ms: 1,
801                sequence: Some(2),
802            },
803            EngineCommand::ApproveAgent {
804                wallet,
805                agent: wallet,
806                expires_at_ms: None,
807                nonce: None,
808                timestamp_ms: 1,
809            },
810            EngineCommand::RevokeAgent {
811                wallet,
812                agent: wallet,
813                nonce: None,
814                timestamp_ms: 1,
815            },
816            EngineCommand::HypercoreEquityUpdate {
817                wallet,
818                account_value: dec!(5000),
819                timestamp_ms: 1,
820            },
821        ];
822        for cmd in &cmds {
823            assert_ne!(
824                cmd.identity_hash(),
825                [0u8; 32],
826                "{} hash should be non-zero",
827                cmd.command_type()
828            );
829        }
830    }
831}