templar_vault_kernel/transitions/
mod.rs

1//! Pure transition functions for the OpState machine.
2//!
3//! These functions define how the vault's operation state machine changes state
4//! in response to events. They are pure functions: no side effects, no storage access.
5//!
6//! # Design Principles
7//!
8//! 1. **Pure Functions**: Each transition takes the current state and inputs,
9//!    returning a new state and a list of effects to execute.
10//! 2. **Explicit State Requirements**: Transitions check that the machine is in
11//!    the expected state before proceeding.
12//! 3. **Effect-Based Output**: Side effects (transfers, burns, etc.) are returned
13//!    as `KernelEffect` values rather than executed directly.
14//!
15//! # State Machine
16//!
17//! ```text
18//! Idle -> Allocating (start_allocation)
19//! Idle -> Withdrawing (start_withdrawal)
20//! Idle -> Refreshing (start_refresh)
21//! Allocating -> Withdrawing | Idle (complete_allocation)
22//! Withdrawing -> Withdrawing (advance_withdrawal)
23//! Withdrawing -> Payout (withdrawal_collected)
24//! Withdrawing -> Idle (stop_withdrawal)
25//! Refreshing -> Idle (complete_refresh)
26//! Payout -> Idle (payout_complete)
27//! ```
28
29use alloc::vec;
30use alloc::vec::Vec;
31
32use crate::effects::{KernelEffect, KernelEvent};
33use crate::state::op_state::{
34    AllocatingState, AllocationPlanEntry, OpState, PayoutState, RefreshingState, TargetId,
35    WithdrawingState,
36};
37use crate::types::Address;
38
39/// Error types for state transitions.
40#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
41#[derive(Clone, PartialEq, Eq)]
42pub enum TransitionError {
43    WrongState,
44    OpIdMismatch { expected: u64, actual: u64 },
45    EmptyAllocationPlan,
46    EmptyRefreshPlan,
47    ZeroWithdrawalAmount,
48    ZeroEscrowShares,
49    InvalidIndex { index: u32, max: u32 },
50    CollectionOverflow { collected: u128, remaining: u128 },
51    AllocationOverflow { allocated: u128, remaining: u128 },
52    ZeroAllocationAmount,
53    BurnExceedsEscrow { burn: u128, escrow: u128 },
54    WithdrawalIncomplete { remaining: u128, collected: u128 },
55}
56
57impl TransitionError {
58    /// Get the name of an OpState variant as a static string.
59    #[allow(dead_code)]
60    #[cfg(not(target_arch = "wasm32"))]
61    pub(crate) fn state_name(state: &OpState) -> &'static str {
62        state.kind_name()
63    }
64}
65
66/// Validate that a plan step index is within bounds.
67#[inline]
68fn validate_plan_index(index: u32, plan_len: usize) -> Result<(), TransitionError> {
69    let len = plan_len as u32;
70    if index >= len {
71        return Err(TransitionError::InvalidIndex {
72            index,
73            max: len.saturating_sub(1),
74        });
75    }
76    Ok(())
77}
78
79/// Result of a successful state transition.
80#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
81#[derive(Clone, PartialEq, Eq)]
82pub struct TransitionResult {
83    /// The new state after the transition.
84    pub new_state: OpState,
85    /// Effects to execute as a result of this transition.
86    pub effects: Vec<KernelEffect>,
87}
88
89impl TransitionResult {
90    /// Create a transition result with no effects.
91    pub fn new(new_state: OpState) -> Self {
92        Self {
93            new_state,
94            effects: vec![],
95        }
96    }
97
98    /// Create a transition result with effects.
99    pub fn with_effects(new_state: OpState, effects: Vec<KernelEffect>) -> Self {
100        Self { new_state, effects }
101    }
102}
103
104/// Type alias for transition function results.
105pub type TransitionRes = Result<TransitionResult, TransitionError>;
106
107/// Extract the inner state of a specific OpState variant, or return a typed error.
108/// Takes ownership of the state to avoid unnecessary clones.
109macro_rules! require_state {
110    ($state:expr, $variant:ident) => {
111        match $state {
112            OpState::$variant(s) => s,
113            _ => {
114                return Err(TransitionError::WrongState);
115            }
116        }
117    };
118}
119
120/// Assert the OpState is Idle, or return WrongState.
121macro_rules! require_idle {
122    ($state:expr) => {
123        if !$state.is_idle() {
124            return Err(TransitionError::WrongState);
125        }
126    };
127}
128
129// Allocation Transitions
130
131/// Start an allocation from Idle state.
132///
133/// # Arguments
134/// * `state` - Current state (must be Idle)
135/// * `plan` - Allocation steps specifying where to allocate
136/// * `op_id` - Unique operation ID for correlation
137///
138/// # Returns
139/// * `Ok(TransitionResult)` with new Allocating state
140/// * `Err(TransitionError::WrongState)` if not in Idle state
141/// * `Err(TransitionError::EmptyAllocationPlan)` if plan is empty
142pub fn start_allocation(
143    state: OpState,
144    plan: Vec<AllocationPlanEntry>,
145    op_id: u64,
146) -> TransitionRes {
147    require_idle!(state);
148
149    if plan.is_empty() {
150        return Err(TransitionError::EmptyAllocationPlan);
151    }
152
153    let mut total = 0u128;
154    for step in &plan {
155        total = total.saturating_add(step.amount);
156    }
157
158    let plan_len = plan.len() as u32;
159    let new_state = OpState::Allocating(AllocatingState {
160        op_id,
161        index: 0,
162        remaining: total,
163        plan,
164    });
165
166    Ok(TransitionResult::with_effects(
167        new_state,
168        vec![KernelEffect::EmitEvent {
169            event: KernelEvent::AllocationStarted {
170                op_id,
171                total,
172                plan_len,
173            },
174        }],
175    ))
176}
177
178/// Process one step of allocation (callback from market).
179///
180/// Advances the allocation index and updates remaining amount.
181///
182/// # Arguments
183/// * `state` - Current state (must be Allocating)
184/// * `success` - Whether the allocation step succeeded
185/// * `amount_allocated` - Amount that was actually allocated in this step
186/// * `op_id` - Operation ID to verify correlation
187///
188/// # Returns
189/// * `Ok(TransitionResult)` with updated Allocating state
190/// * `Err` on state mismatch or op_id mismatch
191pub fn allocation_step_callback(
192    state: OpState,
193    success: bool,
194    amount_allocated: u128,
195    op_id: u64,
196) -> TransitionRes {
197    let alloc = match state {
198        OpState::Allocating(alloc) => alloc,
199        other => {
200            let _ = other;
201            return Err(TransitionError::WrongState);
202        }
203    };
204
205    if alloc.op_id != op_id {
206        return Err(TransitionError::OpIdMismatch {
207            expected: alloc.op_id,
208            actual: op_id,
209        });
210    }
211
212    validate_plan_index(alloc.index, alloc.plan.len())?;
213
214    if !success {
215        // On failure, return to Idle.
216        // Compute total_allocated so caller can restore idle_assets correctly.
217        let mut original_total = 0u128;
218        for step in &alloc.plan {
219            original_total = original_total.saturating_add(step.amount);
220        }
221        let total_allocated = original_total.saturating_sub(alloc.remaining);
222
223        return Ok(TransitionResult::with_effects(
224            OpState::Idle,
225            vec![KernelEffect::EmitEvent {
226                event: KernelEvent::AllocationStepFailed {
227                    op_id: alloc.op_id,
228                    index: alloc.index,
229                    remaining: alloc.remaining,
230                    total_allocated,
231                },
232            }],
233        ));
234    }
235
236    // Reject zero allocation on success - prevents malicious markets from
237    // advancing allocation steps without actually allocating.
238    if amount_allocated == 0 {
239        return Err(TransitionError::ZeroAllocationAmount);
240    }
241
242    if amount_allocated > alloc.remaining {
243        return Err(TransitionError::AllocationOverflow {
244            allocated: amount_allocated,
245            remaining: alloc.remaining,
246        });
247    }
248
249    Ok(TransitionResult::new(OpState::Allocating(
250        alloc.advance(amount_allocated),
251    )))
252}
253
254/// Complete allocation and transition to next state.
255///
256/// # Arguments
257/// * `state` - Current state (must be Allocating)
258/// * `op_id` - Operation ID to verify correlation
259/// * `pending_withdrawal` - Optional pending withdrawal to process next
260///
261/// # Returns
262/// * `Ok(TransitionResult)` with Idle or Withdrawing state
263pub fn complete_allocation(
264    state: OpState,
265    op_id: u64,
266    pending_withdrawal: Option<WithdrawalRequest>,
267) -> TransitionRes {
268    let alloc = require_state!(state, Allocating);
269
270    if alloc.op_id != op_id {
271        return Err(TransitionError::OpIdMismatch {
272            expected: alloc.op_id,
273            actual: op_id,
274        });
275    }
276
277    // Only chain into withdrawal when the pending request is actionable.
278    // Zero-amount requests are handled by the caller's queue-skip path once Idle.
279    let actionable_withdrawal = pending_withdrawal.filter(|req| req.amount > 0);
280
281    match actionable_withdrawal {
282        Some(req) => {
283            if req.escrow_shares == 0 {
284                return Err(TransitionError::ZeroEscrowShares);
285            }
286
287            // Transition to Withdrawing to process the pending request
288            let new_state = OpState::Withdrawing(WithdrawingState {
289                op_id: req.op_id,
290                request_id: req.request_id,
291                index: 0,
292                remaining: req.amount,
293                collected: 0,
294                receiver: req.receiver,
295                owner: req.owner,
296                escrow_shares: req.escrow_shares,
297            });
298            Ok(TransitionResult::with_effects(
299                new_state,
300                vec![KernelEffect::EmitEvent {
301                    event: KernelEvent::AllocationCompleted {
302                        op_id,
303                        has_withdrawal: true,
304                    },
305                }],
306            ))
307        }
308        None => {
309            // No pending withdrawal, return to Idle
310            Ok(TransitionResult::with_effects(
311                OpState::Idle,
312                vec![KernelEffect::EmitEvent {
313                    event: KernelEvent::AllocationCompleted {
314                        op_id,
315                        has_withdrawal: false,
316                    },
317                }],
318            ))
319        }
320    }
321}
322
323// Withdrawal Transitions
324
325/// Request for a withdrawal operation.
326#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
327#[derive(Clone, PartialEq, Eq)]
328pub struct WithdrawalRequest {
329    /// Unique operation ID.
330    pub op_id: u64,
331    /// Unique queue request ID.
332    pub request_id: u64,
333    /// Amount of assets to withdraw.
334    pub amount: u128,
335    /// Receiver of the assets.
336    pub receiver: Address,
337    /// Owner of the shares being redeemed.
338    pub owner: Address,
339    /// Shares held in escrow for this withdrawal.
340    pub escrow_shares: u128,
341}
342
343/// Start a withdrawal from Idle state.
344///
345/// # Arguments
346/// * `state` - Current state (must be Idle)
347/// * `request` - Withdrawal request details
348///
349/// # Returns
350/// * `Ok(TransitionResult)` with new Withdrawing state
351/// * `Err` on validation failure
352pub fn start_withdrawal(state: OpState, request: WithdrawalRequest) -> TransitionRes {
353    require_idle!(state);
354
355    if request.amount == 0 {
356        return Err(TransitionError::ZeroWithdrawalAmount);
357    }
358
359    if request.escrow_shares == 0 {
360        return Err(TransitionError::ZeroEscrowShares);
361    }
362
363    let new_state = OpState::Withdrawing(WithdrawingState {
364        op_id: request.op_id,
365        request_id: request.request_id,
366        index: 0,
367        remaining: request.amount,
368        collected: 0,
369        receiver: request.receiver,
370        owner: request.owner,
371        escrow_shares: request.escrow_shares,
372    });
373
374    Ok(TransitionResult::with_effects(
375        new_state,
376        vec![KernelEffect::EmitEvent {
377            event: KernelEvent::WithdrawalStarted {
378                op_id: request.op_id,
379                amount: request.amount,
380                escrow_shares: request.escrow_shares,
381                owner: request.owner,
382                receiver: request.receiver,
383            },
384        }],
385    ))
386}
387
388/// Advance withdrawal by recording collected funds.
389///
390/// # Arguments
391/// * `state` - Current state (must be Withdrawing)
392/// * `op_id` - Operation ID to verify correlation
393/// * `escrow_address` - Address holding escrowed shares
394/// * `amount_collected` - Amount collected in this step
395///
396/// # Returns
397/// * `Ok(TransitionResult)` with updated Withdrawing state or Payout state
398pub fn withdrawal_step_callback(
399    state: OpState,
400    op_id: u64,
401    amount_collected: u128,
402) -> TransitionRes {
403    let withdraw = require_state!(state, Withdrawing);
404
405    if withdraw.op_id != op_id {
406        return Err(TransitionError::OpIdMismatch {
407            expected: withdraw.op_id,
408            actual: op_id,
409        });
410    }
411
412    if amount_collected > withdraw.remaining {
413        return Err(TransitionError::CollectionOverflow {
414            collected: amount_collected,
415            remaining: withdraw.remaining,
416        });
417    }
418
419    Ok(TransitionResult::new(OpState::Withdrawing(
420        withdraw.advance(amount_collected),
421    )))
422}
423
424/// Transition from Withdrawing to Payout when enough has been collected.
425///
426/// # Arguments
427/// * `state` - Current state (must be Withdrawing)
428/// * `op_id` - Operation ID to verify correlation
429/// * `burn_shares` - Number of shares to burn on successful payout
430///
431/// # Returns
432/// * `Ok(TransitionResult)` with Payout state
433pub fn withdrawal_collected(state: OpState, op_id: u64, burn_shares: u128) -> TransitionRes {
434    let withdraw = require_state!(state, Withdrawing);
435
436    if withdraw.op_id != op_id {
437        return Err(TransitionError::OpIdMismatch {
438            expected: withdraw.op_id,
439            actual: op_id,
440        });
441    }
442
443    if withdraw.remaining > 0 {
444        return Err(TransitionError::WithdrawalIncomplete {
445            remaining: withdraw.remaining,
446            collected: withdraw.collected,
447        });
448    }
449
450    if burn_shares > withdraw.escrow_shares {
451        return Err(TransitionError::BurnExceedsEscrow {
452            burn: burn_shares,
453            escrow: withdraw.escrow_shares,
454        });
455    }
456
457    let new_state = OpState::Payout(PayoutState {
458        op_id: withdraw.op_id,
459        request_id: withdraw.request_id,
460        receiver: withdraw.receiver,
461        amount: withdraw.collected,
462        owner: withdraw.owner,
463        escrow_shares: withdraw.escrow_shares,
464        burn_shares,
465    });
466
467    Ok(TransitionResult::with_effects(
468        new_state,
469        vec![KernelEffect::EmitEvent {
470            event: KernelEvent::WithdrawalCollected {
471                op_id,
472                burn_shares,
473                collected: withdraw.collected,
474            },
475        }],
476    ))
477}
478
479pub fn withdrawal_settled(
480    state: OpState,
481    op_id: u64,
482    amount_collected: u128,
483    burn_shares: u128,
484) -> TransitionRes {
485    let stepped = withdrawal_step_callback(state, op_id, amount_collected)?;
486    let withdraw = require_state!(stepped.new_state, Withdrawing);
487
488    if burn_shares > withdraw.escrow_shares {
489        return Err(TransitionError::BurnExceedsEscrow {
490            burn: burn_shares,
491            escrow: withdraw.escrow_shares,
492        });
493    }
494
495    let new_state = OpState::Payout(PayoutState {
496        op_id: withdraw.op_id,
497        request_id: withdraw.request_id,
498        receiver: withdraw.receiver,
499        amount: withdraw.collected,
500        owner: withdraw.owner,
501        escrow_shares: withdraw.escrow_shares,
502        burn_shares,
503    });
504
505    Ok(TransitionResult::with_effects(
506        new_state,
507        vec![KernelEffect::EmitEvent {
508            event: KernelEvent::WithdrawalCollected {
509                op_id,
510                burn_shares,
511                collected: withdraw.collected,
512            },
513        }],
514    ))
515}
516
517/// Stop withdrawal and refund escrow shares.
518///
519/// # Arguments
520/// * `state` - Current state (must be Withdrawing)
521/// * `op_id` - Operation ID to verify correlation
522///
523/// # Returns
524/// * `Ok(TransitionResult)` with Idle state and refund effects
525pub fn stop_withdrawal(state: OpState, op_id: u64, escrow_address: Address) -> TransitionRes {
526    let withdraw = require_state!(state, Withdrawing);
527
528    if withdraw.op_id != op_id {
529        return Err(TransitionError::OpIdMismatch {
530            expected: withdraw.op_id,
531            actual: op_id,
532        });
533    }
534
535    // Refund all escrow shares to owner
536    let mut effects = vec![];
537
538    // Transfer shares back from escrow to owner (represented as an effect)
539    // The actual escrow address would be handled by the runtime
540    if withdraw.escrow_shares > 0 {
541        let owner_address = withdraw.owner;
542        effects.push(KernelEffect::TransferShares {
543            from: escrow_address,
544            to: owner_address,
545            shares: withdraw.escrow_shares,
546        });
547    }
548
549    effects.push(KernelEffect::EmitEvent {
550        event: KernelEvent::WithdrawalStopped {
551            op_id,
552            escrow_shares: withdraw.escrow_shares,
553        },
554    });
555
556    Ok(TransitionResult::with_effects(OpState::Idle, effects))
557}
558
559// Refresh Transitions
560
561/// Start a refresh operation from Idle state.
562///
563/// # Arguments
564/// * `state` - Current state (must be Idle)
565/// * `plan` - List of target IDs to refresh
566/// * `op_id` - Unique operation ID
567///
568/// # Returns
569/// * `Ok(TransitionResult)` with new Refreshing state
570pub fn start_refresh(state: OpState, plan: Vec<TargetId>, op_id: u64) -> TransitionRes {
571    require_idle!(state);
572
573    if plan.is_empty() {
574        return Err(TransitionError::EmptyRefreshPlan);
575    }
576
577    let plan_len = plan.len() as u32;
578    let new_state = OpState::Refreshing(RefreshingState {
579        op_id,
580        index: 0,
581        plan,
582    });
583
584    Ok(TransitionResult::with_effects(
585        new_state,
586        vec![KernelEffect::EmitEvent {
587            event: KernelEvent::RefreshStarted { op_id, plan_len },
588        }],
589    ))
590}
591
592/// Process one step of refresh (callback from target).
593///
594/// # Arguments
595/// * `state` - Current state (must be Refreshing)
596/// * `op_id` - Operation ID to verify correlation
597///
598/// # Returns
599/// * `Ok(TransitionResult)` with updated Refreshing state
600pub fn refresh_step_callback(state: OpState, op_id: u64) -> TransitionRes {
601    let refresh = match state {
602        OpState::Refreshing(refresh) => refresh,
603        other => {
604            let _ = other;
605            return Err(TransitionError::WrongState);
606        }
607    };
608
609    if refresh.op_id != op_id {
610        return Err(TransitionError::OpIdMismatch {
611            expected: refresh.op_id,
612            actual: op_id,
613        });
614    }
615
616    validate_plan_index(refresh.index, refresh.plan.len())?;
617
618    Ok(TransitionResult::new(OpState::Refreshing(
619        refresh.advance(),
620    )))
621}
622
623/// Complete refresh and return to Idle.
624///
625/// # Arguments
626/// * `state` - Current state (must be Refreshing)
627/// * `op_id` - Operation ID to verify correlation
628///
629/// # Returns
630/// * `Ok(TransitionResult)` with Idle state
631pub fn complete_refresh(state: OpState, op_id: u64) -> TransitionRes {
632    let refresh = require_state!(state, Refreshing);
633
634    if refresh.op_id != op_id {
635        return Err(TransitionError::OpIdMismatch {
636            expected: refresh.op_id,
637            actual: op_id,
638        });
639    }
640
641    Ok(TransitionResult::with_effects(
642        OpState::Idle,
643        vec![KernelEffect::EmitEvent {
644            event: KernelEvent::RefreshCompleted { op_id },
645        }],
646    ))
647}
648
649// Payout Transitions
650
651/// Complete payout and return to Idle.
652///
653/// # Arguments
654/// * `state` - Current state (must be Payout)
655/// * `success` - Whether the transfer succeeded
656/// * `op_id` - Operation ID to verify correlation
657/// * `escrow_address` - Address holding escrowed shares
658///
659/// # Returns
660/// * `Ok(TransitionResult)` with Idle state and appropriate effects
661pub fn payout_complete(
662    state: OpState,
663    success: bool,
664    op_id: u64,
665    escrow_address: Address,
666) -> TransitionRes {
667    let payout = require_state!(state, Payout);
668
669    if payout.op_id != op_id {
670        return Err(TransitionError::OpIdMismatch {
671            expected: payout.op_id,
672            actual: op_id,
673        });
674    }
675
676    let mut effects = vec![];
677
678    let owner_address = payout.owner;
679
680    let mut burn_shares = 0u128;
681    let mut refund_shares = 0u128;
682    let mut amount = 0u128;
683
684    if success {
685        // Defense-in-depth: validate burn <= escrow (should be enforced at Payout creation)
686        if payout.burn_shares > payout.escrow_shares {
687            return Err(TransitionError::BurnExceedsEscrow {
688                burn: payout.burn_shares,
689                escrow: payout.escrow_shares,
690            });
691        }
692
693        // Burn the designated shares
694        if payout.burn_shares > 0 {
695            burn_shares = payout.burn_shares;
696            effects.push(KernelEffect::BurnShares {
697                owner: escrow_address,
698                shares: payout.burn_shares,
699            });
700        }
701
702        // Refund any remaining escrow shares (subtraction is safe after validation)
703        refund_shares = payout.escrow_shares - payout.burn_shares;
704        if refund_shares > 0 {
705            effects.push(KernelEffect::TransferShares {
706                from: escrow_address,
707                to: owner_address,
708                shares: refund_shares,
709            });
710        }
711
712        amount = payout.amount;
713    } else {
714        // On failure, refund all escrow shares
715        if payout.escrow_shares > 0 {
716            refund_shares = payout.escrow_shares;
717            effects.push(KernelEffect::TransferShares {
718                from: escrow_address,
719                to: owner_address,
720                shares: payout.escrow_shares,
721            });
722        }
723    }
724
725    effects.push(KernelEffect::EmitEvent {
726        event: KernelEvent::PayoutCompleted {
727            op_id,
728            success,
729            burn_shares,
730            refund_shares,
731            amount,
732        },
733    });
734
735    Ok(TransitionResult::with_effects(OpState::Idle, effects))
736}
737
738#[cfg(test)]
739mod tests;