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)]
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)]
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)]
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    settle_proportional_raw(entry.shares, entry.expected_assets, actual_assets)
91}
92
93/// Compute a proportional settlement directly from escrow share and asset amounts.
94#[must_use]
95pub fn settle_proportional_raw(
96    shares: u128,
97    expected_assets: u128,
98    actual_assets: u128,
99) -> EscrowSettlement {
100    if shares == 0 {
101        return EscrowSettlement {
102            to_burn: 0,
103            refund: 0,
104        };
105    }
106
107    if actual_assets == 0 {
108        return EscrowSettlement::refund_all(shares);
109    }
110
111    if expected_assets == 0 {
112        return EscrowSettlement::refund_all(shares);
113    }
114
115    if actual_assets >= expected_assets {
116        return EscrowSettlement::burn_all(shares);
117    }
118
119    // Proportional: burn shares proportional to actual/expected ratio.
120    // Use ceil to avoid zero-burn partials (assets out without burning shares).
121    let to_burn = Number::mul_div_ceil(
122        Number::from(shares),
123        Number::from(actual_assets),
124        Number::from(expected_assets),
125    )
126    .as_u128_trunc();
127
128    let refund = shares.saturating_sub(to_burn);
129
130    EscrowSettlement::partial(to_burn, refund)
131}
132
133/// Validate that an escrow entry has sufficient shares for a settlement.
134#[inline]
135#[must_use]
136pub fn can_apply_settlement(entry: &EscrowEntry, settlement: &EscrowSettlement) -> bool {
137    settlement
138        .to_burn
139        .checked_add(settlement.refund)
140        .is_some_and(|total| total <= entry.shares)
141}
142
143/// Check if an escrow entry is stale (past its expected settlement time).
144#[inline]
145#[must_use]
146pub fn is_stale(entry: &EscrowEntry, now_ns: TimestampNs, max_age_ns: u64) -> bool {
147    now_ns > entry.created_at_ns.saturating_add_u64(max_age_ns)
148}
149
150/// Compute aggregate escrow statistics from an iterator of entries.
151#[must_use]
152pub fn compute_escrow_stats<'a, I>(entries: I) -> EscrowStats
153where
154    I: IntoIterator<Item = &'a EscrowEntry>,
155{
156    let mut stats = EscrowStats::default();
157
158    for entry in entries {
159        stats.count = stats.count.saturating_add(1);
160        stats.total_shares = stats.total_shares.saturating_add(entry.shares);
161        stats.total_expected_assets = stats
162            .total_expected_assets
163            .saturating_add(entry.expected_assets);
164    }
165
166    stats
167}
168
169/// Find an escrow entry by owner.
170#[must_use]
171pub fn find_by_owner<'a, I>(entries: I, owner: &Address) -> Option<&'a EscrowEntry>
172where
173    I: IntoIterator<Item = &'a EscrowEntry>,
174{
175    entries.into_iter().find(|e| &e.owner == owner)
176}
177
178/// Calculate total shares that would be burned across multiple settlements.
179#[must_use]
180pub fn total_burn<'a, I>(settlements: I) -> u128
181where
182    I: IntoIterator<Item = &'a EscrowSettlement>,
183{
184    settlements
185        .into_iter()
186        .map(|s| s.to_burn)
187        .fold(0u128, |acc, x| acc.saturating_add(x))
188}
189
190/// Calculate total shares that would be refunded across multiple settlements.
191#[must_use]
192pub fn total_refund<'a, I>(settlements: I) -> u128
193where
194    I: IntoIterator<Item = &'a EscrowSettlement>,
195{
196    settlements
197        .into_iter()
198        .map(|s| s.refund)
199        .fold(0u128, |acc, x| acc.saturating_add(x))
200}
201
202#[cfg(test)]
203mod tests;