templar_vault_kernel/state/queue/
mod.rs

1//! Chain-agnostic withdrawal queue types and pure logic functions.
2//!
3//! This module provides data structures for pending withdrawals and pure
4//! functions for queue logic. Storage implementation is left to chain-specific
5//! executors (NEAR, Soroban, etc.).
6
7#[cfg(feature = "borsh-schema")]
8use alloc::string::ToString;
9
10use crate::math::number::Number;
11use crate::math::wad::Wad;
12use crate::types::{Address, EscrowSettlement, TimestampNs};
13
14/// Minimum withdrawal amount in base asset units to prevent dust.
15/// Withdrawals below this threshold should be rejected.
16pub const MIN_WITHDRAWAL_ASSETS: u128 = 1_000;
17
18/// Maximum queue length before rejecting new requests.
19///
20/// This is a legacy alias of [`MAX_PENDING`] to keep queue helpers consistent
21/// with the kernel config limit and avoid ambiguous capacity thresholds.
22pub const MAX_QUEUE_LENGTH: u32 = crate::state::vault::MAX_PENDING as u32;
23
24/// Default cooldown period in nanoseconds (24 hours).
25/// Withdrawals cannot be processed until this time has elapsed.
26pub const DEFAULT_COOLDOWN_NS: u64 = 24 * 60 * 60 * 1_000_000_000;
27
28/// A pending withdrawal request in the queue.
29///
30/// Represents a user's request to redeem shares for underlying assets.
31/// The shares are held in escrow until the withdrawal is processed.
32#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, serde)]
33#[derive(Clone, PartialEq, Eq)]
34pub struct PendingWithdrawal {
35    pub owner: Address,
36    pub receiver: Address,
37    pub escrow_shares: u128,
38    pub expected_assets: u128,
39    pub requested_at_ns: TimestampNs,
40}
41
42impl PendingWithdrawal {
43    #[inline]
44    #[must_use]
45    pub fn new(
46        owner: Address,
47        receiver: Address,
48        escrow_shares: u128,
49        expected_assets: u128,
50        requested_at_ns: TimestampNs,
51    ) -> Self {
52        Self {
53            owner,
54            receiver,
55            escrow_shares,
56            expected_assets,
57            requested_at_ns,
58        }
59    }
60}
61
62/// Result of attempting to satisfy a withdrawal from available assets.
63#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
64#[derive(Clone, PartialEq, Eq)]
65pub struct WithdrawalResult {
66    pub assets_out: u128,
67    pub settlement: EscrowSettlement,
68}
69
70#[inline]
71#[must_use]
72pub fn compute_idle_settlement(
73    escrow_shares: u128,
74    expected_assets: u128,
75    available_assets: u128,
76) -> Option<WithdrawalResult> {
77    if expected_assets == 0 {
78        return Some(WithdrawalResult {
79            assets_out: 0,
80            settlement: if available_assets > 0 {
81                EscrowSettlement::burn_all(escrow_shares)
82            } else {
83                EscrowSettlement::refund_all(escrow_shares)
84            },
85        });
86    }
87
88    if available_assets >= expected_assets {
89        return Some(WithdrawalResult {
90            assets_out: expected_assets,
91            settlement: EscrowSettlement::burn_all(escrow_shares),
92        });
93    }
94
95    if available_assets == 0 {
96        return None;
97    }
98
99    let assets_out = available_assets;
100    Some(WithdrawalResult {
101        assets_out,
102        settlement: compute_settlement(escrow_shares, expected_assets, assets_out),
103    })
104}
105
106/// Status information for a single withdrawal request in the queue.
107#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
108#[derive(Clone, PartialEq, Eq)]
109pub struct WithdrawalRequestStatus {
110    pub index: u32,
111    pub depth_assets: u128,
112    pub withdrawal: PendingWithdrawal,
113}
114
115/// Aggregate status of the entire withdrawal queue.
116#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
117#[derive(Clone, Default, PartialEq, Eq)]
118pub struct QueueStatus {
119    pub length: u32,
120    pub total_expected_assets: u128,
121    pub total_escrow_shares: u128,
122}
123
124#[inline]
125#[must_use]
126pub fn is_valid_withdrawal_amount(assets: u128) -> bool {
127    assets >= MIN_WITHDRAWAL_ASSETS
128}
129
130#[inline]
131#[must_use]
132pub fn can_enqueue(current_length: u32) -> bool {
133    current_length < MAX_QUEUE_LENGTH
134}
135
136#[inline]
137#[must_use]
138pub fn is_past_cooldown(
139    requested_at_ns: TimestampNs,
140    now_ns: TimestampNs,
141    cooldown_ns: u64,
142) -> bool {
143    now_ns >= requested_at_ns.saturating_add_u64(cooldown_ns)
144}
145
146/// Check if a withdrawal can be satisfied given available assets.
147///
148/// A withdrawal can be satisfied if the available assets meet or exceed
149/// the expected asset amount from the withdrawal request.
150///
151/// # Arguments
152/// * `withdrawal` - The pending withdrawal request.
153/// * `available_assets` - Assets currently available for withdrawal.
154#[inline]
155#[must_use]
156pub fn can_satisfy_withdrawal(withdrawal: &PendingWithdrawal, available_assets: u128) -> bool {
157    available_assets >= withdrawal.expected_assets
158}
159
160/// Check if a withdrawal can be partially satisfied.
161///
162/// A partial satisfaction is possible when:
163/// 1. Available assets are non-zero but less than expected.
164/// 2. The available amount meets the minimum withdrawal threshold.
165///
166/// # Arguments
167/// * `withdrawal` - The pending withdrawal request.
168/// * `available_assets` - Assets currently available.
169#[inline]
170#[must_use]
171pub fn can_partially_satisfy(withdrawal: &PendingWithdrawal, available_assets: u128) -> bool {
172    available_assets > 0
173        && available_assets < withdrawal.expected_assets
174        && available_assets >= MIN_WITHDRAWAL_ASSETS
175}
176
177/// Calculate how many withdrawals can be fully satisfied from a queue.
178///
179/// Iterates through withdrawals in order, counting how many can be fully
180/// satisfied before running out of available assets.
181///
182/// # Arguments
183/// * `withdrawals` - Iterator over pending withdrawals (in queue order).
184/// * `available_assets` - Total assets available for withdrawals.
185///
186/// # Returns
187/// Tuple of (count of satisfiable withdrawals, total assets needed for those withdrawals).
188#[must_use]
189pub fn count_satisfiable<'a, I>(withdrawals: I, available_assets: u128) -> (u32, u128)
190where
191    I: IntoIterator<Item = &'a PendingWithdrawal>,
192{
193    let mut count = 0u32;
194    let mut total_assets = 0u128;
195
196    for withdrawal in withdrawals {
197        let new_total = total_assets.saturating_add(withdrawal.expected_assets);
198        if new_total > available_assets {
199            break;
200        }
201        total_assets = new_total;
202        count = count.saturating_add(1);
203    }
204
205    (count, total_assets)
206}
207
208// Pure Functions - Settlement Computation
209
210/// Compute escrow settlement when completing a withdrawal.
211///
212/// Determines how many shares to burn vs refund based on actual redemption
213/// versus the original expected amount.
214///
215/// # Arguments
216/// * `escrow_shares` - Total shares held in escrow.
217/// * `expected_assets` - Assets expected at time of request.
218/// * `actual_assets` - Assets actually being redeemed.
219///
220/// # Returns
221/// `EscrowSettlement` with shares to burn and shares to refund.
222///
223/// # Logic
224/// - If actual >= expected: burn all shares (full redemption).
225/// - If actual < expected: burn proportional shares, refund the rest.
226/// - If actual == 0: refund all shares (cancellation).
227#[inline]
228#[must_use]
229pub fn compute_settlement(
230    escrow_shares: u128,
231    expected_assets: u128,
232    actual_assets: u128,
233) -> EscrowSettlement {
234    if escrow_shares == 0 {
235        return EscrowSettlement {
236            to_burn: 0,
237            refund: 0,
238        };
239    }
240
241    if actual_assets == 0 {
242        // Full cancellation - refund all shares
243        return EscrowSettlement::refund_all(escrow_shares);
244    }
245
246    if expected_assets == 0 {
247        return EscrowSettlement::refund_all(escrow_shares);
248    }
249
250    if actual_assets >= expected_assets {
251        // Full redemption - burn all shares
252        return EscrowSettlement::burn_all(escrow_shares);
253    }
254
255    // Partial redemption - burn proportional shares, refund the rest.
256    // Use ceil to avoid zero-burn partials (assets out without burning shares).
257    // shares_to_burn = ceil(escrow_shares * actual_assets / expected_assets)
258    let shares_to_burn = Number::mul_div_ceil(
259        Number::from(escrow_shares),
260        Number::from(actual_assets),
261        Number::from(expected_assets),
262    )
263    .as_u128_trunc();
264
265    let shares_to_refund = escrow_shares.saturating_sub(shares_to_burn);
266
267    EscrowSettlement::partial(shares_to_burn, shares_to_refund)
268}
269
270/// Compute settlement using share price (WAD-scaled).
271///
272/// Alternative settlement computation using current share price instead of
273/// asset ratios. Useful when share price is already computed.
274///
275/// # Arguments
276/// * `escrow_shares` - Total shares held in escrow.
277/// * `share_price_wad` - Current share price as a WAD (1e18 = 1.0).
278/// * `original_share_price_wad` - Share price at time of request.
279///
280/// # Returns
281/// `EscrowSettlement` based on price ratio.
282#[inline]
283#[must_use]
284pub fn compute_settlement_by_price(
285    escrow_shares: u128,
286    share_price_wad: Wad,
287    original_share_price_wad: Wad,
288) -> EscrowSettlement {
289    if escrow_shares == 0 || original_share_price_wad.is_zero() {
290        return EscrowSettlement {
291            to_burn: 0,
292            refund: 0,
293        };
294    }
295
296    // If current price >= original price, full burn
297    if share_price_wad.0 >= original_share_price_wad.0 {
298        return EscrowSettlement::burn_all(escrow_shares);
299    }
300
301    // Partial burn: ratio of current to original price.
302    // Use ceil to avoid zero-burn partials (consistent with compute_settlement).
303    // shares_to_burn = ceil(escrow_shares * current_price / original_price)
304    let shares_to_burn = Number::mul_div_ceil(
305        Number::from(escrow_shares),
306        share_price_wad.0,
307        original_share_price_wad.0,
308    )
309    .as_u128_trunc();
310
311    let shares_to_refund = escrow_shares.saturating_sub(shares_to_burn);
312
313    EscrowSettlement::partial(shares_to_burn, shares_to_refund)
314}
315
316/// Compute the withdrawal result for a fully satisfied withdrawal.
317///
318/// # Arguments
319/// * `withdrawal` - The pending withdrawal to process.
320/// * `available_assets` - Assets available (must be >= withdrawal.expected_assets).
321///
322/// # Returns
323/// `Some(WithdrawalResult)` if withdrawal can be satisfied, `None` otherwise.
324#[must_use]
325pub fn compute_full_withdrawal(
326    withdrawal: &PendingWithdrawal,
327    available_assets: u128,
328) -> Option<WithdrawalResult> {
329    compute_idle_settlement(
330        withdrawal.escrow_shares,
331        withdrawal.expected_assets,
332        available_assets,
333    )
334    .filter(|result| result.assets_out == withdrawal.expected_assets)
335}
336
337/// Compute the withdrawal result for a partial withdrawal.
338///
339/// # Arguments
340/// * `withdrawal` - The pending withdrawal to process.
341/// * `available_assets` - Assets available (should be < withdrawal.expected_assets).
342///
343/// # Returns
344/// `WithdrawalResult` with proportional shares burned.
345#[must_use]
346pub fn compute_partial_withdrawal(
347    withdrawal: &PendingWithdrawal,
348    available_assets: u128,
349) -> WithdrawalResult {
350    let actual_assets = available_assets.min(withdrawal.expected_assets);
351
352    let settlement = compute_settlement(
353        withdrawal.escrow_shares,
354        withdrawal.expected_assets,
355        actual_assets,
356    );
357
358    WithdrawalResult {
359        assets_out: actual_assets,
360        settlement,
361    }
362}
363
364// Pure Functions - Queue Aggregation
365
366/// Compute aggregate queue status from an iterator of withdrawals.
367///
368/// # Arguments
369/// * `withdrawals` - Iterator over all pending withdrawals.
370///
371/// # Returns
372/// `QueueStatus` with totals across all requests.
373#[must_use]
374pub fn compute_queue_status<'a, I>(withdrawals: I) -> QueueStatus
375where
376    I: IntoIterator<Item = &'a PendingWithdrawal>,
377{
378    let mut status = QueueStatus::default();
379
380    for withdrawal in withdrawals {
381        status.length = status.length.saturating_add(1);
382        status.total_expected_assets = status
383            .total_expected_assets
384            .saturating_add(withdrawal.expected_assets);
385        status.total_escrow_shares = status
386            .total_escrow_shares
387            .saturating_add(withdrawal.escrow_shares);
388    }
389
390    status
391}
392
393/// Find a withdrawal request's status by owner.
394///
395/// # Arguments
396/// * `withdrawals` - Iterator over pending withdrawals in queue order.
397/// * `owner` - The owner to search for.
398///
399/// # Returns
400/// `Some(WithdrawalRequestStatus)` if found, `None` otherwise.
401#[must_use]
402pub fn find_request_status<'a, I>(
403    withdrawals: I,
404    owner: &Address,
405) -> Option<WithdrawalRequestStatus>
406where
407    I: IntoIterator<Item = &'a PendingWithdrawal>,
408{
409    let mut index = 0u32;
410    let mut depth_assets = 0u128;
411
412    for withdrawal in withdrawals {
413        if &withdrawal.owner == owner {
414            return Some(WithdrawalRequestStatus {
415                index,
416                depth_assets,
417                withdrawal: withdrawal.clone(),
418            });
419        }
420        depth_assets = depth_assets.saturating_add(withdrawal.expected_assets);
421        index = index.saturating_add(1);
422    }
423
424    None
425}
426
427// Queue Storage Types
428
429use alloc::vec::Vec;
430
431pub use crate::state::vault::MAX_PENDING;
432
433#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, serde)]
434#[derive(Clone, PartialEq, Eq, Default)]
435pub struct PendingWithdrawals {
436    entries: Vec<PendingWithdrawalEntry>,
437}
438
439#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, serde)]
440#[derive(Clone, PartialEq, Eq)]
441struct PendingWithdrawalEntry {
442    id: u64,
443    withdrawal: PendingWithdrawal,
444}
445
446impl PendingWithdrawals {
447    #[inline]
448    #[must_use]
449    pub fn new() -> Self {
450        Self {
451            entries: Vec::new(),
452        }
453    }
454
455    #[inline]
456    #[must_use]
457    pub fn len(&self) -> usize {
458        self.entries.len()
459    }
460
461    #[inline]
462    #[must_use]
463    pub fn is_empty(&self) -> bool {
464        self.entries.is_empty()
465    }
466
467    #[inline]
468    fn locate(&self, id: u64) -> Result<usize, usize> {
469        self.entries.binary_search_by(|entry| entry.id.cmp(&id))
470    }
471
472    pub fn insert(&mut self, id: u64, withdrawal: PendingWithdrawal) -> Option<PendingWithdrawal> {
473        match self.locate(id) {
474            Ok(index) => {
475                let old = core::mem::replace(&mut self.entries[index].withdrawal, withdrawal);
476                Some(old)
477            }
478            Err(index) => {
479                self.entries
480                    .insert(index, PendingWithdrawalEntry { id, withdrawal });
481                None
482            }
483        }
484    }
485
486    pub fn remove(&mut self, id: &u64) -> Option<PendingWithdrawal> {
487        self.locate(*id)
488            .ok()
489            .map(|index| self.entries.remove(index).withdrawal)
490    }
491
492    #[inline]
493    #[must_use]
494    pub fn get(&self, id: &u64) -> Option<&PendingWithdrawal> {
495        self.locate(*id)
496            .ok()
497            .map(|index| &self.entries[index].withdrawal)
498    }
499
500    #[inline]
501    #[must_use]
502    pub fn get_mut(&mut self, id: &u64) -> Option<&mut PendingWithdrawal> {
503        self.locate(*id)
504            .ok()
505            .map(|index| &mut self.entries[index].withdrawal)
506    }
507
508    #[inline]
509    #[must_use]
510    pub fn contains_key(&self, id: &u64) -> bool {
511        self.locate(*id).is_ok()
512    }
513
514    #[inline]
515    pub fn iter(&self) -> impl Iterator<Item = (&u64, &PendingWithdrawal)> {
516        self.entries
517            .iter()
518            .map(|entry| (&entry.id, &entry.withdrawal))
519    }
520
521    #[inline]
522    pub fn values(&self) -> impl Iterator<Item = &PendingWithdrawal> {
523        self.entries.iter().map(|entry| &entry.withdrawal)
524    }
525
526    #[inline]
527    pub fn keys(&self) -> impl Iterator<Item = &u64> {
528        self.entries.iter().map(|entry| &entry.id)
529    }
530}
531
532impl FromIterator<(u64, PendingWithdrawal)> for PendingWithdrawals {
533    fn from_iter<T: IntoIterator<Item = (u64, PendingWithdrawal)>>(iter: T) -> Self {
534        let mut pending = Self::new();
535        for (id, withdrawal) in iter {
536            assert!(
537                pending.insert(id, withdrawal).is_none(),
538                "duplicate pending withdrawal id: {id}"
539            );
540        }
541        pending
542    }
543}
544
545/// Withdrawal queue storage with FIFO ordering.
546///
547/// Maintains pending withdrawals keyed by monotonic IDs with escrow parity.
548/// The queue uses a sorted `Vec` for efficient iteration and predictable serialization, with two
549/// pointers to track the FIFO head and next ID to allocate.
550///
551/// # Invariants
552///
553/// - `pending_withdrawals.len() <= max_pending_withdrawals <= MAX_PENDING`
554/// - `next_withdraw_to_execute <= next_pending_withdrawal_id`
555/// - If `pending_withdrawals.len() > 0`, then `pending_withdrawals` contains `next_withdraw_to_execute`
556/// - FIFO withdrawal ordering; no skipping head
557/// - `cached_total_escrow == sum(pending_withdrawals.values().map(|w| w.escrow_shares))`
558/// - `cached_total_expected == sum(pending_withdrawals.values().map(|w| w.expected_assets))`
559#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, serde)]
560#[derive(Clone, PartialEq, Eq)]
561pub struct WithdrawQueue {
562    /// Pending withdrawals keyed by monotonic ID.
563    pending_withdrawals: PendingWithdrawals,
564    /// ID of the next withdrawal to execute (queue head).
565    pub next_withdraw_to_execute: u64,
566    /// Next ID to allocate for new withdrawals (monotonic, never decremented).
567    pub next_pending_withdrawal_id: u64,
568    /// Cached total of escrow shares across all pending withdrawals.
569    /// Maintained incrementally on enqueue/dequeue for O(1) lookups.
570    cached_total_escrow: u128,
571    /// Cached total of expected assets across all pending withdrawals.
572    /// Maintained incrementally on enqueue/dequeue for O(1) lookups.
573    cached_total_expected: u128,
574}
575
576impl Default for WithdrawQueue {
577    fn default() -> Self {
578        Self::new()
579    }
580}
581
582/// Sum escrow shares and expected assets across an iterator of pending withdrawals.
583fn compute_pending_totals<'a>(iter: impl Iterator<Item = &'a PendingWithdrawal>) -> (u128, u128) {
584    iter.fold((0u128, 0u128), |(esc, exp), w| {
585        (
586            esc.saturating_add(w.escrow_shares),
587            exp.saturating_add(w.expected_assets),
588        )
589    })
590}
591
592impl WithdrawQueue {
593    /// Create a new empty withdrawal queue.
594    #[inline]
595    #[must_use]
596    pub fn new() -> Self {
597        Self {
598            pending_withdrawals: PendingWithdrawals::new(),
599            next_withdraw_to_execute: 0,
600            next_pending_withdrawal_id: 0,
601            cached_total_escrow: 0,
602            cached_total_expected: 0,
603        }
604    }
605
606    /// Create a queue with initial state (for testing or recovery).
607    #[must_use]
608    pub fn with_state<I>(
609        pending_withdrawals: I,
610        next_withdraw_to_execute: u64,
611        next_pending_withdrawal_id: u64,
612    ) -> Self
613    where
614        I: IntoIterator<Item = (u64, PendingWithdrawal)>,
615    {
616        let pending_withdrawals: PendingWithdrawals = pending_withdrawals.into_iter().collect();
617        let (cached_total_escrow, cached_total_expected) =
618            compute_pending_totals(pending_withdrawals.values());
619        Self {
620            pending_withdrawals,
621            next_withdraw_to_execute,
622            next_pending_withdrawal_id,
623            cached_total_escrow,
624            cached_total_expected,
625        }
626    }
627
628    /// Returns the current queue length.
629    #[inline]
630    #[must_use]
631    pub fn len(&self) -> usize {
632        self.pending_withdrawals.len()
633    }
634
635    #[inline]
636    #[must_use]
637    pub fn pending_withdrawals(&self) -> &PendingWithdrawals {
638        &self.pending_withdrawals
639    }
640
641    /// Returns true if the queue is empty.
642    #[inline]
643    #[must_use]
644    pub fn is_empty(&self) -> bool {
645        self.pending_withdrawals.is_empty()
646    }
647
648    /// Check if the queue can accept a new withdrawal given the max limit.
649    ///
650    /// # Arguments
651    /// * `max_pending` - Maximum allowed pending withdrawals.
652    ///
653    /// # Returns
654    /// `true` if the queue has room for another withdrawal.
655    #[inline]
656    #[must_use]
657    pub fn can_enqueue(&self, max_pending: u32) -> bool {
658        self.pending_withdrawals.len() < (max_pending as usize).min(MAX_PENDING)
659    }
660
661    /// Enqueue a new pending withdrawal.
662    ///
663    /// Convenience wrapper that constructs a `PendingWithdrawal` and delegates
664    /// to [`enqueue_withdrawal`](Self::enqueue_withdrawal).
665    pub fn enqueue(
666        &mut self,
667        owner: Address,
668        receiver: Address,
669        escrow_shares: u128,
670        expected_assets: u128,
671        requested_at_ns: TimestampNs,
672        max_pending: u32,
673    ) -> Result<u64, QueueError> {
674        let withdrawal = PendingWithdrawal::new(
675            owner,
676            receiver,
677            escrow_shares,
678            expected_assets,
679            requested_at_ns,
680        );
681        self.enqueue_withdrawal(withdrawal, max_pending)
682    }
683
684    /// Enqueue a pre-constructed pending withdrawal.
685    ///
686    /// Allocates a new monotonic ID and inserts the withdrawal at the tail.
687    ///
688    /// # Returns
689    /// `Ok(id)` with the allocated withdrawal ID, or `Err(QueueError)` if full.
690    pub fn enqueue_withdrawal(
691        &mut self,
692        withdrawal: PendingWithdrawal,
693        max_pending: u32,
694    ) -> Result<u64, QueueError> {
695        if !self.can_enqueue(max_pending) {
696            return Err(QueueError::QueueFull {
697                current: self.pending_withdrawals.len() as u32,
698                max: max_pending,
699            });
700        }
701
702        let id = self.next_pending_withdrawal_id;
703        let next_id = id
704            .checked_add(1)
705            .ok_or_else(|| QueueError::InvariantViolation {
706                message: alloc::string::String::from("next_pending_withdrawal_id overflow"),
707            })?;
708
709        // Compute cache totals first so we can fail without mutating queue state.
710        let new_escrow = self
711            .cached_total_escrow
712            .checked_add(withdrawal.escrow_shares)
713            .ok_or(QueueError::CacheOverflow)?;
714        let new_expected = self
715            .cached_total_expected
716            .checked_add(withdrawal.expected_assets)
717            .ok_or(QueueError::CacheOverflow)?;
718
719        self.pending_withdrawals.insert(id, withdrawal);
720        self.next_pending_withdrawal_id = next_id;
721
722        // Update cached totals (overflow already checked)
723        self.cached_total_escrow = new_escrow;
724        self.cached_total_expected = new_expected;
725
726        Ok(id)
727    }
728
729    /// Get the head of the queue without removing it.
730    ///
731    /// # Returns
732    /// `Some((id, &withdrawal))` if non-empty, `None` if empty.
733    #[inline]
734    #[must_use]
735    pub fn head(&self) -> Option<(u64, &PendingWithdrawal)> {
736        self.pending_withdrawals
737            .get(&self.next_withdraw_to_execute)
738            .map(|w| (self.next_withdraw_to_execute, w))
739    }
740
741    /// Dequeue and return the head of the queue (FIFO).
742    ///
743    /// Removes the head and advances `next_withdraw_to_execute` to the next
744    /// available ID in the queue (or to `next_pending_withdrawal_id` if empty).
745    ///
746    /// # Returns
747    /// `Some((id, withdrawal))` if non-empty, `None` if empty.
748    ///
749    pub fn dequeue(&mut self) -> Option<(u64, PendingWithdrawal)> {
750        if self.is_empty() {
751            return None;
752        }
753
754        let head_id = self.next_withdraw_to_execute;
755        let withdrawal = self.pending_withdrawals.remove(&head_id)?;
756
757        self.cached_total_escrow = self
758            .cached_total_escrow
759            .checked_sub(withdrawal.escrow_shares)
760            .expect("dequeue: cached_total_escrow underflow - queue cache corrupt");
761        self.cached_total_expected = self
762            .cached_total_expected
763            .checked_sub(withdrawal.expected_assets)
764            .expect("dequeue: cached_total_expected underflow - queue cache corrupt");
765
766        // Advance to the next ID in the queue
767        self.next_withdraw_to_execute = self
768            .pending_withdrawals
769            .keys()
770            .next()
771            .copied()
772            .unwrap_or(self.next_pending_withdrawal_id);
773
774        Some((head_id, withdrawal))
775    }
776
777    /// Get a pending withdrawal by ID.
778    ///
779    /// # Arguments
780    /// * `id` - The withdrawal ID to look up.
781    ///
782    /// # Returns
783    /// `Some(&withdrawal)` if found, `None` otherwise.
784    #[inline]
785    #[must_use]
786    pub fn get(&self, id: u64) -> Option<&PendingWithdrawal> {
787        self.pending_withdrawals.get(&id)
788    }
789
790    /// Check if a withdrawal ID exists in the queue.
791    ///
792    /// # Arguments
793    /// * `id` - The withdrawal ID to check.
794    ///
795    /// # Returns
796    /// `true` if the withdrawal exists.
797    #[inline]
798    #[must_use]
799    pub fn contains(&self, id: u64) -> bool {
800        self.pending_withdrawals.contains_key(&id)
801    }
802
803    /// Iterate over all pending withdrawals in FIFO order.
804    ///
805    /// # Returns
806    /// Iterator yielding `(id, &withdrawal)` pairs in order.
807    pub fn iter(&self) -> impl Iterator<Item = (u64, &PendingWithdrawal)> {
808        self.pending_withdrawals.iter().map(|(k, v)| (*k, v))
809    }
810
811    /// Check invariants for the withdrawal queue.
812    ///
813    /// Validates:
814    /// - `next_withdraw_to_execute <= next_pending_withdrawal_id`
815    /// - If non-empty, head ID exists in the map
816    /// - Cached totals match computed totals
817    ///
818    /// # Returns
819    /// `true` if all invariants hold.
820    #[must_use]
821    pub fn check_invariants(&self) -> bool {
822        // next_withdraw_to_execute <= next_pending_withdrawal_id
823        if self.next_withdraw_to_execute > self.next_pending_withdrawal_id {
824            return false;
825        }
826
827        // If non-empty, the head must exist
828        if !self.is_empty()
829            && !self
830                .pending_withdrawals
831                .contains_key(&self.next_withdraw_to_execute)
832        {
833            return false;
834        }
835
836        // Verify cached totals match computed totals
837        let (computed_escrow, computed_expected) =
838            compute_pending_totals(self.pending_withdrawals.values());
839
840        if self.cached_total_escrow != computed_escrow {
841            return false;
842        }
843        if self.cached_total_expected != computed_expected {
844            return false;
845        }
846
847        true
848    }
849
850    /// Check invariants including the max pending limit.
851    ///
852    /// # Arguments
853    /// * `max_pending` - Maximum allowed pending withdrawals.
854    ///
855    /// # Returns
856    /// `true` if all invariants hold including queue length bounds.
857    #[must_use]
858    pub fn check_invariants_with_max(&self, max_pending: u32) -> bool {
859        // Check basic invariants first
860        if !self.check_invariants() {
861            return false;
862        }
863
864        // pending_withdrawals.len() <= max_pending_withdrawals <= MAX_PENDING
865        let len = self.pending_withdrawals.len();
866        if len > (max_pending as usize) || (max_pending as usize) > MAX_PENDING {
867            return false;
868        }
869
870        true
871    }
872
873    /// Compute aggregate queue statistics.
874    ///
875    /// # Returns
876    /// `QueueStatus` with totals.
877    #[inline]
878    #[must_use]
879    pub fn status(&self) -> QueueStatus {
880        QueueStatus {
881            length: self.pending_withdrawals.len() as u32,
882            total_expected_assets: self.cached_total_expected,
883            total_escrow_shares: self.cached_total_escrow,
884        }
885    }
886
887    /// Get total escrowed shares across all pending withdrawals.
888    ///
889    /// Returns cached value in O(1) time.
890    ///
891    /// # Returns
892    /// Total escrow shares.
893    #[inline]
894    #[must_use]
895    pub fn total_escrow_shares(&self) -> u128 {
896        self.cached_total_escrow
897    }
898
899    /// Get total expected assets across all pending withdrawals.
900    ///
901    /// Returns cached value in O(1) time.
902    ///
903    /// # Returns
904    /// Total expected assets.
905    #[inline]
906    #[must_use]
907    pub fn total_expected_assets(&self) -> u128 {
908        self.cached_total_expected
909    }
910}
911
912/// Errors that can occur during queue operations.
913#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
914#[derive(Clone, PartialEq, Eq)]
915pub enum QueueError {
916    /// Queue is at maximum capacity.
917    QueueFull { current: u32, max: u32 },
918    /// Withdrawal ID not found.
919    WithdrawalNotFound { id: u64 },
920    /// Queue is empty.
921    QueueEmpty,
922    /// Invariant violation detected.
923    InvariantViolation { message: alloc::string::String },
924    /// Cached total overflow.
925    CacheOverflow,
926}
927
928#[cfg(test)]
929mod tests;