diff --git a/contracts/rust/deployer/README.md b/contracts/rust/deployer/README.md index 700d551afa7..eb9a35789a2 100644 --- a/contracts/rust/deployer/README.md +++ b/contracts/rust/deployer/README.md @@ -53,7 +53,7 @@ unset ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS unset ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS # Execute the deployment command -RUST_LOG=info cargo run --bin deploy -- --deploy-fee --rpc-url=$RPC_URL +RUST_LOG=info cargo run --bin deploy -- --deploy-fee-v1 --rpc-url=$RPC_URL ``` ### Transfer Ownership with Cargo @@ -130,7 +130,7 @@ set +a unset ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS # Deploy the fee contract with a multisig owner (requires ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS to be set which occurs in the step above) -RUST_LOG=info cargo run --bin deploy -- --deploy-fee --rpc-url=$RPC_URL +RUST_LOG=info cargo run --bin deploy -- --deploy-fee-v1 --rpc-url=$RPC_URL ``` ## Timelock Owner @@ -146,7 +146,7 @@ set -a source .env set +a unset ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS -RUST_LOG=info cargo run --bin deploy -- --deploy-ops-timelock --deploy-fee --use-timelock-owner --rpc-url=$RPC_URL +RUST_LOG=info cargo run --bin deploy -- --deploy-ops-timelock --deploy-fee-v1 --use-timelock-owner --rpc-url=$RPC_URL ``` ### Deploying Fee Contract with Docker compose @@ -171,7 +171,7 @@ docker compose run --rm \ -e RPC_URL \ -v $(pwd)/.env.mydemo:/app/.env.mydemo \ deploy-sequencer-contracts \ - deploy --deploy-ops-timelock --deploy-fee --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo + deploy --deploy-ops-timelock --deploy-fee-v1 --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo ``` # Token @@ -200,7 +200,7 @@ source .env set +a unset ESPRESSO_SEQUENCER_ESP_TOKEN_PROXY_ADDRESS unset ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS -RUST_LOG=info cargo run --bin deploy -- --deploy-esp-token --rpc-url=$RPC_URL +RUST_LOG=info cargo run --bin deploy -- --deploy-esp-token-v1 --rpc-url=$RPC_URL ``` ## Multisig Owner @@ -212,7 +212,7 @@ set -a source .env set +a unset ESPRESSO_SEQUENCER_ESP_TOKEN_PROXY_ADDRESS -RUST_LOG=info cargo run --bin deploy -- --deploy-esp-token --rpc-url=$RPC_URL +RUST_LOG=info cargo run --bin deploy -- --deploy-esp-token-v1 --rpc-url=$RPC_URL ``` ## Timelock Owner @@ -228,7 +228,7 @@ set -a source .env set +a unset ESPRESSO_SEQUENCER_ESP_TOKEN_PROXY_ADDRESS -RUST_LOG=info cargo run --bin deploy -- --deploy-safe-exit-timelock --deploy-esp-token --use-timelock-owner --rpc-url=$RPC_URL +RUST_LOG=info cargo run --bin deploy -- --deploy-safe-exit-timelock --deploy-esp-token-v1 --use-timelock-owner --rpc-url=$RPC_URL ``` ### Deploying Token with Docker compose @@ -253,7 +253,7 @@ docker compose run --rm \ -e RPC_URL \ -v $(pwd)/.env.mydemo:/app/.env.mydemo \ deploy-sequencer-contracts \ - deploy --deploy-safe-exit-timelock --deploy-esp-token --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo + deploy --deploy-safe-exit-timelock --deploy-esp-token-v1 --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo ``` Example output file (.env.mydemo) contents after a successful run @@ -284,7 +284,7 @@ export ESPRESSO_OPS_TIMELOCK_ADMIN=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 export ESPRESSO_OPS_TIMELOCK_PROPOSERS=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 export ESPRESSO_OPS_TIMELOCK_EXECUTORS=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 export ESPORESS_OPS_TIMELOCK_DELAY=0 -RUST_LOG=info cargo run --bin deploy -- --deploy-ops-timelock --deploy-fee --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo +RUST_LOG=info cargo run --bin deploy -- --deploy-ops-timelock --deploy-fee-v1 --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo ``` The deployed contracts will be written to `.env.mydemo` @@ -399,7 +399,7 @@ export ESPRESSO_OPS_TIMELOCK_ADMIN=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 export ESPRESSO_OPS_TIMELOCK_PROPOSERS=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 export ESPRESSO_OPS_TIMELOCK_EXECUTORS=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 export ESPORESS_OPS_TIMELOCK_DELAY=0 -RUST_LOG=info cargo run --bin deploy -- --deploy-ops-timelock --deploy-fee --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo +RUST_LOG=info cargo run --bin deploy -- --deploy-ops-timelock --deploy-fee-v1 --use-timelock-owner --rpc-url=$RPC_URL --out .env.mydemo ``` The deployed contracts will be written to `.env.mydemo` @@ -577,7 +577,7 @@ set +a unset ESPRESSO_SEQUENCER_ESP_TOKEN_PROXY_ADDRESS # If doing a real run then, export ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS=YOUR_MULTISIG_ADDRESS RUST_LOG=info cargo run --bin deploy -- \ - --deploy-esp-token \ + --deploy-esp-token-v1 \ --upgrade-esp-token-v2 \ --rpc-url=$RPC_URL \ --use-multisig @@ -593,7 +593,7 @@ docker compose run --rm \ -e ESPRESSO_SEQUENCER_ETH_MNEMONIC \ -v $(pwd)/.env.mydemo:/app/.env.mydemo \ deploy-sequencer-contracts \ - deploy --deploy-esp-token --upgrade-esp-token-v2 --rpc-url=$RPC_URL --use-multisig + deploy --deploy-esp-token-v1 --upgrade-esp-token-v2 --rpc-url=$RPC_URL --use-multisig # to simulate, add --dry-run ``` @@ -654,7 +654,6 @@ export RPC_URL="" cargo run --bin deploy -- \ --propose-transfer-ownership-to-timelock \ --target-contract FeeContract \ - --timelock-address $ESPRESSO_SEQUENCER_OPS_TIMELOCK_ADDRESS \ --fee-contract-proxy $ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS \ --multisig-address $ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS \ --rpc-url $RPC_URL \ @@ -678,7 +677,6 @@ export RPC_URL="" docker-compose run --rm deploy-sequencer-contracts \ --propose-transfer-ownership-to-timelock \ --target-contract lightclient \ - --timelock-address $ESPRESSO_SEQUENCER_TIMELOCK_ADDRESS \ --light-client-proxy $ESPRESSO_SEQUENCER_LIGHT_CLIENT_PROXY_ADDRESS \ --multisig-address $ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS \ --rpc-url $RPC_URL @@ -1117,7 +1115,7 @@ docker compose run --rm \ -v $(pwd)/$OUTPUT_FILE:/app/$OUTPUT_FILE \ \ deploy-sequencer-contracts \ - deploy --deploy-esp-token --use-timelock-owner --rpc-url=$RPC_URL --out $OUTPUT_FILE + deploy --deploy-esp-token-v1 --use-timelock-owner --rpc-url=$RPC_URL --out $OUTPUT_FILE # to simulate, add --dry-run ``` @@ -1226,7 +1224,7 @@ docker compose run --rm \ -v $(pwd)/$OUTPUT_FILE:/app/$OUTPUT_FILE \ \ deploy-sequencer-contracts \ - deploy --deploy-stake-table --upgrade-stake-table-v2 --use-timelock-owner --rpc-url=$RPC_URL --out $OUTPUT_FILE + deploy --deploy-stake-table-v1 --upgrade-stake-table-v2 --use-timelock-owner --rpc-url=$RPC_URL --out $OUTPUT_FILE # to simulate, add --dry-run ``` diff --git a/contracts/rust/deployer/src/builder.rs b/contracts/rust/deployer/src/builder.rs index 3974370b69a..c758d851bb4 100644 --- a/contracts/rust/deployer/src/builder.rs +++ b/contracts/rust/deployer/src/builder.rs @@ -21,8 +21,9 @@ use crate::{ StakeTableV2UpgradeParams, TransferOwnershipParams, }, timelock::{ - cancel_timelock_operation, execute_timelock_operation, schedule_timelock_operation, - TimelockOperationData, TimelockOperationType, + cancel_timelock_operation, derive_timelock_address_from_contract_type, + execute_timelock_operation, schedule_timelock_operation, TimelockOperationData, + TimelockOperationType, }, }, Contract, Contracts, @@ -132,8 +133,6 @@ pub struct DeployerArgs { transfer_ownership_from_eoa: Option, #[builder(default)] transfer_ownership_new_owner: Option
, - #[builder(default)] - timelock_address: Option
, } impl DeployerArgs

