templar_curator_primitives/policy/cap_group/
mod.rs

1//! Cap group enforcement for market allocation limits.
2
3use alloc::string::String;
4#[cfg(feature = "borsh-schema")]
5use alloc::string::ToString;
6use core::num::NonZeroU128;
7#[cfg(not(target_arch = "wasm32"))]
8use derive_more::Display;
9use derive_more::{From, Into};
10use templar_vault_kernel::Wad;
11use typed_builder::TypedBuilder;
12
13#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, schemars, serde)]
14#[cfg_attr(not(target_arch = "wasm32"), derive(Display))]
15#[cfg_attr(not(target_arch = "wasm32"), display("{_0}"))]
16#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, From, Into)]
17pub struct CapGroupId(pub String);
18
19impl From<&str> for CapGroupId {
20    fn from(value: &str) -> Self {
21        Self(String::from(value))
22    }
23}
24
25/// A cap group defines maximum allocation limits for a set of markets.
26///
27/// Caps are optional - `None` means no limit for that cap type.
28/// When both caps are set, the effective cap is the minimum of the two.
29#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, schemars, serde)]
30#[derive(Clone, PartialEq, Eq, Default, TypedBuilder)]
31pub struct CapGroup {
32    /// Absolute cap in underlying asset units.
33    /// `None` means no absolute cap.
34    #[builder(default, setter(transform = |cap: u128| NonZeroU128::new(cap)))]
35    pub absolute_cap: Option<NonZeroU128>,
36    /// Relative cap as a WAD fraction of total vault assets (1e18 = 100%).
37    /// `None` means no relative cap.
38    #[builder(default, setter(transform = |cap: Wad| if cap.is_zero() { None } else { Some(cap) }))]
39    pub relative_cap: Option<Wad>,
40}
41
42impl CapGroup {
43    #[must_use]
44    pub fn is_unlimited(&self) -> bool {
45        self.absolute_cap.is_none() && self.relative_cap.is_none()
46    }
47
48    /// Compute the effective cap for a cap group given total vault assets.
49    ///
50    /// Returns `u128::MAX` if the cap group is unlimited.
51    #[must_use]
52    pub fn effective_cap(&self, total_assets: u128) -> u128 {
53        if self.is_unlimited() {
54            return u128::MAX;
55        }
56
57        let absolute = self.absolute_cap.map(|c| c.get()).unwrap_or(u128::MAX);
58
59        let relative = self
60            .relative_cap
61            .map(|cap| {
62                cap.apply_floored(templar_vault_kernel::Number::from(total_assets))
63                    .as_u128_saturating()
64            })
65            .unwrap_or(u128::MAX);
66
67        absolute.min(relative)
68    }
69
70    /// Check if an allocation is allowed under cap group constraints.
71    #[must_use]
72    pub fn can_allocate(&self, current_principal: u128, amount: u128, total_assets: u128) -> bool {
73        if self.is_unlimited() {
74            return true;
75        }
76
77        let Some(new_principal) = current_principal.checked_add(amount) else {
78            return false;
79        };
80        new_principal <= self.effective_cap(total_assets)
81    }
82
83    /// Enforce cap group constraints on an allocation.
84    pub fn enforce(
85        &self,
86        current_principal: u128,
87        amount: u128,
88        total_assets: u128,
89    ) -> Result<(), CapGroupError> {
90        if self.is_unlimited() {
91            return Ok(());
92        }
93
94        let Some(new_principal) = current_principal.checked_add(amount) else {
95            return Err(CapGroupError::Overflow {
96                current_principal,
97                requested: amount,
98            });
99        };
100
101        if let Some(abs_cap) = self.absolute_cap {
102            if new_principal > abs_cap.get() {
103                return Err(CapGroupError::ExceedsAbsoluteCap {
104                    requested: amount,
105                    current_principal,
106                    absolute_cap: abs_cap.get(),
107                });
108            }
109        }
110
111        if let Some(ref rel_cap) = self.relative_cap {
112            let effective_cap = rel_cap
113                .apply_floored(templar_vault_kernel::Number::from(total_assets))
114                .as_u128_saturating();
115
116            if new_principal > effective_cap {
117                return Err(CapGroupError::ExceedsRelativeCap {
118                    requested: amount,
119                    current_principal,
120                    effective_cap,
121                    total_assets,
122                });
123            }
124        }
125
126        Ok(())
127    }
128
129    /// Compute the maximum additional amount that can be allocated to a cap group.
130    #[must_use]
131    pub fn available_capacity(&self, current_principal: u128, total_assets: u128) -> u128 {
132        if self.is_unlimited() {
133            return u128::MAX;
134        }
135
136        let effective_cap = self.effective_cap(total_assets);
137        effective_cap.saturating_sub(current_principal)
138    }
139}
140
141/// Record tracking the state of a cap group.
142#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, schemars, serde)]
143#[derive(Clone, Default)]
144pub struct CapGroupRecord {
145    /// The cap group configuration.
146    pub cap: CapGroup,
147    /// Current total principal allocated to markets in this group.
148    pub principal: u128,
149}
150
151impl CapGroupRecord {
152    /// Apply an allocation to a cap group record.
153    #[must_use]
154    pub fn apply_allocation(&self, amount: u128) -> Self {
155        Self {
156            cap: self.cap.clone(),
157            principal: self.principal.checked_add(amount).unwrap(),
158        }
159    }
160
161    /// Remove allocation from a cap group record.
162    #[must_use]
163    pub fn remove_allocation(&self, amount: u128) -> Self {
164        Self {
165            cap: self.cap.clone(),
166            principal: self.principal.checked_sub(amount).unwrap(),
167        }
168    }
169
170    /// Check if an allocation is allowed.
171    #[must_use]
172    pub fn can_allocate(&self, amount: u128, total_assets: u128) -> bool {
173        self.cap.can_allocate(self.principal, amount, total_assets)
174    }
175
176    /// Enforce cap constraints.
177    pub fn enforce(&self, amount: u128, total_assets: u128) -> Result<(), CapGroupError> {
178        self.cap.enforce(self.principal, amount, total_assets)
179    }
180
181    /// Get available capacity.
182    #[must_use]
183    pub fn available_capacity(&self, total_assets: u128) -> u128 {
184        self.cap.available_capacity(self.principal, total_assets)
185    }
186}
187
188impl From<CapGroup> for CapGroupRecord {
189    fn from(cap: CapGroup) -> Self {
190        Self { cap, principal: 0 }
191    }
192}
193
194/// Errors that can occur during cap group operations.
195#[templar_vault_macros::vault_derive]
196#[derive(Clone, PartialEq, Eq)]
197pub enum CapGroupError {
198    /// Allocation would exceed the absolute cap.
199    ExceedsAbsoluteCap {
200        requested: u128,
201        current_principal: u128,
202        absolute_cap: u128,
203    },
204    /// Allocation would exceed the relative cap.
205    ExceedsRelativeCap {
206        requested: u128,
207        current_principal: u128,
208        effective_cap: u128,
209        total_assets: u128,
210    },
211    /// Cap group not found.
212    NotFound { id: CapGroupId },
213    /// Arithmetic overflow when computing new principal.
214    Overflow {
215        current_principal: u128,
216        requested: u128,
217    },
218}
219
220/// A cap-group governance update (shared across chains).
221#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, schemars, serde)]
222#[derive(Clone, PartialEq, Eq)]
223pub enum CapGroupUpdate {
224    SetCap {
225        cap_group_id: CapGroupId,
226        new_cap: u128,
227    },
228    SetRelativeCap {
229        cap_group_id: CapGroupId,
230        new_relative_cap_wad: u128,
231    },
232    SetMembership {
233        market_id: templar_vault_kernel::TargetId,
234        cap_group_id: Option<CapGroupId>,
235    },
236}
237
238/// Identifies a cap-group governance update for accept/revoke operations.
239#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, schemars, serde)]
240#[derive(Clone, PartialEq, Eq)]
241pub enum CapGroupUpdateKey {
242    SetCap {
243        cap_group_id: CapGroupId,
244    },
245    SetRelativeCap {
246        cap_group_id: CapGroupId,
247    },
248    SetMembership {
249        market_id: templar_vault_kernel::TargetId,
250    },
251}
252
253impl CapGroupUpdate {
254    /// Build the canonical accept/revoke key for this update.
255    #[must_use]
256    pub fn key(&self) -> CapGroupUpdateKey {
257        self.into()
258    }
259}
260
261impl From<&CapGroupUpdate> for CapGroupUpdateKey {
262    fn from(value: &CapGroupUpdate) -> Self {
263        match value {
264            CapGroupUpdate::SetCap { cap_group_id, .. } => Self::SetCap {
265                cap_group_id: cap_group_id.clone(),
266            },
267            CapGroupUpdate::SetRelativeCap { cap_group_id, .. } => Self::SetRelativeCap {
268                cap_group_id: cap_group_id.clone(),
269            },
270            CapGroupUpdate::SetMembership { market_id, .. } => Self::SetMembership {
271                market_id: *market_id,
272            },
273        }
274    }
275}
276
277/// Validate a list of allocations against their cap groups.
278///
279/// # Arguments
280/// * `allocations` - List of (cap_group_id, cap_group_record, allocation_amount) tuples
281/// * `total_assets` - Total vault assets for relative cap calculation
282///
283/// # Returns
284/// `Ok(())` if all allocations are valid, or the first error encountered.
285///
286/// Note: This function tracks cumulative allocations per cap group to detect
287/// cases where multiple allocations to the same group would exceed the cap,
288/// even if each individual allocation is valid against the original principal.
289pub fn validate_allocations(
290    allocations: &[(&CapGroupId, &CapGroupRecord, u128)],
291    total_assets: u128,
292) -> Result<(), CapGroupError> {
293    use alloc::collections::BTreeMap;
294
295    // Track cumulative allocations per cap group ID
296    let mut cumulative: BTreeMap<&CapGroupId, u128> = BTreeMap::new();
297
298    for (group_id, record, amount) in allocations {
299        let prior_cumulative = cumulative.get(group_id).copied().unwrap_or(0);
300
301        // Compute effective principal including prior allocations in this batch
302        let effective_principal =
303            record
304                .principal
305                .checked_add(prior_cumulative)
306                .ok_or(CapGroupError::Overflow {
307                    current_principal: record.principal,
308                    requested: prior_cumulative,
309                })?;
310
311        // Enforce against the effective (cumulative) principal
312        record
313            .cap
314            .enforce(effective_principal, *amount, total_assets)?;
315
316        // Update cumulative total for this group
317        let new_cumulative =
318            prior_cumulative
319                .checked_add(*amount)
320                .ok_or(CapGroupError::Overflow {
321                    current_principal: prior_cumulative,
322                    requested: *amount,
323                })?;
324        cumulative.insert(group_id, new_cumulative);
325    }
326    Ok(())
327}