templar_common/
governance.rs

1use borsh::{BorshDeserialize, BorshSerialize};
2use near_sdk::{
3    near,
4    serde::Serialize,
5    store::{iterable_map, key, IterableMap},
6    AccountId, IntoStorageKey,
7};
8
9use crate::time::Nanoseconds;
10
11#[near(event_json(standard = "templar-governance"))]
12pub enum Event<T: Serialize> {
13    /// When a new proposal is created.
14    #[event_version("1.0.0")]
15    Created { id: u32, proposal: Proposal<T> },
16    /// When a proposal is cancelled.
17    #[event_version("1.0.0")]
18    Cancelled { id: u32, proposal: Proposal<T> },
19    /// When a proposal is executed.
20    #[event_version("1.0.0")]
21    Executed { id: u32, proposal: Proposal<T> },
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[near(serializers = [json, borsh])]
26pub struct Proposal<T> {
27    pub operation: T,
28    pub created_at: Nanoseconds,
29    pub ttl: Nanoseconds,
30    pub created_by: AccountId,
31}
32
33impl<T> Proposal<T> {
34    pub fn can_execute(&self, now: Nanoseconds) -> bool {
35        now.saturating_sub(self.created_at) >= self.ttl
36    }
37}
38
39pub trait Validatable {
40    type OnCreateError;
41    type OnExecuteError;
42
43    fn on_create(&self) -> Result<(), Self::OnCreateError> {
44        Ok(())
45    }
46
47    fn on_execute(&self) -> Result<(), Self::OnExecuteError> {
48        Ok(())
49    }
50}
51
52#[derive(Debug)]
53#[near(serializers = [borsh])]
54pub struct Governance<T: BorshSerialize> {
55    pub next_id: u32,
56    pub ttl: Nanoseconds,
57    pub proposals: IterableMap<u32, Proposal<T>, key::Identity>,
58}
59
60pub mod error {
61    use crate::time::Nanoseconds;
62
63    use super::Validatable;
64
65    #[derive(thiserror::Error, Debug, PartialEq, Eq)]
66    #[error("ID is out-of-order: expected {expected}, got {actual}")]
67    pub struct IdOutOfOrderError {
68        pub expected: u32,
69        pub actual: u32,
70    }
71
72    #[derive(thiserror::Error, Debug, PartialEq, Eq)]
73    #[error("ID is out-of-bounds: exclusive maximum {exclusive_maximum}, got {actual}")]
74    pub struct IdOutOfBoundsError {
75        pub exclusive_maximum: u32,
76        pub actual: u32,
77    }
78
79    #[derive(thiserror::Error, Debug, PartialEq, Eq)]
80    #[error("The proposal does not exist because it has already been cancelled or executed: {id}")]
81    pub struct ProposalDoesNotExistError {
82        pub id: u32,
83    }
84
85    #[derive(thiserror::Error, Debug, PartialEq, Eq)]
86    #[error("TTL not yet elapsed for proposal {id}: current timestamp {now} < created at {created_at} + TTL {ttl}")]
87    pub struct TtlNotElapsedError {
88        pub id: u32,
89        pub now: Nanoseconds,
90        pub created_at: Nanoseconds,
91        pub ttl: Nanoseconds,
92    }
93
94    #[derive(thiserror::Error, Debug, PartialEq, Eq)]
95    pub enum CreateError<T: Validatable> {
96        #[error(transparent)]
97        IdOutOfOrder(#[from] IdOutOfOrderError),
98        #[error("Validation error: {0}")]
99        Validation(#[source] T::OnCreateError),
100    }
101
102    #[derive(thiserror::Error, Debug, PartialEq, Eq)]
103    pub enum CancelError {
104        #[error(transparent)]
105        IdOutOfBounds(#[from] IdOutOfBoundsError),
106        #[error(transparent)]
107        ProposalDoesNotExist(#[from] ProposalDoesNotExistError),
108    }
109
110    #[derive(thiserror::Error, Debug, PartialEq, Eq)]
111    pub enum ExecuteError<T: Validatable> {
112        #[error(transparent)]
113        IdOutOfBounds(#[from] IdOutOfBoundsError),
114        #[error(transparent)]
115        ProposalDoesNotExist(#[from] ProposalDoesNotExistError),
116        #[error(transparent)]
117        IdOutOfOrder(#[from] IdOutOfOrderError),
118        #[error(transparent)]
119        TtlNotElapsed(#[from] TtlNotElapsedError),
120        #[error("Validation error: {0}")]
121        Validation(#[source] T::OnExecuteError),
122    }
123}
124
125impl<T: BorshSerialize> Governance<T> {
126    pub fn new(prefix: impl IntoStorageKey) -> Self {
127        Self {
128            next_id: 0,
129            ttl: Nanoseconds::zero(),
130            proposals: IterableMap::with_hasher(prefix.into_storage_key()),
131        }
132    }
133}
134
135impl<T: Clone + Serialize + BorshSerialize + BorshDeserialize + Validatable> Governance<T> {
136    /// Creates a new proposal.
137    ///
138    /// # Errors
139    ///
140    /// If the `id` requested to be created is out-of-order.
141    pub fn create(
142        &mut self,
143        id: u32,
144        operation: T,
145        now: Nanoseconds,
146        created_by: AccountId,
147    ) -> Result<Proposal<T>, error::CreateError<T>> {
148        if id != self.next_id {
149            return Err(error::IdOutOfOrderError {
150                expected: self.next_id,
151                actual: id,
152            }
153            .into());
154        }
155
156        operation
157            .on_create()
158            .map_err(error::CreateError::Validation)?;
159
160        self.next_id += 1;
161
162        let proposal = Proposal {
163            operation,
164            created_at: now,
165            ttl: self.ttl,
166            created_by,
167        };
168
169        self.proposals.insert(id, proposal.clone());
170
171        Event::Created {
172            id,
173            proposal: proposal.clone(),
174        }
175        .emit();
176
177        Ok(proposal)
178    }
179
180    /// Cancels a proposal.
181    ///
182    /// # Errors
183    ///
184    /// If the `id` requested to be cancelled is out of bounds or does not exist.
185    pub fn cancel(&mut self, id: u32) -> Result<(), error::CancelError> {
186        if id >= self.next_id {
187            return Err(error::IdOutOfBoundsError {
188                exclusive_maximum: self.next_id,
189                actual: id,
190            }
191            .into());
192        }
193
194        if let Some(proposal) = self.proposals.remove(&id) {
195            Event::Cancelled { id, proposal }.emit();
196            Ok(())
197        } else {
198            Err(error::ProposalDoesNotExistError { id }.into())
199        }
200    }
201
202    /// Executes a proposal.
203    ///
204    /// This function simply removes the proposal from storage if it is
205    /// eligible for execution and returns its associated operation. It is up
206    /// to the caller to actually execute the returned operation.
207    ///
208    /// ```rust
209    /// # use near_sdk::{env, near};
210    /// # use templar_common::oracle::proxy::governance::Governance;
211    /// # #[derive(Debug, Clone)]
212    /// # #[near(serializers = [borsh, json])]
213    /// enum Op {
214    ///     Increment,
215    ///     Decrement,
216    /// }
217    /// # let now_ms = 1000;
218    ///
219    /// let mut g = Governance::<Op>::new(b"g");
220    /// # let id = 0;
221    /// # g.create(id, Op::Increment, now_ms, "alice".parse().unwrap()).unwrap();
222    ///
223    /// match g.execute(id, now_ms).unwrap() {
224    ///     Op::Increment => println!("Actually perform the increment operation here"),
225    ///     Op::Decrement => println!("Actually perform the decrement operation here"),
226    /// }
227    /// ```
228    ///
229    /// # Errors
230    ///
231    /// If an `id` is out-of-bounds, does not exist, or if the proposal cannot
232    /// yet be executed (TTL not elapsed).
233    pub fn execute(&mut self, id: u32, now: Nanoseconds) -> Result<T, error::ExecuteError<T>> {
234        if id >= self.next_id {
235            return Err(error::IdOutOfBoundsError {
236                exclusive_maximum: self.next_id,
237                actual: id,
238            }
239            .into());
240        }
241
242        let min = self.proposals.keys().min().copied();
243
244        let iterable_map::Entry::Occupied(e) = self.proposals.entry(id) else {
245            return Err(error::ProposalDoesNotExistError { id }.into());
246        };
247
248        let Some(min) = min else {
249            // Unreachable.
250            #[cfg(target_family = "wasm")]
251            {
252                near_sdk::env::abort();
253            }
254            #[cfg(not(target_family = "wasm"))]
255            {
256                unreachable!();
257            }
258        };
259
260        // Require that operations are executed in order (or cancelled).
261        if id != min {
262            return Err(error::IdOutOfOrderError {
263                expected: min,
264                actual: id,
265            }
266            .into());
267        }
268
269        let proposal = e.get();
270        proposal
271            .operation
272            .on_execute()
273            .map_err(error::ExecuteError::Validation)?;
274
275        if proposal.can_execute(now) {
276            let proposal = proposal.clone();
277            e.remove();
278
279            Event::Executed {
280                id,
281                proposal: proposal.clone(),
282            }
283            .emit();
284            Ok(proposal.operation)
285        } else {
286            Err(error::TtlNotElapsedError {
287                id,
288                now,
289                created_at: proposal.created_at,
290                ttl: proposal.ttl,
291            }
292            .into())
293        }
294    }
295}
296
297#[macro_export]
298macro_rules! gen_ext_governance {
299    ($ext_name: ident, $trait_name: ident, $operation_ty: ty) => {
300        #[::near_sdk::ext_contract($ext_name)]
301        pub trait $trait_name {
302            fn gov_next_id(&self) -> u32;
303            fn gov_ttl_ns(&self) -> $crate::time::Nanoseconds;
304            fn gov_count(&self) -> u32;
305            fn gov_list(&self, offset: Option<u32>, count: Option<u32>) -> Vec<u32>;
306            fn gov_get(&self, id: u32) -> Option<$crate::governance::Proposal<$operation_ty>>;
307            fn gov_create(
308                &mut self,
309                id: u32,
310                operation: $operation_ty,
311            ) -> $crate::governance::Proposal<$operation_ty>;
312            fn gov_cancel(&mut self, id: u32);
313            fn gov_execute(&mut self, id: u32);
314        }
315    };
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[derive(Debug, Clone, PartialEq, Eq)]
323    #[near(serializers = [json, borsh])]
324    struct Op(String);
325    impl Validatable for Op {
326        type OnCreateError = ();
327        type OnExecuteError = ();
328
329        fn on_create(&self) -> Result<(), Self::OnCreateError> {
330            Ok(())
331        }
332
333        fn on_execute(&self) -> Result<(), Self::OnExecuteError> {
334            if self.0.len() < 10 {
335                Ok(())
336            } else {
337                Err(())
338            }
339        }
340    }
341
342    impl From<&str> for Op {
343        fn from(value: &str) -> Self {
344            Self(value.to_string())
345        }
346    }
347
348    #[test]
349    fn create() {
350        let alice: AccountId = "alice.near".parse().unwrap();
351        let mut g = Governance::<Op>::new(b"g");
352        let now = Nanoseconds::from_ms(12345);
353
354        assert_eq!(
355            g.create(0, "hello".into(), now, alice.clone()).unwrap(),
356            Proposal {
357                operation: "hello".into(),
358                created_at: now,
359                ttl: Nanoseconds::zero(),
360                created_by: alice.clone(),
361            },
362        );
363
364        assert_eq!(
365            g.create(0, "hello 2".into(), now, alice.clone())
366                .unwrap_err(),
367            error::IdOutOfOrderError {
368                expected: 1,
369                actual: 0
370            }
371            .into(),
372        );
373
374        assert_eq!(g.execute(0, now).unwrap(), "hello".into());
375
376        assert_eq!(
377            g.create(0, "hello 3".into(), now, alice.clone())
378                .unwrap_err(),
379            error::IdOutOfOrderError {
380                expected: 1,
381                actual: 0
382            }
383            .into(),
384        );
385
386        assert_eq!(
387            g.create(1, "hello 4".into(), now, alice.clone()).unwrap(),
388            Proposal {
389                operation: "hello 4".into(),
390                created_at: now,
391                ttl: Nanoseconds::zero(),
392                created_by: alice.clone(),
393            },
394        );
395
396        assert_eq!(g.execute(1, now).unwrap(), "hello 4".into());
397
398        g.create(2, "hello 5".into(), now, alice.clone()).unwrap();
399        g.create(3, "hello 6".into(), now, alice.clone()).unwrap();
400        g.create(4, "hello 7".into(), now, alice.clone()).unwrap();
401
402        assert_eq!(
403            g.execute(3, now).unwrap_err(),
404            error::IdOutOfOrderError {
405                expected: 2,
406                actual: 3
407            }
408            .into(),
409        );
410
411        assert_eq!(
412            g.execute(4, now).unwrap_err(),
413            error::IdOutOfOrderError {
414                expected: 2,
415                actual: 4
416            }
417            .into(),
418        );
419
420        assert_eq!(
421            g.execute(5, now).unwrap_err(),
422            error::IdOutOfBoundsError {
423                exclusive_maximum: 5,
424                actual: 5
425            }
426            .into(),
427        );
428
429        assert_eq!(
430            g.cancel(0).unwrap_err(),
431            error::ProposalDoesNotExistError { id: 0 }.into(),
432        );
433        g.cancel(3).unwrap();
434        assert_eq!(
435            g.cancel(5).unwrap_err(),
436            error::IdOutOfBoundsError {
437                exclusive_maximum: 5,
438                actual: 5
439            }
440            .into(),
441        );
442
443        g.execute(2, now).unwrap();
444        assert_eq!(
445            g.execute(3, now).unwrap_err(),
446            error::ProposalDoesNotExistError { id: 3 }.into(),
447        );
448        g.execute(4, now).unwrap();
449        assert_eq!(
450            g.execute(5, now).unwrap_err(),
451            error::IdOutOfBoundsError {
452                exclusive_maximum: 5,
453                actual: 5
454            }
455            .into(),
456        );
457    }
458}