espresso_contract_deployer/proposals/
timelock.rs

1use alloy::{
2    primitives::{Address, B256, Bytes, U256},
3    providers::Provider,
4    rpc::types::TransactionReceipt,
5};
6use anyhow::Result;
7use clap::ValueEnum;
8use hotshot_contract_adapter::sol_types::{
9    EspToken, FeeContract, LightClient, OpsTimelock, RewardClaim, SafeExitTimelock, StakeTable,
10};
11
12use crate::{
13    Contract, Contracts, OwnableContract, proposals::multisig::encode_generic_calldata,
14    retry_until_true,
15};
16
17/// Data structure for timelock operations payload
18#[derive(Debug, Default, Clone)]
19pub struct TimelockOperationPayload {
20    /// The address of the contract to call
21    pub target: Address,
22    /// The value to send with the call
23    pub value: U256,
24    /// The data to send with the call e.g. the calldata of a function call
25    pub data: Bytes,
26    /// The predecessor operation id if you need to chain operations
27    pub predecessor: B256,
28    /// The salt for the operation
29    pub salt: B256,
30    /// The delay for the operation, must be >= the timelock's min delay
31    pub delay: U256,
32}
33
34/// Parameters for executing timelock operations (how to route/execute)
35#[derive(Debug, Clone, Default)]
36pub struct TimelockOperationParams {
37    /// Optional multisig proposer address. If provided, operation will be routed through Safe proposal.
38    pub multisig_proposer: Option<Address>,
39    /// Optional operation ID (for cancel operations when you already have the ID)
40    pub operation_id: Option<B256>,
41    /// Whether to perform a dry run (for testing, no proposal is created)
42    pub dry_run: bool,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
46pub enum TimelockOperationType {
47    Schedule,
48    Execute,
49    Cancel,
50}
51
52/// Enum representing different types of timelock contracts
53#[derive(Debug)]
54pub enum TimelockContract {
55    OpsTimelock(Address),
56    SafeExitTimelock(Address),
57}
58
59impl TimelockContract {
60    pub async fn get_operation_id(
61        &self,
62        operation: &TimelockOperationPayload,
63        provider: &impl Provider,
64    ) -> Result<B256> {
65        match self {
66            TimelockContract::OpsTimelock(timelock_addr) => {
67                Ok(OpsTimelock::new(*timelock_addr, &provider)
68                    .hashOperation(
69                        operation.target,
70                        operation.value,
71                        operation.data.clone(),
72                        operation.predecessor,
73                        operation.salt,
74                    )
75                    .call()
76                    .await?)
77            },
78            TimelockContract::SafeExitTimelock(timelock_addr) => {
79                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
80                    .hashOperation(
81                        operation.target,
82                        operation.value,
83                        operation.data.clone(),
84                        operation.predecessor,
85                        operation.salt,
86                    )
87                    .call()
88                    .await?)
89            },
90        }
91    }
92
93    pub async fn schedule(
94        &self,
95        operation: TimelockOperationPayload,
96        provider: &impl Provider,
97    ) -> Result<TransactionReceipt> {
98        self.call_timelock_method(TimelockOperationType::Schedule, operation, None, provider)
99            .await
100    }
101
102    pub async fn execute(
103        &self,
104        operation: TimelockOperationPayload,
105        provider: &impl Provider,
106    ) -> Result<TransactionReceipt> {
107        self.call_timelock_method(TimelockOperationType::Execute, operation, None, provider)
108            .await
109    }
110
111    pub async fn cancel(
112        &self,
113        operation_id: B256,
114        provider: &impl Provider,
115    ) -> Result<TransactionReceipt> {
116        // the timelock contract only requires the operation_id to cancel an operation
117        let placeholder_operation = TimelockOperationPayload::default();
118        self.call_timelock_method(
119            TimelockOperationType::Cancel,
120            placeholder_operation,
121            Some(operation_id),
122            provider,
123        )
124        .await
125    }
126
127    /// Internal helper to reduce duplication in schedule/execute/cancel
128    async fn call_timelock_method(
129        &self,
130        method: TimelockOperationType,
131        operation: TimelockOperationPayload,
132        operation_id: Option<B256>,
133        provider: &impl Provider,
134    ) -> Result<TransactionReceipt> {
135        let pending_tx = match (self, method) {
136            (TimelockContract::OpsTimelock(addr), TimelockOperationType::Schedule) => {
137                OpsTimelock::new(*addr, &provider)
138                    .schedule(
139                        operation.target,
140                        operation.value,
141                        operation.data,
142                        operation.predecessor,
143                        operation.salt,
144                        operation.delay,
145                    )
146                    .send()
147                    .await?
148            },
149            (TimelockContract::SafeExitTimelock(addr), TimelockOperationType::Schedule) => {
150                SafeExitTimelock::new(*addr, &provider)
151                    .schedule(
152                        operation.target,
153                        operation.value,
154                        operation.data,
155                        operation.predecessor,
156                        operation.salt,
157                        operation.delay,
158                    )
159                    .send()
160                    .await?
161            },
162            (TimelockContract::OpsTimelock(addr), TimelockOperationType::Execute) => {
163                OpsTimelock::new(*addr, &provider)
164                    .execute(
165                        operation.target,
166                        operation.value,
167                        operation.data,
168                        operation.predecessor,
169                        operation.salt,
170                    )
171                    .send()
172                    .await?
173            },
174            (TimelockContract::SafeExitTimelock(addr), TimelockOperationType::Execute) => {
175                SafeExitTimelock::new(*addr, &provider)
176                    .execute(
177                        operation.target,
178                        operation.value,
179                        operation.data,
180                        operation.predecessor,
181                        operation.salt,
182                    )
183                    .send()
184                    .await?
185            },
186            (TimelockContract::OpsTimelock(addr), TimelockOperationType::Cancel) => {
187                OpsTimelock::new(*addr, &provider)
188                    .cancel(
189                        operation_id
190                            .ok_or_else(|| anyhow::anyhow!("operation_id required for cancel"))?,
191                    )
192                    .send()
193                    .await?
194            },
195            (TimelockContract::SafeExitTimelock(addr), TimelockOperationType::Cancel) => {
196                SafeExitTimelock::new(*addr, &provider)
197                    .cancel(
198                        operation_id
199                            .ok_or_else(|| anyhow::anyhow!("operation_id required for cancel"))?,
200                    )
201                    .send()
202                    .await?
203            },
204        };
205
206        let tx_hash = *pending_tx.tx_hash();
207        tracing::info!(%tx_hash, "waiting for tx to be mined");
208        let receipt = pending_tx.get_receipt().await?;
209        Ok(receipt)
210    }
211
212    pub async fn is_operation_pending(
213        &self,
214        operation_id: B256,
215        provider: &impl Provider,
216    ) -> Result<bool> {
217        match self {
218            TimelockContract::OpsTimelock(timelock_addr) => {
219                Ok(OpsTimelock::new(*timelock_addr, &provider)
220                    .isOperationPending(operation_id)
221                    .call()
222                    .await?)
223            },
224            TimelockContract::SafeExitTimelock(timelock_addr) => {
225                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
226                    .isOperationPending(operation_id)
227                    .call()
228                    .await?)
229            },
230        }
231    }
232
233    pub async fn is_operation_ready(
234        &self,
235        operation_id: B256,
236        provider: &impl Provider,
237    ) -> Result<bool> {
238        match self {
239            TimelockContract::OpsTimelock(timelock_addr) => {
240                Ok(OpsTimelock::new(*timelock_addr, &provider)
241                    .isOperationReady(operation_id)
242                    .call()
243                    .await?)
244            },
245            TimelockContract::SafeExitTimelock(timelock_addr) => {
246                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
247                    .isOperationReady(operation_id)
248                    .call()
249                    .await?)
250            },
251        }
252    }
253
254    pub async fn is_operation_done(
255        &self,
256        operation_id: B256,
257        provider: &impl Provider,
258    ) -> Result<bool> {
259        match self {
260            TimelockContract::OpsTimelock(timelock_addr) => {
261                Ok(OpsTimelock::new(*timelock_addr, &provider)
262                    .isOperationDone(operation_id)
263                    .call()
264                    .await?)
265            },
266            TimelockContract::SafeExitTimelock(timelock_addr) => {
267                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
268                    .isOperationDone(operation_id)
269                    .call()
270                    .await?)
271            },
272        }
273    }
274
275    pub async fn is_operation_canceled(
276        &self,
277        operation_id: B256,
278        provider: &impl Provider,
279    ) -> Result<bool> {
280        let pending = self.is_operation_pending(operation_id, provider).await?;
281        let done = self.is_operation_done(operation_id, provider).await?;
282        // it's canceled if it's not pending and not done
283        Ok(!pending && !done)
284    }
285}
286
287// Derive timelock address from contract type
288// FeeContract, LightClient, StakeTable => OpsTimelock
289// EspToken, RewardClaim => SafeExitTimelock
290pub fn derive_timelock_address_from_contract_type(
291    contract_type: OwnableContract,
292    contracts: &Contracts,
293) -> Result<Address> {
294    let timelock_type = match contract_type {
295        OwnableContract::FeeContractProxy
296        | OwnableContract::LightClientProxy
297        | OwnableContract::StakeTableProxy => Contract::OpsTimelock,
298        OwnableContract::EspTokenProxy | OwnableContract::RewardClaimProxy => {
299            Contract::SafeExitTimelock
300        },
301    };
302
303    contracts.address(timelock_type).ok_or_else(|| {
304        anyhow::anyhow!(
305            "{:?} not found in deployed contracts. Deploy it first or provide it via flag.",
306            timelock_type
307        )
308    })
309}
310
311// Get the timelock for a contract by querying the contract owner or current admin
312pub async fn get_timelock_for_contract(
313    provider: &impl Provider,
314    contract_type: Contract,
315    target_addr: Address,
316) -> Result<TimelockContract> {
317    match contract_type {
318        Contract::FeeContractProxy => Ok(TimelockContract::OpsTimelock(
319            FeeContract::new(target_addr, &provider)
320                .owner()
321                .call()
322                .await?,
323        )),
324        Contract::EspTokenProxy => Ok(TimelockContract::SafeExitTimelock(
325            EspToken::new(target_addr, &provider).owner().call().await?,
326        )),
327        Contract::LightClientProxy => Ok(TimelockContract::OpsTimelock(
328            LightClient::new(target_addr, &provider)
329                .owner()
330                .call()
331                .await?,
332        )),
333        Contract::StakeTableProxy => Ok(TimelockContract::OpsTimelock(
334            StakeTable::new(target_addr, &provider)
335                .owner()
336                .call()
337                .await?,
338        )),
339        Contract::RewardClaimProxy => Ok(TimelockContract::SafeExitTimelock(
340            RewardClaim::new(target_addr, &provider)
341                .currentAdmin()
342                .call()
343                .await?,
344        )),
345        _ => anyhow::bail!(
346            "Invalid contract type for timelock get operation: {}",
347            contract_type
348        ),
349    }
350}
351
352/// Unified function to perform timelock operations (schedule, execute, cancel)
353/// Routes to EOA or multisig based on params
354pub async fn perform_timelock_operation(
355    provider: &impl Provider,
356    contract_type: Contract,
357    operation: TimelockOperationPayload,
358    operation_type: TimelockOperationType,
359    params: TimelockOperationParams,
360) -> Result<B256> {
361    let timelock = get_timelock_for_contract(provider, contract_type, operation.target).await?;
362    // for cancel operations: if operation_id is provided, use it directly;
363    // otherwise, compute it from the operation payload
364    let operation_id =
365        if let (TimelockOperationType::Cancel, Some(id)) = (operation_type, params.operation_id) {
366            id
367        } else {
368            timelock.get_operation_id(&operation, &provider).await?
369        };
370
371    if let Some(multisig_proposer) = params.multisig_proposer {
372        perform_timelock_operation_via_multisig(
373            timelock,
374            operation,
375            operation_type,
376            operation_id,
377            multisig_proposer,
378        )
379        .await
380    } else {
381        perform_timelock_operation_via_eoa(
382            timelock,
383            operation,
384            operation_type,
385            operation_id,
386            provider,
387        )
388        .await
389    }
390}
391
392/// Perform timelock operation via EOA (direct transaction)
393async fn perform_timelock_operation_via_eoa(
394    timelock: TimelockContract,
395    operation: TimelockOperationPayload,
396    operation_type: TimelockOperationType,
397    operation_id: B256,
398    provider: &impl Provider,
399) -> Result<B256> {
400    let receipt = match operation_type {
401        TimelockOperationType::Schedule => timelock.schedule(operation, &provider).await?,
402        TimelockOperationType::Execute => timelock.execute(operation, &provider).await?,
403        TimelockOperationType::Cancel => timelock.cancel(operation_id, &provider).await?,
404    };
405
406    tracing::info!(%receipt.gas_used, %receipt.transaction_hash, "tx mined");
407    if !receipt.inner.is_success() {
408        anyhow::bail!("tx failed: {:?}", receipt);
409    }
410
411    // Verify operation state based on type (with retry for RPC timing)
412    match operation_type {
413        TimelockOperationType::Schedule => {
414            let check_name = format!("Schedule operation {}", operation_id);
415            let is_scheduled = retry_until_true(&check_name, || async {
416                Ok(timelock
417                    .is_operation_pending(operation_id, &provider)
418                    .await?
419                    || timelock.is_operation_ready(operation_id, &provider).await?)
420            })
421            .await?;
422            if !is_scheduled {
423                anyhow::bail!("tx not correctly scheduled: {}", operation_id);
424            }
425            tracing::info!("tx scheduled with id: {}", operation_id);
426        },
427        TimelockOperationType::Execute => {
428            let check_name = format!("Execute operation {}", operation_id);
429            let is_done = retry_until_true(&check_name, || async {
430                timelock.is_operation_done(operation_id, &provider).await
431            })
432            .await?;
433            if !is_done {
434                anyhow::bail!("tx not correctly executed: {}", operation_id);
435            }
436            tracing::info!("tx executed with id: {}", operation_id);
437        },
438        TimelockOperationType::Cancel => {
439            tracing::info!("tx cancelled with id: {}", operation_id);
440        },
441    }
442
443    Ok(operation_id)
444}
445
446/// Perform timelock operation via Safe multisig proposal
447async fn perform_timelock_operation_via_multisig(
448    timelock: TimelockContract,
449    operation: TimelockOperationPayload,
450    operation_type: TimelockOperationType,
451    operation_id: B256,
452    multisig_proposer: Address,
453) -> Result<B256> {
454    let timelock_addr = match timelock {
455        TimelockContract::OpsTimelock(addr) => addr,
456        TimelockContract::SafeExitTimelock(addr) => addr,
457    };
458
459    // Determine function signature and arguments based on operation type
460    let (function_signature, function_args) = match operation_type {
461        TimelockOperationType::Schedule => (
462            "schedule(address,uint256,bytes,bytes32,bytes32,uint256)",
463            vec![
464                operation.target.to_string(),
465                operation.value.to_string(),
466                operation.data.to_string(),
467                operation.predecessor.to_string(),
468                operation.salt.to_string(),
469                operation.delay.to_string(),
470            ],
471        ),
472        TimelockOperationType::Execute => (
473            "execute(address,uint256,bytes,bytes32,bytes32)",
474            vec![
475                operation.target.to_string(),
476                operation.value.to_string(),
477                operation.data.to_string(),
478                operation.predecessor.to_string(),
479                operation.salt.to_string(),
480            ],
481        ),
482        TimelockOperationType::Cancel => ("cancel(bytes32)", vec![operation_id.to_string()]),
483    };
484
485    tracing::info!(
486        "Encoding {:?} operation calldata for timelock {}",
487        operation_type,
488        timelock_addr
489    );
490
491    let calldata =
492        encode_generic_calldata(timelock_addr, function_signature, function_args, U256::ZERO)?;
493
494    tracing::info!(
495        "Timelock {:?} operation calldata encoded. Operation ID: {}",
496        operation_type,
497        operation_id
498    );
499    tracing::info!(
500        "Multisig proposer: {}. To: {}, Data: {}",
501        multisig_proposer,
502        calldata.to,
503        calldata.data
504    );
505
506    Ok(operation_id)
507}