templar_curator_primitives/recovery/
mod.rs

1//! Recovery planning for failed or stuck vault operations.
2//!
3//! This module only derives recovery actions from explicit state and evidence.
4//! It does not execute those actions, and it does not invent payout outcomes when
5//! the available information is incomplete.
6
7use 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/// Recovery eligibility policy.
15#[templar_vault_macros::vault_derive]
16#[derive(Clone, PartialEq, Eq)]
17pub enum RecoveryPolicy {
18    /// Never plan recovery automatically.
19    Disabled,
20    /// Plan recovery after a period of inactivity, with an optional total-age cap.
21    AfterInactivity {
22        inactivity_threshold_ns: u64,
23        max_total_age_ns: Option<u64>,
24    },
25    /// Plan recovery immediately regardless of progress timestamps.
26    Force,
27}
28
29/// Context for determining whether recovery is eligible.
30#[templar_vault_macros::vault_derive]
31#[derive(Clone, TypedBuilder)]
32#[builder(field_defaults(setter(into)))]
33pub struct RecoveryContext {
34    /// Current timestamp in nanoseconds.
35    pub current_ns: u64,
36    /// Recovery policy to apply.
37    #[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/// Progress timestamps for a specific in-flight operation.
92#[templar_vault_macros::vault_derive]
93#[derive(Clone, Copy, PartialEq, Eq, TypedBuilder)]
94#[builder(field_defaults(setter(into)))]
95pub struct RecoveryProgress {
96    /// Operation id that these progress timestamps belong to.
97    pub op_id: u64,
98    /// Timestamp when the operation started.
99    pub started_at_ns: u64,
100    /// Timestamp of the last forward progress (may equal started_at_ns).
101    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/// Evidence required to settle a payout during recovery.
125#[templar_vault_macros::vault_derive]
126#[derive(Clone, Copy, PartialEq, Eq)]
127pub enum PayoutRecoveryEvidence {
128    /// The payout transfer failed and the provided idle amount must be restored.
129    Failure { restore_idle: u128 },
130    /// The payout transfer succeeded for the provided collected amount.
131    Success { collected_amount: u128 },
132}
133
134/// Recovery planning error.
135#[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/// Result of planning a recovery operation.
162#[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
190/// Determine the appropriate recovery action for the current state.
191pub 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/// Plan recovery for a failed allocation operation.
221#[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/// Plan recovery for a failed withdrawal operation.
230#[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/// Plan recovery for a failed refresh operation.
239#[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
247/// Plan recovery for a payout using explicit outcome evidence.
248pub 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
257/// Compute the shares to burn and refund based on collected vs expected amounts.
258pub 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
288/// Compute a success payout outcome from escrow and collected amounts.
289pub 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
301/// Compute a failure payout outcome from escrow shares and idle restore amount.
302pub 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/// Compute recovery statistics from the current state.
315#[templar_vault_macros::vault_derive]
316#[derive(Clone, Copy, Default)]
317pub struct RecoveryStats {
318    /// Number of targets completed before failure (for Allocating/Refreshing).
319    pub completed_targets: usize,
320    /// Number of targets remaining (for Allocating/Refreshing).
321    pub remaining_targets: usize,
322    /// Amount already collected (for Withdrawing).
323    pub collected_amount: u128,
324    /// Amount still needed (for Withdrawing).
325    pub remaining_amount: u128,
326    /// Shares at risk (for Withdrawing/Payout).
327    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}