|
| 1 | +//! Proposal execution system with timelock and guardian controls |
| 2 | +
|
| 3 | +use crate::{Error, Result, ProposalId, ProposalType}; |
| 4 | +use serde::{Deserialize, Serialize}; |
| 5 | +use std::collections::HashMap; |
| 6 | + |
| 7 | +/// Timelock delay in blocks |
| 8 | +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] |
| 9 | +pub struct TimelockDelay { |
| 10 | + /// Number of blocks to wait before execution |
| 11 | + pub blocks: u64, |
| 12 | +} |
| 13 | + |
| 14 | +impl TimelockDelay { |
| 15 | + /// Standard timelock delay (e.g., 2 days assuming 12s blocks) |
| 16 | + pub fn standard() -> Self { |
| 17 | + Self { blocks: 14400 } // ~2 days |
| 18 | + } |
| 19 | + |
| 20 | + /// Fast track delay (e.g., 6 hours) |
| 21 | + pub fn fast_track() -> Self { |
| 22 | + Self { blocks: 1800 } // ~6 hours |
| 23 | + } |
| 24 | + |
| 25 | + /// Emergency delay (e.g., 1 hour) |
| 26 | + pub fn emergency() -> Self { |
| 27 | + Self { blocks: 300 } // ~1 hour |
| 28 | + } |
| 29 | +} |
| 30 | + |
| 31 | +impl Default for TimelockDelay { |
| 32 | + fn default() -> Self { |
| 33 | + Self::standard() |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +/// Guardian action types |
| 38 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 39 | +pub enum GuardianAction { |
| 40 | + /// Cancel a proposal |
| 41 | + Cancel(ProposalId), |
| 42 | + |
| 43 | + /// Fast-track a proposal (reduce timelock) |
| 44 | + FastTrack(ProposalId), |
| 45 | + |
| 46 | + /// Veto a proposal execution |
| 47 | + Veto(ProposalId), |
| 48 | +} |
| 49 | + |
| 50 | +/// Queued proposal for execution |
| 51 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 52 | +pub struct QueuedProposal { |
| 53 | + /// Proposal ID |
| 54 | + pub proposal_id: ProposalId, |
| 55 | + |
| 56 | + /// Proposal type |
| 57 | + pub proposal_type: ProposalType, |
| 58 | + |
| 59 | + /// Block when it was queued |
| 60 | + pub queued_block: u64, |
| 61 | + |
| 62 | + /// Timelock delay |
| 63 | + pub timelock: TimelockDelay, |
| 64 | + |
| 65 | + /// Block when it can be executed |
| 66 | + pub execution_block: u64, |
| 67 | +} |
| 68 | + |
| 69 | +impl QueuedProposal { |
| 70 | + pub fn new( |
| 71 | + proposal_id: ProposalId, |
| 72 | + proposal_type: ProposalType, |
| 73 | + queued_block: u64, |
| 74 | + timelock: TimelockDelay, |
| 75 | + ) -> Self { |
| 76 | + Self { |
| 77 | + proposal_id, |
| 78 | + proposal_type, |
| 79 | + queued_block, |
| 80 | + timelock, |
| 81 | + execution_block: queued_block + timelock.blocks, |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + /// Check if proposal is ready for execution |
| 86 | + pub fn is_executable(&self, current_block: u64) -> bool { |
| 87 | + current_block >= self.execution_block |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +/// Execution queue managing timelocked proposals |
| 92 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 93 | +pub struct ExecutionQueue { |
| 94 | + /// Queued proposals awaiting execution |
| 95 | + queue: HashMap<ProposalId, QueuedProposal>, |
| 96 | +} |
| 97 | + |
| 98 | +impl ExecutionQueue { |
| 99 | + pub fn new() -> Self { |
| 100 | + Self { |
| 101 | + queue: HashMap::new(), |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + /// Enqueue a proposal for execution after timelock |
| 106 | + pub fn enqueue( |
| 107 | + &mut self, |
| 108 | + proposal_id: ProposalId, |
| 109 | + current_block: u64, |
| 110 | + proposal_type: ProposalType, |
| 111 | + ) { |
| 112 | + let timelock = match &proposal_type { |
| 113 | + ProposalType::ParameterChange { .. } => TimelockDelay::standard(), |
| 114 | + ProposalType::TreasurySpending { .. } => TimelockDelay::fast_track(), |
| 115 | + ProposalType::ProtocolUpgrade { .. } => TimelockDelay::standard(), |
| 116 | + }; |
| 117 | + |
| 118 | + let queued = QueuedProposal::new( |
| 119 | + proposal_id, |
| 120 | + proposal_type, |
| 121 | + current_block, |
| 122 | + timelock, |
| 123 | + ); |
| 124 | + |
| 125 | + let execution_block = queued.execution_block; |
| 126 | + self.queue.insert(proposal_id, queued); |
| 127 | + |
| 128 | + tracing::info!( |
| 129 | + proposal_id = proposal_id.0, |
| 130 | + execution_block = execution_block, |
| 131 | + "Proposal queued for execution after timelock" |
| 132 | + ); |
| 133 | + } |
| 134 | + |
| 135 | + /// Execute a proposal (must be past timelock) |
| 136 | + pub fn execute( |
| 137 | + &mut self, |
| 138 | + proposal_id: ProposalId, |
| 139 | + current_block: u64, |
| 140 | + ) -> Result<()> { |
| 141 | + let queued = self.queue.get(&proposal_id) |
| 142 | + .ok_or(Error::ProposalNotFound)?; |
| 143 | + |
| 144 | + if !queued.is_executable(current_block) { |
| 145 | + return Err(Error::ExecutionLocked); |
| 146 | + } |
| 147 | + |
| 148 | + // Remove from queue |
| 149 | + self.queue.remove(&proposal_id); |
| 150 | + |
| 151 | + tracing::info!( |
| 152 | + proposal_id = proposal_id.0, |
| 153 | + "Proposal executed and removed from queue" |
| 154 | + ); |
| 155 | + |
| 156 | + Ok(()) |
| 157 | + } |
| 158 | + |
| 159 | + /// Cancel a proposal (guardian action) |
| 160 | + pub fn cancel(&mut self, proposal_id: ProposalId) -> Result<()> { |
| 161 | + self.queue.remove(&proposal_id) |
| 162 | + .ok_or(Error::ProposalNotFound)?; |
| 163 | + |
| 164 | + tracing::warn!( |
| 165 | + proposal_id = proposal_id.0, |
| 166 | + "Proposal cancelled and removed from execution queue" |
| 167 | + ); |
| 168 | + |
| 169 | + Ok(()) |
| 170 | + } |
| 171 | + |
| 172 | + /// Fast-track a proposal (guardian action) |
| 173 | + pub fn fast_track( |
| 174 | + &mut self, |
| 175 | + proposal_id: ProposalId, |
| 176 | + current_block: u64, |
| 177 | + ) -> Result<()> { |
| 178 | + let queued = self.queue.get_mut(&proposal_id) |
| 179 | + .ok_or(Error::ProposalNotFound)?; |
| 180 | + |
| 181 | + queued.timelock = TimelockDelay::fast_track(); |
| 182 | + queued.execution_block = current_block + queued.timelock.blocks; |
| 183 | + |
| 184 | + tracing::info!( |
| 185 | + proposal_id = proposal_id.0, |
| 186 | + new_execution_block = queued.execution_block, |
| 187 | + "Proposal fast-tracked" |
| 188 | + ); |
| 189 | + |
| 190 | + Ok(()) |
| 191 | + } |
| 192 | + |
| 193 | + /// Get all executable proposals |
| 194 | + pub fn get_executable(&self, current_block: u64) -> Vec<ProposalId> { |
| 195 | + self.queue.values() |
| 196 | + .filter(|p| p.is_executable(current_block)) |
| 197 | + .map(|p| p.proposal_id) |
| 198 | + .collect() |
| 199 | + } |
| 200 | + |
| 201 | + /// Get proposal from queue |
| 202 | + pub fn get(&self, proposal_id: ProposalId) -> Option<&QueuedProposal> { |
| 203 | + self.queue.get(&proposal_id) |
| 204 | + } |
| 205 | +} |
| 206 | + |
| 207 | +impl Default for ExecutionQueue { |
| 208 | + fn default() -> Self { |
| 209 | + Self::new() |
| 210 | + } |
| 211 | +} |
| 212 | + |
| 213 | +#[cfg(test)] |
| 214 | +mod tests { |
| 215 | + use super::*; |
| 216 | + |
| 217 | + #[test] |
| 218 | + fn test_timelock_delays() { |
| 219 | + let standard = TimelockDelay::standard(); |
| 220 | + assert_eq!(standard.blocks, 14400); |
| 221 | + |
| 222 | + let fast = TimelockDelay::fast_track(); |
| 223 | + assert_eq!(fast.blocks, 1800); |
| 224 | + |
| 225 | + let emergency = TimelockDelay::emergency(); |
| 226 | + assert_eq!(emergency.blocks, 300); |
| 227 | + } |
| 228 | + |
| 229 | + #[test] |
| 230 | + fn test_queued_proposal() { |
| 231 | + let proposal = QueuedProposal::new( |
| 232 | + ProposalId(1), |
| 233 | + ProposalType::ParameterChange { |
| 234 | + parameter: "test".to_string(), |
| 235 | + new_value: vec![1], |
| 236 | + }, |
| 237 | + 100, |
| 238 | + TimelockDelay::fast_track(), |
| 239 | + ); |
| 240 | + |
| 241 | + assert_eq!(proposal.execution_block, 1900); // 100 + 1800 |
| 242 | + assert!(!proposal.is_executable(1000)); |
| 243 | + assert!(proposal.is_executable(1900)); |
| 244 | + assert!(proposal.is_executable(2000)); |
| 245 | + } |
| 246 | + |
| 247 | + #[test] |
| 248 | + fn test_execution_queue() { |
| 249 | + let mut queue = ExecutionQueue::new(); |
| 250 | + |
| 251 | + queue.enqueue( |
| 252 | + ProposalId(1), |
| 253 | + 100, |
| 254 | + ProposalType::TreasurySpending { |
| 255 | + recipient: [1u8; 33], |
| 256 | + amount: 1000, |
| 257 | + reason: "Test".to_string(), |
| 258 | + }, |
| 259 | + ); |
| 260 | + |
| 261 | + let queued = queue.get(ProposalId(1)).unwrap(); |
| 262 | + assert_eq!(queued.execution_block, 1900); // Fast track for treasury |
| 263 | + |
| 264 | + // Cannot execute before timelock |
| 265 | + let result = queue.execute(ProposalId(1), 1000); |
| 266 | + assert!(matches!(result, Err(Error::ExecutionLocked))); |
| 267 | + |
| 268 | + // Can execute after timelock |
| 269 | + queue.execute(ProposalId(1), 2000).unwrap(); |
| 270 | + assert!(queue.get(ProposalId(1)).is_none()); |
| 271 | + } |
| 272 | + |
| 273 | + #[test] |
| 274 | + fn test_cancel() { |
| 275 | + let mut queue = ExecutionQueue::new(); |
| 276 | + |
| 277 | + queue.enqueue( |
| 278 | + ProposalId(1), |
| 279 | + 100, |
| 280 | + ProposalType::ParameterChange { |
| 281 | + parameter: "test".to_string(), |
| 282 | + new_value: vec![1], |
| 283 | + }, |
| 284 | + ); |
| 285 | + |
| 286 | + queue.cancel(ProposalId(1)).unwrap(); |
| 287 | + assert!(queue.get(ProposalId(1)).is_none()); |
| 288 | + } |
| 289 | + |
| 290 | + #[test] |
| 291 | + fn test_fast_track() { |
| 292 | + let mut queue = ExecutionQueue::new(); |
| 293 | + |
| 294 | + queue.enqueue( |
| 295 | + ProposalId(1), |
| 296 | + 100, |
| 297 | + ProposalType::ParameterChange { |
| 298 | + parameter: "test".to_string(), |
| 299 | + new_value: vec![1], |
| 300 | + }, |
| 301 | + ); |
| 302 | + |
| 303 | + // Original execution block |
| 304 | + let original = queue.get(ProposalId(1)).unwrap().execution_block; |
| 305 | + assert_eq!(original, 14500); // 100 + 14400 (standard) |
| 306 | + |
| 307 | + // Fast track |
| 308 | + queue.fast_track(ProposalId(1), 200).unwrap(); |
| 309 | + |
| 310 | + let new_exec_block = queue.get(ProposalId(1)).unwrap().execution_block; |
| 311 | + assert_eq!(new_exec_block, 2000); // 200 + 1800 (fast track) |
| 312 | + } |
| 313 | + |
| 314 | + #[test] |
| 315 | + fn test_get_executable() { |
| 316 | + let mut queue = ExecutionQueue::new(); |
| 317 | + |
| 318 | + queue.enqueue( |
| 319 | + ProposalId(1), |
| 320 | + 100, |
| 321 | + ProposalType::TreasurySpending { |
| 322 | + recipient: [1u8; 33], |
| 323 | + amount: 1000, |
| 324 | + reason: "Test".to_string(), |
| 325 | + }, |
| 326 | + ); |
| 327 | + |
| 328 | + queue.enqueue( |
| 329 | + ProposalId(2), |
| 330 | + 100, |
| 331 | + ProposalType::ParameterChange { |
| 332 | + parameter: "test".to_string(), |
| 333 | + new_value: vec![1], |
| 334 | + }, |
| 335 | + ); |
| 336 | + |
| 337 | + // At block 2000, only proposal 1 is executable (fast track) |
| 338 | + let executable = queue.get_executable(2000); |
| 339 | + assert_eq!(executable.len(), 1); |
| 340 | + assert_eq!(executable[0].0, 1); |
| 341 | + |
| 342 | + // At block 15000, both are executable |
| 343 | + let executable = queue.get_executable(15000); |
| 344 | + assert_eq!(executable.len(), 2); |
| 345 | + } |
| 346 | +} |
0 commit comments