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 #[event_version("1.0.0")]
15 Created { id: u32, proposal: Proposal<T> },
16 #[event_version("1.0.0")]
18 Cancelled { id: u32, proposal: Proposal<T> },
19 #[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 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 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 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 #[cfg(target_family = "wasm")]
251 {
252 near_sdk::env::abort();
253 }
254 #[cfg(not(target_family = "wasm"))]
255 {
256 unreachable!();
257 }
258 };
259
260 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}