templar_curator_primitives/recovery/
mod.rs

1//! Recovery logic for handling failed or stuck operations.
2//!
3//! This module provides pure functions for determining and executing recovery
4//! actions when vault operations fail or get stuck in unexpected states.
5//!
6//! # Recovery Actions
7//!
8//! - `KernelAction::AbortAllocating`: Cancel an allocation operation and return to Idle
9//! - `KernelAction::AbortWithdrawing`: Cancel a withdrawal operation and refund escrow
10//! - `KernelAction::AbortRefreshing`: Cancel a refresh operation and return to Idle
11//! - `KernelAction::SettlePayout`: Complete a payout operation (success or failure path)
12//!
13//! # Design Principles
14//!
15//! 1. Recovery is deterministic based on state and provided timing context
16//! 2. All recovery paths ensure escrow shares are properly handled
17//! 3. Recovery should be safe to retry
18
19use alloc::string::String;
20use templar_vault_kernel::{
21    settle_proportional, AllocatingState, EscrowEntry, EscrowSettlement, KernelAction, OpState,
22    PayoutOutcome, PayoutState, RefreshingState, WithdrawingState,
23};
24use typed_builder::TypedBuilder;
25
26/// Context for determining recovery actions.
27#[templar_vault_macros::vault_derive]
28#[derive(Clone, TypedBuilder)]
29#[builder(field_defaults(setter(into)))]
30pub struct RecoveryContext {
31    /// Current timestamp in nanoseconds.
32    pub current_ns: u64,
33    /// Maximum time an operation can be in progress before considered stuck.
34    /// A value of `0` means "no delay" (treat as immediately eligible).
35    #[builder(default)]
36    pub stuck_threshold_ns: u64,
37    /// Whether to force recovery even if not stuck.
38    #[builder(default)]
39    pub force_recovery: bool,
40}
41
42impl RecoveryContext {
43    pub fn new(current_ns: u64) -> Self {
44        Self {
45            current_ns,
46            stuck_threshold_ns: 0,
47            force_recovery: false,
48        }
49    }
50
51    pub fn with_stuck_threshold(current_ns: u64, stuck_threshold_ns: u64) -> Self {
52        Self {
53            current_ns,
54            stuck_threshold_ns,
55            force_recovery: false,
56        }
57    }
58
59    pub fn forced(current_ns: u64) -> Self {
60        Self {
61            current_ns,
62            stuck_threshold_ns: 0,
63            force_recovery: true,
64        }
65    }
66}
67
68impl Default for RecoveryContext {
69    fn default() -> Self {
70        Self::new(0)
71    }
72}
73
74/// Progress timestamps for an in-flight operation.
75#[templar_vault_macros::vault_derive]
76#[derive(Clone, Copy, PartialEq, Eq, TypedBuilder)]
77#[builder(field_defaults(setter(into)))]
78pub struct RecoveryProgress {
79    /// Timestamp when the operation started.
80    pub started_at_ns: u64,
81    /// Timestamp of the last forward progress (may equal started_at_ns).
82    pub last_progress_ns: u64,
83}
84
85impl RecoveryProgress {
86    pub const fn new(started_at_ns: u64) -> Self {
87        Self {
88            started_at_ns,
89            last_progress_ns: started_at_ns,
90        }
91    }
92
93    pub const fn with_last_progress(started_at_ns: u64, last_progress_ns: u64) -> Self {
94        Self {
95            started_at_ns,
96            last_progress_ns,
97        }
98    }
99}
100
101/// Outcome of a recovery operation.
102#[templar_vault_macros::vault_derive(borsh, serde)]
103#[derive(Clone, PartialEq, Eq)]
104pub struct RecoveryOutcome {
105    pub action: KernelAction,
106    pub success: bool,
107    pub message: Option<String>,
108}
109
110impl RecoveryOutcome {
111    pub fn success(action: KernelAction) -> Self {
112        Self {
113            action,
114            success: true,
115            message: None,
116        }
117    }
118
119    pub fn success_with_message(action: KernelAction, message: impl Into<String>) -> Self {
120        Self {
121            action,
122            success: true,
123            message: Some(message.into()),
124        }
125    }
126
127    pub fn failure(action: KernelAction, message: impl Into<String>) -> Self {
128        Self {
129            action,
130            success: false,
131            message: Some(message.into()),
132        }
133    }
134}
135
136/// Determine the appropriate recovery action for the current state.
137pub fn determine_recovery_action(
138    state: &OpState,
139    context: &RecoveryContext,
140    progress: &RecoveryProgress,
141) -> Option<KernelAction> {
142    if matches!(state, OpState::Idle) {
143        return None;
144    }
145
146    if !is_recovery_eligible(context, progress) {
147        return None;
148    }
149
150    match state {
151        OpState::Allocating(alloc) => Some(abort_allocating_action(alloc)),
152        OpState::Withdrawing(withdraw) => Some(abort_withdrawing_action(withdraw)),
153        OpState::Refreshing(refresh) => Some(abort_refreshing_action(refresh)),
154        OpState::Payout(payout) => Some(settle_payout_failure_action(payout, payout.amount)),
155        OpState::Idle => None,
156    }
157}
158
159/// Handle a failed allocation operation.
160pub fn handle_allocation_failure(
161    state: &AllocatingState,
162    failure_reason: impl Into<String>,
163) -> RecoveryOutcome {
164    recovery_success_with_message(abort_allocating_action(state), failure_reason)
165}
166
167/// Handle a failed withdrawal operation.
168pub fn handle_withdrawal_failure(
169    state: &WithdrawingState,
170    failure_reason: impl Into<String>,
171) -> RecoveryOutcome {
172    recovery_success_with_message(abort_withdrawing_action(state), failure_reason)
173}
174
175/// Handle a failed refresh operation.
176pub fn handle_refresh_failure(
177    state: &RefreshingState,
178    failure_reason: impl Into<String>,
179) -> RecoveryOutcome {
180    recovery_success_with_message(abort_refreshing_action(state), failure_reason)
181}
182
183/// Handle a failed payout operation.
184pub fn handle_payout_failure(
185    state: &PayoutState,
186    restore_idle: u128,
187    failure_reason: impl Into<String>,
188) -> RecoveryOutcome {
189    recovery_success_with_message(
190        settle_payout_failure_action(state, restore_idle),
191        failure_reason,
192    )
193}
194
195/// Handle a failed payout operation using the payout amount as the idle restore value.
196pub fn handle_payout_failure_default(
197    state: &PayoutState,
198    failure_reason: impl Into<String>,
199) -> RecoveryOutcome {
200    handle_payout_failure(state, state.amount, failure_reason)
201}
202
203/// Compute the shares to burn and refund based on collected vs expected amounts.
204///
205/// If the full withdrawal amount was collected, burn all escrow shares.
206/// If partial, compute proportionally.
207pub fn compute_settlement_shares(
208    escrow_shares: u128,
209    expected_amount: u128,
210    collected_amount: u128,
211) -> EscrowSettlement {
212    if expected_amount == 0 || escrow_shares == 0 {
213        return EscrowSettlement::refund_all(escrow_shares);
214    }
215
216    if collected_amount >= expected_amount {
217        return EscrowSettlement::burn_all(escrow_shares);
218    }
219
220    settle_proportional(
221        &EscrowEntry::new(
222            templar_vault_kernel::Address([0u8; 32]),
223            escrow_shares,
224            templar_vault_kernel::TimestampNs(0),
225            expected_amount,
226        ),
227        collected_amount,
228    )
229}
230
231/// Compute a success payout outcome from escrow and collected amounts.
232///
233/// This maps recovery math into kernel `PayoutOutcome::Success`.
234#[must_use]
235pub fn compute_payout_success_outcome(
236    escrow_shares: u128,
237    expected_amount: u128,
238    collected_amount: u128,
239) -> PayoutOutcome {
240    let EscrowSettlement {
241        to_burn: burn_shares,
242        refund: refund_shares,
243    } = compute_settlement_shares(escrow_shares, expected_amount, collected_amount);
244
245    PayoutOutcome::Success {
246        burn_shares,
247        refund_shares,
248    }
249}
250
251/// Compute a failure payout outcome from escrow shares and idle restore amount.
252#[must_use]
253pub fn compute_payout_failure_outcome(escrow_shares: u128, restore_idle: u128) -> PayoutOutcome {
254    PayoutOutcome::Failure {
255        restore_idle,
256        refund_shares: escrow_shares,
257    }
258}
259
260/// Compute recovery statistics from the current state.
261///
262/// Provides useful metrics for monitoring and debugging recovery operations.
263#[templar_vault_macros::vault_derive]
264#[derive(Clone, Copy, Default)]
265pub struct RecoveryStats {
266    /// Number of targets completed before failure (for Allocating/Refreshing).
267    pub completed_targets: usize,
268    /// Number of targets remaining (for Allocating/Refreshing).
269    pub remaining_targets: usize,
270    /// Amount already collected (for Withdrawing).
271    pub collected_amount: u128,
272    /// Amount still needed (for Withdrawing).
273    pub remaining_amount: u128,
274    /// Shares at risk (for Withdrawing/Payout).
275    pub escrow_shares: u128,
276}
277
278/// Compute recovery statistics from the current state.
279pub fn compute_recovery_stats(state: &OpState) -> RecoveryStats {
280    match state {
281        OpState::Idle => RecoveryStats::default(),
282
283        OpState::Allocating(alloc) => {
284            let completed_targets = (alloc.index as usize).min(alloc.plan.len());
285            RecoveryStats {
286                completed_targets,
287                remaining_targets: alloc.plan.len().saturating_sub(completed_targets),
288                remaining_amount: alloc.remaining,
289                ..RecoveryStats::default()
290            }
291        }
292
293        OpState::Withdrawing(withdraw) => RecoveryStats {
294            completed_targets: withdraw.index as usize,
295            collected_amount: withdraw.collected,
296            remaining_amount: withdraw.remaining,
297            escrow_shares: withdraw.escrow_shares,
298            ..RecoveryStats::default()
299        },
300
301        OpState::Refreshing(refresh) => {
302            let completed_targets = (refresh.index as usize).min(refresh.plan.len());
303            RecoveryStats {
304                completed_targets,
305                remaining_targets: refresh.plan.len().saturating_sub(completed_targets),
306                ..RecoveryStats::default()
307            }
308        }
309
310        OpState::Payout(payout) => RecoveryStats {
311            collected_amount: payout.amount,
312            escrow_shares: payout.escrow_shares,
313            ..RecoveryStats::default()
314        },
315    }
316}
317
318fn recovery_success_with_message(
319    action: KernelAction,
320    message: impl Into<String>,
321) -> RecoveryOutcome {
322    RecoveryOutcome::success_with_message(action, message)
323}
324
325fn is_recovery_eligible(context: &RecoveryContext, progress: &RecoveryProgress) -> bool {
326    if context.force_recovery {
327        return true;
328    }
329
330    let threshold = context.stuck_threshold_ns;
331    if threshold == 0 {
332        return true;
333    }
334
335    context.current_ns.saturating_sub(progress.last_progress_ns) >= threshold
336}
337
338fn abort_allocating_action(state: &AllocatingState) -> KernelAction {
339    KernelAction::AbortAllocating {
340        op_id: state.op_id,
341        restore_idle: state.remaining,
342    }
343}
344
345fn abort_withdrawing_action(state: &WithdrawingState) -> KernelAction {
346    KernelAction::AbortWithdrawing {
347        op_id: state.op_id,
348        refund_shares: state.escrow_shares,
349    }
350}
351
352fn abort_refreshing_action(state: &RefreshingState) -> KernelAction {
353    KernelAction::AbortRefreshing { op_id: state.op_id }
354}
355
356fn settle_payout_failure_action(state: &PayoutState, restore_idle: u128) -> KernelAction {
357    KernelAction::SettlePayout {
358        op_id: state.op_id,
359        outcome: compute_payout_failure_outcome(state.escrow_shares, restore_idle),
360    }
361}