templar_vault_kernel/state/vault/
mod.rs

1//! Core vault state and configuration types.
2//!
3//! This module provides the chain-agnostic `VaultState` struct that holds
4//! all state required by the kernel, including the withdrawal queue.
5//! Executors are responsible for persisting this state to storage.
6
7use crate::fee::FeesSpec;
8use crate::state::op_state::OpState;
9use crate::state::queue::WithdrawQueue;
10use crate::types::TimestampNs;
11
12/// Maximum pending withdrawal queue length.
13/// This is an absolute upper bound enforced by the kernel.
14pub const MAX_PENDING: usize = 1024;
15
16/// Anchor point for fee accrual calculations.
17///
18/// Stores the total assets and timestamp at which fees were last accrued.
19/// Used to calculate time-weighted management fees and performance fees
20/// based on AUM growth.
21#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
22#[derive(Clone, Copy, PartialEq, Eq)]
23pub struct FeeAccrualAnchor {
24    pub total_assets: u128,
25    pub timestamp_ns: TimestampNs,
26}
27
28impl FeeAccrualAnchor {
29    #[inline]
30    #[must_use]
31    pub const fn new(total_assets: u128, timestamp_ns: TimestampNs) -> Self {
32        Self {
33            total_assets,
34            timestamp_ns,
35        }
36    }
37
38    #[inline]
39    #[must_use]
40    pub const fn zero() -> Self {
41        Self {
42            total_assets: 0,
43            timestamp_ns: TimestampNs::ZERO,
44        }
45    }
46
47    #[inline]
48    pub fn update(&mut self, total_assets: u128, timestamp_ns: TimestampNs) {
49        self.total_assets = total_assets;
50        self.timestamp_ns = timestamp_ns;
51    }
52}
53
54impl Default for FeeAccrualAnchor {
55    fn default() -> Self {
56        Self::zero()
57    }
58}
59
60/// Static configuration for a vault.
61///
62/// These settings can typically only be changed through governance.
63///
64/// # Fee Recipients
65///
66/// Fee recipients are 32-byte addresses. Executors are responsible for mapping
67/// chain-native account identifiers (e.g., NEAR AccountId, Soroban Address) to
68/// this canonical 32-byte format, typically using a SHA256 hash.
69#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
70#[derive(Clone, Copy, PartialEq, Eq)]
71pub struct VaultConfig {
72    pub fees: FeesSpec,
73    pub min_withdrawal_assets: u128,
74    pub withdrawal_cooldown_ns: u64,
75    pub max_pending_withdrawals: u32,
76    pub paused: bool,
77    pub virtual_shares: u128,
78    pub virtual_assets: u128,
79}
80
81impl VaultConfig {
82    #[inline]
83    #[must_use]
84    pub fn is_max_pending_valid(&self) -> bool {
85        (self.max_pending_withdrawals as usize) <= MAX_PENDING
86    }
87}
88
89/// Core kernel vault state.
90///
91/// This struct contains all the state managed by the kernel. Chain-specific
92/// executors are responsible for:
93/// - Persisting this state to storage
94/// - Handling share/asset token balances
95///
96/// # Invariants
97///
98/// - `total_assets == idle_assets + external_assets`
99/// - `withdraw_queue.check_invariants()`
100/// - `next_op_id` is monotonically increasing
101/// - Operations can only proceed when `op_state` allows them
102#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
103#[derive(Clone, PartialEq, Eq)]
104pub struct VaultState {
105    pub total_assets: u128,
106    pub total_shares: u128,
107    pub idle_assets: u128,
108    pub external_assets: u128,
109    pub fee_anchor: FeeAccrualAnchor,
110    pub op_state: OpState,
111    pub withdraw_queue: WithdrawQueue,
112    pub next_op_id: u64,
113}
114
115impl VaultState {
116    #[inline]
117    #[must_use]
118    pub fn new() -> Self {
119        Self {
120            total_assets: 0,
121            total_shares: 0,
122            idle_assets: 0,
123            external_assets: 0,
124            fee_anchor: FeeAccrualAnchor::zero(),
125            op_state: OpState::Idle,
126            withdraw_queue: WithdrawQueue::new(),
127            next_op_id: 0,
128        }
129    }
130
131    #[inline]
132    #[must_use]
133    pub fn with_initial(
134        total_assets: u128,
135        total_shares: u128,
136        idle_assets: u128,
137        external_assets: u128,
138        timestamp_ns: TimestampNs,
139    ) -> Self {
140        let computed_total = idle_assets
141            .checked_add(external_assets)
142            .expect("total_assets invariant overflow: idle + external");
143        assert!(total_assets == computed_total);
144        Self {
145            total_assets,
146            total_shares,
147            idle_assets,
148            external_assets,
149            fee_anchor: FeeAccrualAnchor::new(total_assets, timestamp_ns),
150            op_state: OpState::Idle,
151            withdraw_queue: WithdrawQueue::new(),
152            next_op_id: 0,
153        }
154    }
155
156    /// Check the fundamental accounting invariant.
157    ///
158    /// Returns `true` if `total_assets == idle_assets + external_assets`.
159    #[inline]
160    #[must_use]
161    pub fn check_invariant(&self) -> bool {
162        self.idle_assets
163            .checked_add(self.external_assets)
164            .is_some_and(|sum| self.total_assets == sum)
165            && self.withdraw_queue.check_invariants()
166    }
167
168    /// Allocate and return the next operation ID.
169    ///
170    /// Increments `next_op_id` and returns the previous value.
171    #[inline]
172    pub fn allocate_op_id(&mut self) -> u64 {
173        let id = self.next_op_id;
174        self.next_op_id = self.next_op_id.checked_add(1).expect("op_id overflow");
175        id
176    }
177
178    /// Check if the vault is idle (no operation in progress).
179    #[inline]
180    #[must_use]
181    pub fn is_idle(&self) -> bool {
182        self.op_state.is_idle()
183    }
184
185    /// Get the current operation ID if an operation is in progress.
186    #[inline]
187    #[must_use]
188    pub fn current_op_id(&self) -> Option<u64> {
189        self.op_state.op_id()
190    }
191
192    /// Recompute `total_assets` from `idle_assets + external_assets`.
193    ///
194    /// Call this after any mutation of `idle_assets` or `external_assets`
195    /// to restore the fundamental accounting invariant.
196    #[inline]
197    pub fn sync_total_assets(&mut self) {
198        self.total_assets = self
199            .idle_assets
200            .checked_add(self.external_assets)
201            .expect("total_assets overflow: idle + external");
202    }
203
204    /// Add `amount` back to idle assets and recompute totals.
205    ///
206    /// Common pattern during abort / emergency-reset paths where
207    /// in-flight assets are returned to idle.
208    #[inline]
209    pub fn restore_to_idle(&mut self, amount: u128) {
210        self.idle_assets = self.idle_assets.checked_add(amount).unwrap();
211        self.sync_total_assets();
212    }
213}
214
215impl Default for VaultState {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::VaultState;
224
225    #[test]
226    #[should_panic(expected = "total_assets invariant overflow: idle + external")]
227    fn with_initial_panics_on_overflowed_component_sum() {
228        let _ = VaultState::with_initial(u128::MAX, 0, u128::MAX, 1, crate::TimestampNs(0));
229    }
230
231    #[test]
232    #[should_panic(expected = "op_id overflow")]
233    fn allocate_op_id_panics_on_overflow() {
234        let mut state = VaultState::new();
235        state.next_op_id = u64::MAX;
236
237        let _ = state.allocate_op_id();
238    }
239
240    #[test]
241    #[should_panic(expected = "total_assets overflow: idle + external")]
242    fn sync_total_assets_panics_on_overflow() {
243        let mut state = VaultState::new();
244        state.idle_assets = u128::MAX;
245        state.external_assets = 1;
246
247        state.sync_total_assets();
248    }
249}