diff --git a/.env b/.env index c7d4e2eba03..eda06ff7379 100644 --- a/.env +++ b/.env @@ -155,7 +155,7 @@ ESPRESSO_NASTY_CLIENT_PORT=24011 # which ensures that all services work correctly in the demo without admin access. In a real # deployment, we would set this to the address of a multisig wallet. ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS=8626f6940e2eb28930efb4cef49b2d1f2c9c1199 -ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS=8626f6940e2eb28930efb4cef49b2d1f2c9c1199 +ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS=${ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS} # Set this to the number of blocks you would like to confirm the sequencer can reach INTEGRATION_TEST_EXPECTED_BLOCK_HEIGHT=10 @@ -176,6 +176,6 @@ ESPRESSO_OPS_TIMELOCK_ADMIN=${ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS} ESPRESSO_OPS_TIMELOCK_PROPOSERS=${ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS} ESPRESSO_OPS_TIMELOCK_EXECUTORS=${ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS} ESPRESSO_SAFE_EXIT_TIMELOCK_DELAY=1209600 -ESPRESSO_SAFE_EXIT_TIMELOCK_ADMIN=8626f6940e2eb28930efb4cef49b2d1f2c9c1199 +ESPRESSO_SAFE_EXIT_TIMELOCK_ADMIN=${ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS} ESPRESSO_SAFE_EXIT_TIMELOCK_PROPOSERS=${ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS} ESPRESSO_SAFE_EXIT_TIMELOCK_EXECUTORS=${ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS} diff --git a/.gitignore b/.gitignore index 018d308f77c..000185e3e68 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ data/*-actual.bin .env.contracts.water .env*.decaf .env.docker +.env.governance.test # Generated by nix-direnv .direnv/ diff --git a/contracts/rust/deployer/scripts/README.md b/contracts/rust/deployer/scripts/README.md new file mode 100644 index 00000000000..19564efc0b3 --- /dev/null +++ b/contracts/rust/deployer/scripts/README.md @@ -0,0 +1,69 @@ +# Governance script test + +For testing purposes, use this to deploy POS contracts, with timelock ownership, and execute various timelock flows. + +## Pre-requisites + +- `nix` installed +- in the root of the directory, enter `nix develop` + +### Build Optimization + +To avoid rebuilds during script execution, pre-build the deploy binary: + +```bash +cargo build --bin deploy +``` + +## Deploying the contracts + +1. Copy the env file + +```bash +export ENV_FILE={YOUR_ENV_FILE} +cp .env $ENV_FILE +``` + +- and replace the following fields in the `$ENV_FILE` if not deploying to a local network via anvil. + - `ESPRESSO_SEQUENCER_ETH_MNEMONIC` + - `ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS` + +- The default ops and safe exit timelock delays in this script is 30 and 60 seconds respectively. If you want to change + it then also add the following fields to the ENV_FILE: + - `OPS_DELAY` (in seconds) + - `SAFE_EXIT_DELAY` (in seconds) + +2. set the RPC_URL, ACCOUNT_INDEX and OUTPUT_FILE + +```bash +export RPC_URL={YOUR_RPC_URL} +export ACCOUNT_INDEX={YOUR_ACCOUNT_INDEX} # Optional: default value is zero +export OUTPUT_FILE={YOUR_OUTPUT_FILE} # Optional: customize output file +``` + +3. Run the script + +```bash +./contracts/rust/deployer/scripts/testnet-governance-deploy.sh --env-file $ENV_FILE +``` + +## Running the test flow + +1. Assuming the contracts are deployed and their proxy addresses are found `$OUTPUT_FILE` +2. Ensure that you have an RPC URL for the network the contracts are deployed to +3. Have your ledger connected (assumes account index = 0 otherwise set `export ACCOUNT_INDEX=YOUR_ACCOUNT_INDEX`) + +```bash +export RPC_URL={YOUR_RPC_URL} +./contracts/rust/deployer/scripts/testnet-governance-flows.sh --ledger --env-file $OUTPUT_FILE +``` + +**Note**: The `$OUTPUT_FILE` from the deploy script contains the deployed contract addresses and should be used as +`--env-file` for the flows script. + +## Notes + +- The script will prompt for confirmation before each operation +- Operations use a 30-second delay by default (configurable via OPS_DELAY env var) +- For non-localhost RPCs, you'll be prompted to confirm before proceeding +- to use a ledger with any command, use `--ledger` diff --git a/contracts/rust/deployer/scripts/testnet-governance-deploy.sh b/contracts/rust/deployer/scripts/testnet-governance-deploy.sh new file mode 100755 index 00000000000..4e7501b2261 --- /dev/null +++ b/contracts/rust/deployer/scripts/testnet-governance-deploy.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# This script deploys the contracts to testnet so that governance flows can be tested +set -euo pipefail + +# Find repo root and source .env file +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" +echo "REPO_ROOT: $REPO_ROOT" + +# Parse command line arguments +USE_LEDGER=false +ENV_FILE="" +while [[ $# -gt 0 ]]; do + case $1 in + --ledger) + USE_LEDGER=true + shift + ;; + --env-file) + ENV_FILE="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + echo "Usage: $0 [--ledger] [--env-file FILE]" + exit 1 + ;; + esac +done + +# Source env file if provided, otherwise try default .env +if [[ -n "$ENV_FILE" ]]; then + if [[ ! -f "$ENV_FILE" ]]; then + echo "Error: env file not found: $ENV_FILE" + exit 1 + fi + set -a + source "$ENV_FILE" + set +a +elif [[ -f "$REPO_ROOT/.env" ]]; then + set -a + source "$REPO_ROOT/.env" + set +a +fi + +# Unset any variables containing "PROXY_ADDRESS" to force fresh deployment +for var in $(env | grep -i "PROXY_ADDRESS" | cut -d= -f1); do + unset "$var" 2>/dev/null || true +done + + +RPC_URL="${RPC_URL:-http://localhost:8545}" +OUTPUT_FILE="${OUTPUT_FILE:-.env.governance.testnet}" +ACCOUNT_INDEX="${ACCOUNT_INDEX:-0}" +OPS_DELAY="${OPS_DELAY:-30}" # 30 seconds default +SAFE_EXIT_DELAY="${SAFE_EXIT_DELAY:-60}" # 60 seconds default + +# Helper function to check if RPC URL is localhost +is_localhost_rpc() { + local url="$1" + # Check for localhost, 127.0.0.1 + [[ "$url" =~ ^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?(/.*)?$ ]] +} + +if is_localhost_rpc "$RPC_URL"; then + unset ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS +fi + +# Function to prompt user for confirmation on real testnets +confirm() { + local message="${1:-Continue?}" + if is_localhost_rpc "$RPC_URL"; then + return 0 + fi + read -p "$message [y/N] " -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 + fi +} + +# Use hardcoded anvil addresses only for localhost, otherwise use env vars +if is_localhost_rpc "$RPC_URL"; then + ESPRESSO_OPS_TIMELOCK_PROPOSERS="0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" + ESPRESSO_OPS_TIMELOCK_EXECUTORS="0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" + ESPRESSO_SAFE_EXIT_TIMELOCK_PROPOSERS="0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" + ESPRESSO_SAFE_EXIT_TIMELOCK_EXECUTORS="0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" + ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS="0xa0Ee7A142d267C1f36714E4a8F75612F20a79720" +else + ESPRESSO_OPS_TIMELOCK_PROPOSERS="${ESPRESSO_OPS_TIMELOCK_PROPOSERS:?ESPRESSO_OPS_TIMELOCK_PROPOSERS must be set for non-localhost deployments}" + ESPRESSO_OPS_TIMELOCK_EXECUTORS="${ESPRESSO_OPS_TIMELOCK_EXECUTORS:?ESPRESSO_OPS_TIMELOCK_EXECUTORS must be set for non-localhost deployments}" + ESPRESSO_SAFE_EXIT_TIMELOCK_PROPOSERS="${ESPRESSO_SAFE_EXIT_TIMELOCK_PROPOSERS:?ESPRESSO_SAFE_EXIT_TIMELOCK_PROPOSERS must be set for non-localhost deployments}" + ESPRESSO_SAFE_EXIT_TIMELOCK_EXECUTORS="${ESPRESSO_SAFE_EXIT_TIMELOCK_EXECUTORS:?ESPRESSO_SAFE_EXIT_TIMELOCK_EXECUTORS must be set for non-localhost deployments}" + ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS="${ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS:?ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS must be set for non-localhost deployments}" +fi + +DEPLOY_CMD="cargo run --bin deploy --" +if $USE_LEDGER; then + DEPLOY_CMD="$DEPLOY_CMD --ledger" + unset ESPRESSO_SEQUENCER_ETH_MNEMONIC + unset ESPRESSO_DEPLOYER_ACCOUNT_INDEX +fi + +echo "=== Deploying Governance Contracts ===" +echo "RPC URL: $RPC_URL" +if ! is_localhost_rpc "$RPC_URL"; then + echo "WARNING: This will deploy to a non-localhost network!" + confirm "Are you sure you want to proceed with deployment?" +fi + +echo "" +echo "### Deploying Ops Timelock ###" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --deploy-ops-timelock \ + --ops-timelock-admin "$ESPRESSO_OPS_TIMELOCK_ADMIN" \ + --ops-timelock-delay "$OPS_DELAY" \ + --ops-timelock-proposers "$ESPRESSO_OPS_TIMELOCK_PROPOSERS" \ + --ops-timelock-executors "$ESPRESSO_OPS_TIMELOCK_EXECUTORS" \ + --out "$OUTPUT_FILE" + +set -a +source "${OUTPUT_FILE}" +set +a + +echo "" +echo "### Deploying Safe Exit Timelock ###" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --deploy-safe-exit-timelock \ + --safe-exit-timelock-admin "$ESPRESSO_SAFE_EXIT_TIMELOCK_ADMIN" \ + --safe-exit-timelock-delay "$SAFE_EXIT_DELAY" \ + --safe-exit-timelock-proposers "$ESPRESSO_SAFE_EXIT_TIMELOCK_PROPOSERS" \ + --safe-exit-timelock-executors "$ESPRESSO_SAFE_EXIT_TIMELOCK_EXECUTORS" \ + --out "$OUTPUT_FILE" + +set -a +source "${OUTPUT_FILE}" +set +a + +echo "" +echo "### Deploying Core Contracts (v1) ###" +# Deploy contracts without timelock ownership +BASE_ARGS=( + --rpc-url "$RPC_URL" + --account-index "$ACCOUNT_INDEX" + --multisig-pauser-address "$ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS" + --token-name "$ESP_TOKEN_NAME" + --token-symbol "$ESP_TOKEN_SYMBOL" + --initial-token-supply "$ESP_TOKEN_INITIAL_SUPPLY" + --initial-token-grant-recipient "$ESP_TOKEN_INITIAL_GRANT_RECIPIENT_ADDRESS" + --exit-escrow-period "$ESPRESSO_SEQUENCER_STAKE_TABLE_EXIT_ESCROW_PERIOD" + --mock-espresso-live-network +) + +[[ -n "${ESPRESSO_SEQUENCER_PERMISSIONED_PROVER:-}" ]] && \ + BASE_ARGS+=(--permissioned-prover "$ESPRESSO_SEQUENCER_PERMISSIONED_PROVER") + +$DEPLOY_CMD "${BASE_ARGS[@]}" \ + --deploy-light-client-v1 \ + --deploy-esp-token-v1 \ + --deploy-stake-table-v1 \ + --use-mock \ + --upgrade-light-client-v2 \ + --out "$OUTPUT_FILE" + +set -a +source "${OUTPUT_FILE}" +set +a + +echo "" +echo "### Deploying Upgrades (v2/v3) ###" +UPGRADE_OUTPUT_FILE="${OUTPUT_FILE}.upgrade" +$DEPLOY_CMD "${BASE_ARGS[@]}" \ + --deploy-reward-claim-v1 \ + --upgrade-esp-token-v2 \ + --upgrade-light-client-v3 \ + --upgrade-stake-table-v2 \ + --use-timelock-owner \ + --out "${UPGRADE_OUTPUT_FILE}" + +set -a +source "${UPGRADE_OUTPUT_FILE}" +set +a + +echo "" +echo "### Deploy Fee Contract with timelock owner" +$DEPLOY_CMD "${BASE_ARGS[@]}" \ + --deploy-fee-v1 \ + --use-timelock-owner \ + --out "$OUTPUT_FILE" + +echo "" +echo "### Verifying Deployment ###" +"${REPO_ROOT}/scripts/verify-pos-deployment.sh" --rpc-url "$RPC_URL" +echo "" +echo "Deployment complete!" \ No newline at end of file diff --git a/contracts/rust/deployer/scripts/testnet-governance-flows.sh b/contracts/rust/deployer/scripts/testnet-governance-flows.sh new file mode 100755 index 00000000000..b2e767afe3b --- /dev/null +++ b/contracts/rust/deployer/scripts/testnet-governance-flows.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# This script assumes that the contracts have already been deployed and the .env.governance.testnet file has been sourced +# It is used to test the governance flows for the contracts, specifically the timelock operations +# It tests the following flows: +# 1. Scheduling a timelock operation to update the exit escrow period +# 2. Executing a timelock operation to update the exit escrow period +# 3. Scheduling a timelock operation to cancel an operation on StakeTable +# 4. Granting the PAUSER_ROLE via timelock +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +# Parse command line arguments +USE_LEDGER=false +ENV_FILE="" +while [[ $# -gt 0 ]]; do + case $1 in + --ledger) + USE_LEDGER=true + shift + ;; + --env-file) + ENV_FILE="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + echo "Usage: $0 [--ledger] [--env-file FILE]" + exit 1 + ;; + esac +done + +# Source env file if provided, otherwise try default .env +if [[ -n "$ENV_FILE" ]]; then + if [[ ! -f "$ENV_FILE" ]]; then + echo "Error: env file not found: $ENV_FILE" + exit 1 + fi + set -a + source "$ENV_FILE" + set +a +elif [[ -f "$REPO_ROOT/.env" ]]; then + set -a + source "$REPO_ROOT/.env" + set +a +fi + +RPC_URL="${RPC_URL:-http://localhost:8545}" +ACCOUNT_INDEX="${ACCOUNT_INDEX:-0}" +OPS_DELAY="${OPS_DELAY:-30}" # 30 seconds default +SAFE_EXIT_DELAY="${SAFE_EXIT_DELAY:-60}" # 60 seconds default +NEW_ESCROW_PERIOD="${NEW_ESCROW_PERIOD:-172800}" # 2 days in seconds (86400 * 2) default + +export RUST_LOG=warn +DEPLOY_CMD="cargo run --bin deploy --" +if $USE_LEDGER; then + DEPLOY_CMD="$DEPLOY_CMD --ledger" + unset ESPRESSO_SEQUENCER_ETH_MNEMONIC + unset ESPRESSO_DEPLOYER_ACCOUNT_INDEX +fi + +SALT=$(cast keccak "$(date +%s)") + +# Helper function to check if RPC URL is localhost +is_localhost_rpc() { + local url="$1" + # Check for localhost, 127.0.0.1 + [[ "$url" =~ ^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?(/.*)?$ ]] +} + +if is_localhost_rpc "$RPC_URL"; then + unset ESPRESSO_SEQUENCER_ETH_MULTISIG_ADDRESS +fi + +# Function to prompt user for confirmation on real testnets +confirm() { + local message="${1:-Continue?}" + if is_localhost_rpc "$RPC_URL"; then + return 0 + fi + read -p "$message [y/N] " -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 + fi +} + +echo "### Test 1: Scheduling timelock operation to update exit escrow period ###" +confirm "Schedule timelock operation to update exit escrow period?" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --perform-timelock-operation \ + --timelock-operation-type schedule \ + --target-contract StakeTable \ + --function-signature "updateExitEscrowPeriod(uint64)" \ + --function-values "$NEW_ESCROW_PERIOD" \ + --timelock-operation-salt "$SALT" \ + --timelock-operation-delay "$OPS_DELAY" \ + --timelock-operation-value 0 + +echo "" +echo "Waiting for timelock delay (${OPS_DELAY} seconds)..." +sleep "$OPS_DELAY" + +echo "" +echo "### Test 2: Executing timelock operation ###" +confirm "Execute timelock operation to update exit escrow period?" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --perform-timelock-operation \ + --timelock-operation-type execute \ + --target-contract StakeTable \ + --function-signature "updateExitEscrowPeriod(uint64)" \ + --function-values "$NEW_ESCROW_PERIOD" \ + --timelock-operation-salt "$SALT" \ + --timelock-operation-delay "$OPS_DELAY" \ + --timelock-operation-value 0 + +echo "" +echo "Waiting for timelock delay (${OPS_DELAY} seconds)..." +sleep "$OPS_DELAY" + +# Verify the change +CURRENT_PERIOD=$(cast call "$ESPRESSO_SEQUENCER_STAKE_TABLE_PROXY_ADDRESS" "exitEscrowPeriod()(uint256)" --rpc-url "$RPC_URL") +echo "Exit escrow period updated to: $CURRENT_PERIOD" + +echo "" +echo "### Test 3: Scheduling then canceling an operation on StakeTable ###" +CANCEL_SALT=$(cast keccak "$(date +%s)cancel") +confirm "Schedule operation on StakeTable (to be canceled)?" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --perform-timelock-operation \ + --timelock-operation-type schedule \ + --target-contract StakeTable \ + --function-signature "updateExitEscrowPeriod(uint64)" \ + --function-values "172800" \ + --timelock-operation-salt "$CANCEL_SALT" \ + --timelock-operation-delay "$OPS_DELAY" \ + --timelock-operation-value 0 + +echo "" +echo "Perform cancel operation on StakeTable" +confirm "Cancel the scheduled operation on StakeTable?" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --perform-timelock-operation \ + --timelock-operation-type cancel \ + --target-contract StakeTable \ + --function-signature "updateExitEscrowPeriod(uint64)" \ + --function-values "172800" \ + --timelock-operation-salt "$CANCEL_SALT" \ + --timelock-operation-delay "$OPS_DELAY" \ + --timelock-operation-value 0 + +echo "" +echo "### Test 4: Granting PAUSER_ROLE via timelock ###" +PAUSER_ROLE="0x65d7a28e3265b37a6474929f336521b332c1681b933f6cb9f3376673440d862a" # keccak256("PAUSER_ROLE") +if is_localhost_rpc "$RPC_URL"; then + OLD_PAUSER="${ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS:-0xa0Ee7A142d267C1f36714E4a8F75612F20a79720}" +else + OLD_PAUSER="${ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS:?ESPRESSO_SEQUENCER_ETH_MULTISIG_PAUSER_ADDRESS must be set for non-localhost deployments}" +fi +NEW_PAUSER="0x70997970C51812dc3A010C7d01b50e0d17dc79C8" +GRANT_SALT=$(cast keccak "$(date +%s)grant") + +echo "" +echo "Schedule grant role operation on StakeTable" +confirm "Schedule grant PAUSER_ROLE operation on StakeTable?" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --perform-timelock-operation \ + --timelock-operation-type schedule \ + --target-contract StakeTable \ + --function-signature "grantRole(bytes32,address)" \ + --function-values "$PAUSER_ROLE" "$NEW_PAUSER" \ + --timelock-operation-salt "$GRANT_SALT" \ + --timelock-operation-delay "$OPS_DELAY" \ + --timelock-operation-value 0 + +echo "" +echo "Waiting for timelock delay (${OPS_DELAY} seconds)..." +sleep "$OPS_DELAY" + +echo "" +echo "Execute grant role operation on StakeTable" +confirm "Execute grant PAUSER_ROLE operation on StakeTable?" +$DEPLOY_CMD --rpc-url "$RPC_URL" --account-index "$ACCOUNT_INDEX" \ + --perform-timelock-operation \ + --timelock-operation-type execute \ + --target-contract StakeTable \ + --function-signature "grantRole(bytes32,address)" \ + --function-values "$PAUSER_ROLE" "$NEW_PAUSER" \ + --timelock-operation-salt "$GRANT_SALT" \ + --timelock-operation-delay "$OPS_DELAY" \ + --timelock-operation-value 0 + +# Verify the new pauser has the PAUSER_ROLE +if [[ "$(cast call "$ESPRESSO_SEQUENCER_STAKE_TABLE_PROXY_ADDRESS" "hasRole(bytes32,address)(bool)" "$PAUSER_ROLE" "$NEW_PAUSER" --rpc-url "$RPC_URL")" != "true" ]]; then + echo "ERROR: New pauser does not have the PAUSER_ROLE" + exit 1 +fi +# Verify the previous pauser still has the PAUSER_ROLE +if [[ "$(cast call "$ESPRESSO_SEQUENCER_STAKE_TABLE_PROXY_ADDRESS" "hasRole(bytes32,address)(bool)" "$PAUSER_ROLE" "$OLD_PAUSER" --rpc-url "$RPC_URL")" != "true" ]]; then + echo "ERROR: Previous pauser does not have the PAUSER_ROLE" + exit 1 +fi + +echo "All tests passed!" \ No newline at end of file diff --git a/contracts/rust/deployer/src/lib.rs b/contracts/rust/deployer/src/lib.rs index 672c406284f..5c74c993f36 100644 --- a/contracts/rust/deployer/src/lib.rs +++ b/contracts/rust/deployer/src/lib.rs @@ -112,6 +112,10 @@ const LIBRARY_PLACEHOLDER_ADDRESS: &str = "fffffffffffffffffffffffffffffffffffff pub const MAX_HISTORY_RETENTION_SECONDS: u32 = 864000; /// Default exit escrow period for stake table (2 days in seconds) pub const DEFAULT_EXIT_ESCROW_PERIOD_SECONDS: u64 = 172800; +/// Maximum number of retries for state checks after transactions +pub const MAX_RETRY_ATTEMPTS: u32 = 5; +/// Initial delay in milliseconds for retry exponential backoff +pub const RETRY_INITIAL_DELAY_MS: u64 = 500; /// Set of predeployed contracts. #[derive(Clone, Debug, Parser)] @@ -373,6 +377,11 @@ impl Contracts { tracing::info!("deployed {name} at {addr:#x}"); + // Cooldown after deployment to avoid rate limiting on public RPC nodes + // This helps when deploying multiple contracts in sequence + tokio::time::sleep(Duration::from_millis(1000)).await; + tracing::info!("cooldown 1000ms after deployment"); + self.0.insert(name, addr); Ok(addr) } @@ -657,10 +666,22 @@ pub async fn upgrade_light_client_v2( .await? .get_receipt() .await?; + let proxy_as_v2 = LightClientV2::new(proxy_addr, &provider); + if receipt.inner.is_success() { + // check that the upgrade is complete (with retry for RPC timing) + let is_complete = retry_until_true("LightClientProxy V2 version check", || async { + Ok(proxy_as_v2.getVersion().call().await?.majorVersion == 2) + }) + .await?; + + if !is_complete { + anyhow::bail!( + "LightClientProxy version check failed after retries: expected V2" + ); + } + // post deploy verification checks - let proxy_as_v2 = LightClientV2::new(proxy_addr, &provider); - assert_eq!(proxy_as_v2.getVersion().call().await?.majorVersion, 2); assert_eq!(proxy_as_v2.blocksPerEpoch().call().await?, blocks_per_epoch); assert_eq!( proxy_as_v2.epochStartBlock().call().await?, @@ -675,7 +696,7 @@ pub async fn upgrade_light_client_v2( U256::from(provider.get_block_number().await?) ); - tracing::info!(%lcv2_addr, "LightClientProxy successfully upgrade to: "); + tracing::info!(%lcv2_addr, "LightClientProxy successfully upgraded to V2"); tracing::info!( "blocksPerEpoch: {}", proxy_as_v2.blocksPerEpoch().call().await? @@ -796,11 +817,24 @@ pub async fn upgrade_light_client_v3( .await? .get_receipt() .await?; + + let proxy_as_v3 = LightClientV3::new(proxy_addr, &provider); + if receipt.inner.is_success() { - // post deploy verification checks - let proxy_as_v3 = LightClientV3::new(proxy_addr, &provider); - assert_eq!(proxy_as_v3.getVersion().call().await?.majorVersion, 3); - tracing::info!(%lcv3_addr, "LightClientProxy successfully upgrade to: ") + // check that the upgrade is complete (with retry for RPC timing) + let version_is_v3 = + retry_until_true("LightClientProxy V3 version check", || async { + Ok(proxy_as_v3.getVersion().call().await?.majorVersion == 3) + }) + .await?; + + if !version_is_v3 { + anyhow::bail!( + "LightClientProxy version check failed after retries: expected V3" + ); + } + + tracing::info!(%lcv3_addr, "LightClientProxy successfully upgraded to V3"); } else { tracing::error!("LightClientProxy upgrade failed: {:?}", receipt); } @@ -956,9 +990,17 @@ pub async fn upgrade_esp_token_v2( .await?; if receipt.inner.is_success() { + // check that the upgrade is complete (with retry for RPC timing) + let version_is_v2 = retry_until_true("EspTokenProxy V2 version check", || async { + Ok(proxy_as_v2.getVersion().call().await?.majorVersion == 2) + }) + .await?; + + if !version_is_v2 { + anyhow::bail!("EspTokenProxy version check failed after retries: expected V2"); + } + // post deploy verification checks - let proxy_as_v2 = EspTokenV2::new(proxy_addr, &provider); - assert_eq!(proxy_as_v2.getVersion().call().await?.majorVersion, 2); assert_eq!(proxy_as_v2.name().call().await?, "Espresso"); assert_eq!(proxy_as_v2.rewardClaim().call().await?, reward_claim_addr); tracing::info!(%v2_addr, "EspToken successfully upgraded to"); @@ -1234,11 +1276,20 @@ pub async fn upgrade_stake_table_v2( .get_receipt() .await?; + let proxy_as_v2 = StakeTableV2::new(proxy_addr, &provider); + if receipt.inner.is_success() { - // post deploy verification checks - let proxy_as_v2 = StakeTableV2::new(proxy_addr, &provider); - assert_eq!(proxy_as_v2.getVersion().call().await?.majorVersion, 2); + //TODO: check event emission instead as it's more reliable + // check that the upgrade is complete (with retry for RPC timing) + let version_is_v2 = retry_until_true("StakeTableProxy V2 version check", || async { + Ok(proxy_as_v2.getVersion().call().await?.majorVersion == 2) + }) + .await?; + if !version_is_v2 { + anyhow::bail!("StakeTableProxy version check failed after retries: expected V2"); + } + // post deploy verification checks let pauser_role = proxy_as_v2.PAUSER_ROLE().call().await?; assert!( proxy_as_v2.hasRole(pauser_role, pauser).call().await?, @@ -1446,8 +1497,33 @@ pub async fn read_proxy_impl(provider: impl Provider, addr: Address) -> Result { + // storage is readable so return true + Ok(true) + }, + Err(e) => { + tracing::debug!("Storage read failed (will retry): {}", e); + Ok(false) + }, + } + }) + .await?; + + if !is_readable { + anyhow::bail!( + "Proxy implementation storage is not readable after retries at address {addr:#x}" + ); + } + + // Final read to get the actual address (we know it's readable now) let storage = provider.get_storage_at(addr, impl_slot).await?; - Ok(Address::from_slice(&storage.to_be_bytes_vec()[12..])) + let impl_addr = Address::from_slice(&storage.to_be_bytes_vec()[12..]); + + Ok(impl_addr) } pub async fn is_contract(provider: impl Provider, address: Address) -> Result { @@ -1649,6 +1725,49 @@ pub fn encode_function_call(signature: &str, args: Vec) -> Result Ok(data) } +/// retry helper for checking state after transactions +/// Retries up to 5 times with exponential backoff (500ms, 1s, 2s, 4s, 8s) +/// Parameters: +/// - `check_name`: the name of the check +/// - `check_fn`: the function to check, must return `Result` +/// +/// Returns: +/// - `Ok(true)` if the check passed, `Ok(false)` +pub async fn retry_until_true(check_name: &str, mut check_fn: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for attempt in 0..MAX_RETRY_ATTEMPTS { + match check_fn().await { + Ok(true) => return Ok(true), + Ok(false) | Err(_) if attempt < MAX_RETRY_ATTEMPTS - 1 => { + let delay_ms = RETRY_INITIAL_DELAY_MS * (1 << attempt); + tracing::warn!("{} not ready, retrying in {}ms...", check_name, delay_ms); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + }, + Ok(false) => { + tracing::error!( + "{} not ready after {} attempts, returning false", + check_name, + MAX_RETRY_ATTEMPTS + ); + return Ok(false); + }, + Err(e) => { + tracing::error!( + "{} not ready after {} attempts, (treating as not ready): {e:#}", + check_name, + MAX_RETRY_ATTEMPTS + ); + return Ok(false); + }, + } + } + // should never reach here, but defensive fallback to return false + Ok(false) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -1723,7 +1842,8 @@ mod tests { let fee_contract = FeeContract::deploy(&provider).await?; let init_data = fee_contract.initialize(deployer).calldata().clone(); - let proxy = ERC1967Proxy::deploy(&provider, *fee_contract.address(), init_data).await?; + let proxy = + ERC1967Proxy::deploy(&provider, *fee_contract.address(), init_data.clone()).await?; assert!(is_proxy_contract(&provider, *proxy.address()).await?); assert!(!is_proxy_contract(&provider, *fee_contract.address()).await?); diff --git a/contracts/rust/deployer/src/proposals/timelock.rs b/contracts/rust/deployer/src/proposals/timelock.rs index a0d31940141..8019b2cebcb 100644 --- a/contracts/rust/deployer/src/proposals/timelock.rs +++ b/contracts/rust/deployer/src/proposals/timelock.rs @@ -9,7 +9,7 @@ use hotshot_contract_adapter::sol_types::{ EspToken, FeeContract, LightClient, OpsTimelock, RewardClaim, SafeExitTimelock, StakeTable, }; -use crate::{Contract, Contracts, OwnableContract}; +use crate::{retry_until_true, Contract, Contracts, OwnableContract}; /// Data structure for timelock operations #[derive(Debug, Clone)] @@ -189,6 +189,17 @@ impl TimelockContract { } } + pub async fn is_operation_canceled( + &self, + operation_id: B256, + provider: &impl Provider, + ) -> Result { + let pending = self.is_operation_pending(operation_id, provider).await?; + let done = self.is_operation_done(operation_id, provider).await?; + // it's canceled if it's not pending and not done + Ok(!pending && !done) + } + pub async fn execute( &self, operation: TimelockOperationData, @@ -361,12 +372,17 @@ pub async fn schedule_timelock_operation( anyhow::bail!("tx failed: {:?}", receipt); } - // check that the tx is scheduled - if !(timelock - .is_operation_pending(operation_id, &provider) - .await? - || timelock.is_operation_ready(operation_id, &provider).await?) - { + // check that the tx is scheduled (with retry for RPC timing) + let check_name = format!("Schedule operation {}", operation_id); + let is_scheduled = retry_until_true(&check_name, || async { + Ok(timelock + .is_operation_pending(operation_id, &provider) + .await? + || timelock.is_operation_ready(operation_id, &provider).await?) + }) + .await?; + + if !is_scheduled { anyhow::bail!("tx not correctly scheduled: {}", operation_id); } tracing::info!("tx scheduled with id: {}", operation_id); @@ -398,8 +414,14 @@ pub async fn execute_timelock_operation( anyhow::bail!("tx failed: {:?}", receipt); } - // check that the tx is executed - if !timelock.is_operation_done(operation_id, &provider).await? { + // check that the tx is executed (with retry for RPC timing) + let check_name = format!("Execute operation {}", operation_id); + let is_done = retry_until_true(&check_name, || async { + timelock.is_operation_done(operation_id, &provider).await + }) + .await?; + + if !is_done { anyhow::bail!("tx not correctly executed: {}", operation_id); } tracing::info!("tx executed with id: {}", operation_id); @@ -428,6 +450,20 @@ pub async fn cancel_timelock_operation( if !receipt.inner.is_success() { anyhow::bail!("tx failed: {:?}", receipt); } + + // check that the tx is cancelled (with retry for RPC timing) + let check_name = format!("Cancel operation {}", operation_id); + let is_cancelled = retry_until_true(&check_name, || async { + timelock + .is_operation_canceled(operation_id, &provider) + .await + }) + .await?; + + if !is_cancelled { + anyhow::bail!("tx not correctly cancelled: {}", operation_id); + } + tracing::info!("tx cancelled with id: {}", operation_id); Ok(operation_id) } diff --git a/flake.nix b/flake.nix index 9b6036e488e..7ab79cd71ac 100644 --- a/flake.nix +++ b/flake.nix @@ -255,6 +255,7 @@ libusb1 yarn mdbook + bc go golangci-lint diff --git a/sequencer/src/bin/deploy.rs b/sequencer/src/bin/deploy.rs index ebc7a851b00..d5ad319f6e7 100644 --- a/sequencer/src/bin/deploy.rs +++ b/sequencer/src/bin/deploy.rs @@ -377,7 +377,8 @@ struct Options { #[clap( long, env = "ESPRESSO_TIMELOCK_OPERATION_FUNCTION_VALUES", - requires = "perform_timelock_operation" + requires = "perform_timelock_operation", + num_args = 0.. )] function_values: Option>, @@ -646,13 +647,11 @@ async fn main() -> anyhow::Result<()> { ) })?; args_builder.timelock_operation_function_signature(function_signature); - let function_values = opt.function_values.ok_or_else(|| { - anyhow::anyhow!( - "Must provide --function-values or ESPRESSO_TIMELOCK_OPERATION_FUNCTION_VALUES \ - env var when performing timelock operation" - ) - })?; + + // allow empty function_values for functions with no parameters + let function_values = opt.function_values.unwrap_or_default(); args_builder.timelock_operation_function_values(function_values); + let timelock_operation_salt = opt.timelock_operation_salt.ok_or_else(|| { anyhow::anyhow!( "Must provide --timelock-operation-salt or ESPRESSO_TIMELOCK_OPERATION_SALT env \