{ @@ -156,9 +155,8 @@ impl DeployerArgs

{ ); // deployer is the timelock owner if use_timelock_owner { - let timelock_addr = contracts - .address(Contract::OpsTimelock) - .expect("fail to get OpsTimelock address"); + let timelock_addr = + derive_timelock_address_from_contract_type(target, contracts)?; crate::transfer_ownership(provider, target, addr, timelock_addr).await?; } } else if let Some(multisig) = self.multisig { @@ -217,9 +215,8 @@ impl DeployerArgs

{ // - No emergency updates are expected for token functionality // - SafeExitTimelock provides sufficient security for token operations tracing::info!("Transferring ownership to SafeExitTimelock"); - let timelock_addr = contracts - .address(Contract::SafeExitTimelock) - .expect("fail to get SafeExitTimelock address"); + let timelock_addr = + derive_timelock_address_from_contract_type(target, contracts)?; crate::transfer_ownership(provider, target, addr, timelock_addr) .await?; } @@ -340,9 +337,8 @@ impl DeployerArgs

{ tracing::info!("Transferring ownership to OpsTimelock"); // deployer is the timelock owner if use_timelock_owner { - let timelock_addr = contracts - .address(Contract::OpsTimelock) - .expect("fail to get OpsTimelock address"); + let timelock_addr = + derive_timelock_address_from_contract_type(target, contracts)?; crate::transfer_ownership(provider, target, addr, timelock_addr) .await?; } @@ -406,18 +402,18 @@ impl DeployerArgs

{ } else { // Pick admin from config. StakeTable uses OpsTimelock for faster // emergency updates since it handles critical staking ops. - let admin = if let Some(use_timelock_owner) = self.use_timelock_owner { - if use_timelock_owner { - contracts - .address(Contract::OpsTimelock) - .expect("fail to get OpsTimelock address") - } else { - admin // deployer - } - } else if let Some(multisig) = self.multisig { - multisig - } else { - admin // deployer + let admin = match self.use_timelock_owner { + Some(true) => { + derive_timelock_address_from_contract_type(target, contracts)? + }, + Some(false) => admin, // deployer + None => { + if let Some(multisig) = self.multisig { + multisig + } else { + admin // deployer + } + }, }; tracing::info!("Upgrading StakeTableV2 with admin: {:?}", admin); @@ -497,21 +493,16 @@ impl DeployerArgs

{ // RewardClaim uses SafeExitTimelock (longer delay) since it can mint tokens // and users need time to react to upgrades. Can be paused in emergencies. - let admin = if let Some(use_timelock_owner) = self.use_timelock_owner { - if use_timelock_owner { - contracts - .address(Contract::SafeExitTimelock) - .expect("fail to get SafeExitTimelock address") - } else { - self.ops_timelock_admin.context( - "SafeExitTimelock contract address must be set when using \ - --use-timelock-owner flag", - )? - } - } else if let Some(multisig) = self.multisig { - multisig - } else { - admin + let admin = match self.use_timelock_owner { + Some(true) => derive_timelock_address_from_contract_type(target, contracts)?, + Some(false) => admin, // deployer + None => { + if let Some(multisig) = self.multisig { + multisig + } else { + admin // deployer + } + }, }; tracing::info!("Deploying RewardClaimProxy with admin: {:?}", admin); @@ -615,6 +606,12 @@ impl DeployerArgs

{ .context("StakeTableProxy address not found")?, Contract::StakeTableProxy, ), + "RewardClaim" => ( + contracts + .address(Contract::RewardClaimProxy) + .context("RewardClaimProxy address not found")?, + Contract::RewardClaimProxy, + ), _ => anyhow::bail!("Invalid target contract: {}", target_contract), }; @@ -688,29 +685,27 @@ impl DeployerArgs

{ ) })?; - let timelock_address = self.timelock_address.ok_or_else(|| { - anyhow::anyhow!( - "Timelock address must be set when proposing ownership transfer. Use \ - --timelock-address or ESPRESSO_SEQUENCER_TIMELOCK_ADDRESS" - ) - })?; - // Parse the contract type from string let contract_type = match target_contract.to_lowercase().as_str() { "lightclient" | "lightclientproxy" => Contract::LightClientProxy, "feecontract" | "feecontractproxy" => Contract::FeeContractProxy, "esptoken" | "esptokenproxy" => Contract::EspTokenProxy, "staketable" | "staketableproxy" => Contract::StakeTableProxy, + "rewardclaim" | "rewardclaimproxy" => Contract::RewardClaimProxy, _ => anyhow::bail!( "Unknown contract type: {}. Supported types: lightclient, feecontract, esptoken, \ - staketable", + staketable, rewardclaim", target_contract ), }; + let timelock_address = + derive_timelock_address_from_contract_type(contract_type, contracts)?; + tracing::info!( - "Proposing transfer of ownership from multisig to timelock for {}", - target_contract + "Proposing transfer of ownership from multisig to timelock for {} (timelock: {:?})", + target_contract, + timelock_address ); let contract = contract_type; diff --git a/contracts/rust/deployer/src/lib.rs b/contracts/rust/deployer/src/lib.rs index ebe94e089d1..1f418da993e 100644 --- a/contracts/rust/deployer/src/lib.rs +++ b/contracts/rust/deployer/src/lib.rs @@ -117,15 +117,19 @@ pub struct DeployedContracts { /// Use an already-deployed PlonkVerifier.sol instead of deploying a new one. #[clap(long, env = Contract::PlonkVerifier)] plonk_verifier: Option

, + /// OpsTimelock.sol #[clap(long, env = Contract::OpsTimelock)] ops_timelock: Option
, + /// SafeExitTimelock.sol #[clap(long, env = Contract::SafeExitTimelock)] safe_exit_timelock: Option
, + /// PlonkVerifierV2.sol #[clap(long, env = Contract::PlonkVerifierV2)] plonk_verifier_v2: Option
, + /// PlonkVerifierV3.sol #[clap(long, env = Contract::PlonkVerifierV3)] plonk_verifier_v3: Option
, @@ -133,9 +137,11 @@ pub struct DeployedContracts { /// Use an already-deployed LightClient.sol instead of deploying a new one. #[clap(long, env = Contract::LightClient)] light_client: Option
, + /// LightClientV2.sol #[clap(long, env = Contract::LightClientV2)] light_client_v2: Option
, + /// LightClientV3.sol #[clap(long, env = Contract::LightClientV3)] light_client_v3: Option
, @@ -175,9 +181,11 @@ pub struct DeployedContracts { /// Use an already-deployed StakeTable.sol proxy instead of deploying a new one. #[clap(long, env = Contract::StakeTableProxy)] stake_table_proxy: Option
, + /// RewardClaim.sol #[clap(long, env = Contract::RewardClaim)] reward_claim: Option
, + /// Use an already-deployed RewardClaim.sol proxy instead of deploying a new one. #[clap(long, env = Contract::RewardClaimProxy)] reward_claim_proxy: Option
, @@ -326,6 +334,9 @@ impl Contracts { let tx_hash = *pending_tx.tx_hash(); tracing::info!(%tx_hash, "waiting for tx to be mined"); let receipt = pending_tx.get_receipt().await?; + if !receipt.inner.is_success() { + anyhow::bail!("Deployment transaction failed: {:?}", receipt); + } tracing::info!(%receipt.gas_used, %tx_hash, "tx mined"); let addr = receipt .contract_address @@ -1233,7 +1244,6 @@ pub async fn transfer_ownership( /// Grant DEFAULT_ADMIN_ROLE to a new admin for AccessControl-based contracts /// This handles contracts like RewardClaim that use AccessControl instead of Ownable -/// TODO: create a function for pauser roles pub async fn grant_admin_role( provider: impl Provider, target_contract: Contract, @@ -1515,10 +1525,11 @@ mod tests { LightClientV2UpgradeParams, StakeTableV2UpgradeParams, TransferOwnershipParams, }, timelock::{ - cancel_timelock_operation, execute_timelock_operation, schedule_timelock_operation, - TimelockOperationData, + cancel_timelock_operation, derive_timelock_address_from_contract_type, + execute_timelock_operation, schedule_timelock_operation, TimelockOperationData, }, }, + Contracts, }; trait ProviderBuilderExt: Sized { @@ -3590,4 +3601,302 @@ mod tests { Ok(()) } + + #[test_log::test(tokio::test)] + async fn test_perform_timelock_operation_reward_claim() -> Result<()> { + let (anvil, provider, _l1_client) = + ProviderBuilder::new().connect_anvil_with_l1_client()?; + let mut contracts = Contracts::new(); + let delay = U256::from(0); + let provider_wallet = provider.get_accounts().await?[0]; + + // Deploy SafeExitTimelock + let timelock_addr = deploy_safe_exit_timelock( + &provider, + &mut contracts, + delay, + vec![provider_wallet], + vec![provider_wallet], + provider_wallet, + ) + .await?; + + // Deploy dependencies + let token_addr = deploy_token_proxy( + &provider, + &mut contracts, + provider_wallet, + provider_wallet, + U256::from(10_000_000u64), + "Test Token", + "TEST", + ) + .await?; + let lc_addr = deploy_light_client_contract(&provider, &mut contracts, false).await?; + + // Deploy RewardClaim with timelock as admin + let reward_claim_addr = deploy_reward_claim_proxy( + &provider, + &mut contracts, + token_addr, + lc_addr, + timelock_addr, + provider_wallet, + ) + .await?; + + let reward_claim = RewardClaim::new(reward_claim_addr, &provider); + let pauser_role = reward_claim.PAUSER_ROLE().call().await?; + let new_pauser = Address::random(); + + // Verify new_pauser doesn't have the role yet + assert!(!reward_claim.hasRole(pauser_role, new_pauser).call().await?); + + // Use DeployerArgsBuilder to test perform_timelock_operation_on_contract + use builder::DeployerArgsBuilder; + use proposals::timelock::TimelockOperationType; + + let mut args_builder = DeployerArgsBuilder::default(); + args_builder + .deployer(provider.clone()) + .rpc_url(anvil.endpoint_url()) + .timelock_operation_type(TimelockOperationType::Schedule) + .target_contract("RewardClaim".to_string()) + .timelock_operation_value(U256::ZERO) + .timelock_operation_function_signature("grantRole(bytes32,address)".to_string()) + .timelock_operation_function_values(vec![ + format!("{:#x}", pauser_role), + format!("{:#x}", new_pauser), + ]) + .timelock_operation_salt( + "0x0000000000000000000000000000000000000000000000000000000000000001".to_string(), + ) + .timelock_operation_delay(delay); + + let args = args_builder.build()?; + + // Schedule the operation using the high-level function + args.perform_timelock_operation_on_contract(&mut contracts) + .await?; + + // Now execute it + let mut args_builder = DeployerArgsBuilder::default(); + args_builder + .deployer(provider.clone()) + .rpc_url(anvil.endpoint_url()) + .timelock_operation_type(TimelockOperationType::Execute) + .target_contract("RewardClaim".to_string()) + .timelock_operation_value(U256::ZERO) + .timelock_operation_function_signature("grantRole(bytes32,address)".to_string()) + .timelock_operation_function_values(vec![ + format!("{:#x}", pauser_role), + format!("{:#x}", new_pauser), + ]) + .timelock_operation_salt( + "0x0000000000000000000000000000000000000000000000000000000000000001".to_string(), + ) + .timelock_operation_delay(delay); + + let args = args_builder.build()?; + args.perform_timelock_operation_on_contract(&mut contracts) + .await?; + + // Verify the function was actually called + assert!(reward_claim.hasRole(pauser_role, new_pauser).call().await?); + + Ok(()) + } + + #[test_log::test(tokio::test)] + async fn test_derive_timelock_address_from_contracts() -> Result<()> { + use builder::DeployerArgsBuilder; + use proposals::timelock::{get_timelock_for_contract, TimelockContract}; + + let (anvil, provider, _l1_client) = + ProviderBuilder::new().connect_anvil_with_l1_client()?; + let mut contracts = Contracts::new(); + let delay = U256::from(0); + let provider_wallet = provider.get_accounts().await?[0]; + let proposers = vec![provider_wallet]; + let executors = vec![provider_wallet]; + let rpc_url = anvil.endpoint_url(); + + // Deploy both timelocks first + let ops_timelock_addr = deploy_ops_timelock( + &provider, + &mut contracts, + delay, + proposers.clone(), + executors.clone(), + provider_wallet, + ) + .await?; + + let safe_exit_timelock_addr = deploy_safe_exit_timelock( + &provider, + &mut contracts, + delay, + proposers, + executors, + provider_wallet, + ) + .await?; + + // Use DeployerArgsBuilder to deploy FeeContractProxy with use_timelock_owner + // This tests the actual deployment code path and verifies it chooses OpsTimelock + let mut args_builder = DeployerArgsBuilder::default(); + args_builder + .deployer(provider.clone()) + .use_timelock_owner(true) + .rpc_url(rpc_url.clone()); + let args = args_builder.build()?; + + // Deploy FeeContractProxy - it should automatically use OpsTimelock + args.deploy(&mut contracts, Contract::FeeContractProxy) + .await?; + + // Verify derivation function returns correct timelock + let fee_contract_derived_timelock = + derive_timelock_address_from_contract_type(Contract::FeeContractProxy, &contracts)?; + assert_eq!(fee_contract_derived_timelock, ops_timelock_addr); + + // Verify on-chain that FeeContractProxy has OpsTimelock as owner + let fee_contract_addr = contracts + .address(Contract::FeeContractProxy) + .expect("FeeContractProxy should be deployed"); + let fee_contract = FeeContract::new(fee_contract_addr, &provider); + let actual_owner = fee_contract.owner().call().await?; + assert_eq!( + actual_owner, ops_timelock_addr, + "FeeContractProxy should have OpsTimelock as owner" + ); + + let queried_timelock = + get_timelock_for_contract(&provider, Contract::FeeContractProxy, fee_contract_addr) + .await?; + + match queried_timelock { + TimelockContract::OpsTimelock(addr) => { + assert_eq!( + addr, ops_timelock_addr, + "Queried timelock should match deployed OpsTimelock" + ); + assert_eq!( + addr, fee_contract_derived_timelock, + "Queried timelock should match derived timelock" + ); + }, + _ => panic!( + "FeeContractProxy should use OpsTimelock, got: {:?}", + queried_timelock + ), + } + + // Deploy RewardsClaimProxy - it should automatically use SafeExitTimelock + let _esp_token_addr = deploy_token_proxy( + &provider, + &mut contracts, + provider_wallet, + provider_wallet, + U256::from(10_000_000u64), + "Test Token", + "TEST", + ) + .await?; + + // prepare `initialize()` input + let genesis_state = LightClientStateSol::dummy_genesis(); + let genesis_stake = StakeTableStateSol::dummy_genesis(); + let admin = provider.get_accounts().await?[0]; + let prover = admin; + + let _lc_proxy_addr = deploy_light_client_proxy( + &provider, + &mut contracts, + true, // is_mock = true + genesis_state.clone(), + genesis_stake.clone(), + admin, + Some(prover), + ) + .await?; + + let mut args_builder2 = DeployerArgsBuilder::default(); + args_builder2 + .deployer(provider.clone()) + .use_timelock_owner(true) + .rpc_url(rpc_url.clone()); + let args2 = args_builder2.build()?; + + args2 + .deploy(&mut contracts, Contract::RewardClaimProxy) + .await?; + + // Verify derivation + let reward_claim_derived_timelock = + derive_timelock_address_from_contract_type(Contract::RewardClaimProxy, &contracts)?; + assert_eq!(reward_claim_derived_timelock, safe_exit_timelock_addr); + + // Verify on-chain + let reward_claim_addr = contracts + .address(Contract::RewardClaimProxy) + .expect("RewardClaimProxy should be deployed"); + let reward_claim = RewardClaim::new(reward_claim_addr, &provider); + let actual_owner = reward_claim.currentAdmin().call().await?; + assert_eq!( + actual_owner, safe_exit_timelock_addr, + "RewardClaimProxy should have SafeExitTimelock as owner" + ); + + let queried_timelock = + get_timelock_for_contract(&provider, Contract::RewardClaimProxy, reward_claim_addr) + .await?; + + match queried_timelock { + TimelockContract::SafeExitTimelock(addr) => { + assert_eq!( + addr, safe_exit_timelock_addr, + "Queried timelock should match deployed SafeExitTimelock" + ); + assert_eq!( + addr, reward_claim_derived_timelock, + "Queried timelock should match derived timelock" + ); + }, + _ => panic!( + "RewardClaimProxy should use SafeExitTimelock, got: {:?}", + queried_timelock + ), + } + + // Error case - missing timelock + let empty_contracts = Contracts::new(); + let result = derive_timelock_address_from_contract_type( + Contract::FeeContractProxy, + &empty_contracts, + ); + assert!( + result.is_err(), + "Should error when timelock is missing from contracts map" + ); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("not found") || error_msg.contains("OpsTimelock"), + "Error should mention missing timelock, got: {}", + error_msg + ); + + // Error case - invalid contract type + let result = + derive_timelock_address_from_contract_type(Contract::PlonkVerifier, &contracts); + assert!(result.is_err(), "Should error for invalid contract type"); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("Invalid contract type"), + "Error should mention invalid contract type, got: {}", + error_msg + ); + + Ok(()) + } } diff --git a/contracts/rust/deployer/src/proposals/timelock.rs b/contracts/rust/deployer/src/proposals/timelock.rs index 58812e18a16..1e15e26563f 100644 --- a/contracts/rust/deployer/src/proposals/timelock.rs +++ b/contracts/rust/deployer/src/proposals/timelock.rs @@ -6,10 +6,10 @@ use alloy::{ use anyhow::Result; use clap::ValueEnum; use hotshot_contract_adapter::sol_types::{ - EspToken, FeeContract, LightClient, OpsTimelock, SafeExitTimelock, StakeTable, + EspToken, FeeContract, LightClient, OpsTimelock, RewardClaim, SafeExitTimelock, StakeTable, }; -use crate::Contract; +use crate::{Contract, Contracts}; /// Data structure for timelock operations #[derive(Debug, Clone)] @@ -98,6 +98,9 @@ impl TimelockContract { let tx_hash = *pending_tx.tx_hash(); tracing::info!(%tx_hash, "waiting for tx to be mined"); let receipt = pending_tx.get_receipt().await?; + if !receipt.inner.is_success() { + anyhow::bail!("tx failed: {:?}", receipt); + } Ok(receipt) }, TimelockContract::SafeExitTimelock(timelock_addr) => { @@ -115,6 +118,9 @@ impl TimelockContract { let tx_hash = *pending_tx.tx_hash(); tracing::info!(%tx_hash, "waiting for tx to be mined"); let receipt = pending_tx.get_receipt().await?; + if !receipt.inner.is_success() { + anyhow::bail!("tx failed: {:?}", receipt); + } Ok(receipt) }, } @@ -203,6 +209,9 @@ impl TimelockContract { let tx_hash = *pending_tx.tx_hash(); tracing::info!(%tx_hash, "waiting for tx to be mined"); let receipt = pending_tx.get_receipt().await?; + if !receipt.inner.is_success() { + anyhow::bail!("tx failed: {:?}", receipt); + } Ok(receipt) }, TimelockContract::SafeExitTimelock(timelock_addr) => { @@ -219,6 +228,9 @@ impl TimelockContract { let tx_hash = *pending_tx.tx_hash(); tracing::info!(%tx_hash, "waiting for tx to be mined"); let receipt = pending_tx.get_receipt().await?; + if !receipt.inner.is_success() { + anyhow::bail!("tx failed: {:?}", receipt); + } Ok(receipt) }, } @@ -238,6 +250,9 @@ impl TimelockContract { let tx_hash = *pending_tx.tx_hash(); tracing::info!(%tx_hash, "waiting for tx to be mined"); let receipt = pending_tx.get_receipt().await?; + if !receipt.inner.is_success() { + anyhow::bail!("tx failed: {:?}", receipt); + } Ok(receipt) }, TimelockContract::SafeExitTimelock(timelock_addr) => { @@ -248,12 +263,82 @@ impl TimelockContract { let tx_hash = *pending_tx.tx_hash(); tracing::info!(%tx_hash, "waiting for tx to be mined"); let receipt = pending_tx.get_receipt().await?; + if !receipt.inner.is_success() { + anyhow::bail!("tx failed: {:?}", receipt); + } Ok(receipt) }, } } } +// Derive timelock address from contract type +// FeeContract, LightClient, StakeTable => OpsTimelock +// EspToken, RewardClaim => SafeExitTimelock +pub fn derive_timelock_address_from_contract_type( + contract_type: Contract, + contracts: &Contracts, +) -> Result
{ + let timelock_type = match contract_type { + Contract::FeeContractProxy | Contract::LightClientProxy | Contract::StakeTableProxy => { + Contract::OpsTimelock + }, + Contract::EspTokenProxy | Contract::RewardClaimProxy => Contract::SafeExitTimelock, + _ => anyhow::bail!( + "Invalid contract type for timelock derivation: {}", + contract_type + ), + }; + + contracts.address(timelock_type).ok_or_else(|| { + anyhow::anyhow!( + "{:?} not found in deployed contracts. Deploy it first or provide it via flag.", + timelock_type + ) + }) +} + +// Get the timelock for a contract by querying the contract owner or current admin +pub async fn get_timelock_for_contract( + provider: &impl Provider, + contract_type: Contract, + target_addr: Address, +) -> Result { + match contract_type { + Contract::FeeContractProxy => Ok(TimelockContract::OpsTimelock( + FeeContract::new(target_addr, &provider) + .owner() + .call() + .await?, + )), + Contract::EspTokenProxy => Ok(TimelockContract::SafeExitTimelock( + EspToken::new(target_addr, &provider).owner().call().await?, + )), + Contract::LightClientProxy => Ok(TimelockContract::OpsTimelock( + LightClient::new(target_addr, &provider) + .owner() + .call() + .await?, + )), + Contract::StakeTableProxy => Ok(TimelockContract::OpsTimelock( + StakeTable::new(target_addr, &provider) + .owner() + .call() + .await?, + )), + Contract::RewardClaimProxy => Ok(TimelockContract::SafeExitTimelock( + RewardClaim::new(target_addr, &provider) + .currentAdmin() + .call() + .await?, + )), + _ => anyhow::bail!( + "Invalid contract type for timelock get operation: {}", + contract_type + ), + } +} + /// Schedule a timelock operation /// /// Parameters: @@ -269,32 +354,7 @@ pub async fn schedule_timelock_operation( operation: TimelockOperationData, ) -> Result { let target_addr = operation.target; - let timelock = match contract_type { - Contract::FeeContractProxy => { - let proxy = FeeContract::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - Contract::EspTokenProxy => { - let proxy = EspToken::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::SafeExitTimelock(proxy_owner) - }, - Contract::LightClientProxy => { - let proxy = LightClient::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - Contract::StakeTableProxy => { - let proxy = StakeTable::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - _ => anyhow::bail!( - "Invalid contract type for timelock schedule operation: {}", - contract_type - ), - }; + let timelock = get_timelock_for_contract(provider, contract_type, target_addr).await?; let operation_id = timelock.get_operation_id(&operation, &provider).await?; let receipt = timelock.schedule(operation, &provider).await?; @@ -330,32 +390,7 @@ pub async fn execute_timelock_operation( operation: TimelockOperationData, ) -> Result { let target_addr = operation.target; - let timelock = match contract_type { - Contract::FeeContractProxy => { - let proxy = FeeContract::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - Contract::EspTokenProxy => { - let proxy = EspToken::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::SafeExitTimelock(proxy_owner) - }, - Contract::LightClientProxy => { - let proxy = LightClient::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - Contract::StakeTableProxy => { - let proxy = StakeTable::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - _ => anyhow::bail!( - "Invalid contract type for timelock execute operation: {}", - contract_type - ), - }; + let timelock = get_timelock_for_contract(provider, contract_type, target_addr).await?; let operation_id = timelock.get_operation_id(&operation, &provider).await?; // execute the tx @@ -388,32 +423,7 @@ pub async fn cancel_timelock_operation( operation: TimelockOperationData, ) -> Result { let target_addr = operation.target; - let timelock = match contract_type { - Contract::FeeContractProxy => { - let proxy = FeeContract::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - Contract::EspTokenProxy => { - let proxy = EspToken::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::SafeExitTimelock(proxy_owner) - }, - Contract::LightClientProxy => { - let proxy = LightClient::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - Contract::StakeTableProxy => { - let proxy = StakeTable::new(target_addr, &provider); - let proxy_owner = proxy.owner().call().await?; - TimelockContract::OpsTimelock(proxy_owner) - }, - _ => anyhow::bail!( - "Invalid contract type for timelock cancel operation: {}", - contract_type - ), - }; + let timelock = get_timelock_for_contract(provider, contract_type, target_addr).await?; let operation_id = timelock.get_operation_id(&operation, &provider).await?; let receipt = timelock.cancel(operation_id, &provider).await?; tracing::info!(%receipt.gas_used, %receipt.transaction_hash, "tx mined"); diff --git a/docker-compose.yaml b/docker-compose.yaml index 71241400f67..f8eaa3df9f9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,7 +15,7 @@ services: deploy-sequencer-contracts: image: ghcr.io/espressosystems/espresso-sequencer/deploy:${DOCKER_TAG:-main} - command: deploy --deploy-ops-timelock --deploy-safe-exit-timelock --deploy-fee --deploy-esp-token --deploy-stake-table + command: deploy --deploy-ops-timelock --deploy-safe-exit-timelock --deploy-fee-v1 --deploy-esp-token-v1 --deploy-stake-table-v1 environment: - ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS - ESPRESSO_SEQUENCER_L1_PROVIDER=http://demo-l1-network:${ESPRESSO_SEQUENCER_L1_PORT} @@ -109,7 +109,7 @@ services: deploy-pos-contracts-upgrades: image: ghcr.io/espressosystems/espresso-sequencer/deploy:${DOCKER_TAG:-main} - command: deploy --deploy-reward-claim --upgrade-esp-token-v2 --upgrade-stake-table-v2 + command: deploy --deploy-reward-claim-v1 --upgrade-esp-token-v2 --upgrade-stake-table-v2 environment: - ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS - ESPRESSO_SEQUENCER_L1_PROVIDER=http://demo-l1-network:${ESPRESSO_SEQUENCER_L1_PORT} diff --git a/process-compose.yaml b/process-compose.yaml index d7ef610da9c..89b93cb18ef 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -46,7 +46,7 @@ processes: ESPRESSO_SEQUENCER_FEE_CONTRACT_PROXY_ADDRESS ESPRESSO_SEQUENCER_ESP_TOKEN_PROXY_ADDRESS ESPRESSO_SEQUENCER_STAKE_TABLE_PROXY_ADDRESS - && deploy --deploy-ops-timelock --deploy-safe-exit-timelock --deploy-fee --deploy-esp-token --deploy-stake-table + && deploy --deploy-ops-timelock --deploy-safe-exit-timelock --deploy-fee-v1 --deploy-esp-token-v1 --deploy-stake-table-v1 namespace: setup depends_on: demo-l1-network: @@ -93,7 +93,7 @@ processes: deploy-pos-contracts-upgrades: command: unset ESPRESSO_SEQUENCER_REWARD_CLAIM_PROXY_ADDRESS - && deploy --deploy-reward-claim --upgrade-esp-token-v2 --upgrade-stake-table-v2 + && deploy --deploy-reward-claim-v1 --upgrade-esp-token-v2 --upgrade-stake-table-v2 namespace: setup depends_on: demo-l1-network: diff --git a/sequencer/src/bin/deploy.rs b/sequencer/src/bin/deploy.rs index d16175db1c3..293bf8369b2 100644 --- a/sequencer/src/bin/deploy.rs +++ b/sequencer/src/bin/deploy.rs @@ -74,7 +74,7 @@ struct Options { /// Mnemonic for an L1 wallet. /// /// This wallet is used to deploy the contracts, so the account indicated by ACCOUNT_INDEX must - /// be funded with with ETH. + /// be funded with ETH. #[clap( long, name = "MNEMONIC", @@ -128,46 +128,85 @@ struct Options { ledger: bool, /// Option to deploy fee contracts + // (backward compatibility: --deploy-fee is an alias for --deploy-fee-v1) #[clap(long, default_value = "false")] deploy_fee: bool, + + /// Option to deploy fee contracts + #[clap(long, default_value = "false")] + deploy_fee_v1: bool, + /// Option to deploy LightClient V1 and proxy #[clap(long, default_value = "false")] deploy_light_client_v1: bool, + /// Option to upgrade to LightClient V2 #[clap(long, default_value = "false")] upgrade_light_client_v2: bool, + /// Option to upgrade to LightClient V3 #[clap(long, default_value = "false")] upgrade_light_client_v3: bool, + /// Option to deploy esp token + // (backward compatibility: --deploy-esp-token is an alias for --deploy-esp-token-v1) #[clap(long, default_value = "false")] deploy_esp_token: bool, + + /// Option to deploy esp token v1 + #[clap(long, default_value = "false")] + deploy_esp_token_v1: bool, + /// Option to upgrade esp token v2 #[clap(long, default_value = "false")] upgrade_esp_token_v2: bool, /// Option to deploy StakeTable V1 and proxy + // (backward compatibility: --deploy-stake-table is an alias for --deploy-stake-table-v1) #[clap(long, default_value = "false")] deploy_stake_table: bool, + + /// Option to deploy StakeTable V1 + #[clap(long, default_value = "false")] + deploy_stake_table_v1: bool, + /// Option to upgrade to StakeTable V2 #[clap(long, default_value = "false")] upgrade_stake_table_v2: bool, - /// Option to deploy RewardClaim proxy + + /// Option to deploy RewardClaimV1 proxy #[clap(long, default_value = "false")] - deploy_reward_claim: bool, + deploy_reward_claim_v1: bool, + /// Option to deploy ops timelock #[clap(long, default_value = "false")] deploy_ops_timelock: bool, + /// Option to deploy safe exit timelock #[clap(long, default_value = "false")] deploy_safe_exit_timelock: bool, + /// Option to use timelock as the owner of the proxy + /// Transfer contract ownership to a timelock during deployment/upgrade. + /// Which timelock is used depends on the contract: + /// - FeeContract, LightClient, StakeTable → OpsTimelock (shorter delay for critical ops) + /// - EspToken, RewardClaim → SafeExitTimelock (longer delay for token operations) + /// + /// Requires timelocks to be deployed first (--deploy-ops-timelock, --deploy-safe-exit-timelock). + /// If not set, ownership goes to multisig (if --use-multisig) or deployer account. #[clap(long, default_value = "false")] use_timelock_owner: bool, + /// Option to transfer ownership from multisig + /// Propose ownership transfer from multisig to timelock via Safe transaction service. + /// This creates a transaction proposal in the Safe multisig that requires approval + /// before execution. The actual transfer happens after multisig members approve. + /// Requires: --multisig-address, --target-contract + /// Note: Only works on networks where Safe transaction service is available. #[clap(long, default_value = "false")] propose_transfer_ownership_to_timelock: bool, /// Option to transfer ownership directly from EOA to a new owner + /// Requires: --transfer-ownership-new-owner #[clap(long, default_value = "false")] transfer_ownership_from_eoa: bool, @@ -185,15 +224,24 @@ struct Options { contracts: DeployedContracts, /// If toggled, launch a mock LightClient contract with a smaller verification key for testing. - /// Applies to both V1 and V2 of LightClient. + /// Applies to both V1, V2 and V3 of LightClient. #[clap(short, long)] pub use_mock: bool, - /// Option to deploy contracts owned by multisig + /// Option to deploy contracts owned by multisig. + /// + /// DEPLOYMENT: Directly sets multisig as owner (immediate transfer). + /// UPGRADES: Creates Safe multisig proposal (requires approval before execution). + /// + /// For upgrades, uses Safe transaction service to create proposals that + /// multisig members must approve. This adds a governance layer. + /// + /// Requires: --multisig-address or ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS #[clap(long, default_value = "false")] pub use_multisig: bool, /// Option to test upgrade stake table v2 multisig owner dry run + /// TODO: have dry-runs handle all operations #[clap(long, default_value = "false")] pub dry_run: bool, @@ -201,7 +249,7 @@ struct Options { #[clap(long, default_value = "false")] pub mock_espresso_live_network: bool, - /// Option to verify node js files access to upgrade stake table v2 multisig owner dry run + /// Option to verify access to Node.js files required for Safe multisig operations. #[clap(long, default_value = "false")] pub verify_node_js_files: bool, @@ -232,13 +280,14 @@ struct Options { #[clap(long, env = "ESP_TOKEN_INITIAL_GRANT_RECIPIENT_ADDRESS")] initial_token_grant_recipient: Option
, - /// The blocks per epoch + /// The number of blocks per epoch for the HotShot consensus protocol. #[clap(long, env = "ESPRESSO_SEQUENCER_BLOCKS_PER_EPOCH")] blocks_per_epoch: Option, /// The epoch start block #[clap(long, env = "ESPRESSO_SEQUENCER_EPOCH_START_BLOCK")] epoch_start_block: Option, + /// The initial supply of the tokens. #[clap(long, env = "ESP_TOKEN_INITIAL_SUPPLY")] initial_token_supply: Option, @@ -296,8 +345,9 @@ struct Options { )] timelock_operation_type: Option, - /// The target contract of the timelock operation - /// The timelock is the owner of this contract and can perform the timelock operation on it + /// The target contract for timelock operations or ownership transfers. + /// Valid values: "FeeContract", "EspToken", "LightClient", "StakeTable", "RewardClaim". + /// It's version agnostic #[clap(long, env = "ESPRESSO_TARGET_CONTRACT")] target_contract: Option, @@ -342,9 +392,6 @@ struct Options { requires = "perform_timelock_operation" )] timelock_operation_delay: Option, - /// The address of the timelock controller - #[clap(long, env = "ESPRESSO_SEQUENCER_TIMELOCK_ADDRESS")] - timelock_address: Option
, #[clap(flatten)] logging: logging::Config, @@ -616,7 +663,7 @@ async fn main() -> anyhow::Result<()> { args_builder.timelock_operation_value(timelock_operation_value); } - if opt.deploy_esp_token { + if opt.deploy_esp_token || opt.deploy_esp_token_v1 { let token_recipient = opt .initial_token_grant_recipient .expect("Must provide --initial-token-grant-recipient when deploying esp token"); @@ -661,14 +708,7 @@ async fn main() -> anyhow::Result<()> { --propose-transfer-ownership-to-timelock" ) })?; - let timelock_address = opt.timelock_address.ok_or_else(|| { - anyhow::anyhow!( - "Must provide --timelock-address when using \ - --propose-transfer-ownership-to-timelock" - ) - })?; args_builder.target_contract(target_contract); - args_builder.timelock_address(timelock_address); } // then deploy specified contracts @@ -684,11 +724,11 @@ async fn main() -> anyhow::Result<()> { } // Then deploy other contracts - if opt.deploy_fee { + if opt.deploy_fee || opt.deploy_fee_v1 { args.deploy(&mut contracts, Contract::FeeContractProxy) .await?; } - if opt.deploy_esp_token { + if opt.deploy_esp_token || opt.deploy_esp_token_v1 { args.deploy(&mut contracts, Contract::EspTokenProxy).await?; } if opt.deploy_light_client_v1 { @@ -702,14 +742,14 @@ async fn main() -> anyhow::Result<()> { args.deploy(&mut contracts, Contract::LightClientV3).await?; } // Deploy RewardClaimProxy before upgrading EspTokenV2, as the upgrade requires it - if opt.deploy_reward_claim { + if opt.deploy_reward_claim_v1 { args.deploy(&mut contracts, Contract::RewardClaimProxy) .await?; } if opt.upgrade_esp_token_v2 { args.deploy(&mut contracts, Contract::EspTokenV2).await?; } - if opt.deploy_stake_table { + if opt.deploy_stake_table || opt.deploy_stake_table_v1 { args.deploy(&mut contracts, Contract::StakeTableProxy) .await?; }