templar_vault_kernel/state/op_state/mod.rs
1//! Operation state machine for asynchronous vault operations.
2//!
3//! This module provides a chain-agnostic state machine for managing the lifecycle
4//! of allocation, withdrawal, refresh, and payout operations in a vault.
5//!
6//! # State Machine
7//!
8//! ```text
9//! +-------+
10//! | Idle |<-----------------------+
11//! +-------+ |
12//! | |
13//! +-------------+-------------+ |
14//! | | |
15//! v v |
16//! +------------+ +-------------+ |
17//! | Allocating | | Refreshing |--------+
18//! +------------+ +-------------+ |
19//! | |
20//! | (on completion or stop) |
21//! v |
22//! +-------------+ |
23//! | Withdrawing |----------------------------------+
24//! +-------------+ |
25//! | |
26//! | (when enough collected) |
27//! v |
28//! +--------+ |
29//! | Payout |---------------------------------------+
30//! +--------+
31//! ```
32//!
33#[cfg(feature = "borsh-schema")]
34use alloc::string::ToString;
35use alloc::vec::Vec;
36
37use derive_more::{From, IsVariant};
38
39use crate::types::Address;
40
41pub type TargetId = u32;
42
43#[templar_vault_macros::vault_derive(borsh, borsh_schema, serde, postcard)]
44#[derive(Clone, Copy, PartialEq, Eq)]
45pub struct AllocationPlanEntry {
46 pub target_id: TargetId,
47 pub amount: u128,
48}
49
50impl AllocationPlanEntry {
51 #[inline]
52 #[must_use]
53 pub const fn new(target_id: TargetId, amount: u128) -> Self {
54 Self { target_id, amount }
55 }
56}
57
58/// No operation in-flight. The vault is ready to start a new allocation or withdrawal.
59#[templar_vault_macros::vault_derive(borsh, borsh_schema, serde, postcard)]
60#[derive(Clone, PartialEq, Eq)]
61pub struct IdleState;
62
63/// Supplying idle underlying to targets according to a plan or queue.
64///
65/// # Transitions
66/// - On completion of allocation: `Withdrawing` (to satisfy pending user requests) or `Idle` (if stopped).
67/// - On stop/failure: `Idle`.
68#[templar_vault_macros::vault_derive(borsh, borsh_schema, serde, postcard)]
69#[derive(Clone, PartialEq, Eq)]
70pub struct AllocatingState {
71 pub op_id: u64,
72 pub index: u32,
73 pub remaining: u128,
74 pub plan: Vec<AllocationPlanEntry>,
75}
76
77/// Collecting liquidity from targets to satisfy a user withdrawal/redeem request.
78///
79/// # Transitions
80/// - Advance within queue: `Withdrawing` (index increments) while collecting funds.
81/// - When enough is collected to satisfy the request: `Payout`.
82/// - If the op is stopped or cannot proceed and needs to refund: `Idle` (escrow_shares refunded).
83#[templar_vault_macros::vault_derive(borsh, borsh_schema, serde, postcard)]
84#[derive(Clone, PartialEq, Eq)]
85pub struct WithdrawingState {
86 pub op_id: u64,
87 pub index: u32,
88 pub remaining: u128,
89 pub collected: u128,
90 pub receiver: Address,
91 pub owner: Address,
92 pub escrow_shares: u128,
93}
94
95/// Read-only refresh of target principals to update stored AUM.
96///
97/// # Transitions
98/// - On completion: `Idle`.
99/// - On failure: `Idle` (with potentially stale AUM data).
100#[templar_vault_macros::vault_derive(borsh, borsh_schema, serde, postcard)]
101#[derive(Clone, PartialEq, Eq)]
102pub struct RefreshingState {
103 pub op_id: u64,
104 pub index: u32,
105 pub plan: Vec<TargetId>,
106}
107
108/// Final step that transfers assets to the receiver and settles the share escrow.
109///
110/// # Transitions
111/// - On success or failure: `Idle`.
112///
113/// # Invariant hooks
114/// - `idle_balance` decreases only on payout success by `amount`.
115/// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded.
116/// - On failure, all `escrow_shares` are refunded.
117#[templar_vault_macros::vault_derive(borsh, borsh_schema, serde, postcard)]
118#[derive(Clone, PartialEq, Eq)]
119pub struct PayoutState {
120 pub op_id: u64,
121 pub receiver: Address,
122 pub amount: u128,
123 pub owner: Address,
124 pub escrow_shares: u128,
125 pub burn_shares: u128,
126}
127
128impl AllocatingState {
129 /// Advance to the next allocation step after `amount_allocated` was supplied.
130 #[inline]
131 #[must_use]
132 pub fn advance(self, amount_allocated: u128) -> Self {
133 Self {
134 op_id: self.op_id,
135 index: self.index.saturating_add(1),
136 remaining: self.remaining.saturating_sub(amount_allocated),
137 plan: self.plan,
138 }
139 }
140}
141
142impl WithdrawingState {
143 /// Advance to the next withdrawal step after `amount_collected` was received.
144 #[inline]
145 #[must_use]
146 pub fn advance(self, amount_collected: u128) -> Self {
147 Self {
148 op_id: self.op_id,
149 index: self.index.saturating_add(1),
150 remaining: self.remaining.saturating_sub(amount_collected),
151 collected: self.collected.saturating_add(amount_collected),
152 receiver: self.receiver,
153 owner: self.owner,
154 escrow_shares: self.escrow_shares,
155 }
156 }
157}
158
159impl RefreshingState {
160 /// Advance to the next refresh step.
161 #[inline]
162 #[must_use]
163 pub fn advance(self) -> Self {
164 Self {
165 op_id: self.op_id,
166 index: self.index.saturating_add(1),
167 plan: self.plan,
168 }
169 }
170}
171
172/// Operation state machine for asynchronous allocation, withdrawal, and payout flows.
173///
174/// # State Machine
175/// - `Allocating` -> `Withdrawing` (or `Idle` via stop)
176/// - `Withdrawing` -> `Withdrawing` (advance) | `Payout` | `Idle` (refund)
177/// - `Refreshing` -> `Idle`
178/// - `Payout` -> `Idle` (success or failure)
179///
180/// # Invariants
181/// - `idle_balance` increases only when funds are received and decreases only on payout success.
182/// - `escrow_shares` are refunded on stop/failure or partially burned/refunded on payout success.
183#[templar_vault_macros::vault_derive(borsh, borsh_schema, serde, postcard)]
184#[derive(Clone, Default, PartialEq, Eq, From, IsVariant)]
185pub enum OpState {
186 /// No operation in-flight. The vault is ready to start a new allocation or withdrawal.
187 #[default]
188 Idle,
189
190 /// Supplying idle underlying to targets according to a plan or queue.
191 ///
192 /// # Transitions
193 /// - On completion of allocation: `Withdrawing` (to satisfy pending user requests) or `Idle` (if stopped).
194 /// - On stop/failure: `Idle`.
195 Allocating(AllocatingState),
196
197 /// Collecting liquidity from targets to satisfy a user withdrawal/redeem request.
198 ///
199 /// # Transitions
200 /// - Advance within queue: `Withdrawing` (index increments) while collecting funds.
201 /// - When enough is collected to satisfy the request: `Payout`.
202 /// - If the op is stopped or cannot proceed and needs to refund: `Idle` (escrow_shares refunded).
203 Withdrawing(WithdrawingState),
204
205 /// Read-only refresh of target principals to update stored AUM.
206 Refreshing(RefreshingState),
207
208 /// Final step that transfers assets to the receiver and settles the share escrow.
209 ///
210 /// # Transitions
211 /// - On success or failure: `Idle`.
212 ///
213 /// # Invariant hooks
214 /// - `idle_balance` decreases only on payout success by `amount`.
215 /// - On success, `burn_shares` are burned from `escrow_shares`; any remainder is refunded.
216 /// - On failure, all `escrow_shares` are refunded.
217 Payout(PayoutState),
218}
219
220// Note: From<AllocatingState>, From<WithdrawingState>, From<RefreshingState>,
221// From<PayoutState> are auto-generated by derive_more::From
222
223impl From<IdleState> for OpState {
224 fn from(_: IdleState) -> Self {
225 OpState::Idle
226 }
227}
228
229// --- Accessor methods ---
230
231impl OpState {
232 /// Returns a numeric code for the current op state.
233 #[inline]
234 #[must_use]
235 pub const fn kind_code(&self) -> u32 {
236 match self {
237 OpState::Idle => 0,
238 OpState::Allocating(_) => 1,
239 OpState::Withdrawing(_) => 2,
240 OpState::Refreshing(_) => 3,
241 OpState::Payout(_) => 4,
242 }
243 }
244
245 /// Returns a human-readable name for the current op state.
246 #[cfg(not(target_arch = "wasm32"))]
247 #[inline]
248 #[must_use]
249 pub const fn kind_name(&self) -> &'static str {
250 match self {
251 OpState::Idle => "Idle",
252 OpState::Allocating(_) => "Allocating",
253 OpState::Withdrawing(_) => "Withdrawing",
254 OpState::Refreshing(_) => "Refreshing",
255 OpState::Payout(_) => "Payout",
256 }
257 }
258
259 /// Returns a reference to the idle state if this is `Idle`, otherwise `None`.
260 #[inline]
261 #[must_use]
262 pub const fn as_idle(&self) -> Option<&IdleState> {
263 match self {
264 OpState::Idle => Some(&IdleState),
265 _ => None,
266 }
267 }
268
269 /// Returns a reference to the allocating state if this is `Allocating`, otherwise `None`.
270 #[inline]
271 #[must_use]
272 pub const fn as_allocating(&self) -> Option<&AllocatingState> {
273 match self {
274 OpState::Allocating(s) => Some(s),
275 _ => None,
276 }
277 }
278
279 /// Returns a reference to the withdrawing state if this is `Withdrawing`, otherwise `None`.
280 #[inline]
281 #[must_use]
282 pub const fn as_withdrawing(&self) -> Option<&WithdrawingState> {
283 match self {
284 OpState::Withdrawing(s) => Some(s),
285 _ => None,
286 }
287 }
288
289 /// Returns a reference to the refreshing state if this is `Refreshing`, otherwise `None`.
290 #[inline]
291 #[must_use]
292 pub const fn as_refreshing(&self) -> Option<&RefreshingState> {
293 match self {
294 OpState::Refreshing(s) => Some(s),
295 _ => None,
296 }
297 }
298
299 /// Returns a reference to the payout state if this is `Payout`, otherwise `None`.
300 #[inline]
301 #[must_use]
302 pub const fn as_payout(&self) -> Option<&PayoutState> {
303 match self {
304 OpState::Payout(s) => Some(s),
305 _ => None,
306 }
307 }
308
309 // Note: is_idle(), is_allocating(), is_withdrawing(), is_refreshing(), is_payout()
310 // are auto-generated by derive_more::IsVariant
311
312 /// Returns the operation ID if this state has one, otherwise `None`.
313 #[inline]
314 #[must_use]
315 pub const fn op_id(&self) -> Option<u64> {
316 match self {
317 OpState::Idle => None,
318 OpState::Allocating(s) => Some(s.op_id),
319 OpState::Withdrawing(s) => Some(s.op_id),
320 OpState::Refreshing(s) => Some(s.op_id),
321 OpState::Payout(s) => Some(s.op_id),
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests;