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                index: 0,
291                remaining: req.amount,
292                collected: 0,
293                receiver: req.receiver,
294                owner: req.owner,
295                escrow_shares: req.escrow_shares,
296            });
297            Ok(TransitionResult::with_effects(
298                new_state,
299                vec![KernelEffect::EmitEvent {
300                    event: KernelEvent::AllocationCompleted {
301                        op_id,
302                        has_withdrawal: true,
303                    },
304                }],
305            ))
306        }
307        None => {
308            // No pending withdrawal, return to Idle
309            Ok(TransitionResult::with_effects(
310                OpState::Idle,
311                vec![KernelEffect::EmitEvent {
312                    event: KernelEvent::AllocationCompleted {
313                        op_id,
314                        has_withdrawal: false,
315                    },
316                }],
317            ))
318        }
319    }
320}
321
322// Withdrawal Transitions
323
324/// Request for a withdrawal operation.
325#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
326#[derive(Clone, PartialEq, Eq)]
327pub struct WithdrawalRequest {
328    /// Unique operation ID.
329    pub op_id: u64,
330    /// Amount of assets to withdraw.
331    pub amount: u128,
332    /// Receiver of the assets.
333    pub receiver: Address,
334    /// Owner of the shares being redeemed.
335    pub owner: Address,
336    /// Shares held in escrow for this withdrawal.
337    pub escrow_shares: u128,
338}
339
340/// Start a withdrawal from Idle state.
341///
342/// # Arguments
343/// * `state` - Current state (must be Idle)
344/// * `request` - Withdrawal request details
345///
346/// # Returns
347/// * `Ok(TransitionResult)` with new Withdrawing state
348/// * `Err` on validation failure
349pub fn start_withdrawal(state: OpState, request: WithdrawalRequest) -> TransitionRes {
350    require_idle!(state);
351
352    if request.amount == 0 {
353        return Err(TransitionError::ZeroWithdrawalAmount);
354    }
355
356    if request.escrow_shares == 0 {
357        return Err(TransitionError::ZeroEscrowShares);
358    }
359
360    let new_state = OpState::Withdrawing(WithdrawingState {
361        op_id: request.op_id,
362        index: 0,
363        remaining: request.amount,
364        collected: 0,
365        receiver: request.receiver,
366        owner: request.owner,
367        escrow_shares: request.escrow_shares,
368    });
369
370    Ok(TransitionResult::with_effects(
371        new_state,
372        vec![KernelEffect::EmitEvent {
373            event: KernelEvent::WithdrawalStarted {
374                op_id: request.op_id,
375                amount: request.amount,
376                escrow_shares: request.escrow_shares,
377                owner: request.owner,
378                receiver: request.receiver,
379            },
380        }],
381    ))
382}
383
384/// Advance withdrawal by recording collected funds.
385///
386/// # Arguments
387/// * `state` - Current state (must be Withdrawing)
388/// * `op_id` - Operation ID to verify correlation
389/// * `escrow_address` - Address holding escrowed shares
390/// * `amount_collected` - Amount collected in this step
391///
392/// # Returns
393/// * `Ok(TransitionResult)` with updated Withdrawing state or Payout state
394pub fn withdrawal_step_callback(
395    state: OpState,
396    op_id: u64,
397    amount_collected: u128,
398) -> TransitionRes {
399    let withdraw = require_state!(state, Withdrawing);
400
401    if withdraw.op_id != op_id {
402        return Err(TransitionError::OpIdMismatch {
403            expected: withdraw.op_id,
404            actual: op_id,
405        });
406    }
407
408    if amount_collected > withdraw.remaining {
409        return Err(TransitionError::CollectionOverflow {
410            collected: amount_collected,
411            remaining: withdraw.remaining,
412        });
413    }
414
415    Ok(TransitionResult::new(OpState::Withdrawing(
416        withdraw.advance(amount_collected),
417    )))
418}
419
420/// Transition from Withdrawing to Payout when enough has been collected.
421///
422/// # Arguments
423/// * `state` - Current state (must be Withdrawing)
424/// * `op_id` - Operation ID to verify correlation
425/// * `burn_shares` - Number of shares to burn on successful payout
426///
427/// # Returns
428/// * `Ok(TransitionResult)` with Payout state
429pub fn withdrawal_collected(state: OpState, op_id: u64, burn_shares: u128) -> TransitionRes {
430    let withdraw = require_state!(state, Withdrawing);
431
432    if withdraw.op_id != op_id {
433        return Err(TransitionError::OpIdMismatch {
434            expected: withdraw.op_id,
435            actual: op_id,
436        });
437    }
438
439    if withdraw.remaining > 0 {
440        return Err(TransitionError::WithdrawalIncomplete {
441            remaining: withdraw.remaining,
442            collected: withdraw.collected,
443        });
444    }
445
446    if burn_shares > withdraw.escrow_shares {
447        return Err(TransitionError::BurnExceedsEscrow {
448            burn: burn_shares,
449            escrow: withdraw.escrow_shares,
450        });
451    }
452
453    let new_state = OpState::Payout(PayoutState {
454        op_id: withdraw.op_id,
455        receiver: withdraw.receiver,
456        amount: withdraw.collected,
457        owner: withdraw.owner,
458        escrow_shares: withdraw.escrow_shares,
459        burn_shares,
460    });
461
462    Ok(TransitionResult::with_effects(
463        new_state,
464        vec![KernelEffect::EmitEvent {
465            event: KernelEvent::WithdrawalCollected {
466                op_id,
467                burn_shares,
468                collected: withdraw.collected,
469            },
470        }],
471    ))
472}
473
474pub fn withdrawal_settled(
475    state: OpState,
476    op_id: u64,
477    amount_collected: u128,
478    burn_shares: u128,
479) -> TransitionRes {
480    let stepped = withdrawal_step_callback(state, op_id, amount_collected)?;
481    let withdraw = require_state!(stepped.new_state, Withdrawing);
482
483    if burn_shares > withdraw.escrow_shares {
484        return Err(TransitionError::BurnExceedsEscrow {
485            burn: burn_shares,
486            escrow: withdraw.escrow_shares,
487        });
488    }
489
490    let new_state = OpState::Payout(PayoutState {
491        op_id: withdraw.op_id,
492        receiver: withdraw.receiver,
493        amount: withdraw.collected,
494        owner: withdraw.owner,
495        escrow_shares: withdraw.escrow_shares,
496        burn_shares,
497    });
498
499    Ok(TransitionResult::with_effects(
500        new_state,
501        vec![KernelEffect::EmitEvent {
502            event: KernelEvent::WithdrawalCollected {
503                op_id,
504                burn_shares,
505                collected: withdraw.collected,
506            },
507        }],
508    ))
509}
510
511/// Stop withdrawal and refund escrow shares.
512///
513/// # Arguments
514/// * `state` - Current state (must be Withdrawing)
515/// * `op_id` - Operation ID to verify correlation
516///
517/// # Returns
518/// * `Ok(TransitionResult)` with Idle state and refund effects
519pub fn stop_withdrawal(state: OpState, op_id: u64, escrow_address: Address) -> TransitionRes {
520    let withdraw = require_state!(state, Withdrawing);
521
522    if withdraw.op_id != op_id {
523        return Err(TransitionError::OpIdMismatch {
524            expected: withdraw.op_id,
525            actual: op_id,
526        });
527    }
528
529    // Refund all escrow shares to owner
530    let mut effects = vec![];
531
532    // Transfer shares back from escrow to owner (represented as an effect)
533    // The actual escrow address would be handled by the runtime
534    if withdraw.escrow_shares > 0 {
535        let owner_address = withdraw.owner;
536        effects.push(KernelEffect::TransferShares {
537            from: escrow_address,
538            to: owner_address,
539            shares: withdraw.escrow_shares,
540        });
541    }
542
543    effects.push(KernelEffect::EmitEvent {
544        event: KernelEvent::WithdrawalStopped {
545            op_id,
546            escrow_shares: withdraw.escrow_shares,
547        },
548    });
549
550    Ok(TransitionResult::with_effects(OpState::Idle, effects))
551}
552
553// Refresh Transitions
554
555/// Start a refresh operation from Idle state.
556///
557/// # Arguments
558/// * `state` - Current state (must be Idle)
559/// * `plan` - List of target IDs to refresh
560/// * `op_id` - Unique operation ID
561///
562/// # Returns
563/// * `Ok(TransitionResult)` with new Refreshing state
564pub fn start_refresh(state: OpState, plan: Vec<TargetId>, op_id: u64) -> TransitionRes {
565    require_idle!(state);
566
567    if plan.is_empty() {
568        return Err(TransitionError::EmptyRefreshPlan);
569    }
570
571    let plan_len = plan.len() as u32;
572    let new_state = OpState::Refreshing(RefreshingState {
573        op_id,
574        index: 0,
575        plan,
576    });
577
578    Ok(TransitionResult::with_effects(
579        new_state,
580        vec![KernelEffect::EmitEvent {
581            event: KernelEvent::RefreshStarted { op_id, plan_len },
582        }],
583    ))
584}
585
586/// Process one step of refresh (callback from target).
587///
588/// # Arguments
589/// * `state` - Current state (must be Refreshing)
590/// * `op_id` - Operation ID to verify correlation
591///
592/// # Returns
593/// * `Ok(TransitionResult)` with updated Refreshing state
594pub fn refresh_step_callback(state: OpState, op_id: u64) -> TransitionRes {
595    let refresh = match state {
596        OpState::Refreshing(refresh) => refresh,
597        other => {
598            let _ = other;
599            return Err(TransitionError::WrongState);
600        }
601    };
602
603    if refresh.op_id != op_id {
604        return Err(TransitionError::OpIdMismatch {
605            expected: refresh.op_id,
606            actual: op_id,
607        });
608    }
609
610    validate_plan_index(refresh.index, refresh.plan.len())?;
611
612    Ok(TransitionResult::new(OpState::Refreshing(
613        refresh.advance(),
614    )))
615}
616
617/// Complete refresh and return to Idle.
618///
619/// # Arguments
620/// * `state` - Current state (must be Refreshing)
621/// * `op_id` - Operation ID to verify correlation
622///
623/// # Returns
624/// * `Ok(TransitionResult)` with Idle state
625pub fn complete_refresh(state: OpState, op_id: u64) -> TransitionRes {
626    let refresh = require_state!(state, Refreshing);
627
628    if refresh.op_id != op_id {
629        return Err(TransitionError::OpIdMismatch {
630            expected: refresh.op_id,
631            actual: op_id,
632        });
633    }
634
635    Ok(TransitionResult::with_effects(
636        OpState::Idle,
637        vec![KernelEffect::EmitEvent {
638            event: KernelEvent::RefreshCompleted { op_id },
639        }],
640    ))
641}
642
643// Payout Transitions
644
645/// Complete payout and return to Idle.
646///
647/// # Arguments
648/// * `state` - Current state (must be Payout)
649/// * `success` - Whether the transfer succeeded
650/// * `op_id` - Operation ID to verify correlation
651/// * `escrow_address` - Address holding escrowed shares
652///
653/// # Returns
654/// * `Ok(TransitionResult)` with Idle state and appropriate effects
655pub fn payout_complete(
656    state: OpState,
657    success: bool,
658    op_id: u64,
659    escrow_address: Address,
660) -> TransitionRes {
661    let payout = require_state!(state, Payout);
662
663    if payout.op_id != op_id {
664        return Err(TransitionError::OpIdMismatch {
665            expected: payout.op_id,
666            actual: op_id,
667        });
668    }
669
670    let mut effects = vec![];
671
672    let owner_address = payout.owner;
673
674    let mut burn_shares = 0u128;
675    let mut refund_shares = 0u128;
676    let mut amount = 0u128;
677
678    if success {
679        // Defense-in-depth: validate burn <= escrow (should be enforced at Payout creation)
680        if payout.burn_shares > payout.escrow_shares {
681            return Err(TransitionError::BurnExceedsEscrow {
682                burn: payout.burn_shares,
683                escrow: payout.escrow_shares,
684            });
685        }
686
687        // Burn the designated shares
688        if payout.burn_shares > 0 {
689            burn_shares = payout.burn_shares;
690            effects.push(KernelEffect::BurnShares {
691                owner: escrow_address,
692                shares: payout.burn_shares,
693            });
694        }
695
696        // Refund any remaining escrow shares (subtraction is safe after validation)
697        refund_shares = payout.escrow_shares - payout.burn_shares;
698        if refund_shares > 0 {
699            effects.push(KernelEffect::TransferShares {
700                from: escrow_address,
701                to: owner_address,
702                shares: refund_shares,
703            });
704        }
705
706        amount = payout.amount;
707    } else {
708        // On failure, refund all escrow shares
709        if payout.escrow_shares > 0 {
710            refund_shares = payout.escrow_shares;
711            effects.push(KernelEffect::TransferShares {
712                from: escrow_address,
713                to: owner_address,
714                shares: payout.escrow_shares,
715            });
716        }
717    }
718
719    effects.push(KernelEffect::EmitEvent {
720        event: KernelEvent::PayoutCompleted {
721            op_id,
722            success,
723            burn_shares,
724            refund_shares,
725            amount,
726        },
727    });
728
729    Ok(TransitionResult::with_effects(OpState::Idle, effects))
730}
731
732#[cfg(test)]
733mod tests;