1use 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#[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(×tamp_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(×tamp_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(×tamp_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#[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}