diff --git a/.github/workflows/code-coverage.yaml b/.github/workflows/code-coverage.yaml index b2c42453..1a477fe0 100644 --- a/.github/workflows/code-coverage.yaml +++ b/.github/workflows/code-coverage.yaml @@ -14,7 +14,7 @@ jobs: - run: npm ci env: GH_TOKEN: ${{ secrets.github_token }} - - run: npx hardhat test --coverage + - run: npx hardhat test mocha --coverage env: NO_GAS_ENFORCE: '1' COVERAGE: 'true' diff --git a/Justfile b/Justfile index d321618a..7db58783 100644 --- a/Justfile +++ b/Justfile @@ -78,6 +78,14 @@ upgrade env network="": generate-safe-batch env="mainnet": npx tsx scripts/generate-safe-batch.ts --env {{env}} +# Generate hotfix deployment attestation for a two-module mainnet hotfix +generate-hotfix-attestation env clusters validators network="": + npx tsx scripts/generate-hotfix-attestation.ts --env {{env}} --clusters {{clusters}} --validators {{validators}} {{ if network == "" { "" } else { "--network " + network } }} + +# Generate hotfix SAFE batch with exactly SSVClusters and SSVValidators updateModule calls +generate-hotfix-safe-batch env clusters validators: + npx tsx scripts/generate-hotfix-safe-batch.ts --env {{env}} --clusters {{clusters}} --validators {{validators}} + # Generate deployment attestation (bytecode hashes + config summary for committee review) generate-attestation env="mainnet" network="": npx tsx scripts/generate-deployment-attestation.ts --env {{env}} {{ if network == "" { "" } else { "--network " + network } }} @@ -108,6 +116,12 @@ attach-module env module module-address network="": npx hardhat compile --force npx tsx scripts/attach-module.ts --env {{env}} --module {{module}} --module-address {{module-address}} {{ if network == "" { "" } else { "--network " + network } }} +# Deploy a new module implementation and attach it to the proxy in one step +# Example: just update-module SSVValidators 0xProxyAddress hoodi +update-module module proxy network: + npx hardhat compile --force + npx tsx scripts/update-module.ts --module {{module}} --proxy-address {{proxy}} --network {{network}} + # Upgrade a contract via UUPS proxy pattern (optionally with pre-deployed impl) upgrade-contract contract proxy network *impl: npx hardhat compile --force diff --git a/contracts/modules/SSVClusters.sol b/contracts/modules/SSVClusters.sol index 52c6c2cd..8269b532 100644 --- a/contracts/modules/SSVClusters.sol +++ b/contracts/modules/SSVClusters.sol @@ -307,6 +307,10 @@ contract SSVClusters is ISSVClusters, SSVReentrancyGuard { // Deviation-only model: baseline added via ethValidatorCount (in updateClusterOperatorsMigration above) // Only add deviation if cluster has explicit EB tracking uint64 vUnitsCluster = ebSnapshot.vUnits; + if (cluster.validatorCount == 0 && vUnitsCluster > 0) { + ebSnapshot.vUnits = 0; + vUnitsCluster = 0; + } if (vUnitsCluster > 0) { uint64 baseline = uint64(cluster.validatorCount) * BPS_DENOMINATOR; diff --git a/contracts/modules/SSVValidators.sol b/contracts/modules/SSVValidators.sol index 1ca467c2..1a85d8ba 100644 --- a/contracts/modules/SSVValidators.sol +++ b/contracts/modules/SSVValidators.sol @@ -247,6 +247,12 @@ contract SSVValidators is ISSVValidators { } cluster.validatorCount -= validatorsRemoved; + + if (cluster.validatorCount == 0) { + StorageEB storage seb = SSVStorageEB.load(); + seb.clusterEB[hashedCluster].vUnits = 0; + } + s.clusters[hashedCluster] = cluster.hashClusterData(); } else { revert IncorrectClusterVersion(); diff --git a/deployments/hoodi-prod/deploy-result.v2.0.0-upgrade4.json b/deployments/hoodi-prod/deploy-result.v2.0.0-upgrade4.json new file mode 100644 index 00000000..ec8194c4 --- /dev/null +++ b/deployments/hoodi-prod/deploy-result.v2.0.0-upgrade4.json @@ -0,0 +1,25 @@ +{ + "deployer": "0xC564AF154621Ee8D0589758d535511aEc8f67b40", + "chainId": "560048", + "network": "hoodi", + "deployedAt": "2026-03-17T12:18:01.926Z", + "blockNumber": 2434756, + "implementations": { + "SSVNetworkSSVStakingUpgrade": "0x80Be9D66831Ab6a40f16c10f78a81e6CDfB15057", + "SSVNetworkViews": "0xf70E9396a801BabDfe972D74ECce9716f164335b" + }, + "cssvToken": { + "address": "0x6e1a5d27361c666f681af06535c8Ac773E571d4d", + "deployed": false + }, + "modules": { + "SSVOperators": "0x6D4AeD4a18fAb66733EE3C52Eeb795b1ef660a85", + "SSVClusters": "0xD2E740424D339F36a6912e53684d901db2dfD236", + "SSVDAO": "0xf3929A559Aa14C75F41Bc59BfeB5BdCEd0e2ea1D", + "SSVViews": "0xc3d2F9eBa309a666C8c3Fd8580365366530FC1AF", + "SSVOperatorsWhitelist": "0xCd273a679f617144f290B5DBe81575E221965060", + "SSVStaking": "0x6C9aae90F5AFF6d282cE529aEFe258EaF7bd4d70", + "SSVValidators": "0x023Af0382D4243637F4b5264bebA97Ab81AE27A6" + }, + "updatedAt": "2026-04-07T12:17:51.362Z" +} diff --git a/deployments/hoodi-prod/deploy-result.v2.0.0.json b/deployments/hoodi-prod/deploy-result.v2.0.0.json index ec8194c4..36883a32 100644 --- a/deployments/hoodi-prod/deploy-result.v2.0.0.json +++ b/deployments/hoodi-prod/deploy-result.v2.0.0.json @@ -14,12 +14,12 @@ }, "modules": { "SSVOperators": "0x6D4AeD4a18fAb66733EE3C52Eeb795b1ef660a85", - "SSVClusters": "0xD2E740424D339F36a6912e53684d901db2dfD236", + "SSVClusters": "0xa5D12C6c73cA08c70D4b19575320EEAB4c22c51c", "SSVDAO": "0xf3929A559Aa14C75F41Bc59BfeB5BdCEd0e2ea1D", "SSVViews": "0xc3d2F9eBa309a666C8c3Fd8580365366530FC1AF", "SSVOperatorsWhitelist": "0xCd273a679f617144f290B5DBe81575E221965060", "SSVStaking": "0x6C9aae90F5AFF6d282cE529aEFe258EaF7bd4d70", - "SSVValidators": "0x023Af0382D4243637F4b5264bebA97Ab81AE27A6" + "SSVValidators": "0x3965Ebe3302b7dEC0563DaCc2f20DE5171E9ec2D" }, "updatedAt": "2026-04-07T12:17:51.362Z" } diff --git a/deployments/hoodi-stage/deploy-result.v2.0.0-upgrade7.json b/deployments/hoodi-stage/deploy-result.v2.0.0-upgrade7.json new file mode 100644 index 00000000..128e5a00 --- /dev/null +++ b/deployments/hoodi-stage/deploy-result.v2.0.0-upgrade7.json @@ -0,0 +1,24 @@ +{ + "deployer": "0xC564AF154621Ee8D0589758d535511aEc8f67b40", + "chainId": "560048", + "network": "hoodi", + "deployedAt": "2026-03-17T10:15:01.803Z", + "blockNumber": 1773756095, + "implementations": { + "SSVNetworkSSVStakingUpgrade": "0x6749B3Ade59FbA80f4e5c4066A993181843E1c00", + "SSVNetworkViews": "0x4c6f92E6d13748a83E96dF2AA5D3839a9Ed2b607" + }, + "cssvToken": { + "address": "0x6455a0d83FeB099182Fb6D024B9Ae0c2E26C0859", + "deployed": false + }, + "modules": { + "SSVOperators": "0x4a4972222E277794E697759Da629B9dB21b19246", + "SSVClusters": "0x5add198304F3d2596c253edF2A260e8e66c2042c", + "SSVDAO": "0x824e169e138B3B91977e040F80547Fce3779db97", + "SSVViews": "0xae12ceCA94a14aAC6c15A473FAAD9Cb75B2be53A", + "SSVOperatorsWhitelist": "0x5734A479F463e3DddDBB8fFC2C82253d41777BbC", + "SSVStaking": "0x99f2B313BD913d6E11E91BA7B3c5B51c4E486bE5", + "SSVValidators": "0x57aE505496ED7BF377A28bb645d4242CC5c74330" + } +} diff --git a/deployments/hoodi-stage/deploy-result.v2.0.0.json b/deployments/hoodi-stage/deploy-result.v2.0.0.json index 128e5a00..930df07f 100644 --- a/deployments/hoodi-stage/deploy-result.v2.0.0.json +++ b/deployments/hoodi-stage/deploy-result.v2.0.0.json @@ -14,11 +14,11 @@ }, "modules": { "SSVOperators": "0x4a4972222E277794E697759Da629B9dB21b19246", - "SSVClusters": "0x5add198304F3d2596c253edF2A260e8e66c2042c", + "SSVClusters": "0xa5D12C6c73cA08c70D4b19575320EEAB4c22c51c", "SSVDAO": "0x824e169e138B3B91977e040F80547Fce3779db97", "SSVViews": "0xae12ceCA94a14aAC6c15A473FAAD9Cb75B2be53A", "SSVOperatorsWhitelist": "0x5734A479F463e3DddDBB8fFC2C82253d41777BbC", "SSVStaking": "0x99f2B313BD913d6E11E91BA7B3c5B51c4E486bE5", - "SSVValidators": "0x57aE505496ED7BF377A28bb645d4242CC5c74330" + "SSVValidators": "0x3965Ebe3302b7dEC0563DaCc2f20DE5171E9ec2D" } } diff --git a/deployments/mainnet/deploy-result.v2.0.0-initial.json b/deployments/mainnet/deploy-result.v2.0.0-initial.json new file mode 100644 index 00000000..9d40b721 --- /dev/null +++ b/deployments/mainnet/deploy-result.v2.0.0-initial.json @@ -0,0 +1,24 @@ +{ + "deployer": "0x3187a42658417a4d60866163A4534Ce00D40C0C8", + "chainId": "1", + "network": "mainnet", + "deployedAt": "2026-04-19T08:25:37.577Z", + "blockNumber": 24912723, + "implementations": { + "SSVNetworkSSVStakingUpgrade": "0xa72a8F31163d74D708664493d09167dfa13008E9", + "SSVNetworkViews": "0xAdEb99eb2307F874D72b1F814fCa106f6BFaA8E9" + }, + "cssvToken": { + "address": "0xe018D31F120A637828F46aFD6c64EC099d960546", + "deployed": false + }, + "modules": { + "SSVOperators": "0x38DaA3fC4B1E5c02742b67F241B27Dceb8BFFA45", + "SSVClusters": "0x5DD8980c3c8B48BAce3A2Ec481bD61f7dE1523a9", + "SSVDAO": "0x94ef691dAa32cC2d31897aE8767e02988f1add4F", + "SSVViews": "0xf73954Ad8C96647c2238e6B7A435557Def23c19F", + "SSVOperatorsWhitelist": "0xE8CEac3f59EF0214c957Fd72F003bc9671a7196B", + "SSVStaking": "0x238C9C4f6026924c7B51400fa63452FAFF8e959A", + "SSVValidators": "0xCFA765F971B1D10b7bf989aad80cab817b92Fe1e" + } +} diff --git a/deployments/mainnet/deploy-result.v2.0.0.json b/deployments/mainnet/deploy-result.v2.0.0.json index 9d40b721..2d84c685 100644 --- a/deployments/mainnet/deploy-result.v2.0.0.json +++ b/deployments/mainnet/deploy-result.v2.0.0.json @@ -14,11 +14,11 @@ }, "modules": { "SSVOperators": "0x38DaA3fC4B1E5c02742b67F241B27Dceb8BFFA45", - "SSVClusters": "0x5DD8980c3c8B48BAce3A2Ec481bD61f7dE1523a9", + "SSVClusters": "0x3611D36A7C052211D6f3B1a39326ad38A02832B4", "SSVDAO": "0x94ef691dAa32cC2d31897aE8767e02988f1add4F", "SSVViews": "0xf73954Ad8C96647c2238e6B7A435557Def23c19F", "SSVOperatorsWhitelist": "0xE8CEac3f59EF0214c957Fd72F003bc9671a7196B", "SSVStaking": "0x238C9C4f6026924c7B51400fa63452FAFF8e959A", - "SSVValidators": "0xCFA765F971B1D10b7bf989aad80cab817b92Fe1e" + "SSVValidators": "0x9122Fded65Ed6b562243efDC9E55ff0bEF5E7499" } } diff --git a/deployments/mainnet/hotfix-deployment-attestation.json b/deployments/mainnet/hotfix-deployment-attestation.json new file mode 100644 index 00000000..30e97641 --- /dev/null +++ b/deployments/mainnet/hotfix-deployment-attestation.json @@ -0,0 +1,44 @@ +{ + "generatedAt": "2026-05-06T17:21:30.504Z", + "hotfix": { + "env": "mainnet", + "network": "mainnet", + "chainId": "1", + "ssvNetworkProxy": "0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1", + "owner": "0xb35096b074fdb9bBac63E3AdaE0Bbde512B2E6b6", + "description": "Hotfix deployment attestation for replacing only SSVClusters and SSVValidators module pointers." + }, + "contracts": { + "SSVClusters": { + "address": "0x3611D36A7C052211D6f3B1a39326ad38A02832B4", + "moduleId": 1, + "constructorArgs": {}, + "bytecodeHash": "0x2711afb14d4a81c44ea925939951ba37c5b3dfd49dd78ed4b235eb3052332e90" + }, + "SSVValidators": { + "address": "0x9122Fded65Ed6b562243efDC9E55ff0bEF5E7499", + "moduleId": 6, + "constructorArgs": {}, + "bytecodeHash": "0x91b323fa83a049400037c0d0e04843850719c1598385c2780df8bfc2d1a0d2b1" + } + }, + "intendedSafeTransactions": [ + { + "target": "0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1", + "function": "updateModule(uint8,address)", + "moduleName": "SSVClusters", + "moduleId": 1, + "moduleAddress": "0x3611D36A7C052211D6f3B1a39326ad38A02832B4" + }, + { + "target": "0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1", + "function": "updateModule(uint8,address)", + "moduleName": "SSVValidators", + "moduleId": 6, + "moduleAddress": "0x9122Fded65Ed6b562243efDC9E55ff0bEF5E7499" + } + ], + "relatedFileHashes": { + "hotfix-multisig-batch.json": "0xff5ea2ace65e5738340bf7d6512ebd1ebb574325f8cc27c2344575c313ff8bc2" + } +} diff --git a/deployments/mainnet/hotfix-multisig-batch.json b/deployments/mainnet/hotfix-multisig-batch.json new file mode 100644 index 00000000..18ef37d3 --- /dev/null +++ b/deployments/mainnet/hotfix-multisig-batch.json @@ -0,0 +1,56 @@ +{ + "version": "1.0", + "chainId": "1", + "createdAt": 1778088000273, + "meta": { + "name": "SSV Network Module Hotfix (mainnet)", + "description": "Hotfix batch for SSVNetwork.updateModule on SSVClusters and SSVValidators only. No proxy upgrade or parameter changes.", + "createdFromSafeAddress": "0xb35096b074fdb9bBac63E3AdaE0Bbde512B2E6b6" + }, + "transactions": [ + { + "to": "0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1", + "value": "0", + "data": "0xe3e324b000000000000000000000000000000000000000000000000000000000000000010000000000000000000000003611d36a7c052211d6f3b1a39326ad38a02832b4", + "contractMethod": { + "name": "updateModule", + "inputs": [ + { + "name": "moduleId", + "type": "uint8" + }, + { + "name": "moduleAddress", + "type": "address" + } + ] + }, + "contractInputsValues": { + "moduleId": "1", + "moduleAddress": "0x3611D36A7C052211D6f3B1a39326ad38A02832B4" + } + }, + { + "to": "0xDD9BC35aE942eF0cFa76930954a156B3fF30a4E1", + "value": "0", + "data": "0xe3e324b000000000000000000000000000000000000000000000000000000000000000060000000000000000000000009122fded65ed6b562243efdc9e55ff0bef5e7499", + "contractMethod": { + "name": "updateModule", + "inputs": [ + { + "name": "moduleId", + "type": "uint8" + }, + { + "name": "moduleAddress", + "type": "address" + } + ] + }, + "contractInputsValues": { + "moduleId": "6", + "moduleAddress": "0x9122Fded65Ed6b562243efDC9E55ff0bEF5E7499" + } + } + ] +} diff --git a/docs/playbooks/HOTFIX_UPGRADE_PLAYBOOK.md b/docs/playbooks/HOTFIX_UPGRADE_PLAYBOOK.md new file mode 100644 index 00000000..ef289bdc --- /dev/null +++ b/docs/playbooks/HOTFIX_UPGRADE_PLAYBOOK.md @@ -0,0 +1,220 @@ +# Mainnet Module Hotfix Playbook + +## Purpose + +This document describes the operational runbook for a mainnet SSV Network hotfix that deploys and attaches only two modules: + +- `SSVClusters` +- `SSVValidators` + +The hotfix does not upgrade the `SSVNetwork` proxy implementation, does not upgrade `SSVNetworkViews`, does not change protocol parameters, and does not move tokens. The SAFE batch must contain exactly two calls: + +```solidity +SSVNetwork.updateModule(1, ) +SSVNetwork.updateModule(6, ) +``` + +## Roles and Responsibilities + +### SSV Labs + +- Validate the hotfix code and compiled artifacts. +- Deploy the two new module implementations on Ethereum mainnet. +- Generate the hotfix SAFE Transaction Builder JSON. +- Generate the hotfix deployment attestation with bytecode hashes. +- Deliver the module addresses, attestation, SAFE batch, and file hashes to the SAFE committee. +- Run post-execution checks against a fork of the updated mainnet state. + +### SAFE Multisig Committee + +- Review the hotfix scope and confirm it is limited to two `updateModule` calls. +- Verify the two module addresses and bytecode hashes from the attestation. +- Import the hotfix SAFE batch into SAFE Transaction Builder. +- Sign and execute the batch on Ethereum mainnet. + +## Source of Truth + +For this hotfix, the source files are: + +- Config: `deployments/mainnet/config.json` +- Hotfix attestation: `deployments/mainnet/hotfix-deployment-attestation.json` +- Hotfix SAFE batch: `deployments/mainnet/hotfix-multisig-batch.json` + +Do not use `deployments/mainnet/multisig-batch.json` for this hotfix. That file is for the full major-upgrade flow and may include proxy upgrades, all module updates, parameter updates, oracle replacements, approval, and stake calls. + +## Preconditions + +Complete these checks before touching mainnet: + +1. Confirm the release branch/commit contains only the intended hotfix changes for `SSVClusters` and `SSVValidators`. +2. Confirm `deployments/mainnet/config.json` has the correct `owner` SAFE and `ssvNetworkProxy`. +3. Confirm `MAINNET_PRIVATE_KEY` is set for the SSV Labs deployer account. +4. Confirm the deployer account has enough ETH to deploy `SSVClusters` and `SSVValidators`. +5. Run the relevant local tests and fork validation for the hotfix. +6. Confirm the committee execution window and the expected SAFE nonce. +7. Prepare a mainnet fork after execution for the smoke test. + +## Step 1: Deploy Hotfix Modules on Mainnet + +SSV Labs deploys only the two module contracts: + +```bash +just deploy-module SSVClusters mainnet +just deploy-module SSVValidators mainnet +``` + +Capture the printed addresses: + +```bash +SSV_CLUSTERS_MODULE= +SSV_VALIDATORS_MODULE= +``` + +Optional Etherscan verification: + +```bash +just verify "$SSV_CLUSTERS_MODULE" mainnet +just verify "$SSV_VALIDATORS_MODULE" mainnet +``` + +## Step 2: Generate the Hotfix SAFE Batch + +Generate the SAFE Transaction Builder JSON from the two deployed module addresses: + +```bash +just generate-hotfix-safe-batch mainnet "$SSV_CLUSTERS_MODULE" "$SSV_VALIDATORS_MODULE" +``` + +This writes: + +```text +deployments/mainnet/hotfix-multisig-batch.json +``` + +Expected batch contents: + +1. `SSVNetwork.updateModule(1, SSV_CLUSTERS_MODULE)` +2. `SSVNetwork.updateModule(6, SSV_VALIDATORS_MODULE)` + +The command prints the `keccak256` file hash of `hotfix-multisig-batch.json`. Preserve that hash for committee review. + +## Step 3: Generate the Hotfix Attestation + +Generate the hotfix deployment attestation: + +```bash +just generate-hotfix-attestation mainnet "$SSV_CLUSTERS_MODULE" "$SSV_VALIDATORS_MODULE" +``` + +This writes: + +```text +deployments/mainnet/hotfix-deployment-attestation.json +``` + +The attestation includes: + +- generated timestamp +- network and chain ID +- `SSVNetwork` proxy address +- SAFE owner address +- `SSVClusters` module ID and address +- `SSVValidators` module ID and address +- on-chain runtime bytecode hash for each module +- intended SAFE transactions +- `hotfix-multisig-batch.json` hash, if the batch already exists + +To independently verify each bytecode hash: + +```bash +cast keccak $(cast code "$SSV_CLUSTERS_MODULE" --rpc-url "$MAINNET_RPC_URL") +cast keccak $(cast code "$SSV_VALIDATORS_MODULE" --rpc-url "$MAINNET_RPC_URL") +``` + +## Step 4: SAFE Committee Review and Execution + +The multisig committee should: + +1. Import `deployments/mainnet/hotfix-multisig-batch.json` into SAFE Transaction Builder. +2. Confirm the SAFE address matches `deployments/mainnet/config.json` `owner`. +3. Confirm both transaction targets are the `SSVNetwork` proxy from `deployments/mainnet/config.json`. +4. Decode and review the calldata. +5. Confirm transaction 1 is `updateModule(1, )`. +6. Confirm transaction 2 is `updateModule(6, )`. +7. Confirm there are no `upgradeTo`, `upgradeToAndCall`, parameter setter, oracle, token approval, stake, or ETH transfer calls. +8. Verify the module addresses and bytecode hashes against `hotfix-deployment-attestation.json`. +9. Sign and execute the batch on mainnet. + +After execution, preserve the SAFE transaction hash and the emitted `ModuleUpgraded` events for module IDs `1` and `6`. + +## Step 5: Post-Execution Checks + +After the SAFE batch executes, run a smoke test against a fork of the just-updated mainnet state: + +```bash +anvil --fork-url "$MAINNET_RPC_URL" --port 8545 +just smoke-test mainnet local +``` + +The smoke test exercises the main user flows through the current mainnet proxy and therefore validates that calls delegated through the newly attached `SSVClusters` and `SSVValidators` modules still work end to end. + +Recommended additional checks: + +```bash +just verify-upgrade mainnet +``` + +`verify-upgrade` confirms the configured protocol state still matches `deployments/mainnet/config.json`. It does not independently read module pointers, so the module-pointer evidence for this hotfix is the SAFE execution calldata, the `ModuleUpgraded` events, and the successful fork smoke test. + +## Artifacts to Preserve + +Archive the following: + +- deployed `SSVClusters` address and transaction hash +- deployed `SSVValidators` address and transaction hash +- `deployments/mainnet/hotfix-deployment-attestation.json` +- `deployments/mainnet/hotfix-multisig-batch.json` +- printed `keccak256` file hashes +- SAFE transaction hash +- `ModuleUpgraded` logs for module IDs `1` and `6` +- post-execution smoke test output + +## Failure and Abort Conditions + +Abort the hotfix if any of the following occurs: + +- Either deployed module address has no code on mainnet. +- The generated SAFE batch contains anything other than the two expected `updateModule` calls. +- The SAFE target address is not the mainnet `SSVNetwork` proxy. +- Module ID `1` does not point to the new `SSVClusters` address. +- Module ID `6` does not point to the new `SSVValidators` address. +- The committee cannot independently match the bytecode hashes. +- The imported SAFE batch differs from the generated JSON or printed file hash. + +If execution fails or only partially executes, stop and reconcile the executed transaction logs before preparing any replacement batch. + +## Mainnet Command Summary + +SSV Labs: + +```bash +just deploy-module SSVClusters mainnet +just deploy-module SSVValidators mainnet + +SSV_CLUSTERS_MODULE= +SSV_VALIDATORS_MODULE= + +just generate-hotfix-safe-batch mainnet "$SSV_CLUSTERS_MODULE" "$SSV_VALIDATORS_MODULE" +just generate-hotfix-attestation mainnet "$SSV_CLUSTERS_MODULE" "$SSV_VALIDATORS_MODULE" + +# After SAFE execution: +anvil --fork-url "$MAINNET_RPC_URL" --port 8545 +just smoke-test mainnet local +``` + +SAFE committee: + +1. Import `deployments/mainnet/hotfix-multisig-batch.json`. +2. Review exactly two `updateModule` calls. +3. Sign. +4. Execute. diff --git a/docs/UPGRADE_PLAYBOOK.md b/docs/playbooks/UPGRADE_PLAYBOOK.md similarity index 100% rename from docs/UPGRADE_PLAYBOOK.md rename to docs/playbooks/UPGRADE_PLAYBOOK.md diff --git a/hardhat.config.ts b/hardhat.config.ts index 68c07c00..d78bc11a 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -28,6 +28,14 @@ export default defineConfig({ }, }, }, + paths: { + tests: { + mocha: "test", + // Echidna harnesses are compiled by Echidna/Foundry, + // not Hardhat's Solidity test runner. + solidity: "test/solidity", + }, + }, solidity: { npmFilesToBuild: ["@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"], compilers: [ diff --git a/scripts/generate-hotfix-attestation.ts b/scripts/generate-hotfix-attestation.ts new file mode 100644 index 00000000..88ab6143 --- /dev/null +++ b/scripts/generate-hotfix-attestation.ts @@ -0,0 +1,211 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import "dotenv/config"; +import { isAddress, JsonRpcProvider, keccak256 } from "ethers"; +import { + type UpgradeConfig, + parseOptionalArg, + requireAddress, + resolveConfigPath, + resolveEnvDir, + resolveNetworkFromEnv, +} from "./common/config.ts"; +import { SSVModules } from "./common/modules.ts"; + +type HotfixContractEntry = { + address: string; + moduleId: number; + constructorArgs: Record; + bytecodeHash: string; +}; + +type HotfixAttestation = { + generatedAt: string; + hotfix: { + env: string; + network: string; + chainId: string; + ssvNetworkProxy: string; + owner: string; + description: string; + }; + contracts: { + SSVClusters: HotfixContractEntry; + SSVValidators: HotfixContractEntry; + }; + intendedSafeTransactions: Array<{ + target: string; + function: string; + moduleName: "SSVClusters" | "SSVValidators"; + moduleId: number; + moduleAddress: string; + }>; + relatedFileHashes: { + "hotfix-multisig-batch.json"?: string; + }; +}; + +function resolveRpcUrl(targetNetwork: string): string | undefined { + if (targetNetwork === "mainnet") return process.env.MAINNET_RPC_URL; + if (targetNetwork === "hoodi") return process.env.HOODI_RPC_URL; + if (targetNetwork === "local" || targetNetwork === "localhost") return "http://127.0.0.1:8545"; + return undefined; +} + +function parseRequiredAddress(argName: string): string { + const value = parseOptionalArg(argName); + if (!value) throw new Error(`Missing --${argName}`); + if (!isAddress(value)) throw new Error(`Invalid --${argName}: ${value}`); + return value; +} + +async function fetchBytecodeHash(provider: any, address: string): Promise { + const code = await provider.getCode(address); + if (code === "0x") { + throw new Error(`No contract code at ${address}`); + } + return keccak256(code); +} + +async function maybeReadHash(path: string): Promise { + try { + const content = await readFile(path, "utf8"); + return keccak256(new TextEncoder().encode(content)); + } catch { + return undefined; + } +} + +async function main() { + const envFlag = parseOptionalArg("env") ?? "mainnet"; + const networkOverride = parseOptionalArg("network"); + const targetNetwork = networkOverride ?? resolveNetworkFromEnv(envFlag) ?? "mainnet"; + const rpcUrl = parseOptionalArg("rpc-url") ?? resolveRpcUrl(targetNetwork); + if (!rpcUrl) { + throw new Error( + `Missing RPC URL for network '${targetNetwork}'. ` + + "Set MAINNET_RPC_URL/HOODI_RPC_URL or pass --rpc-url .", + ); + } + + const clusters = parseRequiredAddress("clusters"); + const validators = parseRequiredAddress("validators"); + + const config = JSON.parse(await readFile(resolveConfigPath(envFlag), "utf8")) as UpgradeConfig; + const ssvNetworkProxy = requireAddress(config.ssvNetworkProxy, "ssvNetworkProxy"); + if (!config.owner) { + throw new Error(`Missing owner in deployments/${envFlag}/config.json; required for hotfix attestation.`); + } + const ownerAddr = requireAddress(config.owner, "owner"); + + const provider = new JsonRpcProvider(rpcUrl); + const network = await provider.getNetwork(); + + console.log(`Generating hotfix deployment attestation for ${envFlag} on ${targetNetwork}...`); + const [clustersHash, validatorsHash] = await Promise.all([ + fetchBytecodeHash(provider, clusters), + fetchBytecodeHash(provider, validators), + ]); + + const attestationWithoutHashes = { + generatedAt: new Date().toISOString(), + hotfix: { + env: envFlag, + network: targetNetwork, + chainId: network.chainId.toString(), + ssvNetworkProxy, + owner: ownerAddr, + description: + "Hotfix deployment attestation for replacing only SSVClusters and SSVValidators module pointers.", + }, + contracts: { + SSVClusters: { + address: clusters, + moduleId: SSVModules.SSVClusters, + constructorArgs: {}, + bytecodeHash: clustersHash, + }, + SSVValidators: { + address: validators, + moduleId: SSVModules.SSVValidators, + constructorArgs: {}, + bytecodeHash: validatorsHash, + }, + }, + intendedSafeTransactions: [ + { + target: ssvNetworkProxy, + function: "updateModule(uint8,address)", + moduleName: "SSVClusters" as const, + moduleId: SSVModules.SSVClusters, + moduleAddress: clusters, + }, + { + target: ssvNetworkProxy, + function: "updateModule(uint8,address)", + moduleName: "SSVValidators" as const, + moduleId: SSVModules.SSVValidators, + moduleAddress: validators, + }, + ], + }; + + const outputPath = join(resolveEnvDir(envFlag), "hotfix-deployment-attestation.json"); + const batchPath = join(resolveEnvDir(envFlag), "hotfix-multisig-batch.json"); + const batchHash = await maybeReadHash(batchPath); + const attestation: HotfixAttestation = { + ...attestationWithoutHashes, + relatedFileHashes: { + ...(batchHash && { "hotfix-multisig-batch.json": batchHash }), + }, + }; + const content = `${JSON.stringify(attestation, null, 2)}\n`; + await writeFile(outputPath, content, "utf8"); + const attestationFileHash = keccak256(new TextEncoder().encode(content)); + + console.log(`\nHotfix attestation written to: ${outputPath}`); + if (!batchHash) { + console.warn(`Warning: ${batchPath} not found. Generate the SAFE batch to include its hash in the attestation.`); + } + + console.log("\n" + "=".repeat(80)); + console.log("SSV Network Hotfix Deployment Attestation"); + console.log("=".repeat(80)); + console.log(`Scope: SSVClusters + SSVValidators module replacement`); + console.log(`Env: ${envFlag}`); + console.log(`Network: ${targetNetwork} (chain ${network.chainId.toString()})`); + console.log(`SAFE: ${ownerAddr}`); + console.log(`Proxy: ${ssvNetworkProxy}`); + console.log(""); + console.log("Deployed Modules:"); + console.log("-".repeat(80)); + const moduleNameWidth = Math.max(...Object.keys(attestation.contracts).map((name) => name.length)); + for (const [name, entry] of Object.entries(attestation.contracts)) { + console.log(` ${name.padEnd(moduleNameWidth)} moduleId: ${entry.moduleId}`); + console.log(` ${"".padEnd(moduleNameWidth)} address: ${entry.address}`); + console.log(` ${"".padEnd(moduleNameWidth)} bytecodeHash: ${entry.bytecodeHash}`); + } + console.log(""); + console.log("Intended SAFE Calls:"); + console.log("-".repeat(80)); + for (const [index, tx] of attestation.intendedSafeTransactions.entries()) { + console.log(` ${index + 1}. ${tx.function}`); + console.log(` target: ${tx.target}`); + console.log(` moduleName: ${tx.moduleName}`); + console.log(` moduleId: ${tx.moduleId}`); + console.log(` moduleAddress: ${tx.moduleAddress}`); + } + console.log(""); + console.log("File Hashes (keccak256 — for committee verification):"); + console.log("-".repeat(80)); + console.log(` hotfix-deployment-attestation.json: ${attestationFileHash}`); + if (batchHash) { + console.log(` hotfix-multisig-batch.json: ${batchHash}`); + } + console.log("=".repeat(80)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/generate-hotfix-safe-batch.ts b/scripts/generate-hotfix-safe-batch.ts new file mode 100644 index 00000000..40f79179 --- /dev/null +++ b/scripts/generate-hotfix-safe-batch.ts @@ -0,0 +1,137 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Interface, isAddress, keccak256 } from "ethers"; +import { + type UpgradeConfig, + parseOptionalArg, + requireAddress, + resolveConfigPath, + resolveDeployResultPath, + resolveEnvDir, +} from "./common/config.ts"; +import { SSVModules } from "./common/modules.ts"; + +type SafeTransaction = { + to: string; + value: string; + data: string; + contractMethod: { + name: string; + inputs: Array<{ name: string; type: string }>; + }; + contractInputsValues: Record; +}; + +type SafeBatchJson = { + version: string; + chainId: string; + createdAt: number; + meta: { + name: string; + description: string; + createdFromSafeAddress: string; + }; + transactions: SafeTransaction[]; +}; + +function parseRequiredAddress(argName: string): string { + const value = parseOptionalArg(argName); + if (!value) throw new Error(`Missing --${argName}`); + if (!isAddress(value)) throw new Error(`Invalid --${argName}: ${value}`); + return value; +} + +async function resolveChainId(envFlag: string): Promise { + const explicit = parseOptionalArg("chain-id"); + if (explicit) return explicit; + + try { + const deployResult = JSON.parse(await readFile(resolveDeployResultPath(envFlag), "utf8")) as { chainId?: string }; + if (deployResult.chainId) return deployResult.chainId; + } catch { + // Fall back to known env mappings when no deploy-result exists. + } + + if (envFlag === "mainnet") return "1"; + if (envFlag.startsWith("hoodi")) return "560048"; + if (envFlag === "local") return "31337"; + throw new Error(`Could not resolve chain ID for env '${envFlag}'. Pass --chain-id explicitly.`); +} + +async function main() { + const envFlag = parseOptionalArg("env") ?? "mainnet"; + const chainId = await resolveChainId(envFlag); + const name = parseOptionalArg("name") ?? `SSV Network Module Hotfix (${envFlag})`; + + const clusters = parseRequiredAddress("clusters"); + const validators = parseRequiredAddress("validators"); + + const config = JSON.parse(await readFile(resolveConfigPath(envFlag), "utf8")) as UpgradeConfig; + const ssvNetworkProxy = requireAddress(config.ssvNetworkProxy, "ssvNetworkProxy"); + if (!config.owner) { + throw new Error(`Missing owner in deployments/${envFlag}/config.json; required for SAFE batch metadata.`); + } + const ownerAddr = requireAddress(config.owner, "owner"); + + const ssvNetworkIface = new Interface([ + "function updateModule(uint8 moduleId, address moduleAddress)", + ]); + + const inputs = [ + { name: "moduleId", type: "uint8" }, + { name: "moduleAddress", type: "address" }, + ]; + const transactions: SafeTransaction[] = [ + { + to: ssvNetworkProxy, + value: "0", + data: ssvNetworkIface.encodeFunctionData("updateModule", [SSVModules.SSVClusters, clusters]), + contractMethod: { name: "updateModule", inputs }, + contractInputsValues: { + moduleId: String(SSVModules.SSVClusters), + moduleAddress: clusters, + }, + }, + { + to: ssvNetworkProxy, + value: "0", + data: ssvNetworkIface.encodeFunctionData("updateModule", [SSVModules.SSVValidators, validators]), + contractMethod: { name: "updateModule", inputs }, + contractInputsValues: { + moduleId: String(SSVModules.SSVValidators), + moduleAddress: validators, + }, + }, + ]; + + const batch: SafeBatchJson = { + version: "1.0", + chainId, + createdAt: Date.now(), + meta: { + name, + description: + "Hotfix batch for SSVNetwork.updateModule on SSVClusters and SSVValidators only. No proxy upgrade or parameter changes.", + createdFromSafeAddress: ownerAddr, + }, + transactions, + }; + + const outputPath = join(resolveEnvDir(envFlag), "hotfix-multisig-batch.json"); + const content = `${JSON.stringify(batch, null, 2)}\n`; + await writeFile(outputPath, content, "utf8"); + + console.log(`Hotfix SAFE Transaction Builder batch generated: ${outputPath}`); + console.log(`Total transactions: ${transactions.length}`); + console.log(`Chain ID: ${chainId}`); + console.log(`Owner (SAFE address): ${ownerAddr}`); + console.log(`SSVNetwork proxy: ${ssvNetworkProxy}`); + console.log(`SSVClusters module: ${clusters}`); + console.log(`SSVValidators module: ${validators}`); + console.log(`hotfix-multisig-batch.json keccak256: ${keccak256(new TextEncoder().encode(content))}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/update-module.ts b/scripts/update-module.ts new file mode 100644 index 00000000..8655a67d --- /dev/null +++ b/scripts/update-module.ts @@ -0,0 +1,26 @@ +import { attachModule, deployContract, getDeployer, getEthers, parseArg } from "./common/helpers.ts"; +import { SSVModules } from "./common/modules.ts"; + +async function main() { + const moduleName = parseArg("module"); + const proxyAddress = parseArg("proxy-address"); + const targetNetwork = parseArg("network"); + + const moduleEnumKey = moduleName as keyof typeof SSVModules; + if (SSVModules[moduleEnumKey] === undefined) { + throw new Error(`Invalid module: ${moduleName}. Valid: ${Object.keys(SSVModules).join(", ")}`); + } + + const ethers = await getEthers(targetNetwork); + await getDeployer(ethers); + + const { address: moduleAddress } = await deployContract(ethers, moduleName); + await attachModule(ethers, proxyAddress, moduleName, moduleAddress); + + console.log(`Done: ${moduleName} deployed at ${moduleAddress} and attached to proxy ${proxyAddress}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/echidna/SSVLegacyValidatorRemovalEchidna.sol b/test/echidna/SSVLegacyValidatorRemovalEchidna.sol index 1665dcd9..0e99655b 100644 --- a/test/echidna/SSVLegacyValidatorRemovalEchidna.sol +++ b/test/echidna/SSVLegacyValidatorRemovalEchidna.sol @@ -8,6 +8,7 @@ import "../../contracts/libraries/ClusterLib.sol"; import "../../contracts/libraries/ProtocolLib.sol"; import "../../contracts/libraries/ValidatorLib.sol"; import "../../contracts/libraries/storage/SSVStorage.sol"; +import "../../contracts/libraries/storage/SSVStorageEB.sol"; import "../../contracts/libraries/storage/SSVStorageProtocol.sol"; import "../../contracts/modules/SSVClusters.sol"; import "../../contracts/modules/SSVValidators.sol"; @@ -15,7 +16,7 @@ import "../../contracts/test/mocks/MockToken.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/Counters.sol"; -import {PackedETH, PackedSSV, PACKED_ETH_ZERO, PACKED_SSV_ZERO, DEDUCTED_DIGITS} from "../../contracts/libraries/SSVCoreTypes.sol"; +import {PackedETH, PackedSSV, PACKED_ETH_ZERO, PACKED_SSV_ZERO, DEDUCTED_DIGITS, BPS_DENOMINATOR} from "../../contracts/libraries/SSVCoreTypes.sol"; contract LegacySSVValidatorRemovalUser { ISSVValidators public validators; @@ -247,6 +248,9 @@ contract SSVLegacyValidatorRemovalEchidna is SSVClusters, SSVValidators { if (s.clusters[targetClusterId] != expectedAfter.hashClusterData()) return false; if (s.validatorPKs[firstHash] != bytes32(0)) return false; if (secondHash != bytes32(0) && s.validatorPKs[secondHash] != bytes32(0)) return false; + if (expectedAfter.validatorCount == 0 && SSVStorageEB.load().clusterEB[targetClusterId].vUnits != 0) { + return false; + } return true; } @@ -290,6 +294,7 @@ contract SSVLegacyValidatorRemovalEchidna is SSVClusters, SSVValidators { }); s.clusters[activeClusterId] = activeClusterModel.hashClusterData(); + _seedExplicitEBSnapshot(activeClusterId); sp.updateDAOSSV(true, INITIAL_VALIDATOR_COUNT); s.operators[op1].validatorCount = INITIAL_VALIDATOR_COUNT; @@ -318,6 +323,7 @@ contract SSVLegacyValidatorRemovalEchidna is SSVClusters, SSVValidators { }); s.clusters[liquidatedClusterId] = liquidatedClusterModel.hashClusterData(); + _seedExplicitEBSnapshot(liquidatedClusterId); ValidatorLib.registerPublicKey(_validatorKey(1), _operatorIds(), address(liquidatedOwner), s); ValidatorLib.registerPublicKey(_validatorKey(2), _operatorIds(), address(liquidatedOwner), s); @@ -325,6 +331,11 @@ contract SSVLegacyValidatorRemovalEchidna is SSVClusters, SSVValidators { liquidatedValidatorPresent[2] = true; } + function _seedExplicitEBSnapshot(bytes32 clusterId) internal { + SSVStorageEB.load().clusterEB[clusterId].vUnits = + uint64(INITIAL_VALIDATOR_COUNT + 1) * BPS_DENOMINATOR; + } + function _createOperator(StorageData storage s, bytes32 pk) internal returns (uint64) { s.lastOperatorId.increment(); uint64 id = uint64(s.lastOperatorId.current()); diff --git a/test/echidna/SSVMigrationEchidna.sol b/test/echidna/SSVMigrationEchidna.sol index 1fff2e60..1db5c2eb 100644 --- a/test/echidna/SSVMigrationEchidna.sol +++ b/test/echidna/SSVMigrationEchidna.sol @@ -4,27 +4,32 @@ pragma solidity 0.8.24; import "../../contracts/interfaces/ISSVClusters.sol"; import "../../contracts/interfaces/ISSVNetworkCore.sol"; import "../../contracts/interfaces/ISSVOperators.sol"; +import "../../contracts/interfaces/ISSVValidators.sol"; import "../../contracts/libraries/ClusterLib.sol"; import "../../contracts/libraries/ProtocolLib.sol"; +import "../../contracts/libraries/ValidatorLib.sol"; import "../../contracts/libraries/storage/SSVStorage.sol"; import "../../contracts/libraries/storage/SSVStorageEB.sol"; import "../../contracts/libraries/storage/SSVStorageProtocol.sol"; import "../../contracts/modules/SSVClusters.sol"; import "../../contracts/modules/SSVDAO.sol"; import "../../contracts/modules/SSVOperators.sol"; +import "../../contracts/modules/SSVValidators.sol"; import "../../contracts/test/mocks/MockToken.sol"; import "./SSVStakingEchidna.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/Counters.sol"; import {PackedETHLib, PackedSSVLib} from "../../contracts/libraries/SSVPackedLib.sol"; -import {PackedETH, PackedSSV, PACKED_ETH_ZERO, PACKED_SSV_ZERO, DEDUCTED_DIGITS, ETH_DEDUCTED_DIGITS} from "../../contracts/libraries/SSVCoreTypes.sol"; +import {PackedETH, PackedSSV, PACKED_ETH_ZERO, PACKED_SSV_ZERO, DEDUCTED_DIGITS, ETH_DEDUCTED_DIGITS, BPS_DENOMINATOR} from "../../contracts/libraries/SSVCoreTypes.sol"; contract MigrationClusterUser { ISSVClusters public clusters; + ISSVValidators public validators; - constructor(ISSVClusters clusters_) { + constructor(ISSVClusters clusters_, ISSVValidators validators_) { clusters = clusters_; + validators = validators_; } receive() external payable {} @@ -47,6 +52,14 @@ contract MigrationClusterUser { ) external { clusters.updateClusterBalance(blockNum, clusterOwner, operatorIds, cluster, effectiveBalance, merkleProof); } + + function bulkRemoveValidators( + bytes[] calldata publicKeys, + uint64[] calldata operatorIds, + ISSVNetworkCore.Cluster memory cluster + ) external { + validators.bulkRemoveValidator(publicKeys, operatorIds, cluster); + } } contract MigrationOperatorUser { @@ -64,7 +77,7 @@ contract MigrationOperatorUser { } /// @notice Targeted migration harness for BUG-14 class: removed operators and frozen SSV index accounting. -contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { +contract SSVMigrationEchidna is SSVClusters, SSVValidators, SSVOperators(0), SSVDAO { using ClusterLib for ISSVNetworkCore.Cluster; using Counters for Counters.Counter; using ProtocolLib for StorageProtocol; @@ -111,6 +124,8 @@ contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { bool private liquidatedMigrationObserved; bool private ssvEbUpdateViolation; bool private removedOperatorVUnitsMutationViolation; + bool private zeroValidatorMigrationVUnitsViolation; + bool private phantomVUnitsViolation; uint32 private daoValidatorCountBeforeMigration; uint32 private ethDaoValidatorCountBeforeMigration; uint32 private migratedValidatorCount; @@ -124,9 +139,10 @@ contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { _mockSetToken(address(token)); ISSVClusters clustersSelf = ISSVClusters(address(this)); + ISSVValidators validatorsSelf = ISSVValidators(address(this)); ISSVOperators operatorsSelf = ISSVOperators(address(this)); - clusterOwner = new MigrationClusterUser(clustersSelf); + clusterOwner = new MigrationClusterUser(clustersSelf, validatorsSelf); opOwner1 = new MigrationOperatorUser(operatorsSelf); opOwner2 = new MigrationOperatorUser(operatorsSelf); opOwner3 = new MigrationOperatorUser(operatorsSelf); @@ -447,6 +463,151 @@ contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { this.action_migrate_ssv_to_eth(seed); } + function action_migrate_zero_validator_after_real_removal() external { + if (!ssvRecord.exists || !ssvRecord.cluster.active) return; + if (ssvRecord.cluster.validatorCount != INITIAL_VALIDATOR_COUNT) return; + if (_hasRemovedTrackedOperator()) return; + + _settleSsvCluster(); + + StorageData storage s = SSVStorage.load(); + StorageProtocol storage sp = SSVStorageProtocol.load(); + StorageEB storage seb = SSVStorageEB.load(); + + seb.clusterEB[ssvClusterId].vUnits = uint64(ssvRecord.cluster.validatorCount + 1) * BPS_DENOMINATOR; + + ISSVNetworkCore.Cluster memory clusterBeforeRemove = ssvRecord.cluster; + try clusterOwner.bulkRemoveValidators(_initialValidatorPublicKeys(), operatorIds, clusterBeforeRemove) { + ISSVNetworkCore.Cluster memory clusterAfterRemove = clusterBeforeRemove; + clusterAfterRemove.validatorCount = 0; + + if (s.clusters[ssvClusterId] != clusterAfterRemove.hashClusterData()) { + zeroValidatorMigrationVUnitsViolation = true; + return; + } + if (seb.clusterEB[ssvClusterId].vUnits != 0) { + zeroValidatorMigrationVUnitsViolation = true; + return; + } + if (!_initialValidatorsRemoved()) { + zeroValidatorMigrationVUnitsViolation = true; + return; + } + + ssvRecord.cluster = clusterAfterRemove; + } catch { + zeroValidatorMigrationVUnitsViolation = true; + return; + } + + uint32 daoValidatorCountBefore = sp.daoValidatorCount; + uint32 ethDaoValidatorCountBefore = sp.ethDaoValidatorCount; + uint64 daoTotalEthVUnitsBefore = sp.daoTotalEthVUnits; + uint32[] memory operatorEthValidatorCountsBefore = new uint32[](operatorIds.length); + uint64[] memory operatorEthVUnitsBefore = new uint64[](operatorIds.length); + for (uint256 i; i < operatorIds.length; ++i) { + uint64 operatorId = operatorIds[i]; + operatorEthValidatorCountsBefore[i] = s.operators[operatorId].ethValidatorCount; + operatorEthVUnitsBefore[i] = seb.operatorEthVUnits[operatorId]; + } + + try clusterOwner.migrateToETH{value: 0}(operatorIds, ssvRecord.cluster) { + if (sp.daoValidatorCount != daoValidatorCountBefore) { + zeroValidatorMigrationVUnitsViolation = true; + } + if (sp.ethDaoValidatorCount != ethDaoValidatorCountBefore) { + zeroValidatorMigrationVUnitsViolation = true; + } + if (sp.daoTotalEthVUnits != daoTotalEthVUnitsBefore) { + zeroValidatorMigrationVUnitsViolation = true; + } + if (seb.clusterEB[ssvClusterId].vUnits != 0) { + zeroValidatorMigrationVUnitsViolation = true; + } + if (s.clusters[ssvClusterId] != 0 || s.ethClusters[ssvClusterId] == 0) { + zeroValidatorMigrationVUnitsViolation = true; + } + for (uint256 i; i < operatorIds.length; ++i) { + uint64 operatorId = operatorIds[i]; + if (s.operators[operatorId].ethValidatorCount != operatorEthValidatorCountsBefore[i]) { + zeroValidatorMigrationVUnitsViolation = true; + } + if (seb.operatorEthVUnits[operatorId] != operatorEthVUnitsBefore[i]) { + zeroValidatorMigrationVUnitsViolation = true; + } + } + + ssvRecord.exists = false; + } catch { + zeroValidatorMigrationVUnitsViolation = true; + } + } + + function action_migrate_zero_validator_with_phantom_vunits(uint256 seed) external { + if (!ssvRecord.exists || !ssvRecord.cluster.active) return; + if (ssvRecord.cluster.validatorCount != INITIAL_VALIDATOR_COUNT) return; + if (_hasRemovedTrackedOperator()) return; + + _settleSsvCluster(); + + StorageData storage s = SSVStorage.load(); + StorageProtocol storage sp = SSVStorageProtocol.load(); + StorageEB storage seb = SSVStorageEB.load(); + + ISSVNetworkCore.Cluster memory clusterBeforeRemove = ssvRecord.cluster; + try clusterOwner.bulkRemoveValidators(_initialValidatorPublicKeys(), operatorIds, clusterBeforeRemove) { + ISSVNetworkCore.Cluster memory clusterAfterRemove = clusterBeforeRemove; + clusterAfterRemove.validatorCount = 0; + + if (s.clusters[ssvClusterId] != clusterAfterRemove.hashClusterData()) { + phantomVUnitsViolation = true; + return; + } + if (!_initialValidatorsRemoved()) { + phantomVUnitsViolation = true; + return; + } + + ssvRecord.cluster = clusterAfterRemove; + } catch { + return; + } + + uint64 phantom = uint64((seed % 5) + 1) * BPS_DENOMINATOR; + seb.clusterEB[ssvClusterId].vUnits = phantom; + + uint32 daoValidatorCountBefore = sp.daoValidatorCount; + uint32 ethDaoValidatorCountBefore = sp.ethDaoValidatorCount; + uint64 daoVUnitsBefore = sp.daoTotalEthVUnits; + uint32[] memory operatorEthValidatorCountsBefore = new uint32[](operatorIds.length); + uint64[] memory opVUnitsBefore = new uint64[](operatorIds.length); + for (uint256 i; i < operatorIds.length; ++i) { + uint64 operatorId = operatorIds[i]; + operatorEthValidatorCountsBefore[i] = s.operators[operatorId].ethValidatorCount; + opVUnitsBefore[i] = seb.operatorEthVUnits[operatorId]; + } + + try clusterOwner.migrateToETH{value: 0}(operatorIds, ssvRecord.cluster) { + if (seb.clusterEB[ssvClusterId].vUnits != 0) phantomVUnitsViolation = true; + if (sp.daoTotalEthVUnits != daoVUnitsBefore) phantomVUnitsViolation = true; + if (sp.daoValidatorCount != daoValidatorCountBefore) phantomVUnitsViolation = true; + if (sp.ethDaoValidatorCount != ethDaoValidatorCountBefore) phantomVUnitsViolation = true; + for (uint256 i; i < operatorIds.length; ++i) { + uint64 operatorId = operatorIds[i]; + if (seb.operatorEthVUnits[operatorId] != opVUnitsBefore[i]) { + phantomVUnitsViolation = true; + } + if (s.operators[operatorId].ethValidatorCount != operatorEthValidatorCountsBefore[i]) { + phantomVUnitsViolation = true; + } + } + if (s.clusters[ssvClusterId] != 0 || s.ethClusters[ssvClusterId] == 0) phantomVUnitsViolation = true; + ssvRecord.exists = false; + } catch { + phantomVUnitsViolation = true; + } + } + /// @notice Attempts SSV->ETH migration from an already-liquidated legacy cluster. function action_migrate_liquidated_ssv_to_eth(uint256 seed) external { if (!ssvRecord.exists || ssvRecord.cluster.active) return; @@ -494,6 +655,74 @@ contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { } catch {} } + function action_migrate_liquidated_zero_validator_with_phantom_vunits(uint256 seed) external { + if (!ssvRecord.exists) return; + if (ssvRecord.cluster.validatorCount != INITIAL_VALIDATOR_COUNT) return; + if (_hasRemovedTrackedOperator()) return; + + if (ssvRecord.cluster.active) { + this.action_liquidate_ssv(); + } + if (!ssvRecord.exists || ssvRecord.cluster.active) return; + + StorageData storage s = SSVStorage.load(); + StorageProtocol storage sp = SSVStorageProtocol.load(); + StorageEB storage seb = SSVStorageEB.load(); + + ISSVNetworkCore.Cluster memory clusterBeforeRemove = ssvRecord.cluster; + try clusterOwner.bulkRemoveValidators(_initialValidatorPublicKeys(), operatorIds, clusterBeforeRemove) { + ISSVNetworkCore.Cluster memory clusterAfterRemove = clusterBeforeRemove; + clusterAfterRemove.validatorCount = 0; + + if (s.clusters[ssvClusterId] != clusterAfterRemove.hashClusterData()) { + phantomVUnitsViolation = true; + return; + } + if (!_initialValidatorsRemoved()) { + phantomVUnitsViolation = true; + return; + } + + ssvRecord.cluster = clusterAfterRemove; + } catch { + return; + } + + uint64 phantom = uint64((seed % 5) + 1) * BPS_DENOMINATOR; + seb.clusterEB[ssvClusterId].vUnits = phantom; + + uint32 daoValidatorCountBefore = sp.daoValidatorCount; + uint32 ethDaoValidatorCountBefore = sp.ethDaoValidatorCount; + uint64 daoVUnitsBefore = sp.daoTotalEthVUnits; + uint32[] memory operatorEthValidatorCountsBefore = new uint32[](operatorIds.length); + uint64[] memory opVUnitsBefore = new uint64[](operatorIds.length); + for (uint256 i; i < operatorIds.length; ++i) { + uint64 operatorId = operatorIds[i]; + operatorEthValidatorCountsBefore[i] = s.operators[operatorId].ethValidatorCount; + opVUnitsBefore[i] = seb.operatorEthVUnits[operatorId]; + } + + try clusterOwner.migrateToETH{value: 0}(operatorIds, ssvRecord.cluster) { + if (seb.clusterEB[ssvClusterId].vUnits != 0) phantomVUnitsViolation = true; + if (sp.daoTotalEthVUnits != daoVUnitsBefore) phantomVUnitsViolation = true; + if (sp.daoValidatorCount != daoValidatorCountBefore) phantomVUnitsViolation = true; + if (sp.ethDaoValidatorCount != ethDaoValidatorCountBefore) phantomVUnitsViolation = true; + for (uint256 i; i < operatorIds.length; ++i) { + uint64 operatorId = operatorIds[i]; + if (seb.operatorEthVUnits[operatorId] != opVUnitsBefore[i]) { + phantomVUnitsViolation = true; + } + if (s.operators[operatorId].ethValidatorCount != operatorEthValidatorCountsBefore[i]) { + phantomVUnitsViolation = true; + } + } + if (s.clusters[ssvClusterId] != 0 || s.ethClusters[ssvClusterId] == 0) phantomVUnitsViolation = true; + ssvRecord.exists = false; + } catch { + phantomVUnitsViolation = true; + } + } + /// @notice Ensures migration preconditions are reachable and immediately attempts migration. function action_prepare_migration_and_attempt(uint256 seed) external payable { if (!ssvRecord.exists) return; @@ -562,6 +791,14 @@ contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { return !ssvEbUpdateViolation; } + function echidna_zero_validator_migration_has_no_eth_vunits() external view returns (bool) { + return !zeroValidatorMigrationVUnitsViolation; + } + + function echidna_phantom_vunits_cleared_on_migration() external view returns (bool) { + return !phantomVUnitsViolation; + } + function _checkRemoved(uint64 operatorId, StorageData storage s) internal view returns (bool) { if (!removedTracked[operatorId]) return true; @@ -623,6 +860,11 @@ contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { s.operators[operatorIds[i]].validatorCount += cluster.validatorCount; } + bytes[] memory publicKeys = _initialValidatorPublicKeys(); + for (uint256 i; i < publicKeys.length; ++i) { + ValidatorLib.registerPublicKey(publicKeys[i], operatorIds, address(clusterOwner), s); + } + ssvRecord = SSVClusterRecord({ cluster: cluster, owner: address(clusterOwner), @@ -751,6 +993,38 @@ contract SSVMigrationEchidna is SSVClusters, SSVOperators(0), SSVDAO { return opOwner3; } + function _hasRemovedTrackedOperator() internal view returns (bool) { + for (uint256 i; i < operatorIds.length; ++i) { + if (removedTracked[operatorIds[i]]) return true; + } + return false; + } + + function _initialValidatorsRemoved() internal view returns (bool) { + StorageData storage s = SSVStorage.load(); + uint256 count = uint256(INITIAL_VALIDATOR_COUNT); + for (uint256 i = 1; i <= count; ++i) { + if (s.validatorPKs[_initialValidatorHash(i)] != bytes32(0)) return false; + } + return true; + } + + function _initialValidatorHash(uint256 tag) internal view returns (bytes32) { + return keccak256(abi.encodePacked(_initialValidatorPublicKey(tag), address(clusterOwner))); + } + + function _initialValidatorPublicKeys() internal pure returns (bytes[] memory publicKeys) { + uint256 count = uint256(INITIAL_VALIDATOR_COUNT); + publicKeys = new bytes[](count); + for (uint256 i; i < count; ++i) { + publicKeys[i] = _initialValidatorPublicKey(i + 1); + } + } + + function _initialValidatorPublicKey(uint256 tag) internal pure returns (bytes memory) { + return abi.encodePacked(bytes32(uint256(0xC000 + tag)), bytes16(0)); + } + function _singleLeafRoot(bytes32 clusterId, uint32 effectiveBalance) internal pure returns (bytes32) { return keccak256(abi.encodePacked(keccak256(abi.encode(clusterId, effectiveBalance)))); } diff --git a/test/echidna/echidna-ci.yaml b/test/echidna/echidna-ci.yaml index 109c21a7..3bced3ac 100644 --- a/test/echidna/echidna-ci.yaml +++ b/test/echidna/echidna-ci.yaml @@ -128,9 +128,12 @@ filterFunctions: - "SSVMigrationEchidna.action_advance_ssv_without_cluster_sync(uint256)" - "SSVMigrationEchidna.action_fund_eth(uint256)" - "SSVMigrationEchidna.action_liquidate_ssv()" + - "SSVMigrationEchidna.action_migrate_liquidated_zero_validator_with_phantom_vunits(uint256)" - "SSVMigrationEchidna.action_migrate_removed_operator_explicit_eb(uint256)" - "SSVMigrationEchidna.action_migrate_liquidated_ssv_to_eth(uint256)" - "SSVMigrationEchidna.action_migrate_ssv_to_eth(uint256)" + - "SSVMigrationEchidna.action_migrate_zero_validator_after_real_removal()" + - "SSVMigrationEchidna.action_migrate_zero_validator_with_phantom_vunits(uint256)" - "SSVMigrationEchidna.action_prepare_migration_and_attempt(uint256)" - "SSVMigrationEchidna.action_remove_operator(uint256)" - "SSVMigrationEchidna.action_update_ssv_cluster_balance_valid(uint256)" diff --git a/test/echidna/echidna.yaml b/test/echidna/echidna.yaml index 23fa0e51..c97b71b9 100644 --- a/test/echidna/echidna.yaml +++ b/test/echidna/echidna.yaml @@ -131,9 +131,12 @@ filterFunctions: - "SSVMigrationEchidna.action_advance_ssv_without_cluster_sync(uint256)" - "SSVMigrationEchidna.action_fund_eth(uint256)" - "SSVMigrationEchidna.action_liquidate_ssv()" + - "SSVMigrationEchidna.action_migrate_liquidated_zero_validator_with_phantom_vunits(uint256)" - "SSVMigrationEchidna.action_migrate_removed_operator_explicit_eb(uint256)" - "SSVMigrationEchidna.action_migrate_liquidated_ssv_to_eth(uint256)" - "SSVMigrationEchidna.action_migrate_ssv_to_eth(uint256)" + - "SSVMigrationEchidna.action_migrate_zero_validator_after_real_removal()" + - "SSVMigrationEchidna.action_migrate_zero_validator_with_phantom_vunits(uint256)" - "SSVMigrationEchidna.action_prepare_migration_and_attempt(uint256)" - "SSVMigrationEchidna.action_remove_operator(uint256)" - "SSVMigrationEchidna.action_update_ssv_cluster_balance_valid(uint256)" diff --git a/test/integration/SSVNetwork/legacy-zero-validator-eb-migration.test.ts b/test/integration/SSVNetwork/legacy-zero-validator-eb-migration.test.ts new file mode 100644 index 00000000..806aec7b --- /dev/null +++ b/test/integration/SSVNetwork/legacy-zero-validator-eb-migration.test.ts @@ -0,0 +1,337 @@ +import { expect } from "chai"; +import type { NetworkConnection } from "hardhat/types/network"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; +import { + ssvNetworkFullPreUpgradeFixture, + upgradeToStakingVersion, +} from "../../setup/fixtures.ts"; +import type { NetworkHelpersType } from "../../common/types.ts"; +import { + commitEBRoot, + calcOperatorFeeAccrual, + computeClusterId, + computeEBRoot, + defaultVUnits, + extractEventArgs, + getBlockNumber, + getCurrentClusterState, + makePublicKey, + mineBlocks, + parseClusterFromEvent, + registerOperatorsSSV, + setupOracles, + setupTestContext, + whitelistAddresses, +} from "../../helpers/index.js"; +import { + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + EMPTY_CLUSTER, + ETH_DEDUCTED_DIGITS, + TOKEN_REGISTER_AMOUNT, +} from "../../common/constants.ts"; +import { Events } from "../../common/events.ts"; + +describe("SSVNetwork Integration - legacy zero-validator EB migration", () => { + let connection: NetworkConnection<"generic">; + let networkHelpers: NetworkHelpersType; + + let operatorOwner: HardhatEthersSigner; + let clusterOwner: HardhatEthersSigner; + let oracle1: HardhatEthersSigner; + let oracle2: HardhatEthersSigner; + let oracle3: HardhatEthersSigner; + let oracle4: HardhatEthersSigner; + let staker: HardhatEthersSigner; + + before(async function () { + ({ + connection, + networkHelpers, + signers: [operatorOwner, clusterOwner, oracle1, oracle2, oracle3, oracle4, staker], + } = await setupTestContext()); + }); + + const deployFixture = async () => ssvNetworkFullPreUpgradeFixture(connection); + + it("clears stale legacy EB when all SSV validators are removed before migration", async function () { + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await networkHelpers.loadFixture(deployFixture); + + const operatorIds = await registerOperatorsSSV(legacyNetwork, operatorOwner, 4); + await whitelistAddresses(legacyNetwork, operatorOwner, operatorIds, [clusterOwner.address]); + + const publicKeys = [makePublicKey(1), makePublicKey(2)]; + const ssvDeposit = TOKEN_REGISTER_AMOUNT * BigInt(publicKeys.length); + await ssvToken.mint(clusterOwner.address, ssvDeposit); + await ssvToken.connect(clusterOwner).approve(await legacyNetwork.getAddress(), ssvDeposit); + + await legacyNetwork.connect(clusterOwner).registerValidator( + publicKeys[0], + operatorIds, + DEFAULT_SHARES, + TOKEN_REGISTER_AMOUNT, + EMPTY_CLUSTER, + ); + let cluster = await getCurrentClusterState( + connection, + legacyNetwork, + clusterOwner.address, + operatorIds, + ); + await legacyNetwork.connect(clusterOwner).registerValidator( + publicKeys[1], + operatorIds, + DEFAULT_SHARES, + TOKEN_REGISTER_AMOUNT, + cluster, + ); + cluster = await getCurrentClusterState( + connection, + legacyNetwork, + clusterOwner.address, + operatorIds, + ); + expect(BigInt(cluster.validatorCount)).to.equal(2n); + + const { newNetwork, newViews } = await upgradeToStakingVersion( + connection, + legacyNetwork, + legacyViews, + ); + + await setupOracles(newNetwork, ssvToken, staker, [oracle1, oracle2, oracle3, oracle4]); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + const inflatedEffectiveBalance = 4096; + const root = computeEBRoot(clusterId, inflatedEffectiveBalance); + + await mineBlocks(connection.ethers.provider, 1); + const rootBlock = await getBlockNumber(connection.ethers.provider); + await commitEBRoot(newNetwork, root, rootBlock, [oracle1, oracle2, oracle3]); + + const updateTx = await newNetwork + .connect(clusterOwner) + .updateClusterBalance( + rootBlock, + clusterOwner.address, + operatorIds, + cluster, + inflatedEffectiveBalance, + [], + ); + const clusterAfterEB = parseClusterFromEvent( + newNetwork, + await updateTx.wait(), + Events.CLUSTER_BALANCE_UPDATED, + ); + + expect( + await newViews.getEffectiveBalance(clusterOwner.address, operatorIds, clusterAfterEB), + ).to.equal(inflatedEffectiveBalance); + + const removeTx = await newNetwork + .connect(clusterOwner) + .bulkRemoveValidator(publicKeys, operatorIds, clusterAfterEB); + const clusterAfterRemove = parseClusterFromEvent( + newNetwork, + await removeTx.wait(), + Events.VALIDATOR_REMOVED, + ); + + expect(clusterAfterRemove.validatorCount).to.equal(0n); + expect( + await newViews.getEffectiveBalance(clusterOwner.address, operatorIds, clusterAfterRemove), + ).to.equal(0); + + const migrateTx = await newNetwork + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }); + const migrateReceipt = await migrateTx.wait(); + const migrateArgs = extractEventArgs(newNetwork, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + const clusterAfterMigrate = parseClusterFromEvent( + newNetwork, + migrateReceipt, + Events.CLUSTER_MIGRATED_TO_ETH, + ); + + expect(migrateArgs.ethDeposited).to.equal(0n); + expect(migrateArgs.effectiveBalance).to.equal(0); + expect(clusterAfterMigrate.validatorCount).to.equal(0n); + expect(clusterAfterMigrate.balance).to.equal(0n); + expect(clusterAfterMigrate.active).to.equal(true); + expect(await newViews.isLiquidatable(clusterOwner.address, operatorIds, clusterAfterMigrate)).to.equal(false); + expect( + await newViews.getEffectiveBalance(clusterOwner.address, operatorIds, clusterAfterMigrate), + ).to.equal(0); + + const earningsBefore = await Promise.all( + operatorIds.map((operatorId) => newViews.getOperatorEarnings(operatorId)), + ); + + await mineBlocks(connection.ethers.provider, 1000); + + const earningsAfter = await Promise.all( + operatorIds.map((operatorId) => newViews.getOperatorEarnings(operatorId)), + ); + + for (let i = 0; i < operatorIds.length; i++) { + expect(earningsAfter[i]).to.equal(earningsBefore[i]); + } + }); + + it("does not leak stale legacy EB into shared operators when another ETH cluster uses them", async function () { + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await networkHelpers.loadFixture(deployFixture); + const provider = connection.ethers.provider; + const clusterOwnerB = staker; + + const operatorIds = await registerOperatorsSSV(legacyNetwork, operatorOwner, 4); + await whitelistAddresses(legacyNetwork, operatorOwner, operatorIds, [ + clusterOwner.address, + clusterOwnerB.address, + ]); + + const publicKeysA = [makePublicKey(11), makePublicKey(12)]; + const ssvDeposit = TOKEN_REGISTER_AMOUNT * BigInt(publicKeysA.length); + await ssvToken.mint(clusterOwner.address, ssvDeposit); + await ssvToken.connect(clusterOwner).approve(await legacyNetwork.getAddress(), ssvDeposit); + + await legacyNetwork.connect(clusterOwner).registerValidator( + publicKeysA[0], + operatorIds, + DEFAULT_SHARES, + TOKEN_REGISTER_AMOUNT, + EMPTY_CLUSTER, + ); + let clusterA = await getCurrentClusterState( + connection, + legacyNetwork, + clusterOwner.address, + operatorIds, + ); + await legacyNetwork.connect(clusterOwner).registerValidator( + publicKeysA[1], + operatorIds, + DEFAULT_SHARES, + TOKEN_REGISTER_AMOUNT, + clusterA, + ); + clusterA = await getCurrentClusterState( + connection, + legacyNetwork, + clusterOwner.address, + operatorIds, + ); + + const { newNetwork, newViews } = await upgradeToStakingVersion( + connection, + legacyNetwork, + legacyViews, + ); + + await setupOracles(newNetwork, ssvToken, staker, [oracle1, oracle2, oracle3, oracle4]); + + const clusterIdA = computeClusterId(clusterOwner.address, operatorIds); + const inflatedEffectiveBalance = 4096; + const rootA = computeEBRoot(clusterIdA, inflatedEffectiveBalance); + + await mineBlocks(provider, 1); + const rootBlockA = await getBlockNumber(provider); + await commitEBRoot(newNetwork, rootA, rootBlockA, [oracle1, oracle2, oracle3]); + + const updateTxA = await newNetwork + .connect(clusterOwner) + .updateClusterBalance( + rootBlockA, + clusterOwner.address, + operatorIds, + clusterA, + inflatedEffectiveBalance, + [], + ); + const clusterAAfterEB = parseClusterFromEvent( + newNetwork, + await updateTxA.wait(), + Events.CLUSTER_BALANCE_UPDATED, + ); + + const removeTxA = await newNetwork + .connect(clusterOwner) + .bulkRemoveValidator(publicKeysA, operatorIds, clusterAAfterEB); + const clusterAAfterRemove = parseClusterFromEvent( + newNetwork, + await removeTxA.wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAAfterRemove.validatorCount).to.equal(0n); + + const migrateTxA = await newNetwork + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAAfterRemove, { value: 0 }); + const migrateReceiptA = await migrateTxA.wait(); + const migrateArgsA = extractEventArgs(newNetwork, migrateReceiptA, Events.CLUSTER_MIGRATED_TO_ETH); + + expect(migrateArgsA.effectiveBalance).to.equal(0); + + const registerTxB = await newNetwork + .connect(clusterOwnerB) + .registerValidator( + makePublicKey(21), + operatorIds, + DEFAULT_SHARES, + EMPTY_CLUSTER, + { value: DEFAULT_ETH_REGISTER_VALUE }, + ); + const registerReceiptB = await registerTxB.wait(); + const clusterB = parseClusterFromEvent( + newNetwork, + registerReceiptB, + Events.VALIDATOR_ADDED, + ); + const registerBlockB = BigInt(registerReceiptB!.blockNumber); + + const earningsBeforeTouch = await Promise.all( + operatorIds.map((operatorId) => newViews.getOperatorEarnings(BigInt(operatorId))), + ); + + for (let i = 0; i < operatorIds.length; i++) { + expect(earningsBeforeTouch[i]).to.equal(0n); + } + + await mineBlocks(provider, 1000); + + const clusterIdB = computeClusterId(clusterOwnerB.address, operatorIds); + const rootB = computeEBRoot(clusterIdB, 32); + const rootBlockB = await getBlockNumber(provider); + await commitEBRoot(newNetwork, rootB, rootBlockB, [oracle1, oracle2, oracle3]); + + const touchTxB = await newNetwork + .connect(clusterOwnerB) + .updateClusterBalance( + rootBlockB, + clusterOwnerB.address, + operatorIds, + clusterB, + 32, + [], + ); + const touchReceiptB = await touchTxB.wait(); + const touchBlockB = BigInt(touchReceiptB!.blockNumber); + + const earningsAfterTouch = await Promise.all( + operatorIds.map((operatorId) => newViews.getOperatorEarnings(BigInt(operatorId))), + ); + const operatorFeeRaw = + (await newViews.getOperatorFee(BigInt(operatorIds[0]))) / ETH_DEDUCTED_DIGITS; + const expectedDelta = calcOperatorFeeAccrual( + touchBlockB - registerBlockB, + operatorFeeRaw, + defaultVUnits(1n), + ) * ETH_DEDUCTED_DIGITS; + + for (let i = 0; i < operatorIds.length; i++) { + expect(earningsAfterTouch[i] - earningsBeforeTouch[i]).to.equal(expectedDelta); + } + }); +}); diff --git a/test/sanity/deviation-eb-zero-validators-cluster.test.ts b/test/sanity/deviation-eb-zero-validators-cluster.test.ts new file mode 100644 index 00000000..8642dc30 --- /dev/null +++ b/test/sanity/deviation-eb-zero-validators-cluster.test.ts @@ -0,0 +1,451 @@ +import { expect } from "chai"; +import type { NetworkConnection } from "hardhat/types/network"; +import { ssvClustersHarnessFixture, ssvNetworkFullPreUpgradeFixture, upgradeToStakingVersion } from "../setup/fixtures.ts"; +import type { NetworkHelpersType } from "../common/types.ts"; +import { + BPS_DENOMINATOR, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + EMPTY_CLUSTER, + MINIMAL_OPERATOR_ETH_FEE, + TOKEN_REGISTER_AMOUNT, +} from "../common/constants.ts"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; +import { + computeClusterId, + computeEBRoot, + commitEBRoot, + createCluster, + createLegacySSVCluster, + generateMerkleForClusterEB, + getCurrentClusterState, + extractEventArgs, + makePublicKey, + mineBlocks, + getBlockNumber, + parseClusterFromEvent, + registerOperatorsSSV, + setupOracles, + setupTestContext, + whitelistAddresses, +} from "../helpers/index.js"; +import { Events } from "../common/events.js"; + +describe("Deviated effective balance and removed validators sanity", () => { + let connection: NetworkConnection<"generic">; + let networkHelpers: NetworkHelpersType; + let operatorOwner: HardhatEthersSigner; + let clusterOwner: HardhatEthersSigner; + let oracle1: HardhatEthersSigner; + let oracle2: HardhatEthersSigner; + let oracle3: HardhatEthersSigner; + + before(async function () { + ({ + connection, + networkHelpers, + signers: [operatorOwner, clusterOwner, oracle1, oracle2, oracle3], + } = await setupTestContext()); + }); + + const deployFixture = async () => ssvNetworkFullPreUpgradeFixture(connection); + + async function setupSsvClusterWithDeviatedEB( + ssvPublicKeySeed: number, + effectiveBalance = 64, + ) { + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await networkHelpers.loadFixture(deployFixture); + + await ssvToken.mint(clusterOwner.address, TOKEN_REGISTER_AMOUNT); + await ssvToken.connect(clusterOwner).approve(await legacyNetwork.getAddress(), TOKEN_REGISTER_AMOUNT); + + const operatorIds = await registerOperatorsSSV(legacyNetwork, operatorOwner, 4); + await whitelistAddresses(legacyNetwork, operatorOwner, operatorIds, [clusterOwner.address]); + + await legacyNetwork.connect(clusterOwner).registerValidator( + makePublicKey(ssvPublicKeySeed), + operatorIds, + DEFAULT_SHARES, + TOKEN_REGISTER_AMOUNT, + EMPTY_CLUSTER, + ); + + const ssvCluster = await getCurrentClusterState( + connection, + legacyNetwork, + clusterOwner.address, + operatorIds, + ); + + const { newNetwork, newViews } = await upgradeToStakingVersion( + connection, + legacyNetwork, + legacyViews, + ); + + await setupOracles(newNetwork, ssvToken, clusterOwner, [oracle1, oracle2, oracle3]); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + const root = computeEBRoot(clusterId, effectiveBalance); + + await mineBlocks(connection.ethers.provider, 1); + const blockNum = await getBlockNumber(connection.ethers.provider); + await commitEBRoot(newNetwork, root, blockNum, [oracle1, oracle2, oracle3]); + + const updateTx = await newNetwork + .connect(clusterOwner) + .updateClusterBalance( + blockNum, + clusterOwner.address, + operatorIds, + ssvCluster, + effectiveBalance, + [], + ); + const clusterAfterUpdate = parseClusterFromEvent( + newNetwork, + await updateTx.wait(), + Events.CLUSTER_BALANCE_UPDATED, + ); + + return { newNetwork, newViews, operatorIds, ssvCluster, clusterAfterUpdate, clusterId }; + } + + describe("Cluster with deviated effective balance and zero validators", () => { + it("removing last SSV validator clears clusterEB vUnits — getEffectiveBalance returns 0", async function () { + const { newNetwork, newViews, operatorIds, clusterAfterUpdate } = + await setupSsvClusterWithDeviatedEB(1); + + expect( + await newViews.getEffectiveBalance(clusterOwner.address, operatorIds, clusterAfterUpdate), + ).to.equal(64); + + const removeTx = await newNetwork + .connect(clusterOwner) + .removeValidator(makePublicKey(1), operatorIds, clusterAfterUpdate); + const clusterAfterRemove = parseClusterFromEvent( + newNetwork, + await removeTx.wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + + expect( + await newViews.getEffectiveBalance(clusterOwner.address, operatorIds, clusterAfterRemove), + ).to.equal(0); + }); + + it("migrating SSV cluster after removing last validator emits effectiveBalance=0 and adds no phantom deviation", async function () { + const { newNetwork, newViews, operatorIds, clusterAfterUpdate } = + await setupSsvClusterWithDeviatedEB(2); + + const removeTx = await newNetwork + .connect(clusterOwner) + .removeValidator(makePublicKey(2), operatorIds, clusterAfterUpdate); + const clusterAfterRemove = parseClusterFromEvent( + newNetwork, + await removeTx.wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + + const migrateTx = await newNetwork + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { + value: DEFAULT_ETH_REGISTER_VALUE, + }); + const migrateReceipt = await migrateTx.wait(); + const migrateArgs = extractEventArgs(newNetwork, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + expect(migrateArgs.effectiveBalance).to.equal(0); + + const clusterAfterMigrate = parseClusterFromEvent( + newNetwork, + migrateReceipt, + Events.CLUSTER_MIGRATED_TO_ETH, + ); + expect(clusterAfterMigrate.validatorCount).to.equal(0n); + expect(clusterAfterMigrate.active).to.equal(true); + + const regTx = await newNetwork + .connect(clusterOwner) + .registerValidator( + makePublicKey(3), + operatorIds, + DEFAULT_SHARES, + clusterAfterMigrate, + { value: DEFAULT_ETH_REGISTER_VALUE }, + ); + const clusterAfterReg = parseClusterFromEvent( + newNetwork, + await regTx.wait(), + Events.VALIDATOR_ADDED, + ); + expect(clusterAfterReg.validatorCount).to.equal(1n); + + expect( + await newViews.getEffectiveBalance(clusterOwner.address, operatorIds, clusterAfterReg), + ).to.equal(32); + }); + + it("operator ETH earnings frozen after liquidation and removing validators", async function () { + const { newNetwork, newViews, operatorIds, clusterAfterUpdate } = + await setupSsvClusterWithDeviatedEB(4); + + const liqTx = await newNetwork + .connect(clusterOwner) + .liquidateSSV(clusterOwner.address, operatorIds, clusterAfterUpdate); + const clusterAfterLiq = parseClusterFromEvent( + newNetwork, + await liqTx.wait(), + Events.CLUSTER_LIQUIDATED, + ); + expect(clusterAfterLiq.active).to.equal(false); + expect(clusterAfterLiq.validatorCount).to.equal(1n); + + const removeTx = await newNetwork + .connect(clusterOwner) + .removeValidator(makePublicKey(4), operatorIds, clusterAfterLiq); + const clusterAfterRemove = parseClusterFromEvent( + newNetwork, + await removeTx.wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + + const migrateTx = await newNetwork + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { + value: DEFAULT_ETH_REGISTER_VALUE, + }); + const clusterAfterMigrate = parseClusterFromEvent( + newNetwork, + await migrateTx.wait(), + Events.CLUSTER_MIGRATED_TO_ETH, + ); + expect(clusterAfterMigrate.active).to.equal(true); + expect(clusterAfterMigrate.validatorCount).to.equal(0n); + + const earningsBefore = await Promise.all( + operatorIds.map((id) => newViews.getOperatorEarnings(id)), + ); + + await mineBlocks(connection.ethers.provider, 1000); + + const earningsAfter = await Promise.all( + operatorIds.map((id) => newViews.getOperatorEarnings(id)), + ); + + for (let i = 0; i < operatorIds.length; i++) { + expect(earningsAfter[i]).to.equal(earningsBefore[i]); + } + }); + + it("operator earnings are stale on empty migrated ETH cluster", async function () { + const { newNetwork, newViews, operatorIds, clusterAfterUpdate } = + await setupSsvClusterWithDeviatedEB(3); + + const removeTx = await newNetwork + .connect(clusterOwner) + .removeValidator(makePublicKey(3), operatorIds, clusterAfterUpdate); + const clusterAfterRemove = parseClusterFromEvent( + newNetwork, + await removeTx.wait(), + Events.VALIDATOR_REMOVED, + ); + + await newNetwork + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { + value: DEFAULT_ETH_REGISTER_VALUE, + }); + + const earningsBefore = await Promise.all( + operatorIds.map((id) => newViews.getOperatorEarnings(id)), + ); + + await mineBlocks(connection.ethers.provider, 1000); + + const earningsAfter = await Promise.all( + operatorIds.map((id) => newViews.getOperatorEarnings(id)), + ); + + for (let i = 0; i < operatorIds.length; i++) { + expect(earningsAfter[i]).to.equal(earningsBefore[i]); + } + }); + }); + + describe("Two SSV clusters sharing operators — full empty migration cycle", () => { + it("shared operators lifecycle", async function () { + const clusterOwnerB = (await connection.ethers.getSigners())[5]; + + const { network: legacyNetwork, views: legacyViews, ssvToken } = + await networkHelpers.loadFixture(deployFixture); + + await ssvToken.mint(clusterOwner.address, TOKEN_REGISTER_AMOUNT); + await ssvToken.connect(clusterOwner).approve(await legacyNetwork.getAddress(), TOKEN_REGISTER_AMOUNT); + await ssvToken.mint(clusterOwnerB.address, TOKEN_REGISTER_AMOUNT); + await ssvToken.connect(clusterOwnerB).approve(await legacyNetwork.getAddress(), TOKEN_REGISTER_AMOUNT); + + const operatorIds = await registerOperatorsSSV(legacyNetwork, operatorOwner, 4); + await whitelistAddresses(legacyNetwork, operatorOwner, operatorIds, [ + clusterOwner.address, + clusterOwnerB.address, + ]); + + const keysA = Array.from({ length: 5 }, (_, i) => makePublicKey(201 + i)); + await legacyNetwork.connect(clusterOwner).registerValidator( + keysA[0], operatorIds, DEFAULT_SHARES, TOKEN_REGISTER_AMOUNT, EMPTY_CLUSTER, + ); + for (let i = 1; i < 5; i++) { + const state = await getCurrentClusterState(connection, legacyNetwork, clusterOwner.address, operatorIds); + await legacyNetwork.connect(clusterOwner).registerValidator(keysA[i], operatorIds, DEFAULT_SHARES, 0n, state); + } + const ssvClusterA = await getCurrentClusterState(connection, legacyNetwork, clusterOwner.address, operatorIds); + + const keysB = Array.from({ length: 5 }, (_, i) => makePublicKey(206 + i)); + await legacyNetwork.connect(clusterOwnerB).registerValidator( + keysB[0], operatorIds, DEFAULT_SHARES, TOKEN_REGISTER_AMOUNT, EMPTY_CLUSTER, + ); + for (let i = 1; i < 5; i++) { + const state = await getCurrentClusterState(connection, legacyNetwork, clusterOwnerB.address, operatorIds); + await legacyNetwork.connect(clusterOwnerB).registerValidator(keysB[i], operatorIds, DEFAULT_SHARES, 0n, state); + } + const ssvClusterB = await getCurrentClusterState(connection, legacyNetwork, clusterOwnerB.address, operatorIds); + + const { newNetwork, newViews } = await upgradeToStakingVersion(connection, legacyNetwork, legacyViews); + await setupOracles(newNetwork, ssvToken, clusterOwner, [oracle1, oracle2, oracle3]); + + const clusterIdA = computeClusterId(clusterOwner.address, operatorIds); + const clusterIdB = computeClusterId(clusterOwnerB.address, operatorIds); + + await mineBlocks(connection.ethers.provider, 1); + const blockNum1 = await getBlockNumber(connection.ethers.provider); + const { root: root1, proofs: proofs1 } = generateMerkleForClusterEB(connection, [ + { clusterId: clusterIdA, effectiveBalance: 1000 }, + { clusterId: clusterIdB, effectiveBalance: 1000 }, + ]); + await commitEBRoot(newNetwork, root1, blockNum1, [oracle1, oracle2, oracle3]); + + let clusterA = parseClusterFromEvent( + newNetwork, + await (await newNetwork.connect(clusterOwner).updateClusterBalance( + blockNum1, clusterOwner.address, operatorIds, ssvClusterA, 1000, proofs1[clusterIdA], + )).wait(), + Events.CLUSTER_BALANCE_UPDATED, + ); + let clusterB = parseClusterFromEvent( + newNetwork, + await (await newNetwork.connect(clusterOwnerB).updateClusterBalance( + blockNum1, clusterOwnerB.address, operatorIds, ssvClusterB, 1000, proofs1[clusterIdB], + )).wait(), + Events.CLUSTER_BALANCE_UPDATED, + ); + + const clusterAfterRemoveA = parseClusterFromEvent( + newNetwork, + await (await newNetwork.connect(clusterOwner).bulkRemoveValidator(keysA, operatorIds, clusterA)).wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemoveA.validatorCount).to.equal(0n); + + await mineBlocks(connection.ethers.provider, 1); + const blockNum2 = await getBlockNumber(connection.ethers.provider); + const root2 = computeEBRoot(clusterIdB, 160); + await commitEBRoot(newNetwork, root2, blockNum2, [oracle1, oracle2, oracle3]); + clusterB = parseClusterFromEvent( + newNetwork, + await (await newNetwork.connect(clusterOwnerB).updateClusterBalance( + blockNum2, clusterOwnerB.address, operatorIds, clusterB, 160, [], + )).wait(), + Events.CLUSTER_BALANCE_UPDATED, + ); + + const clusterAfterRemoveB = parseClusterFromEvent( + newNetwork, + await (await newNetwork.connect(clusterOwnerB).bulkRemoveValidator(keysB, operatorIds, clusterB)).wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemoveB.validatorCount).to.equal(0n); + + const clusterAfterMigrateA = parseClusterFromEvent( + newNetwork, + await (await newNetwork.connect(clusterOwner).migrateClusterToETH( + operatorIds, clusterAfterRemoveA, { value: DEFAULT_ETH_REGISTER_VALUE }, + )).wait(), + Events.CLUSTER_MIGRATED_TO_ETH, + ); + const clusterAfterMigrateB = parseClusterFromEvent( + newNetwork, + await (await newNetwork.connect(clusterOwnerB).migrateClusterToETH( + operatorIds, clusterAfterRemoveB, { value: DEFAULT_ETH_REGISTER_VALUE }, + )).wait(), + Events.CLUSTER_MIGRATED_TO_ETH, + ); + + expect(await newViews.getEffectiveBalance(clusterOwner.address, operatorIds, clusterAfterMigrateA)).to.equal(0); + expect(await newViews.getEffectiveBalance(clusterOwnerB.address, operatorIds, clusterAfterMigrateB)).to.equal(0); + expect(await newViews.getBurnRate(clusterOwner.address, operatorIds, clusterAfterMigrateA)).to.equal(0); + expect(await newViews.getBurnRate(clusterOwnerB.address, operatorIds, clusterAfterMigrateB)).to.equal(0); + + const earningsBefore = await Promise.all(operatorIds.map((id) => newViews.getOperatorEarnings(id))); + await mineBlocks(connection.ethers.provider, 20); + const earningsAfter = await Promise.all(operatorIds.map((id) => newViews.getOperatorEarnings(id))); + for (let i = 0; i < operatorIds.length; i++) { + expect(earningsAfter[i]).to.equal(earningsBefore[i]); + } + }); + }); + + describe("Shared-operator leakage regression", () => { + const deployHarnessFixture = async () => + ssvClustersHarnessFixture(connection, 4, MINIMAL_OPERATOR_ETH_FEE); + + it("stale SSV clusterEB vUnits do not inject phantom deviation into shared operator pool after empty-cluster migration", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const [, , clusterOwnerB] = await connection.ethers.getSigners(); + + const keyA = makePublicKey(900); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(keyA, operatorIds, clusterOwner.address, ssvCluster); + + const clusterIdA = computeClusterId(clusterOwner.address, operatorIds); + await clusters.mockSetClusterVUnits(clusterIdA, 640_000n); + + const removeTx = await clusters + .connect(clusterOwner) + .removeValidator(keyA, operatorIds, ssvCluster); + const clusterAfterRemove = parseClusterFromEvent( + clusters, + await removeTx.wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + expect(await clusters.getClusterVUnits(clusterIdA)).to.equal(0n); + + await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }); + + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(0n); + } + + await clusters.connect(clusterOwnerB).registerValidator( + makePublicKey(901), + operatorIds, + DEFAULT_SHARES, + createCluster(), + { value: DEFAULT_ETH_REGISTER_VALUE }, + ); + + expect(await clusters.getDaoTotalEthVUnits()).to.equal(BPS_DENOMINATOR); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(0n); + expect(await clusters.getEffectiveOperatorVUnits(op)).to.equal(BPS_DENOMINATOR); + } + }); + }); +}); diff --git a/test/sanity/migrate-cluster-eb-guard.order-regression.test.ts b/test/sanity/migrate-cluster-eb-guard.order-regression.test.ts new file mode 100644 index 00000000..2fa19717 --- /dev/null +++ b/test/sanity/migrate-cluster-eb-guard.order-regression.test.ts @@ -0,0 +1,364 @@ +import { expect } from "chai"; +import { ethers } from "ethers"; +import type { NetworkConnection } from "hardhat/types/network"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; +import { ssvClustersHarnessFixture } from "../setup/fixtures.ts"; +import type { NetworkHelpersType } from "../common/types.ts"; +import type { SSVClustersHarness } from "../../types/ethers-contracts/index.js"; +import { + BPS_DENOMINATOR, + DEFAULT_ETH_REGISTER_VALUE, + MINIMAL_OPERATOR_ETH_FEE, +} from "../common/constants.ts"; +import { + computeClusterId, + createLegacySSVCluster, + extractEventArgs, + makePublicKey, + parseClusterFromEvent, + setupTestContext, +} from "../helpers/index.js"; +import { Events } from "../common/events.js"; +import { Errors } from "../common/errors.js"; + +// Mirrors ClusterLib.hashClusterData: keccak256(abi.encodePacked(validatorCount, networkFeeIndex, index, balance, active)) +function expectedClusterHash(c: { + validatorCount: bigint; + networkFeeIndex: bigint; + index: bigint; + balance: bigint; + active: boolean; +}) { + return ethers.keccak256( + ethers.solidityPacked( + ["uint32", "uint64", "uint64", "uint256", "bool"], + [c.validatorCount, c.networkFeeIndex, c.index, c.balance, c.active], + ), + ); +} + +// Each test pins one ordering constraint inside migrateClusterToETH and shows that +// swapping the two steps would produce a wrong post-state that the assertion catches. + +describe("migrateClusterToETH guard: order-of-checks regression", () => { + let connection: NetworkConnection<"generic">; + let networkHelpers: NetworkHelpersType; + let clusterOwner: HardhatEthersSigner; + + before(async function () { + ({ connection, networkHelpers, signers: [, clusterOwner] } = await setupTestContext()); + }); + + const deployHarnessFixture = async () => + ssvClustersHarnessFixture(connection, 4, MINIMAL_OPERATOR_ETH_FEE); + + // Drives a cluster to vCount=0 with stale phantom vUnits (simulates pre-fix poisoned state). + async function setupPhantomCluster( + clusters: SSVClustersHarness, + operatorIds: bigint[], + seed: number, + phantomVUnits = 640_000n, + ) { + const key = makePublicKey(seed); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, ssvCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + const removeReceipt = await ( + await clusters.connect(clusterOwner).removeValidator(key, operatorIds, ssvCluster) + ).wait(); + const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED); + await clusters.mockSetClusterVUnits(clusterId, phantomVUnits); + return { clusterId, clusterAfterRemove }; + } + + // Returns a 1-validator SSV cluster (validator NOT removed) with optional explicit vUnits. + async function setupOneValidatorCluster( + clusters: SSVClustersHarness, + operatorIds: bigint[], + seed: number, + vUnits?: bigint, + ) { + const key = makePublicKey(seed); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, ssvCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + if (vUnits !== undefined) { + await clusters.mockSetClusterVUnits(clusterId, vUnits); + } + return { clusterId, ssvCluster }; + } + + // ── EB guard fires before deviation loop ───────────────────────────────────────────── + // The guard zeroes vUnitsCluster when vCount=0 && vUnits>0. + // The deviation loop then sees vUnitsCluster=0 and skips. + // If the order were reversed: deviation would add phantom vUnits to DAO/operators before the + // guard zeroed the snapshot, leaving daoTotalEthVUnits permanently inflated. + it("guard zeroes vUnits before deviation loop — DAO stays clean when vCount=0 and phantom vUnits exist", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterId, clusterAfterRemove } = await setupPhantomCluster(clusters, operatorIds, 300); + + const daoTotalEthVUnitsBefore = await clusters.getDaoTotalEthVUnits(); + const operatorEthVUnitsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthVUnits(op)), + ); + + await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(); + + // Guard ran first → vUnitsCluster=0 before deviation block → no phantom deviation injected. + // Ordering violation would leave daoTotalEthVUnits == 640_000. + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + expect(await clusters.getDaoTotalEthVUnits()).to.equal(daoTotalEthVUnitsBefore); + for (let i = 0; i < operatorIds.length; i++) { + expect(await clusters.getOperatorEthVUnits(operatorIds[i])).to.equal(operatorEthVUnitsBefore[i]); + } + }); + + // ── Guard condition is vCount==0 exactly — does NOT fire for vCount=1 ──────────────── + // With a 1-validator cluster that has explicit vUnits=20_000 (64 ETH EB): + // baseline = 1 * BPS_DENOMINATOR = 10_000 + // deviation = 20_000 - 10_000 = 10_000 + // If the guard fired unconditionally on any vUnits>0 (removing the vCount==0 check), it + // would zero out vUnitsCluster before the deviation block, leaving daoTotalEthVUnits at + // only the baseline (10_000) with no deviation applied. + it("guard does NOT zero vUnits when vCount=1 — explicit deviation reaches DAO and operators", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterId, ssvCluster } = await setupOneValidatorCluster( + clusters, + operatorIds, + 301, + 20_000n, + ); + // updateDAO(true, 1) adds baseline (BPS_DENOMINATOR) to daoTotalEthVUnits. + // Deviation block adds (vUnits - baseline). Total DAO = baseline + deviation = vUnits. + const explicitVUnits = 20_000n; + const operatorDeviation = explicitVUnits - BPS_DENOMINATOR; // 10_000 + + await ( + await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, ssvCluster, { value: DEFAULT_ETH_REGISTER_VALUE }) + ).wait(); + + // Guard must NOT have fired (vCount=1 ≠ 0) → vUnits intact, deviation applied. + expect(await clusters.getClusterVUnits(clusterId)).to.equal(explicitVUnits); + // DAO gets baseline + deviation = explicitVUnits; operators get deviation only. + expect(await clusters.getDaoTotalEthVUnits()).to.equal(explicitVUnits); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(operatorDeviation); + } + }); + + // ── validateHashedCluster fires before updateClusterOperatorsMigration ─────────────── + // If hash validation ran after operator state was mutated, a tampered cluster struct would + // partially corrupt operator.ethValidatorCount before the revert cleaned up. + it("validateHashedCluster fires first — tampered cluster reverts before operator ETH state is mutated", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const key = makePublicKey(302); + const realCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, realCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + const ssvHashBefore = await clusters.getSSVClusterHash(clusterId); + const operatorEthValidatorCountsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthValidatorCount(op)), + ); + + // Submit a tampered struct: wrong validatorCount → hash mismatch → IncorrectClusterState + const tamperedCluster = { ...realCluster, validatorCount: 2n }; + await expect( + clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, tamperedCluster, { value: 0 }), + ).to.be.revertedWithCustomError(clusters, Errors.INCORRECT_CLUSTER_STATE); + + // Hash check fired before any mutation: both slots unchanged, operator counts untouched. + expect(await clusters.getSSVClusterHash(clusterId)).to.equal(ssvHashBefore); + expect(await clusters.getClusterHash(clusterId)).to.equal(ethers.ZeroHash); + for (let i = 0; i < operatorIds.length; i++) { + expect(await clusters.getOperatorEthValidatorCount(operatorIds[i])).to.equal( + operatorEthValidatorCountsBefore[i], + ); + } + }); + + // ── cluster.balance = msg.value assigned before isLiquidatableWithEB ───────────────── + // SSV cluster balance is 0 (createLegacySSVCluster({ balance: 0n })). + // If isLiquidatableWithEB read cluster.balance before the assignment, it would see 0, + // which is < minimumLiquidationCollateral, and revert with InsufficientBalance. + // Migration succeeds → proves the assignment `cluster.balance = msg.value` happened first. + it("liquidation check uses msg.value not SSV balance — vCount=1 with SSV balance=0 and msg.value=10 ETH succeeds", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { ssvCluster } = await setupOneValidatorCluster(clusters, operatorIds, 303); + + // ssvCluster.balance == 0 → would fail minimumLiquidationCollateral if used for the check + const migrateReceipt = await ( + await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, ssvCluster, { value: DEFAULT_ETH_REGISTER_VALUE }) + ).wait(); + const clusterAfterMigrate = parseClusterFromEvent( + clusters, + migrateReceipt, + Events.CLUSTER_MIGRATED_TO_ETH, + ); + + expect(clusterAfterMigrate.validatorCount).to.equal(1n); + expect(clusterAfterMigrate.balance).to.equal(DEFAULT_ETH_REGISTER_VALUE); + }); + + // ── updateDAOSSV is conditional on !isLiquidated ───────────────────────────────────── + // Active SSV cluster: updateDAOSSV(false, vCount) called → daoValidatorCount decrements. + // Liquidated SSV cluster: updateDAOSSV SKIPPED (already removed at liquidation time). + // If the `if (!isLiquidated)` guard were removed, a liquidated migration would call + // updateDAOSSV again and decrement daoValidatorCount below its post-liquidation value. + it("updateDAOSSV skipped for liquidated SSV cluster — daoValidatorCount unchanged after liquidated migration", async function () { + // sub-case: active cluster → migration decrements SSV DAO count + const { clusters: clustersA, operatorIds: opsA } = + await networkHelpers.loadFixture(deployHarnessFixture); + + const daoCountBeforeA = await clustersA.getDaoValidatorCount(); + const { ssvCluster: clusterA } = await setupOneValidatorCluster(clustersA, opsA, 304); + expect(await clustersA.getDaoValidatorCount()).to.equal(daoCountBeforeA + 1n); + + await ( + await clustersA + .connect(clusterOwner) + .migrateClusterToETH(opsA, clusterA, { value: DEFAULT_ETH_REGISTER_VALUE }) + ).wait(); + // updateDAOSSV(false, 1) called → decrements back to baseline + expect(await clustersA.getDaoValidatorCount()).to.equal(daoCountBeforeA); + + // sub-case: liquidated cluster → migration does NOT decrement SSV DAO count + const { clusters: clustersB, operatorIds: opsB } = + await networkHelpers.loadFixture(deployHarnessFixture); + + const key = makePublicKey(305); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clustersB.mockRegisterSSVValidator(key, opsB, clusterOwner.address, ssvCluster); + + // Owner self-liquidates (liquidateSSV allows clusterOwner == msg.sender without threshold check) + const liqReceipt = await ( + await clustersB.connect(clusterOwner).liquidateSSV(clusterOwner.address, opsB, ssvCluster) + ).wait(); + const liquidatedCluster = parseClusterFromEvent(clustersB, liqReceipt, Events.CLUSTER_LIQUIDATED); + + // Capture daoValidatorCount post-liquidation (already decremented) + const daoCountPostLiquidation = await clustersB.getDaoValidatorCount(); + + // Liquidated cluster still has validatorCount=1; provide msg.value to pass the collateral check. + await ( + await clustersB + .connect(clusterOwner) + .migrateClusterToETH(opsB, liquidatedCluster, { value: DEFAULT_ETH_REGISTER_VALUE }) + ).wait(); + + // updateDAOSSV must NOT have run (isLiquidated=true) → count stays at post-liquidation value. + // Ordering violation would produce daoCountPostLiquidation - 1. + expect(await clustersB.getDaoValidatorCount()).to.equal(daoCountPostLiquidation); + }); + + // ── updateClusterOperatorsMigration initialises ethSnapshot.block before deviation loop + // The deviation loop skips operators with ethSnapshot.block == 0. + // updateClusterOperatorsMigration calls ensureETHDefaults which sets ethSnapshot.block + // for SSV-only operators. If the deviation loop ran first, all operators would still have + // ethSnapshot.block == 0, the `continue` would skip them, and operatorEthVUnits would + // stay at 0 even though the cluster has explicit deviation. + it("operator deviation applied after updateClusterOperatorsMigration initialises ethSnapshot.block", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + + // Force all operators into SSV-only state so ethSnapshot.block == 0. + // Without this, the fixture already initialises ethSnapshot.block, and the + // deviation loop would credit operators regardless of execution order. + for (const op of operatorIds) { + await clusters.mockSetOperatorLegacySSV(op, 1n); + const [, ethBlockBefore] = await clusters.getOperatorEthSnapshot(op); + expect(ethBlockBefore).to.equal(0); + } + + // vUnits=20_000 (64 ETH EB), vCount=1 → deviation = 20_000 - BPS_DENOMINATOR = 10_000 + const { ssvCluster } = await setupOneValidatorCluster(clusters, operatorIds, 306, 20_000n); + const explicitVUnits = 20_000n; + const operatorDeviation = explicitVUnits - BPS_DENOMINATOR; // 10_000 + + await ( + await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, ssvCluster, { value: DEFAULT_ETH_REGISTER_VALUE }) + ).wait(); + + // updateClusterOperatorsMigration must have run first: ethSnapshot.block is now set. + for (const op of operatorIds) { + const [, ethBlockAfter] = await clusters.getOperatorEthSnapshot(op); + expect(ethBlockAfter).to.be.greaterThan(0); + } + // Each operator receives the deviation; DAO gets baseline + deviation. + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(operatorDeviation); + } + expect(await clusters.getDaoTotalEthVUnits()).to.equal(explicitVUnits); + }); + + // ── Scenario D: vCount=0 AND vUnits=0 (clean implicit baseline) ────────────────────── + // Guard condition `vCount==0 && vUnits>0` is FALSE (vUnits already zero, no write needed). + // Deviation loop `vUnits>0` is also FALSE — skipped entirely. + // updateDAO(true, 0) and updateDAOSSV(false, 0) are both arithmetic no-ops. + // Net effect: only the slot transition (SSV cleared → ETH written) and the event happen; + // every other piece of storage is byte-for-byte identical before and after. + it("vCount=0 && vUnits=0 — guard and deviation loop both no-op, slot transition correct, DAO/operator state untouched", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + + const key = makePublicKey(307); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, ssvCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + const removeReceipt = await ( + await clusters.connect(clusterOwner).removeValidator(key, operatorIds, ssvCluster) + ).wait(); + const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + // vUnits was never poisoned — confirmed clean baseline + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + + // Snapshot every piece of state that must NOT change + const daoTotalEthVUnitsBefore = await clusters.getDaoTotalEthVUnits(); + const daoEthValidatorCountBefore = await clusters.getDaoEthValidatorCount(); + const daoSSVValidatorCountBefore = await clusters.getDaoValidatorCount(); + const operatorEthVUnitsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthVUnits(op)), + ); + const operatorEthValidatorCountsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthValidatorCount(op)), + ); + // SSV slot must be present before migration + expect(await clusters.getSSVClusterHash(clusterId)).to.not.equal(ethers.ZeroHash); + + const migrateReceipt = await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(); + const args = extractEventArgs(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + const clusterAfterMigrate = parseClusterFromEvent(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + // effectiveVUnits = 0 * BPS = 0 → effectiveBalance = vUnitsToEB(0) = 0 + expect(args.effectiveBalance).to.equal(0); + expect(args.ethDeposited).to.equal(0n); + + // EB snapshot: guard did not write (condition was false), stays at 0 + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + + // Slot transitions: SSV slot deleted, ETH slot written with exact hash + expect(await clusters.getSSVClusterHash(clusterId)).to.equal(ethers.ZeroHash); + expect(await clusters.getClusterHash(clusterId)).to.equal(expectedClusterHash(clusterAfterMigrate)); + + // DAO and per-operator state: byte-for-byte identical (all loops were no-ops) + expect(await clusters.getDaoTotalEthVUnits()).to.equal(daoTotalEthVUnitsBefore); + expect(await clusters.getDaoEthValidatorCount()).to.equal(daoEthValidatorCountBefore); + expect(await clusters.getDaoValidatorCount()).to.equal(daoSSVValidatorCountBefore); + for (let i = 0; i < operatorIds.length; i++) { + expect(await clusters.getOperatorEthVUnits(operatorIds[i])).to.equal(operatorEthVUnitsBefore[i]); + expect(await clusters.getOperatorEthValidatorCount(operatorIds[i])).to.equal( + operatorEthValidatorCountsBefore[i], + ); + } + }); +}); diff --git a/test/sanity/migrate-cluster-eb-guard.storage-transition.test.ts b/test/sanity/migrate-cluster-eb-guard.storage-transition.test.ts new file mode 100644 index 00000000..3c840598 --- /dev/null +++ b/test/sanity/migrate-cluster-eb-guard.storage-transition.test.ts @@ -0,0 +1,299 @@ +import { expect } from "chai"; +import { ethers } from "ethers"; +import type { NetworkConnection } from "hardhat/types/network"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; +import { ssvClustersHarnessFixture } from "../setup/fixtures.ts"; +import type { NetworkHelpersType } from "../common/types.ts"; +import type { SSVClustersHarness } from "../../types/ethers-contracts/index.js"; +import { + BPS_DENOMINATOR, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + MINIMAL_OPERATOR_ETH_FEE, +} from "../common/constants.ts"; +import { + computeClusterId, + createLegacySSVCluster, + extractEventArgs, + makePublicKey, + parseClusterFromEvent, + setupTestContext, +} from "../helpers/index.js"; +import { Events } from "../common/events.js"; +import { Errors } from "../common/errors.js"; + +// Exact ClusterLib.hashClusterData encoding: keccak256(abi.encodePacked( +// uint32 validatorCount, uint64 networkFeeIndex, uint64 index, uint256 balance, bool active)) +function expectedClusterHash(c: { + validatorCount: bigint; + networkFeeIndex: bigint; + index: bigint; + balance: bigint; + active: boolean; +}) { + return ethers.keccak256( + ethers.solidityPacked( + ["uint32", "uint64", "uint64", "uint256", "bool"], + [c.validatorCount, c.networkFeeIndex, c.index, c.balance, c.active], + ), + ); +} + +describe("migrateClusterToETH guard: storage transitions for cases E/F/I", () => { + let connection: NetworkConnection<"generic">; + let networkHelpers: NetworkHelpersType; + let clusterOwner: HardhatEthersSigner; + + before(async function () { + ({ connection, networkHelpers, signers: [, clusterOwner] } = await setupTestContext()); + }); + + const deployHarnessFixture = async () => + ssvClustersHarnessFixture(connection, 4, MINIMAL_OPERATOR_ETH_FEE); + + // Drives a legacy SSV cluster into the "phantom" pre-fix shape: + // s.clusters[clusterId] != 0, validatorCount == 0, seb.clusterEB[clusterId].vUnits > 0 + // by registering, optionally liquidating, removing the last validator (which now zeroes vUnits + // via the SSV-side fix), then re-seeding vUnits via mockSetClusterVUnits to simulate a + // cluster that was poisoned before the SSV-side fix shipped. The migration-side guard is the + // only thing standing between this state and a deviation injection on migrate. + async function setupPhantomCluster( + clusters: SSVClustersHarness, + operatorIds: bigint[], + seed: number, + options: { liquidate?: boolean; phantomVUnits?: bigint } = {}, + ) { + const phantomVUnits = options.phantomVUnits ?? 640_000n; + const key = makePublicKey(seed); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, ssvCluster); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + let working = ssvCluster; + + if (options.liquidate) { + const liqReceipt = await ( + await clusters.connect(clusterOwner).liquidateSSV(clusterOwner.address, operatorIds, ssvCluster) + ).wait(); + working = parseClusterFromEvent(clusters, liqReceipt, Events.CLUSTER_LIQUIDATED); + expect(working.active).to.equal(false); + } + + const removeReceipt = await ( + await clusters.connect(clusterOwner).removeValidator(key, operatorIds, working) + ).wait(); + const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); // vUnits starts at 0; mockSetClusterVUnits below poisons it to simulate pre-fix state + + await clusters.mockSetClusterVUnits(clusterId, phantomVUnits); + expect(await clusters.getClusterVUnits(clusterId)).to.equal(phantomVUnits); + + return { clusterId, clusterAfterRemove }; + } + + // ── E: active branch, msg.value == 0 ──────────────────────────────────────────────── + it("E: clears s.clusters slot, writes s.ethClusters to expected hash, zeroes vUnits, leaves DAO/operator state untouched", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterId, clusterAfterRemove } = await setupPhantomCluster(clusters, operatorIds, 200); + + const daoTotalEthVUnitsBefore = await clusters.getDaoTotalEthVUnits(); + const daoValidatorCountBefore = await clusters.getDaoValidatorCount(); + const daoEthValidatorCountBefore = await clusters.getDaoEthValidatorCount(); + const operatorEthVUnitsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthVUnits(op)), + ); + const operatorEthValidatorCountsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthValidatorCount(op)), + ); + const contractEthBefore = await connection.ethers.provider.getBalance(await clusters.getAddress()); + + const migrateReceipt = await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(); + const args = extractEventArgs(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + const clusterAfterMigrate = parseClusterFromEvent(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + // post-state cluster shape (used to reconstruct the expected ETH-slot hash) + expect(clusterAfterMigrate.validatorCount).to.equal(0n); + expect(clusterAfterMigrate.balance).to.equal(0n); + expect(clusterAfterMigrate.active).to.equal(true); + + // ── slot transitions + expect(await clusters.getSSVClusterHash(clusterId)).to.equal(ethers.ZeroHash); + expect(await clusters.getClusterHash(clusterId)).to.equal(expectedClusterHash(clusterAfterMigrate)); + + // ── EB snapshot wiped + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + + // ── event reflects cleared snapshot + expect(args.effectiveBalance).to.equal(0); + expect(args.ethDeposited).to.equal(0n); + + // ── DAO and per-operator state must be byte-for-byte identical: vCount=0 implies + // sp.updateDAOSSV(false, 0) and sp.updateDAO(true, 0) both no-op, deviation skipped. + expect(await clusters.getDaoTotalEthVUnits()).to.equal(daoTotalEthVUnitsBefore); + expect(await clusters.getDaoValidatorCount()).to.equal(daoValidatorCountBefore); + expect(await clusters.getDaoEthValidatorCount()).to.equal(daoEthValidatorCountBefore); + for (let i = 0; i < operatorIds.length; i++) { + expect(await clusters.getOperatorEthVUnits(operatorIds[i])).to.equal(operatorEthVUnitsBefore[i]); + expect(await clusters.getOperatorEthValidatorCount(operatorIds[i])).to.equal(operatorEthValidatorCountsBefore[i]); + } + + // ── contract ETH balance unchanged (msg.value == 0) + expect(await connection.ethers.provider.getBalance(await clusters.getAddress())).to.equal(contractEthBefore); + }); + + // ── F: active branch, msg.value > 0 ───────────────────────────────────────────────── + it("F: msg.value flows into cluster.balance and contract ETH, fix-cleared vUnits keeps deviation untouched", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterId, clusterAfterRemove } = await setupPhantomCluster(clusters, operatorIds, 201); + + const depositValue = ethers.parseEther("3"); + const daoTotalEthVUnitsBefore = await clusters.getDaoTotalEthVUnits(); + const daoEthValidatorCountBefore = await clusters.getDaoEthValidatorCount(); + const operatorEthVUnitsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthVUnits(op)), + ); + const operatorEthValidatorCountsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthValidatorCount(op)), + ); + const contractEthBefore = await connection.ethers.provider.getBalance(await clusters.getAddress()); + + const migrateReceipt = await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: depositValue }) + ).wait(); + const args = extractEventArgs(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + const clusterAfterMigrate = parseClusterFromEvent(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + // ── balance landed in cluster + contract ETH + expect(clusterAfterMigrate.balance).to.equal(depositValue); + expect(args.ethDeposited).to.equal(depositValue); + expect(args.effectiveBalance).to.equal(0); // vUnits cleared → vUnitsToEB(0) == 0 + expect( + (await connection.ethers.provider.getBalance(await clusters.getAddress())) - contractEthBefore, + ).to.equal(depositValue); + + // ── slot transitions and cleared EB snapshot + expect(await clusters.getSSVClusterHash(clusterId)).to.equal(ethers.ZeroHash); + expect(await clusters.getClusterHash(clusterId)).to.equal(expectedClusterHash(clusterAfterMigrate)); + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + + // ── deviation accounting must NOT have absorbed the phantom vUnits + expect(await clusters.getDaoTotalEthVUnits()).to.equal(daoTotalEthVUnitsBefore); + expect(await clusters.getDaoEthValidatorCount()).to.equal(daoEthValidatorCountBefore); + for (let i = 0; i < operatorIds.length; i++) { + expect(await clusters.getOperatorEthVUnits(operatorIds[i])).to.equal(operatorEthVUnitsBefore[i]); + expect(await clusters.getOperatorEthValidatorCount(operatorIds[i])).to.equal(operatorEthValidatorCountsBefore[i]); + } + }); + + // ── I: liquidated branch (cluster.active == false at migration entry) ────────────── + it("I: liquidated SSV cluster with phantom vUnits migrates, reactivates, and emits ClusterReactivated", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterId, clusterAfterRemove } = await setupPhantomCluster(clusters, operatorIds, 202, { + liquidate: true, + }); + expect(clusterAfterRemove.active).to.equal(false); + + const daoTotalEthVUnitsBefore = await clusters.getDaoTotalEthVUnits(); + const daoEthValidatorCountBefore = await clusters.getDaoEthValidatorCount(); + const operatorEthVUnitsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthVUnits(op)), + ); + const operatorEthValidatorCountsBefore = await Promise.all( + operatorIds.map((op) => clusters.getOperatorEthValidatorCount(op)), + ); + + const migrateTx = await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }); + const migrateReceipt = await migrateTx.wait(); + const clusterAfterMigrate = parseClusterFromEvent(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + // ── reactivation path emits the secondary event + await expect(migrateTx).to.emit(clusters, Events.CLUSTER_REACTIVATED); + + // ── cluster flipped to active, vCount stays 0, balance 0 + expect(clusterAfterMigrate.active).to.equal(true); + expect(clusterAfterMigrate.validatorCount).to.equal(0n); + expect(clusterAfterMigrate.balance).to.equal(0n); + + // ── slot transitions and cleared EB snapshot + expect(await clusters.getSSVClusterHash(clusterId)).to.equal(ethers.ZeroHash); + expect(await clusters.getClusterHash(clusterId)).to.equal(expectedClusterHash(clusterAfterMigrate)); + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + + // ── liquidated branch: updateDAOSSV is skipped, updateDAO(true, 0) is no-op, deviation skipped + expect(await clusters.getDaoTotalEthVUnits()).to.equal(daoTotalEthVUnitsBefore); + expect(await clusters.getDaoEthValidatorCount()).to.equal(daoEthValidatorCountBefore); + for (let i = 0; i < operatorIds.length; i++) { + expect(await clusters.getOperatorEthVUnits(operatorIds[i])).to.equal(operatorEthVUnitsBefore[i]); + expect(await clusters.getOperatorEthValidatorCount(operatorIds[i])).to.equal(operatorEthValidatorCountsBefore[i]); + } + }); + + // ── E adversarial: SSV slot is gone, re-migration must revert ─────────────────────── + // The revert is caused by the deleted s.clusters slot (validateHashedCluster sees VERSION_ETH), + // not the EB guard — this is lifecycle idempotency coverage, not guard coverage. + it("E adversarial: re-migration after fix-clean migration reverts IncorrectClusterVersion (s.clusters slot deleted)", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterAfterRemove } = await setupPhantomCluster(clusters, operatorIds, 203); + + const migrateReceipt = await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(); + const clusterAfterMigrate = parseClusterFromEvent(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + // validateHashedCluster sees ETH-only slot → version=ETH; validateClusterVersion(VERSION_SSV) reverts. + await expect( + clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterMigrate, { value: 0 }), + ).to.be.revertedWithCustomError(clusters, Errors.INCORRECT_CLUSTER_VERSION); + }); + + // ── E adversarial: post-fix register lands on a clean implicit baseline ───────────── + it("E adversarial: registerValidator after fix-clean migration produces a clean implicit cluster (vUnits=0, deviation=0, baseline=BPS)", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterId, clusterAfterRemove } = await setupPhantomCluster(clusters, operatorIds, 204); + + const clusterAfterMigrate = parseClusterFromEvent( + clusters, + await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(), + Events.CLUSTER_MIGRATED_TO_ETH, + ); + + const clusterAfterRegister = parseClusterFromEvent( + clusters, + await ( + await clusters + .connect(clusterOwner) + .registerValidator(makePublicKey(304), operatorIds, DEFAULT_SHARES, clusterAfterMigrate, { + value: DEFAULT_ETH_REGISTER_VALUE, + }) + ).wait(), + Events.VALIDATOR_ADDED, + ); + + // post-register cluster shape lands as a clean 1-validator ETH cluster + expect(clusterAfterRegister.validatorCount).to.equal(1n); + expect(clusterAfterRegister.balance).to.equal(DEFAULT_ETH_REGISTER_VALUE); + expect(clusterAfterRegister.active).to.equal(true); + + // SPEC.md: implicit cluster keeps clusterEB.vUnits == 0 (default 32 ETH/validator), no deviation injected + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(0n); + // effectiveOperatorVUnits = operatorEthVUnits + ethValidatorCount * BPS = 0 + 1 * BPS + expect(await clusters.getEffectiveOperatorVUnits(op)).to.equal(BPS_DENOMINATOR); + expect(await clusters.getOperatorEthValidatorCount(op)).to.equal(1n); + } + // DAO baseline tracks 1 validator's worth of vUnits, no deviation contribution + expect(await clusters.getDaoTotalEthVUnits()).to.equal(BPS_DENOMINATOR); + + // SSV slot still empty, ETH slot matches new state + expect(await clusters.getSSVClusterHash(clusterId)).to.equal(ethers.ZeroHash); + expect(await clusters.getClusterHash(clusterId)).to.equal(expectedClusterHash(clusterAfterRegister)); + }); +}); diff --git a/test/sanity/migrate-cluster-eb-guard.test.ts b/test/sanity/migrate-cluster-eb-guard.test.ts new file mode 100644 index 00000000..3dabd4d7 --- /dev/null +++ b/test/sanity/migrate-cluster-eb-guard.test.ts @@ -0,0 +1,330 @@ +import { expect } from "chai"; +import type { NetworkConnection } from "hardhat/types/network"; +import { ssvClustersHarnessFixture } from "../setup/fixtures.ts"; +import type { NetworkHelpersType } from "../common/types.ts"; +import type { SSVClustersHarness } from "../../types/ethers-contracts/index.js"; +import { + BPS_DENOMINATOR, + DEFAULT_ETH_REGISTER_VALUE, + DEFAULT_SHARES, + MINIMAL_OPERATOR_ETH_FEE, +} from "../common/constants.ts"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; +import { + computeClusterId, + computeEBRoot, + createLegacySSVCluster, + extractEventArgs, + makePublicKey, + mineBlocks, + parseClusterFromEvent, + setupTestContext, +} from "../helpers/index.js"; +import { Events } from "../common/events.js"; +import { Errors } from "../common/errors.ts"; + +describe("migrateClusterToETH guard: stale vUnits on empty SSV cluster", () => { + let connection: NetworkConnection<"generic">; + let networkHelpers: NetworkHelpersType; + let clusterOwner: HardhatEthersSigner; + + before(async function () { + ({ + connection, + networkHelpers, + signers: [, clusterOwner], + } = await setupTestContext()); + }); + + const deployHarnessFixture = async () => + ssvClustersHarnessFixture(connection, 4, MINIMAL_OPERATOR_ETH_FEE); + + async function setupBrokenStateCluster( + clusters: SSVClustersHarness, + operatorIds: bigint[], + seed: number, + ) { + const key = makePublicKey(seed); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, ssvCluster); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + const removeTx = await clusters + .connect(clusterOwner) + .removeValidator(key, operatorIds, ssvCluster); + const clusterAfterRemove = parseClusterFromEvent( + clusters, + await removeTx.wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + + await clusters.mockSetClusterVUnits(clusterId, 640_000n); + + return { clusterId, clusterAfterRemove }; + } + + it("guard zeroes stale vUnits on migration, effectiveBalance is nullified", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterAfterRemove } = await setupBrokenStateCluster(clusters, operatorIds, 100); + + const migrateReceipt = await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(); + const migrateArgs = extractEventArgs(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + expect(migrateArgs.effectiveBalance).to.equal(0); + expect(await clusters.getDaoTotalEthVUnits()).to.equal(0n); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(0n); + } + }); + + it("can register validator after guard-patched migration with clean 32 ETH baseline", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterAfterRemove } = await setupBrokenStateCluster(clusters, operatorIds, 101); + + const clusterAfterMigrate = parseClusterFromEvent( + clusters, + await ( + await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(), + Events.CLUSTER_MIGRATED_TO_ETH, + ); + expect(clusterAfterMigrate.validatorCount).to.equal(0n); + expect(clusterAfterMigrate.active).to.equal(true); + + const clusterAfterReg = parseClusterFromEvent( + clusters, + await ( + await clusters.connect(clusterOwner).registerValidator( + makePublicKey(102), + operatorIds, + DEFAULT_SHARES, + clusterAfterMigrate, + { value: DEFAULT_ETH_REGISTER_VALUE }, + ) + ).wait(), + Events.VALIDATOR_ADDED, + ); + expect(clusterAfterReg.validatorCount).to.equal(1n); + + expect(await clusters.getDaoTotalEthVUnits()).to.equal(BPS_DENOMINATOR); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(0n); + expect(await clusters.getEffectiveOperatorVUnits(op)).to.equal(BPS_DENOMINATOR); + } + }); + + it("operator ETH earnings are stale after migration nullified the eb", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterAfterRemove } = await setupBrokenStateCluster(clusters, operatorIds, 103); + + await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }); + + for (const op of operatorIds) { + expect(await clusters.getOperatorEthValidatorCount(op)).to.equal(0); + expect(await clusters.getEffectiveOperatorVUnits(op)).to.equal(0n); + } + + await mineBlocks(connection.ethers.provider, 1000); + + for (const op of operatorIds) { + expect(await clusters.getOperatorEthValidatorCount(op)).to.equal(0); + expect(await clusters.getEffectiveOperatorVUnits(op)).to.equal(0n); + } + }); + + it("Non-zero msg.value — cluster.balance equals msg.value, ETH cluster stored, SSV cluster deleted", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + const { clusterId, clusterAfterRemove } = await setupBrokenStateCluster(clusters, operatorIds, 110); + + const migrateReceipt = await ( + await clusters + .connect(clusterOwner) + .migrateClusterToETH(operatorIds, clusterAfterRemove, { value: DEFAULT_ETH_REGISTER_VALUE }) + ).wait(); + const clusterAfterMigrate = parseClusterFromEvent(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + + expect(clusterAfterMigrate.balance).to.equal(DEFAULT_ETH_REGISTER_VALUE); + + const expectedHash = connection.ethers.solidityPackedKeccak256( + ["uint32", "uint64", "uint64", "uint256", "bool"], + [ + clusterAfterMigrate.validatorCount, + clusterAfterMigrate.networkFeeIndex, + clusterAfterMigrate.index, + clusterAfterMigrate.balance, + clusterAfterMigrate.active, + ], + ); + expect(await clusters.getClusterHash(clusterId)).to.equal(expectedHash); + expect(await clusters.getSSVClusterHash(clusterId)).to.equal(connection.ethers.ZeroHash); + }); + + it("Liquidated SSV cluster with stale vUnits at validatorCount=0 - guard fires, ClusterReactivated emitted", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + const key = makePublicKey(120); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, ssvCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + const clusterAfterLiq = parseClusterFromEvent( + clusters, + await (await clusters.connect(clusterOwner).liquidateSSV(clusterOwner.address, operatorIds, ssvCluster)).wait(), + Events.CLUSTER_LIQUIDATED, + ); + expect(clusterAfterLiq.active).to.equal(false); + + const clusterAfterRemove = parseClusterFromEvent( + clusters, + await (await clusters.connect(clusterOwner).removeValidator(key, operatorIds, clusterAfterLiq)).wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + + await clusters.mockSetClusterVUnits(clusterId, 640_000n); + + const migrateReceipt = await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(); + const migrateArgs = extractEventArgs(clusters, migrateReceipt, Events.CLUSTER_MIGRATED_TO_ETH); + const reactivateArgs = extractEventArgs(clusters, migrateReceipt, Events.CLUSTER_REACTIVATED); + + expect(migrateArgs.effectiveBalance).to.equal(0); + expect(reactivateArgs).to.not.be.undefined; + expect(await clusters.getDaoTotalEthVUnits()).to.equal(0n); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(0n); + } + }); + + it("Explicit vUnits == baseline - no deviation added, vUnits preserved in storage", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(makePublicKey(130), operatorIds, clusterOwner.address, ssvCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + await clusters.mockSetClusterVUnits(clusterId, BPS_DENOMINATOR); + + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, ssvCluster, { value: DEFAULT_ETH_REGISTER_VALUE }); + + expect(await clusters.getDaoTotalEthVUnits()).to.equal(BPS_DENOMINATOR); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(0n); + } + expect(await clusters.getClusterVUnits(clusterId)).to.equal(BPS_DENOMINATOR); + }); + + it("Explicit vUnits > baseline with 2 validators - each operator gets exact deviation, DAO matches", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + + const ssvCluster = createLegacySSVCluster({ validatorCount: 2n, balance: 0n }); + await clusters.mockRegisterSSVValidator(makePublicKey(140), operatorIds, clusterOwner.address, ssvCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + const vUnits = 40_000n; + const baseline = 2n * BPS_DENOMINATOR; + const deviation = vUnits - baseline; + await clusters.mockSetClusterVUnits(clusterId, vUnits); + + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, ssvCluster, { value: DEFAULT_ETH_REGISTER_VALUE }); + + expect(await clusters.getDaoTotalEthVUnits()).to.equal(baseline + deviation); + for (const op of operatorIds) { + expect(await clusters.getOperatorEthVUnits(op)).to.equal(deviation); + } + }); + + it("EBBelowMinimum: oracle cannot set EB below floor (validatorCount * 32 ETH)", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + + const cluster1 = parseClusterFromEvent( + clusters, + await ( + await clusters.connect(clusterOwner).registerValidator( + makePublicKey(200), + operatorIds, + DEFAULT_SHARES, + { validatorCount: 0, networkFeeIndex: 0, index: 0, balance: 0, active: true }, + { value: DEFAULT_ETH_REGISTER_VALUE }, + ) + ).wait(), + Events.VALIDATOR_ADDED, + ); + const cluster2 = parseClusterFromEvent( + clusters, + await ( + await clusters.connect(clusterOwner).registerValidator( + makePublicKey(201), + operatorIds, + DEFAULT_SHARES, + cluster1, + { value: DEFAULT_ETH_REGISTER_VALUE }, + ) + ).wait(), + Events.VALIDATOR_ADDED, + ); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + const rootBelow = computeEBRoot(clusterId, 63); + await clusters.mockSetEBRoot(1, rootBelow); + await expect( + clusters.connect(clusterOwner).updateClusterBalance(1, clusterOwner.address, operatorIds, cluster2, 63, []), + ).to.be.revertedWithCustomError(clusters, Errors.EB_BELOW_MINIMUM); + + const rootZero = computeEBRoot(clusterId, 0); + await clusters.mockSetEBRoot(2, rootZero); + await expect( + clusters.connect(clusterOwner).updateClusterBalance(2, clusterOwner.address, operatorIds, cluster2, 0, []), + ).to.be.revertedWithCustomError(clusters, Errors.EB_BELOW_MINIMUM); + + const rootFloor = computeEBRoot(clusterId, 64); + await clusters.mockSetEBRoot(3, rootFloor); + await expect( + clusters.connect(clusterOwner).updateClusterBalance(3, clusterOwner.address, operatorIds, cluster2, 64, []), + ).to.not.revert(connection.ethers); + }); + + it("EBExceedsMaximum: stale root (EB=1000) rejected after validators removed and cluster migrated", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployHarnessFixture); + + const key = makePublicKey(210); + const ssvCluster = createLegacySSVCluster({ validatorCount: 1n, balance: 0n }); + await clusters.mockRegisterSSVValidator(key, operatorIds, clusterOwner.address, ssvCluster); + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + + const rootEB1000 = computeEBRoot(clusterId, 1000); + await clusters.mockSetEBRoot(10, rootEB1000); + await clusters.updateClusterBalance(10, clusterOwner.address, operatorIds, ssvCluster, 1000, []); + + const clusterAfterRemove = parseClusterFromEvent( + clusters, + await (await clusters.connect(clusterOwner).removeValidator(key, operatorIds, ssvCluster)).wait(), + Events.VALIDATOR_REMOVED, + ); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + + const clusterAfterMigrate = parseClusterFromEvent( + clusters, + await ( + await clusters.connect(clusterOwner).migrateClusterToETH(operatorIds, clusterAfterRemove, { value: 0 }) + ).wait(), + Events.CLUSTER_MIGRATED_TO_ETH, + ); + expect(clusterAfterMigrate.validatorCount).to.equal(0n); + + await clusters.mockSetEBRoot(20, rootEB1000); + await expect( + clusters.updateClusterBalance(20, clusterOwner.address, operatorIds, clusterAfterMigrate, 1000, []), + ).to.be.revertedWithCustomError(clusters, Errors.EB_EXCEEDS_MAXIMUM); + }); +}); diff --git a/test/unit/SSVValidator/bulkRemoveValidator.test.ts b/test/unit/SSVValidator/bulkRemoveValidator.test.ts index 36e2b3ef..0caa5c02 100644 --- a/test/unit/SSVValidator/bulkRemoveValidator.test.ts +++ b/test/unit/SSVValidator/bulkRemoveValidator.test.ts @@ -431,4 +431,80 @@ describe("SSVClusters function `bulkRemoveValidator()`", async () => { clusters.connect(clusterOwner).reactivate(operatorIds, clusterAfterRemove, { value: DEFAULT_ETH_REGISTER_VALUE }) ).to.be.revertedWithCustomError(clusters, Errors.INCORRECT_CLUSTER_VERSION); }); + + it("SSV active cluster bulk-removes all 2 validators with explicit vUnits=640_000 — clears to 0", async function () { + const { clusters, operatorIds } = + await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture); + + const key1 = makePublicKey(51); + const key2 = makePublicKey(52); + const ssvCluster = createLegacySSVCluster({ validatorCount: 2n }); + + await clusters.mockRegisterSSVValidator(key1, operatorIds, clusterOwner.address, ssvCluster); + await clusters.mockRegisterSSVValidator(key2, operatorIds, clusterOwner.address, ssvCluster); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + await clusters.mockSetClusterVUnits(clusterId, 640_000n); + + const removeTx = await clusters + .connect(clusterOwner) + .bulkRemoveValidator([key1, key2], operatorIds, ssvCluster); + const removeReceipt = await removeTx.wait(); + const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED); + + expect(clusterAfterRemove.validatorCount).to.equal(0n); + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + }); + + it("SSV active cluster bulk-removes 1 of 2 validators — vUnits snapshot unchanged", async function () { + const { clusters, operatorIds } = + await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture); + + const key1 = makePublicKey(61); + const key2 = makePublicKey(62); + const ssvCluster = createLegacySSVCluster({ validatorCount: 2n }); + + await clusters.mockRegisterSSVValidator(key1, operatorIds, clusterOwner.address, ssvCluster); + await clusters.mockRegisterSSVValidator(key2, operatorIds, clusterOwner.address, ssvCluster); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + const storedVUnits = 640_000n; + await clusters.mockSetClusterVUnits(clusterId, storedVUnits); + + const removeTx = await clusters + .connect(clusterOwner) + .bulkRemoveValidator([key1], operatorIds, ssvCluster); + await removeTx.wait(); + + expect(await clusters.getClusterVUnits(clusterId)).to.equal(storedVUnits); + }); + + it("SSV liquidated cluster bulk-removes all 2 validators — clears vUnits to 0, operator/DAO SSV counts not decremented", async function () { + const { clusters, operatorIds } = + await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture); + + const key1 = makePublicKey(71); + const key2 = makePublicKey(72); + const ssvClusterLiquidated = createLegacySSVCluster({ validatorCount: 2n, active: false }); + + await clusters.mockRegisterSSVValidator(key1, operatorIds, clusterOwner.address, ssvClusterLiquidated); + await clusters.mockRegisterSSVValidator(key2, operatorIds, clusterOwner.address, ssvClusterLiquidated); + + const clusterId = computeClusterId(clusterOwner.address, operatorIds); + await clusters.mockSetClusterVUnits(clusterId, 640_000n); + + const operatorCountBefore = await clusters.getOperatorValidatorCount(operatorIds[0]); + const daoCountBefore = await clusters.getDaoValidatorCount(); + + const removeTx = await clusters + .connect(clusterOwner) + .bulkRemoveValidator([key1, key2], operatorIds, ssvClusterLiquidated); + const removeReceipt = await removeTx.wait(); + const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED); + + expect(clusterAfterRemove.validatorCount).to.equal(0n); + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0n); + expect(await clusters.getOperatorValidatorCount(operatorIds[0])).to.equal(operatorCountBefore); + expect(await clusters.getDaoValidatorCount()).to.equal(daoCountBefore); + }); }); diff --git a/test/unit/SSVValidator/removeValidator.test.ts b/test/unit/SSVValidator/removeValidator.test.ts index c54ea0f4..392f9ebf 100644 --- a/test/unit/SSVValidator/removeValidator.test.ts +++ b/test/unit/SSVValidator/removeValidator.test.ts @@ -449,7 +449,7 @@ describe("SSVClusters function `removeValidator()`", async () => { expect(await clusters.getClusterHash(clusterId)).to.not.equal(ethers.ZeroHash); }); - it("SSV remove path leaves orphaned EB snapshot untouched (defensive behavior)", async function () { + it("SSV remove path removes EB snapshot if the amount of validators becomes zero", async function () { const { clusters, operatorIds } = await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture); @@ -466,7 +466,7 @@ describe("SSVClusters function `removeValidator()`", async () => { const clusterAfterRemove = parseClusterFromEvent(clusters, removeReceipt, Events.VALIDATOR_REMOVED); expect(clusterAfterRemove.validatorCount).to.equal(0n); - expect(await clusters.getClusterVUnits(clusterId)).to.equal(50_000n); + expect(await clusters.getClusterVUnits(clusterId)).to.equal(0); }); it("Processes SSV and ETH removals in the same block without storage/counter collision", async function () {