espresso_contract_deployer/
builder.rs

1//! builder pattern for
2
3use std::path::PathBuf;
4
5use alloy::{
6    hex::FromHex,
7    primitives::{Address, B256, Bytes, U256},
8    providers::{Provider, WalletProvider},
9};
10use anyhow::{Context, Result};
11use derive_builder::Builder;
12use espresso_types::v0_1::L1Client;
13use hotshot_contract_adapter::sol_types::{LightClientStateSol, StakeTableStateSol};
14use url::Url;
15
16use crate::{
17    Contract, Contracts, OwnableContract, encode_function_call,
18    output::output_safe_tx_builder,
19    proposals::{
20        multisig::{
21            LightClientV2UpgradeParams, MultisigOwnerCheck, StakeTableV2UpgradeParams,
22            TransferOwnershipParams, encode_generic_calldata,
23            transfer_ownership_from_multisig_to_timelock, upgrade_esp_token_v2_multisig_owner,
24            upgrade_fee_contract_multisig_owner, upgrade_light_client_v2_multisig_owner,
25            upgrade_light_client_v3_multisig_owner, upgrade_stake_table_v2_multisig_owner,
26        },
27        timelock::{
28            TimelockOperationParams, TimelockOperationPayload, TimelockOperationType,
29            derive_timelock_address_from_contract_type, perform_timelock_operation,
30        },
31    },
32};
33
34/// Convenient handler that builds all the input arguments ready to be deployed.
35/// - `deployer`: deployer's wallet provider
36/// - `rpc_url`: RPC URL for the L1 network
37/// - `token_recipient`: initial token holder, same as deployer if None.
38/// - `mock_light_client`: flag to indicate whether deploying mocked contract
39/// - `use_multisig`: flag to indicate whether to use multisig for upgrades
40/// - `genesis_lc_state`: Genesis light client state
41/// - `genesis_st_state`: Genesis stake table state
42/// - `permissioned_prover`: permissioned light client prover address
43/// - `blocks_per_epoch`: epoch length in block height
44/// - `epoch_start_block`: block height for the first *activated* epoch
45/// - `exit_escrow_period`: exit escrow period for stake table (in seconds)
46/// - `multisig`: new owner/multisig that owns all the proxy contracts
47/// - `multisig_pauser`: multisig address that has the pauser role
48/// - `initial_token_supply`: initial token supply for the token contract
49/// - `token_name`: name of the token
50/// - `token_symbol`: symbol of the token
51/// - `ops_timelock_admin`: admin address for the ops timelock
52/// - `ops_timelock_delay`: delay for the ops timelock
53/// - `ops_timelock_executors`: executors for the ops timelock
54/// - `ops_timelock_proposers`: proposers for the ops timelock
55/// - `safe_exit_timelock_admin`: admin address for the safe exit timelock
56/// - `safe_exit_timelock_delay`: delay for the safe exit timelock
57/// - `safe_exit_timelock_executors`: executors for the safe exit timelock
58/// - `safe_exit_timelock_proposers`: proposers for the safe exit timelock
59/// - `timelock_operation_type`: type of the timelock operation
60/// - `target_contract`: target contract for the contract operations
61/// - `timelock_operation_value`: value for the timelock operation
62/// - `timelock_operation_delay`: delay for the timelock operation
63/// - `timelock_operation_function_signature`: function signature for the timelock operation
64/// - `timelock_operation_function_values`: function values for the timelock operation
65/// - `timelock_operation_salt`: salt for the timelock operation
66/// - `use_timelock_owner`: flag to indicate whether to transfer ownership to the timelock owner
67/// - `timelock_address`: address of the timelock contract
68#[derive(Builder, Clone)]
69#[builder(setter(strip_option))]
70pub struct DeployerArgs<P: Provider + WalletProvider> {
71    deployer: P,
72    rpc_url: Url,
73    #[builder(default)]
74    token_recipient: Option<Address>,
75    #[builder(default)]
76    mock_light_client: bool,
77    #[builder(default)]
78    use_multisig: bool,
79    #[builder(default)]
80    genesis_lc_state: Option<LightClientStateSol>,
81    #[builder(default)]
82    genesis_st_state: Option<StakeTableStateSol>,
83    #[builder(default)]
84    permissioned_prover: Option<Address>,
85    #[builder(default)]
86    blocks_per_epoch: Option<u64>,
87    #[builder(default)]
88    epoch_start_block: Option<u64>,
89    #[builder(default)]
90    exit_escrow_period: Option<U256>,
91    #[builder(default)]
92    multisig: Option<Address>,
93    #[builder(default)]
94    multisig_pauser: Option<Address>,
95    #[builder(default)]
96    initial_token_supply: Option<U256>,
97    #[builder(default)]
98    token_name: Option<String>,
99    #[builder(default)]
100    token_symbol: Option<String>,
101    #[builder(default)]
102    ops_timelock_admin: Option<Address>,
103    #[builder(default)]
104    ops_timelock_delay: Option<U256>,
105    #[builder(default)]
106    ops_timelock_executors: Option<Vec<Address>>,
107    #[builder(default)]
108    ops_timelock_proposers: Option<Vec<Address>>,
109    #[builder(default)]
110    safe_exit_timelock_admin: Option<Address>,
111    #[builder(default)]
112    safe_exit_timelock_delay: Option<U256>,
113    #[builder(default)]
114    safe_exit_timelock_executors: Option<Vec<Address>>,
115    #[builder(default)]
116    safe_exit_timelock_proposers: Option<Vec<Address>>,
117    #[builder(default)]
118    timelock_operation_type: Option<TimelockOperationType>,
119    #[builder(default)]
120    target_contract: Option<OwnableContract>,
121    #[builder(default)]
122    timelock_operation_value: Option<U256>,
123    #[builder(default)]
124    timelock_operation_delay: Option<U256>,
125    #[builder(default)]
126    timelock_operation_function_signature: Option<String>,
127    #[builder(default)]
128    timelock_operation_function_values: Option<Vec<String>>,
129    #[builder(default)]
130    timelock_operation_salt: Option<String>,
131    #[builder(default)]
132    use_timelock_owner: Option<bool>,
133    #[builder(default)]
134    transfer_ownership_from_eoa: Option<bool>,
135    #[builder(default)]
136    transfer_ownership_new_owner: Option<Address>,
137    #[builder(default)]
138    timelock_operation_id: Option<String>,
139    #[builder(default)]
140    multisig_transaction_target: Option<Address>,
141    #[builder(default)]
142    multisig_transaction_function_signature: Option<String>,
143    #[builder(default)]
144    multisig_transaction_function_args: Option<Vec<String>>,
145    #[builder(default)]
146    multisig_transaction_value: Option<String>,
147    #[builder(default)]
148    output_path: Option<PathBuf>,
149    #[builder(default)]
150    chain_id: u64,
151}
152
153impl<P: Provider + WalletProvider> DeployerArgs<P> {
154    /// deploy target contracts
155    pub async fn deploy(&self, contracts: &mut Contracts, target: Contract) -> Result<()> {
156        let provider = &self.deployer;
157        let admin = provider.default_signer_address();
158        match target {
159            Contract::FeeContractProxy => {
160                if contracts.address(Contract::FeeContractProxy).is_some() {
161                    // Upgrade path
162                    let use_multisig = self.use_multisig;
163
164                    tracing::info!(?use_multisig, "Upgrading FeeContract to V1.0.1");
165                    if use_multisig {
166                        let calldata = upgrade_fee_contract_multisig_owner(
167                            provider,
168                            contracts,
169                            MultisigOwnerCheck::RequireContract,
170                        )
171                        .await?
172                        .with_description("Upgrade FeeContract to V1.0.1".to_string());
173                        output_safe_tx_builder(
174                            &calldata,
175                            self.output_path.as_deref(),
176                            self.chain_id,
177                        )?;
178                    } else {
179                        crate::upgrade_fee_v1(provider, contracts).await?;
180                    }
181                } else {
182                    // Deploy path
183                    let addr = crate::deploy_fee_contract_proxy(provider, contracts, admin).await?;
184
185                    if let Some(use_timelock_owner) = self.use_timelock_owner {
186                        // FeeContract uses OpsTimelock because:
187                        // - It handles critical fee collection and distribution logic
188                        // - May require emergency updates for security or functionality
189                        // - OpsTimelock provides a shorter delay for critical operations
190                        tracing::info!(
191                            "Transferring ownership to OpsTimelock: {:?}",
192                            use_timelock_owner
193                        );
194                        // deployer is the timelock owner
195                        if use_timelock_owner {
196                            let timelock_addr = derive_timelock_address_from_contract_type(
197                                OwnableContract::FeeContractProxy,
198                                contracts,
199                            )?;
200                            crate::transfer_ownership(
201                                provider,
202                                Contract::FeeContractProxy,
203                                addr,
204                                timelock_addr,
205                            )
206                            .await?;
207                        }
208                    } else if let Some(multisig) = self.multisig {
209                        tracing::info!("Transferring ownership to multisig: {:?}", multisig);
210                        crate::transfer_ownership(
211                            provider,
212                            Contract::FeeContractProxy,
213                            addr,
214                            multisig,
215                        )
216                        .await?;
217                    }
218                }
219            },
220            Contract::EspTokenProxy => {
221                let token_recipient = self.token_recipient.unwrap_or(admin);
222                let token_name = self
223                    .token_name
224                    .clone()
225                    .context("Token name must be set when deploying esp token")?;
226                let token_symbol = self
227                    .token_symbol
228                    .clone()
229                    .context("Token symbol must be set when deploying esp token")?;
230                let initial_supply = self
231                    .initial_token_supply
232                    .context("Initial token supply must be set when deploying esp token")?;
233                crate::deploy_token_proxy(
234                    provider,
235                    contracts,
236                    admin,
237                    token_recipient,
238                    initial_supply,
239                    &token_name,
240                    &token_symbol,
241                )
242                .await?;
243
244                // NOTE: we don't transfer ownership to multisig, we only do so after V2 upgrade
245            },
246            Contract::EspTokenV2 => {
247                let use_multisig = self.use_multisig;
248
249                if use_multisig {
250                    let calldata = upgrade_esp_token_v2_multisig_owner(
251                        provider,
252                        contracts,
253                        MultisigOwnerCheck::RequireContract,
254                    )
255                    .await?
256                    .with_description("Upgrade EspToken to V2".to_string());
257                    output_safe_tx_builder(&calldata, self.output_path.as_deref(), self.chain_id)?;
258                } else {
259                    crate::upgrade_esp_token_v2(provider, contracts).await?;
260                    let addr = contracts
261                        .address(Contract::EspTokenProxy)
262                        .expect("fail to get EspTokenProxy address");
263
264                    if let Some(use_timelock_owner) = self.use_timelock_owner {
265                        // deployer is the timelock owner
266                        if use_timelock_owner {
267                            // EspToken uses SafeExitTimelock (not OpsTimelock) because:
268                            // - It's a simple ERC20 token with minimal upgrade complexity
269                            // - No emergency updates are expected for token functionality
270                            // - SafeExitTimelock provides sufficient security for token operations
271                            tracing::info!("Transferring ownership to SafeExitTimelock");
272                            let timelock_addr = derive_timelock_address_from_contract_type(
273                                OwnableContract::EspTokenProxy,
274                                contracts,
275                            )?;
276                            crate::transfer_ownership(
277                                provider,
278                                Contract::EspTokenProxy,
279                                addr,
280                                timelock_addr,
281                            )
282                            .await?;
283                        }
284                    } else if let Some(multisig) = self.multisig {
285                        let token_proxy = contracts
286                            .address(Contract::EspTokenProxy)
287                            .expect("fail to get EspTokenProxy address");
288                        crate::transfer_ownership(
289                            provider,
290                            Contract::EspTokenProxy,
291                            token_proxy,
292                            multisig,
293                        )
294                        .await?;
295                    }
296                }
297            },
298            Contract::LightClientProxy => {
299                assert!(
300                    self.genesis_lc_state.is_some(),
301                    "forget to specify genesis_lc_state()"
302                );
303                assert!(
304                    self.genesis_st_state.is_some(),
305                    "forget to specify genesis_st_state()"
306                );
307                crate::deploy_light_client_proxy(
308                    provider,
309                    contracts,
310                    self.mock_light_client,
311                    self.genesis_lc_state.clone().unwrap(),
312                    self.genesis_st_state.clone().unwrap(),
313                    admin,
314                    self.permissioned_prover,
315                )
316                .await?;
317                // NOTE: we don't transfer ownership to multisig, we only do so after V2 upgrade
318            },
319            Contract::LightClientV2 => {
320                assert!(
321                    self.blocks_per_epoch.is_some(),
322                    "forgot to specify blocks_per_epoch()"
323                );
324                assert!(
325                    self.epoch_start_block.is_some(),
326                    "forgot to specify epoch_start_block()"
327                );
328
329                let use_mock = self.mock_light_client;
330                let use_multisig = self.use_multisig;
331                let mut blocks_per_epoch = self.blocks_per_epoch.unwrap();
332                let epoch_start_block = self.epoch_start_block.unwrap();
333
334                // TEST-ONLY: if this config is not yet set, we use u64::MAX
335                // to avoid contract complaining about invalid zero-valued blocks_per_epoch.
336                // This value will allow tests to proceed with realistic epoch behavior.
337                // TODO: remove this once we have a proper way to set blocks_per_epoch
338                if use_mock && blocks_per_epoch == 0 {
339                    blocks_per_epoch = u64::MAX;
340                }
341                tracing::info!(%blocks_per_epoch, ?use_multisig, "Upgrading LightClientV2 with ");
342                if use_multisig {
343                    let calldata = upgrade_light_client_v2_multisig_owner(
344                        provider,
345                        contracts,
346                        LightClientV2UpgradeParams {
347                            blocks_per_epoch,
348                            epoch_start_block,
349                        },
350                        use_mock,
351                        MultisigOwnerCheck::RequireContract,
352                    )
353                    .await?
354                    .with_description("Upgrade LightClient to V2".to_string());
355                    output_safe_tx_builder(&calldata, self.output_path.as_deref(), self.chain_id)?;
356                } else {
357                    crate::upgrade_light_client_v2(
358                        provider,
359                        contracts,
360                        use_mock,
361                        blocks_per_epoch,
362                        epoch_start_block,
363                    )
364                    .await?;
365                    // NOTE: we don't transfer ownership to multisig, we only do so after V3 upgrade
366                }
367            },
368            Contract::LightClientV3 => {
369                let use_mock = self.mock_light_client;
370                let use_multisig = self.use_multisig;
371
372                tracing::info!(?use_multisig, "Upgrading LightClientV3 with ");
373                if use_multisig {
374                    let calldata = upgrade_light_client_v3_multisig_owner(
375                        provider,
376                        contracts,
377                        use_mock,
378                        MultisigOwnerCheck::RequireContract,
379                    )
380                    .await?
381                    .with_description("Upgrade LightClient to V3".to_string());
382                    output_safe_tx_builder(&calldata, self.output_path.as_deref(), self.chain_id)?;
383                } else {
384                    crate::upgrade_light_client_v3(provider, contracts, use_mock).await?;
385
386                    // Transfer ownership to Timelook or MultiSig
387                    let addr = contracts
388                        .address(Contract::LightClientProxy)
389                        .expect("fail to get LightClientProxy address");
390
391                    if let Some(use_timelock_owner) = self.use_timelock_owner {
392                        // LightClient uses OpsTimelock because:
393                        // - It's a critical security component for the network
394                        // - May require emergency updates for security vulnerabilities
395                        // - OpsTimelock provides a shorter delay for critical operations
396                        tracing::info!("Transferring ownership to OpsTimelock");
397                        // deployer is the timelock owner
398                        if use_timelock_owner {
399                            let timelock_addr = derive_timelock_address_from_contract_type(
400                                OwnableContract::LightClientProxy,
401                                contracts,
402                            )?;
403                            crate::transfer_ownership(
404                                provider,
405                                Contract::LightClientProxy,
406                                addr,
407                                timelock_addr,
408                            )
409                            .await?;
410                        }
411                    } else if let Some(multisig) = self.multisig {
412                        crate::transfer_ownership(
413                            provider,
414                            Contract::LightClientProxy,
415                            addr,
416                            multisig,
417                        )
418                        .await?;
419                    }
420                }
421            },
422            Contract::StakeTableProxy => {
423                let token_addr = contracts
424                    .address(Contract::EspTokenProxy)
425                    .context("no ESP token proxy address")?;
426                let lc_addr = contracts
427                    .address(Contract::LightClientProxy)
428                    .context("no LightClient proxy address")?;
429                let escrow_period = self
430                    .exit_escrow_period
431                    .unwrap_or(U256::from(crate::DEFAULT_EXIT_ESCROW_PERIOD_SECONDS));
432                crate::deploy_stake_table_proxy(
433                    provider,
434                    contracts,
435                    token_addr,
436                    lc_addr,
437                    escrow_period,
438                    admin,
439                )
440                .await?;
441
442                // NOTE: we don't transfer ownership to multisig, we only do so after V2 upgrade
443            },
444            Contract::StakeTableV2 => {
445                let use_multisig = self.use_multisig;
446                // Default to deployer address if pauser not explicitly set (for local demos)
447                let multisig_pauser = self.multisig_pauser.unwrap_or(admin);
448                let l1_client = L1Client::new(vec![self.rpc_url.clone()])?;
449                tracing::info!(?use_multisig, "Upgrading to StakeTableV2 with ");
450                if use_multisig {
451                    let calldata = upgrade_stake_table_v2_multisig_owner(
452                        provider,
453                        l1_client,
454                        contracts,
455                        StakeTableV2UpgradeParams {
456                            multisig_address: self.multisig.context(
457                                "Multisig address must be set when upgrading to --use-multisig \
458                                 flag is present",
459                            )?,
460                            pauser: multisig_pauser,
461                        },
462                        MultisigOwnerCheck::RequireContract,
463                    )
464                    .await?
465                    .with_description("Upgrade StakeTable to V2".to_string());
466                    output_safe_tx_builder(&calldata, self.output_path.as_deref(), self.chain_id)?;
467                } else {
468                    // Pick admin from config. StakeTable uses OpsTimelock for faster
469                    // emergency updates since it handles critical staking ops.
470                    let admin = match self.use_timelock_owner {
471                        Some(true) => derive_timelock_address_from_contract_type(
472                            OwnableContract::StakeTableProxy,
473                            contracts,
474                        )?,
475                        Some(false) => admin, // deployer
476                        None => {
477                            if let Some(multisig) = self.multisig {
478                                multisig
479                            } else {
480                                admin // deployer
481                            }
482                        },
483                    };
484
485                    tracing::info!("Upgrading StakeTableV2 with admin: {:?}", admin);
486                    crate::upgrade_stake_table_v2(
487                        provider,
488                        l1_client,
489                        contracts,
490                        multisig_pauser,
491                        admin,
492                    )
493                    .await?;
494
495                    // initializeV2() handles ownership transfer, so no separate call needed
496                }
497            },
498            Contract::OpsTimelock => {
499                let ops_timelock_delay = self
500                    .ops_timelock_delay
501                    .context("Ops Timelock delay must be set when deploying Ops Timelock")?;
502                let ops_timelock_proposers = self
503                    .ops_timelock_proposers
504                    .clone()
505                    .context("Ops Timelock proposers must be set when deploying Ops Timelock")?;
506                let ops_timelock_executors = self
507                    .ops_timelock_executors
508                    .clone()
509                    .context("Ops Timelock executors must be set when deploying Ops Timelock")?;
510                let ops_timelock_admin = self
511                    .ops_timelock_admin
512                    .context("Ops Timelock admin must be set when deploying Ops Timelock")?;
513                crate::deploy_ops_timelock(
514                    provider,
515                    contracts,
516                    ops_timelock_delay,
517                    ops_timelock_proposers,
518                    ops_timelock_executors,
519                    ops_timelock_admin,
520                )
521                .await?;
522            },
523            Contract::SafeExitTimelock => {
524                let safe_exit_timelock_delay = self.safe_exit_timelock_delay.context(
525                    "SafeExitTimelock delay must be set when deploying SafeExitTimelock",
526                )?;
527                let safe_exit_timelock_proposers =
528                    self.safe_exit_timelock_proposers.clone().context(
529                        "SafeExitTimelock proposers must be set when deploying SafeExitTimelock",
530                    )?;
531                let safe_exit_timelock_executors =
532                    self.safe_exit_timelock_executors.clone().context(
533                        "SafeExitTimelock executors must be set when deploying SafeExitTimelock",
534                    )?;
535                let safe_exit_timelock_admin = self.safe_exit_timelock_admin.context(
536                    "SafeExitTimelock admin must be set when deploying SafeExitTimelock",
537                )?;
538                crate::deploy_safe_exit_timelock(
539                    provider,
540                    contracts,
541                    safe_exit_timelock_delay,
542                    safe_exit_timelock_proposers,
543                    safe_exit_timelock_executors,
544                    safe_exit_timelock_admin,
545                )
546                .await?;
547            },
548            Contract::RewardClaimProxy => {
549                let token_addr = contracts
550                    .address(Contract::EspTokenProxy)
551                    .context("no ESP token proxy address")?;
552                let lc_addr = contracts
553                    .address(Contract::LightClientProxy)
554                    .context("no LightClient proxy address")?;
555                // RewardClaimProxy only needs one pauser
556                // Default to deployer address if pauser not explicitly set (for local demos)
557                let deployer_addr = provider.default_signer_address();
558                let pauser = self.multisig_pauser.unwrap_or(deployer_addr);
559
560                // RewardClaim uses SafeExitTimelock (longer delay) since it can mint tokens
561                // and users need time to react to upgrades. Can be paused in emergencies.
562                let admin = match self.use_timelock_owner {
563                    Some(true) => derive_timelock_address_from_contract_type(
564                        OwnableContract::RewardClaimProxy,
565                        contracts,
566                    )?,
567                    Some(false) => admin, // deployer
568                    None => {
569                        if let Some(multisig) = self.multisig {
570                            multisig
571                        } else {
572                            admin // deployer
573                        }
574                    },
575                };
576
577                tracing::info!("Deploying RewardClaimProxy with admin: {:?}", admin);
578                crate::deploy_reward_claim_proxy(
579                    provider, contracts, token_addr, lc_addr, admin, pauser,
580                )
581                .await?;
582
583                // RewardClaim uses AccessControl only (no Ownable). Admin is set in initialize(),
584                // not via separate transfer_ownership() call.
585            },
586            _ => {
587                panic!("Deploying {target} not supported.");
588            },
589        }
590        Ok(())
591    }
592
593    /// Deploy all contracts up to and including stake table v1
594    pub async fn deploy_to_stake_table_v1(&self, contracts: &mut Contracts) -> Result<()> {
595        // Deploy timelocks first so they can be used as owners for other contracts
596        self.deploy(contracts, Contract::OpsTimelock).await?;
597        self.deploy(contracts, Contract::SafeExitTimelock).await?;
598
599        // Then deploy other contracts
600        self.deploy(contracts, Contract::FeeContractProxy).await?;
601        self.deploy(contracts, Contract::EspTokenProxy).await?;
602        self.deploy(contracts, Contract::LightClientProxy).await?;
603        self.deploy(contracts, Contract::LightClientV2).await?;
604        self.deploy(contracts, Contract::StakeTableProxy).await?;
605        Ok(())
606    }
607
608    /// Deploy all contracts
609    pub async fn deploy_all(&self, contracts: &mut Contracts) -> Result<()> {
610        self.deploy_to_stake_table_v1(contracts).await?;
611        self.deploy(contracts, Contract::StakeTableV2).await?;
612        self.deploy(contracts, Contract::LightClientV3).await?;
613        self.deploy(contracts, Contract::RewardClaimProxy).await?;
614        self.deploy(contracts, Contract::EspTokenV2).await?;
615        Ok(())
616    }
617
618    // Perform a timelock operation
619    ///
620    /// This function can perform timelock operations via two paths:
621    /// - **Multisig path**: If `multisig` field from DeployerArgs is set, the operation will be proposed via Safe multisig
622    /// - **EOA path**: If `multisig` field from DeployerArgs is not set, the operation will be executed directly via EOA (useful for tests/local development)
623    ///
624    /// Parameters:
625    /// - `contracts`: ref to deployed contracts
626    ///
627    pub async fn propose_timelock_operation_for_contract(
628        &self,
629        contracts: &mut Contracts,
630    ) -> Result<()> {
631        let timelock_operation_type = self
632            .timelock_operation_type
633            .context("Timelock operation type not found")?;
634        let target_contract = self.target_contract.context("Timelock target not found")?;
635        let contract_type: Contract = target_contract.into();
636        let target_addr = contracts
637            .address(contract_type)
638            .context(format!("{:?} address not found", contract_type))?;
639
640        let (timelock_operation_data, operation_id) = if timelock_operation_type
641            == TimelockOperationType::Cancel
642            && self.timelock_operation_id.is_some()
643        {
644            // Cancel operation with explicit operation_id - use minimal payload
645            let op_id_str = self
646                .timelock_operation_id
647                .as_ref()
648                .context("Operation ID not found")?;
649            let op_id = if let Some(stripped) = op_id_str.strip_prefix("0x") {
650                B256::from_hex(stripped).context("Invalid operation ID hex format")?
651            } else {
652                B256::from_hex(op_id_str).context("Invalid operation ID hex format")?
653            };
654
655            let minimal_payload = TimelockOperationPayload {
656                target: target_addr,
657                value: U256::ZERO,
658                data: Bytes::new(),
659                predecessor: B256::ZERO,
660                salt: B256::ZERO,
661                delay: U256::ZERO,
662            };
663            (minimal_payload, Some(op_id))
664        } else {
665            // Schedule or Execute operation - we need full operation details
666            let value = self
667                .timelock_operation_value
668                .context("Timelock operation value not found")?;
669            let function_signature = self
670                .timelock_operation_function_signature
671                .as_ref()
672                .context("Timelock operation function signature not found")?;
673            let function_values = self
674                .timelock_operation_function_values
675                .clone()
676                .context("Timelock operation function values not found")?;
677            let salt = self
678                .timelock_operation_salt
679                .clone()
680                .context("Timelock operation salt not found")?;
681            let delay = self
682                .timelock_operation_delay
683                .context("Timelock operation delay not found")?;
684
685            let function_calldata =
686                encode_function_call(function_signature, function_values.clone())
687                    .context("Failed to encode function data")?;
688
689            // Parse salt from string to B256
690            let salt_trimmed = salt.trim();
691            let salt_bytes = if salt_trimmed.is_empty() || salt_trimmed == "0x" {
692                B256::ZERO
693            } else {
694                let hex_str = salt_trimmed.strip_prefix("0x").unwrap_or(salt_trimmed);
695                B256::from_hex(hex_str).context("Invalid salt hex format")?
696            };
697
698            let operation = TimelockOperationPayload {
699                target: target_addr,
700                value,
701                data: function_calldata,
702                predecessor: B256::ZERO, // Default to no predecessor
703                salt: salt_bytes,
704                delay,
705            };
706            (operation, None)
707        };
708
709        let params = if let Some(multisig_proposer) = self.multisig {
710            // Multisig path
711            TimelockOperationParams {
712                multisig_proposer: Some(multisig_proposer),
713                operation_id,
714                dry_run: false,
715            }
716        } else {
717            // EOA path (for tests/local development)
718            TimelockOperationParams {
719                multisig_proposer: None,
720                operation_id,
721                dry_run: false,
722            }
723        };
724
725        perform_timelock_operation(
726            &self.deployer,
727            contract_type,
728            timelock_operation_data,
729            timelock_operation_type,
730            params,
731        )
732        .await?;
733
734        Ok(())
735    }
736
737    /// Encode ownership transfer from multisig to timelock as calldata
738    pub async fn encode_transfer_ownership_to_timelock(
739        &self,
740        contracts: &mut Contracts,
741    ) -> Result<()> {
742        // Validate multisig is set (even though we now encode calldata rather than submit to Safe)
743        let _multisig = self.multisig.expect(
744            "Multisig address must be set when proposing ownership transfer. Use \
745             --multisig-address or ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS",
746        );
747        let ownable_contract = self.target_contract.ok_or_else(|| {
748            anyhow::anyhow!(
749                "Must provide target_contract when using \
750                 --propose-transfer-ownership-to-timelock. Use --target-contract or \
751                 ESPRESSO_TARGET_CONTRACT"
752            )
753        })?;
754
755        let timelock_address =
756            derive_timelock_address_from_contract_type(ownable_contract, contracts)?;
757
758        if !crate::is_contract(&self.deployer, timelock_address).await? {
759            anyhow::bail!(
760                "Timelock address is not a contract (expected timelock at {timelock_address:#x})"
761            );
762        }
763
764        let contract: Contract = ownable_contract.into();
765        tracing::info!(
766            "Encoding transfer of ownership from multisig to timelock for {:?} (timelock: {:?})",
767            contract,
768            timelock_address
769        );
770        let calldata = transfer_ownership_from_multisig_to_timelock(
771            contracts,
772            contract,
773            TransferOwnershipParams {
774                new_owner: timelock_address,
775            },
776        )?
777        .with_description(format!(
778            "Transfer {} ownership to timelock {timelock_address}",
779            contract
780        ));
781        output_safe_tx_builder(&calldata, self.output_path.as_deref(), self.chain_id)?;
782        tracing::info!("Successfully encoded ownership transfer for {}", contract);
783        Ok(())
784    }
785
786    /// Transfer ownership from EOA to new owner
787    pub async fn transfer_ownership_from_eoa(&self, contracts: &mut Contracts) -> Result<()> {
788        let transfer_ownership_from_eoa = self
789            .transfer_ownership_from_eoa
790            .ok_or_else(|| anyhow::anyhow!("transfer_ownership_from_eoa flag not set"))?;
791
792        if !transfer_ownership_from_eoa {
793            return Ok(());
794        }
795
796        let ownable_contract = self.target_contract.ok_or_else(|| {
797            anyhow::anyhow!("Must provide target_contract when using transfer_ownership_from_eoa")
798        })?;
799        let new_owner = self.transfer_ownership_new_owner.ok_or_else(|| {
800            anyhow::anyhow!(
801                "Must provide transfer_ownership_new_owner when using transfer_ownership_from_eoa"
802            )
803        })?;
804
805        let contract_type: Contract = ownable_contract.into();
806        let contract_address = contracts.address(contract_type).ok_or_else(|| {
807            anyhow::anyhow!(
808                "Contract {:?} not found in deployed contracts",
809                contract_type
810            )
811        })?;
812
813        // RewardClaim uses AccessControl instead of Ownable, so we need to grant the admin role
814        // instead of transferring ownership
815        let receipt = if contract_type == Contract::RewardClaimProxy {
816            tracing::info!(
817                "Granting DEFAULT_ADMIN_ROLE for {:?} to {} (RewardClaim uses AccessControl, not \
818                 Ownable)",
819                contract_type,
820                new_owner
821            );
822            crate::grant_admin_role(&self.deployer, contract_type, contract_address, new_owner)
823                .await?
824        } else {
825            tracing::info!(
826                "Transferring ownership of {:?} from EOA to {}",
827                contract_type,
828                new_owner
829            );
830            crate::transfer_ownership(&self.deployer, contract_type, contract_address, new_owner)
831                .await?
832        };
833
834        tracing::info!(
835            "Successfully transferred admin control of {:?} to {}. Transaction: {}",
836            contract_type,
837            new_owner,
838            receipt.transaction_hash
839        );
840
841        Ok(())
842    }
843
844    /// Encode a multisig transaction as calldata and output it
845    pub async fn encode_multisig_transaction(&self) -> Result<()> {
846        let target = self
847            .multisig_transaction_target
848            .context("Multisig transaction target address not found")?;
849        let function_signature = self
850            .multisig_transaction_function_signature
851            .as_ref()
852            .context("Multisig transaction function signature not found")?;
853        let function_args = self
854            .multisig_transaction_function_args
855            .clone()
856            .unwrap_or_default();
857        let value: U256 = self
858            .multisig_transaction_value
859            .as_deref()
860            .unwrap_or("0")
861            .parse()
862            .context("Failed to parse multisig transaction value as U256")?;
863
864        let calldata = encode_generic_calldata(target, function_signature, function_args, value)?
865            .with_description(format!("Call {} on {target}", function_signature));
866        output_safe_tx_builder(&calldata, self.output_path.as_deref(), self.chain_id)?;
867
868        Ok(())
869    }
870}