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 alloc::vec::Vec;
7use core::str::FromStr;
8#[cfg(not(target_arch = "wasm32"))]
9use derive_more::Display;
10use templar_vault_kernel::Wad;
11use typed_builder::TypedBuilder;
12
13#[templar_vault_macros::vault_derive(borsh, borsh_schema, 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)]
17pub struct CapGroupId(String);
18
19impl CapGroupId {
20    const POLICY_STATE_SENTINEL: &'static str = "policy-state";
21
22    #[must_use]
23    pub fn as_str(&self) -> &str {
24        &self.0
25    }
26
27    #[must_use]
28    pub(crate) fn policy_state_sentinel() -> Self {
29        Self(String::from(Self::POLICY_STATE_SENTINEL))
30    }
31
32    fn validate(value: &str) -> Result<(), CapGroupIdError> {
33        const MAX_LEN: usize = 64;
34
35        if value.is_empty() {
36            return Err(CapGroupIdError::Empty);
37        }
38
39        if value.len() > MAX_LEN {
40            return Err(CapGroupIdError::TooLong { max_len: MAX_LEN });
41        }
42
43        if !value.bytes().all(|byte| {
44            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
45        }) {
46            return Err(CapGroupIdError::InvalidCharacter);
47        }
48
49        Ok(())
50    }
51}
52
53impl TryFrom<String> for CapGroupId {
54    type Error = CapGroupIdError;
55
56    fn try_from(value: String) -> Result<Self, Self::Error> {
57        Self::validate(&value)?;
58        Ok(Self(value))
59    }
60}
61
62impl TryFrom<&str> for CapGroupId {
63    type Error = CapGroupIdError;
64
65    fn try_from(value: &str) -> Result<Self, Self::Error> {
66        Self::validate(value)?;
67        Ok(Self(String::from(value)))
68    }
69}
70
71impl FromStr for CapGroupId {
72    type Err = CapGroupIdError;
73
74    fn from_str(s: &str) -> Result<Self, Self::Err> {
75        Self::try_from(s)
76    }
77}
78
79impl From<CapGroupId> for String {
80    fn from(value: CapGroupId) -> Self {
81        value.0
82    }
83}
84
85#[templar_vault_macros::vault_derive]
86#[derive(Clone, PartialEq, Eq)]
87pub enum CapGroupIdError {
88    Empty,
89    TooLong { max_len: usize },
90    InvalidCharacter,
91}
92
93/// A cap group defines maximum allocation limits for a set of markets.
94///
95/// Caps are optional - `None` means no limit for that cap type.
96/// When both caps are set, the effective cap is the minimum of the two.
97#[templar_vault_macros::vault_derive(borsh, borsh_schema, schemars, serde)]
98#[derive(Clone, PartialEq, Eq, Default, TypedBuilder)]
99pub struct CapGroup {
100    /// Absolute cap in underlying asset units.
101    /// `None` means no absolute cap.
102    #[builder(default, setter(transform = |cap: u128| Some(cap)))]
103    absolute_cap: Option<u128>,
104    /// Relative cap as a WAD fraction of total vault assets (1e18 = 100%).
105    /// `None` means no relative cap.
106    #[builder(default, setter(transform = |cap: Wad| Some(cap)))]
107    relative_cap: Option<Wad>,
108}
109
110impl CapGroup {
111    #[must_use]
112    pub fn absolute_cap(&self) -> Option<u128> {
113        self.absolute_cap
114    }
115
116    #[must_use]
117    pub fn relative_cap(&self) -> Option<Wad> {
118        self.relative_cap
119    }
120
121    pub fn set_absolute_cap(&mut self, absolute_cap: Option<u128>) {
122        self.absolute_cap = absolute_cap;
123    }
124
125    pub fn set_relative_cap(&mut self, relative_cap: Option<Wad>) {
126        self.relative_cap = relative_cap;
127    }
128
129    #[must_use]
130    pub fn is_unlimited(&self) -> bool {
131        self.absolute_cap.is_none() && self.relative_cap.is_none()
132    }
133
134    /// Compute the effective cap for a cap group given total vault assets.
135    ///
136    /// Returns `u128::MAX` if the cap group is unlimited.
137    #[must_use]
138    pub fn effective_cap(&self, total_assets: u128) -> u128 {
139        if self.is_unlimited() {
140            return u128::MAX;
141        }
142
143        let absolute = self.absolute_cap.unwrap_or(u128::MAX);
144
145        let relative = self
146            .relative_cap
147            .map(|cap| {
148                cap.apply_floored(templar_vault_kernel::Number::from(total_assets))
149                    .as_u128_saturating()
150            })
151            .unwrap_or(u128::MAX);
152
153        absolute.min(relative)
154    }
155
156    /// Check if an allocation is allowed under cap group constraints.
157    #[must_use]
158    pub fn can_allocate(&self, current_principal: u128, amount: u128, total_assets: u128) -> bool {
159        let Some(new_principal) = current_principal.checked_add(amount) else {
160            return false;
161        };
162        new_principal <= self.effective_cap(total_assets)
163    }
164
165    /// Enforce cap group constraints on an allocation.
166    pub fn enforce(
167        &self,
168        current_principal: u128,
169        amount: u128,
170        total_assets: u128,
171    ) -> Result<(), CapGroupError> {
172        let Some(new_principal) = current_principal.checked_add(amount) else {
173            return Err(CapGroupError::Overflow {
174                current_principal,
175                requested: amount,
176            });
177        };
178
179        if let Some(abs_cap) = self.absolute_cap {
180            if new_principal > abs_cap {
181                return Err(CapGroupError::ExceedsAbsoluteCap {
182                    cap_group_id: None,
183                    requested: amount,
184                    current_principal,
185                    absolute_cap: abs_cap,
186                });
187            }
188        }
189
190        if let Some(ref rel_cap) = self.relative_cap {
191            let effective_cap = rel_cap
192                .apply_floored(templar_vault_kernel::Number::from(total_assets))
193                .as_u128_saturating();
194
195            if new_principal > effective_cap {
196                return Err(CapGroupError::ExceedsRelativeCap {
197                    cap_group_id: None,
198                    requested: amount,
199                    current_principal,
200                    effective_cap,
201                    total_assets,
202                });
203            }
204        }
205
206        Ok(())
207    }
208
209    /// Compute the maximum additional amount that can be allocated to a cap group.
210    #[must_use]
211    pub fn available_capacity(&self, current_principal: u128, total_assets: u128) -> u128 {
212        self.effective_cap(total_assets)
213            .saturating_sub(current_principal)
214    }
215}
216
217/// Record tracking the state of a cap group.
218#[templar_vault_macros::vault_derive(borsh, borsh_schema, schemars, serde)]
219#[cfg_attr(not(target_arch = "wasm32"), derive(PartialEq, Eq))]
220#[derive(Clone, Default)]
221pub struct CapGroupRecord {
222    /// The cap group configuration.
223    pub cap: CapGroup,
224    /// Current total principal allocated to markets in this group.
225    pub principal: u128,
226}
227
228impl CapGroupRecord {
229    /// Apply an allocation to a cap group record.
230    pub fn apply_allocation(&self, amount: u128) -> Result<Self, CapGroupError> {
231        let principal = self
232            .principal
233            .checked_add(amount)
234            .ok_or(CapGroupError::Overflow {
235                current_principal: self.principal,
236                requested: amount,
237            })?;
238
239        Ok(Self {
240            cap: self.cap.clone(),
241            principal,
242        })
243    }
244
245    /// Remove allocation from a cap group record.
246    pub fn remove_allocation(&self, amount: u128) -> Result<Self, CapGroupError> {
247        let principal = self
248            .principal
249            .checked_sub(amount)
250            .ok_or(CapGroupError::Underflow {
251                current_principal: self.principal,
252                requested: amount,
253            })?;
254
255        Ok(Self {
256            cap: self.cap.clone(),
257            principal,
258        })
259    }
260
261    /// Check if an allocation is allowed.
262    #[must_use]
263    pub fn can_allocate(&self, amount: u128, total_assets: u128) -> bool {
264        self.cap.can_allocate(self.principal, amount, total_assets)
265    }
266
267    /// Enforce cap constraints.
268    pub fn enforce(&self, amount: u128, total_assets: u128) -> Result<(), CapGroupError> {
269        self.cap.enforce(self.principal, amount, total_assets)
270    }
271
272    /// Get available capacity.
273    #[must_use]
274    pub fn available_capacity(&self, total_assets: u128) -> u128 {
275        self.cap.available_capacity(self.principal, total_assets)
276    }
277}
278
279impl From<CapGroup> for CapGroupRecord {
280    fn from(cap: CapGroup) -> Self {
281        Self { cap, principal: 0 }
282    }
283}
284
285/// Errors that can occur during cap group operations.
286#[templar_vault_macros::vault_derive]
287#[derive(Clone, PartialEq, Eq)]
288pub enum CapGroupError {
289    /// Allocation would exceed the absolute cap.
290    ExceedsAbsoluteCap {
291        cap_group_id: Option<CapGroupId>,
292        requested: u128,
293        current_principal: u128,
294        absolute_cap: u128,
295    },
296    /// Allocation would exceed the relative cap.
297    ExceedsRelativeCap {
298        cap_group_id: Option<CapGroupId>,
299        requested: u128,
300        current_principal: u128,
301        effective_cap: u128,
302        total_assets: u128,
303    },
304    /// Cap group not found.
305    NotFound {
306        id: CapGroupId,
307    },
308    /// Arithmetic overflow when computing new principal.
309    Overflow {
310        current_principal: u128,
311        requested: u128,
312    },
313    Underflow {
314        current_principal: u128,
315        requested: u128,
316    },
317    InconsistentRecord {
318        id: CapGroupId,
319    },
320}
321
322/// A cap-group governance update (shared across chains).
323#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, schemars, serde)]
324#[derive(Clone, PartialEq, Eq)]
325pub enum CapGroupUpdate {
326    SetCap {
327        cap_group_id: CapGroupId,
328        new_cap: Option<u128>,
329    },
330    SetRelativeCap {
331        cap_group_id: CapGroupId,
332        new_relative_cap: Option<Wad>,
333    },
334    SetMembership {
335        market_id: templar_vault_kernel::TargetId,
336        cap_group_id: Option<CapGroupId>,
337    },
338}
339
340/// Identifies a cap-group governance update for accept/revoke operations.
341#[templar_vault_macros::vault_derive(borsh, borsh_schema, postcard, schemars, serde)]
342#[derive(Clone, PartialEq, Eq)]
343pub enum CapGroupUpdateKey {
344    SetCap {
345        cap_group_id: CapGroupId,
346    },
347    SetRelativeCap {
348        cap_group_id: CapGroupId,
349    },
350    SetMembership {
351        market_id: templar_vault_kernel::TargetId,
352    },
353}
354
355impl CapGroupUpdate {
356    /// Build the canonical accept/revoke key for this update.
357    #[must_use]
358    pub fn key(&self) -> CapGroupUpdateKey {
359        self.into()
360    }
361}
362
363impl From<&CapGroupUpdate> for CapGroupUpdateKey {
364    fn from(value: &CapGroupUpdate) -> Self {
365        match value {
366            CapGroupUpdate::SetCap { cap_group_id, .. } => Self::SetCap {
367                cap_group_id: cap_group_id.clone(),
368            },
369            CapGroupUpdate::SetRelativeCap { cap_group_id, .. } => Self::SetRelativeCap {
370                cap_group_id: cap_group_id.clone(),
371            },
372            CapGroupUpdate::SetMembership { market_id, .. } => Self::SetMembership {
373                market_id: *market_id,
374            },
375        }
376    }
377}
378
379/// Validate a list of allocations against their cap groups.
380///
381/// # Arguments
382/// * `allocations` - List of (cap_group_id, cap_group_record, allocation_amount) tuples
383/// * `total_assets` - Total vault assets for relative cap calculation
384///
385/// # Returns
386/// `Ok(())` if all allocations are valid, or the first error encountered.
387///
388/// Note: This function tracks cumulative allocations per cap group to detect
389/// cases where multiple allocations to the same group would exceed the cap,
390/// even if each individual allocation is valid against the original principal.
391pub fn validate_allocations(
392    allocations: &[(&CapGroupId, &CapGroupRecord, u128)],
393    total_assets: u128,
394) -> Result<(), CapGroupError> {
395    let mut cumulative: Vec<(&CapGroupId, CapGroupRecord, u128)> = Vec::new();
396
397    for (group_id, record, amount) in allocations {
398        let existing = cumulative
399            .iter_mut()
400            .find(|(existing_group_id, _, _)| *existing_group_id == *group_id);
401
402        let (_, canonical_record, prior_cumulative) = match existing {
403            Some(existing) => existing,
404            None => {
405                let index = cumulative.len();
406                cumulative.push((group_id, (*record).clone(), 0));
407                &mut cumulative[index]
408            }
409        };
410
411        if canonical_record.principal != record.principal || canonical_record.cap != record.cap {
412            return Err(CapGroupError::InconsistentRecord {
413                id: (*group_id).clone(),
414            });
415        }
416
417        let effective_principal = canonical_record
418            .principal
419            .checked_add(*prior_cumulative)
420            .ok_or(CapGroupError::Overflow {
421                current_principal: canonical_record.principal,
422                requested: *prior_cumulative,
423            })?;
424
425        canonical_record
426            .cap
427            .enforce(effective_principal, *amount, total_assets)
428            .map_err(|error| match error {
429                CapGroupError::ExceedsAbsoluteCap {
430                    requested,
431                    current_principal,
432                    absolute_cap,
433                    ..
434                } => CapGroupError::ExceedsAbsoluteCap {
435                    cap_group_id: Some((*group_id).clone()),
436                    requested,
437                    current_principal,
438                    absolute_cap,
439                },
440                CapGroupError::ExceedsRelativeCap {
441                    requested,
442                    current_principal,
443                    effective_cap,
444                    total_assets,
445                    ..
446                } => CapGroupError::ExceedsRelativeCap {
447                    cap_group_id: Some((*group_id).clone()),
448                    requested,
449                    current_principal,
450                    effective_cap,
451                    total_assets,
452                },
453                other => other,
454            })?;
455
456        *prior_cumulative =
457            prior_cumulative
458                .checked_add(*amount)
459                .ok_or(CapGroupError::Overflow {
460                    current_principal: *prior_cumulative,
461                    requested: *amount,
462                })?;
463    }
464    Ok(())
465}