1use hypercall_types::{Side, WalletAddress};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use sha3::{Digest, Keccak256};
5
6pub const RECOVERY_PRIORITY_VERSION: u32 = 1;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub enum PmRecoveryTrigger {
10 Debt,
11 OverdueBridge,
12 MaintenanceBreach,
13 UtilizationBreach,
14 CrisisCap,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum PmRecoveryReason {
19 SettlementDebt,
20 TimingBridge,
21 Maintenance,
22 Utilization,
23 CrisisCap,
24 StaleMarketState,
25 NoActionableRecovery,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct PmRecoveryPlannerInput {
30 pub wallet: WalletAddress,
31 pub underlying: String,
32 pub trigger: PmRecoveryTrigger,
33 pub reason: PmRecoveryReason,
34 pub trigger_event_id: String,
35 pub trigger_sequence: u64,
36 pub policy_version: u32,
37 pub liability_shape: String,
38 pub target_reduction_usdc: Decimal,
39 pub pool_capacity_usdc: Decimal,
40 pub active_liability_usdc: Decimal,
41 pub market_state_stale: bool,
42 pub bridge_deadline_ms: Option<i64>,
43 pub now_ms: i64,
44 pub open_orders: Vec<PmRecoveryOpenOrder>,
45 pub perps: Vec<PmRecoveryPerpPosition>,
46 pub options: Vec<PmRecoveryOptionPosition>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct PmRecoveryOpenOrder {
51 pub order_id: u64,
52 pub target_key: String,
53 pub risk_increasing: bool,
54 pub capacity_usdc: Decimal,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct PmRecoveryPerpPosition {
59 pub asset: String,
60 pub size: Decimal,
61 pub unrealized_pnl_usdc: Decimal,
62 pub market_impact_usdc: Decimal,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct PmRecoveryOptionPosition {
67 pub market_id: String,
68 pub size: Decimal,
69 pub side_to_close: Side,
70 pub expiry_ts_ms: i64,
71 pub itm: bool,
72 pub obligation_reduction_usdc: Decimal,
73 pub timing_bridge_usdc: Decimal,
74 pub hedge_breakage_usdc: Decimal,
75 pub market_impact_usdc: Decimal,
76 pub later_dated_hedge: bool,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct PmRecoveryPlan {
81 pub plan_id: String,
82 pub wallet: WalletAddress,
83 pub underlying: String,
84 pub trigger: PmRecoveryTrigger,
85 pub reason: PmRecoveryReason,
86 pub policy_version: u32,
87 pub recovery_priority_version: u32,
88 pub input_digest: String,
89 pub target_reduction_usdc: Decimal,
90 pub expected_usdc_recovered: Decimal,
91 pub expected_obligation_reduced: Decimal,
92 pub expected_impact_usdc: Decimal,
93 pub post_plan_utilization: Option<Decimal>,
94 pub actions: Vec<PmRecoveryAction>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub enum PmRecoveryAction {
99 CancelOrder {
100 order_id: u64,
101 reason: PmRecoveryReason,
102 },
103 ClosePerp {
104 asset: String,
105 size: Decimal,
106 reduce_only: bool,
107 },
108 CloseOption {
109 market_id: String,
110 size: Decimal,
111 side: Side,
112 },
113 TransferCollateral {
114 source: String,
115 amount_usdc: Decimal,
116 },
117 EscalateManual {
118 reason: PmRecoveryReason,
119 },
120}
121
122pub fn plan_pm_recovery(input: PmRecoveryPlannerInput) -> PmRecoveryPlan {
123 let input_digest = input_digest(&input);
124 let mut candidates = recovery_candidates(&input);
125 candidates.sort_by(compare_candidates);
126
127 let actions = if input.market_state_stale {
128 vec![PlannedCandidate::manual(PmRecoveryReason::StaleMarketState)]
129 } else if candidates.is_empty() {
130 vec![PlannedCandidate::manual(
131 PmRecoveryReason::NoActionableRecovery,
132 )]
133 } else {
134 candidates
135 };
136
137 let expected_usdc_recovered = actions.iter().fold(Decimal::ZERO, |acc, action| {
138 acc + action.expected_usdc_recovered
139 });
140 let expected_obligation_reduced = actions.iter().fold(Decimal::ZERO, |acc, action| {
141 acc + action.expected_obligation_reduced
142 });
143 let expected_impact_usdc = actions.iter().fold(Decimal::ZERO, |acc, action| {
144 acc + action.expected_impact_usdc
145 });
146 let projected_active_liability =
147 (input.active_liability_usdc - expected_obligation_reduced).max(Decimal::ZERO);
148 let post_plan_utilization = if input.pool_capacity_usdc > Decimal::ZERO {
149 Some(projected_active_liability / input.pool_capacity_usdc)
150 } else {
151 None
152 };
153
154 PmRecoveryPlan {
155 plan_id: plan_id(&input, &input_digest),
156 wallet: input.wallet,
157 underlying: input.underlying,
158 trigger: input.trigger,
159 reason: input.reason,
160 policy_version: input.policy_version,
161 recovery_priority_version: RECOVERY_PRIORITY_VERSION,
162 input_digest,
163 target_reduction_usdc: input.target_reduction_usdc,
164 expected_usdc_recovered,
165 expected_obligation_reduced,
166 expected_impact_usdc,
167 post_plan_utilization,
168 actions: actions
169 .into_iter()
170 .map(|candidate| candidate.action)
171 .collect(),
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176struct PlannedCandidate {
177 priority: u8,
178 expected_usdc_recovered: Decimal,
179 expected_obligation_reduced: Decimal,
180 expected_impact_usdc: Decimal,
181 bridge_deadline_ms: Option<i64>,
182 target_key: String,
183 original_id: String,
184 action: PmRecoveryAction,
185}
186
187impl PlannedCandidate {
188 fn manual(reason: PmRecoveryReason) -> Self {
189 Self {
190 priority: 99,
191 expected_usdc_recovered: Decimal::ZERO,
192 expected_obligation_reduced: Decimal::ZERO,
193 expected_impact_usdc: Decimal::ZERO,
194 bridge_deadline_ms: None,
195 target_key: format!("manual:{reason:?}"),
196 original_id: "manual".to_string(),
197 action: PmRecoveryAction::EscalateManual { reason },
198 }
199 }
200}
201
202fn recovery_candidates(input: &PmRecoveryPlannerInput) -> Vec<PlannedCandidate> {
203 if input.market_state_stale {
204 return Vec::new();
205 }
206 let mut candidates = Vec::new();
207
208 let mut open_orders = input.open_orders.clone();
209 open_orders.sort_by(|left, right| {
210 left.target_key
211 .cmp(&right.target_key)
212 .then_with(|| left.order_id.cmp(&right.order_id))
213 });
214 for order in open_orders
215 .into_iter()
216 .filter(|order| order.risk_increasing)
217 {
218 candidates.push(PlannedCandidate {
219 priority: 0,
220 expected_usdc_recovered: Decimal::ZERO,
221 expected_obligation_reduced: order.capacity_usdc,
222 expected_impact_usdc: Decimal::ZERO,
223 bridge_deadline_ms: input.bridge_deadline_ms,
224 target_key: order.target_key,
225 original_id: order.order_id.to_string(),
226 action: PmRecoveryAction::CancelOrder {
227 order_id: order.order_id,
228 reason: input.reason,
229 },
230 });
231 }
232
233 let mut perps = input.perps.clone();
234 perps.sort_by(|left, right| left.asset.cmp(&right.asset));
235 for perp in perps
236 .into_iter()
237 .filter(|perp| perp.unrealized_pnl_usdc > Decimal::ZERO && perp.size != Decimal::ZERO)
238 {
239 candidates.push(PlannedCandidate {
240 priority: 1,
241 expected_usdc_recovered: perp.unrealized_pnl_usdc,
242 expected_obligation_reduced: perp.unrealized_pnl_usdc,
243 expected_impact_usdc: perp.market_impact_usdc,
244 bridge_deadline_ms: input.bridge_deadline_ms,
245 target_key: format!("perp:{}", perp.asset),
246 original_id: perp.asset.clone(),
247 action: PmRecoveryAction::ClosePerp {
248 asset: perp.asset,
249 size: perp.size.abs(),
250 reduce_only: true,
251 },
252 });
253 }
254
255 let bridge_is_aged = input
256 .bridge_deadline_ms
257 .is_some_and(|deadline| input.now_ms >= deadline);
258 let mut options = input.options.clone();
259 options.sort_by(|left, right| {
260 left.expiry_ts_ms
261 .cmp(&right.expiry_ts_ms)
262 .then_with(|| left.market_id.cmp(&right.market_id))
263 });
264 for option in options
265 .into_iter()
266 .filter(|option| option.size > Decimal::ZERO)
267 {
268 if option.later_dated_hedge && !bridge_is_aged {
269 continue;
270 }
271 let option_class = OptionRecoveryClass::classify(&option);
272 candidates.push(PlannedCandidate {
273 priority: option_class.priority(),
274 expected_usdc_recovered: Decimal::ZERO,
275 expected_obligation_reduced: option.obligation_reduction_usdc
276 + option.timing_bridge_usdc,
277 expected_impact_usdc: option.market_impact_usdc + option.hedge_breakage_usdc,
278 bridge_deadline_ms: input.bridge_deadline_ms,
279 target_key: format!("option:{}", option.market_id),
280 original_id: option.market_id.clone(),
281 action: PmRecoveryAction::CloseOption {
282 market_id: option.market_id,
283 size: option.size,
284 side: option.side_to_close,
285 },
286 });
287 }
288
289 candidates
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum OptionRecoveryClass {
294 NearTermItmShort,
295 TimingBridgeDriver,
296 AgedCalendarHedge,
297 ResidualRiskReduction,
298}
299
300impl OptionRecoveryClass {
301 fn classify(option: &PmRecoveryOptionPosition) -> Self {
302 match (
303 option.itm,
304 option.timing_bridge_usdc > Decimal::ZERO,
305 option.later_dated_hedge,
306 ) {
307 (true, _, _) => Self::NearTermItmShort,
308 (false, true, _) => Self::TimingBridgeDriver,
309 (false, false, true) => Self::AgedCalendarHedge,
310 (false, false, false) => Self::ResidualRiskReduction,
311 }
312 }
313
314 fn priority(self) -> u8 {
315 match self {
316 Self::NearTermItmShort => 2,
317 Self::TimingBridgeDriver => 3,
318 Self::AgedCalendarHedge => 4,
319 Self::ResidualRiskReduction => 5,
320 }
321 }
322}
323
324fn compare_candidates(left: &PlannedCandidate, right: &PlannedCandidate) -> std::cmp::Ordering {
325 left.priority
326 .cmp(&right.priority)
327 .then_with(|| {
328 right
329 .expected_usdc_recovered
330 .cmp(&left.expected_usdc_recovered)
331 })
332 .then_with(|| {
333 right
334 .expected_obligation_reduced
335 .cmp(&left.expected_obligation_reduced)
336 })
337 .then_with(|| left.expected_impact_usdc.cmp(&right.expected_impact_usdc))
338 .then_with(|| left.bridge_deadline_ms.cmp(&right.bridge_deadline_ms))
339 .then_with(|| left.target_key.cmp(&right.target_key))
340 .then_with(|| left.original_id.cmp(&right.original_id))
341}
342
343fn input_digest(input: &PmRecoveryPlannerInput) -> String {
344 let mut h = Keccak256::new();
345 h.update(input.wallet.as_bytes());
346 update_len_prefixed(&mut h, &input.underlying);
347 h.update([input.trigger as u8]);
348 h.update([input.reason as u8]);
349 update_len_prefixed(&mut h, &input.trigger_event_id);
350 h.update(input.trigger_sequence.to_le_bytes());
351 h.update(input.policy_version.to_le_bytes());
352 update_len_prefixed(&mut h, &input.liability_shape);
353 h.update(input.target_reduction_usdc.serialize());
354 h.update(input.pool_capacity_usdc.serialize());
355 h.update(input.active_liability_usdc.serialize());
356 h.update([input.market_state_stale as u8]);
357 h.update([input.bridge_deadline_ms.is_some() as u8]);
358 h.update(input.bridge_deadline_ms.unwrap_or_default().to_le_bytes());
359 h.update(input.now_ms.to_le_bytes());
360 for order in sorted_orders(&input.open_orders) {
361 h.update(order.order_id.to_le_bytes());
362 update_len_prefixed(&mut h, &order.target_key);
363 h.update([order.risk_increasing as u8]);
364 h.update(order.capacity_usdc.serialize());
365 }
366 for perp in sorted_perps(&input.perps) {
367 update_len_prefixed(&mut h, &perp.asset);
368 h.update(perp.size.serialize());
369 h.update(perp.unrealized_pnl_usdc.serialize());
370 h.update(perp.market_impact_usdc.serialize());
371 }
372 for option in sorted_options(&input.options) {
373 update_len_prefixed(&mut h, &option.market_id);
374 h.update(option.size.serialize());
375 update_len_prefixed(&mut h, side_hash_key(option.side_to_close));
376 h.update(option.expiry_ts_ms.to_le_bytes());
377 h.update([option.itm as u8]);
378 h.update(option.obligation_reduction_usdc.serialize());
379 h.update(option.timing_bridge_usdc.serialize());
380 h.update(option.hedge_breakage_usdc.serialize());
381 h.update(option.market_impact_usdc.serialize());
382 h.update([option.later_dated_hedge as u8]);
383 }
384 format!("0x{}", hex::encode(h.finalize()))
385}
386
387fn side_hash_key(side: Side) -> &'static str {
388 match side {
389 Side::Buy => "Buy",
390 Side::Sell => "Sell",
391 }
392}
393
394fn plan_id(input: &PmRecoveryPlannerInput, input_digest: &str) -> String {
395 let mut h = Keccak256::new();
396 h.update(input.wallet.as_bytes());
397 update_len_prefixed(&mut h, &input.underlying);
398 update_len_prefixed(&mut h, &input.trigger_event_id);
399 h.update([input.reason as u8]);
400 h.update(input.policy_version.to_le_bytes());
401 update_len_prefixed(&mut h, &input.liability_shape);
402 h.update(input.trigger_sequence.to_le_bytes());
403 update_len_prefixed(&mut h, input_digest);
404 format!("pmr-{}", hex::encode(h.finalize()))
405}
406
407fn sorted_orders(orders: &[PmRecoveryOpenOrder]) -> Vec<&PmRecoveryOpenOrder> {
408 let mut sorted: Vec<_> = orders.iter().collect();
409 sorted.sort_by(|left, right| {
410 left.target_key
411 .cmp(&right.target_key)
412 .then_with(|| left.order_id.cmp(&right.order_id))
413 });
414 sorted
415}
416
417fn sorted_perps(perps: &[PmRecoveryPerpPosition]) -> Vec<&PmRecoveryPerpPosition> {
418 let mut sorted: Vec<_> = perps.iter().collect();
419 sorted.sort_by(|left, right| left.asset.cmp(&right.asset));
420 sorted
421}
422
423fn sorted_options(options: &[PmRecoveryOptionPosition]) -> Vec<&PmRecoveryOptionPosition> {
424 let mut sorted: Vec<_> = options.iter().collect();
425 sorted.sort_by(|left, right| {
426 left.expiry_ts_ms
427 .cmp(&right.expiry_ts_ms)
428 .then_with(|| left.market_id.cmp(&right.market_id))
429 });
430 sorted
431}
432
433fn update_len_prefixed<D: Digest>(h: &mut D, value: &str) {
434 h.update((value.len() as u64).to_le_bytes());
435 h.update(value.as_bytes());
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use std::str::FromStr;
442
443 fn wallet() -> WalletAddress {
444 WalletAddress::from_str("0x1234567890123456789012345678901234567890").unwrap()
445 }
446
447 fn base_input() -> PmRecoveryPlannerInput {
448 PmRecoveryPlannerInput {
449 wallet: wallet(),
450 underlying: "BTC".to_string(),
451 trigger: PmRecoveryTrigger::Debt,
452 reason: PmRecoveryReason::SettlementDebt,
453 trigger_event_id: "event-1".to_string(),
454 trigger_sequence: 7,
455 policy_version: 1,
456 liability_shape: "debt:1000".to_string(),
457 target_reduction_usdc: Decimal::from(1000),
458 pool_capacity_usdc: Decimal::from(10_000),
459 active_liability_usdc: Decimal::from(2_000),
460 market_state_stale: false,
461 bridge_deadline_ms: Some(2_000),
462 now_ms: 1_500,
463 open_orders: vec![],
464 perps: vec![],
465 options: vec![],
466 }
467 }
468
469 #[test]
470 fn identical_inputs_produce_identical_plan_id_and_order() {
471 let mut input = base_input();
472 input.open_orders = vec![
473 PmRecoveryOpenOrder {
474 order_id: 2,
475 target_key: "b".to_string(),
476 risk_increasing: true,
477 capacity_usdc: Decimal::from(10),
478 },
479 PmRecoveryOpenOrder {
480 order_id: 1,
481 target_key: "a".to_string(),
482 risk_increasing: true,
483 capacity_usdc: Decimal::from(10),
484 },
485 ];
486 let mut reordered = input.clone();
487 reordered.open_orders.reverse();
488
489 let first = plan_pm_recovery(input);
490 let second = plan_pm_recovery(reordered);
491
492 assert_eq!(first.plan_id, second.plan_id);
493 assert_eq!(first.input_digest, second.input_digest);
494 assert_eq!(first.actions, second.actions);
495 assert!(matches!(
496 first.actions[0],
497 PmRecoveryAction::CancelOrder { order_id: 1, .. }
498 ));
499 }
500
501 #[test]
502 fn bridge_deadline_presence_changes_digest() {
503 let mut none = base_input();
504 none.bridge_deadline_ms = None;
505 let mut zero = none.clone();
506 zero.bridge_deadline_ms = Some(0);
507
508 let none_plan = plan_pm_recovery(none);
509 let zero_plan = plan_pm_recovery(zero);
510
511 assert_ne!(none_plan.input_digest, zero_plan.input_digest);
512 assert_ne!(none_plan.plan_id, zero_plan.plan_id);
513 }
514
515 #[test]
516 fn profitable_perp_ranks_before_unrelated_option() {
517 let mut input = base_input();
518 input.perps = vec![PmRecoveryPerpPosition {
519 asset: "BTC".to_string(),
520 size: Decimal::from(1),
521 unrealized_pnl_usdc: Decimal::from(500),
522 market_impact_usdc: Decimal::from(2),
523 }];
524 input.options = vec![PmRecoveryOptionPosition {
525 market_id: "BTC-20260630-100000-C".to_string(),
526 size: Decimal::from(1),
527 side_to_close: Side::Buy,
528 expiry_ts_ms: 1_780_000_000_000,
529 itm: true,
530 obligation_reduction_usdc: Decimal::from(700),
531 timing_bridge_usdc: Decimal::ZERO,
532 hedge_breakage_usdc: Decimal::ZERO,
533 market_impact_usdc: Decimal::from(1),
534 later_dated_hedge: false,
535 }];
536
537 let plan = plan_pm_recovery(input);
538
539 assert!(matches!(
540 plan.actions[0],
541 PmRecoveryAction::ClosePerp { .. }
542 ));
543 }
544
545 #[test]
546 fn stale_market_state_escalates_without_actions() {
547 let mut input = base_input();
548 input.market_state_stale = true;
549 input.perps = vec![PmRecoveryPerpPosition {
550 asset: "BTC".to_string(),
551 size: Decimal::from(1),
552 unrealized_pnl_usdc: Decimal::from(500),
553 market_impact_usdc: Decimal::from(2),
554 }];
555
556 let plan = plan_pm_recovery(input);
557
558 assert_eq!(
559 plan.actions,
560 vec![PmRecoveryAction::EscalateManual {
561 reason: PmRecoveryReason::StaleMarketState
562 }]
563 );
564 }
565
566 #[test]
567 fn later_dated_hedge_waits_until_bridge_deadline() {
568 let mut input = base_input();
569 input.options = vec![PmRecoveryOptionPosition {
570 market_id: "BTC-20260730-100000-C".to_string(),
571 size: Decimal::from(1),
572 side_to_close: Side::Buy,
573 expiry_ts_ms: 1_782_000_000_000,
574 itm: false,
575 obligation_reduction_usdc: Decimal::ZERO,
576 timing_bridge_usdc: Decimal::from(400),
577 hedge_breakage_usdc: Decimal::from(20),
578 market_impact_usdc: Decimal::from(1),
579 later_dated_hedge: true,
580 }];
581
582 let early = plan_pm_recovery(input.clone());
583 input.now_ms = 2_000;
584 let aged = plan_pm_recovery(input);
585
586 assert!(matches!(
587 early.actions[0],
588 PmRecoveryAction::EscalateManual {
589 reason: PmRecoveryReason::NoActionableRecovery
590 }
591 ));
592 assert!(matches!(
593 aged.actions[0],
594 PmRecoveryAction::CloseOption { .. }
595 ));
596 }
597
598 #[test]
599 fn option_recovery_ranks_larger_liability_reduction_before_lower_impact() {
600 let mut input = base_input();
601 input.options = vec![
602 PmRecoveryOptionPosition {
603 market_id: "BTC-small-bridge".to_string(),
604 size: Decimal::from(1),
605 side_to_close: Side::Buy,
606 expiry_ts_ms: 1_780_000_000_000,
607 itm: false,
608 obligation_reduction_usdc: Decimal::ZERO,
609 timing_bridge_usdc: Decimal::from(50),
610 hedge_breakage_usdc: Decimal::ZERO,
611 market_impact_usdc: Decimal::from(1),
612 later_dated_hedge: false,
613 },
614 PmRecoveryOptionPosition {
615 market_id: "BTC-large-bridge".to_string(),
616 size: Decimal::from(1),
617 side_to_close: Side::Buy,
618 expiry_ts_ms: 1_780_000_000_001,
619 itm: false,
620 obligation_reduction_usdc: Decimal::ZERO,
621 timing_bridge_usdc: Decimal::from(500),
622 hedge_breakage_usdc: Decimal::ZERO,
623 market_impact_usdc: Decimal::from(2),
624 later_dated_hedge: false,
625 },
626 ];
627
628 let plan = plan_pm_recovery(input);
629
630 assert!(matches!(
631 &plan.actions[0],
632 PmRecoveryAction::CloseOption { market_id, .. }
633 if market_id == "BTC-large-bridge"
634 ));
635 }
636}