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