Skip to main content

espresso_contract_deployer/
builder.rs

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