Skip to main content

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