templar_curator_primitives/governance/
mod.rs

1//! Chain-agnostic governance helpers for vault executors.
2//!
3//! These helpers encapsulate the portable parts of governance logic:
4//! timelock queue mechanics, fee/cap change validation, and restriction
5//! relaxation checks. Chain-specific authorization and storage live in
6//! each executor, but the decision math is shared here.
7
8use alloc::collections::{BTreeSet, VecDeque};
9use core::cmp::Ordering;
10
11use templar_vault_kernel::math::wad::{Wad, MAX_MANAGEMENT_FEE_WAD, MAX_PERFORMANCE_FEE_WAD};
12use templar_vault_kernel::types::TimestampNs;
13use templar_vault_kernel::TimeGate;
14
15/// A pending governance value gated by a timelock.
16#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
17#[derive(Clone, PartialEq, Eq)]
18pub struct PendingValue<T> {
19    pub value: T,
20    pub valid_at_ns: TimestampNs,
21}
22
23impl<T> PendingValue<T> {
24    /// Returns true if the timelock has elapsed.
25    #[must_use]
26    pub fn is_mature(&self, now_ns: TimestampNs) -> bool {
27        TimeGate::from_ready_at(self.valid_at_ns).is_ready(now_ns)
28    }
29}
30
31#[templar_vault_macros::vault_derive]
32#[derive(Clone, Copy, PartialEq, Eq)]
33pub enum PendingQueueError {
34    NotMature,
35}
36
37/// Timelocked pending governance values.
38#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
39#[derive(Clone, PartialEq, Eq)]
40pub struct PendingQueue<T> {
41    entries: VecDeque<PendingValue<T>>,
42}
43
44impl<T> Default for PendingQueue<T> {
45    fn default() -> Self {
46        Self {
47            entries: VecDeque::new(),
48        }
49    }
50}
51
52impl<T> PendingQueue<T> {
53    #[must_use]
54    pub fn len(&self) -> usize {
55        self.entries.len()
56    }
57
58    #[must_use]
59    pub fn is_empty(&self) -> bool {
60        self.entries.is_empty()
61    }
62
63    pub fn iter(&self) -> impl Iterator<Item = &PendingValue<T>> {
64        self.entries.iter()
65    }
66
67    #[must_use]
68    pub fn back(&self) -> Option<&PendingValue<T>> {
69        self.entries.back()
70    }
71
72    pub fn push_pending(&mut self, pending: PendingValue<T>) {
73        self.entries.push_back(pending);
74    }
75
76    /// Schedule a new timelocked value.
77    pub fn schedule(&mut self, value: T, now_ns: TimestampNs, timelock_ns: TimestampNs) {
78        let valid_at_ns = TimeGate::schedule_from(now_ns, timelock_ns)
79            .ready_at_ns()
80            .unwrap_or(now_ns);
81        self.entries.push_back(PendingValue { value, valid_at_ns });
82    }
83
84    #[must_use]
85    pub fn has_pending(&self, mut pred: impl FnMut(&T) -> bool) -> bool {
86        self.entries.iter().any(|entry| pred(&entry.value))
87    }
88
89    pub fn take_mature(
90        &mut self,
91        now_ns: TimestampNs,
92        mut pred: impl FnMut(&T) -> bool,
93    ) -> Result<Option<T>, PendingQueueError> {
94        // Find the first entry that matches the predicate AND is mature.
95        // This prevents a stale locked entry from blocking a mature one behind it.
96        let mature_index = self
97            .entries
98            .iter()
99            .position(|entry| pred(&entry.value) && entry.is_mature(now_ns));
100
101        if let Some(index) = mature_index {
102            let Some(pending) = self.entries.remove(index) else {
103                return Ok(None);
104            };
105            return Ok(Some(pending.value));
106        }
107
108        // No mature match found - check if there's any match at all (immature).
109        let has_immature_match = self.entries.iter().any(|entry| pred(&entry.value));
110        if has_immature_match {
111            return Err(PendingQueueError::NotMature);
112        }
113
114        Ok(None)
115    }
116
117    #[must_use]
118    pub fn revoke_pending(&mut self, mut pred: impl FnMut(&T) -> bool) -> bool {
119        let mut removed_any = false;
120        self.entries.retain(|entry| {
121            let keep = !pred(&entry.value);
122            if !keep {
123                removed_any = true;
124            }
125            keep
126        });
127        removed_any
128    }
129}
130
131impl<T> From<VecDeque<PendingValue<T>>> for PendingQueue<T> {
132    fn from(entries: VecDeque<PendingValue<T>>) -> Self {
133        Self { entries }
134    }
135}
136
137impl<T> From<PendingQueue<T>> for VecDeque<PendingValue<T>> {
138    fn from(queue: PendingQueue<T>) -> Self {
139        queue.entries
140    }
141}
142
143pub fn submission_requires_timelock<E>(decision: Result<TimelockDecision, E>) -> Result<bool, E> {
144    decision.map(TimelockDecision::requires_timelock)
145}
146
147/// Decision on whether an action should be timelocked.
148#[templar_vault_macros::vault_derive(borsh, serde)]
149#[derive(Clone, Copy, PartialEq, Eq)]
150pub enum TimelockDecision {
151    Immediate,
152    Timelocked,
153}
154
155impl TimelockDecision {
156    #[must_use]
157    pub fn requires_timelock(self) -> bool {
158        matches!(self, TimelockDecision::Timelocked)
159    }
160
161    #[must_use]
162    pub fn from_requires_timelock(requires_timelock: bool) -> Self {
163        if requires_timelock {
164            TimelockDecision::Timelocked
165        } else {
166            TimelockDecision::Immediate
167        }
168    }
169
170    #[must_use]
171    pub fn is_immediate(self) -> bool {
172        matches!(self, TimelockDecision::Immediate)
173    }
174}
175
176impl TryFrom<Ordering> for TimelockDecision {
177    type Error = ();
178
179    fn try_from(ordering: Ordering) -> Result<Self, Self::Error> {
180        match ordering {
181            Ordering::Equal => Err(()),
182            Ordering::Greater => Ok(TimelockDecision::Timelocked),
183            Ordering::Less => Ok(TimelockDecision::Immediate),
184        }
185    }
186}
187
188/// Generic restrictions enum for shared governance checks.
189#[templar_vault_macros::vault_derive]
190#[derive(Clone, PartialEq, Eq)]
191pub enum Restrictions<T> {
192    Paused,
193    Blacklist(BTreeSet<T>),
194    Whitelist(BTreeSet<T>),
195}
196
197impl<T: Ord> Restrictions<T> {
198    /// Determine if a restriction change is relaxing (thus usually timelocked).
199    #[must_use]
200    pub fn determine_relaxed(current: &Option<Self>, next: &Option<Self>) -> bool {
201        match (current, next) {
202            (None, None) => false,
203            (None, Some(_)) => false,
204            (Some(_), None) => true,
205            (Some(Self::Paused), Some(Self::Paused)) => false,
206            (Some(Self::Paused), Some(Self::Whitelist(new))) => !new.is_empty(),
207            (Some(Self::Paused), Some(_)) => true,
208            (Some(Self::Blacklist(old)), Some(Self::Blacklist(new))) => {
209                old.difference(new).next().is_some()
210            }
211            (Some(Self::Whitelist(old)), Some(Self::Whitelist(new))) => {
212                new.difference(old).next().is_some()
213            }
214            (Some(Self::Blacklist(old)), Some(Self::Whitelist(new))) => {
215                old.intersection(new).next().is_some()
216            }
217            (Some(Self::Whitelist(_)), Some(Self::Paused))
218            | (Some(Self::Blacklist(_)), Some(Self::Paused)) => false,
219            (Some(Self::Whitelist(_)), Some(Self::Blacklist(_))) => true,
220        }
221    }
222}
223
224/// Fee config view for change evaluation.
225pub struct FeeConfig<'a, R> {
226    pub performance_fee: Wad,
227    pub management_fee: Wad,
228    pub performance_recipient: &'a R,
229    pub management_recipient: &'a R,
230    pub max_rate: Option<Wad>,
231}
232
233impl<R: PartialEq> FeeConfig<'_, R> {
234    pub fn evaluate_change(
235        current: &Self,
236        proposed: &Self,
237    ) -> Result<FeeChangeDecision, FeeChangeError> {
238        if proposed.performance_fee > Wad::from(MAX_PERFORMANCE_FEE_WAD) {
239            return Err(FeeChangeError::PerformanceFeeTooHigh);
240        }
241        if proposed.management_fee > Wad::from(MAX_MANAGEMENT_FEE_WAD) {
242            return Err(FeeChangeError::ManagementFeeTooHigh);
243        }
244
245        let performance_fee_changed = proposed.performance_fee != current.performance_fee;
246        let management_fee_changed = proposed.management_fee != current.management_fee;
247        let performance_recipient_changed =
248            proposed.performance_recipient != current.performance_recipient;
249        let management_recipient_changed =
250            proposed.management_recipient != current.management_recipient;
251        let max_rate_changed = proposed.max_rate != current.max_rate;
252
253        if !(performance_fee_changed
254            || management_fee_changed
255            || performance_recipient_changed
256            || management_recipient_changed
257            || max_rate_changed)
258        {
259            return Err(FeeChangeError::NoChange);
260        }
261
262        let fee_increase = proposed.performance_fee > current.performance_fee
263            || proposed.management_fee > current.management_fee;
264        let recipient_changed = performance_recipient_changed || management_recipient_changed;
265
266        let max_rate_relaxed = match (current.max_rate, proposed.max_rate) {
267            (None, None) => false,
268            (None, Some(_)) => false,
269            (Some(_), None) => true,
270            (Some(old), Some(new)) => new > old,
271        };
272
273        Ok(FeeChangeDecision {
274            timelocked: fee_increase || recipient_changed || max_rate_relaxed,
275            fee_increase,
276            recipient_changed,
277            max_rate_relaxed,
278        })
279    }
280}
281
282#[templar_vault_macros::vault_derive(borsh, serde)]
283#[derive(Clone, Copy, PartialEq, Eq)]
284pub struct FeeChangeDecision {
285    pub timelocked: bool,
286    pub fee_increase: bool,
287    pub recipient_changed: bool,
288    pub max_rate_relaxed: bool,
289}
290
291#[templar_vault_macros::vault_derive(borsh, serde)]
292#[derive(Clone, Copy, PartialEq, Eq)]
293pub enum FeeChangeError {
294    NoChange,
295    PerformanceFeeTooHigh,
296    ManagementFeeTooHigh,
297}
298
299#[templar_vault_macros::vault_derive(borsh, serde)]
300#[derive(Clone, Copy, PartialEq, Eq)]
301pub enum TimelockConfigError {
302    NoChange,
303    OutOfBounds,
304}
305
306pub fn timelock_config_decision(
307    current: TimestampNs,
308    proposed: TimestampNs,
309    min: TimestampNs,
310    max: TimestampNs,
311) -> Result<TimelockDecision, TimelockConfigError> {
312    if proposed == current {
313        return Err(TimelockConfigError::NoChange);
314    }
315    if proposed < min || proposed > max {
316        return Err(TimelockConfigError::OutOfBounds);
317    }
318    if proposed < current {
319        Ok(TimelockDecision::Timelocked)
320    } else {
321        Ok(TimelockDecision::Immediate)
322    }
323}
324
325#[templar_vault_macros::vault_derive(borsh, serde)]
326#[derive(Clone, Copy, PartialEq, Eq)]
327pub enum CapChangeError {
328    NoChange,
329}
330
331#[templar_vault_macros::vault_derive(borsh, serde)]
332#[derive(Clone, Copy, PartialEq, Eq)]
333pub enum RelativeCapChangeError {
334    NoChange,
335    RelativeCapTooHigh,
336}
337
338#[templar_vault_macros::vault_derive(borsh, serde)]
339#[derive(Clone, Copy, PartialEq, Eq)]
340pub enum MembershipChangeError {
341    NoChange,
342}
343
344impl TimelockDecision {
345    /// Decide timelock behavior for market caps.
346    ///
347    /// `None` means the market has no existing cap record yet, so setting a cap is
348    /// treated as timelocked.
349    pub fn from_cap_change(current: Option<u128>, proposed: u128) -> Result<Self, CapChangeError> {
350        match current {
351            Some(existing) => {
352                Self::try_from(proposed.cmp(&existing)).map_err(|_| CapChangeError::NoChange)
353            }
354            None => Ok(Self::Timelocked),
355        }
356    }
357
358    /// Decide timelock behavior for optional caps where `0` (or `None`) means unlimited.
359    ///
360    /// This is intended for cap-group absolute caps, where moving from unlimited to a finite
361    /// cap tightens policy and should be immediate, while moving from finite to unlimited
362    /// relaxes policy and should be timelocked.
363    pub fn from_cap_group_cap_change(
364        current: Option<u128>,
365        proposed: u128,
366    ) -> Result<Self, CapChangeError> {
367        let normalize = |cap: Option<u128>| cap.and_then(core::num::NonZeroU128::new);
368        let current_cap = normalize(current);
369        let proposed_cap = core::num::NonZeroU128::new(proposed);
370
371        match (current_cap, proposed_cap) {
372            (None, None) => Err(CapChangeError::NoChange),
373            (None, Some(_)) => Ok(Self::Immediate),
374            (Some(_), None) => Ok(Self::Timelocked),
375            (Some(existing), Some(next)) => Self::try_from(next.get().cmp(&existing.get()))
376                .map_err(|_| CapChangeError::NoChange),
377        }
378    }
379
380    pub fn from_relative_cap_change(
381        current: Option<Wad>,
382        proposed: Wad,
383    ) -> Result<Self, RelativeCapChangeError> {
384        if proposed > Wad::one() {
385            return Err(RelativeCapChangeError::RelativeCapTooHigh);
386        }
387
388        match current {
389            Some(existing) => Self::try_from(proposed.cmp(&existing))
390                .map_err(|_| RelativeCapChangeError::NoChange),
391            None => Ok(Self::Timelocked),
392        }
393    }
394
395    pub fn from_membership_change(changed: bool) -> Result<Self, MembershipChangeError> {
396        if changed {
397            Ok(Self::Timelocked)
398        } else {
399            Err(MembershipChangeError::NoChange)
400        }
401    }
402}