1use alloc::string::String;
8use templar_vault_kernel::{
9 settle_proportional_raw, AllocatingState, EscrowSettlement, KernelAction, OpState,
10 PayoutOutcome, PayoutState, RefreshingState, WithdrawingState,
11};
12use typed_builder::TypedBuilder;
13
14#[templar_vault_macros::vault_derive]
16#[derive(Clone, PartialEq, Eq)]
17pub enum RecoveryPolicy {
18 Disabled,
20 AfterInactivity {
22 inactivity_threshold_ns: u64,
23 max_total_age_ns: Option<u64>,
24 },
25 Force,
27}
28
29#[templar_vault_macros::vault_derive]
31#[derive(Clone, TypedBuilder)]
32#[builder(field_defaults(setter(into)))]
33pub struct RecoveryContext {
34 pub current_ns: u64,
36 #[builder(default = RecoveryPolicy::Disabled)]
38 pub policy: RecoveryPolicy,
39}
40
41impl RecoveryContext {
42 #[must_use]
43 pub fn new(current_ns: u64) -> Self {
44 Self {
45 current_ns,
46 policy: RecoveryPolicy::Disabled,
47 }
48 }
49
50 #[must_use]
51 pub fn after_inactivity(current_ns: u64, inactivity_threshold_ns: u64) -> Self {
52 Self {
53 current_ns,
54 policy: RecoveryPolicy::AfterInactivity {
55 inactivity_threshold_ns,
56 max_total_age_ns: None,
57 },
58 }
59 }
60
61 #[must_use]
62 pub fn after_inactivity_with_max_age(
63 current_ns: u64,
64 inactivity_threshold_ns: u64,
65 max_total_age_ns: u64,
66 ) -> Self {
67 Self {
68 current_ns,
69 policy: RecoveryPolicy::AfterInactivity {
70 inactivity_threshold_ns,
71 max_total_age_ns: Some(max_total_age_ns),
72 },
73 }
74 }
75
76 #[must_use]
77 pub fn forced(current_ns: u64) -> Self {
78 Self {
79 current_ns,
80 policy: RecoveryPolicy::Force,
81 }
82 }
83}
84
85impl Default for RecoveryContext {
86 fn default() -> Self {
87 Self::new(0)
88 }
89}
90
91#[templar_vault_macros::vault_derive]
93#[derive(Clone, Copy, PartialEq, Eq, TypedBuilder)]
94#[builder(field_defaults(setter(into)))]
95pub struct RecoveryProgress {
96 pub op_id: u64,
98 pub started_at_ns: u64,
100 pub last_progress_ns: u64,
102}
103
104impl RecoveryProgress {
105 #[must_use]
106 pub const fn new(op_id: u64, started_at_ns: u64) -> Self {
107 Self {
108 op_id,
109 started_at_ns,
110 last_progress_ns: started_at_ns,
111 }
112 }
113
114 #[must_use]
115 pub const fn with_last_progress(op_id: u64, started_at_ns: u64, last_progress_ns: u64) -> Self {
116 Self {
117 op_id,
118 started_at_ns,
119 last_progress_ns,
120 }
121 }
122}
123
124#[templar_vault_macros::vault_derive]
126#[derive(Clone, Copy, PartialEq, Eq)]
127pub enum PayoutRecoveryEvidence {
128 Failure { restore_idle: u128 },
130 Success { collected_amount: u128 },
132}
133
134#[templar_vault_macros::vault_derive(borsh, serde)]
136#[derive(Clone, PartialEq, Eq)]
137pub enum RecoveryError {
138 UnknownPayoutState {
139 op_id: u64,
140 },
141 ProgressOpMismatch {
142 expected_op_id: u64,
143 progress_op_id: u64,
144 },
145 InvalidProgressTimestamps {
146 started_at_ns: u64,
147 last_progress_ns: u64,
148 current_ns: u64,
149 },
150 ExpectedAmountZero {
151 escrow_shares: u128,
152 collected_amount: u128,
153 },
154 CollectedExceedsExpected {
155 expected_amount: u128,
156 collected_amount: u128,
157 },
158 InvalidPayoutEvidence,
159}
160
161#[templar_vault_macros::vault_derive(borsh, serde)]
163#[derive(Clone, PartialEq, Eq)]
164pub struct RecoveryOutcome {
165 pub action: KernelAction,
166 pub planned: bool,
167 pub message: Option<String>,
168}
169
170impl RecoveryOutcome {
171 #[must_use]
172 pub fn planned(action: KernelAction) -> Self {
173 Self {
174 action,
175 planned: true,
176 message: None,
177 }
178 }
179
180 #[must_use]
181 pub fn planned_with_message(action: KernelAction, message: impl Into<String>) -> Self {
182 Self {
183 action,
184 planned: true,
185 message: Some(message.into()),
186 }
187 }
188}
189
190pub fn determine_recovery_action(
192 state: &OpState,
193 context: &RecoveryContext,
194 progress: &RecoveryProgress,
195 payout_evidence: Option<PayoutRecoveryEvidence>,
196) -> Result<Option<KernelAction>, RecoveryError> {
197 let Some(op_id) = state_op_id(state) else {
198 return Ok(None);
199 };
200
201 validate_progress(op_id, context.current_ns, progress)?;
202
203 if !is_recovery_eligible(context, progress) {
204 return Ok(None);
205 }
206
207 match state {
208 OpState::Allocating(alloc) => Ok(Some(abort_allocating_action(alloc))),
209 OpState::Withdrawing(withdraw) => Ok(Some(abort_withdrawing_action(withdraw))),
210 OpState::Refreshing(refresh) => Ok(Some(abort_refreshing_action(refresh))),
211 OpState::Payout(payout) => payout_evidence
212 .ok_or(RecoveryError::UnknownPayoutState {
213 op_id: payout.op_id,
214 })
215 .and_then(|evidence| settle_payout_action(payout, evidence).map(Some)),
216 OpState::Idle => Ok(None),
217 }
218}
219
220#[must_use]
222pub fn plan_allocation_recovery(
223 state: &AllocatingState,
224 failure_reason: impl Into<String>,
225) -> RecoveryOutcome {
226 RecoveryOutcome::planned_with_message(abort_allocating_action(state), failure_reason)
227}
228
229#[must_use]
231pub fn plan_withdrawal_recovery(
232 state: &WithdrawingState,
233 failure_reason: impl Into<String>,
234) -> RecoveryOutcome {
235 RecoveryOutcome::planned_with_message(abort_withdrawing_action(state), failure_reason)
236}
237
238#[must_use]
240pub fn plan_refresh_recovery(
241 state: &RefreshingState,
242 failure_reason: impl Into<String>,
243) -> RecoveryOutcome {
244 RecoveryOutcome::planned_with_message(abort_refreshing_action(state), failure_reason)
245}
246
247pub fn plan_payout_recovery(
249 state: &PayoutState,
250 evidence: PayoutRecoveryEvidence,
251 failure_reason: impl Into<String>,
252) -> Result<RecoveryOutcome, RecoveryError> {
253 settle_payout_action(state, evidence)
254 .map(|action| RecoveryOutcome::planned_with_message(action, failure_reason))
255}
256
257pub fn compute_settlement_shares(
259 escrow_shares: u128,
260 expected_amount: u128,
261 collected_amount: u128,
262) -> Result<EscrowSettlement, RecoveryError> {
263 if escrow_shares == 0 {
264 return Ok(EscrowSettlement::refund_all(0));
265 }
266
267 if expected_amount == 0 {
268 return Err(RecoveryError::ExpectedAmountZero {
269 escrow_shares,
270 collected_amount,
271 });
272 }
273
274 if collected_amount > expected_amount {
275 return Err(RecoveryError::CollectedExceedsExpected {
276 expected_amount,
277 collected_amount,
278 });
279 }
280
281 Ok(settle_proportional_raw(
282 escrow_shares,
283 expected_amount,
284 collected_amount,
285 ))
286}
287
288pub fn compute_payout_success_outcome(
290 escrow_shares: u128,
291 expected_amount: u128,
292 collected_amount: u128,
293) -> Result<PayoutOutcome, RecoveryError> {
294 compute_settlement_shares(escrow_shares, expected_amount, collected_amount)?;
295 if collected_amount != expected_amount {
296 return Err(RecoveryError::InvalidPayoutEvidence);
297 }
298 Ok(PayoutOutcome::Success)
299}
300
301pub fn compute_payout_failure_outcome(
303 escrow_shares: u128,
304 payout_amount: u128,
305 restore_idle: u128,
306) -> Result<PayoutOutcome, RecoveryError> {
307 let _ = escrow_shares;
308 if restore_idle != 0 && restore_idle != payout_amount {
309 return Err(RecoveryError::InvalidPayoutEvidence);
310 }
311 Ok(PayoutOutcome::Failure)
312}
313
314#[templar_vault_macros::vault_derive]
316#[derive(Clone, Copy, Default)]
317pub struct RecoveryStats {
318 pub completed_targets: usize,
320 pub remaining_targets: usize,
322 pub collected_amount: u128,
324 pub remaining_amount: u128,
326 pub escrow_shares: u128,
328}
329
330pub fn compute_recovery_stats(state: &OpState) -> RecoveryStats {
331 match state {
332 OpState::Idle => RecoveryStats::default(),
333 OpState::Allocating(alloc) => {
334 let completed_targets = (alloc.index as usize).min(alloc.plan.len());
335 RecoveryStats {
336 completed_targets,
337 remaining_targets: alloc.plan.len().saturating_sub(completed_targets),
338 remaining_amount: alloc.remaining,
339 ..RecoveryStats::default()
340 }
341 }
342 OpState::Withdrawing(withdraw) => RecoveryStats {
343 completed_targets: withdraw.index as usize,
344 collected_amount: withdraw.collected,
345 remaining_amount: withdraw.remaining,
346 escrow_shares: withdraw.escrow_shares,
347 ..RecoveryStats::default()
348 },
349 OpState::Refreshing(refresh) => {
350 let completed_targets = (refresh.index as usize).min(refresh.plan.len());
351 RecoveryStats {
352 completed_targets,
353 remaining_targets: refresh.plan.len().saturating_sub(completed_targets),
354 ..RecoveryStats::default()
355 }
356 }
357 OpState::Payout(payout) => RecoveryStats {
358 collected_amount: payout.amount,
359 escrow_shares: payout.escrow_shares,
360 ..RecoveryStats::default()
361 },
362 }
363}
364
365fn validate_progress(
366 expected_op_id: u64,
367 current_ns: u64,
368 progress: &RecoveryProgress,
369) -> Result<(), RecoveryError> {
370 if progress.op_id != expected_op_id {
371 return Err(RecoveryError::ProgressOpMismatch {
372 expected_op_id,
373 progress_op_id: progress.op_id,
374 });
375 }
376
377 if progress.started_at_ns > progress.last_progress_ns || progress.last_progress_ns > current_ns
378 {
379 return Err(RecoveryError::InvalidProgressTimestamps {
380 started_at_ns: progress.started_at_ns,
381 last_progress_ns: progress.last_progress_ns,
382 current_ns,
383 });
384 }
385
386 Ok(())
387}
388
389fn is_recovery_eligible(context: &RecoveryContext, progress: &RecoveryProgress) -> bool {
390 match &context.policy {
391 RecoveryPolicy::Disabled => false,
392 RecoveryPolicy::Force => true,
393 RecoveryPolicy::AfterInactivity {
394 inactivity_threshold_ns,
395 max_total_age_ns,
396 } => {
397 let inactive_for_ns = context.current_ns.saturating_sub(progress.last_progress_ns);
398 let total_age_ns = context.current_ns.saturating_sub(progress.started_at_ns);
399
400 inactive_for_ns >= *inactivity_threshold_ns
401 || max_total_age_ns.is_some_and(|max_total_age_ns| total_age_ns >= max_total_age_ns)
402 }
403 }
404}
405
406fn state_op_id(state: &OpState) -> Option<u64> {
407 match state {
408 OpState::Idle => None,
409 OpState::Allocating(state) => Some(state.op_id),
410 OpState::Withdrawing(state) => Some(state.op_id),
411 OpState::Refreshing(state) => Some(state.op_id),
412 OpState::Payout(state) => Some(state.op_id),
413 }
414}
415
416fn abort_allocating_action(state: &AllocatingState) -> KernelAction {
417 KernelAction::AbortAllocating { op_id: state.op_id }
418}
419
420fn abort_withdrawing_action(state: &WithdrawingState) -> KernelAction {
421 KernelAction::AbortWithdrawing { op_id: state.op_id }
422}
423
424fn abort_refreshing_action(state: &RefreshingState) -> KernelAction {
425 KernelAction::AbortRefreshing { op_id: state.op_id }
426}
427
428fn settle_payout_action(
429 state: &PayoutState,
430 evidence: PayoutRecoveryEvidence,
431) -> Result<KernelAction, RecoveryError> {
432 let outcome = match evidence {
433 PayoutRecoveryEvidence::Failure { restore_idle } => {
434 compute_payout_failure_outcome(state.escrow_shares, state.amount, restore_idle)?
435 }
436 PayoutRecoveryEvidence::Success { collected_amount } => {
437 compute_payout_success_outcome(state.escrow_shares, state.amount, collected_amount)?
438 }
439 };
440
441 Ok(KernelAction::SettlePayout {
442 op_id: state.op_id,
443 outcome,
444 })
445}