diff --git a/.gitignore b/.gitignore index e45c89d85..6952713f3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,18 @@ artifacts .DS_Store .certora_internal/* + +script/data/etherfi-nodes.json +script/data/operators.json + +# Generated operation files +script/operations/auto-compound/*-link-schedule.json +script/operations/auto-compound/*-link-execute.json +script/operations/auto-compound/*-consolidation.json +script/operations/auto-compound/validators.json +script/operations/consolidations/*-txns.json +script/operations/data/*.json +simulation-*.log +tenderly-simulation-*.log +__pycache__/ +*.pyc diff --git a/audits/2026.01.20 - Certora - Liquid-Refer,KING,Cross Pod Approval.pdf b/audits/2026.01.20 - Certora - Liquid-Refer,KING,Cross Pod Approval.pdf new file mode 100644 index 000000000..d07f16ac1 Binary files /dev/null and b/audits/2026.01.20 - Certora - Liquid-Refer,KING,Cross Pod Approval.pdf differ diff --git a/audits/2026.01.29 - Certora - Reaudit Core Contracts.pdf b/audits/2026.01.29 - Certora - Reaudit Core Contracts.pdf new file mode 100644 index 000000000..770354e4c Binary files /dev/null and b/audits/2026.01.29 - Certora - Reaudit Core Contracts.pdf differ diff --git a/deployment/RestakingRewardsRouter/2026-01-19-20-59-47.json b/deployment/RestakingRewardsRouter/2026-01-19-20-59-47.json new file mode 100644 index 000000000..c7fe7a596 --- /dev/null +++ b/deployment/RestakingRewardsRouter/2026-01-19-20-59-47.json @@ -0,0 +1,12 @@ +{ + "contractName": "UUPSProxy", + "deploymentParameters": { + "factory": "0x356d1B83970CeF2018F2c9337cDdb67dff5AEF99", + "salt": "0x1a10a60fc25f1c7f7052123edbe683ed2524943d000000000000000000000000", + "constructorArgs": { + "_implementation": "0xcB6e9a5943946307815eaDF3BEDC49fE30290CA8", + "_data": "0x8129fc1c" + } + }, + "deployedAddress": "0x89E45081437c959A827d2027135bC201Ab33a2C8" +} \ No newline at end of file diff --git a/deployment/RestakingRewardsRouterImplementation/2026-01-19-20-59-47.json b/deployment/RestakingRewardsRouterImplementation/2026-01-19-20-59-47.json new file mode 100644 index 000000000..d4b667676 --- /dev/null +++ b/deployment/RestakingRewardsRouterImplementation/2026-01-19-20-59-47.json @@ -0,0 +1,13 @@ +{ + "contractName": "RestakingRewardsRouterImplementation", + "deploymentParameters": { + "factory": "0x356d1B83970CeF2018F2c9337cDdb67dff5AEF99", + "salt": "0x1a10a60fc25f1c7f7052123edbe683ed2524943d000000000000000000000000", + "constructorArgs": { + "_roleRegistry": "0x62247D29B4B9BECf4BB73E0c722cf6445cfC7cE9", + "_rewardTokenAddress": "0xec53bF9167f50cDEB3Ae105f56099aaaB9061F83", + "_liquidityPool": "0x308861A430be4cce5502d0A12724771Fc6DaF216" + } + }, + "deployedAddress": "0xcB6e9a5943946307815eaDF3BEDC49fE30290CA8" +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 6d40453d8..ae78dd84f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -9,7 +9,13 @@ fs_permissions = [ { access = "read-write", path = "./deployment" }, { access = "read-write", path = "./operations" }, { access = "read", path = "./script/el-exits/val-consolidations" }, - { access = "read", path = "./script/operator-management" } + { access = "read", path = "./script/operator-management" }, + { access = "read-write", path = "./script/operations/auto-compound" }, + { access = "read-write", path = "./script/operations/consolidations" }, + { access = "read-write", path = "./script/operations/exits" }, + { access = "read-write", path = "./script/operations/utils" }, + { access = "read", path = "./script/operations/data" }, + { access = "read", path = "./" } ] gas_reports = ["*"] optimizer_runs = 1500 diff --git a/script/deploys/Deployed.s.sol b/script/deploys/Deployed.s.sol index dd186b621..d522765ac 100644 --- a/script/deploys/Deployed.s.sol +++ b/script/deploys/Deployed.s.sol @@ -27,6 +27,7 @@ contract Deployed { address public constant ETHERFI_NODE_BEACON = 0x3c55986Cfee455E2533F4D29006634EcF9B7c03F; address public constant ETHERFI_NODES_MANAGER = 0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F; address public constant ETHERFI_REDEMPTION_MANAGER = 0xDadEf1fFBFeaAB4f68A9fD181395F68b4e4E7Ae0; + address public constant WEETH_WITHDRAW_ADAPTER = 0xFbfe6b9cEe0E555Bad7e2E7309EFFC75200cBE38; // Oracle address public constant ETHERFI_ORACLE = 0x57AaF0004C716388B21795431CD7D5f9D3Bb6a41; @@ -47,9 +48,11 @@ contract Deployed { address public constant ETHERFI_REWARDS_ROUTER = 0x73f7b1184B5cD361cC0f7654998953E2a251dd58; address public constant ETHERFI_OPERATION_PARAMETERS = 0xD0Ff8996DB4bDB46870b7E833b7532f484fEad1A; address public constant ETHERFI_RATE_LIMITER = 0x6C7c54cfC2225fA985cD25F04d923B93c60a02F8; + address public constant RESTAKING_REWARDS_ROUTER = 0x89E45081437c959A827d2027135bC201Ab33a2C8; address public constant EARLY_ADOPTER_POOL = 0x7623e9DC0DA6FF821ddb9EbABA794054E078f8c4; address public constant CUMULATIVE_MERKLE_REWARDS_DISTRIBUTOR = 0x9A8c5046a290664Bf42D065d33512fe403484534; + address public constant TREASURY = 0x0c83EAe1FE72c390A02E426572854931EefF93BA; // role registry & multi-sig address public constant ROLE_REGISTRY = 0x62247D29B4B9BECf4BB73E0c722cf6445cfC7cE9; @@ -59,6 +62,14 @@ contract Deployed { address public constant ETHERFI_UPGRADE_ADMIN = 0xcdd57D11476c22d265722F68390b036f3DA48c21; // upgrade admin address public constant ADMIN_EOA = 0x12582A27E5e19492b4FcD194a60F8f5e1aa31B0F; // admin eoa + address public constant AVS_OPERATOR_1 = 0xDd777e5158Cb11DB71B4AF93C75A96eA11A2A615; + address public constant AVS_OPERATOR_2 = 0x2c7cB7d5dC4aF9caEE654553a144C76F10D4b320; + + // Utilities + address constant EIGENLAYER_POD_MANAGER = 0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338; + address constant EIGENLAYER_DELEGATION_MANAGER = 0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A; + address constant EIGENLAYER_REWARDS_COORDINATOR = 0x7750d328b314EfFa365A0402CcfD489B80B0adda; + mapping(address => address) public timelockToAdmin; constructor() { diff --git a/script/el-exits/ELExitsTransactions.s.sol b/script/el-exits/ELExitsTransactions.s.sol index 8aa400c1f..76a0ab7a7 100644 --- a/script/el-exits/ELExitsTransactions.s.sol +++ b/script/el-exits/ELExitsTransactions.s.sol @@ -99,24 +99,24 @@ contract ElExitsTransactions is Script { uint256 MIN_DELAY_TIMELOCK = 259200; // 72 hours // External calls selectors - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_ONE = 0x9a15bf92; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_TWO = 0xa9059cbb; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_THREE = 0x3ccc861d; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_FOUR = 0xeea9064b; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_FIVE = 0x7f548071; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_SIX = 0xda8be864; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_SEVEN = 0x0dd8dd02; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_EIGHT = 0x33404396; - bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_NINE = 0x9435bb43; + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_ONE = 0x9a15bf92; // claim(uint256,bytes32[],bytes) + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_TWO = 0xa9059cbb; // transfer(address,uint256) + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_THREE = 0x3ccc861d; // processClaim((uint32,uint32,bytes,(address,bytes32),uint32[],bytes[],(address,uint256)[]),address) + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_FOUR = 0xeea9064b; // delegateTo(address,(bytes,uint256),bytes32) + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_FIVE = 0x7f548071; // delegateToBySignature(address,address,(bytes,uint256),(bytes,uint256),bytes32 + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_SIX = 0xda8be864; // undelegate(address) + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_SEVEN = 0x0dd8dd02; // queueWithdrawals((address[],uint256[],address)[]) + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_EIGHT = 0x33404396; // completeQueuedWithdrawals((address,address,address,uint256,uint32,address[],uint256[])[],address[][],uint256[],bool[]) + bytes4 UPDATE_ALLOWED_FORWARDED_EXTERNAL_CALLS_SELECTOR_NINE = 0x9435bb43; // completeQueuedWithdrawals((address,address,address,uint256,uint32,address[],uint256[])[],address[][],bool[]) // Eigenpod calls selectors - bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_ONE = 0x88676cad; - bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_TWO = 0xf074ba62; - bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_THREE = 0x039157d2; - bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_FOUR = 0x3f65cf19; - bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_FIVE = 0xc4907442; - bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_SIX = 0x0dd8dd02; - bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_SEVEN = 0x9435bb43; + bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_ONE = 0x88676cad; // startCheckpoint(bool) + bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_TWO = 0xf074ba62; // verifyCheckpointProofs((bytes32,bytes),(bytes32,bytes32,bytes)[]) + bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_THREE = 0x039157d2; // verifyStaleBalance(uint64,(bytes32,bytes),(bytes32[],bytes)) + bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_FOUR = 0x3f65cf19; // verifyWithdrawalCredentials(uint64,(bytes32,bytes),uint40[],bytes[],bytes32[][]) + bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_FIVE = 0xc4907442; // withdrawRestakedBeaconChainETH(address,uint256) + bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_SIX = 0x0dd8dd02; // queueWithdrawals((address[],uint256[],address)[]) + bytes4 UPDATE_ALLOWED_FORWARDED_EIGENPOD_CALLS_SELECTOR_SEVEN = 0x9435bb43; // completeQueuedWithdrawals((address,address,address,uint256,uint32,address[],uint256[])[],address[][],bool[]) //-------------------------------------------------------------------------------------- diff --git a/script/el-exits/val-consolidations/topUpFork.s.sol b/script/el-exits/val-consolidations/topUpFork.s.sol index 44e9ebadf..ad456c4e6 100644 --- a/script/el-exits/val-consolidations/topUpFork.s.sol +++ b/script/el-exits/val-consolidations/topUpFork.s.sol @@ -43,8 +43,6 @@ contract TopUpFork is Script, Deployed, Utils, ArrayTestHelper { EtherFiAdmin constant etherFiAdminInstance = EtherFiAdmin(payable(ETHERFI_ADMIN)); WithdrawRequestNFT constant withdrawRequestNFTInstance = WithdrawRequestNFT(payable(WITHDRAW_REQUEST_NFT)); address constant NODE_ADDRESS = 0xfbD914e11dF3DB8f475ae9C36ED46eE0c48f6B79; - address constant AVS_OPERATOR_1 = 0xDd777e5158Cb11DB71B4AF93C75A96eA11A2A615; - address constant AVS_OPERATOR_2 = 0x2c7cB7d5dC4aF9caEE654553a144C76F10D4b320; uint256 constant BID_ID = 110766; bytes constant PUBKEY = hex"a538a38970260348b6258eec086b932a76d369c96b5c87de5645807657c6128312e0c76bcd9987469ffe16d425bc971e"; diff --git a/script/operations/README.md b/script/operations/README.md new file mode 100644 index 000000000..eca1fd74f --- /dev/null +++ b/script/operations/README.md @@ -0,0 +1,422 @@ +# Operations Tools + +Tools for EtherFi validator operations including auto-compounding, consolidation, and exits. + +## Directory Structure + +``` +script/operations/ +├── README.md # This file +├── auto-compound/ +│ ├── AutoCompound.s.sol # Auto-compound workflow script +│ ├── query_validators.py # Query validators from DB +│ └── txns/ # Generated transaction files (gitignored) +├── consolidations/ +│ ├── ConsolidateToTarget.s.sol # Consolidate to target script +│ ├── ConsolidationTransactions.s.sol # General consolidation script +│ └── GnosisConsolidationLib.sol # Consolidation helper library +├── exits/ +│ └── ValidatorExit.s.sol # EL-triggered exit script +├── utils/ +│ ├── simulate.py # Transaction simulation tool +│ ├── SimulateTransactions.s.sol # Forge simulation script +│ └── export_db_data.py # Export DB data to JSON +└── data/ + └── (generated JSON files) +``` + +## Prerequisites + +1. **Environment Variables** (in `.env` file at project root): + ```bash + MAINNET_RPC_URL=https://... + VALIDATOR_DB=postgresql://... + TENDERLY_API_ACCESS_TOKEN=... # For Tenderly simulation + TENDERLY_API_URL=https://api.tenderly.co/api/v1/account/{slug}/project/{slug}/ + ``` + +2. **Python Dependencies**: + ```bash + pip install psycopg2-binary python-dotenv requests + ``` + +--- + +## Workflow 1: Auto-Compound Validators (0x01 → 0x02) + +Convert validators from 0x01 (BLS) to 0x02 (auto-compounding) withdrawal credentials. + +### Step 1: Query Validators from Database + +Run from project root: + +```bash +# List all operators with validator counts +python3 script/operations/auto-compound/query_validators.py --list-operators + +# Query 50 validators for an operator (by name) +python3 script/operations/auto-compound/query_validators.py \ + --operator "Validation Cloud" \ + --count 50 \ + --output script/operations/auto-compound/validators.json + +# Query by operator address +python3 script/operations/auto-compound/query_validators.py \ + --operator-address 0xf92204022cdf7ee0763ef794f69427a9dd9a7834 \ + --count 100 \ + --output script/operations/auto-compound/validators.json + +# Include already consolidated validators (for debugging) +python3 script/operations/auto-compound/query_validators.py \ + --operator "Infstones" \ + --count 50 \ + --include-consolidated \ + --verbose + +``` + +**Query Options:** + +| Option | Description | +|--------|-------------| +| `--operator` | Operator name (e.g., "Validation Cloud") | +| `--operator-address` | Operator address (e.g., 0x...) | +| `--count` | Number of validators to query (default: 50) | +| `--output` | Output JSON file path | +| `--include-consolidated` | Include validators already consolidated (0x02) | +| `--verbose` | Show detailed filtering information | + +### Step 2: Generate Transactions + +```bash +# Basic usage (validators.json in auto-compound directory) +JSON_FILE=validators.json forge script \ + script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \ + --fork-url $MAINNET_RPC_URL -vvvv + +# Custom output filename +JSON_FILE=validators.json OUTPUT_FILE=my-txns.json forge script \ + script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \ + --fork-url $MAINNET_RPC_URL -vvvv + +# Custom batch size (validators per transaction) +JSON_FILE=validators.json BATCH_SIZE=25 forge script \ + script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \ + --fork-url $MAINNET_RPC_URL -vvvv + +# Raw JSON output (instead of Gnosis Safe format) +JSON_FILE=validators.json OUTPUT_FORMAT=raw forge script \ + script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \ + --fork-url $MAINNET_RPC_URL -vvvv + +# With Safe nonce for transaction hash verification +JSON_FILE=validators.json SAFE_NONCE=42 forge script \ + script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \ + --fork-url $MAINNET_RPC_URL -vvvv +``` + +**Safe Transaction Hash Output:** + +When `SAFE_NONCE` is provided, the script outputs EIP-712 signing data for each generated transaction: + +``` +=== EIP-712 SIGNING DATA: link-schedule.json === +Nonce: 42 +Domain Separator: 0x1234... +SafeTx Hash: 0xabcd... +Message Hash (to sign): 0x5678... + +=== EIP-712 SIGNING DATA: link-execute.json === +Nonce: 43 +Domain Separator: 0x1234... +SafeTx Hash: 0xefgh... +Message Hash (to sign): 0x9012... + +=== EIP-712 SIGNING DATA: consolidation.json === +Nonce: 44 +Domain Separator: 0x1234... +SafeTx Hash: 0xijkl... +Message Hash (to sign): 0x3456... +``` + +Nonces are assigned sequentially: schedule (N), execute (N+1), consolidation (N+2). + +**Environment Variables:** + +| Variable | Description | Default | +|----------|-------------|---------| +| `JSON_FILE` | Input JSON file with validators | Required | +| `OUTPUT_FILE` | Output filename | `auto-compound-txns.json` | +| `BATCH_SIZE` | Validators per transaction | `50` | +| `OUTPUT_FORMAT` | `gnosis` or `raw` | `gnosis` | +| `SAFE_ADDRESS` | Gnosis Safe address | Operating Admin | +| `CHAIN_ID` | Chain ID for transaction | `1` | +| `SAFE_NONCE` | Starting Safe nonce for tx hash computation | `0` | + +The script automatically: +- Detects unlinked validators +- Generates linking transactions (if needed) +- Generates consolidation transactions + +**Output Files** (in `script/operations/auto-compound/txns/` directory): + +When validators need linking (with `SAFE_NONCE=N`): +- `N-link-schedule.json` - Timelock schedule transaction (nonce N) +- `N+1-link-execute.json` - Timelock execute transaction (nonce N+1) +- `N+2-consolidation.json`, `N+3-consolidation.json`, ... - One consolidation transaction per EigenPod + +When all validators are already linked: +- `N-consolidation.json`, `N+1-consolidation.json`, ... - One consolidation transaction per EigenPod + +### Step 3: Execute Transactions + +**If validators need linking:** +1. Import `txns/N-link-schedule.json` into Gnosis Safe Transaction Builder +2. Execute the schedule transaction +3. Wait 8 hours for timelock delay +4. Import `txns/N+1-link-execute.json` and execute +5. Import each `txns/N+2-consolidation.json`, `txns/N+3-consolidation.json`, etc. and execute + +**If all validators are already linked:** +1. Import each consolidation JSON file from `txns/` into Gnosis Safe Transaction Builder +2. Execute the consolidation transactions (one per EigenPod) + +### Complete Example: Auto-Compound 50 Validation Cloud Validators + +**Option A: One-liner script (recommended)** + +```bash +./script/operations/auto-compound/run-auto-compound.sh \ + --operator "Validation Cloud" \ + --count 50 \ + --nonce 42 +``` + +This script automatically: +1. Creates output directory: `validation_cloud_50_YYYYMMDD-HHMMSS/` +2. Queries validators from database +3. Generates transactions with Safe nonce +4. Simulates on Tenderly + +**Option B: Manual steps** + +```bash +# 1. Query validators +python3 script/operations/auto-compound/query_validators.py \ + --operator "Validation Cloud" \ + --count 50 \ + --output script/operations/auto-compound/validators.json + +# 2. Generate transactions +JSON_FILE=validators.json SAFE_NONCE=42 forge script \ + script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \ + --fork-url $MAINNET_RPC_URL -vvvv + +# 3. Simulate on Tenderly (optional but recommended) +# Note: Replace N with your actual SAFE_NONCE value +python3 script/operations/utils/simulate.py --tenderly \ + --schedule script/operations/auto-compound/txns/N-link-schedule.json \ + --execute script/operations/auto-compound/txns/N+1-link-execute.json \ + --then script/operations/auto-compound/txns/N+2-consolidation.json \ + --delay 8h \ + --vnet-name "ValidationCloud-AutoCompound" + +# 4. Execute on mainnet via Gnosis Safe +# - Import txns/N-link-schedule.json → Execute +# - Wait 8 hours +# - Import txns/N+1-link-execute.json → Execute +# - Import txns/N+2-consolidation.json (and subsequent) → Execute +``` + +--- + +## Workflow 2: Consolidate to Target Validator + +Consolidate multiple validators to a single target validator (same EigenPod). + +### Generate Transactions + +```bash +JSON_FILE=validators.json TARGET_PUBKEY=0x... forge script \ + script/operations/consolidations/ConsolidateToTarget.s.sol:ConsolidateToTarget \ + --fork-url $MAINNET_RPC_URL -vvvv +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `JSON_FILE` | Input JSON file with validators | Required | +| `TARGET_PUBKEY` | 48-byte target validator pubkey | Required | +| `OUTPUT_FILE` | Output filename | `consolidate-to-target-txns.json` | +| `BATCH_SIZE` | Validators per transaction | `50` | +| `OUTPUT_FORMAT` | `gnosis` or `raw` | `gnosis` | + +--- + +## Workflow 3: Validator Exits (EL-Triggered) + +Generate EL-triggered exit transactions for validators. + +### Generate Exit Transaction + +```bash +VALIDATOR_PUBKEY=0x... forge script \ + script/operations/exits/ValidatorExit.s.sol:ValidatorExit \ + --fork-url $MAINNET_RPC_URL -vvvv +``` + +--- + +## Transaction Simulation + +Simulate timelock-gated transactions before execution. Run from project root. + +### Simulation Modes + +The simulation tool supports two transaction input modes: + +| Mode | Arguments | Description | +|------|-----------|-------------| +| Simple | `--txns` | Single transaction file (no timelock) | +| Timelock | `--schedule` + `--execute` | Schedule → Time Warp → Execute workflow | + +### Using Forge (Local Fork) + +```bash +# Simple: Single transaction file (replace N with your nonce) +python3 script/operations/utils/simulate.py \ + --txns script/operations/auto-compound/txns/N-consolidation.json + +# Timelock: Schedule + Execute with 8h delay +python3 script/operations/utils/simulate.py \ + --schedule script/operations/auto-compound/txns/N-link-schedule.json \ + --execute script/operations/auto-compound/txns/N+1-link-execute.json \ + --delay 8h + +# Full workflow: Schedule → Execute → Follow-up consolidation +python3 script/operations/utils/simulate.py \ + --schedule script/operations/auto-compound/txns/N-link-schedule.json \ + --execute script/operations/auto-compound/txns/N+1-link-execute.json \ + --then script/operations/auto-compound/txns/N+2-consolidation.json \ + --delay 8h +``` + +### Using Tenderly Virtual Testnet + +Tenderly Virtual Testnets provide persistent simulation environments with shareable URLs. + +```bash +# List existing Virtual Testnets +python3 script/operations/utils/simulate.py --tenderly --list-vnets + +# Create new VNet and run full auto-compound workflow (replace N with your nonce) +python3 script/operations/utils/simulate.py --tenderly \ + --schedule script/operations/auto-compound/txns/N-link-schedule.json \ + --execute script/operations/auto-compound/txns/N+1-link-execute.json \ + --then script/operations/auto-compound/txns/N+2-consolidation.json \ + --delay 8h \ + --vnet-name "AutoCompound-Test" + +# Use existing VNet (continue from previous simulation) +python3 script/operations/utils/simulate.py --tenderly \ + --vnet-id 0a7305e5-2654-481c-a2cf-ea2886404ac3 \ + --txns script/operations/auto-compound/txns/N-consolidation.json + +# Simple consolidation on new VNet +python3 script/operations/utils/simulate.py --tenderly \ + --txns script/operations/auto-compound/txns/N-consolidation.json \ + --vnet-name "Consolidation-Test" +``` + +### Simulation CLI Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--txns` | `-t` | Simple transaction file (no timelock) | +| `--schedule` | `-s` | Schedule transaction file (phase 1) | +| `--execute` | `-e` | Execute transaction file (phase 2) | +| `--then` | | Follow-up transaction file (phase 3) | +| `--delay` | `-d` | Timelock delay (e.g., `8h`, `72h`, `1d`, `28800`) | +| `--tenderly` | | Use Tenderly Virtual Testnet | +| `--list-vnets` | | List existing Virtual Testnets | +| `--vnet-id` | | Use existing VNet by ID | +| `--vnet-name` | | Display name for new VNet | +| `--rpc-url` | `-r` | Custom RPC URL (default: `$MAINNET_RPC_URL`) | +| `--safe-address` | | Custom Gnosis Safe address | + +--- + +## Utility Scripts + +### Export Database Data + +Export operator and node data for Solidity scripts. Run from project root: + +```bash +python3 script/operations/utils/export_db_data.py # Export all +python3 script/operations/utils/export_db_data.py --operators-only # Export only operators +python3 script/operations/utils/export_db_data.py --nodes-only # Export only nodes +``` + +--- + +## Gnosis Safe JSON Format + +Generated transactions use this format: + +```json +{ + "chainId": "1", + "safeAddress": "0x2aCA71020De61bb532008049e1Bd41E451aE8AdC", + "meta": { + "txBuilderVersion": "1.16.5" + }, + "transactions": [ + { + "to": "0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F", + "value": "0", + "data": "0x..." + } + ] +} +``` + +Import into Gnosis Safe via Transaction Builder app. + +--- + +## Key Contract Addresses (Mainnet) + +| Contract | Address | +|----------|---------| +| EtherFiNodesManager | `0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F` | +| Operating Timelock | `0xcD425f44758a08BaAB3C4908f3e3dE5776e45d7a` | +| Operating Admin (Safe) | `0x2aCA71020De61bb532008049e1Bd41E451aE8AdC` | +| Role Registry | `0x62247D29B4B9BECf4BB73E0c722cf6445cfC7cE9` | + +--- + +## Troubleshooting + +### "Call to non-contract address" or "Node has no pod" + +This error occurs when validators are not linked in `EtherFiNodesManager`. The `AutoCompound.s.sol` script automatically detects this and generates linking transactions. + +### "VALIDATOR_DB not set" + +Set the PostgreSQL connection string: +```bash +export VALIDATOR_DB="postgresql://user:pass@host:5432/database" +``` + +### "MAINNET_RPC_URL not set" + +Set the RPC URL: +```bash +export MAINNET_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY" +``` + +### Timelock Delay + +The Operating Timelock has an 8-hour (28800 seconds) delay. After scheduling a transaction, you must wait before executing. diff --git a/script/operations/auto-compound/AutoCompound.s.sol b/script/operations/auto-compound/AutoCompound.s.sol new file mode 100644 index 000000000..a1dad4729 --- /dev/null +++ b/script/operations/auto-compound/AutoCompound.s.sol @@ -0,0 +1,706 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import "forge-std/StdJson.sol"; +import "../../utils/utils.sol"; +import "../../utils/GnosisTxGeneratorLib.sol"; +import "../../utils/StringHelpers.sol"; +import "../../utils/ValidatorHelpers.sol"; +import "../../utils/SafeTxHashLib.sol"; +import "../../../src/interfaces/IEtherFiNodesManager.sol"; +import "../../../src/interfaces/IEtherFiNode.sol"; +import "../../../src/eigenlayer-interfaces/IEigenPod.sol"; +import "../consolidations/GnosisConsolidationLib.sol"; +import "@openzeppelin/contracts/governance/TimelockController.sol"; +import "../../../src/EtherFiTimelock.sol"; +/** + * @title AutoCompound + * @notice Generates auto-compounding (0x02) consolidation transactions grouped by EigenPod + * @dev Automatically detects unlinked validators and generates linking transactions via timelock. + * Groups validators by withdrawal credentials (EigenPod) and creates separate consolidation + * transactions for each EigenPod group. + * + * Usage: + * JSON_FILE=validators.json SAFE_NONCE=42 forge script \ + * script/operations/auto-compound/txns/AutoCompound.s.sol:AutoCompound \ + * --fork-url $MAINNET_RPC_URL -vvvv + * + * Environment Variables: + * - JSON_FILE: Path to JSON file with validator data (required) + * - OUTPUT_FILE: Output filename (default: auto-compound-txns.json) + * - BATCH_SIZE: Number of validators per EigenPod transaction (default: 50) + * - OUTPUT_FORMAT: "gnosis" or "raw" (default: gnosis) + * - SAFE_ADDRESS: Gnosis Safe address (default: ETHERFI_OPERATING_ADMIN) + * - CHAIN_ID: Chain ID for transaction (default: 1) + * - SAFE_NONCE: Starting nonce for Safe tx hash computation (default: 0) + * + * Output Files (when linking is needed): + * - *-link-schedule.json: Timelock schedule transaction (nonce N) + * - *-link-execute.json: Timelock execute transaction (nonce N+1) + * - *-consolidation.json: Array of consolidation transactions (nonces N+2, N+3, ...) + * + * The script groups validators by EigenPod (withdrawal credentials) and generates + * separate consolidation transactions for each group. All transactions are output + * in a single JSON array that can be processed by simulation tools. + */ +contract AutoCompound is Script, Utils { + using stdJson for string; + using StringHelpers for uint256; + using StringHelpers for address; + using StringHelpers for bytes; + using StringHelpers for bytes32; + + // === MAINNET CONTRACT ADDRESSES === + IEtherFiNodesManager constant nodesManager = IEtherFiNodesManager(ETHERFI_NODES_MANAGER); + EtherFiTimelock constant etherFiTimelock = EtherFiTimelock(payable(OPERATING_TIMELOCK)); + + // Note: MIN_DELAY_OPERATING_TIMELOCK is inherited from Utils (via TimelockUtils) + + // Default parameters + string constant DEFAULT_OUTPUT_FILE = "auto-compound-txns.json"; + uint256 constant DEFAULT_BATCH_SIZE = 50; + uint256 constant DEFAULT_CHAIN_ID = 1; + string constant DEFAULT_OUTPUT_FORMAT = "gnosis"; + + // Configuration struct to reduce stack depth + struct Config { + string jsonFile; + string outputFile; + uint256 batchSize; + string outputFormat; + uint256 chainId; + address safeAddress; + string root; + uint256 safeNonce; // Starting nonce for Safe transaction hash computation + } + + struct ConsolidationTx { + address to; + uint256 value; + bytes data; + uint256 validatorCount; + } + + function run() external { + console2.log("=== AUTO-COMPOUND TRANSACTION GENERATOR ==="); + console2.log(""); + + // Load configuration + Config memory config = _loadConfig(); + + // Read and parse validators + string memory jsonFilePath = _resolvePath(config.root, config.jsonFile); + string memory jsonData = vm.readFile(jsonFilePath); + + (bytes[] memory pubkeys, uint256[] memory ids, address[] memory podAddrs, uint256 validatorCount) = + _parseValidatorsWithWithdrawalCredentials(jsonData, 10000); + + console2.log("Found", validatorCount, "validators"); + console2.log("Grouping by", _countUniquePods(podAddrs), "unique EigenPods (withdrawal credentials)"); + + if (pubkeys.length == 0) { + console2.log("No validators to process"); + return; + } + + // Process validators + _processValidators(pubkeys, ids, podAddrs, config); + } + + function _loadConfig() internal view returns (Config memory config) { + config.jsonFile = vm.envString("JSON_FILE"); + config.outputFile = vm.envOr("OUTPUT_FILE", string(DEFAULT_OUTPUT_FILE)); + config.batchSize = vm.envOr("BATCH_SIZE", DEFAULT_BATCH_SIZE); + config.outputFormat = vm.envOr("OUTPUT_FORMAT", string(DEFAULT_OUTPUT_FORMAT)); + config.chainId = vm.envOr("CHAIN_ID", DEFAULT_CHAIN_ID); + config.safeAddress = vm.envOr("SAFE_ADDRESS", ETHERFI_OPERATING_ADMIN); + config.root = vm.projectRoot(); + config.safeNonce = vm.envOr("SAFE_NONCE", uint256(0)); + + console2.log("JSON file:", config.jsonFile); + console2.log(""); + } + + function _processValidators( + bytes[] memory pubkeys, + uint256[] memory ids, + address[] memory podAddrs, + Config memory config + ) internal { + // Check linking status + ( + uint256[] memory unlinkedIds, + bytes[] memory unlinkedPubkeys, + uint256 linkedCount + ) = _checkLinkingStatus(pubkeys, ids); + + console2.log("Linked validators:", linkedCount); + console2.log("Unlinked validators:", unlinkedIds.length); + console2.log(""); + + // Note: Fee per request will be determined per pod group during processing + console2.log(""); + + // Generate linking transactions if needed + bool needsLinking = unlinkedIds.length > 0; + + if (needsLinking) { + console2.log("=== GENERATING LINKING TRANSACTIONS ==="); + _generateLinkingTransactions(unlinkedIds, unlinkedPubkeys, config); + } + + // Generate consolidation transactions grouped by EigenPod + console2.log(""); + console2.log("=== GENERATING CONSOLIDATION TRANSACTIONS ==="); + console2.log("Validators will be grouped by withdrawal credentials (EigenPod)"); + _generateAndWriteConsolidation(pubkeys, ids, podAddrs, config, needsLinking); + + // Print summary + _printSummary(pubkeys.length, linkedCount, unlinkedIds.length, needsLinking, config); + } + + function _checkLinkingStatus( + bytes[] memory pubkeys, + uint256[] memory ids + ) internal view returns ( + uint256[] memory unlinkedIds, + bytes[] memory unlinkedPubkeys, + uint256 linkedCount + ) { + uint256 unlinkedCount = 0; + + // First pass: count unlinked + for (uint256 i = 0; i < pubkeys.length; i++) { + if (!_isLinked(pubkeys[i])) { + unlinkedCount++; + } + } + + linkedCount = pubkeys.length - unlinkedCount; + + // Allocate arrays + unlinkedIds = new uint256[](unlinkedCount); + unlinkedPubkeys = new bytes[](unlinkedCount); + + // Second pass: populate arrays + uint256 idx = 0; + for (uint256 i = 0; i < pubkeys.length; i++) { + if (!_isLinked(pubkeys[i])) { + unlinkedIds[idx] = ids[i]; + unlinkedPubkeys[idx] = pubkeys[i]; + idx++; + } + } + } + + function _isLinked(bytes memory pubkey) internal view returns (bool) { + bytes32 pkHash = nodesManager.calculateValidatorPubkeyHash(pubkey); + IEtherFiNode node = nodesManager.etherFiNodeFromPubkeyHash(pkHash); + return address(node) != address(0); + } + + + function _generateLinkingTransactions( + uint256[] memory unlinkedIds, + bytes[] memory unlinkedPubkeys, + Config memory config + ) internal { + // Build timelock calldata + (bytes memory scheduleCalldata, bytes memory executeCalldata) = + _buildTimelockCalldata(unlinkedIds, unlinkedPubkeys); + + // Write schedule transaction (nonce N) + _writeLinkingTx(config, scheduleCalldata, config.safeNonce, "link-schedule"); + + // Write execute transaction (nonce N+1) + _writeLinkingTx(config, executeCalldata, config.safeNonce + 1, "link-execute"); + } + + function _writeLinkingTx( + Config memory config, + bytes memory callData, + uint256 nonce, + string memory txType + ) internal { + // Create transaction + GnosisTxGeneratorLib.GnosisTx[] memory txns = new GnosisTxGeneratorLib.GnosisTx[](1); + txns[0] = GnosisTxGeneratorLib.GnosisTx({ + to: OPERATING_TIMELOCK, + value: 0, + data: callData + }); + + // Generate JSON + string memory jsonContent = GnosisTxGeneratorLib.generateTransactionBatch( + txns, + config.chainId, + config.safeAddress + ); + + // Write file with nonce prefix + string memory fileName = string.concat(nonce.uint256ToString(), "-", txType, ".json"); + string memory filePath = string.concat( + config.root, "/script/operations/auto-compound/txns/", fileName + ); + + vm.writeFile(filePath, jsonContent); + console2.log("Transaction written to:", filePath); + + // Output EIP-712 signing data + _outputSigningData( + config.chainId, + config.safeAddress, + txns[0].to, + txns[0].value, + txns[0].data, + nonce, + fileName + ); + } + + // Selector for EtherFiNodesManager.linkLegacyValidatorIds(uint256[],bytes[]) + bytes4 constant LINK_LEGACY_VALIDATOR_IDS_SELECTOR = bytes4(keccak256("linkLegacyValidatorIds(uint256[],bytes[])")); + + function _buildTimelockCalldata( + uint256[] memory unlinkedIds, + bytes[] memory unlinkedPubkeys + ) internal returns (bytes memory scheduleCalldata, bytes memory executeCalldata) { + // Build linkLegacyValidatorIds calldata + bytes memory linkCalldata = abi.encodeWithSelector( + LINK_LEGACY_VALIDATOR_IDS_SELECTOR, + unlinkedIds, + unlinkedPubkeys + ); + + // Build batch targets + address[] memory targets = new address[](1); + targets[0] = ETHERFI_NODES_MANAGER; + + uint256[] memory values = new uint256[](1); + values[0] = 0; + + bytes[] memory payloads = new bytes[](1); + payloads[0] = linkCalldata; + + bytes32 salt = keccak256(abi.encode(unlinkedIds, unlinkedPubkeys, "link-legacy-validators")); + + // Build schedule calldata + scheduleCalldata = abi.encodeWithSelector( + TimelockController.scheduleBatch.selector, + targets, + values, + payloads, + bytes32(0), // predecessor + salt, + MIN_DELAY_OPERATING_TIMELOCK + ); + + // Build execute calldata + executeCalldata = abi.encodeWithSelector( + TimelockController.executeBatch.selector, + targets, + values, + payloads, + bytes32(0), // predecessor + salt + ); + + vm.prank(address(ETHERFI_OPERATING_ADMIN)); + etherFiTimelock.scheduleBatch(targets, values, payloads, bytes32(0), salt, MIN_DELAY_OPERATING_TIMELOCK); + vm.warp(block.timestamp + MIN_DELAY_OPERATING_TIMELOCK + 1); + vm.prank(address(ETHERFI_OPERATING_ADMIN)); + etherFiTimelock.executeBatch(targets, values, payloads, bytes32(0), salt); + } + + function _generateAndWriteConsolidation( + bytes[] memory pubkeys, + uint256[] memory ids, + address[] memory podAddrs, + Config memory config, + bool needsLinking + ) internal { + // Group validators by pod address and generate consolidation transactions + ConsolidationTx[] memory transactions = _generateConsolidationTransactionsByPod(pubkeys, podAddrs, config); + + // Write output + _writeConsolidationOutput(transactions, config, needsLinking); + } + + function _writeConsolidationOutput( + ConsolidationTx[] memory transactions, + Config memory config, + bool needsLinking + ) internal { + // Starting nonce for consolidation transactions + uint256 startNonce = needsLinking ? config.safeNonce + 2 : config.safeNonce; + + // Generate separate JSON files for each consolidation transaction + for (uint256 i = 0; i < transactions.length; i++) { + uint256 currentNonce = startNonce + i; + + // Determine output filename for this specific transaction + string memory outputFileName = string.concat( + currentNonce.uint256ToString(), "-consolidation.json" + ); + + string memory outputPath = string.concat( + config.root, "/script/operations/auto-compound/txns/", outputFileName + ); + + // Create single transaction array for this consolidation + ConsolidationTx[] memory singleTx = new ConsolidationTx[](1); + singleTx[0] = transactions[i]; + + // Generate JSON for this single transaction + string memory jsonOutput; + + if (keccak256(bytes(config.outputFormat)) == keccak256(bytes("gnosis"))) { + jsonOutput = _generateSingleSafeTransactionJson(singleTx[0], config); + } else { + jsonOutput = _generateRawJson(singleTx); + } + + vm.writeFile(outputPath, jsonOutput); + console2.log("Consolidation transaction", i + 1, "written to:", outputPath); + + // Output EIP-712 signing data for this consolidation transaction + string memory txName = string.concat( + currentNonce.uint256ToString(), + "-consolidation-tx", + (i + 1).uint256ToString() + ); + + _outputSigningData( + config.chainId, + config.safeAddress, + transactions[i].to, + transactions[i].value, + transactions[i].data, + currentNonce, + txName + ); + } + + console2.log(""); + console2.log("=== CONSOLIDATION SUMMARY ==="); + console2.log("Generated", transactions.length, "consolidation transactions (one per EigenPod)"); + console2.log("Starting nonce:", startNonce); + console2.log("Ending nonce:", startNonce + transactions.length - 1); + console2.log("Each transaction saved as separate JSON file"); + } + + function _outputSigningData( + uint256 chainId, + address safeAddress, + address to, + uint256 value, + bytes memory data, + uint256 nonce, + string memory txName + ) internal view { + (bytes32 domainSeparator, bytes32 safeTxHash, bytes32 messageHash) = + SafeTxHashLib.computeSigningData(chainId, safeAddress, to, value, data, nonce); + + console2.log(""); + console2.log("=== EIP-712 SIGNING DATA:", txName, "==="); + console2.log("Nonce:", nonce); + console2.log("Domain Separator:", domainSeparator.bytes32ToHexString()); + console2.log("SafeTx Hash:", safeTxHash.bytes32ToHexString()); + console2.log("Message Hash (to sign):", messageHash.bytes32ToHexString()); + } + + function _printSummary( + uint256 totalValidators, + uint256 linkedCount, + uint256 unlinkedCount, + bool needsLinking, + Config memory config + ) internal view { + console2.log(""); + console2.log("=== SUMMARY ==="); + console2.log("Total validators:", totalValidators); + console2.log("Already linked:", linkedCount); + console2.log("Need linking:", unlinkedCount); + console2.log(""); + + if (needsLinking) { + console2.log("EXECUTION ORDER:"); + console2.log("1. Execute schedule transaction (link-schedule.json)"); + console2.log("2. Wait", MIN_DELAY_OPERATING_TIMELOCK / 3600, "hours for timelock delay"); + console2.log("3. Execute execute transaction (link-execute.json)"); + console2.log("4. Execute consolidation transaction (consolidation.json)"); + } else { + console2.log("All validators are linked. Execute consolidation directly."); + } + } + + function _generateRawJson(ConsolidationTx[] memory transactions) internal pure returns (string memory) { + string memory json = '{\n "transactions": [\n'; + + for (uint256 i = 0; i < transactions.length; i++) { + json = string.concat( + json, + ' {\n', + ' "to": "', transactions[i].to.addressToString(), '",\n', + ' "value": "', transactions[i].value.uint256ToString(), '",\n', + ' "validatorCount": ', transactions[i].validatorCount.uint256ToString(), ',\n', + ' "data": "', transactions[i].data.bytesToHexString(), '"\n', + ' }' + ); + if (i < transactions.length - 1) { + json = string.concat(json, ',\n'); + } else { + json = string.concat(json, '\n'); + } + } + + json = string.concat(json, ' ]\n}'); + return json; + } + + function _resolvePath(string memory root, string memory path) internal pure returns (string memory) { + bytes memory pathBytes = bytes(path); + + // If absolute path, return as-is + if (pathBytes.length > 0 && pathBytes[0] == '/') { + return path; + } + + // If path already starts with "script/", treat as relative to project root + if (pathBytes.length >= 7 && + pathBytes[0] == 's' && pathBytes[1] == 'c' && pathBytes[2] == 'r' && + pathBytes[3] == 'i' && pathBytes[4] == 'p' && pathBytes[5] == 't' && pathBytes[6] == '/') { + return string.concat(root, "/", path); + } + + // Otherwise, assume it's relative to auto-compound directory + return string.concat(root, "/script/operations/auto-compound/txns/", path); + } + + /** + * @notice Parses validators from JSON data and returns pod addresses for each validator + * @param jsonData JSON data string (already read from file) + * @param maxValidators Maximum number of validators to parse (prevents infinite loops) + * @return pubkeys Array of validator public keys + * @return ids Array of validator IDs + * @return podAddrs Array of pod addresses derived from withdrawal credentials + * @return validatorCount Actual number of validators found + */ + function _parseValidatorsWithWithdrawalCredentials( + string memory jsonData, + uint256 maxValidators + ) + internal + view + returns ( + bytes[] memory pubkeys, + uint256[] memory ids, + address[] memory podAddrs, + uint256 validatorCount + ) + { + // Count validators first (with safety limit) + validatorCount = 0; + for (uint256 i = 0; i < maxValidators; i++) { + string memory basePath = string.concat("$[", vm.toString(i), "]"); + if (!stdJson.keyExists(jsonData, string.concat(basePath, ".pubkey"))) { + break; + } + validatorCount++; + } + + // Return early if no validators found + if (validatorCount == 0) { + pubkeys = new bytes[](0); + ids = new uint256[](0); + podAddrs = new address[](0); + return (pubkeys, ids, podAddrs, validatorCount); + } + + pubkeys = new bytes[](validatorCount); + ids = new uint256[](validatorCount); + podAddrs = new address[](validatorCount); + + for (uint256 i = 0; i < validatorCount; i++) { + string memory basePath = string.concat("$[", vm.toString(i), "]"); + ids[i] = stdJson.readUint(jsonData, string.concat(basePath, ".id")); + pubkeys[i] = stdJson.readBytes(jsonData, string.concat(basePath, ".pubkey")); + + bytes memory withdrawalCredentials = stdJson.readBytes(jsonData, string.concat(basePath, ".withdrawal_credentials")); + require(withdrawalCredentials.length == 32, "Invalid withdrawal credentials length"); + podAddrs[i] = address(uint160(uint256(bytes32(withdrawalCredentials)))); + } + } + + /** + * @notice Counts unique pod addresses in the array + */ + function _countUniquePods(address[] memory podAddrs) internal pure returns (uint256) { + if (podAddrs.length == 0) return 0; + + address[] memory uniquePods = new address[](podAddrs.length); + uint256 uniqueCount = 0; + + for (uint256 i = 0; i < podAddrs.length; i++) { + bool seen = false; + for (uint256 j = 0; j < uniqueCount; j++) { + if (uniquePods[j] == podAddrs[i]) { + seen = true; + break; + } + } + if (!seen) { + uniquePods[uniqueCount] = podAddrs[i]; + unchecked { ++uniqueCount; } + } + } + return uniqueCount; + } + + /** + * @notice Groups validators by pod address and generates consolidation transactions + */ + function _generateConsolidationTransactionsByPod( + bytes[] memory pubkeys, + address[] memory podAddrs, + Config memory config + ) internal view returns (ConsolidationTx[] memory) { + require(pubkeys.length == podAddrs.length, "pubkeys/pods length mismatch"); + + // Find unique pod addresses + address[] memory uniquePods = new address[](pubkeys.length); + uint256 uniqueCount = 0; + + for (uint256 i = 0; i < podAddrs.length; i++) { + bool seen = false; + for (uint256 j = 0; j < uniqueCount; j++) { + if (uniquePods[j] == podAddrs[i]) { + seen = true; + break; + } + } + if (!seen) { + uniquePods[uniqueCount] = podAddrs[i]; + unchecked { ++uniqueCount; } + } + } + + // Shrink unique pods array + address[] memory podAddresses = new address[](uniqueCount); + for (uint256 i = 0; i < uniqueCount; i++) { + podAddresses[i] = uniquePods[i]; + } + + console2.log("Found", uniqueCount, "unique EigenPods"); + + // Generate transactions for each pod + ConsolidationTx[] memory allTransactions = new ConsolidationTx[](uniqueCount); + uint256 txCount = 0; + + for (uint256 p = 0; p < uniqueCount; p++) { + address targetPodAddr = podAddresses[p]; + + // Collect validators for this pod + bytes[] memory podPubkeysTmp = new bytes[](pubkeys.length); + uint256 podValidatorCount = 0; + + for (uint256 i = 0; i < pubkeys.length; i++) { + if (podAddrs[i] == targetPodAddr) { + podPubkeysTmp[podValidatorCount] = pubkeys[i]; + unchecked { ++podValidatorCount; } + } + } + + if (podValidatorCount == 0) continue; + + // Shrink pubkeys array + bytes[] memory podPubkeys = new bytes[](podValidatorCount); + for (uint256 i = 0; i < podValidatorCount; i++) { + podPubkeys[i] = podPubkeysTmp[i]; + } + + // Create one consolidation transaction per pod + console2.log(string.concat("EigenPod ", targetPodAddr.addressToString(), " - validators: ", podValidatorCount.uint256ToString())); + + // For now, create one transaction per pod (no sub-batching) + // Get consolidation fee for this pod + uint256 feePerRequest = _getConsolidationFeeForPod(podPubkeys, targetPodAddr); + + // Generate consolidation transaction + (address to, uint256 value, bytes memory data) = + GnosisConsolidationLib.generateConsolidationTransaction( + podPubkeys, + feePerRequest, + address(nodesManager) + ); + + allTransactions[txCount] = ConsolidationTx({ + to: to, + value: value, + data: data, + validatorCount: podValidatorCount + }); + + unchecked { ++txCount; } + } + + // Shrink final array if needed + if (txCount < uniqueCount) { + ConsolidationTx[] memory finalTransactions = new ConsolidationTx[](txCount); + for (uint256 i = 0; i < txCount; i++) { + finalTransactions[i] = allTransactions[i]; + } + return finalTransactions; + } + + return allTransactions; + } + + /** + * @notice Gets consolidation fee for a specific pod + */ + function _getConsolidationFeeForPod(bytes[] memory pubkeys, address targetPodAddr) internal view returns (uint256) { + // Try to find a linked validator from this pod to resolve fee + for (uint256 i = 0; i < pubkeys.length; i++) { + if (_isLinked(pubkeys[i])) { + (, IEigenPod pod) = ValidatorHelpers.resolvePod(nodesManager, pubkeys[i]); + if (address(pod) == targetPodAddr) { + return pod.getConsolidationRequestFee(); + } + } + } + + // If no linked validators in this pod, use the target pod directly + console2.log(string.concat("No linked validators found for pod ", targetPodAddr.addressToString(), " - using pod directly")); + IEigenPod targetPod = IEigenPod(targetPodAddr); + require(address(targetPod) != address(0), "Cannot resolve EigenPod"); + return targetPod.getConsolidationRequestFee(); + } + + /** + * @notice Generates JSON with multiple separate Safe transactions (one per pod group) + */ + function _generateSingleSafeTransactionJson( + ConsolidationTx memory transaction, + Config memory config + ) internal pure returns (string memory) { + // Create single transaction array + GnosisTxGeneratorLib.GnosisTx[] memory singleTx = new GnosisTxGeneratorLib.GnosisTx[](1); + singleTx[0] = GnosisTxGeneratorLib.GnosisTx({ + to: transaction.to, + value: transaction.value, + data: transaction.data + }); + + // Generate Safe transaction JSON (single transaction, not array) + return GnosisTxGeneratorLib.generateTransactionBatch( + singleTx, + config.chainId, + config.safeAddress + ); + } + +} + diff --git a/script/operations/auto-compound/query_validators.py b/script/operations/auto-compound/query_validators.py new file mode 100644 index 000000000..1c7981919 --- /dev/null +++ b/script/operations/auto-compound/query_validators.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +""" +query_validators.py - Query validators from database for auto-compounding + +This script queries the EtherFi validator database to find validators +that need to be converted from 0x01 to 0x02 (auto-compounding) credentials. + +It also checks the beacon chain API to filter out validators that are +already consolidated (have 0x02 credentials). + +Features: +- Bucket-based selection: Groups validators by expected withdrawal time +- Round-robin distribution: Ensures even coverage across withdrawal timeline +- EigenPod grouping: Prepares validators for consolidation by withdrawal credentials + +Usage: + python3 script/operations/auto-compound/query_validators.py --list-operators + python3 script/operations/auto-compound/query_validators.py --operator "Validation Cloud" --count 50 + python3 script/operations/auto-compound/query_validators.py --operator-address 0x123... --count 100 --include-consolidated --bucket-hours 6 + +Examples: + # Get 50 validators distributed across withdrawal time buckets + python3 query_validators.py --operator "Validation Cloud" --count 50 + + # Use 12-hour bucket intervals for finer time distribution + python3 query_validators.py --operator "Validation Cloud" --count 50 --bucket-hours 12 + +Environment Variables: + VALIDATOR_DB: PostgreSQL connection string for validator database + +Output: + JSON file with validator data suitable for AutoCompound.s.sol + Validators are distributed across withdrawal time buckets for optimal consolidation +""" + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Dict, List + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Import reusable utilities +from utils.validator_utils import ( + get_db_connection, + load_operators_from_db, + get_operator_address, + list_operators, + query_validators, + fetch_beacon_state, + calculate_sweep_time, + format_duration, + filter_consolidated_validators, + spread_validators_across_queue, + pick_representative_validators, +) + + +# ============================================================================= +# Compounding-Specific Functions +# ============================================================================= + +def convert_to_output_format(validators: List[Dict]) -> List[Dict]: + """ + Convert database validator records to output JSON format. + + Converts the EigenPod address to full 32-byte withdrawal credentials format. + """ + result = [] + for validator in validators: + pubkey = validator['pubkey'] + + # Convert withdrawal credentials from EigenPod address to full format + # Database stores: EigenPod address (20 bytes) + # We need: 0x01 + 11 zero bytes + 20 byte EigenPod address (32 bytes total) + withdrawal_creds = validator.get('withdrawal_credentials') + if withdrawal_creds and withdrawal_creds.strip(): + # If it's just an address (42 chars = 0x + 40 hex), convert to full format + if len(withdrawal_creds) == 42: + addr_part = withdrawal_creds[2:] # Remove 0x prefix + # Format as withdrawal credentials: 0x01 + 22 zeros + address + withdrawal_creds = '0x01' + '0' * 22 + addr_part + else: + # This should not happen after our filtering, but handle gracefully + withdrawal_creds = None + + result.append({ + 'id': validator['id'], + 'pubkey': pubkey, + 'withdrawal_credentials': withdrawal_creds, + 'etherfi_node': validator['etherfi_node'], + 'status': validator['status'], + 'index': validator['index'] + }) + + return result + + +def write_output(validators: List[Dict], output_file: str, operator_name: str): + """Write validators to JSON file.""" + output = convert_to_output_format(validators) + + with open(output_file, 'w') as f: + json.dump(output, f, indent=2) + + print(f"\nWrote {len(validators)} validators to {output_file}") + print(f"Operator: {operator_name}") + + # Group by withdrawal credentials to show EigenPod distribution + wc_groups = {} + ungrouped_validators = [] + + for v in validators: + wc = v.get('withdrawal_credentials') + if wc and wc.strip(): # Check if withdrawal_credentials exists and is not empty + wc_groups[wc] = wc_groups.get(wc, 0) + 1 + else: + ungrouped_validators.append(v) + + total_grouped = sum(wc_groups.values()) + print(f"\nEigenPod Analysis:") + print(f" Total validators: {len(validators)}") + print(f" Grouped into EigenPods: {total_grouped}") + print(f" Ungrouped (no withdrawal credentials): {len(ungrouped_validators)}") + print(f" Number of EigenPods: {len(wc_groups)}") + + if len(wc_groups) > 1: + print("\nEigenPod distribution:") + for wc, count in sorted(wc_groups.items(), key=lambda x: x[1], reverse=True)[:5]: + print(f" {wc}: {count} validators") + if len(wc_groups) > 5: + remaining = sum(count for wc, count in sorted(wc_groups.items(), key=lambda x: x[1], reverse=True)[5:]) + print(f" ... and {remaining} validators in {len(wc_groups) - 5} other EigenPods") + + if ungrouped_validators: + print(f"\n⚠️ WARNING: {len(ungrouped_validators)} validators have no withdrawal credentials:") + for v in ungrouped_validators[:5]: # Show first 5 + pubkey_short = v.get('pubkey', 'unknown')[:10] + '...' if v.get('pubkey') else 'unknown' + print(f" - Validator {v.get('id', 'unknown')} ({pubkey_short})") + if len(ungrouped_validators) > 5: + print(f" ... and {len(ungrouped_validators) - 5} more") + + print(f"\nThese validators cannot be processed by the AutoCompound contract!") + print(f"Consider excluding them or fixing their withdrawal credentials.") + + # Print next steps + print(f"\n=== Next Steps ===") + print(f"Run the AutoCompound script to generate Gnosis Safe transactions:") + print(f"The script will group validators by EigenPod and create separate consolidation") + print(f"transactions for each withdrawal credential group.") + print(f"") + print(f" JSON_FILE={os.path.basename(output_file)} forge script \\") + print(f" script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \\") + print(f" --fork-url $MAINNET_RPC_URL -vvvv") + + +def main(): + parser = argparse.ArgumentParser( + description='Query validators from database for auto-compounding' + ) + parser.add_argument( + '--operator', + help='Operator name (e.g., "Validation Cloud")' + ) + parser.add_argument( + '--operator-address', + help='Operator address (e.g., 0x123...)' + ) + parser.add_argument( + '--count', + type=int, + default=50, + help='Number of validators to query (default: 50)' + ) + parser.add_argument( + '--output', + default='validators.json', + help='Output JSON file (default: validators.json)' + ) + parser.add_argument( + '--list-operators', + action='store_true', + help='List all operators with validator counts' + ) + parser.add_argument( + '--include-consolidated', + action='store_true', + help='Include validators that are already consolidated (0x02). Default: exclude them' + ) + parser.add_argument( + '--phase', + choices=['LIVE', 'EXITED', 'FULLY_WITHDRAWN', 'WAITING_FOR_APPROVAL', 'READY_FOR_DEPOSIT'], + help='Filter validators by phase (optional)' + ) + parser.add_argument( + '--beacon-api', + default='https://beaconcha.in/api/v1', + help='Beacon chain API base URL (default: https://beaconcha.in/api/v1)' + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Show detailed information about filtered validators' + ) + parser.add_argument( + '--use-sweep-bucketing', + action='store_true', + default=True, + help='Enable sweep-time-aware bucketing for balanced distribution across withdrawal queue' + ) + parser.add_argument( + '--bucket-hours', + type=int, + default=6, + help='Bucket size in hours for sweep time distribution (default: 6)' + ) + + args = parser.parse_args() + + # Validate bucket-hours argument + if args.bucket_hours <= 0: + print(f"Error: --bucket-hours must be a positive integer, got {args.bucket_hours}") + print("Bucket hours must be greater than 0 to avoid division by zero errors.") + sys.exit(1) + + try: + conn = get_db_connection() + except ValueError as e: + print(f"Error: {e}") + print("Set VALIDATOR_DB environment variable to your PostgreSQL connection string") + sys.exit(1) + except Exception as e: + print(f"Database connection error: {e}") + sys.exit(1) + + try: + if args.list_operators: + operators = list_operators(conn) + print("\n=== Operators ===") + print(f"{'Name':<30} {'Address':<44} {'Total':>8}") + print("-" * 85) + for op in operators: + addr_display = op['address'] if op['address'] else 'N/A' + print(f"{op['name']:<30} {addr_display:<44} {op['total']:>8}") + return + + # Resolve operator + if args.operator_address: + operator = args.operator_address.lower() + address_to_name, _ = load_operators_from_db(conn) + operator_name = address_to_name.get(operator, 'Unknown') + elif args.operator: + operator = get_operator_address(conn, args.operator) + if not operator: + print(f"Error: Operator '{args.operator}' not found") + print("Use --list-operators to see available operators") + sys.exit(1) + operator_name = args.operator + else: + print("Error: Must specify --operator or --operator-address") + parser.print_help() + sys.exit(1) + + # Query all validators for the operator, then filter and limit after + # This ensures we get exactly the right number of non-consolidated validators + MAX_VALIDATORS_QUERY = 100000 + query_count = MAX_VALIDATORS_QUERY if not args.include_consolidated else args.count + + print(f"Querying validators for {operator_name} ({operator})") + print(f" Target count: {args.count}") + if args.phase: + print(f" Phase filter: {args.phase}") + + validators = query_validators( + conn, + operator, + query_count, + phase_filter=args.phase + ) + + if not validators: + print(f"No validators found matching criteria") + sys.exit(1) + + print(f" Found {len(validators)} validators from database") + + # Filter out already consolidated validators (0x02) if needed + if not args.include_consolidated: + print(f"\nChecking consolidation status on beacon chain...") + print("(This may take a while for large validator sets)") + + filtered_validators, consolidated_validators = filter_consolidated_validators( + validators, + exclude_consolidated=True, + beacon_api=args.beacon_api, + show_progress=True + ) + + print(f"\nFiltered results:") + print(f" Already consolidated (0x02): {len(consolidated_validators)}") + print(f" Need consolidation (0x01): {len(filtered_validators)}") + + if consolidated_validators and args.verbose: + print("\nConsolidated validators (skipped):") + for v in consolidated_validators[:10]: + print(f" - ID {v.get('id')}: {v.get('pubkey', '')[:20]}...") + if len(consolidated_validators) > 10: + print(f" ... and {len(consolidated_validators) - 10} more") + + # Apply sweep-time-aware bucketing if enabled + if args.use_sweep_bucketing: + print(f"\nApplying sweep-time-aware bucketing ({args.bucket_hours}h intervals)...") + + try: + # Fetch beacon chain state for sweep calculations + beacon_state = fetch_beacon_state() + sweep_index = beacon_state['next_withdrawal_validator_index'] + total_validators = beacon_state['validator_count'] + + print(f" Sweep index: {sweep_index:,}") + print(f" Total validators: {total_validators:,}") + + # Calculate sweep times for all validators + print(" Calculating sweep times...") + sweep_results = [] + excluded_count = 0 + for validator in filtered_validators: + validator_index = validator.get('index') + if validator_index is not None: + sweep_info = calculate_sweep_time(validator_index, sweep_index, total_validators) + sweep_results.append({ + 'pubkey': validator['pubkey'], + 'validatorIndex': validator['id'], # Use id as validatorIndex for compatibility + 'nodeAddress': validator.get('etherfi_node', 'unknown'), + 'balance': '0.00', # Not available in current data + 'secondsUntilSweep': sweep_info['secondsUntilSweep'], + 'estimatedSweepTime': sweep_info['estimatedSweepTime'], + 'positionInQueue': sweep_info['positionInQueue'], + # Include original validator data + **validator + }) + else: + excluded_count += 1 + + # Sort by sweep time + sweep_results.sort(key=lambda x: x['secondsUntilSweep']) + + print(f" ✓ Calculated sweep times for {len(sweep_results)} validators") + + # Warn about excluded validators + if excluded_count > 0: + print(f" ⚠ Warning: {excluded_count} validators excluded due to missing beacon index") + print(" This may happen when validators haven't been indexed on the beacon chain yet") + + # Spread validators across queue + bucket_result = spread_validators_across_queue(sweep_results, args.bucket_hours) + buckets = bucket_result['buckets'] + summary = bucket_result['summary'] + + # Display bucket overview + print(f"\nSweep time bucket overview:") + print("-" * 60) + print(f"{'Bucket':<6} {'Target Time':<14} {'Validators':<12} {'Node Addrs'}") + print("-" * 60) + for bucket_info in summary['bucketsOverview'][:10]: # Show first 10 + print(f"{bucket_info['bucket']:<6} {bucket_info['time']:<14} {bucket_info['validators']:<12} {bucket_info['nodes']}") + + if len(summary['bucketsOverview']) > 10: + print(f"... and {len(summary['bucketsOverview']) - 10} more buckets") + + # Distribute validator selection across all buckets + selected_validators = [] + + # Collect validators from each bucket and sort by proximity to target time + bucket_validators = [] + for bucket in buckets: + bucket_vals = bucket['validators'][:] + # Sort by proximity to target sweep time + target_time = bucket['targetSweepTimeSeconds'] + bucket_vals.sort(key=lambda v: abs(v.get('secondsUntilSweep', 0) - target_time)) + bucket_validators.append(bucket_vals) + + # Round-robin selection across buckets until we reach target count + bucket_count = len(bucket_validators) + bucket_selection_counts = [0] * bucket_count + + if bucket_count > 0: + round_num = 0 + + while len(selected_validators) < args.count: + added_this_round = 0 + + for bucket_idx in range(bucket_count): + if len(selected_validators) >= args.count: + break + + bucket_vals = bucket_validators[bucket_idx] + if round_num < len(bucket_vals): + validator = bucket_vals[round_num] + if validator not in selected_validators: + selected_validators.append(validator) + bucket_selection_counts[bucket_idx] += 1 + added_this_round += 1 + + # If no validators were added this round, we've exhausted all buckets + if added_this_round == 0: + break + + round_num += 1 + + validators = selected_validators + + print(f"\nSelected {len(validators)} validators distributed across {len(buckets)} buckets:") + for i, bucket in enumerate(buckets): + if bucket_selection_counts[i] > 0: + print(f" Bucket {bucket['bucketIndex']} ({bucket['targetSweepTimeFormatted']}): {bucket_selection_counts[i]} validators") + + if len(validators) < args.count: + print(f"Warning: Only selected {len(validators)} validators (requested {args.count})") + print("This may happen when buckets have limited validators available or some validators were excluded") + + except Exception as e: + print(f"Warning: Failed to apply sweep bucketing: {e}") + print("Falling back to standard selection...") + # Fallback to regular selection + if len(filtered_validators) >= args.count: + validators = filtered_validators[:args.count] + else: + validators = filtered_validators + else: + # Take only the requested number (original logic) + if len(filtered_validators) >= args.count: + validators = filtered_validators[:args.count] + else: + validators = filtered_validators + + if len(validators) == 0: + print("\nError: No validators need consolidation (all are already consolidated)") + sys.exit(1) + + else: + validators = validators[:args.count] + + # Filter out validators without proper withdrawal credentials + # These cannot be processed by the AutoCompound contract + valid_validators = [] + invalid_validators = [] + + for v in validators: + wc = v.get('withdrawal_credentials') + if wc and wc.strip() and len(wc.strip()) > 0: + # Additional check: ensure it's a valid withdrawal credentials format + # Accept both EigenPod address (42 chars: 0x + 40 hex) and full withdrawal credentials (66 chars: 0x + 64 hex) + if wc.startswith('0x') and (len(wc) == 42 or len(wc) == 66): + valid_validators.append(v) + else: + invalid_validators.append(v) + else: + invalid_validators.append(v) + + if invalid_validators: + print(f"\n⚠️ FILTERING: {len(invalid_validators)} validators excluded due to invalid/missing withdrawal credentials:") + for v in invalid_validators[:3]: # Show first 3 + pubkey_short = v.get('pubkey', 'unknown')[:10] + '...' if v.get('pubkey') else 'unknown' + wc = v.get('withdrawal_credentials', 'None') + print(f" - Validator {v.get('id', 'unknown')} ({pubkey_short}): WC={wc}") + if len(invalid_validators) > 3: + print(f" ... and {len(invalid_validators) - 3} more") + + print(f"\n✓ Proceeding with {len(valid_validators)} validators that have valid withdrawal credentials") + + validators = valid_validators + + if len(validators) == 0: + print("\n❌ ERROR: No validators remaining after filtering out those without valid withdrawal credentials") + print("This suggests a data integrity issue - please check the validator database") + sys.exit(1) + + write_output(validators, args.output, operator_name) + + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/script/operations/auto-compound/run-auto-compound.sh b/script/operations/auto-compound/run-auto-compound.sh new file mode 100755 index 000000000..1ec67a3b8 --- /dev/null +++ b/script/operations/auto-compound/run-auto-compound.sh @@ -0,0 +1,257 @@ +#!/bin/bash +# +# run-auto-compound.sh - Automated auto-compound workflow +# +# Usage: +# ./script/operations/auto-compound/run-auto-compound.sh \ +# --operator "Validation Cloud" \ +# --count 50 \ +# --nonce 42 +# +# This script: +# 1. Creates an output directory: {operator}_{count}_{timestamp}/ +# 2. Queries validators from the database +# 3. Generates transactions with SAFE_NONCE +# 4. Simulates on Tenderly Virtual Testnet +# + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# Load .env file if it exists +if [ -f "$PROJECT_ROOT/.env" ]; then + # Export variables from .env (skip comments and empty lines) + set -a + source "$PROJECT_ROOT/.env" + set +a +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Parse arguments +OPERATOR="" +COUNT=50 +NONCE=0 +SKIP_SIMULATE=false + +print_usage() { + echo "Usage: $0 --operator [--count ] [--nonce ] [--skip-simulate]" + echo "" + echo "Options:" + echo " --operator Operator name (required)" + echo " --count Number of validators to query (default: 50)" + echo " --nonce Starting Safe nonce for tx hash computation (default: 0)" + echo " --skip-simulate Skip Tenderly simulation step" + echo "" + echo "Examples:" + echo " $0 --operator 'Validation Cloud' --count 50 --nonce 42" + echo " $0 --operator 'Infstones' --count 100 --nonce 10 --skip-simulate" +} + +while [[ $# -gt 0 ]]; do + case $1 in + --operator) + OPERATOR="$2" + shift 2 + ;; + --count) + COUNT="$2" + shift 2 + ;; + --nonce) + NONCE="$2" + shift 2 + ;; + --skip-simulate) + SKIP_SIMULATE=true + shift + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option: $1${NC}" + print_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$OPERATOR" ]; then + echo -e "${RED}Error: --operator is required${NC}" + print_usage + exit 1 +fi + +# Check environment variables +if [ -z "$MAINNET_RPC_URL" ]; then + echo -e "${RED}Error: MAINNET_RPC_URL environment variable not set${NC}" + echo "Set it in your .env file or export it: export MAINNET_RPC_URL=https://..." + exit 1 +fi + +# Create output directory +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +OPERATOR_SLUG=$(echo "$OPERATOR" | tr ' ' '_' | tr '[:upper:]' '[:lower:]') +OUTPUT_DIR="script/operations/auto-compound/txns/${OPERATOR_SLUG}_${COUNT}_${TIMESTAMP}" +mkdir -p "$OUTPUT_DIR" + +echo "" +echo -e "${GREEN}=== AUTO-COMPOUND WORKFLOW ===${NC}" +echo "Operator: $OPERATOR" +echo "Count: $COUNT" +echo "Nonce: $NONCE" +echo "Output: $OUTPUT_DIR" +echo "" + +# Step 1: Query validators +echo -e "${YELLOW}[1/4] Querying validators...${NC}" +python3 script/operations/auto-compound/query_validators.py \ + --operator "$OPERATOR" \ + --count "$COUNT" \ + --output "$OUTPUT_DIR/validators.json" + +if [ ! -f "$OUTPUT_DIR/validators.json" ]; then + echo -e "${RED}Error: Failed to query validators${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ Validators written to $OUTPUT_DIR/validators.json${NC}" +echo "" + +# Step 2: Generate transactions +echo -e "${YELLOW}[2/4] Generating transactions...${NC}" +JSON_FILE="$OUTPUT_DIR/validators.json" \ +OUTPUT_FILE="txns.json" \ +SAFE_NONCE="$NONCE" \ +forge script script/operations/auto-compound/AutoCompound.s.sol:AutoCompound \ + --fork-url "$MAINNET_RPC_URL" -vvvv 2>&1 | tee "$OUTPUT_DIR/forge.log" + +# Move generated files to output directory +# Filenames now have nonce prefix: {nonce}-link-schedule.json, {nonce+1}-link-execute.json, {nonce+2+}-consolidation.json +SCHEDULE_FILE="$NONCE-link-schedule.json" +EXECUTE_FILE="$((NONCE + 1))-link-execute.json" + +# Move all transaction files to output directory +mv "script/operations/auto-compound/txns/$SCHEDULE_FILE" "$OUTPUT_DIR/" 2>/dev/null || true +mv "script/operations/auto-compound/txns/$EXECUTE_FILE" "$OUTPUT_DIR/" 2>/dev/null || true +# Move all consolidation files (may be multiple with incrementing nonces) +mv "script/operations/auto-compound/txns/"*-consolidation.json "$OUTPUT_DIR/" 2>/dev/null || true + +echo "" +echo -e "${GREEN}✓ Transactions generated${NC}" +echo "" + +# Step 3: List generated files +echo -e "${YELLOW}[3/4] Generated files:${NC}" +ls -la "$OUTPUT_DIR"/*.json 2>/dev/null || echo "No JSON files found" +echo "" + +# Step 4: Simulate on Tenderly +if [ "$SKIP_SIMULATE" = true ]; then + echo -e "${YELLOW}[4/4] Skipping Tenderly simulation (--skip-simulate)${NC}" +else + echo -e "${YELLOW}[4/4] Simulating on Tenderly...${NC}" + VNET_NAME="${OPERATOR_SLUG}-${COUNT}-${TIMESTAMP}" + + # Run simulation and check exit code + if [ -f "$OUTPUT_DIR/$SCHEDULE_FILE" ]; then + # Linking needed - run schedule + execute + all consolidation files + echo "Linking required. Running 3-phase simulation with consolidation transactions..." + + # Find all consolidation files + CONSOLIDATION_FILES=$(ls "$OUTPUT_DIR"/*-consolidation.json 2>/dev/null | sort -V) + + if [ -n "$CONSOLIDATION_FILES" ]; then + # Build comma-separated list of consolidation files + CONSOLIDATION_LIST="" + for consolidation_file in $CONSOLIDATION_FILES; do + if [ -z "$CONSOLIDATION_LIST" ]; then + CONSOLIDATION_LIST="$consolidation_file" + else + CONSOLIDATION_LIST="$CONSOLIDATION_LIST,$consolidation_file" + fi + done + + CMD="python3 script/operations/utils/simulate.py --tenderly \ + --schedule \"$OUTPUT_DIR/$SCHEDULE_FILE\" \ + --execute \"$OUTPUT_DIR/$EXECUTE_FILE\" \ + --then \"$CONSOLIDATION_LIST\" \ + --delay 8h --vnet-name \"$VNET_NAME\"" + echo "Running: $CMD" + eval "$CMD" + else + echo -e "${RED}Error: Linking required but no consolidation files found${NC}" + exit 1 + fi + SIMULATION_EXIT_CODE=$? + else + # No linking needed - run all consolidation files sequentially + CONSOLIDATION_FILES=$(ls "$OUTPUT_DIR"/*-consolidation.json 2>/dev/null | sort -V) + + if [ -n "$CONSOLIDATION_FILES" ]; then + echo "No linking required. Running consolidation transactions..." + + # Build comma-separated list of consolidation files + CONSOLIDATION_LIST="" + for consolidation_file in $CONSOLIDATION_FILES; do + if [ -z "$CONSOLIDATION_LIST" ]; then + CONSOLIDATION_LIST="$consolidation_file" + else + CONSOLIDATION_LIST="$CONSOLIDATION_LIST,$consolidation_file" + fi + done + + CMD="python3 script/operations/utils/simulate.py --tenderly --txns \"$CONSOLIDATION_LIST\" --vnet-name \"$VNET_NAME\"" + echo "Running: $CMD" + eval "$CMD" + else + echo -e "${RED}Error: No consolidation files found to simulate${NC}" + exit 1 + fi + SIMULATION_EXIT_CODE=$? + fi + + # Check if simulation was successful + if [ $SIMULATION_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Tenderly simulation failed${NC}" + echo -e "${RED}Check the output above for failed transaction links${NC}" + exit 1 + fi +fi + +echo "" +echo -e "${GREEN}=== COMPLETE ===${NC}" +echo "Output directory: $OUTPUT_DIR" +echo "" +echo "Files:" +ls -1 "$OUTPUT_DIR" +echo "" +echo "Next steps:" +# List consolidation files for user instructions +CONSOLIDATION_FILES_LIST=$(ls "$OUTPUT_DIR"/*-consolidation.json 2>/dev/null | xargs -n1 basename 2>/dev/null | sort -V) + +if [ -f "$OUTPUT_DIR/$SCHEDULE_FILE" ]; then + echo " 1. Import $SCHEDULE_FILE to Gnosis Safe → Execute" + echo " 2. Wait 8 hours for timelock delay" + echo " 3. Import $EXECUTE_FILE to Gnosis Safe → Execute" + echo " 4. Import consolidation file(s) to Gnosis Safe → Execute:" + for f in $CONSOLIDATION_FILES_LIST; do + echo " - $f" + done +else + echo " 1. Import consolidation file(s) to Gnosis Safe → Execute:" + for f in $CONSOLIDATION_FILES_LIST; do + echo " - $f" + done +fi + diff --git a/script/operations/consolidations/ConsolidateToTarget.s.sol b/script/operations/consolidations/ConsolidateToTarget.s.sol new file mode 100644 index 000000000..fab40b832 --- /dev/null +++ b/script/operations/consolidations/ConsolidateToTarget.s.sol @@ -0,0 +1,619 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import "../../utils/utils.sol"; +import "../../utils/GnosisTxGeneratorLib.sol"; +import "../../utils/StringHelpers.sol"; +import "../../utils/ValidatorHelpers.sol"; +import "../../../src/EtherFiNodesManager.sol"; +import "../../../src/eigenlayer-interfaces/IEigenPod.sol"; +import "./GnosisConsolidationLib.sol"; + +/** + * @title ConsolidateToTarget + * @notice Generates transactions to consolidate multiple validators to target validators + * @dev Reads consolidation-data.json and processes all targets in a single run. + * Automatically detects unlinked validators and generates direct linking transactions. + * Uses ADMIN_EOA for all transactions (no timelock). + * + * Usage (Simulation - generates JSON files): + * CONSOLIDATION_DATA_FILE=consolidation-data.json forge script \ + * script/operations/consolidations/ConsolidateToTarget.s.sol:ConsolidateToTarget \ + * --fork-url $MAINNET_RPC_URL -vvvv + * + * Usage (Mainnet broadcast): + * CONSOLIDATION_DATA_FILE=consolidation-data.json BROADCAST=true forge script \ + * script/operations/consolidations/ConsolidateToTarget.s.sol:ConsolidateToTarget \ + * --rpc-url $MAINNET_RPC_URL --private-key $PRIVATE_KEY --broadcast -vvvv + * + * Environment Variables: + * - CONSOLIDATION_DATA_FILE: Path to consolidation-data.json (required) + * - OUTPUT_DIR: Output directory for generated files (default: same as CONSOLIDATION_DATA_FILE) + * - BATCH_SIZE: Number of validators per transaction (default: 50) + * - BROADCAST: Set to "true" to broadcast transactions on mainnet (default: false) + * - CHAIN_ID: Chain ID for transaction (default: 1) + * + * Output Files (simulation mode only): + * - link-validators.json: Direct linking transaction (if linking needed) + * - consolidation-txns-N.json: Consolidation transactions + */ +contract ConsolidateToTarget is Script, Utils { + using StringHelpers for uint256; + using StringHelpers for address; + using StringHelpers for bytes; + + // === MAINNET CONTRACT ADDRESSES === + EtherFiNodesManager constant nodesManager = EtherFiNodesManager(ETHERFI_NODES_MANAGER); + + // Selector for EtherFiNodesManager.linkLegacyValidatorIds(uint256[],bytes[]) + bytes4 constant LINK_LEGACY_VALIDATOR_IDS_SELECTOR = bytes4(keccak256("linkLegacyValidatorIds(uint256[],bytes[])")); + + // Default parameters + uint256 constant DEFAULT_BATCH_SIZE = 50; + uint256 constant DEFAULT_CHAIN_ID = 1; + uint256 constant GAS_WARNING_THRESHOLD = 12_000_000; + + // Config struct to avoid stack too deep + struct Config { + string outputDir; + uint256 batchSize; + uint256 chainId; + address adminAddress; + string root; + bool broadcast; + bool skipGasEstimate; + } + + // Struct for target validator in consolidation-data.json + struct JsonTarget { + bytes pubkey; + uint256 validator_index; + uint256 id; + uint256 current_balance_eth; + bytes withdrawal_credentials; + } + + // Struct for source validator in consolidation-data.json + struct JsonSource { + bytes pubkey; + uint256 validator_index; + uint256 id; + uint256 balance_eth; + bytes withdrawal_credentials; + } + + // Storage for unlinked validators across all targets + uint256[] internal allUnlinkedIds; + bytes[] internal allUnlinkedPubkeys; + + // Selector for EtherFiNodesManager.queueETHWithdrawal(address,uint256) + bytes4 constant QUEUE_ETH_WITHDRAWAL_SELECTOR = bytes4(keccak256("queueETHWithdrawal(address,uint256)")); + + // Storage for consolidation data (to process after linking) + struct ConsolidationData { + bytes targetPubkey; + bytes[] sourcePubkeys; + uint256 withdrawalAmountGwei; + } + ConsolidationData[] internal allConsolidations; + + + function run() external { + console2.log("=== CONSOLIDATE TO TARGET TRANSACTION GENERATOR ==="); + console2.log(""); + + // Load config + Config memory config = _loadConfig(); + + // Read consolidation data file + string memory consolidationDataFile = vm.envString("CONSOLIDATION_DATA_FILE"); + string memory jsonFilePath = _resolvePath(config.root, consolidationDataFile); + string memory jsonData = vm.readFile(jsonFilePath); + + console2.log("Consolidation data file:", consolidationDataFile); + console2.log("Batch size:", config.batchSize); + console2.log("Admin address:", config.adminAddress); + console2.log("Broadcast mode:", config.broadcast); + console2.log("Skip gas estimate:", config.skipGasEstimate); + console2.log(""); + + // Set output directory (default: same directory as consolidation data file) + string memory outputDir = vm.envOr("OUTPUT_DIR", string("")); + if (bytes(outputDir).length == 0) { + outputDir = _getDirectory(jsonFilePath); + } + config.outputDir = outputDir; + + // Get number of consolidations + uint256 numConsolidations = _countConsolidations(jsonData); + console2.log("Number of consolidation targets:", numConsolidations); + console2.log(""); + + if (numConsolidations == 0) { + console2.log("No consolidations to process"); + return; + } + + // ===================================================================== + // PHASE 1: Collect unlinked validators (no fee fetching yet) + // ===================================================================== + console2.log("=== PHASE 1: Collecting unlinked validators ==="); + for (uint256 i = 0; i < numConsolidations; i++) { + _collectConsolidationData(jsonData, i, config); + } + + // ===================================================================== + // PHASE 2: Execute linking if needed (before fee fetching) + // ===================================================================== + bool needsLinking = allUnlinkedIds.length > 0; + if (needsLinking) { + console2.log(""); + console2.log("=== PHASE 2: Linking validators ==="); + console2.log("Total unlinked validators:", allUnlinkedIds.length); + _executeLinking(config); + } else { + console2.log(""); + console2.log("=== PHASE 2: No linking needed ==="); + } + + // ===================================================================== + // PHASE 3: Execute consolidations (fetch fee -> execute, one at a time) + // ===================================================================== + console2.log(""); + console2.log("=== PHASE 3: Executing consolidations (fee fetched per tx) ==="); + _executeConsolidationsWithDynamicFee(config); + + // ===================================================================== + // PHASE 4: Queue ETH withdrawals (one per pod) + // ===================================================================== + console2.log(""); + console2.log("=== PHASE 4: Queue ETH Withdrawals ==="); + _executeQueueETHWithdrawals(config); + + // Summary + console2.log(""); + console2.log("=== CONSOLIDATION COMPLETE ==="); + console2.log("Total consolidation targets:", numConsolidations); + if (config.broadcast) { + console2.log("Mode: MAINNET BROADCAST"); + if (needsLinking) { + console2.log("Linking transaction: BROADCAST"); + } + } else { + console2.log("Mode: SIMULATION (JSON files generated)"); + if (needsLinking) { + console2.log("Link transaction: link-validators.json"); + } + console2.log("Queue withdrawals: queue-withdrawals.json"); + } + console2.log("Admin address:", config.adminAddress); + } + + /// @notice Phase 1: Collect consolidation data and unlinked validators + function _collectConsolidationData(string memory jsonData, uint256 index, Config memory config) internal { + console2.log("Collecting data for consolidation target", index + 1); + + // Parse target + string memory targetPath = string.concat("$.consolidations[", index.uint256ToString(), "].target"); + bytes memory targetPubkey = stdJson.readBytes(jsonData, string.concat(targetPath, ".pubkey")); + uint256 targetValidatorId = stdJson.readUint(jsonData, string.concat(targetPath, ".id")); + + console2.log(" Target pubkey:", targetPubkey.bytesToHexString()); + + // Parse sources + uint256 numSources = _countSources(jsonData, index); + console2.log(" Number of sources:", numSources); + + bytes[] memory sourcePubkeys = new bytes[](numSources); + uint256[] memory sourceIds = new uint256[](numSources); + + for (uint256 i = 0; i < numSources; i++) { + string memory sourcePath = string.concat("$.consolidations[", index.uint256ToString(), "].sources[", i.uint256ToString(), "]"); + sourcePubkeys[i] = stdJson.readBytes(jsonData, string.concat(sourcePath, ".pubkey")); + sourceIds[i] = stdJson.readUint(jsonData, string.concat(sourcePath, ".id")); + } + + // Collect unlinked validators + _collectUnlinkedValidators(targetPubkey, targetValidatorId, sourcePubkeys, sourceIds, config.batchSize); + + // Read withdrawal amount (0 if not present, e.g. non-submarine consolidations) + uint256 withdrawalAmountGwei = 0; + string memory withdrawalPath = string.concat("$.consolidations[", index.uint256ToString(), "].withdrawal_amount_gwei"); + if (stdJson.keyExists(jsonData, withdrawalPath)) { + withdrawalAmountGwei = stdJson.readUint(jsonData, withdrawalPath); + } + if (withdrawalAmountGwei > 0) { + console2.log(" Withdrawal amount:", withdrawalAmountGwei, "gwei"); + } + + // Store consolidation data for Phase 3 + allConsolidations.push(); + uint256 idx = allConsolidations.length - 1; + allConsolidations[idx].targetPubkey = targetPubkey; + allConsolidations[idx].sourcePubkeys = sourcePubkeys; + allConsolidations[idx].withdrawalAmountGwei = withdrawalAmountGwei; + } + + // Counter for transaction numbering + uint256 internal txCount; + + /// @notice Phase 3: Execute consolidations with dynamic fee fetching per transaction + /// @dev Fee is fetched immediately before each transaction to account for non-linear fee changes + function _executeConsolidationsWithDynamicFee(Config memory config) internal { + txCount = 0; + + for (uint256 i = 0; i < allConsolidations.length; i++) { + console2.log("Processing target", i + 1, "of", allConsolidations.length); + _processConsolidationTarget(i, config); + } + + console2.log("Total transactions executed/written:", txCount); + } + + function _processConsolidationTarget(uint256 targetIdx, Config memory config) internal { + ConsolidationData storage consolidation = allConsolidations[targetIdx]; + uint256 numSources = consolidation.sourcePubkeys.length; + uint256 numBatches = (numSources + config.batchSize - 1) / config.batchSize; + + for (uint256 batchIdx = 0; batchIdx < numBatches; batchIdx++) { + _processBatch(targetIdx, batchIdx, config); + } + } + + function _processBatch(uint256 targetIdx, uint256 batchIdx, Config memory config) internal { + ConsolidationData storage consolidation = allConsolidations[targetIdx]; + uint256 startIdx = batchIdx * config.batchSize; + uint256 endIdx = startIdx + config.batchSize; + if (endIdx > consolidation.sourcePubkeys.length) { + endIdx = consolidation.sourcePubkeys.length; + } + + // Extract batch pubkeys + bytes[] memory batchPubkeys = new bytes[](endIdx - startIdx); + for (uint256 j = 0; j < batchPubkeys.length; j++) { + batchPubkeys[j] = consolidation.sourcePubkeys[startIdx + j]; + } + + // Fetch fee RIGHT BEFORE this transaction (fee changes non-linearly) + uint256 feePerRequest = _getConsolidationFee(consolidation.targetPubkey); + console2.log(" Batch", batchIdx + 1, "- Fee:", feePerRequest); + + // Generate and execute/write transaction + txCount++; + _executeOrWriteTx(batchPubkeys, consolidation.targetPubkey, feePerRequest, config); + } + + function _executeOrWriteTx( + bytes[] memory batchPubkeys, + bytes memory targetPubkey, + uint256 feePerRequest, + Config memory config + ) internal { + (address to, uint256 value, bytes memory data) = + GnosisConsolidationLib.generateConsolidationTransactionToTarget( + batchPubkeys, + targetPubkey, + feePerRequest, + address(nodesManager) + ); + + if (config.broadcast) { + if (!config.skipGasEstimate) { + // Estimate gas before broadcasting + vm.prank(config.adminAddress); + uint256 gasBefore = gasleft(); + (bool simSuccess, ) = to.call{value: value}(data); + uint256 gasEstimate = gasBefore - gasleft(); + require(simSuccess, "Consolidation gas estimation failed"); + + console2.log(" Broadcasting tx", txCount, "- Estimated gas:", gasEstimate); + if (gasEstimate > GAS_WARNING_THRESHOLD) { + console2.log(" *** WARNING: Gas exceeds 12M threshold! ***"); + console2.log(" *** Consider reducing batch size ***"); + } + } else { + console2.log(" Broadcasting tx", txCount, "(gas estimation skipped)"); + } + + vm.startBroadcast(); + (bool success, ) = to.call{value: value}(data); + require(success, "Consolidation transaction failed"); + vm.stopBroadcast(); + } else { + _writeAndSimulateTx(to, value, data, config); + } + } + + function _writeAndSimulateTx(address to, uint256 value, bytes memory data, Config memory config) internal { + GnosisTxGeneratorLib.GnosisTx[] memory txns = new GnosisTxGeneratorLib.GnosisTx[](1); + txns[0] = GnosisTxGeneratorLib.GnosisTx({to: to, value: value, data: data}); + + string memory jsonContent = GnosisTxGeneratorLib.generateTransactionBatch( + txns, + config.chainId, + config.adminAddress + ); + + string memory fileName = string.concat("consolidation-txns-", txCount.uint256ToString(), ".json"); + string memory filePath = string.concat(config.outputDir, "/", fileName); + vm.writeFile(filePath, jsonContent); + console2.log(" Written:", fileName); + + // Simulate on fork to update fee state and estimate gas + vm.prank(config.adminAddress); + uint256 gasBefore = gasleft(); + (bool success, ) = to.call{value: value}(data); + uint256 gasUsed = gasBefore - gasleft(); + require(success, "Consolidation simulation failed"); + + console2.log(" Estimated gas:", gasUsed); + if (gasUsed > GAS_WARNING_THRESHOLD) { + console2.log(" *** WARNING: Gas exceeds 12M threshold! ***"); + console2.log(" *** Consider reducing batch size ***"); + } + } + + /// @notice Phase 4: Generate/broadcast queueETHWithdrawal for each pod with a withdrawal amount + /// @dev Bundles all queueETHWithdrawal calls into a single transaction for gas efficiency + function _executeQueueETHWithdrawals(Config memory config) internal { + // Collect withdrawals + uint256 withdrawalCount = 0; + for (uint256 i = 0; i < allConsolidations.length; i++) { + if (allConsolidations[i].withdrawalAmountGwei > 0) { + withdrawalCount++; + } + } + + if (withdrawalCount == 0) { + console2.log("No ETH withdrawals to queue (no withdrawal_amount_gwei in consolidation data)"); + return; + } + + console2.log("Pods with ETH withdrawals:", withdrawalCount); + + // Build an array of (nodeAddress, amountWei) for each pod + GnosisTxGeneratorLib.GnosisTx[] memory withdrawalTxns = new GnosisTxGeneratorLib.GnosisTx[](withdrawalCount); + uint256 txIdx = 0; + + for (uint256 i = 0; i < allConsolidations.length; i++) { + ConsolidationData storage c = allConsolidations[i]; + if (c.withdrawalAmountGwei == 0) continue; + + // Resolve node address from target pubkey + bytes32 pubkeyHash = nodesManager.calculateValidatorPubkeyHash(c.targetPubkey); + address nodeAddr = address(nodesManager.etherFiNodeFromPubkeyHash(pubkeyHash)); + require(nodeAddr != address(0), "Target pubkey not linked - cannot resolve node for withdrawal"); + + uint256 amountWei = c.withdrawalAmountGwei * 1 gwei; + + console2.log(" Pod", i + 1); + console2.log(" Node:", nodeAddr); + console2.log(" Amount (gwei):", c.withdrawalAmountGwei); + + bytes memory callData = abi.encodeWithSelector( + QUEUE_ETH_WITHDRAWAL_SELECTOR, + nodeAddr, + amountWei + ); + + withdrawalTxns[txIdx] = GnosisTxGeneratorLib.GnosisTx({ + to: address(nodesManager), + value: 0, + data: callData + }); + txIdx++; + } + + if (config.broadcast) { + console2.log(" Broadcasting queueETHWithdrawal transactions..."); + vm.startBroadcast(); + for (uint256 i = 0; i < withdrawalTxns.length; i++) { + (bool success, ) = withdrawalTxns[i].to.call{value: withdrawalTxns[i].value}(withdrawalTxns[i].data); + require(success, "queueETHWithdrawal transaction failed"); + } + vm.stopBroadcast(); + console2.log(" All queueETHWithdrawal transactions broadcast successfully"); + } else { + // Write all withdrawal calls into a single transaction file + string memory jsonContent = GnosisTxGeneratorLib.generateTransactionBatch( + withdrawalTxns, + config.chainId, + config.adminAddress + ); + + string memory postSweepDir = string.concat(config.outputDir, "/post-sweep"); + string memory filePath = string.concat(postSweepDir, "/queue-withdrawals.json"); + vm.writeFile(filePath, jsonContent); + console2.log(" Written: queue-withdrawals.json"); + + // Simulate on fork + for (uint256 i = 0; i < withdrawalTxns.length; i++) { + vm.prank(config.adminAddress); + (bool success, ) = withdrawalTxns[i].to.call{value: withdrawalTxns[i].value}(withdrawalTxns[i].data); + require(success, "queueETHWithdrawal simulation failed"); + } + console2.log(" queueETHWithdrawal simulated on fork successfully"); + } + } + + function _loadConfig() internal view returns (Config memory config) { + config.batchSize = vm.envOr("BATCH_SIZE", DEFAULT_BATCH_SIZE); + config.chainId = vm.envOr("CHAIN_ID", DEFAULT_CHAIN_ID); + config.adminAddress = vm.envOr("ADMIN_ADDRESS", ADMIN_EOA); + config.root = vm.projectRoot(); + config.broadcast = vm.envOr("BROADCAST", false); + config.skipGasEstimate = vm.envOr("SKIP_GAS_ESTIMATE", false); + } + + function _countConsolidations(string memory jsonData) internal view returns (uint256) { + // Fast path: read pre-computed count from JSON (added by query_validators_consolidation.py) + if (stdJson.keyExists(jsonData, "$.num_consolidations")) { + return stdJson.readUint(jsonData, "$.num_consolidations"); + } + // Fallback: iterate (slow for large files) + uint256 count = 0; + for (uint256 i = 0; i < 1000; i++) { + string memory path = string.concat("$.consolidations[", i.uint256ToString(), "].target.pubkey"); + if (!stdJson.keyExists(jsonData, path)) { + break; + } + count++; + } + return count; + } + + function _countSources(string memory jsonData, uint256 consolidationIndex) internal view returns (uint256) { + // Fast path: read pre-computed count from JSON (added by query_validators_consolidation.py) + string memory countPath = string.concat("$.consolidations[", consolidationIndex.uint256ToString(), "].source_count"); + if (stdJson.keyExists(jsonData, countPath)) { + return stdJson.readUint(jsonData, countPath); + } + // Fallback: iterate (slow for large files) + uint256 count = 0; + for (uint256 i = 0; i < 10000; i++) { + string memory path = string.concat( + "$.consolidations[", consolidationIndex.uint256ToString(), "].sources[", i.uint256ToString(), "].pubkey" + ); + if (!stdJson.keyExists(jsonData, path)) { + break; + } + count++; + } + return count; + } + + /// @notice Get consolidation fee from the EigenPod (must be called after linking) + function _getConsolidationFee(bytes memory targetPubkey) internal view returns (uint256) { + bytes32 pubkeyHash = nodesManager.calculateValidatorPubkeyHash(targetPubkey); + address nodeAddr = address(nodesManager.etherFiNodeFromPubkeyHash(pubkeyHash)); + require(nodeAddr != address(0), "Target pubkey not linked"); + + IEtherFiNode node = IEtherFiNode(nodeAddr); + IEigenPod pod = node.getEigenPod(); + return pod.getConsolidationRequestFee(); + } + + function _isPubkeyLinked(bytes memory pubkey) internal view returns (bool) { + bytes32 pubkeyHash = nodesManager.calculateValidatorPubkeyHash(pubkey); + address nodeAddr = address(nodesManager.etherFiNodeFromPubkeyHash(pubkeyHash)); + return nodeAddr != address(0); + } + + function _collectUnlinkedValidators( + bytes memory targetPubkey, + uint256 targetValidatorId, + bytes[] memory sourcePubkeys, + uint256[] memory sourceIds, + uint256 batchSize + ) internal { + // Check target + if (!_isPubkeyLinked(targetPubkey)) { + _addUnlinkedIfNew(targetValidatorId, targetPubkey); + console2.log(" Target needs linking"); + } + + // Check first pubkey of each batch + uint256 numBatches = (sourcePubkeys.length + batchSize - 1) / batchSize; + + for (uint256 batchIdx = 0; batchIdx < numBatches; batchIdx++) { + uint256 firstPubkeyIdx = batchIdx * batchSize; + if (!_isPubkeyLinked(sourcePubkeys[firstPubkeyIdx])) { + _addUnlinkedIfNew(sourceIds[firstPubkeyIdx], sourcePubkeys[firstPubkeyIdx]); + console2.log(" Batch", batchIdx + 1, "head needs linking"); + } + } + } + + function _addUnlinkedIfNew(uint256 id, bytes memory pubkey) internal { + // Check if already added + for (uint256 i = 0; i < allUnlinkedIds.length; i++) { + if (allUnlinkedIds[i] == id) { + return; // Already added + } + } + allUnlinkedIds.push(id); + allUnlinkedPubkeys.push(pubkey); + } + + /// @notice Phase 2: Execute linking (broadcast or simulate) + function _executeLinking(Config memory config) internal { + if (config.broadcast) { + // Broadcast mode: execute linking transaction on mainnet + console2.log("Broadcasting linking transaction..."); + vm.startBroadcast(); + nodesManager.linkLegacyValidatorIds(allUnlinkedIds, allUnlinkedPubkeys); + vm.stopBroadcast(); + console2.log("Linking transaction broadcast successfully"); + } else { + // Simulation mode: generate JSON file and simulate on fork + bytes memory linkCalldata = abi.encodeWithSelector( + LINK_LEGACY_VALIDATOR_IDS_SELECTOR, + allUnlinkedIds, + allUnlinkedPubkeys + ); + + GnosisTxGeneratorLib.GnosisTx[] memory txns = new GnosisTxGeneratorLib.GnosisTx[](1); + txns[0] = GnosisTxGeneratorLib.GnosisTx({ + to: ETHERFI_NODES_MANAGER, + value: 0, + data: linkCalldata + }); + + string memory jsonContent = GnosisTxGeneratorLib.generateTransactionBatch( + txns, + config.chainId, + config.adminAddress + ); + + string memory filePath = string.concat(config.outputDir, "/link-validators.json"); + vm.writeFile(filePath, jsonContent); + console2.log("Linking transaction written to:", filePath); + + // Simulate on fork so fee fetching works + vm.prank(config.adminAddress); + nodesManager.linkLegacyValidatorIds(allUnlinkedIds, allUnlinkedPubkeys); + console2.log("Linking simulated on fork successfully"); + } + } + + function _resolvePath(string memory root, string memory path) internal pure returns (string memory) { + // If path starts with /, it's already absolute + if (bytes(path).length > 0 && bytes(path)[0] == '/') { + return path; + } + // Otherwise, prepend root + return string.concat(root, "/", path); + } + + function _getDirectory(string memory filePath) internal pure returns (string memory) { + bytes memory pathBytes = bytes(filePath); + uint256 lastSlash = 0; + bool foundSlash = false; + + for (uint256 i = 0; i < pathBytes.length; i++) { + if (pathBytes[i] == '/') { + lastSlash = i; + foundSlash = true; + } + } + + // No slash found - return current directory + if (!foundSlash) { + return "."; + } + + // Slash at index 0 (root path like /file.json) - return root + if (lastSlash == 0) { + return "/"; + } + + bytes memory dirBytes = new bytes(lastSlash); + for (uint256 i = 0; i < lastSlash; i++) { + dirBytes[i] = pathBytes[i]; + } + + return string(dirBytes); + } +} diff --git a/script/operations/consolidations/ConsolidationTransactions.s.sol b/script/operations/consolidations/ConsolidationTransactions.s.sol new file mode 100644 index 000000000..efbc1ec2a --- /dev/null +++ b/script/operations/consolidations/ConsolidationTransactions.s.sol @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import "forge-std/StdJson.sol"; +import "../../utils/utils.sol"; +import "../../utils/GnosisTxGeneratorLib.sol"; +import "../../utils/StringHelpers.sol"; +import "../../utils/ValidatorHelpers.sol"; +import "../../../src/interfaces/IEtherFiNodesManager.sol"; +import "../../../src/interfaces/IEtherFiNode.sol"; +import "../../../src/eigenlayer-interfaces/IEigenPod.sol"; +import "./GnosisConsolidationLib.sol"; + +/** + * @title ConsolidationTransactions + * @notice Generates consolidation transaction data in multiple formats (Gnosis Safe JSON or raw JSON) + * @dev Unified script for generating transactions for Gnosis Safe, timelock, EOA, or other execution methods + * + * Usage for auto-compounding (Gnosis Safe format): + * forge script script/operations/consolidations/ConsolidationTransactions.s.sol:ConsolidationTransactions \ + * --fork-url $MAINNET_RPC_URL \ + * -- --fs script/operations/consolidations \ + * --json-file consolidation-two.json \ + * --output-file gnosis-consolidation-txns.json \ + * --batch-size 50 \ + * --mode auto-compound \ + * --output-format gnosis \ + * -vvvv + * + * Usage for raw transaction data: + * --output-format raw + * + * Usage for consolidation to target: + * --mode consolidate --target-pubkey + */ +contract ConsolidationTransactions is Script, Utils { + using stdJson for string; + + // === MAINNET CONTRACT ADDRESSES === + IEtherFiNodesManager constant etherFiNodesManager = IEtherFiNodesManager(ETHERFI_NODES_MANAGER); + + // Default parameters + string constant DEFAULT_JSON_FILE = "consolidation-two.json"; + string constant DEFAULT_OUTPUT_FILE = "consolidation-txns.json"; + uint256 constant DEFAULT_BATCH_SIZE = 50; + uint256 constant DEFAULT_CHAIN_ID = 1; + string constant DEFAULT_MODE = "auto-compound"; // "auto-compound" or "consolidate" + string constant DEFAULT_OUTPUT_FORMAT = "raw"; // "raw" or "gnosis" + + // Config struct to reduce stack depth + struct Config { + string jsonFile; + string outputFile; + uint256 batchSize; + string mode; + string outputFormat; + address safeAddress; + uint256 chainId; + string root; + } + + struct ConsolidationTx { + address to; + uint256 value; + bytes data; + uint256 batchIndex; + uint256 validatorCount; + } + + function run() external { + console2.log("=== CONSOLIDATION TRANSACTION GENERATOR ==="); + + // Load config + Config memory config = _loadConfig(); + + console2.log("JSON file:", config.jsonFile); + console2.log("Output file:", config.outputFile); + console2.log("Batch size:", config.batchSize); + console2.log("Mode:", config.mode); + console2.log(""); + + // Read and parse validators + string memory jsonFilePath = string.concat( + config.root, + "/script/el-exits/val-consolidations/", + config.jsonFile + ); + string memory jsonData = vm.readFile(jsonFilePath); + + (bytes[] memory pubkeys, , address targetEigenPod, uint256 validatorCount) = + ValidatorHelpers.parseValidatorsFromJson(jsonData, 10000); + + console2.log("Found", validatorCount, "validators"); + + if (pubkeys.length == 0) { + console2.log("No validators to process"); + return; + } + + // Get fee + uint256 feePerRequest = _getConsolidationFee(pubkeys[0], targetEigenPod); + console2.log("Fee per consolidation request:", feePerRequest); + console2.log(""); + + // Process + _processAndWrite(pubkeys, feePerRequest, config); + } + + function _loadConfig() internal view returns (Config memory config) { + config.jsonFile = vm.envOr("JSON_FILE", string(DEFAULT_JSON_FILE)); + config.outputFile = vm.envOr("OUTPUT_FILE", string(DEFAULT_OUTPUT_FILE)); + config.batchSize = vm.envOr("BATCH_SIZE", DEFAULT_BATCH_SIZE); + config.mode = vm.envOr("MODE", string(DEFAULT_MODE)); + config.outputFormat = vm.envOr("OUTPUT_FORMAT", string(DEFAULT_OUTPUT_FORMAT)); + config.safeAddress = vm.envOr("SAFE_ADDRESS", ETHERFI_OPERATING_ADMIN); + config.chainId = vm.envOr("CHAIN_ID", DEFAULT_CHAIN_ID); + config.root = vm.projectRoot(); + } + + function _getConsolidationFee(bytes memory pubkey, address targetEigenPod) internal view returns (uint256) { + (, IEigenPod targetPod) = ValidatorHelpers.resolvePod(etherFiNodesManager, pubkey); + require(address(targetPod) != address(0), "First validator has no pod"); + require(address(targetPod) == targetEigenPod, "Pod address mismatch"); + return targetPod.getConsolidationRequestFee(); + } + + function _processAndWrite( + bytes[] memory pubkeys, + uint256 feePerRequest, + Config memory config + ) internal { + // Generate transactions based on mode + ConsolidationTx[] memory transactions; + + if (keccak256(bytes(config.mode)) == keccak256(bytes("auto-compound"))) { + transactions = _generateAutoCompoundTransactions(pubkeys, feePerRequest, config.batchSize); + } else if (keccak256(bytes(config.mode)) == keccak256(bytes("consolidate"))) { + bytes memory targetPubkey = vm.envBytes("TARGET_PUBKEY"); + require(targetPubkey.length == 48, "Target pubkey must be 48 bytes"); + transactions = _generateConsolidationTransactions(pubkeys, targetPubkey, feePerRequest, config.batchSize); + } else { + revert("Invalid mode. Use 'auto-compound' or 'consolidate'"); + } + + // Output transaction data + console2.log("=== Generated Transactions ==="); + _logTransactions(transactions); + + // Generate output + string memory outputPath = string.concat(config.root, "/script/el-exits/val-consolidations/", config.outputFile); + string memory jsonOutput = _generateOutput(transactions, config); + + vm.writeFile(outputPath, jsonOutput); + + console2.log(""); + console2.log("=== Transactions generated successfully! ==="); + console2.log("Output file:", outputPath); + console2.log("Total validators:", pubkeys.length); + console2.log("Number of transactions:", transactions.length); + } + + function _logTransactions(ConsolidationTx[] memory transactions) internal view { + for (uint256 i = 0; i < transactions.length; i++) { + console2.log(""); + console2.log(" To:", transactions[i].to); + console2.log(" Value:", transactions[i].value); + console2.log(" Validators:", transactions[i].validatorCount); + } + } + + function _generateOutput( + ConsolidationTx[] memory transactions, + Config memory config + ) internal pure returns (string memory) { + if (keccak256(bytes(config.outputFormat)) == keccak256(bytes("gnosis"))) { + GnosisTxGeneratorLib.Transaction[] memory gnosisTxns = _convertToGnosisTransactions(transactions); + string memory metaName = string.concat("Consolidation Requests (", config.mode, ")"); + string memory metaDescription = string.concat("Transactions for ", config.mode, " mode"); + return GnosisTxGeneratorLib.generateTransactionBatch( + gnosisTxns, + config.chainId, + config.safeAddress, + metaName, + metaDescription + ); + } else { + return _generateRawJsonOutput(transactions); + } + } + + function _generateAutoCompoundTransactions( + bytes[] memory pubkeys, + uint256 feePerRequest, + uint256 batchSize + ) internal pure returns (ConsolidationTx[] memory transactions) { + uint256 numBatches = (pubkeys.length + batchSize - 1) / batchSize; + transactions = new ConsolidationTx[](numBatches); + + for (uint256 batchIdx = 0; batchIdx < numBatches; batchIdx++) { + uint256 startIdx = batchIdx * batchSize; + uint256 endIdx = startIdx + batchSize; + if (endIdx > pubkeys.length) { + endIdx = pubkeys.length; + } + + // Extract batch + bytes[] memory batchPubkeys = new bytes[](endIdx - startIdx); + for (uint256 i = 0; i < batchPubkeys.length; i++) { + batchPubkeys[i] = pubkeys[startIdx + i]; + } + + // Generate transaction for this batch + (address to, uint256 value, bytes memory data) = GnosisConsolidationLib.generateConsolidationTransaction( + batchPubkeys, + feePerRequest, + address(etherFiNodesManager) + ); + + transactions[batchIdx] = ConsolidationTx({ + to: to, + value: value, + data: data, + batchIndex: batchIdx, + validatorCount: batchPubkeys.length + }); + } + } + + function _generateConsolidationTransactions( + bytes[] memory pubkeys, + bytes memory targetPubkey, + uint256 feePerRequest, + uint256 batchSize + ) internal pure returns (ConsolidationTx[] memory transactions) { + uint256 numBatches = (pubkeys.length + batchSize - 1) / batchSize; + transactions = new ConsolidationTx[](numBatches); + + for (uint256 batchIdx = 0; batchIdx < numBatches; batchIdx++) { + uint256 startIdx = batchIdx * batchSize; + uint256 endIdx = startIdx + batchSize; + if (endIdx > pubkeys.length) { + endIdx = pubkeys.length; + } + + // Extract batch + bytes[] memory batchPubkeys = new bytes[](endIdx - startIdx); + for (uint256 i = 0; i < batchPubkeys.length; i++) { + batchPubkeys[i] = pubkeys[startIdx + i]; + } + + // Generate transaction for this batch + (address to, uint256 value, bytes memory data) = GnosisConsolidationLib.generateConsolidationTransactionToTarget( + batchPubkeys, + targetPubkey, + feePerRequest, + address(etherFiNodesManager) + ); + + transactions[batchIdx] = ConsolidationTx({ + to: to, + value: value, + data: data, + batchIndex: batchIdx, + validatorCount: batchPubkeys.length + }); + } + } + + function _convertToGnosisTransactions(ConsolidationTx[] memory transactions) + internal + pure + returns (GnosisTxGeneratorLib.Transaction[] memory gnosisTxns) + { + gnosisTxns = new GnosisTxGeneratorLib.Transaction[](transactions.length); + for (uint256 i = 0; i < transactions.length; i++) { + gnosisTxns[i] = GnosisTxGeneratorLib.Transaction({ + to: transactions[i].to, + value: transactions[i].value, + data: transactions[i].data + }); + } + } + + function _generateRawJsonOutput(ConsolidationTx[] memory transactions) internal pure returns (string memory) { + string memory json = string.concat('{"transactions":['); + + for (uint256 i = 0; i < transactions.length; i++) { + if (i > 0) { + json = string.concat(json, ","); + } + + json = string.concat( + json, + '{"batchIndex":', + vm.toString(transactions[i].batchIndex), + ',"to":"', + vm.toString(transactions[i].to), + '","value":"', + vm.toString(transactions[i].value), + '","validatorCount":', + vm.toString(transactions[i].validatorCount), + ',"data":"', + StringHelpers.bytesToHexString(transactions[i].data), + '"}' + ); + } + + json = string.concat(json, "]}"); + return json; + } +} diff --git a/script/operations/consolidations/GnosisConsolidationLib.sol b/script/operations/consolidations/GnosisConsolidationLib.sol new file mode 100644 index 000000000..60207da60 --- /dev/null +++ b/script/operations/consolidations/GnosisConsolidationLib.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "../../../src/interfaces/IEtherFiNodesManager.sol"; +import "../../../src/eigenlayer-interfaces/IEigenPod.sol"; + +/** + * @title Consolidation Transaction Library + * @notice Pure library for creating consolidation requests and generating transaction data + * @dev Provides reusable functions for consolidation operations + * + * Usage: + * import "./GnosisConsolidationLib.sol"; + * GnosisConsolidationLib.createConsolidationRequests(...) + */ + +library GnosisConsolidationLib { + /** + * @notice Creates auto-compounding consolidation requests (0x02) + * @dev For auto-compounding, srcPubkey == targetPubkey (self-consolidation) + * @param pubkeys Array of validator public keys to upgrade to auto-compounding + * @return reqs Array of ConsolidationRequest structs + */ + function createAutoCompoundRequests(bytes[] memory pubkeys) + internal + pure + returns (IEigenPodTypes.ConsolidationRequest[] memory reqs) + { + reqs = new IEigenPodTypes.ConsolidationRequest[](pubkeys.length); + for (uint256 i = 0; i < pubkeys.length; ++i) { + reqs[i] = IEigenPodTypes.ConsolidationRequest({ + srcPubkey: pubkeys[i], + targetPubkey: pubkeys[i] // Self-consolidation for auto-compounding (0x02) + }); + } + } + + /** + * @notice Creates consolidation requests to a target pubkey + * @dev Consolidates all source pubkeys to a single target pubkey (same pod consolidation) + * @param pubkeys Array of source validator public keys to consolidate + * @param targetPubkey Target validator public key to consolidate to + * @return reqs Array of ConsolidationRequest structs + */ + function createConsolidationRequests(bytes[] memory pubkeys, bytes memory targetPubkey) + internal + pure + returns (IEigenPodTypes.ConsolidationRequest[] memory reqs) + { + reqs = new IEigenPodTypes.ConsolidationRequest[](pubkeys.length); + for (uint256 i = 0; i < pubkeys.length; ++i) { + reqs[i] = IEigenPodTypes.ConsolidationRequest({ + srcPubkey: pubkeys[i], + targetPubkey: targetPubkey // Same pod consolidation + }); + } + } + + /** + * @notice Generates calldata for requestConsolidation with custom requests + * @dev Helper function to encode consolidation requests into calldata + * @param reqs Array of ConsolidationRequest structs + * @return data Encoded calldata for requestConsolidation call + */ + function generateConsolidationCalldata( + IEigenPodTypes.ConsolidationRequest[] memory reqs + ) internal pure returns (bytes memory data) { + data = abi.encodeWithSelector( + IEtherFiNodesManager.requestConsolidation.selector, + reqs + ); + } + + /** + * @notice Generates a single Gnosis Safe transaction for requestConsolidation (auto-compounding) + * @dev Similar to _executeTimelockBatch but outputs Gnosis Safe transaction format + * @param pubkeys Array of validator public keys to consolidate (auto-compounding) + * @param feePerRequest Fee per consolidation request (from pod.getConsolidationRequestFee()) + * @param nodesManagerAddress Address of EtherFiNodesManager contract + * @return to Target address (nodesManagerAddress) + * @return value Total ETH value to send (feePerRequest * pubkeys.length) + * @return data Encoded calldata for requestConsolidation call + */ + function generateConsolidationTransaction( + bytes[] memory pubkeys, + uint256 feePerRequest, + address nodesManagerAddress + ) + internal + pure + returns ( + address to, + uint256 value, + bytes memory data + ) + { + require(pubkeys.length > 0, "Empty pubkeys array"); + + // Create consolidation requests for auto-compounding (0x02) + IEigenPodTypes.ConsolidationRequest[] memory reqs = createAutoCompoundRequests(pubkeys); + + // Encode calldata + data = generateConsolidationCalldata(reqs); + + // Calculate total fee + value = feePerRequest * pubkeys.length; + + to = nodesManagerAddress; + } + + /** + * @notice Generates a single transaction for requestConsolidation to a target pubkey + * @dev Generates transaction data for consolidating to a target validator + * @param pubkeys Array of source validator public keys to consolidate + * @param targetPubkey Target validator public key to consolidate to + * @param feePerRequest Fee per consolidation request (from pod.getConsolidationRequestFee()) + * @param nodesManagerAddress Address of EtherFiNodesManager contract + * @return to Target address (nodesManagerAddress) + * @return value Total ETH value to send (feePerRequest * pubkeys.length) + * @return data Encoded calldata for requestConsolidation call + */ + function generateConsolidationTransactionToTarget( + bytes[] memory pubkeys, + bytes memory targetPubkey, + uint256 feePerRequest, + address nodesManagerAddress + ) + internal + pure + returns ( + address to, + uint256 value, + bytes memory data + ) + { + require(pubkeys.length > 0, "Empty pubkeys array"); + + // Create consolidation requests to target pubkey + IEigenPodTypes.ConsolidationRequest[] memory reqs = createConsolidationRequests(pubkeys, targetPubkey); + + // Encode calldata + data = generateConsolidationCalldata(reqs); + + // Calculate total fee + value = feePerRequest * pubkeys.length; + + to = nodesManagerAddress; + } + +} + diff --git a/script/operations/consolidations/generate_gnosis_txns.py b/script/operations/consolidations/generate_gnosis_txns.py new file mode 100755 index 000000000..4ff89fc3d --- /dev/null +++ b/script/operations/consolidations/generate_gnosis_txns.py @@ -0,0 +1,662 @@ +#!/usr/bin/env python3 +""" +generate_gnosis_txns.py - Generate Gnosis Safe transaction files for validator consolidation + +This script reads consolidation-data.json and generates Gnosis Safe transaction JSON files +for importing into the Gnosis Safe Transaction Builder. + +Generates: + - link-schedule.json: Timelock schedule transaction for linking unlinked validators + - link-execute.json: Timelock execute transaction for linking (after 8h delay) + - consolidation-txns-N.json: Individual consolidation transactions + +No external dependencies required (uses only Python standard library). + +Usage: + python3 generate_gnosis_txns.py --input consolidation-data.json --output-dir ./txns + python3 generate_gnosis_txns.py --input consolidation-data.json --batch-size 50 --fee 1 + +Environment Variables: + SAFE_ADDRESS: Override the default Safe address + CHAIN_ID: Override the default chain ID (1 for mainnet) +""" + +import argparse +import json +import os +import sys +from typing import Dict, List, Optional, Set, Tuple + + +# ============================================================================= +# Constants +# ============================================================================= + +# Contract Addresses (Mainnet) +ETHERFI_NODES_MANAGER = "0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F" +ADMIN_EOA = "0x12582A27E5e19492b4FcD194a60F8f5e1aa31B0F" + +# Default parameters +DEFAULT_BATCH_SIZE = 50 +DEFAULT_CHAIN_ID = 1 +DEFAULT_CONSOLIDATION_FEE = 1 # 1 wei per consolidation request + +# Function selectors +REQUEST_CONSOLIDATION_SELECTOR = "6691954e" # requestConsolidation((bytes,bytes)[]) +LINK_LEGACY_VALIDATOR_IDS_SELECTOR = "83294396" # linkLegacyValidatorIds(uint256[],bytes[]) + + +# ============================================================================= +# ABI Encoding Utilities (No external dependencies) +# ============================================================================= + +def encode_uint256(value: int) -> bytes: + """Encode an integer as a 32-byte uint256.""" + return value.to_bytes(32, byteorder='big') + + +def encode_bytes32(data: bytes) -> bytes: + """Encode bytes as bytes32 (pad or truncate to 32 bytes).""" + if len(data) > 32: + return data[:32] + return data.rjust(32, b'\x00') + + +def encode_address(address: str) -> bytes: + """Encode an address as 32 bytes (left-padded).""" + addr = address.lower() + if addr.startswith('0x'): + addr = addr[2:] + return bytes.fromhex(addr).rjust(32, b'\x00') + + +def encode_bytes_dynamic(data: bytes) -> bytes: + """ + Encode dynamic bytes with length prefix. + Returns length (32 bytes) + data padded to 32-byte boundary. + """ + length = len(data) + padding = (32 - (length % 32)) % 32 + return encode_uint256(length) + data + b'\x00' * padding + + +def encode_uint256_array(values: List[int]) -> bytes: + """Encode a uint256[] array.""" + result = encode_uint256(len(values)) + for v in values: + result += encode_uint256(v) + return result + + +def encode_bytes_array(items: List[bytes]) -> bytes: + """ + Encode a bytes[] array. + Format: length + offsets + data + """ + num_items = len(items) + + # Calculate header size (length + all offsets) + header_size = 32 + num_items * 32 + + # Calculate offsets and encode each item + offsets = [] + encoded_items = [] + current_offset = num_items * 32 # Start after all offset slots + + for item in items: + offsets.append(current_offset) + encoded_item = encode_bytes_dynamic(item) + encoded_items.append(encoded_item) + current_offset += len(encoded_item) + + # Build result + result = encode_uint256(num_items) + for offset in offsets: + result += encode_uint256(offset) + for encoded_item in encoded_items: + result += encoded_item + + return result + + +def encode_address_array(addresses: List[str]) -> bytes: + """Encode an address[] array.""" + result = encode_uint256(len(addresses)) + for addr in addresses: + result += encode_address(addr) + return result + + +def normalize_pubkey(pubkey: str) -> bytes: + """ + Normalize a pubkey string to 48 bytes. + + Args: + pubkey: Pubkey as hex string (with or without 0x prefix) + + Returns: + 48-byte pubkey + """ + if pubkey.startswith('0x'): + pubkey = pubkey[2:] + + # BLS pubkeys are 48 bytes (96 hex chars) + if len(pubkey) != 96: + raise ValueError(f"Invalid pubkey length: expected 96 hex chars, got {len(pubkey)}") + + return bytes.fromhex(pubkey) + + +# ============================================================================= +# Consolidation Transaction Encoding +# ============================================================================= + +def encode_consolidation_requests(source_pubkeys: List[bytes], target_pubkey: bytes) -> bytes: + """ + Encode consolidation requests array for requestConsolidation function. + + The function signature is: + requestConsolidation(ConsolidationRequest[] calldata reqs) + + Where ConsolidationRequest is: + struct ConsolidationRequest { + bytes srcPubkey; + bytes targetPubkey; + } + """ + num_requests = len(source_pubkeys) + + # First 32 bytes: offset to the array (always 0x20 = 32 for single param) + result = encode_uint256(32) + + # Array length + result += encode_uint256(num_requests) + + # Calculate offsets for each tuple element + tuple_offsets = [] + current_offset = num_requests * 32 # Start after all offsets + + # Pre-calculate all tuple data and their offsets + tuple_data_list = [] + + for src_pubkey in source_pubkeys: + src_encoded = encode_bytes_dynamic(src_pubkey) + target_encoded = encode_bytes_dynamic(target_pubkey) + + # Offsets are relative to start of tuple data + src_offset = 64 # After two offset words + target_offset = 64 + len(src_encoded) + + tuple_data = ( + encode_uint256(src_offset) + + encode_uint256(target_offset) + + src_encoded + + target_encoded + ) + + tuple_offsets.append(current_offset) + tuple_data_list.append(tuple_data) + current_offset += len(tuple_data) + + # Add all offsets + for offset in tuple_offsets: + result += encode_uint256(offset) + + # Add all tuple data + for tuple_data in tuple_data_list: + result += tuple_data + + return result + + +def generate_consolidation_calldata(source_pubkeys: List[str], target_pubkey: str) -> str: + """ + Generate the full calldata for requestConsolidation function. + """ + target_bytes = normalize_pubkey(target_pubkey) + source_bytes_list = [normalize_pubkey(pk) for pk in source_pubkeys] + + encoded_params = encode_consolidation_requests(source_bytes_list, target_bytes) + selector = bytes.fromhex(REQUEST_CONSOLIDATION_SELECTOR) + calldata = selector + encoded_params + + return "0x" + calldata.hex() + + +# ============================================================================= +# Linking Transaction Encoding +# ============================================================================= + +def encode_link_legacy_validators(validator_ids: List[int], pubkeys: List[bytes]) -> bytes: + """ + Encode linkLegacyValidatorIds calldata. + + Function signature: + linkLegacyValidatorIds(uint256[] ids, bytes[] pubkeys) + """ + selector = bytes.fromhex(LINK_LEGACY_VALIDATOR_IDS_SELECTOR) + + # Encode parameters + # For functions with multiple dynamic params, we need offsets + # Offset to ids array, offset to pubkeys array, ids data, pubkeys data + + ids_encoded = encode_uint256_array(validator_ids) + pubkeys_encoded = encode_bytes_array(pubkeys) + + # Offsets (relative to start of params) + ids_offset = 64 # After two offset words + pubkeys_offset = 64 + len(ids_encoded) + + params = ( + encode_uint256(ids_offset) + + encode_uint256(pubkeys_offset) + + ids_encoded + + pubkeys_encoded + ) + + return selector + params + + +# ============================================================================= +# Gnosis Safe JSON Generation +# ============================================================================= + +def generate_gnosis_tx_json( + transactions: List[Dict], + chain_id: int, + safe_address: str, + meta_name: str = None, + meta_description: str = None +) -> str: + """Generate Gnosis Safe Transaction Builder JSON format.""" + meta = { + "txBuilderVersion": "1.16.5" + } + + if meta_name: + meta["name"] = meta_name + if meta_description: + meta["description"] = meta_description + + output = { + "chainId": str(chain_id), + "safeAddress": safe_address, + "meta": meta, + "transactions": transactions + } + + return json.dumps(output, indent=2) + + +# ============================================================================= +# Transaction Generation +# ============================================================================= + +def generate_consolidation_tx( + source_pubkeys: List[str], + target_pubkey: str, + fee_per_request: int, + nodes_manager_address: str = ETHERFI_NODES_MANAGER +) -> Dict: + """Generate a single consolidation transaction.""" + calldata = generate_consolidation_calldata(source_pubkeys, target_pubkey) + total_value = fee_per_request * len(source_pubkeys) + + return { + "to": nodes_manager_address, + "value": str(total_value), + "data": calldata + } + + +def collect_validators_needing_linking( + consolidation_data: Dict, + batch_size: int +) -> Tuple[List[int], List[bytes]]: + """ + Collect validators that need linking. + + For consolidation to work, we need to link: + 1. Each target validator (if it has an 'id' field, it may need linking) + 2. The first validator of each batch (head of batch needs to be linked) + + Returns: + Tuple of (validator_ids, pubkeys) for validators needing linking + """ + unlinked_ids: List[int] = [] + unlinked_pubkeys: List[bytes] = [] + seen_ids: Set[int] = set() + + consolidations = consolidation_data.get('consolidations', []) + + for consolidation in consolidations: + target = consolidation.get('target', {}) + sources = consolidation.get('sources', []) + + # Check target + target_id = target.get('id') + target_pubkey = target.get('pubkey', '') + + if target_id is not None and target_id not in seen_ids and target_pubkey: + seen_ids.add(target_id) + unlinked_ids.append(target_id) + unlinked_pubkeys.append(normalize_pubkey(target_pubkey)) + + # Check first source of each batch + source_pubkeys = [s for s in sources if s.get('pubkey')] + num_batches = (len(source_pubkeys) + batch_size - 1) // batch_size + + for batch_idx in range(num_batches): + first_idx = batch_idx * batch_size + if first_idx < len(source_pubkeys): + source = source_pubkeys[first_idx] + source_id = source.get('id') + source_pubkey = source.get('pubkey', '') + + if source_id is not None and source_id not in seen_ids and source_pubkey: + seen_ids.add(source_id) + unlinked_ids.append(source_id) + unlinked_pubkeys.append(normalize_pubkey(source_pubkey)) + + return unlinked_ids, unlinked_pubkeys + + +def generate_linking_transaction( + validator_ids: List[int], + pubkeys: List[bytes], + chain_id: int, + admin_address: str, + output_dir: str +) -> Optional[str]: + """ + Generate direct linking transaction (no timelock). + + Returns: + Path to link-validators.json or None if no linking needed + """ + if not validator_ids or not pubkeys: + return None + + print(f"\n Generating linking transaction for {len(validator_ids)} validators...") + + # Build direct linkLegacyValidatorIds calldata + link_calldata = encode_link_legacy_validators(validator_ids, pubkeys) + + # Write direct linking transaction (to EtherFiNodesManager) + link_tx = { + "to": ETHERFI_NODES_MANAGER, + "value": "0", + "data": "0x" + link_calldata.hex() + } + link_json = generate_gnosis_tx_json( + [link_tx], chain_id, admin_address, + meta_name="Link Validators", + meta_description=f"Link {len(validator_ids)} validators directly via ADMIN_EOA" + ) + link_file = os.path.join(output_dir, "link-validators.json") + with open(link_file, 'w') as f: + f.write(link_json) + print(f" ✓ Written: link-validators.json") + + return link_file + + +def process_consolidation_data( + consolidation_data: Dict, + batch_size: int, + fee_per_request: int +) -> List[Dict]: + """Process consolidation data and generate transaction batches.""" + all_transactions = [] + consolidations = consolidation_data.get('consolidations', []) + + for consolidation in consolidations: + target = consolidation.get('target', {}) + sources = consolidation.get('sources', []) + + target_pubkey = target.get('pubkey', '') + if not target_pubkey: + print(f"Warning: Skipping consolidation with missing target pubkey") + continue + + # Extract source pubkeys + source_pubkeys = [s.get('pubkey', '') for s in sources if s.get('pubkey')] + + if not source_pubkeys: + print(f"Warning: Skipping consolidation with no source pubkeys") + continue + + # Split into batches + for batch_start in range(0, len(source_pubkeys), batch_size): + batch_end = min(batch_start + batch_size, len(source_pubkeys)) + batch_pubkeys = source_pubkeys[batch_start:batch_end] + + tx = generate_consolidation_tx( + batch_pubkeys, + target_pubkey, + fee_per_request + ) + all_transactions.append(tx) + + return all_transactions + + +def write_transaction_files( + transactions: List[Dict], + output_dir: str, + chain_id: int, + safe_address: str +) -> List[str]: + """Write each transaction to a separate JSON file.""" + os.makedirs(output_dir, exist_ok=True) + written_files = [] + + for i, tx in enumerate(transactions, start=1): + tx_list = [tx] + json_content = generate_gnosis_tx_json( + tx_list, + chain_id, + safe_address + ) + + filename = f"consolidation-txns-{i}.json" + filepath = os.path.join(output_dir, filename) + + with open(filepath, 'w') as f: + f.write(json_content) + + written_files.append(filepath) + print(f" ✓ Written: {filename}") + + return written_files + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description='Generate Gnosis Safe transaction files for validator consolidation', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Generate transactions from consolidation data + python3 generate_gnosis_txns.py --input consolidation-data.json + + # Specify output directory and batch size + python3 generate_gnosis_txns.py --input consolidation-data.json --output-dir ./txns --batch-size 50 + + # Skip linking transaction generation + python3 generate_gnosis_txns.py --input consolidation-data.json --skip-linking + + # Use custom fee per request + python3 generate_gnosis_txns.py --input consolidation-data.json --fee 2 + """ + ) + + parser.add_argument( + '--input', '-i', + required=True, + help='Path to consolidation-data.json file' + ) + parser.add_argument( + '--output-dir', '-o', + help='Output directory for transaction files (default: same as input file)' + ) + parser.add_argument( + '--batch-size', + type=int, + default=DEFAULT_BATCH_SIZE, + help=f'Number of sources per transaction (default: {DEFAULT_BATCH_SIZE})' + ) + parser.add_argument( + '--fee', + type=int, + default=DEFAULT_CONSOLIDATION_FEE, + help=f'Fee per consolidation request in wei (default: {DEFAULT_CONSOLIDATION_FEE})' + ) + parser.add_argument( + '--chain-id', + type=int, + default=DEFAULT_CHAIN_ID, + help=f'Chain ID (default: {DEFAULT_CHAIN_ID})' + ) + parser.add_argument( + '--admin-address', + default=ADMIN_EOA, + help=f'Admin address for transactions (default: {ADMIN_EOA})' + ) + parser.add_argument( + '--skip-linking', + action='store_true', + help='Skip generating linking transactions' + ) + parser.add_argument( + '--verbose', '-v', + action='store_true', + help='Show verbose output' + ) + + args = parser.parse_args() + + # Validate input file + if not os.path.exists(args.input): + print(f"Error: Input file not found: {args.input}") + sys.exit(1) + + # Set output directory + output_dir = args.output_dir + if not output_dir: + output_dir = os.path.dirname(os.path.abspath(args.input)) + + # Override from environment if set + chain_id = int(os.environ.get('CHAIN_ID', args.chain_id)) + admin_address = os.environ.get('ADMIN_ADDRESS', args.admin_address) + + print("=" * 60) + print("GNOSIS TRANSACTION GENERATOR") + print("=" * 60) + print(f"Input file: {args.input}") + print(f"Output dir: {output_dir}") + print(f"Batch size: {args.batch_size}") + print(f"Fee/request: {args.fee} wei") + print(f"Chain ID: {chain_id}") + print(f"Admin address: {admin_address}") + print(f"Skip linking: {args.skip_linking}") + print("") + + # Load consolidation data + print("Loading consolidation data...") + with open(args.input, 'r') as f: + consolidation_data = json.load(f) + + consolidations = consolidation_data.get('consolidations', []) + total_targets = len(consolidations) + total_sources = sum(len(c.get('sources', [])) for c in consolidations) + + print(f" Consolidation targets: {total_targets}") + print(f" Total sources: {total_sources}") + print("") + + if total_targets == 0: + print("No consolidations to process") + sys.exit(0) + + os.makedirs(output_dir, exist_ok=True) + + # Generate linking transaction if needed + needs_linking = False + if not args.skip_linking: + print("Checking for validators that need linking...") + unlinked_ids, unlinked_pubkeys = collect_validators_needing_linking( + consolidation_data, args.batch_size + ) + + if unlinked_ids: + print(f" Found {len(unlinked_ids)} validators that may need linking") + link_file = generate_linking_transaction( + unlinked_ids, + unlinked_pubkeys, + chain_id, + admin_address, + output_dir + ) + needs_linking = link_file is not None + else: + print(" No validators need linking") + else: + print("Skipping linking transaction generation (--skip-linking)") + + print("") + + # Process and generate consolidation transactions + print("Generating consolidation transactions...") + transactions = process_consolidation_data( + consolidation_data, + args.batch_size, + args.fee + ) + + print(f" Generated {len(transactions)} transactions") + print("") + + # Write transaction files + print("Writing consolidation transaction files...") + written_files = write_transaction_files( + transactions, + output_dir, + chain_id, + admin_address + ) + + # Summary + print("") + print("=" * 60) + print("GENERATION COMPLETE") + print("=" * 60) + print(f"Total consolidation transactions: {len(written_files)}") + print(f"Output directory: {output_dir}") + print("") + + print("Files generated:") + if needs_linking: + print(f" - link-validators.json (direct linking via ADMIN_EOA)") + for f in written_files: + print(f" - {os.path.basename(f)}") + + print("") + print("Execution order:") + if needs_linking: + print(" 1. Execute link-validators.json from ADMIN_EOA") + print(" 2. Execute each consolidation-txns-*.json file from ADMIN_EOA") + else: + print(" 1. Execute each consolidation-txns-*.json file from ADMIN_EOA") + + print("") + print(f"⚠ Each consolidation request requires {args.fee} wei fee.") + print(f" Total ETH needed for consolidations: {args.fee * total_sources / 1e18:.18f} ETH") + + +if __name__ == '__main__': + main() diff --git a/script/operations/consolidations/query_validators_consolidation.py b/script/operations/consolidations/query_validators_consolidation.py new file mode 100644 index 000000000..8048ecf97 --- /dev/null +++ b/script/operations/consolidations/query_validators_consolidation.py @@ -0,0 +1,1028 @@ +#!/usr/bin/env python3 +""" +query_validators_consolidation.py - Query and select validators for consolidation + +This script queries the EtherFi validator database to find validators for consolidation. +It selects target validators distributed across the withdrawal sweep queue, then assigns +source validators from matching withdrawal credential groups. + +Features: +- Multi-target selection: Targets are auto-selected across sweep queue buckets +- Withdrawal credential grouping: Sources must match target's withdrawal credentials +- Balance overflow prevention: Targets won't exceed max_target_balance post-consolidation +- Sweep queue distribution: Ensures consolidations are spread across the withdrawal timeline + +Usage: + python3 query_validators_consolidation.py --list-operators + python3 query_validators_consolidation.py --operator "Validation Cloud" --count 50 + python3 query_validators_consolidation.py --operator "Infstones" --count 100 --bucket-hours 6 --max-target-balance 2016 + +Examples: + # Get 50 source validators distributed across targets in different sweep buckets + python3 query_validators_consolidation.py --operator "Validation Cloud" --count 50 + + # Use custom max target balance and bucket interval + python3 query_validators_consolidation.py --operator "Validation Cloud" --count 50 --max-target-balance 1984 --bucket-hours 12 + +Environment Variables: + VALIDATOR_DB: PostgreSQL connection string for validator database + BEACON_CHAIN_URL: Beacon chain API URL (default: https://beaconcha.in/api/v1) + +Output: + JSON file with consolidation plan suitable for ConsolidateToTarget.s.sol +""" + +import argparse +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# Add the parent directory to sys.path to enable absolute imports +parent_dir = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(parent_dir)) + +from utils.validator_utils import ( + get_db_connection, + load_operators_from_db, + get_operator_address, + list_operators, + query_validators, + fetch_beacon_state, + fetch_validator_details_batch, + calculate_sweep_time, + filter_consolidated_validators, + spread_validators_across_queue, +) + + +# ============================================================================= +# Constants +# ============================================================================= + +MAX_EFFECTIVE_BALANCE = 2048 # ETH - Protocol max for compounding validators +DEFAULT_MAX_TARGET_BALANCE = 1900 # ETH +DEFAULT_SOURCE_BALANCE = 32 # ETH - Standard validator balance +DEFAULT_BUCKET_HOURS = 6 +BATCH_SIZE=58 # max number of validators that can be consolidated into a target in one transaction + + +# ============================================================================= +# Withdrawal Credential Utilities +# ============================================================================= + +def extract_wc_address(withdrawal_credentials: str) -> Optional[str]: + """ + Extract the 20-byte address from withdrawal credentials. + + Withdrawal credentials format: + - Full format (66 chars): 0x01 + 22 zero chars + 40-char address + - Address only (42 chars): 0x + 40-char address + + Returns: + Lowercase address string (40 hex chars without 0x prefix) or None + """ + if not withdrawal_credentials: + return None + + wc = withdrawal_credentials.lower().strip() + + # Full format: 0x01 + 22 zeros + 40-char address (66 chars total) + if len(wc) == 66: + return wc[-40:] # Last 40 hex chars = 20 bytes + + # Address only format (42 chars) + if len(wc) == 42 and wc.startswith('0x'): + return wc[2:] # Remove 0x prefix + + return None + + +def group_by_withdrawal_credentials(validators: List[Dict]) -> Dict[str, List[Dict]]: + """ + Group validators by their withdrawal credential address (EigenPod). + + Args: + validators: List of validator dictionaries + + Returns: + Dictionary mapping WC address -> list of validators + """ + groups = {} + ungrouped = [] + + for v in validators: + wc_address = extract_wc_address(v.get('withdrawal_credentials')) + if wc_address: + if wc_address not in groups: + groups[wc_address] = [] + groups[wc_address].append(v) + else: + ungrouped.append(v) + + if ungrouped: + print(f" ⚠ Warning: {len(ungrouped)} validators have no withdrawal credentials (skipped)") + + return groups + + +def is_consolidated_credentials(withdrawal_credentials: str) -> bool: + """Check if withdrawal credentials indicate 0x02 (consolidated) type.""" + if not withdrawal_credentials: + return False + return withdrawal_credentials.lower().startswith('0x02') + + +def format_full_withdrawal_credentials(wc_address: str, prefix: str = '01') -> str: + """ + Format address as full 32-byte withdrawal credentials. + + Args: + wc_address: 40-char hex address (without 0x prefix) + prefix: Credential type prefix ('01' or '02') + + Returns: + Full 66-char withdrawal credentials string + """ + return f'0x{prefix}' + '0' * 22 + wc_address.lower() + + +# ============================================================================= +# Balance & Capacity Calculations +# ============================================================================= + +def get_validator_balance_eth(validator: Dict) -> float: + """ + Get validator balance in ETH. + + Tries multiple field names to accommodate different data sources. + """ + # Try various balance field names + for field in ['balance', 'balance_eth', 'effectivebalance', 'effective_balance']: + if field in validator: + bal = validator[field] + if isinstance(bal, (int, float)): + # If balance is in gwei, convert to ETH + if bal > 10000: # Likely gwei + return bal / 1e9 + return float(bal) + try: + bal_float = float(bal) + if bal_float > 10000: + return bal_float / 1e9 + return bal_float + except (ValueError, TypeError): + continue + + # For source validators (0x01), missing balance is expected from DB and + # DEFAULT_SOURCE_BALANCE is the intended planning assumption. + # For existing 0x02 targets, missing balance must fail fast. + if validator.get('_is_existing_target'): + pubkey = validator.get('pubkey', '') + short_pubkey = f"{pubkey[:14]}...{pubkey[-10:]}" if pubkey else "" + raise ValueError( + "Missing beacon balance for existing 0x02 target " + f"{short_pubkey}" + ) + + return DEFAULT_SOURCE_BALANCE + +def calculate_consolidation_capacity( + target_balance_eth: float, + max_target_balance: float = DEFAULT_MAX_TARGET_BALANCE, + source_balance: float = DEFAULT_SOURCE_BALANCE +) -> int: + """ + Calculate how many source validators can consolidate into a target. + + Args: + target_balance_eth: Current target balance in ETH + max_target_balance: Maximum allowed post-consolidation balance + source_balance: Expected source validator balance (default 32 ETH) + + Returns: + Number of 32 ETH validators that can consolidate into target + """ + remaining_capacity = max_target_balance - target_balance_eth + return max(0, int(remaining_capacity // source_balance)) + + +# ============================================================================= +# Target Selection Logic +# ============================================================================= + +def select_targets_from_buckets( + wc_groups: Dict[str, List[Dict]], + buckets: List[Dict], + max_target_balance: float, + prefer_consolidated: bool = True +) -> Dict[str, Dict]: + """ + Select target validators distributed across sweep queue buckets. + + For each withdrawal credential group, selects one target validator + preferring those in different sweep queue buckets to maximize distribution. + + Args: + wc_groups: Validators grouped by withdrawal credential address + buckets: Sweep time buckets from spread_validators_across_queue + max_target_balance: Maximum allowed post-consolidation balance + prefer_consolidated: Prefer existing 0x02 validators as targets + + Returns: + Dictionary mapping WC address -> selected target validator info + """ + targets = {} + bucket_usage = {b['bucketIndex']: 0 for b in buckets} + + # Build validator -> bucket mapping + validator_to_bucket = {} + for bucket in buckets: + for v in bucket.get('validators', []): + pubkey = v.get('pubkey', '') + if pubkey: + validator_to_bucket[pubkey.lower()] = bucket['bucketIndex'] + + for wc_address, validators in wc_groups.items(): + if not validators: + continue + + # Sort validators by preference: + # 1. Already consolidated (0x02) - can still receive consolidations + # 2. Lower bucket usage (spread across queue) + # 3. Lower balance (more capacity) + + def target_score(v): + is_02 = is_consolidated_credentials(v.get('beacon_withdrawal_credentials', v.get('withdrawal_credentials', ''))) + bucket_idx = validator_to_bucket.get(v.get('pubkey', '').lower(), 999) + bucket_count = bucket_usage.get(bucket_idx, 0) + balance = get_validator_balance_eth(v) + + # Score: (prefer 0x02, lower bucket usage, lower balance) + return ( + 0 if (is_02 and prefer_consolidated) else 1, + bucket_count, + balance + ) + + sorted_validators = sorted(validators, key=target_score) + + # Select first validator that has capacity + for candidate in sorted_validators: + balance = get_validator_balance_eth(candidate) + capacity = calculate_consolidation_capacity(balance, max_target_balance) + + if capacity > 0: + bucket_idx = validator_to_bucket.get(candidate.get('pubkey', '').lower(), 0) + bucket_usage[bucket_idx] = bucket_usage.get(bucket_idx, 0) + 1 + + targets[wc_address] = { + 'validator': candidate, + 'balance_eth': balance, + 'capacity': capacity, + 'bucket_index': bucket_idx + } + break + + return targets + + +# ============================================================================= +# Consolidation Planning +# ============================================================================= + +def create_consolidation_plan( + validators: List[Dict], + count: int, + max_target_balance: float, + bucket_hours: int, + existing_targets: List[Dict] = None +) -> Dict: + """ + Create a consolidation plan with targets and sources. + + Args: + validators: All eligible 0x01 validators (can be targets or sources) + count: Number of source validators to consolidate + max_target_balance: Maximum ETH balance for targets + bucket_hours: Bucket interval for sweep queue distribution + existing_targets: Existing 0x02 validators with capacity (target-only, never sources). + Must have 'balance_eth' populated from beacon chain. + + Returns: + Consolidation plan dictionary + """ + if existing_targets is None: + existing_targets = [] + + print(f"\n=== Creating Consolidation Plan ===") + print(f" Target count: {count} source validators") + print(f" Max target balance: {max_target_balance} ETH") + print(f" Bucket interval: {bucket_hours}h") + if existing_targets: + print(f" Existing 0x02 targets with capacity: {len(existing_targets)}") + + # Step 1: Group validators by withdrawal credentials + print(f"\nStep 1: Grouping by withdrawal credentials...") + wc_groups = group_by_withdrawal_credentials(validators) + print(f" Found {len(wc_groups)} unique EigenPods (from 0x01 validators)") + + # Mark existing 0x02 targets so they are never used as sources + for v in existing_targets: + v['_is_existing_target'] = True + + # Group existing targets by WC and merge into wc_groups + existing_target_groups = group_by_withdrawal_credentials(existing_targets) + existing_target_wc_count = 0 + for wc_address, targets in existing_target_groups.items(): + if wc_address in wc_groups: + wc_groups[wc_address].extend(targets) + else: + # 0x02 targets in pods that have no 0x01 sources - skip these + # (nothing to consolidate into them from this operator's 0x01 pool) + pass + existing_target_wc_count += 1 + if existing_targets: + print(f" Added {len(existing_targets)} existing 0x02 targets across {existing_target_wc_count} EigenPods") + + for wc, vals in sorted(wc_groups.items(), key=lambda x: len(x[1]), reverse=True)[:5]: + source_count_in_group = sum(1 for v in vals if not v.get('_is_existing_target')) + target_count_in_group = sum(1 for v in vals if v.get('_is_existing_target')) + extra = f" (+{target_count_in_group} existing 0x02)" if target_count_in_group else "" + print(f" {wc[:10]}...{wc[-6:]}: {source_count_in_group} validators{extra}") + if len(wc_groups) > 5: + print(f" ... and {len(wc_groups) - 5} more EigenPods") + + # Step 2: Calculate sweep times and create buckets + print(f"\nStep 2: Calculating sweep times...") + try: + beacon_state = fetch_beacon_state() + sweep_index = beacon_state['next_withdrawal_validator_index'] + total_validators = beacon_state['validator_count'] + print(f" Sweep index: {sweep_index:,}") + print(f" Total validators: {total_validators:,}") + except Exception as e: + print(f" ⚠ Warning: Failed to fetch beacon state: {e}") + print(f" Using default values...") + sweep_index = 0 + total_validators = 1200000 + + # Add sweep time info to all validators (0x01 + existing 0x02 targets) + all_validators = list(validators) + list(existing_targets) + all_with_sweep = [] + for v in all_validators: + validator_index = v.get('index') + if validator_index is not None: + sweep_info = calculate_sweep_time(validator_index, sweep_index, total_validators) + v_with_sweep = {**v, **sweep_info} + all_with_sweep.append(v_with_sweep) + + all_with_sweep.sort(key=lambda x: x.get('secondsUntilSweep', 0)) + print(f" Calculated sweep times for {len(all_with_sweep)} validators") + + # Create buckets + if all_with_sweep: + bucket_result = spread_validators_across_queue(all_with_sweep, bucket_hours) + buckets = bucket_result.get('buckets', []) + else: + buckets = [] + + # Step 3: Select targets distributed across sweep queue buckets + print(f"\nStep 3: Selecting targets from across withdrawal queue...") + + # Re-group validators with sweep info by WC + wc_groups_with_sweep = {} + for v in all_with_sweep: + wc_address = extract_wc_address(v.get('withdrawal_credentials')) + if wc_address: + if wc_address not in wc_groups_with_sweep: + wc_groups_with_sweep[wc_address] = [] + wc_groups_with_sweep[wc_address].append(v) + + # Use select_targets_from_buckets to pick targets spread across the withdrawal queue + # (0x02 validators are preferred via prefer_consolidated=True) + selected_targets = select_targets_from_buckets( + wc_groups_with_sweep, + buckets, + max_target_balance, + prefer_consolidated=True + ) + print(f" Selected {len(selected_targets)} targets across {len(buckets)} buckets") + + # Step 4: Create consolidation batches using the selected targets + # Rules: + # - Each target is used only ONCE across all consolidation requests + # - post_consolidation_balance_eth must never exceed max_target_balance + # - If more sources remain after using a target, select a new target from remaining validators + print(f"\nStep 4: Creating consolidation batches...") + + consolidations = [] + total_sources = 0 + used_target_pubkeys = set() # Track used targets to prevent reuse + + # Build validator -> bucket mapping for selecting new targets + validator_to_bucket = {} + for bucket in buckets: + for v in bucket.get('validators', []): + pubkey = v.get('pubkey', '') + if pubkey: + validator_to_bucket[pubkey.lower()] = bucket['bucketIndex'] + + # Process each WC group + for wc_address, target_info in selected_targets.items(): + if total_sources >= count: + break + + # Get all validators in this WC group + wc_validators = wc_groups_with_sweep.get(wc_address, []) + + # Need at least 1 source (non-existing-target) + 1 target + source_candidates = [v for v in wc_validators if not v.get('_is_existing_target')] + if not source_candidates: + continue + + # Sort: prefer existing 0x02 targets first (already consolidated, no linking needed), + # then by balance (lowest first = more capacity) + available_validators = sorted(wc_validators, key=lambda v: ( + 0 if v.get('_is_existing_target') else 1, + get_validator_balance_eth(v) + )) + + # Keep consolidating until we run out of source validators or hit count + # Need at least 1 source (non-existing-target) and 1 target candidate + while total_sources < count and \ + any(not v.get('_is_existing_target') for v in available_validators) and \ + len(available_validators) >= 2: + # Select a target from available validators (not yet used) + target = None + target_idx = None + for idx, candidate in enumerate(available_validators): + candidate_pubkey = candidate.get('pubkey', '').lower() + if candidate_pubkey not in used_target_pubkeys: + candidate_balance = get_validator_balance_eth(candidate) + candidate_capacity = calculate_consolidation_capacity(candidate_balance, max_target_balance) + if candidate_capacity > 0: + target = candidate + target_idx = idx + break + + if target is None: + break # No valid target available in this WC group + + target_pubkey = target.get('pubkey', '').lower() + target_balance = get_validator_balance_eth(target) + bucket_idx = validator_to_bucket.get(target_pubkey, 0) + + # Mark target as used + used_target_pubkeys.add(target_pubkey) + + # Get sources (all validators except the target, excluding existing 0x02 targets) + sources_pool = [v for i, v in enumerate(available_validators) + if i != target_idx and not v.get('_is_existing_target')] + + # Select sources that fit within max_target_balance limit + batch_sources = [] + running_balance = target_balance + batch_limit = BATCH_SIZE - 1 # Reserve 1 slot for target + + for source in sources_pool: + if len(batch_sources) >= batch_limit: + break + if total_sources + len(batch_sources) >= count: + break + + source_balance = get_validator_balance_eth(source) + new_balance = running_balance + source_balance + + # Only add source if it doesn't exceed max_target_balance + if new_balance <= max_target_balance: + batch_sources.append(source) + running_balance = new_balance + + if not batch_sources: + # Remove target from available and try next + available_validators = [v for v in available_validators if v.get('pubkey', '').lower() != target_pubkey] + continue + + # Build sources list with target as first element + sources = [target] + batch_sources + post_balance = running_balance + + # Calculate source total (includes target balance for reporting) + source_total = sum(get_validator_balance_eth(s) for s in sources) + + consolidations.append({ + 'target': target, + 'target_balance_eth': target_balance, + 'sources': sources, + 'source_total_eth': source_total, + 'post_consolidation_balance_eth': post_balance, + 'bucket_index': bucket_idx, + 'wc_address': wc_address + }) + + total_sources += len(batch_sources) + + # Remove used validators (target + sources) from available pool + used_pubkeys = {s.get('pubkey', '').lower() for s in sources} + available_validators = [v for v in available_validators if v.get('pubkey', '').lower() not in used_pubkeys] + + print(f" Created {len(consolidations)} consolidation batches") + print(f" Total sources to consolidate: {total_sources}") + print(f" Unique targets used: {len(used_target_pubkeys)}") + + # Step 5: Validate the plan + print(f"\nStep 5: Validating consolidation plan...") + validation = validate_consolidation_plan(consolidations, max_target_balance) + + # Step 6: Generate summary with bucket distribution info + bucket_distribution = {} + for c in consolidations: + bucket_key = f"bucket_{c['bucket_index']}" + bucket_distribution[bucket_key] = bucket_distribution.get(bucket_key, 0) + 1 + + existing_0x02_targets_used = sum( + 1 for c in consolidations if c['target'].get('_is_existing_target') + ) + summary = { + 'total_targets': len(consolidations), + 'total_sources': sum(len(c['sources']) for c in consolidations), + 'total_eth_consolidated': sum(c['source_total_eth'] for c in consolidations), + 'existing_0x02_targets_used': existing_0x02_targets_used, + 'bucket_distribution': bucket_distribution, + 'withdrawal_credential_groups': len(set(c['wc_address'] for c in consolidations)) + } + + return { + 'consolidations': consolidations, + 'summary': summary, + 'validation': validation + } + + +def validate_consolidation_plan(consolidations: List[Dict], max_target_balance: float) -> Dict: + """ + Validate the consolidation plan for safety. + + Checks: + 1. All source validators share credentials with their target + 2. No target exceeds max balance post-consolidation + 3. No duplicate pubkeys across all consolidations + 4. Target in each batch is the first source (sources[0]) + """ + all_credentials_matched = True + all_targets_under_capacity = True + all_targets_are_first_source = True + errors = [] + + all_pubkeys = set() # Track all pubkeys to prevent duplicates + + for c in consolidations: + target_wc = extract_wc_address(c['target'].get('withdrawal_credentials')) + target_pubkey = c['target'].get('pubkey', '').lower() + + # Check that target is the first source in the batch + if c['sources'] and c['sources'][0].get('pubkey', '').lower() != target_pubkey: + all_targets_are_first_source = False + errors.append(f"Target {target_pubkey[:20]}... is not the first source in its batch") + + # Check post-consolidation balance + if c['post_consolidation_balance_eth'] > max_target_balance: + all_targets_under_capacity = False + errors.append(f"Target {target_pubkey[:20]}... exceeds max balance: {c['post_consolidation_balance_eth']:.2f} ETH") + + # Check target pubkey uniqueness + if target_pubkey in all_pubkeys: + errors.append(f"Duplicate target pubkey: {target_pubkey[:20]}...") + all_pubkeys.add(target_pubkey) + + for i, source in enumerate(c['sources']): + source_wc = extract_wc_address(source.get('withdrawal_credentials')) + source_pubkey = source.get('pubkey', '').lower() + + # Check credential match + if source_wc != target_wc: + all_credentials_matched = False + errors.append(f"Source {source_pubkey[:20]}... WC mismatch with target") + + # Check for duplicates across all pubkeys (allow target to be sources[0]) + if source_pubkey in all_pubkeys and not (i == 0 and source_pubkey == target_pubkey): + errors.append(f"Duplicate pubkey: {source_pubkey[:20]}...") + all_pubkeys.add(source_pubkey) + + # Calculate sweep distribution score (0-1, higher is better) + if consolidations: + unique_buckets = len(set(c['bucket_index'] for c in consolidations)) + sweep_distribution_score = min(1.0, unique_buckets / len(consolidations)) + else: + sweep_distribution_score = 0.0 + + validation = { + 'all_credentials_matched': all_credentials_matched, + 'all_targets_under_capacity': all_targets_under_capacity, + 'all_targets_are_first_source': all_targets_are_first_source, + 'sweep_distribution_score': round(sweep_distribution_score, 2), + 'no_duplicate_pubkeys': len(errors) == 0 or not any('Duplicate' in e for e in errors), + 'errors': errors if errors else None + } + + # Print validation results + print(f" ✓ Credentials matched: {validation['all_credentials_matched']}") + print(f" ✓ Targets under capacity: {validation['all_targets_under_capacity']}") + print(f" ✓ Targets are first source: {validation['all_targets_are_first_source']}") + print(f" ✓ Sweep distribution score: {validation['sweep_distribution_score']}") + + if errors: + print(f" ⚠ Validation errors:") + for e in errors[:5]: + print(f" - {e}") + if len(errors) > 5: + print(f" ... and {len(errors) - 5} more") + + return validation + + +# ============================================================================= +# Output Generation +# ============================================================================= + +def convert_to_output_format(plan: Dict) -> Dict: + """ + Convert consolidation plan to JSON output format for Solidity script. + + Output format is designed to be compatible with ValidatorHelpers.parseValidatorsFromJson() + which expects each validator to have: pubkey, id, withdrawal_credentials + """ + consolidations_output = [] + + for c in plan['consolidations']: + target = c['target'] + wc_address = c['wc_address'] + full_wc = format_full_withdrawal_credentials(wc_address) + + target_output = { + 'pubkey': target.get('pubkey', ''), + 'validator_index': target.get('index'), + 'id': target.get('id'), + 'current_balance_eth': c['target_balance_eth'], + 'is_existing_0x02': bool(target.get('_is_existing_target')), + 'withdrawal_credentials': full_wc, + 'sweep_bucket': f"bucket_{c['bucket_index']}" + } + + sources_output = [] + for source in c['sources']: + sources_output.append({ + 'pubkey': source.get('pubkey', ''), + 'validator_index': source.get('index'), + 'id': source.get('id'), + 'balance_eth': get_validator_balance_eth(source), + # Include withdrawal_credentials for ValidatorHelpers compatibility + 'withdrawal_credentials': full_wc + }) + + consolidations_output.append({ + 'target': target_output, + 'sources': sources_output, + 'source_count': len(sources_output), + 'post_consolidation_balance_eth': c['post_consolidation_balance_eth'] + }) + + return { + 'num_consolidations': len(consolidations_output), + 'consolidations': consolidations_output, + 'summary': plan['summary'], + 'validation': plan['validation'], + 'generated_at': datetime.now().isoformat() + } + + +def write_targets_json(plan: Dict, output_dir: str) -> str: + """ + Write a separate targets.json file with target validator info for linking. + + Contains: validatorId, pubkey, estimated sweep time, bucket index + Used by ConsolidateToTarget.s.sol for linking validators. + """ + targets_output = [] + seen_pubkeys = set() # Deduplicate targets (same target may appear in multiple consolidations) + + for c in plan['consolidations']: + target = c['target'] + pubkey = target.get('pubkey', '') + + # Skip duplicates (target may be used across multiple consolidation batches) + if pubkey.lower() in seen_pubkeys: + continue + seen_pubkeys.add(pubkey.lower()) + + targets_output.append({ + 'id': target.get('id'), + 'pubkey': pubkey, + 'validator_index': target.get('index'), + 'estimated_sweep_seconds': target.get('secondsUntilSweep'), + 'estimated_sweep_time': target.get('estimatedSweepTime'), + 'bucket_index': c['bucket_index'], + 'current_balance_eth': c['target_balance_eth'], + 'withdrawal_credentials': format_full_withdrawal_credentials(c['wc_address']) + }) + + # Sort by bucket index for easier review + targets_output.sort(key=lambda x: (x['bucket_index'], x.get('estimated_sweep_seconds', 0))) + + targets_file = os.path.join(output_dir, 'targets.json') + with open(targets_file, 'w') as f: + json.dump(targets_output, f, indent=2, default=str) + + return targets_file + + +def write_output(plan: Dict, output_file: str, operator_name: str): + """Write consolidation plan to JSON file.""" + output = convert_to_output_format(plan) + + # Get output directory for targets.json + output_dir = os.path.dirname(output_file) or '.' + + with open(output_file, 'w') as f: + json.dump(output, f, indent=2, default=str) + + # Write separate targets.json file + targets_file = write_targets_json(plan, output_dir) + + print(f"\n=== Output Written ===") + print(f"Consolidation data: {output_file}") + print(f"Targets file: {targets_file}") + print(f"Operator: {operator_name}") + print(f"Total targets: {plan['summary']['total_targets']}") + print(f"Total sources: {plan['summary']['total_sources']}") + print(f"Total ETH to consolidate: {plan['summary']['total_eth_consolidated']:.2f}") + + # Print bucket distribution + print(f"\nBucket distribution:") + for bucket, count in sorted(plan['summary']['bucket_distribution'].items()): + print(f" {bucket}: {count} targets") + + # Print next steps + print(f"\n=== Next Steps ===") + print(f"Run the ConsolidateToTarget script with this data:") + print(f"") + print(f" CONSOLIDATION_DATA={os.path.basename(output_file)} TARGETS_DATA={os.path.basename(targets_file)} \\") + print(f" forge script script/operations/consolidations/ConsolidateToTarget.s.sol:ConsolidateToTarget \\") + print(f" --fork-url $MAINNET_RPC_URL -vvvv") + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description='Query validators from database for consolidation', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # List all operators + python3 query_validators_consolidation.py --list-operators + + # Get 50 source validators for consolidation + python3 query_validators_consolidation.py --operator "Validation Cloud" --count 50 + + # Custom max target balance and bucket interval + python3 query_validators_consolidation.py --operator "Infstones" --count 100 \\ + --max-target-balance 1984 --bucket-hours 12 + + # Dry run to preview plan without writing output + python3 query_validators_consolidation.py --operator "Validation Cloud" --count 50 --dry-run + """ + ) + parser.add_argument( + '--operator', + help='Operator name (e.g., "Validation Cloud")' + ) + parser.add_argument( + '--operator-address', + help='Operator address (e.g., 0x123...)' + ) + parser.add_argument( + '--count', + type=int, + default=0, + help='Number of source validators to consolidate (default: 0 = use all available)' + ) + parser.add_argument( + '--bucket-hours', + type=int, + default=DEFAULT_BUCKET_HOURS, + help=f'Bucket size in hours for sweep time distribution (default: {DEFAULT_BUCKET_HOURS})' + ) + parser.add_argument( + '--max-target-balance', + type=float, + default=DEFAULT_MAX_TARGET_BALANCE, + help=f'Maximum ETH balance allowed on target post-consolidation (default: {DEFAULT_MAX_TARGET_BALANCE})' + ) + parser.add_argument( + '--output', + default='consolidation-data.json', + help='Output JSON file (default: consolidation-data.json)' + ) + parser.add_argument( + '--list-operators', + action='store_true', + help='List all operators with validator counts' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Preview consolidation plan without writing output file' + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Show detailed information' + ) + parser.add_argument( + '--beacon-api', + default='https://beaconcha.in/api/v1', + help='Beacon chain API base URL (default: https://beaconcha.in/api/v1)' + ) + + args = parser.parse_args() + + # Validate arguments + if args.bucket_hours <= 0: + print(f"Error: --bucket-hours must be a positive integer, got {args.bucket_hours}") + sys.exit(1) + + if args.max_target_balance > MAX_EFFECTIVE_BALANCE: + print(f"Error: --max-target-balance cannot exceed {MAX_EFFECTIVE_BALANCE} ETH (protocol max)") + sys.exit(1) + + if args.max_target_balance < DEFAULT_SOURCE_BALANCE * 2: + print(f"Error: --max-target-balance must be at least {DEFAULT_SOURCE_BALANCE * 2} ETH") + sys.exit(1) + + # Connect to database + try: + conn = get_db_connection() + except ValueError as e: + print(f"Error: {e}") + print("Set VALIDATOR_DB environment variable to your PostgreSQL connection string") + sys.exit(1) + except Exception as e: + print(f"Database connection error: {e}") + sys.exit(1) + + try: + if args.list_operators: + operators = list_operators(conn) + print("\n=== Operators ===") + print(f"{'Name':<30} {'Address':<44} {'Total':>8}") + print("-" * 85) + for op in operators: + addr_display = op['address'] if op['address'] else 'N/A' + print(f"{op['name']:<30} {addr_display:<44} {op['total']:>8}") + return + + # Resolve operator + if args.operator_address: + operator_address = args.operator_address.lower() + address_to_name, _ = load_operators_from_db(conn) + operator_name = address_to_name.get(operator_address, 'Unknown') + elif args.operator: + operator_address = get_operator_address(conn, args.operator) + if not operator_address: + print(f"Error: Operator '{args.operator}' not found") + print("Use --list-operators to see available operators") + sys.exit(1) + operator_name = args.operator + else: + print("Error: Must specify --operator or --operator-address") + parser.print_help() + sys.exit(1) + + # Query validators - get more than needed to allow for filtering + MAX_VALIDATORS_QUERY = 100000 + + print(f"\n=== Querying Validators ===") + print(f"Operator: {operator_name} ({operator_address})") + print(f"Target source count: {args.count if args.count > 0 else 'all available'}") + print(f"Max target balance: {args.max_target_balance} ETH") + + validators = query_validators( + conn, + operator_address, + MAX_VALIDATORS_QUERY + ) + + if not validators: + print(f"No validators found matching criteria") + sys.exit(1) + + print(f"Found {len(validators)} validators from database") + + # Filter out already consolidated validators (we want 0x01 -> 0x02) + print(f"\nChecking consolidation status on beacon chain...") + filtered_validators, consolidated_validators = filter_consolidated_validators( + validators, + exclude_consolidated=True, + beacon_api=args.beacon_api, + show_progress=True + ) + + print(f"\nFiltered results:") + print(f" Already consolidated (0x02): {len(consolidated_validators)}") + print(f" Need consolidation (0x01): {len(filtered_validators)}") + + if len(filtered_validators) == 0: + print("\nError: No validators need consolidation (all are already 0x02)") + sys.exit(1) + + # Fetch beacon chain balances for existing 0x02 validators to use as targets + existing_targets = [] + if consolidated_validators: + print(f"\nFetching beacon chain balances for {len(consolidated_validators)} existing 0x02 validators...") + consolidated_pubkeys = [v.get('pubkey', '') for v in consolidated_validators if v.get('pubkey')] + beacon_details = fetch_validator_details_batch(consolidated_pubkeys, beacon_api=args.beacon_api) + missing_balance_pubkeys = [] + + for v in consolidated_validators: + pubkey = v.get('pubkey', '') + details = beacon_details.get(pubkey, {}) + is_consolidated = details.get('is_consolidated') + balance_eth = details.get('balance_eth') + + # Strict mode: existing 0x02 targets must have resolvable beacon balances. + if not details or is_consolidated is None or balance_eth is None: + missing_balance_pubkeys.append(pubkey) + continue + + if balance_eth > 0 and balance_eth < args.max_target_balance: + capacity = calculate_consolidation_capacity(balance_eth, args.max_target_balance) + if capacity > 0: + v['balance_eth'] = balance_eth + # Use beacon withdrawal credentials (already 0x02) + if details.get('beacon_withdrawal_credentials'): + v['beacon_withdrawal_credentials'] = details['beacon_withdrawal_credentials'] + existing_targets.append(v) + + if missing_balance_pubkeys: + print("\nError: Missing beacon balance for existing 0x02 validator targets.") + print("Failed pubkeys:") + for pk in missing_balance_pubkeys[:20]: + print(f" - {pk}") + if len(missing_balance_pubkeys) > 20: + print(f" ... and {len(missing_balance_pubkeys) - 20} more") + print("Aborting to avoid using fallback/default balances for existing 0x02 targets.") + sys.exit(1) + + print(f" 0x02 validators with capacity (balance < {args.max_target_balance} ETH): {len(existing_targets)}") + if existing_targets: + total_capacity = sum( + calculate_consolidation_capacity(v['balance_eth'], args.max_target_balance) + for v in existing_targets + ) + print(f" Total additional capacity: ~{total_capacity} source validators") + + # Use all available validators if count is 0 (default) + # Note: Use filtered_validators count (0x01 validators) not raw validators count + source_count = args.count if args.count > 0 else len(filtered_validators) + print(f"\nUsing source count: {source_count}") + + # Create consolidation plan + plan = create_consolidation_plan( + filtered_validators, + source_count, + args.max_target_balance, + args.bucket_hours, + existing_targets=existing_targets + ) + + if plan['summary']['total_sources'] == 0: + print("\nError: Could not create any consolidations") + print("This may happen if all validators are already targets or at capacity") + sys.exit(1) + + # Check for validation errors + if plan['validation'].get('errors'): + print("\n⚠ WARNING: Validation errors found!") + print("Review the plan carefully before proceeding.") + + # Write output or just preview + if args.dry_run: + print("\n=== DRY RUN - Plan Preview ===") + output = convert_to_output_format(plan) + print(json.dumps(output, indent=2, default=str)) + print("\n(Use without --dry-run to write to file)") + else: + write_output(plan, args.output, operator_name) + + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/script/operations/consolidations/run-consolidation-python.sh b/script/operations/consolidations/run-consolidation-python.sh new file mode 100755 index 000000000..ee837a587 --- /dev/null +++ b/script/operations/consolidations/run-consolidation-python.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# +# run-consolidation-python.sh - Python-first validator consolidation workflow +# +# Mirrors run-consolidation.sh flags, but parses consolidation data in Python +# and uses cast send for mainnet broadcasting. +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# Load .env file if it exists +if [ -f "$PROJECT_ROOT/.env" ]; then + set -a + source "$PROJECT_ROOT/.env" + set +a +fi + +python3 "$SCRIPT_DIR/run_consolidation_python.py" "$@" diff --git a/script/operations/consolidations/run-consolidation.sh b/script/operations/consolidations/run-consolidation.sh new file mode 100755 index 000000000..cfcbc7624 --- /dev/null +++ b/script/operations/consolidations/run-consolidation.sh @@ -0,0 +1,466 @@ +#!/bin/bash +# +# run-consolidation.sh - Automated validator consolidation workflow +# +# This script consolidates multiple source validators into target validators, +# with targets auto-selected to ensure distribution across the withdrawal sweep queue. +# +# Usage: +# ./script/operations/consolidations/run-consolidation.sh \ +# --operator "Validation Cloud" \ +# --count 50 \ +# --bucket-hours 6 \ +# --max-target-balance 2016 +# +# This script: +# 1. Creates an output directory: consolidations/{operator}_{count}_{timestamp}/ +# 2. Queries validators and creates consolidation plan +# 3. Generates Gnosis Safe transactions for each target +# 4. Simulates on Tenderly Virtual Testnet +# + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# Load .env file if it exists +if [ -f "$PROJECT_ROOT/.env" ]; then + # Export variables from .env (skip comments and empty lines) + set -a + source "$PROJECT_ROOT/.env" + set +a +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default parameters +OPERATOR="" # operator name from the address-remapping table in Database +COUNT=0 # number of source validators to consolidate (0 = use all available) +BUCKET_HOURS=6 +MAX_TARGET_BALANCE=1900 # max balance of the target validator after consolidation +DRY_RUN=false +SKIP_SIMULATE=false +SKIP_FORGE_SIM=false # pass --skip-simulation to forge (skip EVM simulation, just broadcast) +VERBOSE=false # pass -vvvv to forge for full execution traces +BATCH_SIZE=58 # max number of consolidations per transaction +MAINNET=false # broadcast transactions on mainnet using ADMIN_EOA + +print_usage() { + echo "Usage: $0 --operator [options]" + echo "" + echo "Consolidate multiple source validators into target validators." + echo "Targets are auto-selected to ensure distribution across the withdrawal sweep queue." + echo "" + echo "Required:" + echo " --operator Operator name (e.g., 'Validation Cloud')" + echo "" + echo "Options:" + echo " --count Number of source validators to consolidate (default: 0 = all available)" + echo " --bucket-hours Time bucket duration for sweep queue distribution (default: 6)" + echo " --max-target-balance Maximum ETH balance allowed on target post-consolidation (default: 1900)" + echo " --batch-size Number of consolidations per transaction (default: 58)" + echo " --dry-run Output consolidation plan JSON without executing forge script" + echo " --skip-simulate Skip Tenderly simulation step" + echo " --skip-forge-sim Pass --skip-simulation to forge (skip EVM simulation, just broadcast)" + echo " --verbose, -v Enable verbose forge output (-vvvv traces). Default: minimal verbosity" + echo " --mainnet Broadcast transactions on mainnet using ADMIN_EOA (requires PRIVATE_KEY)" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " # Consolidate all validators for operator (simulation only)" + echo " $0 --operator 'Validation Cloud'" + echo "" + echo " # Consolidation with custom settings (limit to 100 validators)" + echo " $0 --operator 'Infstones' --count 100 --bucket-hours 6 --max-target-balance 1900" + echo "" + echo " # Dry run to preview plan" + echo " $0 --operator 'Validation Cloud' --dry-run" + echo "" + echo " # Execute on mainnet" + echo " $0 --operator 'Validation Cloud' --count 50 --mainnet" + echo "" + echo "Environment Variables:" + echo " MAINNET_RPC_URL Ethereum mainnet RPC URL (required)" + echo " VALIDATOR_DB PostgreSQL connection string for validator database" + echo " PRIVATE_KEY Private key for ADMIN_EOA (required for --mainnet)" + echo " BEACON_CHAIN_URL Beacon chain API URL (optional)" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --operator) + OPERATOR="$2" + shift 2 + ;; + --count) + COUNT="$2" + shift 2 + ;; + --bucket-hours) + BUCKET_HOURS="$2" + shift 2 + ;; + --max-target-balance) + MAX_TARGET_BALANCE="$2" + shift 2 + ;; + --batch-size) + BATCH_SIZE="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --skip-simulate) + SKIP_SIMULATE=true + shift + ;; + --skip-forge-sim) + SKIP_FORGE_SIM=true + shift + ;; + --verbose|-v) + VERBOSE=true + shift + ;; + --mainnet) + MAINNET=true + shift + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option: $1${NC}" + print_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$OPERATOR" ]; then + echo -e "${RED}Error: --operator is required${NC}" + print_usage + exit 1 +fi + +# Check environment variables +if [ -z "$MAINNET_RPC_URL" ]; then + echo -e "${RED}Error: MAINNET_RPC_URL environment variable not set${NC}" + echo "Set it in your .env file or export it: export MAINNET_RPC_URL=https://..." + exit 1 +fi + +if [ -z "$VALIDATOR_DB" ]; then + echo -e "${RED}Error: VALIDATOR_DB environment variable not set${NC}" + echo "Set it in your .env file or export it: export VALIDATOR_DB=postgres://..." + exit 1 +fi + +# Check PRIVATE_KEY if --mainnet is used +if [ "$MAINNET" = true ] && [ -z "$PRIVATE_KEY" ]; then + echo -e "${RED}Error: PRIVATE_KEY environment variable not set${NC}" + echo "Set it in your .env file or export it for --mainnet mode" + exit 1 +fi + +# Create output directory +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +OPERATOR_SLUG=$(echo "$OPERATOR" | tr ' ' '_' | tr '[:upper:]' '[:lower:]') +OUTPUT_DIR="$SCRIPT_DIR/txns/${OPERATOR_SLUG}_consolidation_${COUNT}_${TIMESTAMP}" +mkdir -p "$OUTPUT_DIR" + +echo "" +echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ VALIDATOR CONSOLIDATION WORKFLOW ║${NC}" +echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${BLUE}Configuration:${NC}" +echo " Operator: $OPERATOR" +if [ "$COUNT" -eq 0 ]; then + echo " Source count: all available" +else + echo " Source count: $COUNT validators" +fi +echo " Bucket interval: ${BUCKET_HOURS}h" +echo " Max target balance: ${MAX_TARGET_BALANCE} ETH" +echo " Batch size: $BATCH_SIZE" +echo " Dry run: $DRY_RUN" +echo " Skip forge sim: $SKIP_FORGE_SIM" +echo " Verbose forge: $VERBOSE" +echo " Mainnet mode: $MAINNET" +echo " Output directory: $OUTPUT_DIR" +echo "" + +# ============================================================================ +# Step 1: Query validators and create consolidation plan +# ============================================================================ +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${YELLOW}[1/4] Creating consolidation plan...${NC}" +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + +QUERY_ARGS=( + --operator "$OPERATOR" + --count "$COUNT" + --bucket-hours "$BUCKET_HOURS" + --max-target-balance "$MAX_TARGET_BALANCE" + --output "$OUTPUT_DIR/consolidation-data.json" +) + +if [ "$DRY_RUN" = true ]; then + QUERY_ARGS+=(--dry-run) +fi + +python3 "$SCRIPT_DIR/query_validators_consolidation.py" "${QUERY_ARGS[@]}" + +if [ "$DRY_RUN" = true ]; then + echo "" + echo -e "${GREEN}✓ Dry run complete. No transactions generated.${NC}" + exit 0 +fi + +if [ ! -f "$OUTPUT_DIR/consolidation-data.json" ]; then + echo -e "${RED}Error: Failed to create consolidation plan${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}✓ Consolidation plan written to $OUTPUT_DIR/consolidation-data.json${NC}" +echo "" + +# ============================================================================ +# Step 2: Generate transactions / Broadcast on mainnet +# ============================================================================ +if [ "$MAINNET" = true ]; then + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[2/4] Broadcasting transactions on MAINNET...${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${RED}⚠ WARNING: This will execute REAL transactions on mainnet!${NC}" + echo "" +else + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[2/4] Generating transaction files...${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +fi + +# Parse the consolidation data +CONSOLIDATION_DATA="$OUTPUT_DIR/consolidation-data.json" + +# Check if jq is available for JSON parsing +if ! command -v jq &> /dev/null; then + echo -e "${RED}Error: jq is required for JSON parsing${NC}" + echo "Install with: brew install jq (macOS) or apt-get install jq (Linux)" + exit 1 +fi + +# Get number of consolidations (targets) +NUM_TARGETS=$(jq '.consolidations | length' "$CONSOLIDATION_DATA") +TOTAL_SOURCES=$(jq '[.consolidations[].sources | length] | add' "$CONSOLIDATION_DATA") +echo "Processing $NUM_TARGETS target consolidations with $TOTAL_SOURCES total sources..." + +# Build forge command +FORGE_CMD="CONSOLIDATION_DATA_FILE=\"$CONSOLIDATION_DATA\" OUTPUT_DIR=\"$OUTPUT_DIR\" BATCH_SIZE=\"$BATCH_SIZE\"" + +# Pass skip gas estimate to Solidity script when skipping forge simulation +if [ "$SKIP_FORGE_SIM" = true ]; then + FORGE_CMD="$FORGE_CMD SKIP_GAS_ESTIMATE=true" +fi + +# Build optional forge flags +FORGE_EXTRA_FLAGS="" +if [ "$SKIP_FORGE_SIM" = true ]; then + FORGE_EXTRA_FLAGS="$FORGE_EXTRA_FLAGS --skip-simulation" +fi +if [ "$VERBOSE" = true ]; then + FORGE_EXTRA_FLAGS="$FORGE_EXTRA_FLAGS -vvvv" +fi + +if [ "$MAINNET" = true ]; then + # Mainnet mode: broadcast transactions using ADMIN_EOA + FORGE_CMD="$FORGE_CMD BROADCAST=true forge script \"$SCRIPT_DIR/ConsolidateToTarget.s.sol:ConsolidateToTarget\" \ + --rpc-url \"$MAINNET_RPC_URL\" \ + --private-key \"$PRIVATE_KEY\" \ + --broadcast${FORGE_EXTRA_FLAGS:+ $FORGE_EXTRA_FLAGS}" +else + # Simulation mode: generate JSON transaction files + FORGE_CMD="$FORGE_CMD forge script \"$SCRIPT_DIR/ConsolidateToTarget.s.sol:ConsolidateToTarget\" \ + --fork-url \"$MAINNET_RPC_URL\"${FORGE_EXTRA_FLAGS:+ $FORGE_EXTRA_FLAGS}" +fi + +echo "Running forge script..." +eval "$FORGE_CMD" 2>&1 | tee "$OUTPUT_DIR/forge_all_targets.log" +FORGE_EXIT_CODE=${PIPESTATUS[0]} + +if [ $FORGE_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Forge script failed with exit code $FORGE_EXIT_CODE${NC}" + exit 1 +fi + +# Check for generated files +echo "" +echo "Looking for generated files in: $OUTPUT_DIR" +TX_FILES=() + +for generated_file in "$OUTPUT_DIR"/*.json; do + filename=$(basename "$generated_file") + # Skip consolidation-data.json + if [ "$filename" != "consolidation-data.json" ]; then + TX_FILES+=("$generated_file") + echo -e "${GREEN} ✓ Found $filename${NC}" + fi +done + +if [ ${#TX_FILES[@]} -eq 0 ]; then + echo -e "${YELLOW} Warning: No transaction files found${NC}" + echo " Contents of $OUTPUT_DIR:" + ls -la "$OUTPUT_DIR"/*.json 2>/dev/null || echo " No JSON files found" +fi + +echo "" +echo -e "${GREEN}✓ Generated transaction files for $NUM_TARGETS targets${NC}" +echo "" + +# ============================================================================ +# Step 3: List generated files (skip if mainnet mode) +# ============================================================================ +if [ "$MAINNET" = true ]; then + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[3/4] Transactions broadcast on mainnet${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" +else + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[3/4] Generated files:${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + ls -la "$OUTPUT_DIR"/*.json 2>/dev/null || echo "No JSON files found" + echo "" +fi + +# ============================================================================ +# Step 4: Simulate on Tenderly (skip if mainnet mode) +# ============================================================================ +if [ "$MAINNET" = true ]; then + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[4/4] Skipping simulation (transactions already broadcast on mainnet)${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +elif [ "$SKIP_SIMULATE" = true ]; then + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[4/4] Skipping Tenderly simulation (--skip-simulate)${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +else + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[4/4] Simulating on Tenderly...${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + VNET_NAME="${OPERATOR_SLUG}-consolidation-${COUNT}-${TIMESTAMP}" + + # Check if linking is needed by looking for link-validators file + LINK_FILE="$OUTPUT_DIR/link-validators.json" + + # Find consolidation files (individual files: consolidation-txns-1.json, consolidation-txns-2.json, etc.) + CONSOLIDATION_FILES=($(ls "$OUTPUT_DIR"/consolidation-txns-*.json 2>/dev/null | sort -V)) + + if [ ${#CONSOLIDATION_FILES[@]} -gt 0 ]; then + echo "Found ${#CONSOLIDATION_FILES[@]} consolidation transaction file(s)" + + # Build list of all transaction files + ALL_TX_FILES=() + if [ -f "$LINK_FILE" ]; then + echo "Linking required. Adding link-validators.json to transaction list..." + ALL_TX_FILES+=("$LINK_FILE") + fi + ALL_TX_FILES+=("${CONSOLIDATION_FILES[@]}") + + # Join all transaction files with commas for the simulate.py script + TX_FILES_CSV=$(IFS=,; echo "${ALL_TX_FILES[*]}") + + echo "Transaction files to simulate: ${#ALL_TX_FILES[@]}" + for f in "${ALL_TX_FILES[@]}"; do + echo " - $(basename "$f")" + done + echo "" + + CMD="python3 $PROJECT_ROOT/script/operations/utils/simulate.py --tenderly \ + --txns \"$TX_FILES_CSV\" \ + --vnet-name \"$VNET_NAME\"" + echo "Running: $CMD" + eval "$CMD" + SIMULATION_EXIT_CODE=$? + else + echo -e "${RED}Error: No consolidation files found to simulate${NC}" + SIMULATION_EXIT_CODE=1 + fi + + # Check if simulation was successful + if [ $SIMULATION_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Tenderly simulation failed${NC}" + echo -e "${RED}Check the output above for failed transaction links${NC}" + exit 1 + fi +fi + +# ============================================================================ +# Summary +# ============================================================================ +echo "" +echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ CONSOLIDATION COMPLETE ║${NC}" +echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${BLUE}Output directory:${NC} $OUTPUT_DIR" +echo "" +echo -e "${BLUE}Generated files:${NC}" +ls -1 "$OUTPUT_DIR"/*.json 2>/dev/null | while read -r file; do + echo " - $(basename "$file")" +done + +# Extract summary from consolidation data +if [ -f "$CONSOLIDATION_DATA" ]; then + echo "" + echo -e "${BLUE}Consolidation Summary:${NC}" + TOTAL_TARGETS=$(jq '.summary.total_targets' "$CONSOLIDATION_DATA") + TOTAL_SOURCES=$(jq '.summary.total_sources' "$CONSOLIDATION_DATA") + TOTAL_ETH=$(jq '.summary.total_eth_consolidated' "$CONSOLIDATION_DATA") + echo " Total targets: $TOTAL_TARGETS" + echo " Total sources: $TOTAL_SOURCES" + echo " Total ETH consolidated: $TOTAL_ETH" +fi + +if [ "$MAINNET" = true ]; then + echo "" + echo -e "${BLUE}Mainnet Execution Complete:${NC}" + echo " All transactions have been broadcast to mainnet." + echo " Check the forge output above for transaction hashes." + echo "" + echo -e "${YELLOW}⚠ Note: Monitor transactions on Etherscan for confirmation.${NC}" + echo "" +else + echo "" + echo -e "${BLUE}Next steps:${NC}" + echo " 1. Review the consolidation plan in consolidation-data.json" + + # Check if linking was needed + if [ -f "$OUTPUT_DIR/link-validators.json" ]; then + echo " 2. Execute link-validators.json from ADMIN_EOA" + echo " 3. Execute consolidation-txns-*.json files from ADMIN_EOA (each one)" + else + echo " 2. Execute consolidation-txns-*.json files from ADMIN_EOA (each one)" + fi + echo "" + echo " Execute each transaction from ADMIN_EOA (one file at a time)" + echo "" + echo -e "${YELLOW}⚠ Note: Each consolidation request requires a small fee paid to the beacon chain.${NC}" + echo -e "${YELLOW} Ensure ADMIN_EOA has sufficient ETH balance for fees.${NC}" + echo "" +fi diff --git a/script/operations/consolidations/run-consolidationThroughPython.sh b/script/operations/consolidations/run-consolidationThroughPython.sh new file mode 100755 index 000000000..2c0adb6f2 --- /dev/null +++ b/script/operations/consolidations/run-consolidationThroughPython.sh @@ -0,0 +1,409 @@ +#!/bin/bash +# Make script executable +# chmod +x script/operations/consolidations/run-consolidationThroughPython.sh +# +# run-consolidationThroughPython.sh - Automated validator consolidation workflow +# +# This script consolidates multiple source validators into target validators, +# with targets auto-selected to ensure distribution across the withdrawal sweep queue. +# +# Usage: +# ./script/operations/consolidations/run-consolidationThroughPython.sh \ +# --operator "Validation Cloud" \ +# --count 50 \ +# --bucket-hours 6 \ +# --max-target-balance 2016 +# +# This script: +# 1. Creates an output directory: consolidations/{operator}_{count}_{timestamp}/ +# 2. Queries validators and creates consolidation plan +# 3. Generates Gnosis Safe transactions for each target +# 4. Simulates on Tenderly Virtual Testnet +# + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# Load .env file if it exists +if [ -f "$PROJECT_ROOT/.env" ]; then + # Export variables from .env (skip comments and empty lines) + set -a + source "$PROJECT_ROOT/.env" + set +a +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default parameters +OPERATOR="" # operator name from the address-remapping table in Database +COUNT=0 # number of source validators to consolidate (0 = use all available) +BUCKET_HOURS=6 +MAX_TARGET_BALANCE=1888 # max balance of the target validator after consolidation +DRY_RUN=false +SKIP_SIMULATE=false +NONCE=0 # starting nonce for the Safe transactions +BATCH_SIZE=50 # number of consolidations per transaction + +print_usage() { + echo "Usage: $0 --operator [options]" + echo "" + echo "Consolidate multiple source validators into target validators." + echo "Targets are auto-selected to ensure distribution across the withdrawal sweep queue." + echo "" + echo "Required:" + echo " --operator Operator name (e.g., 'Validation Cloud')" + echo "" + echo "Options:" + echo " --count Number of source validators to consolidate (default: 0 = all available)" + echo " --bucket-hours Time bucket duration for sweep queue distribution (default: 6)" + echo " --max-target-balance Maximum ETH balance allowed on target post-consolidation (default: 1888)" + echo " --nonce Starting Safe nonce for tx hash computation (default: 0)" + echo " --batch-size Number of consolidations per transaction (default: 58)" + echo " --dry-run Output consolidation plan JSON without executing forge script" + echo " --skip-simulate Skip Tenderly simulation step" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " # Consolidate all validators for operator" + echo " $0 --operator 'Validation Cloud'" + echo "" + echo " # Consolidation with custom settings (limit to 100 validators)" + echo " $0 --operator 'Infstones' --count 100 --bucket-hours 6 --max-target-balance 1888" + echo "" + echo " # Dry run to preview plan" + echo " $0 --operator 'Validation Cloud' --dry-run" + echo "" + echo "Environment Variables:" + echo " MAINNET_RPC_URL Ethereum mainnet RPC URL (required)" + echo " VALIDATOR_DB PostgreSQL connection string for validator database" + echo " BEACON_CHAIN_URL Beacon chain API URL (optional)" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --operator) + OPERATOR="$2" + shift 2 + ;; + --count) + COUNT="$2" + shift 2 + ;; + --bucket-hours) + BUCKET_HOURS="$2" + shift 2 + ;; + --max-target-balance) + MAX_TARGET_BALANCE="$2" + shift 2 + ;; + --nonce) + NONCE="$2" + shift 2 + ;; + --batch-size) + BATCH_SIZE="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --skip-simulate) + SKIP_SIMULATE=true + shift + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option: $1${NC}" + print_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$OPERATOR" ]; then + echo -e "${RED}Error: --operator is required${NC}" + print_usage + exit 1 +fi + +# Check environment variables +if [ -z "$MAINNET_RPC_URL" ]; then + echo -e "${RED}Error: MAINNET_RPC_URL environment variable not set${NC}" + echo "Set it in your .env file or export it: export MAINNET_RPC_URL=https://..." + exit 1 +fi + +if [ -z "$VALIDATOR_DB" ]; then + echo -e "${RED}Error: VALIDATOR_DB environment variable not set${NC}" + echo "Set it in your .env file or export it: export VALIDATOR_DB=postgres://..." + exit 1 +fi + +# Create output directory +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +OPERATOR_SLUG=$(echo "$OPERATOR" | tr ' ' '_' | tr '[:upper:]' '[:lower:]') +OUTPUT_DIR="$SCRIPT_DIR/txns/${OPERATOR_SLUG}_consolidation_${COUNT}_${TIMESTAMP}" +mkdir -p "$OUTPUT_DIR" + +echo "" +echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ VALIDATOR CONSOLIDATION WORKFLOW ║${NC}" +echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${BLUE}Configuration:${NC}" +echo " Operator: $OPERATOR" +if [ "$COUNT" -eq 0 ]; then + echo " Source count: all available" +else + echo " Source count: $COUNT validators" +fi +echo " Bucket interval: ${BUCKET_HOURS}h" +echo " Max target balance: ${MAX_TARGET_BALANCE} ETH" +echo " Batch size: $BATCH_SIZE" +echo " Safe nonce: $NONCE" +echo " Dry run: $DRY_RUN" +echo " Output directory: $OUTPUT_DIR" +echo "" + +# ============================================================================ +# Step 1: Query validators and create consolidation plan +# ============================================================================ +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${YELLOW}[1/4] Creating consolidation plan...${NC}" +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + +QUERY_ARGS=( + --operator "$OPERATOR" + --count "$COUNT" + --bucket-hours "$BUCKET_HOURS" + --max-target-balance "$MAX_TARGET_BALANCE" + --output "$OUTPUT_DIR/consolidation-data.json" +) + +if [ "$DRY_RUN" = true ]; then + QUERY_ARGS+=(--dry-run) +fi + +python3 "$SCRIPT_DIR/query_validators_consolidation.py" "${QUERY_ARGS[@]}" + +if [ "$DRY_RUN" = true ]; then + echo "" + echo -e "${GREEN}✓ Dry run complete. No transactions generated.${NC}" + exit 0 +fi + +if [ ! -f "$OUTPUT_DIR/consolidation-data.json" ]; then + echo -e "${RED}Error: Failed to create consolidation plan${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}✓ Consolidation plan written to $OUTPUT_DIR/consolidation-data.json${NC}" +echo "" + +# ============================================================================ +# Step 2: Generate Gnosis Safe transactions +# ============================================================================ +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${YELLOW}[2/4] Generating Gnosis Safe transactions...${NC}" +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + +# Parse the consolidation data +CONSOLIDATION_DATA="$OUTPUT_DIR/consolidation-data.json" + +# Check if jq is available for JSON parsing +if ! command -v jq &> /dev/null; then + echo -e "${RED}Error: jq is required for JSON parsing${NC}" + echo "Install with: brew install jq (macOS) or apt-get install jq (Linux)" + exit 1 +fi + +# Get number of consolidations (targets) +NUM_TARGETS=$(jq '.consolidations | length' "$CONSOLIDATION_DATA") +TOTAL_SOURCES=$(jq '[.consolidations[].sources | length] | add' "$CONSOLIDATION_DATA") +echo "Processing $NUM_TARGETS target consolidations with $TOTAL_SOURCES total sources..." + +# Generate transactions using Python script (faster, no forge compilation required) +GENERATE_ARGS=( + --input "$CONSOLIDATION_DATA" + --output-dir "$OUTPUT_DIR" + --batch-size "$BATCH_SIZE" +) + +python3 "$SCRIPT_DIR/generate_gnosis_txns.py" "${GENERATE_ARGS[@]}" 2>&1 | tee "$OUTPUT_DIR/generate_txns.log" +GENERATE_EXIT_CODE=${PIPESTATUS[0]} + +if [ $GENERATE_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Failed to generate transaction files${NC}" + exit 1 +fi + +# Check for generated files +echo "" +echo "Looking for generated files in: $OUTPUT_DIR" +TX_FILES=() + +for generated_file in "$OUTPUT_DIR"/*.json; do + filename=$(basename "$generated_file") + # Skip consolidation-data.json and targets.json + if [ "$filename" != "consolidation-data.json" ] && [ "$filename" != "targets.json" ]; then + TX_FILES+=("$generated_file") + echo -e "${GREEN} ✓ Found $filename${NC}" + fix +done + +if [ ${#TX_FILES[@]} -eq 0 ]; then + echo -e "${YELLOW} Warning: No transaction files found${NC}" + echo " Contents of $OUTPUT_DIR:" + ls -la "$OUTPUT_DIR"/*.json 2>/dev/null || echo " No JSON files found" +fi + +echo "" +echo -e "${GREEN}✓ Generated transaction files for $NUM_TARGETS targets${NC}" +echo "" + +# ============================================================================ +# Step 3: List generated files +# ============================================================================ +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${YELLOW}[3/4] Generated files:${NC}" +echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +ls -la "$OUTPUT_DIR"/*.json 2>/dev/null || echo "No JSON files found" +echo "" + +# ============================================================================ +# Step 4: Simulate on Tenderly +# ============================================================================ +if [ "$SKIP_SIMULATE" = true ]; then + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[4/4] Skipping Tenderly simulation (--skip-simulate)${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +else + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW}[4/4] Simulating on Tenderly...${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + VNET_NAME="${OPERATOR_SLUG}-consolidation-${COUNT}-${TIMESTAMP}" + + # Check if linking is needed by looking for schedule file + SCHEDULE_FILE="$OUTPUT_DIR/link-schedule.json" + EXECUTE_FILE="$OUTPUT_DIR/link-execute.json" + + # Find consolidation files (now individual files: consolidation-txns-1.json, consolidation-txns-2.json, etc.) + CONSOLIDATION_FILES=($(ls "$OUTPUT_DIR"/consolidation-txns-*.json 2>/dev/null | sort -V)) + + if [ ${#CONSOLIDATION_FILES[@]} -gt 0 ]; then + echo "Found ${#CONSOLIDATION_FILES[@]} consolidation transaction file(s)" + + # Join all consolidation files with commas for the simulate.py script + CONSOLIDATION_FILES_CSV=$(IFS=,; echo "${CONSOLIDATION_FILES[*]}") + + if [ -f "$SCHEDULE_FILE" ] && [ -f "$EXECUTE_FILE" ]; then + # Linking needed - run with timelock delay, pass all consolidation files via --then + echo "Linking required. Running simulation with timelock delay..." + echo " Schedule: $(basename "$SCHEDULE_FILE")" + echo " Execute: $(basename "$EXECUTE_FILE")" + echo " Consolidation files: ${#CONSOLIDATION_FILES[@]}" + for f in "${CONSOLIDATION_FILES[@]}"; do + echo " - $(basename "$f")" + done + echo "" + + CMD="python3 $PROJECT_ROOT/script/operations/utils/simulate.py --tenderly \ + --schedule \"$SCHEDULE_FILE\" \ + --execute \"$EXECUTE_FILE\" \ + --then \"$CONSOLIDATION_FILES_CSV\" \ + --delay 8h --vnet-name \"$VNET_NAME\"" + echo "Running: $CMD" + eval "$CMD" + SIMULATION_EXIT_CODE=$? + else + # No linking needed - pass all consolidation files via --txns + echo "No linking required. Running all consolidation transactions..." + echo " Consolidation files: ${#CONSOLIDATION_FILES[@]}" + for f in "${CONSOLIDATION_FILES[@]}"; do + echo " - $(basename "$f")" + done + echo "" + + CMD="python3 $PROJECT_ROOT/script/operations/utils/simulate.py --tenderly \ + --txns \"$CONSOLIDATION_FILES_CSV\" \ + --vnet-name \"$VNET_NAME\"" + echo "Running: $CMD" + eval "$CMD" + SIMULATION_EXIT_CODE=$? + fi + else + echo -e "${RED}Error: No consolidation files found to simulate${NC}" + SIMULATION_EXIT_CODE=1 + fi + + # Check if simulation was successful + if [ $SIMULATION_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Tenderly simulation failed${NC}" + echo -e "${RED}Check the output above for failed transaction links${NC}" + exit 1 + fi +fi + +# ============================================================================ +# Summary +# ============================================================================ +echo "" +echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ CONSOLIDATION COMPLETE ║${NC}" +echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${BLUE}Output directory:${NC} $OUTPUT_DIR" +echo "" +echo -e "${BLUE}Generated files:${NC}" +ls -1 "$OUTPUT_DIR"/*.json 2>/dev/null | while read -r file; do + echo " - $(basename "$file")" +done + +# Extract summary from consolidation data +if [ -f "$CONSOLIDATION_DATA" ]; then + echo "" + echo -e "${BLUE}Consolidation Summary:${NC}" + TOTAL_TARGETS=$(jq '.summary.total_targets' "$CONSOLIDATION_DATA") + TOTAL_SOURCES=$(jq '.summary.total_sources' "$CONSOLIDATION_DATA") + TOTAL_ETH=$(jq '.summary.total_eth_consolidated' "$CONSOLIDATION_DATA") + echo " Total targets: $TOTAL_TARGETS" + echo " Total sources: $TOTAL_SOURCES" + echo " Total ETH consolidated: $TOTAL_ETH" +fi + +echo "" +echo -e "${BLUE}Next steps:${NC}" +echo " 1. Review the consolidation plan in consolidation-data.json" + +# Check if linking was needed +if [ -f "$OUTPUT_DIR/link-schedule.json" ]; then + echo " 2. Import link-schedule.json to Gnosis Safe → Execute" + echo " 3. Wait 8 hours for timelock delay" + echo " 4. Import link-execute.json to Gnosis Safe → Execute" + echo " 5. Import consolidation-txns-*.json files to Gnosis Safe → Execute each one" +else + echo " 2. Import consolidation-txns-*.json files to Gnosis Safe → Execute each one" +fi +echo "" +echo " Execute each transaction from Gnosis Safe (one file at a time)" +echo "" +echo -e "${YELLOW}⚠ Note: Each consolidation request requires a small fee paid to the beacon chain.${NC}" +echo -e "${YELLOW} Ensure the Safe has sufficient ETH balance for fees.${NC}" +echo "" diff --git a/script/operations/consolidations/run-submarine-withdrawal.sh b/script/operations/consolidations/run-submarine-withdrawal.sh new file mode 100755 index 000000000..242debcc7 --- /dev/null +++ b/script/operations/consolidations/run-submarine-withdrawal.sh @@ -0,0 +1,577 @@ +#!/bin/bash +# +# run-submarine-withdrawal.sh - Submarine withdrawal via validator consolidation +# +# Withdraws a large amount of ETH by consolidating validators within a single +# EigenPod into one target. The excess above 2048 ETH is auto-swept by the +# beacon chain. +# +# Usage: +# ./script/operations/consolidations/run-submarine-withdrawal.sh \ +# --operator "Cosmostation" \ +# --amount 10000 +# +# This script: +# 1. Runs submarine_withdrawal.py to find the best pod and generate tx files +# 2. Simulates on Tenderly Virtual Testnet +# 3. Prints execution order +# + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# Load .env file if it exists +if [ -f "$PROJECT_ROOT/.env" ]; then + set -a + source "$PROJECT_ROOT/.env" + set +a +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Default parameters +OPERATOR="" +AMOUNT=0 +DRY_RUN=false +SKIP_SIMULATE=false +MAINNET=false +BATCH_SIZE=150 +UNRESTAKE_ONLY=false + +print_usage() { + echo "Usage: $0 --operator --amount [options]" + echo "" + echo "Withdraw a large ETH amount via submarine consolidation." + echo "Consolidates validators within a single EigenPod into one target." + echo "Excess above 2048 ETH is automatically swept by the beacon chain." + echo "" + echo "Required:" + echo " --operator Operator name (e.g., 'Cosmostation')" + echo " --amount ETH amount to withdraw (e.g., 10000)" + echo "" + echo "Options:" + echo " --batch-size Validators per tx including target at [0] (default: 150)" + echo " --unrestake-only Skip consolidation; queue ETH withdrawals directly from pod balances" + echo " --dry-run Preview plan without generating transactions" + echo " --skip-simulate Skip Tenderly simulation" + echo " --mainnet Broadcast on mainnet using ADMIN_EOA (requires PRIVATE_KEY)" + echo " --help, -h Show this help" + echo "" + echo "Examples:" + echo " # Preview plan" + echo " $0 --operator 'Cosmostation' --amount 10000 --dry-run" + echo "" + echo " # Generate files and simulate" + echo " $0 --operator 'Cosmostation' --amount 10000" + echo "" + echo " # Unrestake: withdraw directly from pod balances (no consolidation)" + echo " $0 --operator 'Cosmostation' --amount 1000 --unrestake-only" + echo "" + echo " # Skip simulation" + echo " $0 --operator 'Cosmostation' --amount 10000 --skip-simulate" + echo "" + echo " # Broadcast on mainnet" + echo " $0 --operator 'Cosmostation' --amount 10000 --mainnet" + echo "" + echo "Environment Variables:" + echo " MAINNET_RPC_URL Ethereum mainnet RPC URL (required for simulation/mainnet)" + echo " VALIDATOR_DB PostgreSQL connection string for validator database" + echo " PRIVATE_KEY Private key for ADMIN_EOA (required for --mainnet)" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --operator) + OPERATOR="$2" + shift 2 + ;; + --amount) + AMOUNT="$2" + shift 2 + ;; + --batch-size) + BATCH_SIZE="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --unrestake-only) + UNRESTAKE_ONLY=true + shift + ;; + --skip-simulate) + SKIP_SIMULATE=true + shift + ;; + --mainnet) + MAINNET=true + shift + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option: $1${NC}" + print_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$OPERATOR" ]; then + echo -e "${RED}Error: --operator is required${NC}" + print_usage + exit 1 +fi + +if [ "$AMOUNT" = "0" ] || [ -z "$AMOUNT" ]; then + echo -e "${RED}Error: --amount is required${NC}" + print_usage + exit 1 +fi + +# Check environment variables +if [ -z "$VALIDATOR_DB" ]; then + echo -e "${RED}Error: VALIDATOR_DB environment variable not set${NC}" + exit 1 +fi + +if [ "$MAINNET" = true ] && [ -z "$PRIVATE_KEY" ]; then + echo -e "${RED}Error: PRIVATE_KEY environment variable not set (required for --mainnet)${NC}" + exit 1 +fi + +# Create output directory +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +OPERATOR_SLUG=$(echo "$OPERATOR" | tr ' ' '_' | tr '[:upper:]' '[:lower:]') +if [ "$UNRESTAKE_ONLY" = true ]; then + MODE_SLUG="unrestake" +else + MODE_SLUG="submarine" +fi +OUTPUT_DIR="$SCRIPT_DIR/txns/${OPERATOR_SLUG}_${MODE_SLUG}_${AMOUNT}eth_${TIMESTAMP}" +mkdir -p "$OUTPUT_DIR" + +echo "" +if [ "$UNRESTAKE_ONLY" = true ]; then + echo -e "${GREEN}================================================================${NC}" + echo -e "${GREEN} UNRESTAKE WITHDRAWAL ${NC}" + echo -e "${GREEN}================================================================${NC}" +else + echo -e "${GREEN}================================================================${NC}" + echo -e "${GREEN} SUBMARINE WITHDRAWAL ${NC}" + echo -e "${GREEN}================================================================${NC}" +fi +echo "" +echo -e "${BLUE}Configuration:${NC}" +echo " Operator: $OPERATOR" +echo " Amount: $AMOUNT ETH" +echo " Mode: $([ "$UNRESTAKE_ONLY" = true ] && echo 'UNRESTAKE' || echo 'SUBMARINE')" +if [ "$UNRESTAKE_ONLY" != true ]; then + echo " Batch size: $BATCH_SIZE" +fi +echo " Dry run: $DRY_RUN" +echo " Mainnet mode: $MAINNET" +echo " Output: $OUTPUT_DIR" +echo "" + +# ============================================================================ +# Step 1: Generate submarine withdrawal plan and transaction files +# ============================================================================ +echo -e "${YELLOW}[1/3] Generating submarine withdrawal plan...${NC}" +echo -e "${YELLOW}================================================================${NC}" + +PLAN_ARGS=( + --operator "$OPERATOR" + --amount "$AMOUNT" + --output-dir "$OUTPUT_DIR" + --batch-size "$BATCH_SIZE" +) + +if [ "$UNRESTAKE_ONLY" = true ]; then + PLAN_ARGS+=(--unrestake-only) +fi + +if [ "$DRY_RUN" = true ]; then + PLAN_ARGS+=(--dry-run) +fi + +python3 "$SCRIPT_DIR/submarine_withdrawal.py" "${PLAN_ARGS[@]}" + +if [ "$DRY_RUN" = true ]; then + echo "" + echo -e "${GREEN}Dry run complete. No files generated.${NC}" + exit 0 +fi + +if [ ! -f "$OUTPUT_DIR/submarine-plan.json" ]; then + echo -e "${RED}Error: Failed to generate submarine plan${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}Plan generated successfully.${NC}" +echo "" + +# ============================================================================ +# Step 2: Simulate on Tenderly (or broadcast on mainnet) +# ============================================================================ +if [ "$MAINNET" = true ]; then + echo -e "${YELLOW}[2/3] Broadcasting on MAINNET...${NC}" + echo -e "${YELLOW}================================================================${NC}" + echo -e "${RED}WARNING: This will execute REAL transactions on mainnet!${NC}" + echo "" + + if [ -z "$MAINNET_RPC_URL" ]; then + echo -e "${RED}Error: MAINNET_RPC_URL required for mainnet broadcast${NC}" + exit 1 + fi + + NODES_MANAGER="0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F" + ADMIN_ADDRESS="0x12582A27E5e19492b4FcD194a60F8f5e1aa31B0F" + + if [ "$UNRESTAKE_ONLY" = true ]; then + # Unrestake mode: only execute queueETHWithdrawal (no linking/consolidation) + QUEUE_FILE="$OUTPUT_DIR/queue-withdrawals.json" + else + # Submarine mode: execute linking, consolidation, then queueETHWithdrawal + + # Execute linking transactions first (if file exists) + LINK_FILE="$OUTPUT_DIR/link-validators.json" + if [ -f "$LINK_FILE" ]; then + NUM_LINK_TXS=$(jq '.transactions | length' "$LINK_FILE") + echo "Executing link-validators.json ($NUM_LINK_TXS linking tx(s))..." + for IDX in $(seq 0 $((NUM_LINK_TXS - 1))); do + TX_TO=$(jq -r ".transactions[$IDX].to" "$LINK_FILE") + TX_VALUE=$(jq -r ".transactions[$IDX].value" "$LINK_FILE") + TX_DATA=$(jq -r ".transactions[$IDX].data" "$LINK_FILE") + + echo " Sending link tx $((IDX + 1))/$NUM_LINK_TXS..." + cast send "$TX_TO" "$TX_DATA" \ + --value "$TX_VALUE" \ + --rpc-url "$MAINNET_RPC_URL" \ + --private-key "$PRIVATE_KEY" 2>&1 | tee -a "$OUTPUT_DIR/mainnet_broadcast.log" + CAST_EXIT_CODE=${PIPESTATUS[0]} + + if [ $CAST_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Linking tx $((IDX + 1)) failed${NC}" + exit 1 + fi + done + echo -e "${GREEN}All linking transactions sent successfully.${NC}" + echo "" + fi + + # Execute consolidation transactions sequentially with dynamic fee + GAS_WARNING_THRESHOLD=12000000 + CONSOLIDATION_FILES=($(ls "$OUTPUT_DIR"/consolidation-txns-*.json 2>/dev/null | sort -V)) + for f in "${CONSOLIDATION_FILES[@]}"; do + echo "Executing $(basename "$f")..." + TX_TO=$(jq -r '.transactions[0].to' "$f") + TX_DATA=$(jq -r '.transactions[0].data' "$f") + TARGET_PUBKEY=$(jq -r '.metadata.target_pubkey' "$f") + NUM_VALIDATORS=$(jq -r '.metadata.num_validators' "$f") + + # Fetch dynamic consolidation fee from EigenPod + echo " Fetching consolidation fee for target ${TARGET_PUBKEY:0:20}..." + PUBKEY_HASH=$(cast call "$NODES_MANAGER" "calculateValidatorPubkeyHash(bytes)(bytes32)" "$TARGET_PUBKEY" --rpc-url "$MAINNET_RPC_URL") + NODE_ADDR=$(cast call "$NODES_MANAGER" "etherFiNodeFromPubkeyHash(bytes32)(address)" "$PUBKEY_HASH" --rpc-url "$MAINNET_RPC_URL") + EIGENPOD=$(cast call "$NODE_ADDR" "getEigenPod()(address)" --rpc-url "$MAINNET_RPC_URL") + FEE_PER_REQUEST=$(cast call "$EIGENPOD" "getConsolidationRequestFee()(uint256)" --rpc-url "$MAINNET_RPC_URL") + + # Compute total value = fee * num_validators + TOTAL_VALUE=$((FEE_PER_REQUEST * NUM_VALIDATORS)) + echo " Fee per request: $FEE_PER_REQUEST wei" + echo " Num validators: $NUM_VALIDATORS" + echo " Total value: $TOTAL_VALUE wei" + + # Estimate gas + echo " Estimating gas..." + GAS_ESTIMATE=$(cast estimate "$TX_TO" "$TX_DATA" \ + --value "$TOTAL_VALUE" \ + --from "$ADMIN_ADDRESS" \ + --rpc-url "$MAINNET_RPC_URL" 2>&1) + ESTIMATE_EXIT_CODE=$? + + if [ $ESTIMATE_EXIT_CODE -ne 0 ]; then + echo -e "${RED} Gas estimation failed: $GAS_ESTIMATE${NC}" + echo -e "${RED} Proceeding without gas limit override${NC}" + GAS_LIMIT_FLAG="" + else + echo " Estimated gas: $GAS_ESTIMATE" + if [ "$GAS_ESTIMATE" -gt 12000000 ]; then + echo -e "${RED} *** WARNING: Gas estimate ($GAS_ESTIMATE) exceeds 12M! ***${NC}" + echo -e "${RED} *** Consider reducing batch size (current: $BATCH_SIZE) ***${NC}" + fi + # Add 20% buffer to gas estimate + GAS_LIMIT=$(( (GAS_ESTIMATE * 120) / 100 )) + echo " Gas limit (with 20% buffer): $GAS_LIMIT" + GAS_LIMIT_FLAG="--gas-limit $GAS_LIMIT" + fi + + cast send "$TX_TO" "$TX_DATA" \ + --value "$TOTAL_VALUE" \ + $GAS_LIMIT_FLAG \ + --rpc-url "$MAINNET_RPC_URL" \ + --private-key "$PRIVATE_KEY" 2>&1 | tee -a "$OUTPUT_DIR/mainnet_broadcast.log" + CAST_EXIT_CODE=${PIPESTATUS[0]} + + if [ $CAST_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: $(basename "$f") failed${NC}" + exit 1 + fi + echo -e "${GREEN}$(basename "$f") sent successfully.${NC}" + echo "" + done + + QUEUE_FILE="$OUTPUT_DIR/post-sweep/queue-withdrawals.json" + fi + + # Execute queueETHWithdrawal transactions (shared by both modes) + if [ -f "$QUEUE_FILE" ]; then + NUM_WITHDRAWALS=$(jq '.transactions | length' "$QUEUE_FILE") + echo -e "${YELLOW}Executing queueETHWithdrawal for $NUM_WITHDRAWALS pod(s)...${NC}" + + for IDX in $(seq 0 $((NUM_WITHDRAWALS - 1))); do + TARGET_PUBKEY=$(jq -r ".transactions[$IDX].target_pubkey" "$QUEUE_FILE") + TARGET_ID=$(jq -r ".transactions[$IDX].target_id" "$QUEUE_FILE") + WITHDRAWAL_GWEI=$(jq -r ".transactions[$IDX].withdrawal_amount_gwei" "$QUEUE_FILE") + WITHDRAWAL_ETH=$(jq -r ".transactions[$IDX].withdrawal_amount_eth" "$QUEUE_FILE") + TX_TO=$(jq -r ".transactions[$IDX].to" "$QUEUE_FILE") + TX_DATA=$(jq -r ".transactions[$IDX].data" "$QUEUE_FILE") + + # Compute wei from gwei (integer) via python to avoid bash overflow + WITHDRAWAL_WEI=$(python3 -c "print($WITHDRAWAL_GWEI * 10**9)") + + echo " Pod $((IDX + 1)): target id=$TARGET_ID" + echo " Amount: $WITHDRAWAL_ETH ETH ($WITHDRAWAL_GWEI gwei / $WITHDRAWAL_WEI wei)" + + if [ "$TX_DATA" = "0x" ] || [ -z "$TX_DATA" ] || [ "$TX_DATA" = "null" ]; then + # Data not pre-encoded; resolve node address and encode on the fly + NODE_ADDR=$(jq -r ".transactions[$IDX].node_address // empty" "$QUEUE_FILE") + + if [ -z "$NODE_ADDR" ] || [ "$NODE_ADDR" = "null" ]; then + echo " Resolving node via etherfiNodeAddress($TARGET_ID)..." + NODE_ADDR=$(cast call "$NODES_MANAGER" "etherfiNodeAddress(uint256)(address)" "$TARGET_ID" --rpc-url "$MAINNET_RPC_URL") + fi + + if [ "$NODE_ADDR" = "0x0000000000000000000000000000000000000000" ]; then + echo -e "${RED}Error: Node not found for target id=$TARGET_ID${NC}" + exit 1 + fi + + echo " Node: $NODE_ADDR" + + cast send "$NODES_MANAGER" "queueETHWithdrawal(address,uint256)" \ + "$NODE_ADDR" "$WITHDRAWAL_WEI" \ + --rpc-url "$MAINNET_RPC_URL" \ + --private-key "$PRIVATE_KEY" 2>&1 | tee -a "$OUTPUT_DIR/mainnet_broadcast.log" + else + # Use pre-encoded calldata from Python (handles large amounts correctly) + echo " Using pre-encoded calldata" + cast send "$TX_TO" "$TX_DATA" \ + --rpc-url "$MAINNET_RPC_URL" \ + --private-key "$PRIVATE_KEY" 2>&1 | tee -a "$OUTPUT_DIR/mainnet_broadcast.log" + fi + CAST_EXIT_CODE=${PIPESTATUS[0]} + + if [ $CAST_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: queueETHWithdrawal failed for pod $((IDX + 1))${NC}" + exit 1 + fi + echo -e "${GREEN} queueETHWithdrawal for pod $((IDX + 1)) sent successfully.${NC}" + done + echo "" + fi + +elif [ "$SKIP_SIMULATE" = true ]; then + echo -e "${YELLOW}[2/3] Skipping Tenderly simulation (--skip-simulate)${NC}" + echo -e "${YELLOW}================================================================${NC}" + +else + echo -e "${YELLOW}[2/3] Simulating on Tenderly...${NC}" + echo -e "${YELLOW}================================================================${NC}" + + if [ -z "$MAINNET_RPC_URL" ]; then + echo -e "${RED}Error: MAINNET_RPC_URL required for simulation${NC}" + exit 1 + fi + + VNET_NAME="${OPERATOR_SLUG}-${MODE_SLUG}-${AMOUNT}eth-${TIMESTAMP}" + + # Collect all transaction files in order + ALL_TX_FILES=() + + if [ "$UNRESTAKE_ONLY" = true ]; then + # Unrestake: only queue-withdrawals.json at root + QUEUE_FILE="$OUTPUT_DIR/queue-withdrawals.json" + if [ -f "$QUEUE_FILE" ]; then + ALL_TX_FILES+=("$QUEUE_FILE") + echo " Including: queue-withdrawals.json" + fi + else + # Submarine: link + consolidation + post-sweep/queue-withdrawals + LINK_FILE="$OUTPUT_DIR/link-validators.json" + if [ -f "$LINK_FILE" ]; then + ALL_TX_FILES+=("$LINK_FILE") + echo " Including: link-validators.json" + fi + + CONSOLIDATION_FILES=($(ls "$OUTPUT_DIR"/consolidation-txns-*.json 2>/dev/null | sort -V)) + if [ ${#CONSOLIDATION_FILES[@]} -gt 0 ]; then + for f in "${CONSOLIDATION_FILES[@]}"; do + ALL_TX_FILES+=("$f") + echo " Including: $(basename "$f")" + done + fi + + QUEUE_FILE="$OUTPUT_DIR/post-sweep/queue-withdrawals.json" + if [ -f "$QUEUE_FILE" ]; then + ALL_TX_FILES+=("$QUEUE_FILE") + echo " Including: post-sweep/queue-withdrawals.json" + fi + fi + + if [ ${#ALL_TX_FILES[@]} -eq 0 ]; then + echo -e "${RED}Error: No transaction files found to simulate${NC}" + exit 1 + fi + + # Join with commas + TX_FILES_CSV=$(IFS=,; echo "${ALL_TX_FILES[*]}") + + echo "" + echo "Simulating ${#ALL_TX_FILES[@]} transaction file(s)..." + CMD="python3 $PROJECT_ROOT/script/operations/utils/simulate.py --tenderly \ + --txns \"$TX_FILES_CSV\" \ + --vnet-name \"$VNET_NAME\"" + echo "Running: $CMD" + eval "$CMD" + SIMULATION_EXIT_CODE=$? + + if [ $SIMULATION_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Tenderly simulation failed${NC}" + exit 1 + fi +fi + +# ============================================================================ +# Step 3: Summary +# ============================================================================ +echo "" +if [ "$UNRESTAKE_ONLY" = true ]; then + echo -e "${GREEN}================================================================${NC}" + echo -e "${GREEN} UNRESTAKE WITHDRAWAL COMPLETE ${NC}" + echo -e "${GREEN}================================================================${NC}" +else + echo -e "${GREEN}================================================================${NC}" + echo -e "${GREEN} SUBMARINE WITHDRAWAL COMPLETE ${NC}" + echo -e "${GREEN}================================================================${NC}" +fi +echo "" +echo -e "${BLUE}Output directory:${NC} $OUTPUT_DIR" +echo "" + +# Extract summary from submarine-plan.json +SUBMARINE_PLAN="$OUTPUT_DIR/submarine-plan.json" +if [ -f "$SUBMARINE_PLAN" ] && command -v jq &> /dev/null; then + REQUESTED=$(jq '.requested_amount_eth' "$SUBMARINE_PLAN") + TOTAL_WITHDRAWAL=$(jq '.total_withdrawal_eth' "$SUBMARINE_PLAN") + NUM_PODS=$(jq '.num_pods_used' "$SUBMARINE_PLAN") + QUEUE_TXS=$(jq '.transactions.queue_withdrawals // 0' "$SUBMARINE_PLAN") + TOTAL_TXS=$(jq '.transactions.total // 0' "$SUBMARINE_PLAN") + + echo -e "${BLUE}Summary:${NC}" + echo " Requested withdrawal: $REQUESTED ETH" + echo " Total withdrawal: $TOTAL_WITHDRAWAL ETH" + echo " Pods used: $NUM_PODS" + + if [ "$UNRESTAKE_ONLY" != true ]; then + NUM_SOURCES=$(jq '.consolidation.total_sources' "$SUBMARINE_PLAN") + LINK_TXS=$(jq '.transactions.linking // 0' "$SUBMARINE_PLAN") + CONSOL_TXS=$(jq '.transactions.consolidation // .consolidation.num_transactions' "$SUBMARINE_PLAN") + echo " Sources consolidated: $NUM_SOURCES" + echo "" + echo " Transactions:" + echo " Linking: $LINK_TXS" + echo " Consolidation: $CONSOL_TXS" + echo " Queue withdrawals: $QUEUE_TXS" + echo " Total: $TOTAL_TXS" + else + echo "" + echo " Transactions:" + echo " Queue withdrawals: $QUEUE_TXS" + echo " Total: $TOTAL_TXS" + fi + echo "" + + # Show per-pod details + for IDX in $(seq 0 $((NUM_PODS - 1))); do + POD_ADDR=$(jq -r ".pods[$IDX].eigenpod" "$SUBMARINE_PLAN") + POD_TARGET=$(jq -r ".pods[$IDX].target_pubkey" "$SUBMARINE_PLAN") + POD_WITHDRAWAL=$(jq ".pods[$IDX].withdrawal_eth" "$SUBMARINE_PLAN") + echo " Pod $((IDX + 1)): $POD_ADDR" + echo " Target: ${POD_TARGET:0:20}..." + if [ "$UNRESTAKE_ONLY" != true ]; then + POD_SOURCES=$(jq ".pods[$IDX].num_sources" "$SUBMARINE_PLAN") + POD_0X02=$(jq -r ".pods[$IDX].is_target_0x02" "$SUBMARINE_PLAN") + echo " Sources: $POD_SOURCES" + echo " Is 0x02: $POD_0X02" + fi + echo " Withdrawal: $POD_WITHDRAWAL ETH" + done + echo "" +fi + +echo -e "${BLUE}Generated files:${NC}" +ls -1 "$OUTPUT_DIR"/*.json 2>/dev/null | while read -r file; do + echo " - $(basename "$file")" +done +if [ "$UNRESTAKE_ONLY" != true ]; then + ls -1 "$OUTPUT_DIR"/post-sweep/*.json 2>/dev/null | while read -r file; do + echo " - post-sweep/$(basename "$file")" + done +fi + +echo "" +echo -e "${BLUE}Execution order:${NC}" +if [ "$UNRESTAKE_ONLY" = true ]; then + echo " 1. Execute queue-withdrawals.json from ADMIN_EOA (queueETHWithdrawal)" + echo " 2. Wait for EigenLayer withdrawal delay, then completeQueuedETHWithdrawals" +else + STEP=1 + if [ -f "$OUTPUT_DIR/link-validators.json" ]; then + echo " $STEP. Execute link-validators.json from ADMIN_EOA" + STEP=$((STEP + 1)) + fi + for f in "$OUTPUT_DIR"/consolidation-txns-*.json; do + if [ -f "$f" ]; then + echo " $STEP. Execute $(basename "$f") from ADMIN_EOA" + STEP=$((STEP + 1)) + fi + done + echo " $STEP. Wait for beacon chain consolidation + sweep" + STEP=$((STEP + 1)) + if [ -f "$OUTPUT_DIR/post-sweep/queue-withdrawals.json" ]; then + echo " $STEP. Execute queue-withdrawals.json from ADMIN_EOA (queueETHWithdrawal)" + STEP=$((STEP + 1)) + echo " $STEP. Wait for EigenLayer withdrawal delay, then completeQueuedETHWithdrawals" + fi + echo "" + echo -e "${YELLOW}Note: Each consolidation request requires a small fee paid to the beacon chain.${NC}" + echo -e "${YELLOW}Ensure ADMIN_EOA has sufficient ETH balance for fees.${NC}" +fi +echo "" diff --git a/script/operations/consolidations/run_consolidation_python.py b/script/operations/consolidations/run_consolidation_python.py new file mode 100644 index 000000000..12f67db1d --- /dev/null +++ b/script/operations/consolidations/run_consolidation_python.py @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +""" +Python-first consolidation runner. + +This mirrors the main CLI surface of run-consolidation.sh, but avoids heavy +JSON parsing in Solidity. It: + 1) Builds consolidation-data.json (via query_validators_consolidation.py) + 2) Parses consolidation data in Python + 3) Generates transaction JSON files + 4) Optionally broadcasts immediately on mainnet using cast send +""" + +from __future__ import annotations + +import argparse +from decimal import Decimal, InvalidOperation +import json +import os +import re +import subprocess +import sys +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from generate_gnosis_txns import ( + ADMIN_EOA, + ETHERFI_NODES_MANAGER, + encode_link_legacy_validators, + generate_consolidation_calldata, + generate_gnosis_tx_json, +) + + +DEFAULT_BUCKET_HOURS = 6 +DEFAULT_MAX_TARGET_BALANCE = 1900.0 +DEFAULT_BATCH_SIZE = 58 +DEFAULT_CHAIN_ID = 1 +CONSOLIDATION_GAS_LIMIT = 15_000_000 +TX_DELAY_SECONDS = 5 +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +QUEUE_ETH_WITHDRAWAL_SELECTOR = "0x96d373e5" # queueETHWithdrawal(address,uint256) + + +@dataclass +class Config: + operator: str + count: int + bucket_hours: int + max_target_balance: float + batch_size: int + dry_run: bool + skip_simulate: bool + skip_forge_sim: bool + verbose: bool + mainnet: bool + project_root: Path + script_dir: Path + output_dir: Path + mainnet_rpc_url: str + validator_db: str + private_key: Optional[str] + chain_id: int + admin_address: str + + +def load_dotenv_if_present(project_root: Path) -> None: + env_file = project_root / ".env" + if not env_file.exists(): + return + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'").strip('"') + os.environ.setdefault(key, value) + + +def run_cmd( + cmd: List[str], + *, + check: bool = True, + capture_output: bool = True, + cwd: Optional[Path] = None, +) -> subprocess.CompletedProcess: + if cwd is None: + cwd = Path.cwd() + if capture_output: + proc = subprocess.run( + cmd, + cwd=str(cwd), + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + else: + proc = subprocess.run( + cmd, + cwd=str(cwd), + check=False, + ) + if check and proc.returncode != 0: + stderr = proc.stderr.strip() if proc.stderr else "" + stdout = proc.stdout.strip() if proc.stdout else "" + detail = stderr or stdout or "command failed" + raise RuntimeError(f"{' '.join(cmd)} failed: {detail}") + return proc + + +def cast_call(rpc_url: str, to: str, signature: str, *args: str) -> str: + cmd = ["cast", "call", to, signature, *args, "--rpc-url", rpc_url] + proc = run_cmd(cmd) + return (proc.stdout or "").strip() + + +def cast_calldata(signature: str, *args: str) -> str: + cmd = ["cast", "calldata", signature, *args] + proc = run_cmd(cmd) + return (proc.stdout or "").strip() + + +def cast_send_raw( + rpc_url: str, + to: str, + private_key: str, + data: str, + value_wei: int = 0, + gas_limit: Optional[int] = None, +) -> str: + cmd = [ + "cast", + "send", + to, + data, + "--rpc-url", + rpc_url, + "--private-key", + private_key, + "--json", + "--value", + str(value_wei), + ] + if gas_limit is not None: + cmd.extend(["--gas-limit", str(gas_limit)]) + proc = run_cmd(cmd) + out = (proc.stdout or "").strip() + tx_hash = parse_tx_hash_from_send_output(out) + if not tx_hash: + raise RuntimeError(f"failed to parse tx hash from cast send output: {out}") + return tx_hash + + +def parse_tx_hash_from_send_output(out: str) -> Optional[str]: + if not out: + return None + # cast --json prints a JSON object; keep parsing conservative. + try: + data = json.loads(out) + if isinstance(data, dict): + tx_hash = data.get("transactionHash") or data.get("hash") + if isinstance(tx_hash, str) and tx_hash.startswith("0x"): + return tx_hash + except json.JSONDecodeError: + pass + for token in out.replace('"', " ").replace(",", " ").split(): + if token.startswith("0x") and len(token) == 66: + return token + return None + + +def wait_for_receipt(rpc_url: str, tx_hash: str, timeout_seconds: int = 600) -> Dict: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + proc = run_cmd( + ["cast", "receipt", tx_hash, "--rpc-url", rpc_url, "--json"], + check=False, + ) + if proc.returncode == 0 and proc.stdout: + try: + receipt = json.loads(proc.stdout) + if isinstance(receipt, dict) and receipt.get("status") is not None: + return receipt + except json.JSONDecodeError: + pass + time.sleep(2) + raise RuntimeError(f"timeout waiting for receipt: {tx_hash}") + + +def normalize_hex_bytes(value: str) -> str: + v = value.strip() + if not v.startswith("0x"): + v = "0x" + v + return v.lower() + + +def parse_int_hex_or_decimal(value: str) -> int: + v = value.strip() + if not v: + raise ValueError("cannot parse empty string as integer") + + # `cast call` can return annotations like `39621 [3.962e4]`. + # Prefer the primary token before any bracketed annotation. + primary = v.split("[", 1)[0].strip() or v + token = primary.split()[0].strip().rstrip(",;") + token = token.replace(",", "").replace("_", "") + + if token.lower().startswith("0x"): + return int(token, 16) + if re.fullmatch(r"[+-]?\d+", token): + return int(token, 10) + if re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)[eE][+-]?\d+", token): + try: + return int(Decimal(token)) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"cannot parse scientific integer from: {value!r}") from exc + + # Fallback for mixed outputs; try hex, then int, then scientific notation. + match = re.search( + r"0x[0-9a-fA-F]+|[+-]?(?:\d+(?:\.\d*)?|\.\d+)[eE][+-]?\d+|[+-]?\d+", + v, + ) + if not match: + raise ValueError(f"cannot parse integer from: {value!r}") + + parsed = match.group(0) + if parsed.lower().startswith("0x"): + return int(parsed, 16) + if re.fullmatch(r"[+-]?\d+", parsed): + return int(parsed, 10) + + try: + return int(Decimal(parsed)) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"cannot parse integer from: {value!r}") from exc + + +def get_signer_address(private_key: str) -> str: + proc = run_cmd(["cast", "wallet", "address", "--private-key", private_key]) + return (proc.stdout or "").strip() + + +def count_sources(consolidations: List[Dict]) -> int: + return sum(len(c.get("sources", [])) for c in consolidations) + + +def pubkey_hash(rpc_url: str, pubkey: str) -> str: + return cast_call( + rpc_url, + ETHERFI_NODES_MANAGER, + "calculateValidatorPubkeyHash(bytes)(bytes32)", + normalize_hex_bytes(pubkey), + ) + + +def node_from_pubkey_hash(rpc_url: str, pk_hash: str) -> str: + return cast_call( + rpc_url, + ETHERFI_NODES_MANAGER, + "etherFiNodeFromPubkeyHash(bytes32)(address)", + pk_hash, + ) + + +def is_linked(rpc_url: str, pubkey: str) -> bool: + pk_hash = pubkey_hash(rpc_url, pubkey) + node = node_from_pubkey_hash(rpc_url, pk_hash) + return node.lower() != ZERO_ADDRESS.lower() + + +def extract_address_from_withdrawal_credentials(withdrawal_credentials: str) -> Optional[str]: + wc = withdrawal_credentials.strip().lower() + if not wc: + return None + if wc.startswith("0x"): + wc = wc[2:] + # full 32-byte withdrawal credentials: 1-byte prefix + 11-byte zero padding + 20-byte address + if len(wc) == 64: + addr = wc[-40:] + elif len(wc) == 40: + addr = wc + else: + return None + return "0x" + addr + + +def get_eigenpod_for_target(rpc_url: str, target_pubkey: str, target: Dict, allow_unlinked_fallback: bool) -> str: + pk_hash = pubkey_hash(rpc_url, target_pubkey) + node = node_from_pubkey_hash(rpc_url, pk_hash) + + if node.lower() != ZERO_ADDRESS.lower(): + return cast_call(rpc_url, node, "getEigenPod()(address)") + + if allow_unlinked_fallback: + # In file-generation mode we do not mutate fork state with a linking transaction. + # Use withdrawal credentials as the EigenPod address for fee lookup. + wc = target.get("withdrawal_credentials", "") + pod = extract_address_from_withdrawal_credentials(wc) + if pod: + return pod + + raise RuntimeError(f"target pubkey not linked: {target_pubkey}") + + +def get_consolidation_fee_wei(rpc_url: str, target_pubkey: str, target: Dict, allow_unlinked_fallback: bool) -> int: + pod = get_eigenpod_for_target(rpc_url, target_pubkey, target, allow_unlinked_fallback) + fee = cast_call(rpc_url, pod, "getConsolidationRequestFee()(uint256)") + return parse_int_hex_or_decimal(fee) + + +def write_json_file(path: Path, content: Dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(content, indent=2) + "\n") + + +def build_gnosis_single_tx_json(chain_id: int, safe_address: str, to: str, value: int, data: str) -> Dict: + raw = generate_gnosis_tx_json( + [{"to": to, "value": str(value), "data": data}], + chain_id, + safe_address, + ) + return json.loads(raw) + + +def build_linking_payload( + cfg: Config, + consolidations: List[Dict], +) -> Tuple[List[int], List[str], Optional[str]]: + unlinked_ids: List[int] = [] + unlinked_pubkeys: List[str] = [] + seen_ids = set() + + for c in consolidations: + target = c.get("target", {}) + target_id = target.get("id") + target_pubkey = target.get("pubkey") + if target_id is not None and target_pubkey: + if not is_linked(cfg.mainnet_rpc_url, target_pubkey) and target_id not in seen_ids: + seen_ids.add(target_id) + unlinked_ids.append(int(target_id)) + unlinked_pubkeys.append(normalize_hex_bytes(target_pubkey)) + + sources = c.get("sources", []) + num_batches = (len(sources) + cfg.batch_size - 1) // cfg.batch_size + for batch_idx in range(num_batches): + first_idx = batch_idx * cfg.batch_size + if first_idx >= len(sources): + continue + source = sources[first_idx] + source_id = source.get("id") + source_pubkey = source.get("pubkey") + if source_id is None or not source_pubkey: + continue + if source_id in seen_ids: + continue + if not is_linked(cfg.mainnet_rpc_url, source_pubkey): + seen_ids.add(source_id) + unlinked_ids.append(int(source_id)) + unlinked_pubkeys.append(normalize_hex_bytes(source_pubkey)) + + if not unlinked_ids: + return unlinked_ids, unlinked_pubkeys, None + + pubkeys_as_bytes = [bytes.fromhex(pk[2:]) for pk in unlinked_pubkeys] + calldata = "0x" + encode_link_legacy_validators(unlinked_ids, pubkeys_as_bytes).hex() + return unlinked_ids, unlinked_pubkeys, calldata + + +def maybe_broadcast_linking(cfg: Config, calldata: str) -> str: + if not cfg.private_key: + raise RuntimeError("PRIVATE_KEY required for --mainnet") + tx_hash = cast_send_raw( + cfg.mainnet_rpc_url, + ETHERFI_NODES_MANAGER, + cfg.private_key, + calldata, + value_wei=0, + ) + receipt = wait_for_receipt(cfg.mainnet_rpc_url, tx_hash) + status = parse_int_hex_or_decimal(str(receipt.get("status"))) + if status != 1: + raise RuntimeError(f"linking tx failed: {tx_hash}") + time.sleep(TX_DELAY_SECONDS) + return tx_hash + + +def split_batches(items: List[Dict], batch_size: int) -> List[List[Dict]]: + return [items[i : i + batch_size] for i in range(0, len(items), batch_size)] + + +def generate_or_broadcast_consolidations(cfg: Config, consolidations: List[Dict]) -> int: + tx_count = 0 + for idx, c in enumerate(consolidations, start=1): + target = c.get("target", {}) + target_pubkey = target.get("pubkey") + sources = c.get("sources", []) + if not target_pubkey or not sources: + continue + + source_batches = split_batches(sources, cfg.batch_size) + if cfg.verbose: + print(f"Processing target {idx}/{len(consolidations)} with {len(sources)} sources ({len(source_batches)} batches)") + + for batch_idx, batch in enumerate(source_batches, start=1): + batch_pubkeys = [normalize_hex_bytes(s["pubkey"]) for s in batch if s.get("pubkey")] + if not batch_pubkeys: + continue + + fee_per_request = get_consolidation_fee_wei( + cfg.mainnet_rpc_url, + target_pubkey, + target, + allow_unlinked_fallback=not cfg.mainnet, + ) + value_wei = fee_per_request * len(batch_pubkeys) + calldata = generate_consolidation_calldata(batch_pubkeys, normalize_hex_bytes(target_pubkey)) + + tx_count += 1 + if cfg.mainnet: + if not cfg.private_key: + raise RuntimeError("PRIVATE_KEY required for --mainnet") + tx_hash = cast_send_raw( + cfg.mainnet_rpc_url, + ETHERFI_NODES_MANAGER, + cfg.private_key, + calldata, + value_wei=value_wei, + gas_limit=CONSOLIDATION_GAS_LIMIT, + ) + receipt = wait_for_receipt(cfg.mainnet_rpc_url, tx_hash) + status = parse_int_hex_or_decimal(str(receipt.get("status"))) + if status != 1: + raise RuntimeError(f"consolidation tx failed: {tx_hash}") + print( + f" Broadcast tx {tx_count} (target {idx}, batch {batch_idx}, fee {fee_per_request}, value {value_wei}) -> {tx_hash}" + ) + time.sleep(TX_DELAY_SECONDS) + else: + tx_json = build_gnosis_single_tx_json( + cfg.chain_id, + cfg.admin_address, + ETHERFI_NODES_MANAGER, + value_wei, + calldata, + ) + out_file = cfg.output_dir / f"consolidation-txns-{tx_count}.json" + write_json_file(out_file, tx_json) + if cfg.verbose: + print( + f" Written consolidation-txns-{tx_count}.json " + f"(target {idx}, batch {batch_idx}, fee {fee_per_request}, value {value_wei})" + ) + return tx_count + + +def generate_or_broadcast_queue_withdrawals(cfg: Config, consolidations: List[Dict]) -> int: + withdrawals: List[Tuple[str, int]] = [] + for c in consolidations: + withdrawal_gwei = c.get("withdrawal_amount_gwei", 0) + if not withdrawal_gwei: + continue + target_pubkey = c.get("target", {}).get("pubkey") + if not target_pubkey: + continue + pk_hash = pubkey_hash(cfg.mainnet_rpc_url, target_pubkey) + node = node_from_pubkey_hash(cfg.mainnet_rpc_url, pk_hash) + if node.lower() == ZERO_ADDRESS.lower(): + raise RuntimeError(f"target pubkey not linked for queue-withdrawal: {target_pubkey}") + withdrawals.append((node, int(withdrawal_gwei) * 10**9)) + + if not withdrawals: + if cfg.verbose: + print("No queue-withdrawals to process") + return 0 + + if cfg.mainnet: + if not cfg.private_key: + raise RuntimeError("PRIVATE_KEY required for --mainnet") + sent = 0 + for node, amount_wei in withdrawals: + calldata = cast_calldata("queueETHWithdrawal(address,uint256)", node, str(amount_wei)) + tx_hash = cast_send_raw( + cfg.mainnet_rpc_url, + ETHERFI_NODES_MANAGER, + cfg.private_key, + calldata, + value_wei=0, + ) + receipt = wait_for_receipt(cfg.mainnet_rpc_url, tx_hash) + status = parse_int_hex_or_decimal(str(receipt.get("status"))) + if status != 1: + raise RuntimeError(f"queueETHWithdrawal failed: {tx_hash}") + sent += 1 + print(f" Broadcast queue-withdrawal {sent}/{len(withdrawals)} -> {tx_hash}") + time.sleep(TX_DELAY_SECONDS) + return sent + + txs: List[Dict] = [] + for node, amount_wei in withdrawals: + calldata = cast_calldata("queueETHWithdrawal(address,uint256)", node, str(amount_wei)) + txs.append({"to": ETHERFI_NODES_MANAGER, "value": "0", "data": calldata}) + + raw = generate_gnosis_tx_json(txs, cfg.chain_id, cfg.admin_address) + parsed = json.loads(raw) + out_file = cfg.output_dir / "post-sweep" / "queue-withdrawals.json" + write_json_file(out_file, parsed) + return len(withdrawals) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Python-first validator consolidation workflow") + parser.add_argument("--operator", required=True, help="Operator name") + parser.add_argument("--count", type=int, default=0, help="Number of source validators to consolidate (0 = all)") + parser.add_argument("--bucket-hours", type=int, default=DEFAULT_BUCKET_HOURS, help="Sweep queue bucket hours") + parser.add_argument( + "--max-target-balance", + type=float, + default=DEFAULT_MAX_TARGET_BALANCE, + help="Maximum ETH balance allowed on target post-consolidation", + ) + parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help="Consolidations per transaction") + parser.add_argument("--dry-run", action="store_true", help="Only produce consolidation-data.json") + parser.add_argument("--skip-simulate", action="store_true", help="Skip Tenderly simulation (not integrated in Python runner)") + parser.add_argument( + "--skip-forge-sim", + action="store_true", + help="Compatibility flag. Python runner does not execute forge simulation.", + ) + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + parser.add_argument("--mainnet", action="store_true", help="Broadcast transactions on mainnet via cast send") + return parser.parse_args() + + +def make_config(args: argparse.Namespace) -> Config: + script_dir = Path(__file__).resolve().parent + project_root = script_dir.parent.parent.parent + load_dotenv_if_present(project_root) + + mainnet_rpc_url = os.environ.get("MAINNET_RPC_URL", "").strip() + validator_db = os.environ.get("VALIDATOR_DB", "").strip() + private_key = os.environ.get("PRIVATE_KEY", "").strip() or None + chain_id = int(os.environ.get("CHAIN_ID", str(DEFAULT_CHAIN_ID))) + admin_address = os.environ.get("ADMIN_ADDRESS", ADMIN_EOA) + + if not mainnet_rpc_url: + raise RuntimeError("MAINNET_RPC_URL environment variable not set") + if not validator_db: + raise RuntimeError("VALIDATOR_DB environment variable not set") + if args.mainnet and not private_key: + raise RuntimeError("PRIVATE_KEY environment variable not set (required for --mainnet)") + + timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + operator_slug = args.operator.replace(" ", "_").lower() + output_dir = script_dir / "txns" / f"{operator_slug}_consolidation_{args.count}_{timestamp}" + output_dir.mkdir(parents=True, exist_ok=True) + + return Config( + operator=args.operator, + count=args.count, + bucket_hours=args.bucket_hours, + max_target_balance=args.max_target_balance, + batch_size=args.batch_size, + dry_run=args.dry_run, + skip_simulate=args.skip_simulate, + skip_forge_sim=args.skip_forge_sim, + verbose=args.verbose, + mainnet=args.mainnet, + project_root=project_root, + script_dir=script_dir, + output_dir=output_dir, + mainnet_rpc_url=mainnet_rpc_url, + validator_db=validator_db, + private_key=private_key, + chain_id=chain_id, + admin_address=admin_address, + ) + + +def print_header(cfg: Config) -> None: + print("") + print("╔════════════════════════════════════════════════════════════╗") + print("║ VALIDATOR CONSOLIDATION WORKFLOW (PYTHON) ║") + print("╚════════════════════════════════════════════════════════════╝") + print("") + print("Configuration:") + print(f" Operator: {cfg.operator}") + print(f" Source count: {cfg.count if cfg.count > 0 else 'all available'}") + print(f" Bucket interval: {cfg.bucket_hours}h") + print(f" Max target balance: {cfg.max_target_balance} ETH") + print(f" Batch size: {cfg.batch_size}") + print(f" Dry run: {cfg.dry_run}") + print(f" Skip forge sim: {cfg.skip_forge_sim}") + print(f" Verbose: {cfg.verbose}") + print(f" Mainnet mode: {cfg.mainnet}") + print(f" Output directory: {cfg.output_dir}") + if cfg.mainnet: + signer = get_signer_address(cfg.private_key or "") + print(f" Broadcaster signer: {signer}") + print("") + + +def run_query_step(cfg: Config) -> Path: + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("[1/4] Creating consolidation plan...") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + output_file = cfg.output_dir / "consolidation-data.json" + cmd = [ + "python3", + str(cfg.script_dir / "query_validators_consolidation.py"), + "--operator", + cfg.operator, + "--count", + str(cfg.count), + "--bucket-hours", + str(cfg.bucket_hours), + "--max-target-balance", + str(cfg.max_target_balance), + "--output", + str(output_file), + ] + if cfg.dry_run: + cmd.append("--dry-run") + + # query script already prints progress; stream output. + proc = subprocess.run(cmd, cwd=str(cfg.project_root), check=False) + if proc.returncode != 0: + raise RuntimeError(f"query_validators_consolidation.py failed with exit code {proc.returncode}") + + if cfg.dry_run: + print("") + print("✓ Dry run complete. No transactions generated.") + sys.exit(0) + + if not output_file.exists(): + raise RuntimeError("Failed to create consolidation-data.json") + + print("") + print(f"✓ Consolidation plan written to {output_file}") + print("") + return output_file + + +def process_transactions_step(cfg: Config, consolidation_data_file: Path) -> Dict: + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + if cfg.mainnet: + print("[2/4] Broadcasting transactions on MAINNET...") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print("⚠ WARNING: This will execute REAL transactions on mainnet!") + else: + print("[2/4] Generating transaction files...") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + data = json.loads(consolidation_data_file.read_text()) + consolidations = data.get("consolidations", []) + num_targets = len(consolidations) + total_sources = count_sources(consolidations) + print(f"Processing {num_targets} target consolidations with {total_sources} total sources...") + + unlinked_ids, unlinked_pubkeys, link_calldata = build_linking_payload(cfg, consolidations) + print(f"Validators requiring linking: {len(unlinked_ids)}") + + link_tx_hash = None + link_file = None + if link_calldata: + if cfg.mainnet: + print("Broadcasting linking transaction...") + link_tx_hash = maybe_broadcast_linking(cfg, link_calldata) + print(f"✓ Linking tx confirmed: {link_tx_hash}") + else: + tx_json = build_gnosis_single_tx_json( + cfg.chain_id, + cfg.admin_address, + ETHERFI_NODES_MANAGER, + 0, + link_calldata, + ) + link_file = cfg.output_dir / "link-validators.json" + write_json_file(link_file, tx_json) + print("✓ Written: link-validators.json") + + print("Processing consolidations...") + tx_count = generate_or_broadcast_consolidations(cfg, consolidations) + print(f"✓ Processed consolidation transactions: {tx_count}") + + print("Processing queue-withdrawals...") + withdrawal_count = generate_or_broadcast_queue_withdrawals(cfg, consolidations) + if withdrawal_count > 0: + if cfg.mainnet: + print(f"✓ Broadcast queue-withdrawals: {withdrawal_count}") + else: + print("✓ Written: post-sweep/queue-withdrawals.json") + + return { + "num_targets": num_targets, + "total_sources": total_sources, + "tx_count": tx_count, + "link_file": str(link_file) if link_file else None, + "link_tx_hash": link_tx_hash, + } + + +def step3_list_files(cfg: Config) -> None: + print("") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + if cfg.mainnet: + print("[3/4] Transactions broadcast on mainnet") + else: + print("[3/4] Generated files:") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + if cfg.mainnet: + return + + json_files = sorted(cfg.output_dir.glob("*.json")) + if not json_files: + print("No JSON files found") + for p in json_files: + print(f" - {p.name}") + post_sweep = cfg.output_dir / "post-sweep" / "queue-withdrawals.json" + if post_sweep.exists(): + print(" - post-sweep/queue-withdrawals.json") + + +def step4_simulation_notice(cfg: Config) -> None: + print("") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + if cfg.mainnet: + print("[4/4] Skipping simulation (transactions already broadcast on mainnet)") + elif cfg.skip_simulate: + print("[4/4] Skipping Tenderly simulation (--skip-simulate)") + else: + print("[4/4] Skipping Tenderly simulation (not integrated in Python runner)") + print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + + +def print_summary(cfg: Config, consolidation_data_file: Path) -> None: + print("") + print("╔════════════════════════════════════════════════════════════╗") + print("║ CONSOLIDATION COMPLETE ║") + print("╚════════════════════════════════════════════════════════════╝") + print("") + print(f"Output directory: {cfg.output_dir}") + print("") + print("Generated files:") + for p in sorted(cfg.output_dir.glob("*.json")): + print(f" - {p.name}") + if (cfg.output_dir / "post-sweep" / "queue-withdrawals.json").exists(): + print(" - post-sweep/queue-withdrawals.json") + + data = json.loads(consolidation_data_file.read_text()) + summary = data.get("summary", {}) + if summary: + print("") + print("Consolidation Summary:") + print(f" Total targets: {summary.get('total_targets')}") + print(f" Total sources: {summary.get('total_sources')}") + print(f" Total ETH consolidated: {summary.get('total_eth_consolidated')}") + + print("") + if cfg.mainnet: + print("Mainnet execution complete.") + print(" All transactions have been broadcast to mainnet.") + print(" Monitor transaction confirmations on Etherscan.") + else: + print("Next steps:") + if (cfg.output_dir / "link-validators.json").exists(): + print(" 1. Execute link-validators.json from ADMIN_EOA") + print(" 2. Execute consolidation-txns-*.json files from ADMIN_EOA") + else: + print(" 1. Execute consolidation-txns-*.json files from ADMIN_EOA") + if (cfg.output_dir / "post-sweep" / "queue-withdrawals.json").exists(): + print(" 3. Execute post-sweep/queue-withdrawals.json from ADMIN_EOA") + print(" Execute one transaction file at a time.") + + +def ensure_tools_available() -> None: + for tool in ("python3", "cast"): + proc = run_cmd(["which", tool], check=False) + if proc.returncode != 0: + raise RuntimeError(f"required tool not found: {tool}") + + +def main() -> None: + args = parse_args() + cfg = make_config(args) + ensure_tools_available() + + print_header(cfg) + consolidation_data_file = run_query_step(cfg) + process_transactions_step(cfg, consolidation_data_file) + step3_list_files(cfg) + step4_simulation_notice(cfg) + print_summary(cfg, consolidation_data_file) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/script/operations/consolidations/send-consolidations-from-json.py b/script/operations/consolidations/send-consolidations-from-json.py new file mode 100644 index 000000000..b4bab37fe --- /dev/null +++ b/script/operations/consolidations/send-consolidations-from-json.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +""" +Broadcast consolidation transactions from an existing consolidation-data.json. + +Features: + - Broadcast-only flow (no dry-run mode) + - Optional linking step via --linking-file (Gnosis tx JSON) + - Sends consolidation transactions with fixed 15,000,000 gas limit + - Reads MAINNET_RPC_URL and PRIVATE_KEY from project .env / environment +""" + +from __future__ import annotations + +import argparse +from decimal import Decimal, InvalidOperation +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path +from typing import Dict, List, Optional + +from generate_gnosis_txns import ETHERFI_NODES_MANAGER, generate_consolidation_calldata + + +DEFAULT_BATCH_SIZE = 58 +CONSOLIDATION_GAS_LIMIT = 15_000_000 +TX_DELAY_SECONDS = 5 +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + + +def load_dotenv_if_present(project_root: Path) -> None: + def strip_inline_comment(raw_value: str) -> str: + in_single = False + in_double = False + out_chars: List[str] = [] + for ch in raw_value: + if ch == "'" and not in_double: + in_single = not in_single + out_chars.append(ch) + continue + if ch == '"' and not in_single: + in_double = not in_double + out_chars.append(ch) + continue + if ch == "#" and not in_single and not in_double: + break + out_chars.append(ch) + return "".join(out_chars).strip() + + env_file = project_root / ".env" + if not env_file.exists(): + return + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = strip_inline_comment(value).strip().strip("'").strip('"') + os.environ.setdefault(key, value) + + +def run_cmd(cmd: List[str], check: bool = True) -> subprocess.CompletedProcess: + proc = subprocess.run(cmd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if check and proc.returncode != 0: + msg = (proc.stderr or proc.stdout or "").strip() + raise RuntimeError(f"{' '.join(cmd)} failed: {msg}") + return proc + + +def parse_int_hex_or_decimal(value: str) -> int: + v = value.strip() + if not v: + raise ValueError("cannot parse empty string as integer") + + # `cast call` can return annotations like `39621 [3.962e4]`. + # Prefer the primary token before any bracketed annotation. + primary = v.split("[", 1)[0].strip() or v + token = primary.split()[0].strip().rstrip(",;") + token = token.replace(",", "").replace("_", "") + + if token.lower().startswith("0x"): + return int(token, 16) + if re.fullmatch(r"[+-]?\d+", token): + return int(token, 10) + if re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)[eE][+-]?\d+", token): + try: + return int(Decimal(token)) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"cannot parse scientific integer from: {value!r}") from exc + + # Fallback for mixed outputs; try hex, then int, then scientific notation. + match = re.search( + r"0x[0-9a-fA-F]+|[+-]?(?:\d+(?:\.\d*)?|\.\d+)[eE][+-]?\d+|[+-]?\d+", + v, + ) + if not match: + raise ValueError(f"cannot parse integer from: {value!r}") + + parsed = match.group(0) + if parsed.lower().startswith("0x"): + return int(parsed, 16) + if re.fullmatch(r"[+-]?\d+", parsed): + return int(parsed, 10) + + try: + return int(Decimal(parsed)) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"cannot parse integer from: {value!r}") from exc + + +def parse_tx_hash_from_send_output(out: str) -> Optional[str]: + if not out: + return None + try: + data = json.loads(out) + if isinstance(data, dict): + tx_hash = data.get("transactionHash") or data.get("hash") + if isinstance(tx_hash, str) and tx_hash.startswith("0x"): + return tx_hash + except json.JSONDecodeError: + pass + for token in out.replace('"', " ").replace(",", " ").split(): + if token.startswith("0x") and len(token) == 66: + return token + return None + + +def wait_for_receipt(rpc_url: str, tx_hash: str, timeout_seconds: int = 900) -> Dict: + deadline = time.time() + timeout_seconds + while time.time() < deadline: + proc = run_cmd(["cast", "receipt", tx_hash, "--rpc-url", rpc_url, "--json"], check=False) + if proc.returncode == 0 and proc.stdout: + try: + receipt = json.loads(proc.stdout) + if isinstance(receipt, dict) and receipt.get("status") is not None: + return receipt + except json.JSONDecodeError: + pass + time.sleep(2) + raise RuntimeError(f"timeout waiting for receipt: {tx_hash}") + + +def cast_call(rpc_url: str, to: str, signature: str, *args: str) -> str: + proc = run_cmd(["cast", "call", to, signature, *args, "--rpc-url", rpc_url]) + return (proc.stdout or "").strip() + + +def cast_send_raw( + rpc_url: str, + to: str, + private_key: str, + data: str, + value_wei: int = 0, + gas_limit: Optional[int] = None, +) -> str: + cmd = [ + "cast", + "send", + to, + data, + "--rpc-url", + rpc_url, + "--private-key", + private_key, + "--json", + "--value", + str(value_wei), + ] + if gas_limit is not None: + cmd.extend(["--gas-limit", str(gas_limit)]) + + proc = run_cmd(cmd) + tx_hash = parse_tx_hash_from_send_output((proc.stdout or "").strip()) + if not tx_hash: + raise RuntimeError(f"failed to parse tx hash from cast send output: {(proc.stdout or '').strip()}") + return tx_hash + + +def normalize_hex_bytes(value: str) -> str: + v = value.strip() + if not v.startswith("0x"): + v = "0x" + v + return v.lower() + + +def split_batches(items: List[Dict], batch_size: int) -> List[List[Dict]]: + return [items[i : i + batch_size] for i in range(0, len(items), batch_size)] + + +def pubkey_hash(rpc_url: str, pubkey: str) -> str: + return cast_call( + rpc_url, + ETHERFI_NODES_MANAGER, + "calculateValidatorPubkeyHash(bytes)(bytes32)", + normalize_hex_bytes(pubkey), + ) + + +def node_from_pubkey_hash(rpc_url: str, pk_hash: str) -> str: + return cast_call( + rpc_url, + ETHERFI_NODES_MANAGER, + "etherFiNodeFromPubkeyHash(bytes32)(address)", + pk_hash, + ) + + +def get_eigenpod_for_target(rpc_url: str, target_pubkey: str) -> str: + pk_hash = pubkey_hash(rpc_url, target_pubkey) + node = node_from_pubkey_hash(rpc_url, pk_hash) + if node.lower() == ZERO_ADDRESS.lower(): + raise RuntimeError( + f"target pubkey is not linked: {target_pubkey}. " + "Provide --linking-file (and ensure it succeeds) before consolidations." + ) + return cast_call(rpc_url, node, "getEigenPod()(address)") + + +def get_consolidation_fee_wei(rpc_url: str, target_pubkey: str) -> int: + pod = get_eigenpod_for_target(rpc_url, target_pubkey) + fee = cast_call(rpc_url, pod, "getConsolidationRequestFee()(uint256)") + return parse_int_hex_or_decimal(fee) + + +def get_signer_address(private_key: str) -> str: + proc = run_cmd(["cast", "wallet", "address", "--private-key", private_key]) + return (proc.stdout or "").strip() + + +def broadcast_linking_file(rpc_url: str, private_key: str, linking_file: Path) -> None: + if not linking_file.exists(): + raise RuntimeError(f"linking file not found: {linking_file}") + payload = json.loads(linking_file.read_text()) + txs = payload.get("transactions", []) + if not txs: + raise RuntimeError(f"no transactions found in linking file: {linking_file}") + + print(f"Broadcasting linking tx file: {linking_file}") + for idx, tx in enumerate(txs, start=1): + to = tx.get("to") + data = tx.get("data") + value = parse_int_hex_or_decimal(str(tx.get("value", "0"))) + if not to or not data: + raise RuntimeError(f"invalid tx at index {idx} in linking file") + tx_hash = cast_send_raw(rpc_url, to, private_key, data, value_wei=value) + receipt = wait_for_receipt(rpc_url, tx_hash) + status = parse_int_hex_or_decimal(str(receipt.get("status"))) + if status != 1: + raise RuntimeError(f"linking tx failed: {tx_hash}") + print(f" ✓ linking tx {idx}/{len(txs)} confirmed: {tx_hash}") + time.sleep(TX_DELAY_SECONDS) + + +def broadcast_consolidations( + rpc_url: str, + private_key: str, + consolidation_data_file: Path, + batch_size: int, +) -> None: + data = json.loads(consolidation_data_file.read_text()) + consolidations = data.get("consolidations", []) + total_sources = sum(len(c.get("sources", [])) for c in consolidations) + + print(f"Targets: {len(consolidations)}") + print(f"Sources: {total_sources}") + print(f"Batch size: {batch_size}") + print(f"Gas limit per consolidation tx: {CONSOLIDATION_GAS_LIMIT}") + print("") + + tx_count = 0 + for target_idx, c in enumerate(consolidations, start=1): + target = c.get("target", {}) + target_pubkey = target.get("pubkey") + sources = c.get("sources", []) + if not target_pubkey or not sources: + continue + + source_batches = split_batches(sources, batch_size) + print(f"Target {target_idx}/{len(consolidations)}: {len(sources)} sources, {len(source_batches)} batch(es)") + + for batch_idx, batch in enumerate(source_batches, start=1): + batch_pubkeys = [normalize_hex_bytes(s["pubkey"]) for s in batch if s.get("pubkey")] + if not batch_pubkeys: + continue + + fee_per_request = get_consolidation_fee_wei(rpc_url, target_pubkey) + value_wei = fee_per_request * len(batch_pubkeys) + calldata = generate_consolidation_calldata(batch_pubkeys, normalize_hex_bytes(target_pubkey)) + + tx_hash = cast_send_raw( + rpc_url, + ETHERFI_NODES_MANAGER, + private_key, + calldata, + value_wei=value_wei, + gas_limit=CONSOLIDATION_GAS_LIMIT, + ) + receipt = wait_for_receipt(rpc_url, tx_hash) + status = parse_int_hex_or_decimal(str(receipt.get("status"))) + if status != 1: + raise RuntimeError(f"consolidation tx failed: {tx_hash}") + + tx_count += 1 + print( + f" ✓ tx {tx_count} (target {target_idx}, batch {batch_idx}, " + f"fee {fee_per_request}, value {value_wei}) {tx_hash}" + ) + time.sleep(TX_DELAY_SECONDS) + + print("") + print(f"Completed. Total consolidation txs broadcast: {tx_count}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Broadcast consolidation txs from consolidation-data.json") + parser.add_argument("--input", required=True, help="Path to consolidation-data.json") + parser.add_argument( + "--linking-file", + help="Optional path to linking tx JSON (e.g. link-validators.json). If provided, sent before consolidations.", + ) + parser.add_argument( + "--batch-size", + type=int, + default=DEFAULT_BATCH_SIZE, + help=f"Consolidations per tx (default: {DEFAULT_BATCH_SIZE})", + ) + return parser.parse_args() + + +def ensure_tools_available() -> None: + for tool in ("cast", "python3"): + proc = run_cmd(["which", tool], check=False) + if proc.returncode != 0: + raise RuntimeError(f"required tool not found: {tool}") + + +def main() -> None: + args = parse_args() + + script_dir = Path(__file__).resolve().parent + project_root = script_dir.parent.parent.parent + load_dotenv_if_present(project_root) + + input_file = Path(args.input).resolve() + if not input_file.exists(): + raise RuntimeError(f"input file not found: {input_file}") + linking_file = Path(args.linking_file).resolve() if args.linking_file else None + + rpc_url = os.environ.get("MAINNET_RPC_URL", "").strip() + private_key = os.environ.get("PRIVATE_KEY", "").strip() + if not rpc_url: + raise RuntimeError("MAINNET_RPC_URL not set in env/.env") + if not private_key: + raise RuntimeError("PRIVATE_KEY not set in env/.env") + + ensure_tools_available() + + signer = get_signer_address(private_key) + print("") + print("=== SEND CONSOLIDATIONS FROM JSON ===") + print(f"Input: {input_file}") + print(f"Linking file: {linking_file if linking_file else 'none'}") + print(f"Broadcaster: {signer}") + print("") + + if linking_file: + broadcast_linking_file(rpc_url, private_key, linking_file) + print("") + + broadcast_consolidations(rpc_url, private_key, input_file, args.batch_size) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/script/operations/consolidations/submarine_withdrawal.py b/script/operations/consolidations/submarine_withdrawal.py new file mode 100644 index 000000000..efb7bddb1 --- /dev/null +++ b/script/operations/consolidations/submarine_withdrawal.py @@ -0,0 +1,1171 @@ +#!/usr/bin/env python3 +""" +submarine_withdrawal.py - Plan a large ETH withdrawal via validator consolidation + +Withdraws a large amount of ETH by consolidating many validators within one or more +EigenPods into target validators. The excess above 2048 ETH per target gets automatically +swept by the beacon chain's withdrawal mechanism. + +If a single EigenPod doesn't have enough ETH, the script splits across multiple pods, +starting from the largest and descending. + +Key design: + - Target validator is always vals[0] in every consolidation transaction + - The first consolidation request in each tx is a self-consolidation (src=target, dst=target) + - This auto-compounds 0x01 -> 0x02 if needed, with no separate step or waiting + - Linking is done once for all validators + +Unrestake mode (--unrestake-only): + - Skips consolidation entirely; queues ETH withdrawals directly from existing pod balances + - Each pod's full balance is withdrawable (no 2048 ETH cap math) + - One queueETHWithdrawal call per pod + +Usage: + python3 submarine_withdrawal.py --operator "Cosmostation" --amount 10000 + python3 submarine_withdrawal.py --operator "Cosmostation" --amount 10000 --dry-run + python3 submarine_withdrawal.py --operator "Cosmostation" --amount 1000 --unrestake-only + python3 submarine_withdrawal.py --list-operators + +Environment Variables: + VALIDATOR_DB: PostgreSQL connection string for validator database + BEACON_CHAIN_URL: Beacon chain API URL (default: https://beaconcha.in/api/v1) +""" + +import argparse +import hashlib +import json +import math +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# Add parent directory to sys.path for absolute imports +parent_dir = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(parent_dir)) + +from utils.validator_utils import ( + get_db_connection, + get_operator_address, + list_operators, + query_validators, + fetch_validator_details_batch, +) + +from query_validators_consolidation import ( + extract_wc_address, + group_by_withdrawal_credentials, + is_consolidated_credentials, + format_full_withdrawal_credentials, + get_validator_balance_eth, +) + +from generate_gnosis_txns import ( + generate_consolidation_calldata, + encode_link_legacy_validators, + normalize_pubkey, + encode_address, + encode_uint256, + ETHERFI_NODES_MANAGER, + ADMIN_EOA, + DEFAULT_CHAIN_ID, +) + + +# ============================================================================= +# Constants +# ============================================================================= + +MAX_EFFECTIVE_BALANCE = 2048 # ETH - protocol max for compounding validators +DEFAULT_SOURCE_BALANCE = 32 # ETH - standard validator balance +DEFAULT_BATCH_SIZE = 150 # validators per tx (including target at [0]) +DEFAULT_FEE = 1 # wei per consolidation request +MIN_WITHDRAWAL_AMOUNT = 32 # ETH - minimum sensible withdrawal +MAX_VALIDATORS_QUERY = 100000 + + +# ============================================================================= +# Pod Evaluation +# ============================================================================= + +def get_balance(v: Dict) -> float: + """Get a validator's balance, preferring beacon data.""" + return v.get('beacon_balance_eth', get_validator_balance_eth(v)) + + +def get_effective_balance(v: Dict) -> float: + """Get a validator's effective balance, falling back to actual balance.""" + return v.get('beacon_effective_balance_eth', get_balance(v)) + + +def evaluate_pod(wc_address: str, validators: List[Dict]) -> Dict: + """ + Evaluate an EigenPod's capacity for submarine withdrawal. + + Returns a dict with pod stats and the best target candidate. + Always returns a result (even for pods with < 2 validators). + """ + total_eth = sum(get_balance(v) for v in validators) + + # Separate 0x02 and 0x01 validators + consolidated = [v for v in validators if v.get('is_consolidated') is True] + unconsolidated = [v for v in validators if v.get('is_consolidated') is not True] + + # Select target: prefer 0x02 with highest balance, else 0x01 with highest balance + target = None + is_target_0x02 = False + if consolidated: + consolidated.sort(key=get_balance, reverse=True) + target = consolidated[0] + is_target_0x02 = True + elif unconsolidated: + unconsolidated.sort(key=get_balance, reverse=True) + target = unconsolidated[0] + + target_balance = get_balance(target) if target else 0 + available_sources = len(validators) - 1 if target else 0 + + # Max withdrawal if we consolidate ALL sources into this target + max_withdrawal = max(0, target_balance + available_sources * DEFAULT_SOURCE_BALANCE - MAX_EFFECTIVE_BALANCE) + + return { + 'wc_address': wc_address, + 'total_validators': len(validators), + 'total_eth': total_eth, + 'consolidated_count': len(consolidated), + 'unconsolidated_count': len([v for v in validators if v.get('is_consolidated') is False]), + 'target': target, + 'target_balance_eth': target_balance, + 'is_target_0x02': is_target_0x02, + 'available_sources': available_sources, + 'max_withdrawal_eth': max_withdrawal, + } + + +def evaluate_pod_unrestake(wc_address: str, validators: List[Dict]) -> Dict: + """ + Evaluate an EigenPod for direct unrestake withdrawal (no consolidation). + + Returns the same dict shape as evaluate_pod so display_eigenpods_table works unchanged. + Uses effective_balance (not actual balance) to compute withdrawable ETH, since + effective_balance reflects what the beacon chain will actually sweep to the pod. + """ + total_eth = sum(get_effective_balance(v) for v in validators) + + consolidated = [v for v in validators if v.get('is_consolidated') is True] + unconsolidated = [v for v in validators if v.get('is_consolidated') is not True] + + # Pick highest-balance validator as representative (for node address resolution) + representative = max(validators, key=get_effective_balance) if validators else None + + # Max withdrawal retains 2048 ETH in the pod (same as submarine post-consolidation) + max_withdrawal = max(0, total_eth - MAX_EFFECTIVE_BALANCE) + + return { + 'wc_address': wc_address, + 'total_validators': len(validators), + 'total_eth': total_eth, + 'consolidated_count': len(consolidated), + 'unconsolidated_count': len([v for v in validators if v.get('is_consolidated') is False]), + 'target': representative, + 'target_balance_eth': get_effective_balance(representative) if representative else 0, + 'is_target_0x02': representative.get('is_consolidated', False) if representative else False, + 'available_sources': 0, + 'max_withdrawal_eth': max_withdrawal, + } + + +def display_eigenpods_table(evaluations: List[Dict]): + """Always print a table of all EigenPods for the operator.""" + # Sort by total ETH descending + sorted_evals = sorted(evaluations, key=lambda e: e['total_eth'], reverse=True) + + print(f"\n {'#':<4} {'EigenPod Address':<44} {'Vals':>6} {'0x02':>6} {'Total ETH':>12} {'Max Withdraw':>14}") + print(f" {'-' * 90}") + + total_vals = 0 + total_eth = 0 + total_max = 0 + for i, e in enumerate(sorted_evals, start=1): + wc_addr = f"0x{e['wc_address']}" + total_vals += e['total_validators'] + total_eth += e['total_eth'] + total_max += e['max_withdrawal_eth'] + print(f" {i:<4} {wc_addr:<44} {e['total_validators']:>6} {e['consolidated_count']:>6} {e['total_eth']:>10,.0f} ETH {e['max_withdrawal_eth']:>12,.0f} ETH") + + print(f" {'-' * 90}") + print(f" {'':4} {'TOTAL':<44} {total_vals:>6} {'':>6} {total_eth:>10,.0f} ETH {total_max:>12,.0f} ETH") + + +# ============================================================================= +# Multi-Pod Selection +# ============================================================================= + +def select_pods_for_withdrawal( + evaluations: List[Dict], + wc_groups: Dict[str, List[Dict]], + amount_eth: float, +) -> Tuple[List[Dict], float]: + """ + Select one or more EigenPods to cover the requested withdrawal amount. + + Greedy approach: pick pods with the most max_withdrawal_eth first, descending. + For the last pod, only use as many sources as needed. + + Returns: + Tuple of (list of pod_selections, total_withdrawal_eth) + Each pod_selection has: pod_eval, target, sources, withdrawal_eth, post_consolidation_eth + """ + # Sort by max_withdrawal_eth descending + candidates = [e for e in evaluations if e['max_withdrawal_eth'] > 0 and e['target'] is not None] + candidates.sort(key=lambda e: e['max_withdrawal_eth'], reverse=True) + + selections = [] + remaining = amount_eth + + for pod_eval in candidates: + if remaining <= 0: + break + + wc_address = pod_eval['wc_address'] + pod_validators = wc_groups[wc_address] + target = pod_eval['target'] + target_balance = pod_eval['target_balance_eth'] + + # How many sources needed to cover the remaining amount (or all available) + num_sources_needed = math.ceil((remaining + MAX_EFFECTIVE_BALANCE - target_balance) / DEFAULT_SOURCE_BALANCE) + num_sources = min(num_sources_needed, pod_eval['available_sources']) + + # Select sources + sources = select_sources(pod_validators, target, num_sources) + actual_num_sources = len(sources) + + post_consolidation = target_balance + actual_num_sources * DEFAULT_SOURCE_BALANCE + withdrawal = post_consolidation - MAX_EFFECTIVE_BALANCE + + selections.append({ + 'pod_eval': pod_eval, + 'target': target, + 'sources': sources, + 'num_sources': actual_num_sources, + 'post_consolidation_eth': post_consolidation, + 'withdrawal_eth': withdrawal, + }) + + remaining -= withdrawal + + total_withdrawal = sum(s['withdrawal_eth'] for s in selections) + return selections, total_withdrawal + + +def select_pods_for_unrestake( + evaluations: List[Dict], + amount_eth: float, +) -> Tuple[List[Dict], float]: + """ + Select EigenPods for direct unrestake withdrawal (no consolidation). + + Greedy: sort pods by max_withdrawal_eth descending, withdraw up to (total_eth - 2048) per pod. + + Returns: + Tuple of (list of pod_selections, total_withdrawal_eth) + Each pod_selection has same shape as select_pods_for_withdrawal output. + """ + candidates = [e for e in evaluations if e['max_withdrawal_eth'] > 0 and e['target'] is not None] + candidates.sort(key=lambda e: e['max_withdrawal_eth'], reverse=True) + + selections = [] + remaining = amount_eth + + for pod_eval in candidates: + if remaining <= 0: + break + + withdrawal = min(pod_eval['max_withdrawal_eth'], remaining) + + selections.append({ + 'pod_eval': pod_eval, + 'target': pod_eval['target'], + 'sources': [], + 'num_sources': 0, + 'post_consolidation_eth': pod_eval['total_eth'], + 'withdrawal_eth': withdrawal, + }) + + remaining -= withdrawal + + total_withdrawal = sum(s['withdrawal_eth'] for s in selections) + return selections, total_withdrawal + + +def select_sources( + pod_validators: List[Dict], + target: Dict, + num_sources: int, +) -> List[Dict]: + """Select source validators from the pod (excluding target), lowest balance first.""" + target_pubkey = target.get('pubkey', '').lower() + sources = [v for v in pod_validators if v.get('pubkey', '').lower() != target_pubkey] + sources.sort(key=get_balance) + return sources[:num_sources] + + +# ============================================================================= +# Transaction Generation +# ============================================================================= + +def generate_consolidation_batches( + target: Dict, + sources: List[Dict], + batch_size: int, + fee_per_request: int, + tx_start_index: int = 1, +) -> List[Dict]: + """ + Generate consolidation transaction batches for one pod. + + Each batch has target as vals[0] (self-consolidation) + source validators. + tx_start_index controls the numbering for multi-pod output. + + Returns: + List of transaction dicts. + """ + target_pubkey = target.get('pubkey', '') + actual_sources_per_batch = batch_size - 1 + + batches = [] + for i in range(0, len(sources), actual_sources_per_batch): + batch_sources = sources[i:i + actual_sources_per_batch] + batch_pubkeys = [target_pubkey] + [s.get('pubkey', '') for s in batch_sources] + calldata = generate_consolidation_calldata(batch_pubkeys, target_pubkey) + total_value = fee_per_request * len(batch_pubkeys) + + batches.append({ + 'to': ETHERFI_NODES_MANAGER, + 'value': str(total_value), + 'data': calldata, + 'num_validators': len(batch_pubkeys), + 'num_sources': len(batch_sources), + 'target_pubkey': target_pubkey, + 'tx_index': tx_start_index + len(batches), + }) + + return batches + + +def collect_src0_ids_and_pubkeys( + selections: List[Dict], +) -> Tuple[List[int], List[bytes]]: + """Collect src[0] (== target) validator IDs and pubkeys for linking. One per pod.""" + seen_ids = set() + seen_pubkeys = set() + ids = [] + pubkeys = [] + + for sel in selections: + t = sel['target'] + vid = t.get('id') + vpk = t.get('pubkey', '') + pk_lower = vpk.lower() + if vid is not None and vpk and vid not in seen_ids and pk_lower not in seen_pubkeys: + seen_ids.add(vid) + seen_pubkeys.add(pk_lower) + ids.append(vid) + pubkeys.append(normalize_pubkey(vpk)) + + return ids, pubkeys + + +def compute_pubkey_hash(pubkey_hex: str) -> str: + """Compute the SSZ validator pubkey hash: sha256(pubkey || 16_zero_bytes).""" + pk = pubkey_hex[2:] if pubkey_hex.startswith('0x') else pubkey_hex + pubkey_bytes = bytes.fromhex(pk) + h = hashlib.sha256(pubkey_bytes + b'\x00' * 16).digest() + return '0x' + h.hex() + + +def is_pubkey_linked(pubkey_hex: str, rpc_url: str) -> bool: + """Check if a validator pubkey is already linked on-chain via etherFiNodeFromPubkeyHash.""" + pubkey_hash = compute_pubkey_hash(pubkey_hex) + try: + result = subprocess.run( + ['cast', 'call', ETHERFI_NODES_MANAGER, + 'etherFiNodeFromPubkeyHash(bytes32)(address)', + pubkey_hash, + '--rpc-url', rpc_url], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + print(f" Warning: Could not check linking status for {pubkey_hex[:20]}...") + return False + address = result.stdout.strip() + return address != '0x0000000000000000000000000000000000000000' + except Exception as e: + print(f" Warning: Exception checking linking status for {pubkey_hex[:20]}...: {e}") + return False + + +def filter_unlinked_validators( + ids: List[int], + pubkeys: List[bytes], + rpc_url: str, +) -> Tuple[List[int], List[bytes]]: + """Filter out already-linked validators, returning only those that need linking.""" + if not ids: + return ids, pubkeys + + unlinked_ids = [] + unlinked_pubkeys = [] + + for vid, pk_bytes in zip(ids, pubkeys): + pk_hex = '0x' + pk_bytes.hex() + linked = is_pubkey_linked(pk_hex, rpc_url) + if linked: + print(f" Target {pk_hex[:20]}... (id={vid}) already linked, skipping") + else: + print(f" Target {pk_hex[:20]}... (id={vid}) not linked, will include") + unlinked_ids.append(vid) + unlinked_pubkeys.append(pk_bytes) + + return unlinked_ids, unlinked_pubkeys + + +# ============================================================================= +# Output Generation +# ============================================================================= + +def write_consolidation_data( + selections: List[Dict], + output_dir: str, +) -> str: + """Write consolidation-data.json with one entry per pod.""" + consolidations = [] + total_sources = 0 + total_eth = 0 + + for sel in selections: + target = sel['target'] + sources = sel['sources'] + wc_address = sel['pod_eval']['wc_address'] + full_wc = format_full_withdrawal_credentials(wc_address) + + target_output = { + 'pubkey': target.get('pubkey', ''), + 'validator_index': target.get('index') or target.get('validator_index'), + 'id': target.get('id'), + 'current_balance_eth': get_balance(target), + 'withdrawal_credentials': full_wc, + } + + # sources[0] = target for self-consolidation + sources_output = [{ + 'pubkey': target.get('pubkey', ''), + 'validator_index': target.get('index') or target.get('validator_index'), + 'id': target.get('id'), + 'balance_eth': get_balance(target), + 'withdrawal_credentials': full_wc, + }] + for source in sources: + sources_output.append({ + 'pubkey': source.get('pubkey', ''), + 'validator_index': source.get('index') or source.get('validator_index'), + 'id': source.get('id'), + 'balance_eth': get_balance(source), + 'withdrawal_credentials': full_wc, + }) + + consolidations.append({ + 'target': target_output, + 'sources': sources_output, + 'post_consolidation_balance_eth': sel['post_consolidation_eth'], + 'withdrawal_amount_gwei': int(round(sel['withdrawal_eth'] * 1e9)), + }) + + total_sources += len(sources_output) + total_eth += sel['post_consolidation_eth'] + + output = { + 'consolidations': consolidations, + 'summary': { + 'total_targets': len(selections), + 'total_sources': total_sources, + 'total_eth_consolidated': total_eth, + 'withdrawal_credential_groups': len(selections), + }, + 'generated_at': datetime.now().isoformat(), + } + + filepath = os.path.join(output_dir, 'consolidation-data.json') + with open(filepath, 'w') as f: + json.dump(output, f, indent=2, default=str) + return filepath + + +def write_linking_transaction( + validator_ids: List[int], + pubkeys: List[bytes], + chain_id: int, + from_address: str, + output_dir: str, +) -> Optional[str]: + """Generate link-validators.json with a single batched linkLegacyValidatorIds call.""" + if not validator_ids or not pubkeys: + return None + + print(f"\n Generating linking transaction for {len(validator_ids)} src[0] validator(s)...") + + # Batch all validators into a single linkLegacyValidatorIds(uint256[],bytes[]) call + link_calldata = encode_link_legacy_validators(validator_ids, pubkeys) + + tx_data = { + "chainId": str(chain_id), + "from": from_address, + "transactions": [{ + "to": ETHERFI_NODES_MANAGER, + "value": "0", + "data": "0x" + link_calldata.hex(), + "description": f"Link {len(validator_ids)} validator(s): ids={validator_ids}", + }], + "description": f"Link {len(validator_ids)} src[0] validator(s) via ADMIN_EOA", + } + + filepath = os.path.join(output_dir, "link-validators.json") + with open(filepath, 'w') as f: + json.dump(tx_data, f, indent=2) + print(f" Written: link-validators.json") + return filepath + + +def write_transaction_files( + all_batches: List[Dict], + output_dir: str, + chain_id: int = DEFAULT_CHAIN_ID, + from_address: str = ADMIN_EOA, +) -> List[str]: + """Write each consolidation batch as a raw transaction JSON file for direct EOA execution.""" + written = [] + for batch in all_batches: + idx = batch['tx_index'] + tx_data = { + "chainId": str(chain_id), + "from": from_address, + "transactions": [{ + "to": batch['to'], + "value": batch['value'], + "data": batch['data'], + }], + "metadata": { + "target_pubkey": batch['target_pubkey'], + "num_validators": batch['num_validators'], + }, + "description": f"Submarine Consolidation Batch {idx}: {batch['num_sources']} sources into target (vals[0])", + } + filename = f"consolidation-txns-{idx}.json" + filepath = os.path.join(output_dir, filename) + with open(filepath, 'w') as f: + json.dump(tx_data, f, indent=2) + written.append(filepath) + return written + + +def write_submarine_plan( + selections: List[Dict], + all_batches: List[Dict], + amount_eth: float, + total_withdrawal: float, + operator_name: str, + output_dir: str, + needs_linking: bool, +) -> str: + """Write submarine-plan.json with full plan metadata.""" + pods_info = [] + for sel in selections: + pe = sel['pod_eval'] + t = sel['target'] + pods_info.append({ + 'eigenpod': f"0x{pe['wc_address']}", + 'target_pubkey': t.get('pubkey', ''), + 'target_id': t.get('id'), + 'target_balance_eth': pe['target_balance_eth'], + 'is_target_0x02': pe['is_target_0x02'], + 'num_sources': sel['num_sources'], + 'post_consolidation_eth': sel['post_consolidation_eth'], + 'withdrawal_eth': sel['withdrawal_eth'], + }) + + num_batches = len(all_batches) + plan = { + 'type': 'submarine_withdrawal', + 'operator': operator_name, + 'requested_amount_eth': amount_eth, + 'total_withdrawal_eth': total_withdrawal, + 'num_pods_used': len(selections), + 'pods': pods_info, + 'transactions': { + 'linking': 1 if needs_linking else 0, + 'consolidation': num_batches, + 'queue_withdrawals': len(selections), + 'total': (1 if needs_linking else 0) + num_batches + len(selections), + }, + 'consolidation': { + 'total_sources': sum(s['num_sources'] for s in selections), + 'num_transactions': num_batches, + }, + 'files': { + 'link_validators': 'link-validators.json' if needs_linking else None, + 'consolidation_txns': [f'consolidation-txns-{b["tx_index"]}.json' for b in all_batches], + 'queue_withdrawals': 'post-sweep/queue-withdrawals.json', + }, + 'execution_order': [], + 'generated_at': datetime.now().isoformat(), + } + + step = 1 + if needs_linking: + plan['execution_order'].append(f"{step}. Execute link-validators.json from ADMIN_EOA") + step += 1 + for b in all_batches: + plan['execution_order'].append(f"{step}. Execute consolidation-txns-{b['tx_index']}.json from ADMIN_EOA") + step += 1 + plan['execution_order'].append(f"{step}. Wait for beacon chain consolidation + sweep (excess above 2048 ETH is auto-withdrawn)") + step += 1 + plan['execution_order'].append(f"{step}. Execute queue-withdrawals.json from ADMIN_EOA (queueETHWithdrawal for each pod)") + step += 1 + plan['execution_order'].append(f"{step}. Wait for EigenLayer withdrawal delay, then completeQueuedETHWithdrawals") + + filepath = os.path.join(output_dir, 'submarine-plan.json') + with open(filepath, 'w') as f: + json.dump(plan, f, indent=2, default=str) + return filepath + + +def write_unrestake_plan( + selections: List[Dict], + amount_eth: float, + total_withdrawal: float, + operator_name: str, + output_dir: str, +) -> str: + """Write submarine-plan.json for unrestake mode (no consolidation/linking).""" + pods_info = [] + for sel in selections: + pe = sel['pod_eval'] + t = sel['target'] + pods_info.append({ + 'eigenpod': f"0x{pe['wc_address']}", + 'target_pubkey': t.get('pubkey', ''), + 'target_id': t.get('id'), + 'total_eth': pe['total_eth'], + 'withdrawal_eth': sel['withdrawal_eth'], + }) + + plan = { + 'type': 'unrestake_withdrawal', + 'operator': operator_name, + 'requested_amount_eth': amount_eth, + 'total_withdrawal_eth': total_withdrawal, + 'num_pods_used': len(selections), + 'pods': pods_info, + 'transactions': { + 'linking': 0, + 'consolidation': 0, + 'queue_withdrawals': len(selections), + 'total': len(selections), + }, + 'files': { + 'queue_withdrawals': 'queue-withdrawals.json', + }, + 'execution_order': [ + "1. Execute queue-withdrawals.json from ADMIN_EOA (queueETHWithdrawal for each pod)", + "2. Wait for EigenLayer withdrawal delay, then completeQueuedETHWithdrawals", + ], + 'generated_at': datetime.now().isoformat(), + } + + filepath = os.path.join(output_dir, 'submarine-plan.json') + with open(filepath, 'w') as f: + json.dump(plan, f, indent=2, default=str) + return filepath + + +# ============================================================================= +# Queue ETH Withdrawal Transaction Generation +# ============================================================================= + +QUEUE_ETH_WITHDRAWAL_SELECTOR = "03f49be8" # queueETHWithdrawal(address,uint256) + + +def get_node_address(validator_id: int, rpc_url: str) -> Optional[str]: + """Resolve EtherFi node address from a legacy validator ID via on-chain query. + + Uses etherfiNodeAddress(uint256) which works for legacy IDs without linking. + """ + try: + result = subprocess.run( + ['cast', 'call', ETHERFI_NODES_MANAGER, + 'etherfiNodeAddress(uint256)(address)', + str(validator_id), + '--rpc-url', rpc_url], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + print(f" Warning: Could not resolve node for validator id={validator_id}") + return None + address = result.stdout.strip() + if address == '0x0000000000000000000000000000000000000000': + print(f" Warning: Node address is zero for validator id={validator_id}") + return None + return address + except Exception as e: + print(f" Warning: Exception resolving node address for validator id={validator_id}: {e}") + return None + + +def encode_queue_eth_withdrawal(node_address: str, amount_wei: int) -> str: + """Encode queueETHWithdrawal(address,uint256) calldata.""" + selector = bytes.fromhex(QUEUE_ETH_WITHDRAWAL_SELECTOR) + params = encode_address(node_address) + encode_uint256(amount_wei) + return "0x" + (selector + params).hex() + + +def write_queue_withdrawal_transactions( + selections: List[Dict], + output_dir: str, + chain_id: int, + from_address: str, + rpc_url: str, + subdirectory: Optional[str] = "post-sweep", +) -> Optional[str]: + """ + Generate queue-withdrawals.json with queueETHWithdrawal calls for each pod. + + Each pod selection has a target pubkey and withdrawal_eth amount. + Resolves node addresses on-chain and encodes calldata. + All calls are bundled into a single transaction file for gas efficiency. + + Args: + subdirectory: Subdirectory within output_dir. Default "post-sweep" for submarine mode. + Pass None to write directly to output_dir (unrestake mode). + """ + if not rpc_url: + print(" Warning: MAINNET_RPC_URL not set, writing queue-withdrawals metadata only") + + transactions = [] + for sel in selections: + target = sel['target'] + target_pubkey = target.get('pubkey', '') + target_id = target.get('id') + withdrawal_eth = sel['withdrawal_eth'] + withdrawal_gwei = int(round(withdrawal_eth * 1e9)) + withdrawal_wei = withdrawal_gwei * (10 ** 9) + + node_address = None + if rpc_url and target_id is not None: + node_address = get_node_address(target_id, rpc_url) + if node_address: + print(f" Target id={target_id} -> node {node_address}") + + tx_entry = { + "target_pubkey": target_pubkey, + "target_id": target_id, + "withdrawal_amount_gwei": withdrawal_gwei, + "withdrawal_amount_eth": withdrawal_eth, + "node_address": node_address, + "to": ETHERFI_NODES_MANAGER, + "value": "0", + } + + if node_address: + tx_entry["data"] = encode_queue_eth_withdrawal(node_address, withdrawal_wei) + else: + tx_entry["data"] = "0x" + tx_entry["requires_resolution"] = True + + transactions.append(tx_entry) + + tx_data = { + "chainId": str(chain_id), + "from": from_address, + "transactions": transactions, + "description": f"Queue ETH withdrawals for {len(transactions)} pod(s) after beacon chain consolidation + sweep", + } + + # Write to subdirectory (or output root if subdirectory is None) + if subdirectory: + target_dir = os.path.join(output_dir, subdirectory) + os.makedirs(target_dir, exist_ok=True) + filepath = os.path.join(target_dir, "queue-withdrawals.json") + display_path = f"{subdirectory}/queue-withdrawals.json" + else: + filepath = os.path.join(output_dir, "queue-withdrawals.json") + display_path = "queue-withdrawals.json" + with open(filepath, 'w') as f: + json.dump(tx_data, f, indent=2) + print(f" Written: {display_path} ({len(transactions)} withdrawal(s))") + return filepath + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description='Plan a large ETH withdrawal via submarine consolidation', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # List operators + python3 submarine_withdrawal.py --list-operators + + # Preview plan for 10k ETH withdrawal + python3 submarine_withdrawal.py --operator "Cosmostation" --amount 10000 --dry-run + + # Generate all transaction files + python3 submarine_withdrawal.py --operator "Cosmostation" --amount 10000 + + # Custom batch size and output directory + python3 submarine_withdrawal.py --operator "Cosmostation" --amount 10000 --batch-size 100 --output-dir ./my-txns + + # Unrestake: withdraw directly from pod balances (no consolidation) + python3 submarine_withdrawal.py --operator "Cosmostation" --amount 1000 --unrestake-only + """ + ) + parser.add_argument('--operator', help='Operator name (e.g., "Cosmostation")') + parser.add_argument('--amount', type=float, help='ETH amount to withdraw') + parser.add_argument('--output-dir', help='Output directory (auto-generated if omitted)') + parser.add_argument('--batch-size', type=int, default=DEFAULT_BATCH_SIZE, + help=f'Validators per tx including target at [0] (default: {DEFAULT_BATCH_SIZE})') + parser.add_argument('--fee', type=int, default=DEFAULT_FEE, + help=f'Fee per consolidation request in wei (default: {DEFAULT_FEE})') + parser.add_argument('--unrestake-only', action='store_true', + help='Skip consolidation; queue ETH withdrawals directly from existing pod balances') + parser.add_argument('--dry-run', action='store_true', help='Preview plan without writing files') + parser.add_argument('--list-operators', action='store_true', help='List available operators') + parser.add_argument('--beacon-api', default='https://beaconcha.in/api/v1', + help='Beacon chain API base URL') + + args = parser.parse_args() + + # Validate + if not args.list_operators and not args.operator: + print("Error: --operator is required (or use --list-operators)") + parser.print_help() + sys.exit(1) + + if not args.list_operators and not args.amount: + print("Error: --amount is required") + parser.print_help() + sys.exit(1) + + if args.amount and args.amount < MIN_WITHDRAWAL_AMOUNT: + print(f"Error: --amount must be at least {MIN_WITHDRAWAL_AMOUNT} ETH") + sys.exit(1) + + # Connect to DB + try: + conn = get_db_connection() + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + try: + # List operators + if args.list_operators: + operators = list_operators(conn) + print(f"\n{'Name':<30} {'Address':<44} {'Validators':>10}") + print("-" * 88) + for op in operators: + addr = op['address'] or 'N/A' + print(f"{op['name']:<30} {addr:<44} {op['total']:>10}") + return + + # Resolve operator + operator_address = get_operator_address(conn, args.operator) + if not operator_address: + print(f"Error: Operator '{args.operator}' not found") + print("Use --list-operators to see available operators") + sys.exit(1) + + mode = "UNRESTAKE" if args.unrestake_only else "SUBMARINE" + print(f"\n{'=' * 60}") + print(f"{mode} WITHDRAWAL PLANNER") + print(f"{'=' * 60}") + print(f"Operator: {args.operator} ({operator_address})") + print(f"Target amount: {args.amount:,.0f} ETH") + if not args.unrestake_only: + print(f"Batch size: {args.batch_size}") + print(f"Fee/request: {args.fee} wei") + print(f"Mode: {mode}") + print() + + # ================================================================ + # Step 1: Query validators + # ================================================================ + print("Step 1: Querying validators from database...") + validators = query_validators(conn, operator_address, MAX_VALIDATORS_QUERY) + if not validators: + print("Error: No validators found for this operator") + sys.exit(1) + print(f" Found {len(validators)} validators") + + # ================================================================ + # Step 2: Fetch beacon chain details (balance + consolidation status) + # ================================================================ + print("\nStep 2: Fetching beacon chain details (balance + status)...") + pubkeys = [v.get('pubkey', '') for v in validators if v.get('pubkey')] + details = fetch_validator_details_batch(pubkeys, beacon_api=args.beacon_api) + + for v in validators: + pk = v.get('pubkey', '') + if pk in details: + d = details[pk] + v['beacon_balance_eth'] = d['balance_eth'] + v['beacon_effective_balance_eth'] = d.get('effective_balance_eth', d['balance_eth']) + v['is_consolidated'] = d['is_consolidated'] + v['beacon_withdrawal_credentials'] = d['beacon_withdrawal_credentials'] + if d['validator_index'] is not None: + v['validator_index'] = d['validator_index'] + + consolidated_count = sum(1 for v in validators if v.get('is_consolidated') is True) + unconsolidated_count = sum(1 for v in validators if v.get('is_consolidated') is False) + unknown_count = len(validators) - consolidated_count - unconsolidated_count + print(f" 0x02 (consolidated): {consolidated_count}") + print(f" 0x01 (unconsolidated): {unconsolidated_count}") + if unknown_count > 0: + print(f" Unknown status: {unknown_count}") + + # ================================================================ + # Step 3: Group by EigenPod and display table + # ================================================================ + print("\nStep 3: Grouping by EigenPod (withdrawal credentials)...") + wc_groups = group_by_withdrawal_credentials(validators) + print(f" Found {len(wc_groups)} unique EigenPods") + + # Evaluate all pods + eval_fn = evaluate_pod_unrestake if args.unrestake_only else evaluate_pod + evaluations = [] + for wc_address, pod_validators in wc_groups.items(): + evaluations.append(eval_fn(wc_address, pod_validators)) + + # HOTFIX: cap max withdrawal for specific pod + HOTFIX_POD = "82a5b8abea11b1c969ccd7ea59f0f4c2fb392089" + HOTFIX_MAX_WITHDRAWAL = 3099 + for e in evaluations: + if e['wc_address'].lower() == HOTFIX_POD: + if e['max_withdrawal_eth'] > HOTFIX_MAX_WITHDRAWAL: + print(f" HOTFIX: Capping 0x{HOTFIX_POD} max withdrawal from {e['max_withdrawal_eth']:,.0f} to {HOTFIX_MAX_WITHDRAWAL} ETH") + e['max_withdrawal_eth'] = HOTFIX_MAX_WITHDRAWAL + + # Always print the full pod table + display_eigenpods_table(evaluations) + + # ================================================================ + # Step 4: Select pods for withdrawal + # ================================================================ + print(f"\nStep 4: Selecting EigenPods for {args.amount:,.0f} ETH withdrawal...") + + total_max = sum(e['max_withdrawal_eth'] for e in evaluations) + if total_max < args.amount: + print(f"\n Error: Total max withdrawal across ALL pods is {total_max:,.0f} ETH") + print(f" Requested: {args.amount:,.0f} ETH") + print(f" The operator does not have enough validators.") + sys.exit(1) + + if args.unrestake_only: + selections, total_withdrawal = select_pods_for_unrestake(evaluations, args.amount) + else: + selections, total_withdrawal = select_pods_for_withdrawal(evaluations, wc_groups, args.amount) + + if not selections: + print(" Error: Could not select any pods for withdrawal") + sys.exit(1) + + # ================================================================ + # Step 5: Print plan summary + # ================================================================ + if args.unrestake_only: + print(f"\n{'=' * 60}") + print(f"UNRESTAKE WITHDRAWAL PLAN") + print(f"{'=' * 60}") + print(f"Pods used: {len(selections)}") + print(f"Requested amount: {args.amount:,.0f} ETH") + print(f"Total withdrawal: {total_withdrawal:,.2f} ETH") + surplus = total_withdrawal - args.amount + if surplus > 0: + print(f"Surplus over requested: {surplus:,.2f} ETH") + + for i, sel in enumerate(selections, start=1): + pe = sel['pod_eval'] + t = sel['target'] + print(f"\n Pod {i}: 0x{pe['wc_address']}") + print(f" Representative pubkey: {t.get('pubkey', '')[:20]}...") + print(f" Representative ID: {t.get('id')}") + print(f" Pod total balance: {pe['total_eth']:,.2f} ETH") + print(f" Withdrawal amount: {sel['withdrawal_eth']:,.2f} ETH") + else: + actual_sources_per_batch = args.batch_size - 1 + total_sources = sum(s['num_sources'] for s in selections) + total_batches = sum(math.ceil(s['num_sources'] / actual_sources_per_batch) for s in selections) + + print(f"\n{'=' * 60}") + print(f"SUBMARINE WITHDRAWAL PLAN") + print(f"{'=' * 60}") + print(f"Pods used: {len(selections)}") + print(f"Total sources: {total_sources}") + print(f"Total transactions: {total_batches}") + print(f"Requested amount: {args.amount:,.0f} ETH") + print(f"Total auto-withdrawal: {total_withdrawal:,.2f} ETH") + surplus = total_withdrawal - args.amount + if surplus > 0: + print(f"Surplus over requested: {surplus:,.2f} ETH") + + for i, sel in enumerate(selections, start=1): + pe = sel['pod_eval'] + t = sel['target'] + pod_batches = math.ceil(sel['num_sources'] / actual_sources_per_batch) + print(f"\n Pod {i}: 0x{pe['wc_address']}") + print(f" Target pubkey: {t.get('pubkey', '')[:20]}...") + print(f" Target ID: {t.get('id')}") + print(f" Target balance: {pe['target_balance_eth']:.2f} ETH") + print(f" Target is 0x02: {'Yes' if pe['is_target_0x02'] else 'No (auto-compound via vals[0])'}") + print(f" Sources: {sel['num_sources']}") + print(f" Post-consolidation: {sel['post_consolidation_eth']:,.2f} ETH") + print(f" Auto-withdrawal: {sel['withdrawal_eth']:,.2f} ETH") + print(f" Transactions: {pod_batches}") + + if args.dry_run: + print(f"\n(Dry run - no files written)") + return + + # ================================================================ + # Step 6: Generate output files + # ================================================================ + print(f"\nStep 6: Generating output files...") + + if args.output_dir: + output_dir = args.output_dir + else: + script_dir = Path(__file__).resolve().parent + timestamp = datetime.now().strftime('%Y%m%d-%H%M%S') + operator_slug = args.operator.replace(' ', '_').lower() + mode_slug = "unrestake" if args.unrestake_only else "submarine" + output_dir = str(script_dir / 'txns' / f"{operator_slug}_{mode_slug}_{int(args.amount)}eth_{timestamp}") + + os.makedirs(output_dir, exist_ok=True) + + chain_id = int(os.environ.get('CHAIN_ID', DEFAULT_CHAIN_ID)) + admin_address = os.environ.get('ADMIN_ADDRESS', ADMIN_EOA) + rpc_url = os.environ.get('MAINNET_RPC_URL', '') + + if args.unrestake_only: + # Unrestake mode: only queue-withdrawals.json + submarine-plan.json + write_queue_withdrawal_transactions( + selections, output_dir, chain_id, admin_address, rpc_url, + subdirectory=None, + ) + + write_unrestake_plan( + selections, args.amount, total_withdrawal, + args.operator, output_dir, + ) + print(f" Written: submarine-plan.json") + + # Summary + print(f"\n{'=' * 60}") + print(f"OUTPUT COMPLETE") + print(f"{'=' * 60}") + print(f"Directory: {output_dir}") + print(f"\nExecution order:") + print(f" 1. Execute queue-withdrawals.json from ADMIN_EOA (queueETHWithdrawal)") + print(f" 2. Wait for EigenLayer withdrawal delay, then completeQueuedETHWithdrawals") + print() + total_withdrawal_eth = sum(s['withdrawal_eth'] for s in selections) + print(f"Total ETH to queue for withdrawal: {total_withdrawal_eth:,.2f} ETH across {len(selections)} pod(s)") + print() + + else: + # Submarine mode: full consolidation flow + # 6a: consolidation-data.json + write_consolidation_data(selections, output_dir) + print(f" Written: consolidation-data.json") + + # 6b: link-validators.json (only link src[0] per batch, i.e. the target pubkey per pod) + all_ids, all_pubkeys = collect_src0_ids_and_pubkeys(selections) + + needs_linking = False + if all_ids: + print(f"\n Checking on-chain linking status for {len(all_ids)} src[0] validator(s)...") + if rpc_url: + all_ids, all_pubkeys = filter_unlinked_validators(all_ids, all_pubkeys, rpc_url) + else: + print(" Warning: MAINNET_RPC_URL not set, skipping on-chain link check") + + if all_ids: + link_file = write_linking_transaction( + all_ids, all_pubkeys, chain_id, admin_address, output_dir, + ) + needs_linking = link_file is not None + else: + print(" All src[0] validators already linked, no linking transaction needed.") + + # 6c: consolidation-txns-N.json (sequentially numbered across all pods) + all_batches = [] + tx_index = 1 + for sel in selections: + batches = generate_consolidation_batches( + sel['target'], sel['sources'], args.batch_size, args.fee, tx_start_index=tx_index, + ) + all_batches.extend(batches) + tx_index += len(batches) + + tx_files = write_transaction_files(all_batches, output_dir, chain_id, admin_address) + for f in tx_files: + print(f" Written: {os.path.basename(f)}") + + # 6d: queue-withdrawals.json (queueETHWithdrawal per pod) + write_queue_withdrawal_transactions( + selections, output_dir, chain_id, admin_address, rpc_url, + ) + + # 6e: submarine-plan.json + write_submarine_plan( + selections, all_batches, args.amount, total_withdrawal, + args.operator, output_dir, needs_linking, + ) + print(f" Written: submarine-plan.json") + + # Summary + print(f"\n{'=' * 60}") + print(f"OUTPUT COMPLETE") + print(f"{'=' * 60}") + print(f"Directory: {output_dir}") + print(f"\nExecution order:") + step = 1 + if needs_linking: + print(f" {step}. Execute link-validators.json from ADMIN_EOA") + step += 1 + for b in all_batches: + print(f" {step}. Execute consolidation-txns-{b['tx_index']}.json from ADMIN_EOA") + step += 1 + print(f" {step}. Wait for beacon chain consolidation + sweep") + step += 1 + print(f" {step}. Execute queue-withdrawals.json from ADMIN_EOA (queueETHWithdrawal)") + step += 1 + print(f" {step}. Wait for EigenLayer withdrawal delay, then completeQueuedETHWithdrawals") + print() + total_requests = sum(b['num_validators'] for b in all_batches) + print(f"Each consolidation request costs {args.fee} wei.") + print(f"Total requests: {total_requests} ({total_requests * args.fee / 1e18:.18f} ETH in fees)") + total_withdrawal_eth = sum(s['withdrawal_eth'] for s in selections) + print(f"Total ETH to queue for withdrawal: {total_withdrawal_eth:,.2f} ETH across {len(selections)} pod(s)") + print() + + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/script/operations/eigenlayer/CompleteEigenLayerWithdrawals.s.sol b/script/operations/eigenlayer/CompleteEigenLayerWithdrawals.s.sol new file mode 100644 index 000000000..c21adcdd3 --- /dev/null +++ b/script/operations/eigenlayer/CompleteEigenLayerWithdrawals.s.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import "../../../src/interfaces/IEtherFiNodesManager.sol"; +import "../../../src/interfaces/IRoleRegistry.sol"; + +/** + * @title CompleteEigenLayerWithdrawals + * @notice Completes queued EigenLayer ETH withdrawals for EtherFi nodes + * + * @dev This script calls completeQueuedETHWithdrawals on EtherFiNodesManager + * to finalize ETH withdrawals that have passed the slashing period. + * + * The caller must have ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE. + * ETH is sent to the LiquidityPool when receiveAsTokens=true. + * + * Usage: + * # Dry run (simulation on fork) - requires PRIVATE_KEY env var + * PRIVATE_KEY=0x... forge script script/operations/eigenlayer/CompleteEigenLayerWithdrawals.s.sol:CompleteEigenLayerWithdrawals \ + * --fork-url $MAINNET_RPC_URL -vvvv + * + * # Actual execution (broadcast transactions) + * PRIVATE_KEY=0x... forge script script/operations/eigenlayer/CompleteEigenLayerWithdrawals.s.sol:CompleteEigenLayerWithdrawals \ + * --rpc-url $MAINNET_RPC_URL \ + * --broadcast -vvvv + * + * Nodes to process: + * - Node 1: 0x7779Ebb3CE29261FA60d738C3BAB35A05D8d6f65 (~43,102 ETH) + * - Node 2: 0x4Cb9384E3cc72f9302288f64edadE772d7F2DD06 (~35,919 ETH) + * - Total: ~79,021 ETH + */ +contract CompleteEigenLayerWithdrawals is Script { + // Mainnet addresses + address constant ETHERFI_NODES_MANAGER = 0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F; + + // Nodes with queued withdrawals ready to complete + address constant NODE_1 = 0x7779Ebb3CE29261FA60d738C3BAB35A05D8d6f65; + address constant NODE_2 = 0x4Cb9384E3cc72f9302288f64edadE772d7F2DD06; + + // Role constant (must match EtherFiNodesManager) + bytes32 constant ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE = keccak256("ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE"); + + function run() external { + IEtherFiNodesManager nodesManager = IEtherFiNodesManager(ETHERFI_NODES_MANAGER); + + // Derive caller address from private key + uint256 privateKey = vm.envUint("PRIVATE_KEY"); + address caller = vm.addr(privateKey); + + console2.log("============================================================"); + console2.log("COMPLETE EIGENLAYER ETH WITHDRAWALS"); + console2.log("============================================================"); + console2.log(""); + console2.log("EtherFiNodesManager:", ETHERFI_NODES_MANAGER); + console2.log("Node 1:", NODE_1); + console2.log("Node 2:", NODE_2); + console2.log("Caller:", caller); + console2.log(""); + + // Verify role (informational - will revert on actual call if missing) + _checkRole(caller); + + // Log initial balances + console2.log("------------------------------------------------------------"); + console2.log("PRE-EXECUTION STATE"); + console2.log("------------------------------------------------------------"); + uint256 node1Balance = NODE_1.balance; + uint256 node2Balance = NODE_2.balance; + console2.log("Node 1 ETH balance:", node1Balance / 1 ether, "ETH"); + console2.log("Node 2 ETH balance:", node2Balance / 1 ether, "ETH"); + console2.log(""); + + // Start broadcast for actual execution + vm.startBroadcast(privateKey); + + // Complete withdrawal for Node 1 + console2.log("------------------------------------------------------------"); + console2.log("COMPLETING WITHDRAWAL - NODE 1"); + console2.log("------------------------------------------------------------"); + console2.log("Node:", NODE_1); + console2.log("receiveAsTokens: true (ETH -> LiquidityPool)"); + + nodesManager.completeQueuedETHWithdrawals(NODE_1, false); + console2.log("SUCCESS: Node 1 withdrawal completed"); + console2.log(""); + + // Complete withdrawal for Node 2 + console2.log("------------------------------------------------------------"); + console2.log("COMPLETING WITHDRAWAL - NODE 2"); + console2.log("------------------------------------------------------------"); + console2.log("Node:", NODE_2); + console2.log("receiveAsTokens: true (ETH -> LiquidityPool)"); + + nodesManager.completeQueuedETHWithdrawals(NODE_2, false); + console2.log("SUCCESS: Node 2 withdrawal completed"); + console2.log(""); + + vm.stopBroadcast(); + + // Log final state + console2.log("------------------------------------------------------------"); + console2.log("POST-EXECUTION STATE"); + console2.log("------------------------------------------------------------"); + uint256 node1BalanceAfter = NODE_1.balance; + uint256 node2BalanceAfter = NODE_2.balance; + console2.log("Node 1 ETH balance:", node1BalanceAfter / 1 ether, "ETH"); + console2.log("Node 2 ETH balance:", node2BalanceAfter / 1 ether, "ETH"); + console2.log(""); + + console2.log("============================================================"); + console2.log("EXECUTION COMPLETE"); + console2.log("============================================================"); + console2.log("Both withdrawals have been completed."); + console2.log("ETH has been transferred to the LiquidityPool."); + console2.log(""); + console2.log("Next steps:"); + console2.log(" 1. Run checkpoint proofs to re-verify beacon balance, OR"); + console2.log(" 2. Accept validators as 'staked but not restaked'"); + } + + function _checkRole(address account) internal view { + // Get roleRegistry from nodesManager + // Note: roleRegistry is immutable in EtherFiNodesManager + (bool success, bytes memory data) = ETHERFI_NODES_MANAGER.staticcall( + abi.encodeWithSignature("roleRegistry()") + ); + + if (success && data.length >= 32) { + address roleRegistryAddr = abi.decode(data, (address)); + IRoleRegistry roleRegistry = IRoleRegistry(roleRegistryAddr); + + bool hasRole = roleRegistry.hasRole(ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE, account); + + if (hasRole) { + console2.log("Role check: PASSED"); + console2.log(" Account has ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE"); + } else { + console2.log("Role check: WARNING"); + console2.log(" Account does NOT have ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE"); + console2.log(" Transaction will revert with IncorrectRole()"); + } + } else { + console2.log("Role check: SKIPPED (could not query roleRegistry)"); + } + console2.log(""); + } +} + diff --git a/script/operations/exits/ValidatorExit.s.sol b/script/operations/exits/ValidatorExit.s.sol new file mode 100644 index 000000000..ccbe21a18 --- /dev/null +++ b/script/operations/exits/ValidatorExit.s.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "../../../src/interfaces/IEtherFiNode.sol"; +import "../../../src/interfaces/IEtherFiNodesManager.sol"; +import {IEigenPod, IEigenPodTypes} from "../../../src/eigenlayer-interfaces/IEigenPod.sol"; +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; + +/** + * @title ValidatorExit + * @notice Generates EL-triggered exit transactions for validators + * @dev Supports both single validator and batch exits using requestExecutionLayerTriggeredWithdrawal + * + * Usage for single validator: + * PUBKEY=0x... forge script \ + * script/operations/exits/ValidatorExit.s.sol:ValidatorExit \ + * --fork-url $MAINNET_RPC_URL -vvvv + * + * Environment Variables: + * - PUBKEY: Validator pubkey to exit (hex string) + * - NODE_ID: Alternative to PUBKEY - use node ID + */ +contract ValidatorExit is Script { + // === MAINNET CONTRACT ADDRESSES === + address constant ETHERFI_NODES_MANAGER_ADDR = 0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F; + + IEtherFiNodesManager constant nodesManager = IEtherFiNodesManager(ETHERFI_NODES_MANAGER_ADDR); + + function run() public { + console2.log("================================================"); + console2.log("====== Validator Exit Transactions ======"); + console2.log("================================================"); + + // Try to get pubkey from environment + bytes memory pubkey; + try vm.envBytes("PUBKEY") returns (bytes memory pk) { + pubkey = pk; + } catch { + // Try node ID instead + uint256 nodeId = vm.envUint("NODE_ID"); + pubkey = _getPubkeyFromNodeId(nodeId); + } + + require(pubkey.length == 48, "Invalid pubkey length - must be 48 bytes"); + + console2.log("Validator pubkey:"); + console2.logBytes(pubkey); + console2.log(""); + + // Resolve the EtherFi node and EigenPod + bytes32 pkHash = nodesManager.calculateValidatorPubkeyHash(pubkey); + IEtherFiNode etherFiNode = nodesManager.etherFiNodeFromPubkeyHash(pkHash); + require(address(etherFiNode) != address(0), "Validator not found in EtherFiNodesManager"); + + IEigenPod pod = etherFiNode.getEigenPod(); + require(address(pod) != address(0), "Node has no EigenPod"); + + console2.log("EtherFi Node:", address(etherFiNode)); + console2.log("EigenPod:", address(pod)); + console2.log(""); + + // Get exit request fee (full exit uses amountGwei = 0) + uint256 exitFee = pod.getWithdrawalRequestFee(); + console2.log("Withdrawal request fee:", exitFee); + + // Generate exit request (amountGwei = 0 means full exit) + IEigenPodTypes.WithdrawalRequest[] memory exitRequests = new IEigenPodTypes.WithdrawalRequest[](1); + exitRequests[0] = IEigenPodTypes.WithdrawalRequest({ + pubkey: pubkey, + amountGwei: 0 // 0 = full exit + }); + + // Encode the calldata + bytes memory callData = abi.encodeWithSelector( + IEtherFiNodesManager.requestExecutionLayerTriggeredWithdrawal.selector, + exitRequests + ); + + console2.log(""); + console2.log("=== Transaction Data ==="); + console2.log("To:", ETHERFI_NODES_MANAGER_ADDR); + console2.log("Value:", exitFee); + console2.log("Calldata:"); + console2.logBytes(callData); + + // Output Gnosis Safe format + console2.log(""); + console2.log("=== Gnosis Safe Transaction JSON ==="); + string memory json = _generateGnosisJson(callData, exitFee); + console2.log(json); + } + + function _getPubkeyFromNodeId(uint256 nodeId) internal view returns (bytes memory) { + // This would need to be implemented based on how node IDs map to pubkeys + // For now, revert with a helpful message + revert("NODE_ID lookup not implemented - please provide PUBKEY directly"); + } + + function _generateGnosisJson(bytes memory callData, uint256 value) internal pure returns (string memory) { + return string.concat( + '{\n', + ' "chainId": "1",\n', + ' "safeAddress": "0x2aCA71020De61bb532008049e1Bd41E451aE8AdC",\n', + ' "meta": {\n', + ' "txBuilderVersion": "1.16.5",\n', + ' "name": "Validator Exit Request"\n', + ' },\n', + ' "transactions": [\n', + ' {\n', + ' "to": "', _addressToHex(ETHERFI_NODES_MANAGER_ADDR), '",\n', + ' "value": "', _uint256ToString(value), '",\n', + ' "data": "', _bytesToHex(callData), '"\n', + ' }\n', + ' ]\n', + '}' + ); + } + + function _uint256ToString(uint256 value) internal pure returns (string memory) { + if (value == 0) return "0"; + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } + + function _addressToHex(address addr) internal pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory data = abi.encodePacked(addr); + bytes memory str = new bytes(42); + str[0] = '0'; + str[1] = 'x'; + for (uint256 i = 0; i < 20; i++) { + str[2 + i * 2] = alphabet[uint8(data[i] >> 4)]; + str[3 + i * 2] = alphabet[uint8(data[i] & 0x0f)]; + } + return string(str); + } + + function _bytesToHex(bytes memory data) internal pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory str = new bytes(2 + data.length * 2); + str[0] = '0'; + str[1] = 'x'; + for (uint256 i = 0; i < data.length; i++) { + str[2 + i * 2] = alphabet[uint8(data[i] >> 4)]; + str[3 + i * 2] = alphabet[uint8(data[i] & 0x0f)]; + } + return string(str); + } +} + diff --git a/script/operations/restaking-router/ConfigureRestakingRewardsRouter.s.sol b/script/operations/restaking-router/ConfigureRestakingRewardsRouter.s.sol new file mode 100644 index 000000000..59780e5a3 --- /dev/null +++ b/script/operations/restaking-router/ConfigureRestakingRewardsRouter.s.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "../../../src/RestakingRewardsRouter.sol"; +import "../../../src/interfaces/IRoleRegistry.sol"; +import "../../utils/utils.sol"; + +// forge script script/operations/restaking-router/ConfigureRestakingRewardsRouter.s.sol --fork-url $MAINNET_RPC_URL -vvvv +contract ConfigureRestakingRewardsRouter is Script, Utils { + bytes32 constant ETHERFI_REWARDS_ROUTER_ADMIN_ROLE = + keccak256("ETHERFI_REWARDS_ROUTER_ADMIN_ROLE"); + bytes32 constant ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE = + keccak256("ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE"); + + address constant SELINI_MARKET_MAKER = 0x0B7178f2f1f44Cae3aed801c21D589CbAb458118; + + EtherFiTimelock etherFiTimelock = EtherFiTimelock(payable(UPGRADE_TIMELOCK)); + + function run() public { + console2.log("================================================"); + console2.log("== Configure RestakingRewardsRouter Roles =="); + console2.log("================================================"); + console2.log(""); + + address[] memory targets = new address[](2); + bytes[] memory data = new bytes[](2); + uint256[] memory values = new uint256[](2); + + // Grant ETHERFI_REWARDS_ROUTER_ADMIN_ROLE to Operating Admin (multisig) + targets[0] = ROLE_REGISTRY; + data[0] = _encodeRoleGrant(ETHERFI_REWARDS_ROUTER_ADMIN_ROLE, ETHERFI_OPERATING_ADMIN); + + // Grant ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE to Admin EOA + targets[1] = ROLE_REGISTRY; + data[1] = _encodeRoleGrant(ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE, ADMIN_EOA); + + bytes32 timelockSalt = keccak256(abi.encode(targets, data, block.number)); + + // schedule + bytes memory scheduleCalldata = abi.encodeWithSelector( + etherFiTimelock.scheduleBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt, + MIN_DELAY_TIMELOCK + ); + + console2.log("====== Schedule Role Grants Tx:"); + console2.logBytes(scheduleCalldata); + console2.log("================================================"); + console2.log(""); + + // execute + bytes memory executeCalldata = abi.encodeWithSelector( + etherFiTimelock.executeBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt + ); + + console2.log("====== Execute Role Grants Tx:"); + console2.logBytes(executeCalldata); + console2.log("================================================"); + console2.log(""); + + // uncomment to run against fork + // vm.startBroadcast(ETHERFI_UPGRADE_ADMIN); + vm.startPrank(ETHERFI_UPGRADE_ADMIN); + etherFiTimelock.scheduleBatch(targets, values, data, bytes32(0), timelockSalt, MIN_DELAY_TIMELOCK); + console2.log("====== Role Grants Scheduled Successfully"); + console2.log("================================================"); + console2.log(""); + vm.warp(block.timestamp + MIN_DELAY_TIMELOCK + 1); + etherFiTimelock.executeBatch(targets, values, data, bytes32(0), timelockSalt); + vm.stopPrank(); + + console2.log("====== Role Grants Executed Successfully"); + console2.log("================================================"); + console2.log(""); + + vm.prank(ETHERFI_OPERATING_ADMIN); + RestakingRewardsRouter(payable(RESTAKING_REWARDS_ROUTER)).setRecipientAddress(SELINI_MARKET_MAKER); + + //-------------------------------------------------------------------------------------- + //-------------- Set Recipient Address (via Operating Admin Multisig) -------------- + //-------------------------------------------------------------------------------------- + + // Calldata for Operating Admin multisig to call setRecipientAddress + bytes memory setRecipientCalldata = abi.encodeWithSelector( + RestakingRewardsRouter.setRecipientAddress.selector, + SELINI_MARKET_MAKER + ); + + console2.log("====== setRecipientAddress Calldata (Operating Admin Multisig):"); + console2.log("Target: %s", address(RESTAKING_REWARDS_ROUTER)); + console2.logBytes(setRecipientCalldata); + console2.log("================================================"); + console2.log(""); + } + + function _encodeRoleGrant(bytes32 role, address account) internal pure returns (bytes memory) { + return abi.encodeWithSelector(IRoleRegistry.grantRole.selector, role, account); + } +} diff --git a/script/operations/restaking-router/DeployRestakingRewardsRouter.s.sol b/script/operations/restaking-router/DeployRestakingRewardsRouter.s.sol new file mode 100644 index 000000000..115c6a501 --- /dev/null +++ b/script/operations/restaking-router/DeployRestakingRewardsRouter.s.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "../../../src/RestakingRewardsRouter.sol"; +import "../../../src/UUPSProxy.sol"; +import "../../utils/utils.sol"; + +// forge script script/operations/restaking-router/DeployRestakingRewardsRouter.s.sol --fork-url $MAINNET_RPC_URL -vvvv +contract DeployRestakingRewardsRouter is Script, Utils { + address routerImpl; + address routerProxy; + bytes32 commitHashSalt = + bytes32(bytes20(hex"1a10a60fc25f1c7f7052123edbe683ed2524943d")); + + ICreate2Factory constant factory = ICreate2Factory(0x356d1B83970CeF2018F2c9337cDdb67dff5AEF99); + address constant REWARD_TOKEN_ADDRESS = 0xec53bF9167f50cDEB3Ae105f56099aaaB9061F83; + + function run() public { + console2.log("================================================"); + console2.log( + "======== Running Deploy Restaking Rewards Router ========" + ); + console2.log("================================================"); + console2.log(""); + + vm.startBroadcast(); + + // Deploy RestakingRewardsRouter implementation + { + string memory contractName = "RestakingRewardsRouter"; + bytes memory constructorArgs = abi.encode( + ROLE_REGISTRY, + REWARD_TOKEN_ADDRESS, + LIQUIDITY_POOL + ); + bytes memory bytecode = abi.encodePacked( + type(RestakingRewardsRouter).creationCode, + constructorArgs + ); + routerImpl = deploy( + contractName, + constructorArgs, + bytecode, + commitHashSalt, + true, + factory + ); + } + console2.log("====== Restaking Rewards Router Implementation Deployed Successfully"); + console2.log("================================================"); + console2.log(""); + console2.log("====== Restaking Rewards Router Implementation Address:"); + console2.log(routerImpl); + console2.log("================================================"); + console2.log(""); + + // Deploy UUPSProxy + { + string memory contractName = "UUPSProxy"; + + // Prepare initialization data (initialize takes no parameters) + bytes memory initializerData = abi.encodeWithSelector( + RestakingRewardsRouter.initialize.selector + ); + + bytes memory constructorArgs = abi.encode( + routerImpl, + initializerData + ); + bytes memory bytecode = abi.encodePacked( + type(UUPSProxy).creationCode, + constructorArgs + ); + routerProxy = deploy( + contractName, + constructorArgs, + bytecode, + commitHashSalt, + true, + factory + ); + } + + console2.log("====== Restaking Rewards Router Deployed Successfully"); + console2.log("================================================"); + console2.log(""); + + console2.log("====== Restaking Rewards Router Address:"); + console2.log(routerProxy); + console2.log("================================================"); + console2.log(""); + + vm.stopBroadcast(); + } +} diff --git a/script/operations/restaking-router/VerifyRestakingRewardsRouterConfig.s.sol b/script/operations/restaking-router/VerifyRestakingRewardsRouterConfig.s.sol new file mode 100644 index 000000000..36e7d3f08 --- /dev/null +++ b/script/operations/restaking-router/VerifyRestakingRewardsRouterConfig.s.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "forge-std/Test.sol"; +import {ContractCodeChecker} from "../../ContractCodeChecker.sol"; +import {RestakingRewardsRouter} from "../../../src/RestakingRewardsRouter.sol"; +import {UUPSProxy} from "../../../src/UUPSProxy.sol"; +import {RoleRegistry} from "../../../src/RoleRegistry.sol"; +import "../../utils/utils.sol"; + +// forge script script/operations/restaking-router/VerifyRestakingRewardsRouterConfig.s.sol --fork-url $MAINNET_RPC_URL -vvvv +contract VerifyRestakingRewardsRouterConfig is Script, Utils { + bytes32 commitHashSalt = bytes32(bytes20(hex"1a10a60fc25f1c7f7052123edbe683ed2524943d")); + ICreate2Factory constant factory = ICreate2Factory(0x356d1B83970CeF2018F2c9337cDdb67dff5AEF99); + + ContractCodeChecker public contractCodeChecker; + + // === DEPLOYED ADDRESSES === + address constant RESTAKING_REWARDS_ROUTER_PROXY = 0x89E45081437c959A827d2027135bC201Ab33a2C8; + address constant RESTAKING_REWARDS_ROUTER_IMPL = 0xcB6e9a5943946307815eaDF3BEDC49fE30290CA8; + + // === CONSTRUCTOR ARGS === + address constant REWARD_TOKEN_ADDRESS = 0xec53bF9167f50cDEB3Ae105f56099aaaB9061F83; + + // === EXPECTED CONFIGURATION === + address constant SELINI_MARKET_MAKER = 0x0B7178f2f1f44Cae3aed801c21D589CbAb458118; + + // === ROLES === + bytes32 constant ETHERFI_REWARDS_ROUTER_ADMIN_ROLE = keccak256("ETHERFI_REWARDS_ROUTER_ADMIN_ROLE"); + bytes32 constant ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE = keccak256("ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE"); + + RestakingRewardsRouter router = RestakingRewardsRouter(payable(RESTAKING_REWARDS_ROUTER_PROXY)); + RestakingRewardsRouter routerImpl = RestakingRewardsRouter(payable(RESTAKING_REWARDS_ROUTER_IMPL)); + RoleRegistry roleRegistry = RoleRegistry(ROLE_REGISTRY); + + function run() public { + console2.log("================================================"); + console2.log("Running Verify RestakingRewardsRouter Config"); + console2.log("================================================"); + console2.log(""); + + contractCodeChecker = new ContractCodeChecker(); + + verifyAddress(); + verifyBytecode(); + verifyImmutables(); + verifyRoles(); + verifyConfiguration(); + } + + function verifyAddress() public { + console2.log("Verifying Create2 addresses..."); + + // Implementation + { + bytes memory constructorArgs = abi.encode( + ROLE_REGISTRY, + REWARD_TOKEN_ADDRESS, + LIQUIDITY_POOL + ); + bytes memory bytecode = abi.encodePacked( + type(RestakingRewardsRouter).creationCode, + constructorArgs + ); + address predictedAddress = factory.computeAddress(commitHashSalt, bytecode); + require(RESTAKING_REWARDS_ROUTER_IMPL == predictedAddress, "RestakingRewardsRouter implementation address mismatch"); + } + + // Proxy + { + bytes memory initializerData = abi.encodeWithSelector( + RestakingRewardsRouter.initialize.selector + ); + bytes memory constructorArgs = abi.encode( + RESTAKING_REWARDS_ROUTER_IMPL, + initializerData + ); + bytes memory bytecode = abi.encodePacked( + type(UUPSProxy).creationCode, + constructorArgs + ); + address predictedAddress = factory.computeAddress(commitHashSalt, bytecode); + require(RESTAKING_REWARDS_ROUTER_PROXY == predictedAddress, "RestakingRewardsRouter proxy address mismatch"); + } + + console2.log(unicode"✓ Create2 addresses verified successfully"); + console2.log(""); + } + + function verifyBytecode() public { + console2.log("Verifying bytecode..."); + + RestakingRewardsRouter newRouterImpl = new RestakingRewardsRouter( + ROLE_REGISTRY, + REWARD_TOKEN_ADDRESS, + LIQUIDITY_POOL + ); + + contractCodeChecker.verifyContractByteCodeMatch(RESTAKING_REWARDS_ROUTER_IMPL, address(newRouterImpl)); + + console2.log(unicode"✓ Bytecode verified successfully"); + console2.log(""); + } + + function verifyImmutables() public view { + console2.log("Verifying immutables..."); + + require(router.liquidityPool() == LIQUIDITY_POOL, "liquidityPool mismatch"); + require(router.rewardTokenAddress() == REWARD_TOKEN_ADDRESS, "rewardTokenAddress mismatch"); + require(address(router.roleRegistry()) == ROLE_REGISTRY, "roleRegistry mismatch"); + + console2.log(" liquidityPool: %s", router.liquidityPool()); + console2.log(" rewardTokenAddress: %s", router.rewardTokenAddress()); + console2.log(" roleRegistry: %s", address(router.roleRegistry())); + + console2.log(unicode"✓ Immutables verified successfully"); + console2.log(""); + } + + function verifyRoles() public view { + console2.log("Verifying roles..."); + + // ETHERFI_REWARDS_ROUTER_ADMIN_ROLE -> ETHERFI_OPERATING_ADMIN + require( + roleRegistry.hasRole(ETHERFI_REWARDS_ROUTER_ADMIN_ROLE, ETHERFI_OPERATING_ADMIN), + "ETHERFI_OPERATING_ADMIN does not have ETHERFI_REWARDS_ROUTER_ADMIN_ROLE" + ); + console2.log(" ETHERFI_REWARDS_ROUTER_ADMIN_ROLE granted to ETHERFI_OPERATING_ADMIN: %s", ETHERFI_OPERATING_ADMIN); + + // ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE -> ADMIN_EOA + require( + roleRegistry.hasRole(ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE, ADMIN_EOA), + "ADMIN_EOA does not have ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE" + ); + console2.log(" ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE granted to ADMIN_EOA: %s", ADMIN_EOA); + + console2.log(unicode"✓ Roles verified successfully"); + console2.log(""); + } + + function verifyConfiguration() public view { + console2.log("Verifying configuration..."); + + // Verify recipientAddress is set to SELINI_MARKET_MAKER + require( + router.recipientAddress() == SELINI_MARKET_MAKER, + "recipientAddress mismatch" + ); + console2.log(" recipientAddress: %s", router.recipientAddress()); + + // Verify implementation address + require( + router.getImplementation() == RESTAKING_REWARDS_ROUTER_IMPL, + "Implementation address mismatch" + ); + console2.log(" implementation: %s", router.getImplementation()); + + console2.log(unicode"✓ Configuration verified successfully"); + console2.log(""); + } +} diff --git a/script/operations/setValidatorSpawner.s.sol b/script/operations/setValidatorSpawner.s.sol new file mode 100644 index 000000000..671ef8749 --- /dev/null +++ b/script/operations/setValidatorSpawner.s.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import {EtherFiTimelock} from "../../src/EtherFiTimelock.sol"; +import {LiquidityPool} from "../../src/LiquidityPool.sol"; +import {Deployed} from "../deploys/Deployed.s.sol"; +import {Utils} from "../utils/utils.sol"; + +/// @title SetValidatorSpawner +/// @notice Script to register a validator spawner via Operating Timelock +/// @dev Run: forge script script/operations/setValidatorSpawner.s.sol:SetValidatorSpawnerScript --fork-url $MAINNET_RPC_URL -vvvv +contract SetValidatorSpawnerScript is Script, Deployed, Utils { + EtherFiTimelock constant etherFiOperatingTimelock = EtherFiTimelock(payable(OPERATING_TIMELOCK)); + LiquidityPool constant liquidityPool = LiquidityPool(payable(LIQUIDITY_POOL)); + + address constant VALIDATOR_SPAWNER = 0xA8304775e435146650A7Ae65aa39B2a38F0152AE; + + function run() public { + address[] memory targets = new address[](1); + bytes[] memory data = new bytes[](1); + uint256[] memory values = new uint256[](1); + + // Register validator spawner + targets[0] = LIQUIDITY_POOL; + data[0] = abi.encodeWithSelector( + LiquidityPool.registerValidatorSpawner.selector, + VALIDATOR_SPAWNER + ); + values[0] = 0; + + bytes32 timelockSalt = keccak256(abi.encode(targets, data, block.number)); + + console2.log("=== SET VALIDATOR SPAWNER ==="); + console2.log("Target:", targets[0]); + console2.log("Validator Spawner:", VALIDATOR_SPAWNER); + console2.log("Operating Timelock:", address(etherFiOperatingTimelock)); + console2.log("Min Delay:", MIN_DELAY_OPERATING_TIMELOCK, "seconds (8 hours)"); + console2.log(""); + + // Generate schedule calldata + bytes memory scheduleCalldata = abi.encodeWithSelector( + etherFiOperatingTimelock.scheduleBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt, + MIN_DELAY_OPERATING_TIMELOCK + ); + + console2.log("=== SCHEDULE CALLDATA ==="); + console2.log("Call to:", address(etherFiOperatingTimelock)); + console2.logBytes(scheduleCalldata); + console2.log(""); + + // Generate execute calldata + bytes memory executeCalldata = abi.encodeWithSelector( + etherFiOperatingTimelock.executeBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt + ); + + console2.log("=== EXECUTE CALLDATA ==="); + console2.log("Call to:", address(etherFiOperatingTimelock)); + console2.logBytes(executeCalldata); + console2.log(""); + + console2.log("=== INNER TX DATA ==="); + console2.log("Target:", targets[0]); + console2.logBytes(data[0]); + console2.log(""); + + console2.log("=== TIMELOCK SALT ==="); + console2.logBytes32(timelockSalt); + console2.log(""); + + runFork(); + } + + /// @notice Simulate the full flow on a fork + function runFork() public { + string memory forkUrl = vm.envString("MAINNET_RPC_URL"); + vm.selectFork(vm.createFork(forkUrl)); + + address[] memory targets = new address[](1); + bytes[] memory data = new bytes[](1); + uint256[] memory values = new uint256[](1); + + targets[0] = LIQUIDITY_POOL; + data[0] = abi.encodeWithSelector( + LiquidityPool.registerValidatorSpawner.selector, + VALIDATOR_SPAWNER + ); + values[0] = 0; + + bytes32 timelockSalt = keccak256(abi.encode(targets, data, block.number)); + + console2.log("=== SIMULATING ON FORK ==="); + console2.log("Validator Spawner before:", liquidityPool.validatorSpawner(VALIDATOR_SPAWNER) ? "registered" : "not registered"); + + // Schedule + vm.startPrank(ETHERFI_OPERATING_ADMIN); + etherFiOperatingTimelock.scheduleBatch( + targets, + values, + data, + bytes32(0), + timelockSalt, + MIN_DELAY_OPERATING_TIMELOCK + ); + console2.log("Scheduled successfully"); + + // Fast forward time + vm.warp(block.timestamp + MIN_DELAY_OPERATING_TIMELOCK + 1); + console2.log("Time warped past delay"); + + // Execute + etherFiOperatingTimelock.executeBatch( + targets, + values, + data, + bytes32(0), + timelockSalt + ); + vm.stopPrank(); + + console2.log("Executed successfully"); + console2.log("Validator Spawner after:", liquidityPool.validatorSpawner(VALIDATOR_SPAWNER) ? "registered" : "not registered"); + console2.log(""); + console2.log("[OK] Validator spawner registered successfully"); + } +} diff --git a/script/operations/tvl/compare_tvl.py b/script/operations/tvl/compare_tvl.py new file mode 100644 index 000000000..6c3def16b --- /dev/null +++ b/script/operations/tvl/compare_tvl.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +compare_tvl.py - Compare Chainlink-reported TVL vs LiquidityPool.getTotalPooledEther() + +Strategy: +1. Use getRoundData() on the Chainlink proxy to iterate through the last 30 days of rounds +2. Use the updatedAt timestamp from each round to find the exact block via binary search +3. Call LiquidityPool.getTotalPooledEther() at each of those blocks +4. Compare the two values +""" + +import os +import sys +import json +from datetime import datetime, timezone, timedelta +from pathlib import Path + +try: + from dotenv import load_dotenv + env_path = Path('.env') + if not env_path.exists(): + script_dir = Path(__file__).resolve().parent + for _ in range(5): + script_dir = script_dir.parent + candidate = script_dir / '.env' + if candidate.exists(): + env_path = candidate + break + load_dotenv(dotenv_path=env_path) +except ImportError: + pass + +try: + from web3 import Web3 +except ImportError: + print("Error: web3 not installed. Run: pip install web3") + sys.exit(1) + +# --- Config --- +CHAINLINK_TVL_PROXY = "0xC8cd82067eA907EA4af81b625d2bB653E21b5156" +LIQUIDITY_POOL = "0x308861A430be4cce5502d0A12724771Fc6DaF216" + +CHAINLINK_PROXY_ABI = json.loads("""[ + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + {"name": "roundId", "type": "uint80"}, + {"name": "answer", "type": "int256"}, + {"name": "startedAt", "type": "uint256"}, + {"name": "updatedAt", "type": "uint256"}, + {"name": "answeredInRound", "type": "uint80"} + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{"name": "_roundId", "type": "uint80"}], + "name": "getRoundData", + "outputs": [ + {"name": "roundId", "type": "uint80"}, + {"name": "answer", "type": "int256"}, + {"name": "startedAt", "type": "uint256"}, + {"name": "updatedAt", "type": "uint256"}, + {"name": "answeredInRound", "type": "uint80"} + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{"name": "", "type": "uint8"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "aggregator", + "outputs": [{"name": "", "type": "address"}], + "stateMutability": "view", + "type": "function" + } +]""") + +LIQUIDITY_POOL_ABI = json.loads("""[ + { + "inputs": [], + "name": "getTotalPooledEther", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + } +]""") + + +def find_block_by_timestamp(w3, target_ts, lo_hint=None, hi_hint=None): + """Binary search to find the first block with timestamp >= target_ts.""" + lo = lo_hint if lo_hint else 18_000_000 + hi = hi_hint if hi_hint else w3.eth.block_number + + # Ensure lo block is before target + lo_block = w3.eth.get_block(lo) + if lo_block["timestamp"] >= target_ts: + return lo + + while lo < hi: + mid = (lo + hi) // 2 + block = w3.eth.get_block(mid) + if block["timestamp"] < target_ts: + lo = mid + 1 + else: + hi = mid + + return lo + + +def main(): + rpc_url = os.environ.get("MAINNET_RPC_URL") + if not rpc_url: + print("Error: MAINNET_RPC_URL environment variable not set") + sys.exit(1) + + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + print("Error: Cannot connect to RPC") + sys.exit(1) + + chainlink = w3.eth.contract( + address=Web3.to_checksum_address(CHAINLINK_TVL_PROXY), + abi=CHAINLINK_PROXY_ABI, + ) + liquidity_pool = w3.eth.contract( + address=Web3.to_checksum_address(LIQUIDITY_POOL), + abi=LIQUIDITY_POOL_ABI, + ) + + # Get decimals + decimals = chainlink.functions.decimals().call() + aggregator_addr = chainlink.functions.aggregator().call() + print(f"Chainlink feed decimals: {decimals}") + print(f"Underlying aggregator: {aggregator_addr}") + + # Get latest round info + latest_round = chainlink.functions.latestRoundData().call() + latest_round_id = latest_round[0] + phase_id = latest_round_id >> 64 + latest_agg_round = latest_round_id & 0xFFFFFFFFFFFFFFFF + print(f"Latest round: phase={phase_id}, aggRound={latest_agg_round}") + print(f"Latest answer: {latest_round[1] / 10**decimals:,.4f} ETH") + print(f"Latest updatedAt: {datetime.fromtimestamp(latest_round[3], tz=timezone.utc)}") + print() + + # Determine cutoff: 30 days ago + cutoff_ts = int((datetime.now(timezone.utc) - timedelta(days=30)).timestamp()) + print(f"Cutoff: {datetime.fromtimestamp(cutoff_ts, tz=timezone.utc)}") + + # Collect rounds from latest going back 30 days + rounds = [] + agg_round = latest_agg_round + while agg_round > 0: + round_id = (phase_id << 64) | agg_round + try: + data = chainlink.functions.getRoundData(round_id).call() + except Exception: + break + updated_at = data[3] + if updated_at < cutoff_ts: + break + rounds.append({ + "round_id": round_id, + "agg_round": agg_round, + "answer": data[1], + "updated_at": updated_at, + }) + agg_round -= 1 + + rounds.reverse() # chronological order + print(f"Found {len(rounds)} rounds in the past 30 days") + print() + + # Find block numbers for each round using binary search on timestamps + print("Finding block numbers for each round via timestamp binary search...") + current_block = w3.eth.block_number + + # Use a reasonable lower bound for the search + first_ts = rounds[0]["updated_at"] if rounds else cutoff_ts + # Estimate: ~12 sec/block, go back from current block + seconds_back = current_block * 12 - (first_ts - 1600000000) + lo_hint = max(1, current_block - (current_block - int(first_ts / 12)) // 1) + # Simpler: just estimate ~7200 blocks/day, 31 days back from current + lo_hint = max(1, current_block - 7200 * 35) + + prev_block = lo_hint + for r in rounds: + block_num = find_block_by_timestamp(w3, r["updated_at"], lo_hint=prev_block, hi_hint=current_block) + r["block_number"] = block_num + prev_block = block_num # next search starts from here + # Verify: check if this block or the one before has the exact timestamp + block_data = w3.eth.get_block(block_num) + r["block_timestamp"] = block_data["timestamp"] + + print("Done.\n") + + # Print comparison table + header = f"{'Date (UTC)':<22} {'Block':<12} {'Chainlink TVL (ETH)':>22} {'Pool TVL (ETH)':>22} {'Diff (ETH)':>14} {'Diff %':>10}" + print(header) + print("-" * len(header)) + + results = [] + for r in rounds: + block_number = r["block_number"] + chainlink_tvl = r["answer"] / 10**decimals + date_str = datetime.fromtimestamp(r["updated_at"], tz=timezone.utc).strftime("%Y-%m-%d %H:%M") + + # Get LiquidityPool TVL at the same block + try: + pool_tvl_raw = liquidity_pool.functions.getTotalPooledEther().call( + block_identifier=block_number + ) + pool_tvl = pool_tvl_raw / 10**18 + except Exception as e: + pool_tvl = None + + diff = None + diff_pct = None + if pool_tvl is not None: + diff = pool_tvl - chainlink_tvl + if chainlink_tvl != 0: + diff_pct = (diff / chainlink_tvl) * 100 + + results.append({ + "date": date_str, + "block": block_number, + "chainlink_tvl_eth": chainlink_tvl, + "pool_tvl_eth": pool_tvl, + "diff_eth": diff, + "diff_pct": diff_pct, + }) + + pool_str = f"{pool_tvl:,.4f}" if pool_tvl is not None else "ERROR" + diff_str = f"{diff:,.4f}" if diff is not None else "N/A" + pct_str = f"{diff_pct:+.4f}%" if diff_pct is not None else "N/A" + + print(f"{date_str:<22} {block_number:<12} {chainlink_tvl:>22,.4f} {pool_str:>22} {diff_str:>14} {pct_str:>10}") + + # Summary stats + print() + print("=" * len(header)) + diffs = [r["diff_pct"] for r in results if r["diff_pct"] is not None] + abs_diffs = [r["diff_eth"] for r in results if r["diff_eth"] is not None] + if diffs: + print(f"Average diff: {sum(diffs)/len(diffs):+.4f}% ({sum(abs_diffs)/len(abs_diffs):+,.4f} ETH)") + print(f"Max diff: {max(diffs):+.4f}% ({max(abs_diffs):+,.4f} ETH)") + print(f"Min diff: {min(diffs):+.4f}% ({min(abs_diffs):+,.4f} ETH)") + + # Save to JSON + output_path = Path(__file__).resolve().parent / "tvl_comparison.json" + with open(output_path, "w") as f: + json.dump(results, f, indent=2, default=str) + print(f"\nResults saved to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/script/operations/unrestaking/run-unrestake-validators.sh b/script/operations/unrestaking/run-unrestake-validators.sh new file mode 100755 index 000000000..722b649b1 --- /dev/null +++ b/script/operations/unrestaking/run-unrestake-validators.sh @@ -0,0 +1,348 @@ +#!/bin/bash +# +# run-unrestake-validators.sh - Unrestake validators for an operator +# +# Queues ETH withdrawals from EigenPods, accounting for pending withdrawal +# roots already on-chain. +# +# Usage: +# ./script/operations/consolidations/run-unrestake-validators.sh \ +# --operator "Cosmostation" \ +# --amount 1000 +# + +set -eu + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# Load .env file if it exists +if [ -f "$PROJECT_ROOT/.env" ]; then + set -a + source "$PROJECT_ROOT/.env" + set +a +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Default parameters +OPERATOR="" +AMOUNT=0 +DRY_RUN=false +SKIP_SIMULATE=false +MAINNET=false +IGNORE_PENDING=false + +print_usage() { + echo "Usage: $0 --operator --amount [options]" + echo "" + echo "Unrestake validators for an operator by queuing ETH withdrawals." + echo "Checks for pending withdrawal roots before queuing new ones." + echo "" + echo "Required:" + echo " --operator Operator name or address (e.g., 'Cosmostation')" + echo " --amount ETH amount to unrestake (e.g., 1000). Use 0 to unrestake all available." + echo "" + echo "Options:" + echo " --dry-run Preview plan without generating transactions" + echo " --skip-simulate Skip Tenderly simulation" + echo " --ignore-pending-withdrawals Skip pending withdrawal check, use full balance" + echo " --mainnet Broadcast on mainnet (requires PRIVATE_KEY)" + echo " --help, -h Show this help" + echo "" + echo "Examples:" + echo " # Preview plan" + echo " $0 --operator 'Cosmostation' --amount 1000 --dry-run" + echo "" + echo " # Generate files and simulate on Tenderly" + echo " $0 --operator 'Cosmostation' --amount 1000" + echo "" + echo " # Skip simulation" + echo " $0 --operator 'Cosmostation' --amount 1000 --skip-simulate" + echo "" + echo " # Broadcast on mainnet" + echo " $0 --operator 'Cosmostation' --amount 1000 --mainnet" + echo "" + echo "Environment Variables:" + echo " MAINNET_RPC_URL Ethereum mainnet RPC URL (required)" + echo " VALIDATOR_DB PostgreSQL connection string" + echo " PRIVATE_KEY Private key for ADMIN_EOA (required for --mainnet)" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --operator) + OPERATOR="$2" + shift 2 + ;; + --amount) + AMOUNT="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --skip-simulate) + SKIP_SIMULATE=true + shift + ;; + --ignore-pending-withdrawals) + IGNORE_PENDING=true + shift + ;; + --mainnet) + MAINNET=true + shift + ;; + --help|-h) + print_usage + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option: $1${NC}" + print_usage + exit 1 + ;; + esac +done + +# Validate required arguments +if [ -z "$OPERATOR" ]; then + echo -e "${RED}Error: --operator is required${NC}" + print_usage + exit 1 +fi + +if [ -z "$AMOUNT" ]; then + echo -e "${RED}Error: --amount is required (use 0 to unrestake all)${NC}" + print_usage + exit 1 +fi + +# Check environment variables +if [ -z "${VALIDATOR_DB:-}" ]; then + echo -e "${RED}Error: VALIDATOR_DB environment variable not set${NC}" + exit 1 +fi + +if [ "$MAINNET" = true ] && [ -z "${PRIVATE_KEY:-}" ]; then + echo -e "${RED}Error: PRIVATE_KEY environment variable not set (required for --mainnet)${NC}" + exit 1 +fi + +# Create output directory +TIMESTAMP=$(date +%Y%m%d-%H%M%S) +OPERATOR_SLUG=$(echo "$OPERATOR" | tr ' ' '_' | tr '[:upper:]' '[:lower:]') +if [ "$AMOUNT" = "0" ]; then + AMOUNT_SLUG="all" +else + AMOUNT_SLUG="${AMOUNT}eth" +fi +OUTPUT_DIR="$SCRIPT_DIR/txns/${OPERATOR_SLUG}_unrestake_${AMOUNT_SLUG}_${TIMESTAMP}" +mkdir -p "$OUTPUT_DIR" + +echo "" +echo -e "${GREEN}================================================================${NC}" +echo -e "${GREEN} UNRESTAKE VALIDATORS ${NC}" +echo -e "${GREEN}================================================================${NC}" +echo "" +echo -e "${BLUE}Configuration:${NC}" +echo " Operator: $OPERATOR" +if [ "$AMOUNT" = "0" ]; then + echo " Amount: ALL (unrestake everything available)" +else + echo " Amount: $AMOUNT ETH" +fi +echo " Dry run: $DRY_RUN" +echo " Mainnet mode: $MAINNET" +echo " Output: $OUTPUT_DIR" +echo "" + +# ============================================================================ +# Step 1: Generate unrestake plan and transaction files +# ============================================================================ +echo -e "${YELLOW}[1/3] Generating unrestake plan...${NC}" +echo -e "${YELLOW}================================================================${NC}" + +PLAN_ARGS=( + --operator "$OPERATOR" + --amount "$AMOUNT" + --output-dir "$OUTPUT_DIR" +) + +if [ "$DRY_RUN" = true ]; then + PLAN_ARGS+=(--dry-run) +fi + +if [ "$IGNORE_PENDING" = true ]; then + PLAN_ARGS+=(--ignore-pending-withdrawals) +fi + +python3 "$SCRIPT_DIR/unrestake_validators.py" "${PLAN_ARGS[@]}" + +if [ "$DRY_RUN" = true ]; then + echo "" + echo -e "${GREEN}Dry run complete. No files generated.${NC}" + exit 0 +fi + +if [ ! -f "$OUTPUT_DIR/unrestake-plan.json" ]; then + echo -e "${RED}Error: Failed to generate unrestake plan${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}Plan generated successfully.${NC}" +echo "" + +# ============================================================================ +# Step 2: Simulate on Tenderly or broadcast on mainnet +# ============================================================================ +NODES_MANAGER="0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F" +QUEUE_FILE="$OUTPUT_DIR/queue-withdrawals.json" + +if [ "$MAINNET" = true ]; then + echo -e "${YELLOW}[2/3] Broadcasting on MAINNET...${NC}" + echo -e "${YELLOW}================================================================${NC}" + echo -e "${RED}WARNING: This will execute REAL transactions on mainnet!${NC}" + echo "" + + if [ -z "${MAINNET_RPC_URL:-}" ]; then + echo -e "${RED}Error: MAINNET_RPC_URL required for mainnet broadcast${NC}" + exit 1 + fi + + if [ -f "$QUEUE_FILE" ]; then + NUM_WITHDRAWALS=$(jq '.transactions | length' "$QUEUE_FILE") + echo -e "${YELLOW}Executing queueETHWithdrawal for $NUM_WITHDRAWALS pod(s)...${NC}" + + for IDX in $(seq 0 $((NUM_WITHDRAWALS - 1))); do + NODE_ADDR=$(jq -r ".transactions[$IDX].node_address" "$QUEUE_FILE") + WITHDRAWAL_GWEI=$(jq -r ".transactions[$IDX].withdrawal_amount_gwei" "$QUEUE_FILE") + WITHDRAWAL_ETH=$(jq -r ".transactions[$IDX].withdrawal_amount_eth" "$QUEUE_FILE") + TX_TO=$(jq -r ".transactions[$IDX].to" "$QUEUE_FILE") + TX_DATA=$(jq -r ".transactions[$IDX].data" "$QUEUE_FILE") + + WITHDRAWAL_WEI=$(python3 -c "print($WITHDRAWAL_GWEI * 10**9)") + + echo " Pod $((IDX + 1)): node=$NODE_ADDR" + echo " Amount: $WITHDRAWAL_ETH ETH ($WITHDRAWAL_GWEI gwei / $WITHDRAWAL_WEI wei)" + + if [ "$TX_DATA" = "0x" ] || [ -z "$TX_DATA" ] || [ "$TX_DATA" = "null" ]; then + echo " Using cast send with function signature" + cast send "$NODES_MANAGER" "queueETHWithdrawal(address,uint256)" \ + "$NODE_ADDR" "$WITHDRAWAL_WEI" \ + --rpc-url "$MAINNET_RPC_URL" \ + --private-key "$PRIVATE_KEY" 2>&1 | tee -a "$OUTPUT_DIR/mainnet_broadcast.log" + else + echo " Using pre-encoded calldata" + cast send "$TX_TO" "$TX_DATA" \ + --rpc-url "$MAINNET_RPC_URL" \ + --private-key "$PRIVATE_KEY" 2>&1 | tee -a "$OUTPUT_DIR/mainnet_broadcast.log" + fi + CAST_EXIT_CODE=${PIPESTATUS[0]} + + if [ $CAST_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: queueETHWithdrawal failed for pod $((IDX + 1))${NC}" + exit 1 + fi + echo -e "${GREEN} queueETHWithdrawal for pod $((IDX + 1)) sent successfully.${NC}" + done + echo "" + fi + +elif [ "$SKIP_SIMULATE" = true ]; then + echo -e "${YELLOW}[2/3] Skipping Tenderly simulation (--skip-simulate)${NC}" + echo -e "${YELLOW}================================================================${NC}" + +else + echo -e "${YELLOW}[2/3] Simulating on Tenderly...${NC}" + echo -e "${YELLOW}================================================================${NC}" + + if [ -z "${MAINNET_RPC_URL:-}" ]; then + echo -e "${RED}Error: MAINNET_RPC_URL required for simulation${NC}" + exit 1 + fi + + VNET_NAME="${OPERATOR_SLUG}-unrestake-${AMOUNT}eth-${TIMESTAMP}" + + if [ -f "$QUEUE_FILE" ]; then + echo " Including: queue-withdrawals.json" + echo "" + echo "Simulating transaction file..." + python3 "$PROJECT_ROOT/script/operations/utils/simulate.py" --tenderly \ + --txns "$QUEUE_FILE" \ + --vnet-name "$VNET_NAME" + SIMULATION_EXIT_CODE=$? + + if [ $SIMULATION_EXIT_CODE -ne 0 ]; then + echo -e "${RED}Error: Tenderly simulation failed${NC}" + exit 1 + fi + else + echo -e "${RED}Error: No queue-withdrawals.json found to simulate${NC}" + exit 1 + fi +fi + +# ============================================================================ +# Step 3: Summary +# ============================================================================ +echo "" +echo -e "${GREEN}================================================================${NC}" +echo -e "${GREEN} UNRESTAKE COMPLETE ${NC}" +echo -e "${GREEN}================================================================${NC}" +echo "" +echo -e "${BLUE}Output directory:${NC} $OUTPUT_DIR" +echo "" + +# Extract summary from unrestake-plan.json +PLAN_FILE="$OUTPUT_DIR/unrestake-plan.json" +if [ -f "$PLAN_FILE" ] && command -v jq &> /dev/null; then + REQUESTED=$(jq '.requested_amount_eth' "$PLAN_FILE") + TOTAL_WITHDRAWAL=$(jq '.total_withdrawal_eth' "$PLAN_FILE") + NUM_PODS=$(jq '.num_pods_used' "$PLAN_FILE") + QUEUE_TXS=$(jq '.transactions.queue_withdrawals // 0' "$PLAN_FILE") + + echo -e "${BLUE}Summary:${NC}" + echo " Requested withdrawal: $REQUESTED ETH" + echo " Total withdrawal: $TOTAL_WITHDRAWAL ETH" + echo " Pods used: $NUM_PODS" + echo "" + echo " Transactions:" + echo " Queue withdrawals: $QUEUE_TXS" + echo "" + + # Show per-pod details + for IDX in $(seq 0 $((NUM_PODS - 1))); do + POD_NODE=$(jq -r ".pods[$IDX].node_address" "$PLAN_FILE") + POD_EIGENPOD=$(jq -r ".pods[$IDX].eigenpod" "$PLAN_FILE") + POD_PENDING=$(jq ".pods[$IDX].pending_withdrawal_eth" "$PLAN_FILE") + POD_WITHDRAWAL=$(jq ".pods[$IDX].withdrawal_eth" "$PLAN_FILE") + echo " Pod $((IDX + 1)): $POD_NODE" + echo " EigenPod: $POD_EIGENPOD" + echo " Pending: $POD_PENDING ETH" + echo " Withdrawal: $POD_WITHDRAWAL ETH" + done + echo "" +fi + +echo -e "${BLUE}Generated files:${NC}" +for file in "$OUTPUT_DIR"/*.json; do + [ -f "$file" ] || continue + echo " - $(basename "$file")" +done + +echo "" +echo -e "${BLUE}Execution order:${NC}" +echo " 1. Execute queue-withdrawals.json from ADMIN_EOA (queueETHWithdrawal)" +echo " 2. Wait for EigenLayer withdrawal delay, then completeQueuedETHWithdrawals" +echo "" diff --git a/script/operations/unrestaking/unrestake_validators.py b/script/operations/unrestaking/unrestake_validators.py new file mode 100644 index 000000000..9fa176fd8 --- /dev/null +++ b/script/operations/unrestaking/unrestake_validators.py @@ -0,0 +1,748 @@ +#!/usr/bin/env python3 +""" +unrestake_validators.py - Unrestake validators for an operator + +Queues ETH withdrawals from EigenPods for a given operator, accounting for +any pending withdrawal roots already queued on-chain. + +Flow: + 1. Query operator's pods and total balances from the DB + 2. Check pending withdrawals on each node via DelegationManager + 3. Compute available (unrestakable) ETH per pod + 4. Greedily select pods to fulfill the requested amount + 5. Generate queueETHWithdrawal transaction files + +Usage: + python3 unrestake_validators.py --operator "Cosmostation" --amount 1000 + python3 unrestake_validators.py --operator "Cosmostation" --amount 1000 --dry-run + python3 unrestake_validators.py --list-operators + +Environment Variables: + VALIDATOR_DB: PostgreSQL connection string for validator database + MAINNET_RPC_URL: Ethereum mainnet RPC URL (required for pending withdrawal checks) +""" + +import argparse +import json +import math +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# Add parent directory to sys.path for absolute imports +parent_dir = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(parent_dir)) + +from utils.validator_utils import ( + get_db_connection, + get_operator_address, + list_operators, +) + +from consolidations.generate_gnosis_txns import ( + encode_address, + encode_uint256, + ETHERFI_NODES_MANAGER, + ADMIN_EOA, + DEFAULT_CHAIN_ID, +) + + +# ============================================================================= +# Constants +# ============================================================================= + +DELEGATION_MANAGER = "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A" +QUEUE_ETH_WITHDRAWAL_SELECTOR = "03f49be8" +MIN_WITHDRAWAL_AMOUNT = 32 # ETH + + +# ============================================================================= +# Database Queries +# ============================================================================= + +def query_operator_pods( + conn, + operator_address: str, +) -> List[Dict]: + """Query pods and their total balances for an operator. + + Returns list of dicts with node_address, eigenpod, validator_count, + total_balance_eth. + """ + query = """ + SELECT + node_address, + '0x' || RIGHT(withdrawal_credentials, 40) AS eigenpod, + COUNT(*) AS validator_count, + SUM(balance) AS total_balance_wei, + SUM(balance) / 1e18 AS total_balance_eth + FROM etherfi_validators + WHERE timestamp = ( + SELECT MAX(timestamp) FROM etherfi_validators + ) + AND status = 'active_ongoing' + AND operator = %s + GROUP BY node_address, withdrawal_credentials + ORDER BY total_balance_eth DESC + """ + pods = [] + with conn.cursor() as cur: + cur.execute(query, (operator_address,)) + for row in cur.fetchall(): + pods.append({ + 'node_address': row[0], + 'eigenpod': row[1], + 'validator_count': row[2], + 'total_balance_wei': int(row[3]) if row[3] else 0, + 'total_balance_eth': float(row[4]) if row[4] else 0.0, + }) + return pods + + +# ============================================================================= +# On-chain Pending Withdrawal Queries +# ============================================================================= + +def get_pending_withdrawal_eth( + node_address: str, + rpc_url: str, +) -> float: + """Query pending (queued) withdrawal ETH for a node. + + Calls getQueuedWithdrawals(address) on DelegationManager without a + return-type annotation so cast returns raw ABI hex. We then decode + only the uint256[][] (second return element) to avoid double-counting + shares that also appear inside each Withdrawal struct. + """ + try: + result = subprocess.run( + [ + 'cast', 'call', DELEGATION_MANAGER, + 'getQueuedWithdrawals(address)', + node_address, + '--rpc-url', rpc_url, + ], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + print( + f" Warning: getQueuedWithdrawals failed for " + f"{node_address}: {result.stderr.strip()}" + ) + return 0.0 + + raw_hex = result.stdout.strip() + if not raw_hex or raw_hex == '0x': + return 0.0 + + return _decode_shares_from_raw(raw_hex) + except subprocess.TimeoutExpired: + print( + f" Warning: Timeout querying pending withdrawals " + f"for {node_address}" + ) + return 0.0 + except Exception as e: + print( + f" Warning: Exception querying pending withdrawals " + f"for {node_address}: {e}" + ) + return 0.0 + + +def _decode_shares_from_raw(raw_hex: str) -> float: + """Decode uint256[][] shares from getQueuedWithdrawals ABI output. + + Return type is (Withdrawal[], uint256[][]). We extract only the + second element to avoid double-counting shares that also appear + inside each Withdrawal struct. + + ABI layout of the top-level tuple: + word 0: offset to Withdrawal[] encoding + word 1: offset to uint256[][] encoding + """ + hex_str = raw_hex[2:] if raw_hex.startswith('0x') else raw_hex + if len(hex_str) < 128: + return 0.0 + + data = bytes.fromhex(hex_str) + + # Offset to uint256[][] (second tuple element) + shares_offset = int.from_bytes(data[32:64], 'big') + if shares_offset + 32 > len(data): + return 0.0 + + num_withdrawals = int.from_bytes( + data[shares_offset:shares_offset + 32], 'big', + ) + if num_withdrawals == 0: + return 0.0 + + head_start = shares_offset + 32 + total_shares_wei = 0 + + for i in range(num_withdrawals): + offset_pos = head_start + i * 32 + if offset_pos + 32 > len(data): + break + + inner_offset = int.from_bytes( + data[offset_pos:offset_pos + 32], 'big', + ) + inner_pos = head_start + inner_offset + if inner_pos + 32 > len(data): + break + + inner_count = int.from_bytes( + data[inner_pos:inner_pos + 32], 'big', + ) + + for j in range(inner_count): + val_pos = inner_pos + 32 + j * 32 + if val_pos + 32 > len(data): + break + val = int.from_bytes( + data[val_pos:val_pos + 32], 'big', + ) + total_shares_wei += val + + return total_shares_wei / 1e18 + + +# ============================================================================= +# Pod Evaluation +# ============================================================================= + +def evaluate_pods( + pods: List[Dict], + rpc_url: str, +) -> List[Dict]: + """Enrich pods with pending withdrawal data and available ETH.""" + for pod in pods: + node = pod['node_address'] + if rpc_url and node: + pending = get_pending_withdrawal_eth(node, rpc_url) + else: + pending = 0.0 + + pod['pending_withdrawal_eth'] = pending + pod['available_eth'] = max( + 0.0, + pod['total_balance_eth'] - pending, + ) + + return pods + + +def display_pods_table(pods: List[Dict]): + """Print a table of all EigenPods for the operator.""" + print( + f"\n {'#':<4} {'Node Address':<44} " + f"{'Vals':>6} {'Total ETH':>12} " + f"{'Pending':>12} {'Available':>12}" + ) + print(f" {'-' * 94}") + + total_vals = 0 + total_eth = 0.0 + total_pending = 0.0 + total_available = 0.0 + + for i, pod in enumerate(pods, start=1): + node = pod['node_address'] or 'N/A' + total_vals += pod['validator_count'] + total_eth += pod['total_balance_eth'] + total_pending += pod['pending_withdrawal_eth'] + total_available += pod['available_eth'] + + print( + f" {i:<4} {node:<44} " + f"{pod['validator_count']:>6} " + f"{pod['total_balance_eth']:>10,.0f} ETH " + f"{pod['pending_withdrawal_eth']:>10,.0f} ETH " + f"{pod['available_eth']:>10,.0f} ETH" + ) + + print(f" {'-' * 94}") + print( + f" {'':4} {'TOTAL':<44} " + f"{total_vals:>6} " + f"{total_eth:>10,.0f} ETH " + f"{total_pending:>10,.0f} ETH " + f"{total_available:>10,.0f} ETH" + ) + + +# ============================================================================= +# Pod Selection +# ============================================================================= + +def select_pods( + pods: List[Dict], + amount_eth: float, + whole_eth: bool = False, +) -> Tuple[List[Dict], float]: + """Select pods to cover the requested unrestake amount. + + Greedy: sort by available_eth descending, take from each pod until + the requested amount is fulfilled. + + Args: + whole_eth: If True, floor each pod's withdrawal to whole ETH. + Used with --amount 0 to avoid fractional withdrawals. + + Returns (selected_pods_with_withdrawal_amount, total_withdrawal_eth). + """ + candidates = [p for p in pods if p['available_eth'] > 0] + candidates.sort(key=lambda p: p['available_eth'], reverse=True) + + selections = [] + remaining = amount_eth + + for pod in candidates: + if remaining <= 0: + break + + withdrawal = min(pod['available_eth'], remaining) + if whole_eth: + withdrawal = math.floor(withdrawal) + if withdrawal <= 0: + continue + selections.append({ + **pod, + 'withdrawal_eth': withdrawal, + }) + remaining -= withdrawal + + total = sum(s['withdrawal_eth'] for s in selections) + return selections, total + + +# ============================================================================= +# Transaction Generation +# ============================================================================= + +def encode_queue_eth_withdrawal( + node_address: str, + amount_wei: int, +) -> str: + """Encode queueETHWithdrawal(address,uint256) calldata.""" + selector = bytes.fromhex(QUEUE_ETH_WITHDRAWAL_SELECTOR) + params = encode_address(node_address) + encode_uint256(amount_wei) + return "0x" + (selector + params).hex() + + +def write_transactions( + selections: List[Dict], + output_dir: str, + chain_id: int, + from_address: str, +) -> Optional[str]: + """Write queue-withdrawals.json with queueETHWithdrawal calls.""" + transactions = [] + for sel in selections: + node_address = sel['node_address'] + withdrawal_eth = sel['withdrawal_eth'] + withdrawal_gwei = int(withdrawal_eth * 1e9) + withdrawal_wei = withdrawal_gwei * (10 ** 9) + + tx_entry = { + "node_address": node_address, + "eigenpod": sel['eigenpod'], + "withdrawal_amount_gwei": withdrawal_gwei, + "withdrawal_amount_eth": withdrawal_gwei / 1e9, + "to": ETHERFI_NODES_MANAGER, + "value": "0", + "data": encode_queue_eth_withdrawal( + node_address, withdrawal_wei + ), + } + transactions.append(tx_entry) + + tx_data = { + "chainId": str(chain_id), + "from": from_address, + "transactions": transactions, + "description": ( + f"Queue ETH withdrawals for " + f"{len(transactions)} pod(s)" + ), + } + + filepath = os.path.join(output_dir, "queue-withdrawals.json") + with open(filepath, 'w') as f: + json.dump(tx_data, f, indent=2) + print( + f" Written: queue-withdrawals.json " + f"({len(transactions)} withdrawal(s))" + ) + return filepath + + +def write_plan( + selections: List[Dict], + amount_eth: float, + total_withdrawal: float, + operator_name: str, + output_dir: str, +) -> str: + """Write unrestake-plan.json with plan metadata.""" + pods_info = [] + for sel in selections: + pods_info.append({ + 'node_address': sel['node_address'], + 'eigenpod': sel['eigenpod'], + 'validator_count': sel['validator_count'], + 'total_balance_eth': sel['total_balance_eth'], + 'pending_withdrawal_eth': sel['pending_withdrawal_eth'], + 'available_eth': sel['available_eth'], + 'withdrawal_eth': sel['withdrawal_eth'], + }) + + plan = { + 'type': 'unrestake_withdrawal', + 'operator': operator_name, + 'requested_amount_eth': amount_eth, + 'total_withdrawal_eth': total_withdrawal, + 'num_pods_used': len(selections), + 'pods': pods_info, + 'transactions': { + 'queue_withdrawals': len(selections), + 'total': len(selections), + }, + 'files': { + 'queue_withdrawals': 'queue-withdrawals.json', + }, + 'execution_order': [ + "1. Execute queue-withdrawals.json from ADMIN_EOA " + "(queueETHWithdrawal)", + "2. Wait for EigenLayer withdrawal delay, " + "then completeQueuedETHWithdrawals", + ], + 'generated_at': datetime.now().isoformat(), + } + + filepath = os.path.join(output_dir, 'unrestake-plan.json') + with open(filepath, 'w') as f: + json.dump(plan, f, indent=2, default=str) + return filepath + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description='Unrestake validators for an operator', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # List operators + python3 unrestake_validators.py --list-operators + + # Preview plan (no files written) + python3 unrestake_validators.py --operator "Cosmostation" --amount 1000 --dry-run + + # Generate transaction files + python3 unrestake_validators.py --operator "Cosmostation" --amount 1000 + """ + ) + parser.add_argument( + '--operator', + help='Operator name or address', + ) + parser.add_argument( + '--amount', + type=float, + help='ETH amount to unrestake (0 to unrestake all available)', + ) + parser.add_argument( + '--output-dir', + help='Output directory (auto-generated if omitted)', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Preview plan without writing files', + ) + parser.add_argument( + '--ignore-pending-withdrawals', + action='store_true', + help='Skip pending withdrawal check, treat full balance as available', + ) + parser.add_argument( + '--list-operators', + action='store_true', + help='List available operators', + ) + + args = parser.parse_args() + + if not args.list_operators and not args.operator: + print("Error: --operator is required (or use --list-operators)") + parser.print_help() + sys.exit(1) + + if not args.list_operators and args.amount is None: + print("Error: --amount is required (use 0 to unrestake all)") + parser.print_help() + sys.exit(1) + + if args.amount and args.amount < MIN_WITHDRAWAL_AMOUNT: + print( + f"Error: --amount must be at least " + f"{MIN_WITHDRAWAL_AMOUNT} ETH (or 0 to unrestake all)" + ) + sys.exit(1) + + try: + conn = get_db_connection() + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + try: + # List operators + if args.list_operators: + operators = list_operators(conn) + print( + f"\n{'Name':<30} {'Address':<44} " + f"{'Validators':>10}" + ) + print("-" * 88) + for op in operators: + addr = op['address'] or 'N/A' + print( + f"{op['name']:<30} {addr:<44} " + f"{op['total']:>10}" + ) + return + + # Resolve operator + operator_address = get_operator_address(conn, args.operator) + if not operator_address: + print(f"Error: Operator '{args.operator}' not found") + print("Use --list-operators to see available operators") + sys.exit(1) + + print(f"\n{'=' * 60}") + print("UNRESTAKE VALIDATORS") + print(f"{'=' * 60}") + print(f"Operator: {args.operator} ({operator_address})") + if args.amount == 0: + print("Target amount: ALL (unrestake everything available)") + else: + print(f"Target amount: {args.amount:,.0f} ETH") + print() + + # ============================================================== + # Step 1: Query pods from DB + # ============================================================== + print("Step 1: Querying pods from database...") + pods = query_operator_pods(conn, operator_address) + if not pods: + print("Error: No pods found for this operator") + sys.exit(1) + total_balance = sum(p['total_balance_eth'] for p in pods) + total_validators = sum(p['validator_count'] for p in pods) + print( + f" Found {len(pods)} pod(s), " + f"{total_validators} validators, " + f"{total_balance:,.0f} ETH total" + ) + + # ============================================================== + # Step 2: Check pending withdrawals + # ============================================================== + rpc_url = os.environ.get('MAINNET_RPC_URL', '') + if args.ignore_pending_withdrawals: + print( + "\nStep 2: Skipping pending withdrawal check " + "(--ignore-pending-withdrawals)" + ) + pods = evaluate_pods(pods, rpc_url="") + elif rpc_url: + print( + "\nStep 2: Checking pending withdrawals on-chain..." + ) + pods = evaluate_pods(pods, rpc_url) + else: + print( + "\nStep 2: Skipping pending withdrawal check " + "(MAINNET_RPC_URL not set)" + ) + pods = evaluate_pods(pods, rpc_url="") + + # Display table + display_pods_table(pods) + + # ============================================================== + # Step 3: Check capacity and resolve amount=0 + # ============================================================== + total_available = sum(p['available_eth'] for p in pods) + target_amount = args.amount + + if target_amount == 0: + target_amount = total_available + print( + f"\n Unrestaking ALL available: " + f"{total_available:,.0f} ETH" + ) + elif total_available < target_amount: + print( + f"\n Error: Total available across all pods is " + f"{total_available:,.0f} ETH" + ) + print(f" Requested: {target_amount:,.0f} ETH") + total_pending = sum( + p['pending_withdrawal_eth'] for p in pods + ) + if total_pending > 0: + print( + f" Already pending: {total_pending:,.0f} ETH" + ) + sys.exit(1) + + if total_available <= 0: + print("\n Error: No available ETH to unrestake") + sys.exit(1) + + # ============================================================== + # Step 4: Select pods + # ============================================================== + print( + f"\nStep 3: Selecting pods for " + f"{target_amount:,.0f} ETH unrestake..." + ) + selections, total_withdrawal = select_pods( + pods, target_amount, + whole_eth=(args.amount == 0), + ) + + if not selections: + print(" Error: Could not select any pods") + sys.exit(1) + + # ============================================================== + # Step 5: Print plan + # ============================================================== + print(f"\n{'=' * 60}") + print("UNRESTAKE PLAN") + print(f"{'=' * 60}") + print(f"Pods used: {len(selections)}") + if args.amount == 0: + print("Requested amount: ALL") + else: + print( + f"Requested amount: {target_amount:,.0f} ETH" + ) + print(f"Total withdrawal: {total_withdrawal:,.2f} ETH") + if args.amount != 0: + surplus = total_withdrawal - target_amount + if surplus > 0: + print( + f"Surplus over requested: {surplus:,.2f} ETH" + ) + + for i, sel in enumerate(selections, start=1): + print(f"\n Pod {i}: {sel['node_address']}") + print(f" EigenPod: {sel['eigenpod']}") + print(f" Validators: {sel['validator_count']}") + print( + f" Total balance: " + f"{sel['total_balance_eth']:,.2f} ETH" + ) + print( + f" Pending withdrawals: " + f"{sel['pending_withdrawal_eth']:,.2f} ETH" + ) + print( + f" Available: " + f"{sel['available_eth']:,.2f} ETH" + ) + print( + f" Withdrawal amount: " + f"{sel['withdrawal_eth']:,.2f} ETH" + ) + + if args.dry_run: + print("\n(Dry run - no files written)") + return + + # ============================================================== + # Step 6: Generate output files + # ============================================================== + print(f"\nStep 4: Generating output files...") + + if args.output_dir: + output_dir = args.output_dir + else: + script_dir = Path(__file__).resolve().parent + timestamp = datetime.now().strftime('%Y%m%d-%H%M%S') + operator_slug = ( + args.operator.replace(' ', '_').lower() + ) + if args.amount == 0: + amount_slug = "all" + else: + amount_slug = f"{int(target_amount)}eth" + output_dir = str( + script_dir / 'txns' + / f"{operator_slug}_unrestake" + f"_{amount_slug}_{timestamp}" + ) + + os.makedirs(output_dir, exist_ok=True) + + chain_id = int( + os.environ.get('CHAIN_ID', DEFAULT_CHAIN_ID) + ) + admin_address = os.environ.get( + 'ADMIN_ADDRESS', ADMIN_EOA + ) + + write_transactions( + selections, output_dir, chain_id, admin_address, + ) + + write_plan( + selections, target_amount, total_withdrawal, + args.operator, output_dir, + ) + print(" Written: unrestake-plan.json") + + # Summary + print(f"\n{'=' * 60}") + print("OUTPUT COMPLETE") + print(f"{'=' * 60}") + print(f"Directory: {output_dir}") + print(f"\nExecution order:") + print( + " 1. Execute queue-withdrawals.json from ADMIN_EOA " + "(queueETHWithdrawal)" + ) + print( + " 2. Wait for EigenLayer withdrawal delay, " + "then completeQueuedETHWithdrawals" + ) + print() + print( + f"Total ETH to queue for withdrawal: " + f"{total_withdrawal:,.2f} ETH " + f"across {len(selections)} pod(s)" + ) + print() + + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/script/operations/utils/SimulateBatchApprove.s.sol b/script/operations/utils/SimulateBatchApprove.s.sol new file mode 100644 index 000000000..5bfd9e8b4 --- /dev/null +++ b/script/operations/utils/SimulateBatchApprove.s.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import "forge-std/StdJson.sol"; +import "../../../src/interfaces/ILiquidityPool.sol"; +import "../../../src/interfaces/IEtherFiNodesManager.sol"; + +/** + * @title SimulateBatchApprove + * @notice Simulates batchApproveRegistration with validator data from JSON file + * + * Usage: + * JSON_FILE=validators.json forge script \ + * script/operations/utils/SimulateBatchApprove.s.sol:SimulateBatchApprove \ + * --fork-url $MAINNET_RPC_URL -vvvv + * + * JSON Format: + * [ + * {"validator_id": 31225, "pubkey": "0xb4d601...", "eigenpod": "0x9ad4d1..."}, + * {"validator_id": 31226, "pubkey": "0xa5eefc...", "eigenpod": "0x9ad4d1..."} + * ] + */ +contract SimulateBatchApprove is Script { + using stdJson for string; + + // Mainnet addresses + address constant LIQUIDITY_POOL = 0x308861A430be4cce5502d0A12724771Fc6DaF216; + address constant NODES_MANAGER = 0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F; + address constant ETHERFI_ADMIN = 0x0EF8fa4760Db8f5Cd4d993f3e3416f30f942D705; + + function run() external { + ILiquidityPool liquidityPool = ILiquidityPool(LIQUIDITY_POOL); + IEtherFiNodesManager nodesManager = IEtherFiNodesManager(NODES_MANAGER); + + // Load JSON file + string memory jsonFile = vm.envString("JSON_FILE"); + string memory jsonPath = _resolvePath(jsonFile); + string memory jsonData = vm.readFile(jsonPath); + + console2.log("=== BATCH APPROVE SIMULATION ==="); + console2.log("JSON file:", jsonPath); + console2.log(""); + + // Count validators + uint256 count = 0; + for (uint256 i = 0; i < 1000; i++) { + string memory path = string.concat("$[", vm.toString(i), "].validator_id"); + if (!stdJson.keyExists(jsonData, path)) break; + count++; + } + + console2.log("Validators found:", count); + if (count == 0) { + console2.log("No validators in JSON file"); + return; + } + + // Parse validators + uint256[] memory validatorIds = new uint256[](count); + bytes[] memory pubkeys = new bytes[](count); + bytes[] memory signatures = new bytes[](count); + + for (uint256 i = 0; i < count; i++) { + string memory basePath = string.concat("$[", vm.toString(i), "]"); + validatorIds[i] = stdJson.readUint(jsonData, string.concat(basePath, ".validator_id")); + pubkeys[i] = stdJson.readBytes(jsonData, string.concat(basePath, ".pubkey")); + signatures[i] = new bytes(96); // Dummy signature + } + + console2.log(""); + + // Analyze node distribution + console2.log("=== NODE ANALYSIS ==="); + address firstNode = nodesManager.etherfiNodeAddress(validatorIds[0]); + console2.log("First validator:", validatorIds[0]); + console2.log("First node:", firstNode); + + bool allSameNode = true; + uint256 mismatchIndex = 0; + for (uint256 i = 1; i < count; i++) { + address node = nodesManager.etherfiNodeAddress(validatorIds[i]); + if (node != firstNode) { + allSameNode = false; + mismatchIndex = i; + console2.log(""); + console2.log("MISMATCH at index:", i); + console2.log(" Validator:", validatorIds[i]); + console2.log(" Node:", node); + console2.log(" Expected:", firstNode); + break; + } + } + + if (allSameNode) { + console2.log("All", count, "validators belong to same node"); + } + + console2.log(""); + console2.log("=== SIMULATING batchApproveRegistration ==="); + console2.log("Caller: EtherFiAdmin", ETHERFI_ADMIN); + console2.log("Validators:", count); + console2.log(""); + + vm.prank(ETHERFI_ADMIN); + + try liquidityPool.batchApproveRegistration(validatorIds, pubkeys, signatures) { + console2.log("RESULT: SUCCESS"); + console2.log(""); + console2.log("The batch would succeed with valid signatures."); + } catch (bytes memory err) { + bytes4 selector = bytes4(err); + + if (selector == bytes4(keccak256("InvalidEtherFiNode()"))) { + console2.log("RESULT: FAILED - InvalidEtherFiNode()"); + console2.log(""); + console2.log("Validators belong to different EtherFiNodes."); + console2.log("Split into separate batches by node."); + } else if (selector == bytes4(keccak256("UnlinkedPubkey()"))) { + console2.log("RESULT: PASSED node check, failed at UnlinkedPubkey()"); + console2.log(""); + console2.log("All validators are in same node. Would succeed with valid signatures."); + } else if (selector == bytes4(keccak256("IncorrectRole()"))) { + console2.log("RESULT: FAILED - IncorrectRole()"); + console2.log(""); + console2.log("Caller doesn't have LIQUIDITY_POOL_VALIDATOR_APPROVER_ROLE"); + } else { + console2.log("RESULT: FAILED - Unknown error"); + console2.log(" Selector:", vm.toString(selector)); + } + } + } + + function _resolvePath(string memory path) internal view returns (string memory) { + if (bytes(path).length > 0 && bytes(path)[0] == '/') { + return path; + } + // Check if path starts with "script/" + bytes memory pathBytes = bytes(path); + if (pathBytes.length >= 7 && + pathBytes[0] == 's' && pathBytes[1] == 'c' && pathBytes[2] == 'r' && + pathBytes[3] == 'i' && pathBytes[4] == 'p' && pathBytes[5] == 't' && pathBytes[6] == '/') { + return string.concat(vm.projectRoot(), "/", path); + } + return string.concat(vm.projectRoot(), "/", path); + } +} + diff --git a/script/operations/utils/SimulateTransactions.s.sol b/script/operations/utils/SimulateTransactions.s.sol new file mode 100644 index 000000000..43e9c58f2 --- /dev/null +++ b/script/operations/utils/SimulateTransactions.s.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import "forge-std/StdJson.sol"; + +/** + * @title SimulateTransactions + * @notice Simulates timelock-gated transactions with time warping + * @dev Executes schedule transactions, warps time, then executes execute transactions + * + * Usage: + * TXNS=schedule.json,execute.json DELAY=28800 forge script \ + * script/operations/utils/SimulateTransactions.s.sol:SimulateTransactions \ + * --fork-url $MAINNET_RPC_URL -vvvv + * + * Environment Variables: + * - TXNS: Comma-separated list of transaction JSON files + * - DELAY: Timelock delay in seconds (default: 28800 = 8 hours) + * - SAFE_ADDRESS: Address to execute transactions from + */ +contract SimulateTransactions is Script { + using stdJson for string; + + // Default values + uint256 constant DEFAULT_DELAY = 28800; // 8 hours + address constant DEFAULT_SAFE = 0x2aCA71020De61bb532008049e1Bd41E451aE8AdC; + + // Transaction struct - fields ordered alphabetically for JSON parsing + struct Transaction { + bytes data; // 'd' comes first alphabetically + address to; // 't' comes second + string value; // 'v' comes third (string to handle "0" values) + } + + struct TransactionFile { + Transaction[] transactions; + } + + function run() external { + console2.log("=== TRANSACTION SIMULATION ==="); + console2.log(""); + + // Parse environment + string memory txnsEnv = vm.envString("TXNS"); + uint256 delay = vm.envOr("DELAY", DEFAULT_DELAY); + address safeAddress = vm.envOr("SAFE_ADDRESS", DEFAULT_SAFE); + // DELAY_AFTER_FILE: only warp time after this file index (default: warp after all except last) + // Use max uint256 as sentinel to mean "warp after all files except last" + uint256 delayAfterFile = vm.envOr("DELAY_AFTER_FILE", type(uint256).max); + + console2.log("Safe address:", safeAddress); + console2.log("Timelock delay:", delay, "seconds"); + console2.log(""); + + // Split transaction files + string[] memory txnFiles = _splitString(txnsEnv, ","); + console2.log("Transaction files:", txnFiles.length); + + // Fund the safe address for transactions + vm.deal(safeAddress, 1000 ether); + + // Execute each file + for (uint256 i = 0; i < txnFiles.length; i++) { + console2.log(""); + console2.log("--- Processing file ---"); + console2.log("File:", txnFiles[i]); + + _executeTransactionsFromFile(txnFiles[i], safeAddress); + + // Warp time only after specific file index (for schedule→execute delay) + // If delayAfterFile is max uint256, warp after all files except last (legacy behavior) + bool shouldWarp; + if (delayAfterFile == type(uint256).max) { + shouldWarp = i < txnFiles.length - 1; + } else { + shouldWarp = i == delayAfterFile; + } + + if (shouldWarp && delay > 0) { + console2.log(""); + console2.log("Warping time by", delay, "seconds..."); + vm.warp(block.timestamp + delay); + console2.log("New timestamp:", block.timestamp); + } + } + + console2.log(""); + console2.log("=== SIMULATION COMPLETE ==="); + } + + function _executeTransactionsFromFile(string memory filename, address safeAddress) internal { + string memory root = vm.projectRoot(); + string memory path = _resolvePath(root, filename); + + console2.log("Reading:", path); + + string memory jsonData = vm.readFile(path); + + // Parse transactions array + bytes memory txnsRaw = jsonData.parseRaw(".transactions"); + Transaction[] memory txns = abi.decode(txnsRaw, (Transaction[])); + + console2.log("Found", txns.length, "transaction(s)"); + + for (uint256 i = 0; i < txns.length; i++) { + console2.log(""); + console2.log("Executing transaction", i + 1); + console2.log(" To:", txns[i].to); + + // Parse value from string + uint256 value = _parseUint(txns[i].value); + console2.log(" Value:", value); + + // Execute transaction + vm.prank(safeAddress); + (bool success, bytes memory returnData) = txns[i].to.call{value: value}(txns[i].data); + + if (success) { + console2.log(" Status: SUCCESS"); + } else { + console2.log(" Status: FAILED"); + console2.log(" Error:"); + console2.logBytes(returnData); + } + } + } + + function _resolvePath(string memory root, string memory filename) internal pure returns (string memory) { + // If filename starts with /, it's absolute + bytes memory filenameBytes = bytes(filename); + if (filenameBytes.length > 0 && filenameBytes[0] == '/') { + return filename; + } + + // Check if it already has script/operations in the path + if (_contains(filename, "script/operations")) { + return string.concat(root, "/", filename); + } + + // Default to auto-compound directory + return string.concat(root, "/script/operations/auto-compound/", filename); + } + + function _contains(string memory str, string memory substr) internal pure returns (bool) { + bytes memory strBytes = bytes(str); + bytes memory substrBytes = bytes(substr); + + if (substrBytes.length > strBytes.length) return false; + + for (uint256 i = 0; i <= strBytes.length - substrBytes.length; i++) { + bool found = true; + for (uint256 j = 0; j < substrBytes.length; j++) { + if (strBytes[i + j] != substrBytes[j]) { + found = false; + break; + } + } + if (found) return true; + } + return false; + } + + function _splitString(string memory str, string memory delimiter) internal pure returns (string[] memory) { + bytes memory strBytes = bytes(str); + bytes memory delimBytes = bytes(delimiter); + + // Count delimiters + uint256 count = 1; + for (uint256 i = 0; i < strBytes.length; i++) { + if (strBytes[i] == delimBytes[0]) { + count++; + } + } + + string[] memory parts = new string[](count); + uint256 partIndex = 0; + uint256 start = 0; + + for (uint256 i = 0; i < strBytes.length; i++) { + if (strBytes[i] == delimBytes[0]) { + parts[partIndex] = _substring(str, start, i); + partIndex++; + start = i + 1; + } + } + + // Last part + parts[partIndex] = _substring(str, start, strBytes.length); + + return parts; + } + + function _substring(string memory str, uint256 start, uint256 end) internal pure returns (string memory) { + bytes memory strBytes = bytes(str); + bytes memory result = new bytes(end - start); + for (uint256 i = start; i < end; i++) { + result[i - start] = strBytes[i]; + } + return string(result); + } + + function _parseUint(string memory str) internal pure returns (uint256) { + bytes memory b = bytes(str); + uint256 result = 0; + + for (uint256 i = 0; i < b.length; i++) { + uint8 c = uint8(b[i]); + if (c >= 48 && c <= 57) { + result = result * 10 + (c - 48); + } + } + + return result; + } +} + diff --git a/script/operations/utils/export_db_data.py b/script/operations/utils/export_db_data.py new file mode 100644 index 000000000..7edff47dc --- /dev/null +++ b/script/operations/utils/export_db_data.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +export_db_data.py - Export operator and node data from database to JSON + +This script exports data from the EtherFi validator database +to JSON files that can be consumed by Solidity scripts. + +Usage: + python export_db_data.py + python export_db_data.py --operators-only + python export_db_data.py --nodes-only + python export_db_data.py --output-dir ./data + +Environment Variables: + VALIDATOR_DB: PostgreSQL connection string for validator database + +Output: + - operators.json: Operator name to address mapping + - etherfi-nodes.json: EtherFi node contract addresses +""" + +import argparse +import json +import os +import sys +from pathlib import Path + +# Load .env file if python-dotenv is available +try: + from dotenv import load_dotenv + # Try loading from current directory, then from script's parent directories + env_path = Path('.env') + if not env_path.exists(): + script_dir = Path(__file__).resolve().parent + for _ in range(5): + script_dir = script_dir.parent + candidate = script_dir / '.env' + if candidate.exists(): + env_path = candidate + break + load_dotenv(dotenv_path=env_path) +except ImportError: + pass # dotenv is optional + +try: + import psycopg2 +except ImportError: + print("Error: psycopg2 not installed. Run: pip install psycopg2-binary") + sys.exit(1) + + +def get_db_connection(): + """Get database connection from environment variable.""" + db_url = os.environ.get('VALIDATOR_DB') + if not db_url: + raise ValueError("VALIDATOR_DB environment variable not set") + return psycopg2.connect(db_url) + + +def export_operators(conn, output_dir: Path): + """Export operator data to JSON.""" + operators = {} + + with conn.cursor() as cur: + cur.execute('SELECT "operatorAdress", "operatorName" FROM "OperatorMetadata"') + for addr, name in cur.fetchall(): + operators[name] = addr.lower() + + output_file = output_dir / 'operators.json' + with open(output_file, 'w') as f: + json.dump(operators, f, indent=2) + + print(f"Exported {len(operators)} operators to {output_file}") + + +def export_etherfi_nodes(conn, output_dir: Path): + """Export EtherFi node addresses to JSON.""" + nodes = [] + + with conn.cursor() as cur: + cur.execute(''' + SELECT DISTINCT etherfi_node_contract + FROM "MainnetValidators" + WHERE etherfi_node_contract IS NOT NULL + ORDER BY etherfi_node_contract + ''') + for (node,) in cur.fetchall(): + if node: + nodes.append(node.lower()) + + output_file = output_dir / 'etherfi-nodes.json' + with open(output_file, 'w') as f: + json.dump(nodes, f, indent=2) + + print(f"Exported {len(nodes)} EtherFi nodes to {output_file}") + + +def main(): + parser = argparse.ArgumentParser( + description='Export operator and EtherFi node data from database to JSON files.' + ) + parser.add_argument( + '--operators-only', + action='store_true', + help='Export only operator data' + ) + parser.add_argument( + '--nodes-only', + action='store_true', + help='Export only EtherFi node data' + ) + parser.add_argument( + '--output-dir', + type=Path, + default=Path(__file__).parent.parent / 'data', + help='Output directory for JSON files (default: script/operations/data)' + ) + + args = parser.parse_args() + + # Create output directory if it doesn't exist + args.output_dir.mkdir(parents=True, exist_ok=True) + + try: + conn = get_db_connection() + except ValueError as e: + print(f"Error: {e}") + print("Set VALIDATOR_DB environment variable to your PostgreSQL connection string") + sys.exit(1) + except Exception as e: + print(f"Database connection error: {e}") + sys.exit(1) + + try: + if args.operators_only: + export_operators(conn, args.output_dir) + elif args.nodes_only: + export_etherfi_nodes(conn, args.output_dir) + else: + export_operators(conn, args.output_dir) + export_etherfi_nodes(conn, args.output_dir) + + print("\nDone! JSON files are ready for Solidity scripts.") + finally: + conn.close() + + +if __name__ == '__main__': + main() + diff --git a/script/operations/utils/simulate.py b/script/operations/utils/simulate.py new file mode 100644 index 000000000..be8de2db8 --- /dev/null +++ b/script/operations/utils/simulate.py @@ -0,0 +1,977 @@ +#!/usr/bin/env python3 +""" +simulate.py - Transaction simulation tool with Tenderly Virtual Testnet support + +This script simulates timelock-gated transactions by: +1. Running schedule transactions +2. Warping time to simulate timelock delay +3. Running execute transactions + +Supports two modes: +- Forge simulation (local fork with vm.warp) +- Tenderly Virtual Testnet (persistent simulation environment) + +Usage: + # Simple simulation (no timelock) + python simulate.py --txns consolidation.json + + # Schedule + Execute with timelock (8 hour delay) + python simulate.py --schedule link-schedule.json --execute link-execute.json --delay 8h + + # Full auto-compound workflow with multiple consolidation transactions + python simulate.py \\ + --schedule link-schedule.json \\ + --execute link-execute.json \\ + --then consolidation.json \\ + --delay 8h + + # Tenderly simulation (creates new VNet) + python simulate.py --tenderly \\ + --schedule link-schedule.json \\ + --execute link-execute.json \\ + --vnet-name "AutoCompound-Test" + + # Tenderly simulation (use existing VNet) + python simulate.py --tenderly \\ + --vnet-id "7113fe5d-bc69-475c-bfd5-a2a720c14d56" \\ + --schedule schedule.json \\ + --execute execute.json + + # List existing Tenderly VNets + python simulate.py --tenderly --list-vnets + +Environment Variables: + MAINNET_RPC_URL: RPC URL for mainnet fork + TENDERLY_API_ACCESS_TOKEN: Tenderly API access token + TENDERLY_API_URL: Tenderly API URL (contains account/project slugs) + SAFE_ADDRESS: Gnosis Safe address (default: EtherFi Operating Admin) +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# Load .env file if python-dotenv is available +try: + from dotenv import load_dotenv + # Try loading from current directory, then from script's parent directories + env_path = Path('.env') + if not env_path.exists(): + script_dir = Path(__file__).resolve().parent + for _ in range(5): + script_dir = script_dir.parent + candidate = script_dir / '.env' + if candidate.exists(): + env_path = candidate + break + load_dotenv(dotenv_path=env_path) +except ImportError: + pass # dotenv is optional + +try: + import requests +except ImportError: + requests = None + +# Default addresses +DEFAULT_SAFE_ADDRESS = "0x2aCA71020De61bb532008049e1Bd41E451aE8AdC" # EtherFi Operating Admin + + +def get_project_root() -> Path: + """Find the project root (where foundry.toml is).""" + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / 'foundry.toml').exists(): + return current + current = current.parent + return Path.cwd() + + +def parse_delay(delay_str: str) -> int: + """Parse delay string like '8h', '72h', '1d', '28800' into seconds.""" + delay_str = delay_str.strip().lower() + + if delay_str.endswith('h'): + return int(delay_str[:-1]) * 3600 + elif delay_str.endswith('d'): + return int(delay_str[:-1]) * 86400 + elif delay_str.endswith('m'): + return int(delay_str[:-1]) * 60 + elif delay_str.endswith('s'): + return int(delay_str[:-1]) + else: + return int(delay_str) + + +def resolve_file_path(project_root: Path, file_name: str) -> Path: + """Resolve transaction file path.""" + # Check if it's an absolute path + if file_name.startswith('/'): + return Path(file_name) + + # Check if it exists relative to cwd + cwd_path = Path.cwd() / file_name + if cwd_path.exists(): + return cwd_path + + # Check if it exists relative to project root + root_path = project_root / file_name + if root_path.exists(): + return root_path + + # Default: look in auto-compound directory + auto_compound_path = project_root / 'script' / 'operations' / 'auto-compound' / file_name + if auto_compound_path.exists(): + return auto_compound_path + + # Return the original path (will fail with helpful error) + return Path(file_name) + + +def load_transactions_from_file(file_path: Path) -> Tuple[List[Dict], str]: + """Load transactions from a Gnosis Safe JSON file. + + Supports both formats: + 1. Single transaction batch: {"transactions": [...], "safeAddress": "..."} + 2. Multiple transaction batches: [{"transactions": [...], "safeAddress": "..."}, ...] + + Used for auto-compound consolidation files that may contain multiple transactions + grouped by EigenPod (withdrawal credentials). + """ + with open(file_path, 'r') as f: + data = json.load(f) + + # Handle new format: array of transaction batches + if isinstance(data, list): + if len(data) == 0: + return [], DEFAULT_SAFE_ADDRESS + + # Use the first batch's safe/from address and collect all transactions + safe_address = data[0].get('safeAddress', data[0].get('from', DEFAULT_SAFE_ADDRESS)) + all_transactions = [] + for batch in data: + batch_transactions = batch.get('transactions', []) + all_transactions.extend(batch_transactions) + return all_transactions, safe_address + + # Handle old format: single transaction batch, or raw EOA format with "from" + else: + transactions = data.get('transactions', []) + safe_address = data.get('safeAddress', data.get('from', DEFAULT_SAFE_ADDRESS)) + return transactions, safe_address + + +# ============================================================================== +# Tenderly API Functions +# ============================================================================== + +def get_tenderly_credentials() -> Tuple[str, str, str]: + """Get Tenderly credentials from environment variables. + + Supports two formats: + 1. Separate variables: TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG + 2. Combined URL: TENDERLY_API_URL (e.g., https://api.tenderly.co/api/v1/account/{slug}/project/{slug}/) + """ + access_token = os.environ.get('TENDERLY_API_ACCESS_TOKEN') + account_slug = os.environ.get('TENDERLY_ACCOUNT_SLUG') + project_slug = os.environ.get('TENDERLY_PROJECT_SLUG') + + # Try to extract from TENDERLY_API_URL if slugs not provided + if not account_slug or not project_slug: + api_url = os.environ.get('TENDERLY_API_URL', '') + # Parse URL like: https://api.tenderly.co/api/v1/account/{account}/project/{project}/ + match = re.search(r'/account/([^/]+)/project/([^/]+)', api_url) + if match: + if not account_slug: + account_slug = match.group(1) + if not project_slug: + project_slug = match.group(2) + + if not access_token: + raise ValueError("TENDERLY_API_ACCESS_TOKEN not set") + if not account_slug or not project_slug: + raise ValueError("Could not determine Tenderly account/project slugs. Set TENDERLY_ACCOUNT_SLUG and TENDERLY_PROJECT_SLUG, or TENDERLY_API_URL") + + return access_token, account_slug, project_slug + + +def list_virtual_testnets(verbose: bool = True) -> List[Dict]: + """List all Virtual Testnets in the project.""" + if not requests: + raise ImportError("requests library required for Tenderly. Run: pip install requests") + + access_token, account_slug, project_slug = get_tenderly_credentials() + + url = f"https://api.tenderly.co/api/v1/account/{account_slug}/project/{project_slug}/vnets" + headers = { + "X-Access-Key": access_token, + "Content-Type": "application/json" + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + + # Handle both response formats: list directly or dict with 'vnets' key + if isinstance(data, list): + vnets = data + elif isinstance(data, dict): + vnets = data.get('vnets', []) + else: + vnets = [] + + if verbose: + print(f"\n{'='*60}") + print("Tenderly Virtual Testnets") + print(f"{'='*60}") + + if not vnets: + print("No virtual testnets found.") + else: + for vnet in vnets: + status = vnet.get('status', 'unknown') + status_emoji = "🟢" if status == 'running' else "🔴" + print(f"\n{status_emoji} {vnet.get('display_name', 'Unnamed')}") + print(f" ID: {vnet.get('id')}") + print(f" Slug: {vnet.get('slug')}") + print(f" Status: {status}") + + fork_config = vnet.get('fork_config', {}) + print(f" Network: {fork_config.get('network_id', 'N/A')}") + print(f" Fork Block: {fork_config.get('block_number', 'N/A')}") + + # Print Admin RPC URL + rpcs = vnet.get('rpcs', []) + admin_rpc = next((r['url'] for r in rpcs if r.get('name') == 'Admin RPC'), None) + if admin_rpc: + print(f" Admin RPC: {admin_rpc}") + + print(f"\n{'='*60}\n") + + return vnets + + +def create_virtual_testnet(name: str, chain_id: int = 1, verbose: bool = True) -> Dict: + """Create a new Virtual Testnet.""" + if not requests: + raise ImportError("requests library required for Tenderly. Run: pip install requests") + + access_token, account_slug, project_slug = get_tenderly_credentials() + + url = f"https://api.tenderly.co/api/v1/account/{account_slug}/project/{project_slug}/vnets" + headers = { + "X-Access-Key": access_token, + "Content-Type": "application/json" + } + + # Generate unique slug + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + slug = f"{name.lower().replace(' ', '-')}-{timestamp}" + + # Get latest block from mainnet + block_number = None + rpc_url = os.environ.get('MAINNET_RPC_URL') + if rpc_url: + try: + resp = requests.post(rpc_url, json={ + "jsonrpc": "2.0", + "method": "eth_blockNumber", + "params": [], + "id": 1 + }, timeout=10) + block_number = int(resp.json()['result'], 16) + except Exception as e: + print(f" Warning: Failed to fetch latest block number: {e}") + + payload = { + "slug": slug, + "display_name": name, + "fork_config": { + "network_id": chain_id + }, + "virtual_network_config": { + "chain_config": { + "chain_id": chain_id + } + }, + "sync_state_config": { + "enabled": False + }, + "explorer_page_config": { + "enabled": True, + "verification_visibility": "bytecode" + } + } + + if block_number: + payload["fork_config"]["block_number"] = block_number + + if verbose: + print(f"Creating Virtual Testnet: {name}") + print(f" Slug: {slug}") + if block_number: + print(f" Fork Block: {block_number}") + + response = requests.post(url, headers=headers, json=payload) + response.raise_for_status() + + result = response.json() + + if verbose: + print(f" ID: {result.get('id')}") + rpcs = result.get('rpcs', []) + admin_rpc = next((r['url'] for r in rpcs if r.get('name') == 'Admin RPC'), None) + if admin_rpc: + print(f" Admin RPC: {admin_rpc}") + print(f" ✅ Created successfully!") + + return result + + +def get_vnet_by_id(vnet_id: str) -> Optional[Dict]: + """Get a virtual testnet by ID.""" + if not requests: + raise ImportError("requests library required for Tenderly. Run: pip install requests") + + access_token, account_slug, project_slug = get_tenderly_credentials() + + url = f"https://api.tenderly.co/api/v1/account/{account_slug}/project/{project_slug}/vnets/{vnet_id}" + headers = { + "X-Access-Key": access_token, + "Content-Type": "application/json" + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError: + return None + + +def get_admin_rpc_url(vnet_data: Dict) -> str: + """Extract Admin RPC URL from VNet data.""" + rpcs = vnet_data.get('rpcs', []) + for rpc in rpcs: + if rpc.get('name') == 'Admin RPC': + return rpc.get('url') + raise ValueError("No Admin RPC URL found in VNet data") + + +def rpc_request(rpc_url: str, method: str, params: List = None) -> Any: + """Make a JSON-RPC request to a VNet RPC endpoint.""" + if not requests: + raise ImportError("requests library required for Tenderly") + + payload = { + "jsonrpc": "2.0", + "method": method, + "params": params or [], + "id": 1 + } + + response = requests.post(rpc_url, json=payload, timeout=60) + response.raise_for_status() + + result = response.json() + if 'error' in result: + raise RuntimeError(f"RPC error: {result['error']}") + return result.get('result') + + +def warp_time_on_vnet(rpc_url: str, delay_seconds: int, verbose: bool = True) -> int: + """Warp time on a Tenderly VNet using evm_setNextBlockTimestamp and mine a block.""" + # Get current block timestamp + block = rpc_request(rpc_url, "eth_getBlockByNumber", ["latest", False]) + current_timestamp = int(block.get('timestamp', '0x0'), 16) + + new_timestamp = current_timestamp + delay_seconds + + if verbose: + print(f" Current timestamp: {current_timestamp} ({datetime.fromtimestamp(current_timestamp)})") + print(f" Warping by: {delay_seconds}s ({delay_seconds / 3600:.1f}h)") + print(f" New timestamp: {new_timestamp} ({datetime.fromtimestamp(new_timestamp)})") + + # Set the next block timestamp + rpc_request(rpc_url, "evm_setNextBlockTimestamp", [new_timestamp]) + + # Mine a block to apply the timestamp + rpc_request(rpc_url, "evm_mine", []) + + if verbose: + print(f" ✅ Time warp complete, mined new block") + + return new_timestamp + + +def submit_tx_via_rpc( + rpc_url: str, + from_addr: str, + to_addr: str, + data: str, + value: str = "0x0", + verbose: bool = True +) -> Dict: + """Submit a transaction via Admin RPC using eth_sendTransaction.""" + if not requests: + raise ImportError("requests library required for Tenderly") + + if verbose: + print(f" Submitting transaction...") + print(f" From: {from_addr}") + print(f" To: {to_addr}") + data_preview = f"{data[:66]}..." if len(data) > 66 else data + print(f" Data: {data_preview}") + + # Use eth_sendTransaction + payload = { + "jsonrpc": "2.0", + "method": "eth_sendTransaction", + "params": [{ + "from": from_addr, + "to": to_addr, + "value": value, + "data": data, + "gas": "0x7a1200" # 8M gas + }], + "id": 1 + } + + response = requests.post(rpc_url, json=payload, timeout=60) + response.raise_for_status() + result = response.json() + + if 'error' in result: + if verbose: + print(f" ❌ Error: {result['error']}") + return {"status": "failed", "error": result['error']} + + tx_hash = result.get('result') + if verbose: + print(f" ✅ Tx submitted: {tx_hash}") + + # Wait for transaction to be mined and check receipt + if tx_hash: + receipt = wait_for_tx_receipt(rpc_url, tx_hash, verbose=verbose) + + # Check if receipt is empty (timeout) + if not receipt: + if verbose: + print(f" ❌ Tx failed - Timeout waiting for receipt") + return {"status": "failed", "error": "Timeout waiting for transaction receipt", "tx_hash": tx_hash, "receipt": receipt} + + # Extract gas usage from receipt + gas_used_hex = receipt.get('gasUsed', '0x0') + gas_used = int(gas_used_hex, 16) + + # Check for excessive gas usage + GAS_LIMIT_MAX = 10_000_000 # 10 million gas limit + if gas_used > GAS_LIMIT_MAX: + if verbose: + print(f" ❌ Tx failed - Gas used: {gas_used:,} (exceeds limit of {GAS_LIMIT_MAX:,})") + return {"status": "failed", "error": f"Gas usage {gas_used:,} exceeds limit of {GAS_LIMIT_MAX:,}", "tx_hash": tx_hash, "receipt": receipt, "gas_used": gas_used} + + if receipt.get('status') == '0x1': + if verbose: + print(f" ✅ Tx successful - Gas used: {gas_used:,}") + return {"status": "success", "tx_hash": tx_hash, "receipt": receipt, "gas_used": gas_used} + elif receipt.get('status') == '0x0': + # Transaction reverted + if verbose: + print(f" ❌ Tx reverted - Gas used: {gas_used:,}") + return {"status": "failed", "error": "Transaction reverted", "tx_hash": tx_hash, "receipt": receipt, "gas_used": gas_used} + else: + # Unknown status + if verbose: + print(f" ❌ Tx failed - Unknown status: {receipt.get('status')}") + return {"status": "failed", "error": f"Unknown transaction status: {receipt.get('status')}", "tx_hash": tx_hash, "receipt": receipt, "gas_used": gas_used} + + # If no transaction hash was returned, submission failed + if verbose: + print(f" ❌ Tx submission failed - No transaction hash returned") + return {"status": "failed", "error": "Transaction submission failed - no hash returned", "tx_hash": None} + + +def wait_for_tx_receipt(rpc_url: str, tx_hash: str, timeout: int = 30, verbose: bool = True) -> Dict: + """Wait for transaction receipt and return it.""" + start_time = time.time() + + while time.time() - start_time < timeout: + try: + receipt = rpc_request(rpc_url, "eth_getTransactionReceipt", [tx_hash]) + if receipt: + return receipt + except Exception: + pass + time.sleep(1) + + if verbose: + print(f" ⚠️ Timeout waiting for receipt") + return {} + + +# ============================================================================== +# Simulation Functions +# ============================================================================== + +def run_tenderly_simulation(args) -> int: + """Run simulation using Tenderly Virtual Testnet.""" + project_root = get_project_root() + + print("=" * 60) + print("TENDERLY VIRTUAL TESTNET SIMULATION") + print("=" * 60) + print(f"Timestamp: {datetime.now().isoformat()}") + print("") + + try: + access_token, account_slug, project_slug = get_tenderly_credentials() + print(f"Account: {account_slug}") + print(f"Project: {project_slug}") + except ValueError as e: + print(f"Error: {e}") + return 1 + + # Get or create Virtual Testnet + vnet_id = args.vnet_id + vnet_data = None + + if vnet_id: + print(f"\nUsing existing VNet: {vnet_id}") + vnet_data = get_vnet_by_id(vnet_id) + if not vnet_data: + print(f"Error: VNet not found: {vnet_id}") + return 1 + else: + vnet_name = args.vnet_name or f"Simulation-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + print("") + try: + vnet_data = create_virtual_testnet(vnet_name) + vnet_id = vnet_data.get('id') + except Exception as e: + print(f"Failed to create Virtual Testnet: {e}") + return 1 + + # Get Admin RPC URL + try: + admin_rpc = get_admin_rpc_url(vnet_data) + except ValueError as e: + print(f"Error: {e}") + return 1 + + print(f"\nVNet ID: {vnet_id}") + print(f"Admin RPC: {admin_rpc}") + + # Determine safe address + safe_address = args.safe_address or os.environ.get('SAFE_ADDRESS', DEFAULT_SAFE_ADDRESS) + print(f"Safe Address: {safe_address}") + + all_success = True + total_gas_used = 0 + + # Simple mode (--txns) + if args.txns: + print(f"\n{'='*40}") + print("SIMPLE MODE (No Timelock)") + print(f"{'='*40}") + + # Handle comma-separated list of files + txn_files = [f.strip() for f in args.txns.split(',')] + all_transactions = [] + file_safe = None + + for i, txn_file in enumerate(txn_files): + file_path = resolve_file_path(project_root, txn_file) + print(f"Loading file {i+1}/{len(txn_files)}: {file_path}") + + if not file_path.exists(): + print(f"Error: File not found: {file_path}") + return 1 + + transactions, current_safe = load_transactions_from_file(file_path) + if file_safe is None: + file_safe = current_safe + elif file_safe != current_safe and current_safe is not None: + print(f"Warning: File {file_path} has different safe address ({current_safe}) than previous files ({file_safe})") + + all_transactions.extend(transactions) + + safe = args.safe_address or file_safe + transactions = all_transactions + print(f"Transactions: {len(transactions)}") + + phase_gas_used = 0 + for i, tx in enumerate(transactions): + print(f"\n--- Transaction {i+1}/{len(transactions)} ---") + value = tx.get('value', '0') + if not str(value).startswith('0x'): + value = hex(int(value)) + + result = submit_tx_via_rpc( + admin_rpc, + safe, + tx['to'], + tx['data'], + value + ) + if result.get('status') != 'success': + all_success = False + if result.get('tx_hash'): + print(f" 🔗 Tx Link: https://dashboard.tenderly.co/{account_slug}/{project_slug}/testnet/{vnet_id}/tx/{result['tx_hash']}") + # Accumulate gas usage + if 'gas_used' in result: + phase_gas_used += result['gas_used'] + total_gas_used += result['gas_used'] + + print(f"\n📊 Phase Summary: {len(transactions)} transactions, {phase_gas_used:,} gas used") + + # Timelock mode (--schedule + --execute) + elif args.schedule and args.execute: + # Phase 1: Schedule + print(f"\n{'='*40}") + print("PHASE 1: SCHEDULE") + print(f"{'='*40}") + + schedule_path = resolve_file_path(project_root, args.schedule) + print(f"Loading: {schedule_path}") + + if not schedule_path.exists(): + print(f"Error: File not found: {schedule_path}") + return 1 + + transactions, file_safe = load_transactions_from_file(schedule_path) + safe = args.safe_address or file_safe + print(f"Transactions: {len(transactions)}") + + phase_gas_used = 0 + for i, tx in enumerate(transactions): + print(f"\n--- Schedule Transaction {i+1}/{len(transactions)} ---") + value = tx.get('value', '0') + if not str(value).startswith('0x'): + value = hex(int(value)) + + result = submit_tx_via_rpc( + admin_rpc, + safe, + tx['to'], + tx['data'], + value + ) + if result.get('status') != 'success': + all_success = False + if result.get('tx_hash'): + print(f" 🔗 Tx Link: https://dashboard.tenderly.co/{account_slug}/{project_slug}/testnet/{vnet_id}/tx/{result['tx_hash']}") + # Accumulate gas usage + if 'gas_used' in result: + phase_gas_used += result['gas_used'] + total_gas_used += result['gas_used'] + + print(f"\n📊 Schedule Phase Summary: {len(transactions)} transactions, {phase_gas_used:,} gas used") + + # Time Warp + delay_seconds = parse_delay(args.delay) if args.delay else 28800 + print(f"\n{'='*40}") + print("TIME WARP") + print(f"{'='*40}") + warp_time_on_vnet(admin_rpc, delay_seconds) + + # Phase 2: Execute + print(f"\n{'='*40}") + print("PHASE 2: EXECUTE") + print(f"{'='*40}") + + execute_path = resolve_file_path(project_root, args.execute) + print(f"Loading: {execute_path}") + + if not execute_path.exists(): + print(f"Error: File not found: {execute_path}") + return 1 + + transactions, _ = load_transactions_from_file(execute_path) + print(f"Transactions: {len(transactions)}") + + phase_gas_used = 0 + for i, tx in enumerate(transactions): + print(f"\n--- Execute Transaction {i+1}/{len(transactions)} ---") + value = tx.get('value', '0') + if not str(value).startswith('0x'): + value = hex(int(value)) + + result = submit_tx_via_rpc( + admin_rpc, + safe, + tx['to'], + tx['data'], + value + ) + if result.get('status') != 'success': + all_success = False + if result.get('tx_hash'): + print(f" 🔗 Tx Link: https://dashboard.tenderly.co/{account_slug}/{project_slug}/testnet/{vnet_id}/tx/{result['tx_hash']}") + # Accumulate gas usage + if 'gas_used' in result: + phase_gas_used += result['gas_used'] + total_gas_used += result['gas_used'] + + print(f"\n📊 Execute Phase Summary: {len(transactions)} transactions, {phase_gas_used:,} gas used") + + # Phase 3: Follow-up (optional --then) + if args.then: + print(f"\n{'='*40}") + print("PHASE 3: FOLLOW-UP") + print(f"{'='*40}") + + # Handle comma-separated list of then files + then_files = [f.strip() for f in args.then.split(',')] + all_then_transactions = [] + + for j, then_file in enumerate(then_files): + then_path = resolve_file_path(project_root, then_file) + print(f"Loading follow-up file {j+1}/{len(then_files)}: {then_path}") + + if not then_path.exists(): + print(f"Error: File not found: {then_path}") + return 1 + + then_transactions, _ = load_transactions_from_file(then_path) + all_then_transactions.extend(then_transactions) + + transactions = all_then_transactions + print(f"Total follow-up transactions: {len(transactions)}") + + phase_gas_used = 0 + for i, tx in enumerate(transactions): + print(f"\n--- Follow-up Transaction {i+1}/{len(transactions)} ---") + value = tx.get('value', '0') + if not str(value).startswith('0x'): + value = hex(int(value)) + + result = submit_tx_via_rpc( + admin_rpc, + safe, + tx['to'], + tx['data'], + value + ) + if result.get('status') != 'success': + all_success = False + if result.get('tx_hash'): + print(f" 🔗 Tx Link: https://dashboard.tenderly.co/{account_slug}/{project_slug}/testnet/{vnet_id}/tx/{result['tx_hash']}") + # Accumulate gas usage + if 'gas_used' in result: + phase_gas_used += result['gas_used'] + total_gas_used += result['gas_used'] + + print(f"\n📊 Follow-up Phase Summary: {len(transactions)} transactions, {phase_gas_used:,} gas used") + + # Summary + print(f"\n{'='*60}") + print("SIMULATION COMPLETE") + print(f"{'='*60}") + print(f"VNet ID: {vnet_id}") + print(f"View in Tenderly: https://dashboard.tenderly.co/{account_slug}/{project_slug}/testnet/{vnet_id}") + print(f"Result: {'✅ SUCCESS' if all_success else '❌ FAILED'}") + print(f"Total Gas Used: {total_gas_used:,}") + + return 0 if all_success else 1 + + +def run_forge_simulation(args) -> int: + """Run simulation using Forge script.""" + print("=" * 60) + print("FORGE SIMULATION") + print("=" * 60) + print("") + + rpc_url = args.rpc_url or os.environ.get('MAINNET_RPC_URL') + if not rpc_url: + print("Error: MAINNET_RPC_URL not set") + return 1 + + # Build forge command + cmd = [ + 'forge', 'script', + 'script/operations/utils/SimulateTransactions.s.sol:SimulateTransactions', + '--fork-url', rpc_url, + '-vvvv' + ] + + # Set environment variables for the script + env = os.environ.copy() + + if args.txns: + env['TXNS'] = args.txns + env['DELAY_AFTER_FILE'] = '0' # No delay in simple mode + elif args.schedule and args.execute: + # Compose TXNS from schedule, execute, and optionally then files + txns_list = [args.schedule, args.execute] + if args.then: + # Handle comma-separated then files + then_files = [f.strip() for f in args.then.split(',')] + txns_list.extend(then_files) + env['TXNS'] = ','.join(txns_list) + # Only apply delay after file index 0 (between schedule and execute) + # No delay between execute and then files (index 1→...) + env['DELAY_AFTER_FILE'] = '0' # Only delay after first file + + # Also set individual file vars for reference + if args.schedule: + env['SCHEDULE_FILE'] = args.schedule + if args.execute: + env['EXECUTE_FILE'] = args.execute + if args.then: + env['THEN_FILE'] = args.then + + delay_seconds = parse_delay(args.delay) if args.delay else 28800 + env['DELAY'] = str(delay_seconds) + + if args.safe_address: + env['SAFE_ADDRESS'] = args.safe_address + + # Run forge + print(f"Running: {' '.join(cmd)}") + print("") + + result = subprocess.run(cmd, env=env) + + return result.returncode + + +def main(): + parser = argparse.ArgumentParser( + description='Simulate timelock-gated transactions', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Simple simulation (Forge) + python simulate.py --txns consolidation.json + + # Timelock workflow (Forge) + python simulate.py --schedule schedule.json --execute execute.json --delay 8h + + # Full auto-compound workflow (Forge) + python simulate.py \\ + --schedule auto-compound-txns-link-schedule.json \\ + --execute auto-compound-txns-link-execute.json \\ + --then auto-compound-txns-consolidation.json + + # List Tenderly VNets + python simulate.py --tenderly --list-vnets + + # Tenderly simulation (creates new VNet) + python simulate.py --tenderly \\ + --schedule auto-compound-txns-link-schedule.json \\ + --execute auto-compound-txns-link-execute.json \\ + --vnet-name "AutoCompound-Test" + + # Tenderly simulation (use existing VNet) + python simulate.py --tenderly \\ + --vnet-id "7113fe5d-bc69-475c-bfd5-a2a720c14d56" \\ + --schedule schedule.json \\ + --execute execute.json + """ + ) + + # Mode selection + parser.add_argument( + '--tenderly', + action='store_true', + help='Use Tenderly Virtual Testnet instead of Forge fork' + ) + parser.add_argument( + '--list-vnets', + action='store_true', + help='List existing Tenderly Virtual Testnets and exit' + ) + + # Tenderly-specific options + parser.add_argument( + '--vnet-id', + help='Use existing Tenderly VNet by ID' + ) + parser.add_argument( + '--vnet-name', + help='Display name for new Tenderly VNet' + ) + + # Transaction files + parser.add_argument( + '--txns', '-t', + help='Simple transaction file(s) (no timelock). Can be comma-separated for multiple files' + ) + parser.add_argument( + '--schedule', '-s', + help='Schedule transaction file (phase 1)' + ) + parser.add_argument( + '--execute', '-e', + help='Execute transaction file (phase 2, after timelock)' + ) + parser.add_argument( + '--then', + help='Follow-up transaction file(s) (phase 3, optional). Can be comma-separated for multiple files' + ) + + # Options + parser.add_argument( + '--delay', '-d', + default='8h', + help='Timelock delay (e.g., 8h, 72h, 1d, 28800). Default: 8h' + ) + parser.add_argument( + '--rpc-url', '-r', + help='RPC URL for fork. Default: $MAINNET_RPC_URL' + ) + parser.add_argument( + '--safe-address', + help='Gnosis Safe address. Default: EtherFi Operating Admin' + ) + + args = parser.parse_args() + + # Handle --list-vnets + if args.list_vnets: + if not args.tenderly: + print("Note: --list-vnets implies --tenderly") + + try: + list_virtual_testnets(verbose=True) + return 0 + except Exception as e: + print(f"Error listing Virtual Testnets: {e}") + return 1 + + # Validate arguments + if args.txns and (args.schedule or args.execute): + parser.error("Cannot use --txns with --schedule/--execute") + + if not args.txns and not (args.schedule and args.execute): + parser.error("Must provide either --txns or both --schedule and --execute") + + if (args.schedule and not args.execute) or (args.execute and not args.schedule): + parser.error("--schedule and --execute must be used together") + + if args.txns and args.then: + parser.error("--then cannot be used with --txns. Use --schedule/--execute for multi-phase workflows") + + # Run simulation + try: + if args.tenderly: + return run_tenderly_simulation(args) + else: + return run_forge_simulation(args) + except Exception as e: + print(f"Error: {e}") + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/script/operations/utils/simulate_batch_approve.py b/script/operations/utils/simulate_batch_approve.py new file mode 100644 index 000000000..373396fae --- /dev/null +++ b/script/operations/utils/simulate_batch_approve.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +""" +Simulate batchApproveRegistration on Tenderly Virtual Testnet. + +Usage: + python3 script/operations/utils/simulate_batch_approve.py \ + --json validators.json \ + --vnet-name "BatchApprove-Test" + + # Or use existing VNet + python3 script/operations/utils/simulate_batch_approve.py \ + --json validators.json \ + --vnet-id "existing-vnet-id" + +JSON Format: + [ + {"validator_id": 31225, "pubkey": "0xb4d601...", "eigenpod": "0x9ad4d1..."}, + {"validator_id": 31226, "pubkey": "0xa5eefc...", "eigenpod": "0x9ad4d1..."} + ] +""" + +import argparse +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +try: + from eth_abi import encode + HAS_ETH_ABI = True +except ImportError: + HAS_ETH_ABI = False + +try: + import requests +except ImportError: + print("Error: requests library required. Install with: pip install requests") + sys.exit(1) + +def load_env_file(): + """Load .env file from project root (where foundry.toml exists).""" + # Start from script directory and walk up to find foundry.toml (project root) + current = Path(__file__).resolve().parent + for _ in range(10): + foundry_file = current / 'foundry.toml' + if foundry_file.exists(): + env_file = current / '.env' + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ[key] = value + return + if current.parent == current: + break + current = current.parent + +load_env_file() + +# Mainnet addresses +LIQUIDITY_POOL = "0x308861A430be4cce5502d0A12724771Fc6DaF216" +NODES_MANAGER = "0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F" +ETHERFI_ADMIN = "0x0EF8fa4760Db8f5Cd4d993f3e3416f30f942D705" + +TENDERLY_API_BASE = "https://api.tenderly.co/api/v1" + + +def get_tenderly_credentials() -> Tuple[str, str, str]: + """Get Tenderly credentials from environment.""" + import re + + access_token = os.environ.get('TENDERLY_API_ACCESS_TOKEN') + account_slug = os.environ.get('TENDERLY_ACCOUNT_SLUG') + project_slug = os.environ.get('TENDERLY_PROJECT_SLUG') + + # Try to extract from TENDERLY_API_URL + if not account_slug or not project_slug: + api_url = os.environ.get('TENDERLY_API_URL', '') + match = re.search(r'/account/([^/]+)/project/([^/]+)', api_url) + if match: + if not account_slug: + account_slug = match.group(1) + if not project_slug: + project_slug = match.group(2) + + if not access_token or not account_slug or not project_slug: + print("Error: Missing Tenderly credentials") + print(" Set: TENDERLY_API_ACCESS_TOKEN, TENDERLY_ACCOUNT_SLUG, TENDERLY_PROJECT_SLUG") + print(" Or: TENDERLY_API_ACCESS_TOKEN, TENDERLY_API_URL") + sys.exit(1) + + return access_token, account_slug, project_slug + + +def tenderly_request(method: str, endpoint: str, data: Optional[Dict] = None) -> Dict: + """Make a request to Tenderly API.""" + access_token, _, _ = get_tenderly_credentials() + + url = f"{TENDERLY_API_BASE}{endpoint}" + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Access-Key': access_token + } + + response = requests.request(method, url, headers=headers, json=data, timeout=60) + + if not response.ok: + print(f"Tenderly API error ({response.status_code}): {response.text}") + sys.exit(1) + + return response.json() + + +def create_virtual_testnet(display_name: str) -> Dict: + """Create a new Tenderly Virtual Testnet.""" + _, account_slug, project_slug = get_tenderly_credentials() + + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + slug = f"{display_name.lower().replace(' ', '-')}-{timestamp}" + + payload = { + "slug": slug, + "display_name": display_name, + "fork_config": {"network_id": 1}, + "virtual_network_config": {"chain_config": {"chain_id": 1}}, + "sync_state_config": {"enabled": True, "commitment_level": "latest"}, + "explorer_page_config": {"enabled": True, "verification_visibility": "src"} + } + + print(f"Creating Virtual Testnet: {display_name}") + endpoint = f"/account/{account_slug}/project/{project_slug}/vnets" + result = tenderly_request('POST', endpoint, data=payload) + + print(f" ID: {result.get('id')}") + rpcs = result.get('rpcs', []) + admin_rpc = next((r['url'] for r in rpcs if r.get('name') == 'Admin RPC'), None) + if admin_rpc: + print(f" Admin RPC: {admin_rpc}") + + return result + + +def get_vnet_by_id(vnet_id: str) -> Optional[Dict]: + """Get a virtual testnet by ID.""" + _, account_slug, project_slug = get_tenderly_credentials() + endpoint = f"/account/{account_slug}/project/{project_slug}/vnets" + vnets = tenderly_request('GET', endpoint) + + if isinstance(vnets, list): + for vnet in vnets: + if vnet.get('id') == vnet_id: + return vnet + return None + + +def get_admin_rpc_url(vnet_data: Dict) -> str: + """Extract Admin RPC URL from VNet data.""" + rpcs = vnet_data.get('rpcs', []) + for rpc in rpcs: + if rpc.get('name') == 'Admin RPC': + return rpc.get('url') + raise ValueError("No Admin RPC URL found") + + +def rpc_call(rpc_url: str, method: str, params: List = None) -> any: + """Make a JSON-RPC call.""" + payload = {"jsonrpc": "2.0", "method": method, "params": params or [], "id": 1} + response = requests.post(rpc_url, json=payload, timeout=60) + result = response.json() + if 'error' in result: + raise RuntimeError(f"RPC error: {result['error']}") + return result.get('result') + + +def encode_batch_approve_calldata(validator_ids: List[int], pubkeys: List[bytes]) -> str: + """Encode batchApproveRegistration calldata.""" + # Function selector: batchApproveRegistration(uint256[],bytes[],bytes[]) + # keccak256("batchApproveRegistration(uint256[],bytes[],bytes[])")[:4] + selector = "0x84b0196e" # We'll compute it properly + + # Actually compute the selector + import hashlib + sig = "batchApproveRegistration(uint256[],bytes[],bytes[])" + selector = "0x" + hashlib.sha3_256(sig.encode()).hexdigest()[:8] + # Use keccak256 instead + from eth_hash.auto import keccak + selector = "0x" + keccak(sig.encode()).hex()[:8] + + # Create dummy signatures (96 bytes each) + signatures = [b'\x00' * 96 for _ in validator_ids] + + if HAS_ETH_ABI: + # Use eth_abi for proper encoding + encoded = encode( + ['uint256[]', 'bytes[]', 'bytes[]'], + [validator_ids, pubkeys, signatures] + ) + return selector + encoded.hex() + else: + # Manual encoding fallback (simplified) + print("Warning: eth_abi not installed, using simplified encoding") + # This is a simplified version - for production use eth_abi + return selector + "..." + + +def load_validators(json_path: str) -> Tuple[List[int], List[bytes]]: + """Load validators from JSON file.""" + with open(json_path, 'r') as f: + data = json.load(f) + + validator_ids = [] + pubkeys = [] + + for item in data: + validator_ids.append(item['validator_id']) + pubkey = item['pubkey'] + if pubkey.startswith('0x'): + pubkey = pubkey[2:] + pubkeys.append(bytes.fromhex(pubkey)) + + return validator_ids, pubkeys + + +def analyze_validators(rpc_url: str, validator_ids: List[int]) -> Dict[str, List[int]]: + """Analyze which validators belong to which nodes.""" + nodes = {} + + for vid in validator_ids: + # Call etherfiNodeAddress(uint256) + from eth_hash.auto import keccak + selector = keccak(b"etherfiNodeAddress(uint256)").hex()[:8] + data = "0x" + selector + hex(vid)[2:].zfill(64) + + result = rpc_call(rpc_url, "eth_call", [{ + "to": NODES_MANAGER, + "data": data + }, "latest"]) + + # Extract address from result (last 20 bytes of 32-byte response) + node_addr = "0x" + result[-40:] + + if node_addr not in nodes: + nodes[node_addr] = [] + nodes[node_addr].append(vid) + + return nodes + + +def submit_transaction(rpc_url: str, from_addr: str, to_addr: str, data: str, value: str = "0x0") -> Dict: + """Submit a transaction via eth_sendTransaction.""" + print(f" Submitting transaction...") + print(f" From: {from_addr}") + print(f" To: {to_addr}") + print(f" Data: {data[:66]}..." if len(data) > 66 else f" Data: {data}") + + result = rpc_call(rpc_url, "eth_sendTransaction", [{ + "from": from_addr, + "to": to_addr, + "value": value, + "data": data, + "gas": "0x7a1200" # 8M gas + }]) + + print(f" Tx Hash: {result}") + + # Get transaction receipt + receipt = rpc_call(rpc_url, "eth_getTransactionReceipt", [result]) + + status = int(receipt.get('status', '0x0'), 16) + if status == 1: + print(f" ✅ Status: SUCCESS") + else: + print(f" ❌ Status: REVERTED") + + return {"tx_hash": result, "status": "success" if status == 1 else "failed", "receipt": receipt} + + +def main(): + parser = argparse.ArgumentParser( + description='Simulate batchApproveRegistration on Tenderly Virtual Testnet', + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument('--json', '-j', required=True, help='JSON file with validator data') + parser.add_argument('--vnet-id', help='Use existing VNet by ID') + parser.add_argument('--vnet-name', default='BatchApprove-Sim', help='Name for new VNet') + parser.add_argument('--analyze-only', action='store_true', help='Only analyze node distribution') + + args = parser.parse_args() + + # Resolve JSON path + json_path = args.json + if not os.path.isabs(json_path): + # Try relative to current dir, then project root + if not os.path.exists(json_path): + project_root = Path(__file__).resolve().parent.parent.parent.parent + json_path = project_root / json_path + + if not os.path.exists(json_path): + print(f"Error: JSON file not found: {args.json}") + sys.exit(1) + + print("=" * 60) + print("BATCH APPROVE SIMULATION (Tenderly)") + print("=" * 60) + print(f"JSON file: {json_path}") + print("") + + # Load validators + validator_ids, pubkeys = load_validators(json_path) + print(f"Validators loaded: {len(validator_ids)}") + print(f" First: {validator_ids[0]}") + print(f" Last: {validator_ids[-1]}") + print("") + + # Get or create VNet + if args.vnet_id: + print(f"Using existing VNet: {args.vnet_id}") + vnet_data = get_vnet_by_id(args.vnet_id) + if not vnet_data: + print(f"Error: VNet not found: {args.vnet_id}") + sys.exit(1) + else: + vnet_data = create_virtual_testnet(args.vnet_name) + + admin_rpc = get_admin_rpc_url(vnet_data) + print(f"Admin RPC: {admin_rpc}") + print("") + + # Analyze node distribution + print("=" * 40) + print("NODE ANALYSIS") + print("=" * 40) + + nodes = analyze_validators(admin_rpc, validator_ids) + + print(f"Unique nodes: {len(nodes)}") + for node_addr, vids in nodes.items(): + print(f" {node_addr}: {len(vids)} validators") + if len(vids) <= 5: + print(f" IDs: {vids}") + else: + print(f" IDs: {vids[:3]} ... {vids[-2:]}") + + if len(nodes) > 1: + print("") + print("⚠️ WARNING: Validators span multiple nodes!") + print(" batchApproveRegistration will FAIL with InvalidEtherFiNode()") + print(" Split into separate batches by node.") + + if args.analyze_only: + print("") + print("Analysis complete (--analyze-only)") + return 0 + + print("") + print("=" * 40) + print("SIMULATING batchApproveRegistration") + print("=" * 40) + print(f"Caller: EtherFiAdmin ({ETHERFI_ADMIN})") + print(f"Target: LiquidityPool ({LIQUIDITY_POOL})") + print(f"Validators: {len(validator_ids)}") + print("") + + # Encode calldata + try: + calldata = encode_batch_approve_calldata(validator_ids, pubkeys) + except Exception as e: + print(f"Error encoding calldata: {e}") + print("Install eth_abi: pip install eth_abi") + sys.exit(1) + + # Submit transaction + result = submit_transaction( + admin_rpc, + ETHERFI_ADMIN, + LIQUIDITY_POOL, + calldata + ) + + print("") + print("=" * 60) + print("SIMULATION COMPLETE") + print("=" * 60) + print(f"VNet ID: {vnet_data.get('id')}") + print(f"Result: {result['status'].upper()}") + + if result['status'] == 'failed' and len(nodes) > 1: + print("") + print("Expected failure: validators from different nodes.") + print("Solution: Split into separate calls per node.") + + return 0 if result['status'] == 'success' else 1 + + +if __name__ == '__main__': + sys.exit(main()) + diff --git a/script/operations/utils/validator_utils.py b/script/operations/utils/validator_utils.py new file mode 100644 index 000000000..1b0d6b38f --- /dev/null +++ b/script/operations/utils/validator_utils.py @@ -0,0 +1,901 @@ +#!/usr/bin/env python3 +""" +validator_utils.py - Reusable utilities for validator operations + +This module provides common utilities for: +- Database connections and queries +- Beacon chain API interactions +- Sweep time calculations +- Operator and validator lookups + +These utilities can be used by various scripts that need to interact +with the validator database and beacon chain. +""" + +import math +import os +import sys +import time +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple + +# Load .env file if python-dotenv is available +try: + from pathlib import Path + from dotenv import load_dotenv + # Try loading from current directory, then from script's parent directories + env_path = Path('.env') + if not env_path.exists(): + # Try to find .env in parent directories (up to 5 levels) + script_dir = Path(__file__).resolve().parent + for _ in range(5): + script_dir = script_dir.parent + candidate = script_dir / '.env' + if candidate.exists(): + env_path = candidate + break + load_dotenv(dotenv_path=env_path) +except ImportError: + pass # dotenv is optional + +try: + import psycopg2 + from psycopg2.extras import RealDictCursor +except ImportError: + print("Error: psycopg2 not installed. Run: pip install psycopg2-binary") + sys.exit(1) + +try: + import requests +except ImportError: + requests = None + + +# ============================================================================= +# Constants +# ============================================================================= + +# Beacon Chain Constants +VALIDATORS_PER_SLOT = 16 # Validators processed per slot in withdrawal sweep +SLOTS_PER_EPOCH = 32 # Slots per epoch +SECONDS_PER_SLOT = 12 # Seconds per slot +VALIDATORS_PER_SECOND = VALIDATORS_PER_SLOT / SECONDS_PER_SLOT + + +# ============================================================================= +# Database Utilities +# ============================================================================= + +def get_db_connection(): + """Get database connection from environment variable.""" + db_url = os.environ.get('VALIDATOR_DB') + if not db_url: + raise ValueError("VALIDATOR_DB environment variable not set") + return psycopg2.connect(db_url) + + +def load_operators_from_db(conn) -> Tuple[Dict[str, str], Dict[str, str]]: + """Load operators from address_remapping table.""" + address_to_name = {} + name_to_address = {} + + with conn.cursor() as cur: + cur.execute('SELECT payee_address, name FROM address_remapping') + for addr, name in cur.fetchall(): + addr_lower = addr.lower() + name_lower = name.lower() + address_to_name[addr_lower] = name + name_to_address[name_lower] = addr_lower + + return address_to_name, name_to_address + + +def get_operator_address(conn, operator: str) -> Optional[str]: + """Resolve operator name or address to address.""" + _, name_to_address = load_operators_from_db(conn) + + # If it looks like an address, normalize and return + if operator.startswith('0x') and len(operator) == 42: + return operator.lower() + + # Otherwise, look up by name + return name_to_address.get(operator.lower()) + + +def list_operators(conn) -> List[Dict]: + """List all operators with validator counts from etherfi_validators table.""" + address_to_name, _ = load_operators_from_db(conn) + + operators = [] + with conn.cursor() as cur: + # Query using the correct column name: operator + cur.execute(''' + SELECT + operator, + COUNT(*) AS total_validators + FROM "etherfi_validators" + WHERE timestamp = (SELECT MAX(timestamp) FROM "etherfi_validators") + AND operator IS NOT NULL + AND status = 'active_ongoing' + GROUP BY operator + ORDER BY total_validators DESC + ''') + + for row in cur.fetchall(): + addr = row[0] if row[0] else None + operators.append({ + 'address': addr, + 'name': address_to_name.get(addr, 'Unknown'), + 'total': row[1], + }) + + return operators + + +def query_validators( + conn, + operator: str, + count: int, + phase_filter: Optional[str] = None +) -> List[Dict]: + """ + Query validators from etherfi_validators table by node operator. + + Args: + conn: PostgreSQL connection + operator: Node operator address (normalized lowercase) + count: Maximum number of validators to return + phase_filter: Optional phase filter (e.g., 'LIVE', 'EXITED') + + Returns: + List of validator dictionaries + """ + query = """ + SELECT + pubkey, + id, + withdrawal_credentials, + phase, + status, + index, + node_address + FROM "etherfi_validators" + WHERE timestamp = (SELECT MAX(timestamp) FROM "etherfi_validators") + AND LOWER(operator) = %s + AND status LIKE %s + """ + + params = [operator, '%active%'] + + if phase_filter: + query += " AND phase = %s" + params.append(phase_filter) + + query += ' ORDER BY id LIMIT %s' + params.append(count) + + validators = [] + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, params) + for row in cur.fetchall(): + # Normalize pubkey format + pubkey = row['pubkey'] + if pubkey and not pubkey.startswith('0x'): + pubkey = '0x' + pubkey + + # Store raw withdrawal credentials (will be converted later) + withdrawal_creds = row['withdrawal_credentials'] + if withdrawal_creds and not withdrawal_creds.startswith('0x'): + withdrawal_creds = '0x' + withdrawal_creds + + validators.append({ + 'id': row['id'], + 'pubkey': pubkey, + 'withdrawal_credentials': withdrawal_creds, + 'etherfi_node': row['node_address'], + 'phase': row['phase'], + 'status': row['status'], + 'index': row['index'] + }) + + return validators + + +def query_validators_by_ids(conn, validator_ids: List[int]) -> List[Dict]: + """ + Query validators from MainnetValidators table by specific validator IDs. + + Args: + conn: PostgreSQL connection + validator_ids: List of validator IDs (etherfi_id) to query + + Returns: + List of validator dictionaries + """ + if not validator_ids: + return [] + + query = """ + SELECT + pubkey, + etherfi_id, + eigen_pod_contract, + phase, + status, + beacon_index, + etherfi_node_contract + FROM "MainnetValidators" + WHERE etherfi_id = ANY(%s) + """ + + validators = [] + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(query, [validator_ids]) + for row in cur.fetchall(): + # Normalize pubkey format + pubkey = row['pubkey'] + if pubkey and not pubkey.startswith('0x'): + pubkey = '0x' + pubkey + + # Store eigenpod address as withdrawal credentials + eigenpod = row['eigen_pod_contract'] + if eigenpod and not eigenpod.startswith('0x'): + eigenpod = '0x' + eigenpod + + validators.append({ + 'id': row['etherfi_id'], + 'pubkey': pubkey, + 'withdrawal_credentials': eigenpod, + 'etherfi_node': row['etherfi_node_contract'], + 'phase': row['phase'], + 'status': row['status'], + 'index': row['beacon_index'] + }) + + return validators + + +# ============================================================================= +# Beacon Chain Utilities +# ============================================================================= + +def get_beacon_chain_url() -> str: + """Get beacon chain API URL from environment or use default.""" + return os.environ.get('BEACON_CHAIN_URL', 'https://beaconcha.in/api/v1') + + +def fetch_next_withdrawal_index() -> Optional[Dict]: + """ + Fetch next withdrawal validator index from beacon chain API. + + Returns: + Dict with currentSweepIndex, currentSlot, lastWithdrawalIndex or None if failed + """ + if not requests: + raise ImportError("requests library required for beacon chain API") + + beacon_url = get_beacon_chain_url() + + try: + # Try beacon API endpoint for latest block + response = requests.get(f"{beacon_url}/eth/v2/beacon/blocks/head", timeout=30) + response.raise_for_status() + data = response.json() + + if data.get('data'): + block = data['data'].get('message', {}) + slot = int(block.get('slot', 0)) + + # Get withdrawals from execution payload + withdrawals = block.get('body', {}).get('execution_payload', {}).get('withdrawals', []) + + if withdrawals: + # The last withdrawal's validator_index + 1 gives us the next sweep index + last_withdrawal = withdrawals[-1] + next_sweep_index = int(last_withdrawal.get('validator_index', 0)) + 1 + + return { + 'currentSweepIndex': next_sweep_index, + 'currentSlot': slot, + 'lastWithdrawalIndex': int(last_withdrawal.get('validator_index', 0)) + } + except Exception as e: + print(f"Warning: Failed to fetch withdrawal index: {e}") + + return None + + +def fetch_validator_count() -> int: + """ + Fetch total active validator count from beacon chain. + + Returns: + Total validator count + """ + if not requests: + raise ImportError("requests library required for beacon chain API") + + beacon_url = get_beacon_chain_url() + + try: + # Try to get validator count from beacon API + response = requests.get(f"{beacon_url}/eth/v1/beacon/states/head/validators?status=active_ongoing", + headers={'Accept': 'application/json'}, timeout=30) + + if response.ok: + data = response.json() + if data.get('data'): + return len(data['data']) + else: + print(f"Warning: Beacon API returned status {response.status_code} when fetching validator count") + except Exception as e: + print(f"Warning: Failed to fetch validator count: {e}") + + # Fallback to approximate count + print("Warning: Using fallback validator count of 1,200,000") + return 1200000 + + +def fetch_beacon_state() -> Dict: + """ + Fetch current beacon chain state including next_withdrawal_validator_index. + + Returns: + Dict containing beacon state data + """ + if not requests: + raise ImportError("requests library required for beacon chain API") + + # Try the new withdrawal index method first + sweep_data = fetch_next_withdrawal_index() + validator_count = fetch_validator_count() + + if sweep_data: + return { + 'next_withdrawal_validator_index': sweep_data['currentSweepIndex'], + 'validator_count': validator_count, + 'epoch': 0, # Not available from this method + 'slot': sweep_data.get('currentSlot'), + 'last_withdrawal_index': sweep_data.get('lastWithdrawalIndex') + } + + # Fallback to original method + beacon_url = get_beacon_chain_url() + + try: + response = requests.get(f"{beacon_url}/epoch/latest", timeout=30) + response.raise_for_status() + data = response.json() + + if 'data' in data and len(data['data']) > 0: + epoch_data = data['data'][0] + return { + 'next_withdrawal_validator_index': epoch_data.get('nextwithdrawalvalidatorindex', 0), + 'validator_count': validator_count, + 'epoch': epoch_data.get('epoch', 0) + } + except Exception as e: + print(f"Warning: Failed to fetch from beaconcha.in: {e}") + + # Fallback: Try direct beacon node API if available + beacon_node_url = os.environ.get('BEACON_NODE_URL') + if beacon_node_url: + try: + response = requests.get(f"{beacon_node_url}/eth/v1/beacon/states/head", timeout=30) + response.raise_for_status() + data = response.json() + + state = data.get('data', {}) + next_withdrawal_index = state.get('next_withdrawal_validator_index', 0) + + return { + 'next_withdrawal_validator_index': next_withdrawal_index, + 'validator_count': validator_count, + 'epoch': state.get('epoch', 0) + } + except Exception as e: + print(f"Warning: Failed to fetch from beacon node: {e}") + + raise ValueError("Could not fetch beacon chain state from any source") + + +# ============================================================================= +# Sweep Time Calculations +# ============================================================================= + +def calculate_sweep_time(validator_index: int, current_sweep_index: int, total_validators: int) -> Dict: + """ + Calculate sweep time for a validator using the JavaScript algorithm. + + Args: + validator_index: The validator's index + current_sweep_index: Current next_withdrawal_validator_index + total_validators: Total active validators + + Returns: + Dict with position, slots, seconds until sweep, and estimated time + """ + # Calculate position in queue + if validator_index >= current_sweep_index: + # Validator is ahead in current sweep cycle + position_in_queue = validator_index - current_sweep_index + else: + # Validator was already passed, will be swept in next cycle + position_in_queue = (total_validators - current_sweep_index) + validator_index + + # Calculate time until sweep + slots_until_sweep = math.ceil(position_in_queue / VALIDATORS_PER_SLOT) + seconds_until_sweep = slots_until_sweep * SECONDS_PER_SLOT + + estimated_sweep_time = datetime.now() + timedelta(seconds=seconds_until_sweep) + + return { + 'positionInQueue': position_in_queue, + 'slotsUntilSweep': slots_until_sweep, + 'secondsUntilSweep': seconds_until_sweep, + 'estimatedSweepTime': estimated_sweep_time + } + + +def format_duration(seconds: float) -> str: + """Format duration in seconds to human readable string.""" + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + minutes = int((seconds % 3600) // 60) + + if days > 0: + return f"{days}d {hours}h {minutes}m" + elif hours > 0: + return f"{hours}h {minutes}m" + else: + return f"{minutes}m" + + +# ============================================================================= +# Validator Consolidation Status Checking +# ============================================================================= + +def fetch_validator_details_batch( + pubkeys: List[str], + beacon_api: str = "https://beaconcha.in/api/v1", + batch_size: int = 100, + max_retries: int = 3, + show_progress: bool = True +) -> Dict[str, Dict]: + """ + Fetch validator details (balance, consolidation status, validator index) from beacon chain. + + Uses the same beaconcha.in batch API as check_validators_consolidation_status_batch + but extracts additional fields: balance and validator index. + + Args: + pubkeys: List of validator public keys (with or without 0x prefix) + beacon_api: Beacon chain API base URL + batch_size: Number of validators per API request (max 100) + max_retries: Maximum number of retry attempts per batch + show_progress: Show progress messages + + Returns: + Dictionary mapping pubkey -> { + 'balance_eth': float, + 'is_consolidated': bool or None (True=0x02, False=0x01, None=unknown), + 'beacon_withdrawal_credentials': str, + 'validator_index': int or None, + } + """ + result = {} + if not pubkeys or not requests: + return {pk: {'balance_eth': 32.0, 'is_consolidated': None, 'beacon_withdrawal_credentials': '', 'validator_index': None} for pk in pubkeys} + + batch_size = min(batch_size, 100) + total_batches = (len(pubkeys) + batch_size - 1) // batch_size + + for batch_idx in range(total_batches): + start = batch_idx * batch_size + end = min(start + batch_size, len(pubkeys)) + batch = pubkeys[start:end] + + if show_progress: + print(f" Fetching details batch {batch_idx + 1}/{total_batches} ({end}/{len(pubkeys)})...", end='\r', flush=True) + + batch_result = _fetch_details_single_batch(batch, beacon_api, max_retries) + result.update(batch_result) + + if show_progress and total_batches > 0: + print(f" Fetched details for {len(pubkeys)} validators in {total_batches} batches" + " " * 20) + + # Fill in defaults for any pubkeys not found + for pk in pubkeys: + if pk not in result: + result[pk] = {'balance_eth': 32.0, 'is_consolidated': None, 'beacon_withdrawal_credentials': '', 'validator_index': None} + + return result + + +def _fetch_details_single_batch( + pubkeys: List[str], + beacon_api: str, + max_retries: int +) -> Dict[str, Dict]: + """Fetch details for a single batch of pubkeys from beaconcha.in.""" + pubkeys_clean = [pk[2:] if pk.startswith('0x') else pk for pk in pubkeys] + pubkeys_str = ','.join(pubkeys_clean) + + for attempt in range(max_retries): + try: + url = f"{beacon_api}/validator/{pubkeys_str}" + response = requests.get(url, timeout=30) + response.raise_for_status() + data = response.json() + + result = {} + if data.get('status') == 'OK' and 'data' in data: + validator_data_list = data['data'] + if not isinstance(validator_data_list, list): + validator_data_list = [validator_data_list] + + for vd in validator_data_list: + vpk = vd.get('pubkey', '') + if not vpk: + continue + + vpk_norm = vpk.lower().replace('0x', '') + matching = None + for pk in pubkeys: + if pk.lower().replace('0x', '') == vpk_norm: + matching = pk + break + if not matching: + continue + + wc = vd.get('withdrawalcredentials', '') + is_consolidated = None + if wc.startswith('0x02'): + is_consolidated = True + elif wc.startswith('0x01'): + is_consolidated = False + + balance_gwei = vd.get('balance', 0) + if isinstance(balance_gwei, (int, float)) and balance_gwei > 10000: + balance_eth = balance_gwei / 1e9 + else: + balance_eth = float(balance_gwei) if balance_gwei else 32.0 + + effective_balance_gwei = vd.get('effectivebalance', 0) + if isinstance(effective_balance_gwei, (int, float)) and effective_balance_gwei > 10000: + effective_balance_eth = effective_balance_gwei / 1e9 + else: + effective_balance_eth = float(effective_balance_gwei) if effective_balance_gwei else 32.0 + + result[matching] = { + 'balance_eth': balance_eth, + 'effective_balance_eth': effective_balance_eth, + 'is_consolidated': is_consolidated, + 'beacon_withdrawal_credentials': wc, + 'validator_index': vd.get('validatorindex'), + } + + return result + + except Exception as e: + if attempt < max_retries - 1: + time.sleep(0.5 * (attempt + 1)) + continue + print(f"Warning: Failed to fetch validator details after {max_retries} retries: {e}") + return {} + + return {} + + +def check_validators_consolidation_status_batch( + pubkeys: List[str], + beacon_api: str = "https://beaconcha.in/api/v1", + max_retries: int = 3 +) -> Dict[str, Optional[bool]]: + """ + Check consolidation status for multiple validators using batch API request. + + Args: + pubkeys: List of validator public keys (with or without 0x prefix) + beacon_api: Beacon chain API base URL + max_retries: Maximum number of retry attempts + + Returns: + Dictionary mapping pubkey -> True (consolidated), False (not consolidated), or None (unknown) + """ + if not pubkeys or not requests: + return {pk: None for pk in pubkeys} + + # Clean pubkeys (remove 0x prefix) + pubkeys_clean = [pk[2:] if pk.startswith('0x') else pk for pk in pubkeys] + + # Join pubkeys with commas for batch request + pubkeys_str = ','.join(pubkeys_clean) + + result = {pk: None for pk in pubkeys} # Initialize all as None + + for attempt in range(max_retries): + try: + # Batch API endpoint: /validator/{pubkey1},{pubkey2},... + url = f"{beacon_api}/validator/{pubkeys_str}" + response = requests.get(url, timeout=30) # Longer timeout for batch + response.raise_for_status() + data = response.json() + + if data.get('status') == 'OK' and 'data' in data: + # Handle both single and batch responses + validator_data_list = data['data'] + if not isinstance(validator_data_list, list): + validator_data_list = [validator_data_list] + + # Map results back to pubkeys + for validator_data in validator_data_list: + validator_pubkey = validator_data.get('pubkey', '') + if not validator_pubkey: + continue + + # Normalize pubkey for matching (remove 0x, lowercase) + validator_pubkey_normalized = validator_pubkey.lower().replace('0x', '') + + # Find matching original pubkey + matching_pubkey = None + for pk in pubkeys: + pk_normalized = pk.lower().replace('0x', '') + if validator_pubkey_normalized == pk_normalized: + matching_pubkey = pk + break + + if matching_pubkey: + withdrawal_creds = validator_data.get('withdrawalcredentials', '') + if withdrawal_creds: + # Check first byte: 0x01 = traditional, 0x02 = auto-compounding (consolidated) + if withdrawal_creds.startswith('0x02'): + result[matching_pubkey] = True # Already consolidated + elif withdrawal_creds.startswith('0x01'): + result[matching_pubkey] = False # Not consolidated + else: + result[matching_pubkey] = None # Unexpected format + + return result + + except Exception as e: + # Network/API error - retry with backoff + if attempt < max_retries - 1: + time.sleep(0.5 * (attempt + 1)) # Exponential backoff + continue + # After max retries, return None for all (safer) + return result + + return result + + +def filter_consolidated_validators( + validators: List[Dict], + exclude_consolidated: bool = True, + beacon_api: str = "https://beaconcha.in/api/v1", + show_progress: bool = True, + batch_size: int = 100 +) -> Tuple[List[Dict], List[Dict]]: + """ + Filter out validators that are already consolidated (0x02) using batch API requests. + + Args: + validators: List of validator dictionaries + exclude_consolidated: If True, exclude consolidated validators + beacon_api: Beacon chain API base URL + show_progress: Show progress messages + batch_size: Number of validators to check per API request (max 100) + + Returns: + Tuple of (filtered_validators, consolidated_validators) + """ + if not exclude_consolidated: + return validators, [] + + if not requests: + print("Warning: requests library not installed, skipping beacon chain check") + return validators, [] + + # Limit batch size to API maximum + batch_size = min(batch_size, 100) + + filtered = [] + consolidated = [] + unknown = [] + + # Extract pubkeys for batch checking + validator_pubkeys = [] + validator_map = {} # Map pubkey -> validator dict + + for validator in validators: + pubkey = validator.get('pubkey', '') + if not pubkey: + continue + validator_pubkeys.append(pubkey) + validator_map[pubkey] = validator + + # Process in batches + total_batches = (len(validator_pubkeys) + batch_size - 1) // batch_size + + for batch_idx in range(total_batches): + start_idx = batch_idx * batch_size + end_idx = min(start_idx + batch_size, len(validator_pubkeys)) + batch_pubkeys = validator_pubkeys[start_idx:end_idx] + + if show_progress: + print(f" Checking batch {batch_idx + 1}/{total_batches} ({end_idx}/{len(validator_pubkeys)} validators)...", end='\r', flush=True) + + # Check batch + batch_results = check_validators_consolidation_status_batch( + batch_pubkeys, + beacon_api=beacon_api + ) + + # Process results + for pubkey in batch_pubkeys: + validator = validator_map[pubkey] + is_consolidated = batch_results.get(pubkey) + + if is_consolidated is True: + # Already consolidated - exclude it + consolidated.append(validator) + elif is_consolidated is False: + # Not consolidated - include it + filtered.append(validator) + else: + # Unknown status - include it (assume not consolidated) + filtered.append(validator) + unknown.append(validator) + + + if show_progress: + print(f" Checked {len(validator_pubkeys)} validators in {total_batches} batches" + " " * 20) # Clear progress line + if unknown: + print(f" Warning: {len(unknown)} validators had unknown consolidation status (included anyway)") + + return filtered, consolidated + + +# ============================================================================= +# Validator Queue Distribution +# ============================================================================= + +def spread_validators_across_queue(sorted_results: List[Dict], interval_hours: int = 6) -> Dict: + """ + Spread validators across the withdrawal queue at fixed intervals. + + Args: + sorted_results: Results sorted by sweep time (ascending) + interval_hours: Interval between buckets (default 6 hours) + + Returns: + Dict with buckets and summary + """ + interval_seconds = interval_hours * 3600 + + if not sorted_results: + return {'buckets': [], 'summary': {}} + + # Find the first validator's sweep time as the starting point + first_sweep_seconds = sorted_results[0]['secondsUntilSweep'] + last_sweep_seconds = sorted_results[-1]['secondsUntilSweep'] + + # Calculate how many buckets we need + total_duration = last_sweep_seconds - first_sweep_seconds + num_buckets = math.ceil(total_duration / interval_seconds) + 1 + + print(f"\nCreating {num_buckets} buckets at {interval_hours}-hour intervals...") + + # Initialize buckets with target times + buckets = [] + for i in range(num_buckets): + target_seconds = first_sweep_seconds + (i * interval_seconds) + buckets.append({ + 'bucketIndex': i, + 'targetSweepTimeSeconds': target_seconds, + 'targetSweepTimeFormatted': format_duration(target_seconds), + 'estimatedSweepTime': (datetime.now() + timedelta(seconds=target_seconds)).isoformat(), + 'validators': [], + 'byNodeAddress': {} + }) + + # Assign each validator to the nearest bucket + for validator in sorted_results: + # Find the bucket whose target time is closest + time_since_first = validator['secondsUntilSweep'] - first_sweep_seconds + bucket_index = round(time_since_first / interval_seconds) + clamped_index = max(0, min(bucket_index, len(buckets) - 1)) + + bucket = buckets[clamped_index] + bucket['validators'].append(validator) + + # Group by node address within bucket + node_addr = validator.get('nodeAddress', validator.get('etherfi_node', 'unknown')) + if node_addr not in bucket['byNodeAddress']: + bucket['byNodeAddress'][node_addr] = [] + bucket['byNodeAddress'][node_addr].append(validator) + + # Process buckets and add stats + processed_buckets = [] + for bucket in buckets: + validator_count = len(bucket['validators']) + node_count = len(bucket['byNodeAddress']) + + if validator_count > 0: # Only include non-empty buckets + processed_buckets.append({ + 'bucketIndex': bucket['bucketIndex'], + 'targetSweepTimeSeconds': bucket['targetSweepTimeSeconds'], + 'targetSweepTimeFormatted': bucket['targetSweepTimeFormatted'], + 'estimatedSweepTime': bucket['estimatedSweepTime'], + 'validatorCount': validator_count, + 'nodeAddressCount': node_count, + 'validators': bucket['validators'], + 'byNodeAddress': bucket['byNodeAddress'] + }) + + # Create summary + summary = { + 'totalValidators': len(sorted_results), + 'intervalHours': interval_hours, + 'totalBuckets': len(processed_buckets), + 'firstSweepTime': format_duration(first_sweep_seconds), + 'lastSweepTime': format_duration(last_sweep_seconds), + 'totalQueueDuration': format_duration(total_duration), + 'bucketsOverview': [ + { + 'bucket': b['bucketIndex'], + 'time': b['targetSweepTimeFormatted'], + 'validators': b['validatorCount'], + 'nodes': b['nodeAddressCount'] + } + for b in processed_buckets + ] + } + + return {'buckets': processed_buckets, 'summary': summary} + + +def pick_representative_validators(buckets: List[Dict]) -> Dict: + """ + Pick one representative validator per bucket (closest to target time) + for display/analysis purposes. Note: Final selection uses round-robin + distribution across all buckets for better coverage. + + Args: + buckets: List of bucket dictionaries + + Returns: + Dict with representatives and byNodeAddress grouping + """ + representatives = [] + + for bucket in buckets: + if not bucket['validators']: + continue + + # Find the validator closest to the bucket's target time + target_time = bucket['targetSweepTimeSeconds'] + closest = bucket['validators'][0] + min_diff = abs(closest['secondsUntilSweep'] - target_time) + + for validator in bucket['validators']: + diff = abs(validator['secondsUntilSweep'] - target_time) + if diff < min_diff: + min_diff = diff + closest = validator + + representatives.append({ + 'bucketIndex': bucket['bucketIndex'], + 'targetTime': bucket['targetSweepTimeFormatted'], + 'validator': closest + }) + + # Group representatives by node address + by_node = {} + for rep in representatives: + node_addr = rep['validator'].get('nodeAddress', rep['validator'].get('etherfi_node', 'unknown')) + if node_addr not in by_node: + by_node[node_addr] = [] + by_node[node_addr].append(rep) + + return {'representatives': representatives, 'byNodeAddress': by_node} diff --git a/script/upgrades/CrossPodApproval/deploy.s.sol b/script/upgrades/CrossPodApproval/deploy.s.sol new file mode 100644 index 000000000..19aa092f4 --- /dev/null +++ b/script/upgrades/CrossPodApproval/deploy.s.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import {LiquidityPool} from "../../../src/LiquidityPool.sol"; +import {EtherFiNodesManager} from "../../../src/EtherFiNodesManager.sol"; +import {Deployed} from "../../deploys/Deployed.s.sol"; +import {Utils, ICreate2Factory} from "../../utils/Utils.sol"; + +/** +command: +forge script script/upgrades/CrossPodApproval/deploy.s.sol:CrossPodApprovalDeployScript --fork-url $MAINNET_RPC_URL --verify --etherscan-api-key $ETHERSCAN_API_KEY + */ + +contract CrossPodApprovalDeployScript is Script, Deployed, Utils { + ICreate2Factory public constant factory = ICreate2Factory(0x356d1B83970CeF2018F2c9337cDdb67dff5AEF99); + + address liquidityPoolImpl; + address etherFiNodesManagerImpl; + bytes32 public constant commitHashSalt = bytes32(bytes20(hex"a6b8291c80e620ed48cdf999f546fee4f1ecfd48")); + + function run() public { + console2.log("================================================"); + console2.log("======================== Deploying Liquidity Pool and EtherFiNodesManager ========================"); + console2.log("================================================"); + console2.log(""); + + vm.startBroadcast(); + // vm.startPrank(ETHERFI_OPERATING_ADMIN); + + // LiquidityPool + { + string memory contractName = "LiquidityPool"; + bytes memory constructorArgs = abi.encode(); + bytes memory bytecode = abi.encodePacked( + type(LiquidityPool).creationCode, + constructorArgs + ); + liquidityPoolImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, false, factory); + } + console2.log("LiquidityPool deployed at:", liquidityPoolImpl); + + // EtherFiNodesManager implementation + { + string memory contractName = "EtherFiNodesManager"; + bytes memory constructorArgs = abi.encode( + STAKING_MANAGER, + ROLE_REGISTRY, + ETHERFI_RATE_LIMITER + ); + bytes memory bytecode = abi.encodePacked( + type(EtherFiNodesManager).creationCode, + constructorArgs + ); + etherFiNodesManagerImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, false, factory); + } + console2.log("EtherFiNodesManager deployed at:", etherFiNodesManagerImpl); + } +} \ No newline at end of file diff --git a/script/upgrades/CrossPodApproval/transactions.s.sol b/script/upgrades/CrossPodApproval/transactions.s.sol new file mode 100644 index 000000000..e38d75420 --- /dev/null +++ b/script/upgrades/CrossPodApproval/transactions.s.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import {UUPSUpgradeable} from "../../../lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {RoleRegistry} from "../../../src/RoleRegistry.sol"; +import {EtherFiTimelock} from "../../../src/EtherFiTimelock.sol"; +import {EtherFiNodesManager} from "../../../src/EtherFiNodesManager.sol"; +import {LiquidityPool} from "../../../src/LiquidityPool.sol"; +import {EtherFiRateLimiter} from "../../../src/EtherFiRateLimiter.sol"; +import {IEtherFiNodesManager} from "../../../src/interfaces/IEtherFiNodesManager.sol"; +import {ContractCodeChecker} from "../../ContractCodeChecker.sol"; +import {Deployed} from "../../deploys/Deployed.s.sol"; +import {Utils} from "../../utils/Utils.sol"; +import {IEigenPodTypes} from "../../../src/eigenlayer-interfaces/IEigenPod.sol"; + +// forge script script/upgrades/CrossPodApproval/transactions.s.sol:CrossPodApprovalScript --fork-url $MAINNET_RPC_URL -vvvv +contract CrossPodApprovalScript is Script, Deployed, Utils { + address constant liquidityPoolImpl = 0x8765bb2f362a4b72e614DF81E2841275b9358f8b; + address constant etherFiNodesManagerImpl = 0x789CbBe0739F1458905C9Ca6d6e74f7997622A9B; + + EtherFiTimelock constant etherFiTimelock = EtherFiTimelock(payable(UPGRADE_TIMELOCK)); + RoleRegistry constant roleRegistry = RoleRegistry(ROLE_REGISTRY); + EtherFiNodesManager constant etherFiNodesManager = EtherFiNodesManager(payable(ETHERFI_NODES_MANAGER)); + uint256 constant TIMELOCK_MIN_DELAY = 259200; + + ContractCodeChecker public contractCodeChecker; + + bytes32 public ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE; + bytes32 public LIQUIDITY_POOL_VALIDATOR_CREATOR_ROLE; + bytes32 public STAKING_MANAGER_VALIDATOR_INVALIDATOR_ROLE = keccak256("STAKING_MANAGER_VALIDATOR_INVALIDATOR_ROLE"); + bytes32 public ETHERFI_NODES_MANAGER_EL_CONSOLIDATION_ROLE = keccak256("ETHERFI_NODES_MANAGER_EL_CONSOLIDATION_ROLE"); + bytes32 public CONSOLIDATION_REQUEST_LIMIT_ID = keccak256("CONSOLIDATION_REQUEST_LIMIT_ID"); + // How many vals to consolidate for 250k ETH daily ? 250k/32 = 7812.5 vals + // For 7813 vals, 2048 * 7813 = 16_001_024 gwei needs to be set on the capacity rate limiter + uint64 public constant CAPACITY_RATE_LIMITER = 16_001_024 * 1e9; + // 16_001_024 gwei / 86400 seconds = 185.2 ETH per second + uint64 public constant REFILL_RATE = 185_200_000_000; + uint256 public constant FULL_EXIT_GWEI = 2_048_000_000_000; + + function run() public { + string memory forkUrl = vm.envString("TENDERLY_TEST_RPC"); + vm.selectFork(vm.createFork(forkUrl)); + + setUpEtherFiRateLimiter(); + ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE = EtherFiNodesManager(payable(etherFiNodesManagerImpl)).ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE(); + LIQUIDITY_POOL_VALIDATOR_CREATOR_ROLE = LiquidityPool(payable(liquidityPoolImpl)).LIQUIDITY_POOL_VALIDATOR_CREATOR_ROLE(); + + address[] memory targets = new address[](6); + bytes[] memory data = new bytes[](targets.length); + uint256[] memory values = new uint256[](targets.length); + + // Upgrade EtherFiNodesManager implementation first (adds the role) + targets[0] = ETHERFI_NODES_MANAGER; + data[0] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, etherFiNodesManagerImpl); + + // Upgrade LiquidityPool implementation + targets[1] = LIQUIDITY_POOL; + data[1] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, liquidityPoolImpl); + + // Grant legacy linker role to ETHERFI_OPERATING_ADMIN + targets[2] = ROLE_REGISTRY; + data[2] = abi.encodeWithSelector( + RoleRegistry.grantRole.selector, + ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE, + ADMIN_EOA + ); + targets[3] = ROLE_REGISTRY; + data[3] = abi.encodeWithSelector( + RoleRegistry.grantRole.selector, + LIQUIDITY_POOL_VALIDATOR_CREATOR_ROLE, + ADMIN_EOA + ); + targets[4] = ROLE_REGISTRY; + data[4] = abi.encodeWithSelector( + RoleRegistry.grantRole.selector, + STAKING_MANAGER_VALIDATOR_INVALIDATOR_ROLE, + ADMIN_EOA + ); + targets[5] = ROLE_REGISTRY; + data[5] = abi.encodeWithSelector( + RoleRegistry.grantRole.selector, + ETHERFI_NODES_MANAGER_EL_CONSOLIDATION_ROLE, + ADMIN_EOA + ); + + bytes32 timelockSalt = keccak256(abi.encode(targets, data, block.number)); + + bytes memory scheduleCalldata = abi.encodeWithSelector( + etherFiTimelock.scheduleBatch.selector, + targets, + values, + data, + bytes32(0), + timelockSalt, + TIMELOCK_MIN_DELAY + ); + + console2.log("Schedule calldata:"); + console2.logBytes(scheduleCalldata); + console2.log(""); + + bytes memory executeCalldata = abi.encodeWithSelector( + etherFiTimelock.executeBatch.selector, + targets, + values, + data, + bytes32(0), + timelockSalt + ); + + console2.log("Execute calldata:"); + console2.logBytes(executeCalldata); + console2.log(""); + + vm.startPrank(ETHERFI_UPGRADE_ADMIN); + etherFiTimelock.scheduleBatch(targets, values, data, bytes32(0), timelockSalt, TIMELOCK_MIN_DELAY); + vm.warp(block.timestamp + TIMELOCK_MIN_DELAY + 1); + etherFiTimelock.executeBatch(targets, values, data, bytes32(0), timelockSalt); + vm.stopPrank(); + + console2.log("Upgrade executed successfully"); + console2.log("================================================"); + console2.log(""); + + contractCodeChecker = new ContractCodeChecker(); + verifyBytecode(); + checkUpgrade(); + } + + function setUpEtherFiRateLimiter() public { + console2.log("Setting up EtherFiRateLimiter"); + console2.log("================================================"); + + bytes[] memory data = new bytes[](2); + address[] memory targets = new address[](2); + + data[0] = abi.encodeWithSelector( + EtherFiRateLimiter.createNewLimiter.selector, + CONSOLIDATION_REQUEST_LIMIT_ID, + CAPACITY_RATE_LIMITER, + REFILL_RATE + ); + data[1] = abi.encodeWithSelector( + EtherFiRateLimiter.updateConsumers.selector, + CONSOLIDATION_REQUEST_LIMIT_ID, + ETHERFI_NODES_MANAGER, + true + ); + for (uint256 i = 0; i < 2; i++) { + console2.log("====== EtherFiRateLimiter Tx", i); + targets[i] = address(ETHERFI_RATE_LIMITER); + console2.log("target: ", targets[i]); + console2.log("data: "); + console2.logBytes(data[i]); + console2.log("--------------------------------"); + } + console2.log("================================================"); + console2.log(""); + // Uncomment to run against fork + vm.startPrank(ETHERFI_OPERATING_ADMIN); + EtherFiRateLimiter(payable(ETHERFI_RATE_LIMITER)).createNewLimiter(CONSOLIDATION_REQUEST_LIMIT_ID, CAPACITY_RATE_LIMITER, REFILL_RATE); + EtherFiRateLimiter(payable(ETHERFI_RATE_LIMITER)).updateConsumers(CONSOLIDATION_REQUEST_LIMIT_ID, ETHERFI_NODES_MANAGER, true); + vm.stopPrank(); + console2.log("EtherFiRateLimiter setup completed"); + console2.log("================================================"); + console2.log(""); + } + + function checkUpgrade() internal { + require( + roleRegistry.hasRole(ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE, ADMIN_EOA), + "role grant failed" + ); + require( + roleRegistry.hasRole(LIQUIDITY_POOL_VALIDATOR_CREATOR_ROLE, ADMIN_EOA), + "role grant failed" + ); + require( + roleRegistry.hasRole(STAKING_MANAGER_VALIDATOR_INVALIDATOR_ROLE, ADMIN_EOA), + "role grant failed" + ); + + uint256[] memory validatorIds = new uint256[](1); + validatorIds[0] = 10270; + bytes[] memory pubkeys = new bytes[](1); + pubkeys[0] = hex"8f9c0aab19ee7586d3d470f132842396af606947a0589382483308fdffdaf544078c3be24210677a9c471ce70b3b4c2c"; + + vm.expectRevert(IEtherFiNodesManager.IncorrectRole.selector); + vm.prank(OPERATING_TIMELOCK); + EtherFiNodesManager(payable(ETHERFI_NODES_MANAGER)).linkLegacyValidatorIds(validatorIds, pubkeys); + + vm.prank(ADMIN_EOA); + EtherFiNodesManager(payable(ETHERFI_NODES_MANAGER)).linkLegacyValidatorIds(validatorIds, pubkeys); + + bytes[] memory pubkeys_consolidation = new bytes[](3); + pubkeys_consolidation[0] = hex"b5dba57ec7c1dda1a95061b2b37f4121d7745f66b3aebe9767927f4e55167755bf4377dd163529d30c169f589eb105dd"; + pubkeys_consolidation[1] = hex"99ff790e514eff01e51bcc845705676477ad9ee43346fdf12a7326e413afd2cd389efb8a899e2df833634bd15aa4b8ce"; + pubkeys_consolidation[2] = hex"96649b10c031d3cdb1ad158a3c0977ac32b6dea18971195afd788da6863f0960cf95019798c69f99cfef20b906504267"; + + IEigenPodTypes.ConsolidationRequest[] memory consolidationRequests = new IEigenPodTypes.ConsolidationRequest[](pubkeys_consolidation.length - 1); + for (uint256 i = 1; i < pubkeys_consolidation.length; i++) { + consolidationRequests[i - 1] = IEigenPodTypes.ConsolidationRequest({ + srcPubkey: pubkeys_consolidation[i], + targetPubkey: pubkeys_consolidation[0] + }); + } + + uint64 remaining = etherFiNodesManager.rateLimiter().consumable(CONSOLIDATION_REQUEST_LIMIT_ID); + console2.log("Remaining capacity: ", remaining); + + vm.prank(ADMIN_EOA); + etherFiNodesManager.requestConsolidation{value: consolidationRequests.length}(consolidationRequests); + + uint64 remaining2 = etherFiNodesManager.rateLimiter().consumable(CONSOLIDATION_REQUEST_LIMIT_ID); + console2.log("Remaining capacity: ", remaining2); + + if (remaining2 == remaining - FULL_EXIT_GWEI*2) { + console2.log("[OK] Consolidation request limit rate limited correctly"); + } else { + console2.log("[ERROR] Consolidation request limit rate limited incorrectly"); + } + + console2.log("[OK] Legacy linker role granted to ADMIN_EOA"); + console2.log("[OK] Consolidation role granted to ADMIN_EOA"); + console2.log("[OK] Liquidity pool validator creator role granted to ADMIN_EOA"); + console2.log("[OK] Staking manager validator invalidator role granted to ADMIN_EOA"); + console2.log("[OK] EtherFiRateLimiter setup completed"); + console2.log("================================================"); + } + + function logSetCapacityAndRefillRateCalldata() public view { + console2.log("Set Capacity and Refill Rate Calldata"); + console2.log("================================================"); + console2.log("Target: ", ETHERFI_RATE_LIMITER); + console2.log(""); + + bytes memory setCapacityData = abi.encodeWithSelector( + EtherFiRateLimiter.setCapacity.selector, + CONSOLIDATION_REQUEST_LIMIT_ID, + CAPACITY_RATE_LIMITER + ); + console2.log("setCapacity calldata:"); + console2.logBytes(setCapacityData); + console2.log(""); + + bytes memory setRefillRateData = abi.encodeWithSelector( + EtherFiRateLimiter.setRefillRate.selector, + CONSOLIDATION_REQUEST_LIMIT_ID, + REFILL_RATE + ); + console2.log("setRefillRate calldata:"); + console2.logBytes(setRefillRateData); + console2.log("================================================"); + } + + function verifyBytecode() internal { + LiquidityPool newLiquidityPoolImplementation = new LiquidityPool(); + EtherFiNodesManager newEtherFiNodesManagerImplementation = new EtherFiNodesManager( + address(STAKING_MANAGER), + address(ROLE_REGISTRY), + address(ETHERFI_RATE_LIMITER) + ); + contractCodeChecker.verifyContractByteCodeMatch(liquidityPoolImpl, address(newLiquidityPoolImplementation)); + contractCodeChecker.verifyContractByteCodeMatch(etherFiNodesManagerImpl, address(newEtherFiNodesManagerImplementation)); + + console2.log("[OK] Bytecode verified successfully"); + } +} diff --git a/script/upgrades/reaudit-fixes/deploy-reaudit-fixes.s.sol b/script/upgrades/reaudit-fixes/deploy-reaudit-fixes.s.sol new file mode 100644 index 000000000..c9951a908 --- /dev/null +++ b/script/upgrades/reaudit-fixes/deploy-reaudit-fixes.s.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import {EtherFiNode} from "../../../src/EtherFiNode.sol"; +import {EtherFiRedemptionManager} from "../../../src/EtherFiRedemptionManager.sol"; +import {EtherFiRestaker} from "../../../src/EtherFiRestaker.sol"; +import {EtherFiRewardsRouter} from "../../../src/EtherFiRewardsRouter.sol"; +import {Liquifier} from "../../../src/Liquifier.sol"; +import {WithdrawRequestNFT} from "../../../src/WithdrawRequestNFT.sol"; +import {EtherFiViewer} from "../../../src/helpers/EtherFiViewer.sol"; +import {Utils} from "../../utils/utils.sol"; + +/** + * @title DeployReauditedContracts + * @notice Deploys implementation contracts for the re-audit fixes PR + * @dev Uses CREATE2 for deterministic deployment addresses + * + * Changes deployed: + * - EtherFiNode: Caps ETH transfers by totalValueOutOfLp + * - EtherFiRedemptionManager: Fee handling order fix & totalRedeemableAmount fix + * - EtherFiRestaker: Lido withdrawal fix & withdrawEther cap + * - EtherFiRewardsRouter: withdrawToLiquidityPool cap + * - Liquifier: stETH rounding fix, withdrawEther cap, simplified getTotalPooledEther + * - WithdrawRequestNFT: Event emission fix + * - EtherFiViewer: Changed from validatorPubkeyToInfo to validatorPubkeyHashToInfo + +Command: +forge script script/upgrades/reaudit-fixes/deploy-reaudit-fixes.s.sol --fork-url $MAINNET_RPC_URL -vvvv + */ + +contract DeployReauditFixes is Utils { + // Deployed implementation addresses (populated after deployment) + address public etherFiNodeImpl; + address public etherFiRedemptionManagerImpl; + address public etherFiRestakerImpl; + address public etherFiRewardsRouterImpl; + address public liquifierImpl; + address public withdrawRequestNFTImpl; + address public etherFiViewerImpl; + + // Salt for deterministic deployment - use commit hash or unique identifier + bytes32 commitHashSalt = bytes32(bytes20(hex"77381e3f2ef7ac8ff04f2a044e59432e2486195d")); // final audited commit hash + + function run() public { + console2.log("================================================"); + console2.log("=== Deploying Re-audit Fixes Implementation ===="); + console2.log("================================================"); + console2.log(""); + + vm.startBroadcast(); + + // 1. EtherFiNode Implementation + { + string memory contractName = "EtherFiNode"; + bytes memory constructorArgs = abi.encode( + LIQUIDITY_POOL, + ETHERFI_NODES_MANAGER, + EIGENLAYER_POD_MANAGER, + EIGENLAYER_DELEGATION_MANAGER, + ROLE_REGISTRY + ); + bytes memory bytecode = abi.encodePacked( + type(EtherFiNode).creationCode, + constructorArgs + ); + etherFiNodeImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, mainnetCreate2Factory); + } + + // 2. EtherFiRedemptionManager Implementation + { + string memory contractName = "EtherFiRedemptionManager"; + bytes memory constructorArgs = abi.encode( + LIQUIDITY_POOL, + EETH, + WEETH, + TREASURY, + ROLE_REGISTRY, + ETHERFI_RESTAKER + ); + bytes memory bytecode = abi.encodePacked( + type(EtherFiRedemptionManager).creationCode, + constructorArgs + ); + etherFiRedemptionManagerImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, mainnetCreate2Factory); + } + + // 3. EtherFiRestaker Implementation + { + string memory contractName = "EtherFiRestaker"; + bytes memory constructorArgs = abi.encode( + EIGENLAYER_REWARDS_COORDINATOR, + ETHERFI_REDEMPTION_MANAGER + ); + bytes memory bytecode = abi.encodePacked( + type(EtherFiRestaker).creationCode, + constructorArgs + ); + etherFiRestakerImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, mainnetCreate2Factory); + } + + // 4. EtherFiRewardsRouter Implementation + { + string memory contractName = "EtherFiRewardsRouter"; + bytes memory constructorArgs = abi.encode( + LIQUIDITY_POOL, + TREASURY, + ROLE_REGISTRY + ); + bytes memory bytecode = abi.encodePacked( + type(EtherFiRewardsRouter).creationCode, + constructorArgs + ); + etherFiRewardsRouterImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, mainnetCreate2Factory); + } + + // 5. Liquifier Implementation (no constructor args - uses initializer) + { + string memory contractName = "Liquifier"; + bytes memory constructorArgs = abi.encode(); + bytes memory bytecode = abi.encodePacked( + type(Liquifier).creationCode, + constructorArgs + ); + liquifierImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, mainnetCreate2Factory); + } + + // 6. WithdrawRequestNFT Implementation + { + string memory contractName = "WithdrawRequestNFT"; + bytes memory constructorArgs = abi.encode(TREASURY); + bytes memory bytecode = abi.encodePacked( + type(WithdrawRequestNFT).creationCode, + constructorArgs + ); + withdrawRequestNFTImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, mainnetCreate2Factory); + } + + // 7. EtherFiViewer Implementation + { + string memory contractName = "EtherFiViewer"; + bytes memory constructorArgs = abi.encode( + EIGENLAYER_POD_MANAGER, + EIGENLAYER_DELEGATION_MANAGER + ); + bytes memory bytecode = abi.encodePacked( + type(EtherFiViewer).creationCode, + constructorArgs + ); + etherFiViewerImpl = deploy(contractName, constructorArgs, bytecode, commitHashSalt, true, mainnetCreate2Factory); + } + + vm.stopBroadcast(); + + // Print summary + console2.log(""); + console2.log("=== Deployment Summary ==="); + console2.log("EtherFiNode Implementation:", etherFiNodeImpl); + console2.log("EtherFiRedemptionManager Implementation:", etherFiRedemptionManagerImpl); + console2.log("EtherFiRestaker Implementation:", etherFiRestakerImpl); + console2.log("EtherFiRewardsRouter Implementation:", etherFiRewardsRouterImpl); + console2.log("Liquifier Implementation:", liquifierImpl); + console2.log("WithdrawRequestNFT Implementation:", withdrawRequestNFTImpl); + console2.log("EtherFiViewer Implementation:", etherFiViewerImpl); + } +} diff --git a/script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol b/script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol new file mode 100644 index 000000000..a444f92fa --- /dev/null +++ b/script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/Script.sol"; +import "forge-std/console2.sol"; +import {Utils, ICreate2Factory} from "../../utils/utils.sol"; +import {EtherFiTimelock} from "../../../src/EtherFiTimelock.sol"; +import {EtherFiNode} from "../../../src/EtherFiNode.sol"; +import {EtherFiRedemptionManager} from "../../../src/EtherFiRedemptionManager.sol"; +import {EtherFiRestaker} from "../../../src/EtherFiRestaker.sol"; +import {EtherFiRewardsRouter} from "../../../src/EtherFiRewardsRouter.sol"; +import {Liquifier} from "../../../src/Liquifier.sol"; +import {WithdrawRequestNFT} from "../../../src/WithdrawRequestNFT.sol"; +import {EtherFiViewer} from "../../../src/helpers/EtherFiViewer.sol"; +import {StakingManager} from "../../../src/StakingManager.sol"; +import {LiquidityPool} from "../../../src/LiquidityPool.sol"; +import "@openzeppelin-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {ContractCodeChecker} from "../../../script/ContractCodeChecker.sol"; + +/** + * @title ReauditFixesTransactions + * @notice Schedules and executes upgrades for re-audit fixes + * @dev Run with: forge script script/upgrades/reaudit-fixes/transactions-reaudit-fixes.s.sol --fork-url $MAINNET_RPC_URL + * + * Changes being upgraded: + * - EtherFiNode: Caps ETH transfers by totalValueOutOfLp + * - EtherFiRedemptionManager: Fee handling order fix & totalRedeemableAmount fix + * - EtherFiRestaker: Lido withdrawal fix & withdrawEther cap + * - EtherFiRewardsRouter: withdrawToLiquidityPool cap + * - Liquifier: stETH rounding fix, withdrawEther cap, simplified getTotalPooledEther + * - WithdrawRequestNFT: Event emission fix + * - EtherFiViewer: Changed from validatorPubkeyToInfo to validatorPubkeyHashToInfo + */ +contract ReauditFixesTransactions is Utils { + EtherFiTimelock etherFiTimelock = EtherFiTimelock(payable(UPGRADE_TIMELOCK)); + ContractCodeChecker contractCodeChecker; + uint256 constant TIMELOCK_MIN_DELAY = 259200; // 72 hours + + //-------------------------------------------------------------------------------------- + //---------------------------- NEW IMPLEMENTATION ADDRESSES ---------------------------- + //-------------------------------------------------------------------------------------- + address constant etherFiNodeImpl = 0xA91F8a52F0C1b4D3fDC256fC5bEBCA4D627da392; + address constant etherFiRedemptionManagerImpl = 0xb4fed2BF48EF08b93256AE67ad3bFaB6F1f5c13a; + address constant etherFiRestakerImpl = 0x9D795b303B9dA3488FD3A4ca4702c872576BD0c6; + address constant etherFiRewardsRouterImpl = 0x408de8D339F40086c5643EE4778E0F872aB5E423; + address constant liquifierImpl = 0x0E7489D32D34CCdC12d7092067bf53Aa38bf2BF6; + address constant withdrawRequestNFTImpl = 0xDdD4278396A22757F2a857ADE3E6Cb35B933f9Cb; + address constant etherFiViewerImpl = 0x69585767FDAEC9a7c18FeB99D59B5CbEDA740483; + + // Salt used for CREATE2 deployment + bytes32 constant commitHashSalt = bytes32(bytes20(hex"77381e3f2ef7ac8ff04f2a044e59432e2486195d")); + + function run() public { + console2.log("================================================"); + console2.log("=== Re-audit Fixes Upgrade Transactions ========"); + console2.log("================================================"); + console2.log(""); + + // string memory forkUrl = vm.envString("MAINNET_RPC_URL"); + // vm.selectFork(vm.createFork(forkUrl)); + + contractCodeChecker = new ContractCodeChecker(); + + // Step 1: Verify deployed bytecode matches expected + verifyDeployedBytecode(); + + // Step 2: Execute upgrade via timelock + executeUpgrade(); + + // Step 3: Verify upgrades were successful + verifyUpgrades(); + + // Step 4: Run fork tests + forkTests(); + } + + //-------------------------------------------------------------------------------------- + //------------------------------- BYTECODE VERIFICATION -------------------------------- + //-------------------------------------------------------------------------------------- + function verifyDeployedBytecode() public { + console2.log("=== Verifying Deployed Bytecode ==="); + console2.log(""); + + EtherFiNode newEtherFiNodeImplementation = new EtherFiNode(address(LIQUIDITY_POOL), address(ETHERFI_NODES_MANAGER), address(EIGENLAYER_POD_MANAGER), address(EIGENLAYER_DELEGATION_MANAGER), address(ROLE_REGISTRY)); + EtherFiRedemptionManager newEtherFiRedemptionManagerImplementation = new EtherFiRedemptionManager(address(LIQUIDITY_POOL), address(EETH), address(WEETH), address(TREASURY), address(ROLE_REGISTRY), address(ETHERFI_RESTAKER)); + EtherFiRestaker newEtherFiRestakerImplementation = new EtherFiRestaker(address(EIGENLAYER_REWARDS_COORDINATOR), address(ETHERFI_REDEMPTION_MANAGER)); + EtherFiRewardsRouter newEtherFiRewardsRouterImplementation = new EtherFiRewardsRouter(address(LIQUIDITY_POOL), address(TREASURY), address(ROLE_REGISTRY)); + Liquifier newLiquifierImplementation = new Liquifier(); + WithdrawRequestNFT newWithdrawRequestNFTImplementation = new WithdrawRequestNFT(address(TREASURY)); + EtherFiViewer newEtherFiViewerImplementation = new EtherFiViewer(address(EIGENLAYER_POD_MANAGER), address(EIGENLAYER_DELEGATION_MANAGER)); + + contractCodeChecker.verifyContractByteCodeMatch(etherFiNodeImpl, address(newEtherFiNodeImplementation)); + contractCodeChecker.verifyContractByteCodeMatch(etherFiRedemptionManagerImpl, address(newEtherFiRedemptionManagerImplementation)); + contractCodeChecker.verifyContractByteCodeMatch(etherFiRestakerImpl, address(newEtherFiRestakerImplementation)); + contractCodeChecker.verifyContractByteCodeMatch(etherFiRewardsRouterImpl, address(newEtherFiRewardsRouterImplementation)); + contractCodeChecker.verifyContractByteCodeMatch(liquifierImpl, address(newLiquifierImplementation)); + contractCodeChecker.verifyContractByteCodeMatch(withdrawRequestNFTImpl, address(newWithdrawRequestNFTImplementation)); + contractCodeChecker.verifyContractByteCodeMatch(etherFiViewerImpl, address(newEtherFiViewerImplementation)); + + console2.log(""); + console2.log("All bytecode verifications passed!"); + console2.log("================================================"); + console2.log(""); + } + + //-------------------------------------------------------------------------------------- + //------------------------------- EXECUTE UPGRADE -------------------------------------- + //-------------------------------------------------------------------------------------- + function executeUpgrade() public { + console2.log("=== Executing Upgrade ==="); + console2.log(""); + + // Build upgrade batch (7 upgrades total) + address[] memory targets = new address[](6); + bytes[] memory data = new bytes[](targets.length); + uint256[] memory values = new uint256[](targets.length); // Default to 0 + + //-------------------------------------------------------------------------------------- + //------------------------------- CONTRACT UPGRADES ----------------------------------- + //-------------------------------------------------------------------------------------- + + // 1. EtherFiNode (via StakingManager.upgradeEtherFiNode - beacon proxy) + targets[0] = STAKING_MANAGER; + data[0] = abi.encodeWithSelector(StakingManager.upgradeEtherFiNode.selector, etherFiNodeImpl); + + // 2. EtherFiRedemptionManager (UUPS) + targets[1] = ETHERFI_REDEMPTION_MANAGER; + data[1] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, etherFiRedemptionManagerImpl); + + // 3. EtherFiRestaker (UUPS) + targets[2] = ETHERFI_RESTAKER; + data[2] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, etherFiRestakerImpl); + + // 4. EtherFiRewardsRouter (UUPS) + targets[3] = ETHERFI_REWARDS_ROUTER; + data[3] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, etherFiRewardsRouterImpl); + + // 5. Liquifier (UUPS) + targets[4] = LIQUIFIER; + data[4] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, liquifierImpl); + + // 6. WithdrawRequestNFT (UUPS) + targets[5] = WITHDRAW_REQUEST_NFT; + data[5] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, withdrawRequestNFTImpl); + + // 7. EtherFiViewer (UUPS) + // NOTE: EtherFiViewer is not being upgraded in this transaction because the owner is an EOA - 0xf8a86ea1Ac39EC529814c377Bd484387D395421e + // targets[6] = ETHERFI_VIEWER; + // data[6] = abi.encodeWithSelector(UUPSUpgradeable.upgradeTo.selector, etherFiViewerImpl); + + bytes32 timelockSalt = keccak256(abi.encode(targets, data, "reaudit-fixes-upgrade-v1")); + + // Generate and log schedule calldata + bytes memory scheduleCalldata = abi.encodeWithSelector( + etherFiTimelock.scheduleBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt, + TIMELOCK_MIN_DELAY + ); + + console2.log("=== Schedule Tx Calldata ==="); + console2.log("Target: Upgrade Timelock", UPGRADE_TIMELOCK); + console2.logBytes(scheduleCalldata); + console2.log(""); + + // Generate and log execute calldata + bytes memory executeCalldata = abi.encodeWithSelector( + etherFiTimelock.executeBatch.selector, + targets, + values, + data, + bytes32(0), // predecessor + timelockSalt + ); + + console2.log("=== Execute Tx Calldata ==="); + console2.log("Target: Upgrade Timelock", UPGRADE_TIMELOCK); + console2.logBytes(executeCalldata); + console2.log(""); + + // Execute against fork for testing + console2.log("=== Scheduling Batch on Fork ==="); + vm.prank(ETHERFI_UPGRADE_ADMIN); + etherFiTimelock.scheduleBatch(targets, values, data, bytes32(0), timelockSalt, TIMELOCK_MIN_DELAY); + + // Warp past timelock delay + vm.warp(block.timestamp + TIMELOCK_MIN_DELAY + 1); + + console2.log("=== Executing Batch on Fork ==="); + vm.prank(ETHERFI_UPGRADE_ADMIN); + etherFiTimelock.executeBatch(targets, values, data, bytes32(0), timelockSalt); + + console2.log("Upgrade executed successfully on fork!"); + console2.log("================================================"); + console2.log(""); + } + + //-------------------------------------------------------------------------------------- + //------------------------------- VERIFY UPGRADES -------------------------------------- + //-------------------------------------------------------------------------------------- + function verifyUpgrades() public view { + console2.log("=== Verifying Upgrades ==="); + console2.log(""); + + // 1. EtherFiNode (beacon proxy - check beacon implementation) + { + // The beacon stores the implementation - we need to check it was updated + StakingManager stakingManager = StakingManager(STAKING_MANAGER); + address currentImpl = stakingManager.implementation(); + require(currentImpl == etherFiNodeImpl, "EtherFiNode upgrade failed"); + console2.log("EtherFiNode implementation:", currentImpl); + } + + // 2. EtherFiRedemptionManager + { + address currentImpl = getImplementation(ETHERFI_REDEMPTION_MANAGER); + require(currentImpl == etherFiRedemptionManagerImpl, "EtherFiRedemptionManager upgrade failed"); + console2.log("EtherFiRedemptionManager implementation:", currentImpl); + } + + // 3. EtherFiRestaker + { + address currentImpl = getImplementation(ETHERFI_RESTAKER); + require(currentImpl == etherFiRestakerImpl, "EtherFiRestaker upgrade failed"); + console2.log("EtherFiRestaker implementation:", currentImpl); + } + + // 4. EtherFiRewardsRouter + { + address currentImpl = getImplementation(ETHERFI_REWARDS_ROUTER); + require(currentImpl == etherFiRewardsRouterImpl, "EtherFiRewardsRouter upgrade failed"); + console2.log("EtherFiRewardsRouter implementation:", currentImpl); + } + + // 5. Liquifier + { + address currentImpl = getImplementation(LIQUIFIER); + require(currentImpl == liquifierImpl, "Liquifier upgrade failed"); + console2.log("Liquifier implementation:", currentImpl); + } + + // 6. WithdrawRequestNFT + { + address currentImpl = getImplementation(WITHDRAW_REQUEST_NFT); + require(currentImpl == withdrawRequestNFTImpl, "WithdrawRequestNFT upgrade failed"); + console2.log("WithdrawRequestNFT implementation:", currentImpl); + } + + // 7. EtherFiViewer + // { + // address currentImpl = getImplementation(ETHERFI_VIEWER); + // require(currentImpl == etherFiViewerImpl, "EtherFiViewer upgrade failed"); + // console2.log("EtherFiViewer implementation:", currentImpl); + // } + + console2.log(""); + console2.log("All upgrades verified successfully!"); + console2.log("================================================"); + console2.log(""); + } + + //-------------------------------------------------------------------------------------- + //------------------------------- FORK TESTS ------------------------------------------- + //-------------------------------------------------------------------------------------- + function forkTests() public { + console2.log("=== Running Fork Tests ==="); + console2.log(""); + + testEtherFiRestakerDelegation(); + testLiquifierGetTotalPooledEther(); + testWithdrawRequestNFTBasicFunctionality(); + + console2.log(""); + console2.log("All fork tests passed!"); + console2.log("================================================"); + console2.log(""); + } + + function testEtherFiRestakerDelegation() internal view { + console2.log("Testing EtherFiRestaker delegation check..."); + + EtherFiRestaker restaker = EtherFiRestaker(payable(ETHERFI_RESTAKER)); + require(restaker.isDelegated(), "EtherFiRestaker: should be delegated"); + + console2.log(" EtherFiRestaker delegation check passed"); + } + + function testLiquifierGetTotalPooledEther() internal view { + console2.log("Testing Liquifier getTotalPooledEther..."); + + Liquifier liquifier = Liquifier(payable(LIQUIFIER)); + uint256 totalPooled = liquifier.getTotalPooledEther(); + + // Should return a value (even if 0, function should not revert) + console2.log(" Liquifier getTotalPooledEther:", totalPooled); + console2.log(" Liquifier getTotalPooledEther check passed"); + } + + function testWithdrawRequestNFTBasicFunctionality() internal view { + console2.log("Testing WithdrawRequestNFT basic functionality..."); + + WithdrawRequestNFT withdrawNFT = WithdrawRequestNFT(payable(WITHDRAW_REQUEST_NFT)); + + // Check name and symbol are accessible (basic sanity check) + string memory name = withdrawNFT.name(); + string memory symbol = withdrawNFT.symbol(); + + console2.log(" WithdrawRequestNFT name:", name); + console2.log(" WithdrawRequestNFT symbol:", symbol); + console2.log(" WithdrawRequestNFT basic functionality check passed"); + } +} diff --git a/script/utils/GnosisTxGeneratorLib.sol b/script/utils/GnosisTxGeneratorLib.sol new file mode 100644 index 000000000..09785997d --- /dev/null +++ b/script/utils/GnosisTxGeneratorLib.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "./StringHelpers.sol"; + +/** + * @title Gnosis Transaction Generator Library + * @notice Generates prettified JSON for Gnosis Safe Transaction Builder + * @dev Produces JSON compatible with Gnosis Safe Transaction Builder import format + */ +library GnosisTxGeneratorLib { + using StringHelpers for uint256; + using StringHelpers for address; + using StringHelpers for bytes; + + /** + * @notice Transaction data structure for Gnosis Safe + */ + struct GnosisTx { + address to; + uint256 value; + bytes data; + } + + // Alias for backward compatibility + struct Transaction { + address to; + uint256 value; + bytes data; + } + + /** + * @notice Generates a prettified Gnosis Safe transaction batch JSON + * @param transactions Array of transactions + * @param chainId Chain ID for the transaction + * @param safeAddress Address of the Gnosis Safe + * @return json Prettified JSON string + */ + function generateTransactionBatch( + GnosisTx[] memory transactions, + uint256 chainId, + address safeAddress + ) internal pure returns (string memory json) { + json = string.concat( + '{\n', + ' "chainId": "', chainId.uint256ToString(), '",\n', + ' "safeAddress": "', safeAddress.addressToString(), '",\n', + ' "meta": {\n', + ' "txBuilderVersion": "1.16.5"\n', + ' },\n', + ' "transactions": [\n' + ); + + for (uint256 i = 0; i < transactions.length; i++) { + json = string.concat( + json, + ' {\n', + ' "to": "', transactions[i].to.addressToString(), '",\n', + ' "value": "', transactions[i].value.uint256ToString(), '",\n', + ' "data": "', transactions[i].data.bytesToHexString(), '"\n', + ' }' + ); + + if (i < transactions.length - 1) { + json = string.concat(json, ',\n'); + } else { + json = string.concat(json, '\n'); + } + } + + json = string.concat(json, ' ]\n}'); + } + + /** + * @notice Generates a prettified Gnosis Safe transaction batch JSON with metadata + * @param transactions Array of transactions + * @param chainId Chain ID for the transaction + * @param safeAddress Address of the Gnosis Safe + * @param metaName Name for the transaction batch + * @param metaDescription Description for the transaction batch + * @return json Prettified JSON string + */ + function generateTransactionBatchWithMeta( + GnosisTx[] memory transactions, + uint256 chainId, + address safeAddress, + string memory metaName, + string memory metaDescription + ) internal pure returns (string memory json) { + json = string.concat( + '{\n', + ' "chainId": "', chainId.uint256ToString(), '",\n', + ' "safeAddress": "', safeAddress.addressToString(), '",\n', + ' "meta": {\n', + ' "txBuilderVersion": "1.16.5",\n', + ' "name": "', metaName, '",\n', + ' "description": "', metaDescription, '"\n', + ' },\n', + ' "transactions": [\n' + ); + + for (uint256 i = 0; i < transactions.length; i++) { + json = string.concat( + json, + ' {\n', + ' "to": "', transactions[i].to.addressToString(), '",\n', + ' "value": "', transactions[i].value.uint256ToString(), '",\n', + ' "data": "', transactions[i].data.bytesToHexString(), '"\n', + ' }' + ); + + if (i < transactions.length - 1) { + json = string.concat(json, ',\n'); + } else { + json = string.concat(json, '\n'); + } + } + + json = string.concat(json, ' ]\n}'); + } + + /** + * @notice Creates a single GnosisTx struct + */ + function createTx( + address to, + uint256 value, + bytes memory data + ) internal pure returns (GnosisTx memory) { + return GnosisTx({ + to: to, + value: value, + data: data + }); + } + + /** + * @notice Generates transaction batch using Transaction struct (backward compatible) + */ + function generateTransactionBatch( + Transaction[] memory transactions, + uint256 chainId, + address safeAddress, + string memory metaName, + string memory metaDescription + ) internal pure returns (string memory json) { + json = string.concat( + '{\n', + ' "chainId": "', chainId.uint256ToString(), '",\n', + ' "safeAddress": "', safeAddress.addressToString(), '",\n', + ' "meta": {\n', + ' "txBuilderVersion": "1.16.5",\n', + ' "name": "', metaName, '",\n', + ' "description": "', metaDescription, '"\n', + ' },\n', + ' "transactions": [\n' + ); + + for (uint256 i = 0; i < transactions.length; i++) { + json = string.concat( + json, + ' {\n', + ' "to": "', transactions[i].to.addressToString(), '",\n', + ' "value": "', transactions[i].value.uint256ToString(), '",\n', + ' "data": "', transactions[i].data.bytesToHexString(), '"\n', + ' }' + ); + + if (i < transactions.length - 1) { + json = string.concat(json, ',\n'); + } else { + json = string.concat(json, '\n'); + } + } + + json = string.concat(json, ' ]\n}'); + } +} + diff --git a/script/utils/SafeTxHashLib.sol b/script/utils/SafeTxHashLib.sol new file mode 100644 index 000000000..1c7efb453 --- /dev/null +++ b/script/utils/SafeTxHashLib.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "./StringHelpers.sol"; + +/** + * @title Safe Transaction Hash Library + * @notice Computes EIP-712 domain separator and SafeTxHash for Gnosis Safe + * @dev Used to output signing data for transaction verification + */ +library SafeTxHashLib { + using StringHelpers for bytes32; + + // EIP-712 Domain Typehash for Gnosis Safe + bytes32 constant private DOMAIN_SEPARATOR_TYPEHASH = keccak256( + "EIP712Domain(uint256 chainId,address verifyingContract)" + ); + + // Safe Transaction Typehash + bytes32 constant private SAFE_TX_TYPEHASH = keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ); + + /** + * @notice Computes the EIP-712 domain separator for a Gnosis Safe + * @param chainId The chain ID + * @param safeAddress The Gnosis Safe address + * @return The domain separator hash + */ + function getDomainSeparator( + uint256 chainId, + address safeAddress + ) internal pure returns (bytes32) { + return keccak256( + abi.encode( + DOMAIN_SEPARATOR_TYPEHASH, + chainId, + safeAddress + ) + ); + } + + /** + * @notice Computes the SafeTxHash for a transaction + * @param to Destination address + * @param value ETH value + * @param data Transaction calldata + * @param operation Operation type (0 = Call, 1 = DelegateCall) + * @param safeTxGas Gas for the safe transaction + * @param baseGas Base gas cost + * @param gasPrice Gas price + * @param gasToken Token for gas payment (address(0) for ETH) + * @param refundReceiver Address to receive gas refund + * @param nonce Safe nonce + * @return The transaction hash + */ + function getSafeTxHash( + address to, + uint256 value, + bytes memory data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 nonce + ) internal pure returns (bytes32) { + return keccak256( + abi.encode( + SAFE_TX_TYPEHASH, + to, + value, + keccak256(data), + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce + ) + ); + } + + /** + * @notice Computes the final message hash to sign + * @param domainSeparator The EIP-712 domain separator + * @param safeTxHash The Safe transaction hash + * @return The final hash to sign + */ + function getMessageHash( + bytes32 domainSeparator, + bytes32 safeTxHash + ) internal pure returns (bytes32) { + return keccak256( + abi.encodePacked( + bytes1(0x19), + bytes1(0x01), + domainSeparator, + safeTxHash + ) + ); + } + + /** + * @notice Helper to compute signing data for a simple transaction + * @dev Uses default values for gas parameters (0) and ETH for gas + */ + function computeSigningData( + uint256 chainId, + address safeAddress, + address to, + uint256 value, + bytes memory data, + uint256 nonce + ) internal pure returns ( + bytes32 domainSeparator, + bytes32 safeTxHash, + bytes32 messageHash + ) { + domainSeparator = getDomainSeparator(chainId, safeAddress); + safeTxHash = getSafeTxHash( + to, + value, + data, + 0, // operation: Call + 0, // safeTxGas + 0, // baseGas + 0, // gasPrice + address(0), // gasToken: ETH + address(0), // refundReceiver + nonce + ); + messageHash = getMessageHash(domainSeparator, safeTxHash); + } +} + diff --git a/script/utils/StringHelpers.sol b/script/utils/StringHelpers.sol new file mode 100644 index 000000000..8645b9474 --- /dev/null +++ b/script/utils/StringHelpers.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/** + * @title String Helpers Library + * @notice Utility functions for string manipulation in Solidity + * @dev Pure functions for converting types to strings and formatting + */ +library StringHelpers { + + /** + * @notice Converts uint256 to string + * @param value The uint256 value to convert + * @return The string representation + */ + function uint256ToString(uint256 value) internal pure returns (string memory) { + if (value == 0) { + return "0"; + } + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); + } + + /** + * @notice Converts address to lowercase hex string with 0x prefix + * @param addr The address to convert + * @return The hex string representation + */ + function addressToString(address addr) internal pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory data = abi.encodePacked(addr); + bytes memory str = new bytes(42); + str[0] = '0'; + str[1] = 'x'; + for (uint256 i = 0; i < 20; i++) { + str[2 + i * 2] = alphabet[uint8(data[i] >> 4)]; + str[3 + i * 2] = alphabet[uint8(data[i] & 0x0f)]; + } + return string(str); + } + + /** + * @notice Converts bytes to hex string with 0x prefix + * @param data The bytes to convert + * @return The hex string representation + */ + function bytesToHexString(bytes memory data) internal pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory str = new bytes(2 + data.length * 2); + str[0] = '0'; + str[1] = 'x'; + for (uint256 i = 0; i < data.length; i++) { + str[2 + i * 2] = alphabet[uint8(data[i] >> 4)]; + str[3 + i * 2] = alphabet[uint8(data[i] & 0x0f)]; + } + return string(str); + } + + /** + * @notice Converts bytes32 to hex string with 0x prefix + * @param data The bytes32 to convert + * @return The hex string representation + */ + function bytes32ToHexString(bytes32 data) internal pure returns (string memory) { + return bytesToHexString(abi.encodePacked(data)); + } +} + diff --git a/script/utils/ValidatorHelpers.sol b/script/utils/ValidatorHelpers.sol new file mode 100644 index 000000000..dfa386897 --- /dev/null +++ b/script/utils/ValidatorHelpers.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/StdJson.sol"; +import "../../src/interfaces/IEtherFiNodesManager.sol"; +import "../../src/interfaces/IEtherFiNode.sol"; +import "../../src/eigenlayer-interfaces/IEigenPod.sol"; + +/** + * @title Validator Helpers Library + * @notice Utility functions for parsing validator data and resolving pods + * @dev Library functions for validator operations (requires vm context for JSON parsing) + */ +library ValidatorHelpers { + using stdJson for string; + + /** + * @notice Parses validators from JSON data string + * @param jsonData JSON data string (already read from file) + * @param maxValidators Maximum number of validators to parse (prevents infinite loops) + * @return pubkeys Array of validator public keys + * @return ids Array of validator IDs + * @return targetEigenPod EigenPod address extracted from withdrawal credentials + * @return validatorCount Actual number of validators found + */ + function parseValidatorsFromJson( + string memory jsonData, + uint256 maxValidators + ) + internal + view + returns ( + bytes[] memory pubkeys, + uint256[] memory ids, + address targetEigenPod, + uint256 validatorCount + ) + { + // Count validators first (with safety limit) + validatorCount = 0; + for (uint256 i = 0; i < maxValidators; i++) { + string memory basePath = _buildJsonPath(i); + if (!stdJson.keyExists(jsonData, string.concat(basePath, ".pubkey"))) { + break; + } + validatorCount++; + } + + // Return early if no validators found + if (validatorCount == 0) { + pubkeys = new bytes[](0); + ids = new uint256[](0); + targetEigenPod = address(0); + return (pubkeys, ids, targetEigenPod, validatorCount); + } + + // Extract withdrawal credentials from first validator to get EigenPod address + bytes memory withdrawalCredentials = stdJson.readBytes(jsonData, "$[0].withdrawal_credentials"); + targetEigenPod = address(uint160(uint256(bytes32(withdrawalCredentials)))); + + pubkeys = new bytes[](validatorCount); + ids = new uint256[](validatorCount); + + for (uint256 i = 0; i < validatorCount; i++) { + string memory basePath = _buildJsonPath(i); + ids[i] = stdJson.readUint(jsonData, string.concat(basePath, ".id")); + pubkeys[i] = stdJson.readBytes(jsonData, string.concat(basePath, ".pubkey")); + } + } + + /** + * @notice Resolves EigenPod from validator pubkey + * @param nodesManager EtherFiNodesManager contract instance + * @param pubkey Validator public key + * @return etherFiNode EtherFiNode instance + * @return pod EigenPod instance + */ + function resolvePod(IEtherFiNodesManager nodesManager, bytes memory pubkey) + internal + view + returns (IEtherFiNode etherFiNode, IEigenPod pod) + { + bytes32 pkHash = nodesManager.calculateValidatorPubkeyHash(pubkey); + etherFiNode = nodesManager.etherFiNodeFromPubkeyHash(pkHash); + require(address(etherFiNode) != address(0), "ValidatorHelpers: validator not linked"); + pod = etherFiNode.getEigenPod(); + require(address(pod) != address(0), "ValidatorHelpers: node has no pod"); + } + + /** + * @notice Helper to build JSON path for array index + */ + function _buildJsonPath(uint256 index) private pure returns (string memory) { + // Handle single-digit cases (0-9) for performance + if (index < 10) { + bytes memory single = new bytes(1); + single[0] = bytes1(uint8(48 + index)); + return string.concat("$[", string(single), "]"); + } + + // For larger numbers (10+), convert to string + uint256 temp = index; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + temp = index; + while (temp != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(temp % 10))); + temp /= 10; + } + return string.concat("$[", string(buffer), "]"); + } +} + diff --git a/script/utils/export_data.py b/script/utils/export_data.py new file mode 100644 index 000000000..c2a8e8588 --- /dev/null +++ b/script/utils/export_data.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +""" +Export Database Data to JSON + +This script exports operator and EtherFi node data from the PostgreSQL database +to JSON files that can be consumed by Solidity scripts (via DataLoader.sol). + +Usage: + # Export both operators and etherfi-nodes + python export_data.py + + # Export only operators + python export_data.py --operators-only + + # Export only etherfi-nodes + python export_data.py --nodes-only + + # Custom output directory + python export_data.py --output-dir /path/to/output + +Configuration: + Set VALIDATOR_DB environment variable to the PostgreSQL connection string. +""" + +import os +import sys +import json +import argparse +from pathlib import Path +from typing import List, Dict +import psycopg2 + + +def get_db_connection() -> psycopg2.extensions.connection: + """Get PostgreSQL connection from VALIDATOR_DB environment variable.""" + db_url = os.getenv('VALIDATOR_DB') + if not db_url: + raise ValueError("VALIDATOR_DB environment variable not set") + + return psycopg2.connect(db_url) + + +def export_operators(conn: psycopg2.extensions.connection, output_path: Path) -> int: + """ + Export operators from OperatorMetadata table to JSON. + + Args: + conn: PostgreSQL connection + output_path: Path to output JSON file + + Returns: + Number of operators exported + """ + with conn.cursor() as cur: + cur.execute(''' + SELECT "operatorAdress", "operatorName" + FROM "OperatorMetadata" + ORDER BY "operatorName" + ''') + rows = cur.fetchall() + + operators = [] + for addr, name in rows: + operators.append({ + "name": name, + "address": addr.lower() + }) + + data = {"operators": operators} + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, 'w') as f: + json.dump(data, f, indent=2) + + return len(operators) + + +def export_etherfi_nodes(conn: psycopg2.extensions.connection, output_path: Path) -> int: + """ + Export EtherFi node contract addresses from MainnetValidators table to JSON. + + Args: + conn: PostgreSQL connection + output_path: Path to output JSON file + + Returns: + Number of node addresses exported + """ + with conn.cursor() as cur: + cur.execute(''' + SELECT DISTINCT etherfi_node_contract + FROM "MainnetValidators" + WHERE etherfi_node_contract IS NOT NULL + ORDER BY etherfi_node_contract + ''') + rows = cur.fetchall() + + # Preserve original checksum format from DB + addresses = [row[0] for row in rows] + + data = { + "description": "EtherFi node addresses exported from database", + "count": len(addresses), + "addresses": addresses + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, 'w') as f: + json.dump(data, f, indent=2) + + return len(addresses) + + +def main(): + parser = argparse.ArgumentParser( + description='Export database data to JSON for Solidity scripts', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + parser.add_argument( + '--output-dir', + type=Path, + default=Path(__file__).parent.parent / 'data', + help='Output directory for JSON files (default: script/data/)' + ) + + parser.add_argument( + '--operators-only', + action='store_true', + help='Export only operators.json' + ) + + parser.add_argument( + '--nodes-only', + action='store_true', + help='Export only etherfi-nodes.json' + ) + + parser.add_argument( + '--db-url', + help='Override VALIDATOR_DB environment variable' + ) + + args = parser.parse_args() + + # Determine what to export + export_ops = not args.nodes_only + export_nodes = not args.operators_only + + # Connect to database + try: + if args.db_url: + os.environ['VALIDATOR_DB'] = args.db_url + conn = get_db_connection() + print("Connected to database") + except Exception as e: + print(f"Database connection error: {e}", file=sys.stderr) + print("Set VALIDATOR_DB environment variable or use --db-url", file=sys.stderr) + sys.exit(1) + + try: + # Export operators + if export_ops: + operators_path = args.output_dir / 'operators.json' + count = export_operators(conn, operators_path) + print(f"Exported {count} operators to {operators_path}") + + # Export EtherFi nodes + if export_nodes: + nodes_path = args.output_dir / 'etherfi-nodes.json' + count = export_etherfi_nodes(conn, nodes_path) + print(f"Exported {count} EtherFi node addresses to {nodes_path}") + + print("\nDone! JSON files are ready for Solidity scripts.") + + except Exception as e: + print(f"Export error: {e}", file=sys.stderr) + sys.exit(1) + finally: + conn.close() + + +if __name__ == '__main__': + main() + diff --git a/script/utils/utils.sol b/script/utils/utils.sol index 66c4b9daa..723414fe5 100644 --- a/script/utils/utils.sol +++ b/script/utils/utils.sol @@ -26,6 +26,7 @@ contract Utils is Script, Deployed { // ERC1967 storage slot for implementation address bytes32 constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + ICreate2Factory constant mainnetCreate2Factory = ICreate2Factory(0x356d1B83970CeF2018F2c9337cDdb67dff5AEF99); uint256 constant MIN_DELAY_OPERATING_TIMELOCK = 28800; // 8 hours uint256 constant MIN_DELAY_TIMELOCK = 259200; // 72 hours diff --git a/src/EtherFiNode.sol b/src/EtherFiNode.sol index 9b6433ea5..4b892a909 100644 --- a/src/EtherFiNode.sol +++ b/src/EtherFiNode.sol @@ -126,7 +126,9 @@ contract EtherFiNode is IEtherFiNode { if (!anyWithdrawalsCompleted) revert NoCompleteableWithdrawals(); // bad dev experience if function completes but nothing happened // if there are available rewards, forward them to the liquidityPool - uint256 balance = address(this).balance; + uint256 contractBalance = address(this).balance; + uint256 totalValueOutOfLp = liquidityPool.totalValueOutOfLp(); + uint256 balance = contractBalance < totalValueOutOfLp ? contractBalance : totalValueOutOfLp; if (balance > 0) { (bool sent, ) = payable(address(liquidityPool)).call{value: balance, gas: 20000}(""); if (!sent) revert TransferFailed(); @@ -155,7 +157,9 @@ contract EtherFiNode is IEtherFiNode { // @dev under normal operations it is not expected for eth to accumulate in the nodes, // this is just to handle any exceptional cases such as someone sending directly to the node. function sweepFunds() external onlyEtherFiNodesManager returns (uint256 balance) { - uint256 balance = address(this).balance; + uint256 contractBalance = address(this).balance; + uint256 totalValueOutOfLp = liquidityPool.totalValueOutOfLp(); + balance = contractBalance < totalValueOutOfLp ? contractBalance : totalValueOutOfLp; if (balance > 0) { (bool sent, ) = payable(address(liquidityPool)).call{value: balance, gas: 20000}(""); if (!sent) revert TransferFailed(); diff --git a/src/EtherFiNodesManager.sol b/src/EtherFiNodesManager.sol index a5fe7a803..53112cee6 100644 --- a/src/EtherFiNodesManager.sol +++ b/src/EtherFiNodesManager.sol @@ -48,12 +48,14 @@ contract EtherFiNodesManager is bytes32 public constant ETHERFI_NODES_MANAGER_CALL_FORWARDER_ROLE = keccak256("ETHERFI_NODES_MANAGER_CALL_FORWARDER_ROLE"); bytes32 public constant ETHERFI_NODES_MANAGER_EL_TRIGGER_EXIT_ROLE = keccak256("ETHERFI_NODES_MANAGER_EL_TRIGGER_EXIT_ROLE"); bytes32 public constant ETHERFI_NODES_MANAGER_EL_CONSOLIDATION_ROLE = keccak256("ETHERFI_NODES_MANAGER_EL_CONSOLIDATION_ROLE"); + bytes32 public constant ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE = keccak256("ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE"); //------------------------------------------------------------------------- //----------------------------- Rate Limiter Buckets --------------------- //------------------------------------------------------------------------- bytes32 public constant UNRESTAKING_LIMIT_ID = keccak256("UNRESTAKING_LIMIT_ID"); bytes32 public constant EXIT_REQUEST_LIMIT_ID = keccak256("EXIT_REQUEST_LIMIT_ID"); + bytes32 public constant CONSOLIDATION_REQUEST_LIMIT_ID = keccak256("CONSOLIDATION_REQUEST_LIMIT_ID"); // maximum exitable balance in gwei uint256 public constant FULL_EXIT_GWEI = 2_048_000_000_000; @@ -249,7 +251,7 @@ contract EtherFiNodesManager is /** * @notice Triggers EIP-7251 consolidation requests for validators in the same EigenPod. - * @dev Access: only admin role, pausable, nonReentrant. + * @dev Access: only ETHERFI_NODES_MANAGER_EL_CONSOLIDATION_ROLE, pausable, nonReentrant. * @param requests Array of ConsolidationRequest: * - srcPubkey: 48-byte BLS pubkey of source validator * - targetPubkey: 48-byte BLS pubkey of target validator @@ -261,6 +263,10 @@ contract EtherFiNodesManager is if (!roleRegistry.hasRole(ETHERFI_NODES_MANAGER_EL_CONSOLIDATION_ROLE, msg.sender)) revert IncorrectRole(); if (requests.length == 0) revert EmptyConsolidationRequest(); + // rate limit consolidation requests - each request could affect up to FULL_EXIT_GWEI + uint256 totalConsolidationGwei = getTotalConsolidationGwei(requests); + rateLimiter.consume(CONSOLIDATION_REQUEST_LIMIT_ID, SafeCast.toUint64(totalConsolidationGwei)); + // eigenlayer will revert if all validators don't belong to the same pod bytes32 pubKeyHash = calculateValidatorPubkeyHash(requests[0].srcPubkey); IEtherFiNode node = etherFiNodeFromPubkeyHash[pubKeyHash]; @@ -284,6 +290,22 @@ contract EtherFiNodesManager is } } + /// @notice Calculates the total Gwei affected by consolidation requests for rate limiting + /// @dev For true consolidations (src != target), the source validator's full balance is merged + /// For credential switches (src == target), we count 0 as no ETH movement occurs + /// @param requests The consolidation requests to process + /// @return totalGwei The total Gwei to rate limit + function getTotalConsolidationGwei(IEigenPod.ConsolidationRequest[] calldata requests) internal pure returns (uint256 totalGwei) { + for (uint256 i = 0; i < requests.length; ) { + // Only count true consolidations where source validator balance is moved + // Credential switches (src == target) don't move ETH + if (calculateValidatorPubkeyHash(requests[i].srcPubkey) != calculateValidatorPubkeyHash(requests[i].targetPubkey)) { + totalGwei += FULL_EXIT_GWEI; + } + unchecked { ++i; } + } + } + // returns withdrawal fee per each request function getWithdrawalRequestFee(address pod) public view returns (uint256) { return IEigenPod(pod).getWithdrawalRequestFee(); @@ -349,7 +371,7 @@ contract EtherFiNodesManager is /// @dev this method is for linking our old legacy validator ids that were created before /// we started tracking the pubkeys onchain. We can delete this method once we have linked all of our legacy validators - function linkLegacyValidatorIds(uint256[] calldata validatorIds, bytes[] calldata pubkeys) external onlyAdmin { + function linkLegacyValidatorIds(uint256[] calldata validatorIds, bytes[] calldata pubkeys) external onlyLegacyLinker { if (validatorIds.length != pubkeys.length) revert LengthMismatch(); for (uint256 i = 0; i < validatorIds.length; i++) { @@ -460,4 +482,9 @@ contract EtherFiNodesManager is if (!roleRegistry.hasRole(ETHERFI_NODES_MANAGER_POD_PROVER_ROLE, msg.sender)) revert IncorrectRole(); _; } + + modifier onlyLegacyLinker() { + if (!roleRegistry.hasRole(ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE, msg.sender)) revert IncorrectRole(); + _; + } } diff --git a/src/EtherFiRedemptionManager.sol b/src/EtherFiRedemptionManager.sol index 0c1a37935..54fe65abe 100644 --- a/src/EtherFiRedemptionManager.sol +++ b/src/EtherFiRedemptionManager.sol @@ -219,6 +219,9 @@ contract EtherFiRedemptionManager is Initializable, PausableUpgradeable, Reentra uint256 eEthShareFee = eEthShares - sharesToBurn; uint256 feeShareToStakers = eEthShareFee - feeShareToTreasury; + // Common fee handling: Transfer to Treasury + IERC20(address(eEth)).safeTransfer(treasury, eEthFeeAmountToTreasury); + if(outputToken == ETH_ADDRESS) { _processETHRedemption(receiver, eEthAmountToReceiver, sharesToBurn, feeShareToStakers); } else if(outputToken == address(lido)) { @@ -226,8 +229,6 @@ contract EtherFiRedemptionManager is Initializable, PausableUpgradeable, Reentra } else { revert InvalidOutputToken(); } - // Common fee handling: Transfer to Treasury - IERC20(address(eEth)).safeTransfer(treasury, eEthFeeAmountToTreasury); emit Redeemed(receiver, ethAmount, eEthFeeAmountToTreasury, eEthAmountToReceiver, outputToken); } @@ -252,13 +253,15 @@ contract EtherFiRedemptionManager is Initializable, PausableUpgradeable, Reentra */ function totalRedeemableAmount(address token) external view returns (uint256) { uint256 liquidEthAmount = getInstantLiquidityAmount(token); + uint256 lowWatermark = lowWatermarkInETH(token); - if (liquidEthAmount < lowWatermarkInETH(token)) { + if (liquidEthAmount < lowWatermark) { return 0; } + uint256 availableAmount = liquidEthAmount - lowWatermark; uint64 consumableBucketUnits = BucketLimiter.consumable(tokenToRedemptionInfo[token].limit); uint256 consumableAmount = _convertFromBucketUnit(consumableBucketUnits); - return Math.min(consumableAmount, liquidEthAmount); + return Math.min(consumableAmount, availableAmount); } /** diff --git a/src/EtherFiRestaker.sol b/src/EtherFiRestaker.sol index 1ef3c8e6b..a1169d444 100644 --- a/src/EtherFiRestaker.sol +++ b/src/EtherFiRestaker.sol @@ -111,15 +111,26 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable, /// @notice Request for a specific amount of stETH holdings /// @param _amount the amount of stETH to request function stEthRequestWithdrawal(uint256 _amount) public onlyAdmin returns (uint256[] memory) { - if (_amount < lidoWithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT()) revert IncorrectAmount(); + uint256 minAmount = lidoWithdrawalQueue.MIN_STETH_WITHDRAWAL_AMOUNT(); + uint256 maxAmount = lidoWithdrawalQueue.MAX_STETH_WITHDRAWAL_AMOUNT(); + + if (_amount < minAmount) revert IncorrectAmount(); if (_amount > lido.balanceOf(address(this))) revert NotEnoughBalance(); - uint256 maxAmount = lidoWithdrawalQueue.MAX_STETH_WITHDRAWAL_AMOUNT(); uint256 numReqs = (_amount + maxAmount - 1) / maxAmount; uint256[] memory reqAmounts = new uint256[](numReqs); for (uint256 i = 0; i < numReqs; i++) { reqAmounts[i] = (i == numReqs - 1) ? _amount - i * maxAmount : maxAmount; } + + // Ensure the last request meets MIN_STETH_WITHDRAWAL_AMOUNT + // If too small and we have multiple requests, reduce the penultimate to increase the last + if (numReqs > 1 && reqAmounts[numReqs - 1] < minAmount) { + uint256 deficit = minAmount - reqAmounts[numReqs - 1]; + reqAmounts[numReqs - 2] -= deficit; + reqAmounts[numReqs - 1] = minAmount; + } + lido.approve(address(lidoWithdrawalQueue), _amount); uint256[] memory reqIds = lidoWithdrawalQueue.requestWithdrawals(reqAmounts, address(this)); @@ -132,7 +143,6 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable, /// @param _requestIds array of request ids to claim /// @param _hints checkpoint hint for each id. Can be obtained with `findCheckpointHints()` function stEthClaimWithdrawals(uint256[] calldata _requestIds, uint256[] calldata _hints) external onlyAdmin { - uint256 balance = address(this).balance; lidoWithdrawalQueue.claimWithdrawals(_requestIds, _hints); withdrawEther(); @@ -142,7 +152,7 @@ contract EtherFiRestaker is Initializable, UUPSUpgradeable, OwnableUpgradeable, // Send the ETH back to the liquidity pool function withdrawEther() public onlyAdmin { - uint256 amountToLiquidityPool = address(this).balance; + uint256 amountToLiquidityPool = _min(address(this).balance, liquidityPool.totalValueOutOfLp()); (bool sent, ) = payable(address(liquidityPool)).call{value: amountToLiquidityPool, gas: 20000}(""); require(sent, "ETH_SEND_TO_LIQUIDITY_POOL_FAILED"); } diff --git a/src/EtherFiRewardsRouter.sol b/src/EtherFiRewardsRouter.sol index 31ac83bbf..b39691945 100644 --- a/src/EtherFiRewardsRouter.sol +++ b/src/EtherFiRewardsRouter.sol @@ -7,6 +7,7 @@ import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "./RoleRegistry.sol"; +import "./interfaces/ILiquidityPool.sol"; contract EtherFiRewardsRouter is OwnableUpgradeable, UUPSUpgradeable { using SafeERC20 for IERC20; @@ -43,7 +44,9 @@ contract EtherFiRewardsRouter is OwnableUpgradeable, UUPSUpgradeable { function withdrawToLiquidityPool() external { - uint256 balance = address(this).balance; + uint256 contractBalance = address(this).balance; + uint256 totalValueOutOfLp = ILiquidityPool(payable(liquidityPool)).totalValueOutOfLp(); + uint256 balance = contractBalance < totalValueOutOfLp ? contractBalance : totalValueOutOfLp; require(balance > 0, "Contract balance is zero"); (bool success, ) = liquidityPool.call{value: balance}(""); require(success, "TRANSFER_FAILED"); diff --git a/src/LiquidityPool.sol b/src/LiquidityPool.sol index b35361aea..751c16a17 100644 --- a/src/LiquidityPool.sol +++ b/src/LiquidityPool.sol @@ -107,8 +107,8 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL error InsufficientLiquidity(); error SendFail(); error IncorrectRole(); - error InvalidEtherFiNode(); error InvalidValidatorSize(); + error InvalidArrayLengths(); //-------------------------------------------------------------------------------------- //---------------------------- STATE-CHANGING FUNCTIONS ------------------------------ @@ -320,11 +320,7 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL ) external whenNotPaused { if (!roleRegistry.hasRole(LIQUIDITY_POOL_VALIDATOR_APPROVER_ROLE, msg.sender)) revert IncorrectRole(); if (validatorSizeWei < 32 ether || validatorSizeWei > 2048 ether) revert InvalidValidatorSize(); - - // all validators provided should belong to same node - IEtherFiNode etherFiNode = IEtherFiNode(nodesManager.etherfiNodeAddress(_validatorIds[0])); - address eigenPod = address(etherFiNode.getEigenPod()); - bytes memory withdrawalCredentials = nodesManager.addressToCompoundingWithdrawalCredentials(eigenPod); + if (_validatorIds.length == 0 || _validatorIds.length != _pubkeys.length || _validatorIds.length != _signatures.length) revert InvalidArrayLengths(); // we have already deposited the initial amount to create the validator on the beacon chain uint256 remainingEthPerValidator = validatorSizeWei - stakingManager.initialDepositAmount(); @@ -332,9 +328,11 @@ contract LiquidityPool is Initializable, OwnableUpgradeable, UUPSUpgradeable, IL // In order to maintain compatibility with current callers in this upgrade // need to construct data from old format IStakingManager.DepositData[] memory depositData = new IStakingManager.DepositData[](_validatorIds.length); + for (uint256 i = 0; i < _validatorIds.length; i++) { - // enforce that all validators are part of same node - if (address(etherFiNode) != address(nodesManager.etherfiNodeAddress(_validatorIds[i]))) revert InvalidEtherFiNode(); + IEtherFiNode etherFiNode = IEtherFiNode(nodesManager.etherfiNodeAddress(_validatorIds[i])); + address eigenPod = address(etherFiNode.getEigenPod()); + bytes memory withdrawalCredentials = nodesManager.addressToCompoundingWithdrawalCredentials(eigenPod); bytes32 confirmDepositDataRoot = stakingManager.generateDepositDataRoot( _pubkeys[i], diff --git a/src/Liquifier.sol b/src/Liquifier.sol index fa8a99434..2bf1ac376 100644 --- a/src/Liquifier.sol +++ b/src/Liquifier.sol @@ -96,7 +96,6 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab event QueuedStEthWithdrawals(uint256[] _reqIds); event CompletedStEthQueuedWithdrawals(uint256[] _reqIds); - error StrategyShareNotEnough(); error NotSupportedToken(); error EthTransferFailed(); error NotEnoughBalance(); @@ -151,16 +150,22 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab function depositWithERC20(address _token, uint256 _amount, address _referral) public whenNotPaused nonReentrant returns (uint256) { require(isTokenWhitelisted(_token) && (!tokenInfos[_token].isL2Eth || msg.sender == l1SyncPool), "NOT_ALLOWED"); + // Measure actual amount received to handle stETH's 1-2 wei rounding issue + uint256 amountReceived; if (tokenInfos[_token].isL2Eth) { - IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount); + uint256 balanceBefore = IERC20(_token).balanceOf(address(this)); + IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount); + amountReceived = IERC20(_token).balanceOf(address(this)) - balanceBefore; } else { + uint256 balanceBefore = IERC20(_token).balanceOf(address(etherfiRestaker)); IERC20(_token).safeTransferFrom(msg.sender, address(etherfiRestaker), _amount); + amountReceived = IERC20(_token).balanceOf(address(etherfiRestaker)) - balanceBefore; } // The L1SyncPool's `_anticipatedDeposit` should be the only place to mint the `token` and always send its entirety to the Liquifier contract if(tokenInfos[_token].isL2Eth) _L2SanityChecks(_token); - uint256 dx = quoteByDiscountedValue(_token, _amount); + uint256 dx = quoteByDiscountedValue(_token, amountReceived); require(!isDepositCapReached(_token, dx), "CAPPED"); uint256 eEthShare = liquidityPool.depositToRecipient(msg.sender, dx, _referral); @@ -178,7 +183,7 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab // Send the redeemed ETH back to the liquidity pool & Send the fee to Treasury function withdrawEther() external onlyAdmin { - uint256 amountToLiquidityPool = address(this).balance; + uint256 amountToLiquidityPool = _min(address(this).balance, liquidityPool.totalValueOutOfLp()); (bool sent, ) = payable(address(liquidityPool)).call{value: amountToLiquidityPool, gas: 20000}(""); if (!sent) revert EthTransferFailed(); } @@ -212,11 +217,6 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab timeBoundCapRefreshInterval = _timeBoundCapRefreshInterval; } - function pauseDeposits(address _token) external onlyPauser { - tokenInfos[_token].timeBoundCapInEther = 0; - tokenInfos[_token].totalCapInEther = 0; - } - function updateAdmin(address _address, bool _isAdmin) external onlyOwner { admins[_address] = _isAdmin; } @@ -309,7 +309,7 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab } function getTotalPooledEther() public view returns (uint256 total) { - total = address(this).balance + getTotalPooledEther(address(lido)) + getTotalPooledEther(address(cbEth)) + getTotalPooledEther(address(wbEth)); + total = address(this).balance; for (uint256 i = 0; i < dummies.length; i++) { total += getTotalPooledEther(address(dummies[i])); } @@ -323,11 +323,7 @@ contract Liquifier is Initializable, UUPSUpgradeable, OwnableUpgradeable, Pausab TokenInfo memory info = tokenInfos[_token]; if (!isTokenWhitelisted(_token)) return (0, 0, 0); - if (info.strategy != IStrategy(address(0))) { - restaked = quoteByFairValue(_token, info.strategy.sharesToUnderlyingView(info.strategyShare)); /// restaked & pending for withdrawals - } holding = quoteByFairValue(_token, IERC20(_token).balanceOf(address(this))); /// eth value for erc20 holdings - pendingForWithdrawals = info.ethAmountPendingForWithdrawals; /// eth pending for withdrawals } function getTotalPooledEther(address _token) public view returns (uint256) { diff --git a/src/RestakingRewardsRouter.sol b/src/RestakingRewardsRouter.sol new file mode 100644 index 000000000..21cf95197 --- /dev/null +++ b/src/RestakingRewardsRouter.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "./interfaces/IRoleRegistry.sol"; + +contract RestakingRewardsRouter is UUPSUpgradeable { + using SafeERC20 for IERC20; + + address public immutable liquidityPool; + address public immutable rewardTokenAddress; + IRoleRegistry public immutable roleRegistry; + + bytes32 public constant ETHERFI_REWARDS_ROUTER_ADMIN_ROLE = + keccak256("ETHERFI_REWARDS_ROUTER_ADMIN_ROLE"); + + bytes32 public constant ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE = + keccak256("ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE"); + + address public recipientAddress; + + event EthSent(address indexed from, address indexed to, address indexed sender, uint256 value); + event RecipientAddressSet(address indexed recipient); + event Erc20Recovered( + address indexed token, + address indexed recipient, + uint256 amount + ); + + error InvalidAddress(); + error NoRecipientSet(); + error TransferFailed(); + error IncorrectRole(); + + constructor( + address _roleRegistry, + address _rewardTokenAddress, + address _liquidityPool + ) { + _disableInitializers(); + if ( + _rewardTokenAddress == address(0) || + _liquidityPool == address(0) || + _roleRegistry == address(0) + ) revert InvalidAddress(); + roleRegistry = IRoleRegistry(_roleRegistry); + rewardTokenAddress = _rewardTokenAddress; + liquidityPool = _liquidityPool; + } + + receive() external payable { + (bool success, ) = liquidityPool.call{value: msg.value}(""); + if (!success) revert TransferFailed(); + emit EthSent(address(this), liquidityPool, msg.sender, msg.value); + } + + function initialize() public initializer { + __UUPSUpgradeable_init(); + } + + function setRecipientAddress(address _recipient) external { + if ( + !roleRegistry.hasRole(ETHERFI_REWARDS_ROUTER_ADMIN_ROLE, msg.sender) + ) revert IncorrectRole(); + if (_recipient == address(0)) revert InvalidAddress(); + recipientAddress = _recipient; + emit RecipientAddressSet(_recipient); + } + + /// @dev Manual transfer function to recover reward tokens that may have accumulated in the contract + function recoverERC20() external { + if ( + !roleRegistry.hasRole( + ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE, + msg.sender + ) + ) revert IncorrectRole(); + if (recipientAddress == address(0)) revert NoRecipientSet(); + + uint256 balance = IERC20(rewardTokenAddress).balanceOf(address(this)); + if (balance > 0) { + IERC20(rewardTokenAddress).safeTransfer(recipientAddress, balance); + emit Erc20Recovered( + rewardTokenAddress, + recipientAddress, + balance + ); + } + } + + function _authorizeUpgrade( + address /* newImplementation */ + ) internal override { + roleRegistry.onlyProtocolUpgrader(msg.sender); + } + + function getImplementation() external view returns (address) { + return _getImplementation(); + } +} diff --git a/src/WithdrawRequestNFT.sol b/src/WithdrawRequestNFT.sol index b0f3dd310..da4352c6d 100644 --- a/src/WithdrawRequestNFT.sol +++ b/src/WithdrawRequestNFT.sol @@ -281,7 +281,7 @@ contract WithdrawRequestNFT is ERC721Upgradeable, UUPSUpgradeable, OwnableUpgrad require (beforeEEthShares - eEthSharesToMoved == eETH.shares(address(this)), "Invalid eETH shares after remainder handling"); - emit HandledRemainderOfClaimedWithdrawRequests(eEthAmountToTreasury, liquidityPool.amountForShare(eEthSharesToBurn)); + emit HandledRemainderOfClaimedWithdrawRequests(eEthAmountToTreasury, eEthAmountToBurn); } function getEEthRemainderAmount() public view returns (uint256) { diff --git a/src/helpers/EtherFiViewer.sol b/src/helpers/EtherFiViewer.sol index f85419267..db6b32e3d 100644 --- a/src/helpers/EtherFiViewer.sol +++ b/src/helpers/EtherFiViewer.sol @@ -58,12 +58,12 @@ contract EtherFiViewer is Initializable, OwnableUpgradeable, UUPSUpgradeable { } } - function EigenPod_validatorPubkeyHashToInfo(uint256[] memory _validatorIds, bytes[][] memory _validatorPubkeys) external view returns (IEigenPod.ValidatorInfo[][] memory _validatorInfos) { + function EigenPod_validatorPubkeyHashToInfo(uint256[] memory _validatorIds, bytes32[][] memory _validatorPubkeyHashes) external view returns (IEigenPod.ValidatorInfo[][] memory _validatorInfos) { _validatorInfos = new IEigenPod.ValidatorInfo[][](_validatorIds.length); for (uint256 i = 0; i < _validatorIds.length; i++) { - _validatorInfos[i] = new IEigenPod.ValidatorInfo[](_validatorPubkeys[i].length); - for (uint256 j = 0; j < _validatorPubkeys[i].length; j++) { - _validatorInfos[i][j] = _getEigenPod(_validatorIds[i]).validatorPubkeyToInfo(_validatorPubkeys[i][j]); + _validatorInfos[i] = new IEigenPod.ValidatorInfo[](_validatorPubkeyHashes[i].length); + for (uint256 j = 0; j < _validatorPubkeyHashes[i].length; j++) { + _validatorInfos[i][j] = _getEigenPod(_validatorIds[i]).validatorPubkeyHashToInfo(_validatorPubkeyHashes[i][j]); } } } diff --git a/src/interfaces/IEtherFiNodesManager.sol b/src/interfaces/IEtherFiNodesManager.sol index 1ac85bf19..fd544cb60 100644 --- a/src/interfaces/IEtherFiNodesManager.sol +++ b/src/interfaces/IEtherFiNodesManager.sol @@ -45,6 +45,7 @@ interface IEtherFiNodesManager { // rate limiting constants function UNRESTAKING_LIMIT_ID() external view returns (bytes32); function EXIT_REQUEST_LIMIT_ID() external view returns (bytes32); + function CONSOLIDATION_REQUEST_LIMIT_ID() external view returns (bytes32); // call forwarding function updateAllowedForwardedExternalCalls(address user, bytes4 selector, address target, bool allowed) external; diff --git a/test/DepositAdapter.t.sol b/test/DepositAdapter.t.sol index d11892d78..dd3ed4926 100644 --- a/test/DepositAdapter.t.sol +++ b/test/DepositAdapter.t.sol @@ -9,7 +9,7 @@ contract DepositAdapterTest is TestSetup { event Deposit(address indexed sender, uint256 amount, uint8 source, address referral); - DepositAdapter depositAdapterInstance; + // DepositAdapter depositAdapterInstance; IWETH public wETH; IERC20Upgradeable public stETHmainnet; @@ -22,21 +22,21 @@ contract DepositAdapterTest is TestSetup { stETHmainnet = IERC20Upgradeable(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); wstETHmainnet = IwstETH(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0); - // deploying+initializing the deposit adapter - address depositAdapterImpl = address( - new DepositAdapter( - address(liquidityPoolInstance), - address(liquifierInstance), - address(weEthInstance), - address(eETHInstance), - address(wETH), - address(stETHmainnet), - address(wstETHmainnet) - ) - ); - address depositAdapterProxy = address(new UUPSProxy(depositAdapterImpl, "")); - depositAdapterInstance = DepositAdapter(payable(depositAdapterProxy)); - depositAdapterInstance.initialize(); + // // deploying+initializing the deposit adapter + // address depositAdapterImpl = address( + // new DepositAdapter( + // address(liquidityPoolInstance), + // address(liquifierInstance), + // address(weEthInstance), + // address(eETHInstance), + // address(wETH), + // address(stETHmainnet), + // address(wstETHmainnet) + // ) + // ); + // address depositAdapterProxy = address(new UUPSProxy(depositAdapterImpl, "")); + // depositAdapterInstance = DepositAdapter(payable(depositAdapterProxy)); + // depositAdapterInstance.initialize(); vm.startPrank(owner); diff --git a/test/EtherFiNodesManager.t.sol b/test/EtherFiNodesManager.t.sol index 87eede33d..8852e5354 100644 --- a/test/EtherFiNodesManager.t.sol +++ b/test/EtherFiNodesManager.t.sol @@ -34,7 +34,9 @@ contract EtherFiNodesManagerTest is TestSetup { podProver = vm.addr(101); callForwarder = vm.addr(102); elTriggerExit = vm.addr(103); - + + address nodesManagerImplementation = address(new EtherFiNodesManager(address(stakingManagerInstance), address(roleRegistryInstance), address(rateLimiterInstance))); + vm.startPrank(managerInstance.owner()); roleRegistryInstance.grantRole(managerInstance.ETHERFI_NODES_MANAGER_ADMIN_ROLE(), admin); roleRegistryInstance.grantRole(managerInstance.ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE(), eigenlayerAdmin); @@ -46,6 +48,8 @@ contract EtherFiNodesManagerTest is TestSetup { roleRegistryInstance.grantRole(roleRegistryInstance.PROTOCOL_UNPAUSER(), admin); roleRegistryInstance.grantRole(rateLimiterInstance.ETHERFI_RATE_LIMITER_ADMIN_ROLE(), admin); roleRegistryInstance.grantRole(managerInstance.ETHERFI_NODES_MANAGER_EIGENLAYER_ADMIN_ROLE(), deployed.STAKING_MANAGER()); + managerInstance.upgradeTo(nodesManagerImplementation); + roleRegistryInstance.grantRole(managerInstance.ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE(), elTriggerExit); vm.stopPrank(); // Setup rate limiter - check if limiters already exist before creating @@ -57,9 +61,14 @@ contract EtherFiNodesManagerTest is TestSetup { if (!rateLimiterInstance.limitExists(managerInstance.EXIT_REQUEST_LIMIT_ID())) { rateLimiterInstance.createNewLimiter(managerInstance.EXIT_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); } + + if (!rateLimiterInstance.limitExists(managerInstance.CONSOLIDATION_REQUEST_LIMIT_ID())) { + rateLimiterInstance.createNewLimiter(managerInstance.CONSOLIDATION_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); + } rateLimiterInstance.updateConsumers(managerInstance.UNRESTAKING_LIMIT_ID(), address(managerInstance), true); rateLimiterInstance.updateConsumers(managerInstance.EXIT_REQUEST_LIMIT_ID(), address(managerInstance), true); + rateLimiterInstance.updateConsumers(managerInstance.CONSOLIDATION_REQUEST_LIMIT_ID(), address(managerInstance), true); vm.stopPrank(); // // Create a proper beacon and upgrade the node implementation @@ -177,7 +186,11 @@ contract EtherFiNodesManagerTest is TestSetup { function test_EXIT_REQUEST_LIMIT_ID() public view { assertEq(managerInstance.EXIT_REQUEST_LIMIT_ID(), keccak256("EXIT_REQUEST_LIMIT_ID")); } - + + function test_CONSOLIDATION_REQUEST_LIMIT_ID() public view { + assertEq(managerInstance.CONSOLIDATION_REQUEST_LIMIT_ID(), keccak256("CONSOLIDATION_REQUEST_LIMIT_ID")); + } + function test_FULL_EXIT_GWEI() public view { assertEq(managerInstance.FULL_EXIT_GWEI(), 2_048_000_000_000); } @@ -520,7 +533,7 @@ contract EtherFiNodesManagerTest is TestSetup { legacyIds[0] = 28689; amounts[0] = 0; - vm.prank(deployed.OPERATING_TIMELOCK()); + vm.prank(elTriggerExit); managerInstance.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -597,7 +610,7 @@ contract EtherFiNodesManagerTest is TestSetup { bytes[] memory pubkeysForOneValidator = new bytes[](1); pubkeysForOneValidator[0] = PK_80143; - vm.prank(deployed.OPERATING_TIMELOCK()); + vm.prank(elTriggerExit); managerInstance.linkLegacyValidatorIds(legacyIdsForOneValidator, pubkeysForOneValidator); vm.stopPrank(); diff --git a/test/EtherFiRedemptionManager.t.sol b/test/EtherFiRedemptionManager.t.sol index 527c46f30..1efc4f05c 100644 --- a/test/EtherFiRedemptionManager.t.sol +++ b/test/EtherFiRedemptionManager.t.sol @@ -756,7 +756,7 @@ contract EtherFiRedemptionManagerTest is TestSetup { uint256 totalValueOutOfLpAfter = liquidityPoolInstance.totalValueOutOfLp(); uint256 totalValueInLpAfter = liquidityPoolInstance.totalValueInLp(); uint256 treasuryBalanceAfter = eETHInstance.balanceOf(address(etherFiRedemptionManagerInstance.treasury())); - assertApproxEqAbs(stethBalanceAfter - stethBalanceBefore, 1 ether, 3); + assertApproxEqAbs(stethBalanceAfter - stethBalanceBefore, 1 ether, 5); assertApproxEqAbs(totalSharesBefore - totalSharesAfter, sharesFor_999_ether, 1); assertApproxEqAbs(totalValueOutOfLpBefore - totalValueOutOfLpAfter, 1 ether, 3); assertEq(totalValueInLpAfter- totalValueInLpBefore, 0); diff --git a/test/EtherFiRewardsRouter.t.sol b/test/EtherFiRewardsRouter.t.sol index e7d41ccd5..659365fb3 100644 --- a/test/EtherFiRewardsRouter.t.sol +++ b/test/EtherFiRewardsRouter.t.sol @@ -8,6 +8,16 @@ import "../src/UUPSProxy.sol"; import "./TestERC20.sol"; import "./TestERC721.sol"; +contract MockLiquidityPool { + uint128 public totalValueOutOfLp; + + function setTotalValueOutOfLp(uint128 _value) external { + totalValueOutOfLp = _value; + } + + receive() external payable {} +} + contract EtherFiRewardsRouterTest is Test { EtherFiRewardsRouter public rewardsRouter; EtherFiRewardsRouter public rewardsRouterImpl; @@ -15,6 +25,8 @@ contract EtherFiRewardsRouterTest is Test { RoleRegistry public roleRegistry; RoleRegistry public roleRegistryImpl; UUPSProxy public roleRegistryProxy; + + MockLiquidityPool public mockLiquidityPool; TestERC20 public testToken; TestERC721 public testNFT; @@ -22,7 +34,7 @@ contract EtherFiRewardsRouterTest is Test { address public owner = vm.addr(1); address public admin = vm.addr(2); address public unauthorizedUser = vm.addr(3); - address public liquidityPool = vm.addr(4); + address public liquidityPool; address public treasury = vm.addr(5); address public user = vm.addr(6); @@ -42,6 +54,9 @@ contract EtherFiRewardsRouterTest is Test { abi.encodeWithSelector(RoleRegistry.initialize.selector, owner) ); roleRegistry = RoleRegistry(address(roleRegistryProxy)); + + mockLiquidityPool = new MockLiquidityPool(); + liquidityPool = address(mockLiquidityPool); // Deploy EtherFiRewardsRouter implementation rewardsRouterImpl = new EtherFiRewardsRouter( @@ -142,6 +157,9 @@ contract EtherFiRewardsRouterTest is Test { uint256 amount = 10 ether; vm.deal(address(rewardsRouter), amount); + // Set totalValueOutOfLp to allow full withdrawal + mockLiquidityPool.setTotalValueOutOfLp(uint128(amount)); + uint256 initialLiquidityPoolBalance = liquidityPool.balance; vm.expectEmit(true, true, false, true); @@ -152,10 +170,43 @@ contract EtherFiRewardsRouterTest is Test { assertEq(address(rewardsRouter).balance, 0); assertEq(liquidityPool.balance, initialLiquidityPoolBalance + amount); } + + function test_withdrawToLiquidityPool_cappedByTotalValueOutOfLp() public { + uint256 contractBalance = 10 ether; + uint128 totalValueOutOfLpAmount = 3 ether; + vm.deal(address(rewardsRouter), contractBalance); + + mockLiquidityPool.setTotalValueOutOfLp(totalValueOutOfLpAmount); + + uint256 initialLiquidityPoolBalance = liquidityPool.balance; + + vm.expectEmit(true, true, false, true); + emit EthSent(address(rewardsRouter), liquidityPool, totalValueOutOfLpAmount); + + rewardsRouter.withdrawToLiquidityPool(); + + // Only totalValueOutOfLp amount should be withdrawn + assertEq(address(rewardsRouter).balance, contractBalance - totalValueOutOfLpAmount); + assertEq(liquidityPool.balance, initialLiquidityPoolBalance + totalValueOutOfLpAmount); + } function test_withdrawToLiquidityPool_revertsWhenBalanceIsZero() public { assertEq(address(rewardsRouter).balance, 0); + // Even with high totalValueOutOfLp, should revert if contract balance is 0 + mockLiquidityPool.setTotalValueOutOfLp(type(uint128).max); + + vm.expectRevert("Contract balance is zero"); + rewardsRouter.withdrawToLiquidityPool(); + } + + function test_withdrawToLiquidityPool_revertsWhenTotalValueOutOfLpIsZero() public { + uint256 amount = 5 ether; + vm.deal(address(rewardsRouter), amount); + + // totalValueOutOfLp is 0, so min(balance, 0) = 0 + mockLiquidityPool.setTotalValueOutOfLp(0); + vm.expectRevert("Contract balance is zero"); rewardsRouter.withdrawToLiquidityPool(); } @@ -164,6 +215,8 @@ contract EtherFiRewardsRouterTest is Test { uint256 amount = 5 ether; vm.deal(address(rewardsRouter), amount); + mockLiquidityPool.setTotalValueOutOfLp(uint128(amount)); + vm.prank(unauthorizedUser); rewardsRouter.withdrawToLiquidityPool(); @@ -176,6 +229,8 @@ contract EtherFiRewardsRouterTest is Test { uint256 amount2 = 3 ether; vm.deal(address(rewardsRouter), amount1 + amount2); + mockLiquidityPool.setTotalValueOutOfLp(uint128(amount1 + amount2)); + uint256 balanceBefore = address(rewardsRouter).balance; rewardsRouter.withdrawToLiquidityPool(); @@ -421,6 +476,8 @@ contract EtherFiRewardsRouterTest is Test { vm.deal(address(rewardsRouter), ethAmount); testToken.mint(address(rewardsRouter), tokenAmount); + mockLiquidityPool.setTotalValueOutOfLp(uint128(ethAmount)); + // Recover ERC20 first vm.prank(admin); rewardsRouter.recoverERC20(address(testToken), tokenAmount); @@ -458,6 +515,9 @@ contract EtherFiRewardsRouterTest is Test { testToken.mint(address(rewardsRouter), 500 ether); uint256 tokenId = testNFT.mint(address(rewardsRouter)); + // Set totalValueOutOfLp to allow full withdrawal + mockLiquidityPool.setTotalValueOutOfLp(uint128(10 ether)); + // Withdraw ETH rewardsRouter.withdrawToLiquidityPool(); assertEq(address(rewardsRouter).balance, 0); diff --git a/test/EtherFiTimelock.t.sol b/test/EtherFiTimelock.t.sol index 72e935ea6..902c211e7 100644 --- a/test/EtherFiTimelock.t.sol +++ b/test/EtherFiTimelock.t.sol @@ -356,6 +356,31 @@ contract TimelockTest is TestSetup { assertEq(roleRegistryInstance.owner(), testOwner); assertEq(roleRegistryInstance.pendingOwner(), address(0)); } + + function test_grant_ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE() public { + initializeRealisticFork(MAINNET_FORK); + roleRegistryInstance = RoleRegistry(address(0x62247D29B4B9BECf4BB73E0c722cf6445cfC7cE9)); + + // Define the role and wallet address + bytes32 role = keccak256("ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE"); + address wallet = address(0x12582A27E5e19492b4FcD194a60F8f5e1aa31B0F); + + // Verify the wallet doesn't have the role initially (optional check) + bool hasRoleBefore = roleRegistryInstance.hasRole(role, wallet); + console2.log("Wallet has role before:", hasRoleBefore); + + // Grant the role via timelock + bytes memory grantRoleData = abi.encodeWithSelector(RoleRegistry.grantRole.selector, role, wallet); + _execute_timelock(address(roleRegistryInstance), grantRoleData, true, true, true, true); + + // Verify the wallet now has the role + bool hasRoleAfter = roleRegistryInstance.hasRole(role, wallet); + assertTrue(hasRoleAfter, "Wallet should have ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE after timelock execution"); + + // Log the result for verification + console2.log("Role granted successfully to:", wallet); + console2.logBytes32(role); + } } diff --git a/test/Liquifier.t.sol b/test/Liquifier.t.sol index 8e8281b21..a08cb82cd 100644 --- a/test/Liquifier.t.sol +++ b/test/Liquifier.t.sol @@ -105,7 +105,7 @@ contract LiquifierTest is TestSetup { uint256 aliceQuotedEETH = liquifierInstance.quoteByDiscountedValue(address(stEth), 10 ether); // alice will actually receive 1 wei less due to the infamous 1 wei rounding corner case - assertApproxEqAbs(eETHInstance.balanceOf(alice), aliceQuotedEETH, 1); + assertApproxEqAbs(eETHInstance.balanceOf(alice), aliceQuotedEETH, 1e1); } function test_deopsit_stEth_and_swap() internal { diff --git a/test/RestakingRewardsRouter.t.sol b/test/RestakingRewardsRouter.t.sol new file mode 100644 index 000000000..b8bec9fe4 --- /dev/null +++ b/test/RestakingRewardsRouter.t.sol @@ -0,0 +1,502 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/RestakingRewardsRouter.sol"; +import "../src/RoleRegistry.sol"; +import "../src/UUPSProxy.sol"; +import "../src/LiquidityPool.sol"; +import "./TestERC20.sol"; +import "../src/interfaces/ILiquidityPool.sol"; + +contract RestakingRewardsRouterTest is Test { + RestakingRewardsRouter public router; + RestakingRewardsRouter public routerImpl; + UUPSProxy public proxy; + RoleRegistry public roleRegistry; + RoleRegistry public roleRegistryImpl; + UUPSProxy public roleRegistryProxy; + TestERC20 public rewardToken; + TestERC20 public otherToken; + LiquidityPool public liquidityPool; + LiquidityPool public liquidityPoolImpl; + UUPSProxy public liquidityPoolProxy; + address public owner = vm.addr(1); + address public admin = vm.addr(2); + address public transferRoleUser = vm.addr(7); + address public unauthorizedUser = vm.addr(3); + address public recipient = vm.addr(5); + address public user = vm.addr(6); + + bytes32 public constant ETHERFI_REWARDS_ROUTER_ADMIN_ROLE = + keccak256("ETHERFI_REWARDS_ROUTER_ADMIN_ROLE"); + bytes32 public constant ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE = + keccak256("ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE"); + + event EthSent(address indexed from, address indexed to, address indexed sender, uint256 value); + event RecipientAddressSet(address indexed recipient); + event Erc20Recovered( + address indexed token, + address indexed recipient, + uint256 amount + ); + + function setUp() public { + // Deploy RoleRegistry + vm.startPrank(owner); + roleRegistryImpl = new RoleRegistry(); + roleRegistryProxy = new UUPSProxy( + address(roleRegistryImpl), + abi.encodeWithSelector(RoleRegistry.initialize.selector, owner) + ); + roleRegistry = RoleRegistry(address(roleRegistryProxy)); + + // Deploy tokens + rewardToken = new TestERC20("Reward Token", "RWD"); + otherToken = new TestERC20("Other Token", "OTH"); + + // Deploy LiquidityPool + liquidityPoolImpl = new LiquidityPool(); + liquidityPoolProxy = new UUPSProxy(address(liquidityPoolImpl), ""); + liquidityPool = LiquidityPool(payable(address(liquidityPoolProxy))); + + // Set initial totalValueOutOfLp to allow receive() to work + // Storage slot 207: totalValueOutOfLp (uint128, offset 0) + totalValueInLp (uint128, offset 16) + // The receive() function does: totalValueOutOfLp -= uint128(msg.value) and totalValueInLp += uint128(msg.value) + // Set: totalValueOutOfLp = 1000 ether (lower 16 bytes), totalValueInLp = 0 (upper 16 bytes) + bytes32 value = bytes32(uint256(1000 ether)); // Lower 16 bytes = 1000 ether, upper 16 bytes = 0 + vm.store(address(liquidityPool), bytes32(uint256(207)), value); + + // Deploy RestakingRewardsRouter implementation + routerImpl = new RestakingRewardsRouter( + address(roleRegistry), + address(rewardToken), + address(liquidityPool) + ); + + // Grant admin role + roleRegistry.grantRole(ETHERFI_REWARDS_ROUTER_ADMIN_ROLE, admin); + // Grant transfer role + roleRegistry.grantRole( + ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE, + admin + ); + roleRegistry.grantRole( + ETHERFI_REWARDS_ROUTER_ERC20_TRANSFER_ROLE, + transferRoleUser + ); + vm.stopPrank(); + + // Deploy proxy and initialize (outside prank so owner is address(this)) + proxy = new UUPSProxy( + address(routerImpl), + abi.encodeWithSelector(RestakingRewardsRouter.initialize.selector) + ); + router = RestakingRewardsRouter(payable(address(proxy))); + } + + // ============ Constructor Tests ============ + + function test_constructor_setsImmutableValues() public { + assertEq(router.rewardTokenAddress(), address(rewardToken)); + assertEq(router.liquidityPool(), address(liquidityPool)); + assertEq(address(router.roleRegistry()), address(roleRegistry)); + } + + function test_constructor_revertsWithZeroRewardToken() public { + vm.expectRevert(RestakingRewardsRouter.InvalidAddress.selector); + new RestakingRewardsRouter( + address(roleRegistry), + address(0), + address(liquidityPool) + ); + } + + function test_constructor_revertsWithZeroLiquidityPool() public { + vm.expectRevert(RestakingRewardsRouter.InvalidAddress.selector); + new RestakingRewardsRouter( + address(roleRegistry), + address(rewardToken), + address(0) + ); + } + + function test_constructor_revertsWithZeroRoleRegistry() public { + vm.expectRevert(RestakingRewardsRouter.InvalidAddress.selector); + new RestakingRewardsRouter( + address(0), + address(rewardToken), + address(liquidityPool) + ); + } + + function test_constructor_disablesInitializers() public { + vm.expectRevert(); + routerImpl.initialize(); + } + + // ============ Initialization Tests ============ + + function test_initialize_canOnlyBeCalledOnce() public { + vm.expectRevert("Initializable: contract is already initialized"); + router.initialize(); + } + + // ============ Receive ETH Tests ============ + + function test_receive_emitsEthSentEvent() public { + vm.deal(user, 10 ether); + + vm.expectEmit(true, true, true, true); + emit EthSent(address(router), address(liquidityPool), user, 10 ether); + + vm.prank(user); + (bool success, ) = address(router).call{value: 10 ether}(""); + assertTrue(success); + } + + function test_receive_forwardsEthToLiquidityPool() public { + uint256 amount = 10 ether; + vm.deal(user, amount); + uint256 initialLiquidityPoolBalance = address(liquidityPool).balance; + uint256 initialTotalValueInLp = liquidityPool.totalValueInLp(); + uint256 initialTotalValueOutOfLp = liquidityPool.totalValueOutOfLp(); + + vm.expectEmit(true, true, true, true); + emit EthSent(address(router), address(liquidityPool), user, amount); + + vm.prank(user); + (bool success, ) = address(router).call{value: amount}(""); + assertTrue(success); + + uint256 totalValueInLp = liquidityPool.totalValueInLp(); + uint256 totalValueOutOfLp = liquidityPool.totalValueOutOfLp(); + + assertEq(address(router).balance, 0); + assertEq(address(liquidityPool).balance, initialLiquidityPoolBalance + amount); + assertEq(totalValueInLp, initialTotalValueInLp + amount); + // totalValueOutOfLp decreases by amount (real LiquidityPool will revert if underflow) + assertEq(totalValueOutOfLp, initialTotalValueOutOfLp - amount); + } + + function test_receive_handlesMultipleDeposits() public { + vm.deal(user, 20 ether); + uint256 initialLiquidityPoolBalance = address(liquidityPool).balance; + + vm.prank(user); + (bool success1, ) = address(router).call{value: 5 ether}(""); + assertTrue(success1); + + vm.prank(user); + (bool success2, ) = address(router).call{value: 10 ether}(""); + assertTrue(success2); + + assertEq(address(router).balance, 0); + assertEq(address(liquidityPool).balance, initialLiquidityPoolBalance + 15 ether); + } + + function test_receive_revertsIfLiquidityPoolTransferFails() public { + // Create a contract that will revert on receive + RevertingReceiver revertingPool = new RevertingReceiver(); + + // Deploy new router with reverting pool + RestakingRewardsRouter newRouterImpl = new RestakingRewardsRouter( + address(roleRegistry), + address(rewardToken), + address(revertingPool) + ); + + UUPSProxy newProxy = new UUPSProxy( + address(newRouterImpl), + abi.encodeWithSelector(RestakingRewardsRouter.initialize.selector) + ); + RestakingRewardsRouter newRouter = RestakingRewardsRouter( + payable(address(newProxy)) + ); + + vm.deal(user, 10 ether); + vm.prank(user); + vm.expectRevert(RestakingRewardsRouter.TransferFailed.selector); + address(newRouter).call{value: 10 ether}(""); + } + + // ============ Set Recipient Address Tests ============ + + function test_setRecipientAddress_success() public { + vm.prank(admin); + router.setRecipientAddress(recipient); + + assertEq(router.recipientAddress(), recipient); + } + + function test_setRecipientAddress_emitsEvent() public { + vm.expectEmit(true, false, false, false); + emit RecipientAddressSet(recipient); + + vm.prank(admin); + router.setRecipientAddress(recipient); + } + + function test_setRecipientAddress_revertsWithoutRole() public { + vm.prank(unauthorizedUser); + vm.expectRevert(RestakingRewardsRouter.IncorrectRole.selector); + router.setRecipientAddress(recipient); + } + + function test_setRecipientAddress_revertsWithZeroAddress() public { + vm.prank(admin); + vm.expectRevert(RestakingRewardsRouter.InvalidAddress.selector); + router.setRecipientAddress(address(0)); + } + + function test_setRecipientAddress_canUpdateRecipient() public { + address newRecipient = vm.addr(100); + + vm.startPrank(admin); + router.setRecipientAddress(recipient); + assertEq(router.recipientAddress(), recipient); + + router.setRecipientAddress(newRecipient); + assertEq(router.recipientAddress(), newRecipient); + vm.stopPrank(); + } + + // ============ recoverERC20 Tests ============ + + function test_recoverERC20_forwardsBalance() public { + vm.startPrank(admin); + router.setRecipientAddress(recipient); + vm.stopPrank(); + + uint256 amount = 1000 ether; + rewardToken.mint(address(router), amount); + + uint256 initialRecipientBalance = rewardToken.balanceOf(recipient); + + vm.expectEmit(true, true, false, true); + emit Erc20Recovered(address(rewardToken), recipient, amount); + + vm.prank(admin); + router.recoverERC20(); + + assertEq(rewardToken.balanceOf(address(router)), 0); + assertEq( + rewardToken.balanceOf(recipient), + initialRecipientBalance + amount + ); + } + + function test_recoverERC20_revertsWhenNoRecipientSet() public { + uint256 amount = 1000 ether; + rewardToken.mint(address(router), amount); + + vm.prank(admin); + vm.expectRevert(RestakingRewardsRouter.NoRecipientSet.selector); + router.recoverERC20(); + } + + function test_recoverERC20_handlesZeroBalance() public { + vm.startPrank(admin); + router.setRecipientAddress(recipient); + vm.stopPrank(); + + // Should not revert with zero balance + vm.prank(admin); + router.recoverERC20(); + + assertEq(rewardToken.balanceOf(address(router)), 0); + } + + function test_recoverERC20_requiresRole() public { + vm.startPrank(admin); + router.setRecipientAddress(recipient); + vm.stopPrank(); + + uint256 amount = 500 ether; + rewardToken.mint(address(router), amount); + + vm.prank(unauthorizedUser); + vm.expectRevert(RestakingRewardsRouter.IncorrectRole.selector); + router.recoverERC20(); + + // Should still have tokens since transfer failed + assertEq(rewardToken.balanceOf(address(router)), amount); + } + + function test_recoverERC20_withTransferRole() public { + vm.startPrank(admin); + router.setRecipientAddress(recipient); + vm.stopPrank(); + + uint256 amount = 500 ether; + rewardToken.mint(address(router), amount); + + vm.prank(transferRoleUser); + router.recoverERC20(); + + assertEq(rewardToken.balanceOf(address(router)), 0); + assertEq(rewardToken.balanceOf(recipient), amount); + } + + function test_recoverERC20_handlesPartialTransfers() public { + vm.startPrank(admin); + router.setRecipientAddress(recipient); + vm.stopPrank(); + + uint256 amount1 = 500 ether; + uint256 amount2 = 300 ether; + rewardToken.mint(address(router), amount1); + + vm.prank(admin); + router.recoverERC20(); + assertEq(rewardToken.balanceOf(recipient), amount1); + + rewardToken.mint(address(router), amount2); + vm.prank(admin); + router.recoverERC20(); + assertEq(rewardToken.balanceOf(recipient), amount1 + amount2); + } + + // ============ Role Management Tests ============ + + function test_roleManagement_grantAndRevoke() public { + address newAdmin = vm.addr(100); + + // Grant role + vm.prank(owner); + roleRegistry.grantRole(ETHERFI_REWARDS_ROUTER_ADMIN_ROLE, newAdmin); + + vm.prank(newAdmin); + router.setRecipientAddress(recipient); + assertEq(router.recipientAddress(), recipient); + + // Revoke role + vm.prank(owner); + roleRegistry.revokeRole(ETHERFI_REWARDS_ROUTER_ADMIN_ROLE, newAdmin); + + vm.prank(newAdmin); + vm.expectRevert(RestakingRewardsRouter.IncorrectRole.selector); + router.setRecipientAddress(vm.addr(101)); + } + + // ============ Upgrade Tests ============ + + function test_upgrade_onlyProtocolUpgrader() public { + RestakingRewardsRouter newImpl = new RestakingRewardsRouter( + address(roleRegistry), + address(rewardToken), + address(liquidityPool) + ); + + // Unauthorized user cannot upgrade + vm.prank(unauthorizedUser); + vm.expectRevert(); + router.upgradeTo(address(newImpl)); + + // Owner of RoleRegistry (protocol upgrader) can upgrade + vm.prank(owner); + router.upgradeTo(address(newImpl)); + + assertEq(router.getImplementation(), address(newImpl)); + } + + function test_getImplementation_returnsCurrentImplementation() public { + address impl = router.getImplementation(); + assertEq(impl, address(routerImpl)); + } + + function test_upgrade_preservesState() public { + // Set up some state + uint256 ethAmount = 5 ether; + uint256 tokenAmount = 100 ether; + vm.deal(address(router), ethAmount); + rewardToken.mint(address(router), tokenAmount); + + vm.startPrank(admin); + router.setRecipientAddress(recipient); + vm.stopPrank(); + + RestakingRewardsRouter newImpl = new RestakingRewardsRouter( + address(roleRegistry), + address(rewardToken), + address(liquidityPool) + ); + + // Owner of RoleRegistry (protocol upgrader) can upgrade + vm.prank(owner); + router.upgradeTo(address(newImpl)); + + // State should be preserved + assertEq(address(router).balance, ethAmount); + assertEq(rewardToken.balanceOf(address(router)), tokenAmount); + assertEq(router.rewardTokenAddress(), address(rewardToken)); + assertEq(router.liquidityPool(), address(liquidityPool)); + assertEq(router.recipientAddress(), recipient); + } + + // ============ Edge Cases ============ + + function test_multipleOperations_sequence() public { + // Set recipient + vm.prank(admin); + router.setRecipientAddress(recipient); + + // Send ETH + vm.deal(user, 10 ether); + vm.prank(user); + (bool success, ) = address(router).call{value: 10 ether}(""); + assertTrue(success); + assertEq(address(liquidityPool).balance, 10 ether); + + // Transfer tokens (they will accumulate since there's no hook) + uint256 tokenAmount = 500 ether; + rewardToken.mint(user, tokenAmount); + vm.prank(user); + rewardToken.transfer(address(router), tokenAmount); + assertEq(rewardToken.balanceOf(address(router)), tokenAmount); + + // Manual transfer to recover accumulated tokens + vm.prank(admin); + router.recoverERC20(); + assertEq(rewardToken.balanceOf(recipient), tokenAmount); + + // Manual transfer for additional tokens + uint256 manualAmount = 200 ether; + rewardToken.mint(address(router), manualAmount); + vm.prank(admin); + router.recoverERC20(); + assertEq(rewardToken.balanceOf(recipient), tokenAmount + manualAmount); + } + + function test_receiveAndTransfer_combined() public { + vm.startPrank(admin); + router.setRecipientAddress(recipient); + vm.stopPrank(); + + // Send ETH and tokens simultaneously + vm.deal(user, 10 ether); + uint256 tokenAmount = 1000 ether; + rewardToken.mint(user, tokenAmount); + + vm.startPrank(user); + (bool success, ) = address(router).call{value: 10 ether}(""); + assertTrue(success); + rewardToken.transfer(address(router), tokenAmount); + vm.stopPrank(); + + assertEq(address(liquidityPool).balance, 10 ether); + // Tokens accumulate since there's no hook + assertEq(rewardToken.balanceOf(address(router)), tokenAmount); + + // Manual transfer to recover tokens + vm.prank(admin); + router.recoverERC20(); + assertEq(rewardToken.balanceOf(recipient), tokenAmount); + } +} + +contract RevertingReceiver { + receive() external payable { + revert("Reverting receiver"); + } +} diff --git a/test/TestSetup.sol b/test/TestSetup.sol index 14f3c880f..1647fa402 100644 --- a/test/TestSetup.sol +++ b/test/TestSetup.sol @@ -59,6 +59,9 @@ import "../src/EtherFiRewardsRouter.sol"; import "../src/CumulativeMerkleRewardsDistributor.sol"; import "../script/deploys/Deployed.s.sol"; +import "../src/DepositAdapter.sol"; +import "../src/interfaces/IWeETHWithdrawAdapter.sol"; + contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { event Schedule(address target, uint256 value, bytes data, bytes32 predecessor, bytes32 salt, uint256 delay); @@ -196,6 +199,12 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { EtherFiAdmin public etherFiAdminImplementation; EtherFiAdmin public etherFiAdminInstance; + DepositAdapter public depositAdapterImplementation; + DepositAdapter public depositAdapterInstance; + + IWeETHWithdrawAdapter public weEthWithdrawAdapterInstance; + IWeETHWithdrawAdapter public weEthWithdrawAdapterImplementation; + EtherFiRewardsRouter public etherFiRewardsRouterInstance = EtherFiRewardsRouter(payable(0x73f7b1184B5cD361cC0f7654998953E2a251dd58)); EtherFiNode public node; @@ -236,6 +245,7 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { address liquidityPool = vm.addr(9); address shonee = vm.addr(1200); address jess = vm.addr(1201); + address tom = vm.addr(1202); address committeeMember = address(0x12582A27E5e19492b4FcD194a60F8f5e1aa31B0F); address timelock = address(0x9f26d4C958fD811A1F59B01B86Be7dFFc9d20761); address buybackWallet = address(0x2f5301a3D59388c509C65f8698f521377D41Fd0F); @@ -367,6 +377,9 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { eigenLayerRewardsCoordinator = IRewardsCoordinator(0x7750d328b314EfFa365A0402CcfD489B80B0adda); eigenLayerTimelock = ITimelock(0xA6Db1A8C5a981d1536266D2a393c5F8dDb210EAF); + depositAdapterInstance = DepositAdapter(payable(deployed.DEPOSIT_ADAPTER())); + + membershipManagerV1Instance = MembershipManager(payable(deployed.MEMBERSHIP_MANAGER())); } else if (forkEnum == TESTNET_FORK) { vm.selectFork(vm.createFork(vm.envString("TESTNET_RPC_URL"))); @@ -401,7 +414,6 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { liquidityPoolInstance = LiquidityPool(payable(addressProviderInstance.getContractAddress("LiquidityPool"))); eETHInstance = EETH(addressProviderInstance.getContractAddress("EETH")); weEthInstance = WeETH(addressProviderInstance.getContractAddress("WeETH")); - membershipManagerV1Instance = MembershipManager(payable(addressProviderInstance.getContractAddress("MembershipManager"))); membershipNftInstance = MembershipNFT(addressProviderInstance.getContractAddress("MembershipNFT")); auctionInstance = AuctionManager(addressProviderInstance.getContractAddress("AuctionManager")); stakingManagerInstance = StakingManager(addressProviderInstance.getContractAddress("StakingManager")); @@ -418,7 +430,9 @@ contract TestSetup is Test, ContractCodeChecker, DepositDataGeneration { etherFiRedemptionManagerInstance = EtherFiRedemptionManager(payable(address(0xDadEf1fFBFeaAB4f68A9fD181395F68b4e4E7Ae0))); etherFiRestakerInstance = EtherFiRestaker(payable(address(0x1B7a4C3797236A1C37f8741c0Be35c2c72736fFf))); roleRegistryInstance = RoleRegistry(addressProviderInstance.getContractAddress("RoleRegistry")); - cumulativeMerkleRewardsDistributorInstance = CumulativeMerkleRewardsDistributor(payable(deployed.CUMULATIVE_MERKLE_REWARDS_DISTRIBUTOR())); + cumulativeMerkleRewardsDistributorInstance = CumulativeMerkleRewardsDistributor(payable(0x9A8c5046a290664Bf42D065d33512fe403484534)); + treasuryInstance = 0x0c83EAe1FE72c390A02E426572854931EefF93BA; + weEthWithdrawAdapterInstance = IWeETHWithdrawAdapter(deployed.WEETH_WITHDRAW_ADAPTER()); } function upgradeEtherFiRedemptionManager() public { diff --git a/test/behaviour-tests/ELExitsForkTestingDeployment.t.sol b/test/behaviour-tests/ELExitsForkTestingDeployment.t.sol index 7bdd85cf3..d224cb1a8 100644 --- a/test/behaviour-tests/ELExitsForkTestingDeployment.t.sol +++ b/test/behaviour-tests/ELExitsForkTestingDeployment.t.sol @@ -212,11 +212,14 @@ contract ELExitsForkTestingDeploymentTest is Test { // Initialize buckets exactly like prelude.t.sol rateLimiter.createNewLimiter(etherFiNodesManager.UNRESTAKING_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); rateLimiter.createNewLimiter(etherFiNodesManager.EXIT_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); + rateLimiter.createNewLimiter(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); rateLimiter.updateConsumers(etherFiNodesManager.UNRESTAKING_LIMIT_ID(), address(etherFiNodesManager), true); rateLimiter.updateConsumers(etherFiNodesManager.EXIT_REQUEST_LIMIT_ID(), address(etherFiNodesManager), true); + rateLimiter.updateConsumers(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), address(etherFiNodesManager), true); console2.log("[OK] UNRESTAKING_LIMIT_ID bucket initialized"); console2.log("[OK] EXIT_REQUEST_LIMIT_ID bucket initialized"); + console2.log("[OK] CONSOLIDATION_REQUEST_LIMIT_ID bucket initialized"); vm.stopPrank(); console2.log(""); diff --git a/test/behaviour-tests/pectra-fork-tests/Request-consolidation.t.sol b/test/behaviour-tests/pectra-fork-tests/Request-consolidation.t.sol index f26264a10..a0eb53163 100644 --- a/test/behaviour-tests/pectra-fork-tests/Request-consolidation.t.sol +++ b/test/behaviour-tests/pectra-fork-tests/Request-consolidation.t.sol @@ -6,8 +6,11 @@ import "forge-std/console2.sol"; import "../../../src/EtherFiNodesManager.sol"; import "../../../src/EtherFiNode.sol"; import "../../../src/EtherFiTimelock.sol"; +import "../../../src/EtherFiRateLimiter.sol"; import "../../../src/RoleRegistry.sol"; import "../../../src/interfaces/IRoleRegistry.sol"; +import "../../../src/interfaces/IEtherFiRateLimiter.sol"; +import "../../../src/interfaces/IStakingManager.sol"; import {IEigenPod, IEigenPodTypes } from "../../../src/eigenlayer-interfaces/IEigenPod.sol"; import "../../TestSetup.sol"; import "../../../script/deploys/Deployed.s.sol"; @@ -21,6 +24,8 @@ contract RequestConsolidationTest is TestSetup, Deployed { // === MAINNET CONTRACT ADDRESSES === EtherFiNodesManager constant etherFiNodesManager = EtherFiNodesManager(payable(0x8B71140AD2e5d1E7018d2a7f8a288BD3CD38916F)); RoleRegistry constant roleRegistry = RoleRegistry(0x62247D29B4B9BECf4BB73E0c722cf6445cfC7cE9); + IStakingManager constant stakingManager = IStakingManager(0x25e821b7197B146F7713C3b89B6A4D83516B912d); + IEtherFiRateLimiter constant rateLimiter = IEtherFiRateLimiter(0x6C7c54cfC2225fA985cD25F04d923B93c60a02F8); EtherFiTimelock constant etherFiOperatingTimelock = EtherFiTimelock(payable(0xcD425f44758a08BaAB3C4908f3e3dE5776e45d7a)); address constant realElExiter = 0x12582A27E5e19492b4FcD194a60F8f5e1aa31B0F; @@ -35,6 +40,29 @@ contract RequestConsolidationTest is TestSetup, Deployed { function setUp() public { initializeRealisticFork(MAINNET_FORK); + + // Deploy and upgrade to new EtherFiNodesManager implementation with consolidation rate limiting + EtherFiNodesManager newEtherFiNodesManagerImpl = new EtherFiNodesManager( + address(stakingManager), + address(roleRegistry), + address(rateLimiter) + ); + vm.prank(roleRegistry.owner()); + etherFiNodesManager.upgradeTo(address(newEtherFiNodesManagerImpl)); + + // Setup consolidation rate limiter bucket + vm.startPrank(roleRegistry.owner()); + bytes32 rateLimiterAdminRole = keccak256("ETHERFI_RATE_LIMITER_ADMIN_ROLE"); + roleRegistry.grantRole(rateLimiterAdminRole, roleRegistry.owner()); + roleRegistry.grantRole(etherFiNodesManager.ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE(), realElExiter); + vm.stopPrank(); + + vm.startPrank(roleRegistry.owner()); + if (!rateLimiter.limitExists(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID())) { + rateLimiter.createNewLimiter(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); + } + rateLimiter.updateConsumers(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), address(etherFiNodesManager), true); + vm.stopPrank(); } function _resolvePod(bytes memory pubkey) internal view returns (IEtherFiNode etherFiNode, IEigenPod pod) { @@ -98,7 +126,7 @@ contract RequestConsolidationTest is TestSetup, Deployed { bytes[] memory pubkeysForOneValidator = new bytes[](1); pubkeysForOneValidator[0] = PK_80143; - vm.prank(address(etherFiOperatingTimelock)); + vm.prank(realElExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIdsForOneValidator, pubkeysForOneValidator); vm.stopPrank(); console.log("Linking legacy validator ids for one validator complete"); @@ -132,7 +160,7 @@ contract RequestConsolidationTest is TestSetup, Deployed { pubkeys[0] = PK_28689; legacyIds[0] = 28689; - vm.prank(address(etherFiOperatingTimelock)); + vm.prank(realElExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -175,7 +203,7 @@ contract RequestConsolidationTest is TestSetup, Deployed { bytes[] memory linkOnlyOneValidatorPubkeys = new bytes[](1); linkOnlyOneValidatorPubkeys[0] = PK_80143; - vm.prank(address(etherFiOperatingTimelock)); + vm.prank(realElExiter); etherFiNodesManager.linkLegacyValidatorIds(linkOnlyOneValidatorlegacyId, linkOnlyOneValidatorPubkeys); vm.stopPrank(); diff --git a/test/behaviour-tests/prelude.t.sol b/test/behaviour-tests/prelude.t.sol index 518b52f54..f50b94321 100644 --- a/test/behaviour-tests/prelude.t.sol +++ b/test/behaviour-tests/prelude.t.sol @@ -122,13 +122,16 @@ contract PreludeTest is Test, ArrayTestHelper { roleRegistry.grantRole(liquidityPoolImpl.LIQUIDITY_POOL_VALIDATOR_APPROVER_ROLE(), admin); roleRegistry.grantRole(liquidityPoolImpl.LIQUIDITY_POOL_ADMIN_ROLE(), admin); roleRegistry.grantRole(rateLimiter.ETHERFI_RATE_LIMITER_ADMIN_ROLE(), admin); + roleRegistry.grantRole(etherFiNodesManager.ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE(), elExiter); vm.stopPrank(); vm.startPrank(admin); rateLimiter.createNewLimiter(etherFiNodesManager.UNRESTAKING_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); rateLimiter.createNewLimiter(etherFiNodesManager.EXIT_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); + rateLimiter.createNewLimiter(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); // (2048*60 one consolidation request) . For 50 such requests, 2048*60*50 = 6_144_000_000_000_000 gwei = 6_144_000_000 ETH rateLimiter.updateConsumers(etherFiNodesManager.UNRESTAKING_LIMIT_ID(), address(etherFiNodesManager), true); rateLimiter.updateConsumers(etherFiNodesManager.EXIT_REQUEST_LIMIT_ID(), address(etherFiNodesManager), true); + rateLimiter.updateConsumers(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), address(etherFiNodesManager), true); vm.stopPrank(); defaultTestValidatorParams = TestValidatorParams({ @@ -318,7 +321,7 @@ contract PreludeTest is Test, ArrayTestHelper { bytes memory signature = hex"877bee8d83cac8bf46c89ce50215da0b5e370d282bb6c8599aabdbc780c33833687df5e1f5b5c2de8a6cd20b6572c8b0130b1744310a998e1079e3286ff03e18e4f94de8cdebecf3aaac3277b742adb8b0eea074e619c20d13a1dda6cba6e3df"; // need to link because this validator is already past this step from the old flow - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(toArray_u256(bidId), toArray_bytes(pubkey)); vm.prank(admin); @@ -341,7 +344,7 @@ contract PreludeTest is Test, ArrayTestHelper { // link it to an arbitrary id uint256 legacyID = 10885; bytes memory pubkey = hex"8f9c0aab19ee7586d3d470f132842396af606947a0589382483308fdffdaf544078c3be24210677a9c471ce70b3b4c2c"; - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(toArray_u256(legacyID), toArray_bytes(pubkey)); // user with no role should not be able to forward calls @@ -405,7 +408,7 @@ contract PreludeTest is Test, ArrayTestHelper { // Setup: link to legacy ID uint256 legacyID = 10886; bytes memory pubkey = hex"8f9c0aab19ee7586d3d470f132842396af606947a0589382483308fdffdaf544078c3be24210677a9c471ce70b3b4c2c"; - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(toArray_u256(legacyID), toArray_bytes(pubkey)); // Setup: create two users with CALL_FORWARDER_ROLE @@ -613,7 +616,7 @@ contract PreludeTest is Test, ArrayTestHelper { uint256 legacyID = 10885; // force link this validator - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(toArray_u256(legacyID), toArray_bytes(validatorPubkey)); // Set up rate limiter for the linked EtherFiNode proxy @@ -909,19 +912,19 @@ contract PreludeTest is Test, ArrayTestHelper { vm.expectRevert(IEtherFiNodesManager.IncorrectRole.selector); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); // should fail if attempt to re-link already linked ids vm.expectRevert(IEtherFiNodesManager.AlreadyLinked.selector); - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); // should fail if attempt to link unknown node uint256 badId = 9999999; bytes memory badPubkey = vm.randomBytes(48); vm.expectRevert(IEtherFiNodesManager.UnknownNode.selector); - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(toArray_u256(badId), toArray_bytes(badPubkey)); } @@ -1034,7 +1037,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[1] = 51716; legacyIds[2] = 51717; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); _setExitRateLimit(172800, 2); @@ -1092,7 +1095,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[2] = 51717; // Link and init - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); _setExitRateLimit(172800, 2); @@ -1167,7 +1170,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[1] = 51716; legacyIds[2] = 51717; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -1189,7 +1192,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[1] = 51716; legacyIds[2] = 51717; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); _setExitRateLimit(172800, 2); @@ -1307,7 +1310,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[0] = 51715; amounts[0] = 1_000_000_000; // 1 ETH partial exit - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); // Grant role to the triggering EOA @@ -1486,7 +1489,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[1] = 51716; legacyIds[2] = 51717; - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); (, IEigenPod pod0) = _resolvePod(pubkeys[0]); @@ -1540,7 +1543,7 @@ contract PreludeTest is Test, ArrayTestHelper { pubkeys[0] = PK_16171; legacyIds[0] = 51715; - vm.prank(admin); + vm.prank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); (, IEigenPod pod0) = _resolvePod(pubkeys[0]); @@ -1559,7 +1562,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[0] = 51715; legacyIds[1] = 51716; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -1612,7 +1615,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[1] = 51716; legacyIds[2] = 51717; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -1659,7 +1662,7 @@ contract PreludeTest is Test, ArrayTestHelper { pubkeys[0] = PK_16171; legacyIds[0] = 51715; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -1696,7 +1699,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[0] = 51715; legacyIds[1] = 51716; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); @@ -1750,7 +1753,7 @@ contract PreludeTest is Test, ArrayTestHelper { legacyIds[1] = 51716; legacyIds[2] = 51717; - vm.startPrank(admin); + vm.startPrank(elExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIds, pubkeys); vm.stopPrank(); diff --git a/test/fork-tests/pectra-fork-tests/Consolidation-through-EOA.sol b/test/fork-tests/pectra-fork-tests/Consolidation-through-EOA.sol index 3301e8765..800a15ad9 100644 --- a/test/fork-tests/pectra-fork-tests/Consolidation-through-EOA.sol +++ b/test/fork-tests/pectra-fork-tests/Consolidation-through-EOA.sol @@ -43,9 +43,22 @@ contract ConsolidationThroughEOATest is Test { vm.selectFork(vm.createFork(vm.envString("MAINNET_RPC_URL"))); //upgrade the etherfi nodes manager contract - newEtherFiNodesManagerImpl = new EtherFiNodesManager(address(stakingManager), address(roleRegistry), address(rateLimiter)); - vm.prank(roleRegistry.owner()); + newEtherFiNodesManagerImpl = new EtherFiNodesManager(address(stakingManager), address(roleRegistry), address(rateLimiter)); + vm.startPrank(roleRegistry.owner()); etherFiNodesManager.upgradeTo(address(newEtherFiNodesManagerImpl)); + roleRegistry.grantRole(etherFiNodesManager.ETHERFI_NODES_MANAGER_LEGACY_LINKER_ROLE(), realElExiter); + + // Setup consolidation rate limiter bucket (required for new rate limiting) + bytes32 rateLimiterAdminRole = keccak256("ETHERFI_RATE_LIMITER_ADMIN_ROLE"); + roleRegistry.grantRole(rateLimiterAdminRole, roleRegistry.owner()); + + vm.stopPrank(); + + vm.startPrank(roleRegistry.owner()); + if (!rateLimiter.limitExists(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID())) { + rateLimiter.createNewLimiter(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), 172_800_000_000_000, 2_000_000_000); + } + rateLimiter.updateConsumers(etherFiNodesManager.CONSOLIDATION_REQUEST_LIMIT_ID(), address(etherFiNodesManager), true); vm.stopPrank(); console2.log("=== SETUP COMPLETE ==="); } @@ -126,8 +139,8 @@ contract ConsolidationThroughEOATest is Test { pubkeysonlyOneValidator[0] = PK_80143; legacyIdsonlyOneValidator[0] = 80143; - // Link legacy validator id (requires admin role, so use timelock) - vm.prank(address(etherFiOperatingTimelock)); + // Link legacy validator id (requires legacy linker role) + vm.prank(realElExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIdsonlyOneValidator, pubkeysonlyOneValidator); vm.stopPrank(); console2.log("Linking legacy validator ids complete"); @@ -177,8 +190,9 @@ contract ConsolidationThroughEOATest is Test { pubkeysonlyOneValidator[0] = PK_80143; legacyIdsonlyOneValidator[0] = 80143; - // Link legacy validator id (requires admin role, so use timelock) - vm.prank(address(etherFiOperatingTimelock)); + // Link legacy validator id (requires legacy linker role) + + vm.prank(realElExiter); etherFiNodesManager.linkLegacyValidatorIds(legacyIdsonlyOneValidator, pubkeysonlyOneValidator); vm.stopPrank(); diff --git a/test/fork-tests/validator-key-gen.t.sol b/test/fork-tests/validator-key-gen.t.sol index 9642d6bd4..5e72aa0d9 100644 --- a/test/fork-tests/validator-key-gen.t.sol +++ b/test/fork-tests/validator-key-gen.t.sol @@ -82,6 +82,41 @@ contract ValidatorKeyGenTest is Test, ArrayTestHelper { return (pubkey, signature, withdrawalCredentials, depositDataRoot, depositData, etherFiNode); } + function helper_getDataForValidatorKeyGen_2() internal returns (bytes[] memory pubkeys, bytes[] memory signatures, bytes32[] memory depositDataRoots, IStakingManager.DepositData[] memory depositData, bytes memory withdrawalCredentials, address etherFiNode) { + pubkeys = new bytes[](2); + signatures = new bytes[](pubkeys.length); + depositDataRoots = new bytes32[](pubkeys.length); + depositData = new IStakingManager.DepositData[](pubkeys.length); + + pubkeys[0] = hex"83a0a764e3b70283cdbe0c6017edd0a5f9ada4786d64aa9f0580b77a86839b02c24c0b14c3a7b2c5dc921e5cfcc89412"; + signatures[0] = hex"b6a4e867465350c9ab8b739f3c5e0c057e82642a6129c296b6ec649fd637e9056d2ecc4a33498920f95274b650004d660e0cb7ce804657eaed36905cee6cffd309ebda5f68d61740b2c2f9ae69ed82cb4fe46907fc6e08662692cc981620d19b"; + + pubkeys[1] = hex"97ecf2290c8035877c75bbad78f8d00eb6997db5e94a8d64c99c5a07d275292ea34177527301b19f2733e0c84b878f07"; + signatures[1] = hex"84363d21a69ce0d63bce8895f168503989b87d3bc2b73e55581422937bf79ee71b8f0bd7f14643ebefbb022d62a8dec614e159d3e93fde4cc0c4343b9b077d4d52dfc23252e217e90c0bfb44e70b0d5d4b83ad42b52ae54858b5de24a85a6bdf"; + + // pubkeys[2] = hex"af529413ce07701b1589f09e71107ab77b164875cee2e6899f12ba0e6b0e6d0a23c23ac03d0a4f50014c8959eb324aa3"; + // signatures[2] = hex"9963dd3ec9faadfbe6571360ecf91c361409a20af57c9df4d2e72f05e7390e5997e68ede694237ab4e0d5a626c31a28e12330956807239ba227044d0d4e0afc795206506a9ebb663da433d6e61c5f8d243fa82f2f2441f091bd3992f44553070"; + + // pubkeys[3] = hex"8f385794be657d619751231557cebd29f34ad647219a3215b7da4e5ac038e361c1ab481afc0142945f951c504ad1e207"; + // signatures[3] = hex"a05317ec9c815648fe9af692790ba126df8e6cf0204e30215d9cd763f8d0018b26ce0f5931227277dde75b26a59a0fb4008804e70ea190991f5583548892210d4c42c96568da2a7c4f27db1a58a550fedfee6cd7577c2a864f07cab832c23ff4"; + + // pubkeys[4] = hex"991a70adf6851ac8697ba42e0de490e7ff4b829efe98041500d93aabb88903a20b6b7d53f2ce17408568ca946798b43d"; + // signatures[4] = hex"865380d63ff512d815054912515a49b8bf03c139bdf797c458e3f04026557d5363cdee2fca764c6190949ff4d14a3acf19df0bbcbd41bd7c54eb769ca55efd739eeaef3eb66953511558b1cfc14e0b96fe969b83b62a648d2dc0321f5453bcd5"; + + etherFiNode = address(0x00A6c74d55aB9b334d013295579F2B04241555eE); + address eigenPod = address(IEtherFiNode(etherFiNode).getEigenPod()); + + withdrawalCredentials = etherFiNodesManager.addressToCompoundingWithdrawalCredentials(eigenPod); + for (uint256 i = 0; i < pubkeys.length; i++) { + depositDataRoots[i] = depositDataRootGenerator.generateDepositDataRoot(pubkeys[i], signatures[i], withdrawalCredentials, 1 ether); + depositData[i] = IStakingManager.DepositData({ + publicKey: pubkeys[i], signature: signatures[i], depositDataRoot: depositDataRoots[i], ipfsHashForEncryptedValidatorKey: "test_ipfs_hash" + }); + } + + return (pubkeys, signatures, depositDataRoots, depositData, withdrawalCredentials, etherFiNode); + } + function test_liquidityPool_newFlow() public { // STEP 1: Whitelist the user vm.prank(admin); @@ -320,6 +355,29 @@ contract ValidatorKeyGenTest is Test, ArrayTestHelper { ); } + function test_batchRegister_2_succeeds() public { + address spawner = vm.addr(0x1234); + vm.deal(spawner, 100 ether); + vm.prank(spawner); + nodeOperatorManager.registerNodeOperator("ipfs_hash", 1000); + bytes memory data2 = abi.encodeWithSelector(NodeOperatorManager.registerNodeOperator.selector, "jisdvjioqvjdnsjvnefbveiobeiub", 1000); + + vm.prank(operatingTimelock); + liquidityPool.registerValidatorSpawner(spawner); + + uint256[] memory createdBids = new uint256[](2); + createdBids[0] = 120129; + createdBids[1] = 120130; + + (bytes[] memory pubkeys, bytes[] memory signatures, bytes32[] memory depositDataRoots, IStakingManager.DepositData[] memory depositData, bytes memory withdrawalCredentials, address etherFiNode) = helper_getDataForValidatorKeyGen_2(); + + vm.prank(spawner); + bytes memory data = abi.encodeWithSelector(LiquidityPool.batchRegister.selector, depositData, createdBids, etherFiNode); + console2.logBytes(data); + console2.log("================================================"); + console2.log(""); + } + function test_batchRegister_revertsWhenAlreadyRegistered() public { address spawner = vm.addr(0x1234); diff --git a/test/integration-tests/Deposit.t.sol b/test/integration-tests/Deposit.t.sol new file mode 100644 index 000000000..5e99a94c8 --- /dev/null +++ b/test/integration-tests/Deposit.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../TestSetup.sol"; +import "@openzeppelin-upgradeable/contracts/token/ERC20/extensions/draft-IERC20PermitUpgradeable.sol"; + +import "../../src/DepositAdapter.sol"; + +contract DepositIntegrationTest is TestSetup { + address internal constant MAINNET_WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address internal constant MAINNET_WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + + // DepositAdapter internal depositAdapterInstance; + IWETH internal weth = IWETH(MAINNET_WETH); + IwstETH internal wstEthToken = IwstETH(MAINNET_WSTETH); + + function setUp() public { + initializeRealisticFork(MAINNET_FORK); + } + + function test_Deposit_LiquidityPool_deposit() public { + vm.deal(alice, 10 ether); + + uint256 beforeShares = eETHInstance.shares(alice); + + vm.prank(alice); + uint256 mintedShares = liquidityPoolInstance.deposit{value: 1 ether}(); + + assertGt(mintedShares, 0); + assertEq(eETHInstance.shares(alice), beforeShares + mintedShares); + } + + function test_Deposit_Liquifier_depositWithERC20_stETH() public { + vm.deal(alice, 10 ether); + vm.prank(alice); + stEth.submit{value: 1 ether}(address(0)); + + uint256 stEthAmount = stEth.balanceOf(alice); + uint256 beforeShares = eETHInstance.shares(alice); + + vm.startPrank(alice); + stEth.approve(address(liquifierInstance), stEthAmount); + uint256 mintedShares = liquifierInstance.depositWithERC20(address(stEth), stEthAmount, address(0)); + vm.stopPrank(); + + assertGt(mintedShares, 0); + assertEq(eETHInstance.shares(alice), beforeShares + mintedShares); + } + + function test_Deposit_Liquifier_depositWithERC20WithPermit_stETH() public { + if (liquifierInstance.isDepositCapReached(address(stEth), 1 ether)) { + vm.startPrank(liquifierInstance.owner()); + liquifierInstance.updateDepositCap(address(stEth), type(uint32).max, type(uint32).max); + vm.stopPrank(); + } + vm.deal(tom, 10 ether); + vm.prank(tom); + stEth.submit{value: 1 ether}(address(0)); + + uint256 stEthAmount = stEth.balanceOf(tom); + uint256 beforeShares = eETHInstance.shares(tom); + + ILiquifier.PermitInput memory permitInput = _permitInputForStEth( + 1202, address(liquifierInstance), stEthAmount, stEth.nonces(tom), 2**256 - 1, stEth.DOMAIN_SEPARATOR() // tom = vm.addr(1202) + ); + + vm.prank(tom); + uint256 mintedShares = liquifierInstance.depositWithERC20WithPermit(address(stEth), stEthAmount, address(0), permitInput); + + assertGt(mintedShares, 0); + assertEq(eETHInstance.shares(tom), beforeShares + mintedShares); + } + + function test_Deposit_DepositAdapter_depositETHForWeETH() public { + vm.deal(alice, 10 ether); + + uint256 beforeWeETH = weEthInstance.balanceOf(alice); + uint256 beforeEETHShares = eETHInstance.shares(alice); + uint256 beforeEETHAmount = eETHInstance.balanceOf(address(weEthInstance)); + uint256 liquidityPoolBalanceBeforeDeposit = address(liquidityPoolInstance).balance; + uint256 ETHAmount = 1 ether; + + uint256 weETHAmountForEETHAmount = liquidityPoolInstance.sharesForAmount(ETHAmount); // weETH amount for the eETH amount + + vm.prank(alice); + uint256 weEthOut = depositAdapterInstance.depositETHForWeETH{value: ETHAmount}(address(0)); + + assertApproxEqAbs(weEthOut, weETHAmountForEETHAmount, 1e1); + assertApproxEqAbs(weEthInstance.balanceOf(alice), beforeWeETH + weEthOut, 1e1); // weETH is transferred to the alice + assertApproxEqAbs(eETHInstance.balanceOf(address(weEthInstance)), beforeEETHAmount + ETHAmount, 1e1); // eETH is transferred to the weETH contract + assertApproxEqAbs(address(liquidityPoolInstance).balance, liquidityPoolBalanceBeforeDeposit + ETHAmount, 1e1); // ETH is transferred to the liquidity pool + } + + function test_Deposit_DepositAdapter_depositWETHForWeETH() public { + vm.deal(alice, 10 ether); + + // Get wETH to deposit adapter + uint256 wETHAmount = 1 ether; + uint256 beforeWETHContractBalance = weth.balanceOf(address(weth)); + vm.startPrank(alice); + weth.deposit{value: wETHAmount}(); + weth.approve(address(depositAdapterInstance), wETHAmount); + vm.stopPrank(); + + uint256 beforeWeETH = weEthInstance.balanceOf(alice); + uint256 beforeWETHBalance = weth.balanceOf(alice); + uint256 beforeDepositAdapterWETHBalance = weth.balanceOf(address(depositAdapterInstance)); + uint256 beforeEETHAmount = eETHInstance.balanceOf(address(weEthInstance)); + uint256 liquidityPoolBalanceBeforeDeposit = address(liquidityPoolInstance).balance; + + uint256 weETHAmountForEETHAmount = liquidityPoolInstance.sharesForAmount(wETHAmount); // weETH amount for the eETH amount + + vm.prank(alice); + uint256 weEthOut = depositAdapterInstance.depositWETHForWeETH(wETHAmount, address(0)); + + assertApproxEqAbs(weEthOut, weETHAmountForEETHAmount, 1e1); + assertApproxEqAbs(weEthInstance.balanceOf(alice), beforeWeETH + weEthOut, 1e1); // weETH is transferred to the alice + assertApproxEqAbs(eETHInstance.balanceOf(address(weEthInstance)), beforeEETHAmount + wETHAmount, 1e1); // eETH is transferred to the weETH contract + assertApproxEqAbs(address(liquidityPoolInstance).balance, liquidityPoolBalanceBeforeDeposit + wETHAmount, 1e1); // ETH is transferred to the liquidity pool + assertApproxEqAbs(weth.balanceOf(alice), beforeWETHBalance - wETHAmount, 1e1); // WETH is consumed from alice + assertApproxEqAbs(weth.balanceOf(address(weth)), beforeWETHContractBalance, 1e1); // WETH is taken from the weth contract and sent back to the weth contract + assertApproxEqAbs(weth.balanceOf(address(depositAdapterInstance)), beforeDepositAdapterWETHBalance, 1e1); // WETH balance of the deposit adapter is unchanged + } + + function test_Deposit_DepositAdapter_depositStETHForWeETHWithPermit() public { + vm.deal(tom, 10 ether); + vm.prank(tom); + stEth.submit{value: 1 ether}(address(0)); + + uint256 stEthAmount = stEth.balanceOf(tom); + uint256 beforeWeETH = weEthInstance.balanceOf(tom); + uint256 beforeEETHAmount = eETHInstance.balanceOf(address(weEthInstance)); + uint256 liquidityPoolBalanceBeforeDeposit = address(liquidityPoolInstance).balance; + uint256 beforeStETHBalance = stEth.balanceOf(address(etherFiRestakerInstance)); + + (uint256 weETHAmountForEETHAmount, uint256 eETHAmountForShares) = _expectedWeETHOutAndEETHAmountForStEth(stEthAmount); + + ILiquifier.PermitInput memory permitInput = _permitInputForStEth(1202, address(depositAdapterInstance), stEthAmount, stEth.nonces(tom), 2**256 - 1, stEth.DOMAIN_SEPARATOR()); // tom = vm.addr(1202) + + vm.prank(tom); + uint256 weEthOut = depositAdapterInstance.depositStETHForWeETHWithPermit(stEthAmount, address(0), permitInput); + + assertApproxEqAbs(weEthOut, weETHAmountForEETHAmount, 1e1); + assertApproxEqAbs(weEthInstance.balanceOf(tom), beforeWeETH + weEthOut, 1e1); // weETH is transferred to the tom + assertApproxEqAbs(eETHInstance.balanceOf(address(weEthInstance)), beforeEETHAmount + eETHAmountForShares, 1e1); // eETH is transferred to the weETH contract + assertApproxEqAbs(address(liquidityPoolInstance).balance, liquidityPoolBalanceBeforeDeposit, 1e1); // stETH path should not move ETH in the pool + assertApproxEqAbs(stEth.balanceOf(address(etherFiRestakerInstance)), beforeStETHBalance + stEthAmount, 1e3); // stETH is transferred to the etherFiRestakerInstance + } + + function _permitInputForStEth(uint256 privateKey, address spender, uint256 value, uint256 nonce, uint256 deadline, bytes32 domainSeparator) + internal + view + returns (ILiquifier.PermitInput memory permitInput) + { + address _owner = vm.addr(privateKey); + bytes32 digest = calculatePermitDigest(_owner, spender, value, nonce, deadline, domainSeparator); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + permitInput = ILiquifier.PermitInput({value: value, deadline: deadline, v: v, r: r, s: s}); + return permitInput; + } + + function _expectedWeETHOutAndEETHAmountForStEth(uint256 stEthAmount) + internal + view + returns (uint256 expectedWeETHOut, uint256 expectedEETHAmount) + { + uint256 eETHAmountForStEthAmount = liquifierInstance.quoteByDiscountedValue(address(stEth), stEthAmount); + uint256 eETHSharesForAmount = liquidityPoolInstance.sharesForAmount(eETHAmountForStEthAmount); + expectedEETHAmount = liquidityPoolInstance.amountForShare(eETHSharesForAmount); + expectedWeETHOut = liquidityPoolInstance.sharesForAmount(expectedEETHAmount); + } + + function test_Deposit_DepositAdapter_depositWstETHForWeETHWithPermit() public { + vm.deal(tom, 10 ether); + vm.prank(tom); + stEth.submit{value: 1 ether}(address(0)); + + // Wrap stETH -> wstETH + vm.startPrank(tom); + stEth.approve(MAINNET_WSTETH, stEth.balanceOf(tom)); + uint256 wstEthAmount = wstEthToken.wrap(stEth.balanceOf(tom)); + vm.stopPrank(); + + uint256 beforeWeETH = weEthInstance.balanceOf(tom); + uint256 beforeEETHAmount = eETHInstance.balanceOf(address(weEthInstance)); + uint256 liquidityPoolBalanceBeforeDeposit = address(liquidityPoolInstance).balance; + uint256 beforeStETHBalance = stEth.balanceOf(address(etherFiRestakerInstance)); + + uint256 stEthAmountForWstEthAmount = _stEthForWstEth(wstEthAmount); + (uint256 weETHAmountForEETHAmount, uint256 eETHAmountForShares) = + _expectedWeETHOutAndEETHAmountForStEth(stEthAmountForWstEthAmount); + + ILiquifier.PermitInput memory permitInput = _permitInputForStEth( + 1202, // tom = vm.addr(1202) + address(depositAdapterInstance), + wstEthAmount, + IERC20PermitUpgradeable(MAINNET_WSTETH).nonces(tom), + 2**256 - 1, + IERC20PermitUpgradeable(MAINNET_WSTETH).DOMAIN_SEPARATOR() + ); + + vm.prank(tom); + uint256 weEthOut = depositAdapterInstance.depositWstETHForWeETHWithPermit(wstEthAmount, address(0), permitInput); + + assertApproxEqAbs(weEthOut, weETHAmountForEETHAmount, 1e1); + assertApproxEqAbs(weEthInstance.balanceOf(tom), beforeWeETH + weEthOut, 1e1); // weETH is transferred to the tom + assertApproxEqAbs(eETHInstance.balanceOf(address(weEthInstance)), beforeEETHAmount + eETHAmountForShares, 1e1); // eETH is transferred to the weETH contract + assertApproxEqAbs(address(liquidityPoolInstance).balance, liquidityPoolBalanceBeforeDeposit, 1e1); // wstETH path should not move ETH in the pool + assertApproxEqAbs(stEth.balanceOf(address(etherFiRestakerInstance)), beforeStETHBalance + stEthAmountForWstEthAmount, 1e3); // stETH is transferred to the etherFiRestakerInstance + } + + function _stEthForWstEth(uint256 wstEthAmount) internal view returns (uint256 stEthAmount) { + // Prefer canonical Lido view helpers; fall back to stEthPerToken(). + (bool ok, bytes memory data) = MAINNET_WSTETH.staticcall( + abi.encodeWithSignature("getStETHByWstETH(uint256)", wstEthAmount) + ); + if (ok && data.length == 32) return abi.decode(data, (uint256)); + + (ok, data) = MAINNET_WSTETH.staticcall(abi.encodeWithSignature("stEthPerToken()")); + require(ok && data.length == 32, "WSTETH_CONVERSION_UNAVAILABLE"); + uint256 stEthPerToken = abi.decode(data, (uint256)); + return (wstEthAmount * stEthPerToken) / 1e18; + } + +// No role of EETH in this flow. + function test_Deposit_EtherFiRestaker_depositIntoStrategy() public { + vm.deal(alice, 10 ether); + + vm.prank(alice); + stEth.submit{value: 1 ether}(address(0)); + + uint256 stEthAmount = stEth.balanceOf(alice); + + address eigenLayerRestakingStrategy = address(etherFiRestakerInstance.getEigenLayerRestakingStrategy(address(stEth))); + uint256 stETHBalanceOfStrategyBeforeDeposit = stEth.balanceOf(eigenLayerRestakingStrategy); + console.log("eigenLayerRestakingStrategy", eigenLayerRestakingStrategy); + + // transfer stETH from alice to the restaker + vm.prank(alice); + stEth.transfer(address(etherFiRestakerInstance), stEthAmount); + + uint256 stETHAmountOfRestakerBeforeDeposit = stEth.balanceOf(address(etherFiRestakerInstance)); + vm.prank(etherFiRestakerInstance.owner()); + uint256 shares = etherFiRestakerInstance.depositIntoStrategy(address(stEth), stEthAmount); + + assertGt(shares, 0); + assertApproxEqAbs(stEth.balanceOf(eigenLayerRestakingStrategy), stETHBalanceOfStrategyBeforeDeposit + stEthAmount, 1e3); + assertApproxEqAbs(stEth.balanceOf(address(etherFiRestakerInstance)), stETHAmountOfRestakerBeforeDeposit - stEthAmount, 1e3); + assertApproxEqAbs(stEth.balanceOf(alice), 0, 1e3); + } +} \ No newline at end of file diff --git a/test/integration-tests/Handle-Remainder-Shares.t.sol b/test/integration-tests/Handle-Remainder-Shares.t.sol new file mode 100644 index 000000000..5086dcae0 --- /dev/null +++ b/test/integration-tests/Handle-Remainder-Shares.t.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/console2.sol"; +import "../TestSetup.sol"; +import "../../script/deploys/Deployed.s.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; + +contract HandleRemainderSharesIntegrationTest is TestSetup, Deployed { + + function setUp() public { + initializeRealisticFork(MAINNET_FORK); + vm.etch(alice, bytes("")); + vm.etch(bob, bytes("")); + } + + function test_HandleRemainder() public { + // Setup: Create remainder by depositing, requesting withdrawal, rebase, and claiming + vm.deal(bob, 10 ether); + vm.startPrank(bob); + liquidityPoolInstance.deposit{value: 10 ether}(); + eETHInstance.approve(address(liquidityPoolInstance), 5 ether); + uint256 requestId = liquidityPoolInstance.requestWithdraw(bob, 5 ether); + vm.stopPrank(); + + // Rebase to create remainder (increase liquidity pool's ETH backing) + vm.prank(address(membershipManagerV1Instance)); + liquidityPoolInstance.rebase(5 ether); + + // Finalize and claim the withdrawal to create remainder + vm.prank(ETHERFI_ADMIN); + withdrawRequestNFTInstance.finalizeRequests(requestId); + + vm.prank(bob); + withdrawRequestNFTInstance.claimWithdraw(requestId); + + uint256 remainderAmount = withdrawRequestNFTInstance.getEEthRemainderAmount(); + assertGt(remainderAmount, 0, "Remainder amount should be greater than 0"); + + // Grant the IMPLICIT_FEE_CLAIMER_ROLE to alice + vm.startPrank(address(roleRegistryInstance.owner())); + withdrawRequestNFTInstance.upgradeTo(address(new WithdrawRequestNFT(address(buybackWallet)))); + roleRegistryInstance.grantRole(withdrawRequestNFTInstance.IMPLICIT_FEE_CLAIMER_ROLE(), alice); + vm.stopPrank(); + + // Record state before handling remainder + uint256 treasuryBalanceBefore = eETHInstance.balanceOf(buybackWallet); + uint256 contractSharesBefore = eETHInstance.shares(address(withdrawRequestNFTInstance)); + uint256 totalRemainderBefore = withdrawRequestNFTInstance.totalRemainderEEthShares(); + + // Calculate expected values + uint256 shareRemainderSplitToTreasury = withdrawRequestNFTInstance.shareRemainderSplitToTreasuryInBps(); + uint256 expectedToTreasury = Math.mulDiv(remainderAmount, shareRemainderSplitToTreasury, 10000); + uint256 expectedToBurn = remainderAmount - expectedToTreasury; + + uint256 expectedSharesToBurn = liquidityPoolInstance.sharesForAmount(expectedToBurn); + uint256 expectedSharesToTreasury = liquidityPoolInstance.sharesForAmount(expectedToTreasury); + uint256 expectedTotalSharesMoved = expectedSharesToBurn + expectedSharesToTreasury; + + // Handle the remainder + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit WithdrawRequestNFT.HandledRemainderOfClaimedWithdrawRequests(expectedToTreasury, expectedToBurn); + withdrawRequestNFTInstance.handleRemainder(remainderAmount); + + // Verify state changes + uint256 treasuryBalanceAfter = eETHInstance.balanceOf(buybackWallet); + uint256 contractSharesAfter = eETHInstance.shares(address(withdrawRequestNFTInstance)); + uint256 totalRemainderAfter = withdrawRequestNFTInstance.totalRemainderEEthShares(); + + // Treasury received correct amount + assertApproxEqAbs(treasuryBalanceAfter - treasuryBalanceBefore, expectedToTreasury, 1e9, "Treasury should receive correct portion"); + + // Contract shares decreased by expected amount + assertApproxEqAbs(contractSharesBefore - contractSharesAfter, expectedTotalSharesMoved, 1e9, "Contract shares should decrease by moved amount"); + + // Total remainder shares decreased correctly + assertApproxEqAbs(totalRemainderBefore - totalRemainderAfter, expectedTotalSharesMoved, 1e9, "Total remainder shares should decrease"); + + // Invariant: contract shares should match expected after accounting for moves + assertApproxEqAbs(contractSharesAfter, contractSharesBefore - expectedTotalSharesMoved, 1e9, "Contract shares invariant check"); + } + + function test_HandleRemainder_PartialHandling() public { + // Setup: Create remainder and handle only part of it + vm.deal(bob, 500 ether); + vm.startPrank(bob); + liquidityPoolInstance.deposit{value: 500 ether}(); + eETHInstance.approve(address(liquidityPoolInstance), 200 ether); + + // Create multiple withdrawal requests to generate larger remainder + uint256[] memory requestIds = new uint256[](20); + for (uint256 i = 0; i < 20; i++) { + requestIds[i] = liquidityPoolInstance.requestWithdraw(bob, 10 ether); + } + vm.stopPrank(); + + // Skip rebase or do minimal rebase to create larger remainder + vm.prank(address(membershipManagerV1Instance)); + liquidityPoolInstance.rebase(1 ether); + + // Finalize and claim all requests + for (uint256 i = 0; i < 20; i++) { + vm.prank(ETHERFI_ADMIN); + withdrawRequestNFTInstance.finalizeRequests(requestIds[i]); + + vm.prank(bob); + withdrawRequestNFTInstance.claimWithdraw(requestIds[i]); + } + + uint256 remainderAmount = withdrawRequestNFTInstance.getEEthRemainderAmount(); + assertGt(remainderAmount, 0.05 ether, "Remainder amount should be greater than 0.05 ether for partial handling"); + + // Now upgrade the contract and grant roles + vm.startPrank(address(roleRegistryInstance.owner())); + withdrawRequestNFTInstance.upgradeTo(address(new WithdrawRequestNFT(address(buybackWallet)))); + roleRegistryInstance.grantRole(withdrawRequestNFTInstance.IMPLICIT_FEE_CLAIMER_ROLE(), alice); + vm.stopPrank(); + + uint256 partialAmount = remainderAmount / 2; + + // Record state before + uint256 treasuryBalanceBefore = eETHInstance.balanceOf(buybackWallet); + uint256 totalRemainderBefore = withdrawRequestNFTInstance.totalRemainderEEthShares(); + + // Handle partial remainder + vm.prank(alice); + withdrawRequestNFTInstance.handleRemainder(partialAmount); + + // Verify partial handling + uint256 treasuryBalanceAfter = eETHInstance.balanceOf(buybackWallet); + uint256 totalRemainderAfter = withdrawRequestNFTInstance.totalRemainderEEthShares(); + + uint256 shareRemainderSplitToTreasury = withdrawRequestNFTInstance.shareRemainderSplitToTreasuryInBps(); + uint256 expectedToTreasury = Math.mulDiv(partialAmount, shareRemainderSplitToTreasury, 10000); + + assertApproxEqAbs(treasuryBalanceAfter - treasuryBalanceBefore, expectedToTreasury, 1e9, "Treasury should receive partial amount"); + assertLt(totalRemainderAfter, totalRemainderBefore, "Total remainder should decrease"); + + // Remaining remainder should be available for further handling + uint256 remainingRemainder = withdrawRequestNFTInstance.getEEthRemainderAmount(); + assertGt(remainingRemainder, 0, "Remaining remainder should be greater than 0"); + + // Handle remaining remainder + vm.prank(alice); + withdrawRequestNFTInstance.handleRemainder(remainingRemainder); + + // Should be no remainder left + assertApproxEqAbs(withdrawRequestNFTInstance.getEEthRemainderAmount(), 0, 1e9, "All remainder should be handled"); + } + + function test_HandleRemainder_DifferentSplitRatios() public { + // Test with different treasury split ratios + uint16[] memory splitRatios = new uint16[](3); + splitRatios[0] = 2000; // 20% + splitRatios[1] = 5000; // 50% + splitRatios[2] = 8000; // 80% + + address[] memory testUsers = new address[](3); + testUsers[0] = bob; + testUsers[1] = makeAddr("user2"); + vm.etch(testUsers[1], bytes("")); + testUsers[2] = makeAddr("user3"); + vm.etch(testUsers[2], bytes("")); + + for (uint256 i = 0; i < splitRatios.length; i++) { + address user = testUsers[i]; + + // Setup: Create remainder by depositing, requesting withdrawal, rebase, and claiming + vm.deal(user, 10 ether); + vm.startPrank(user); + liquidityPoolInstance.deposit{value: 10 ether}(); + eETHInstance.approve(address(liquidityPoolInstance), 5 ether); + uint256 requestId = liquidityPoolInstance.requestWithdraw(user, 5 ether); + vm.stopPrank(); + + // Rebase to create remainder (increase liquidity pool's ETH backing) + vm.prank(address(membershipManagerV1Instance)); + liquidityPoolInstance.rebase(5 ether); + + // Finalize and claim the withdrawal to create remainder + vm.prank(ETHERFI_ADMIN); + withdrawRequestNFTInstance.finalizeRequests(requestId); + + vm.prank(user); + withdrawRequestNFTInstance.claimWithdraw(requestId); + + uint256 remainderAmount = withdrawRequestNFTInstance.getEEthRemainderAmount(); + assertGt(remainderAmount, 0, "Remainder amount should be greater than 0"); + + // Update split ratio + vm.prank(withdrawRequestNFTInstance.owner()); + withdrawRequestNFTInstance.updateShareRemainderSplitToTreasuryInBps(splitRatios[i]); + + // Grant the IMPLICIT_FEE_CLAIMER_ROLE to alice + vm.startPrank(address(roleRegistryInstance.owner())); + withdrawRequestNFTInstance.upgradeTo(address(new WithdrawRequestNFT(address(buybackWallet)))); + roleRegistryInstance.grantRole(withdrawRequestNFTInstance.IMPLICIT_FEE_CLAIMER_ROLE(), alice); + vm.stopPrank(); + + uint256 treasuryBalanceBefore = eETHInstance.balanceOf(buybackWallet); + + vm.prank(alice); + withdrawRequestNFTInstance.handleRemainder(remainderAmount); + + uint256 treasuryBalanceAfter = eETHInstance.balanceOf(buybackWallet); + uint256 expectedToTreasury = Math.mulDiv(remainderAmount, splitRatios[i], 10000); + + assertApproxEqAbs(treasuryBalanceAfter - treasuryBalanceBefore, expectedToTreasury, 1e14, + string(abi.encodePacked("Treasury should receive correct portion for ratio ", vm.toString(splitRatios[i])))); + } + } +} diff --git a/test/integration-tests/Validator-Flows.t.sol b/test/integration-tests/Validator-Flows.t.sol new file mode 100644 index 000000000..2901f426d --- /dev/null +++ b/test/integration-tests/Validator-Flows.t.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../TestSetup.sol"; +import "../../script/deploys/Deployed.s.sol"; + +import "../../src/interfaces/IStakingManager.sol"; +import "../../src/interfaces/IEtherFiNode.sol"; + +import "../../src/libraries/DepositDataRootGenerator.sol"; + +contract ValidatorFlowsIntegrationTest is TestSetup, Deployed { + function setUp() public { + initializeRealisticFork(MAINNET_FORK); + } + + function _toArray(IStakingManager.DepositData memory d) internal pure returns (IStakingManager.DepositData[] memory arr) { + arr = new IStakingManager.DepositData[](1); + arr[0] = d; + } + + function _toArrayU256(uint256 x) internal pure returns (uint256[] memory arr) { + arr = new uint256[](1); + arr[0] = x; + } + + function _ensureValCreationRoles() internal { + address roleOwner = roleRegistryInstance.owner(); + + // Ensure the operating admin can manage LP spawners + create validators. + vm.startPrank(roleOwner); + roleRegistryInstance.grantRole(liquidityPoolInstance.LIQUIDITY_POOL_ADMIN_ROLE(), ETHERFI_OPERATING_ADMIN); + roleRegistryInstance.grantRole(liquidityPoolInstance.LIQUIDITY_POOL_VALIDATOR_CREATOR_ROLE(), ETHERFI_OPERATING_ADMIN); + + // Ensure operating timelock can create nodes. + roleRegistryInstance.grantRole(stakingManagerInstance.STAKING_MANAGER_NODE_CREATOR_ROLE(), OPERATING_TIMELOCK); + vm.stopPrank(); + + // Ensure operating admin is an admin on NodeOperatorManager (required for whitelist ops) + vm.prank(nodeOperatorManagerInstance.owner()); + nodeOperatorManagerInstance.updateAdmin(ETHERFI_OPERATING_ADMIN, true); + } + + function _prepareSingleValidator(address spawner) + internal + returns (IStakingManager.DepositData memory depositData, uint256 bidId, address etherFiNode) + { + _ensureValCreationRoles(); + + // Step 1: Whitelist + register node operator. + vm.prank(ETHERFI_OPERATING_ADMIN); + nodeOperatorManagerInstance.addToWhitelist(spawner); + + vm.deal(spawner, 10 ether); + vm.startPrank(spawner); + if (!nodeOperatorManagerInstance.registered(spawner)) { + nodeOperatorManagerInstance.registerNodeOperator("test_ipfs_hash", 1000); + } + uint256[] memory bidIds = auctionInstance.createBid{value: 0.1 ether}(1, 0.1 ether); + vm.stopPrank(); + bidId = bidIds[0]; + + // Step 2: Create a new EtherFiNode (with EigenPod) for compounding withdrawal creds. + vm.prank(OPERATING_TIMELOCK); + etherFiNode = stakingManagerInstance.instantiateEtherFiNode(true /*createEigenPod*/); + address eigenPod = address(IEtherFiNode(etherFiNode).getEigenPod()); + + // Step 3: Register validator spawner. + vm.prank(ETHERFI_OPERATING_ADMIN); + liquidityPoolInstance.registerValidatorSpawner(spawner); + + // Step 4: Build 1-ETH deposit data (must match compounding withdrawal creds). + bytes memory pubkey = vm.randomBytes(48); + bytes memory signature = vm.randomBytes(96); + bytes memory withdrawalCredentials = managerInstance.addressToCompoundingWithdrawalCredentials(eigenPod); + bytes32 depositDataRoot = + depositDataRootGenerator.generateDepositDataRoot(pubkey, signature, withdrawalCredentials, stakingManagerInstance.initialDepositAmount()); + + depositData = IStakingManager.DepositData({ + publicKey: pubkey, + signature: signature, + depositDataRoot: depositDataRoot, + ipfsHashForEncryptedValidatorKey: "test_ipfs_hash" + }); + } + + function _executeValidatorApprovalTask(IEtherFiOracle.OracleReport memory report, bytes[] memory pubkeys, bytes[] memory signatures) internal returns (bool completed, bool exists) { + bytes32 reportHash = etherFiOracleInstance.generateReportHash(report); + bytes32 taskHash = keccak256(abi.encode(reportHash, report.validatorsToApprove)); + vm.prank(ADMIN_EOA); + etherFiAdminInstance.executeValidatorApprovalTask(reportHash, report.validatorsToApprove, pubkeys, signatures); + (completed, exists) = etherFiAdminInstance.validatorApprovalTaskStatus(taskHash); + return (completed, exists); + } + + function test_ValCreation_batchRegisterAndCreateBeaconValidators_succeeds() public { + address spawner = vm.addr(0x1234); + + (IStakingManager.DepositData memory depositData, uint256 bidId, address etherFiNode) = _prepareSingleValidator(spawner); + + // Step 5: batchRegister (spawner) + vm.prank(spawner); + liquidityPoolInstance.batchRegister(_toArray(depositData), _toArrayU256(bidId), etherFiNode); + + bytes32 validatorHash = keccak256( + abi.encode(depositData.publicKey, depositData.signature, depositData.depositDataRoot, depositData.ipfsHashForEncryptedValidatorKey, bidId, etherFiNode) + ); + assertEq(uint8(stakingManagerInstance.validatorCreationStatus(validatorHash)), uint8(IStakingManager.ValidatorCreationStatus.REGISTERED)); + + // Step 6: Create validator (operating admin / validator creator role) + vm.prank(ETHERFI_OPERATING_ADMIN); + liquidityPoolInstance.batchCreateBeaconValidators(_toArray(depositData), _toArrayU256(bidId), etherFiNode); + + assertEq(uint8(stakingManagerInstance.validatorCreationStatus(validatorHash)), uint8(IStakingManager.ValidatorCreationStatus.CONFIRMED)); + } + + function test_EntireValidatorCreationFlow_accountsForEthCorrectly() public { + address spawner = vm.addr(0x5678); + + (IStakingManager.DepositData memory depositData, uint256 bidId, address etherFiNode) = _prepareSingleValidator(spawner); + + vm.prank(spawner); + liquidityPoolInstance.batchRegister(_toArray(depositData), _toArrayU256(bidId), etherFiNode); + + uint128 initialTotalOut = liquidityPoolInstance.totalValueOutOfLp(); + uint128 initialTotalIn = liquidityPoolInstance.totalValueInLp(); + + // 1 ETH per validator (current protocol design) + uint128 expectedEthOut = 1 ether; + + vm.prank(ETHERFI_OPERATING_ADMIN); + liquidityPoolInstance.batchCreateBeaconValidators(_toArray(depositData), _toArrayU256(bidId), etherFiNode); + + assertEq(liquidityPoolInstance.totalValueOutOfLp(), initialTotalOut + expectedEthOut); + assertEq(liquidityPoolInstance.totalValueInLp(), initialTotalIn - expectedEthOut); + + // Advance time until the oracle considers the next report epoch finalized. + // Condition inside oracle: (slotEpoch + 2 < currEpoch) <=> currEpoch >= slotEpoch + 3 + while (true) { + uint32 slot = etherFiOracleInstance.slotForNextReport(); + uint32 curr = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + uint32 min = ((slot / 32) + 3) * 32; + if (curr >= min) break; + uint256 d = min - curr; + vm.roll(block.number + d); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (curr + uint32(d))); + } + + IEtherFiOracle.OracleReport memory report; + uint256[] memory emptyVals = new uint256[](0); + report = IEtherFiOracle.OracleReport( + etherFiOracleInstance.consensusVersion(), 0, 0, 0, 0, 0, 0, emptyVals, emptyVals, 0, 0 + ); + + (report.refSlotFrom, report.refSlotTo, report.refBlockFrom) = etherFiOracleInstance.blockStampForNextReport(); + + bytes[] memory pubkeys = new bytes[](1); + uint256[] memory ids = new uint256[](1); + bytes[] memory signatures = new bytes[](1); + pubkeys[0] = depositData.publicKey; + ids[0] = bidId; + signatures[0] = depositData.signature; + report.validatorsToApprove = ids; + report.lastFinalizedWithdrawalRequestId = withdrawRequestNFTInstance.lastFinalizedRequestId(); + + // Set refBlockTo to a block number that is < block.number and > lastAdminExecutionBlock + report.refBlockTo = uint32(block.number - 1); + if (report.refBlockTo <= etherFiAdminInstance.lastAdminExecutionBlock()) { + report.refBlockTo = etherFiAdminInstance.lastAdminExecutionBlock() + 1; + } + + vm.prank(AVS_OPERATOR_1); + etherFiOracleInstance.submitReport(report); + + vm.prank(AVS_OPERATOR_2); + etherFiOracleInstance.submitReport(report); + + // Advance time for postReportWaitTimeInSlots + uint256 slotsToWait = uint256(etherFiAdminInstance.postReportWaitTimeInSlots() + 1); + uint32 slotAfterReport = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + vm.roll(block.number + slotsToWait); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (slotAfterReport + slotsToWait)); + + uint256 LiquidityPoolBalanceBefore = address(liquidityPoolInstance).balance; + vm.prank(ADMIN_EOA); + etherFiAdminInstance.executeTasks(report); + (bool completed, bool exists) = _executeValidatorApprovalTask(report, pubkeys, signatures); + uint256 LiquidityPoolBalanceAfter = address(liquidityPoolInstance).balance; + assertApproxEqAbs(LiquidityPoolBalanceAfter, LiquidityPoolBalanceBefore - liquidityPoolInstance.validatorSizeWei() + stakingManagerInstance.initialDepositAmount(), 1e3); + } +} diff --git a/test/integration-tests/Withdraw.t.sol b/test/integration-tests/Withdraw.t.sol new file mode 100644 index 000000000..ad4fd37b7 --- /dev/null +++ b/test/integration-tests/Withdraw.t.sol @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/console2.sol"; +import "../TestSetup.sol"; +import "lib/BucketLimiter.sol"; +import "../../script/deploys/Deployed.s.sol"; +import "../../src/interfaces/IWeETHWithdrawAdapter.sol"; + +contract WithdrawIntegrationTest is TestSetup, Deployed { + address public constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + address public constant LIDO_ADDRESS = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + + function setUp() public { + initializeRealisticFork(MAINNET_FORK); + vm.etch(alice, bytes("")); + } + + function test_Withdraw_EtherFiRedemptionManager_redeemEEth() public { + // setUp(); + vm.startPrank(OPERATING_TIMELOCK); + // Ensure bucket limiter has enough capacity and is fully refilled + etherFiRedemptionManagerInstance.setCapacity(3000 ether, ETH_ADDRESS); + etherFiRedemptionManagerInstance.setRefillRatePerSecond(3000 ether, ETH_ADDRESS); + vm.stopPrank(); + + // Warp time forward to ensure bucket is fully refilled + vm.warp(block.timestamp + 1); + vm.deal(alice, 2010 ether); + vm.startPrank(alice); + + liquidityPoolInstance.deposit{value: 2005 ether}(); + address receiver = makeAddr("withdraw-receiver"); + vm.etch(receiver, bytes("")); + + uint256 beforeEETHBalance = eETHInstance.balanceOf(alice); + uint256 eETHAmountToRedeem = 2000 ether; + uint256 beforeReceiverBalance = address(receiver).balance; + uint256 beforeTreasuryBalance = eETHInstance.balanceOf(address(etherFiRedemptionManagerInstance.treasury())); + address treasury = address(etherFiRedemptionManagerInstance.treasury()); + + // Get actual fee configuration from contract + (, uint16 exitFeeSplitToTreasuryBps, uint16 exitFeeBps, ) = + etherFiRedemptionManagerInstance.tokenToRedemptionInfo(ETH_ADDRESS); + + // Calculate expected values using shares (more accurate) + uint256 eEthShares = liquidityPoolInstance.sharesForAmount(eETHAmountToRedeem); + uint256 expectedAmountToReceiver = liquidityPoolInstance.amountForShare((eEthShares * (10000 - exitFeeBps)) / 10000); + uint256 eEthShareFee = eEthShares - liquidityPoolInstance.sharesForWithdrawalAmount(expectedAmountToReceiver); + uint256 expectedTreasuryFee = liquidityPoolInstance.amountForShare((eEthShareFee * exitFeeSplitToTreasuryBps) / 10000); + + eETHInstance.approve(address(etherFiRedemptionManagerInstance), eETHAmountToRedeem); + etherFiRedemptionManagerInstance.redeemEEth(eETHAmountToRedeem, receiver, ETH_ADDRESS); + + assertApproxEqAbs(address(receiver).balance, beforeReceiverBalance + expectedAmountToReceiver, 1e15); // receiver gets ETH + assertApproxEqAbs(eETHInstance.balanceOf(alice), beforeEETHBalance - eETHAmountToRedeem, 1e15); // eETH is consumed from alice + assertApproxEqAbs(eETHInstance.balanceOf(treasury), beforeTreasuryBalance + expectedTreasuryFee, 1e15); // treasury gets ETH + + vm.stopPrank(); + } + + function test_Withdraw_EtherFiRedemptionManager_redeemEEthWithPermit() public { + vm.startPrank(OPERATING_TIMELOCK); + // Ensure bucket limiter has enough capacity and is fully refilled + etherFiRedemptionManagerInstance.setCapacity(3000 ether, ETH_ADDRESS); + etherFiRedemptionManagerInstance.setRefillRatePerSecond(3000 ether, ETH_ADDRESS); + vm.stopPrank(); + + // Warp time forward to ensure bucket is fully refilled + vm.warp(block.timestamp + 1); + vm.deal(alice, 2010 ether); + vm.startPrank(alice); + + liquidityPoolInstance.deposit{value: 2005 ether}(); + address receiver = makeAddr("withdraw-receiver"); + vm.etch(receiver, bytes("")); + + uint256 beforeEETHBalance = eETHInstance.balanceOf(alice); + uint256 eETHAmountToRedeem = 2000 ether; + uint256 beforeReceiverBalance = address(receiver).balance; + uint256 beforeTreasuryBalance = eETHInstance.balanceOf(address(etherFiRedemptionManagerInstance.treasury())); + address treasury = address(etherFiRedemptionManagerInstance.treasury()); + + IeETH.PermitInput memory permit = eEth_createPermitInput(2, address(etherFiRedemptionManagerInstance), eETHAmountToRedeem, eETHInstance.nonces(alice), 2**256 - 1, eETHInstance.DOMAIN_SEPARATOR()); // alice = vm.addr(2) + + // Get actual fee configuration from contract + (, uint16 exitFeeSplitToTreasuryBps, uint16 exitFeeBps, ) = + etherFiRedemptionManagerInstance.tokenToRedemptionInfo(ETH_ADDRESS); + + // Calculate expected values using shares (more accurate) + uint256 eEthShares = liquidityPoolInstance.sharesForAmount(eETHAmountToRedeem); + uint256 expectedAmountToReceiver = liquidityPoolInstance.amountForShare((eEthShares * (10000 - exitFeeBps)) / 10000); + uint256 eEthShareFee = eEthShares - liquidityPoolInstance.sharesForWithdrawalAmount(expectedAmountToReceiver); + uint256 expectedTreasuryFee = liquidityPoolInstance.amountForShare((eEthShareFee * exitFeeSplitToTreasuryBps) / 10000); + + eETHInstance.approve(address(etherFiRedemptionManagerInstance), eETHAmountToRedeem); + etherFiRedemptionManagerInstance.redeemEEthWithPermit(eETHAmountToRedeem, receiver, permit, ETH_ADDRESS); + + assertApproxEqAbs(address(receiver).balance, beforeReceiverBalance + expectedAmountToReceiver, 1e15); // receiver gets ETH + assertApproxEqAbs(eETHInstance.balanceOf(alice), beforeEETHBalance - eETHAmountToRedeem, 1e15); // eETH is consumed from alice + assertApproxEqAbs(eETHInstance.balanceOf(treasury), beforeTreasuryBalance + expectedTreasuryFee, 1e15); // treasury gets ETH + + vm.stopPrank(); + } + + function test_Withdraw_EtherFiRedemptionManager_redeemWeEth() public { + vm.deal(alice, 100 ether); + vm.startPrank(alice); + liquidityPoolInstance.deposit{value: 10 ether}(); // to get eETH to generate weETH + eETHInstance.approve(address(weEthInstance), 10 ether); + weEthInstance.wrap(1 ether); // to get weETH to redeem + + uint256 weEthAmount = weEthInstance.balanceOf(alice); + uint256 eEthAmount = weEthInstance.getEETHByWeETH(weEthAmount); + uint256 eEthShares = liquidityPoolInstance.sharesForAmount(eEthAmount); + // NOTE: on mainnet forks, vm.addr(N) can map to an address that already has code + // and may forward ETH in its receive/fallback, making balance-based asserts flaky. + address receiver = makeAddr("withdraw-receiver"); + vm.etch(receiver, bytes("")); + uint256 receiverBalance = address(receiver).balance; + address treasury = etherFiRedemptionManagerInstance.treasury(); + uint256 treasuryBalanceBefore = eETHInstance.balanceOf(treasury); + uint256 beforeWeETHBalance = weEthInstance.balanceOf(alice); + + weEthInstance.approve(address(etherFiRedemptionManagerInstance), 1 ether); + etherFiRedemptionManagerInstance.redeemWeEth(weEthAmount, receiver, ETH_ADDRESS); + + // Use exact same calculation flow as _calcRedemption to account for rounding differences + (, uint16 exitFeeSplitToTreasuryBps, uint16 exitFeeBps, ) = + etherFiRedemptionManagerInstance.tokenToRedemptionInfo(ETH_ADDRESS); + uint256 expectedAmountToReceiver = liquidityPoolInstance.amountForShare( + eEthShares * (10000 - exitFeeBps) / 10000 + ); + uint256 sharesToBurn = liquidityPoolInstance.sharesForWithdrawalAmount(expectedAmountToReceiver); + uint256 eEthShareFee = eEthShares - sharesToBurn; + uint256 feeShareToTreasury = eEthShareFee * exitFeeSplitToTreasuryBps / 10000; + uint256 expectedTreasuryFee = liquidityPoolInstance.amountForShare(feeShareToTreasury); + + assertApproxEqAbs(eETHInstance.balanceOf(treasury), treasuryBalanceBefore + expectedTreasuryFee, 1e15); // treasury gets ETH + assertApproxEqAbs(address(receiver).balance, receiverBalance + expectedAmountToReceiver, 1e15); // receiver gets ETH + vm.stopPrank(); + } + + function test_Withdraw_EtherFiRedemptionManager_redeemWeEthWithPermit() public { + + vm.deal(alice, 100 ether); + vm.startPrank(alice); + liquidityPoolInstance.deposit{value: 10 ether}(); // to get eETH to generate weETH + eETHInstance.approve(address(weEthInstance), 10 ether); + weEthInstance.wrap(1 ether); // to get weETH to redeem + + uint256 weEthAmount = weEthInstance.balanceOf(alice); + uint256 eEthAmount = weEthInstance.getEETHByWeETH(weEthAmount); + uint256 eEthShares = liquidityPoolInstance.sharesForAmount(eEthAmount); + // NOTE: on mainnet forks, vm.addr(N) can map to an address that already has code + // and may forward ETH in its receive/fallback, making balance-based asserts flaky. + address receiver = makeAddr("withdraw-receiver"); + vm.etch(receiver, bytes("")); + uint256 receiverBalance = address(receiver).balance; + address treasury = etherFiRedemptionManagerInstance.treasury(); + uint256 treasuryBalanceBefore = eETHInstance.balanceOf(treasury); + uint256 beforeWeETHBalance = weEthInstance.balanceOf(alice); + + IWeETH.PermitInput memory permit = weEth_createPermitInput(2, address(etherFiRedemptionManagerInstance), weEthAmount, weEthInstance.nonces(alice), 2**256 - 1, weEthInstance.DOMAIN_SEPARATOR()); // alice = vm.addr(2) + + weEthInstance.approve(address(etherFiRedemptionManagerInstance), 1 ether); + etherFiRedemptionManagerInstance.redeemWeEthWithPermit(weEthAmount, receiver, permit, ETH_ADDRESS); + + // Use exact same calculation flow as _calcRedemption to account for rounding differences + (, uint16 exitFeeSplitToTreasuryBps, uint16 exitFeeBps, ) = + etherFiRedemptionManagerInstance.tokenToRedemptionInfo(ETH_ADDRESS); + uint256 expectedAmountToReceiver = liquidityPoolInstance.amountForShare( + eEthShares * (10000 - exitFeeBps) / 10000 + ); + uint256 sharesToBurn = liquidityPoolInstance.sharesForWithdrawalAmount(expectedAmountToReceiver); + uint256 eEthShareFee = eEthShares - sharesToBurn; + uint256 feeShareToTreasury = eEthShareFee * exitFeeSplitToTreasuryBps / 10000; + uint256 expectedTreasuryFee = liquidityPoolInstance.amountForShare(feeShareToTreasury); + + assertApproxEqAbs(eETHInstance.balanceOf(treasury), treasuryBalanceBefore + expectedTreasuryFee, 1e15); // treasury gets ETH + assertApproxEqAbs(address(receiver).balance, receiverBalance + expectedAmountToReceiver, 1e15); // receiver gets ETH + vm.stopPrank(); + } + + function test_LiquidityPool_requestWithdraw() public { + vm.deal(alice, 100 ether); + uint256 amountToWithdraw = 1 ether; + vm.startPrank(alice); + liquidityPoolInstance.deposit{value: amountToWithdraw}(); + + uint256 nextRequestId = withdrawRequestNFTInstance.nextRequestId(); + + uint256 beforeAliceBalance = alice.balance; + eETHInstance.approve(address(liquidityPoolInstance), amountToWithdraw); + uint256 requestId = liquidityPoolInstance.requestWithdraw(alice, amountToWithdraw); + assertEq(requestId, nextRequestId); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), alice); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).isValid, true); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).feeGwei, 0); + vm.stopPrank(); + // Advance time until the oracle considers the next report epoch finalized. + // Condition inside oracle: (slotEpoch + 2 < currEpoch) <=> currEpoch >= slotEpoch + 3 + while (true) { + uint32 slot = etherFiOracleInstance.slotForNextReport(); + uint32 curr = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + uint32 min = ((slot / 32) + 3) * 32; + if (curr >= min) break; + uint256 d = min - curr; + vm.roll(block.number + d); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (curr + uint32(d))); + } + + IEtherFiOracle.OracleReport memory report; + uint256[] memory emptyVals = new uint256[](0); + report = IEtherFiOracle.OracleReport( + etherFiOracleInstance.consensusVersion(), 0, 0, 0, 0, 0, 0, emptyVals, emptyVals, 0, 0 + ); + report.lastFinalizedWithdrawalRequestId = uint32(requestId); + + (report.refSlotFrom, report.refSlotTo, report.refBlockFrom) = etherFiOracleInstance.blockStampForNextReport(); + // Set refBlockTo to a block number that is < block.number and > lastAdminExecutionBlock + report.refBlockTo = uint32(block.number - 1); + if (report.refBlockTo <= etherFiAdminInstance.lastAdminExecutionBlock()) { + report.refBlockTo = etherFiAdminInstance.lastAdminExecutionBlock() + 1; + } + + vm.prank(AVS_OPERATOR_1); + etherFiOracleInstance.submitReport(report); + + vm.prank(AVS_OPERATOR_2); + etherFiOracleInstance.submitReport(report); + + // Advance time for postReportWaitTimeInSlots + uint256 slotsToWait = uint256(etherFiAdminInstance.postReportWaitTimeInSlots() + 1); + uint32 slotAfterReport = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + vm.roll(block.number + slotsToWait); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (slotAfterReport + slotsToWait)); + + vm.prank(ADMIN_EOA); + etherFiAdminInstance.executeTasks(report); + + vm.startPrank(alice); + withdrawRequestNFTInstance.claimWithdraw(requestId); + assertApproxEqAbs(alice.balance, beforeAliceBalance + amountToWithdraw, 1e3); + vm.stopPrank(); + } + + function test_LiquidityPool_requestWithdrawWithPermit() public { + vm.deal(alice, 100 ether); + uint256 amountToWithdraw = 1 ether; + vm.startPrank(alice); + liquidityPoolInstance.deposit{value: amountToWithdraw}(); + + uint256 nextRequestId = withdrawRequestNFTInstance.nextRequestId(); + + ILiquidityPool.PermitInput memory permit = createPermitInput(2, address(liquidityPoolInstance), amountToWithdraw, eETHInstance.nonces(alice), 2**256 - 1, eETHInstance.DOMAIN_SEPARATOR()); // alice = vm.addr(2) + + uint256 beforeAliceBalance = alice.balance; + uint256 requestId = liquidityPoolInstance.requestWithdrawWithPermit(alice, amountToWithdraw, permit); + assertEq(requestId, nextRequestId); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), alice); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).isValid, true); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).feeGwei, 0); + vm.stopPrank(); + // Advance time until the oracle considers the next report epoch finalized. + // Condition inside oracle: (slotEpoch + 2 < currEpoch) <=> currEpoch >= slotEpoch + 3 + while (true) { + uint32 slot = etherFiOracleInstance.slotForNextReport(); + uint32 curr = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + uint32 min = ((slot / 32) + 3) * 32; + if (curr >= min) break; + uint256 d = min - curr; + vm.roll(block.number + d); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (curr + uint32(d))); + } + + IEtherFiOracle.OracleReport memory report; + uint256[] memory emptyVals = new uint256[](0); + report = IEtherFiOracle.OracleReport( + etherFiOracleInstance.consensusVersion(), 0, 0, 0, 0, 0, 0, emptyVals, emptyVals, 0, 0 + ); + report.lastFinalizedWithdrawalRequestId = uint32(requestId); + + (report.refSlotFrom, report.refSlotTo, report.refBlockFrom) = etherFiOracleInstance.blockStampForNextReport(); + // Set refBlockTo to a block number that is < block.number and > lastAdminExecutionBlock + report.refBlockTo = uint32(block.number - 1); + if (report.refBlockTo <= etherFiAdminInstance.lastAdminExecutionBlock()) { + report.refBlockTo = etherFiAdminInstance.lastAdminExecutionBlock() + 1; + } + + vm.prank(AVS_OPERATOR_1); + etherFiOracleInstance.submitReport(report); + + vm.prank(AVS_OPERATOR_2); + etherFiOracleInstance.submitReport(report); + + // Advance time for postReportWaitTimeInSlots + uint256 slotsToWait = uint256(etherFiAdminInstance.postReportWaitTimeInSlots() + 1); + uint32 slotAfterReport = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + vm.roll(block.number + slotsToWait); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (slotAfterReport + slotsToWait)); + + vm.prank(ADMIN_EOA); + etherFiAdminInstance.executeTasks(report); + + vm.startPrank(alice); + withdrawRequestNFTInstance.claimWithdraw(requestId); + assertApproxEqAbs(alice.balance, beforeAliceBalance + amountToWithdraw, 1e3); + vm.stopPrank(); + } + + function test_LiquidityPool_requestWithdraw_batchClaimWithdraw() public { + vm.deal(alice, 100 ether); + uint256 numRequests = 3; + uint256 amountPerRequest = 1 ether; + uint256 totalAmountToWithdraw = amountPerRequest * numRequests; + vm.startPrank(alice); + liquidityPoolInstance.deposit{value: totalAmountToWithdraw}(); + + uint256 nextRequestId = withdrawRequestNFTInstance.nextRequestId(); + + uint256 beforeAliceBalance = alice.balance; + eETHInstance.approve(address(liquidityPoolInstance), totalAmountToWithdraw); + + uint256[] memory tokenIds = new uint256[](numRequests); + uint256 requestId; + for (uint256 i = 0; i < numRequests; i++) { + requestId = liquidityPoolInstance.requestWithdraw(alice, amountPerRequest); + tokenIds[i] = requestId; + + assertEq(requestId, nextRequestId + i); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), alice); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).isValid, true); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).feeGwei, 0); + } + vm.stopPrank(); + // Advance time until the oracle considers the next report epoch finalized. + // Condition inside oracle: (slotEpoch + 2 < currEpoch) <=> currEpoch >= slotEpoch + 3 + while (true) { + uint32 slot = etherFiOracleInstance.slotForNextReport(); + uint32 curr = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + uint32 min = ((slot / 32) + 3) * 32; + if (curr >= min) break; + uint256 d = min - curr; + vm.roll(block.number + d); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (curr + uint32(d))); + } + + IEtherFiOracle.OracleReport memory report; + uint256[] memory emptyVals = new uint256[](0); + report = IEtherFiOracle.OracleReport( + etherFiOracleInstance.consensusVersion(), 0, 0, 0, 0, 0, 0, emptyVals, emptyVals, 0, 0 + ); + report.lastFinalizedWithdrawalRequestId = uint32(requestId); + + (report.refSlotFrom, report.refSlotTo, report.refBlockFrom) = etherFiOracleInstance.blockStampForNextReport(); + // Set refBlockTo to a block number that is < block.number and > lastAdminExecutionBlock + report.refBlockTo = uint32(block.number - 1); + if (report.refBlockTo <= etherFiAdminInstance.lastAdminExecutionBlock()) { + report.refBlockTo = etherFiAdminInstance.lastAdminExecutionBlock() + 1; + } + + vm.prank(AVS_OPERATOR_1); + etherFiOracleInstance.submitReport(report); + + vm.prank(AVS_OPERATOR_2); + etherFiOracleInstance.submitReport(report); + + // Advance time for postReportWaitTimeInSlots + uint256 slotsToWait = uint256(etherFiAdminInstance.postReportWaitTimeInSlots() + 1); + uint32 slotAfterReport = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + vm.roll(block.number + slotsToWait); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (slotAfterReport + slotsToWait)); + + vm.prank(ADMIN_EOA); + etherFiAdminInstance.executeTasks(report); + + vm.startPrank(alice); + withdrawRequestNFTInstance.batchClaimWithdraw(tokenIds); + assertApproxEqAbs(alice.balance, beforeAliceBalance + totalAmountToWithdraw, 1e3); + vm.stopPrank(); + } + + function test_EtherFiRestaker_withdrawEther_sendsEthToLiquidityPool() public { + uint256 amount = 3 ether; + vm.deal(address(etherFiRestakerInstance), amount); + + uint256 lpBalanceBefore = address(liquidityPoolInstance).balance; + uint256 restakerBalanceBefore = address(etherFiRestakerInstance).balance; + assertEq(restakerBalanceBefore, amount); + + vm.prank(etherFiRestakerInstance.owner()); + etherFiRestakerInstance.withdrawEther(); + + assertEq(address(etherFiRestakerInstance).balance, 0); + assertEq(address(liquidityPoolInstance).balance, lpBalanceBefore + amount); + } + + function test_EtherFiRestaker_undelegate_tracksWithdrawalRoots() public { + bool delegatedBefore = etherFiRestakerInstance.isDelegated(); + + vm.prank(etherFiRestakerInstance.owner()); + if (!delegatedBefore) { + vm.expectRevert(); + etherFiRestakerInstance.undelegate(); + return; + } + + bytes32[] memory roots = etherFiRestakerInstance.undelegate(); + + assertEq(etherFiRestakerInstance.isDelegated(), false); + for (uint256 i = 0; i < roots.length; i++) { + assertEq(etherFiRestakerInstance.isPendingWithdrawal(roots[i]), true); + } + } + + function test_Withdraw_WeETHWithdrawAdapter_requestWithdraw() public { + vm.deal(alice, 100 ether); + + vm.startPrank(alice); + liquidityPoolInstance.deposit{value: 10 ether}(); // mint eETH for wrapping + + uint256 eEthToWrap = 1 ether; + eETHInstance.approve(address(weEthInstance), eEthToWrap); + weEthInstance.wrap(eEthToWrap); + + uint256 weEthAmountToWithdraw = weEthInstance.balanceOf(alice); + weEthInstance.approve(address(weEthWithdrawAdapterInstance), weEthAmountToWithdraw); + + uint256 nextRequestId = withdrawRequestNFTInstance.nextRequestId(); + uint256 requestId = weEthWithdrawAdapterInstance.requestWithdraw(weEthAmountToWithdraw, alice); + assertEq(requestId, nextRequestId); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), alice); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).isValid, true); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).feeGwei, 0); + vm.stopPrank(); + // Advance time until the oracle considers the next report epoch finalized. + // Condition inside oracle: (slotEpoch + 2 < currEpoch) <=> currEpoch >= slotEpoch + 3 + while (true) { + uint32 slot = etherFiOracleInstance.slotForNextReport(); + uint32 curr = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + uint32 min = ((slot / 32) + 3) * 32; + if (curr >= min) break; + uint256 d = min - curr; + vm.roll(block.number + d); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (curr + uint32(d))); + } + + IEtherFiOracle.OracleReport memory report; + uint256[] memory emptyVals = new uint256[](0); + report = IEtherFiOracle.OracleReport( + etherFiOracleInstance.consensusVersion(), 0, 0, 0, 0, 0, 0, emptyVals, emptyVals, 0, 0 + ); + report.lastFinalizedWithdrawalRequestId = uint32(requestId); + + (report.refSlotFrom, report.refSlotTo, report.refBlockFrom) = etherFiOracleInstance.blockStampForNextReport(); + // Set refBlockTo to a block number that is < block.number and > lastAdminExecutionBlock + report.refBlockTo = uint32(block.number - 1); + if (report.refBlockTo <= etherFiAdminInstance.lastAdminExecutionBlock()) { + report.refBlockTo = etherFiAdminInstance.lastAdminExecutionBlock() + 1; + } + + vm.prank(AVS_OPERATOR_1); + etherFiOracleInstance.submitReport(report); + + vm.prank(AVS_OPERATOR_2); + etherFiOracleInstance.submitReport(report); + + // Advance time for postReportWaitTimeInSlots + uint256 slotsToWait = uint256(etherFiAdminInstance.postReportWaitTimeInSlots() + 1); + uint32 slotAfterReport = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + vm.roll(block.number + slotsToWait); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (slotAfterReport + slotsToWait)); + + vm.prank(ADMIN_EOA); + etherFiAdminInstance.executeTasks(report); + + vm.startPrank(alice); + uint256 claimableAmount = withdrawRequestNFTInstance.getClaimableAmount(requestId); + uint256 beforeClaimBalance = alice.balance; + withdrawRequestNFTInstance.claimWithdraw(requestId); + assertApproxEqAbs(alice.balance, beforeClaimBalance + claimableAmount, 1e3); + vm.stopPrank(); + } + + function test_Withdraw_WeETHWithdrawAdapter_requestWithdrawWithPermit() public { + vm.deal(alice, 100 ether); + + vm.startPrank(alice); + liquidityPoolInstance.deposit{value: 10 ether}(); // mint eETH for wrapping + + uint256 eEthToWrap = 1 ether; + eETHInstance.approve(address(weEthInstance), eEthToWrap); + weEthInstance.wrap(eEthToWrap); + + uint256 weEthAmountToWithdraw = weEthInstance.balanceOf(alice); + + IWeETH.PermitInput memory weEthPermit = weEth_createPermitInput( + 2, + address(weEthWithdrawAdapterInstance), + weEthAmountToWithdraw, + weEthInstance.nonces(alice), + 2 ** 256 - 1, + weEthInstance.DOMAIN_SEPARATOR() + ); + + IWeETHWithdrawAdapter.PermitInput memory permit = IWeETHWithdrawAdapter.PermitInput({ + value: weEthPermit.value, + deadline: weEthPermit.deadline, + v: weEthPermit.v, + r: weEthPermit.r, + s: weEthPermit.s + }); + + uint256 nextRequestId = withdrawRequestNFTInstance.nextRequestId(); + uint256 requestId = weEthWithdrawAdapterInstance.requestWithdrawWithPermit(weEthAmountToWithdraw, alice, permit); + assertEq(requestId, nextRequestId); + assertEq(withdrawRequestNFTInstance.ownerOf(requestId), alice); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).isValid, true); + assertEq(withdrawRequestNFTInstance.getRequest(requestId).feeGwei, 0); + vm.stopPrank(); + // Advance time until the oracle considers the next report epoch finalized. + // Condition inside oracle: (slotEpoch + 2 < currEpoch) <=> currEpoch >= slotEpoch + 3 + while (true) { + uint32 slot = etherFiOracleInstance.slotForNextReport(); + uint32 curr = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + uint32 min = ((slot / 32) + 3) * 32; + if (curr >= min) break; + uint256 d = min - curr; + vm.roll(block.number + d); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (curr + uint32(d))); + } + + IEtherFiOracle.OracleReport memory report; + uint256[] memory emptyVals = new uint256[](0); + report = IEtherFiOracle.OracleReport( + etherFiOracleInstance.consensusVersion(), 0, 0, 0, 0, 0, 0, emptyVals, emptyVals, 0, 0 + ); + report.lastFinalizedWithdrawalRequestId = uint32(requestId); + + (report.refSlotFrom, report.refSlotTo, report.refBlockFrom) = etherFiOracleInstance.blockStampForNextReport(); + // Set refBlockTo to a block number that is < block.number and > lastAdminExecutionBlock + report.refBlockTo = uint32(block.number - 1); + if (report.refBlockTo <= etherFiAdminInstance.lastAdminExecutionBlock()) { + report.refBlockTo = etherFiAdminInstance.lastAdminExecutionBlock() + 1; + } + + vm.prank(AVS_OPERATOR_1); + etherFiOracleInstance.submitReport(report); + + vm.prank(AVS_OPERATOR_2); + etherFiOracleInstance.submitReport(report); + + // Advance time for postReportWaitTimeInSlots + uint256 slotsToWait = uint256(etherFiAdminInstance.postReportWaitTimeInSlots() + 1); + uint32 slotAfterReport = etherFiOracleInstance.computeSlotAtTimestamp(block.timestamp); + vm.roll(block.number + slotsToWait); + vm.warp(etherFiOracleInstance.beaconGenesisTimestamp() + 12 * (slotAfterReport + slotsToWait)); + + vm.prank(ADMIN_EOA); + etherFiAdminInstance.executeTasks(report); + + vm.startPrank(alice); + uint256 claimableAmount = withdrawRequestNFTInstance.getClaimableAmount(requestId); + uint256 beforeClaimBalance = alice.balance; + withdrawRequestNFTInstance.claimWithdraw(requestId); + assertApproxEqAbs(alice.balance, beforeClaimBalance + claimableAmount, 1e3); + vm.stopPrank(); + } + + +} \ No newline at end of file