Skip to main content

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, anyhow};
7use clap::ValueEnum;
8use hotshot_contract_adapter::sol_types::{
9    EspToken, FeeContract, LightClient, OpsTimelock, RewardClaim, SafeExitTimelock, StakeTable,
10    StakeTableV3,
11};
12
13use crate::{
14    Contract, Contracts, OwnableContract,
15    output::CalldataInfo,
16    proposals::multisig::{encode_generic_calldata, encode_upgrade_calldata},
17    retry_until_true,
18};
19
20/// Data structure for timelock operations payload
21#[derive(Debug, Default, Clone)]
22pub struct TimelockOperationPayload {
23    /// The address of the contract to call
24    pub target: Address,
25    /// The value to send with the call
26    pub value: U256,
27    /// The data to send with the call e.g. the calldata of a function call
28    pub data: Bytes,
29    /// The predecessor operation id if you need to chain operations
30    pub predecessor: B256,
31    /// The salt for the operation
32    pub salt: B256,
33    /// The delay for the operation, must be >= the timelock's min delay
34    pub delay: U256,
35}
36
37/// Parameters for executing timelock operations (how to route/execute)
38#[derive(Debug, Clone, Default)]
39pub struct TimelockOperationParams {
40    /// Optional multisig proposer address. If provided, operation will be routed through Safe proposal.
41    pub multisig_proposer: Option<Address>,
42    /// Optional operation ID (for cancel operations when you already have the ID)
43    pub operation_id: Option<B256>,
44    /// Whether to perform a dry run (for testing, no proposal is created)
45    pub dry_run: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
49pub enum TimelockOperationType {
50    Schedule,
51    Execute,
52    Cancel,
53}
54
55/// Enum representing different types of timelock contracts
56#[derive(Debug)]
57pub enum TimelockContract {
58    OpsTimelock(Address),
59    SafeExitTimelock(Address),
60}
61
62impl TimelockContract {
63    pub async fn get_operation_id(
64        &self,
65        operation: &TimelockOperationPayload,
66        provider: &impl Provider,
67    ) -> Result<B256> {
68        match self {
69            TimelockContract::OpsTimelock(timelock_addr) => {
70                Ok(OpsTimelock::new(*timelock_addr, &provider)
71                    .hashOperation(
72                        operation.target,
73                        operation.value,
74                        operation.data.clone(),
75                        operation.predecessor,
76                        operation.salt,
77                    )
78                    .call()
79                    .await?)
80            },
81            TimelockContract::SafeExitTimelock(timelock_addr) => {
82                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
83                    .hashOperation(
84                        operation.target,
85                        operation.value,
86                        operation.data.clone(),
87                        operation.predecessor,
88                        operation.salt,
89                    )
90                    .call()
91                    .await?)
92            },
93        }
94    }
95
96    pub async fn schedule(
97        &self,
98        operation: TimelockOperationPayload,
99        provider: &impl Provider,
100    ) -> Result<TransactionReceipt> {
101        self.call_timelock_method(TimelockOperationType::Schedule, operation, None, provider)
102            .await
103    }
104
105    pub async fn execute(
106        &self,
107        operation: TimelockOperationPayload,
108        provider: &impl Provider,
109    ) -> Result<TransactionReceipt> {
110        self.call_timelock_method(TimelockOperationType::Execute, operation, None, provider)
111            .await
112    }
113
114    pub async fn cancel(
115        &self,
116        operation_id: B256,
117        provider: &impl Provider,
118    ) -> Result<TransactionReceipt> {
119        // the timelock contract only requires the operation_id to cancel an operation
120        let placeholder_operation = TimelockOperationPayload::default();
121        self.call_timelock_method(
122            TimelockOperationType::Cancel,
123            placeholder_operation,
124            Some(operation_id),
125            provider,
126        )
127        .await
128    }
129
130    /// Internal helper to reduce duplication in schedule/execute/cancel
131    async fn call_timelock_method(
132        &self,
133        method: TimelockOperationType,
134        operation: TimelockOperationPayload,
135        operation_id: Option<B256>,
136        provider: &impl Provider,
137    ) -> Result<TransactionReceipt> {
138        let pending_tx = match (self, method) {
139            (TimelockContract::OpsTimelock(addr), TimelockOperationType::Schedule) => {
140                OpsTimelock::new(*addr, &provider)
141                    .schedule(
142                        operation.target,
143                        operation.value,
144                        operation.data,
145                        operation.predecessor,
146                        operation.salt,
147                        operation.delay,
148                    )
149                    .send()
150                    .await?
151            },
152            (TimelockContract::SafeExitTimelock(addr), TimelockOperationType::Schedule) => {
153                SafeExitTimelock::new(*addr, &provider)
154                    .schedule(
155                        operation.target,
156                        operation.value,
157                        operation.data,
158                        operation.predecessor,
159                        operation.salt,
160                        operation.delay,
161                    )
162                    .send()
163                    .await?
164            },
165            (TimelockContract::OpsTimelock(addr), TimelockOperationType::Execute) => {
166                OpsTimelock::new(*addr, &provider)
167                    .execute(
168                        operation.target,
169                        operation.value,
170                        operation.data,
171                        operation.predecessor,
172                        operation.salt,
173                    )
174                    .send()
175                    .await?
176            },
177            (TimelockContract::SafeExitTimelock(addr), TimelockOperationType::Execute) => {
178                SafeExitTimelock::new(*addr, &provider)
179                    .execute(
180                        operation.target,
181                        operation.value,
182                        operation.data,
183                        operation.predecessor,
184                        operation.salt,
185                    )
186                    .send()
187                    .await?
188            },
189            (TimelockContract::OpsTimelock(addr), TimelockOperationType::Cancel) => {
190                OpsTimelock::new(*addr, &provider)
191                    .cancel(
192                        operation_id
193                            .ok_or_else(|| anyhow::anyhow!("operation_id required for cancel"))?,
194                    )
195                    .send()
196                    .await?
197            },
198            (TimelockContract::SafeExitTimelock(addr), TimelockOperationType::Cancel) => {
199                SafeExitTimelock::new(*addr, &provider)
200                    .cancel(
201                        operation_id
202                            .ok_or_else(|| anyhow::anyhow!("operation_id required for cancel"))?,
203                    )
204                    .send()
205                    .await?
206            },
207        };
208
209        let tx_hash = *pending_tx.tx_hash();
210        tracing::info!(%tx_hash, "waiting for tx to be mined");
211        let receipt = pending_tx.get_receipt().await?;
212        Ok(receipt)
213    }
214
215    pub async fn is_operation_pending(
216        &self,
217        operation_id: B256,
218        provider: &impl Provider,
219    ) -> Result<bool> {
220        match self {
221            TimelockContract::OpsTimelock(timelock_addr) => {
222                Ok(OpsTimelock::new(*timelock_addr, &provider)
223                    .isOperationPending(operation_id)
224                    .call()
225                    .await?)
226            },
227            TimelockContract::SafeExitTimelock(timelock_addr) => {
228                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
229                    .isOperationPending(operation_id)
230                    .call()
231                    .await?)
232            },
233        }
234    }
235
236    pub async fn is_operation_ready(
237        &self,
238        operation_id: B256,
239        provider: &impl Provider,
240    ) -> Result<bool> {
241        match self {
242            TimelockContract::OpsTimelock(timelock_addr) => {
243                Ok(OpsTimelock::new(*timelock_addr, &provider)
244                    .isOperationReady(operation_id)
245                    .call()
246                    .await?)
247            },
248            TimelockContract::SafeExitTimelock(timelock_addr) => {
249                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
250                    .isOperationReady(operation_id)
251                    .call()
252                    .await?)
253            },
254        }
255    }
256
257    pub async fn is_operation_done(
258        &self,
259        operation_id: B256,
260        provider: &impl Provider,
261    ) -> Result<bool> {
262        match self {
263            TimelockContract::OpsTimelock(timelock_addr) => {
264                Ok(OpsTimelock::new(*timelock_addr, &provider)
265                    .isOperationDone(operation_id)
266                    .call()
267                    .await?)
268            },
269            TimelockContract::SafeExitTimelock(timelock_addr) => {
270                Ok(SafeExitTimelock::new(*timelock_addr, &provider)
271                    .isOperationDone(operation_id)
272                    .call()
273                    .await?)
274            },
275        }
276    }
277
278    pub async fn is_operation_canceled(
279        &self,
280        operation_id: B256,
281        provider: &impl Provider,
282    ) -> Result<bool> {
283        let pending = self.is_operation_pending(operation_id, provider).await?;
284        let done = self.is_operation_done(operation_id, provider).await?;
285        // it's canceled if it's not pending and not done
286        Ok(!pending && !done)
287    }
288}
289
290// Derive timelock address from contract type
291// FeeContract, LightClient, StakeTable => OpsTimelock
292// EspToken, RewardClaim => SafeExitTimelock
293pub fn derive_timelock_address_from_contract_type(
294    contract_type: OwnableContract,
295    contracts: &Contracts,
296) -> Result<Address> {
297    let timelock_type = match contract_type {
298        OwnableContract::FeeContractProxy
299        | OwnableContract::LightClientProxy
300        | OwnableContract::StakeTableProxy => Contract::OpsTimelock,
301        OwnableContract::EspTokenProxy | OwnableContract::RewardClaimProxy => {
302            Contract::SafeExitTimelock
303        },
304    };
305
306    contracts.address(timelock_type).ok_or_else(|| {
307        anyhow::anyhow!(
308            "{:?} not found in deployed contracts. Deploy it first or provide it via flag.",
309            timelock_type
310        )
311    })
312}
313
314// Get the timelock for a contract by querying the contract owner or current admin
315pub async fn get_timelock_for_contract(
316    provider: &impl Provider,
317    contract_type: Contract,
318    target_addr: Address,
319) -> Result<TimelockContract> {
320    match contract_type {
321        Contract::FeeContractProxy => Ok(TimelockContract::OpsTimelock(
322            FeeContract::new(target_addr, &provider)
323                .owner()
324                .call()
325                .await?,
326        )),
327        Contract::EspTokenProxy => Ok(TimelockContract::SafeExitTimelock(
328            EspToken::new(target_addr, &provider).owner().call().await?,
329        )),
330        Contract::LightClientProxy => Ok(TimelockContract::OpsTimelock(
331            LightClient::new(target_addr, &provider)
332                .owner()
333                .call()
334                .await?,
335        )),
336        Contract::StakeTableProxy => Ok(TimelockContract::OpsTimelock(
337            StakeTable::new(target_addr, &provider)
338                .owner()
339                .call()
340                .await?,
341        )),
342        Contract::RewardClaimProxy => Ok(TimelockContract::SafeExitTimelock(
343            RewardClaim::new(target_addr, &provider)
344                .currentAdmin()
345                .call()
346                .await?,
347        )),
348        _ => anyhow::bail!(
349            "Invalid contract type for timelock get operation: {}",
350            contract_type
351        ),
352    }
353}
354
355/// Unified function to perform timelock operations (schedule, execute, cancel)
356/// Routes to EOA or multisig based on params
357pub async fn perform_timelock_operation(
358    provider: &impl Provider,
359    contract_type: Contract,
360    operation: TimelockOperationPayload,
361    operation_type: TimelockOperationType,
362    params: TimelockOperationParams,
363) -> Result<B256> {
364    let timelock = get_timelock_for_contract(provider, contract_type, operation.target).await?;
365    // for cancel operations: if operation_id is provided, use it directly;
366    // otherwise, compute it from the operation payload
367    let operation_id =
368        if let (TimelockOperationType::Cancel, Some(id)) = (operation_type, params.operation_id) {
369            id
370        } else {
371            timelock.get_operation_id(&operation, &provider).await?
372        };
373
374    if let Some(multisig_proposer) = params.multisig_proposer {
375        perform_timelock_operation_via_multisig(
376            timelock,
377            operation,
378            operation_type,
379            operation_id,
380            multisig_proposer,
381        )
382        .await
383    } else {
384        perform_timelock_operation_via_eoa(
385            timelock,
386            operation,
387            operation_type,
388            operation_id,
389            provider,
390        )
391        .await
392    }
393}
394
395/// Perform timelock operation via EOA (direct transaction)
396async fn perform_timelock_operation_via_eoa(
397    timelock: TimelockContract,
398    operation: TimelockOperationPayload,
399    operation_type: TimelockOperationType,
400    operation_id: B256,
401    provider: &impl Provider,
402) -> Result<B256> {
403    let receipt = match operation_type {
404        TimelockOperationType::Schedule => timelock.schedule(operation, &provider).await?,
405        TimelockOperationType::Execute => timelock.execute(operation, &provider).await?,
406        TimelockOperationType::Cancel => timelock.cancel(operation_id, &provider).await?,
407    };
408
409    tracing::info!(%receipt.gas_used, %receipt.transaction_hash, "tx mined");
410    if !receipt.inner.is_success() {
411        anyhow::bail!("tx failed: {:?}", receipt);
412    }
413
414    // Verify operation state based on type (with retry for RPC timing)
415    match operation_type {
416        TimelockOperationType::Schedule => {
417            let check_name = format!("Schedule operation {}", operation_id);
418            let is_scheduled = retry_until_true(&check_name, || async {
419                Ok(timelock
420                    .is_operation_pending(operation_id, &provider)
421                    .await?
422                    || timelock.is_operation_ready(operation_id, &provider).await?)
423            })
424            .await?;
425            if !is_scheduled {
426                anyhow::bail!("tx not correctly scheduled: {}", operation_id);
427            }
428            tracing::info!("tx scheduled with id: {}", operation_id);
429        },
430        TimelockOperationType::Execute => {
431            let check_name = format!("Execute operation {}", operation_id);
432            let is_done = retry_until_true(&check_name, || async {
433                timelock.is_operation_done(operation_id, &provider).await
434            })
435            .await?;
436            if !is_done {
437                anyhow::bail!("tx not correctly executed: {}", operation_id);
438            }
439            tracing::info!("tx executed with id: {}", operation_id);
440        },
441        TimelockOperationType::Cancel => {
442            tracing::info!("tx cancelled with id: {}", operation_id);
443        },
444    }
445
446    Ok(operation_id)
447}
448
449/// Perform timelock operation via Safe multisig proposal
450async fn perform_timelock_operation_via_multisig(
451    timelock: TimelockContract,
452    operation: TimelockOperationPayload,
453    operation_type: TimelockOperationType,
454    operation_id: B256,
455    multisig_proposer: Address,
456) -> Result<B256> {
457    let timelock_addr = match timelock {
458        TimelockContract::OpsTimelock(addr) => addr,
459        TimelockContract::SafeExitTimelock(addr) => addr,
460    };
461
462    // Determine function signature and arguments based on operation type
463    let (function_signature, function_args) = match operation_type {
464        TimelockOperationType::Schedule => (
465            "schedule(address target, uint256 value, bytes data, bytes32 predecessor, bytes32 \
466             salt, uint256 delay)",
467            vec![
468                operation.target.to_string(),
469                operation.value.to_string(),
470                operation.data.to_string(),
471                operation.predecessor.to_string(),
472                operation.salt.to_string(),
473                operation.delay.to_string(),
474            ],
475        ),
476        TimelockOperationType::Execute => (
477            "execute(address target, uint256 value, bytes payload, bytes32 predecessor, bytes32 \
478             salt)",
479            vec![
480                operation.target.to_string(),
481                operation.value.to_string(),
482                operation.data.to_string(),
483                operation.predecessor.to_string(),
484                operation.salt.to_string(),
485            ],
486        ),
487        TimelockOperationType::Cancel => ("cancel(bytes32 id)", vec![operation_id.to_string()]),
488    };
489
490    tracing::info!(
491        "Encoding {:?} operation calldata for timelock {}",
492        operation_type,
493        timelock_addr
494    );
495
496    let calldata =
497        encode_generic_calldata(timelock_addr, function_signature, function_args, U256::ZERO)?;
498
499    tracing::info!(
500        "Timelock {:?} operation calldata encoded. Operation ID: {}",
501        operation_type,
502        operation_id
503    );
504    tracing::info!(
505        "Multisig proposer: {}. To: {}, Data: {}",
506        multisig_proposer,
507        calldata.to,
508        calldata.data
509    );
510
511    Ok(operation_id)
512}
513
514/// Parameters for proposing a StakeTable V3 upgrade through a timelock owner.
515#[derive(Clone, Debug)]
516pub struct StakeTableV3TimelockProposalParams {
517    /// Salt for the timelock `schedule`/`execute` operation.
518    pub salt: B256,
519    /// Delay for the timelock `schedule` operation (must be >= the timelock min delay).
520    pub delay: U256,
521}
522
523/// Encoded timelock transactions for a StakeTable V3 upgrade.
524///
525/// `schedule` is submitted first (by a timelock proposer), then after the delay
526/// elapses `execute` is submitted (by a timelock executor).
527pub struct StakeTableV3TimelockProposal {
528    pub schedule: CalldataInfo,
529    pub execute: CalldataInfo,
530    /// Address of the freshly deployed StakeTableV3 implementation.
531    pub v3_impl_addr: Address,
532    /// Timelock address that must submit both txs.
533    pub timelock_addr: Address,
534}
535
536/// Encode timelock `schedule` + `execute` calldata wrapping a StakeTable V3 upgrade.
537///
538/// The inner payload is `proxy.upgradeToAndCall(v3_impl, init_data)` where
539/// `init_data` is `initializeV3()` calldata (or empty if the proxy is already at V3).
540///
541/// This is a pure encoding helper: it does not deploy contracts or make RPC calls,
542/// which keeps it unit-testable without an Anvil instance.
543pub fn encode_stake_table_v3_timelock_proposal(
544    proxy_addr: Address,
545    v3_impl_addr: Address,
546    timelock_addr: Address,
547    init_data: Bytes,
548    params: &StakeTableV3TimelockProposalParams,
549) -> Result<StakeTableV3TimelockProposal> {
550    // Inner call: proxy.upgradeToAndCall(v3_impl, init_data).
551    let upgrade_calldata = encode_upgrade_calldata(proxy_addr, v3_impl_addr, init_data)?;
552
553    let schedule = encode_generic_calldata(
554        timelock_addr,
555        "schedule(address target, uint256 value, bytes data, bytes32 predecessor, bytes32 salt, \
556         uint256 delay)",
557        vec![
558            proxy_addr.to_string(),
559            U256::ZERO.to_string(),
560            upgrade_calldata.data.to_string(),
561            B256::ZERO.to_string(),
562            params.salt.to_string(),
563            params.delay.to_string(),
564        ],
565        U256::ZERO,
566    )?
567    .with_description(format!(
568        "Schedule StakeTable V2 -> V3 upgrade via timelock {timelock_addr:#x} (proxy \
569         {proxy_addr:#x}, impl {v3_impl_addr:#x})"
570    ));
571
572    let execute = encode_generic_calldata(
573        timelock_addr,
574        "execute(address target, uint256 value, bytes payload, bytes32 predecessor, bytes32 salt)",
575        vec![
576            proxy_addr.to_string(),
577            U256::ZERO.to_string(),
578            upgrade_calldata.data.to_string(),
579            B256::ZERO.to_string(),
580            params.salt.to_string(),
581        ],
582        U256::ZERO,
583    )?
584    .with_description(format!(
585        "Execute StakeTable V2 -> V3 upgrade via timelock {timelock_addr:#x} (proxy \
586         {proxy_addr:#x}, impl {v3_impl_addr:#x})"
587    ));
588
589    Ok(StakeTableV3TimelockProposal {
590        schedule,
591        execute,
592        v3_impl_addr,
593        timelock_addr,
594    })
595}
596
597/// Upgrade the stake table proxy to StakeTableV3 through a timelock owner.
598///
599/// Deploys the V3 implementation, then encodes `schedule(...)` and `execute(...)`
600/// timelock calldata. The inner payload is `upgradeToAndCall(v3_impl, initializeV3())`
601/// targeting the stake table proxy. Mirrors `upgrade_stake_table_v3_multisig_owner`
602/// but routes through the timelock instead of a multisig.
603pub async fn upgrade_stake_table_v3_timelock_proposal(
604    provider: impl Provider,
605    contracts: &mut Contracts,
606    params: StakeTableV3TimelockProposalParams,
607) -> Result<StakeTableV3TimelockProposal> {
608    let expected_major_version: u8 = 3;
609
610    tracing::info!("Encoding StakeTableProxy -> StakeTableV3 upgrade via timelock owner");
611    let proxy_addr = contracts
612        .address(Contract::StakeTableProxy)
613        .ok_or_else(|| anyhow!("StakeTableProxy not found, can't upgrade"))?;
614
615    let proxy = StakeTableV3::new(proxy_addr, &provider);
616
617    // The proxy owner must be the OpsTimelock for this flow.
618    let owner_addr = proxy.owner().call().await?;
619    let timelock_addr =
620        derive_timelock_address_from_contract_type(OwnableContract::StakeTableProxy, contracts)?;
621    if owner_addr != timelock_addr {
622        anyhow::bail!(
623            "StakeTableProxy owner {owner_addr:#x} is not the OpsTimelock {timelock_addr:#x}"
624        );
625    }
626
627    // V3 requires V2 as a prerequisite.
628    let version = proxy.getVersion().call().await?;
629    if version.majorVersion < 2 {
630        anyhow::bail!(
631            "StakeTableProxy must be at major version >= 2 to upgrade to V3, found {}",
632            version.majorVersion
633        );
634    }
635
636    let v3_impl_addr = contracts
637        .deploy(
638            Contract::StakeTableV3,
639            StakeTableV3::deploy_builder(&provider),
640        )
641        .await?;
642
643    // If already at V3, skip initializeV3() to avoid the "already initialized" revert.
644    let init_data =
645        if crate::already_initialized(&provider, proxy_addr, expected_major_version).await? {
646            tracing::info!(
647                "StakeTableProxy already initialized at V{expected_major_version}, skipping \
648                 initializeV3()"
649            );
650            Bytes::new()
651        } else {
652            StakeTableV3::new(Address::ZERO, &provider)
653                .initializeV3()
654                .calldata()
655                .to_owned()
656        };
657
658    encode_stake_table_v3_timelock_proposal(
659        proxy_addr,
660        v3_impl_addr,
661        timelock_addr,
662        init_data,
663        &params,
664    )
665}
666
667#[cfg(test)]
668mod tests {
669    use alloy::{
670        primitives::{Address, U256},
671        sol_types::SolCall,
672    };
673    use hotshot_contract_adapter::sol_types::OpsTimelock;
674
675    use super::*;
676
677    /// Verify that `encode_stake_table_v3_timelock_proposal` produces non-empty
678    /// `schedule` + `execute` calldata targeting the timelock, with the inner
679    /// payload matching `proxy.upgradeToAndCall(v3_impl, initializeV3())`.
680    #[test]
681    fn test_encode_stake_table_v3_timelock_proposal() -> Result<()> {
682        let proxy_addr = Address::random();
683        let v3_impl_addr = Address::random();
684        let timelock_addr = Address::random();
685        let salt = B256::repeat_byte(0x42);
686        let delay = U256::from(3600);
687        let init_data: Bytes = StakeTableV3::initializeV3Call {}.abi_encode().into();
688
689        let proposal = encode_stake_table_v3_timelock_proposal(
690            proxy_addr,
691            v3_impl_addr,
692            timelock_addr,
693            init_data.clone(),
694            &StakeTableV3TimelockProposalParams { salt, delay },
695        )?;
696
697        assert_eq!(proposal.timelock_addr, timelock_addr);
698        assert_eq!(proposal.v3_impl_addr, v3_impl_addr);
699        assert_eq!(proposal.schedule.to, timelock_addr);
700        assert_eq!(proposal.execute.to, timelock_addr);
701        assert!(proposal.schedule.data.len() > 4);
702        assert!(proposal.execute.data.len() > 4);
703
704        // The inner payload the timelock will run is
705        // `proxy.upgradeToAndCall(v3_impl, initializeV3())`.
706        let expected_inner: Bytes = StakeTableV3::upgradeToAndCallCall {
707            newImplementation: v3_impl_addr,
708            data: init_data,
709        }
710        .abi_encode()
711        .into();
712
713        let expected_schedule = OpsTimelock::scheduleCall {
714            target: proxy_addr,
715            value: U256::ZERO,
716            data: expected_inner.clone(),
717            predecessor: B256::ZERO,
718            salt,
719            delay,
720        }
721        .abi_encode();
722        assert_eq!(proposal.schedule.data.to_vec(), expected_schedule);
723
724        let expected_execute = OpsTimelock::executeCall {
725            target: proxy_addr,
726            value: U256::ZERO,
727            payload: expected_inner,
728            predecessor: B256::ZERO,
729            salt,
730        }
731        .abi_encode();
732        assert_eq!(proposal.execute.data.to_vec(), expected_execute);
733
734        Ok(())
735    }
736
737    /// When the proxy is already at V3, the inner `upgradeToAndCall` carries
738    /// empty init data so we don't re-run `initializeV3()`.
739    #[test]
740    fn test_encode_stake_table_v3_timelock_proposal_already_initialized() -> Result<()> {
741        let proxy_addr = Address::random();
742        let v3_impl_addr = Address::random();
743        let timelock_addr = Address::random();
744        let salt = B256::ZERO;
745        let delay = U256::ZERO;
746
747        let proposal = encode_stake_table_v3_timelock_proposal(
748            proxy_addr,
749            v3_impl_addr,
750            timelock_addr,
751            Bytes::new(),
752            &StakeTableV3TimelockProposalParams { salt, delay },
753        )?;
754
755        let expected_inner: Bytes = StakeTableV3::upgradeToAndCallCall {
756            newImplementation: v3_impl_addr,
757            data: Bytes::new(),
758        }
759        .abi_encode()
760        .into();
761
762        let expected_schedule = OpsTimelock::scheduleCall {
763            target: proxy_addr,
764            value: U256::ZERO,
765            data: expected_inner,
766            predecessor: B256::ZERO,
767            salt,
768            delay,
769        }
770        .abi_encode();
771        assert_eq!(proposal.schedule.data.to_vec(), expected_schedule);
772
773        Ok(())
774    }
775}