templar_vault_kernel/actions/
mod.rs

1//! Kernel action dispatch for vault state transitions.
2//!
3//! This module defines the public `KernelAction` enum and a dispatcher that
4//! applies actions to `VaultState` and returns effects.
5
6extern crate alloc;
7
8use core::mem;
9
10use crate::effects::{KernelEffect, KernelEvent, WithdrawalSkipReason};
11use crate::error::{InvalidConfigCode, InvalidStateCode, KernelError};
12use crate::{
13    math::{
14        number::Number,
15        wad::{mul_div_ceil, mul_div_floor},
16    },
17    restrictions::{RestrictionKind, Restrictions},
18};
19use crate::{
20    state::{
21        op_state::{AllocationPlanEntry, OpState, PayoutState, TargetId},
22        queue::{compute_idle_settlement, is_past_cooldown, QueueError, WithdrawQueue},
23        vault::{VaultConfig, VaultState},
24    },
25    transitions::TransitionResult,
26};
27use crate::{
28    transitions::{start_withdrawal, TransitionError, WithdrawalRequest},
29    types::{Address, TimestampNs},
30};
31use alloc::vec;
32use alloc::vec::Vec;
33
34#[cfg(any(feature = "action-refresh-fees", test))]
35use crate::math::wad::{
36    compute_fee_shares_from_assets, compute_management_fee_shares, total_assets_for_fee_accrual,
37};
38#[cfg(any(feature = "action-refresh-fees", test))]
39use crate::state::vault::FeeAccrualAnchor;
40
41#[cfg(any(feature = "action-recovery", test))]
42use crate::transitions::stop_withdrawal;
43
44#[cfg(any(feature = "action-allocation-lifecycle", test))]
45use crate::transitions::{complete_allocation, start_allocation};
46#[cfg(any(feature = "action-refresh-lifecycle", test))]
47use crate::transitions::{complete_refresh, start_refresh};
48
49/// Result of applying a kernel action.
50#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
51#[derive(Clone, PartialEq, Eq)]
52pub struct KernelResult {
53    pub state: VaultState,
54    pub effects: Vec<KernelEffect>,
55}
56
57impl KernelResult {
58    #[must_use]
59    pub fn new(state: VaultState, effects: Vec<KernelEffect>) -> Self {
60        Self { state, effects }
61    }
62}
63
64/// Outcome for payout settlement.
65#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
66#[derive(Clone, PartialEq, Eq)]
67pub enum PayoutOutcome {
68    Success {
69        burn_shares: u128,
70        refund_shares: u128,
71    },
72    Failure {
73        restore_idle: u128,
74        refund_shares: u128,
75    },
76}
77
78/// Planned payout details for satisfying a queued withdrawal from idle assets.
79#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
80#[derive(Clone, PartialEq, Eq)]
81pub struct IdlePayoutPlan {
82    pub op_id: u64,
83    pub receiver: Address,
84    pub assets_out: u128,
85    pub outcome: PayoutOutcome,
86}
87
88#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
89#[derive(Clone, PartialEq, Eq)]
90enum WithdrawalQueueOutcome {
91    None,
92    CoolingDown { requested_at_ns: TimestampNs },
93    Ready(WithdrawalRequest),
94}
95
96#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
97#[derive(Clone, Copy, PartialEq, Eq)]
98struct PendingWithdrawalHead {
99    owner: Address,
100    receiver: Address,
101    escrow_shares: u128,
102    expected_assets: u128,
103    requested_at_ns: TimestampNs,
104}
105
106#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
107#[derive(Clone, PartialEq, Eq)]
108enum WithdrawalHeadOutcome {
109    Skip(WithdrawalSkipReason),
110    CoolingDown { requested_at_ns: TimestampNs },
111    Ready,
112}
113
114#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
115#[derive(Clone, Copy, PartialEq, Eq)]
116struct PayoutSettlement {
117    burn_shares: u128,
118    refund_shares: u128,
119    completed_amount: u128,
120    success: bool,
121    restore_idle: u128,
122}
123
124#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
125#[derive(Clone, Copy, PartialEq, Eq)]
126struct WithdrawalRequestPlan {
127    owner: Address,
128    receiver: Address,
129    shares: u128,
130    expected_assets: u128,
131}
132
133#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
134#[derive(Clone, Copy, PartialEq, Eq)]
135struct ExternalAssetSyncPlan {
136    new_external_assets: u128,
137    new_total_assets: u128,
138}
139
140/// Plan an idle-funded payout from the current vault state.
141///
142/// Returns `Ok(None)` when the vault is in a valid withdrawing state but there is
143/// not enough idle liquidity to satisfy the queue head yet.
144pub fn plan_idle_payout(state: &VaultState) -> Result<Option<IdlePayoutPlan>, KernelError> {
145    planning::plan_idle_payout(state)
146}
147
148/// Kernel actions supported by the dispatcher.
149///
150/// These actions drive the vault state machine. Each action validates preconditions,
151/// updates state, and returns effects to be executed by the chain-specific runtime.
152#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
153#[derive(Clone, PartialEq, Eq)]
154pub enum KernelAction {
155    /// Begin allocating idle assets to external markets according to a plan.
156    ///
157    /// Transition: Idle -> Allocating
158    BeginAllocating {
159        op_id: u64,
160        plan: Vec<AllocationPlanEntry>,
161        now_ns: TimestampNs,
162    },
163
164    /// Deposit assets into the vault and mint shares to the receiver.
165    Deposit {
166        owner: Address,
167        receiver: Address,
168        assets_in: u128,
169        min_shares_out: u128,
170        now_ns: TimestampNs,
171    },
172
173    AtomicWithdraw {
174        owner: Address,
175        receiver: Address,
176        operator: Address,
177        amount: u128,
178        kind: AtomicPayoutKind,
179        now_ns: TimestampNs,
180    },
181
182    /// Request a withdrawal by escrowing shares in the queue.
183    RequestWithdraw {
184        owner: Address,
185        receiver: Address,
186        shares: u128,
187        min_assets_out: u128,
188        now_ns: TimestampNs,
189    },
190
191    /// Execute the next pending withdrawal from the queue.
192    ///
193    /// Transition: Idle -> Withdrawing
194    ExecuteWithdraw { now_ns: TimestampNs },
195
196    /// Begin refreshing external market balances.
197    ///
198    /// Transition: Idle -> Refreshing
199    BeginRefreshing {
200        op_id: u64,
201        plan: Vec<TargetId>,
202        now_ns: TimestampNs,
203    },
204
205    /// Complete an allocation operation.
206    ///
207    /// Transition: Allocating -> Idle or Withdrawing
208    FinishAllocating { op_id: u64, now_ns: TimestampNs },
209
210    /// Sync external asset balances during an active operation.
211    SyncExternalAssets {
212        new_external_assets: u128,
213        op_id: u64,
214        now_ns: TimestampNs,
215    },
216
217    RebalanceWithdraw {
218        op_id: u64,
219        amount: u128,
220        now_ns: TimestampNs,
221    },
222
223    /// Complete a refresh operation.
224    ///
225    /// Transition: Refreshing -> Idle
226    FinishRefreshing { op_id: u64, now_ns: TimestampNs },
227
228    /// Abort a refresh operation (e.g., on external call failure).
229    ///
230    /// Transition: Refreshing -> Idle
231    AbortRefreshing { op_id: u64 },
232
233    /// Settle a payout after asset transfer attempt.
234    ///
235    /// Transition: Payout -> Idle
236    SettlePayout { op_id: u64, outcome: PayoutOutcome },
237
238    /// Abort an allocation operation (e.g., on external call failure).
239    ///
240    /// Transition: Allocating -> Idle
241    AbortAllocating { op_id: u64, restore_idle: u128 },
242
243    /// Abort a withdrawal operation (e.g., on external call failure).
244    ///
245    /// Transition: Withdrawing -> Idle
246    AbortWithdrawing { op_id: u64, refund_shares: u128 },
247
248    /// Refresh fee calculations and mint fee shares.
249    RefreshFees { now_ns: TimestampNs },
250
251    /// Emit a pause-state update for executor-owned pause configuration.
252    Pause { paused: bool },
253
254    /// Emergency reset: force the vault back to Idle from any non-Idle state.
255    ///
256    /// Unlike the regular abort actions, this does not require op_id matching.
257    /// For Withdrawing/Payout states, escrowed shares are refunded to the owner
258    /// and the queue head is dequeued.
259    ///
260    /// Authorization (Owner-only, timelock-gated) must be enforced by the executor.
261    EmergencyReset,
262}
263
264impl KernelAction {
265    #[must_use]
266    pub fn begin_allocating(
267        op_id: u64,
268        plan: Vec<AllocationPlanEntry>,
269        now_ns: TimestampNs,
270    ) -> Self {
271        Self::BeginAllocating {
272            op_id,
273            plan,
274            now_ns,
275        }
276    }
277
278    #[must_use]
279    pub fn deposit(
280        owner: Address,
281        receiver: Address,
282        assets_in: u128,
283        min_shares_out: u128,
284        now_ns: TimestampNs,
285    ) -> Self {
286        Self::Deposit {
287            owner,
288            receiver,
289            assets_in,
290            min_shares_out,
291            now_ns,
292        }
293    }
294
295    #[must_use]
296    pub fn atomic_withdraw(
297        owner: Address,
298        receiver: Address,
299        operator: Address,
300        assets_out: u128,
301        now_ns: TimestampNs,
302    ) -> Self {
303        Self::AtomicWithdraw {
304            owner,
305            receiver,
306            operator,
307            amount: assets_out,
308            kind: AtomicPayoutKind::Withdraw,
309            now_ns,
310        }
311    }
312
313    #[must_use]
314    pub fn atomic_redeem(
315        owner: Address,
316        receiver: Address,
317        operator: Address,
318        shares: u128,
319        now_ns: TimestampNs,
320    ) -> Self {
321        Self::AtomicWithdraw {
322            owner,
323            receiver,
324            operator,
325            amount: shares,
326            kind: AtomicPayoutKind::Redeem,
327            now_ns,
328        }
329    }
330
331    #[must_use]
332    pub fn request_withdraw(
333        owner: Address,
334        receiver: Address,
335        shares: u128,
336        min_assets_out: u128,
337        now_ns: TimestampNs,
338    ) -> Self {
339        Self::RequestWithdraw {
340            owner,
341            receiver,
342            shares,
343            min_assets_out,
344            now_ns,
345        }
346    }
347
348    #[must_use]
349    pub fn execute_withdraw(now_ns: TimestampNs) -> Self {
350        Self::ExecuteWithdraw { now_ns }
351    }
352
353    #[must_use]
354    pub fn begin_refreshing(op_id: u64, plan: Vec<TargetId>, now_ns: TimestampNs) -> Self {
355        Self::BeginRefreshing {
356            op_id,
357            plan,
358            now_ns,
359        }
360    }
361
362    #[must_use]
363    pub fn finish_allocating(op_id: u64, now_ns: TimestampNs) -> Self {
364        Self::FinishAllocating { op_id, now_ns }
365    }
366
367    #[must_use]
368    pub fn sync_external_assets(
369        new_external_assets: u128,
370        op_id: u64,
371        now_ns: TimestampNs,
372    ) -> Self {
373        Self::SyncExternalAssets {
374            new_external_assets,
375            op_id,
376            now_ns,
377        }
378    }
379
380    #[must_use]
381    pub fn rebalance_withdraw(op_id: u64, amount: u128, now_ns: TimestampNs) -> Self {
382        Self::RebalanceWithdraw {
383            op_id,
384            amount,
385            now_ns,
386        }
387    }
388
389    #[must_use]
390    pub fn finish_refreshing(op_id: u64, now_ns: TimestampNs) -> Self {
391        Self::FinishRefreshing { op_id, now_ns }
392    }
393
394    #[must_use]
395    pub fn abort_refreshing(op_id: u64) -> Self {
396        Self::AbortRefreshing { op_id }
397    }
398
399    #[must_use]
400    pub fn settle_payout(op_id: u64, outcome: PayoutOutcome) -> Self {
401        Self::SettlePayout { op_id, outcome }
402    }
403
404    #[must_use]
405    pub fn abort_allocating(op_id: u64, restore_idle: u128) -> Self {
406        Self::AbortAllocating {
407            op_id,
408            restore_idle,
409        }
410    }
411
412    #[must_use]
413    pub fn abort_withdrawing(op_id: u64, refund_shares: u128) -> Self {
414        Self::AbortWithdrawing {
415            op_id,
416            refund_shares,
417        }
418    }
419
420    #[must_use]
421    pub fn refresh_fees(now_ns: TimestampNs) -> Self {
422        Self::RefreshFees { now_ns }
423    }
424
425    #[must_use]
426    pub fn pause(paused: bool) -> Self {
427        Self::Pause { paused }
428    }
429
430    #[must_use]
431    pub const fn emergency_reset() -> Self {
432        Self::EmergencyReset
433    }
434
435    #[must_use]
436    pub const fn op_id(&self) -> Option<u64> {
437        match self {
438            Self::BeginAllocating { op_id, .. }
439            | Self::BeginRefreshing { op_id, .. }
440            | Self::FinishAllocating { op_id, .. }
441            | Self::SyncExternalAssets { op_id, .. }
442            | Self::RebalanceWithdraw { op_id, .. }
443            | Self::FinishRefreshing { op_id, .. }
444            | Self::AbortRefreshing { op_id }
445            | Self::SettlePayout { op_id, .. }
446            | Self::AbortAllocating { op_id, .. }
447            | Self::AbortWithdrawing { op_id, .. } => Some(*op_id),
448            Self::Deposit { .. }
449            | Self::AtomicWithdraw { .. }
450            | Self::RequestWithdraw { .. }
451            | Self::ExecuteWithdraw { .. }
452            | Self::RefreshFees { .. }
453            | Self::Pause { .. }
454            | Self::EmergencyReset => None,
455        }
456    }
457
458    #[must_use]
459    pub const fn timestamp_ns(&self) -> Option<TimestampNs> {
460        match self {
461            Self::BeginAllocating { now_ns, .. }
462            | Self::Deposit { now_ns, .. }
463            | Self::AtomicWithdraw { now_ns, .. }
464            | Self::RequestWithdraw { now_ns, .. }
465            | Self::ExecuteWithdraw { now_ns }
466            | Self::BeginRefreshing { now_ns, .. }
467            | Self::FinishAllocating { now_ns, .. }
468            | Self::SyncExternalAssets { now_ns, .. }
469            | Self::RebalanceWithdraw { now_ns, .. }
470            | Self::FinishRefreshing { now_ns, .. }
471            | Self::RefreshFees { now_ns } => Some(*now_ns),
472            Self::AbortRefreshing { .. }
473            | Self::SettlePayout { .. }
474            | Self::AbortAllocating { .. }
475            | Self::AbortWithdrawing { .. }
476            | Self::Pause { .. }
477            | Self::EmergencyReset => None,
478        }
479    }
480}
481
482#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
483#[derive(Clone, Copy, PartialEq, Eq)]
484pub enum AtomicPayoutKind {
485    Withdraw,
486    Redeem,
487}
488
489/// Effective totals after applying virtual share/asset offsets.
490///
491/// Named fields prevent callers from confusing supply vs assets.
492#[cfg_attr(not(target_arch = "wasm32"), derive(Debug))]
493#[derive(Clone, Copy, PartialEq, Eq)]
494pub struct EffectiveTotals {
495    pub supply: u128,
496    pub assets: u128,
497}
498
499/// Compute effective totals including virtual shares/assets for conversion math.
500pub fn effective_totals(state: &VaultState, config: &VaultConfig) -> EffectiveTotals {
501    conversions::effective_totals(state, config)
502}
503
504/// Convert an asset amount to shares (floor rounding — fewer shares, favors vault).
505pub fn convert_to_shares(state: &VaultState, config: &VaultConfig, assets: u128) -> u128 {
506    conversions::convert_to_shares(state, config, assets)
507}
508
509/// Convert a share amount to assets (floor rounding — fewer assets, favors vault).
510pub fn convert_to_assets(state: &VaultState, config: &VaultConfig, shares: u128) -> u128 {
511    conversions::convert_to_assets(state, config, shares)
512}
513
514/// Convert an asset amount to shares (ceil rounding — more shares, favors user).
515///
516/// Used by ERC-4626 `preview_withdraw` to compute shares burned (rounds against user).
517pub fn convert_to_shares_ceil(state: &VaultState, config: &VaultConfig, assets: u128) -> u128 {
518    conversions::convert_to_shares_ceil(state, config, assets)
519}
520
521/// Convert a share amount to assets (ceil rounding — more assets, favors user).
522///
523/// Used by ERC-4626 `preview_mint` to compute assets needed (rounds against user).
524pub fn convert_to_assets_ceil(state: &VaultState, config: &VaultConfig, shares: u128) -> u128 {
525    conversions::convert_to_assets_ceil(state, config, shares)
526}
527
528/// Preview the shares minted for a deposit of `assets` using kernel conversions.
529#[inline]
530#[must_use]
531pub fn preview_deposit_shares(state: &VaultState, config: &VaultConfig, assets: u128) -> u128 {
532    convert_to_shares(state, config, assets)
533}
534
535/// Preview the assets redeemed for `shares` using kernel conversions.
536#[inline]
537#[must_use]
538pub fn preview_withdraw_assets(state: &VaultState, config: &VaultConfig, shares: u128) -> u128 {
539    convert_to_assets(state, config, shares)
540}
541
542#[cfg(any(feature = "action-recovery", feature = "action-sync-external", test))]
543fn require_active_op_id(
544    op_state: &OpState,
545    provided: u64,
546    error_code: InvalidStateCode,
547) -> Result<(), KernelError> {
548    let active = match op_state.op_id() {
549        Some(active) => active,
550        None => return Err(KernelError::from(error_code)),
551    };
552    if active != provided {
553        return Err(KernelError::OpIdMismatch {
554            expected: active,
555            actual: provided,
556        });
557    }
558    Ok(())
559}
560
561/// Validate that a destructured op_id matches the provided one.
562#[inline]
563fn check_op_id(expected: u64, actual: u64) -> Result<(), KernelError> {
564    if expected != actual {
565        return Err(KernelError::OpIdMismatch { expected, actual });
566    }
567    Ok(())
568}
569
570/// Validate that the withdrawal queue head matches the expected owner/receiver/escrow.
571///
572/// Used by both `AbortWithdrawing` and `SettlePayout` to ensure consistency
573/// between the op-state and the queue.
574fn validate_queue_head(
575    queue: &WithdrawQueue,
576    owner: &Address,
577    receiver: &Address,
578    escrow_shares: u128,
579) -> Result<(), KernelError> {
580    let Some((_, pending)) = queue.head() else {
581        return Err(KernelError::NoPendingWithdrawals);
582    };
583    if pending.owner != *owner
584        || pending.receiver != *receiver
585        || pending.escrow_shares != escrow_shares
586    {
587        return Err(KernelError::from(
588            InvalidStateCode::WithdrawalQueueHeadMismatch,
589        ));
590    }
591    Ok(())
592}
593
594/// Push a `TransferShares` effect to refund escrowed shares to an owner.
595///
596/// No-op if `shares` is zero.
597#[inline]
598fn push_refund_shares(
599    effects: &mut Vec<KernelEffect>,
600    escrow: Address,
601    owner: Address,
602    shares: u128,
603) {
604    if shares > 0 {
605        effects.push(KernelEffect::TransferShares {
606            from: escrow,
607            to: owner,
608            shares,
609        });
610    }
611}
612
613#[cfg(any(feature = "action-refresh-fees", test))]
614#[inline]
615fn mint_fee_shares(
616    effects: &mut Vec<KernelEffect>,
617    total_supply: &mut u128,
618    shares: Number,
619    recipient: Address,
620) -> Result<(), KernelError> {
621    if shares > Number::zero() {
622        let minted: u128 = shares.into();
623        *total_supply = total_supply
624            .checked_add(minted)
625            .ok_or_else(|| KernelError::from(InvalidStateCode::FeeMintOverflowTotalSupply))?;
626        effects.push(KernelEffect::MintShares {
627            owner: recipient,
628            shares: minted,
629        });
630    }
631    Ok(())
632}
633
634#[inline]
635fn map_transition_result<T>(result: Result<T, TransitionError>) -> Result<T, KernelError> {
636    result.map_err(KernelError::Transition)
637}
638
639#[inline]
640fn apply_transition_result(
641    mut state: VaultState,
642    result: Result<TransitionResult, TransitionError>,
643) -> Result<KernelResult, KernelError> {
644    let result = map_transition_result(result)?;
645    state.op_state = result.new_state;
646    Ok(KernelResult::new(state, result.effects))
647}
648
649#[inline]
650fn map_queue_error(err: QueueError) -> KernelError {
651    match err {
652        QueueError::QueueFull { current, max } => KernelError::QueueFull { current, max },
653        QueueError::CacheOverflow => {
654            KernelError::from(InvalidStateCode::WithdrawalQueueCacheOverflow)
655        }
656        QueueError::WithdrawalNotFound { .. } => {
657            KernelError::from(InvalidStateCode::WithdrawalQueueMissingEntry)
658        }
659        QueueError::QueueEmpty => KernelError::from(InvalidStateCode::UnexpectedEmptyQueue),
660        QueueError::InvariantViolation { .. } => {
661            KernelError::from(InvalidStateCode::WithdrawalQueueInvariantViolation)
662        }
663    }
664}
665
666/// Process a deposit: validate restrictions, convert assets→shares, update totals.
667#[allow(clippy::too_many_arguments)]
668fn handle_deposit(
669    mut state: VaultState,
670    config: &VaultConfig,
671    restrictions: Option<&Restrictions>,
672    self_id: &Address,
673    owner: Address,
674    receiver: Address,
675    assets_in: u128,
676    min_shares_out: u128,
677) -> Result<KernelResult, KernelError> {
678    enforce_restrictions(config, restrictions, self_id, &owner)?;
679    enforce_restrictions(config, restrictions, self_id, &receiver)?;
680    if !state.is_idle() {
681        return Err(KernelError::from(InvalidStateCode::DepositRequiresIdle));
682    }
683    if assets_in == 0 {
684        return Err(KernelError::ZeroAmount);
685    }
686
687    let shares_out = convert_to_shares(&state, config, assets_in);
688    if shares_out < min_shares_out {
689        return Err(KernelError::Slippage {
690            min: min_shares_out,
691            actual: shares_out,
692        });
693    }
694
695    state.total_assets = state
696        .total_assets
697        .checked_add(assets_in)
698        .ok_or_else(|| KernelError::from(InvalidStateCode::DepositOverflowTotalAssets))?;
699    state.idle_assets = state
700        .idle_assets
701        .checked_add(assets_in)
702        .ok_or_else(|| KernelError::from(InvalidStateCode::DepositOverflowIdleAssets))?;
703    state.total_shares = state
704        .total_shares
705        .checked_add(shares_out)
706        .ok_or_else(|| KernelError::from(InvalidStateCode::MintOverflowTotalShares))?;
707
708    let effects = vec![
709        KernelEffect::TransferAssetsFrom {
710            from: owner,
711            to: *self_id,
712            amount: assets_in,
713        },
714        KernelEffect::MintShares {
715            owner: receiver,
716            shares: shares_out,
717        },
718        KernelEffect::EmitEvent {
719            event: crate::effects::KernelEvent::DepositProcessed {
720                owner,
721                receiver,
722                assets_in,
723                shares_out,
724            },
725        },
726    ];
727
728    Ok(KernelResult::new(state, effects))
729}
730
731#[inline]
732fn push_atomic_burn_shares(
733    effects: &mut Vec<KernelEffect>,
734    owner: Address,
735    operator: Address,
736    shares: u128,
737) {
738    if operator == owner {
739        effects.push(KernelEffect::BurnShares { owner, shares });
740    } else {
741        effects.push(KernelEffect::BurnSharesFrom {
742            spender: operator,
743            owner,
744            shares,
745        });
746    }
747}
748
749#[inline]
750fn enforce_withdrawal_actors(
751    config: &VaultConfig,
752    restrictions: Option<&Restrictions>,
753    self_id: &Address,
754    owner: &Address,
755    receiver: &Address,
756) -> Result<(), KernelError> {
757    enforce_restrictions(config, restrictions, self_id, owner)?;
758    enforce_restrictions(config, restrictions, self_id, receiver)
759}
760
761#[inline]
762fn require_idle_with_nonzero_amount(
763    state: &VaultState,
764    idle_error: InvalidStateCode,
765    amount: u128,
766) -> Result<(), KernelError> {
767    if !state.is_idle() {
768        return Err(KernelError::from(idle_error));
769    }
770    if amount == 0 {
771        return Err(KernelError::ZeroAmount);
772    }
773    Ok(())
774}
775
776#[inline]
777fn restricted_withdraw_actor(
778    restrictions: Option<&Restrictions>,
779    self_id: &Address,
780    owner: &Address,
781    receiver: &Address,
782) -> Option<RestrictionKind> {
783    restrictions
784        .and_then(|r| r.is_restricted(owner, self_id))
785        .or_else(|| restrictions.and_then(|r| r.is_restricted(receiver, self_id)))
786}
787
788#[inline]
789fn pending_withdrawal_skip_reason(
790    restrictions: Option<&Restrictions>,
791    self_id: &Address,
792    owner: &Address,
793    receiver: &Address,
794    expected_assets: u128,
795) -> Option<WithdrawalSkipReason> {
796    if restricted_withdraw_actor(restrictions, self_id, owner, receiver).is_some() {
797        Some(WithdrawalSkipReason::Restricted)
798    } else if expected_assets == 0 {
799        Some(WithdrawalSkipReason::ZeroExpectedAssets)
800    } else {
801        None
802    }
803}
804
805fn dequeue_skipped_withdrawal(
806    state: &mut VaultState,
807    self_id: &Address,
808    skipped_effects: &mut Vec<KernelEffect>,
809    reason: WithdrawalSkipReason,
810) -> Result<(), KernelError> {
811    let (pending_id, pending) = state
812        .withdraw_queue
813        .dequeue()
814        .ok_or(KernelError::NoPendingWithdrawals)?;
815    push_refund_shares(
816        skipped_effects,
817        *self_id,
818        pending.owner,
819        pending.escrow_shares,
820    );
821    skipped_effects.push(KernelEffect::EmitEvent {
822        event: KernelEvent::WithdrawalSkipped {
823            id: pending_id,
824            owner: pending.owner,
825            receiver: pending.receiver,
826            escrow_shares: pending.escrow_shares,
827            expected_assets: pending.expected_assets,
828            reason,
829        },
830    });
831    Ok(())
832}
833
834#[inline]
835fn pending_withdrawal_head(state: &VaultState) -> Option<PendingWithdrawalHead> {
836    state
837        .withdraw_queue
838        .head()
839        .map(|(_, pending)| PendingWithdrawalHead {
840            owner: pending.owner,
841            receiver: pending.receiver,
842            escrow_shares: pending.escrow_shares,
843            expected_assets: pending.expected_assets,
844            requested_at_ns: pending.requested_at_ns,
845        })
846}
847
848#[inline]
849fn classify_withdrawal_head(
850    head: PendingWithdrawalHead,
851    config: &VaultConfig,
852    restrictions: Option<&Restrictions>,
853    self_id: &Address,
854    now_ns: TimestampNs,
855) -> WithdrawalHeadOutcome {
856    if let Some(reason) = pending_withdrawal_skip_reason(
857        restrictions,
858        self_id,
859        &head.owner,
860        &head.receiver,
861        head.expected_assets,
862    ) {
863        WithdrawalHeadOutcome::Skip(reason)
864    } else if !is_past_cooldown(head.requested_at_ns, now_ns, config.withdrawal_cooldown_ns) {
865        WithdrawalHeadOutcome::CoolingDown {
866            requested_at_ns: head.requested_at_ns,
867        }
868    } else {
869        WithdrawalHeadOutcome::Ready
870    }
871}
872
873#[inline]
874fn withdrawal_request_from_head(
875    state: &mut VaultState,
876    head: PendingWithdrawalHead,
877) -> WithdrawalRequest {
878    WithdrawalRequest {
879        op_id: state.allocate_op_id(),
880        amount: head.expected_assets,
881        receiver: head.receiver,
882        owner: head.owner,
883        escrow_shares: head.escrow_shares,
884    }
885}
886
887#[inline]
888fn plan_withdrawal_request(
889    state: &VaultState,
890    config: &VaultConfig,
891    owner: Address,
892    receiver: Address,
893    shares: u128,
894    min_assets_out: u128,
895) -> Result<WithdrawalRequestPlan, KernelError> {
896    let expected_assets = convert_to_assets(state, config, shares);
897    if expected_assets < min_assets_out {
898        return Err(KernelError::Slippage {
899            min: min_assets_out,
900            actual: expected_assets,
901        });
902    }
903    if expected_assets < config.min_withdrawal_assets {
904        return Err(KernelError::MinWithdrawal {
905            amount: expected_assets,
906            min: config.min_withdrawal_assets,
907        });
908    }
909
910    Ok(WithdrawalRequestPlan {
911        owner,
912        receiver,
913        shares,
914        expected_assets,
915    })
916}
917
918#[inline]
919fn sync_external_in_flight_assets(op_state: &OpState) -> u128 {
920    match op_state {
921        OpState::Allocating(state) => state.remaining,
922        _ => 0,
923    }
924}
925
926#[inline]
927fn ensure_sync_external_state_allowed(op_state: &OpState) -> Result<(), KernelError> {
928    match op_state {
929        OpState::Allocating(_) | OpState::Withdrawing(_) | OpState::Refreshing(_) => Ok(()),
930        _ => Err(KernelError::from(
931            InvalidStateCode::SyncExternalRequiresAllowedStates,
932        )),
933    }
934}
935
936#[inline]
937fn plan_external_asset_sync(
938    state: &VaultState,
939    new_external_assets: u128,
940) -> Result<ExternalAssetSyncPlan, KernelError> {
941    let new_total_assets = state
942        .idle_assets
943        .checked_add(new_external_assets)
944        .ok_or_else(|| KernelError::from(InvalidStateCode::SyncExternalOverflowIdlePlusExternal))?;
945
946    let reference_total = state
947        .total_assets
948        .saturating_add(sync_external_in_flight_assets(&state.op_state));
949    if reference_total > 0 && new_total_assets > reference_total.saturating_mul(2) {
950        return Err(KernelError::from(
951            InvalidStateCode::SyncExternalWouldMoreThanDoubleTotalAssets,
952        ));
953    }
954
955    Ok(ExternalAssetSyncPlan {
956        new_external_assets,
957        new_total_assets,
958    })
959}
960
961fn next_withdrawal_queue_outcome(
962    state: &mut VaultState,
963    config: &VaultConfig,
964    restrictions: Option<&Restrictions>,
965    self_id: &Address,
966    now_ns: TimestampNs,
967    skipped_effects: &mut Vec<KernelEffect>,
968) -> Result<WithdrawalQueueOutcome, KernelError> {
969    loop {
970        let Some(head) = pending_withdrawal_head(state) else {
971            return Ok(WithdrawalQueueOutcome::None);
972        };
973
974        match classify_withdrawal_head(head, config, restrictions, self_id, now_ns) {
975            WithdrawalHeadOutcome::Skip(reason) => {
976                dequeue_skipped_withdrawal(state, self_id, skipped_effects, reason)?;
977            }
978            WithdrawalHeadOutcome::CoolingDown { requested_at_ns } => {
979                return Ok(WithdrawalQueueOutcome::CoolingDown { requested_at_ns });
980            }
981            WithdrawalHeadOutcome::Ready => {
982                return Ok(WithdrawalQueueOutcome::Ready(withdrawal_request_from_head(
983                    state, head,
984                )));
985            }
986        }
987    }
988}
989
990#[allow(clippy::too_many_arguments)]
991fn handle_atomic_withdraw(
992    mut state: VaultState,
993    config: &VaultConfig,
994    restrictions: Option<&Restrictions>,
995    self_id: &Address,
996    owner: Address,
997    receiver: Address,
998    operator: Address,
999    amount: u128,
1000    kind: AtomicPayoutKind,
1001) -> Result<KernelResult, KernelError> {
1002    enforce_withdrawal_actors(config, restrictions, self_id, &owner, &receiver)?;
1003    require_idle_with_nonzero_amount(&state, InvalidStateCode::AtomicWithdrawRequiresIdle, amount)?;
1004
1005    let (shares, assets_out) = match kind {
1006        AtomicPayoutKind::Withdraw => {
1007            if amount > state.idle_assets {
1008                return Err(KernelError::from(
1009                    InvalidStateCode::AtomicWithdrawExceedsIdleAssets,
1010                ));
1011            }
1012            (convert_to_shares_ceil(&state, config, amount), amount)
1013        }
1014        AtomicPayoutKind::Redeem => {
1015            let assets_out = convert_to_assets(&state, config, amount);
1016            if assets_out > state.idle_assets {
1017                return Err(KernelError::from(
1018                    InvalidStateCode::AtomicWithdrawExceedsIdleAssets,
1019                ));
1020            }
1021            (amount, assets_out)
1022        }
1023    };
1024
1025    if assets_out > state.idle_assets {
1026        return Err(KernelError::from(
1027            InvalidStateCode::AtomicWithdrawExceedsIdleAssets,
1028        ));
1029    }
1030    state.total_shares = state
1031        .total_shares
1032        .checked_sub(shares)
1033        .ok_or_else(|| KernelError::from(InvalidStateCode::AtomicWithdrawBurnExceedsTotalShares))?;
1034    state.idle_assets = state
1035        .idle_assets
1036        .checked_sub(assets_out)
1037        .ok_or_else(|| KernelError::from(InvalidStateCode::AtomicWithdrawExceedsIdleAssets))?;
1038    state.total_assets = state
1039        .total_assets
1040        .checked_sub(assets_out)
1041        .ok_or_else(|| KernelError::from(InvalidStateCode::AtomicWithdrawTotalAssetsUnderflow))?;
1042
1043    let mut effects = Vec::new();
1044    push_atomic_burn_shares(&mut effects, owner, operator, shares);
1045    effects.push(KernelEffect::TransferAssets {
1046        to: receiver,
1047        amount: assets_out,
1048    });
1049    effects.push(KernelEffect::EmitEvent {
1050        event: KernelEvent::AtomicWithdrawProcessed {
1051            owner,
1052            receiver,
1053            shares_burned: shares,
1054            assets_out,
1055        },
1056    });
1057    Ok(KernelResult::new(state, effects))
1058}
1059
1060/// Enqueue a withdrawal request: validate, compute expected assets, escrow shares.
1061#[allow(clippy::too_many_arguments)]
1062fn handle_request_withdraw(
1063    mut state: VaultState,
1064    config: &VaultConfig,
1065    restrictions: Option<&Restrictions>,
1066    self_id: &Address,
1067    owner: Address,
1068    receiver: Address,
1069    shares: u128,
1070    min_assets_out: u128,
1071    now_ns: TimestampNs,
1072) -> Result<KernelResult, KernelError> {
1073    enforce_withdrawal_actors(config, restrictions, self_id, &owner, &receiver)?;
1074    require_idle_with_nonzero_amount(
1075        &state,
1076        InvalidStateCode::RequestWithdrawRequiresIdle,
1077        shares,
1078    )?;
1079
1080    let request_plan =
1081        plan_withdrawal_request(&state, config, owner, receiver, shares, min_assets_out)?;
1082
1083    let id = state
1084        .withdraw_queue
1085        .enqueue(
1086            request_plan.owner,
1087            request_plan.receiver,
1088            request_plan.shares,
1089            request_plan.expected_assets,
1090            now_ns,
1091            config.max_pending_withdrawals,
1092        )
1093        .map_err(map_queue_error)?;
1094
1095    let effects = vec![
1096        KernelEffect::TransferShares {
1097            from: request_plan.owner,
1098            to: *self_id,
1099            shares: request_plan.shares,
1100        },
1101        KernelEffect::EmitEvent {
1102            event: crate::effects::KernelEvent::WithdrawalRequested {
1103                id,
1104                owner: request_plan.owner,
1105                receiver: request_plan.receiver,
1106                shares: request_plan.shares,
1107                expected_assets: request_plan.expected_assets,
1108            },
1109        },
1110    ];
1111
1112    Ok(KernelResult::new(state, effects))
1113}
1114
1115/// Execute the next queued withdrawal after cooldown.
1116fn handle_execute_withdraw(
1117    mut state: VaultState,
1118    config: &VaultConfig,
1119    restrictions: Option<&Restrictions>,
1120    self_id: &Address,
1121    now_ns: TimestampNs,
1122) -> Result<KernelResult, KernelError> {
1123    if !state.op_state.is_idle() {
1124        let error_code = if state.op_state.is_withdrawing() {
1125            InvalidStateCode::ExecuteWithdrawRequiresIdleUseCallbacks
1126        } else {
1127            InvalidStateCode::ExecuteWithdrawRequiresIdle
1128        };
1129        return Err(KernelError::from(error_code));
1130    }
1131
1132    if config.paused {
1133        return Err(KernelError::Restricted(RestrictionKind::Paused));
1134    }
1135    if matches!(restrictions, Some(Restrictions::Paused)) {
1136        return Err(KernelError::Restricted(RestrictionKind::Paused));
1137    }
1138
1139    let mut skipped_effects = Vec::new();
1140    match next_withdrawal_queue_outcome(
1141        &mut state,
1142        config,
1143        restrictions,
1144        self_id,
1145        now_ns,
1146        &mut skipped_effects,
1147    )? {
1148        WithdrawalQueueOutcome::None => {
1149            if skipped_effects.is_empty() {
1150                Err(KernelError::NoPendingWithdrawals)
1151            } else {
1152                Ok(KernelResult::new(state, skipped_effects))
1153            }
1154        }
1155        WithdrawalQueueOutcome::CoolingDown { requested_at_ns } => {
1156            if skipped_effects.is_empty() {
1157                Err(KernelError::Cooldown {
1158                    requested_at: requested_at_ns.into(),
1159                    now: now_ns.into(),
1160                    cooldown_ns: config.withdrawal_cooldown_ns,
1161                })
1162            } else {
1163                Ok(KernelResult::new(state, skipped_effects))
1164            }
1165        }
1166        WithdrawalQueueOutcome::Ready(request) => {
1167            let transition = start_withdrawal(mem::take(&mut state.op_state), request);
1168            let mut result = apply_transition_result(state, transition)?;
1169            skipped_effects.append(&mut result.effects);
1170            result.effects = skipped_effects;
1171            Ok(result)
1172        }
1173    }
1174}
1175
1176/// Start an allocation: transition to Allocating and decrement idle assets.
1177#[cfg(any(feature = "action-allocation-lifecycle", test))]
1178fn handle_begin_allocating(
1179    mut state: VaultState,
1180    op_id: u64,
1181    plan: Vec<AllocationPlanEntry>,
1182) -> Result<KernelResult, KernelError> {
1183    let result = map_transition_result(start_allocation(
1184        mem::take(&mut state.op_state),
1185        plan,
1186        op_id,
1187    ))?;
1188
1189    // Compute allocation total from the plan and decrement idle_assets.
1190    let alloc_total = match result.new_state.as_allocating() {
1191        Some(allocating) => allocating.remaining,
1192        None => {
1193            return Err(KernelError::from(
1194                InvalidStateCode::StartAllocationMustReturnAllocating,
1195            ))
1196        }
1197    };
1198
1199    if alloc_total > state.idle_assets {
1200        return Err(KernelError::from(
1201            InvalidStateCode::AllocationPlanExceedsIdleAssets,
1202        ));
1203    }
1204
1205    state.idle_assets -= alloc_total;
1206    state.sync_total_assets();
1207    state.op_state = result.new_state;
1208    Ok(KernelResult::new(state, result.effects))
1209}
1210
1211/// Finish an allocation, optionally chaining into a pending withdrawal.
1212#[cfg(any(feature = "action-allocation-lifecycle", test))]
1213fn handle_finish_allocating(
1214    mut state: VaultState,
1215    config: &VaultConfig,
1216    restrictions: Option<&Restrictions>,
1217    self_id: &Address,
1218    op_id: u64,
1219    now_ns: TimestampNs,
1220) -> Result<KernelResult, KernelError> {
1221    let mut skipped_effects = Vec::new();
1222
1223    let pending_req = if config.paused {
1224        None
1225    } else {
1226        match next_withdrawal_queue_outcome(
1227            &mut state,
1228            config,
1229            restrictions,
1230            self_id,
1231            now_ns,
1232            &mut skipped_effects,
1233        )? {
1234            WithdrawalQueueOutcome::Ready(request) => Some(request),
1235            WithdrawalQueueOutcome::None | WithdrawalQueueOutcome::CoolingDown { .. } => None,
1236        }
1237    };
1238
1239    let transition = complete_allocation(mem::take(&mut state.op_state), op_id, pending_req);
1240    let mut result = apply_transition_result(state, transition)?;
1241    skipped_effects.append(&mut result.effects);
1242    result.effects = skipped_effects;
1243    Ok(result)
1244}
1245
1246#[cfg(any(feature = "action-sync-external", test))]
1247fn handle_sync_external_assets(
1248    mut state: VaultState,
1249    new_external_assets: u128,
1250    op_id: u64,
1251) -> Result<KernelResult, KernelError> {
1252    require_active_op_id(
1253        &state.op_state,
1254        op_id,
1255        InvalidStateCode::SyncExternalRequiresActiveOp,
1256    )?;
1257
1258    ensure_sync_external_state_allowed(&state.op_state)?;
1259    let sync_plan = plan_external_asset_sync(&state, new_external_assets)?;
1260
1261    state.external_assets = sync_plan.new_external_assets;
1262    state.total_assets = sync_plan.new_total_assets;
1263
1264    let total_assets = state.total_assets;
1265    Ok(KernelResult::new(
1266        state,
1267        vec![KernelEffect::EmitEvent {
1268            event: crate::effects::KernelEvent::ExternalAssetsSynced {
1269                op_id,
1270                new_external_assets: sync_plan.new_external_assets,
1271                total_assets,
1272            },
1273        }],
1274    ))
1275}
1276
1277#[cfg(any(feature = "action-sync-external", test))]
1278fn handle_rebalance_withdraw(
1279    mut state: VaultState,
1280    op_id: u64,
1281    amount: u128,
1282) -> Result<KernelResult, KernelError> {
1283    match &state.op_state {
1284        OpState::Idle => {}
1285        OpState::Allocating(_) => {
1286            require_active_op_id(
1287                &state.op_state,
1288                op_id,
1289                InvalidStateCode::SyncExternalRequiresActiveOp,
1290            )?;
1291        }
1292        _ => {
1293            return Err(KernelError::from(
1294                InvalidStateCode::RebalanceWithdrawRequiresIdle,
1295            ));
1296        }
1297    }
1298
1299    if amount > state.external_assets {
1300        return Err(KernelError::from(
1301            InvalidStateCode::RebalanceWithdrawExceedsExternalAssets,
1302        ));
1303    }
1304
1305    state.external_assets -= amount;
1306    state.idle_assets = state
1307        .idle_assets
1308        .checked_add(amount)
1309        .ok_or_else(|| KernelError::from(InvalidStateCode::RebalanceWithdrawOverflowsIdleAssets))?;
1310    state.sync_total_assets();
1311
1312    let new_external_assets = state.external_assets;
1313    let total_assets = state.total_assets;
1314    Ok(KernelResult::new(
1315        state,
1316        vec![KernelEffect::EmitEvent {
1317            event: crate::effects::KernelEvent::ExternalAssetsSynced {
1318                op_id,
1319                new_external_assets,
1320                total_assets,
1321            },
1322        }],
1323    ))
1324}
1325
1326#[cfg(any(feature = "action-recovery", test))]
1327fn handle_abort_refreshing(mut state: VaultState, op_id: u64) -> Result<KernelResult, KernelError> {
1328    require_active_op_id(
1329        &state.op_state,
1330        op_id,
1331        InvalidStateCode::AbortRefreshingRequiresActiveOp,
1332    )?;
1333
1334    if !matches!(state.op_state, OpState::Refreshing(_)) {
1335        return Err(KernelError::from(
1336            InvalidStateCode::AbortRefreshingRequiresRefreshing,
1337        ));
1338    }
1339
1340    state.op_state = OpState::Idle;
1341    Ok(KernelResult::new(state, vec![]))
1342}
1343
1344#[cfg(any(feature = "action-recovery", test))]
1345fn handle_abort_allocating(
1346    mut state: VaultState,
1347    op_id: u64,
1348    restore_idle: u128,
1349) -> Result<KernelResult, KernelError> {
1350    let alloc = match &state.op_state {
1351        OpState::Allocating(s) => s,
1352        _ => {
1353            return Err(KernelError::from(
1354                InvalidStateCode::AbortAllocatingRequiresAllocating,
1355            ))
1356        }
1357    };
1358
1359    check_op_id(alloc.op_id, op_id)?;
1360    if restore_idle != alloc.remaining {
1361        return Err(KernelError::from(
1362            InvalidStateCode::AbortAllocatingRestoreIdleMismatch,
1363        ));
1364    }
1365
1366    state.restore_to_idle(restore_idle);
1367    state.op_state = OpState::Idle;
1368    Ok(KernelResult::new(state, vec![]))
1369}
1370
1371#[cfg(any(feature = "action-recovery", test))]
1372fn handle_abort_withdrawing(
1373    mut state: VaultState,
1374    self_id: &Address,
1375    op_id: u64,
1376    refund_shares: u128,
1377) -> Result<KernelResult, KernelError> {
1378    let withdraw = match &state.op_state {
1379        OpState::Withdrawing(s) => s,
1380        _ => {
1381            return Err(KernelError::from(
1382                InvalidStateCode::AbortWithdrawingRequiresWithdrawing,
1383            ))
1384        }
1385    };
1386
1387    check_op_id(withdraw.op_id, op_id)?;
1388    if refund_shares != withdraw.escrow_shares {
1389        return Err(KernelError::from(
1390            InvalidStateCode::AbortWithdrawingRefundMismatch,
1391        ));
1392    }
1393
1394    validate_queue_head(
1395        &state.withdraw_queue,
1396        &withdraw.owner,
1397        &withdraw.receiver,
1398        withdraw.escrow_shares,
1399    )?;
1400
1401    let result = map_transition_result(stop_withdrawal(
1402        mem::take(&mut state.op_state),
1403        op_id,
1404        *self_id,
1405    ))?;
1406    state.op_state = result.new_state;
1407    state.withdraw_queue.dequeue();
1408    Ok(KernelResult::new(state, result.effects))
1409}
1410
1411#[inline]
1412fn plan_payout_settlement(
1413    payout: &PayoutState,
1414    outcome: PayoutOutcome,
1415) -> Result<PayoutSettlement, KernelError> {
1416    match outcome {
1417        PayoutOutcome::Success {
1418            burn_shares,
1419            refund_shares,
1420        } => {
1421            let settled_shares = burn_shares.checked_add(refund_shares).ok_or_else(|| {
1422                KernelError::from(InvalidStateCode::PayoutSuccessSettlementMismatch)
1423            })?;
1424
1425            if settled_shares != payout.escrow_shares {
1426                return Err(KernelError::from(
1427                    InvalidStateCode::PayoutSuccessSettlementMismatch,
1428                ));
1429            }
1430
1431            Ok(PayoutSettlement {
1432                burn_shares,
1433                refund_shares,
1434                completed_amount: payout.amount,
1435                success: true,
1436                restore_idle: 0,
1437            })
1438        }
1439        PayoutOutcome::Failure {
1440            restore_idle,
1441            refund_shares,
1442        } => {
1443            if refund_shares != payout.escrow_shares {
1444                return Err(KernelError::from(
1445                    InvalidStateCode::PayoutFailureSettlementMismatch,
1446                ));
1447            }
1448            if restore_idle != payout.amount {
1449                return Err(KernelError::from(
1450                    InvalidStateCode::PayoutFailureRestoreIdleMismatch,
1451                ));
1452            }
1453
1454            Ok(PayoutSettlement {
1455                burn_shares: 0,
1456                refund_shares,
1457                completed_amount: 0,
1458                success: false,
1459                restore_idle,
1460            })
1461        }
1462    }
1463}
1464
1465fn apply_payout_settlement(
1466    state: &mut VaultState,
1467    payout: &PayoutState,
1468    settlement: PayoutSettlement,
1469    escrow_address: Address,
1470    effects: &mut Vec<KernelEffect>,
1471) -> Result<(), KernelError> {
1472    if settlement.burn_shares > 0 {
1473        effects.push(KernelEffect::BurnShares {
1474            owner: escrow_address,
1475            shares: settlement.burn_shares,
1476        });
1477        state.total_shares = state
1478            .total_shares
1479            .checked_sub(settlement.burn_shares)
1480            .ok_or_else(|| KernelError::from(InvalidStateCode::PayoutBurnExceedsTotalShares))?;
1481    }
1482
1483    push_refund_shares(
1484        effects,
1485        escrow_address,
1486        payout.owner,
1487        settlement.refund_shares,
1488    );
1489
1490    if settlement.success {
1491        state.idle_assets = state
1492            .idle_assets
1493            .checked_sub(payout.amount)
1494            .ok_or_else(|| KernelError::from(InvalidStateCode::PayoutFailureRestoreIdleMismatch))?;
1495        state.sync_total_assets();
1496    } else {
1497        state.restore_to_idle(settlement.restore_idle);
1498    }
1499
1500    state.op_state = OpState::Idle;
1501    Ok(())
1502}
1503
1504/// Settle a payout after asset transfer attempt (success or failure).
1505fn handle_settle_payout(
1506    mut state: VaultState,
1507    self_id: &Address,
1508    op_id: u64,
1509    outcome: PayoutOutcome,
1510) -> Result<KernelResult, KernelError> {
1511    let payout = match mem::take(&mut state.op_state) {
1512        OpState::Payout(s) => s,
1513        _ => {
1514            return Err(KernelError::from(
1515                InvalidStateCode::SettlePayoutRequiresPayout,
1516            ))
1517        }
1518    };
1519
1520    check_op_id(payout.op_id, op_id)?;
1521
1522    validate_queue_head(
1523        &state.withdraw_queue,
1524        &payout.owner,
1525        &payout.receiver,
1526        payout.escrow_shares,
1527    )?;
1528
1529    let escrow_address = *self_id;
1530    let mut effects = Vec::new();
1531
1532    let settlement = plan_payout_settlement(&payout, outcome)?;
1533    apply_payout_settlement(
1534        &mut state,
1535        &payout,
1536        settlement,
1537        escrow_address,
1538        &mut effects,
1539    )?;
1540
1541    effects.push(KernelEffect::EmitEvent {
1542        event: KernelEvent::PayoutCompleted {
1543            op_id,
1544            success: settlement.success,
1545            burn_shares: settlement.burn_shares,
1546            refund_shares: settlement.refund_shares,
1547            amount: settlement.completed_amount,
1548        },
1549    });
1550
1551    state.withdraw_queue.dequeue();
1552    Ok(KernelResult::new(state, effects))
1553}
1554
1555#[cfg(any(feature = "action-refresh-fees", test))]
1556fn handle_refresh_fees(
1557    mut state: VaultState,
1558    config: &VaultConfig,
1559    now_ns: TimestampNs,
1560) -> Result<KernelResult, KernelError> {
1561    if !state.is_idle() {
1562        return Err(KernelError::from(InvalidStateCode::RefreshFeesRequiresIdle));
1563    }
1564
1565    // Reject backwards time to prevent fee calculation issues
1566    if now_ns <= state.fee_anchor.timestamp_ns {
1567        return Err(KernelError::from(
1568            InvalidStateCode::FeeRefreshTimestampMustAdvance,
1569        ));
1570    }
1571
1572    let cur_total_assets = state.total_assets;
1573    let mut total_supply = state.total_shares;
1574    let anchor = state.fee_anchor;
1575    let mut effects = Vec::new();
1576
1577    // Cap effective total_assets for fee accrual (mitigates donation attacks)
1578    let fee_total_assets = total_assets_for_fee_accrual(
1579        cur_total_assets,
1580        anchor.total_assets,
1581        anchor.timestamp_ns.into(),
1582        now_ns.into(),
1583        config.fees.max_total_assets_growth_rate,
1584    );
1585
1586    // Management fees (time-based, pro-rated over elapsed time)
1587    let mgmt_shares = compute_management_fee_shares(
1588        fee_total_assets,
1589        cur_total_assets,
1590        total_supply,
1591        config.fees.management.fee_wad,
1592        anchor.timestamp_ns.into(),
1593        now_ns.into(),
1594    );
1595    mint_fee_shares(
1596        &mut effects,
1597        &mut total_supply,
1598        mgmt_shares,
1599        config.fees.management.recipient,
1600    )?;
1601
1602    // Performance fees (profit-based)
1603    let profit = fee_total_assets.saturating_sub(anchor.total_assets);
1604    let fee_assets = config
1605        .fees
1606        .performance
1607        .fee_wad
1608        .apply_floored(Number::from(profit));
1609    let perf_shares = compute_fee_shares_from_assets(
1610        fee_assets,
1611        Number::from(cur_total_assets),
1612        Number::from(total_supply),
1613    );
1614    mint_fee_shares(
1615        &mut effects,
1616        &mut total_supply,
1617        perf_shares,
1618        config.fees.performance.recipient,
1619    )?;
1620
1621    state.total_shares = total_supply;
1622    state.fee_anchor = FeeAccrualAnchor::new(cur_total_assets, now_ns);
1623
1624    effects.push(KernelEffect::EmitEvent {
1625        event: crate::effects::KernelEvent::FeesRefreshed {
1626            now_ns: now_ns.into(),
1627            total_assets: cur_total_assets,
1628        },
1629    });
1630
1631    Ok(KernelResult::new(state, effects))
1632}
1633
1634#[cfg(any(feature = "action-recovery", test))]
1635fn handle_emergency_reset(
1636    mut state: VaultState,
1637    self_id: &Address,
1638) -> Result<KernelResult, KernelError> {
1639    let prev_state = mem::take(&mut state.op_state);
1640    let from_code = prev_state.kind_code();
1641    let op_id = match prev_state.op_id() {
1642        Some(op_id) => op_id,
1643        None => {
1644            return Err(KernelError::from(
1645                InvalidStateCode::EmergencyResetAlreadyIdle,
1646            ))
1647        }
1648    };
1649
1650    let mut effects = Vec::new();
1651    let escrow_address = *self_id;
1652
1653    match prev_state {
1654        OpState::Idle => {
1655            return Err(KernelError::from(
1656                InvalidStateCode::EmergencyResetAlreadyIdle,
1657            ))
1658        }
1659        OpState::Refreshing(_) => {
1660            // No assets in-flight, just reset.
1661        }
1662        OpState::Allocating(alloc) => {
1663            // Restore unallocated assets back to idle.
1664            state.restore_to_idle(alloc.remaining);
1665        }
1666        OpState::Withdrawing(w) => {
1667            push_refund_shares(&mut effects, escrow_address, w.owner, w.escrow_shares);
1668            // Restore any collected assets back to idle.
1669            state.restore_to_idle(w.collected);
1670            state.withdraw_queue.dequeue();
1671        }
1672        OpState::Payout(p) => {
1673            push_refund_shares(&mut effects, escrow_address, p.owner, p.escrow_shares);
1674            // Restore payout amount back to idle.
1675            state.restore_to_idle(p.amount);
1676            state.withdraw_queue.dequeue();
1677        }
1678    }
1679
1680    state.op_state = OpState::Idle;
1681    effects.push(KernelEffect::EmitEvent {
1682        event: KernelEvent::EmergencyResetCompleted {
1683            op_id,
1684            from_state: from_code,
1685        },
1686    });
1687
1688    Ok(KernelResult::new(state, effects))
1689}
1690
1691/// Apply a kernel action to state, returning updated state and effects.
1692#[allow(unused_mut)]
1693pub fn apply_action(
1694    mut state: VaultState,
1695    config: &VaultConfig,
1696    restrictions: Option<&Restrictions>,
1697    self_id: &Address,
1698    action: KernelAction,
1699) -> Result<KernelResult, KernelError> {
1700    dispatch::apply_action(state, config, restrictions, self_id, action)
1701}
1702
1703fn enforce_restrictions(
1704    config: &VaultConfig,
1705    restrictions: Option<&Restrictions>,
1706    self_id: &Address,
1707    actor: &Address,
1708) -> Result<(), KernelError> {
1709    access::enforce_restrictions(config, restrictions, self_id, actor)
1710}
1711
1712mod planning {
1713    use super::*;
1714
1715    pub(super) fn plan_idle_payout(
1716        state: &VaultState,
1717    ) -> Result<Option<IdlePayoutPlan>, KernelError> {
1718        let (request_owner, request_receiver, request_escrow, request_expected) = state
1719            .withdraw_queue
1720            .head()
1721            .map(|(_, request)| {
1722                (
1723                    request.owner,
1724                    request.receiver,
1725                    request.escrow_shares,
1726                    request.expected_assets,
1727                )
1728            })
1729            .ok_or_else(|| KernelError::from(InvalidStateCode::UnexpectedEmptyQueue))?;
1730
1731        let withdrawing = match &state.op_state {
1732            OpState::Withdrawing(withdrawing) => withdrawing,
1733            _ => {
1734                return Err(KernelError::from(
1735                    InvalidStateCode::ExecuteWithdrawRequiresIdleUseCallbacks,
1736                ))
1737            }
1738        };
1739
1740        if request_owner != withdrawing.owner
1741            || request_receiver != withdrawing.receiver
1742            || request_escrow != withdrawing.escrow_shares
1743        {
1744            return Err(KernelError::from(
1745                InvalidStateCode::WithdrawalQueueHeadMismatch,
1746            ));
1747        }
1748
1749        let available_assets = state.idle_assets;
1750        if available_assets < request_expected
1751            && available_assets < crate::state::queue::MIN_WITHDRAWAL_ASSETS
1752        {
1753            return Ok(None);
1754        }
1755
1756        let Some(settlement) =
1757            compute_idle_settlement(request_escrow, request_expected, available_assets)
1758        else {
1759            return Ok(None);
1760        };
1761
1762        if settlement.assets_out == 0 {
1763            return Ok(None);
1764        }
1765
1766        Ok(Some(IdlePayoutPlan {
1767            op_id: withdrawing.op_id,
1768            receiver: withdrawing.receiver,
1769            assets_out: settlement.assets_out,
1770            outcome: PayoutOutcome::Success {
1771                burn_shares: settlement.settlement.to_burn,
1772                refund_shares: settlement.settlement.refund,
1773            },
1774        }))
1775    }
1776}
1777
1778mod conversions {
1779    use super::*;
1780
1781    pub(super) fn effective_totals(state: &VaultState, config: &VaultConfig) -> EffectiveTotals {
1782        EffectiveTotals {
1783            supply: state
1784                .total_shares
1785                .saturating_add(config.virtual_shares.max(1)),
1786            assets: state
1787                .total_assets
1788                .saturating_add(config.virtual_assets.max(1)),
1789        }
1790    }
1791
1792    pub(super) fn convert_to_shares(
1793        state: &VaultState,
1794        config: &VaultConfig,
1795        assets: u128,
1796    ) -> u128 {
1797        let t = effective_totals(state, config);
1798        u128::from(mul_div_floor(
1799            Number::from(assets),
1800            Number::from(t.supply),
1801            Number::from(t.assets),
1802        ))
1803    }
1804
1805    pub(super) fn convert_to_assets(
1806        state: &VaultState,
1807        config: &VaultConfig,
1808        shares: u128,
1809    ) -> u128 {
1810        let t = effective_totals(state, config);
1811        u128::from(mul_div_floor(
1812            Number::from(shares),
1813            Number::from(t.assets),
1814            Number::from(t.supply),
1815        ))
1816    }
1817
1818    pub(super) fn convert_to_shares_ceil(
1819        state: &VaultState,
1820        config: &VaultConfig,
1821        assets: u128,
1822    ) -> u128 {
1823        let t = effective_totals(state, config);
1824        u128::from(mul_div_ceil(
1825            Number::from(assets),
1826            Number::from(t.supply),
1827            Number::from(t.assets),
1828        ))
1829    }
1830
1831    pub(super) fn convert_to_assets_ceil(
1832        state: &VaultState,
1833        config: &VaultConfig,
1834        shares: u128,
1835    ) -> u128 {
1836        let t = effective_totals(state, config);
1837        u128::from(mul_div_ceil(
1838            Number::from(shares),
1839            Number::from(t.assets),
1840            Number::from(t.supply),
1841        ))
1842    }
1843}
1844
1845mod access {
1846    use super::*;
1847
1848    pub(super) fn enforce_restrictions(
1849        config: &VaultConfig,
1850        restrictions: Option<&Restrictions>,
1851        self_id: &Address,
1852        actor: &Address,
1853    ) -> Result<(), KernelError> {
1854        if config.paused {
1855            return Err(KernelError::Restricted(RestrictionKind::Paused));
1856        }
1857        if let Some(restrictions) = restrictions {
1858            if let Some(kind) = restrictions.is_restricted(actor, self_id) {
1859                return Err(KernelError::Restricted(kind));
1860            }
1861        }
1862        Ok(())
1863    }
1864}
1865
1866mod dispatch {
1867    use super::*;
1868
1869    #[allow(unused_mut)]
1870    pub(super) fn apply_action(
1871        mut state: VaultState,
1872        config: &VaultConfig,
1873        restrictions: Option<&Restrictions>,
1874        self_id: &Address,
1875        action: KernelAction,
1876    ) -> Result<KernelResult, KernelError> {
1877        if !config.is_max_pending_valid() {
1878            return Err(KernelError::from(
1879                InvalidConfigCode::MaxPendingWithdrawalsExceedsLimit,
1880            ));
1881        }
1882
1883        match action {
1884            KernelAction::Deposit {
1885                owner,
1886                receiver,
1887                assets_in,
1888                min_shares_out,
1889                now_ns: _,
1890            } => handle_deposit(
1891                state,
1892                config,
1893                restrictions,
1894                self_id,
1895                owner,
1896                receiver,
1897                assets_in,
1898                min_shares_out,
1899            ),
1900
1901            KernelAction::AtomicWithdraw {
1902                owner,
1903                receiver,
1904                operator,
1905                amount,
1906                kind,
1907                now_ns: _,
1908            } => handle_atomic_withdraw(
1909                state,
1910                config,
1911                restrictions,
1912                self_id,
1913                owner,
1914                receiver,
1915                operator,
1916                amount,
1917                kind,
1918            ),
1919
1920            KernelAction::RequestWithdraw {
1921                owner,
1922                receiver,
1923                shares,
1924                min_assets_out,
1925                now_ns,
1926            } => handle_request_withdraw(
1927                state,
1928                config,
1929                restrictions,
1930                self_id,
1931                owner,
1932                receiver,
1933                shares,
1934                min_assets_out,
1935                now_ns,
1936            ),
1937
1938            KernelAction::ExecuteWithdraw { now_ns } => {
1939                handle_execute_withdraw(state, config, restrictions, self_id, now_ns)
1940            }
1941
1942            #[cfg(any(feature = "action-allocation-lifecycle", test))]
1943            KernelAction::BeginAllocating { op_id, plan, .. } => {
1944                handle_begin_allocating(state, op_id, plan)
1945            }
1946            #[cfg(not(any(feature = "action-allocation-lifecycle", test)))]
1947            KernelAction::BeginAllocating { .. } => Err(KernelError::NotImplemented),
1948
1949            #[cfg(any(feature = "action-allocation-lifecycle", test))]
1950            KernelAction::FinishAllocating { op_id, now_ns } => {
1951                handle_finish_allocating(state, config, restrictions, self_id, op_id, now_ns)
1952            }
1953            #[cfg(not(any(feature = "action-allocation-lifecycle", test)))]
1954            KernelAction::FinishAllocating { .. } => Err(KernelError::NotImplemented),
1955
1956            #[cfg(any(feature = "action-refresh-lifecycle", test))]
1957            KernelAction::BeginRefreshing { op_id, plan, .. } => {
1958                let transition = start_refresh(mem::take(&mut state.op_state), plan, op_id);
1959                apply_transition_result(state, transition)
1960            }
1961            #[cfg(not(any(feature = "action-refresh-lifecycle", test)))]
1962            KernelAction::BeginRefreshing { .. } => Err(KernelError::NotImplemented),
1963
1964            #[cfg(any(feature = "action-refresh-lifecycle", test))]
1965            KernelAction::FinishRefreshing { op_id, .. } => {
1966                let transition = complete_refresh(mem::take(&mut state.op_state), op_id);
1967                apply_transition_result(state, transition)
1968            }
1969            #[cfg(not(any(feature = "action-refresh-lifecycle", test)))]
1970            KernelAction::FinishRefreshing { .. } => Err(KernelError::NotImplemented),
1971
1972            #[cfg(any(feature = "action-sync-external", test))]
1973            KernelAction::SyncExternalAssets {
1974                new_external_assets,
1975                op_id,
1976                ..
1977            } => handle_sync_external_assets(state, new_external_assets, op_id),
1978            #[cfg(not(any(feature = "action-sync-external", test)))]
1979            KernelAction::SyncExternalAssets { .. } => Err(KernelError::NotImplemented),
1980
1981            #[cfg(any(feature = "action-sync-external", test))]
1982            KernelAction::RebalanceWithdraw { op_id, amount, .. } => {
1983                handle_rebalance_withdraw(state, op_id, amount)
1984            }
1985            #[cfg(not(any(feature = "action-sync-external", test)))]
1986            KernelAction::RebalanceWithdraw { .. } => Err(KernelError::NotImplemented),
1987
1988            #[cfg(any(feature = "action-recovery", test))]
1989            KernelAction::AbortRefreshing { op_id } => handle_abort_refreshing(state, op_id),
1990            #[cfg(not(any(feature = "action-recovery", test)))]
1991            KernelAction::AbortRefreshing { .. } => Err(KernelError::NotImplemented),
1992
1993            #[cfg(any(feature = "action-recovery", test))]
1994            KernelAction::AbortAllocating {
1995                op_id,
1996                restore_idle,
1997            } => handle_abort_allocating(state, op_id, restore_idle),
1998            #[cfg(not(any(feature = "action-recovery", test)))]
1999            KernelAction::AbortAllocating { .. } => Err(KernelError::NotImplemented),
2000
2001            #[cfg(any(feature = "action-recovery", test))]
2002            KernelAction::AbortWithdrawing {
2003                op_id,
2004                refund_shares,
2005            } => handle_abort_withdrawing(state, self_id, op_id, refund_shares),
2006            #[cfg(not(any(feature = "action-recovery", test)))]
2007            KernelAction::AbortWithdrawing { .. } => Err(KernelError::NotImplemented),
2008
2009            KernelAction::SettlePayout { op_id, outcome } => {
2010                handle_settle_payout(state, self_id, op_id, outcome)
2011            }
2012
2013            #[cfg(any(feature = "action-pause", test))]
2014            KernelAction::Pause { paused } => Ok(KernelResult::new(
2015                state,
2016                vec![KernelEffect::EmitEvent {
2017                    event: crate::effects::KernelEvent::PauseUpdated { paused },
2018                }],
2019            )),
2020            #[cfg(not(any(feature = "action-pause", test)))]
2021            KernelAction::Pause { .. } => Err(KernelError::NotImplemented),
2022
2023            #[cfg(any(feature = "action-refresh-fees", test))]
2024            KernelAction::RefreshFees { now_ns } => handle_refresh_fees(state, config, now_ns),
2025            #[cfg(not(any(feature = "action-refresh-fees", test)))]
2026            KernelAction::RefreshFees { .. } => Err(KernelError::NotImplemented),
2027
2028            #[cfg(any(feature = "action-recovery", test))]
2029            KernelAction::EmergencyReset => handle_emergency_reset(state, self_id),
2030            #[cfg(not(any(feature = "action-recovery", test)))]
2031            KernelAction::EmergencyReset => Err(KernelError::NotImplemented),
2032        }
2033    }
2034}
2035
2036// Tests
2037
2038#[cfg(test)]
2039mod tests;