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::vec::Vec;
9
10use templar_vault_kernel::math::wad::{Wad, MAX_MANAGEMENT_FEE_WAD, MAX_PERFORMANCE_FEE_WAD};
11use templar_vault_kernel::types::{DurationNs, TimestampNs};
12use templar_vault_kernel::TimeGate;
13
14/// A pending governance value gated by a timelock.
15#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
16#[derive(Clone, PartialEq, Eq)]
17pub struct PendingValue<T> {
18    pub value: T,
19    pub ready_at_ns: TimestampNs,
20}
21
22impl<T> PendingValue<T> {
23    /// Returns true if the timelock has elapsed.
24    #[must_use]
25    pub fn is_mature(&self, now_ns: TimestampNs) -> bool {
26        TimeGate::from_ready_at(self.ready_at_ns).is_ready(now_ns)
27    }
28}
29
30#[templar_vault_macros::vault_derive]
31#[derive(Clone, PartialEq, Eq)]
32pub enum TakePending<T> {
33    Missing,
34    Pending { ready_at_ns: TimestampNs },
35    Ready(T),
36}
37
38pub struct ScheduledPending<T> {
39    pub ready_at_ns: TimestampNs,
40    pub replaced: Vec<T>,
41}
42
43#[templar_vault_macros::vault_derive(borsh, schemars, serde, std_borsh_schema)]
44#[derive(Clone, PartialEq, Eq)]
45pub struct PendingActions<T> {
46    entries: Vec<PendingValue<T>>,
47}
48
49impl<T> Default for PendingActions<T> {
50    fn default() -> Self {
51        Self {
52            entries: Vec::new(),
53        }
54    }
55}
56
57impl<T> PendingActions<T> {
58    #[must_use]
59    pub fn len(&self) -> usize {
60        self.entries.len()
61    }
62
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.entries.is_empty()
66    }
67
68    pub fn iter(&self) -> impl Iterator<Item = &PendingValue<T>> {
69        self.entries.iter()
70    }
71
72    #[must_use]
73    pub fn back(&self) -> Option<&PendingValue<T>> {
74        self.entries.last()
75    }
76
77    pub fn schedule(
78        &mut self,
79        value: T,
80        now_ns: TimestampNs,
81        timelock_ns: DurationNs,
82    ) -> TimestampNs {
83        let ready_at_ns = TimeGate::schedule_from(now_ns, timelock_ns)
84            .ready_at_ns()
85            .expect("TimeGate::schedule_from always yields a ready timestamp");
86        self.entries.push(PendingValue { value, ready_at_ns });
87        ready_at_ns
88    }
89
90    #[must_use]
91    pub fn has_pending_key<K: PartialEq>(&self, key: &K, mut key_of: impl FnMut(&T) -> K) -> bool {
92        self.entries
93            .iter()
94            .any(|entry| key_of(&entry.value) == *key)
95    }
96
97    pub fn take_by_key<K: PartialEq>(
98        &mut self,
99        now_ns: TimestampNs,
100        key: &K,
101        mut key_of: impl FnMut(&T) -> K,
102    ) -> TakePending<T> {
103        let mut mature_index = None;
104        let mut next_ready_at: Option<TimestampNs> = None;
105
106        for (index, entry) in self.entries.iter().enumerate() {
107            if key_of(&entry.value) != *key {
108                continue;
109            }
110
111            if entry.is_mature(now_ns) {
112                mature_index = Some(index);
113                break;
114            }
115
116            next_ready_at = Some(match next_ready_at {
117                Some(current) => current.min(entry.ready_at_ns),
118                None => entry.ready_at_ns,
119            });
120        }
121
122        if let Some(index) = mature_index {
123            let pending = self.entries.remove(index);
124            return TakePending::Ready(pending.value);
125        }
126
127        match next_ready_at {
128            Some(ready_at_ns) => TakePending::Pending { ready_at_ns },
129            None => TakePending::Missing,
130        }
131    }
132
133    #[must_use]
134    pub fn revoke_by_key<K: PartialEq>(
135        &mut self,
136        key: &K,
137        mut key_of: impl FnMut(&T) -> K,
138    ) -> Vec<T> {
139        let mut retained = Vec::with_capacity(self.entries.len());
140        let mut removed = Vec::new();
141
142        for entry in self.entries.drain(..) {
143            if key_of(&entry.value) == *key {
144                removed.push(entry.value);
145            } else {
146                retained.push(entry);
147            }
148        }
149
150        self.entries = retained;
151        removed
152    }
153
154    pub fn schedule_replacing<K: PartialEq>(
155        &mut self,
156        key: &K,
157        key_of: impl FnMut(&T) -> K,
158        value: T,
159        now_ns: TimestampNs,
160        timelock_ns: DurationNs,
161    ) -> ScheduledPending<T> {
162        let replaced = self.revoke_by_key(key, key_of);
163        let ready_at_ns = self.schedule(value, now_ns, timelock_ns);
164        ScheduledPending {
165            ready_at_ns,
166            replaced,
167        }
168    }
169
170    #[must_use]
171    pub fn from_restored_entries(entries: Vec<PendingValue<T>>) -> Self {
172        Self { entries }
173    }
174
175    #[must_use]
176    pub fn from_entries<I>(entries: I) -> Self
177    where
178        I: IntoIterator<Item = PendingValue<T>>,
179    {
180        Self {
181            entries: entries.into_iter().collect(),
182        }
183    }
184
185    #[must_use]
186    pub fn into_entries(self) -> Vec<PendingValue<T>> {
187        self.entries
188    }
189}
190
191impl<T> IntoIterator for PendingActions<T> {
192    type Item = PendingValue<T>;
193    type IntoIter = alloc::vec::IntoIter<PendingValue<T>>;
194
195    fn into_iter(self) -> Self::IntoIter {
196        self.entries.into_iter()
197    }
198}
199
200impl<'a, T> IntoIterator for &'a PendingActions<T> {
201    type Item = &'a PendingValue<T>;
202    type IntoIter = core::slice::Iter<'a, PendingValue<T>>;
203
204    fn into_iter(self) -> Self::IntoIter {
205        self.entries.iter()
206    }
207}
208
209/// Decision on whether an action should be timelocked.
210#[templar_vault_macros::vault_derive(borsh, serde)]
211#[derive(Clone, Copy, PartialEq, Eq)]
212pub enum TimelockDecision {
213    Immediate,
214    Timelocked,
215}
216
217impl TimelockDecision {
218    #[must_use]
219    pub fn requires_timelock(self) -> bool {
220        matches!(self, TimelockDecision::Timelocked)
221    }
222
223    #[must_use]
224    pub fn from_requires_timelock(requires_timelock: bool) -> Self {
225        if requires_timelock {
226            TimelockDecision::Timelocked
227        } else {
228            TimelockDecision::Immediate
229        }
230    }
231
232    #[must_use]
233    pub fn is_immediate(self) -> bool {
234        matches!(self, TimelockDecision::Immediate)
235    }
236}
237
238/// Generic restrictions enum for shared governance checks.
239#[templar_vault_macros::vault_derive]
240#[derive(Clone, PartialEq, Eq)]
241pub enum Restrictions<T> {
242    Paused,
243    Blacklist(Vec<T>),
244    Whitelist(Vec<T>),
245}
246
247fn slice_contains<T: PartialEq>(items: &[T], target: &T) -> bool {
248    items.iter().any(|item| item == target)
249}
250
251fn any_missing_from<T: PartialEq>(source: &[T], candidate_superset: &[T]) -> bool {
252    source
253        .iter()
254        .any(|item| !slice_contains(candidate_superset, item))
255}
256
257fn any_overlap<T: PartialEq>(left: &[T], right: &[T]) -> bool {
258    left.iter().any(|item| slice_contains(right, item))
259}
260
261impl<T: PartialEq> Restrictions<T> {
262    #[must_use]
263    pub fn blacklist(members: Vec<T>) -> Self {
264        Self::Blacklist(normalize_members(members))
265    }
266
267    #[must_use]
268    pub fn whitelist(members: Vec<T>) -> Self {
269        Self::Whitelist(normalize_members(members))
270    }
271
272    #[must_use]
273    pub fn normalized(self) -> Self {
274        match self {
275            Self::Paused => Self::Paused,
276            Self::Blacklist(members) => Self::Blacklist(normalize_members(members)),
277            Self::Whitelist(members) => Self::Whitelist(normalize_members(members)),
278        }
279    }
280
281    #[must_use]
282    pub fn members(&self) -> Option<&[T]> {
283        match self {
284            Self::Paused => None,
285            Self::Blacklist(members) | Self::Whitelist(members) => Some(members),
286        }
287    }
288
289    /// Determine if a restriction change is relaxing (thus usually timelocked).
290    #[must_use]
291    pub fn determine_relaxed(current: &Option<Self>, next: &Option<Self>) -> bool {
292        match (current, next) {
293            (None, None) => false,
294            (None, Some(_)) => false,
295            (Some(_), None) => true,
296            (Some(Self::Paused), Some(Self::Paused)) => false,
297            (Some(Self::Paused), Some(Self::Whitelist(new))) => !new.is_empty(),
298            (Some(Self::Paused), Some(_)) => true,
299            (Some(Self::Blacklist(old)), Some(Self::Blacklist(new))) => any_missing_from(old, new),
300            (Some(Self::Whitelist(old)), Some(Self::Whitelist(new))) => any_missing_from(new, old),
301            (Some(Self::Blacklist(old)), Some(Self::Whitelist(new))) => any_overlap(old, new),
302            (Some(Self::Whitelist(_)), Some(Self::Paused))
303            | (Some(Self::Blacklist(_)), Some(Self::Paused)) => false,
304            (Some(Self::Whitelist(_)), Some(Self::Blacklist(_))) => true,
305        }
306    }
307}
308
309fn normalize_members<T: PartialEq>(members: Vec<T>) -> Vec<T> {
310    let mut normalized = Vec::with_capacity(members.len());
311    for member in members {
312        if !normalized.iter().any(|existing| existing == &member) {
313            normalized.push(member);
314        }
315    }
316    normalized
317}
318
319/// Fee config view for change evaluation.
320pub struct FeeConfig<'a, R> {
321    pub performance_fee: Wad,
322    pub management_fee: Wad,
323    pub performance_recipient: &'a R,
324    pub management_recipient: &'a R,
325    pub max_rate: Option<Wad>,
326}
327
328impl<R: PartialEq> FeeConfig<'_, R> {
329    pub fn evaluate_change(
330        current: &Self,
331        proposed: &Self,
332    ) -> Result<FeeChangeDecision, FeeChangeError> {
333        if proposed.performance_fee > Wad::from(MAX_PERFORMANCE_FEE_WAD) {
334            return Err(FeeChangeError::PerformanceFeeTooHigh);
335        }
336        if proposed.management_fee > Wad::from(MAX_MANAGEMENT_FEE_WAD) {
337            return Err(FeeChangeError::ManagementFeeTooHigh);
338        }
339
340        let performance_fee_changed = proposed.performance_fee != current.performance_fee;
341        let management_fee_changed = proposed.management_fee != current.management_fee;
342        let performance_recipient_changed =
343            proposed.performance_recipient != current.performance_recipient;
344        let management_recipient_changed =
345            proposed.management_recipient != current.management_recipient;
346        let max_rate_changed = proposed.max_rate != current.max_rate;
347
348        if !(performance_fee_changed
349            || management_fee_changed
350            || performance_recipient_changed
351            || management_recipient_changed
352            || max_rate_changed)
353        {
354            return Err(FeeChangeError::NoChange);
355        }
356
357        let fee_increase = proposed.performance_fee > current.performance_fee
358            || proposed.management_fee > current.management_fee;
359        let recipient_changed = performance_recipient_changed || management_recipient_changed;
360
361        let max_rate_relaxed = match (current.max_rate, proposed.max_rate) {
362            (None, None) => false,
363            (None, Some(_)) => false,
364            (Some(_), None) => true,
365            (Some(old), Some(new)) => new > old,
366        };
367
368        Ok(FeeChangeDecision {
369            timelocked: fee_increase || recipient_changed || max_rate_relaxed,
370            fee_increase,
371            recipient_changed,
372            max_rate_relaxed,
373        })
374    }
375}
376
377#[templar_vault_macros::vault_derive(borsh, serde)]
378#[derive(Clone, Copy, PartialEq, Eq)]
379pub struct FeeChangeDecision {
380    pub timelocked: bool,
381    pub fee_increase: bool,
382    pub recipient_changed: bool,
383    pub max_rate_relaxed: bool,
384}
385
386#[templar_vault_macros::vault_derive(borsh, serde)]
387#[derive(Clone, Copy, PartialEq, Eq)]
388pub enum FeeChangeError {
389    NoChange,
390    PerformanceFeeTooHigh,
391    ManagementFeeTooHigh,
392}
393
394#[templar_vault_macros::vault_derive(borsh, serde)]
395#[derive(Clone, Copy, PartialEq, Eq)]
396pub enum TimelockConfigError {
397    NoChange,
398    OutOfBounds,
399}
400
401pub fn timelock_config_decision(
402    current: DurationNs,
403    proposed: DurationNs,
404    min: DurationNs,
405    max: DurationNs,
406) -> Result<TimelockDecision, TimelockConfigError> {
407    if proposed == current {
408        return Err(TimelockConfigError::NoChange);
409    }
410    if proposed < min || proposed > max {
411        return Err(TimelockConfigError::OutOfBounds);
412    }
413    if proposed < current {
414        Ok(TimelockDecision::Timelocked)
415    } else {
416        Ok(TimelockDecision::Immediate)
417    }
418}
419
420#[templar_vault_macros::vault_derive(borsh, serde)]
421#[derive(Clone, Copy, PartialEq, Eq)]
422pub enum CapChangeError {
423    NoChange,
424}
425
426#[templar_vault_macros::vault_derive(borsh, serde)]
427#[derive(Clone, Copy, PartialEq, Eq)]
428pub enum RelativeCapChangeError {
429    NoChange,
430    RelativeCapTooHigh,
431}
432
433#[templar_vault_macros::vault_derive(borsh, serde)]
434#[derive(Clone, Copy, PartialEq, Eq)]
435pub enum MembershipChangeError {
436    NoChange,
437}
438
439#[templar_vault_macros::vault_derive(borsh, serde)]
440#[derive(Clone, Copy, PartialEq, Eq)]
441pub enum MembershipChangeKind {
442    Added,
443    Removed,
444    Reassigned,
445}
446
447impl TimelockDecision {
448    /// Decide timelock behavior for market caps.
449    ///
450    /// `None` means the market has no existing cap record yet, so setting a cap is
451    /// treated as timelocked.
452    pub fn from_cap_change(current: Option<u128>, proposed: u128) -> Result<Self, CapChangeError> {
453        match current {
454            Some(existing) if proposed == existing => Err(CapChangeError::NoChange),
455            Some(existing) if proposed > existing => Ok(Self::Timelocked),
456            Some(_) => Ok(Self::Immediate),
457            None => Ok(Self::Timelocked),
458        }
459    }
460
461    pub fn from_cap_group_cap_change(
462        current: Option<u128>,
463        proposed: Option<u128>,
464    ) -> Result<Self, CapChangeError> {
465        match (current, proposed) {
466            (None, None) => Err(CapChangeError::NoChange),
467            (None, Some(_)) => Ok(Self::Immediate),
468            (Some(_), None) => Ok(Self::Timelocked),
469            (Some(existing), Some(next)) if next == existing => Err(CapChangeError::NoChange),
470            (Some(existing), Some(next)) if next > existing => Ok(Self::Timelocked),
471            (Some(_), Some(_)) => Ok(Self::Immediate),
472        }
473    }
474
475    pub fn from_relative_cap_change(
476        current: Option<Wad>,
477        proposed: Option<Wad>,
478    ) -> Result<Self, RelativeCapChangeError> {
479        if let Some(proposed) = proposed {
480            if proposed > Wad::one() {
481                return Err(RelativeCapChangeError::RelativeCapTooHigh);
482            }
483        }
484
485        match (current, proposed) {
486            (None, None) => Err(RelativeCapChangeError::NoChange),
487            (None, Some(_)) => Ok(Self::Immediate),
488            (Some(_), None) => Ok(Self::Timelocked),
489            (Some(existing), Some(next)) if next == existing => {
490                Err(RelativeCapChangeError::NoChange)
491            }
492            (Some(existing), Some(next)) if next > existing => Ok(Self::Timelocked),
493            (Some(_), Some(_)) => Ok(Self::Immediate),
494        }
495    }
496
497    #[must_use]
498    pub fn membership_change_kind<T: PartialEq>(
499        current: Option<&T>,
500        proposed: Option<&T>,
501    ) -> Option<MembershipChangeKind> {
502        match (current, proposed) {
503            (None, None) => None,
504            (None, Some(_)) => Some(MembershipChangeKind::Added),
505            (Some(_), None) => Some(MembershipChangeKind::Removed),
506            (Some(current), Some(proposed)) if current == proposed => None,
507            (Some(_), Some(_)) => Some(MembershipChangeKind::Reassigned),
508        }
509    }
510
511    pub fn from_membership_assignment_change<T: PartialEq>(
512        current: Option<&T>,
513        proposed: Option<&T>,
514    ) -> Result<Self, MembershipChangeError> {
515        match Self::membership_change_kind(current, proposed) {
516            Some(_) => Ok(Self::Timelocked),
517            None => Err(MembershipChangeError::NoChange),
518        }
519    }
520
521    #[must_use]
522    pub fn from_membership_change_kind(_change: MembershipChangeKind) -> Self {
523        Self::Timelocked
524    }
525}