Skip to main content

hypercall_engine/
command.rs

1//! Engine command and output types.
2//!
3//! Every state mutation enters the engine as an [`EngineCommand`]. The engine
4//! processes commands synchronously and returns an [`ApplyOutput`] containing
5//! the events produced and a state hash for replication.
6//!
7//! ```text
8//! apply(state, EngineCommand) -> ApplyOutput { events, hash }
9//! ```
10//!
11//! Commands are the *only* way to change engine state. The engine never reads
12//! from external services, databases, or the network during command processing.
13
14use alloy::primitives::FixedBytes;
15use hypercall_margin::margin_mode::MarginMode;
16use hypercall_margin::portfolio::{
17    PmAccountSettlementFacts, PmSettlementObligation, PmSettlementPoolConfig,
18    PmSettlementPoolSnapshot,
19};
20use hypercall_types::{
21    api_models::TradingLimits, LiquidationStateMessage, MarketActionMessage, OrderActionMessage,
22    Side, TradingModes, WalletAddress,
23};
24use hypercall_vol_oracle::VolatilitySurface;
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use uuid::Uuid;
28
29/// Commands that mutate engine state.
30///
31/// Every state-affecting input enters the engine as one of these variants.
32/// Commands are the sole mechanism for state change, making the engine fully
33/// deterministic: the same command sequence always produces the same state.
34///
35/// Commands arrive from multiple sources in the runtime:
36/// - REST API handlers produce `Order`, `MmpConfigUpdate`, `TierUpdate`
37/// - Oracle feeds produce `PriceUpdate`, `IvUpdate`
38/// - On-chain event listeners produce `DepositUpdate`, `HypercorePositionUpdate`
39/// - Timer ticks produce `TickExpiry`, `TradingModeUpdate`
40/// - Liquidation monitor produces `LiquidationState`
41#[derive(Debug, Clone)]
42pub enum EngineCommand {
43    OrderAction(OrderActionMessage),
44    MarketAction(MarketActionCommand),
45    LiquidationState(LiquidationStateMessage),
46    TickExpiry {
47        now_ms: u64,
48        context: TickExpiryContext,
49    },
50    TickSnapshot {
51        now_ms: u64,
52    },
53    PriceUpdate {
54        underlying: String,
55        spot_price: Decimal,
56        timestamp_ms: u64,
57    },
58    IvUpdate {
59        underlying: String,
60        surface: VolatilitySurface,
61        journal_data: Option<Vec<u8>>,
62        timestamp_ms: u64,
63    },
64    TierUpdate {
65        wallet: WalletAddress,
66        margin_mode: MarginMode,
67        tier: String,
68        trading_limits: TradingLimits,
69    },
70    LegacyTierMarginModeUpdate {
71        wallet: WalletAddress,
72        margin_mode: MarginMode,
73    },
74    HypercorePositionUpdate {
75        account: String,
76        coin: String,
77        size: f64,
78        entry_price: f64,
79        unrealized_pnl: f64,
80        timestamp_ms: u64,
81    },
82    MmpConfigUpdate {
83        wallet: WalletAddress,
84        currency: String,
85        enabled: bool,
86        interval_ms: i64,
87        frozen_time_ms: i64,
88        qty_limit: Option<f64>,
89        delta_limit: Option<f64>,
90        vega_limit: Option<f64>,
91    },
92    RfqExecute(RfqExecuteCommand),
93    TradingModeUpdate {
94        modes: std::collections::HashMap<String, TradingModes>,
95        timestamp_ms: u64,
96    },
97    DepositUpdate {
98        wallet: WalletAddress,
99        amount: Decimal,
100        timestamp_ms: u64,
101        sequence: Option<u64>,
102        source_event_hash: FixedBytes<32>,
103    },
104    OptionDepositUpdate {
105        request_id: String,
106        wallet: WalletAddress,
107        symbol: String,
108        quantity: Decimal,
109        timestamp_ms: u64,
110    },
111    OptionWithdrawalUpdate {
112        request_id: String,
113        wallet: WalletAddress,
114        account: WalletAddress,
115        signer: WalletAddress,
116        rsm_signer: WalletAddress,
117        symbol: String,
118        quantity: Decimal,
119        nonce: Option<u64>,
120        action: Vec<u8>,
121        timestamp_ms: u64,
122    },
123    CashWithdrawalUpdate {
124        request_id: String,
125        wallet: WalletAddress,
126        account: WalletAddress,
127        destination: WalletAddress,
128        signer: WalletAddress,
129        rsm_signer: WalletAddress,
130        amount: Decimal,
131        amount_wei: u64,
132        nonce: Option<u64>,
133        timestamp_ms: u64,
134    },
135    LiquidationBonusUpdate {
136        wallet: WalletAddress,
137        amount: Decimal,
138        balance_after: Decimal,
139        timestamp_ms: u64,
140        sequence: Option<u64>,
141    },
142    ApproveAgent {
143        wallet: WalletAddress,
144        agent: WalletAddress,
145        expires_at_ms: Option<u64>,
146        nonce: Option<u64>,
147        timestamp_ms: u64,
148    },
149    RevokeAgent {
150        wallet: WalletAddress,
151        agent: WalletAddress,
152        nonce: Option<u64>,
153        timestamp_ms: u64,
154    },
155    NonceAdvance {
156        wallet: WalletAddress,
157        nonce: u64,
158        timestamp_ms: u64,
159    },
160    HypercoreEquityUpdate {
161        wallet: WalletAddress,
162        account_value: Decimal,
163        timestamp_ms: u64,
164    },
165    SetPmSettlementPoolConfig(SetPmSettlementPoolConfigCommand),
166    RecordPmVaultDeposit(RecordPmVaultDepositCommand),
167    RequestPmVaultWithdrawal(RequestPmVaultWithdrawalCommand),
168    AccruePmSettlementInterest(AccruePmSettlementInterestCommand),
169    ApplyPmSettlementRepayment(ApplyPmSettlementRepaymentCommand),
170    JournalPmRecoveryPlan(JournalPmRecoveryPlanCommand),
171    MarkPmRecoveryActionSubmitted(MarkPmRecoveryActionSubmittedCommand),
172    ResolvePmRecoveryAction(ResolvePmRecoveryActionCommand),
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct SetPmSettlementPoolConfigCommand {
178    pub request_id: Uuid,
179    pub input_digest: String,
180    pub underlying: String,
181    pub config_version: u32,
182    pub config: PmSettlementPoolConfig,
183    pub timestamp_ms: u64,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(deny_unknown_fields)]
188pub struct RecordPmVaultDepositCommand {
189    pub request_id: Uuid,
190    pub input_digest: String,
191    pub depositor: WalletAddress,
192    pub underlying: String,
193    pub amount_usdc: Decimal,
194    pub chain_id: u64,
195    pub source_contract_address: WalletAddress,
196    pub tx_hash: String,
197    pub log_index: u32,
198    pub max_listed_expiry_ts_ms: i64,
199    pub settlement_grace_ms: i64,
200    pub timestamp_ms: u64,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct RequestPmVaultWithdrawalCommand {
206    pub request_id: Uuid,
207    pub input_digest: String,
208    pub depositor: WalletAddress,
209    pub underlying: String,
210    pub deposit_id: Uuid,
211    pub amount_usdc: Decimal,
212    pub timestamp_ms: u64,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct AccruePmSettlementInterestCommand {
218    pub request_id: Uuid,
219    pub input_digest: String,
220    pub wallet: WalletAddress,
221    pub underlying: String,
222    pub to_ms: i64,
223    pub timestamp_ms: u64,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct ApplyPmSettlementRepaymentCommand {
229    pub request_id: Uuid,
230    pub input_digest: String,
231    pub wallet: WalletAddress,
232    pub underlying: String,
233    pub amount_usdc: Decimal,
234    pub reason: String,
235    pub source_event_id: String,
236    pub timestamp_ms: u64,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct JournalPmRecoveryPlanCommand {
241    pub request_id: Uuid,
242    pub input_digest: String,
243    pub plan: PmRecoveryPlanCommand,
244    pub timestamp_ms: u64,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
248pub struct PmRecoveryPlanCommand {
249    pub plan_id: String,
250    pub wallet: WalletAddress,
251    pub underlying: String,
252    pub trigger: PmRecoveryTrigger,
253    pub reason: PmRecoveryReason,
254    pub policy_version: u32,
255    pub recovery_priority_version: u32,
256    pub target_reduction_usdc: Decimal,
257    pub expected_usdc_recovered: Decimal,
258    pub expected_obligation_reduced: Decimal,
259    pub expected_impact_usdc: Decimal,
260    pub post_plan_utilization: Option<Decimal>,
261    pub actions: Vec<PmRecoveryActionCommand>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
265pub struct PmRecoveryActionCommand {
266    pub action_index: u32,
267    pub action: PmRecoveryActionKind,
268    pub expected_usdc_recovered: Decimal,
269    pub expected_obligation_reduced: Decimal,
270    pub expected_impact_usdc: Decimal,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274pub enum PmRecoveryActionKind {
275    CancelOrder {
276        order_id: u64,
277        reason: PmRecoveryReason,
278    },
279    ClosePerp {
280        asset: String,
281        size: Decimal,
282        reduce_only: bool,
283    },
284    CloseOption {
285        market_id: String,
286        size: Decimal,
287        side: Side,
288    },
289    TransferCollateral {
290        source: String,
291        amount_usdc: Decimal,
292    },
293    EscalateManual {
294        reason: PmRecoveryReason,
295    },
296}
297
298#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
299pub enum PmRecoveryTrigger {
300    Debt,
301    OverdueBridge,
302    MaintenanceBreach,
303    UtilizationBreach,
304    CrisisCap,
305}
306
307#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
308pub enum PmRecoveryReason {
309    SettlementDebt,
310    TimingBridge,
311    Maintenance,
312    Utilization,
313    CrisisCap,
314    StaleMarketState,
315    NoActionableRecovery,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
319pub struct MarkPmRecoveryActionSubmittedCommand {
320    pub request_id: Uuid,
321    pub input_digest: String,
322    pub wallet: WalletAddress,
323    pub plan_id: String,
324    pub action_index: u32,
325    pub attempt: u32,
326    pub external_id: String,
327    pub external_kind: PmRecoveryExternalKind,
328    pub timestamp_ms: u64,
329}
330
331#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
332pub enum PmRecoveryExternalKind {
333    Directive,
334    EngineOrder,
335    Manual,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339pub struct ResolvePmRecoveryActionCommand {
340    pub request_id: Uuid,
341    pub input_digest: String,
342    pub wallet: WalletAddress,
343    pub plan_id: String,
344    pub action_index: u32,
345    pub attempt: u32,
346    pub result: PmRecoveryActionResult,
347    pub recovered_usdc: Decimal,
348    pub liability_reduction_usdc: Decimal,
349    pub result_external_id: Option<String>,
350    pub timestamp_ms: u64,
351}
352
353#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
354pub enum PmRecoveryActionResult {
355    Confirmed,
356    Failed,
357    Canceled,
358    TimedOut,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct MarketActionCommand {
363    pub message: MarketActionMessage,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub expiry_context: Option<TickExpiryContext>,
366}
367
368impl MarketActionCommand {
369    pub fn new(message: MarketActionMessage) -> Self {
370        Self {
371            message,
372            expiry_context: None,
373        }
374    }
375
376    pub fn with_expiry_context(
377        message: MarketActionMessage,
378        expiry_context: TickExpiryContext,
379    ) -> Self {
380        Self {
381            message,
382            expiry_context: Some(expiry_context),
383        }
384    }
385}
386
387impl From<MarketActionMessage> for MarketActionCommand {
388    fn from(message: MarketActionMessage) -> Self {
389        Self::new(message)
390    }
391}
392
393#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
394pub struct TickExpiryContext {
395    pub due_expiries: Vec<TickExpiryDueGroup>,
396    pub pending_settlements: Vec<TickExpiryPendingGroup>,
397    pub settlement_prices: Vec<TickExpirySettlementPrice>,
398    pub margin_modes: Vec<TickExpiryWalletMarginMode>,
399    pub pm_settlements: Vec<TickExpiryPmSettlement>,
400}
401
402impl TickExpiryContext {
403    pub fn empty() -> Self {
404        Self::default()
405    }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
409pub struct TickExpiryDueGroup {
410    pub expiry_ts: i64,
411    pub symbols: Vec<String>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
415pub struct TickExpiryPendingGroup {
416    pub underlying: String,
417    pub expiry_ts: i64,
418    pub symbols: Vec<String>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
422pub struct TickExpirySettlementPrice {
423    pub underlying: String,
424    pub expiry_ts: i64,
425    pub price: Decimal,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
429pub struct TickExpiryWalletMarginMode {
430    pub wallet: WalletAddress,
431    pub margin_mode: MarginMode,
432    pub pm_settlement_required: bool,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
436pub struct PmSettlementEventKey {
437    pub wallet: WalletAddress,
438    pub market_id: String,
439    pub expiry_ts_ms: i64,
440    pub margin_mode: String,
441    pub settlement_event_sequence: u64,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
445pub struct TickExpiryPmSettlement {
446    pub request_id: Uuid,
447    pub event_key: PmSettlementEventKey,
448    pub wallet: WalletAddress,
449    pub market_id: String,
450    pub underlying: String,
451    pub expiry_ts_ms: i64,
452    pub settlement_obligation_usdc: Decimal,
453    pub liquid_usdc: Decimal,
454    pub pm_facts: Option<PmAccountSettlementFacts>,
455    pub pool_snapshot: Option<PmSettlementPoolSnapshot>,
456    pub policy_version: u32,
457    pub facts_digest: String,
458    pub unavailable_reason: Option<String>,
459    pub obligation: PmSettlementObligation,
460}
461
462fn update_len_prefixed<D: sha3::Digest>(h: &mut D, value: &str) {
463    h.update((value.len() as u64).to_le_bytes());
464    h.update(value.as_bytes());
465}
466
467fn update_tick_expiry_context_hash<D: sha3::Digest>(h: &mut D, context: &TickExpiryContext) {
468    h.update((context.due_expiries.len() as u64).to_le_bytes());
469    for group in &context.due_expiries {
470        h.update(group.expiry_ts.to_le_bytes());
471        h.update((group.symbols.len() as u64).to_le_bytes());
472        for symbol in &group.symbols {
473            update_len_prefixed(h, symbol);
474        }
475    }
476    h.update((context.pending_settlements.len() as u64).to_le_bytes());
477    for group in &context.pending_settlements {
478        update_len_prefixed(h, &group.underlying);
479        h.update(group.expiry_ts.to_le_bytes());
480        h.update((group.symbols.len() as u64).to_le_bytes());
481        for symbol in &group.symbols {
482            update_len_prefixed(h, symbol);
483        }
484    }
485    h.update((context.settlement_prices.len() as u64).to_le_bytes());
486    for price in &context.settlement_prices {
487        update_len_prefixed(h, &price.underlying);
488        h.update(price.expiry_ts.to_le_bytes());
489        h.update(price.price.serialize());
490    }
491    h.update((context.margin_modes.len() as u64).to_le_bytes());
492    for mode in &context.margin_modes {
493        h.update(mode.wallet.as_bytes());
494        update_len_prefixed(h, &format!("{:?}", mode.margin_mode));
495        h.update([mode.pm_settlement_required as u8]);
496    }
497    h.update((context.pm_settlements.len() as u64).to_le_bytes());
498    for settlement in &context.pm_settlements {
499        h.update(settlement.event_key.wallet.as_bytes());
500        update_len_prefixed(h, &settlement.event_key.market_id);
501        h.update(settlement.event_key.expiry_ts_ms.to_le_bytes());
502        update_len_prefixed(h, &settlement.event_key.margin_mode);
503        h.update(settlement.event_key.settlement_event_sequence.to_le_bytes());
504        h.update(settlement.request_id.as_bytes());
505        h.update(settlement.wallet.as_bytes());
506        update_len_prefixed(h, &settlement.market_id);
507        update_len_prefixed(h, &settlement.underlying);
508        h.update(settlement.expiry_ts_ms.to_le_bytes());
509        h.update(settlement.settlement_obligation_usdc.serialize());
510        h.update(settlement.liquid_usdc.serialize());
511        h.update(settlement.obligation.wallet.as_bytes());
512        update_len_prefixed(h, &settlement.obligation.market_id);
513        h.update(settlement.obligation.expiry_ts_ms.to_le_bytes());
514        update_len_prefixed(h, &settlement.obligation.underlying);
515        h.update(settlement.obligation.net_pnl_usdc.serialize());
516        h.update(settlement.obligation.settlement_obligation_usdc.serialize());
517        h.update([settlement.pm_facts.is_some() as u8]);
518        if let Some(facts) = &settlement.pm_facts {
519            h.update(facts.wallet.as_bytes());
520            update_len_prefixed(h, &facts.underlying);
521            h.update(facts.liquid_usdc.serialize());
522            h.update(facts.pm_equity_usdc.serialize());
523            h.update(facts.pm_maintenance_requirement_usdc.serialize());
524            h.update(facts.recoverable_collateral_usdc.serialize());
525            h.update(facts.facts_as_of_ms.to_le_bytes());
526            h.update([facts.stale as u8]);
527        }
528        h.update([settlement.pool_snapshot.is_some() as u8]);
529        if let Some(pool_snapshot) = &settlement.pool_snapshot {
530            update_len_prefixed(h, &pool_snapshot.underlying);
531            h.update(pool_snapshot.pool_available_usdc.serialize());
532            h.update(pool_snapshot.pool_target_usdc.serialize());
533            h.update(pool_snapshot.active_timing_bridge_usdc.serialize());
534            h.update(pool_snapshot.active_settlement_debt_usdc.serialize());
535            h.update(pool_snapshot.config_version.to_le_bytes());
536            h.update(pool_snapshot.policy_version.to_le_bytes());
537        }
538        h.update(settlement.policy_version.to_le_bytes());
539        update_len_prefixed(h, &settlement.facts_digest);
540        h.update([settlement.unavailable_reason.is_some() as u8]);
541        if let Some(reason) = &settlement.unavailable_reason {
542            update_len_prefixed(h, reason);
543        }
544    }
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct RfqExecuteCommand {
549    pub request_id: String,
550    pub fill_id: String,
551    pub rfq_id: String,
552    pub quote_id: String,
553    pub taker_wallet: WalletAddress,
554    pub qp_wallet: WalletAddress,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub builder_code_address: Option<WalletAddress>,
557    pub timestamp_ms: u64,
558    pub legs: Vec<RfqExecuteLeg>,
559    pub net_premium: Decimal,
560    pub taker_signature: String,
561    pub qp_signature: String,
562    #[serde(default)]
563    pub taker_nonce: Option<u64>,
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub taker_accept_nonce: Option<u64>,
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub taker_submit_signer: Option<WalletAddress>,
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub taker_accept_signer: Option<WalletAddress>,
570}
571
572impl RfqExecuteCommand {
573    pub fn taker_submit_signer(&self) -> WalletAddress {
574        self.taker_submit_signer.unwrap_or(self.taker_wallet)
575    }
576
577    pub fn taker_accept_signer(&self) -> WalletAddress {
578        self.taker_accept_signer
579            .or(self.taker_submit_signer)
580            .unwrap_or(self.taker_wallet)
581    }
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct RfqExecuteLeg {
586    pub instrument: String,
587    pub taker_side: Side,
588    pub price: Decimal,
589    pub size: Decimal,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub enum RfqExecuteResult {
594    Success { fill_id: String },
595    Failed { reason: String },
596}
597
598impl EngineCommand {
599    pub fn command_type(&self) -> &'static str {
600        match self {
601            EngineCommand::OrderAction(msg) => match msg.action {
602                hypercall_types::OrderAction::CreateOrder => "CreateOrder",
603                hypercall_types::OrderAction::CancelOrder => "CancelOrder",
604                hypercall_types::OrderAction::ReplaceOrder => "ReplaceOrder",
605            },
606            EngineCommand::MarketAction(cmd) => match cmd.message.action {
607                hypercall_types::MarketAction::CreateMarket => "CreateMarket",
608                hypercall_types::MarketAction::DeleteMarket => "DeleteMarket",
609                hypercall_types::MarketAction::ExpireMarket => "ExpireMarket",
610            },
611            EngineCommand::LiquidationState(_) => "LiquidationState",
612            EngineCommand::TickExpiry { .. } => "TickExpiry",
613            EngineCommand::TickSnapshot { .. } => "TickSnapshot",
614            EngineCommand::PriceUpdate { .. } => "PriceUpdate",
615            EngineCommand::IvUpdate { .. } => "IvUpdate",
616            EngineCommand::TierUpdate { .. } => "TierUpdate",
617            EngineCommand::LegacyTierMarginModeUpdate { .. } => "TierUpdate",
618            EngineCommand::HypercorePositionUpdate { .. } => "HypercorePositionUpdate",
619            EngineCommand::MmpConfigUpdate { .. } => "MmpConfigUpdate",
620            EngineCommand::RfqExecute(_) => "RfqExecute",
621            EngineCommand::TradingModeUpdate { .. } => "TradingModeUpdate",
622            EngineCommand::DepositUpdate { .. } => "DepositUpdate",
623            EngineCommand::OptionDepositUpdate { .. } => "OptionDepositUpdate",
624            EngineCommand::OptionWithdrawalUpdate { .. } => "OptionWithdrawalUpdate",
625            EngineCommand::CashWithdrawalUpdate { .. } => "CashWithdrawalUpdate",
626            EngineCommand::LiquidationBonusUpdate { .. } => "LiquidationBonusUpdate",
627            EngineCommand::ApproveAgent { .. } => "ApproveAgent",
628            EngineCommand::RevokeAgent { .. } => "RevokeAgent",
629            EngineCommand::NonceAdvance { .. } => "NonceAdvance",
630            EngineCommand::HypercoreEquityUpdate { .. } => "HypercoreEquityUpdate",
631            EngineCommand::SetPmSettlementPoolConfig(_) => "SetPmSettlementPoolConfig",
632            EngineCommand::RecordPmVaultDeposit(_) => "RecordPmVaultDeposit",
633            EngineCommand::RequestPmVaultWithdrawal(_) => "RequestPmVaultWithdrawal",
634            EngineCommand::AccruePmSettlementInterest(_) => "AccruePmSettlementInterest",
635            EngineCommand::ApplyPmSettlementRepayment(_) => "ApplyPmSettlementRepayment",
636            EngineCommand::JournalPmRecoveryPlan(_) => "JournalPmRecoveryPlan",
637            EngineCommand::MarkPmRecoveryActionSubmitted(_) => "MarkPmRecoveryActionSubmitted",
638            EngineCommand::ResolvePmRecoveryAction(_) => "ResolvePmRecoveryAction",
639        }
640    }
641
642    pub fn request_id(&self) -> Option<String> {
643        match self {
644            EngineCommand::OrderAction(msg) => msg.request_id.clone(),
645            EngineCommand::RfqExecute(cmd) => {
646                if cmd.request_id.is_empty() {
647                    None
648                } else {
649                    Some(cmd.request_id.clone())
650                }
651            }
652            EngineCommand::OptionDepositUpdate { request_id, .. }
653            | EngineCommand::OptionWithdrawalUpdate { request_id, .. }
654            | EngineCommand::CashWithdrawalUpdate { request_id, .. } => Some(request_id.clone()),
655            EngineCommand::SetPmSettlementPoolConfig(cmd) => Some(cmd.request_id.to_string()),
656            EngineCommand::RecordPmVaultDeposit(cmd) => Some(cmd.request_id.to_string()),
657            EngineCommand::RequestPmVaultWithdrawal(cmd) => Some(cmd.request_id.to_string()),
658            EngineCommand::AccruePmSettlementInterest(cmd) => Some(cmd.request_id.to_string()),
659            EngineCommand::ApplyPmSettlementRepayment(cmd) => Some(cmd.request_id.to_string()),
660            EngineCommand::JournalPmRecoveryPlan(cmd) => Some(cmd.request_id.to_string()),
661            EngineCommand::MarkPmRecoveryActionSubmitted(cmd) => Some(cmd.request_id.to_string()),
662            EngineCommand::ResolvePmRecoveryAction(cmd) => Some(cmd.request_id.to_string()),
663            _ => None,
664        }
665    }
666
667    pub fn wallet(&self) -> Option<&WalletAddress> {
668        match self {
669            EngineCommand::OrderAction(msg) => Some(&msg.wallet),
670            EngineCommand::LiquidationState(msg) => Some(&msg.wallet),
671            EngineCommand::TierUpdate { wallet, .. } => Some(wallet),
672            EngineCommand::LegacyTierMarginModeUpdate { wallet, .. } => Some(wallet),
673            EngineCommand::MmpConfigUpdate { wallet, .. } => Some(wallet),
674            EngineCommand::RfqExecute(cmd) => Some(&cmd.taker_wallet),
675            EngineCommand::DepositUpdate { wallet, .. } => Some(wallet),
676            EngineCommand::OptionDepositUpdate { wallet, .. }
677            | EngineCommand::OptionWithdrawalUpdate { wallet, .. }
678            | EngineCommand::CashWithdrawalUpdate { wallet, .. } => Some(wallet),
679            EngineCommand::LiquidationBonusUpdate { wallet, .. } => Some(wallet),
680            EngineCommand::ApproveAgent { wallet, .. } => Some(wallet),
681            EngineCommand::RevokeAgent { wallet, .. } => Some(wallet),
682            EngineCommand::NonceAdvance { wallet, .. } => Some(wallet),
683            EngineCommand::HypercoreEquityUpdate { wallet, .. } => Some(wallet),
684            EngineCommand::RecordPmVaultDeposit(cmd) => Some(&cmd.depositor),
685            EngineCommand::RequestPmVaultWithdrawal(cmd) => Some(&cmd.depositor),
686            EngineCommand::AccruePmSettlementInterest(cmd) => Some(&cmd.wallet),
687            EngineCommand::ApplyPmSettlementRepayment(cmd) => Some(&cmd.wallet),
688            EngineCommand::JournalPmRecoveryPlan(cmd) => Some(&cmd.plan.wallet),
689            EngineCommand::MarkPmRecoveryActionSubmitted(cmd) => Some(&cmd.wallet),
690            EngineCommand::ResolvePmRecoveryAction(cmd) => Some(&cmd.wallet),
691            _ => None,
692        }
693    }
694
695    pub fn symbol(&self) -> Option<&str> {
696        match self {
697            EngineCommand::OrderAction(msg) => Some(&msg.info.symbol),
698            EngineCommand::MarketAction(cmd) => Some(&cmd.message.market.symbol),
699            EngineCommand::OptionDepositUpdate { symbol, .. }
700            | EngineCommand::OptionWithdrawalUpdate { symbol, .. } => Some(symbol),
701            _ => None,
702        }
703    }
704
705    pub fn order_id(&self) -> Option<u64> {
706        match self {
707            EngineCommand::OrderAction(msg) => msg.info.order_id,
708            _ => None,
709        }
710    }
711
712    pub fn identity_hash(&self) -> [u8; 32] {
713        use sha3::{Digest, Keccak256};
714
715        let mut h = Keccak256::new();
716        h.update(self.command_type().as_bytes());
717        match self {
718            EngineCommand::OrderAction(msg) => {
719                h.update(msg.wallet.as_bytes());
720                h.update(msg.timestamp.to_le_bytes());
721                h.update(msg.info.symbol.as_bytes());
722                h.update(msg.info.price.serialize());
723                h.update(msg.info.size.serialize());
724                h.update(format!("{:?}", msg.info.side).as_bytes());
725                h.update(format!("{:?}", msg.info.tif).as_bytes());
726                h.update([msg.info.is_perp as u8]);
727                h.update([msg.info.mmp_enabled as u8]);
728                if let Some(ro) = msg.info.reduce_only {
729                    h.update([ro as u8]);
730                }
731                if let Some(oid) = msg.info.order_id {
732                    h.update(oid.to_le_bytes());
733                }
734                if let Some(ref cid) = msg.info.client_id {
735                    h.update(cid.as_bytes());
736                }
737            }
738            EngineCommand::MarketAction(cmd) => {
739                let msg = &cmd.message;
740                h.update(format!("{:?}", msg.action).as_bytes());
741                h.update(msg.market.symbol.as_bytes());
742                h.update(msg.market.underlying.as_bytes());
743                h.update(msg.market.expiry.to_le_bytes());
744                h.update(msg.market.strike.serialize());
745                h.update([cmd.expiry_context.is_some() as u8]);
746                if let Some(context) = &cmd.expiry_context {
747                    update_tick_expiry_context_hash(&mut h, context);
748                }
749            }
750            EngineCommand::LiquidationState(msg) => {
751                h.update(msg.wallet.as_bytes());
752                h.update(format!("{:?}", msg.new_state).as_bytes());
753            }
754            EngineCommand::TickExpiry { now_ms, context } => {
755                h.update(now_ms.to_le_bytes());
756                update_tick_expiry_context_hash(&mut h, context);
757            }
758            EngineCommand::TickSnapshot { now_ms } => {
759                h.update(now_ms.to_le_bytes());
760            }
761            EngineCommand::PriceUpdate {
762                underlying,
763                spot_price,
764                timestamp_ms,
765            } => {
766                h.update(underlying.as_bytes());
767                h.update(spot_price.serialize());
768                h.update(timestamp_ms.to_le_bytes());
769            }
770            EngineCommand::IvUpdate {
771                underlying,
772                timestamp_ms,
773                surface,
774                journal_data,
775            } => {
776                h.update(underlying.as_bytes());
777                h.update(timestamp_ms.to_le_bytes());
778                if let Some(data) = journal_data {
779                    h.update(data);
780                } else {
781                    let mut strike_points = surface.export_all_points();
782                    strike_points.sort_by(|a, b| {
783                        a.expiry
784                            .cmp(&b.expiry)
785                            .then_with(|| a.strike.total_cmp(&b.strike))
786                            .then_with(|| a.iv.total_cmp(&b.iv))
787                            .then_with(|| a.timestamp.cmp(&b.timestamp))
788                    });
789                    h.update((strike_points.len() as u64).to_le_bytes());
790                    for point in strike_points {
791                        h.update(point.strike.to_bits().to_le_bytes());
792                        h.update(point.expiry.to_le_bytes());
793                        h.update(point.iv.to_bits().to_le_bytes());
794                        h.update(point.timestamp.to_le_bytes());
795                    }
796
797                    let mut atm_vols = surface.export_atm_vols();
798                    atm_vols.sort_by_key(|(expiry, _)| *expiry);
799                    h.update((atm_vols.len() as u64).to_le_bytes());
800                    for (expiry, iv) in atm_vols {
801                        h.update(expiry.to_le_bytes());
802                        h.update(iv.to_bits().to_le_bytes());
803                    }
804
805                    let mut delta_curves = surface.export_delta_curves();
806                    delta_curves.sort_by_key(|curve| curve.expiry);
807                    h.update((delta_curves.len() as u64).to_le_bytes());
808                    for mut curve in delta_curves {
809                        h.update(curve.expiry.to_le_bytes());
810                        curve.points.sort_by(|a, b| {
811                            a.delta
812                                .total_cmp(&b.delta)
813                                .then_with(|| a.iv.total_cmp(&b.iv))
814                        });
815                        h.update((curve.points.len() as u64).to_le_bytes());
816                        for point in curve.points {
817                            h.update(point.delta.to_bits().to_le_bytes());
818                            h.update(point.iv.to_bits().to_le_bytes());
819                        }
820                    }
821                }
822            }
823            EngineCommand::TierUpdate {
824                wallet,
825                margin_mode,
826                tier,
827                trading_limits,
828            } => {
829                h.update(wallet.as_bytes());
830                h.update(format!("{:?}", margin_mode).as_bytes());
831                h.update(tier.as_bytes());
832                h.update(&trading_limits.max_open_orders.to_le_bytes());
833                h.update(&trading_limits.max_open_positions.to_le_bytes());
834                h.update(&trading_limits.orders_per_minute.to_le_bytes());
835                h.update(&trading_limits.cancels_per_minute.to_le_bytes());
836                h.update(&trading_limits.api_requests_per_minute.to_le_bytes());
837            }
838            EngineCommand::LegacyTierMarginModeUpdate {
839                wallet,
840                margin_mode,
841            } => {
842                h.update(wallet.as_bytes());
843                h.update(format!("{:?}", margin_mode).as_bytes());
844            }
845            EngineCommand::HypercorePositionUpdate {
846                account,
847                coin,
848                size,
849                entry_price,
850                unrealized_pnl,
851                timestamp_ms,
852            } => {
853                h.update(account.as_bytes());
854                h.update(coin.as_bytes());
855                h.update(size.to_le_bytes());
856                h.update(entry_price.to_le_bytes());
857                h.update(unrealized_pnl.to_le_bytes());
858                h.update(timestamp_ms.to_le_bytes());
859            }
860            EngineCommand::MmpConfigUpdate {
861                wallet,
862                currency,
863                enabled,
864                interval_ms,
865                frozen_time_ms,
866                qty_limit,
867                delta_limit,
868                vega_limit,
869            } => {
870                h.update(wallet.as_bytes());
871                h.update(currency.as_bytes());
872                h.update([*enabled as u8]);
873                h.update(interval_ms.to_le_bytes());
874                h.update(frozen_time_ms.to_le_bytes());
875                h.update([qty_limit.is_some() as u8]);
876                if let Some(q) = qty_limit {
877                    h.update(q.to_le_bytes());
878                }
879                h.update([delta_limit.is_some() as u8]);
880                if let Some(d) = delta_limit {
881                    h.update(d.to_le_bytes());
882                }
883                h.update([vega_limit.is_some() as u8]);
884                if let Some(v) = vega_limit {
885                    h.update(v.to_le_bytes());
886                }
887            }
888            EngineCommand::RfqExecute(cmd) => {
889                h.update(cmd.rfq_id.as_bytes());
890                h.update(cmd.request_id.as_bytes());
891                h.update(cmd.fill_id.as_bytes());
892                h.update(cmd.quote_id.as_bytes());
893                h.update(cmd.taker_wallet.as_bytes());
894                h.update([cmd.taker_submit_signer.is_some() as u8]);
895                if let Some(ref signer) = cmd.taker_submit_signer {
896                    h.update(signer.as_bytes());
897                }
898                h.update([cmd.taker_accept_signer.is_some() as u8]);
899                if let Some(ref signer) = cmd.taker_accept_signer {
900                    h.update(signer.as_bytes());
901                }
902                h.update(cmd.qp_wallet.as_bytes());
903                h.update([cmd.builder_code_address.is_some() as u8]);
904                if let Some(ref builder_code_address) = cmd.builder_code_address {
905                    h.update(builder_code_address.as_bytes());
906                }
907                h.update(cmd.timestamp_ms.to_le_bytes());
908                h.update((cmd.legs.len() as u64).to_le_bytes());
909                for leg in &cmd.legs {
910                    h.update(leg.instrument.as_bytes());
911                    h.update(format!("{:?}", leg.taker_side).as_bytes());
912                    h.update(leg.price.serialize());
913                    h.update(leg.size.serialize());
914                }
915                h.update(cmd.net_premium.serialize());
916                h.update(cmd.taker_signature.as_bytes());
917                h.update(cmd.qp_signature.as_bytes());
918                h.update([cmd.taker_nonce.is_some() as u8]);
919                if let Some(nonce) = cmd.taker_nonce {
920                    h.update(nonce.to_le_bytes());
921                }
922                h.update([cmd.taker_accept_nonce.is_some() as u8]);
923                if let Some(nonce) = cmd.taker_accept_nonce {
924                    h.update(nonce.to_le_bytes());
925                }
926            }
927            EngineCommand::TradingModeUpdate {
928                modes,
929                timestamp_ms,
930            } => {
931                let mut keys: Vec<&String> = modes.keys().collect();
932                keys.sort();
933                for k in keys {
934                    h.update(k.as_bytes());
935                    h.update(format!("{:?}", modes[k]).as_bytes());
936                }
937                h.update(timestamp_ms.to_le_bytes());
938            }
939            EngineCommand::DepositUpdate {
940                wallet,
941                amount,
942                timestamp_ms,
943                sequence,
944                source_event_hash,
945            } => {
946                h.update(wallet.as_bytes());
947                h.update(amount.serialize());
948                h.update(timestamp_ms.to_le_bytes());
949                h.update([sequence.is_some() as u8]);
950                if let Some(sequence) = sequence {
951                    h.update(sequence.to_le_bytes());
952                }
953                h.update(source_event_hash.as_slice());
954            }
955            EngineCommand::LiquidationBonusUpdate {
956                wallet,
957                amount,
958                balance_after,
959                timestamp_ms,
960                sequence,
961            } => {
962                h.update(wallet.as_bytes());
963                h.update(amount.serialize());
964                h.update(balance_after.serialize());
965                h.update(timestamp_ms.to_le_bytes());
966                h.update([sequence.is_some() as u8]);
967                if let Some(sequence) = sequence {
968                    h.update(sequence.to_le_bytes());
969                }
970            }
971            EngineCommand::OptionDepositUpdate {
972                request_id,
973                wallet,
974                symbol,
975                quantity,
976                timestamp_ms,
977            } => {
978                h.update(request_id.as_bytes());
979                h.update(wallet.as_bytes());
980                h.update(symbol.as_bytes());
981                h.update(&quantity.serialize());
982                h.update(&timestamp_ms.to_le_bytes());
983            }
984            EngineCommand::OptionWithdrawalUpdate {
985                request_id,
986                wallet,
987                account,
988                signer,
989                rsm_signer,
990                symbol,
991                quantity,
992                nonce,
993                action,
994                timestamp_ms,
995            } => {
996                h.update(request_id.as_bytes());
997                h.update(wallet.as_bytes());
998                h.update(account.as_bytes());
999                h.update(signer.as_bytes());
1000                h.update(rsm_signer.as_bytes());
1001                h.update(symbol.as_bytes());
1002                h.update(&quantity.serialize());
1003                h.update(&[nonce.is_some() as u8]);
1004                if let Some(nonce) = nonce {
1005                    h.update(&nonce.to_le_bytes());
1006                }
1007                h.update(action);
1008                h.update(&timestamp_ms.to_le_bytes());
1009            }
1010            EngineCommand::CashWithdrawalUpdate {
1011                request_id,
1012                wallet,
1013                account,
1014                destination,
1015                signer,
1016                rsm_signer,
1017                amount,
1018                amount_wei,
1019                nonce,
1020                timestamp_ms,
1021            } => {
1022                h.update(request_id.as_bytes());
1023                h.update(wallet.as_bytes());
1024                h.update(account.as_bytes());
1025                h.update(destination.as_bytes());
1026                h.update(signer.as_bytes());
1027                h.update(rsm_signer.as_bytes());
1028                h.update(&amount.serialize());
1029                h.update(&amount_wei.to_le_bytes());
1030                h.update(&[nonce.is_some() as u8]);
1031                if let Some(nonce) = nonce {
1032                    h.update(&nonce.to_le_bytes());
1033                }
1034                h.update(&timestamp_ms.to_le_bytes());
1035            }
1036            EngineCommand::ApproveAgent {
1037                wallet,
1038                agent,
1039                expires_at_ms,
1040                nonce,
1041                timestamp_ms,
1042            } => {
1043                h.update(wallet.as_bytes());
1044                h.update(agent.as_bytes());
1045                h.update([expires_at_ms.is_some() as u8]);
1046                if let Some(ts) = expires_at_ms {
1047                    h.update(ts.to_le_bytes());
1048                }
1049                h.update([nonce.is_some() as u8]);
1050                if let Some(n) = nonce {
1051                    h.update(n.to_le_bytes());
1052                }
1053                h.update(timestamp_ms.to_le_bytes());
1054            }
1055            EngineCommand::RevokeAgent {
1056                wallet,
1057                agent,
1058                nonce,
1059                timestamp_ms,
1060            } => {
1061                h.update(wallet.as_bytes());
1062                h.update(agent.as_bytes());
1063                h.update([nonce.is_some() as u8]);
1064                if let Some(n) = nonce {
1065                    h.update(n.to_le_bytes());
1066                }
1067                h.update(timestamp_ms.to_le_bytes());
1068            }
1069            EngineCommand::NonceAdvance {
1070                wallet,
1071                nonce,
1072                timestamp_ms,
1073            } => {
1074                h.update(wallet.as_bytes());
1075                h.update(nonce.to_le_bytes());
1076                h.update(timestamp_ms.to_le_bytes());
1077            }
1078            EngineCommand::HypercoreEquityUpdate {
1079                wallet,
1080                account_value,
1081                timestamp_ms,
1082            } => {
1083                h.update(wallet.as_bytes());
1084                h.update(account_value.serialize());
1085                h.update(timestamp_ms.to_le_bytes());
1086            }
1087            EngineCommand::SetPmSettlementPoolConfig(cmd) => {
1088                h.update(cmd.request_id.as_bytes());
1089                h.update(cmd.input_digest.as_bytes());
1090                h.update(cmd.underlying.as_bytes());
1091                h.update(cmd.config_version.to_le_bytes());
1092                h.update(cmd.config.target_short_oi_notional_multiplier.serialize());
1093                h.update(cmd.config.utilization_kink.serialize());
1094                h.update(cmd.config.apr_at_kink.serialize());
1095                h.update(cmd.config.max_apr.serialize());
1096                h.update(cmd.config.normal_utilization_cap.serialize());
1097                h.update(cmd.config.crisis_utilization_cap.serialize());
1098                h.update(cmd.config.bridge_window_ms.to_le_bytes());
1099                h.update(cmd.config.policy_version.to_le_bytes());
1100                h.update(cmd.timestamp_ms.to_le_bytes());
1101            }
1102            EngineCommand::RecordPmVaultDeposit(cmd) => {
1103                h.update(cmd.request_id.as_bytes());
1104                h.update(cmd.input_digest.as_bytes());
1105                h.update(cmd.depositor.as_bytes());
1106                h.update(cmd.underlying.as_bytes());
1107                h.update(cmd.amount_usdc.serialize());
1108                h.update(cmd.chain_id.to_le_bytes());
1109                h.update(cmd.source_contract_address.as_bytes());
1110                update_len_prefixed(&mut h, &cmd.tx_hash);
1111                h.update(cmd.log_index.to_le_bytes());
1112                h.update(cmd.max_listed_expiry_ts_ms.to_le_bytes());
1113                h.update(cmd.settlement_grace_ms.to_le_bytes());
1114                h.update(cmd.timestamp_ms.to_le_bytes());
1115            }
1116            EngineCommand::RequestPmVaultWithdrawal(cmd) => {
1117                h.update(cmd.request_id.as_bytes());
1118                h.update(cmd.input_digest.as_bytes());
1119                h.update(cmd.depositor.as_bytes());
1120                h.update(cmd.underlying.as_bytes());
1121                h.update(cmd.deposit_id.as_bytes());
1122                h.update(cmd.amount_usdc.serialize());
1123                h.update(cmd.timestamp_ms.to_le_bytes());
1124            }
1125            EngineCommand::AccruePmSettlementInterest(cmd) => {
1126                h.update(cmd.request_id.as_bytes());
1127                h.update(cmd.input_digest.as_bytes());
1128                h.update(cmd.wallet.as_bytes());
1129                h.update(cmd.underlying.as_bytes());
1130                h.update(cmd.to_ms.to_le_bytes());
1131                h.update(cmd.timestamp_ms.to_le_bytes());
1132            }
1133            EngineCommand::ApplyPmSettlementRepayment(cmd) => {
1134                h.update(cmd.request_id.as_bytes());
1135                h.update(cmd.input_digest.as_bytes());
1136                h.update(cmd.wallet.as_bytes());
1137                h.update(cmd.underlying.as_bytes());
1138                h.update(cmd.amount_usdc.serialize());
1139                h.update(cmd.reason.as_bytes());
1140                h.update(cmd.source_event_id.as_bytes());
1141                h.update(cmd.timestamp_ms.to_le_bytes());
1142            }
1143            EngineCommand::JournalPmRecoveryPlan(cmd) => {
1144                h.update(cmd.request_id.as_bytes());
1145                h.update(cmd.input_digest.as_bytes());
1146                update_pm_recovery_plan_hash(&mut h, &cmd.plan);
1147                h.update(cmd.timestamp_ms.to_le_bytes());
1148            }
1149            EngineCommand::MarkPmRecoveryActionSubmitted(cmd) => {
1150                h.update(cmd.request_id.as_bytes());
1151                h.update(cmd.input_digest.as_bytes());
1152                h.update(cmd.wallet.as_bytes());
1153                update_len_prefixed(&mut h, &cmd.plan_id);
1154                h.update(cmd.action_index.to_le_bytes());
1155                h.update(cmd.attempt.to_le_bytes());
1156                update_len_prefixed(&mut h, &cmd.external_id);
1157                h.update([cmd.external_kind as u8]);
1158                h.update(cmd.timestamp_ms.to_le_bytes());
1159            }
1160            EngineCommand::ResolvePmRecoveryAction(cmd) => {
1161                h.update(cmd.request_id.as_bytes());
1162                h.update(cmd.input_digest.as_bytes());
1163                h.update(cmd.wallet.as_bytes());
1164                update_len_prefixed(&mut h, &cmd.plan_id);
1165                h.update(cmd.action_index.to_le_bytes());
1166                h.update(cmd.attempt.to_le_bytes());
1167                h.update([cmd.result as u8]);
1168                h.update(cmd.recovered_usdc.serialize());
1169                h.update(cmd.liability_reduction_usdc.serialize());
1170                h.update([cmd.result_external_id.is_some() as u8]);
1171                if let Some(external_id) = &cmd.result_external_id {
1172                    update_len_prefixed(&mut h, external_id);
1173                }
1174                h.update(cmd.timestamp_ms.to_le_bytes());
1175            }
1176        }
1177
1178        h.finalize().into()
1179    }
1180}
1181
1182fn update_pm_recovery_plan_hash<D: sha3::Digest>(h: &mut D, plan: &PmRecoveryPlanCommand) {
1183    update_len_prefixed(h, &plan.plan_id);
1184    h.update(plan.wallet.as_bytes());
1185    update_len_prefixed(h, &plan.underlying);
1186    h.update([plan.trigger as u8]);
1187    h.update([plan.reason as u8]);
1188    h.update(plan.policy_version.to_le_bytes());
1189    h.update(plan.recovery_priority_version.to_le_bytes());
1190    h.update(plan.target_reduction_usdc.serialize());
1191    h.update(plan.expected_usdc_recovered.serialize());
1192    h.update(plan.expected_obligation_reduced.serialize());
1193    h.update(plan.expected_impact_usdc.serialize());
1194    h.update([plan.post_plan_utilization.is_some() as u8]);
1195    if let Some(utilization) = plan.post_plan_utilization {
1196        h.update(utilization.serialize());
1197    }
1198    h.update((plan.actions.len() as u64).to_le_bytes());
1199    for action in &plan.actions {
1200        h.update(action.action_index.to_le_bytes());
1201        h.update(action.expected_usdc_recovered.serialize());
1202        h.update(action.expected_obligation_reduced.serialize());
1203        h.update(action.expected_impact_usdc.serialize());
1204        match &action.action {
1205            PmRecoveryActionKind::CancelOrder { order_id, reason } => {
1206                h.update([0]);
1207                h.update(order_id.to_le_bytes());
1208                h.update([*reason as u8]);
1209            }
1210            PmRecoveryActionKind::ClosePerp {
1211                asset,
1212                size,
1213                reduce_only,
1214            } => {
1215                h.update([1]);
1216                update_len_prefixed(h, asset);
1217                h.update(size.serialize());
1218                h.update([*reduce_only as u8]);
1219            }
1220            PmRecoveryActionKind::CloseOption {
1221                market_id,
1222                size,
1223                side,
1224            } => {
1225                h.update([2]);
1226                update_len_prefixed(h, market_id);
1227                h.update(size.serialize());
1228                update_len_prefixed(h, &format!("{side:?}"));
1229            }
1230            PmRecoveryActionKind::TransferCollateral {
1231                source,
1232                amount_usdc,
1233            } => {
1234                h.update([3]);
1235                update_len_prefixed(h, source);
1236                h.update(amount_usdc.serialize());
1237            }
1238            PmRecoveryActionKind::EscalateManual { reason } => {
1239                h.update([4]);
1240                h.update([*reason as u8]);
1241            }
1242        }
1243    }
1244}
1245
1246/// Output from `apply()`: the events produced and a state hash.
1247#[derive(Debug, Default)]
1248pub struct ApplyOutput {
1249    pub events: Vec<crate::traits::EngineEvent>,
1250    pub hash: [u8; 32],
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256    use rust_decimal_macros::dec;
1257
1258    #[test]
1259    fn pm_interest_command_rejects_legacy_financial_fields() {
1260        let command = AccruePmSettlementInterestCommand {
1261            request_id: Uuid::nil(),
1262            input_digest: "digest".to_string(),
1263            wallet: hypercall_types::test_wallet(1),
1264            underlying: "BTC".to_string(),
1265            to_ms: 1_000,
1266            timestamp_ms: 1_000,
1267        };
1268        let mut value =
1269            serde_json::to_value(command).expect("PM interest command should serialize");
1270        let object = value
1271            .as_object_mut()
1272            .expect("PM interest command should serialize as object");
1273        object.insert("from_ms".to_string(), serde_json::json!(0));
1274        object.insert("utilization".to_string(), serde_json::json!(dec!(0.8)));
1275        object.insert("apr".to_string(), serde_json::json!(dec!(0.12)));
1276        object.insert("interest_usdc".to_string(), serde_json::json!(dec!(1.23)));
1277        object.insert("policy_version".to_string(), serde_json::json!(1));
1278
1279        let error = serde_json::from_value::<AccruePmSettlementInterestCommand>(value)
1280            .expect_err("legacy PM interest command shape must fail fast");
1281
1282        assert!(
1283            error.to_string().contains("unknown field"),
1284            "unexpected error: {error}"
1285        );
1286    }
1287}