templar_vault_kernel/state/escrow/
mod.rs

1//! Chain-agnostic escrow types and pure logic functions.
2//!
3//! This module provides data structures for escrow operations and pure
4//! functions for escrow logic. Storage implementation is left to chain-specific
5//! executors (NEAR, Soroban, etc.).
6
7use crate::math::number::Number;
8use crate::types::{Address, TimestampNs};
9
10pub use crate::types::EscrowSettlement;
11
12/// Escrow entry for a single actor.
13///
14/// Tracks shares held in escrow for a pending withdrawal.
15#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
16#[derive(Clone, PartialEq, Eq)]
17pub struct EscrowEntry {
18    pub owner: Address,
19    pub shares: u128,
20    pub created_at_ns: TimestampNs,
21    pub expected_assets: u128,
22}
23
24impl EscrowEntry {
25    #[inline]
26    #[must_use]
27    pub fn new(
28        owner: Address,
29        shares: u128,
30        created_at_ns: TimestampNs,
31        expected_assets: u128,
32    ) -> Self {
33        Self {
34            owner,
35            shares,
36            created_at_ns,
37            expected_assets,
38        }
39    }
40
41    #[inline]
42    #[must_use]
43    pub fn is_empty(&self) -> bool {
44        self.shares == 0
45    }
46}
47
48/// Result of applying a settlement to an escrow entry.
49#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
50#[derive(Clone, PartialEq, Eq)]
51pub struct SettlementResult {
52    pub burned: u128,
53    pub refunded: u128,
54    pub remaining: u128,
55}
56
57/// Aggregate escrow statistics.
58#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
59#[derive(Clone, Default, PartialEq, Eq)]
60pub struct EscrowStats {
61    pub count: u32,
62    pub total_shares: u128,
63    pub total_expected_assets: u128,
64}
65
66/// Apply an escrow settlement to an escrow entry.
67#[must_use]
68pub fn apply_settlement(
69    entry: &EscrowEntry,
70    settlement: &EscrowSettlement,
71) -> Option<SettlementResult> {
72    let total_settled = settlement.to_burn.checked_add(settlement.refund)?;
73
74    if total_settled > entry.shares {
75        return None;
76    }
77
78    let remaining = entry.shares.saturating_sub(total_settled);
79
80    Some(SettlementResult {
81        burned: settlement.to_burn,
82        refunded: settlement.refund,
83        remaining,
84    })
85}
86
87/// Compute a proportional settlement based on actual vs expected assets.
88#[must_use]
89pub fn settle_proportional(entry: &EscrowEntry, actual_assets: u128) -> EscrowSettlement {
90    if entry.shares == 0 {
91        return EscrowSettlement {
92            to_burn: 0,
93            refund: 0,
94        };
95    }
96
97    if actual_assets == 0 {
98        return EscrowSettlement::refund_all(entry.shares);
99    }
100
101    if entry.expected_assets == 0 {
102        return EscrowSettlement::refund_all(entry.shares);
103    }
104
105    if actual_assets >= entry.expected_assets {
106        return EscrowSettlement::burn_all(entry.shares);
107    }
108
109    // Proportional: burn shares proportional to actual/expected ratio.
110    // Use ceil to avoid zero-burn partials (assets out without burning shares).
111    let to_burn = Number::mul_div_ceil(
112        Number::from(entry.shares),
113        Number::from(actual_assets),
114        Number::from(entry.expected_assets),
115    )
116    .as_u128_trunc();
117
118    let refund = entry.shares.saturating_sub(to_burn);
119
120    EscrowSettlement::partial(to_burn, refund)
121}
122
123/// Validate that an escrow entry has sufficient shares for a settlement.
124#[inline]
125#[must_use]
126pub fn can_apply_settlement(entry: &EscrowEntry, settlement: &EscrowSettlement) -> bool {
127    settlement
128        .to_burn
129        .checked_add(settlement.refund)
130        .is_some_and(|total| total <= entry.shares)
131}
132
133/// Check if an escrow entry is stale (past its expected settlement time).
134#[inline]
135#[must_use]
136pub fn is_stale(entry: &EscrowEntry, now_ns: TimestampNs, max_age_ns: u64) -> bool {
137    now_ns > entry.created_at_ns.saturating_add_u64(max_age_ns)
138}
139
140/// Compute aggregate escrow statistics from an iterator of entries.
141#[must_use]
142pub fn compute_escrow_stats<'a, I>(entries: I) -> EscrowStats
143where
144    I: IntoIterator<Item = &'a EscrowEntry>,
145{
146    let mut stats = EscrowStats::default();
147
148    for entry in entries {
149        stats.count = stats.count.saturating_add(1);
150        stats.total_shares = stats.total_shares.saturating_add(entry.shares);
151        stats.total_expected_assets = stats
152            .total_expected_assets
153            .saturating_add(entry.expected_assets);
154    }
155
156    stats
157}
158
159/// Find an escrow entry by owner.
160#[must_use]
161pub fn find_by_owner<'a, I>(entries: I, owner: &Address) -> Option<&'a EscrowEntry>
162where
163    I: IntoIterator<Item = &'a EscrowEntry>,
164{
165    entries.into_iter().find(|e| &e.owner == owner)
166}
167
168/// Calculate total shares that would be burned across multiple settlements.
169#[must_use]
170pub fn total_burn<'a, I>(settlements: I) -> u128
171where
172    I: IntoIterator<Item = &'a EscrowSettlement>,
173{
174    settlements
175        .into_iter()
176        .map(|s| s.to_burn)
177        .fold(0u128, |acc, x| acc.saturating_add(x))
178}
179
180/// Calculate total shares that would be refunded across multiple settlements.
181#[must_use]
182pub fn total_refund<'a, I>(settlements: I) -> u128
183where
184    I: IntoIterator<Item = &'a EscrowSettlement>,
185{
186    settlements
187        .into_iter()
188        .map(|s| s.refund)
189        .fold(0u128, |acc, x| acc.saturating_add(x))
190}
191
192#[cfg(test)]
193mod tests;