diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md new file mode 100644 index 00000000..c3a14ef8 --- /dev/null +++ b/.claude/skills/audit/SKILL.md @@ -0,0 +1,366 @@ +--- +name: audit +description: "Run a standardized security and correctness audit on SSV Network contracts. Use when the user wants to audit a module, PR, branch diff, or the full codebase. Outputs findings in MAINNET-READINESS.md format." +argument-hint: "[scope: module name, pr number, 'full', or file path]" +--- + +# SSV Network — Contract Audit Skill + +Run a standardized audit against SSV Network v2.0.0 smart contracts. Dispatches parallel subtask workers to check spec compliance, security, accounting correctness, edge cases, test coverage, and code quality. + +## Scope Resolution + +Parse `$ARGUMENTS` to determine the audit scope: + +| Input | Scope | Files | +|-------|-------|-------| +| `clusters` | SSVClusters module | `contracts/modules/SSVClusters.sol`, `contracts/libraries/ClusterLib.sol` | +| `operators` | SSVOperators module | `contracts/modules/SSVOperators.sol`, `contracts/libraries/OperatorLib.sol`, `contracts/modules/SSVOperatorsWhitelist.sol` | +| `validators` | SSVValidators module | `contracts/modules/SSVValidators.sol`, `contracts/libraries/ValidatorLib.sol` | +| `staking` | SSVStaking module | `contracts/modules/SSVStaking.sol`, `contracts/token/CSSVToken.sol`, `contracts/libraries/storage/SSVStorageStaking.sol` | +| `dao` | SSVDAO module | `contracts/modules/SSVDAO.sol`, `contracts/libraries/ProtocolLib.sol` | +| `views` | SSVViews module | `contracts/modules/SSVViews.sol` | +| `pr ` | Pull request diff | Run `gh pr diff ` to get files | +| `full` | Full codebase | All `contracts/` files | +| `` | Specific file | The given file | + +If no argument provided, ask the user what to audit. + +## Execution + +Use `subtask` to dispatch **3 parallel workers**, each handling a different audit dimension. All workers must use `--base-branch` matching the current branch. + +**IMPORTANT:** Unset CLAUDECODE before running subtask commands: `unset CLAUDECODE && subtask ...` + +### Worker 1: Security & Spec Compliance + +```bash +unset CLAUDECODE && subtask draft audit/security-[SCOPE] --base-branch [BRANCH] --title "Security audit: [SCOPE]" <<'TASK' +You are performing a security and spec compliance audit on SSV Network v2.0.0. + +## Required Reading +1. `CLAUDE.md` — Architecture, storage pattern, security rules +2. `docs/SPEC.md` — Technical specification (source of truth) +3. `docs/FLOWS.md` — Contract flows with invariants +4. `ssv-review/Internal - [DIP-X] SSV Staking.txt` — DIP-X proposal (source of truth for requirements) + +## Scope +[SCOPE_FILES] + +## Checks + +### 1. Spec Compliance +- [ ] Every function matches its specification in SPEC.md +- [ ] Event signatures and parameters match SPEC.md +- [ ] Error conditions and reverts match SPEC.md +- [ ] State mutations match FLOWS.md +- [ ] Invariants from FLOWS.md hold after every state transition +- [ ] DIP-X requirements satisfied — use claim-by-claim comparison with verdicts: MATCH / PARTIAL / MISMATCH / GAP / EXTRA + +### 2. Memory/Storage Safety (CRITICAL — caught our worst bug) +- [ ] **Stale memory copy detection:** For each function that reads a struct into `memory`, check: does any subsequent call modify the same struct in `storage`? If so, does the memory copy get written back, overwriting the storage change? +- [ ] **Storage→memory→storage roundtrip audit:** List every `Type memory x = s.something; ...; s.something = x;` pattern. Verify no storage-modifying functions are called between the read and write-back. +- [ ] **Flag every explicit downcast** (`uint64()`, `uint128()`, `uint192()`) — is there overflow checking? + +### 3. Entity Lifecycle State Machine (caught multiple HIGH bugs) +- [ ] **Operator lifecycle:** Map full state machine (registered → active → fee-changing → removed). For each state, list which fields are non-zero/zero. Check every function that interacts with operators — does it detect the state correctly? +- [ ] **Cluster lifecycle:** Map (created → active → liquidated → reactivated → migrated). For each transition, verify what state is cleaned up and what persists. +- [ ] **"Removed" detection consistency:** Grep for EVERY check that determines if an operator/cluster is removed/dead. Verify ALL checks use the same condition. +- [ ] **State resurrection:** Can any function unintentionally make a dead entity appear alive? (e.g., setting a zeroed field back to non-zero) + +### 4. Double-Accounting Prevention (caught HIGH bug) +- [ ] **Resource cleanup tracing:** For every counter/balance cleaned up on lifecycle transitions (liquidation, removal, migration), trace ALL code paths that modify it. Verify no path assumes another hasn't run. +- [ ] **Sequential operation analysis:** For critical pairs (liquidate → remove validators, EB update → liquidate, register → EB update), trace state changes and verify no double-counting or double-subtraction. + +### 5. Reentrancy +- [ ] **Completeness audit:** List EVERY `external`/`public` function across ALL modules. For each: has `nonReentrant`? Makes external calls? Document justification for any missing guard. +- [ ] **Shared slot verification:** Verify all modules use the same reentrancy guard storage slot via `SSVStorageReentrancy`. + +### 6. Access Control +- [ ] Owner-only at proxy level (`onlyOwner` modifier on SSVNetwork.sol) +- [ ] Operator owner: `operator.checkOwner()` in every operator management function +- [ ] Cluster owner: keyed by `keccak256(owner, operatorIds)` +- [ ] Oracle-only: `oracleIdOf[msg.sender] != 0` in `commitRoot` +- [ ] cSSV-only: `msg.sender == CSSV_ADDRESS` in `onCSSVTransfer` + +### 7. Cross-Module State Dependencies +- [ ] **State dependency graph:** Identify storage variables read by one module and written by another. Any variable with cross-module read/write without synchronization? +- [ ] **Coupled state variables:** Identify pairs that must stay synchronized (e.g., `ethDaoBalance` ↔ `stakingEthPoolBalance`). Verify all mutating functions maintain the coupling. + +### 8. Accounting Correctness +- [ ] **Per-operation balance flow:** For each operation (deposit, withdraw, liquidate, reactivate, migrate, register, remove, claimEthRewards, withdrawOperatorEarnings), trace what increases/decreases `contract.balance` and each accounting bucket. Do both sides match? +- [ ] **Cross-pool isolation:** Can any code path cause ETH to flow from operator pool to staking pool or vice versa? +- [ ] **vUnit math:** ceiling for ETH→vUnits (`ebToVUnits`), floor for vUnits→ETH (`vUnitsToEB`), VUNITS_PRECISION = 10_000 +- [ ] **Packed types:** non-divisible values revert with MaxPrecisionExceeded +- [ ] **Liquidation threshold:** vUnit-weighted burn rate correctly computed + +### 9. Accumulator Edge Analysis +- [ ] **Zero-supply state:** What happens when cSSV totalSupply is 0? Are rewards lost, deferred, or correctly handled? +- [ ] **Regression state:** Can `accEthPerShare` decrease? If so, what happens to users whose index is higher? +- [ ] **Dust analysis:** Maximum dust per operation? Where does it accumulate? Can it be recovered? +- [ ] **First-staker advantage:** Can the first staker after a gap capture undistributed rewards? + +### 10. Governance Parameter Validation +- [ ] **For every governance setter:** What is min/max valid value? Is there bounds validation? What breaks at 0 or max? +- [ ] **Single-block attack chains:** Can governance execute a dangerous sequence in one tx? (e.g., setQuorumBps(0) → replaceOracle → commitRoot) +- [ ] **Timelock presence:** Which critical governance functions lack a timelock? + +### 11. UUPS Proxy Safety +- [ ] `_disableInitializers()` called in implementation constructor +- [ ] `_authorizeUpgrade()` is `onlyOwner` +- [ ] `reinitializer(N)` version correct for target chain (current: N=3) +- [ ] No storage slot collisions across 5 storage libraries (verify keccak256 strings are unique) +- [ ] Fallback function routes correctly to SSVViews +- [ ] `msg.sender` and `msg.value` preserved correctly through delegatecall +- [ ] No module uses `address(this)` expecting implementation address + +### 12. Merkle Tree Security +- [ ] Double-hash convention verified (prevents second preimage attack) +- [ ] Cross-cluster proof substitution impossible (leaf includes clusterID) +- [ ] Proof replay across root transitions blocked (staleness + monotonicity) +- [ ] Zero/empty leaf handling + +### 13. Oracle Security +- [ ] Vote weight consistency across voting window (totalStaked can change between votes) +- [ ] Oracle replacement mid-vote (pending votes from replaced oracle persist) +- [ ] Multi-root voting (same oracle, conflicting roots, same block) +- [ ] Quorum unreachability (100% quorum + integer division) +- [ ] Oracle liveness failure handling + +### 14. Flash Loan Resistance +- [ ] Can flash-loaned SSV affect oracle voting weight? (check cooldown enforcement) +- [ ] Can flash-loaned ETH manipulate cluster balance checks? +- [ ] Are governance-sensitive calculations resistant to same-block manipulation? + +### 15. ERC20 Interaction Safety +- [ ] SSV token confirmed as standard ERC20 (no callbacks, no fee-on-transfer) +- [ ] Return values checked on all token transfers +- [ ] `rescueERC20` correctly blocks SSV and cSSV + +### 16. Event Completeness +- [ ] Every state change emits a corresponding event +- [ ] No ambiguous event reuse (same event for semantically different operations) +- [ ] Events provide enough data for off-chain state reconstruction (oracle, liquidator bot) + +### 17. Guard Consistency +- [ ] For operations with parallel implementations (normal liquidation vs auto-liquidation, SSV vs ETH paths), compare conditions side by side. Flag any inconsistency. + +## Output Format + +Write findings to `ssv-review/planning/verified/audit-security-[SCOPE]-[DATE].md` + +Before reporting, check `ssv-review/planning/MAINNET-READINESS.md` — skip already-tracked items. + +Include a **Verified Safe** section documenting areas investigated and confirmed correct. + +For each NEW issue use this format: +### [NEW-N] Title +- **Type:** Critical Bug Fix / Security Hardening / etc. +- **Priority:** P0 / P1 / P2 +- **Status:** Open + +**Requirement:** +**Context:** +**Acceptance Criteria:** +- [ ] criterion + +**Agent Instructions:** +TASK +``` + +### Worker 2: Test Coverage & Edge Cases + +```bash +unset CLAUDECODE && subtask draft audit/tests-[SCOPE] --base-branch [BRANCH] --title "Test coverage audit: [SCOPE]" <<'TASK' +You are auditing test coverage for SSV Network v2.0.0. + +## Required Reading +1. `CLAUDE.md` — Test conventions, helpers, patterns +2. The scoped contract files: [SCOPE_FILES] +3. ALL test files related to this scope in `test/unit/`, `test/integration/`, `test/sanity/` +4. Test helpers: `test/helpers/contract-helpers.ts`, `test/common/constants.ts`, `test/common/errors.ts`, `test/common/events.ts` +5. Echidna tests if relevant: `test/echidna/` + +## Checks + +### 1. Test Coverage Mapping +- [ ] Read every test file for the scoped module +- [ ] List what IS tested (scenarios covered) +- [ ] List what is NOT tested (gaps) +- [ ] For gaps, classify: P0 (security), P1 (correctness), P2 (edge case) + +### 2. Systemic Blind Spot Detection (caught our worst test gaps) +- [ ] **Parameter coverage matrix:** For each test file, check: do tests use non-zero operator fees? Non-baseline EB? Multiple operators? Multiple validators? If ANY major parameter is always zero/default across ALL tests, flag as P0. +- [ ] **Fee path coverage:** Every function that settles fees must be tested with concrete non-zero fees and verified against manual calculation. +- [ ] **EB path coverage:** Every function that uses vUnits must be tested with non-baseline EB (e.g., EB=1000, vUnits=312500). + +### 3. Balance Delta Assertions +- [ ] Every function that transfers ETH or SSV must have a test checking `balance_before - balance_after == expected_amount`. +- [ ] Check contract.balance, not just user balance. +- [ ] Liquidation: verify liquidator receives correct residual. +- [ ] Operator withdrawal: verify exact ETH/SSV amount. +- [ ] Staking claims: verify exact reward payout. + +### 4. Test Quality Deep Checks +- [ ] **Mock fidelity:** Do mock contracts faithfully reproduce production behavior? Check MockCSSV has `onCSSVTransfer` callback. +- [ ] **Commented-out assertions:** Search for assertions inside `/* */` or after `//` — flag immediately as P0. +- [ ] **Echidna invariant correctness:** Read each property: (a) assertion direction correct? (b) no identical properties? (c) helper functions bug-free? +- [ ] **View function verification:** Do tests call view functions after state changes to verify state? +- [ ] **Revert testing:** Are reverts tested with exact custom error names, not just generic revert? + +### 5. Specific Missing Test Patterns +- [ ] **Full lifecycle test:** register → EB update → fee accrual → liquidate → reactivate → EB update → withdraw → operator withdraw — with concrete balance verification at each step. +- [ ] **Sequential operation tests:** liquidate then remove validators, EB update then withdraw, register then EB decrease. +- [ ] **Stress test:** 13 operators, max fee, 3000 validators, EB=2048, 5-year block advance — verify no overflow. +- [ ] **Cross-module E2E:** commitRoot → updateClusterBalance → fee recalculation with concrete verification. + +### 6. Edge Cases +- [ ] Zero values: 0 validators, 0 balance, 0 fees, 0 operators, 0 staked +- [ ] Max values: 13 operators, 3000 validators/operator, EB=2048 +- [ ] Boundaries: exact liquidation threshold, exact min/max EB, exact cooldown expiry +- [ ] Empty/removed: removed operators, liquidated clusters, 0 cSSV supply +- [ ] Ordering: does operation order matter? (register before deposit, migrate before add) +- [ ] Concurrency: shared operators, same-block operations, EB update + withdraw + +### 7. Write Specific Test Descriptions +For each gap found, write a concrete test description including: +- Test name: `it('should [behavior] when [condition]')` +- Setup: what state to create +- Action: what function to call with what params +- Assertions: what to check (specific values, not just "should work") + +## Output Format + +Write findings to `ssv-review/planning/verified/audit-tests-[SCOPE]-[DATE].md` + +Check `ssv-review/planning/MAINNET-READINESS.md` first — skip already-tracked items. + +Include a **Well-Covered Areas** section documenting what IS tested adequately. + +Use MAINNET-READINESS.md format: [NEW-N] with Type, Priority, Requirement, Context, Acceptance Criteria, Agent Instructions. +TASK +``` + +### Worker 3: Code Quality & Best Practices + +```bash +unset CLAUDECODE && subtask draft audit/quality-[SCOPE] --base-branch [BRANCH] --title "Code quality audit: [SCOPE]" <<'TASK' +You are auditing code quality and best practices for SSV Network v2.0.0. + +## Required Reading +1. `CLAUDE.md` — Code conventions, architecture +2. The scoped contract files: [SCOPE_FILES] +3. `ssv-review/Internal - [DIP-X] SSV Staking.txt` — DIP-X proposal + +## Checks + +### 1. Memory/Storage Patterns (CRITICAL — caught our worst bug) +- [ ] **Flag every `Type memory x = s.field; ...; s.field = x;` pattern** as potentially dangerous. Check if any storage-modifying function is called between read and write-back. +- [ ] **Flag every explicit downcast** (`uint64()`, `uint128()`, `uint192()`) — is there overflow risk? +- [ ] **Flag every `unchecked` block** — is the arithmetic truly safe? + +### 2. Dead Code +- [ ] Unused functions, events, errors, imports, structs +- [ ] Commented-out code (should be removed) +- [ ] TODO/FIXME/HACK comments + +### 3. Code Quality +- [ ] Naming: variables/functions match behavior +- [ ] Patterns: consistent with rest of codebase +- [ ] Duplication: repeated logic that should be shared +- [ ] Gas: redundant SLOADs, unnecessary memory copies, storage→memory→storage roundtrips +- [ ] NatSpec: public/external functions documented + +### 4. Guard Consistency +- [ ] For operations with parallel implementations (normal liquidation vs auto-liquidation, SSV vs ETH paths), compare conditions side by side. Flag any inconsistency. +- [ ] Check that all "is entity removed/dead?" checks use the same condition across all functions. + +### 5. Dead State Cleanup +- [ ] On operator removal: list every storage field. Is each cleared? If not, can it cause issues? +- [ ] On cluster liquidation: what state persists? Can it cause issues on reactivation? +- [ ] Pending operations (fee change requests, unstake requests) — cleaned up on entity removal? +- [ ] Whitelist state — cleaned up on operator removal? + +### 6. Backward Compatibility +- [ ] Event signature changes (breaks oracle: ValidatorAdded, ClusterLiquidated, etc.) +- [ ] Function signature changes (breaks SDK/webapp) +- [ ] Cluster struct changes (breaks everything) +- [ ] Check against oracle ABI dependencies + +### 7. DIP Compliance +- [ ] **Claim-by-claim comparison:** For each DIP section in scope, enumerate every claim. Verdict: MATCH / PARTIAL / MISMATCH / GAP / EXTRA. +- [ ] **Precision/packability validation:** Every DIP-specified numeric value — is it storable in the packed type? (divisible by ETH_DEDUCTED_DIGITS or DEDUCTED_DIGITS) +- [ ] **Check for EXTRA behavior:** Code does more than spec says — is it intentional and safe? + +### 8. Compiler & Dependency Safety +- [ ] Compiler version pinned (not floating `^`) +- [ ] Optimizer settings documented and appropriate +- [ ] OpenZeppelin version current, no known CVEs +- [ ] Import paths match package versions + +### 9. Deployment Script Validation +- [ ] Script function signatures match contract ABIs +- [ ] Constructor arguments correct for all contracts +- [ ] Initializer parameters complete (check quorumBps, defaultOracleIds, cooldownDuration) +- [ ] No hardcoded addresses that differ per chain +- [ ] Scripts don't import from test files + +### 10. Deployment Readiness +- [ ] Contract sizes under 24KB (which are close to limit?) +- [ ] Constructor args correct +- [ ] Initializer version correct (reinitializer(3)) +- [ ] Governance parameters match DIP-X spec + +## Output Format + +Write findings to `ssv-review/planning/verified/audit-quality-[SCOPE]-[DATE].md` + +Check `ssv-review/planning/MAINNET-READINESS.md` first — skip already-tracked items. + +Include a **Already Correct** section documenting areas verified as clean. + +Use MAINNET-READINESS.md format for new findings. +TASK +``` + +## After Workers Complete + +1. Read all 3 output files from `ssv-review/planning/verified/` +2. Present a summary to the user: + - Total new findings by severity + - Key highlights + - Items already tracked in MAINNET-READINESS.md (skipped) + - Verified-safe areas +3. Ask the user if they want to merge new findings into MAINNET-READINESS.md +4. If yes, dispatch a merge worker: + +```bash +unset CLAUDECODE && subtask draft merge/audit-[SCOPE] --base-branch [BRANCH] --title "Merge audit findings for [SCOPE]" <<'TASK' +Read the 3 audit output files: +- ssv-review/planning/verified/audit-security-[SCOPE]-[DATE].md +- ssv-review/planning/verified/audit-tests-[SCOPE]-[DATE].md +- ssv-review/planning/verified/audit-quality-[SCOPE]-[DATE].md + +Read the current: ssv-review/planning/MAINNET-READINESS.md + +For each NEW finding (not already in MAINNET-READINESS.md): +1. Assign a real ID (continue from highest existing: BUG-N, SEC-N, TEST-N, etc.) +2. Append to the correct Type section in MAINNET-READINESS.md +3. Add to the Priority Summary table + +Do NOT remove or rewrite existing items. Only ADD. +Commit the changes. +TASK +``` + +## PR Audit Variant + +When auditing a PR, get the diff first: +```bash +gh pr diff [NUMBER] --name-only +``` +Then use those files as the scope for all 3 workers. Also include: +```bash +gh pr view [NUMBER] --json title,body,commits +``` +as context in each worker's task description. diff --git a/.gitignore b/.gitignore index 33ef6f35..a3661e82 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,14 @@ out/ gas-report.json crytic-export/combined_solc.json + +# subtask working files +.subtask/ + +# ssv-review — keep only mainnet-readiness docs and DIP-X on remote +ssv-review/* +!ssv-review/Internal-[DIP-X]-SSV-Staking.md +!ssv-review/planning +ssv-review/planning/* +!ssv-review/planning/MAINNET-READINESS.md +!ssv-review/planning/verified diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..cbfebc26 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,261 @@ +# CLAUDE.md — SSV Network Smart Contracts + +This file guides Claude Code when working with the SSV Network smart contracts repository. Read this fully before making any changes. + +## Project Overview + +SSV Network is a decentralized Ethereum staking infrastructure using Secret Shared Validators (SSV/DVT). This repository contains the on-chain smart contracts that manage operators, validators, clusters, and protocol economics. + +**Current Release Target: v2.0.0 — "SSV Staking"** + +This release introduces three tightly coupled upgrades: +1. **ETH Payments** — transition from SSV-token fees to native ETH-denominated fees +2. **Effective Balance (EB) Accounting** — fees scale with actual validator effective balance instead of fixed 32 ETH assumption +3. **SSV Staking** — SSV holders stake tokens, receive cSSV, and earn pro-rata ETH protocol revenue + +## Build & Test Commands + +```bash +npm install # Install dependencies +just build # Compile contracts (force recompile) +just test # Run all tests +just test-unit # Run unit tests only (test/unit/) +just test-integration # Run integration tests only (test/integration/) +just test-forked # Run fork tests (requires MAINNET_ETH_NODE_URL in .env) +just coverage # Generate coverage report + HTML output +``` + +**Foundry (for Echidna fuzzing):** +```bash +forge build # Build with Foundry +# Echidna tests are in test/echidna/ +``` + +## Architecture + +### Module System (UUPS Proxy + Delegatecall) + +SSVNetwork.sol is a UUPS upgradeable proxy that routes calls via `delegatecall` to specialized modules: + +``` +SSVNetwork (proxy, UUPS, Ownable2Step) + ├── SSV_OPERATORS → SSVOperators.sol + ├── SSV_CLUSTERS → SSVClusters.sol + ├── SSV_DAO → SSVDAO.sol + ├── SSV_VIEWS → SSVViews.sol (also fallback) + ├── SSV_OPERATORS_WHITELIST → SSVOperatorsWhitelist.sol + ├── SSV_STAKING → SSVStaking.sol + └── SSV_VALIDATORS → SSVValidators.sol +``` + +### Storage Pattern (Diamond/EIP-2535 style) + +All state is stored at deterministic slots via `keccak256(slot) - 1` with inline assembly. **Never add storage variables to module contracts directly** — all state goes through storage libraries. + +| Storage | Slot Key | Purpose | +|---|---|---| +| SSVStorage | `ssv.network.storage.main` | Operators, clusters, validators, module addresses, token | +| SSVStorageProtocol | `ssv.network.storage.protocol` | Fee indices, DAO balances, liquidation params (both SSV and ETH) | +| SSVStorageEB | `ssv.network.storage.eb` | Merkle roots, cluster EB snapshots, oracle voting, operator vUnits | +| SSVStorageStaking | `ssv.network.storage.staking` | Staking state, rewards accumulator, oracles, withdrawal requests | +| SSVStorageReentrancy | `ssv.network.storage.reentrancy` | Custom reentrancy guard status | + +### Dual Cluster System + +The protocol maintains two parallel cluster records during the transition period: +- `s.clusters[hash]` — legacy SSV-denominated clusters (VERSION_SSV = 0) +- `s.ethClusters[hash]` — new ETH-denominated clusters (VERSION_ETH = 1) + +Each operator tracks dual snapshots: SSV (`.snapshot`, `.fee`, `.validatorCount`) and ETH (`.ethSnapshot`, `.ethFee`, `.ethValidatorCount`). + +### Packed Types (Critical for Precision) + +``` +PackedSSV (uint64): actual_value = raw * 10_000_000 (DEDUCTED_DIGITS) +PackedETH (uint64): actual_value = raw * 100_000 (ETH_DEDUCTED_DIGITS) +``` + +Values not divisible by the precision factor revert with `MaxPrecisionExceeded`. + +## Key Accounting Rules + +### ETH Cluster Fee Calculation (vUnit Model) + +``` +vUnits = ceil(effectiveBalanceETH * 10_000 / 32) +operatorFee = blockDiff * ethFee * effectiveVUnits / VUNITS_PRECISION +networkFee = (networkFeeIndexDelta * effectiveVUnits) / VUNITS_PRECISION +totalFees = (operatorFeeUnits + networkFeeUnits) * ETH_DEDUCTED_DIGITS +cluster.balance -= totalFees +``` + +- Implicit EB (default): `vUnits = validatorCount * 10_000` (assumes 32 ETH/validator) +- Explicit EB: set after first `updateClusterBalance` oracle update + +### SSV Cluster Fee Calculation (Legacy) + +``` +fees = (operatorIndexDelta + networkFeeIndexDelta) * validatorCount +cluster.balance -= unpack(fees) +``` + +### ETH Liquidation Check + +``` +liquidatable IF: + balance < minimumLiquidationCollateral (0.00094 ETH) + OR balance < minimumBlocksBeforeLiquidation * (burnRate + networkFee) * vUnits / VUNITS_PRECISION * ETH_DEDUCTED_DIGITS +``` + +### Staking Rewards (Accumulator Pattern) + +``` +accEthPerShare += (newFeesWei * 1e18) / totalCSSVSupply +pendingReward = cSSVBalance * (accEthPerShare - userIndex) / 1e18 +``` + +Rewards settle on: stake, requestUnstake, claimEthRewards, cSSV transfer (via onCSSVTransfer hook). + +## Governance Parameters (DIP-X Proposed Values) + +| Parameter | Value | Update Function | +|---|---|---| +| ethNetworkFee | 0.000000003550900000 ETH/block (~0.00928 ETH/year) | `updateNetworkFee(uint256)` | +| minimumLiquidationCollateral | 0.00094 ETH | `updateMinimumLiquidationCollateral(uint256)` | +| minimumBlocksBeforeLiquidation | 50190 (~7 days) | `updateLiquidationThresholdPeriod(uint64)` | +| defaultOperatorETHFee | 0.000000001775400000 ETH/block (~0.00464 ETH/year) | Hardcoded in contract | +| cooldownDuration | 604,800 seconds (7 days) | `setUnstakeCooldownDuration(uint64)` | +| quorumBps | 7500 (75%) | `setQuorumBps(uint16)` | +| Oracle set | 4 oracles, 3-of-4 threshold | `replaceOracle(uint32, address)` | + +## Security Rules — MUST Follow + +### Reentrancy +- All functions that transfer ETH or tokens MUST use the `nonReentrant` modifier +- The custom reentrancy guard lives at a deterministic storage slot (NOT inherited state) +- Currently protected: `liquidate`, `liquidateSSV`, `withdraw`, `updateClusterBalance`, all operator withdrawals, all staking functions, `withdrawNetworkSSVEarnings` +- Intentionally NOT protected (no external calls before state writes): `reactivate`, `deposit`, `migrateClusterToETH`, validator register/remove + +### Storage Safety +- NEVER add storage variables to module contracts — use the diamond storage pattern +- NEVER modify existing storage struct field order — append only +- When adding new storage fields, add them at the END of the struct +- Verify storage slot computation matches the pattern: `keccak256(abi.encode(SLOT_STRING)) - 1` + +### Access Control +- Owner-only functions are enforced at the SSVNetwork proxy level (Ownable2Step), not in modules +- Oracle-only: `commitRoot` checks `oracleIdOf[msg.sender] != 0` +- cSSV-only: `onCSSVTransfer` checks `msg.sender == CSSV_ADDRESS` +- Operator owner: `operator.checkOwner()` verifies `msg.sender == operator.owner` +- Cluster owner: keyed by `keccak256(owner, operatorIds)` — only owner can call cluster management functions + +### Upgrade Safety +- UUPS pattern — `_authorizeUpgrade` is owner-only +- New initializers use `reinitializer(N)` (current: N=3 for v2.0.0) +- `UPGRADE_TIMESTAMP` immutable in SSVOperators prevents pre-migration fee declarations from being executed post-migration + +### Integer Overflow/Precision +- All fee calculations use packed types — be aware of precision loss from packing/unpacking +- vUnit conversions use ceiling division for ETH→vUnits, floor for vUnits→ETH +- Cluster balance underflow: use `max(0, balance - fees)` pattern, never allow negative + +### Oracle Security +- Merkle proofs use OpenZeppelin's double-hash convention: `keccak256(keccak256(abi.encode(clusterID, effectiveBalance)))` +- EB limits enforced: min 32 ETH/validator, max 2048 ETH/validator +- Block numbers must be strictly monotonically increasing (`blockNum > latestCommittedBlock`) +- Quorum is weighted by equal cSSV splits across oracle slots + +## Backward Compatibility (Critical) + +Any changes to events or function signatures can break external integrations (oracle, liquidator bots, SDK, webapp). Before modifying: + +1. **Events**: The SSV Oracle (`github.com/ssvlabs/ssv-oracle`) subscribes to: `ValidatorAdded`, `ValidatorRemoved`, `ClusterLiquidated`, `ClusterReactivated`, `ClusterWithdrawn`, `ClusterDeposited`, `ClusterMigratedToETH`, `ClusterBalanceUpdated`, `RootCommitted`, `WeightedRootProposed`. Changing these signatures requires oracle client updates. + +2. **Function signatures**: `registerValidator`, `bulkRegisterValidator`, `deposit`, `reactivate` have already changed (removed `amount` param, added `payable`). The `getBalance` view now returns `(uint256 balance, uint256 ebBalance)` instead of just `uint256`. + +3. **Cluster struct**: `(uint32 validatorCount, uint64 networkFeeIndex, uint64 index, bool active, uint256 balance)` — changing this struct breaks ALL event decoding and function calls. + +4. When in doubt, check the oracle repo at `github.com/ssvlabs/ssv-oracle` for ABI dependencies. + +## Working Branch & Git Workflow + +- **Working branch**: `ssv-staking` (contains all v2.0.0 changes) +- **Create feature branches off `ssv-staking`** for each task, then PR back +- Follow existing commit message conventions in the repo + +## Project Structure + +``` +contracts/ +├── SSVNetwork.sol # UUPS proxy + routing +├── SSVNetworkViews.sol # Read-only views contract +├── SSVProxy.sol # Delegatecall base +├── abstract/SSVReentrancyGuard.sol # Custom reentrancy guard +├── interfaces/ # All interfaces (ISSVClusters, ISSVDAO, ISSVStaking, etc.) +├── libraries/ +│ ├── ClusterLib.sol # Cluster operations (balance, liquidation, hashing) +│ ├── OperatorLib.sol # Operator operations (snapshots, fees, validation) +│ ├── ValidatorLib.sol # Validator registration/removal logic +│ ├── ProtocolLib.sol # Protocol-level accounting (DAO, indices) +│ ├── CoreLib.sol # Token transfers, module management +│ ├── SSVPackedLib.sol # Packed type packing/unpacking +│ ├── SSVCoreTypes.sol # Type definitions (PackedSSV, PackedETH, Snapshot, etc.) +│ └── storage/ # Diamond storage structs +├── modules/ +│ ├── SSVClusters.sol # Cluster lifecycle (deposit, withdraw, liquidate, migrate, updateEB) +│ ├── SSVDAO.sol # Governance, oracle management, fee params +│ ├── SSVOperators.sol # Operator management, fee changes, earnings withdrawal +│ ├── SSVOperatorsWhitelist.sol # Whitelist management (bitmap + contracts) +│ ├── SSVStaking.sol # SSV staking, cSSV rewards, unstaking +│ ├── SSVValidators.sol # Validator register/remove/exit +│ └── SSVViews.sol # View function implementations +├── token/ +│ ├── SSVToken.sol # SSV ERC-20 token +│ └── CSSVToken.sol # cSSV receipt token (mint/burn by SSVStaking only) +├── whitelisting/BasicWhitelisting.sol +└── upgrades/stage/hoodi/ # Upgrade initializer (reinitializer(3)) +scripts/ # Deployment & upgrade scripts (TypeScript) +test/ +├── unit/ # Per-module unit tests +├── integration/ # Full integration tests +├── sanity/ # Sanity/regression tests +├── echidna/ # Foundry-based fuzzing +├── test-forked/ # Fork tests against v1.2.0 +├── helpers/ # Test utilities +├── common/ # Constants, errors, events, types +└── setup/ # Deploy, fixtures, fork setup +``` + +## Key Constants + +``` +VUNITS_PRECISION = 10_000 +MAX_EB_PER_VALIDATOR = 2048 ETH +DEFAULT_EB_PER_VALIDATOR = 32 ETH +ETH_DEDUCTED_DIGITS = 100_000 +DEDUCTED_DIGITS = 10_000_000 +DEFAULT_OPERATOR_ETH_FEE = 1_770_000_000 wei (1.77 gwei/vUnit/block) +MINIMAL_LIQUIDATION_THRESHOLD = 21_480 blocks +MAX_PENDING_REQUESTS = 2000 +MINIMAL_STAKING_AMOUNT = 1_000_000_000 +MAX_DELEGATION_SLOTS = 4 +VERSION_SSV = 0 +VERSION_ETH = 1 +``` + +## Reference Documentation + +- **docs/SPEC.md** — Full DIP-X specification with detailed accounting formulas, storage layout, and all function/event signatures +- **docs/FLOWS.md** — Step-by-step contract flows with state mutations, invariants, and sequence diagrams +- **ssv-review/** — Original proposal documents and mainnet readiness coverage report + +## Test Expectations + +When writing tests: +- Use the existing test helper patterns in `test/helpers/contract-helpers.ts` +- Follow the Mocha + Chai + ethers v6 patterns used in existing tests +- Include both happy path and revert/edge case tests +- Verify event emissions with exact parameter matching +- Check balance invariants before and after operations (contract ETH balance, SSV token balance, operator earnings, cluster balances) +- For migration tests: verify both SSV balance refund AND ETH deposit correctness +- For staking tests: verify accEthPerShare accumulator math with precision diff --git a/Justfile b/Justfile index 1eb34644..3f67d43e 100644 --- a/Justfile +++ b/Justfile @@ -10,6 +10,18 @@ clean: test: NO_GAS_ENFORCE=true npx hardhat test +# Run unit tests only (test/unit/) +test-unit: + NO_GAS_ENFORCE=true npx hardhat test $(find test/unit -name "*.test.ts" | xargs) + +# Run integration tests only (test/integration/) +test-integration: + NO_GAS_ENFORCE=true npx hardhat test $(find test/integration -maxdepth 1 -name "*.test.ts" | xargs) + +# Run fork tests against mainnet state (requires MAINNET_ETH_NODE_URL in .env) +test-forked: + NO_GAS_ENFORCE=true RUN_FORK=true npx hardhat test $(find test/test-forked -name "*.test.ts" | xargs) + # Run tests with coverage report, then generate HTML report coverage: COVERAGE=true npx hardhat test --coverage diff --git a/contracts/interfaces/ISSVDAO.sol b/contracts/interfaces/ISSVDAO.sol index 86eff144..f7068c6e 100644 --- a/contracts/interfaces/ISSVDAO.sol +++ b/contracts/interfaces/ISSVDAO.sol @@ -94,7 +94,7 @@ interface ISSVDAO is ISSVNetworkCore { /** * @dev Emitted when the unstake cooldown duration is updated - * @param newCooldownDuration The new duration + * @param newCooldownDuration The new duration in seconds */ event CooldownDurationUpdated(uint64 newCooldownDuration); @@ -204,7 +204,7 @@ interface ISSVDAO is ISSVNetworkCore { /** * @notice Sets the unstake cooldown duration - * @param duration The new duration + * @param duration The new duration in seconds */ function setUnstakeCooldownDuration(uint64 duration) external; diff --git a/contracts/libraries/storage/SSVStorageStaking.sol b/contracts/libraries/storage/SSVStorageStaking.sol index d8402620..5ee0f04c 100644 --- a/contracts/libraries/storage/SSVStorageStaking.sol +++ b/contracts/libraries/storage/SSVStorageStaking.sol @@ -13,6 +13,7 @@ struct UnstakeRequest { } struct StorageStaking { + /// @notice Unstake cooldown duration in seconds uint64 cooldownDuration; /// @notice Total ETH-denominated rewards (shrunk) allocated to the staking pool PackedETH stakingEthPoolBalance; diff --git a/contracts/upgrades/stage/hoodi/SSVNetworkSSVStakingUpgrade.sol b/contracts/upgrades/stage/hoodi/SSVNetworkSSVStakingUpgrade.sol index bf94c686..90e2c155 100644 --- a/contracts/upgrades/stage/hoodi/SSVNetworkSSVStakingUpgrade.sol +++ b/contracts/upgrades/stage/hoodi/SSVNetworkSSVStakingUpgrade.sol @@ -5,6 +5,9 @@ import "../../../SSVNetwork.sol"; import {MAX_DELEGATION_SLOTS} from "../../../libraries/storage/SSVStorageStaking.sol"; contract SSVNetworkSSVStakingUpgrade is SSVNetwork { + /// @notice One-time initializer for the SSV Staking upgrade + /// @param cooldownDuration Unstake cooldown duration in seconds (e.g. 604800 for 7 days) + /// @param defaultOracleIds Default oracle IDs for new delegations function initializeSSVStaking( uint64 cooldownDuration, uint32[MAX_DELEGATION_SLOTS] memory defaultOracleIds, diff --git a/deployments/params-candidate.json b/deployments/params-candidate.json new file mode 100644 index 00000000..9ab79cef --- /dev/null +++ b/deployments/params-candidate.json @@ -0,0 +1,11 @@ +{ + "networkFeeEth": "3550900000", + "minimumLiquidationCollateralEth": "940000000000000", + "liquidationThresholdPeriod": "35800", + "minOperatorEthFee": "1065200000", + "maxOperatorEthFee": "5326300000", + "defaultOperatorEthFee": "1770000000", + "quorumBps": 7500, + "cooldownDuration": 604800, + "defaultOracleIds": [1, 2, 3, 4] +} diff --git a/docs/FLOWS.md b/docs/FLOWS.md new file mode 100644 index 00000000..1b6566d9 --- /dev/null +++ b/docs/FLOWS.md @@ -0,0 +1,1016 @@ +# SSV Network v2.0.0 — Contract Flows + +This document is the **implementation verification checklist** for the SSV Staking upgrade (v2.0.0). It describes every contract flow with preconditions, step-by-step state mutations, events, postconditions, and invariants. For design intent, rules, and accounting formulas, see [SPEC.md](./SPEC.md). + +| Document | Purpose | +|---|---| +| **SPEC.md** | Design intent · rules · formulas · invariants · source of truth | +| **FLOWS.md** (this file) | Step-by-step execution · preconditions · state mutations · test checklist | + +## Table of Contents + +1. [Cluster Flows](#1-cluster-flows) + - [Register Validator (ETH)](#11-register-validator-eth) + - [Bulk Register Validators (ETH)](#12-bulk-register-validators-eth) + - [Remove Validator](#13-remove-validator) + - [Bulk Remove Validators](#14-bulk-remove-validators) + - [Exit Validator](#15-exit-validator) + - [Bulk Exit Validators](#16-bulk-exit-validators) + - [Deposit ETH](#17-deposit-eth) + - [Withdraw ETH](#18-withdraw-eth) + - [Liquidate (ETH)](#19-liquidate-eth) + - [Liquidate (SSV Legacy)](#110-liquidate-ssv-legacy) + - [Reactivate](#111-reactivate) +2. [Migration Flows](#2-migration-flows) + - [Migrate Cluster to ETH](#21-migrate-cluster-to-eth) +3. [Effective Balance Flows](#3-effective-balance-flows) + - [Commit Root (Oracle)](#31-commit-root-oracle) + - [Update Cluster Balance](#32-update-cluster-balance) +4. [Operator Flows](#4-operator-flows) + - [Register Operator](#41-register-operator) + - [Remove Operator](#42-remove-operator) + - [Declare Operator Fee](#43-declare-operator-fee) + - [Execute Operator Fee](#44-execute-operator-fee) + - [Reduce Operator Fee](#45-reduce-operator-fee) + - [Cancel Declared Operator Fee](#46-cancel-declared-operator-fee) + - [Withdraw Operator Earnings (ETH)](#47-withdraw-operator-earnings-eth) + - [Withdraw Operator Earnings (SSV)](#48-withdraw-operator-earnings-ssv) +5. [Staking Flows](#5-staking-flows) + - [Stake SSV](#51-stake-ssv) + - [Request Unstake](#52-request-unstake) + - [Withdraw Unlocked](#53-withdraw-unlocked) + - [Claim ETH Rewards](#54-claim-eth-rewards) + - [Sync Fees](#55-sync-fees) +6. [DAO Governance Flows](#6-dao-governance-flows) + - [Update Network Fee](#61-update-network-fee) + - [Replace Oracle](#62-replace-oracle) +- [Global Invariants](#global-invariants) + +--- + +## Global Invariants + +### ETH Contract Balance Accounting Invariant + +``` +address(this).balance == Σ(cluster.balance) + Σ(operator.ethEarnings) + ethDaoBalance + stakingEthPoolBalance +``` + +This invariant holds by construction across all ETH flows. If accounting is correct, every `cluster.balance` is always ≤ `address(this).balance` — no explicit contract-balance guard is needed in `withdraw`. A violation indicates a protocol bug, not a user error. + +--- + +## 1. Cluster Flows + +### 1.1 Register Validator (ETH) + +**Caller:** Cluster owner (or new cluster creator) +**Payable:** Yes (msg.value = ETH to deposit) + +#### Preconditions +- Public key length must be valid (48 bytes) +- Validator must not already exist +- Operator IDs must be sorted ascending, length 4–13 +- All operators must exist and not be removed +- If operators are private, caller must be whitelisted +- If cluster doesn't exist, this creates a new ETH cluster +- If cluster exists, it must be an ETH cluster (VERSION_ETH) +- Cluster must be active (not liquidated) + +#### State Mutations +1. For each operator: + - Update ETH snapshot (accumulate earnings) + - Increment `operator.ethValidatorCount` + - If first ETH interaction: `ensureETHDefaults()` sets ethFee and ethSnapshot.block +2. Store validator: `validatorPKs[hash(pubkey, owner)] = hash(operatorIds | active=true)` +3. Update cluster state: + - `cluster.validatorCount++` + - `cluster.balance += msg.value` + - `cluster.index = current cumulative operator ETH index` + - `cluster.networkFeeIndex = current ETH network fee index` +4. Update DAO: `ethDaoValidatorCount++`, `daoTotalEthVUnits += VUNITS_PRECISION` — baseline EB of 32 ETH per validator is always applied here for all ETH clusters +5. If cluster has explicit EB (oracle has previously submitted an EB update): also update `ebSnapshot.vUnits` to include the new validators' baseline. Operator and DAO deviation vUnits are NOT updated — new validators start at exactly 32 ETH so their deviation is zero +6. Store cluster hash in `ethClusters` +7. Liquidation check: cluster must not be liquidatable after registration + +#### Events +```solidity +emit ValidatorAdded(owner, operatorIds, publicKey, shares, cluster); +``` + +#### Postcondition Invariants +- `contract.balance == previous_contract_balance + msg.value` +- `operator.ethValidatorCount == previous + 1` for each operator +- `ethDaoValidatorCount == previous + 1` +- Cluster is not liquidatable +- Validator is retrievable via `getValidator(owner, publicKey)` + +--- + +### 1.2 Bulk Register Validators (ETH) + +Same as 1.1 but for multiple validators in one transaction. Each validator emits a separate `ValidatorAdded` event. `msg.value` is added to cluster balance once (not per validator). + +#### Additional Invariants +- `contract.balance == previous + msg.value` (single ETH deposit) +- `operator.ethValidatorCount == previous + N` for each operator (N = number of validators) +- `ethDaoValidatorCount == previous + N` + +--- + +### 1.3 Remove Validator + +**Caller:** Cluster owner + +#### Preconditions +- Validator must exist and be owned by caller +- Cluster must exist as ETH cluster (VERSION_ETH) +- Operator IDs must match the registered operator set + +#### State Mutations (ETH cluster) +1. Update operator ETH snapshots +2. Decrement `operator.ethValidatorCount` +3. Delete validator record +4. Update cluster: + - `cluster.validatorCount--` + - Settle fees up to current block + - Update indices +5. Update DAO: `ethDaoValidatorCount--`, reduce vUnits +6. If last validator removed: cluster balance remains (can withdraw later) + +#### Events +```solidity +emit ValidatorRemoved(owner, operatorIds, publicKey, cluster); +``` + +#### Postcondition Invariants +- `operator.ethValidatorCount == previous - 1` +- `ethDaoValidatorCount == previous - 1` +- Validator no longer retrievable +- Cluster balance reflects settled fees + +--- + +### 1.4 Bulk Remove Validators + +**Caller:** Cluster owner + +Same as 1.3 but removes multiple validators in one transaction. All validators must belong to the same cluster (same operator set). Each validator emits a separate `ValidatorRemoved` event. Cluster fee settlement and DAO accounting happen once for the full batch. + +#### Additional Invariants vs 1.3 +- `operator.ethValidatorCount == previous - N` for each operator (N = validators removed) +- `ethDaoValidatorCount == previous - N` +- `cluster.validatorCount == previous - N` +- If cluster had explicit EB tracking (`ebSnapshot.vUnits > 0`): `ebSnapshot.vUnits -= N * VUNITS_PRECISION` +- If `cluster.validatorCount` reaches 0 and cluster is active: any remaining deviation vUnits are cleaned from `operatorEthVUnits` and DAO + +--- + +### 1.5 Exit Validator + +**Caller:** Cluster owner +**nonReentrant:** No +**Payable:** No + +#### Preconditions +- Validator must exist and be owned by caller +- Validator must be registered with the given operator set (state check via `validateCorrectState`) + +#### State Mutations +None — `exitValidator` is a pure signal (event emission). No on-chain state is modified. + +#### Events +```solidity +emit ValidatorExited(owner, operatorIds, publicKey); +``` + +#### Postcondition Invariants +- No storage state changes +- Event is emitted; SSV oracle nodes observe it and initiate voluntary exit on the beacon chain +- Validator record remains in storage until `removeValidator` is called + +> **Note:** Exit is a two-step off-chain process. `exitValidator` signals intent; the actual beacon-chain exit is performed by the SSV nodes network upon observing the event. The cluster continues to accrue fees until `removeValidator` is called. + +--- + +### 1.6 Bulk Exit Validators + +**Caller:** Cluster owner +**nonReentrant:** No +**Payable:** No + +Same as 1.5 but signals exit for multiple validators in one transaction. All validators must belong to the same operator set. Each validator emits a separate `ValidatorExited` event. + +#### Preconditions +- `publicKeys.length > 0` (empty list reverts with `ValidatorDoesNotExist`) +- Each validator must exist and be owned by caller with the given operator set + +#### State Mutations +None — pure signal, identical to 1.5 per validator. + +#### Events +```solidity +// emitted once per validator +emit ValidatorExited(owner, operatorIds, publicKeys[i]); +``` + +#### Postcondition Invariants +- No storage state changes +- N `ValidatorExited` events emitted (one per validator) +- All validator records remain in storage until `bulkRemoveValidator` is called + +--- + +### 1.7 Deposit ETH + +**Caller:** Anyone (on behalf of cluster owner) +**Payable:** Yes + +#### Preconditions +- Cluster must exist as ETH cluster (VERSION_ETH) + +> **Note — deposits allowed on liquidated clusters:** `deposit` does not require the cluster to be active. Depositing to a liquidated cluster, and later reactivating it, will accumulate both the deposit and the reactivation amount. + +#### State Mutations +1. `cluster.balance += msg.value` +2. Update stored cluster hash + +#### Events +```solidity +emit ClusterDeposited(owner, operatorIds, msg.value, cluster); +``` + +#### Postcondition Invariants +- `contract.balance == previous_contract_balance + msg.value` +- `cluster.balance == previous_settled_balance + msg.value` +- Cluster state hash is updated + +--- + +### 1.8 Withdraw ETH + +**Caller:** Cluster owner +**nonReentrant:** Yes + +#### Preconditions +- Cluster must exist as ETH cluster (VERSION_ETH) +- `amount <= cluster.balance` (after fee settlement if active) +- If cluster is active and has validators: cluster must not become liquidatable after withdrawal + +> **Note — withdrawal allowed on liquidated clusters:** `withdraw` does not require the cluster to be active. A liquidated cluster may have received deposits (via `deposit`) in preparation for reactivation. If the owner decides not to reactivate, they can recover those funds via `withdraw`. +> +> **Note — operator removal and reactivation:** If one or more operators in a cluster's operator set have been removed (via `removeOperator`), the cluster can still be reactivated, but removed operators are silently skipped during `updateClusterOperatorsOnReactivation` (see `OperatorLib.sol:311`). The cluster will operate with reduced operator coverage (e.g., 3/4 instead of 4/4), which may compromise the cluster's fault tolerance. The reactivation fee calculation excludes removed operators' fees. No on-chain event signals which operators were skipped, but this is detectable off-chain by checking operator states before reactivation. + +#### State Mutations +1. If cluster is active: update operator snapshots and settle cluster fees +2. `cluster.balance -= amount` +3. If cluster is active and has validators: liquidation check +4. Update stored cluster hash +5. Transfer `amount` ETH to caller + +#### Events +```solidity +emit ClusterWithdrawn(owner, operatorIds, amount, cluster); +``` + +#### Postcondition Invariants +- `cluster.balance == previous_settled_balance - amount` +- `owner.balance == previous_owner_balance + amount` +- If cluster is active and has validators: cluster is not liquidatable + +> **Accounting invariant:** See [Global Invariants — ETH Contract Balance Accounting Invariant](#eth-contract-balance-accounting-invariant). + +--- + +### 1.9 Liquidate (ETH) + +**Caller:** Anyone (self-liquidation always allowed; third-party only if cluster is liquidatable) +**nonReentrant:** Yes + +#### Preconditions +- Cluster must exist as ETH cluster (VERSION_ETH) +- Cluster must be active +- If caller != owner: cluster must be liquidatable (balance below threshold) + +#### State Mutations +1. Update operator snapshots with fee settlement +2. Decrement `operator.ethValidatorCount` for each operator +3. Reduce operators' effective balance (EB) tracking: decrement `operator.vUnits` by cluster's vUnits +4. Compute liquidation bounty = remaining cluster balance +5. Set cluster state: `active = false, balance = 0, index = 0, networkFeeIndex = 0` +6. Update DAO: `ethDaoValidatorCount -= cluster.validatorCount`, reduce DAO vUnits and EB tracking +7. Update stored cluster hash +8. Transfer bounty ETH to caller (liquidator) + +#### Events +```solidity +emit ClusterLiquidated(owner, operatorIds, cluster); +``` + +#### Postcondition Invariants +- `cluster.active == false` +- `cluster.balance == 0` +- `operator.ethValidatorCount` decreased by cluster's validator count +- `ethDaoValidatorCount` decreased +- Liquidator received bounty ETH +- `contract.balance == previous - bounty` + +--- + +### 1.10 Liquidate (SSV Legacy) + +Same flow as 1.9 but for SSV clusters. Uses `s.clusters` instead of `s.ethClusters`. SSV balance transferred via SSV token transfer (not ETH). + +--- + +### 1.11 Reactivate + +**Caller:** Cluster owner +**Payable:** Yes (msg.value = ETH deposit) + +#### Preconditions +- Cluster must exist as ETH cluster +- Cluster must be liquidated (`active == false`) + + +> **Note — Stale EB risk:** The solvency check uses the stored `clusterEB.vUnits` snapshot, which may be stale if the beacon-chain EB changed during liquidation. Ref: SPEC §2 "Stale EB Risk on Reactivation" for full analysis and mitigation options. + +#### State Mutations +1. Update operator ETH snapshots +2. Increment `operator.ethValidatorCount` for each operator +3. Increase operators' effective balance (EB) tracking: increment `operator.vUnits` by cluster's vUnits +4. Set cluster: `active = true, balance = msg.value, index = current, networkFeeIndex = current` +5. Update DAO: `ethDaoValidatorCount += cluster.validatorCount`, add DAO vUnits and increase EB tracking +6. Liquidation check: must not be immediately liquidatable (uses stored `clusterEB.vUnits`) +7. Update stored cluster hash + +#### Events +```solidity +emit ClusterReactivated(owner, operatorIds, cluster); +``` + +#### Postcondition Invariants +- `cluster.active == true` +- `cluster.balance += msg.value` +- `contract.balance == previous + msg.value` +- Cluster is not liquidatable + +--- + +## 2. Migration Flows + +### 2.1 Migrate Cluster to ETH + +**Caller:** Cluster owner +**Payable:** Yes (msg.value = ETH for new cluster balance) + +#### Preconditions +- Cluster must exist in `s.clusters` (VERSION_SSV) +- Cluster can be active or liquidated — if liquidated, migration also reactivates it +- Caller must be cluster owner +- msg.value must be sufficient to pass ETH liquidation check + +#### State Mutations + +1. **Operator migration (for each operator):** + - Update SSV snapshot (accumulate final SSV earnings) + - Decrement `operator.validatorCount` (SSV count) — skip if cluster was liquidated + - If first ETH interaction: `ensureETHDefaults()` (set ethFee, ethSnapshot.block) + - Else: update ETH snapshot + - Increment `operator.ethValidatorCount` + +2. **Settle SSV balance:** + - Compute remaining SSV balance after fees + - Store as `ssvClusterBalance` for refund + +3. **Set up ETH cluster:** + - `cluster.balance = msg.value` + - `cluster.active = true` + - `cluster.index = cumulative ETH operator index` + - `cluster.networkFeeIndex = current ETH network fee index` + +4. **DAO accounting:** + - If NOT previously liquidated: `sp.updateDAOSSV(false, validatorCount)` (reduce SSV DAO count) + - Always: `sp.updateDAO(true, validatorCount)` (increase ETH DAO count + baseline vUnits) + +5. **Liquidation check:** Verify ETH cluster is not liquidatable + +6. **Store & delete:** + - `s.ethClusters[key] = cluster.hashClusterData()` + - `delete s.clusters[key]` + +7. **EB deviation sync (if applicable):** + - If cluster had explicit EB snapshot with vUnits > baseline: + - Add deviation to `sp.daoTotalEthVUnits` + - Add deviation to each `seb.operatorEthVUnits[operatorId]` + +8. **Refund SSV:** Transfer remaining SSV balance to owner + +#### Events +```solidity +emit ClusterMigratedToETH(owner, operatorIds, msg.value, ssvRefunded, effectiveBalance, cluster); + +// If the SSV cluster was liquidated, migration also reactivates it: +if (isLiquidated) emit ClusterReactivated(owner, operatorIds, cluster); +``` + +#### Postcondition Invariants +- `s.clusters[key]` is deleted (no longer exists as SSV cluster) +- `s.ethClusters[key]` exists with new ETH cluster data +- `cluster.active == true` +- `cluster.balance == msg.value` +- `contract.balance == previous_contract_balance + msg.value` +- `owner SSV balance == previous + ssvRefunded` +- `operator.validatorCount` decreased (SSV), `operator.ethValidatorCount` increased (ETH) — net zero change in total validators +- `ethDaoValidatorCount` increased, `daoValidatorCount` decreased (unless was liquidated) +- Cluster is not liquidatable under ETH rules +- SSV cluster record is completely removed + +--- + +## 3. Effective Balance Flows + +### 3.1 Commit Root (Oracle) + +**Caller:** Registered oracle only + +#### Preconditions +- `oracleIdOf[msg.sender] != 0` +- `blockNum > latestCommittedBlock` (strictly monotonic) +- `blockNum <= block.number` (not future) +- `cSSV.totalSupply() > 0` (staking is active) +- Oracle has not already voted for this `(blockNum, merkleRoot)` pair + +#### State Mutations + +1. Mark oracle as voted: `hasVoted[commitmentKey][oracleId] = true` +2. Compute weight: `weight = totalCSSVSupply / defaultOracleIds.length` +3. Accumulate: `rootCommitments[commitmentKey] += weight` +4. Compute threshold: `threshold = (totalCSSVSupply * quorumBps) / 10_000` +5. **If quorum reached** (`accumulatedWeight >= threshold`): + - Store root: `ebRoots[blockNum] = merkleRoot` + - Update: `latestCommittedBlock = blockNum` + - Cleanup: `delete rootCommitments[commitmentKey]` + - **Note:** `hasVoted` mappings are intentionally NOT deleted to prevent re-voting on the same key +6. **If quorum not reached**: no root storage, no cleanup — see SPEC §4 "Failed Quorum Behavior" for full persistence rules + +#### Events +```solidity +// If quorum reached: +emit RootCommitted(merkleRoot, blockNum); + +// If quorum not reached: +emit WeightedRootProposed(merkleRoot, blockNum, accumulatedWeight, quorum, oracleId, oracle); +``` + +#### Postcondition Invariants +- If quorum reached: `ebRoots[blockNum] == merkleRoot`, `latestCommittedBlock == blockNum`, `rootCommitments[commitmentKey]` deleted +- If quorum NOT reached: storage persists — ref SPEC §4 "Failed Quorum Behavior" +- Oracle cannot vote again for same `(blockNum, merkleRoot)`; can vote same `blockNum` with different root +- Total votes for this commitment <= oracle count + +--- + +### 3.2 Update Cluster Balance + +**Caller:** Anyone (permissionless) +**nonReentrant:** Yes + +#### Preconditions +- Committed root exists for `blockNum`: `ebRoots[blockNum] != bytes32(0)` +- Update frequency check: `block.number >= lastUpdateBlock + minBlocksBetweenUpdates` +- Staleness check: `blockNum > lastRootBlockNum` (strictly increasing) +- Merkle proof valid: `verify(proof, ebRoots[blockNum], doubleHash(clusterId, effectiveBalance))` +- EB limits: `32 * validatorCount <= effectiveBalance <= 2048 * validatorCount` +- Cluster must exist (ETH or SSV) + +> **Note — Liquidated clusters:** The EB snapshot is **always updated** regardless of cluster state; fee/accounting steps are skipped when `cluster.active == false`. Ref: SPEC §4 "Behavior on liquidated clusters" for full rules and use cases. + +#### State Mutations (ETH Cluster) + +1. Convert `effectiveBalance` to `newVUnits = ebToVUnits(effectiveBalance)` +2. Compute `effectiveOldVUnits`: + - If `storedVUnits == 0`: `validatorCount * VUNITS_PRECISION` + - Else: `storedVUnits` +3. If cluster active: settle operator and network fees using OLD vUnits +4. If `newVUnits != effectiveOldVUnits` AND cluster active: + - For each operator: `operatorEthVUnits[opId] += (newVUnits - effectiveOldVUnits)` — **full delta applied to every operator, no division by operator count** + - `daoTotalEthVUnits += (newVUnits - effectiveOldVUnits)` +5. Update EB snapshot: `{vUnits: newVUnits, lastRootBlockNum: blockNum, lastUpdateBlock: block.number}` +6. **Auto-liquidation check** (active clusters only): if cluster now undercollateralized: + - Liquidate immediately (same as liquidate flow) + - Bounty goes to `msg.sender` (updater) +7. If not liquidated: store updated cluster hash + +#### State Mutations (SSV Cluster) +- Only stores EB snapshot: `{vUnits: newVUnits, lastRootBlockNum: blockNum, lastUpdateBlock: block.number}` +- **No balance/fee updates**: SSV clusters continue using `validatorCount`-based accounting (see section 1.10) +- **No vUnit deviation tracking**: operator and DAO vUnit deviations are NOT updated for SSV clusters +- Prepares data for future migration to ETH (see section 2.1) + +#### Events +```solidity +emit ClusterBalanceUpdated(owner, operatorIds, blockNum, effectiveBalance, cluster); + +// If auto-liquidated: +emit ClusterLiquidated(owner, operatorIds, cluster); +``` + +#### Postcondition Invariants +- `clusterEB[clusterId].vUnits == newVUnits` +- `clusterEB[clusterId].lastRootBlockNum == blockNum` +- `clusterEB[clusterId].lastUpdateBlock == block.number` +- If EB increased: future fee accrual is higher +- If EB decreased: future fee accrual is lower +- Sum of all `operatorEthVUnits` deviations + baselines == `daoTotalEthVUnits` +- If auto-liquidated: `cluster.active == false`, bounty transferred to caller + +--- + +## 4. Operator Flows + +### 4.1 Register Operator + +**Caller:** Anyone + +#### Preconditions +- Public key must not already be registered +- Fee must be divisible by ETH_DEDUCTED_DIGITS (100,000) +- Fee must be within `[minimumOperatorEthFee, operatorMaxFee]` + +#### State Mutations +1. Increment `lastOperatorId` +2. Store operator: `{owner: msg.sender, ethFee: packed(fee), ethSnapshot: {block: block.number, index: 0, balance: 0}}` +3. Store public key mapping +4. If `setPrivate`: mark operator as whitelisted + +#### Events +```solidity +emit OperatorAdded(operatorId, msg.sender, publicKey, fee); +if (setPrivate) emit OperatorPrivacyStatusUpdated([operatorId], true); +``` + +#### Postcondition Invariants +- `lastOperatorId == previous + 1` +- `operators[id].owner == msg.sender` +- `operators[id].ethFee == packed(fee)` +- `operators[id].validatorCount == 0` (SSV) +- `operators[id].ethValidatorCount == 0` (ETH) + +--- + +### 4.2 Remove Operator + +**Caller:** Operator owner +**nonReentrant:** Yes + +#### Preconditions +- Operator must exist (`snapshot.block != 0 || ethSnapshot.block != 0`) +- Caller must be operator owner + +#### State Mutations +1. Update SSV snapshot (final earnings) +2. Update ETH snapshot (final earnings) +3. Reset operator state via `_resetOperatorState`: + - Zeros `ethSnapshot.block`, `ethSnapshot.balance`, `snapshot.block`, `snapshot.balance`, `ethFee`, `fee`, `ethValidatorCount`, `validatorCount` + - Keeps `ethSnapshot.index`, `snapshot.index` +4. **`operator.owner` is intentionally preserved** — allows off-chain systems (explorer, `getOperatorById`) to query the original owner after removal +5. Withdraw all SSV earnings to owner (if any) +6. Withdraw all ETH earnings to owner (if any) +7. Delete whitelist mapping +8. Delete fee change request (if any) + +#### Events +```solidity +if (ssvEarnings > 0) emit OperatorWithdrawnSSV(owner, operatorId, ssvEarnings); +if (ethEarnings > 0) emit OperatorWithdrawn(owner, operatorId, ethEarnings); +emit OperatorRemoved(operatorId); +``` + +#### Removed Operator Detection + +After removal, different code paths detect removed operators via different checks — all are consistent: + +| Check | Location | How it detects removed operators | +|-------|----------|--------------------------------| +| `checkOwner` | `OperatorLib.sol:131` | `snapshot.block == 0 && ethSnapshot.block == 0` → reverts `OperatorDoesNotExist` | +| `ensureOperatorExist` | `OperatorLib.sol:159` | `owner == address(0)` OR `(ethSnapshot.block == 0 && snapshot.block == 0)` → reverts (catches via second condition since owner is preserved) | +| `getSSVBurnRate` | `SSVViews.sol:356` | `owner != address(0)` — removed operators pass this but contribute zero fee (fee already zeroed) | +| `getOperatorById` | `SSVViews.sol:83` | Returns preserved `owner`; `isActive = false` (`ethSnapshot.block == 0`) | + +#### Postcondition Invariants +- `operators[id].owner` preserves the original owner address (non-zero) +- All other operator fields are zeroed: snapshots, fees, validator counts +- No earnings remain in the system for this operator +- Public key can be re-registered + +--- + +### 4.3 Declare Operator Fee + +**Caller:** Operator owner + +#### Preconditions +- Operator must exist +- New fee within `[minimumOperatorEthFee, operatorMaxFee]` +- Fee increase limited by `operatorMaxFeeIncrease` (percentage) +- Cannot increase if both SSV fee = 0 AND ETH fee = 0 + +> **Note — Existing pre-upgrade declarations:** Previous declarations (before the upgrade timestamp, `UPGRADE_TIMESTAMP` in `SSVOperators`) are rejected when executing the fee update via `executeOperatorFee`. The operator owner can declare a new fee at any time. + +> **Note — Multiple declarations:** Calling `declareOperatorFee` multiple times within the declare period will override any pending fee change request. The most recent declaration replaces the previous one, resetting the approval begin/end times. Only the last declared fee can be executed. + +#### State Mutations +1. Store `OperatorFeeChangeRequest{fee: packed(newFee), approvalBeginTime: now + declarePeriod, approvalEndTime: now + declarePeriod + executePeriod}` (overwrites any existing pending request) + +#### Events +```solidity +emit OperatorFeeDeclared(owner, operatorId, block.number, fee); +``` + +--- + +### 4.4 Execute Operator Fee + +**Caller:** Operator owner + +#### Preconditions +- Pending fee change request exists +- `approvalBeginTime > UPGRADE_TIMESTAMP` (reject pre-migration declarations) +- Current time within `[approvalBeginTime, approvalEndTime]` +- Fee still within `operatorMaxFee` + +#### State Mutations +1. Update operator ETH snapshot — ref SPEC §10 "Fee Settlement Rule": settles at old fee up to this block; new fee applies only to future blocks +2. Set `operator.ethFee = request.fee` (packed) +3. Delete fee change request + +#### Events +```solidity +emit OperatorFeeExecuted(owner, operatorId, block.number, fee); +``` + +#### Postcondition Invariants +- `operator.ethFee == request.fee` (packed) +- No pending fee change request +- ETH snapshot block updated to current + +--- + +### 4.5 Reduce Operator Fee + +**Caller:** Operator owner (immediate, no timelock) + +#### Preconditions +- New fee within `[minimumOperatorEthFee, currentFee)` +- New fee strictly less than current + +#### State Mutations +1. Update operator ETH snapshot — ref SPEC §10 "Fee Settlement Rule": settles at old fee up to this block; new fee applies only to future blocks +2. Set `operator.ethFee = packed(newFee)` +3. Delete any pending fee change request + +#### Events +```solidity +emit OperatorFeeExecuted(owner, operatorId, block.number, fee); +``` + +--- + +### 4.6 Cancel Declared Operator Fee + +**Caller:** Operator owner + +#### Preconditions +- Operator must exist +- Caller must be operator owner +- A pending fee change request must exist (`approvalBeginTime != 0`) + +#### State Mutations +1. Delete the pending `OperatorFeeChangeRequest` for this operator + +#### Events +```solidity +emit OperatorFeeDeclarationCancelled(owner, operatorId); +``` + +#### Postcondition Invariants +- No pending fee change request for this operator +- Operator's current fee is unchanged + +--- + +### 4.7 Withdraw Operator Earnings (ETH) + +**Caller:** Operator owner +**nonReentrant:** Yes + +#### Preconditions +- Operator must exist +- `amount <= accumulated ETH earnings` + +#### State Mutations +1. Update ETH snapshot (accumulate latest earnings) +2. Deduct `amount` from snapshot balance +3. Transfer `amount` ETH to operator owner + +#### Events +```solidity +emit OperatorWithdrawn(owner, operatorId, amount); +``` + +#### Postcondition Invariants +- `operator.ethSnapshot.balance == previous_settled - amount` +- `owner.balance == previous + amount` +- `contract.balance == previous - amount` + +--- + +### 4.8 Withdraw Operator Earnings (SSV) + +Same as 4.7 but for SSV-denominated earnings. SSV token transferred instead of ETH. + +#### Events +```solidity +emit OperatorWithdrawnSSV(owner, operatorId, amount); +``` + +--- + +### 4.9 Withdraw All Operator Earnings (ETH + SSV) + +**Caller:** Operator owner +**nonReentrant:** Yes + +#### Preconditions +- Operator must exist + +#### State Mutations +1. Update both ETH and SSV snapshots (accumulate latest earnings for both) +2. Deduct full ETH balance from `ethSnapshot.balance` (set to zero) +3. Deduct full SSV balance from `snapshot.balance` (set to zero) +4. Transfer full ETH earnings to operator owner (if non-zero) +5. Transfer full SSV token earnings to operator owner (if non-zero) + +#### Events +```solidity +emit OperatorWithdrawn(owner, operatorId, ethAmount); // ETH portion +emit OperatorWithdrawnSSV(owner, operatorId, ssvAmount); // SSV portion +``` + +#### Postcondition Invariants +- `operator.ethSnapshot.balance == 0` +- `operator.snapshot.balance == 0` +- `owner.balance == previous + ethEarnings` +- `owner.ssvBalance == previous + ssvEarnings` +- `contract.balance == previous - ethEarnings` + +--- + +## 5. Staking Flows + +### 5.1 Stake SSV + +**Caller:** Anyone with SSV tokens +**nonReentrant:** Yes + +#### Preconditions +- `amount > 0` +- `amount >= MINIMAL_STAKING_AMOUNT` (1,000,000,000) +- User has approved SSV token transfer to contract + +> **Note — cSSV supply cap:** `cSSV.totalSupply` can never exceed `SSV.totalSupply` by construction. `mint(amount)` is only called after `transferFrom` succeeds, so cSSV is always backed 1:1 by SSV already held in the contract. No explicit supply cap check is needed. + +#### State Mutations +1. `_syncFees()`: Update `accEthPerShare` with latest DAO ETH earnings +2. `_settle(msg.sender)`: Settle pending rewards for user +3. Transfer `amount` SSV tokens from user to contract +4. Mint `amount` cSSV to user + +#### Events +```solidity +emit FeesSynced(newFeesWei, accEthPerShare); +emit RewardsSettled(user, pending, accrued, userIndex); +emit Staked(user, amount); +``` + +#### Postcondition Invariants +- `cSSV.totalSupply() == previous + amount` +- `cSSV.balanceOf(user) == previous + amount` +- `ssvToken.balanceOf(contract) == previous + amount` +- `ssvToken.balanceOf(user) == previous - amount` +- `userIndex[user] == accEthPerShare` (freshly settled) +- User begins earning pro-rata rewards immediately + +--- + +### 5.2 Request Unstake + +**Caller:** cSSV holder +**nonReentrant:** Yes + +> **Overview:** Multi-request unstaking with per-request cooldown. Ref: SPEC §3 "Unstaking (Two-Step)" for full semantics. + +#### Preconditions +- `amount > 0` +- `amount <= cSSV.balanceOf(msg.sender)` +- Pending unstake requests < MAX_PENDING_REQUESTS (2000) + +#### State Mutations +1. `_syncFees()`: Update `accEthPerShare` +2. `_settleWithBalance(user, balance)`: Settle rewards using CURRENT cSSV balance (before burn) +3. Push `UnstakeRequest{amount, unlockTime: block.timestamp + cooldownDuration}` +4. Burn `amount` cSSV from user + +#### Events +```solidity +emit FeesSynced(newFeesWei, accEthPerShare); +emit RewardsSettled(user, pending, accrued, userIndex); +emit UnstakeRequested(user, amount, unlockTime); +``` + +#### Postcondition Invariants +- `cSSV.totalSupply() == previous - amount` +- `cSSV.balanceOf(user) == previous - amount` +- `withdrawalRequests[user].length == previous + 1` +- Rewards STOP accruing for the burned cSSV portion +- Previously accrued rewards remain claimable +- SSV tokens are NOT yet returned (locked until cooldown) + +--- + +### 5.3 Withdraw Unlocked + +**Caller:** User with matured unstake requests +**nonReentrant:** Yes + +> **Overview:** Finalizes all matured unstake requests in one call. Ref: SPEC §3 "Unstaking (Two-Step)" for full semantics. + +#### Preconditions +- At least one `UnstakeRequest` where `unlockTime <= block.timestamp` — reverts with `NothingToWithdraw` if none exist or all are still within cooldown + +#### State Mutations +1. Iterate **all** withdrawal requests in a single pass; remove every matured entry via swap-and-pop (O(1) per removal, order of remaining entries may change) +2. Sum total unlocked amount across all removed entries (`totalAmount = Σ matured request amounts`) +3. Transfer `totalAmount` SSV tokens to user + +> **Note:** Immature requests (where `unlockTime > block.timestamp`) remain untouched in the array and will be processed in a future `withdrawUnlocked` call after their lock period expires. + +#### Events +```solidity +emit UnstakedWithdrawn(user, totalAmount); +``` + +#### Postcondition Invariants +- `ssvToken.balanceOf(user) == previous + totalAmount` +- `ssvToken.balanceOf(contract) == previous - totalAmount` +- All matured requests removed from array +- Immature requests preserved + +--- + +### 5.4 Claim ETH Rewards + +**Caller:** cSSV holder +**nonReentrant:** Yes + +#### Preconditions +- User has accrued rewards > 0 (after truncation to ETH_DEDUCTED_DIGITS) + +#### State Mutations +1. `_syncFees()`: Update `accEthPerShare` +2. `_settle(user)`: Settle latest rewards +3. Compute payout: `payout = accrued - (accrued % 100_000)` (precision truncation) +4. Deduct from `accrued[user]` +5. Deduct from `stakingEthPoolBalance` (packed) +6. Deduct from `sp.ethDaoBalance` (packed) +7. Transfer `payout` ETH to user + +#### Events +```solidity +emit FeesSynced(newFeesWei, accEthPerShare); +emit RewardsSettled(user, pending, accrued, userIndex); +emit RewardsClaimed(user, payout); +``` + +#### Postcondition Invariants +- `user.balance == previous + payout` +- `contract.balance == previous - payout` +- `accrued[user] == previous_accrued - payout` (may have dust remainder < 100,000) +- `stakingEthPoolBalance` decreased by packed(payout) +- `ethDaoBalance` decreased by packed(payout) + +--- + +### 5.5 Sync Fees + +**Caller:** Anyone +**nonReentrant:** Yes + +#### Purpose +Publicly callable function to update the global `accEthPerShare` without settling any specific user. Useful for keeping the accumulator current. + +#### State Mutations +1. Compute current DAO ETH earnings +2. If new fees since last sync: update `accEthPerShare` and `stakingEthPoolBalance` + +#### Events +```solidity +emit FeesSynced(newFeesWei, accEthPerShare); +``` + +--- + +### 5.6 cSSV Transfer (Reward Settlement Hook) + +**Caller:** Any cSSV holder (triggered automatically on ERC-20 transfer) +**nonReentrant:** No (hook is called from within the cSSV token contract) + +#### Purpose +Ensures that rewards accrued by the sender up to the moment of transfer remain claimable by the sender, and that the receiver starts accruing rewards only from the moment they receive cSSV. Without this hook, a receiver could claim rewards earned before they held the tokens. + +#### Hook Trigger +`CSSVToken._beforeTokenTransfer` calls `SSVStaking.onCSSVTransfer(from, to, amount)` before every transfer, **except**: +- Mint (`from == address(0)`) +- Burn (`to == address(0)`) +- Self-transfer (`from == to`) +- Calls originating from the staking contract itself (`msg.sender == ssvStaking`) — covers internal mint/burn during `stake` and `requestUnstake` + +#### State Mutations +1. `_syncFees()`: Update global `accEthPerShare` with latest DAO ETH earnings +2. `_settle(from)`: Snapshot sender's accrued rewards at current `accEthPerShare` using their **pre-transfer** balance +3. `_settle(to)`: Snapshot receiver's accrued rewards at current `accEthPerShare` using their **pre-transfer** balance + +After the hook returns, the ERC-20 transfer executes, changing both balances. Future `_settle` calls will compute rewards from the new balances, but only from this block forward. + +#### Events +None emitted by the hook itself. The ERC-20 `Transfer` event is emitted by the token contract after the hook. + +#### Postcondition Invariants +- `userIndex[from] == accEthPerShare` (sender's rewards locked in at pre-transfer share) +- `userIndex[to] == accEthPerShare` (receiver starts accruing from now, not before) +- `accrued[from]` includes all rewards earned up to this block +- `accrued[to]` includes all rewards earned up to this block (on their existing balance, if any) +- If sender's cSSV balance reaches 0 after the transfer, `accrued[from]` is still non-zero and fully claimable via `claimEthRewards()` — rewards are stored in `accrued` independently of cSSV balance + +--- + +## 6. DAO Governance Flows + +### 6.1 Update Network Fee + +**Caller:** Owner only + +#### State Mutations +1. Settle current ETH DAO earnings up to current block +2. Update `ethNetworkFee` to new value +3. Update `ethNetworkFeeIndex` to current +4. Update `ethNetworkFeeIndexBlockNumber` to current block + +#### Events +```solidity +emit NetworkFeeUpdated(oldFee, newFee); +``` + +#### Postcondition Invariants +- All fee accrual up to this block uses old fee +- All fee accrual from this block forward uses new fee +- DAO earnings are settled (no gap or double-counting) + +--- + +### 6.2 Replace Oracle + +**Caller:** Owner only + +#### State Mutations +1. Clear old oracle's `oracleIdOf` mapping +2. Set new oracle's `oracleIdOf` mapping +3. Update `oracles[oracleId]` to new address + +#### Events +```solidity +emit OracleReplaced(oracleId, oldOracle, newOracle); +``` + +#### Postcondition Invariants +- Old oracle can no longer call `commitRoot` +- New oracle can call `commitRoot` +- Outstanding votes by old oracle for pending commitments remain counted + +--- + +## Global Invariants (Must Always Hold) + +These invariants should be verified across all flows: + +1. **ETH conservation**: `contract.ETH_balance >= Σ(all active ETH cluster balances) + Σ(all operator ETH earnings) + staking_pool_balance` +2. **SSV conservation**: `contract.SSV_balance >= Σ(all active SSV cluster balances) + Σ(all operator SSV earnings) + Σ(staked SSV)` +3. **Validator count consistency**: `ethDaoValidatorCount == Σ(cluster.validatorCount)` across all active ETH clusters — note: `Σ(operator.ethValidatorCount)` is NOT equivalent because operators are shared across clusters and would double-count +4. **vUnit consistency**: `daoTotalEthVUnits == ethDaoValidatorCount * VUNITS_PRECISION + Σ(cluster_deviations)` +5. **Cluster hash integrity**: Every cluster operation must end with `s.ethClusters[key] = cluster.hashClusterData()` matching the actual cluster state +6. **cSSV supply**: `cSSV.totalSupply() == Σ(all staked SSV that has not been unstake-requested)` +7. **Rewards conservation**: `accEthPerShare` only increases, never decreases +8. **Oracle monotonicity**: `latestCommittedBlock` only increases +9. **Cluster version exclusivity**: A cluster key exists in EITHER `s.clusters` OR `s.ethClusters`, never both +10. **Operator dual tracking**: SSV validatorCount + ETH validatorCount == total validators using this operator diff --git a/docs/SOLIDITY_BEST_PRACTICES.md b/docs/SOLIDITY_BEST_PRACTICES.md new file mode 100644 index 00000000..c6da5dbf --- /dev/null +++ b/docs/SOLIDITY_BEST_PRACTICES.md @@ -0,0 +1,520 @@ +# Solidity & Smart Contract Security — Best Practices + +Consolidated reference for secure Solidity development, derived from Trail of Bits' [Building Secure Contracts](https://github.com/crytic/building-secure-contracts). Use this document when implementing fixes, reviewing code, or writing new features. + +--- + +## Table of Contents + +1. [Design Principles](#1-design-principles) +2. [Implementation Guidelines](#2-implementation-guidelines) +3. [Upgradeability & Proxy Patterns](#3-upgradeability--proxy-patterns) +4. [Arithmetic Safety](#4-arithmetic-safety) +5. [Access Control](#5-access-control) +6. [Reentrancy & External Interactions](#6-reentrancy--external-interactions) +7. [Event Logging & Monitoring](#7-event-logging--monitoring) +8. [Token Integration](#8-token-integration) +9. [Testing Strategy](#9-testing-strategy) +10. [Static Analysis](#10-static-analysis) +11. [Fuzzing with Echidna](#11-fuzzing-with-echidna) +12. [Security Properties & Invariants](#12-security-properties--invariants) +13. [Code Maturity Checklist](#13-code-maturity-checklist) +14. [Deployment & Incident Response](#14-deployment--incident-response) +15. [Pre-Audit Checklist](#15-pre-audit-checklist) +16. [EVM Internals Quick Reference](#16-evm-internals-quick-reference) + +--- + +## 1. Design Principles + +### Keep it simple +Use the simplest solution that meets requirements. Every team member should understand the design. + +### Minimize on-chain logic +Keep as much computation off-chain as possible. Pre-process data off-chain, verify on-chain. Example: sort a list off-chain, verify order on-chain. + +### Document before coding +Write documentation at three levels before implementation: +1. **Plain English** — system purpose, assumptions, threat model +2. **Architecture diagrams** — contract interactions, state machine, data flow +3. **Code-level** — NatSpec for every public/external function, inline comments for non-obvious logic + +### Specification alignment +- Every arithmetic formula should map 1:1 to a specification +- Document precision loss expectations for every formula +- Specify parameter ranges (min/max) and propagate through docs +- System and function-level invariants should be explicitly stated + +--- + +## 2. Implementation Guidelines + +### Function design +- **Small functions with clear purpose** — one function, one job +- **Divide logic** across contracts or into grouped functions (auth, arithmetic, state) +- **Minimal cyclomatic complexity** — avoid deep nesting of if/else/ternary + +### Inheritance +- Keep inheritance trees shallow and narrow +- Be aware of C3 linearization — `contract A is B, C` and `contract A is C, B` have different storage layouts +- Watch for function shadowing across the inheritance chain +- Use Slither's inheritance-graph printer to visualize hierarchy + +### Dependencies +- Use well-tested libraries (OpenZeppelin) — don't copy-paste +- Pin dependency versions, keep them updated +- Audit third-party code before integrating + +### Solidity-specific +- **Use a stable compiler release** for deployment, but check for warnings with the latest +- **Avoid inline assembly** unless absolutely necessary — requires EVM mastery +- If assembly is used: justify it, document every operation, provide a high-level reference implementation, and test with differential fuzzing +- **Solidity 0.8+** provides built-in overflow/underflow checks — do not disable (`unchecked`) without explicit justification and documentation +- **Favor explicit over implicit** — be explicit about visibility, mutability, return types + +### Code hygiene +- No dead code — remove anything replaced +- No redundant logic — if similar code exists, extend it +- Clear naming conventions, consistent throughout +- Use custom errors instead of `require` strings (gas efficient, more informative) +- Types should enforce correctness where possible (e.g., custom types for packed values) + +--- + +## 3. Upgradeability & Proxy Patterns + +### General guidance +- **Prefer contract migration over upgradeability** — migration offers the same benefits without delegatecall complexity +- **If using delegatecall proxies, use data separation patterns** when possible +- **Document the upgrade procedure before deployment** — include: initialization calls, key locations, post-deployment verification scripts + +### Delegatecall proxy safety checklist + +| Risk | Mitigation | +|------|------------| +| **Storage layout mismatch** | Proxy and implementation must inherit from the same shared base. Never define state variables independently. | +| **Inheritance order** | `contract A is B, C` vs `contract A is C, B` produce different layouts. Lock inheritance order. | +| **Uninitialized implementation** | Initialize immediately on deployment. Use a factory pattern. Disable direct implementation usage with a constructor flag. | +| **Function shadowing** | If proxy and implementation define the same function, the proxy's version wins. Audit admin functions (`setOwner`, etc.). | +| **Immutable/constant drift** | Immutables are embedded in bytecode — they can diverge between proxy and implementation. | +| **Contract existence checks** | `delegatecall` to an address with no code returns `true`. Verify target contract exists. Most proxy libraries do NOT check this automatically. | +| **Storage struct ordering** | Append-only for storage structs — NEVER reorder or remove existing fields. | + +### Tools +- [`slither-check-upgradeability`](https://github.com/crytic/slither/wiki/Upgradeability-Checks) — automated safety checks for proxy patterns + +--- + +## 4. Arithmetic Safety + +### Overflow/underflow +- Solidity 0.8+ provides automatic checks for `+`, `-`, `*` +- `unchecked` blocks disable these checks — only use when overflow is mathematically impossible and document why +- When using assembly arithmetic, implement checks manually (see below) + +### Precision and rounding +- **Explicitly choose rounding direction** for every operation with precision loss +- Use ceiling division for conservative estimates (e.g., ETH to vUnits) +- Use floor division for safe payouts (e.g., vUnits to ETH) +- **Document precision loss** against a ground-truth (infinite-precision reference) +- Bound and document all trapping operations (divide-by-zero, etc.) + +### Packed types +- When packing values into smaller types (uint64, uint32), verify that overflow cannot occur before packing +- Document the precision lost by packing (e.g., `value / 100_000` loses last 5 digits) + +### Assembly arithmetic patterns +For `uint256` addition overflow check: +```solidity +unchecked { + c = a + b; + if (a > c) revert Overflow(); // Solidity 0.8.16+ +} +``` + +For `uint256` multiplication overflow check: +```solidity +unchecked { + c = a * b; + if (a != 0 && b != c / a) revert Overflow(); // Solidity 0.8.17+ +} +``` + +For sub-32-byte types (e.g., `int64`), clean upper bits with `signextend` or cast to `int256` first, then bounds-check. + +### Balance underflow protection +Always use `max(0, balance - fees)` pattern: +```solidity +uint256 usage = computeFees(); +cluster.balance = (usage >= cluster.balance) ? 0 : cluster.balance - usage; +``` + +--- + +## 5. Access Control + +### Principles +- **Least privilege** — each role should only access what it needs +- **Separation of concerns** — don't combine roles (fee-setter shouldn't have upgrade power) +- **No single EOA as sole admin** — use multisig/MPC for privileged operations +- **Two-step processes** for critical operations (e.g., `Ownable2Step`) +- Roles should be revocable + +### Implementation patterns +- Document all actors and their privileges in a matrix +- Test every actor-specific privilege explicitly +- Verify no privilege escalation paths exist +- Protect against leaked/lost keys — loss of one signer should not compromise the system + +### Checklist +- [ ] All privileged functions have access control +- [ ] Different roles have non-overlapping privileges +- [ ] Owner/admin functions use `onlyOwner` or equivalent +- [ ] Operator functions verify `operator.checkOwner()` +- [ ] No function can be called by an unauthorized party to modify state + +--- + +## 6. Reentrancy & External Interactions + +### Patterns +- **Checks-Effects-Interactions (CEI)** — validate, update state, then make external calls +- **Use `nonReentrant`** on any function that makes external calls or transfers ETH/tokens +- Never trust return values from external contracts without validation + +### External call risks +- External calls in transfer functions can lead to reentrancy (especially ERC777 hooks, `onERC721Received`) +- `delegatecall` returns `true` for addresses with no code +- Low-level calls (`call`, `delegatecall`, `staticcall`) return `true` for empty addresses — always check contract existence + +### Token transfers +- Use `SafeERC20` for token interactions (handles non-standard return values) +- Verify ETH transfers succeeded — check return value of `.call{value: amount}("")` +- Be aware of fee-on-transfer tokens, rebasing tokens, and tokens with hooks + +--- + +## 7. Event Logging & Monitoring + +### Design +- **Log ALL critical operations** — state changes, parameter updates, admin actions, transfers +- Use consistent event naming and parameter ordering +- Events facilitate debugging during development and monitoring after deployment +- Don't reuse the same event for different purposes + +### Monitoring +- Set up off-chain monitoring infrastructure that logs and alerts on events +- Document how to interpret each event and how to audit failures from logs +- Consider automated responses to suspicious patterns (pause, safe mode) +- Implement an incident response plan (see Section 14) + +### Event documentation should include +- Purpose of the event +- How it should be used by third parties (oracle, SDK, indexer) +- Assumptions about event ordering and completeness + +--- + +## 8. Token Integration + +When integrating with external tokens, verify: + +### ERC20 checklist +- [ ] Token has been security reviewed +- [ ] `transfer` and `transferFrom` return a boolean (some don't — use `SafeERC20`) +- [ ] Token mitigates ERC20 race condition on `approve` +- [ ] No fee-on-transfer behavior (deflationary tokens) +- [ ] No external calls in transfer functions (ERC777 hooks → reentrancy) +- [ ] No interest accrual that could get trapped +- [ ] Token is not upgradeable (or upgradeability is understood and acceptable) +- [ ] Owner cannot pause, blacklist, or perform unlimited minting +- [ ] Supply is distributed (not concentrated in few addresses) +- [ ] No flash minting capability + +### Known non-standard tokens +Be aware of specific tokens with non-standard behavior: +- **Missing revert**: BAT, HT, cUSDC, ZRX +- **Transfer hooks**: AMP, imBTC (reentrancy risk) +- **Missing return data**: BNB, OMG, USDT +- **Permit no-op**: WETH + +--- + +## 9. Testing Strategy + +### Unit tests +- Cover all happy paths, revert cases, edge conditions, and boundary values +- Test event emissions with exact parameter verification +- Test balance invariants (before/after checks) +- Test state consistency via view functions after operations +- Achieve 100% reachable branch and statement coverage + +### Test quality +- Tests should be isolated — no dependency on execution order +- Use descriptive test names that explain the scenario +- Follow Arrange-Act-Assert pattern +- Don't test the same thing twice — each test should verify one behavior +- Test code should compile without warnings + +### Integration tests +- Test cross-module interactions +- Test upgrade paths end-to-end +- Test with realistic parameter values (not just toy examples) + +### Advanced techniques +- **Fuzzing** (Echidna) — find edge cases through random transaction sequences +- **Symbolic execution** (Manticore) — prove properties mathematically +- **Mutation testing** — verify that tests catch intentional bugs +- **Differential testing** — compare assembly/optimized code against reference implementation + +--- + +## 10. Static Analysis + +### Slither +Run on every check-in. Triage and resolve all findings. + +**Key detectors:** +- Reentrancy vulnerabilities +- Uninitialized state variables +- Unused return values +- Incorrect visibility +- Shadowed state variables +- Unchecked low-level calls + +**Key printers:** +- `inheritance-graph` — check for shadowing and C3 linearization issues +- `function-summary` — review visibility and access controls +- `vars-and-auth` — review which functions write to which state variables +- `human-summary` — get a high-level overview of contract complexity + +**Specialized tools:** +- `slither-check-upgradeability` — proxy safety checks +- `slither-check-erc` — ERC conformance verification +- `slither-prop` — auto-generate security properties for ERC20 + +--- + +## 11. Fuzzing with Echidna + +### When to use +- State machine validation — verify no invalid states are reachable +- Access control — verify only authorized users can perform actions +- Arithmetic properties — verify invariants hold across random inputs +- Complex multi-transaction scenarios that are hard to unit test + +### Property types +1. **Boolean properties** — functions that return `true` if invariant holds +2. **Assertions** — `assert()` statements that must never fail +3. **Optimization** — find inputs that maximize/minimize a value + +### Writing effective properties +```solidity +// Good: specific, testable invariant +function echidna_total_supply_invariant() public view returns (bool) { + return token.totalSupply() == initialSupply + totalMinted - totalBurned; +} + +// Good: access control check +function echidna_only_owner_can_pause() public view returns (bool) { + if (msg.sender != owner) { + return !paused; // non-owners should never be able to pause + } + return true; +} +``` + +### Best practices +- Start with simple properties, iterate toward complexity +- Use filtering (modulo operator) to constrain inputs +- Collect corpus for coverage analysis +- Run periodically in CI, not just once +- Handle ETH: use `maxValue` config for payable functions + +--- + +## 12. Security Properties & Invariants + +### Categories of properties to verify + +| Category | What to check | Recommended tool | +|----------|---------------|------------------| +| **State machine** | No invalid state reachable; all valid states reachable; no trapped states | Echidna, Manticore | +| **Access control** | Only authorized users can perform actions; no privilege escalation | Slither, Echidna | +| **Arithmetic** | No overflow/underflow; rounding is correct; precision loss bounded | Manticore, Echidna | +| **Inheritance** | No shadowing; correct C3 linearization; `super` calls not missed | Slither | +| **External interactions** | Resilient to malicious external contracts; oracle manipulation handled | Echidna, Manticore | +| **Standard conformance** | ERC20/ERC721 behavior matches specification | Slither, Echidna | + +### What automated tools CANNOT easily find +- Privacy violations (all transactions are public in the mempool) +- Front-running / sandwich attacks / MEV +- Cryptographic implementation flaws +- Risky interactions with external DeFi protocols +- Social engineering or off-chain vulnerabilities + +### Transaction ordering risks (MEV) +- Identify and document all front-running opportunities +- Use time delays and slippage checks where applicable +- Use tamper-resistant oracles +- Test privileged operations for ordering risks +- Document known MEV opportunities visibly for users + +--- + +## 13. Code Maturity Checklist + +Self-evaluation framework (rate each area: Missing / Weak / Moderate / Satisfactory / Strong): + +### Arithmetic +- [ ] Explicit overflow protection (Solidity 0.8+ or equivalent) +- [ ] All `unchecked` blocks justified and documented +- [ ] Specification matches code for all formulas +- [ ] Rounding direction explicit for all precision-losing operations +- [ ] Parameter ranges bounded and documented +- [ ] Automated testing (fuzzing/formal methods) covers arithmetic + +### Access Controls +- [ ] All privileged functions have access control +- [ ] Principle of least privilege followed +- [ ] Different roles with non-overlapping privileges +- [ ] Two-step processes for privileged EOA operations +- [ ] Key loss/leakage does not compromise the system + +### Complexity Management +- [ ] Functions have low cyclomatic complexity (< 11) +- [ ] No unnecessary code duplication +- [ ] Clear naming conventions applied consistently +- [ ] Types enforce correctness where possible +- [ ] Each function has a specific, documented purpose + +### Testing & Verification +- [ ] All normal use cases tested +- [ ] All tests pass +- [ ] Code coverage measured and reported +- [ ] Automated testing (fuzzing) used for critical components +- [ ] Tests run in CI/CD pipeline +- [ ] Integration tests implemented +- [ ] Test cases are isolated (no order dependency) + +### Documentation +- [ ] System architecture documented with diagrams +- [ ] All critical functions documented (NatSpec) +- [ ] Known risks and limitations documented +- [ ] Glossary of terms exists +- [ ] User stories cover all operations +- [ ] Invariants clearly defined in documentation + +### Low-level Code +- [ ] Assembly usage is limited and justified +- [ ] Inline comments present for every assembly operation +- [ ] High-level reference implementation exists for complex assembly +- [ ] Differential fuzzing validates assembly against reference +- [ ] No re-implementation of well-established library functionality + +--- + +## 14. Deployment & Incident Response + +### Pre-deployment +- Document the full deployment process (including upgrade/migration steps) +- Write and test post-deployment verification scripts +- Use fork testing to validate deployment on a mainnet fork +- Freeze a stable commit before deployment + +### Post-deployment +- Monitor contracts — observe logs, set up alerts +- Publish security contact information +- Secure privileged wallets (hardware wallets, multisig) +- Have an incident response plan ready + +### Incident response plan +**Application design considerations:** +- Identify which components should be pausable, migratable, upgradeable +- Assess impact of pausing on dependent contracts +- Define system invariants to monitor + +**Documentation to prepare:** +- Runbook of common emergency actions (pause, key rotation, upgrade) +- How to interpret event emissions +- How to access wallets with special roles +- Deployment/upgrade verification procedures +- Stakeholder contact procedures + +**Process:** +- Designate incident roles: technical lead, communication lead, legal lead +- Conduct periodic training and incident response exercises +- Set up monitoring tools (third-party + in-house) +- Consider automated responses (auto-pause on suspicious activity) + +**Threat intelligence:** +- Monitor similar protocols for vulnerabilities +- Follow dependency communication channels +- Maintain contact with dependency maintainers + +--- + +## 15. Pre-Audit Checklist + +Before submitting code for security review: + +### Resolve easy issues +- [ ] Run Slither — triage all findings +- [ ] Achieve high test coverage +- [ ] Remove dead code, unused libraries, stale features +- [ ] If upgradeable, run `slither-check-upgradeability` +- [ ] If ERC20/721, run `slither-check-erc` + +### Make code accessible +- [ ] Provide a detailed list of in-scope files +- [ ] Clear build instructions (verified on fresh environment) +- [ ] Frozen commit hash / branch / release +- [ ] Identify boilerplate, dependencies, and forked code differences + +### Documentation +- [ ] Flowcharts and sequence diagrams for primary workflows +- [ ] User stories +- [ ] On-chain / off-chain assumptions (oracles, bridges, data validation) +- [ ] Actor list with roles and privileges +- [ ] Function documentation with inline comments for complex areas +- [ ] System and function invariants documented +- [ ] Parameter ranges (min/max) documented +- [ ] Arithmetic formulas mapped to specification with precision loss expectations +- [ ] Glossary of terms + +--- + +## 16. EVM Internals Quick Reference + +### Key concepts +- **Two's complement** — negative numbers represented by flipping bits + 1: `-a = ~a + 1` +- **Signed vs unsigned opcodes** — use `slt`/`sgt` for signed comparisons, `lt`/`gt` for unsigned +- **Sub-32-byte types** — require `signextend` or explicit bounds checking; Solidity may optimize away cleanup +- **Division by zero** — EVM returns 0 (no revert); Solidity adds a check automatically outside assembly + +### Critical opcodes for security +| Opcode | Note | +|--------|------| +| `DELEGATECALL` | Executes in caller's storage context — proxy pattern foundation | +| `SELFDESTRUCT` | Deprecated post-Dencun but still exists — can force-send ETH | +| `CREATE2` | Deterministic address — can be used for metamorphic contracts | +| `CALL` | Returns true for addresses with no code — always verify | +| `SSTORE`/`SLOAD` | Expensive — batch storage operations; use transient storage (EIP-1153) where appropriate | + +### Gas awareness +- Storage writes (`SSTORE`) are the most expensive operation (~20K gas for cold, 5K for warm) +- Avoid unbounded loops that could exceed block gas limit +- Pack storage variables into 32-byte slots when possible +- Use `calldata` instead of `memory` for read-only function parameters + +--- + +## References + +- [Trail of Bits — Building Secure Contracts](https://github.com/crytic/building-secure-contracts) +- [Slither — Static Analysis](https://github.com/crytic/slither) +- [Echidna — Fuzzing](https://github.com/crytic/echidna) +- [Manticore — Symbolic Execution](https://github.com/trailofbits/manticore) +- [OpenZeppelin Contracts](https://github.com/OpenZeppelin/openzeppelin-contracts) +- [EVM Codes Reference](https://evm.codes) +- [Solidity Documentation](https://docs.soliditylang.org) diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 00000000..f7c9a201 --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,1092 @@ +# SSV Network v2.0.0 — Technical Specification + +This document is the **source of truth** for design intent, rules, and accounting formulas for the SSV Staking upgrade (v2.0.0), derived from the DIP-X proposal. For step-by-step execution flows and implementation verification, see [FLOWS.md](./FLOWS.md). + +| Document | Purpose | +|---|---| +| **SPEC.md** (this file) | Design intent · rules · formulas · invariants · source of truth | +| **FLOWS.md** | Step-by-step execution · preconditions · state mutations · test checklist | + +### Task Mapping Guide + +When working on a BUG-X, TEST-Y, or FUZZ-Z task, use this map to find the relevant documentation: + +| Task area | FLOWS section | SPEC section | +|---|---|---| +| Cluster operations (register, remove, deposit, withdraw, liquidate, reactivate) | §1 Cluster Flows | §1 ETH Payments, §2 Effective Balance Accounting | +| Migration (SSV → ETH) | §2 Migration Flows | §1 ETH Payments — Cluster Migration | +| Effective balance / oracle | §3 Effective Balance Flows | §4 Oracle System | +| Operator operations (fees, earnings, whitelist) | §4 Operator Flows | §10 Accounting Formulas — Fee Settlement Rule | +| Staking / unstaking / rewards | §5 Staking Flows | §3 SSV Staking | +| DAO governance | §6 DAO Governance Flows | §11 Governance Parameters | +| Accounting verification | §1.8 Accounting Invariant | §10 Accounting Formulas | +| Access control | §9 Access Control Matrix | §9 Access Control Matrix | +| Error codes | — | §12 Error Codes | +| Constants | — | §13 Constants | + +### Decision Trees + +Use these to quickly locate the right section when resolving a BUG/TEST/FUZZ task. Questions are grouped by topic. + +--- + +#### Cluster Accounting + +**Q: How do I calculate what a cluster currently owes in fees?** +- ETH cluster → SPEC §10 "ETH Cluster Balance Update" + FLOWS §1.1 State Mutations +- SSV cluster (legacy) → SPEC §10 "SSV Cluster Balance Update (Legacy)" + +**Q: What is `cluster.index` and `cluster.networkFeeIndex`?** +- Snapshots of the cumulative operator/network fee indices at the last settlement point. Current debt = `(currentIndex - cluster.index) * vUnits` → SPEC §10 "Accounting Formulas" + +**Q: What is `vUnits` and how does it relate to ETH?** +- Internal accounting unit: `vUnits = ceil(effectiveBalanceETH * 10_000 / 32)`. 1 validator at 32 ETH = 10,000 vUnits → SPEC §2 "vUnit System" + +**Q: When does a cluster switch from implicit to explicit EB?** +- On first successful `updateClusterBalance` call with a valid Merkle proof. Before that, `clusterEB.vUnits == 0` and the system uses `validatorCount * VUNITS_PRECISION` → SPEC §2 "Implicit vs Explicit EB" + +**Q: Does EB affect SSV legacy cluster fee calculations?** +- No. SSV clusters store the EB snapshot (for future migration) but fees continue using `validatorCount * fee`. EB only affects ETH cluster accounting → SPEC §2 "Implicit vs Explicit EB" note + +**Q: Can a liquidated cluster withdraw ETH?** +- Yes — `withdraw` does not require an active cluster. Fee settlement is skipped; balance is deducted directly → FLOWS §1.8 preconditions + +**Q: Can a liquidated cluster receive deposits?** +- Yes — `deposit` has no active-cluster check. Useful for funding a cluster in preparation for reactivation → FLOWS §1.7, SPEC §1 "Existing Clusters" + +**Q: What is the minimum ETH required to reactivate or migrate a cluster?** +- `max(minimumLiquidationCollateral, burnRateThreshold)` where `burnRateThreshold = minimumBlocksBeforeLiquidation * totalBurnRate * vUnits / VUNITS_PRECISION * ETH_DEDUCTED_DIGITS` → SPEC §1 "Minimum ETH Calculation" + +--- + +#### Effective Balance & Oracle + +**Q: When is the EB snapshot updated?** +- Always on `updateClusterBalance`, even if the cluster is liquidated. Fee/accounting updates are skipped for inactive clusters, but `clusterEB.vUnits` is always written → SPEC §4 "Behavior on liquidated clusters" + +**Q: Does `updateClusterBalance` auto-liquidate?** +- Only for active ETH clusters. If the cluster becomes undercollateralized after the EB update, it is auto-liquidated within the same call → SPEC §4 "Update Flow" step 7 + +**Q: What happens if oracle quorum is not reached?** +- The `commitRoot` call does NOT revert — it emits `WeightedRootProposed` and persists the partial vote. The root is only committed (and `RootCommitted` emitted) when accumulated weight reaches quorum → SPEC §4 "Failed Quorum Behavior" + +**Q: Can oracles re-vote on the same block number with a different root?** +- Yes — `commitmentKey = keccak256(blockNum, merkleRoot)`, so a different root = a different key. Oracles cannot re-vote on the exact same `(blockNum, merkleRoot)` pair → SPEC §4 "Failed Quorum Behavior" + +**Q: What is the risk of reactivating a cluster with a stale EB snapshot?** +- If EB increased during liquidation: solvency check passes with less ETH than needed → risk of immediate auto-liquidation after next `updateClusterBalance`. Mitigation: call `updateClusterBalance` before reactivating → SPEC §2 "Stale EB Risk on Reactivation" + +**Q: How is the Merkle leaf encoded?** +- `keccak256(keccak256(abi.encode(clusterID, effectiveBalance)))` where `effectiveBalance` is `uint32` in whole ETH and `clusterID = keccak256(abi.encodePacked(owner, sortedOperatorIds))` → SPEC §4 "Merkle Tree Structure" + +--- + +#### Operator Fees & Earnings + +**Q: Which fee rate applies after `executeOperatorFee` or `reduceOperatorFee`?** +- Old rate up to (and including) the current block; new rate from the next block onward. The ETH snapshot is settled at the old rate before the new fee is stored → SPEC §10 "Fee Settlement Rule" + +**Q: How is operator ETH earnings balance computed?** +- `operator.ethSnapshot.balance + (block.number - ethSnapshot.block) * PackedETH.unwrap(operator.ethFee) * ethValidatorCount` — but scaled by vUnits for EB-weighted clusters → SPEC §10 "ETH Operator Fee Index" + +**Q: What happens to operator earnings when an operator is removed?** +- Final SSV and ETH snapshots are settled and stored. Earnings remain withdrawable by the owner even after removal. `operator.owner` is preserved (non-zero) → FLOWS §4.2 State Mutations + +**Q: Can an ETH-only operator call `withdrawOperatorEarningsSSV`?** +- Yes (no guard), but it is a no-op — SSV snapshot balance is zero. See SEC-18 → FLOWS §4.8 + +**Q: What is `DEFAULT_OPERATOR_ETH_FEE` and when is it applied?** +- 1,770,000,000 wei/block/validator. Applied automatically on first ETH cluster interaction for pre-v2 operators that had SSV fee > 0. Operators with SSV fee = 0 get ETH fee = 0 → SPEC §1 "Operator Fee Transition" + +--- + +#### Staking & Rewards + +**Q: How are ETH rewards distributed to stakers?** +- Accumulator pattern: `accEthPerShare` grows as DAO earns ETH. On `settle(user)`: `pending = cSSVBalance * (accEthPerShare - userIndex) / 1e18`. Rewards stop accruing for burned cSSV → SPEC §3 "Reward Distribution" + +**Q: What happens to rewards when cSSV is transferred?** +- `_beforeTokenTransfer` hook calls `onCSSVTransfer(from, to, amount)` which settles both sender and receiver before the transfer. Rewards earned up to that point stay with the sender → SPEC §3 "cSSV Token Behavior", FLOWS §5.6 + +**Q: How many unstake requests can be pending at once?** +- Up to `MAX_PENDING_REQUESTS = 2000` per user. Exceeding this reverts with `MaxRequestsAmountReached` → SPEC §3 "Unstaking (Two-Step)" + +**Q: Does `withdrawUnlocked` process all matured requests or just one?** +- All matured requests in a single call (swap-and-pop iteration). Immature requests remain untouched → SPEC §3 "Unstaking (Two-Step)" + +**Q: What is the minimum stake amount?** +- `MINIMAL_STAKING_AMOUNT = 1,000,000,000` SSV wei → SPEC §13 "Constants" + +**Q: What happens if `syncFees` is called when `totalStaked == 0`?** +- `accEthPerShare` is not updated (division by zero avoided). DAO balance is still updated. Fees accrued during this period are effectively lost to stakers (see BUG-6) → FLOWS §5.5 + +--- + +#### Cluster Lifecycle & Versioning + +**Q: How do I tell if a cluster is ETH or SSV?** +- Check `validateHashedCluster` return value: `version == VERSION_ETH` (2) → ETH cluster in `s.ethClusters`; `version == VERSION_SSV` (1) → SSV cluster in `s.clusters` → SPEC §6 "Type System & Packing" + +**Q: What operations are blocked on legacy SSV clusters?** +- Blocked: `registerValidator`, `bulkRegisterValidator`, `removeValidator` (BUG-11), `bulkRemoveValidator` (BUG-11), `reactivate`, `deposit` (SSV), `withdraw` (SSV) +- Allowed: `exitValidator`, `bulkExitValidator`, `liquidate`, `liquidateSSV`, `migrateClusterToETH`, `updateClusterBalance` → SPEC §1 "Existing Clusters" + +**Q: What happens to removed operators in a cluster?** +- Removed operators are skipped during `updateClusterOperatorsOnReactivation` and migration. The cluster operates with reduced operator coverage (e.g., 3/4). No on-chain event signals which operators were skipped — detectable off-chain by checking operator states → FLOWS §1.8 note, SPEC §1 "Minimum ETH Calculation" special cases + +**Q: Can a cluster be reactivated after migration to ETH?** +- Migration is one-way and irreversible. A migrated cluster that is later liquidated can be reactivated via `reactivate` (ETH flow) → SPEC §1 "Cluster Migration" + +--- + +#### Storage & Data Structures + +**Q: Where is ETH cluster state stored vs SSV cluster state?** +- ETH clusters: `StorageData.ethClusters[hashedCluster]` (hashed `Cluster` struct) +- SSV clusters: `StorageData.clusters[hashedCluster]` +- Both use the same key: `keccak256(abi.encodePacked(owner, sortedOperatorIds))` → SPEC §5 "Storage Layout" + +**Q: Where is EB data stored?** +- `SSVStorageEB.clusterEB[clusterId]` → `ClusterEBSnapshot{vUnits, lastRootBlockNum, lastUpdateBlock}` +- `SSVStorageEB.operatorEthVUnits[operatorId]` → deviation vUnits per operator +- `SSVStorageEB.ebRoots[blockNum]` → committed Merkle root → SPEC §5 "SSVStorageEB" + +**Q: How is `PackedETH` different from raw wei?** +- `PackedETH` stores values divided by `ETH_DEDUCTED_DIGITS` (100,000) to fit in `uint64`. Unpack with `PackedETH.unwrap(x)` which multiplies by 100,000. Operator fees must be divisible by 100,000 → SPEC §6 "Type System & Packing" + +**Q: What does `operator.snapshot.block == 0 && operator.ethSnapshot.block == 0` mean?** +- The operator has been removed (`_resetOperatorState` zeroed all fields except `owner`). Such operators are skipped during cluster operations → SPEC §1 "Minimum ETH Calculation" special cases + +### Version Delta (v1.x → v2.0.0) + +| Area | v1.x | v2.0.0 | +|---|---|---| +| Payment token | SSV | ETH (new clusters); SSV (legacy) | +| Fee unit | SSV/block/validator | ETH/block/validator, scaled by vUnits (EB) | +| Cluster creation | SSV deposit | ETH deposit via `msg.value` | +| Validator count scaling | flat per-validator | EB-weighted via vUnits | +| Operator earnings | SSV | ETH (new) + SSV (legacy accrual continues) | +| Staking | none | SSV → cSSV, earns ETH rewards from network fees | +| Oracle | none | Merkle-root EB oracle with quorum voting | +| Liquidation collateral | SSV-denominated | SSV-denominated (legacy SSV clusters) and ETH-denominated, EB-aware | +| SSV cluster operations | full | blocked (remove, liquidate, and migrate only) | +| Withdraw from liquidated | blocked | allowed (ETH clusters) | + +### Related Documents + +- [FLOWS.md](./FLOWS.md): Step-by-step contract flows for all external functions. + +## Table of Contents + +1. [ETH Payments](#1-eth-payments) +2. [Effective Balance Accounting](#2-effective-balance-accounting) +3. [SSV Staking](#3-ssv-staking) +4. [Oracle System](#4-oracle-system) +5. [Storage Layout](#5-storage-layout) +6. [Type System & Packing](#6-type-system--packing) +7. [All Events](#7-all-events) +8. [All External Functions](#8-all-external-functions) +9. [Access Control Matrix](#9-access-control-matrix) +10. [Accounting Formulas](#10-accounting-formulas) +11. [Governance Parameters](#11-governance-parameters) +12. [Error Codes](#12-error-codes) +13. [Constants](#13-constants) + +--- + +## 1. ETH Payments + +### Overview + +ETH replaces SSV as the payment asset for network and operator fees. All new clusters operate exclusively with ETH. Existing SSV clusters are legacy — they cannot add/remove validators, deposit SSV, or reactivate. The only forward path is migration to ETH. + +### New Clusters (ETH-based) + +- Operator fees paid in ETH +- Network fees paid in ETH +- Operates with EB accounting +- ETH deposited upfront for runway +- Fees scale with effective balance (vUnits), not validator count + +### Existing Clusters (SSV-based, Legacy) + +- Continue running with existing SSV runway +- **Blocked operations**: add validators, remove validators, reactivate, deposit SSV, withdraw SSV +- **Allowed operations**: self-liquidate, migrate to ETH, exit validators +- SSV fee accrual continues normally until runway depletes or migration occurs + +### Cluster Migration (`migrateClusterToETH`) + +- One-way, irreversible +- Single transaction: switches accounting from SSV to ETH +- Only callable by the cluster owner +- Remaining SSV balance refunded to cluster owner +- ETH deposited via `msg.value` as new cluster balance +- Must pass ETH liquidation check post-migration or reverts with `InsufficientBalance` + +**Minimum ETH Calculation (Post-Migration Liquidation Check):** + +The migrated cluster must have sufficient balance to avoid immediate liquidation. The minimum required ETH is computed in steps: + +``` +Step 1: Compute vUnits (EB-normalized accounting units) + vUnits = clusterEB[clusterId].vUnits + if (vUnits == 0): + vUnits = validatorCount * VUNITS_PRECISION // implicit EB (32 ETH/validator) + +Step 2: Compute total burn rate (operator fees + network fee) + operatorFeeSum = Σ(operator.ethFee) for all operators in cluster // packed wei/block + networkFee = ethNetworkFee // packed wei/block + totalBurnRate = operatorFeeSum + networkFee // packed wei/block + +Step 3: Compute burn-rate-based threshold (how much ETH consumed over liquidation period) + burnRateThresholdUnits = (minimumBlocksBeforeLiquidation * totalBurnRate * vUnits) / VUNITS_PRECISION + burnRateThreshold = burnRateThresholdUnits * ETH_DEDUCTED_DIGITS // convert to wei + +Step 4: Take maximum of both thresholds + minimumETHRequired = max(minimumLiquidationCollateral, burnRateThreshold) +``` + +**Special Cases:** +- With zero-fee operators: `operatorFeeSum = 0`, so `totalBurnRate = networkFee` only +- The absolute floor is always `minimumLiquidationCollateral` (currently 0.00094 ETH) +- **Removed operators** are skipped during migration (detected by `operator.snapshot.block == 0 && operator.ethSnapshot.block == 0`; their fees do not contribute to `operatorFeeSum`) +- Reactivates a liquidated cluster and emits the `ClusterReactivated` event in addition to `ClusterMigratedToETH` + +### Operator Fee Transition + +**New operators**: Register with ETH fee only (no SSV fee option) + +**Existing operators**: +- SSV fees frozen (cannot modify) +- SSV fee accrual continues for non-migrated clusters +- Default ETH fee assigned automatically on first ETH cluster interaction: + - If SSV fee = 0 → ETH fee = 0 + - If SSV fee > 0 → ETH fee = `DEFAULT_OPERATOR_ETH_FEE` (1,770,000,000 wei = ~0.00464 ETH/year per 32 ETH validator) + +### Breaking Function Signature Changes + +| Old Signature | New Signature | Change | +|---|---|---| +| `registerValidator(..., uint256 amount, Cluster)` | `registerValidator(..., Cluster) payable` | `amount` removed, now `payable` | +| `bulkRegisterValidator(..., uint256 amount, Cluster)` | `bulkRegisterValidator(..., Cluster) payable` | `amount` removed, now `payable` | +| `deposit(..., uint256 amount, Cluster)` | `deposit(..., Cluster) payable` | `amount` removed, now `payable` | +| `reactivate(..., uint256 amount, Cluster)` | `reactivate(..., Cluster) payable` | `amount` removed, now `payable` | +| `getBalance(...) returns (uint256)` | `getBalance(...) returns (uint256, uint256)` | Now also returns `ebBalance` | + +--- + +## 2. Effective Balance Accounting + +### Overview + +Fees are calculated based on a cluster's total effective balance rather than validator count. Effective balance is always an integer number of ETH (e.g. 32 ETH, 64 ETH) — fractional values are not valid, matching the beacon chain's own representation. This supports post-Pectra validators with variable effective balances (32–2048 ETH per validator). + +### vUnit System + +vUnits are the internal accounting unit that normalizes effective balance: + +``` +ETH → vUnits (ceiling): vUnits = ceil(effectiveBalanceETH * VUNITS_PRECISION / 32) +vUnits → ETH (floor): effectiveBalanceETH = floor(vUnits * 32 / VUNITS_PRECISION) + +VUNITS_PRECISION = 10,000 +``` + +Examples: +- 1 validator at 32 ETH → 10,000 vUnits +- 1 validator at 64 ETH → 20,000 vUnits +- 3 validators at 32 ETH each → 30,000 vUnits + +### Implicit vs Explicit EB + +- **Implicit** (default): `clusterEB.vUnits == 0` → system uses `validatorCount * VUNITS_PRECISION` +- **Explicit**: Set after first `updateClusterBalance` call with oracle Merkle proof + +> **Note — EB tracking vs EB-based accounting:** While both ETH and SSV clusters can have their EB snapshot updated via `updateClusterBalance`, **only ETH clusters use EB for fee accounting**. SSV legacy clusters store the EB snapshot (for future migration) but continue to use validator-count-based fee calculations (`validatorCount * fee`). The EB snapshot does not affect SSV cluster balance deductions. + +### EB Update Constraints + +- `effectiveBalance >= validatorCount * 32` (minimum 32 ETH per validator) +- `effectiveBalance <= validatorCount * 2048` (maximum 2048 ETH per validator) +- Block numbers must be strictly monotonically increasing +- Minimum blocks between updates enforced (`minBlocksBetweenUpdates`) + +### DAO vUnit Tracking + +``` +daoTotalEthVUnits = ethDaoValidatorCount * VUNITS_PRECISION + Σ(cluster_deviations) +``` + +Where deviation = `cluster.vUnits - (cluster.validatorCount * VUNITS_PRECISION)` for clusters with explicit EB. + +### Operator vUnit Deviation Cleanup on Liquidation + +When a cluster is liquidated (via `liquidate`, `liquidateSSV`, or auto-liquidation in `updateClusterBalance`): +- **Baseline** is removed by decrementing `operator.ethValidatorCount` for each operator +- **Deviation** (explicit EB above baseline) is removed from `operatorEthVUnits[opId]` and `daoTotalEthVUnits` +- Implicit clusters (`clusterEB.vUnits == 0`) have no deviation — only baseline removal applies + +### Stale EB Risk on Reactivation + +**Oracle behavior:** SSV oracles typically do not proactively update EB for liquidated clusters in their regular sweeps (since fee/accounting updates are skipped for inactive clusters and there is no economic benefit to the liquidated cluster owner). However, **the protocol allows permissionless EB updates** — the `updateClusterBalance` function can be called by anyone (including the cluster owner) on liquidated clusters to refresh the EB snapshot in preparation for reactivation. + +**Why this matters:** During the liquidation period, the beacon-chain EB may diverge from the stored snapshot: + +- **EB increases** (e.g. owner consolidates validators): reactivation solvency check uses stale lower EB → cluster passes with less ETH than required → auto-liquidation risk on next `updateClusterBalance` (if not updated before reactivation) +- **EB decreases** (e.g. slashing): reactivation solvency check uses stale higher EB → cluster owner overestimates required deposit → wastes ETH (conservative but safe) + +**Mitigation:** Cluster owners (or any interested party) can call `updateClusterBalance` on a liquidated cluster **before reactivation** to ensure the stored EB snapshot reflects current beacon-chain state. This eliminates the risk of immediate auto-liquidation after reactivation. If the owner does not perform this update, they should deposit a conservative ETH buffer to account for potential EB drift during the liquidation period. + +--- + +## 3. SSV Staking + +### Overview + +SSV holders stake tokens → receive cSSV (ERC-20, 1:1 ratio) → earn pro-rata share of ETH protocol revenue (network fees). + +### Staking Flow + +1. User approves SSV token transfer +2. User calls `stake(amount)` — minimum `MINIMAL_STAKING_AMOUNT` (1,000,000,000) SSV wei +3. SSV tokens transferred to contract +4. cSSV minted to user at 1:1 ratio +5. Rewards begin accruing immediately + +### Reward Distribution (Accumulator Pattern) + +```solidity +// On syncFees(): +currentDaoEarnings = sp.networkTotalEarnings() // total ETH DAO has earned +newFees = currentDaoEarnings - stakingEthPoolBalance +accEthPerShare += (unpack(newFees) * 1e18) / cSSV.totalSupply() +stakingEthPoolBalance = currentDaoEarnings + +// On settle(user): +pending = (cSSVBalance * (accEthPerShare - userIndex[user])) / 1e18 +accrued[user] += pending +userIndex[user] = accEthPerShare +``` + +### Claiming Rewards + +- Call `claimEthRewards()` at any time +- Payout truncated to ETH_DEDUCTED_DIGITS precision: `payout = accrued - (accrued % 100_000)` +- Deducted from both `stakingEthPoolBalance` and `sp.ethDaoBalance` +- ETH transferred to user + +### cSSV Token Behavior + +- Mint: only by SSVStaking on `stake()` +- Burn: only by SSVStaking on `requestUnstake()` +- Transfer hook: `_beforeTokenTransfer` calls `SSVStaking.onCSSVTransfer(from, to, amount)` + - Settles rewards for both sender and receiver before transfer + - Ensures rewards accrued up to transfer point stay with original holder +- Retains full DAO governance voting power + +### Unstaking (Two-Step) + +Stakers may submit multiple withdrawal requests over time. When finalizing an unstake, the staker can claim the **cumulative amount of all requests whose lock period has fully elapsed**, while any requests still in their lock period remain locked. A maximum of **2,000 active withdrawal requests per staker** is supported. + +1. **`requestUnstake(amount)`**: Burns cSSV, creates `UnstakeRequest{amount, unlockTime = now + cooldownDuration}`. Reverts with `ZeroAmount` if `amount == 0`, `MaxRequestsAmountReached` if pending request count exceeds `MAX_PENDING_REQUESTS` (2000). + +2. **`withdrawUnlocked()`**: After cooldown, returns SSV at 1:1. Processes **all** matured requests in a single call — iterates the full request array, removes every entry where `unlockTime <= block.timestamp` via swap-and-pop, and transfers the cumulative sum. **Immature requests (still in lock period) remain untouched** in the array. Reverts with `NothingToWithdraw` if no matured requests exist. + +**Rewards behavior:** Rewards STOP accruing for the unstaked portion at the moment of `requestUnstake`. Previously accrued rewards remain claimable via `claimEthRewards`. + +--- + +## 4. Oracle System + +### Overview + +Effective Balance Oracles track validator balances on the beacon chain and commit Merkle roots on-chain. The protocol uses a permissioned set of 4 oracles with a 3-of-4 (75%) quorum threshold. + +**Initialization:** Oracle addresses, cooldown duration, and quorum are bootstrapped during the upgrade via `initializeSSVStaking`, which sets `StorageStaking.defaultOracleIds`, `cooldownDuration`, and `quorumBps` atomically. The initializer validates `quorumBps != 0 && quorumBps <= 10_000` — zero or out-of-range values revert with `InvalidQuorum`. There is no window where the contract is live with oracles uninitialized or quorum unset. + +### Commit Flow (`commitRoot`) + +1. Oracle calls `commitRoot(merkleRoot, blockNum)` +2. Contract validates: `blockNum > latestCommittedBlock` (monotonic), `blockNum <= block.number` (not future) +3. Requires `cSSV.totalSupply() > 0` (reverts with `OracleHasZeroWeight` otherwise) +4. Each oracle has equal weight: `weight = totalCSSVSupply / 4` +5. Accumulated weight tracked per `commitmentKey = keccak256(blockNum, merkleRoot)` +6. When `accumulatedWeight >= (totalCSSVSupply * quorumBps) / 10_000`: + - Root is committed: `ebRoots[blockNum] = merkleRoot` + - `latestCommittedBlock = blockNum` + - Cleanup: `delete rootCommitments[commitmentKey]` + - Emits `RootCommitted` +7. Below quorum: emits `WeightedRootProposed` + +**Failed Quorum Behavior:** +- If a proposal fails to reach quorum (e.g., only 2 of 4 oracles vote), the `hasVoted[commitmentKey][oracleId]` mappings and `rootCommitments[commitmentKey]` persist indefinitely +- Oracles cannot re-vote on the exact same `(blockNum, merkleRoot)` pair (reverts with `AlreadyVoted`) +- Oracles **can** vote on the same `blockNum` with a **different** `merkleRoot` since the `commitmentKey` is computed from both parameters +- No automatic cleanup occurs for failed proposals — storage entries remain until overwritten by future successful commits or contract upgrade +- If the last oracle to vote still does not bring the proposal to quorum, the state remains unchanged (no root is committed, no cleanup occurs) + +### Merkle Tree Structure (OpenZeppelin StandardMerkleTree compatible) + +**Leaf encoding**: `keccak256(keccak256(abi.encode(clusterID, effectiveBalance)))` +- Double-hash prevents second pre-image attacks +- `clusterID`: `keccak256(abi.encodePacked(owner, sortedOperatorIds))` +- `effectiveBalance`: `uint32` in whole ETH + +**Tree construction**: +- Leaves sorted by hash value +- Internal nodes: siblings sorted before hashing (smaller hash first) +- Odd nodes duplicated + +### Update Flow (`updateClusterBalance`) + +Permissionless — anyone can submit a valid proof: + +1. Verify committed root exists for `blockNum` +2. Verify update frequency (min blocks between updates) +3. Verify staleness (blockNum > last root used for this cluster) +4. Verify Merkle proof against committed root +5. Verify EB limits (32–2048 ETH per validator) +6. Convert to vUnits, update EB snapshot +7. **ETH clusters only**: apply fee settlements, update operator/DAO vUnit deviations, auto-liquidate if undercollateralized +8. **SSV clusters**: no fee/accounting updates; EB snapshot stored for future migration only + +**Behavior on liquidated clusters:** The EB snapshot (`clusterEB[clusterId].vUnits`) is **always updated**, even if the cluster is liquidated (`cluster.active == false`). Fee settlements, vUnit deviation updates, and the auto-liquidation check are all skipped. `ClusterBalanceUpdated` is still emitted. This means the stale EB is corrected in storage even while the cluster is inactive, so that reactivation uses the latest known EB. + +**SSV cluster accounting:** Legacy SSV clusters continue to use `validatorCount`-based fee calculations (see "SSV Cluster Balance Update (Legacy)" in Accounting Formulas). The EB snapshot is stored but does not affect fee deductions — it only prepares the cluster for future migration to ETH. + +### Oracle API (External Reference) + +The SSV Oracle (`github.com/ssvlabs/ssv-oracle`) exposes: +- `GET /api/commit` — latest committed root info +- `GET /api/proof/{clusterId}` — Merkle proof for a specific cluster + +--- + +## 5. Storage Layout + +### SSVStorage (`keccak256("ssv.network.storage.main") - 1`) + +```solidity +struct StorageData { + mapping(bytes32 => bytes32) validatorPKs; // keccak256(pubkey, owner) → hashed(operatorIds | active) + mapping(bytes32 => bytes32) clusters; // SSV clusters: keccak256(owner, opIds) → clusterHash + mapping(bytes32 => uint64) operatorsPKs; // keccak256(pubkey) → operatorId + mapping(SSVModules => address) ssvContracts; // module enum → implementation + mapping(uint64 => address) operatorsWhitelist; // operatorId → whitelist address/contract + mapping(uint64 => OperatorFeeChangeRequest) operatorFeeChangeRequests; + mapping(uint64 => Operator) operators; // operatorId → Operator struct + IERC20 token; // SSV ERC-20 + Counters.Counter lastOperatorId; // auto-increment + mapping(address => mapping(uint256 => uint256)) addressWhitelistedForOperators; // bitmap + mapping(bytes32 => bytes32) ethClusters; // ETH clusters: same key → clusterHash +} +``` + +### Operator Struct + +```solidity +struct Operator { + uint32 validatorCount; // SSV validator count + PackedSSV fee; // SSV fee (packed /10M) + address owner; + bool whitelisted; // private flag + Snapshot snapshot; // SSV earnings: {uint32 block, uint64 index, PackedSSV balance} + uint32 ethValidatorCount; // ETH validator count + PackedETH ethFee; // ETH fee (packed /100K) + EthSnapshot ethSnapshot; // ETH earnings: {uint32 block, uint64 index, PackedETH balance} +} +``` + +### Cluster Struct + +```solidity +struct Cluster { + uint32 validatorCount; + uint64 networkFeeIndex; // snapshot of cumulative network fee index + uint64 index; // snapshot of cumulative operator fee index + bool active; + uint256 balance; // ETH wei (ETH clusters) or SSV tokens (SSV clusters) +} +``` + +### SSVStorageProtocol (`keccak256("ssv.network.storage.protocol") - 1`) + +```solidity +struct StorageProtocol { + // SSV (legacy) fields + uint32 networkFeeIndexBlockNumber; + uint32 daoValidatorCount; + uint32 daoIndexBlockNumber; + uint32 validatorsPerOperatorLimit; + PackedSSV networkFee; + uint64 networkFeeIndex; + PackedSSV daoBalance; + uint64 minimumBlocksBeforeLiquidationSSV; + PackedSSV minimumLiquidationCollateralSSV; + uint64 declareOperatorFeePeriod; + uint64 executeOperatorFeePeriod; + uint64 operatorMaxFeeIncrease; + uint64 operatorMaxFeeSSV; + + // ETH fields + uint32 ethNetworkFeeIndexBlockNumber; + uint32 ethDaoValidatorCount; + uint32 ethDaoIndexBlockNumber; + PackedETH ethNetworkFee; + uint64 ethNetworkFeeIndex; + PackedETH ethDaoBalance; + PackedETH minimumLiquidationCollateral; + uint64 minimumBlocksBeforeLiquidation; + PackedETH operatorMaxFee; + + // EB fields + uint64 daoTotalEthVUnits; + PackedETH minimumOperatorEthFee; +} +``` + +### SSVStorageEB (`keccak256("ssv.network.storage.eb") - 1`) + +```solidity +struct StorageEB { + mapping(uint64 => bytes32) ebRoots; // blockNum → Merkle root + mapping(bytes32 => ClusterEBSnapshot) clusterEB; // clusterId → EB snapshot + mapping(uint64 => uint64) operatorEthVUnits; // operatorId → deviation vUnits + uint64 latestCommittedBlock; + uint32 minBlocksBetweenUpdates; + mapping(bytes32 => uint256) rootCommitments; // commitKey → accumulated weight + mapping(bytes32 => mapping(uint32 => bool)) hasVoted; // commitKey → oracleId → voted +} + +struct ClusterEBSnapshot { + uint64 vUnits; // 0 = implicit (use validatorCount * 10_000) + uint64 lastRootBlockNum; // block of last root used + uint64 lastUpdateBlock; // actual block.number of last update +} +``` + +### SSVStorageStaking (`keccak256("ssv.network.storage.staking") - 1`) + +```solidity +struct StorageStaking { + uint64 cooldownDuration; + PackedETH stakingEthPoolBalance; + uint128 accEthPerShare; // scaled by 1e18 + mapping(address => uint256) userIndex; + mapping(address => uint256) accrued; // unclaimed ETH in wei + mapping(uint32 => address) oracles; // oracleId → address + mapping(address => uint32) oracleIdOf; // address → oracleId + uint32[4] defaultOracleIds; + uint16 quorumBps; + mapping(address => UnstakeRequest[]) withdrawalRequests; +} + +struct UnstakeRequest { + uint192 amount; + uint64 unlockTime; +} +``` + +--- + +## 6. Type System & Packing + +### PackedSSV (uint64) + +``` +Pack: raw = value / 10_000_000 +Unpack: value = raw * 10_000_000 +``` + +Reverts with `MaxPrecisionExceeded` if `value % 10_000_000 != 0`. + +### PackedETH (uint64) + +``` +Pack: raw = value / 100_000 +Unpack: value = raw * 100_000 +``` + +Reverts with `MaxPrecisionExceeded` if `value % 100_000 != 0`. + +### Version Constants + +``` +VERSION_SSV = 0 // Legacy SSV-fee clusters +VERSION_ETH = 1 // New ETH-fee clusters +VERSION_UNDEFINED = 255 +``` + +### Cluster Hashing + +```solidity +keccak256(abi.encodePacked( + cluster.validatorCount, + cluster.networkFeeIndex, + cluster.index, + cluster.balance, + cluster.active +)) +``` + +### Cluster ID (Identity Key) + +```solidity +keccak256(abi.encodePacked(ownerAddress, operatorIds)) +``` + +--- + +## 7. All Events + +### Operator Events + +```solidity +event OperatorAdded(uint64 indexed operatorId, address indexed owner, bytes publicKey, uint256 fee); +event OperatorRemoved(uint64 indexed operatorId); +event OperatorFeeDeclared(address indexed owner, uint64 indexed operatorId, uint256 blockNumber, uint256 fee); +event OperatorFeeDeclarationCancelled(address indexed owner, uint64 indexed operatorId); +event OperatorFeeExecuted(address indexed owner, uint64 indexed operatorId, uint256 blockNumber, uint256 fee); +event OperatorWithdrawn(address indexed owner, uint64 indexed operatorId, uint256 value); +event OperatorWithdrawnSSV(address indexed owner, uint64 indexed operatorId, uint256 value); +event OperatorPrivacyStatusUpdated(uint64[] operatorIds, bool toPrivate); +event FeeRecipientAddressUpdated(address indexed owner, address recipientAddress); +``` + +### Whitelist Events + +```solidity +event OperatorMultipleWhitelistUpdated(uint64[] operatorIds, address[] whitelistAddresses); +event OperatorMultipleWhitelistRemoved(uint64[] operatorIds, address[] whitelistAddresses); +event OperatorWhitelistingContractUpdated(uint64[] operatorIds, address whitelistingContract); +``` + +### Validator Events + +```solidity +event ValidatorAdded(address indexed owner, uint64[] operatorIds, bytes publicKey, bytes shares, Cluster cluster); +event ValidatorRemoved(address indexed owner, uint64[] operatorIds, bytes publicKey, Cluster cluster); +event ValidatorExited(address indexed owner, uint64[] operatorIds, bytes publicKey); +``` + +### Cluster Events + +```solidity +event ClusterLiquidated(address indexed owner, uint64[] operatorIds, Cluster cluster); +event ClusterReactivated(address indexed owner, uint64[] operatorIds, Cluster cluster); +event ClusterMigratedToETH(address indexed owner, uint64[] operatorIds, uint256 ethDeposited, uint256 ssvRefunded, uint32 effectiveBalance, Cluster cluster); +event ClusterWithdrawn(address indexed owner, uint64[] operatorIds, uint256 value, Cluster cluster); +event ClusterDeposited(address indexed owner, uint64[] operatorIds, uint256 value, Cluster cluster); +event ClusterBalanceUpdated(address indexed owner, uint64[] operatorIds, uint64 indexed blockNum, uint32 effectiveBalance, Cluster cluster); +``` + +### DAO Events + +```solidity +event NetworkFeeUpdated(uint256 oldFee, uint256 newFee); +event NetworkFeeUpdatedSSV(uint256 oldFee, uint256 newFee); +event NetworkEarningsWithdrawn(uint256 value, address recipient); +event OperatorFeeIncreaseLimitUpdated(uint64 value); +event DeclareOperatorFeePeriodUpdated(uint64 value); +event ExecuteOperatorFeePeriodUpdated(uint64 value); +event LiquidationThresholdPeriodUpdated(uint64 value); +event LiquidationThresholdPeriodSSVUpdated(uint64 value); +event MinimumLiquidationCollateralUpdated(uint256 value); +event MinimumLiquidationCollateralSSVUpdated(uint256 value); +event OperatorMaximumFeeUpdated(uint256 maxFee); +event MinimumOperatorEthFeeUpdated(uint256 minFee); +event RootCommitted(bytes32 indexed merkleRoot, uint64 indexed blockNum); +event WeightedRootProposed(bytes32 indexed merkleRoot, uint64 indexed blockNum, uint256 accumulatedWeight, uint256 quorum, uint32 oracleId, address oracle); +event OracleReplaced(uint32 indexed oracleId, address indexed oldOracle, address indexed newOracle); +event QuorumUpdated(uint16 newQuorum); +event CooldownDurationUpdated(uint64 newCooldownDuration); +``` + +### Staking Events + +```solidity +event Staked(address indexed user, uint256 amount); +event UnstakeRequested(address indexed user, uint256 amount, uint256 unlockTime); +event UnstakedWithdrawn(address indexed user, uint256 amount); +event FeesSynced(uint256 newFeesWei, uint256 accEthPerShare); +event RewardsSettled(address indexed user, uint256 pending, uint256 accrued, uint256 userIndex); +event RewardsClaimed(address indexed user, uint256 amount); +event ERC20Rescued(address indexed token, address indexed to, uint256 amount); +``` + +### Module Events + +```solidity +event ModuleUpgraded(SSVModules indexed moduleId, address moduleAddress); +``` + +--- + +## 8. All External Functions + +### SSVOperators + +```solidity +function registerOperator(bytes calldata publicKey, uint256 fee, bool setPrivate) external returns (uint64) +function removeOperator(uint64 operatorId) external nonReentrant +function declareOperatorFee(uint64 operatorId, uint256 fee) external +function executeOperatorFee(uint64 operatorId) external +function cancelDeclaredOperatorFee(uint64 operatorId) external +function reduceOperatorFee(uint64 operatorId, uint256 fee) external +function setOperatorsPrivateUnchecked(uint64[] calldata operatorIds) external +function setOperatorsPublicUnchecked(uint64[] calldata operatorIds) external +function withdrawOperatorEarnings(uint64 operatorId, uint256 amount) external nonReentrant +function withdrawAllOperatorEarnings(uint64 operatorId) external nonReentrant +function withdrawAllVersionOperatorEarnings(uint64 operatorId) external nonReentrant +function withdrawOperatorEarningsSSV(uint64 operatorId, uint256 amount) external nonReentrant +function withdrawAllOperatorEarningsSSV(uint64 operatorId) external nonReentrant +``` + +### SSVOperatorsWhitelist + +```solidity +function setOperatorsWhitelists(uint64[] calldata operatorIds, address[] calldata whitelistAddresses) external +function removeOperatorsWhitelists(uint64[] calldata operatorIds, address[] calldata whitelistAddresses) external +function setOperatorsWhitelistingContract(uint64[] calldata operatorIds, ISSVWhitelistingContract whitelistingContract) external +function removeOperatorsWhitelistingContract(uint64[] calldata operatorIds) external +``` + +### SSVValidators + +```solidity +function registerValidator(bytes calldata publicKey, uint64[] memory operatorIds, bytes calldata sharesData, Cluster memory cluster) external payable +function bulkRegisterValidator(bytes[] memory publicKeys, uint64[] memory operatorIds, bytes[] calldata sharesData, Cluster memory cluster) external payable +function removeValidator(bytes calldata publicKey, uint64[] memory operatorIds, Cluster memory cluster) external +function bulkRemoveValidator(bytes[] calldata publicKeys, uint64[] memory operatorIds, Cluster memory cluster) external +function exitValidator(bytes calldata publicKey, uint64[] calldata operatorIds) external +function bulkExitValidator(bytes[] calldata publicKeys, uint64[] calldata operatorIds) external +``` + +### SSVClusters + +```solidity +function liquidate(address clusterOwner, uint64[] calldata operatorIds, Cluster memory cluster) external nonReentrant +function liquidateSSV(address clusterOwner, uint64[] calldata operatorIds, Cluster memory cluster) external nonReentrant +function reactivate(uint64[] calldata operatorIds, Cluster memory cluster) external payable +function deposit(address clusterOwner, uint64[] calldata operatorIds, Cluster memory cluster) external payable +function withdraw(uint64[] calldata operatorIds, uint256 amount, Cluster memory cluster) external nonReentrant +function migrateClusterToETH(uint64[] calldata operatorIds, Cluster memory cluster) external payable +function updateClusterBalance(uint64 blockNum, address clusterOwner, uint64[] calldata operatorIds, Cluster memory cluster, uint32 effectiveBalance, bytes32[] calldata merkleProof) external nonReentrant +``` + +### SSVDAO + +```solidity +function updateNetworkFee(uint256 fee) external // onlyOwner +function updateNetworkFeeSSV(uint256 fee) external // onlyOwner +function withdrawNetworkSSVEarnings(uint256 amount) external nonReentrant // onlyOwner +function updateOperatorFeeIncreaseLimit(uint64 percentage) external // onlyOwner +function updateDeclareOperatorFeePeriod(uint64 timeInSeconds) external // onlyOwner +function updateExecuteOperatorFeePeriod(uint64 timeInSeconds) external // onlyOwner +function updateLiquidationThresholdPeriod(uint64 blocks) external // onlyOwner +function updateLiquidationThresholdPeriodSSV(uint64 blocks) external // onlyOwner +function updateMinimumLiquidationCollateral(uint256 amount) external // onlyOwner +function updateMinimumLiquidationCollateralSSV(uint256 amount) external // onlyOwner +function updateMaximumOperatorFee(uint256 maxFee) external // onlyOwner +function updateMinimumOperatorEthFee(uint256 minFee) external // onlyOwner +function commitRoot(bytes32 merkleRoot, uint64 blockNum) external // oracle only +function replaceOracle(uint32 oracleId, address newOracle) external // onlyOwner +function setQuorumBps(uint16 quorum) external // onlyOwner +function setUnstakeCooldownDuration(uint64 duration) external // onlyOwner +``` + +### SSVStaking + +```solidity +function syncFees() external nonReentrant +function stake(uint256 amount) external nonReentrant +function requestUnstake(uint256 amount) external nonReentrant +function withdrawUnlocked() external nonReentrant +function claimEthRewards() external nonReentrant +function rescueERC20(address token, address to, uint256 amount) external nonReentrant // onlyOwner +function onCSSVTransfer(address from, address to, uint256 amount) external // cSSV only +``` + +### SSVNetwork (Proxy-level) + +```solidity +function initialize(...) external initializer onlyProxy +function setFeeRecipientAddress(address recipientAddress) external // anyone +function updateModule(SSVModules moduleId, address moduleAddress) external // onlyOwner +function getVersion() external pure returns (string memory) // "v2.0.0" +``` + +--- + +## 9. Access Control Matrix + +| Role | Who | Functions | +|---|---|---| +| **Owner** | Contract owner (Ownable2Step) | All `update*`, `withdraw*Network*`, `replaceOracle`, `setQuorumBps`, `setUnstakeCooldownDuration`, `updateModule`, `rescueERC20`, `_authorizeUpgrade` | +| **Operator Owner** | `msg.sender == operator.owner` | `removeOperator`, `declareOperatorFee`, `executeOperatorFee`, `cancelDeclaredOperatorFee`, `reduceOperatorFee`, `setOperators*`, `withdraw*OperatorEarnings*` | +| **Cluster Owner** | `msg.sender == owner` in cluster key | `reactivate`, `withdraw`, `migrateClusterToETH`, `registerValidator`, `bulkRegisterValidator`, `removeValidator`, `bulkRemoveValidator`, `exitValidator`, `bulkExitValidator` | +| **Oracle** | `oracleIdOf[msg.sender] != 0` | `commitRoot` | +| **cSSV Token** | `msg.sender == CSSV_ADDRESS` | `onCSSVTransfer` | +| **Anyone** | Any address | `liquidate` (if liquidatable), `liquidateSSV` (if liquidatable), `deposit`, `updateClusterBalance`, `registerOperator`, `syncFees`, `stake`, `requestUnstake`, `withdrawUnlocked`, `claimEthRewards`, `setFeeRecipientAddress`, all view functions | + +--- + +## 10. Accounting Formulas + +### Fee Settlement Rule + +When an operator fee changes (`executeOperatorFee`, `reduceOperatorFee`), the operator's ETH snapshot is updated **before** the new fee is stored. This ensures all earnings accrued up to the current block are settled at the **old** fee rate. The new fee applies only to blocks going forward — there is no retroactive impact on cluster index calculations. + +``` +// On fee change: +operator.ethSnapshot.balance += (block.number - ethSnapshot.block) * PackedETH.unwrap(operator.ethFee) +operator.ethSnapshot.block = block.number +operator.ethFee = newFee // takes effect from this block onward +``` + +### ETH Network Fee Index + +``` +currentIndex = sp.ethNetworkFeeIndex + (block.number - sp.ethNetworkFeeIndexBlockNumber) * PackedETH.unwrap(sp.ethNetworkFee) +``` + +### ETH Operator Fee Index + +``` +operator.ethSnapshot.index += (block.number - ethSnapshot.block) * PackedETH.unwrap(operator.ethFee) +``` + +### ETH Operator Earnings (with EB) + +``` +effectiveVUnits = seb.operatorEthVUnits[operatorId] + operator.ethValidatorCount * VUNITS_PRECISION +operator.ethSnapshot.balance += (blockDiff * ethFee * effectiveVUnits) / VUNITS_PRECISION +``` + +### ETH Cluster Balance Update + +``` +clusterVUnits = (seb.clusterEB[id].vUnits == 0) ? validatorCount * 10_000 : seb.clusterEB[id].vUnits + +idxOp = clusterIndex - cluster.index +idxNet = currentNetworkFeeIndex - cluster.networkFeeIndex +networkFeeUnits = (idxNet * clusterVUnits) / VUNITS_PRECISION +operatorFeeUnits = (idxOp * clusterVUnits) / VUNITS_PRECISION +totalFees = (networkFeeUnits + operatorFeeUnits) * ETH_DEDUCTED_DIGITS + +cluster.balance = max(0, cluster.balance - totalFees) +``` + +### SSV Network Fee Index (Legacy) + +``` +currentIndex = sp.networkFeeIndex + (block.number - sp.networkFeeIndexBlockNumber) * PackedSSV.unwrap(sp.networkFee) +``` + +### SSV Cluster Balance Update (Legacy) + +``` +usage = (clusterIndexSSV - cluster.index + currentNetworkFeeIndexSSV - cluster.networkFeeIndex) * cluster.validatorCount +cluster.balance = max(0, cluster.balance - unpack(usage)) +``` + +### ETH Liquidation Check + +``` +burnRate = Σ PackedETH.unwrap(operator.ethFee) for all operators in cluster +networkFee = PackedETH.unwrap(sp.ethNetworkFee) +thresholdUnits = (minimumBlocksBeforeLiquidation * (burnRate + networkFee) * vUnits) / VUNITS_PRECISION + +liquidatable = (balance < unpack(minimumLiquidationCollateral)) + || (balance < thresholdUnits * ETH_DEDUCTED_DIGITS) +``` + +### SSV Liquidation Check (Legacy) + +``` +burnRate = Σ PackedSSV.unwrap(operator.fee) +networkFee = PackedSSV.unwrap(sp.networkFee) + +liquidatable = (balance < unpack(minimumLiquidationCollateralSSV)) + || (balance < unpack((burnRate + networkFee) * validatorCount * minimumBlocksBeforeLiquidationSSV)) +``` + +### Staking Reward Accumulation + +``` +// syncFees: +newDaoEarnings = sp.networkTotalEarnings() // ETH DAO total +newFees = newDaoEarnings - stakingEthPoolBalance +accEthPerShare += (unpack(newFees) * 1e18) / cSSV.totalSupply() +stakingEthPoolBalance = newDaoEarnings + +// settle(user): +pending = (cSSVBalance * (accEthPerShare - userIndex[user])) / 1e18 +accrued[user] += pending +userIndex[user] = accEthPerShare +``` + +--- + +## 11. Governance Parameters + +### ETH Cluster Parameters + +| Parameter | Initial Value | Update Function | +|---|---|---| +| `ethNetworkFee` | 0.000000003550929823 ETH/block (~0.00928 ETH/year) | `updateNetworkFee(uint256)` | +| `minimumLiquidationCollateral` | 0.00094 ETH | `updateMinimumLiquidationCollateral(uint256)` | +| `minimumBlocksBeforeLiquidation` | 50,190 blocks (~7 days) | `updateLiquidationThresholdPeriod(uint64)` | +| `operatorMaxFee` | TBD | `updateMaximumOperatorFee(uint256)` | +| `minimumOperatorEthFee` | TBD | `updateMinimumOperatorEthFee(uint256)` | + +### SSV Cluster Parameters (Legacy) + +| Parameter | Current Value | Proposed Value | Update Function | +|---|---|---|---| +| `networkFee` (SSV) | current | current | `updateNetworkFeeSSV(uint256)` | +| `minimumLiquidationCollateralSSV` | 1.53 SSV | 0.883 SSV | `updateMinimumLiquidationCollateralSSV(uint256)` | +| `minimumBlocksBeforeLiquidationSSV` | 100,380 (~14 days) | 100,380 (~14 days) | `updateLiquidationThresholdPeriodSSV(uint64)` | +| `operatorMaxFeeSSV` | current | -- | No update function (read-only, frozen) | + +### Staking Parameters + +| Parameter | Initial Value | Update Function | +|---|---|---| +| `cooldownDuration` | 604,800 seconds (7 days) | `setUnstakeCooldownDuration(uint64)` | + +**Note on units:** `cooldownDuration` is measured in **seconds** (timestamp-based, via `block.timestamp`), not blocks. The value 604,800 = 7 days in seconds. See `SSVStaking.sol:88`: `uint64(block.timestamp + s.cooldownDuration)`. + +### Oracle Parameters + +| Parameter | Initial Value | Update Function | +|---|---|---| +| `quorumBps` | 7,500 (75%) | `setQuorumBps(uint16)` | +| Oracle set | 4 oracles | `replaceOracle(uint32, address)` | + +### Operator Fee Parameters + +| Parameter | Value | Update Function | +|---|---|---| +| `defaultOperatorETHFee` | 1,770,000,000 wei (~0.00464 ETH/year) | Hardcoded | +| `declareOperatorFeePeriod` | Governance-set | `updateDeclareOperatorFeePeriod(uint64)` | +| `executeOperatorFeePeriod` | Governance-set | `updateExecuteOperatorFeePeriod(uint64)` | +| `operatorMaxFeeIncrease` | Governance-set | `updateOperatorFeeIncreaseLimit(uint64)` | + +--- + +## 12. Error Codes + +### Cluster Errors +- `ClusterAlreadyEnabled` — reactivating an already active cluster +- `ClusterIsLiquidated` — operating on a liquidated cluster +- `ClusterNotLiquidatable` — liquidation attempted but cluster is solvent +- `ClusterDoesNotExist` — cluster not found +- `InsufficientBalance` — balance too low for operation +- `InvalidPublicKeyLength` — validator public key wrong length +- `ValidatorAlreadyExistsWithData(bytes publicKey)` — validator already registered +- `ValidatorDoesNotExist` — validator not found +- `IncorrectClusterState` — submitted cluster struct doesn't match stored hash +- `IncorrectClusterVersion` — operating on wrong cluster version (e.g. SSV cluster for ETH operation) +- `IncorrectValidatorStateWithData(bytes publicKey)` — validator state mismatch +- `NewBlockPeriodIsBelowMinimum` — liquidation threshold too low +- `InvalidOperatorIdsLength` — wrong number of operator IDs +- `UnsortedOperatorsList` — operator IDs not sorted +- `EmptyPublicKeysList` — no public keys provided +- `PublicKeysSharesLengthMismatch` — public keys and shares arrays differ in length + +### Operator Errors +- `CallerNotOwnerWithData(address caller, address owner)` — msg.sender not operator owner +- `CallerNotWhitelistedWithData(uint64 operatorId)` — whitelist check failed +- `OperatorAlreadyExists` — duplicate operator registration +- `OperatorDoesNotExist` — operator not found +- `InsufficientBalance` — insufficient earnings to withdraw +- `FeeTooLow` — fee below minimum operator ETH fee +- `FeeTooHigh` — fee exceeds maximum operator fee +- `FeeExceedsIncreaseLimit` — fee increase exceeds max allowed +- `FeeIncreaseNotAllowed` — zero-fee operator cannot increase +- `SameFeeChangeNotAllowed` — declared fee same as current +- `ApprovalNotWithinTimeframe` — fee execute outside window +- `NoFeeDeclared` — no pending fee change request +- `ExceedValidatorLimitWithData(uint64 operatorId)` — operator at validator capacity +- `TargetModuleDoesNotExistWithData(uint8 moduleId)` — module not registered +- `IncorrectOperatorVersion(uint8 operatorVersion)` — wrong operator version for operation +- `LegacyOperatorFeeDeclarationInvalid` — pre-migration fee declaration +- `OperatorsListNotUnique` — duplicate operator IDs in list + +### Whitelist Errors +- `InvalidContractAddress` — invalid whitelist contract address +- `AddressIsWhitelistingContract(address contractAddress)` — address already a whitelisting contract +- `InvalidWhitelistingContract(address contractAddress)` — contract doesn't implement interface +- `InvalidWhitelistAddressesLength` — whitelist address array length mismatch +- `ZeroAddressNotAllowed` — zero address not permitted + +### Packing Errors +- `MaxValueExceeded` — packed value overflow +- `MaxPrecisionExceeded` — fee value not divisible by precision factor + +### Oracle/EB Errors +- `NotOracle` — caller not registered oracle +- `AlreadyVoted` — oracle already voted for this block +- `StaleBlockNumber` — block number not newer than last committed +- `FutureBlockNumber` — block number in the future +- `InvalidProof` — Merkle proof verification failed +- `RootNotFound` — no committed root for block number +- `StaleUpdate` — EB update is outdated +- `UpdateTooFrequent` — min blocks between updates not met +- `EBBelowMinimum` — effective balance below minimum +- `EBExceedsMaximum` — effective balance above maximum +- `OracleAlreadyAssigned` — oracle address already in use +- `OracleHasZeroWeight` — cSSV totalSupply is zero (no oracle weight) +- `InvalidQuorum` — quorum value out of valid range + +### Staking Errors +- `NothingToWithdraw` — no unlocked unstake requests +- `NothingToClaim` — no accrued rewards to claim +- `MaxRequestsAmountReached` — exceeded MAX_PENDING_REQUESTS (2000) +- `UnstakeAmountExceedsBalance` — unstake amount exceeds cSSV balance +- `StakeTooLow` — stake amount below MINIMAL_STAKING_AMOUNT +- `ZeroAmount` — amount is zero +- `InvalidToken` — cannot rescue protected tokens +- `NotCSSV` — caller is not the cSSV token contract +- `ZeroAmount` — SSV amount to stake is zero + +### General Errors +- `NotAuthorized` — unauthorized action +- `ZeroAddress` — zero address not allowed +- `ETHTransferFailed` — ETH transfer reverted +- `TokenTransferFailed` — ERC-20 transfer reverted + +--- + +## 13. Constants + +```solidity +// Precision +uint32 constant VUNITS_PRECISION = 10_000; +uint256 constant ETH_DEDUCTED_DIGITS = 100_000; +uint256 constant DEDUCTED_DIGITS = 10_000_000; + +// EB Limits +uint256 constant MAX_EB_PER_VALIDATOR = 2048 ether; +uint256 constant DEFAULT_EB_PER_VALIDATOR = 32 ether; + +// Operator Defaults +uint256 constant DEFAULT_OPERATOR_ETH_FEE = 1_770_000_000; // 1.77 gwei/vUnit/block + +// Protocol Limits +uint64 constant MINIMAL_LIQUIDATION_THRESHOLD = 21_480; // blocks +uint256 constant MAX_PENDING_REQUESTS = 2000; +uint256 constant MINIMAL_STAKING_AMOUNT = 1_000_000_000; +uint256 constant MAX_DELEGATION_SLOTS = 4; + +// Version +uint8 constant VERSION_SSV = 0; +uint8 constant VERSION_ETH = 1; +uint8 constant VERSION_UNDEFINED = 255; +``` + +--- + +END OF SPEC.md diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index cda8e922..00000000 --- a/docs/architecture.md +++ /dev/null @@ -1,40 +0,0 @@ -# SSV Network - -### [Intro](../README.md) | Architecture | [Setup](setup.md) | [Tasks](tasks.md) | [Local development](local-dev.md) | [Roles](roles.md) | [Publish](publish.md) | [Operator owners](operators.md) - -## Contract Architecture - -The architecture of the contracts is based on [EIP-2535 Diamond MultiFacet Proxy](https://eips.ethereum.org/EIPS/eip-2535) with some changes mainly to be compatible with regular block explorers like Etherscan. Main goals: - -- **Modularity** - As the system evolves, we need to be able to move fast incorporating or changing functionalities without facing limitations like the contract size or disturbing existing architecture. -- **Upgradeability** - Allowing the DAO to evolve the system or solve issues. The process can be deactivated if such a decision is made. -- **Resilient innovation** - To encourage developer adoption, we designed a system easy to integrate and use. - -### Main components - -#### SSVNetwork - -It's the main entry point for users, used for operations and management. It acts as a proxy for the _module_ contracts, where all functions that contain logic reside. All events are fired from the SSVNetwork contract. - -It's an [UUPS](https://eips.ethereum.org/EIPS/eip-1822) upgradeable contract. Apart from the state variables inherited by the UUPS Openzeppelin implementation, the contract storage is managed by the [Diamond storage pattern](https://eip2535diamonds.substack.com/i/65777640/diamond-storage) using a specific library. - -The fallback function is implemented to delegate all calls to the SSVViews module. -Any module interface can be used with this contract, so then you can access only the functions and events related to the specific interface of the module. This is helpful when you want access to a restricted set of functionalities belonging to Operators, Clusters, etc. - -#### SSVNetworkViews - -It's the main contract for reading information about the network and its participants. - -#### Modules - -Non-upgradeable, stateless contracts that contain the logic to support Clusters, Operators, and Protocol (DAO / Network) functionalities. - -**Important**: Interacting directly with module contracts is not effective as you are not interacting with the correct state maintained by the main contract `SSVNetwork`. All interactions should be done via main contracts: `SSVNetwork` or `SSVNetworkViews`. - -#### Libraries - -Libraries are a fundamental part of the architecture to support reusable pieces efficiently. Also, `SSVStorage` and `SSVStorageProtocol` implement the Diamond storage pattern. - -#### SSV Token - -The native SSV token is used to facilitate payments between stakers and SSV node operators to maintain their validators. diff --git a/docs/local-dev.md b/docs/local-dev.md deleted file mode 100644 index f392bfe1..00000000 --- a/docs/local-dev.md +++ /dev/null @@ -1,140 +0,0 @@ -# SSV Network - -### [Intro](../README.md) | [Architecture](architecture.md) | [Setup](setup.md) | [Tasks](tasks.md) | Local development | [Roles](roles.md) | [Publish](publish.md) | [Operator owners](operators.md) - -## Running against a local node / testnet - -You can deploy and run these contracts in a local node like Hardhat's, Ganache, or public testnets. This guide will cover the process. - -### Run [Setup](setup.md) - -Execute the steps to set up all tools needed. - -### Configure Environment - -Copy [.env.example](../.env.example) to `.env` and edit to suit. - -- `[NETWORK]_ETH_NODE_URL` RPC URL of the node -- `[NETWORK]_OWNER_PRIVATE_KEY` Private key of the deployer account, without 0x prefix -- `GAS_PRICE` Example 30000000000 -- `GAS` Example 8000000 -- `ETHERSCAN_KEY` Etherescan API key to verify deployed contracts -- `SSV_TOKEN_ADDRESS` SSV Token contract address to be used in custom networks. Keep it empty to deploy a mocked SSV token. -- `MINIMUM_BLOCKS_BEFORE_LIQUIDATION` A number of blocks before the cluster enters into a liquidatable state. Example: 214800 = 30 days -- `OPERATOR_MAX_FEE_INCREASE` The fee increase limit in percentage with this format: 100% = 10000, 10% = 1000 - using 10000 to represent 2 digit precision -- `DECLARE_OPERATOR_FEE_PERIOD` The period in which an operator can declare a fee change (seconds) -- `EXECUTE_OPERATOR_FEE_PERIOD` The period in which an operator fee change can be executed (seconds) -- `VALIDATORS_PER_OPERATOR_LIMIT` The number of validators an operator can manage -- `MINIMUM_LIQUIDATION_COLLATERAL` The lowest number in wei a cluster can have before its liquidatable -- `QUORUM_BPS` Oracle quorum threshold in basis points (0-10000). Example: 6700 = 67% -- `DEFAULT_ORACLE_IDS` Comma-separated list of 4 oracle IDs used for default delegation. Example: 1,2,3,4 - -#### Network configuration - -In [hardhat.config.ts](../hardhat.config.ts) you can find specific configs for different networks, that are taken into account only when the `[NETWORK]_ETH_NODE_URL` parameter in `.env` file is set. -For example, in `.env` file you can set: - -``` -HOLESKY_ETH_NODE_URL="https://holesky.infura.io/v3/..." -NODE_PROVIDER_KEY="abcd1234..." -HOLESKY_OWNER_PRIVATE_KEY="d79d.." -``` - -That means Hardhat will pick `config.networks.holesky` section in `hardhat.config.ts` to set the network parameters. - -### Start the local node - -To run the local node, execute the command in a separate terminal. - -```sh -npx hardhat node -``` - -For more details about it and how to use MainNet forking you can find [here](https://hardhat.org/hardhat-network/). - -### Deployment - -The inital deployment process involves the deployment of all main modules (SSVClusters, SSVOperators, SSVDAO and SSVViews), SSVNetwork and SSVNetworkViews contracts. - -Note: The SSV token address used when deploying to live networks (holesky, mainnet) is set in the hardhat config file. To deploy the contracts to a custom network defined in the hardhat config file, leave `SSVTOKEN_ADDRESS` empty in the `.env` file. You can set a specific SSV token address for custom networks too, if needed. - -To run the deployment, execute: - -```sh -npx hardhat --network deploy:all -``` - -Output of this action will be: - -```sh -Deploying contracts with the account:0xf39... -SSVOperators module deployed to: 0x5Fb... -SSVClsuters module deployed to: 0xe7f1... -SSVDAO module deployed to: 0x9fE4... -SSVViews module deployed to: 0xCf7E... -Deploying SSVNetwork with ssvToken 0x3a9f... -SSVNetwork proxy deployed to: 0x5FC8... -SSVNetwork implementation deployed to: 0xDc64... -Deploying SSVNetworkViews with SSVNetwork 0x5FC8... -SSVNetworkViews proxy deployed to: 0xa513... -SSVNetworkViews implementation deployed to: 0x0165... -``` - -As general rule, you can target any network configured in the `hardhat.config.ts`, specifying the right [network]\_ETH_NODE_URL and [network]\_OWNER_PRIVATE_KEY in `.env` file. - -### Verification on etherscan (only public networks) - -You can now go to Etherscan and see: - -- `SSVNetwork` proxy contract is deployed to the address shown previously in `SSVNetwork proxy deployed to` -- `SSVNetwork` implementation contract is deployed to the address shown previously in `SSVNetwork implementation deployed to` -- `SSVNetworkViews` proxy contract is deployed to the address shown previously in `SSVNetworkViews proxy deployed to` -- `SSVNetworkViews` implementation contract is deployed to the address shown previously in `SSVNetworkViews implementation deployed to` - -Open `.openzeppelin/.json` file and find `[impls..address]` value which is the implementation smart contract address. -You will find 2 `[impls.]` entries, one for `SSVNetwork` and another for `SSVNetworkViews`. -Run this verification process for both. - -You can take it from the output of the `npx hardhat --network deploy:all` command. - -To verify a proxy contract (SSVNetwork, SSVNetworkViews), run this: - -```sh -npx hardhat verify --network -``` - -By verifying a contract using its proxy address, the verification process for both the proxy and the implementation contracts is conducted seamlessly. -The proxy contract is automatically linked to the implementation contract. -As a result, users will be able to view interfaces of both the proxy and the implementation contracts on the Etherscan website's contract page, ensuring comprehensive visibility and transparency. - -To verify a module contract (SSVClusters, SSVOperators, SSVDAO, SSVViews), run this: - -```sh -npx hardhat verify --network -``` - -Output of this action will be: - -```sh -Nothing to compile -No need to generate any newer typings. -Successfully submitted source code for contract -contracts/SSVNetwork.sol:SSVNetwork at 0x2279B7... -for verification on the block explorer. Waiting for verification result... - -Successfully verified contract SSVNetwork on Etherscan. -https://holesky.etherscan.io/address/0x227...#code -``` - -After this action, you can go to the proxy contract in Etherscan and start interacting with it. - -### How to resolve issues during the verification - -- Error: no such file or directory, open ‘…/artifacts/build-info/XXXX...XXXX.json’ - -This issue can be resolved by executing the following commands. - -```sh -npx hardhat clean -npx hardhat compile -``` diff --git a/docs/operators.md b/docs/operators.md deleted file mode 100644 index 7acb41d6..00000000 --- a/docs/operators.md +++ /dev/null @@ -1,75 +0,0 @@ -# SSV Network - -### [Intro](../README.md) | [Architecture](architecture.md) | [Setup](setup.md) | [Tasks](tasks.md) | [Local development](local-dev.md) | [Roles](roles.md) | [Publish](publish.md) | Operator owners - -## Registering an operator -The function `SSVNetwork.registerOperator()` is used to register a validator. -Input parameters: -`publicKey`: The public key of the operator -`fee`: Should be `0` or greater than `100000000` and less than the value returned by `SSVNetworkViews.getMaximumOperatorFee()` -`setPrivate`: Flag to set the privacy status of the operator. Public means anyone can use the operator for registering validators. Private means only the operator's whitelisted addresses can. - -After the operator is registered, the caller becomes the `owner`, the `fee` is set and the `whitelisted` status is set to `false`. -The `whitelisted` flag of the operator indicates if the operator is private (when set to `true`) or public (`false`), - -## Whitelisted operators -An operator owner can restrict the usage of it to specific EOAs, generic contracts and whitelisting contracts. -A whitelisting contract is the one that implements the [ISSVWhitelistingContract](../contracts/interfaces/external/ISSVWhitelistingContract.sol) interface. - -The restriction is only effective when the operator owner sets the privacy status of the operator to *private*. - -To manage the whitelisted addresses, these 2 data structures are used: - -`mapping(uint64 => address) operatorsWhitelist`: Keeps the relation between an operator and a whitelisting contract. -`mapping(address => mapping(uint256 => uint256)) addressWhitelistedForOperators`: Links an address (EOA/generic contract) to a list of operators identified by its `operatorId` using bitmaps. - -### What is a Whitelisting Contract? -The operators can choose to whitelist an external contract with custom logic to manage authorized addresses externally. To be used in SSV contracts, it needs to implement the [ISSVWhitelistingContract](../contracts/interfaces/external/ISSVWhitelistingContract.sol) interface, that requires to implement the `isWhitelisted(address account, uint256 operatorId)` function. This function is called in the register validator process, that must return `true/false` to indicate if the caller (`msg.sender`) is whitelisted for the operator. - -It's up to the implementation of the whitelisting contract to use the `operatorId` parameter in the `isWhitelisted` function. - -To check if a contact is a valid whitelisting contract, use the function `SSVNetworkViews.isWhitelistingContract(address contractAddress)`. - -To check if an account is whitelisted in a whitelisting contract, use the function `SSVNetworkViews.isAddressWhitelistedInWhitelistingContract(address account, uint256 operatorId, address whitelistingContract)`. - -### Legacy whitelisted addresses transition process -Up until v1.1.1, operators use the `operatorsWhitelist` mapping to save EOAs and generic contracts. Now in v1.2.0, those type of addresses are stored in `addressWhitelistedForOperators`, leaving `operatorsWhitelist` to save only whitelisting contracts. -When whitelisting a new whitelisting contract, the current address stored in `operatorsWhitelist` will be moved to `addressWhitelistedForOperators`, and the new address stored in `operatorsWhitelist`. -When whitelisting a new EOA/generic contract, it will be saved in `addressWhitelistedForOperators`, leaving the previous address in `operatorsWhitelist` intact. - -### Operator whitelist states -The following table shows all possible combinations of whitelisted addresses for a given operator. -| Use legacy EOA/generic contract | Use whitelisting contract | Use EOAs/generic contracts | -|---|---|---| -| Y | | | -| Y | | Y | -| | Y | | -| | | Y | -| | Y | Y | - -The operarator status changes to private (`Operator.whitelisted == true`), so only the whitelisted addresses can use the operator's services when the operator owner explicitly sets the *private* status calling `SSVNetwork.setOperatorsPrivateUnchecked()`, no matter if it has whitelisted addresses. - -The operarator status changes to public (`Operator.whitelisted == false`), so anyone can use the operator's services when the operator owner explicitly sets the public status calling `SSVNetwork.setOperatorsPublicUnchecked()`, no matter if it still has whitelisted addresses. - -### Registering whitelist addresses -Functions related to whitelisting contracts: -- Register: `SSVNetwork.setOperatorsWhitelistingContract(uint64[] calldata operatorIds, ISSVWhitelistingContract whitelistingContract)` -- Remove: `SSVNetwork.removeOperatorsWhitelistingContract(uint64[] calldata operatorIds)` - -Functions related to EOAs/generic contracts: -- Register multiple addresses to multiple operators: `SSVNetwork.setOperatorsWhitelists(uint64[] calldata operatorIds, address[] calldata whitelistAddresses)` -- Remove multiple addresses for multiple operators: `SSVNetwork.removeOperatorsWhitelists(uint64[] calldata operatorIds, address[] calldata whitelistAddresses)` - -### Registering validators using whitelisted operators -When registering validators using `SSVNetwork.registerValidator` or `SSVNetwork.registerValidator`, the flow to check if the caller is authorized to use a whitelisted operator is the following: -1. Check if the operator is whitelisted via the SSV whitelisting module, using `addressWhitelistedForOperators`. -2. Check if the operator has a whitelisted address in `operatorsWhitelist`. - 1. Check if the caller is the whitelisted address. In this step we keep the whitelisting system backward compatible with previous whitelisted EOAs/generic contracts. - 2. Check if the address is a whitelisting contract. Then call its `isWhitelisted()` function. - -If the caller is not authorized for any of the whitelisted operators, the transaction will revert with the `CallerNotWhitelistedWithData()` error. - -**Important**: Changes to an operator's whitelist will not impact existing validators registered with that operator. Only new validator registrations will adhere to the updated whitelist rules. - - - diff --git a/docs/publish.md b/docs/publish.md deleted file mode 100644 index ebe0ba6a..00000000 --- a/docs/publish.md +++ /dev/null @@ -1,44 +0,0 @@ -# SSV Network - -### [Intro](../README.md) | [Architecture](architecture.md) | [Setup](setup.md) | [Tasks](tasks.md) | [Local development](local-dev.md) | [Roles](roles.md) | Publish | [Operator owners](operators.md) - -## Prerequisites - -- Ensure you have [Node.js](https://nodejs.org/) and [npm](https://www.npmjs.com/) installed. -- An npm account. [Sign up here](https://www.npmjs.com/signup) if you don't have one. - -## Prepare Package - -Before publishing, make sure your `package.json` is properly set up: - -- `name`: The package name (must be unique on npm). -- `version`: The current version of the package. -- `description`: A brief description of your package. -- `main`: The entry point of your package (usually `index.js`). -- `scripts`: Any scripts you want to include, like build or test scripts. -- `author`: The author's name and contact information. -- `repository`: The repository URL where your code is located. -- `keywords`: An array of keywords to help users discover your package. -- `files`: An array of file patterns that describes which files should be included when your package is installed. -- `dependencies` and `devDependencies`: Any required packages. - -## Authenticate with npm - -- Log in to your npm account from the command line: - -```bash -npm login -``` - -- Enter your npm username, password, and email address when prompted. - -## Configure GitHub Actions for Automated Publishing - -- Create a [.github/workflows/publish.yaml](../.github/workflows/publish.yaml) file in your project. -- Define the npm publishing process using GitHub Actions: -- Add your npm token `NPM_TOKEN` to the GitHub repository secrets (Settings > Secrets). - -## Publish Package - -- Generate a release in the `main` branch of the `ssv_network` GitHub repository. -- The GitHub Actions workflow will automatically publish the package to npm. diff --git a/docs/roles.md b/docs/roles.md deleted file mode 100644 index 8cf1da08..00000000 --- a/docs/roles.md +++ /dev/null @@ -1,54 +0,0 @@ -# SSV Network - -### [Intro](../README.md) | [Architecture](architecture.md) | [Setup](setup.md) | [Tasks](tasks.md) | [Local development](local-dev.md) | Roles | [Publish](publish.md) | [Operator owners](operators.md) - -## Contract owner - -The contract owner can perform operational actions over the contract and protocol updates. - -### Contract operations - -- Upgrade `SSVNetwork` and `SSVNetworkViews` -- `SSVNetwork.upgradeModule()` - Update any module - -### Protocol updates - -- `SSVNetwork.updateNetworkFee()` - Updates the network fee -- `SSVNetwork.withdrawNetworkEarnings()` - Withdraws network earnings -- `SSVNetwork.updateOperatorFeeIncreaseLimit()` - Updates the limit on the percentage increase in operator fees -- `SSVNetwork.updateDeclareOperatorFeePeriod()` - Updates the period for declaring operator fees -- `SSVNetwork.updateExecuteOperatorFeePeriod()` - Updates the period for executing operator fees -- `SSVNetwork.updateLiquidationThresholdPeriod()` - Updates the liquidation threshold period -- `SSVNetwork.updateMinimumLiquidationCollateral()` - Updates the minimum collateral required to prevent liquidation -- `SSVNetwork.updateMaximumOperatorFee()` - Updates the maximum fee an operator can set - -## Operator owner - -Only the owner of an operator can execute these functions: - -- `SSVNetwork.removeOperator` - Removes an existing operator -- `SSVNetwork.setOperatorsWhitelists` - Sets a list of whitelisted addresses (EOAs or generic contracts) for a list of operators -- `SSVNetwork.removeOperatorsWhitelists` - Removes a list of whitelisted addresses (EOAs or generic contracts) for a list of operators -- `SSVNetwork.setOperatorsWhitelistingContract` - Sets a whitelisting contract for a list of operators -- `SSVNetwork.removeOperatorsWhitelistingContract` - Removes the whitelisting contract set for a list of operators -- `SSVNetwork.setOperatorsPrivateUnchecked` - Set the list of operators as private without checking for any whitelisting address -- `SSVNetwork.setOperatorsPublicUnchecked` - Set the list of operators as public without removing any whitelisting address -- `SSVNetwork.declareOperatorFee` - Declares the operator's fee change -- `SSVNetwork.executeOperatorFee` - Executes the operator's fee change -- `SSVNetwork.cancelDeclaredOperatorFee` - Cancels the declared operator's fee -- `SSVNetwork.reduceOperatorFee` - Reduces the operator's fee -- `SSVNetwork.withdrawOperatorEarnings` - Withdraws operator earnings -- `SSVNetwork.withdrawAllOperatorEarnings` - Withdraws all operator earnings - -## Cluster owner - -Only the owner of a cluster can execute these functions: - -- `SSVNetwork.registerValidator` - Registers a new validator on the SSV Network -- `SSVNetwork.bulkRegisterValidator` - Registers a set of validators in the same cluster on the SSV Network -- `SSVNetwork.removeValidator` - Removes an existing validator from the SSV Network -- `SSVNetwork.bulkRemoveValidator` - Bulk removes a set of existing validators in the same cluster from the SSV Network -- `SSVNetwork.reactivate` - Reactivates a cluster -- `SSVNetwork.withdraw` - Withdraws tokens from a cluster -- `SSVNetwork.exitValidator` - Starts the exit protocol for an exisiting validator -- `SSVNetwork.bulkExitValidator` - Starts the exit protocol for a set of existing validators diff --git a/docs/setup.md b/docs/setup.md deleted file mode 100644 index b4307d7b..00000000 --- a/docs/setup.md +++ /dev/null @@ -1,34 +0,0 @@ -# SSV Network - -### [Intro](../README.md) | [Architecture](architecture.md) | Setup | [Tasks](tasks.md) | [Local development](local-dev.md) | [Roles](roles.md) | [Publish](publish.md) | [Operator owners](operators.md) - -## Developer Setup - -The stack is a simple one: - -- Solidity -- JavaScript -- Node/NPM -- HardHat -- Ethers - -### Install Node (also installs NPM) - -- Use the latest [LTS (long-term support) version](https://nodejs.org/en/download/). - -### Install required Node modules - -All NPM resources are project local. No global installs are required. - -``` -cd path/to/ssv-network -npm install -``` - -### Configure Environment - -- Copy [.env.example](../.env.example) to `.env` and edit to suit. -- API keys are only needed for deploying to public networks. -- `.env` is included in `.gitignore` and will not be committed to the repo. - -At this moment you are ready to run tests, compile contracts and run coverage tests. diff --git a/docs/tasks.md b/docs/tasks.md deleted file mode 100644 index dfa7882c..00000000 --- a/docs/tasks.md +++ /dev/null @@ -1,152 +0,0 @@ -# SSV Network - -### [Intro](../README.md) | [Architecture](architecture.md) | [Setup](setup.md) | Tasks | [Local development](local-dev.md) | [Roles](roles.md) | [Publish](publish.md) | [Operator owners](operators.md) - -## Development scripts - -All scripts can be executed using `package.json` scripts. - -### Build the contracts - -This creates the build artifacts for deployment or testing - -``` -npm run build -``` - -### Test the contracts - -This builds the contracts and runs the unit tests. It also runs the gas reporter and it outputs the report at the end of the tests. - -``` -npm run test -``` - -### Run the code coverage - -This builds the contracts and runs the code coverage. This is slower than testing since it makes sure that every line of our contracts is tested. It outputs the report in folder `coverage`. - -``` -npm run solidity-coverage -``` - -### Slither - -Runs the static analyzer [Slither](https://github.com/crytic/slither), to search for common solidity vulnerabilities. By default it analyzes all contracts. -`npm run slither` - -### Size contracts - -Compiles the contracts and report the size of each one. Useful to check to not surpass the 24k limit. - -``` -npm run size-contracts -``` - -## Development tasks - -This project uses hardhat tasks to perform the deployment and upgrade of the main contracts and modules. - -Following Hardhat's way of working, you must specify the network against which you want to run the task using the `--network` parameter. In all the following examples, the holesky network will be used, but you can specify any defined in your `hardhat.config` file. - -### Deploy all contracts - -Runs the deployment of the main SSVNetwork and SSVNetworkViews contracts, along with their associated modules: - -``` -npx hardhat --network holesky_testnet deploy:all -``` - -When deploying to live networks like Holesky or Mainnet, please double check the environment variables: - -- MINIMUM_BLOCKS_BEFORE_LIQUIDATION -- MINIMUM_LIQUIDATION_COLLATERAL -- VALIDATORS_PER_OPERATOR_LIMIT -- DECLARE_OPERATOR_FEE_PERIOD -- EXECUTE_OPERATOR_FEE_PERIOD -- OPERATOR_MAX_FEE_INCREASE -- QUORUM_BPS -- DEFAULT_ORACLE_IDS - -## Upgrade process - -We use [UUPS Proxy Upgrade pattern](https://docs.openzeppelin.com/contracts/4.x/api/proxy) for `SSVNetwork` and `SSVNetworkViews` contracts to have an ability to upgrade them later. - -**Important**: It's critical to not add any state variable to `SSVNetwork` nor `SSVNetworkViews` when upgrading. All the state variables are managed by [SSVStorage](../contracts/libraries/storage/SSVStorage.sol) and [SSVStorageProtocol](../contracts/libraries/storage/SSVStorageProtocol.sol). Only modify the logic part of the main contracts or the modules. - -### Upgrade SSVNetwork / SSVNetworkViews - -#### Upgrade contract logic - -In this case, the upgrade add / delete / modify a function, but no other piece in the system is changed (libraries or modules). - -Set `SSVNETWORK_PROXY_ADDRESS` in `.env` file to the right value. - -Run the upgrade task: - -``` -Usage: hardhat [GLOBAL OPTIONS] upgrade:proxy [--contract ] [--init-function ] [--proxy-address ] [...params] - -OPTIONS: - --contract New contract upgrade - --init-function Function to be executed after upgrading - --proxy-address Proxy address of SSVNetwork / SSVNetworkViews - -POSITIONAL ARGUMENTS: - params Function parameters - -Example: -npx hardhat --network holesky_testnet upgrade:proxy --proxy-address 0x1234... --contract SSVNetworkV2 --init-function initializev2 param1 param2 -``` - -It is crucial to verify the upgraded contract using its proxy address. -This ensures that users can interact with the correct, upgraded implementation on Etherscan. - -### Update a module - -Sometimes you only need to perform changes in the logic of a function of a module, add a private function or do something that doesn't affect other components in the architecture. Then you can use the task to update a module. - -This task first deploys a new version of a specified SSV module contract, and then updates the SSVNetwork contract to use this new module version only if `--attach-module` flag is set to `true`. - -``` -Usage: hardhat [GLOBAL OPTIONS] update:module [--attach-module ] [--module ] [--proxy-address ] - -OPTIONS: - - --attach-module Attach module to SSVNetwork contract (default: false) - --module SSV Module - --proxy-address Proxy address of SSVNetwork / SSVNetworkViews (default: null) - - -Example: -Update 'SSVOperators' module contract in the SSVNetwork -npx hardhat --network holesky_testnet update:module --module SSVOperators --attach-module true --proxy-address 0x1234... -``` - -### Upgrade a library - -When you change a library that `SSVNetwork` uses, you need to also update all modules where that library is used. - -Set `SSVNETWORK_PROXY_ADDRESS` in `.env` file to the right value. - -Run the task to upgrade SSVNetwork proxy contract as described in [Upgrade SSVNetwork / SSVNetworkViews](#upgrade-contract-logic) - -Run the right script to update the module affected by the library change, as described in [Update a module](#update-a-module) section. - -### Manual upgrade of SSVNetwork / SSVNetworkViews - -Validates and deploys a new implementation contract. Use this task to prepare an upgrade to be run from an owner address you do not control directly or cannot use from Hardhat. - -``` -Usage: hardhat [GLOBAL OPTIONS] upgrade:prepare [--contract ] [--proxy-address ] - -OPTIONS: - - --contract New contract upgrade (default: null) - --proxy-address Proxy address of SSVNetwork / SSVNetworkViews (default: null) - -Example: -npx hardhat --network holesky_testnet upgrade:prepare --proxy-address 0x1234... --contract SSVNetworkViewsV2 -``` - -The task will return the new implementation address. After that, you can run `upgradeTo` or `upgradeToAndCall` in SSVNetwork / SSVNetworkViews proxy address, providing it as a parameter. diff --git a/hardhat.config.ts b/hardhat.config.ts index a56e9090..99c08238 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -64,7 +64,7 @@ export default defineConfig({ allowUnlimitedContractSize: true, blockGasLimit: 100_000_000, forking: { - url: localForkRpcUrl, + url: mainnetRpcUrl, blockNumber: process.env.FORK_BLOCK_NUMBER ? Number(process.env.FORK_BLOCK_NUMBER) : undefined, } }, diff --git a/package-lock.json b/package-lock.json index 9c73fdd4..94a92ed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -977,6 +977,7 @@ "integrity": "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1016,6 +1017,7 @@ "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1128,7 +1130,6 @@ "integrity": "sha512-DtYjmHtPM1BenmNm5ZMVn5fTGD4RdDPGE/ElpaLUjDGbkQnn4ytvhqnGsY+osLaWFvDxKfhdI8fyISg53bk8Qw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@nomicfoundation/hardhat-errors": "^3.0.2", "@nomicfoundation/hardhat-utils": "^3.0.5", @@ -1146,7 +1147,6 @@ "integrity": "sha512-nkg+z+fq5PXcRxS/zadyosAA+oPp3sdWrKpuOcASDf0RjqsN2LsNymML0VNNkZF8TF+hYa36fbV+QOas2Fm2BQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@nomicfoundation/hardhat-errors": "^3.0.5", "@nomicfoundation/hardhat-utils": "^3.0.5", @@ -1167,7 +1167,6 @@ "integrity": "sha512-o5nkadpYS0LsYQzYO56pTvYngtXmB72FRTZcAMEHG+K9TMjI7EHPn4ecXmatJ5fbUSf/CplkqWxbKkOaVnfqXg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@nomicfoundation/hardhat-errors": "^3.0.2", "@nomicfoundation/hardhat-utils": "^3.0.5", @@ -1326,7 +1325,6 @@ "integrity": "sha512-AkwFvx/r0AFDk0H53mReYpkw2pvi5Jq34zAyk2+cTM7o/OnOvq0xcAaidw4BQvBf9+FMeFAKjJe+zNYgrsLatg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/abi": "^5.8.0", "@nomicfoundation/hardhat-errors": "^3.0.3", @@ -1362,7 +1360,6 @@ "integrity": "sha512-o5CTrlQ1PEQW85ppS7fxXCsSVl3j/T/3roTSA795lRJf7SQdJzr5y12rSTvoqR2YbeF5zDxVdqgzEqoMd8n6Cw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ethersproject/address": "5.6.1", "@nomicfoundation/hardhat-errors": "^3.0.2", @@ -1703,6 +1700,7 @@ "integrity": "sha512-F+GklO8jBWlsaVV+9oHaPh5NJdd6rAKN4tklGfInX1Q7h0xPgVLP39Jl3eCulPB5qexI71ZFHwbljx4ZXNfouA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lodash": "^4.17.15", "ts-essentials": "^7.0.1" @@ -1759,7 +1757,8 @@ "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/adm-zip": { "version": "0.4.16", @@ -1784,7 +1783,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1862,6 +1860,7 @@ "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -2065,7 +2064,6 @@ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -2226,6 +2224,7 @@ "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-back": "^3.1.0", "find-replace": "^3.0.0", @@ -2242,6 +2241,7 @@ "integrity": "sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-back": "^4.0.2", "chalk": "^2.4.2", @@ -2258,6 +2258,7 @@ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -2271,6 +2272,7 @@ "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2281,6 +2283,7 @@ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -2296,6 +2299,7 @@ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -2305,7 +2309,8 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/command-line-usage/node_modules/escape-string-regexp": { "version": "1.0.5", @@ -2313,6 +2318,7 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.0" } @@ -2323,6 +2329,7 @@ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -2333,6 +2340,7 @@ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -2346,6 +2354,7 @@ "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2365,7 +2374,8 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/config-chain": { "version": "1.1.13", @@ -2711,7 +2721,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", @@ -2822,6 +2831,7 @@ "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-back": "^3.0.1" }, @@ -2895,6 +2905,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -3022,7 +3033,6 @@ "integrity": "sha512-nv9m2QEatqyieC24blPSdaN6FVMXtxCXe6iFPGSx9Pxd6qpucj9rjlADL4MgU1Doq5pLvHkwUxsrXuZY6dK7SQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@nomicfoundation/edr": "0.12.0-next.17", "@nomicfoundation/hardhat-errors": "^3.0.6", @@ -3355,6 +3365,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -3457,7 +3468,8 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.truncate": { "version": "4.4.2", @@ -3689,6 +3701,7 @@ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "mkdirp": "bin/cmd.js" }, @@ -3702,7 +3715,6 @@ "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", @@ -3913,6 +3925,7 @@ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4112,6 +4125,7 @@ "integrity": "sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4525,7 +4539,8 @@ "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", "dev": true, - "license": "WTFPL OR MIT" + "license": "WTFPL OR MIT", + "peer": true }, "node_modules/string-width": { "version": "5.1.2", @@ -4676,6 +4691,7 @@ "integrity": "sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-back": "^4.0.1", "deep-extend": "~0.6.0", @@ -4692,6 +4708,7 @@ "integrity": "sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4702,6 +4719,7 @@ "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4775,6 +4793,7 @@ "integrity": "sha512-H69ZwTw3rFHb5WYpQya40YAX2/w7Ut75uUECbgBIsLmM+BNuYnxsltfyyLMxy6sEeKxgijLTnQtLd0nKd6+IYw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "chalk": "^4.1.0", "command-line-args": "^5.1.1", @@ -4791,6 +4810,7 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -4807,6 +4827,7 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4824,6 +4845,7 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4837,6 +4859,7 @@ "integrity": "sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "typescript": ">=3.7.0" } @@ -4900,6 +4923,7 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4912,6 +4936,7 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -4933,6 +4958,7 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -4961,6 +4987,7 @@ "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4988,6 +5015,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -5031,6 +5059,7 @@ "integrity": "sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "reduce-flatten": "^2.0.0", "typical": "^5.2.0" @@ -5045,6 +5074,7 @@ "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5285,7 +5315,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ssv-review/Internal-[DIP-X]-SSV-Staking.md b/ssv-review/Internal-[DIP-X]-SSV-Staking.md new file mode 100644 index 00000000..83c02b40 --- /dev/null +++ b/ssv-review/Internal-[DIP-X]-SSV-Staking.md @@ -0,0 +1,496 @@ +# Proposing Effective balance oracles and SSV staking to support new ETH-denominated network fees + +*Everything discussed below is a work in progress, intended to spark discussion within the ssv.network DAO and beyond. Implementation details and binding steps will be submitted to the ssv.network DAO snapshot after community feedback is gathered.* + +# Introduction + +The ssv.network DAO ("DAO") proposes introducing SSV Staking as part of a *broader set of protocol upgrades* designed to support ETH-denominated payments and native effective balance accounting within the SSV Network. + +The transition to ETH payments simplifies the protocol's economic model by aligning fee settlement with the asset in which validator rewards are generated. Moving fee payments to ETH removes cross-asset dependencies, reduces operational complexity, and enables more direct and predictable protocol-level accounting. + +In parallel, supporting Ethereum's post-Pectra validator model requires effective balance-aware accounting. Effective Balance Accounting ensures that fees, runway calculations, and liquidation logic scale with the actual stake secured by validators, rather than relying on fixed assumptions. Implementing this model natively requires the protocol to reflect validator effective balances on-chain throughout their lifecycle. + +To bridge the gap between Ethereum's consensus layer and on-chain accounting, the protocol introduces Effective Balance Oracles, which track validator balances and update protocol state. Operating this oracle system in a decentralized and resilient manner requires participation and delegation by parties economically aligned with the protocol. + +SSV Staking provides such a delegation mechanism, allowing SSV holders to stake their tokens and delegate stake toward the selection of Effective Balance Oracles. In doing so, protocol fee flows are reflected through the staking mechanism in proportion to protocol usage, strengthening alignment between token holders and the network. + +--- + +# Components of SSV Staking + +SSV Staking is enabled through three tightly coupled components: + +* **ETH Payments** introduces native ETH-denominated fees at the protocol level, allowing network and operator fees to be paid and settled in ETH. + +* **Effective Balance Accounting** upgrades the protocol's accounting model to calculate fees, runway consumption, and liquidation conditions based on validators' actual effective balance, rather than assuming a fixed 32 ETH per validator. This enables stake-aware accounting that natively aligns the protocol with Ethereum's post-Pectra validator model. + +* **SSV Staking** introduces staking and delegation functionality for SSV holders. Through staking, participants lock SSV and support the protocol's operation by participating in the distributed selection of *Effective Balance Oracles*. In turn, they are rewarded in ETH for their effort based on the amount of SSV staked. + +--- + +# ETH Payments + +ETH Payments introduce a fundamental change to how economic accounting is handled within the SSV Network. With such payments, operator fees and network fees are paid in ETH, replacing the existing SSV-denominated payment model. + +## Motivation + +The SSV Network operates at the validator layer of Ethereum, where rewards are generated exclusively in ETH. However, the current fee model requires participants to manage and pay fees in SSV, creating a structural mismatch between where value is produced and how costs are paid. + +Transitioning to ETH payments addresses this mismatch and delivers several standalone benefits: + +* **Asset alignment** - Clusters pay fees in the same asset that their validators earn. This removes the need for conversions, hedging, or the complexities of using another token in order to operate validators. + +* **Economic predictability** - SSV-denominated fees fluctuate independently of validator rewards, forcing frequent adjustments to pricing and governance parameters. + +* **Operational simplicity** - Paying fees in ETH simplifies accounting, budgeting, and automation for cluster owners and operators. ETH balances directly represent the operational runway without requiring additional token management. + +* **Institutional accessibility** - ETH-denominated payments remove a major adoption barrier for institutional and regulated participants, who often prefer or require minimizing exposure to additional tokens and non-native protocol tokens. + +## ETH as the Native Payment Asset + +Transitioning to ETH payments defines a clear separation between how new clusters are created and how existing SSV-based clusters are handled going forward: + +### New Clusters + +All new clusters will operate with ETH payments from the outset: + +* Operator fees are paid in ETH + +* Network fees are paid in ETH + +* ETH must be deposited upfront to fund the cluster's operational runway + +### Existing Clusters (SSV-based) + +Existing SSV-based clusters are treated as **legacy**, and support for actively operating them under the SSV payment model is removed. While these clusters may continue running as long as they have sufficient runway, they can no longer be maintained through operational changes. + +This means that adding new validators, removing existing validators, reactivating liquidated clusters or depositing additional SSV to extend a cluster's runway is no longer supported. As a result, **the only path forward for maintaining an existing cluster is migration to ETH payments**, which restores full cluster functionality under the new payment and accounting model. + +For cluster owners who do not wish to migrate or are unable to do so, the remaining option is to voluntarily liquidate the cluster. Self-liquidation returns the remaining cluster balance to the owner and signals operators to stop operating the cluster's validators. However, if the intention is to continue operating the validators in the future, migration to ETH payments will be required in order to do so. + +For cluster owners who anticipate needing more time to migrate but intend to continue operating their validators, it is critical to deposit sufficient SSV in advance to ensure enough operational runway until migration can be completed. + +*To guarantee all users have the option to top up their clusters before the transition to ETH payments, the SSV Foundation is requested to publish a prominent message on DAO-managed channels and assets relevant to disseminating information regarding the future inability to fund clusters with SSV.* + +## Cluster Migration + +Cluster migration allows existing SSV-based clusters to transition into ETH payments. Migration applies at the cluster level, and each cluster can be migrated in a single interaction, which upgrades it to ETH payments immediately. + +To migrate, the cluster owner initiates the migration and deposits sufficient ETH to fund the cluster's future operation runway under the ETH payment model. As part of the migration, the cluster's accounting is switched from SSV to ETH, and any remaining SSV balance is returned to the cluster owner. + +Migration is a one-way process - once a cluster is migrated to ETH payments, it cannot revert back to SSV-based payments. + +## Operator Payments & Fee Transition + +Transitioning to ETH payments defines a clear separation between how new operators are onboarded and how existing operators transition from SSV-based fees to ETH-based fees. + +### New Operators + +New operators onboard directly with ETH-denominated fees. From launch onward, operators registering in the network will not be able to define or configure fees in SSV and will operate exclusively under the ETH payment model. + +### Existing Operators + +Existing operators continue earning SSV-denominated fees only for clusters that have not yet migrated. These SSV fees continue to accrue, but operators are no longer able to modify or adjust their SSV fee configuration. Accrued fees can still be withdrawn. + +Once clusters migrate to ETH payments, or when new ETH-denominated clusters are onboarded, operators begin earning fees in ETH based on their assigned *default ETH fee* configuration. + +#### Default ETH Fee + +At launch, **all existing operators are assigned a default ETH fee** to ensure that operator pricing does not become a blocker for cluster migration: + +* Operators with a **0 SSV** fee default to a **0 ETH** fee + +* Operators with a **non-zero SSV fee** default to a network-defined ETH fee + +We propose setting the default ETH fee for non-zero SSV operators to an amount equivalent to approximately 0.5% of Ethereum staking rewards per 32 ETH validator. Based on a 2.9% ETH staking APR, this corresponds to: + +* 0.00928 ETH per validator per year + +Under this default: + +* A standard 4-operator cluster pays ~2% of staking rewards to operators, with each operator earning ~0.5% + +* Clusters with more than four operators pay proportionally more (e.g., a 7-operator cluster pays ~3.5%) + +The proposed default ETH operator fee was evaluated by examining the current fee structure on the SSV Network. At present, the weighted average fee charged by public operators is approximately **0.761 SSV**, which corresponds to roughly **0.1%** of Ethereum staking rewards. + +Over time, SSV-denominated operator fees have converged toward very low levels, resulting in a fee structure that no longer reflects the underlying cost, responsibility, or risk associated with operating validators. + +Against this backdrop, the proposed default ETH operator fee - set at **0.5%** of Ethereum staking rewards per operator, is intentionally and materially higher than the current network average. This higher starting point establishes a new baseline under the ETH-based model, from which operators can subsequently reprice based on market dynamics and competition. Any such fee adjustments remain subject to the existing fee update constraints and limitations. + +## Governance Parameters + +The transition to ETH payments introduces a set of new governance-controlled parameters that define the economic and risk boundaries of the protocol. A detailed evaluation of these parameters, including assumptions and methodology, is provided in the [Liquidation Collateral Parameter Evaluation](#liquidation-collateral-parameter-evaluation) and [Network Fee Implications](#network-fee-implications) sections of this proposal The values for the parameters discussed in the aforementioned sections are mentioned in those sections and below only as examples. + +| Variable | Description | Update function | Initial Value | +| :---- | :---- | :---- | :---- | +| *ethNetworkFee* | Protocol network fee charged in ETH. | updateNetworkFee(uint256 fee) | 0.000000003550929823 ETH (0.00928 ETH - annual) | +| *minimumLiquidationCollateral* | Minimum ETH collateral an ETH-denominated cluster must maintain; falling below this level contributes to liquidation eligibility. | updateMinimumLiquidationCollateral(uint256 amount) | 0.00094 ETH | +| *minimumBlocksBeforeLiquidation* | Minimum number of blocks an ETH-denominated cluster must maintain sufficient balance before becoming eligible for liquidation. | updateLiquidationThresholdPeriod(uint64 blocks) | 50190 (7 days) | +| *operatorMaxFee* | Maximum operator fee cap, setting a technical upper bound on operator fees denominated in ETH. This parameter exists as a protocol safety constraint to prevent extreme fee configurations and is not intended to express economic policy or target fee levels. | updateMaximumOperatorFee(uint64 maxFee) | | +| *defaultOperatorETHFee* | Default ETH-denominated operator fee applied to existing operators during the transition from SSV-denominated fees to ETH-denominated fees. | Not governance-controlled. The default value is defined in the contract and applied automatically; it exists solely to facilitate operator migration and ensure continuity during the transition period. | 0.000000001775464912 ETH (0.00464 ETH - annual) | + +--- + +# Effective Balance Accounting + +Effective Balance Accounting updates how fees, cluster runway, and liquidations are calculated across the SSV Network by aligning them with validators' actual effective balance, rather than assuming a fixed 32 ETH per validator. + +This change is required to natively support Ethereum's post-Pectra validator model, where a single validator can secure and earn rewards on significantly more than 32 ETH. Historically, this gap was partially addressed through off-chain mechanisms, but Effective Balance Accounting brings this logic fully on-chain and applies it consistently across network fees, operator fees, and cluster payments. + +Specifically, this issue was partially mitigated through the Incentivized Mainnet (IM) program, which relied on an off-chain script to calculate validator balances and deduct unpaid network fees from monthly incentive rewards. This approach had several limitations: it did not apply to operator fees, it relied on periodic off-chain reconciliation, and it would not function once fees are denominated in ETH, as ETH fees cannot be deducted from SSV-based rewards. + +As a result, validators with higher effective balances have remained only partially accounted for. With the transition to ETH payments, natively supporting effective balance accounting is no longer optional \- it is required to ensure all fees are correctly calculated, collected, and enforced within the protocol. + +## Motivation + +Moving to effective balance accounting is a long-overdue evolution of the SSV Network's core accounting model, following Ethereum's Pectra upgrade and the introduction of validators with variable effective balances. As validator structures on Ethereum have matured, the protocol must move beyond fixed assumptions and provide native support that improves correctness, reliability, and long-term sustainability across operators, clusters, and the network itself. + +* **Native support for consolidated validators** - With effective balance accounting in place, the protocol natively adjusts its accounting to validators with varying effective balances. Fees, runway calculations, and safety checks all scale directly with effective balance, eliminating the need for off-chain tools to fill this gap. + +* **Fair operator compensation** - Effective balance accounting enables operators to be compensated according to the actual effective balance they manage, rather than being paid under a fixed 32 ETH assumption, ensuring correct compensation for operators managing consolidated validators. + +* **Preserving network revenue** - Without native effective balance support, the network would be unable to correctly collect network fees from ETH-based clusters operating consolidated validators. The Incentivized Mainnet program previously mitigated this through off-chain deductions, but this approach cannot be applied to ETH-denominated fees. Supporting effective balance accounting natively is therefore critical to prevent revenue loss as the network transitions to ETH payments. + +## Accounting Changes + +Effective Balance Accounting changes how fees are calculated at the cluster level by replacing validator count as a proxy with the cluster's effective balance. + +### Existing Clusters (SSV-based) + +In the SSV-based model, validators act as a proxy for effective balance. + +Each validator is implicitly assumed to represent a fixed 32 ETH of effective balance. Fees therefore scale linearly with the number of validators in the cluster, regardless of how much effective balance those validators actually secure. + +![image|690x88, 50%](upload://p2BelvkqZe0zO4ofvF91O7Zpzp7.png) + +Under this model: + +* Fees are defined per validator + +* Total fees scale with validator count + +* Consolidated validators are not fully accounted for + +This model continues to apply to all SSV-based clusters. As a result: + +* Network fee deduction for compensation via the Incentivized Mainnet script continues to operate + +* Operators managing SSV-based clusters are not compensated based on the amount of stake they manage + +### New clusters (ETH-based) + +In the ETH-based model, effective balance becomes the billing unit. + +Fees are defined per 32 ETH of effective balance and scale with a cluster's total effective balance, rather than with validator count: + +![image|690x111, 50%](upload://uWnpB7vC9bYmwDXRceiHDTJHj9i.png) + +Here, *total effective balance* refers to the **cumulative effective balance of all validators belonging to the cluster**. All accounting is performed using this aggregated cluster-level value. + +As a result, ETH-based clusters pay fees proportional to the actual effective balance they secure, independent of how that balance is distributed across validator keys. + +Effective balance-based accounting applies only to ETH-based clusters. SSV-based clusters continue operating under the validator-count model until they migrate, after which this becomes the only accounting model used by the protocol. + +## Effective Balance Oracles + +In order to achieve the DAO's stated goal of decentralizing Ethereum but doing so in the most ETH aligned way, this document suggests for the DAO to adopt Effective Balance Oracles that will perform Effective Balance Accounting. In this regard, the Effective Balance Oracles on ssv.network play a similar role to that of validators on the Ethereum blockchain, both requiring a staking mechanism and possibly a delegation to a third-party performing the needed duties, thus fulfilling a crucial part of the process. While oracles don't validate transactions as validators do, they do maintain the integrity and security of the protocol by accurately attesting what validator effective balance is, which is key for the safety of the ssv.network as discussed below. + +For Effective Balance accounting to work natively, the protocol must be able to track the effective balance of validators across the network and reflect this data on-chain. Validator effective balances, however, exist only on Ethereum's consensus layer and cannot be accessed directly by smart contracts efficiently in a way that serves the purpose of this protocol. + +To fill this gap, it is proposed that the protocol will rely on a dedicated set of **Effective Balance Oracles**. + +Effective Balance Oracles are responsible for tracking validator effective balances on the beacon chain and enabling the protocol to keep its on-chain accounting aligned with real validator state as balances evolve over time. + +### Oracle Set Composition and Evolution + +#### Initial Permissioned Oracle Set + +At launch, the protocol will operate with a permissioned set of four Effective Balance Oracles, operating under a 3-of-4 threshold for oracle commitments. + +This initial configuration is intentionally temporary and is designed to mitigate early-stage operational and correctness risks. Effective Balance Oracles play a critical role in protocol accounting and liquidation safety, and incorrect or inconsistent balance updates could have direct and dire consequences. + +Beginning with a permissioned set allows the protocol to validate, in production, the full oracle workflow under controlled conditions. This approach reduces the risk associated with unproven implementations, misconfigured clients, or adversarial behavior during the initial rollout of effective balance accounting. + +Once the oracle workflow and assumptions have been validated and observed to operate reliably over time, the protocol is intended to transition toward a permissionless oracle model, as described in subsequent sections. + +**The DAO is responsible for electing the initial oracle set and overseeing its composition over time, including making changes if required to maintain correctness, availability, and operational reliability during the early phase of effective balance accounting.** + +#### Oracle Compensation (Initial Phase) + +During the initial permissioned phase, oracle operators will be compensated to cover the operational costs of running the Effective Balance Oracle infrastructure. + +Each oracle will receive a fixed compensation of **$250 per month denominated in SSV, with a 30-day trailing average calculated on the first of the month, transferred on each consequent first msig batch** to cover infrastructure and operational costs associated with running the oracle client. In addition, oracle operators will be **fully reimbursed by the DAO for all Ethereum transaction costs** incurred as part of their oracle duties, including balance updates and Merkle root submissions. This compensation model is intended to ensure operational sustainability at launch while keeping the system simple and avoiding premature complexity around protocol-level incentives. + +#### Future Permissionless Oracle Set + +After the initial permissioned phase, the oracle set is intended to transition to a permissionless model. In this phase, any participant will be able to operate an Effective Balance Oracle, and the composition of the active oracle set will be determined automatically through SSV staking delegation rather than direct DAO selection. + +Under this model, SSV stakers delegate their staking weight to oracle operators, using stake as voting power. The oracle set is then composed of the operators with the highest delegated stake, allowing the set to evolve and rotate over time based on staker preferences and observed performance. + +Stake-based delegation is a critical component of this design. Effective Balance Oracles directly influence protocol accounting and liquidation behavior, making correctness and reliability essential. By tying oracle selection to delegated stake, the protocol ensures that oracle operators are economically aligned with the system: operators with higher delegated stake are incentivized to behave correctly, while stakers can reallocate delegation away from underperforming or untrusted oracles. + +This mechanism enables the protocol to maintain decentralization and security without relying on manual selection by a trusted entity, while allowing the oracle set to adapt dynamically as conditions change. In this phase, a protocol-level compensation mechanism will also be introduced to sustainably reward oracle operators for their ongoing duties. + +### Effective Balance Updates + +Effective balance updates are performed in two steps, moving from global observation to cluster-level updates. + +#### Step 1: Snapshot and consensus + +Effective Balance Oracles continuously track validator effective balances on the beacon chain. At defined intervals, they take a snapshot of all validator balances, aggregate them per cluster, and construct a Merkle tree representing the effective balances of all clusters at that snapshot. + +To reach consensus on this snapshot, each oracle independently commits the Merkle root representing this snapshot. Once a threshold of oracle commitments is reached, the snapshot is accepted by the protocol as the authoritative and accurate view of effective balances for that point in time. This threshold-based mechanism ensures both the correctness of the data and that no single oracle can dictate balance updates. + +#### Step 2: Cluster balance updates + +Once a snapshot is accepted, cluster-level effective balances can be updated on-chain by submitting a proof derived from the committed Merkle tree for a specific cluster. + +Updating cluster balances is **permissionless**: anyone can submit a valid proof and bear the transaction cost. As a failsafe, Effective Balance Oracles are expected to periodically perform these updates themselves to ensure cluster balances remain current even if third parties do not act. + +When a cluster's effective balance is updated, the protocol updates all related accounting based on the new value. This affects cluster runway calculations as well as future network and operator fee accruals tied to the amount of effective balance being managed. If an update causes a cluster to fall below liquidation thresholds, the cluster can be liquidated as part of the same process, ensuring that increases in effective balance are always matched by sufficient funding and collateral. + +#### Operational Considerations for Balance Updates + +Because updates are performed through periodic cluster-level sweeps, validators added to or removed from a cluster are initially accounted for using a default assumption of 32 ETH per validator. The actual effective balance of these validators - such as in the case of consolidated validators - will only be reflected once the next sweep occurs. As a result, cluster owners must account for the potential impact of delayed updates on runway and fee accrual, particularly when adding validators with higher effective balances. + +## Governance Parameters + +Effective Balance Accounting introduces new governance-controlled parameters that define how oracle consensus is reached for effective balance snapshots. + +| Variable | Description | Update function | Initial Value | +| :---- | :---- | :---- | :---- | +| quorumBps | Quorum threshold (in BPS) required for committing an effective balance snapshot | setQuorumBps(uint16 quorum) | 7500 (75.00%) considering a ¾ threshold. | +| | Replaces an existing Oracle with another one. | replaceOracle(uint32 oracleId, address newOracle) | | + +--- + +# SSV Staking + +SSV Staking introduces a staking and delegation mechanism that enables SSV holders to support the operation and maintenance of the protocol. Through staking, participants lock SSV and delegate stake toward the selection of Effective Balance Oracles, which are responsible for maintaining accurate effective balance accounting within the network. + +In return for participating in this process, protocol fees denominated in ETH and generated by network usage are reflected through the staking mechanism in proportion to participation. This introduces a tokenomic model in which SSV functions as an ETH accrual token, with value derived directly from protocol usage. + +## Motivation + +SSV Staking strengthens the role of SSV holders within the network by expanding their responsibilities beyond passive ownership. Through staking, token holders take part in selecting the oracles responsible for maintaining core protocol functions, giving them a direct role in the ongoing operation and reliability of the system. + +This model places protocol maintenance in the hands of participants with long-term economic exposure to the network, while allowing responsibility to be distributed and adjusted over time through delegation. + +This approach mirrors the participation model used in Ethereum staking, where ETH holders contribute to network maintenance through delegation to node operators or staking services. Similarly, SSV Staking allows token holders to participate in maintaining the protocol through delegation, without requiring direct operation of oracle infrastructure, while preserving accountability and decentralization. + +By tying economic participation to long-term staking, SSV Staking also strengthens governance. Participants who benefit from sustained protocol usage and growth are more incentivized to actively engage in governance and contribute to decisions that support the protocol's long-term reliability and evolution + +## Staking and cSSV + +SSV holders can stake their tokens into the SSV Staking contract and receive **cSSV**, an ERC-20 token that represents their staked position at a **1:1 ratio**. + +cSSV represents a claim on the underlying staked SSV, as well as a proportional share of protocol fees accrued to stakers. + +As part of staking, stakers must **delegate** their staking voting power. This delegation determines the composition of the Effective Balance Oracle set, which is responsible for maintaining effective balance data on-chain. + +In the temporary initial phase, staking delegation is automatically split evenly across the DAO-elected oracle set, providing a smooth starting point while establishing the foundation for stake-driven oracle selection in future phases. + +## Rewards and Claiming + +Protocol fees accrue continuously as validators operate on the SSV Network and generate ongoing network fees. Stakers earn a **pro-rata share of ETH-denominated fees**, based on their share of the total staked SSV. + +Rewards can be claimed at any time without unstaking, and claiming does not affect the staking position. + +When cSSV is transferred, rewards accrued up to that point remain claimable by the original holder, while the new holder begins accruing rewards only from the moment they receive the cSSV. + +## Unstaking + +Unstaking is a two-step process: + +First, the staker submits a withdrawal request, which locks the specified amount of cSSV and stops reward accrual for that portion. It is proposed that the protocol will launch with a **7-day lock period**. + +Once the lock period ends, the staker can finalize the unstake. The locked cSSV is burned, and the underlying SSV is returned at a 1:1 ratio relative to the original stake. + +## Governance Rights + +Staked SSV, represented by cSSV, **retains full governance and voting power**. Holding cSSV does not reduce a user's ability to participate in DAO governance compared to holding unstaked SSV. + +This ensures that participants who stake their SSV continue to influence the protocol's direction, while aligning governance participation with sustained economic exposure to the network. + +## Governance Parameters + +SSV Staking introduces new governance-controlled parameters that define the lifecycle and constraints of staking and unstaking within the protocol. + +| Variable | Description | Update function | Initial Value | +| :---- | :---- | :---- | :---- | +| cooldownDuration | Unstake cooldown duration (in blocks): the period users must wait between requesting an unstake and being able to withdraw their unlocked SSV. | setUnstakeCooldownDuration(uint64 blocks) | 50120 (7 days) | + +# Protocol Transition and Governance Implications + +The introduction of ETH-denominated payments and native effective balance accounting represents a structural upgrade to the SSV Network. Beyond the core protocol design, these changes require deliberate updates to incentives, parameters, and legacy governance decisions. + +## Incentivized Mainnet Transition + +With the introduction of ETH payments, network fees for ETH-denominated clusters are no longer compatible with the Incentivized Mainnet fee deduction mechanism (Incentivized Mainnet rewards are distributed in SSV, while network fees for these clusters are paid in ETH). As a result, network fees cannot be deducted from Incentivized Mainnet rewards for validators operating as part of ETH-denominated clusters. + +At the same time, ETH-denominated clusters operate under the new effective balance accounting model, where network fees are calculated and collected natively by the protocol. Because these fees are already enforced on-chain, applying additional off-chain deductions via the Incentivized Mainnet script becomes obsolete for ETH-denominated clusters. + +To reflect this distinction, the Incentivized Mainnet script will be updated to differentiate between legacy SSV-based clusters and ETH-denominated clusters: + +* **ETH-denominated clusters -** Network fee deductions are removed. + +* **SSV-based clusters -** Network fees continue to be deducted from Incentivized Mainnet rewards under the existing model. + +This update ensures that Incentivized Mainnet behavior remains aligned with the accounting and fee mechanisms applicable to each cluster type, while correctly supporting ETH-denominated clusters under the upgraded protocol model. + +--- + +## Liquidation Collateral Parameter Evaluation + +The liquidation collateral and liquidation threshold parameters currently in effect were derived using a DAO-approved calculation framework, most recently formalized in [DIP-44](https://snapshot.org/#/s:mainnet.ssvnetwork.eth/proposal/0x5ab8383681f4efec61c1e89388477e18de3f1b9a34ce1fef001e55043a8f3273). With the introduction of ETH payments, the protocol introduces dedicated liquidation parameters for ETH-denominated clusters. As part of defining these new parameters, it is appropriate to revisit the existing calculation framework to ensure that its underlying assumptions remain valid under current network conditions. + +### Revisiting the Calculation Framework + +The existing framework relies on a **1-year historical lookback window** for gas price data. This choice was appropriate at the time of adoption, when gas prices were higher and more volatile. + +However, recent Ethereum network conditions differ materially from those reflected in earlier datasets. In particular: + +* Average gas prices have declined significantly + +* Gas price volatility has stabilized + +* Sustained Layer 2 adoption has structurally reduced congestion on Ethereum mainnet + +As a result, a full 1-year lookback increasingly overweights historical periods that are no longer representative of current or expected near-term conditions. + +To illustrate this shift, the following charts compare historical gas price behavior under different lookback windows: + +![image|690x280](upload://8hRge5dE8zSuB6g0BBWEvKnMusw.png) + +*Ethereum gas prices over the last year (reference \- [ycharts.com](https://ycharts.com/indicators/ethereum_average_gas_price))* + +![image|690x267](upload://joYrIivA0jpY5kms7LbgyHIxov9.png) + +*Ethereum gas prices over the last 6 months (reference \- [ycharts.com](https://ycharts.com/indicators/ethereum_average_gas_price))* + +Under a 1-year lookback window: + +* **Average gas price:** \~3.51 GWEI + +* **Gas price standard deviation:** \~4.63 GWEI + +Under a 6-month lookback window: + +* **Average gas price:** \~1.86 GWEI + +* **Gas price standard deviation:** \~1.86 GWEI + +This represents a substantial reduction in both average gas costs and volatility. Continuing to rely on a 1-year window would therefore embed outdated assumptions into the liquidation model, resulting in parameters that are more conservative than current network conditions justify. + +For this reason, it is proposed to update the calculation framework to use a **rolling 6-month lookback window**. By grounding liquidation cost assumptions in more recent gas price data, the framework reflects both a lower average gas cost and reduced volatility. This, in turn, lowers the estimated worst-case cost of executing a liquidation and reduces the amount of collateral required to safely incentivize liquidators, improving capital efficiency without weakening safety guarantees. + +This change applies to the framework itself, and therefore affects all parameter evaluations derived from it going forward. + +### Impact on Existing SSV-Based Parameters + +Applying the updated 6-month lookback window to the existing framework results in revised parameter values for SSV-denominated clusters: + +| Parameter | Current Value | Proposed Value | Deviance | +| :---- | :---- | :---- | :---- | +| *minimumLiquidationCollateralSSV* | 1.53 SSV | 0.883 SSV | \-42.52% (\>15%) | +| *minimumBlocksBeforeLiquidationSSV* | 14 days | 100380 (14 days) | 0% (\<15%) | + +[*Calculations sheet*](https://docs.google.com/spreadsheets/d/1pa7VDZywlc5He2rS7qVbAKVv2KQrj7wZ-yvabpMRvUo/edit?usp=sharing) + +These updated values are a direct consequence of revised inputs rather than a change in liquidation logic. They are presented to maintain methodological consistency with prior DAO decisions. + +The DAO may choose to adopt these updated SSV-denominated values as part of this proposal or defer their application to a separate governance decision. + +### ETH-Denominated Liquidation Parameters + +In parallel to the existing SSV-denominated parameters, ETH-denominated clusters require a **dedicated set of liquidation parameters** derived from the same framework but adjusted to reflect their materially different risk profile. + +#### Reduced Risk from Removing SSV from the Calculation Framework + +Under the legacy SSV-based model, liquidation parameters were required to account for a cross-asset mismatch: liquidation execution costs are paid in ETH, while liquidation rewards and fee accrual are denominated in SSV. This required incorporating assumptions around SSV/ETH price ratios and their deviations, increasing uncertainty and necessitating more conservative parameter values. + +By removing SSV from the calculation framework, ETH-denominated clusters eliminate this cross-asset exposure entirely. Network fees, collateral, and liquidation execution are all denominated in ETH, resulting in a more predictable and tightly bounded liquidation model. + +#### Revised Liquidation Functions for ETH-Denominated Clusters + +With SSV-denominated components removed from the calculation framework, the existing liquidation functions can be simplified and recalibrated for ETH-denominated accounting. + +The calculation framework uses the following formulas for SSV-denominated clusters: + +* Minimum Liquidation Collateral + +![image|690x57, 50%](upload://3dvCyE3Kh3eHUJPOWEt6TMrHSSY.png) + +* Liquidation Threshold + +![image|690x66, 50%](upload://ae570VVYXDfsFMPbdp5oe3InDRN.png) + +New formulas for ETH-denominated clusters: + +* Minimum Liquidation Collateral + +![image|690x97, 50%](upload://eBvHtGoMdNmprbjB6n7ckxMoo9q.png) + +* Liquidation Threshold + +![image|690x88, 50%](upload://xy3dPLIc4Rxe43ouHptc4woj9jR.png) + +These ETH-denominated functions maintain the same safety objectives as the legacy framework, while allowing parameters to reflect the reduced risk profile enabled by ETH-denominated accounting. + +#### Proposed Initial Parameters for ETH-Denominated Clusters + +Applying the ETH-specific liquidation functions yields the following proposed **initial liquidation parameters** for ETH-denominated clusters: + +| Parameter | Current Value | Proposed Value | Deviance | +| :---- | :---- | :---- | :---- | +| *minimumLiquidationCollateral* | - | 0.00094 ETH | 100% (>15%) | +| *minimumBlocksBeforeLiquidation* | - | 50190 (7 days) | 100% (>15%) | + +[*Calculations sheet*](https://docs.google.com/spreadsheets/d/1pa7VDZywlc5He2rS7qVbAKVv2KQrj7wZ-yvabpMRvUo/edit?usp=sharing) + +These values are proposed as initial settings and remain fully governance-controlled. As with all liquidation-related parameters, the DAO retains the ability to adjust them as network conditions and assumptions evolve. + +--- + +## Network Fee Implications + +### Network Fee for ETH-Denominated Clusters + +As part of the transition to ETH-denominated clusters, the protocol introduces a **dedicated network fee denominated in ETH**, applied to ETH-denominated clusters. + +Under the legacy SSV-based model, the network fee calculation incorporated an ETH/SSV conversion factor, reflecting the fact that protocol fees were accrued in SSV while staking rewards and execution costs were denominated in ETH. With ETH-denominated clusters, this conversion is no longer required. + +For ETH-denominated clusters, the network fee is calculated natively in ETH as: + +![image|690x70, 50%](upload://ri9U6MpvfFhv8iWC0aOubQIUgiM.png) + +This formulation removes SSV entirely from the network fee calculation and aligns fee accrual directly with ETH-denominated validator rewards. + +##### Proposed Network Fee + +Applying the ETH-denominated network fee formulation yields the following **proposed initial network fee parameter** for ETH-denominated clusters: + +| Parameter | Current Value | Proposed Value | Deviance | +| :---- | :---- | :---- | :---- | +| *ethNetworkFee* | – | 0.000000003550929823 ETH (0.00928 ETH \- annual) | 100% (\>15%) | + +### Implications for the Legacy SSV Network Fee + +Once all clusters have migrated from SSV-based accounting to ETH-denominated clusters, the protocol will no longer rely on SSV-denominated network fees or ETH/SSV conversion logic. + +The existing governance mechanism for bounding the SSV network fee via a ratio-based maximum, as defined in [DIP-49](https://snapshot.org/#/s:mainnet.ssvnetwork.eth/proposal/0x5300de7fd0df8c07b06b1e4ad71bdf036945b26787b0157d70ab80fee3ad4126), was introduced to constrain the network fee under a model where fees were denominated in SSV and implicitly exposed to ETH price dynamics. + +Under an ETH-denominated fee model, this constraint becomes irrelevant. With network fees calculated and collected directly in ETH, there is no longer an SSV/ETH ratio to bound, and governance of the protocol network fee is expressed solely through the ETH-denominated network fee parameter. + +--- + +## Future Consideration: Public-Good DVT Clusters (SSV-Based) + +In future versions of the protocol, the SSV Network may explore supporting SSV-based clusters as a dedicated mode for public-good DVT use cases. + +Under this model, public-good DVT clusters would operate without paying protocol-level network fees. In exchange, these clusters would not participate in incentive programs such as the Incentivized Mainnet (IM). This preserves economic neutrality while allowing certain DVT deployments to operate purely as public infrastructure. + +This approach acknowledges that while SSV-based clusters are being deprecated for ongoing commercial operation, they may still serve a purpose as a constrained and clearly defined execution mode for non-commercial validator setups - such as research, experimentation, or ecosystem infrastructure - without distorting the protocol's economic model. + +This concept is not part of the current release and is presented as a potential future extension to support public-good DVT use cases in a principled and economically isolated manner. diff --git a/ssv-review/planning/MAINNET-READINESS.md b/ssv-review/planning/MAINNET-READINESS.md new file mode 100644 index 00000000..d65c9132 --- /dev/null +++ b/ssv-review/planning/MAINNET-READINESS.md @@ -0,0 +1,3154 @@ +# SSV Network v2.0.0 — Mainnet Readiness Checklist + +**Generated:** 2026-02-17 +**Updated:** 2026-02-17 (new audit findings folded in) +**Sources:** Verified bug report, verified test coverage gap analysis, verified scripts & ops audit, DIP-X vs implementation review reports (ETH Payments, Effective Balance, SSV Staking) +**Branch:** `ssv-staking` (base for all feature branches) + +--- + +## Priority Summary + +| ID | Task | Type | Priority | Effort | +|----|------|------|----------|--------| +| BUG-1 | ~~`ensureETHDefaults` overwritten by stale memory copy~~ | Critical Bug Fix | P0 | ✅ Fixed | +| BUG-2 | ~~`_resetOperatorState` doesn't clear `operator.owner`~~ | ~~Critical Bug Fix~~ Won't Fix | ~~P0~~ | By design | +| BUG-3 | ~~`ensureETHDefaults` resurrects removed operators~~ | Critical Bug Fix | P0 | ✅ Mitigated | +| BUG-4 | ~~Double deviation cleanup on liquidated cluster validator removal~~ | Critical Bug Fix | P0 | ✅ Fixed ([PR #429](https://github.com/ssvlabs/ssv-network/pull/429)) | +| BUG-5 | ~~`_liquidateAfterEBUpdateIfNeeded` condition too strict for ETH-only operators~~ | Critical Bug Fix | P1 | ✅ Fixed | +| BUG-6 | Rewards lost when `totalStaked == 0` in staking `_syncFees` | Critical Bug Fix | P1 | ✅ Mitigated (deployment) | +| BUG-7 | ~~`DEFAULT_OPERATOR_ETH_FEE` value deviates from DIP-X spec~~ | ~~Critical Bug Fix~~ | ~~P1~~ | ✅ Closed (negligible) | +| BUG-8 | ~~ Cooldown duration uses `block.timestamp` but DIP specifies blocks~~ | ~~Critical Bug Fix~~ | ~~P1~~ | ✅ Closed (not a bug, added NatSpec) | +| BUG-9 | ~~`uint64(delta)` silent truncation in operator earnings accumulation~~ | ~~Critical Bug Fix~~ | ~~P1~~ | ✅ Closed (not realistic) | +| BUG-10 | Remove liquidation check in `withdraw` function | Critical Bug Fix | P2 | ⚠️ Needs Product approval | +| BUG-11 | `removeValidator` / `bulkRemoveValidator` blocked for legacy SSV clusters | Critical Bug Fix | P1 | ⚠️ Needs Product approval | +| SEC-1 | `setQuorumBps(0)` allows zero-threshold oracle commits | Security Hardening | P2 | ✅ Mitigated (owner-only) | +| SEC-2 | ~~`quorumBps` not initialized during upgrade — zero by default~~ | Security Hardening | P0 | ✅ Fixed — `initializeSSVStaking` now takes `quorumBps` param and validates `!= 0 && <= 10_000` | +| SEC-3 | ~~`replaceOracle` doesn't invalidate pending votes~~ | Security Hardening | ~~P1~~ P2 | ✅ Mitigated (owner-only + coordinated oracles) | +| SEC-4 | ~~`setUnstakeCooldownDuration` allows zero cooldown~~ | Security Hardening | ~~P1~~ P2 | ✅ Mitigated (owner-only, no accounting risk) | +| SEC-5 | ~~`totalStaked` changes between oracle votes (front-running)~~ | Security Hardening | ~~P1~~ P2 | ✅ Mitigated (impractical) | +| SEC-6 | ~~Add `nonReentrant` to `migrateClusterToETH`~~ | Security Hardening | P2 | ✅ Closed (no callback risk) | +| SEC-7 | ~~Add `nonReentrant` to `onCSSVTransfer`~~ | Security Hardening | P2 | ✅ Closed (trusted cSSV contract) | +| SEC-8 | ~~`reactivate` not emitting warning for removed operators~~ | Security Hardening | P2 | ✅ Closed (visible off-chain) | +| SEC-9 | ~~`operatorMaxFee` function signature differs from DIP-X spec~~ | Security Hardening | P2 | ✅ Closed (by design, PR #390) | +| SEC-10 | ~~cSSV token lacks governance/voting extensions (ERC20Votes)~~ | Security Hardening | P2 | ✅ Closed (Snapshot-based governance, same as SSV) | +| SEC-11 | ~~`hasDeviation` reactivation optimization uses global counter for per-operator decision~~ | Security Hardening | ~~P1~~ P3 | ✅ Closed (BUG-4 fix resolves root cause) | +| SEC-12 | ~~`deposit()` accepts deposits to liquidated ETH clusters without fee settlement~~ | Security Hardening | P2 | ✅ Closed (by design — document in FLOWS.md) | +| SEC-13 | `OperatorWithdrawn` event doesn't distinguish ETH vs SSV withdrawals | Security Hardening | P2 | Keep `OperatorWithdrawn` for ETH; add `OperatorWithdrawnSSV` for SSV | +| SEC-14 | ~~`commitRoot` accepts `bytes32(0)` as merkleRoot — permanently wastes block slot~~ | Security Hardening | P2 | ✅ Closed (coordinated oracles) | +| SEC-15 | ~~Min/max operator fee can be set to contradictory values~~ | Security Hardening | P2 | ✅ Closed (owner-only setters) | +| SEC-16 | ~~Missing zero-value/zero-address guards on deposit and withdraw~~ | Security Hardening | P2 | ✅ Closed | +| SEC-16b | Dust ETH stranded in `accrued` after full cSSV transfer + claim | Security Hardening | P1 | S | +| SEC-17 | DAO governance functions lack input guardrails (min/max/non-zero) | Security Hardening | P1 | M | +| SEC-18 | ETH-only operators can call `withdrawOperatorEarningsSSV` (no-op but wastes gas) | Security Hardening | P3 | S | +| SEC-19 | `minBlocksBetweenUpdates` never initialized — EB update rate limit silently disabled | Security Hardening | P1 | S | +| TEST-1 | Validator register/remove with non-zero operator fees | Unit Test Completeness | P0 | M | +| TEST-2 | EB-weighted operator earnings accumulation | Unit Test Completeness | P0 | M | +| TEST-3 | ~~Balance delta assertions in liquidation paths~~ | Unit Test Completeness | P0 | ✅ Closed (Addressed in PR #445) | +| TEST-4 | `updateClusterBalance` on liquidated clusters | Unit Test Completeness | P0 | S | +| TEST-5 | Oracle quorum edge cases | Unit Test Completeness | P0 | M | +| TEST-6 | EB decrease scenarios | Unit Test Completeness | P0 | M | +| TEST-7 | Reentrancy in staking functions | Unit Test Completeness | P0 | S | +| TEST-8 | Forbid creating clusters with removed operators | Unit Test Completeness | P0 | S | +| TEST-9 | Migration balance accounting verification | Unit Test Completeness | P1 | M | +| TEST-10 | Operator fee change + EB burn rate interaction | Unit Test Completeness | P1 | M | +| TEST-11 | Network fee update impact on active clusters | Unit Test Completeness | P1 | S | +| TEST-12 | Multi-staker reward fairness | Unit Test Completeness | P1 | M | +| TEST-13 | Liquidation + reactivation multi-cycle accounting | Unit Test Completeness | P1 | M | +| TEST-14 | Reactivation with EB deviation solvency check | Unit Test Completeness | P1 | S | +| TEST-15 | SSV cluster operations completeness | Unit Test Completeness | P1 | M | +| TEST-16 | View function coverage (SSVViews) | Unit Test Completeness | P1 | M | +| TEST-17 | Staking rewards from EB-weighted cluster fees | Unit Test Completeness | P1 | S | +| TEST-18 | `withdrawNetworkETHEarnings` (DAO ETH withdrawal) | Unit Test Completeness | P1 | S | +| TEST-19 | Operator removal impact on active ETH clusters | Unit Test Completeness | P1 | S | +| TEST-20 | Cooldown duration changes affecting pending requests | Unit Test Completeness | P1 | S | +| TEST-21 | EB boundary values (min/max per validator) | Unit Test Completeness | P2 | S | +| TEST-22 | Dust/precision edge cases | Unit Test Completeness | P2 | S | +| TEST-23 | Max operator count (13) with EB | Unit Test Completeness | P2 | S | +| TEST-24 | Idempotency and double-operation checks | Unit Test Completeness | P2 | S | +| TEST-25 | Upgrade path (reinitializer) tests | Unit Test Completeness | P2 | S | +| TEST-26 | Zero-validator cluster operations | Unit Test Completeness | P2 | S | +| TEST-27 | Operator at max validator limit | Unit Test Completeness | P2 | S | +| TEST-28 | Uncomment SSV reentrancy test assertions | Unit Test Completeness | P0 | S | +| TEST-29 | Add contract ETH balance delta assertions to deposit tests | Unit Test Completeness | P1 | S | +| TEST-30 | Resolve TODO comments with deferred assertions | Unit Test Completeness | P1 | M | +| TEST-31 | Expand onCSSVTransfer test coverage | Unit Test Completeness | P1 | S | +| TEST-32 | Add access control tests for DAO governance functions | Unit Test Completeness | P1 | S | +| TEST-33 | Mainnet governance config validation & edge-case tests | Unit Test Completeness | P1 | M | +| TEST-34 | Staking solvency invariant: cSSV supply must not exceed SSV held by staking contract | Unit Test Completeness | P1 | S | +| ITEST-1 | `commitRoot` → `updateClusterBalance` E2E flow | Integration / E2E Tests | P1 | L | +| ITEST-2 | Migration with multiple EB updates E2E | Integration / E2E Tests | P1 | M | +| DEPLOY-1 | ~~Fix `deploy-all.ts` broken signature and constructor args~~ | Deployment & Scripts | P0 | ✅ Fixed — `deploy-all.ts` replaced by `deploy-fresh.ts` + `upgrade.ts` with correct `initializeSSVStaking(uint64,uint32[4],uint16)` signature | +| DEPLOY-2 | Verify `liquidationThresholdPeriod` config vs spec mismatch | Deployment & Scripts | P1 | S | +| DEPLOY-3 | ~~Verify `ethNetworkFee` rounding in config~~ | Deployment & Scripts | P2 | ✅ Closed (negligible) | +| DEPLOY-4 | Remove unused error declarations in `ISSVNetworkCore.sol` | Deployment & Scripts | P2 | 🧹 Cleanup PR candidate | +| DEPLOY-5 | Document `operatorMinFee` governance parameter in DIP-X | Deployment & Scripts | P2 | 🧹 Cleanup PR candidate (spec doc) | +| DEPLOY-6 | DIP-X unstaking description doesn't match implementation | Deployment & Scripts | P2 | 🧹 Cleanup PR candidate (spec doc) | +| DEPLOY-7 | ~~Deploy scripts import from test files~~ | Deployment & Scripts | P2 | ✅ Fixed — `upgrade.ts` and `deploy-fresh.ts` import from `scripts/common/config.ts`, no test file imports | +| QUALITY-1 | ~~`operatorFeeChangeRequests` not cleared on operator removal~~ | Code Quality | P2 | ✅ Closed (dead storage, off-chain sees OperatorRemoved) | +| QUALITY-2 | Redundant `SSVStorage.load()` calls in view function loops | Code Quality | P2 | 🧹 Cleanup PR candidate | +| QUALITY-3 | `withdraw` in SSVClusters duplicates operator loop inline | Code Quality | P2 | S | +| QUALITY-4 | ~~`_resetOperatorState` returns unused `Operator memory`~~ | Code Quality | P3 | ✅ Closed (cosmetic) | +| OPS-1 | Create mainnet deployment runbook | Operational Readiness | P1 | M | +| OPS-2 | Create emergency rollback procedure | Operational Readiness | P1 | M | +| OPS-3 | Update `.env.example` for v2.0.0 | Operational Readiness | P2 | 🧹 Cleanup PR candidate | +| FUZZ-1 | Strengthen 5 partially-covered echidna invariants | Echidna Invariant Suite | P1 | M | +| FUZZ-2 | Add 16 high-priority new echidna invariants (oracle/EB/fees/liquidation/staking) | Echidna Invariant Suite | P1 | L | +| FUZZ-3 | Add 8 medium-priority echidna invariants (Merkle proof, operator fee gov, legacy SSV) | Echidna Invariant Suite | P2 | L | +| FUZZ-4 | Add 6 lower-priority echidna invariants (vUnit aggregation, migration, overflow) | Echidna Invariant Suite | P2 | XL | +| FUZZ-5 | ETH contract balance accounting invariant: `address(this).balance == Σ cluster.balance + Σ operator.ethEarnings + ethDaoBalance + stakingEthPoolBalance` | Echidna Invariant Suite | P1 | M | + +--- + +## Critical Bug Fix + +### [BUG-1] `ensureETHDefaults` overwritten by stale memory copy +- **Type:** Critical Bug Fix +- **Priority:** P0 +- **Status:** Fixed (verified on `ssv-staking`) +- **Owner:** (resolved) +- **Timeline:** (complete) +- **Github Link:** (empty) + +**Requirement:** +Fix `updateClusterOperatorsOnRegistration` so that the memory copy of an operator is taken AFTER `ensureETHDefaults` writes to storage, not before. The stale memory copy currently overwrites the ETH defaults that were just set. + +**Context:** +In `OperatorLib.sol:185`, the operator is loaded into memory. At line 201, `ensureETHDefaults` correctly writes to storage. But at line 239, `s.operators[operatorId] = operator` overwrites storage with the stale memory copy where `ethFee == 0` and `ethSnapshot.block == 0`. For pre-v2 operators that never had ETH fields initialized, this means they silently get zero ETH fees and cluster liquidation thresholds use an incorrect burn rate. This is the highest-severity bug in the codebase. + +**Resolution:** +Code refactored on `ssv-staking` — the function now uses a storage reference (`operatorSt`), calls `ensureOperatorExist` and `ensureETHDefaults` on it, and only then copies to memory. See `OperatorLib.sol:197-201`. + +**Acceptance Criteria:** +- [x] Operator loaded into memory AFTER `ensureETHDefaults` is called, or `ensureETHDefaults` is called on the memory copy and then written back +- [x] Pre-v2 operators get correct `ethFee` (default ETH fee) after first validator registration +- [x] Pre-v2 operators get correct `ethSnapshot.block` (current block) after first registration +- [x] `cumulativeFee` accumulates correctly (not zero) for clusters with pre-v2 operators +- [ ] Existing unit tests still pass +- [ ] New unit test covers registering a validator with a pre-v2 operator and verifying `ethFee != 0` + +**Agent Instructions:** +1. Read `contracts/libraries/OperatorLib.sol` fully, focusing on `updateClusterOperatorsOnRegistration` (line 162). +2. The fix: Move the memory copy (`Operator memory operator = s.operators[operatorId]` at line 185) to AFTER the `ensureETHDefaults(s.operators[operatorId])` call at line 201. Alternatively, call `ensureETHDefaults` on the storage reference first, then load into memory. +3. Ensure the loop structure still works — `ensureETHDefaults` must be called on the storage reference, and then the memory copy should reflect the updated storage. +4. Do NOT change the `ensureETHDefaults` function itself. +5. Do NOT change `updateClusterOperators` or `updateClusterOperatorsOnReactivation` — they are separate code paths. +6. Add a unit test in `test/unit/SSVValidator/` that registers a validator using operators whose `ethFee` and `ethSnapshot.block` are both zero (simulating pre-v2 state), then verifies: + - `operator.ethFee` is set to the default ETH fee after registration + - `operator.ethSnapshot.block` is the current block + - The cluster's cumulative fee correctly includes the operator's ETH fee +7. Run `npm run test:unit` to verify all tests pass. + +#### Sub-items: +- [ ] Sub-task 1: Reorder memory load to after `ensureETHDefaults` in `updateClusterOperatorsOnRegistration` +- [ ] Sub-task 2: Write unit test for pre-v2 operator ETH fee initialization during validator registration +- [ ] Sub-task 3: Run full unit test suite and verify no regressions + +--- + +### [BUG-2] `_resetOperatorState` doesn't clear `operator.owner` +- **Type:** ~~Critical Bug Fix~~ Informational — Won't Fix +- **Priority:** ~~P0~~ N/A +- **Status:** Closed (by design) +- **Owner:** (resolved) +- **Timeline:** (complete) +- **Github Link:** (empty) + +**Original Requirement:** +When an operator is removed via `removeOperator`, the `_resetOperatorState` function must also clear `operator.owner` to ensure removed operators are consistently detectable across all code paths. + +**Resolution — Intentional Design:** +Preserving `operator.owner` after removal is intentional behavior, consistent since v1 (`main` branch). Reasons: + +1. **Off-chain queryability:** `getOperatorById` (SSVViews.sol:89) returns the preserved owner so explorers/UIs can display who owned a removed operator. Clearing it would lose this information on-chain. +2. **All on-chain guards are already safe:** + - `checkOwner` (OperatorLib.sol:131): catches removed operators via `snapshot.block == 0 && ethSnapshot.block == 0` — never reaches the owner check + - `ensureOperatorExist` (OperatorLib.sol:159): catches via `(ethSnapshot.block == 0 && snapshot.block == 0)` — second condition fires even though `owner != address(0)` + - `getSSVBurnRate` (SSVViews.sol:356): removed operators pass `owner != address(0)` but contribute zero fee (fee is already zeroed) — no impact +3. **No exploit path:** there is no code path where a non-zero owner on a removed operator leads to incorrect state mutation or access control bypass. + +Updated documentation in `docs/FLOWS.md` section 4.2 to reflect this design with a full detection-method table. + +#### Sub-items: +- [ ] Sub-task 1: Add `operator.owner = address(0)` to `_resetOperatorState` +- [ ] Sub-task 2: Audit all `operator.owner` references for compatibility +- [ ] Sub-task 3: Add unit test verifying owner is cleared after removal +- [ ] Sub-task 4: Run full test suite + +--- + +### [BUG-3] `ensureETHDefaults` resurrects removed operators +- **Type:** ~~Critical Bug Fix~~ Mitigated +- **Priority:** ~~P0~~ N/A +- **Status:** Closed (mitigated by upstream guards on `ssv-staking`) +- **Owner:** (resolved) +- **Timeline:** (complete) +- **Github Link:** (empty) + +**Original Requirement:** +`ensureETHDefaults` must not set `ethSnapshot.block` on removed operators. Add a guard to skip operators that have been removed. + +**Resolution — All call sites are already guarded:** +While `ensureETHDefaults` itself has no removed-operator guard, no code path can reach it with a removed operator: + +1. **`updateClusterOperatorsOnRegistration` (line 200):** `ensureOperatorExist` (line 198) reverts first for removed operators (both snapshot blocks are 0). +2. **`declareOperatorFee` (SSVOperators.sol:107):** `checkOwner` (line 100) reverts first for removed operators (both snapshot blocks are 0). +3. **`updateClusterOperatorsMigration` (line 395):** Explicit `continue` at line 380 skips removed operators (`snapshot.block == 0 && ethSnapshot.block == 0`). Only operators with at least one non-zero snapshot block reach `ensureETHDefaults`. + +**Acceptance Criteria:** +- [x] `ensureETHDefaults` does not modify removed operators (unreachable via all call sites) +- [x] Removed operators keep `ethSnapshot.block == 0` after any call path +- [x] New validators cannot be registered to clusters containing removed operators (enforced by `ensureOperatorExist`, PR #410) +- [x] Existing migration and registration tests still pass + +--- + +### [BUG-4] ~~Double deviation cleanup on liquidated cluster validator removal~~ +- **Type:** Critical Bug Fix +- **Priority:** P0 +- **Status:** ✅ Fixed +- **Owner:** N/A +- **Timeline:** Merged 2026-02-17 +- **Github Link:** [PR #429](https://github.com/ssvlabs/ssv-network/pull/429) (merged) + +**Requirement:** +Fix `_bulkRemoveValidator` so that when removing the last validators from a liquidated cluster with explicit EB tracking, deviation is not double-subtracted from `operatorEthVUnits` and `daoTotalEthVUnits`. + +**Context:** +In `SSVValidators.sol:164-247`, when a cluster is liquidated (`!cluster.active`), the `if (cluster.active)` guard at line 194 skips the operator update. However, the EB deviation cleanup block at lines 211-240 still runs. If the cluster had explicit EB tracking and was liquidated, the deviation was already cleaned up during `_executeLiquidation` (`SSVClusters.sol:554-614`). When `_bulkRemoveValidator` subtracts deviation again at lines 230 and 233, this double-subtracts from `operatorEthVUnits` and `daoTotalEthVUnits`, potentially causing underflow and reverting — which blocks validator removal entirely. + +**Acceptance Criteria:** +- [ ] Removing validators from a liquidated cluster with explicit EB tracking does NOT double-subtract deviation +- [ ] `operatorEthVUnits` and `daoTotalEthVUnits` are correct after removing validators from a liquidated cluster +- [ ] Removing validators from a liquidated cluster without explicit EB tracking still works +- [ ] Removing validators from an active cluster is unchanged +- [ ] New test: liquidate a cluster with explicit EB → remove validators → verify no revert and correct deviation values + +**Agent Instructions:** +1. Read `contracts/modules/SSVValidators.sol`, focus on `_bulkRemoveValidator` (line 164), particularly the EB deviation cleanup block at lines 211-240. +2. Read `contracts/modules/SSVClusters.sol`, focus on `_executeLiquidation` (line 554) to understand what deviation cleanup liquidation already performs. +3. The fix: Add a guard in the deviation cleanup block (around line 218-237) that skips the `operatorEthVUnits` and `daoTotalEthVUnits` subtraction when `!cluster.active`. The `ebSnapshot.vUnits` zeroing can remain (it's per-cluster and not double-counted). +4. Alternatively, wrap the deviation cleanup in `if (cluster.active || ...)` to only clean up deviation for active clusters. +5. Follow the existing pattern in the codebase where `cluster.active` guards are used. +6. Add a test in `test/unit/SSVValidator/` that: creates a cluster with EB tracking → liquidates it → removes validators → verifies `operatorEthVUnits` and `daoTotalEthVUnits` are correct (not underflowed). +7. Run `npm run test:unit`. + +#### Sub-items: +- [x] Sub-task 1: Add `cluster.active` guard around deviation cleanup in `_bulkRemoveValidator` +- [x] Sub-task 2: Write test for validator removal from liquidated cluster with explicit EB (`test/unit/SSVValidator/bug4-double-deviation-liquidated.test.ts`) +- [ ] Sub-task 3: Run full test suite + +--- + +### [BUG-5] `_liquidateAfterEBUpdateIfNeeded` condition too strict for ETH-only operators +- **Type:** Critical Bug Fix +- **Priority:** P1 +- **Status:** Fixed +- **Owner:** (resolved) +- **Timeline:** (complete) +- **Github Link:** (empty) + +**Requirement:** +Fix the condition at `SSVClusters.sol:543` so that `ethValidatorCount` is decremented for ETH-only operators (those with `ethSnapshot.block != 0` but `snapshot.block == 0`). + +**Context:** +In `_liquidateAfterEBUpdateIfNeeded` at `SSVClusters.sol:521-552`, line 543 checks `op.ethSnapshot.block != 0 && op.snapshot.block != 0` before decrementing `ethValidatorCount`. Operators registered after the v2.0.0 migration may have `snapshot.block == 0` (never had SSV activity), so the decrement is skipped — leaving `ethValidatorCount` inflated. + +**Acceptance Criteria:** +- [ ] `ethValidatorCount` is decremented for operators with `ethSnapshot.block != 0` regardless of `snapshot.block` +- [ ] Operators with `ethSnapshot.block == 0` (removed) are still skipped +- [ ] No change to the `_executeLiquidation` call + +**Agent Instructions:** +1. Read `contracts/modules/SSVClusters.sol`, focus on `_liquidateAfterEBUpdateIfNeeded` (line 521). +2. Change the condition at line 543 from `op.ethSnapshot.block != 0 && op.snapshot.block != 0` to just `op.ethSnapshot.block != 0`. +3. Verify this doesn't break the removed-operator skip (removed operators have `ethSnapshot.block == 0` after `_resetOperatorState`). +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Fix condition in `_liquidateAfterEBUpdateIfNeeded` +- [ ] Sub-task 2: Add test for EB auto-liquidation with ETH-only operators +- [ ] Sub-task 3: Run full test suite + +--- + +### [BUG-6] Rewards lost when `totalStaked == 0` in staking `_syncFees` +- **Type:** Critical Bug Fix +- **Priority:** P1 +- **Status:** ✅ Mitigated (deployment) +- **Owner:** (deployment team) +- **Timeline:** At upgrade +- **Github Link:** Mitigated via [PR #431](https://github.com/ssvlabs/ssv-network/pull/431) (upgrade batch includes initial DAO stake) +- **DIP-X Review Source:** SSV Staking review findings DIP-18, DIP-19 + +**Requirement:** +When `totalStaked == 0` in `_syncFees`, ETH rewards must not be silently lost. Either accumulate them for the next sync when stakers exist, or redirect them to the DAO. + +**Context:** +`SSVStaking.sol:179-203`: When `totalStaked == 0`, line 196 skips the `accEthPerShare` increment but line 201 still advances `stakingEthPoolBalance`. The fees earned during the zero-staked period are permanently locked in the contract — they can never be distributed to future stakers. + +**Additional context from DIP-X review (DIP-19):** The `_syncFees` function also has a related edge case when `current <= previous` (DAO earnings decrease). At `SSVStaking.sol:187-190`, if `current.lte(previous)`, the function silently updates `stakingEthPoolBalance` to the lower value and returns without distributing. This can happen after reward claims reduce `sp.ethDaoBalance`. While `claimEthRewards` reduces both `stakingEthPoolBalance` and `sp.ethDaoBalance` by the same packed amount (so `current == previous` after normal claims), this edge case acts as a safety valve. The fix for BUG-6 should also consider this interaction to ensure no fees are lost in either direction. + +**Mitigation:** +This is mitigated by deployment procedure rather than a code fix. The DAO multisig (Safe) upgrade batch transaction includes an SSV `approve` + `stake(1 SSV)` call immediately after `upgradeToAndCall`. This ensures `totalStaked > 0` before any network fees can accrue, making the zero-staked window impossible in practice. The 1 SSV stake goes to the DAO address, so the tokens are not lost. The full upgrade batch is: +1. `upgradeToAndCall` (proxy upgrade + `initializeSSVStaking` with quorumBps=7500) +2. `updateModule` × 7 (all module addresses) +3. SSV token `approve` (SSVNetwork contract as spender) +4. `stake(1_000_000_000)` (1 SSV minimum stake from DAO) +5. Governance parameter updates (`updateNetworkFee`, `updateLiquidationThresholdPeriod`, etc.) + +All executed atomically in a single Safe multisig batch transaction. + +**Acceptance Criteria:** +- [x] Deployment runbook includes DAO stake as part of upgrade batch +- [x] `initializeSSVStaking` now validates `quorumBps` (PR #431) +- [ ] Verify Safe batch transaction encoding before mainnet execution +- [ ] Post-upgrade: confirm `totalStaked > 0` on-chain + +#### Sub-items: +- [x] Sub-task 1: Document deployment mitigation in MAINNET-READINESS.md +- [x] Sub-task 2: Add quorumBps to initializer (PR #431) +- [ ] Sub-task 3: Encode and test Safe batch transaction before mainnet + +--- + +### [BUG-7] ~~`DEFAULT_OPERATOR_ETH_FEE` value deviates from DIP-X spec~~ +- **Type:** ~~Critical Bug Fix~~ +- **Priority:** ~~P1~~ Closed +- **Status:** ✅ Closed (negligible) +- **Owner:** N/A +- **Timeline:** N/A +- **Github Link:** N/A + +**Resolution:** Difference is ~0.31% (~0.0000143 ETH/year per validator). Negligible. Mainnet config uses the DIP-X intended value adjusted for packability. +- **DIP-X Review Source:** ETH Payments review findings ETH-7, ETH-14 + +**Requirement:** +The `DEFAULT_OPERATOR_ETH_FEE` constant is set to `1,770,000,000` wei (1.77 gwei) but the DIP-X specifies `0.000000001775464912 ETH` (1,775,464,912 wei = 1.775464912 gwei). The DIP value is not packable (not divisible by `ETH_DEDUCTED_DIGITS = 100,000`), so a rounded value must be used. The implementation chose `1,770,000,000` which is further from the spec than necessary. The closest packable value rounding up is `1,775,500,000`. + +**Context:** +`SSVCoreTypes.sol:14`: `DEFAULT_OPERATOR_ETH_FEE = 1770_000_000`. The DIP value `1,775,464,912 % 100,000 = 64,912` (not divisible), so it would revert with `MaxPrecisionExceeded`. The closest valid values are `1,775,400,000` (rounding down) or `1,775,500,000` (rounding up). The current value under-delivers by ~0.31% on the stated fee. Per-block difference: 5,464,912 wei. Annual impact per validator: ~0.0000143 ETH less than DIP target. + +**Acceptance Criteria:** +- [ ] `DEFAULT_OPERATOR_ETH_FEE` updated to `1_775_500_000` (closest packable value rounding up) or team explicitly documents acceptance of the current rounded value +- [ ] Value is verified to be divisible by `ETH_DEDUCTED_DIGITS` (100,000) +- [ ] DIP-X document updated to note the rounding constraint if current value is kept +- [ ] Existing unit tests still pass with updated constant + +**Agent Instructions:** +1. Read `contracts/libraries/SSVCoreTypes.sol`, find the `DEFAULT_OPERATOR_ETH_FEE` constant. +2. Verify `1_775_500_000 % 100_000 == 0` (it is). +3. Change `DEFAULT_OPERATOR_ETH_FEE = 1770_000_000` to `DEFAULT_OPERATOR_ETH_FEE = 1_775_500_000`. +4. Run `npx hardhat compile` to verify compilation. +5. Run `npm run test:unit` to verify no regressions. +6. If tests fail due to hardcoded expectations, update test constants to match. + +#### Sub-items: +- [ ] Sub-task 1: Update `DEFAULT_OPERATOR_ETH_FEE` constant or document acceptance of current value +- [ ] Sub-task 2: Verify packability and run tests +- [ ] Sub-task 3: Update DIP-X if needed + +--- + +### [BUG-8] ~~Cooldown duration uses `block.timestamp` but DIP specifies blocks~~ +- **Type:** ~~Critical Bug Fix~~ +- **Priority:** ~~P1~~ Closed +- **Status:** ✅ Closed (not a bug) +- **Owner:** N/A +- **Timeline:** N/A +- **Github Link:** N/A +- **DIP-X Review Source:** SSV Staking review finding DIP-8 + +**Resolution:** Implementation correctly uses `block.timestamp` (seconds). The deployment config (`deployments/hoodi-prod/config.json`) already has `cooldownDuration: 604800` (7 days in seconds). The DIP spec wording saying "blocks" was imprecise — team confirmed (Yurii) it's seconds. The spreadsheet value `50120` was a blocks-equivalent reference, not the actual config value. + +**Requirement:** +The DIP-X governance table explicitly states `cooldownDuration` is "in blocks" with initial value "50120 (7 days)" and setter `setUnstakeCooldownDuration(uint64 blocks)`. However, the implementation uses `block.timestamp` (seconds-based), not `block.number`. This creates a critical configuration risk: if `cooldownDuration` is initialized to 50120 thinking it's blocks, the actual cooldown would be ~13.9 hours instead of 7 days. + +**Context:** +`SSVStaking.sol:88`: `uint64 unlockTime = uint64(block.timestamp + s.cooldownDuration)`. The `UnstakeRequest` struct field is named `unlockTime` (timestamp-like), and `SSVStaking.sol:232` checks `requests[i].unlockTime <= block.timestamp`. Using `block.timestamp` is actually more reliable for user-facing cooldowns (block times can vary), so the implementation choice is reasonable — but the DIP/spec and the initial value must align. If using seconds, the correct 7-day value is 604,800, not 50,120. + +**Acceptance Criteria:** +- [ ] Either: DIP-X updated to say "in seconds" and initial value changed to `604800` (7 days in seconds) +- [ ] Or: implementation changed to use `block.number` instead of `block.timestamp` to match DIP +- [ ] The upgrade initializer sets the correct value for whichever unit is chosen +- [ ] `setUnstakeCooldownDuration` parameter is documented with correct units +- [ ] Existing tests verified to use the correct unit + +**Agent Instructions:** +1. Read `contracts/modules/SSVStaking.sol`, focus on `requestUnstake` (line 66) and `calculateTotalUnfrozenBalance` (line 226). +2. Read `contracts/modules/SSVDAO.sol`, focus on `setUnstakeCooldownDuration` (line 245). +3. Read `contracts/upgrades/stage/hoodi/SSVNetworkSSVStakingUpgrade.sol` for the initial value set during upgrade. +4. Recommended fix (simpler): Keep `block.timestamp` usage (it's better UX), but: + a. Update the DIP-X governance table to say "in seconds" instead of "in blocks" + b. Ensure the upgrade initializer sets `cooldownDuration = 604800` (7 days in seconds) + c. Update `setUnstakeCooldownDuration` parameter name from `blocks` to `duration` in the interface +5. Check deployment configs (`deployments/hoodi-prod/config.json`, `deployments/hoodi-stage/config.json`) for the cooldown value and verify it matches the chosen unit. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Decide on units (seconds vs blocks) and align implementation + DIP +- [ ] Sub-task 2: Verify upgrade initializer sets correct value for chosen unit +- [ ] Sub-task 3: Update interface parameter name if needed +- [ ] Sub-task 4: Run full test suite + +--- + +### [BUG-9] ~~`uint64(delta)` silent truncation in operator earnings accumulation~~ +- **Type:** ~~Critical Bug Fix~~ +- **Priority:** ~~P1~~ Closed +- **Status:** ✅ Closed (not realistic) +- **Owner:** N/A +- **Timeline:** N/A +- **Github Link:** N/A + +**Resolution:** Overflow is not realistic under DAO-enforced fee caps. Worst case with `maxOperatorEthFee = 5,326,300,000` wei/block (DAO cap), 500 validators at max EB (2048 ETH), and 1 year without any snapshot update: `delta ≈ 4.48e15`, which is **4,100x below** `uint64.max` (1.845e19). Even at 10 years with zero snapshot updates (impossible in practice — every cluster operation triggers a snapshot), delta would still be 400x below the threshold. The original audit example used an unrestricted fee value not bounded by the DAO's `maxOperatorEthFee`. + +**Original context (for reference):** +In `OperatorLib.sol:68-69` (also lines 93-94, 326-327), `PackedETH.wrap(uint64(delta))` silently truncates when delta exceeds `uint64.max` (1.845e19). With 500 validators at max EB (2048 ETH), 2.7 years between snapshots: `delta = 4.078e21`, which is 221x larger than `uint64.max`. The operator loses ~99.5% of accumulated earnings. + +**Concrete example:** Operator with `effectiveVUnits=320,000,000`, `ethFee=17,700` packed, `7,200,000` block gap → `delta = 320_000_000 * 17_700 * 7_200_000 = 4.078e16 * 100_000 = 4.078e21`, which overflows `uint64.max` and silently truncates. + +**Acceptance Criteria:** +- [ ] `delta` exceeding `uint64.max` either reverts with a clear error or is safely handled +- [ ] Use `SafeCast.toUint64(delta)` or add `require(delta <= type(uint64).max)` at all three locations +- [ ] Existing tests pass +- [ ] New test: operator with high vUnits and long gap → verify no silent truncation + +**Agent Instructions:** +1. Read `contracts/libraries/OperatorLib.sol`, focus on lines 68-69, 93-94, and 326-327. +2. Import OpenZeppelin's `SafeCast` or add manual bounds checks. +3. Replace `uint64(delta)` with `SafeCast.toUint64(delta)` at all three locations. +4. Add a unit test with high vUnits and long block gap to verify the fix catches overflow. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Replace `uint64(delta)` with SafeCast at all three locations in OperatorLib.sol +- [ ] Sub-task 2: Add unit test for operator earnings overflow scenario +- [ ] Sub-task 3: Run full test suite + +--- + +## Security Hardening + +### [SEC-1] `setQuorumBps(0)` allows zero-threshold oracle commits +- **Type:** Security Hardening +- **Priority:** P2 (downgraded from P0) +- **Status:** ✅ Mitigated (owner-only) +- **Owner:** N/A +- **Timeline:** N/A +- **Github Link:** N/A + +**Requirement:** +Add a minimum quorum validation to `setQuorumBps`. A quorum of 0 allows a single oracle vote to commit any root. + +**Context:** +`SSVDAO.sol:234-239`: The function only checks `quorum > BPS_DENOMINATOR` (max bound). Setting `quorumBps = 0` makes the threshold in `commitRoot` (line 186) equal to 0, meaning any single oracle can unilaterally commit roots. Combined with SEC-2 (quorum defaults to 0 after upgrade), this is an immediate post-upgrade vulnerability. + +**Mitigation:** Downgraded to P2. `setQuorumBps` is owner-only (DAO multisig). A compromised or negligent owner can already upgrade the entire contract, so zero-quorum via the setter is not an independent attack vector. The critical path (SEC-2: quorum defaulting to 0 after upgrade) is already fixed in PR #431 by validating quorumBps in the initializer. + +**Acceptance Criteria:** +- [ ] `setQuorumBps(0)` reverts with `InvalidQuorum()` +- [ ] A reasonable minimum is enforced (e.g., `quorum >= 2500` for 25%, or at minimum `quorum > 0`) +- [ ] Existing tests for `setQuorumBps` updated to reflect new validation +- [ ] New test: call `setQuorumBps(0)` → expect revert + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol`, focus on `setQuorumBps` (line 234). +2. Add `if (quorum == 0) revert InvalidQuorum();` before the existing check. Consider also adding a minimum like `if (quorum < 2500)` for stronger safety. +3. Read `test/unit/SSVDAO/setQuorumBps.test.ts` for existing test patterns. +4. Add a test case for `setQuorumBps(0)` expecting `InvalidQuorum` revert. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add minimum quorum validation to `setQuorumBps` +- [ ] Sub-task 2: Update/add unit tests for quorum boundary +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-2] ~~`quorumBps` not initialized during upgrade — zero by default~~ +- **Type:** Security Hardening +- **Priority:** P0 +- **Status:** ✅ Fixed +- **Owner:** (resolved) +- **Timeline:** (complete) +- **Github Link:** [PR #431](https://github.com/ssvlabs/ssv-network/pull/431) + +**Requirement:** +Set `quorumBps` during the upgrade initializer (`reinitializer(3)`) to prevent a window where any oracle can unilaterally commit roots. + +**Context:** +`SSVNetworkSSVStakingUpgrade.sol` (line 8) initialized `cooldownDuration` and `defaultOracleIds` but NOT `quorumBps`. After upgrade, `quorumBps` was 0 in storage until the DAO manually called `setQuorumBps()`. During this window, combined with SEC-1, a single oracle could commit arbitrary Merkle roots. Now fixed — see Resolution below. + +**Resolution:** +`initializeSSVStaking` now accepts `quorumBps` as a third parameter (`uint16`) and validates `if (quorumBps == 0 || quorumBps > 10_000) revert InvalidQuorum()` before writing to storage. Both `upgrade.ts` and `generate-safe-batch.ts` pass `quorumBps` from the deployment config. This closes the initialization window entirely. + +**Acceptance Criteria:** +- [x] `quorumBps` is set during the upgrade initializer to a safe default (7500 = 75% per DIP-X spec) +- [x] Initializer validates `quorumBps != 0` (rejects zero with `InvalidQuorum`) +- [x] Post-upgrade verification confirms `quorumBps != 0` + +**Agent Instructions:** +1. Read `contracts/upgrades/stage/hoodi/SSVNetworkSSVStakingUpgrade.sol` (line 8). +2. Option A (preferred): Add `SSVStorageStaking.load().quorumBps = 7500;` to the `initializeSSVStaking` function. Also add `quorumBps` as a parameter: `initializeSSVStaking(uint64 cooldownDuration, uint32[4] memory defaultOracleIds, uint16 quorumBps)`. Update the function signature in `scripts/upgrade.ts` and `scripts/generate-safe-batch.ts` accordingly. +3. Option B (simpler): Add a hardcoded `SSVStorageStaking.load().quorumBps = 7500;` directly in the initializer without adding a parameter. +4. Emit `QuorumUpdated(7500)` event after setting. +5. Update the initializer ABI references in deploy scripts. +6. Run `npm run test:unit` and `npm run test:integration`. + +#### Sub-items: +- [x] Sub-task 1: Add `quorumBps` initialization to upgrade initializer +- [x] Sub-task 2: Update deploy scripts to match new signature +- [ ] Sub-task 3: Add test verifying `quorumBps` is set after upgrade +- [ ] Sub-task 4: Run full test suite + +--- + +### [SEC-3] ~~`replaceOracle` doesn't invalidate pending votes~~ +- **Type:** Security Hardening +- **Priority:** ~~P1~~ P2 (downgraded) +- **Status:** ✅ Mitigated (owner-only + coordinated oracles) +- **Owner:** N/A +- **Timeline:** N/A +- **Github Link:** N/A + +**Resolution:** `replaceOracle` is owner-only (DAO multisig), and the oracle set is a small coordinated group working with the DAO. If an oracle is compromised and replaced mid-vote, the remaining honest oracles can simply propose and vote on a correct root — the compromised oracle's stale vote alone cannot reach quorum (needs 3-of-4). Any edge case is resolvable operationally by the DAO + oracle operators. + +**Original context (for reference):** +`SSVDAO.sol:205-229`: When `replaceOracle` is called, the old oracle's address is removed from `oracleIdOf` but the `oracleId` stays the same. The `hasVoted` mapping uses `oracleId`, so: (1) the old oracle's votes persist and count toward quorum, (2) the new oracle cannot re-vote on pending commitments since `hasVoted[commitmentKey][oracleId]` is already true. A compromised oracle replaced mid-vote still influences quorum. + +**Acceptance Criteria:** +- [ ] Either: pending votes for the replaced oracleId are reset when `replaceOracle` is called +- [ ] Or: this behavior is explicitly documented with risk analysis, and a mechanism exists to clear stale votes if needed +- [ ] Test: replace oracle mid-vote → verify new oracle can vote on pending commitments + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol`, focus on `replaceOracle` (line 205) and `commitRoot` (line 155). +2. Read the `SSVStorageEB` storage struct to understand the `hasVoted` and `commitmentWeight` mappings. +3. To reset pending votes: after replacing the oracle, iterate over pending commitments and clear `hasVoted[commitmentKey][oracleId]` and subtract the old oracle's weight from `commitmentWeight[commitmentKey]`. However, this requires tracking pending commitments, which may not be stored. +4. Simpler alternative: add a `voteNonce` per oracleId. Increment it on replacement. Use `keccak256(commitmentKey, oracleId, voteNonce)` for the hasVoted key. This invalidates all old votes automatically. +5. Ensure the fix doesn't break the quorum mechanism for non-replaced oracles. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Design vote invalidation mechanism +- [ ] Sub-task 2: Implement in `replaceOracle` and `commitRoot` +- [ ] Sub-task 3: Write tests for oracle replacement mid-vote +- [ ] Sub-task 4: Run full test suite + +--- + +### [SEC-4] ~~`setUnstakeCooldownDuration` allows zero cooldown~~ +- **Type:** Security Hardening +- **Priority:** ~~P1~~ P2 (downgraded) +- **Status:** ✅ Mitigated (owner-only, no accounting risk) +- **Owner:** N/A +- **Timeline:** N/A +- **Github Link:** N/A + +**Resolution:** `setUnstakeCooldownDuration` is owner-only (DAO multisig). Zero cooldown allows instant unstaking but causes no accounting issues — `requestUnstake` still goes through `_syncFees`, `_settleWithBalance`, cSSV burn, and proper reward settlement. The "stake/vote/unstake" attack described below isn't viable because oracle voting is based on oracle addresses (not staking), and staking weight only affects quorum threshold which is DAO-controlled. Same owner-trust argument as SEC-1/SEC-3. + +**Original context (for reference):** +`SSVDAO.sol:245-248`: No minimum check. Zero cooldown allows stake/vote/unstake in one block, defeating the economic security mechanism. An attacker could stake, earn oracle voting rights, manipulate a vote, and immediately unstake. + +**Acceptance Criteria:** +- [ ] `setUnstakeCooldownDuration(0)` reverts +- [ ] A reasonable minimum is enforced (e.g., 1 day = 86400 seconds) +- [ ] Existing tests updated + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol`, focus on `setUnstakeCooldownDuration` (line 245). +2. Add `if (duration == 0) revert InvalidCooldownDuration();` (define new error in `ISSVNetworkCore.sol` if needed, or reuse an existing generic error). +3. Consider adding a minimum like `if (duration < 86400) revert ...;` for 1-day minimum. +4. Update `test/unit/SSVDAO/setUnstakeCooldownDuration.test.ts`. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add minimum cooldown validation +- [ ] Sub-task 2: Update/add unit tests +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-5] ~~`totalStaked` changes between oracle votes (front-running risk)~~ +- **Type:** Security Hardening +- **Priority:** ~~P1~~ P2 (downgraded) +- **Status:** ✅ Mitigated (impractical) + +**Resolution:** Oracles vote 3 times per day across separate blocks. To block quorum, an attacker would need to stake exponentially increasing amounts of SSV between each vote (e.g., 9K → 90K → 900K). This is economically impractical — the attacker's SSV is locked in cooldown, and the capital requirement grows exponentially per blocked commitment. Even if one commitment is blocked, oracles simply propose a new one. Pure liveness attack with no safety impact (can't force bad roots). +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Snapshot `totalStaked` at the start of a voting round (first proposal) and use the snapshotted value for all subsequent votes in that round, preventing front-running via stake/unstake between votes. + +**Context:** +`SSVDAO.sol:155-200` (`commitRoot`): Each oracle vote reads `totalStaked` fresh (line 172). Between votes, `totalStaked` can change via stake/unstake. This makes the quorum threshold inconsistent within a single voting round — someone could front-run oracle votes with large stake/unstake operations to either block legitimate quorum or force premature quorum. + +**Acceptance Criteria:** +- [ ] `totalStaked` is captured once per voting round and used for all votes in that round +- [ ] Weight calculation and threshold calculation use the same snapshotted value +- [ ] Test: oracle A votes, large stake change, oracle B votes → quorum uses consistent weight + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol`, focus on `commitRoot` (line 155). +2. Read `contracts/libraries/storage/SSVStorageEB.sol` to understand what state is tracked per commitment. +3. Design: Add a `snapshotTotalStaked` field to the commitment state. On first vote for a new commitmentKey, snapshot `totalStaked`. On subsequent votes, use the snapshot instead of re-reading. +4. Store the snapshot in `SSVStorageEB` alongside `commitmentWeight`. +5. When a commitment is finalized (root committed), clean up the snapshot. +6. This is a more involved change — be careful not to break existing oracle voting logic. +7. Run `npm run test:unit` and `npm run test:integration`. + +#### Sub-items: +- [ ] Sub-task 1: Add `snapshotTotalStaked` to commitment state in SSVStorageEB +- [ ] Sub-task 2: Snapshot on first vote, use snapshot for subsequent votes +- [ ] Sub-task 3: Clean up snapshot on commitment finalization +- [ ] Sub-task 4: Write tests for consistent weight across votes +- [ ] Sub-task 5: Run full test suite + +--- + +### [SEC-6] Add `nonReentrant` to `migrateClusterToETH` +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add the `nonReentrant` modifier to `migrateClusterToETH` for defense-in-depth. The function calls `CoreLib.transferTokenBalance` (SSV ERC20 transfer) at line 341. + +**Context:** +`SSVClusters.sol:264`: While the SSV token is a standard ERC20 without transfer hooks (so reentrancy via token callback is unlikely), adding `nonReentrant` follows the codebase's established pattern for functions that make external calls. State changes happen before the transfer (checks-effects-interactions), but the modifier provides an additional safety layer. + +**Acceptance Criteria:** +- [ ] `migrateClusterToETH` has the `nonReentrant` modifier +- [ ] Existing migration tests still pass + +**Agent Instructions:** +1. Read `contracts/modules/SSVClusters.sol`, focus on `migrateClusterToETH` (line 264). +2. Add `nonReentrant` modifier to the function signature, following the pattern used by `liquidate`, `withdraw`, etc. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add `nonReentrant` modifier to `migrateClusterToETH` +- [ ] Sub-task 2: Run full test suite + +--- + +### [SEC-7] Add `nonReentrant` to `onCSSVTransfer` +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add `nonReentrant` modifier to `onCSSVTransfer` for defense-in-depth consistency. + +**Context:** +`SSVStaking.sol:169`: The function makes external calls to `ICSSVToken.totalSupply()` and `ICSSVToken.balanceOf()`. While the cSSV token is trusted (deployed by the protocol), the modifier provides protection if cSSV is ever upgraded or replaced. All other staking functions already have `nonReentrant`. + +**Acceptance Criteria:** +- [ ] `onCSSVTransfer` has the `nonReentrant` modifier +- [ ] Existing staking tests still pass + +**Agent Instructions:** +1. Read `contracts/modules/SSVStaking.sol`, focus on `onCSSVTransfer` (line 169). +2. Add `nonReentrant` modifier. Import `SSVReentrancyGuard` if not already imported. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add `nonReentrant` modifier to `onCSSVTransfer` +- [ ] Sub-task 2: Run full test suite + +--- + +### [SEC-8] `reactivate` not emitting warning for removed operators +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +When a cluster is reactivated and one or more of its operators have been removed, emit an event indicating which operators are inactive so users and off-chain systems are aware. + +**Context:** +`SSVClusters.sol:133-185`: `reactivate` calls `updateClusterOperatorsOnReactivation` (line 151), which skips removed operators at `OperatorLib.sol:311`. The cluster is reactivated with fewer active operators, but no event signals this. Users may not realize their cluster is running with reduced operator coverage. + +**Acceptance Criteria:** +- [ ] A new event (e.g., `InactiveOperatorInCluster(uint64 operatorId)`) is emitted for each removed operator during reactivation +- [ ] OR: existing `ClusterReactivated` event includes information about skipped operators +- [ ] Test: reactivate a cluster with a removed operator → verify event emission + +**Agent Instructions:** +1. Read `contracts/modules/SSVClusters.sol`, focus on `reactivate` (line 133). +2. Read `contracts/libraries/OperatorLib.sol`, focus on `updateClusterOperatorsOnReactivation` (line 295), particularly the `ethSnapshot.block != 0` check at line 311. +3. Add return data from `updateClusterOperatorsOnReactivation` that indicates which operators were skipped, or emit events directly from the library function. +4. Define the new event in `ISSVClusters.sol`. +5. Add test in `test/unit/SSVClusters/reactivate.test.ts`. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Define and emit inactive operator event +- [ ] Sub-task 2: Write test for reactivation with removed operator event +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-9] `operatorMaxFee` function signature differs from DIP-X spec +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) +- **DIP-X Review Source:** ETH Payments review finding ETH-13 + +**Requirement:** +The DIP-X governance table specifies `updateMaximumOperatorFee(uint64 maxFee)` but the implementation uses `updateMaximumOperatorFee(uint256 maxFee)`. While the `uint256` parameter is more user-friendly (users pass the full wei value, packing handles conversion), the DIP and implementation should be aligned. + +**Context:** +`SSVDAO.sol:138`: `function updateMaximumOperatorFee(uint256 maxFee)`. The `uint256` value is packed into `PackedETH` (uint64) internally via `PackedETHLib.pack(maxFee)`. This is a cosmetic interface difference, not a functional issue. The `uint256` parameter prevents users from needing to pre-pack their values. However, ABIs and documentation should be consistent. + +**Acceptance Criteria:** +- [ ] Either: DIP-X updated to document `uint256` parameter type (recommended — matches implementation's user-friendly design) +- [ ] Or: implementation changed to `uint64` to match DIP (not recommended — less user-friendly) +- [ ] ABI documentation updated to match + +**Agent Instructions:** +1. This is primarily a documentation alignment task. +2. Read `contracts/modules/SSVDAO.sol`, focus on `updateMaximumOperatorFee` (line 138). +3. Read `contracts/interfaces/ISSVDAO.sol` for the interface declaration. +4. Update the DIP-X governance table to specify `uint256` instead of `uint64`. +5. No code change needed if DIP is updated. + +#### Sub-items: +- [ ] Sub-task 1: Align DIP-X and implementation on parameter type +- [ ] Sub-task 2: Update ABI documentation + +--- + +### [SEC-10] cSSV token lacks governance/voting extensions (ERC20Votes) +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) +- **DIP-X Review Source:** SSV Staking review finding DIP-10 + +**Requirement:** +The DIP-X states: "Staked SSV, represented by cSSV, retains full governance and voting power. Holding cSSV does not reduce a user's ability to participate in DAO governance compared to holding unstaked SSV." However, `CSSVToken.sol` is a plain `ERC20` with no `ERC20Votes` or delegation mechanism. Whether governance rights are preserved depends entirely on off-chain configuration (e.g., Snapshot strategy). + +**Context:** +`CSSVToken.sol:10`: `contract CSSVToken is ERC20`. No `ERC20Votes`, no `ERC20VotesComp`, no delegation mechanism. The SSV DAO uses Snapshot (off-chain governance), which can be configured to count cSSV balances. If the Snapshot strategy includes cSSV, the DIP claim holds. If on-chain governance is ever needed, cSSV holders would lose voting power compared to SSV holders. + +**Acceptance Criteria:** +- [ ] Decision documented: is off-chain governance (Snapshot) the permanent governance mechanism? +- [ ] If yes: verify the Snapshot strategy is updated to include cSSV balances before mainnet launch +- [ ] If on-chain governance is planned: add `ERC20Votes` extension to `CSSVToken` +- [ ] DIP-X updated to clarify governance mechanism (on-chain vs off-chain) + +**Agent Instructions:** +1. Read `contracts/token/CSSVToken.sol` fully. +2. This is primarily a governance/product decision, not a pure code fix. +3. If the team confirms Snapshot is the permanent mechanism: + a. Ensure the Snapshot space strategy counts cSSV + b. Document this in the DIP and deployment runbook +4. If on-chain governance is needed: + a. Add `ERC20Votes` to `CSSVToken` inheritance + b. Override `_afterTokenTransfer` (or `_update` in OZ v5) to call `_transferVotingUnits` + c. Add `clock()` and `CLOCK_MODE()` overrides + d. This requires careful upgrade planning since `CSSVToken` is not upgradeable +5. Flag this for team decision before proceeding. + +#### Sub-items: +- [ ] Sub-task 1: Get team decision on governance mechanism +- [ ] Sub-task 2: Implement chosen approach (Snapshot config update or ERC20Votes addition) +- [ ] Sub-task 3: Update DIP-X governance section + +--- + +### [SEC-11] ~~`hasDeviation` reactivation optimization uses global counter for per-operator decision~~ +- **Type:** Security Hardening +- **Priority:** ~~P1~~ P3 (downgraded) +- **Status:** ✅ Closed (BUG-4 fix resolves root cause) +- **Owner:** N/A +- **Timeline:** N/A +- **Github Link:** N/A + +**Resolution:** The only known path to make `daoTotalEthVUnits` wrong was BUG-4 (double-subtraction on liquidated cluster validator removal), which is fixed in PR #429. The optimization is valid when the global counter is accurate. Removing it wouldn't provide a real safeguard — per-operator `operatorEthVUnits` values are updated by the same code paths as the global counter, so if a bug corrupts one, it likely corrupts both. + +**Original requirement:** +Replace the global `daoTotalEthVUnits` optimization in `updateClusterOperatorsOnReactivation` with per-operator `operatorEthVUnits` reads. + +**Context:** +In `OperatorLib.sol:305`, `bool hasDeviation = sp.daoTotalEthVUnits != uint64(sp.ethDaoValidatorCount) * VUNITS_PRECISION` uses a global signal for per-operator decisions. While deviations are always non-negative (EB floor=32), this couples correctness to BUG-4's accounting accuracy. If `daoTotalEthVUnits` is ever incorrect (from BUG-4's double-subtraction), reactivation could skip reading actual per-operator deviation, leading to incorrect vUnit accounting. + +**Acceptance Criteria:** +- [ ] Reactivation always reads `seb.operatorEthVUnits[operatorId]` instead of relying on the global optimization +- [ ] No behavior change when global and per-operator values are consistent +- [ ] Correct behavior even when BUG-4 causes `daoTotalEthVUnits` to be incorrect +- [ ] Existing reactivation tests pass + +**Agent Instructions:** +1. Read `contracts/libraries/OperatorLib.sol`, focus on `updateClusterOperatorsOnReactivation` (line 295), particularly the `hasDeviation` check at line 305. +2. Remove the `hasDeviation` optimization and always read `seb.operatorEthVUnits[operatorId]` for each operator. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Remove global `hasDeviation` optimization, use per-operator reads +- [ ] Sub-task 2: Run full test suite + +--- + +### [SEC-12] `deposit()` accepts deposits to liquidated ETH clusters without fee settlement +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add `validateClusterIsNotLiquidated()` to the ETH `deposit()` function, or document the current behavior as intentional. + +**Context:** +In `SSVClusters.sol:190-205`, `deposit()` has no `validateClusterIsNotLiquidated()` check and no fee settlement. Compare with `withdraw()` at line 210 which does both. A user can deposit ETH into a liquidated cluster, but the deposit does not settle fees or reactivate the cluster. The event shows a misleading balance. The user must call `reactivate()` separately to resume the cluster. + +**Concrete example:** Cluster liquidated with `balance=0`, user deposits 1 ETH. No fee settlement occurs. Event shows misleading balance. User must call `reactivate()` separately. + +**Acceptance Criteria:** +- [ ] Either: `deposit()` reverts on liquidated clusters with `ClusterIsLiquidated()` +- [ ] Or: behavior is explicitly documented as intentional with rationale +- [ ] Test: deposit to liquidated cluster → verify defined behavior + +**Agent Instructions:** +1. Read `contracts/modules/SSVClusters.sol`, focus on `deposit` (line 190). +2. Compare with `withdraw()` at line 210 which validates cluster is not liquidated. +3. Add `cluster.validateClusterIsNotLiquidated()` before the balance update. +4. Add a test in `test/unit/SSVClusters/deposit.test.ts` for deposit to liquidated cluster. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add liquidation check to `deposit()` or document as intentional +- [ ] Sub-task 2: Add test for deposit to liquidated cluster +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-13] `OperatorWithdrawn` event doesn't distinguish ETH vs SSV withdrawals +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Keep `OperatorWithdrawn` for ETH withdrawals and introduce a new `OperatorWithdrawnSSV` event for SSV withdrawal earnings. This ensures 3rd-party SDKs and off-chain indexers can correctly track operator earnings by denomination without breaking existing integrations that already listen to `OperatorWithdrawn`. + +**Context:** +In `SSVOperators.sol:337-344`, both `_transferOperatorBalanceUnsafe` (ETH) and `_transferOperatorTokenBalanceUnsafe` (SSV) emit the same `OperatorWithdrawn` event. Off-chain indexers (SDK, oracle, dashboard) cannot distinguish between ETH and SSV withdrawal events, making it impossible to correctly calculate total accumulated operator earnings per denomination. + +**Decision:** +- `OperatorWithdrawn(operatorId, owner, value)` — **kept as-is**, emitted only by `_transferOperatorBalanceUnsafe` (ETH withdrawals) +- `OperatorWithdrawnSSV(operatorId, owner, value)` — **new event**, emitted only by `_transferOperatorTokenBalanceUnsafe` (SSV withdrawals) + +**Acceptance Criteria:** +- [ ] `OperatorWithdrawnSSV` event defined in `contracts/interfaces/ISSVOperators.sol` +- [ ] `_transferOperatorBalanceUnsafe` emits `OperatorWithdrawn` (ETH) — no change +- [ ] `_transferOperatorTokenBalanceUnsafe` emits `OperatorWithdrawnSSV` instead of `OperatorWithdrawn` +- [ ] Off-chain indexers and SDK updated to listen to `OperatorWithdrawnSSV` for SSV earnings +- [ ] ABI change impact documented for oracle and SDK clients + +**Agent Instructions:** +1. Read `contracts/modules/SSVOperators.sol`, focus on `_transferOperatorBalanceUnsafe` and `_transferOperatorTokenBalanceUnsafe` (lines 337-344). +2. Add `event OperatorWithdrawnSSV(uint64 indexed operatorId, address indexed owner, uint256 value);` to `contracts/interfaces/ISSVOperators.sol`. +3. In `_transferOperatorTokenBalanceUnsafe`, replace `emit OperatorWithdrawn(...)` with `emit OperatorWithdrawnSSV(...)`. +4. Leave `_transferOperatorBalanceUnsafe` unchanged. +5. Update any tests that assert `OperatorWithdrawn` was emitted for SSV withdrawals to expect `OperatorWithdrawnSSV` instead. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Define `OperatorWithdrawnSSV` event in `ISSVOperators.sol` +- [ ] Sub-task 2: Update `_transferOperatorTokenBalanceUnsafe` to emit `OperatorWithdrawnSSV` +- [ ] Sub-task 3: Update tests for new event signature +- [ ] Sub-task 4: Run full test suite + +--- + +### [SEC-14] `commitRoot` accepts `bytes32(0)` as merkleRoot — permanently wastes block slot +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add a zero-root check to `commitRoot` to prevent permanently wasting a block slot with an unusable root. + +**Context:** +In `SSVDAO.sol:155`, `commitRoot` accepts `bytes32(0)` as a valid merkle root. The zero root is stored but unusable — `SSVClusters.sol:426` reverts on zero root during `updateClusterBalance`. Meanwhile, `latestCommittedBlock` advances, so the block slot is permanently consumed and cannot be reused. + +**Acceptance Criteria:** +- [ ] `commitRoot` reverts with `InvalidRoot()` when `merkleRoot == bytes32(0)` +- [ ] Define `InvalidRoot` error if it doesn't exist +- [ ] Test: commit zero root → expect revert + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol`, focus on `commitRoot` (line 155). +2. Add `if (merkleRoot == bytes32(0)) revert InvalidRoot();` near the top of the function. +3. Define `InvalidRoot` error in `contracts/interfaces/ISSVNetworkCore.sol` if not already defined. +4. Add test in `test/unit/SSVDAO/commitRoot.test.ts`. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add zero-root validation to `commitRoot` +- [ ] Sub-task 2: Add test for zero-root revert +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-15] Min/max operator fee can be set to contradictory values +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add cross-validation between `updateMinimumOperatorEthFee` and `updateMaximumOperatorFee` to prevent contradictory values where `minFee > maxFee`. + +**Context:** +In `SSVDAO.sol:138-149`, neither setter cross-validates against the other. If `minFee > maxFee`, no valid non-zero fee exists for operator registration, effectively blocking all new operator registrations and fee changes. While both are owner-only functions, a configuration mistake could cause unexpected operational impact. + +**Acceptance Criteria:** +- [ ] `updateMinimumOperatorEthFee` reverts if the new min would exceed current max +- [ ] `updateMaximumOperatorFee` reverts if the new max would be below current min +- [ ] Test: set contradictory min/max → expect revert + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol`, focus on `updateMinimumOperatorEthFee` (line 147) and `updateMaximumOperatorFee` (line 138). +2. In `updateMinimumOperatorEthFee`: add check `if (packed > sp.operatorMaxFeeETH) revert ...;`. +3. In `updateMaximumOperatorFee`: add check `if (packed < sp.operatorMinFeeETH) revert ...;`. +4. Add tests for both cross-validation directions. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add cross-validation to both fee setters +- [ ] Sub-task 2: Add tests for contradictory fee values +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-16] Missing zero-value/zero-address guards on deposit and withdraw +- **Type:** Security Hardening +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add zero-value and zero-address guards to deposit and withdraw functions to prevent meaningless transactions. + +**Context:** +- `SSVClusters.sol:190` (`deposit`): no zero-address check for `clusterOwner`, no `msg.value > 0` check. +- `SSVClusters.sol:210` (`withdraw`): no zero-amount check. +- `SSVDAO.sol:52` (`withdrawNetworkSSVEarnings`): no zero-amount check. +These allow gas-wasting no-op transactions that emit misleading events with zero values. + +**Acceptance Criteria:** +- [ ] `deposit()` reverts when `msg.value == 0` +- [ ] `withdraw()` reverts when `amount == 0` +- [ ] `withdrawNetworkSSVEarnings()` reverts when `amount == 0` +- [ ] Tests added for each zero-value guard + +**Agent Instructions:** +1. Read `contracts/modules/SSVClusters.sol`, focus on `deposit` (line 190) and `withdraw` (line 210). +2. Read `contracts/modules/SSVDAO.sol`, focus on `withdrawNetworkSSVEarnings` (line 52). +3. Add `require(msg.value > 0)` to deposit, `require(amount > 0)` to withdraw functions. +4. Add tests for each guard. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add zero-value guards to deposit and withdraw +- [ ] Sub-task 2: Add tests for zero-value reverts +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-16b] Dust ETH stranded in `accrued` after full cSSV transfer + claim +- **Type:** Security Hardening +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +When a user transfers all their cSSV tokens and then calls `claimEthRewards`, a sub-`ETH_DEDUCTED_DIGITS` dust remainder is left in `s.accrued[msg.sender]`. Because the user holds no cSSV, `_settle` will never add to it again, so the dust is permanently unclaimable (any future `claimEthRewards` call hits the `payout == 0` revert). From the user's perspective the UI shows a non-zero claimable balance that can never be withdrawn. + +**Context:** +- `SSVStaking.sol:123`: `payout = claimable - (claimable % ETH_DEDUCTED_DIGITS)` — the remainder stays in `accrued`. +- `SSVStaking.sol:139` (original): `s.accrued[msg.sender] = claimable - payout` — remainder is preserved even when the user holds 0 cSSV. +- Reproduction: stake → transfer all cSSV to another address → call `claimEthRewards` → `accrued` contains dust that can never be claimed or grown. + +**Proposed Fix on claimEthRewards (pending product approval):** +```solidity +uint256 bal = ICSSVToken(CSSV_ADDRESS).balanceOf(msg.sender); +s.accrued[msg.sender] = (bal == 0) ? 0 : claimable - payout; +``` +When `bal == 0` the dust is zeroed rather than preserved. The zeroed wei remains in `stakingEthPoolBalance` and `ethDaoBalance` — it is never deducted from the pool — so it is effectively redistributed to remaining stakers via future `accEthPerShare` increments in `_syncFees`. + +**⚠️ Product approval required:** Confirm that silently absorbing dust into the shared pool (rather than returning it to the user or burning it) is acceptable behaviour before merging the fix. + +**Acceptance Criteria:** +- [ ] Product sign-off on dust-absorption behaviour +- [ ] `claimEthRewards` zeros `accrued` when caller holds 0 cSSV +- [ ] After a full transfer + claim, `accrued[user] == 0` +- [ ] Test: stake → transfer all cSSV → claim → assert `accrued == 0` and no further `NothingToClaim` revert on a second claim attempt + +**Agent Instructions:** +1. Fix already applied at `SSVStaking.sol:139-140` — review and confirm correctness. +2. Add a regression test covering the reproduction flow above. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Product approval on dust-absorption behaviour +- [ ] Sub-task 2: Add regression test +- [ ] Sub-task 3: Run full test suite + +--- + +### [SEC-17] DAO governance functions lack input guardrails (min/max/non-zero) +- **Type:** Security Hardening +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add input validation guardrails (non-zero, min/max bounds) to all DAO-governed setter functions in `SSVDAO.sol`. Currently most functions accept any value including `0`, which can be harmful to the protocol. While the DAO multisig (5/7) mitigates the risk of accidental misconfiguration, defense-in-depth requires on-chain guardrails. + +**⚠️ Action required:** Consult Product/governance team to define the concrete min/max bounds for each parameter before implementation. The table below uses `TBD` placeholders. + +**Context:** +`SSVDAO.sol` contains 12 setter functions. Only 2 have any input validation today: +- `updateLiquidationThresholdPeriod` / `updateLiquidationThresholdPeriodSSV`: enforce `>= MINIMAL_LIQUIDATION_THRESHOLD` (21,480 blocks) +- `setQuorumBps`: enforces `<= BPS_DENOMINATOR` (10,000) — but allows 0 (see SEC-1) + +All other setters accept any value, including 0 and extreme values that could break protocol invariants. + +**Affected functions and proposed guardrails:** + +| # | Function | Parameter | Current guard | Proposed guardrail | Risk if unguarded | +|---|---|---|---|---|---| +| 1 | `updateNetworkFee` | `fee` (wei/block) | None | `fee <= TBD_MAX_NETWORK_FEE` | Extreme fee drains all clusters rapidly | +| 2 | `updateNetworkFeeSSV` | `fee` (SSV/block) | None | `fee <= TBD_MAX_NETWORK_FEE_SSV` | Same as above for SSV clusters | +| 3 | `updateOperatorFeeIncreaseLimit` | `percentage` | None | `percentage > 0 && percentage <= TBD_MAX_INCREASE_LIMIT` | `0` blocks all operator fee increases forever; extreme value allows unlimited fee jumps | +| 4 | `updateDeclareOperatorFeePeriod` | `timeInSeconds` | None | `timeInSeconds >= TBD_MIN_DECLARE_PERIOD && timeInSeconds <= TBD_MAX_DECLARE_PERIOD` | `0` allows instant fee declarations (no review window); extreme value blocks fee changes | +| 5 | `updateExecuteOperatorFeePeriod` | `timeInSeconds` | None | `timeInSeconds >= TBD_MIN_EXECUTE_PERIOD && timeInSeconds <= TBD_MAX_EXECUTE_PERIOD` | `0` allows instant fee execution (no user reaction window); extreme value blocks fee changes | +| 6 | `updateLiquidationThresholdPeriod` | `blocks` | `>= 21,480` ✅ | Add max: `blocks <= TBD_MAX_LIQUIDATION_THRESHOLD` | ✅ Min exists. Extreme max could make liquidation economically unviable | +| 7 | `updateLiquidationThresholdPeriodSSV` | `blocks` | `>= 21,480` ✅ | Add max: `blocks <= TBD_MAX_LIQUIDATION_THRESHOLD_SSV` | Same as above for SSV | +| 8 | `updateMinimumLiquidationCollateral` | `amount` (wei) | None | `amount > 0 && amount <= TBD_MAX_MIN_COLLATERAL` | `0` allows clusters with no safety margin; extreme value blocks cluster creation | +| 9 | `updateMinimumLiquidationCollateralSSV` | `amount` (SSV) | None | `amount > 0 && amount <= TBD_MAX_MIN_COLLATERAL_SSV` | Same as above for SSV | +| 10 | `updateMaximumOperatorFee` | `maxFee` (wei) | None | `maxFee > 0 && maxFee >= sp.minimumOperatorEthFee` | `0` blocks all operator registrations; see also SEC-15 for cross-validation | +| 11 | `updateMinimumOperatorEthFee` | `minFee` (wei) | None | `minFee <= sp.operatorMaxFee` | Extreme value blocks operator registrations; see also SEC-15 for cross-validation | +| 12 | `setQuorumBps` | `quorum` | `<= 10,000` | Add min: `quorum >= TBD_MIN_QUORUM_BPS` | `0` allows single-oracle root commits; see SEC-1 | +| 13 | `setUnstakeCooldownDuration` | `duration` | None | `duration >= TBD_MIN_COOLDOWN && duration <= TBD_MAX_COOLDOWN` | `0` allows instant unstaking (no cooldown); see SEC-4 | + +**Note:** Items 10-11 overlap with SEC-15, and items 12-13 overlap with SEC-1/SEC-4. Those items can be closed as sub-items of this one, or this item can reference them as "already covered" — team's choice. + +**Acceptance Criteria:** +- [ ] Product/governance team provides concrete min/max values for all `TBD` placeholders +- [ ] Each function in the table above has the agreed guardrail implemented +- [ ] Existing guardrails (liquidation threshold min) are preserved +- [ ] Cross-validation between related parameters (min/max operator fee) is enforced +- [ ] All new guards revert with descriptive custom errors +- [ ] Unit tests cover each boundary: at min, at max, below min (revert), above max (revert) +- [ ] Existing tests updated where they set extreme/zero values that now revert +- [ ] No behavioral change for values within the accepted range + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol` fully — all setter functions. +2. Read `contracts/libraries/ProtocolLib.sol` — `updateNetworkFee` and `updateNetworkFeeSSV` delegate here. +3. Read `contracts/libraries/storage/SSVStorageProtocol.sol` for the `StorageProtocol` struct fields. +4. Read `contracts/libraries/storage/SSVStorageStaking.sol` for the `StorageStaking` struct fields. +5. **Wait for Product to fill in `TBD` values before implementing.** If values are not yet defined, implement only the non-zero guards (where `0` is clearly harmful) and add `// TODO: add max bound per SEC-17` comments. +6. Define new custom errors in `contracts/interfaces/ISSVNetworkCore.sol` as needed (e.g., `InvalidParameter()`, `ValueOutOfRange()`). +7. For each function, add the guard at the top before any state changes. +8. Update tests in `test/unit/SSVDAO/` for each modified function. +9. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Get Product sign-off on min/max bounds for all parameters +- [ ] Sub-task 2: Implement non-zero guards for all unguarded setters +- [ ] Sub-task 3: Implement min/max bounds once Product provides values +- [ ] Sub-task 4: Add unit tests for each boundary (at min, at max, below min, above max) +- [ ] Sub-task 5: Reconcile with SEC-1, SEC-4, SEC-15 (close or cross-reference) +- [ ] Sub-task 6: Run full test suite + +--- + +### [SEC-18] ETH-only operators can call `withdrawOperatorEarningsSSV` (no-op but wastes gas) +- **Type:** Security Hardening +- **Priority:** P3 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add an early-exit guard in `withdrawOperatorEarningsSSV` (or its underlying helper) that reverts when called by the owner of an ETH-only operator, preventing a pointless transaction that wastes gas. + +**Context:** +Operators registered after the v2.0.0 migration may be ETH-only (`snapshot.block == 0`, `ethSnapshot.block != 0`). New validator registrations for these operators use the ETH payment path exclusively, so they can never accumulate SSV earnings. Despite this, nothing prevents their owner from calling `withdrawOperatorEarningsSSV`. The call will succeed (the SSV balance is 0, so no tokens move), but the user pays gas for a no-op. Echidna invariants already confirm that the accounting system cannot credit SSV earnings to ETH-only operators, so there is no risk of fund loss — this is purely a UX/gas waste issue. + +**Acceptance Criteria:** +- [ ] `withdrawOperatorEarningsSSV` reverts with a descriptive error (e.g., `NoSSVEarnings()`) when the operator has `snapshot.block == 0` (ETH-only) +- [ ] ETH-capable operators (both `snapshot.block != 0` and `ethSnapshot.block != 0`) are unaffected +- [ ] Confirm via Echidna that SSV balance of ETH-only operators cannot be artificially inflated + +**Agent Instructions:** +1. Read `contracts/modules/SSVOperators.sol`, focus on `withdrawOperatorEarningsSSV` and its internal helper. +2. After the `checkOwner` call, add: `if (operator.snapshot.block == 0) revert NoSSVEarnings();` +3. Define `NoSSVEarnings` error in `contracts/interfaces/ISSVNetworkCore.sol` if not already present. +4. Add a unit test: register an ETH-only operator → call `withdrawOperatorEarningsSSV` → expect revert with `NoSSVEarnings`. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add ETH-only operator guard to `withdrawOperatorEarningsSSV` +- [ ] Sub-task 2: Define `NoSSVEarnings` custom error +- [ ] Sub-task 3: Add unit test for ETH-only operator calling SSV withdrawal +- [ ] Sub-task 4: Run full test suite + +--- + +### [SEC-19] `minBlocksBetweenUpdates` never initialized — EB update rate limit silently disabled +- **Type:** Security Hardening +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Initialize `minBlocksBetweenUpdates` to a non-zero value during the upgrade, and add a governance setter so it can be adjusted post-deployment. + +**Context:** +`StorageEB.minBlocksBetweenUpdates` is a `uint32` in diamond storage. It is read by `_verifyEBUpdateFrequency` to rate-limit how often a cluster's EB can be updated: + +```solidity +if (ebSnapshot.lastUpdateBlock != 0 && block.number < ebSnapshot.lastUpdateBlock + seb.minBlocksBetweenUpdates) { + revert UpdateTooFrequent(); +} +``` + +Because the field is never set — neither in the upgrade initializer nor via any governance function — it defaults to `0`. The condition `block.number < lastUpdateBlock + 0` is always `false`, so the rate limit is **completely inoperative**. Any caller can submit a valid `updateClusterBalance` proof every block for every cluster. + +The threat model (`docs/audit/07-trust-boundaries-integrations.md`) explicitly lists this rate limit as a mitigation against forced EB update spam and auto-liquidation attacks. With it disabled, an attacker holding a valid oracle proof of a cluster's reduced EB can trigger auto-liquidation in the same block as a root commitment, with no cooldown. + +**Acceptance Criteria:** +- [ ] `minBlocksBetweenUpdates` initialized to a non-zero value in the upgrade reinitializer (suggested: `7200` blocks ≈ 1 day, matching oracle sweep frequency) +- [ ] Governance setter added (e.g. `setMinBlocksBetweenUpdates(uint32)`, owner-only) +- [ ] Setter emits an event (e.g. `MinBlocksBetweenUpdatesUpdated(uint32)`) +- [ ] Unit test: second `updateClusterBalance` within the cooldown window reverts with `UpdateTooFrequent` +- [ ] Unit test: `updateClusterBalance` succeeds after cooldown window passes +- [ ] Governance parameter documented in SPEC.md §11 and FLOWS.md + +**Agent Instructions:** +1. In the upgrade reinitializer, add: `SSVStorageEB.load().minBlocksBetweenUpdates = 7200;` +2. Add a governance setter in `SSVDAO.sol` (or equivalent): `function setMinBlocksBetweenUpdates(uint32 blocks) external onlyOwner`. +3. Emit `MinBlocksBetweenUpdatesUpdated(blocks)` from the setter. +4. Add the event to `ISSVNetworkCore.sol` or the DAO interface. +5. Add unit tests covering both the cooldown revert and the post-cooldown success path. +6. Update SPEC.md §11 governance parameters table and FLOWS.md §3.3 preconditions. +7. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Initialize `minBlocksBetweenUpdates` in upgrade reinitializer +- [ ] Sub-task 2: Add governance setter and event +- [ ] Sub-task 3: Unit tests for rate-limit enforcement +- [ ] Sub-task 4: Update SPEC.md and FLOWS.md + +--- + +## Unit Test Completeness + +### [TEST-1] Validator register/remove with non-zero operator fees +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add unit tests for validator registration and removal with operators that have non-zero ETH fees. Currently ALL SSVValidator tests use operators with `fee=0` (the default), leaving the entire fee settlement mechanism untested. + +**Context:** +This is the #1 systemic test gap. The fee settlement mechanism (`updateClusterOperators` / `settleClusterBalance`) during register/remove has zero real coverage with actual fee deductions. If fee settlement is wrong, clusters are overcharged or undercharged on every register/remove. The EB-weighted fee model (`vUnits`) makes this even more critical. + +**Acceptance Criteria:** +- [ ] Test: Register validator with 4 operators each charging different ETH fees → verify cluster balance deduction = `blocksDelta * sum(operatorFees) * vUnits / VUNITS_PRECISION * ETH_DEDUCTED_DIGITS` +- [ ] Test: Register second validator after N blocks → verify fees from first validator settled correctly before adding second +- [ ] Test: Remove validator with non-zero fees → verify operator earnings accumulated match expected +- [ ] Test: Bulk register 10 validators with non-zero fees → verify total deduction +- [ ] All new tests pass + +**Agent Instructions:** +1. Read `test/unit/SSVValidator/registerValidator.test.ts` to understand existing patterns and test helpers. +2. Read `test/helpers/contract-helpers.ts` to understand how operators are registered and fees are set. Look for `registerOperator` helper and how `declareOperatorFee` / `executeOperatorFee` work. +3. Read `test/common/constants.ts` for fee-related constants. +4. Create a new test file or add a describe block to existing files. Use the existing `CONFIG` fixture pattern. +5. For each test: + - Register operators with non-zero ETH fees (use `declareOperatorFee` → advance blocks → `executeOperatorFee`) + - Register validators + - Advance blocks with `mine(N)` + - Perform the operation (register/remove) + - Calculate expected fees independently: `blocksDelta * sum(PackedETH.unwrap(fee)) * vUnits / VUNITS_PRECISION * ETH_DEDUCTED_DIGITS` + - Assert cluster balance = initial deposit - expected fees + - Assert operator earnings match expected accumulation +6. Use `ethers.provider.getBalance` for ETH balance checks and the SSVViews contract for cluster/operator balance queries. +7. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Register validator with non-zero operator fees — verify cluster balance deduction +- [ ] Sub-task 2: Sequential validator registration with fee settlement verification +- [ ] Sub-task 3: Remove validator with non-zero fees — verify operator earnings +- [ ] Sub-task 4: Bulk register with non-zero fees — verify total deduction + +--- + +### [TEST-2] EB-weighted operator earnings accumulation +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add unit tests verifying that operators earn proportionally more when serving clusters with higher effective balance. The EB settlement tests check fee deductions from the cluster side but don't verify operator earnings. + +**Context:** +The vUnit model is the core economic change in v2.0.0. If operator earnings don't scale with EB, the entire incentive model is broken. No unit test currently verifies the operator earnings side of EB-weighted accounting. + +**Acceptance Criteria:** +- [ ] Test: Operator serves two clusters, EB=32 and EB=64 → after N blocks, verify operator earnings = `(blocks * fee * 10000 + blocks * fee * 20000) / VUNITS_PRECISION * ETH_DEDUCTED_DIGITS` +- [ ] Test: Operator fee change after EB update → verify earnings split correctly at boundary +- [ ] Test: `withdrawOperatorEarnings` after EB-weighted accrual → verify exact ETH withdrawn matches expected + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/ebSettlement.test.ts` to understand EB test patterns. +2. Read `test/unit/SSVOperators/withdrawOperatorEarnings.test.ts` for withdrawal test patterns. +3. Read `contracts/libraries/OperatorLib.sol`, focus on `updateSnapshot` to understand how operator earnings accumulate with vUnits. +4. Create tests that: + - Register an operator + - Create two clusters with different EBs (use `updateClusterBalance` with Merkle proofs to set EB) + - Advance blocks + - Verify operator earnings via `SSVViews.getOperatorEarnings(operatorId)` + - Withdraw and verify exact ETH amount +5. Use the Merkle proof helpers in `test/helpers/` to create valid proofs for EB updates. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Operator earning from two clusters with different EBs +- [ ] Sub-task 2: Operator fee change boundary with EB-weighted clusters +- [ ] Sub-task 3: Withdraw operator earnings after EB-weighted accrual + +--- + +### [TEST-3] Balance delta assertions in liquidation paths +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add balance delta assertions to liquidation tests. Current tests check events and state transitions but do not assert actual ETH/SSV token transfer amounts. + +**Context:** +A liquidation could emit the correct event but transfer the wrong amount (or nothing). Without balance delta assertions, incorrect transfer logic is invisible to the test suite. + +**Acceptance Criteria:** +- [ ] Test: Liquidate ETH cluster → assert `liquidator.balance.after - liquidator.balance.before == cluster.remainingBalance` (accounting for gas) +- [ ] Test: Liquidate SSV cluster → assert `SSVToken.balanceOf(liquidator).after - before == cluster.remainingSSVBalance` +- [ ] Test: Liquidate cluster with 0 remaining balance → assert no ETH transferred +- [ ] Test: Self-liquidation → assert owner receives remaining balance + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/liquidate.test.ts` and `test/unit/SSVClusters/liquidateSSV.test.ts`. +2. Add balance capture before/after each liquidation call: + ```typescript + const balanceBefore = await ethers.provider.getBalance(liquidator.address); + const tx = await ssvNetwork.connect(liquidator).liquidate(...); + const receipt = await tx.wait(); + const gasCost = receipt.gasUsed * receipt.gasPrice; + const balanceAfter = await ethers.provider.getBalance(liquidator.address); + expect(balanceAfter - balanceBefore + gasCost).to.equal(expectedReward); + ``` +3. For SSV token liquidations, use `SSVToken.balanceOf()` instead of native balance. +4. Calculate expected remaining balance independently using the cluster balance formula. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: ETH liquidation balance delta assertions +- [ ] Sub-task 2: SSV liquidation balance delta assertions +- [ ] Sub-task 3: Zero-balance liquidation +- [ ] Sub-task 4: Self-liquidation balance check + +--- + +### [TEST-4] `updateClusterBalance` on liquidated clusters +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests for calling `updateClusterBalance` (EB oracle update) on an already-liquidated cluster. + +**Context:** +No test exists for this path. If the contract doesn't handle it, oracle updates on liquidated clusters could corrupt accounting or revert unexpectedly. + +**Acceptance Criteria:** +- [ ] Test: Call `updateClusterBalance` with valid proof on a liquidated cluster → verify defined behavior (revert or update EB without settling fees) +- [ ] Test: EB update that makes a liquidated cluster even more insolvent → verify no state corruption + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/updateClusterBalance.test.ts` for existing patterns. +2. Create a cluster, liquidate it, then call `updateClusterBalance` with a valid Merkle proof. +3. Verify behavior: does it revert? Does it update EB? Does it try to settle fees? +4. Read `contracts/modules/SSVClusters.sol` to trace the `updateClusterBalance` code path for liquidated clusters. +5. Add assertions based on actual contract behavior. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: `updateClusterBalance` on liquidated cluster — basic behavior +- [ ] Sub-task 2: EB increase on already-insolvent liquidated cluster + +--- + +### [TEST-5] Oracle quorum edge cases +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add comprehensive edge case tests for the oracle quorum mechanism in `commitRoot`. + +**Context:** +Only basic quorum tests exist. Missing: boundary conditions, weight manipulation, oracle replacement during voting, quorum parameter changes mid-vote. + +**Acceptance Criteria:** +- [ ] Test: Quorum at exactly 100% — all 4 oracles must vote +- [ ] Test: Quorum at 1 bps — single oracle vote commits +- [ ] Test: Oracle replaced between proposing and committing — verify vote behavior +- [ ] Test: Quorum changed between votes — verify consistent threshold +- [ ] Test: Oracles propose different roots for same block number — verify correct root wins + +**Agent Instructions:** +1. Read `test/unit/SSVDAO/commitRoot.test.ts` for existing patterns. +2. Read `contracts/modules/SSVDAO.sol`, focus on `commitRoot` (line 155) for the voting/quorum logic. +3. Add tests for each scenario. For oracle replacement mid-vote, call `replaceOracle` between two `commitRoot` calls for the same block number. +4. Use `setQuorumBps` to set boundary values before testing. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: 100% quorum boundary test +- [ ] Sub-task 2: Minimal quorum (1 bps) test +- [ ] Sub-task 3: Oracle replacement mid-vote +- [ ] Sub-task 4: Quorum change mid-vote +- [ ] Sub-task 5: Conflicting root proposals + +--- + +### [TEST-6] EB decrease scenarios +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add unit tests for effective balance decreases. All current EB tests only cover increases (32→higher). Validators can have EB decrease due to penalties. + +**Context:** +If EB decreases aren't handled correctly, vUnits could be wrong, operators could be overpaid, or liquidation thresholds could be miscalculated. EB decrease is a completely untested code path. + +**Acceptance Criteria:** +- [ ] Test: EB decrease from 64 ETH to 32 ETH → verify vUnits decrease, operator fees decrease, liquidation threshold recalculated +- [ ] Test: EB decrease below 32 ETH → should revert with `EBBelowMinimum` +- [ ] Test: EB decrease while cluster is near liquidation threshold → verify decrease triggers liquidation if below threshold +- [ ] Test: Operator deviation negative after EB decrease → verify `daoTotalEthVUnits` updated correctly + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/ebSettlement.test.ts` and `test/unit/SSVClusters/updateClusterBalance.test.ts`. +2. Create test scenarios where EB starts high and is updated to a lower value via `updateClusterBalance` with a Merkle proof for the lower EB. +3. Use the Merkle tree helpers to generate proofs for decreased EB values. +4. Verify vUnits, deviation, burn rate, and liquidation threshold after decrease. +5. For the below-32-ETH case, verify the contract reverts with the correct error. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: EB decrease from 64→32 ETH — vUnits and fee verification +- [ ] Sub-task 2: EB below minimum (< 32 ETH) — revert test +- [ ] Sub-task 3: EB decrease triggering liquidation +- [ ] Sub-task 4: Negative deviation after EB decrease + +--- + +### [TEST-7] Reentrancy in staking functions +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add reentrancy tests for SSVStaking functions that transfer ETH or tokens. These functions are marked `nonReentrant` but no test verifies the protection works. + +**Context:** +`claimEthRewards`, `withdrawUnlocked`, `stake`, `requestUnstake` all handle ETH or SSV token transfers. Reentrancy via a `receive()` hook could theoretically drain rewards. The `nonReentrant` modifier should prevent this, but it's untested. The existing SSVOperators reentrancy test (`test/unit/SSVOperators/reentrancy.test.ts`) can serve as a pattern. + +**Acceptance Criteria:** +- [ ] Test: Attacker contract with `receive()` hook calls `claimEthRewards` reentrantly → verify reverts +- [ ] Test: Attacker calls `withdrawUnlocked` reentrantly during SSV token transfer → verify reverts +- [ ] All reentrancy tests use a custom attacker contract deployed in the test + +**Agent Instructions:** +1. Read `test/unit/SSVOperators/reentrancy.test.ts` for the existing reentrancy test pattern. +2. Read the attacker contract used (look for a reentrant test helper contract in `contracts/` or `test/`). +3. Create similar reentrancy tests for `claimEthRewards` and `withdrawUnlocked`. +4. Deploy a contract that: receives ETH → calls back into `claimEthRewards` → expect revert with reentrancy error. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: `claimEthRewards` reentrancy test +- [ ] Sub-task 2: `withdrawUnlocked` reentrancy test + +--- + +### [TEST-8] Forbid creating clusters with removed operators +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add explicit tests for PR #410 (forbid creating clusters with removed operators). Verify both `registerValidator` and `bulkRegisterValidator` revert when given a removed operator ID. + +**Context:** +PR #410 added a fix but no explicit test exists for this scenario. Creating clusters with removed operators would result in stuck funds with no one to service the validator. + +**Acceptance Criteria:** +- [ ] Test: Register validator using operatorIds where one operator was previously removed → should revert +- [ ] Test: Bulk register where one of the operator IDs belongs to a removed operator → should revert + +**Agent Instructions:** +1. Read `test/unit/SSVValidator/registerValidator.test.ts` and `test/unit/SSVValidator/bulkRegisterValidator.test.ts`. +2. Add a test that: registers 4 operators, removes one, then tries to register a validator with all 4 operator IDs → expect revert. +3. Add the same for bulk registration. +4. Identify the specific error that the contract reverts with (likely `OperatorDoesNotExist` — check `contracts/libraries/OperatorLib.sol`). +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: `registerValidator` with removed operator → revert test +- [ ] Sub-task 2: `bulkRegisterValidator` with removed operator → revert test + +--- + +### [TEST-9] Migration balance accounting verification +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests that verify exact SSV refund amounts and ETH deposit amounts during migration, calculated independently from contract logic. + +**Context:** +Migration tests verify events and state but don't verify exact token transfer amounts against independently calculated values. + +**Acceptance Criteria:** +- [ ] Test: Migrate after 1000 blocks → verify SSV refund = `initial_deposit - (blocks * sum(ssv_fees) * validatorCount) * DEDUCTED_DIGITS` +- [ ] Test: Migrate with partial SSV balance remaining → verify exact token transfer amount +- [ ] Test: Migrate cluster where operators have both SSV and ETH fees set → verify ETH side correctly initialized + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/migrateClusterToETH.test.ts` for existing patterns. +2. Add independent balance calculations using JavaScript BigInt arithmetic matching the contract's formula. +3. Assert `SSVToken.balanceOf(owner).after - SSVToken.balanceOf(owner).before == expectedRefund`. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Exact SSV refund after N blocks +- [ ] Sub-task 2: Migration with partial balance +- [ ] Sub-task 3: Migration with dual SSV/ETH fees + +--- + +### [TEST-10] Operator fee change + EB burn rate interaction +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests combining operator fee changes (declare/execute/reduce) with EB-weighted clusters. + +**Context:** +No tests combine operator fee changes with EB-weighted clusters. The burn rate depends on both operator fee and vUnits, and fee changes must properly settle the old rate before applying the new one. + +**Acceptance Criteria:** +- [ ] Test: Operator increases fee while serving EB=64 cluster → verify burn rate doubles +- [ ] Test: Operator reduces fee with EB-weighted cluster → verify savings reflected +- [ ] Test: Fee execution changes mid-block for EB-weighted cluster → verify boundary accounting + +**Agent Instructions:** +1. Read `test/unit/SSVOperators/declareOperatorFee.test.ts` and `test/unit/SSVOperators/executeOperatorFee.test.ts`. +2. Read `test/unit/SSVClusters/ebSettlement.test.ts`. +3. Create combined tests: register operator with fee, create cluster with EB, change fee, verify cluster balance reflects correct burn rate split. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Fee increase with EB-weighted cluster +- [ ] Sub-task 2: Fee reduction with EB-weighted cluster +- [ ] Sub-task 3: Fee change boundary accounting + +--- + +### [TEST-11] Network fee update impact on active clusters +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests verifying that `updateNetworkFee` changes the actual burn rate for existing active clusters. + +**Context:** +DAO parameter tests verify storage changes but not enforcement on active clusters. + +**Acceptance Criteria:** +- [ ] Test: Increase ETH network fee with active ETH cluster → verify cluster burns faster +- [ ] Test: Decrease ETH network fee → verify cluster burn rate decreases +- [ ] Test: Update network fee with EB-weighted cluster → verify vUnit scaling applied + +**Agent Instructions:** +1. Read `test/unit/SSVDAO/updateNetworkFee.test.ts`. +2. Create cluster, advance blocks, check balance, then update network fee, advance more blocks, check balance again. +3. Verify the balance difference in each period matches the respective fee rates. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Network fee increase enforcement +- [ ] Sub-task 2: Network fee decrease enforcement +- [ ] Sub-task 3: Network fee with EB scaling + +--- + +### [TEST-12] Multi-staker reward fairness +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add comprehensive multi-staker scenarios testing proportional reward distribution and cSSV transfer settlement. + +**Context:** +`onCSSVTransfer` has only 2 tests. Staking integration tests have basic proportional distribution but don't test complex scenarios with multiple stakers entering/exiting at different times or transferring cSSV. + +**Acceptance Criteria:** +- [ ] Test: 3 stakers with different amounts → each receives exactly proportional rewards +- [ ] Test: Staker A stakes, rewards accrue, staker B stakes → A gets both periods, B gets only second +- [ ] Test: cSSV transfer from A to B → verify reward settlement for both, B earns at higher rate +- [ ] Test: Sequential cSSV transfers A→B→C → verify accumulated rewards at each step + +**Agent Instructions:** +1. Read `test/unit/SSVStaking/claimEthRewards.test.ts` and `test/unit/SSVStaking/onCSSVTransfer.test.ts`. +2. Read `test/integration/SSVNetwork/staking.test.ts` for integration patterns. +3. Use the `accEthPerShare` formula: `pendingReward = cSSVBalance * (accEthPerShare - userIndex) / 1e18`. +4. Calculate expected rewards independently and assert exact values (accounting for precision loss). +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Three-staker proportional distribution +- [ ] Sub-task 2: Time-weighted staking (A early, B late) +- [ ] Sub-task 3: cSSV transfer settlement +- [ ] Sub-task 4: Sequential cSSV transfer chain + +--- + +### [TEST-13] Liquidation + reactivation multi-cycle accounting +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests for multiple liquidation/reactivation cycles to verify no accounting drift accumulates. + +**Context:** +Only single liquidation/reactivation cycles are tested. Over multiple cycles, rounding errors or state leakage could accumulate. + +**Acceptance Criteria:** +- [ ] Test: Liquidate → reactivate → operate → liquidate → reactivate → verify cumulative balances, no drift +- [ ] Test: Operator earnings across multiple liquidation cycles → verify no double-counting + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/liquidate.test.ts` and `test/unit/SSVClusters/reactivate.test.ts`. +2. Create a test that performs 3+ full cycles: deposit → advance blocks → liquidate → reactivate with deposit → repeat. +3. Track operator earnings and cluster balance at each step, verify consistency. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Multi-cycle liquidation/reactivation accounting +- [ ] Sub-task 2: Operator earnings across cycles + +--- + +### [TEST-14] Reactivation with EB deviation solvency check +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Test that reactivation solvency checks account for EB-weighted burn rate. + +**Context:** +Reactivate tests don't verify that the minimum deposit scales with vUnits. A cluster with EB=2048 has 64x the burn rate and should require a proportionally higher deposit. + +**Acceptance Criteria:** +- [ ] Test: Reactivate cluster with EB=64 → verify minimum deposit requirement scales with 2x vUnits +- [ ] Test: Reactivate with EB=2048 → verify high deposit requirement enforced + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/reactivate.test.ts`. +2. Create clusters with different EBs, liquidate them, then try to reactivate with minimal deposits. +3. Verify that insufficient deposits for high-EB clusters revert. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Reactivation solvency with EB=64 +- [ ] Sub-task 2: Reactivation solvency with EB=2048 + +--- + +### [TEST-15] SSV cluster operations completeness +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add comprehensive tests for SSV-denominated cluster operations. Most tests focus on ETH clusters, leaving SSV cluster paths undertested. + +**Context:** +The dual cluster system maintains parallel SSV and ETH records. SSV cluster operations should still work correctly during the transition period. + +**Acceptance Criteria:** +- [ ] Test: Register/remove validators in SSV cluster with non-zero SSV fees → verify fee deductions +- [ ] Test: SSV cluster with non-zero network fee → verify fee deductions +- [ ] Test: Withdraw from SSV cluster → verify balance and token transfer + +**Agent Instructions:** +1. Read existing SSV-related tests: `test/unit/SSVClusters/liquidateSSV.test.ts`, `test/integration/SSVNetwork/legacy-ssv.test.ts`. +2. Create tests that operate entirely in the SSV version (VERSION_SSV = 0). +3. Set non-zero SSV fees on operators before creating clusters. +4. Verify SSV token balance changes match expected fee deductions. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: SSV validator registration with fees +- [ ] Sub-task 2: SSV cluster network fee deductions +- [ ] Sub-task 3: SSV cluster withdrawal + +--- + +### [TEST-16] View function coverage (SSVViews) +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add dedicated unit tests for SSVViews functions. Currently view functions are tested only indirectly. + +**Context:** +No dedicated unit test file exists for SSVViews. Functions like `getBalance`, `isLiquidatable`, `getBurnRate`, `getOperatorEarnings` are used as helpers in other tests but their correctness is never directly asserted. + +**Acceptance Criteria:** +- [ ] Test: `getBalance` returns correct `(balance, ebBalance)` tuple +- [ ] Test: `getBalance` for liquidated cluster returns `(0, 0)` +- [ ] Test: `isLiquidatable` at exact boundary returns correct boolean +- [ ] Test: `getBurnRate` with EB-weighted cluster scales with vUnits +- [ ] Test: `getOperatorEarnings` for operator with both ETH and SSV balances +- [ ] Test: All view functions after migration — SSV views return 0, ETH views return correct values + +**Agent Instructions:** +1. Read `contracts/modules/SSVViews.sol` to understand all view functions. +2. Create `test/unit/SSVViews/views.test.ts` (or similar) following existing test patterns. +3. Set up various cluster states (active, liquidated, migrated) and verify view function return values. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: `getBalance` basic and edge cases +- [ ] Sub-task 2: `isLiquidatable` boundary tests +- [ ] Sub-task 3: `getBurnRate` with EB +- [ ] Sub-task 4: `getOperatorEarnings` dual-version +- [ ] Sub-task 5: View functions after migration + +--- + +### [TEST-17] Staking rewards from EB-weighted cluster fees +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Test that EB-weighted clusters produce proportionally more staking rewards via the network fee. + +**Context:** +Staking integration tests use basic network fees but don't verify that higher-EB clusters contribute proportionally more to the staking pool. + +**Acceptance Criteria:** +- [ ] Test: Cluster with EB=64 generates 2x network fees vs EB=32 → verify staking pool receives 2x rewards +- [ ] Test: Multiple clusters with different EBs → verify cumulative staking rewards match sum of EB-weighted network fees + +**Agent Instructions:** +1. Read `test/integration/SSVNetwork/staking.test.ts`. +2. Create two clusters with different EBs, advance blocks, sync fees, verify `accEthPerShare` increment matches EB-weighted expectation. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: EB=64 vs EB=32 staking reward comparison +- [ ] Sub-task 2: Multi-cluster cumulative staking rewards + +--- + +### [TEST-18] `withdrawNetworkETHEarnings` (DAO ETH withdrawal) +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add unit tests for DAO ETH earnings withdrawal. Only SSV DAO withdrawal (`withdrawNetworkSSVEarnings`) is currently tested. + +**Context:** +There is no test for `withdrawNetworkETHEarnings`. The function should exist for withdrawing accumulated ETH network fees. + +**Acceptance Criteria:** +- [ ] Test: Withdraw ETH network earnings → verify balance, event, access control +- [ ] Test: Withdraw more than available → verify revert +- [ ] Test: Withdraw after multiple clusters accrue fees → verify cumulative amount + +**Agent Instructions:** +1. Read `test/unit/SSVDAO/withdrawNetworkSSVEarnings.test.ts` for the SSV withdrawal pattern. +2. Search for `withdrawNetworkETHEarnings` or similar function in `contracts/modules/SSVDAO.sol`. +3. Create equivalent tests for the ETH version. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Basic ETH withdrawal test +- [ ] Sub-task 2: Over-withdrawal revert test +- [ ] Sub-task 3: Cumulative multi-cluster accrual test + +--- + +### [TEST-19] Operator removal impact on active ETH clusters +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Test the impact of operator removal on active ETH clusters' fee calculations. + +**Context:** +`removeOperator` tests don't test the downstream effect on active ETH clusters' fee calculations. + +**Acceptance Criteria:** +- [ ] Test: Remove operator from set of 4 while cluster has active validators → verify fee calculation excludes removed operator +- [ ] Test: Verify removed operator stops earning from both ETH and SSV clusters + +**Agent Instructions:** +1. Read `test/unit/SSVOperators/removeOperator.test.ts`. +2. Read `test/sanity/removed-operator.test.ts` for the existing removed operator scenario. +3. Create a cluster with 4 operators, remove one, advance blocks, verify cluster balance only decreases by 3 operators' fees. +4. Verify the removed operator's earnings are frozen (no new earnings after removal). +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Fee calculation after operator removal +- [ ] Sub-task 2: Removed operator earnings freeze + +--- + +### [TEST-20] Cooldown duration changes affecting pending requests +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Test how changes to `cooldownDuration` affect pending unstake withdrawal requests. + +**Context:** +`setUnstakeCooldownDuration` is tested for storage but not for impact on existing pending requests. + +**Acceptance Criteria:** +- [ ] Test: User requests unstake, DAO reduces cooldown → can user withdraw earlier? +- [ ] Test: User requests unstake, DAO increases cooldown → does user's original unlock time hold? + +**Agent Instructions:** +1. Read `test/unit/SSVStaking/requestUnstake.test.ts` and `test/unit/SSVStaking/withdrawUnlocked.test.ts`. +2. Read `contracts/modules/SSVStaking.sol` to understand how `unlockTime` is stored (is it absolute timestamp or relative?). +3. Create tests: stake → request unstake → change cooldown → attempt withdraw → verify behavior. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Cooldown reduction — earlier withdrawal test +- [ ] Sub-task 2: Cooldown increase — original unlock time test + +--- + +### [TEST-21] EB boundary values (min/max per validator) +- **Type:** Unit Test Completeness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add boundary tests for EB values at minimum (32 ETH) and maximum (2048 ETH) per validator. + +**Context:** +Limited boundary testing exists. The sanity tests cover conversions but not the full cluster accounting at boundaries. + +**Acceptance Criteria:** +- [ ] Test: EB exactly 32 ETH per validator (10000 vUnits) — baseline behavior +- [ ] Test: EB exactly 2048 ETH per validator (640000 vUnits) — max behavior +- [ ] Test: EB at 2049 per validator — verify revert + +**Agent Instructions:** +1. Read `test/sanity/effective-balance.ts`. +2. Read `test/unit/SSVClusters/updateClusterBalance.test.ts`. +3. Add boundary-value tests using `updateClusterBalance` with Merkle proofs at exact boundaries. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: EB=32 baseline test +- [ ] Sub-task 2: EB=2048 maximum test +- [ ] Sub-task 3: EB>2048 revert test + +--- + +### [TEST-22] Dust/precision edge cases +- **Type:** Unit Test Completeness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add precision edge case tests for packed type boundaries and tiny values. + +**Acceptance Criteria:** +- [ ] Test: Withdraw amount of exactly 1 * ETH_DEDUCTED_DIGITS (minimum non-zero) +- [ ] Test: Cluster balance that rounds to 0 after fee deduction +- [ ] Test: Operator earnings of exactly 1 packed unit — verify withdrawable +- [ ] Test: accEthPerShare with tiny fee and large totalStaked — verify no rounding to zero + +**Agent Instructions:** +1. Read `test/unit/packedLib.test.ts` for packed type patterns. +2. Create edge case tests using minimum possible values. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Minimum withdrawal amount +- [ ] Sub-task 2: Zero-rounding cluster balance +- [ ] Sub-task 3: Minimum operator earnings +- [ ] Sub-task 4: Precision in accEthPerShare + +--- + +### [TEST-23] Max operator count (13) with EB +- **Type:** Unit Test Completeness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests for 13-operator clusters with high EB values to verify no overflow. + +**Acceptance Criteria:** +- [ ] Test: 13 operators with EB=2048 — verify no overflow, correct accounting +- [ ] Test: Liquidation with 13 operators and high EB — verify threshold calculation + +**Agent Instructions:** +1. Read existing gas tests for 13 operators in `test/unit/SSVValidator/`. +2. Create tests combining 13 operators with maximum EB. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: 13 operators + EB=2048 accounting +- [ ] Sub-task 2: 13 operators + high EB liquidation + +--- + +### [TEST-24] Idempotency and double-operation checks +- **Type:** Unit Test Completeness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests verifying that double-calling operations either reverts or is safely idempotent. + +**Acceptance Criteria:** +- [ ] Test: `exitValidator` twice on same validator → verify second reverts +- [ ] Test: `syncFees` twice in same block → verify no double-counting +- [ ] Test: `updateClusterBalance` with same proof twice → verify stale block revert + +**Agent Instructions:** +1. Read relevant test files for each operation. +2. Call each operation twice and verify the second call either reverts with the correct error or is safely no-op. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Double `exitValidator` +- [ ] Sub-task 2: Double `syncFees` in same block +- [ ] Sub-task 3: Double `updateClusterBalance` with same proof + +--- + +### [TEST-25] Upgrade path (reinitializer) tests +- **Type:** Unit Test Completeness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests for the upgrade initializer (`reinitializer(3)`) behavior. + +**Acceptance Criteria:** +- [ ] Test: Call initializer with `reinitializer(3)` → verify new state set correctly +- [ ] Test: Call initializer again → verify reverts (already initialized) +- [ ] Test: Verify `UPGRADE_TIMESTAMP` immutable prevents pre-migration fee declarations + +**Agent Instructions:** +1. Read `contracts/upgrades/stage/hoodi/SSVNetworkSSVStakingUpgrade.sol`. +2. Read `test/setup/` for how upgrades are performed in tests. +3. Create tests that upgrade the proxy and verify the initializer runs correctly, then fails on re-call. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Successful reinitializer(3) execution +- [ ] Sub-task 2: Re-initialization revert +- [ ] Sub-task 3: UPGRADE_TIMESTAMP fee declaration guard + +--- + +### [TEST-26] Zero-validator cluster operations +- **Type:** Unit Test Completeness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add tests for clusters with 0 validators. + +**Acceptance Criteria:** +- [ ] Test: Deposit into cluster with 0 validators → verify no fees accrue +- [ ] Test: Withdraw from cluster with 0 validators → verify full balance withdrawable +- [ ] Test: EB update on cluster with 0 validators → verify no vUnits change +- [ ] Test: Oracle EB report (`effectiveBalance = 0`) on active cluster with `validatorCount == 0` (all validators removed, cluster not deleted) → verify: (a) `_verifyEBLimits` passes (`0 >= 0 * 32`), (b) `ebToVUnits(0)` returns `0`, (c) `clusterEB.vUnits` written as `0` (resets any prior explicit EB back to implicit-EB sentinel), (d) no `operatorEthVUnits` or `daoTotalEthVUnits` changes, (e) no auto-liquidation triggered, (f) `ClusterBalanceUpdated` emitted with `effectiveBalance = 0` + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/deposit.test.ts` and `test/unit/SSVClusters/withdraw.test.ts`. +2. Create a cluster, remove all validators, then perform operations. +3. For sub-task 4: register a cluster with explicit EB (run one `updateClusterBalance` with non-zero EB first), then remove all validators, then submit a valid oracle proof with `effectiveBalance = 0`. Assert all storage fields and events per acceptance criteria above. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Deposit with 0 validators +- [ ] Sub-task 2: Withdrawal with 0 validators +- [ ] Sub-task 3: EB update with 0 validators (generic) +- [ ] Sub-task 4: Oracle EB report with `effectiveBalance = 0` on active zero-validator cluster — full state assertion (see DISC.md §2.2) + +--- + +### [TEST-27] Operator at max validator limit +- **Type:** Unit Test Completeness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Test `VALIDATORS_PER_OPERATOR_LIMIT` (3000) boundary. + +**Acceptance Criteria:** +- [ ] Test: Register validator pushing operator to limit+1 → verify revert +- [ ] Test: Remove validator then re-register at limit → verify succeeds + +**Agent Instructions:** +1. Read `contracts/libraries/OperatorLib.sol` for the limit check. +2. This requires registering many validators. May need to use bulk registration. +3. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Exceed operator validator limit — revert +- [ ] Sub-task 2: Re-register at limit after removal + +--- + +### [TEST-28] Uncomment SSV reentrancy test assertions +- **Type:** Unit Test Completeness +- **Priority:** P0 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Uncomment the three commented-out assertions in the SSV operator reentrancy test and verify they pass. + +**Context:** +In `test/unit/SSVOperators/reentrancy.test.ts:101-107`, three assertions are commented out inside `/* */`. The SSV token reentrancy guard is effectively untested. The ETH reentrancy test in the same file IS properly asserted. This means the SSV withdrawal path has no verified reentrancy protection. + +**Acceptance Criteria:** +- [ ] Lines 101-107 uncommented +- [ ] All three assertions pass +- [ ] If assertions fail, fix the mock contract or reentrancy guard to make them pass + +**Agent Instructions:** +1. Read `test/unit/SSVOperators/reentrancy.test.ts`, focus on lines 95-110. +2. Uncomment the three assertions at lines 101-107. +3. Run `npm run test:unit` to verify they pass. +4. If they fail, investigate whether the mock reentrancy contract or the reentrancy guard needs fixing. + +#### Sub-items: +- [ ] Sub-task 1: Uncomment SSV reentrancy assertions +- [ ] Sub-task 2: Verify test passes (fix if needed) + +--- + +### [TEST-29] Add contract ETH balance delta assertions to deposit tests +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add `address(contract).balance` before/after assertions to ETH deposit tests. Currently tests verify cluster balance in events but never check the actual contract ETH balance change. + +**Context:** +In `test/unit/SSVClusters/deposit.test.ts`, tests verify cluster balance in events but never check `address(contract).balance` before and after the deposit. This means the contract could emit the correct event but not actually receive the ETH. + +**Concrete test:** Register with 10 ETH, deposit 5 ETH, assert `contractBalance_after - contractBalance_before == 5 ETH`. + +**Acceptance Criteria:** +- [ ] At least one deposit test captures contract ETH balance before and after +- [ ] Asserts `balanceAfter - balanceBefore == msg.value` +- [ ] Both single and bulk deposit scenarios covered + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/deposit.test.ts` for existing patterns. +2. Add balance capture: `const before = await ethers.provider.getBalance(ssvNetwork.address)`. +3. After deposit: `const after = await ethers.provider.getBalance(ssvNetwork.address)`. +4. Assert: `expect(after - before).to.equal(depositAmount)`. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Add ETH balance delta assertion to deposit test +- [ ] Sub-task 2: Run full test suite + +--- + +### [TEST-30] Resolve TODO comments with deferred assertions +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Resolve the 12 TODO comments across test files that indicate event args not verified against computed expected values. + +**Context:** +In `test/unit/SSVValidator/registerValidator.test.ts:56`, `bulkRegisterValidator.test.ts:58`, and 10 other locations, TODO comments indicate that event arguments are not being verified against independently computed expected values. These represent deferred test assertions that should be completed. + +**Acceptance Criteria:** +- [ ] All 12 TODO comments identified and resolved +- [ ] Each TODO replaced with actual assertion or removed with justification +- [ ] No new test failures introduced + +**Agent Instructions:** +1. Grep for `TODO` across all test files to identify the 12 locations. +2. For each TODO: read the surrounding test context, compute the expected value, add the assertion. +3. Run `npm run test:unit` after each batch of changes. + +#### Sub-items: +- [ ] Sub-task 1: Identify all 12 TODO locations +- [ ] Sub-task 2: Resolve each TODO with actual assertions +- [ ] Sub-task 3: Run full test suite + +--- + +### [TEST-31] Expand onCSSVTransfer test coverage +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Expand `onCSSVTransfer` tests from the current 2 tests to cover multi-transfer sequences, transfers after fee accruals, and transfers between users with pending rewards. + +**Context:** +In `test/unit/SSVStaking/onCSSVTransfer.test.ts`, only 2 tests exist. Missing scenarios: multi-transfer sequences, transfer after fee accruals, transfer between users with pending rewards. The `onCSSVTransfer` hook is critical for correct reward settlement during cSSV transfers. + +**Concrete test:** User A (100 cSSV) transfers 50 to User B (200 cSSV) after fee sync. Verify both parties' rewards settled correctly using `pendingReward = cSSVBalance * (accEthPerShare - userIndex) / 1e18`. + +**Acceptance Criteria:** +- [ ] Test: multi-transfer sequence (A→B→C) with reward verification at each step +- [ ] Test: transfer after fee accruals — verify accumulated rewards settled before transfer +- [ ] Test: transfer between users with pending rewards — verify both rewards correct +- [ ] At least 5 total test cases for `onCSSVTransfer` + +**Agent Instructions:** +1. Read `test/unit/SSVStaking/onCSSVTransfer.test.ts` for existing patterns. +2. Read `contracts/modules/SSVStaking.sol`, focus on `onCSSVTransfer` (line 169). +3. Add multi-transfer, fee-accrual, and pending-reward test scenarios. +4. Calculate expected rewards independently using the accumulator formula. +5. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Multi-transfer sequence test +- [ ] Sub-task 2: Transfer after fee accrual test +- [ ] Sub-task 3: Transfer with pending rewards test + +--- + +### [TEST-32] Add access control tests for DAO governance functions +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add non-owner revert tests for all DAO governance functions. Currently all SSVDAO test files only test happy path from owner. + +**Context:** +All 11+ governance functions (`updateNetworkFee`, `updateLiquidationThresholdPeriod`, `replaceOracle`, `setQuorumBps`, `setUnstakeCooldownDuration`, `updateMaximumOperatorFee`, `updateMinimumOperatorEthFee`, etc.) are tested only from the owner account. No test verifies that non-owner calls are rejected. + +**Acceptance Criteria:** +- [ ] Each governance function has a test calling from non-owner that expects revert +- [ ] Revert reason matches expected access control error (e.g., `OwnableUnauthorizedAccount`) +- [ ] All 11+ functions covered + +**Agent Instructions:** +1. Read `test/unit/SSVDAO/` directory for all existing DAO test files. +2. For each governance function, add a test that calls from a non-owner signer. +3. Assert revert with the expected access control error. +4. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Identify all governance functions requiring access control tests +- [ ] Sub-task 2: Add non-owner revert test for each function +- [ ] Sub-task 3: Run full test suite + +--- + +### [TEST-33] Mainnet governance config validation & edge-case tests +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add a dedicated test suite that uses the exact mainnet governance parameters and validates system behavior at the boundaries implied by those values. This ensures the production config is safe before deployment. + +**Mainnet Config (from deployment spreadsheet):** +| Param | Value | Wei/Raw | +|-------|-------|---------| +| ethNetworkFee | 0.000000003550929823 ETH/block | 3,550,929,823 | +| minimumLiquidationCollateral | 0.00094 ETH | 940,000,000,000 | +| minimumBlocksBeforeLiquidation | ~5 days | 35,800 | +| operatorMinFee | 0.000000001065278947 ETH/block | 1,065,278,947 | +| operatorMaxFee | 0.000000005326394735 ETH/block | 5,326,394,735 | +| defaultOperatorETHFee | 0.000000001775464912 ETH/block | 1,775,464,912 | +| quorumBps | 75% | 7,500 | +| cooldownDuration | 7 days | 50,120 | + +**Test scenarios:** +1. **Packability** — verify all fee values survive pack/unpack round-trip without precision loss (divisible by `ETH_DEDUCTED_DIGITS`). If a value isn't packable, document the closest packable equivalent. +2. **Liquidation threshold math** — with 4 operators at defaultOperatorETHFee + ethNetworkFee, calculate exactly how many blocks / how much balance keeps a cluster solvent vs liquidatable. Verify `isLiquidatable` agrees. +3. **Operator fee boundaries** — declare fees at operatorMinFee and operatorMaxFee, verify both accepted. Declare fee at operatorMinFee-1 and operatorMaxFee+1, verify both rejected. +4. **Cluster burn rate** — with mainnet fees and varying validator counts (1, 4, 13), compute expected burn rate per block. Verify `getBalance` returns correct remaining balance after N blocks. +5. **Cooldown duration** — set cooldownDuration to 50,120. Request unstake, verify cannot claim before 50,120 blocks/seconds elapse, can claim after. (Also clarifies the blocks-vs-seconds question from BUG-8.) +6. **Quorum** — with 4 oracles and quorumBps=7500, verify exactly 3 votes are needed to commit a root. 2 votes should fail, 3 should succeed. +7. **Liquidation collateral** — deposit exactly minimumLiquidationCollateral, verify cluster is NOT liquidatable at block 0. Verify it IS liquidatable after enough blocks to exhaust balance below threshold. +8. **Long-running clusters** — with mainnet fees, simulate a cluster running for 1 year (~2,628,000 blocks). Verify no overflow in fee index calculations and balance accounting remains correct. + +**Acceptance Criteria:** +- [ ] Test file `test/unit/mainnet-config-validation.test.ts` (or similar) created +- [ ] All 8 test scenarios above implemented with exact mainnet values +- [ ] Each test includes numeric assertions (expected vs actual) with comments showing the math +- [ ] All tests pass +- [ ] Any packability issues documented (values that need rounding for on-chain use) + +**Agent Instructions:** +1. Read `test/setup/fixtures.ts` and `test/common/` for test patterns and constants. +2. Create a new test file for mainnet config validation. +3. Use the exact wei values from the table above as test constants. +4. For each scenario, include a comment with the expected math (e.g., "4 operators × 1,775,464,912 wei/block × 35,800 blocks = X wei burn"). +5. For packability tests, use `SSVPackedLib` to pack/unpack each value and assert round-trip equality. +6. Run `npm run test:unit`. + +#### Sub-items: +- [ ] Sub-task 1: Create test file with mainnet config constants +- [ ] Sub-task 2: Implement packability round-trip tests +- [ ] Sub-task 3: Implement liquidation/solvency boundary tests +- [ ] Sub-task 4: Implement operator fee boundary tests +- [ ] Sub-task 5: Implement burn rate and long-running cluster tests +- [ ] Sub-task 6: Implement cooldown and quorum tests +- [ ] Sub-task 7: Run full test suite + +--- + +### [TEST-34] Staking solvency invariant: cSSV supply must not exceed SSV held by staking contract +- **Type:** Unit Test Completeness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add invariant coverage for staking solvency: `cSSV.totalSupply() <= SSV.balanceOf(SSVStaking)` at all times. + +**Product concern:** +Product asked for explicit safety validation to ensure cSSV issuance cannot exceed backing SSV even if future changes introduce bugs. Current implementation is by-construction (SSV transfer happens before cSSV mint), but the invariant should be continuously enforced by tests. + +**Context:** +`SSVStaking.stake()` transfers SSV to staking contract before minting cSSV, and `requestUnstake()` burns cSSV before eventual SSV withdrawal. This implies the solvency relationship should always hold, but there is no explicit invariant test guarding against regressions. + +**Invariant to test:** +`cSSV.totalSupply() <= SSV.balanceOf(address(SSVStaking))` + +**Acceptance Criteria:** +- [ ] Add an Echidna invariant test that continuously asserts `cSSV.totalSupply() <= SSV.balanceOf(address(staking))` across stake/unstake/transfer/withdraw flows +- [ ] Add at least one deterministic unit regression test for the invariant around `stake` and `requestUnstake` ordering +- [ ] Include edge scenarios: multiple users, partial unstake requests, full unstake + withdraw cycle +- [ ] No invariant violations in fuzz runs + +**Agent Instructions:** +1. Read `contracts/modules/SSVStaking.sol` and `contracts/token/CSSVToken.sol` for mint/burn ordering. +2. Extend the Echidna suite under `test/echidna/` with a dedicated solvency invariant check. +3. Add a deterministic unit test in `test/unit/SSVStaking/` asserting the invariant before/after `stake`, `requestUnstake`, and `withdrawUnlocked`. +4. Run the relevant unit tests and Echidna target. + +#### Sub-items: +- [ ] Sub-task 1: Add Echidna solvency invariant +- [ ] Sub-task 2: Add deterministic unit regression tests +- [ ] Sub-task 3: Cover multi-user + partial/full unstake scenarios +- [ ] Sub-task 4: Run unit + Echidna checks + +--- + +## Integration / E2E Tests + +### [ITEST-1] `commitRoot` → `updateClusterBalance` E2E flow +- **Type:** Integration / E2E Tests +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Create an end-to-end test connecting oracle voting → root commitment → cluster EB update → fee recalculation. + +**Context:** +Unit tests for `commitRoot` and `updateClusterBalance` exist separately but no test connects the full flow. This is the core oracle→cluster pipeline. + +**Acceptance Criteria:** +- [ ] Test: 3 oracles propose same root → root committed → cluster calls `updateClusterBalance` with proof from committed root → verify fees recalculated with new EB +- [ ] Test: Multiple clusters update EB from same root → verify independent accounting + +**Agent Instructions:** +1. Read `test/unit/SSVDAO/commitRoot.test.ts` and `test/unit/SSVClusters/updateClusterBalance.test.ts`. +2. Read `test/integration/SSVNetwork.test.ts` for integration test patterns. +3. Create a new integration test file or add to existing. +4. Build the full flow: deploy, create cluster, stake SSV for oracle weight, commit oracle root with Merkle tree, then call `updateClusterBalance` with proof from the committed root. +5. Verify the cluster's EB is updated and fee calculations reflect the new EB. +6. Run `npm run test:integration`. + +#### Sub-items: +- [ ] Sub-task 1: Full oracle → cluster EB update flow +- [ ] Sub-task 2: Multiple clusters from same root + +--- + +### [ITEST-2] Migration with multiple EB updates E2E +- **Type:** Integration / E2E Tests +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Test migration of a cluster that has had multiple EB updates, verifying the latest snapshot is used. + +**Context:** +Migration with EB snapshot is tested but edge cases with multiple prior EB updates are not. + +**Acceptance Criteria:** +- [ ] Test: Migrate cluster that has had multiple EB updates → verify latest snapshot used +- [ ] Test: Migrate cluster where EB was set and then validators were added → verify vUnits calculated correctly + +**Agent Instructions:** +1. Read `test/unit/SSVClusters/migrateClusterToETH.test.ts`. +2. Create a cluster, update EB multiple times via `updateClusterBalance`, then migrate to ETH. +3. Verify the migrated cluster uses the latest EB values. +4. Run `npm run test:integration`. + +#### Sub-items: +- [ ] Sub-task 1: Migration after multiple EB updates +- [ ] Sub-task 2: Migration after EB set + validators added + +--- + +## Deployment & Scripts + +### [DEPLOY-1] ~~Fix `deploy-all.ts` broken signature and constructor args~~ +- **Type:** Deployment & Scripts +- **Priority:** P0 +- **Status:** ✅ Fixed +- **Owner:** (resolved) +- **Timeline:** (complete) +- **Github Link:** [PR #431](https://github.com/ssvlabs/ssv-network/pull/431) + +**Requirement:** +Fix deployment scripts so that fresh deployments work. `deploy-all.ts` had wrong `initializeSSVStaking` signature and missing constructor args for 3 modules. + +**Context:** +`scripts/deploy-all.ts` (now deleted) used `"initializeSSVStaking(address,uint64)"` with `[cssvTokenAddr, cooldown]`. Actual contract signature is `initializeSSVStaking(uint64,uint32[4],uint16)`. Also, `SSVDAO`, `SSVViews`, `SSVStaking` all require `_cssv` address as constructor arg but were deployed without args. + +**Resolution:** +`deploy-all.ts` replaced by `deploy-fresh.ts` (fresh deployments) and `upgrade.ts` (upgrades). Both use the correct `initializeSSVStaking(uint64,uint32[4],uint16)` three-parameter signature and pass `quorumBps` from config. `CSSVToken` deployed before modules and its address passed as constructor arg. `generate-safe-batch.ts` handles Safe multisig batch encoding. + +**Acceptance Criteria:** +- [x] `initializeSSVStaking` signature is `"initializeSSVStaking(uint64,uint32[4],uint16)"` +- [x] `quorumBps` passed as third argument from deployment config +- [x] `CSSVToken` deployed before modules that need its address +- [x] `SSVDAO`, `SSVViews`, `SSVStaking` deployed with `cssvTokenAddr` as constructor arg + +**Agent Instructions:** +~~Obsolete — resolved by replacing `deploy-all.ts` with `deploy-fresh.ts` and `upgrade.ts`. See Resolution above.~~ + +#### Sub-items: +- [ ] Sub-task 1: Fix `initializeSSVStaking` call signature and params +- [ ] Sub-task 2: Fix constructor args for SSVDAO, SSVViews, SSVStaking +- [ ] Sub-task 3: Reorder CSSVToken deployment before modules +- [ ] Sub-task 4: Verify script runs against local Hardhat + +--- + +### [DEPLOY-2] Verify `liquidationThresholdPeriod` config vs spec mismatch +- **Type:** Deployment & Scripts +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Resolve the mismatch between `liquidationThresholdPeriod` in `deployments/hoodi-stage/config.json` (35,800) and the DIP-X spec (50,190 blocks). + +**Context:** +`deployments/hoodi-stage/config.json` sets `liquidationThresholdPeriod: 35800` but the DIP-X spec proposes 50,190 blocks (~7 days). This is a significant difference — 35,800 blocks is ~5 days. If this is intentional for the testnet, it should be documented. The mainnet config (`deployments/mainnet/config.json`) must use the correct value. + +**Acceptance Criteria:** +- [ ] Decision documented: is 35,800 intentional for Hoodi testnet? +- [ ] Mainnet config (when created) uses 50,190 or the final DIP-X approved value +- [ ] Comment added to config explaining the discrepancy if intentional + +**Agent Instructions:** +1. Read `deployments/hoodi-stage/config.json` and `deployments/mainnet/config.json`. +2. Read `docs/SPEC.md` section 11 for the governance parameters. +3. If this is a testnet-specific value, add a comment. If it's a bug, update to 50,190. +4. This is primarily a decision item — flag it for team review if uncertain. + +#### Sub-items: +- [ ] Sub-task 1: Verify intended value with team +- [ ] Sub-task 2: Update config or add documentation + +--- + +### [DEPLOY-3] Verify `ethNetworkFee` rounding in config +- **Type:** Deployment & Scripts +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) +- **DIP-X Review Source:** ETH Payments review finding ETH-10 + +**Requirement:** +Verify whether the rounding of `ethNetworkFee` (config: 3,550,900,000 vs spec: 3,550,929,823) is acceptable or needs correction. + +**Context:** +The config rounds to 3,550,900,000 while the spec says 3,550,929,823. The difference is ~30k wei, which over millions of blocks could accumulate to meaningful amounts. + +**Additional context from DIP-X review (ETH-10):** The DIP-specified value `3,550,929,823 % 100,000 = 29,823` — it is NOT divisible by `ETH_DEDUCTED_DIGITS (100,000)`, so the exact DIP value cannot be stored in `PackedETH`. The closest packable values are `3,550,900,000` (rounding down) or `3,551,000,000` (rounding up). The DIP should be updated to note this packing constraint. The initial value is set at deployment/upgrade time (not hardcoded), so the contract itself has no validation that a specific initial value is used — this is a governance responsibility. + +**Acceptance Criteria:** +- [ ] Decision documented: acceptable rounding or needs exact value +- [ ] If exact value needed, verify it passes `MaxPrecisionExceeded` check (divisible by ETH_DEDUCTED_DIGITS = 100,000) + +**Agent Instructions:** +1. Check if 3,550,929,823 is divisible by 100,000 (ETH_DEDUCTED_DIGITS). It's not (remainder = 29,823), so it may need rounding. +2. Verify what the contract's precision check allows. +3. The closest valid value is either 3,550,900,000 or 3,551,000,000. +4. Document the decision. + +#### Sub-items: +- [ ] Sub-task 1: Verify precision constraints +- [ ] Sub-task 2: Document accepted rounding + +--- + +### [DEPLOY-4] Remove unused error declarations +- **Type:** Deployment & Scripts +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Remove unused error declarations `NotAuthorized()` and `InvalidContractAddress()` from `ISSVNetworkCore.sol`. + +**Context:** +`contracts/interfaces/ISSVNetworkCore.sol`: `NotAuthorized()` (line 185) and `InvalidContractAddress()` (line 235) are declared but never used (never reverted with). Dead code. + +**Acceptance Criteria:** +- [ ] Both unused errors removed from `ISSVNetworkCore.sol` +- [ ] No references to these errors exist in any contract +- [ ] Compilation succeeds + +**Agent Instructions:** +1. Grep for `NotAuthorized` and `InvalidContractAddress` across all `.sol` files to confirm they're unused. +2. Remove the declarations from `contracts/interfaces/ISSVNetworkCore.sol`. +3. Run `npx hardhat compile`. + +#### Sub-items: +- [ ] Sub-task 1: Verify errors are unused +- [ ] Sub-task 2: Remove declarations +- [ ] Sub-task 3: Verify compilation + +--- + +### [DEPLOY-5] Document `operatorMinFee` governance parameter in DIP-X +- **Type:** Deployment & Scripts +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) +- **DIP-X Review Source:** ETH Payments review finding ETH-20 + +**Requirement:** +The DIP-X governance table leaves the `operatorMinFee` update function and initial value cells blank/empty. The implementation provides `updateMinimumOperatorEthFee(uint256 minFee)` as a fully-functional governance parameter (`SSVDAO.sol:147-150`), used for validation during operator registration and fee changes. The DIP should document this parameter completely. + +**Context:** +`SSVDAO.sol:147`: `function updateMinimumOperatorEthFee(uint256 minFee)`. Used in: `SSVOperators.registerOperator()` line 38, `declareOperatorFee()` line 106, `reduceOperatorFee()` line 187. The parameter exists and is enforced but the DIP specification does not document its update function or initial value. + +**Acceptance Criteria:** +- [ ] DIP-X governance table updated with: update function = `updateMinimumOperatorEthFee(uint256 minFee)`, initial value = (team to specify) +- [ ] Deployment config (`deployments/hoodi-prod/config.json`) verified to include a reasonable initial value + +**Agent Instructions:** +1. Read `contracts/modules/SSVDAO.sol`, focus on `updateMinimumOperatorEthFee` (line 147). +2. Read `deployments/hoodi-prod/config.json` for current config value. +3. Update the DIP-X governance table to document the update function and initial value. +4. This is a documentation task — no code change needed. + +#### Sub-items: +- [ ] Sub-task 1: Document `operatorMinFee` in DIP-X governance table +- [ ] Sub-task 2: Verify deployment config includes the parameter + +--- + +### [DEPLOY-6] DIP-X unstaking description doesn't match implementation +- **Type:** Deployment & Scripts +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) +- **DIP-X Review Source:** SSV Staking review finding DIP-7 + +**Requirement:** +The DIP-X describes unstaking as "lock cSSV → wait → burn cSSV + return SSV", but the implementation does "burn cSSV + create withdrawal request → wait → return SSV". The economic effect is identical but the mechanism and user experience differ (users see cSSV balance decrease immediately on `requestUnstake`, not at `withdrawUnlocked`). The DIP should be updated to match the implementation. + +**Context:** +`SSVStaking.sol:66-94` (`requestUnstake`): Burns cSSV immediately at line 91 via `ICSSVToken(CSSV_ADDRESS).burn(msg.sender, amount)`, then creates `UnstakeRequest{amount, unlockTime}` at line 89. The DIP says the request "locks the specified amount of cSSV" and that "The locked cSSV is burned" at finalization. The implementation is arguably better (simpler, no locked-cSSV tracking mechanism needed). + +**Acceptance Criteria:** +- [ ] DIP-X unstaking section updated to describe the actual burn-first mechanism +- [ ] User-facing documentation (SDK docs, webapp) reflects the correct behavior +- [ ] No code change needed — the implementation is correct and simpler + +**Agent Instructions:** +1. This is purely a documentation task. +2. Read `contracts/modules/SSVStaking.sol`, focus on `requestUnstake` (line 66) and `withdrawUnlocked` (line 99) to confirm the actual flow. +3. Update the DIP-X section on unstaking to describe: + - Step 1: `requestUnstake(amount)` — burns cSSV immediately, creates withdrawal request with unlock time + - Step 2: `withdrawUnlocked()` — after cooldown, returns SSV 1:1 +4. Note that rewards stop accruing immediately because cSSV is burned (reducing the user's share of `totalSupply`). + +#### Sub-items: +- [ ] Sub-task 1: Update DIP-X unstaking section +- [ ] Sub-task 2: Verify user-facing documentation + +--- + +### [DEPLOY-7] ~~Deploy scripts import from test files~~ +- **Type:** Deployment & Scripts +- **Priority:** P2 +- **Status:** ✅ Fixed +- **Owner:** (resolved) +- **Timeline:** (complete) +- **Github Link:** (empty) + +**Requirement:** +Move shared constants out of test files so deploy scripts don't import from test directories. + +**Context:** +`scripts/deploy-all.ts`, `scripts/staking-upgrade.ts`, and `scripts/upgrade-fork.ts` (all now deleted/replaced) imported `DEFAULT_UNSTAKE_COOLDOWN` from `"../test/common/constants.ts"`. Deploy scripts should not depend on test files — this creates a fragile dependency where test refactors can break deployment. + +**Resolution:** +`upgrade.ts` and `deploy-fresh.ts` import all shared config from `scripts/common/config.ts` (new in this merge). No deploy script imports from `test/common/` any longer. The only remaining reference is `scripts/common/fork-test.ts` which uses a local env-var constant — not a cross-boundary import. + +**Acceptance Criteria:** +- [x] Shared constants in `scripts/common/config.ts` +- [x] Deploy scripts import from the new location +- [x] No deploy script imports from `test/common/` + +**Agent Instructions:** +~~Obsolete — resolved. `upgrade.ts` and `deploy-fresh.ts` import from `scripts/common/config.ts`. See Resolution above.~~ + +#### Sub-items: +- [ ] Sub-task 1: Create shared constants file +- [ ] Sub-task 2: Update deploy script imports +- [ ] Sub-task 3: Verify scripts still work + +--- + +## Operational Readiness + +### [OPS-1] Create mainnet deployment runbook +- **Type:** Operational Readiness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Create a step-by-step runbook for the v2.0.0 mainnet upgrade, including pre-flight checks, deployment steps, post-deployment verification, and rollback triggers. + +**Context:** +No mainnet deployment checklist exists. The upgrade involves UUPS proxy upgrades, new module deployments, CSSVToken deployment, initializer execution, and governance parameter setup. The existing `scripts/deployment.md` covers generic deployment but not the v2.0.0-specific flow. + +**Acceptance Criteria:** +- [ ] Document includes pre-flight checks (contract sizes, gas estimates, parameter verification) +- [ ] Step-by-step deployment sequence matching `upgrade.ts` / `generate-safe-batch.ts` flow +- [ ] Post-deployment verification checklist (all parameters set, quorumBps != 0, oracle addresses correct) +- [ ] Rollback triggers and procedure for each step +- [ ] Links to relevant scripts for each step + +**Agent Instructions:** +1. Read `scripts/upgrade.ts` for the upgrade flow reference. +2. Read `scripts/generate-safe-batch.ts` for the mainnet Safe batch encoding flow. +3. Read `scripts/deployment.md` for existing documentation patterns. +4. Create `docs/MAINNET-UPGRADE-RUNBOOK.md` with: + - Pre-flight checklist + - Deployment sequence (numbered steps with exact commands) + - Post-deployment verification queries (using SSVViews) + - Rollback procedures + - Emergency contacts / escalation paths (placeholder) +5. Ensure the runbook explicitly states: "Call `setQuorumBps(7500)` immediately after upgrade" (see SEC-2). + +#### Sub-items: +- [ ] Sub-task 1: Write pre-flight checks section +- [ ] Sub-task 2: Write deployment sequence +- [ ] Sub-task 3: Write post-deployment verification +- [ ] Sub-task 4: Write rollback procedures + +--- + +### [OPS-2] Create emergency rollback procedure +- **Type:** Operational Readiness +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Document how to downgrade/rollback modules if critical issues are found post-deployment. + +**Context:** +The UUPS proxy pattern allows module replacement. If a bug is found in a deployed module, the DAO owner can replace it with a patched version. But there's no documented procedure for this. + +**Acceptance Criteria:** +- [ ] Document covers: how to replace a module with a patched version +- [ ] Covers: how to pause operations if needed (does a pause mechanism exist?) +- [ ] Covers: which state is recoverable and which is not +- [ ] Covers: communication plan for operators/users + +**Agent Instructions:** +1. Read `contracts/SSVNetwork.sol` to understand `updateModule` function. +2. Read `scripts/upgrade.ts` for the module replacement / `updateModule` call pattern. +3. Document the rollback procedure for each module type. +4. Identify what state changes are irreversible (e.g., token transfers, oracle commits). + +#### Sub-items: +- [ ] Sub-task 1: Document module replacement procedure +- [ ] Sub-task 2: Document irrecoverable state changes +- [ ] Sub-task 3: Document communication plan template + +--- + +### [OPS-3] Update `.env.example` for v2.0.0 +- **Type:** Operational Readiness +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Update `.env.example` with v2.0.0 parameter names and values. + +**Context:** +`.env.example` still contains v1 values: `MINIMUM_BLOCKS_BEFORE_LIQUIDATION=100800`, `MINIMUM_LIQUIDATION_COLLATERAL=200000000` (SSV-denominated), `OPERATOR_MAX_FEE_INCREASE=3`, `QUORUM_BPS=6700`. Missing all ETH-specific params. + +**Acceptance Criteria:** +- [ ] All v1-only params removed or updated +- [ ] ETH-specific params added: `NETWORK_FEE_ETH`, `MIN_OPERATOR_ETH_FEE`, `MAX_OPERATOR_ETH_FEE`, `DEFAULT_OPERATOR_ETH_FEE` +- [ ] Values match DIP-X spec defaults +- [ ] Comments explain each parameter + +**Agent Instructions:** +1. Read `.env.example`. +2. Read `deployments/hoodi-prod/config.json` for reference values. +3. Update the file with v2.0.0 parameters and inline comments. + +#### Sub-items: +- [ ] Sub-task 1: Update existing params +- [ ] Sub-task 2: Add ETH-specific params +- [ ] Sub-task 3: Add inline comments + +--- + +## Echidna Invariant Suite + +**Current state:** 73 invariants across 9 test contracts (see `test/echidna/README.md` for full master list). +**Source:** Evaluated from `ssv-review/planning/SSVNetwork — Enrich Invariant Suite.md` — cross-referenced all 50 proposed invariants against existing 73, identified 30 new + 5 strengthening items. + +### [FUZZ-1] Strengthen 5 partially-covered echidna invariants +- **Type:** Echidna Invariant Suite +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Upgrade 5 existing invariants from partial to full coverage: +1. `echidna_network_fee_matches_expected` → add explicit monotonicity tracking (ref A8) +2. `echidna_cssv_supply_matches_users` → add per-operation mint/burn delta assertions (ref A11) +3. `echidna_user_index_leq_acc` → strengthen to exact equality after `_settle` (ref A14) +4. `echidna_pool_matches_dao_balance` → add per-claim delta tracking (ref A16) +5. `echidna_accrued_within_pool` → add cumulative payout tracking (ref C2) + +**Acceptance Criteria:** +- [ ] Each upgraded invariant catches the class of bugs described in the ref +- [ ] All echidna tests still pass after modifications +- [ ] Harness bookkeeping added (prev-value tracking, per-claim deltas, cumulative payout counter) + +--- + +### [FUZZ-2] Add 16 high-priority new echidna invariants +- **Type:** Echidna Invariant Suite +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add 16 new invariants covering critical gaps. Full list with descriptions in `test/echidna/README.md` under "High Priority — New Invariants". Summary: + +**Oracle / EB Governance (3):** Finalized weight cleared (A4), commitment weight ≤ supply (A5), finalization implies quorum (B1) + +**DAO Accounting (2):** DAO earnings monotonicity (A9), DAO index block ≤ current (A10) + +**Staking Rewards Precision (3):** cSSV transfer settles both (A15), claim payout precision (A17), no free rewards on transfer (C3) + +**EB Snapshot Safety (2):** Snapshot block ≤ current (A18), snapshot root monotonic per cluster (A19) + +**EB Update Correctness (3):** Update requires root (B3), frequency enforced (B4), staleness enforced (B5) + +**Fee Settlement (2):** Fee index current after settle (B9), fee uses old vUnits on EB change (B11) + +**Liquidation Completeness (2):** Liquidation clears EB snapshot (B13), liquidation pays exact balance (B14) + +**Acceptance Criteria:** +- [ ] All 16 invariants implemented and passing +- [ ] Harness features added: prev-value tracking, touched-key arrays, 2-actor reward tracking +- [ ] Each invariant documented in `test/echidna/README.md` + +--- + +### [FUZZ-3] Add 8 medium-priority echidna invariants +- **Type:** Echidna Invariant Suite +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add 8 medium-priority invariants requiring more harness setup. Full list in `test/echidna/README.md` under "Medium Priority". Summary: + +**EB Proof (3):** Merkle proof verified (B6), EB bounds enforced (B7), snapshot fields exact (B8) + +**Operator Fee Gov (2):** Declare fee from zero reverts (B17), execute rejects legacy declarations (B19) + +**Legacy SSV (1):** SSV liquidation resets and pays (B15) + +**DAO Formula (1):** DAO earnings matches formula exactly (C4) + +**Acceptance Criteria:** +- [ ] All 8 invariants implemented and passing +- [ ] Merkle tree builder added to harness for valid proof happy paths +- [ ] Each invariant documented in `test/echidna/README.md` + +--- + +### [FUZZ-4] Add 6 lower-priority echidna invariants (heavy harness) +- **Type:** Echidna Invariant Suite +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add 6 lower-priority invariants requiring significant harness work. Full list in `test/echidna/README.md` under "Lower Priority". Summary: + +**vUnit Aggregation (2):** DAO vUnits = sum of clusters (C5), operator vUnits matches clusters (C6) + +**Migration (1):** Migration one-way and returns SSV (C7) + +**Overflow/Extreme (3):** ETH accrual no overflow (X4), SSV accrual no overflow (X5), intermediate mul no overflow (X6), pack reverts on overflow (X7) + +**Acceptance Criteria:** +- [ ] All invariants implemented and passing +- [ ] Delta-block simulator added for overflow testing +- [ ] Max-parameter configurator added +- [ ] Per-cluster EB tracking arrays added +- [ ] Each invariant documented in `test/echidna/README.md` + +--- + +### [FUZZ-5] ETH contract balance accounting invariant +- **Type:** Echidna Invariant Suite +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Add an Echidna invariant that continuously asserts the ETH accounting identity: + +``` +address(this).balance == Σ(cluster.balance) + Σ(operator.ethEarnings) + ethDaoBalance + stakingEthPoolBalance +``` + +**Context:** +Product raised the question of whether `withdraw` needs an explicit `amount <= address(this).balance` guard. The answer is: not as a runtime check — if accounting is correct, `cluster.balance` is always ≤ `address(this).balance` by construction. However, this invariant should be continuously enforced by fuzzing to catch any accounting divergence (rounding errors, missed fee settlement paths, ETH drain via another function). A violation means a protocol bug, not a user error. See FLOWS.md §1.8 for the full rationale. + +**Acceptance Criteria:** +- [ ] Echidna invariant `echidna_eth_balance_accounting` implemented in the staking/cluster harness +- [ ] Invariant asserts `address(this).balance >= sum_of_all_cluster_balances + sum_of_operator_eth_earnings + ethDaoBalance + stakingEthPoolBalance` after every operation +- [ ] Harness tracks all cluster balances and operator earnings across stake/unstake/deposit/withdraw/liquidate/reactivate flows +- [ ] No invariant violations in fuzz runs + +**Agent Instructions:** +1. Read `test/echidna/` for existing harness patterns and how cluster/operator state is tracked. +2. Add a new invariant function that sums all tracked cluster balances and operator ETH earnings and compares to `address(this).balance`. +3. Ensure the harness exercises all ETH-moving operations: `deposit`, `withdraw`, `liquidate`, `reactivate`, `claimEthRewards`, `withdrawNetworkETHEarnings`, `withdrawOperatorEarnings`. +4. Run Echidna and confirm no violations. + +#### Sub-items: +- [ ] Sub-task 1: Implement `echidna_eth_balance_accounting` invariant +- [ ] Sub-task 2: Extend harness to track all ETH-moving operations +- [ ] Sub-task 3: Run Echidna and confirm no violations + +--- + +## Code Quality + +### [QUALITY-1] `operatorFeeChangeRequests` not cleared on operator removal +- **Type:** Code Quality +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Clear `operatorFeeChangeRequests[operatorId]` in `_resetOperatorState` when an operator is removed. + +**Context:** +In `SSVOperators.sol:324-335`, `_resetOperatorState` doesn't delete stale fee change requests for the removed operator. No functional impact since `declareOperatorFee` and `executeOperatorFee` both check `checkOwner()` first (which reverts for removed operators), but the stale data wastes storage and could confuse off-chain readers querying operator fee change requests. + +**Acceptance Criteria:** +- [ ] `delete s.operatorFeeChangeRequests[operatorId]` added to `_resetOperatorState` +- [ ] Existing removal tests pass +- [ ] New test: declare fee change, remove operator, verify fee change request is cleared + +#### Sub-items: +- [ ] Sub-task 1: Add fee change request cleanup to `_resetOperatorState` +- [ ] Sub-task 2: Add test verifying cleanup +- [ ] Sub-task 3: Run full test suite + +--- + +### [QUALITY-2] Redundant `SSVStorage.load()` calls in view function loops +- **Type:** Code Quality +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Hoist `SSVStorage.load()` out of loops in `SSVViews.sol` to avoid redundant storage slot computation. + +**Context:** +In `SSVViews.sol` at 6 locations, `SSVStorage.load()` is called every loop iteration instead of once before the loop. Each call computes `keccak256` of the storage slot string, costing ~1200 gas per call. With 13 operators (maximum), this wastes ~15,600 gas per view call. While view functions are typically free (off-chain calls), they cost real gas when called from other contracts. + +**Acceptance Criteria:** +- [ ] `SSVStorage.load()` called once before each loop, stored in a local variable +- [ ] Same pattern applied to `SSVStorageProtocol.load()` and `SSVStorageEB.load()` if they have the same issue +- [ ] Existing view tests pass with identical return values + +#### Sub-items: +- [ ] Sub-task 1: Identify all redundant `load()` calls in loops +- [ ] Sub-task 2: Hoist to pre-loop variables +- [ ] Sub-task 3: Run full test suite + +--- + +### [QUALITY-3] `withdraw` in SSVClusters duplicates operator loop inline +- **Type:** Code Quality +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Refactor the inline operator loop in `SSVClusters.withdraw()` to use the shared function from `OperatorLib`. + +**Context:** +In `SSVClusters.sol:220-231`, the `withdraw` function inlines a read-only version of the operator loop instead of calling the shared function in `OperatorLib.sol:253-282`. This means future changes to the index formula must be updated in two places, creating a maintenance burden and risk of divergence. + +**Acceptance Criteria:** +- [ ] `withdraw()` uses a shared function from `OperatorLib` instead of inline loop +- [ ] Behavior is identical before and after refactor +- [ ] All withdrawal tests pass + +#### Sub-items: +- [ ] Sub-task 1: Extract shared function or reuse existing one +- [ ] Sub-task 2: Replace inline loop in `withdraw()` +- [ ] Sub-task 3: Run full test suite + +--- + +### [QUALITY-4] `_resetOperatorState` returns unused `Operator memory` +- **Type:** Code Quality +- **Priority:** P3 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Remove the unused return value from `_resetOperatorState` to save gas. + +**Context:** +In `SSVOperators.sol:324`, `_resetOperatorState` returns `Operator memory` but the caller at line 82 discards the return value. The unnecessary SLOAD to populate the return struct wastes ~2100 gas per operator removal. + +**Acceptance Criteria:** +- [ ] `_resetOperatorState` changed to return `void` (no return value) +- [ ] Caller at line 82 updated to not expect a return value +- [ ] Existing operator removal tests pass + +#### Sub-items: +- [ ] Sub-task 1: Remove return value from `_resetOperatorState` +- [ ] Sub-task 2: Update caller +- [ ] Sub-task 3: Run full test suite + +--- + +### [BUG-10] Remove liquidation check in `withdraw` function +- **Type:** Code Quality +- **Priority:** P2 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Remove the `cluster.validateClusterIsNotLiquidated()` check from the `withdraw` function in `SSVClusters.sol`. + +**Context:** +In `SSVClusters.sol:215`, the `withdraw` function prevents withdrawals from liquidated clusters. This restriction is unnecessarily restrictive: users may deposit funds to prepare a liquidated cluster for reactivation but later decide not to reactivate. In this scenario, they should be able to withdraw their deposited funds without being forced to complete the reactivation. The liquidation check should be removed to allow this flexibility. + +**Rationale:** +- Users can deposit to liquidated clusters (allowed by design, see SEC-12) +- If users change their mind about reactivation, they should be able to retrieve their deposits +- The balance accounting is correct whether the cluster is liquidated or not +- **IMPORTANT:** Double-check this change with Product team before implementation to ensure it aligns with intended UX + +**Acceptance Criteria:** +- [ ] Product team approval obtained for this change +- [ ] Remove `cluster.validateClusterIsNotLiquidated()` from `withdraw` function (line 215) +- [ ] Add test: deposit to liquidated cluster, then withdraw without reactivating +- [ ] Verify existing withdrawal tests still pass +- [ ] Update FLOWS.md to document that withdrawals are allowed on liquidated clusters + +#### Sub-items: +- [ ] Sub-task 1: Get Product team approval +- [ ] Sub-task 2: Remove liquidation check from withdraw function +- [ ] Sub-task 3: Add test for withdraw from liquidated cluster +- [ ] Sub-task 4: Update documentation in FLOWS.md + +--- + +### [BUG-11] `removeValidator` / `bulkRemoveValidator` blocked for legacy SSV clusters +- **Type:** Critical Bug Fix +- **Priority:** P1 +- **Status:** Open +- **Owner:** (unassigned) +- **Timeline:** (empty) +- **Github Link:** (empty) + +**Requirement:** +Allow `removeValidator` and `bulkRemoveValidator` to operate on legacy SSV clusters, not just ETH clusters. + +**Context:** +`_bulkRemoveValidator` in `SSVValidators.sol:177` calls `ClusterLib.validateClusterVersion(version, VERSION_ETH)`, which reverts with `IncorrectClusterVersion` for any SSV cluster. This means owners of legacy SSV clusters cannot remove individual validators — they can only exit (signal off-chain) or migrate the entire cluster to ETH. This is a UX regression from v1.x where `removeValidator` worked on all clusters. + +The SSV cluster removal path is distinct from the ETH path in two ways: +1. It uses `s.clusters` (SSV storage) instead of `s.ethClusters` +2. It does not involve ETH snapshot updates or EB deviation cleanup + +The fix requires branching `_bulkRemoveValidator` on `version`: for `VERSION_SSV`, use the legacy SSV cluster removal path (update SSV operator snapshots, decrement `operator.validatorCount`, update SSV cluster hash in `s.clusters`); for `VERSION_ETH`, keep the existing ETH path. + +**Rationale:** +- SSV cluster owners may want to remove specific validators without migrating the entire cluster +- Without this, the only way to reduce validator count in a legacy cluster is full migration +- The FLOWS.md and SPEC.md already document SSV cluster operations as including `removeValidator` (see FLOWS §1.10, SPEC §1 "Existing Clusters") +- **IMPORTANT:** Confirm with Product team whether this is intentionally blocked or an oversight + +**Acceptance Criteria:** +- [ ] Product team approval obtained +- [ ] `_bulkRemoveValidator` branches on `version`: `VERSION_SSV` uses SSV cluster path, `VERSION_ETH` uses ETH cluster path +- [ ] SSV path: updates SSV operator snapshots (`operator.snapshot`), decrements `operator.validatorCount`, updates `s.clusters[hashedCluster]` +- [ ] SSV path: does NOT touch ETH snapshots, `ethValidatorCount`, `ethClusters`, or EB storage +- [ ] Add test: remove validator from active SSV cluster, verify SSV cluster hash updated and operator count decremented +- [ ] Add test: remove validator from liquidated SSV cluster (should be allowed — no active-cluster check in current code) +- [ ] Existing ETH removal tests still pass +- [ ] Update FLOWS §1.3 and §1.4 to document SSV cluster support + +#### Sub-items: +- [ ] Sub-task 1: Get Product team approval +- [ ] Sub-task 2: Branch `_bulkRemoveValidator` on cluster version +- [ ] Sub-task 3: Implement SSV cluster removal path +- [ ] Sub-task 4: Add unit tests +- [ ] Sub-task 5: Update FLOWS.md §1.3 and §1.4 + +--- + +## Changes from DIP-X Review + +**Date:** 2026-02-17 +**Sources:** `ssv-review/planning/verified/dip-review-eth-payments.md`, `ssv-review/planning/verified/dip-review-effective-balance.md`, `ssv-review/planning/verified/dip-review-ssv-staking.md` + +### New Items Added (6) + +| ID | Title | Source Finding | Rationale | +|----|-------|---------------|-----------| +| BUG-7 | `DEFAULT_OPERATOR_ETH_FEE` value deviates from DIP-X spec | ETH-7, ETH-14 | Implementation uses 1,770,000,000 wei but closest packable value to DIP spec is 1,775,500,000 wei (~0.31% deviation) | +| BUG-8 | Cooldown duration uses `block.timestamp` but DIP specifies blocks | DIP-8 | HIGH risk: if initial value set as 50120 (blocks), actual cooldown would be ~13.9 hours instead of 7 days | +| SEC-9 | `operatorMaxFee` function signature differs from DIP-X spec | ETH-13 | DIP says `uint64`, implementation uses `uint256`; cosmetic but should be aligned | +| SEC-10 | cSSV token lacks governance/voting extensions | DIP-10 | DIP claims cSSV retains governance power, but `CSSVToken` has no `ERC20Votes`; depends on off-chain Snapshot config | +| DEPLOY-5 | Document `operatorMinFee` governance parameter in DIP-X | ETH-20 | DIP leaves update function and initial value blank; implementation has `updateMinimumOperatorEthFee(uint256)` | +| DEPLOY-6 | DIP-X unstaking description doesn't match implementation | DIP-7 | DIP says "lock cSSV → burn later"; code does "burn immediately → return SSV later"; same economics, different UX | + +### Existing Items Updated (2) + +| ID | Change | Source Finding | +|----|--------|---------------| +| BUG-6 | Added DIP-X review source tag; added context about `_syncFees` behavior when DAO earnings decrease (`current <= previous` edge case) | DIP-18, DIP-19 | +| DEPLOY-3 | Added DIP-X review source tag; added context explaining why DIP value is not packable (`3,550,929,823 % 100,000 = 29,823`) and noting this is a governance responsibility | ETH-10 | + +### DIP-X Findings Already Covered by Existing Items (4) + +| DIP Finding | Already Covered By | Notes | +|---|---|---| +| EB-OBS-1 (auto-liquidation operator decrement condition) | BUG-5 | Same issue: `_liquidateAfterEBUpdateIfNeeded` condition `op.ethSnapshot.block != 0 && op.snapshot.block != 0` is too strict vs `updateClusterOperators` which only checks `ethSnapshot.block != 0` | +| ETH-19 (migrateClusterToETH lacks nonReentrant) | SEC-6 | Exact same recommendation | +| DIP-18 (zero totalStaked fee loss) | BUG-6 | Exact same issue and recommended fix | +| DIP-23/DIP-24 (no bounds on cooldown/quorum) | SEC-4, SEC-1 | Already covered with same recommendations | + +### DIP-X Findings Not Requiring Action (informational only) + +| DIP Finding | Verdict | Reason No Action Needed | +|---|---|---| +| ETH-1 through ETH-6 | MATCH | Implementation matches DIP specification | +| ETH-8, ETH-9, ETH-11, ETH-12 | MATCH | Implementation matches DIP specification | +| ETH-15, ETH-16, ETH-21, ETH-22 | MATCH | Implementation matches DIP specification | +| ETH-17, ETH-18, ETH-23 | EXTRA | Implementation adds beneficial features beyond DIP | +| ETH-24 | MATCH | Liquidation check correctly uses vUnit model | +| ETH-25 (no SSV cluster withdrawal) | GAP (minor) | More restrictive than DIP but aligns with migration intent; users can migrate or self-liquidate to recover SSV | +| EB-01 through EB-25 (excl. OBS-1) | MATCH | All core EB accounting claims implemented correctly | +| DIP-1, DIP-2, DIP-4–6 | MATCH | Staking core mechanics implemented correctly | +| DIP-3 (auto-delegation) | PARTIAL | By-design for initial phase; future per-user delegation requires upgrade | +| DIP-9 (min staking amount) | GAP | Implementation adds reasonable dust-prevention constraint not in DIP | +| DIP-11–13, DIP-15–17 | MATCH | Oracle and reward mechanics correct | +| DIP-14 (uint128 overflow) | PARTIAL | Theoretically possible but practically impossible for realistic scenarios | +| DIP-20 (flash-loan prevention) | MATCH | Not vulnerable in current permissioned oracle model | +| DIP-25–28 | MATCH | Revenue source, views, ordering, minting ratio all correct | + +--- + +## Changes from New Audit Findings + +**Date:** 2026-02-17 +**Sources:** Research-driven gap analysis audit + +### Status Updates (4) + +| ID | Previous Status | New Status | Rationale | +|----|----------------|------------|-----------| +| BUG-1 | Fixed (verified on `ssv-staking`) | ✅ Fixed | Confirmed fixed in Monday.com | +| BUG-2 | Closed (by design) | Won't Fix (By Design) | Confirmed by-design in Monday.com | +| BUG-3 | Closed (mitigated) | ✅ Mitigated | Confirmed mitigated in Monday.com | +| BUG-5 | Open | ✅ Fixed | Confirmed fixed in Monday.com | + +### New Items Added (16) + +| ID | Title | Type | Priority | +|----|-------|------|----------| +| BUG-9 | `uint64(delta)` silent truncation in operator earnings accumulation | Critical Bug Fix | P1 | +| SEC-11 | `hasDeviation` reactivation optimization uses global counter for per-operator decision | Security Hardening | P1 | +| SEC-12 | `deposit()` accepts deposits to liquidated ETH clusters without fee settlement | Security Hardening | P2 | +| SEC-13 | `OperatorWithdrawn` event doesn't distinguish ETH vs SSV withdrawals | Security Hardening | P2 | +| SEC-14 | `commitRoot` accepts `bytes32(0)` as merkleRoot — permanently wastes block slot | Security Hardening | P2 | +| SEC-15 | Min/max operator fee can be set to contradictory values | Security Hardening | P2 | +| SEC-16 | Missing zero-value/zero-address guards on deposit and withdraw | Security Hardening | P2 | +| TEST-28 | Uncomment SSV reentrancy test assertions | Unit Test Completeness | P0 | +| TEST-29 | Add contract ETH balance delta assertions to deposit tests | Unit Test Completeness | P1 | +| TEST-30 | Resolve TODO comments with deferred assertions | Unit Test Completeness | P1 | +| TEST-31 | Expand onCSSVTransfer test coverage | Unit Test Completeness | P1 | +| TEST-32 | Add access control tests for DAO governance functions | Unit Test Completeness | P1 | +| DEPLOY-7 | Deploy scripts import from test files | Deployment & Scripts | P2 | +| QUALITY-1 | `operatorFeeChangeRequests` not cleared on operator removal | Code Quality | P2 | +| QUALITY-2 | Redundant `SSVStorage.load()` calls in view function loops | Code Quality | P2 | +| QUALITY-3 | `withdraw` in SSVClusters duplicates operator loop inline | Code Quality | P2 | +| QUALITY-4 | `_resetOperatorState` returns unused `Operator memory` | Code Quality | P3 | diff --git a/test/echidna/README.md b/test/echidna/README.md index ed4be317..881a52d1 100644 --- a/test/echidna/README.md +++ b/test/echidna/README.md @@ -1,6 +1,6 @@ -# Echidna Security Testing for CSSVToken +# Echidna Invariant Testing — SSV Network v2 -Fuzz testing for CSSVToken using [Echidna](https://github.com/crytic/echidna). +Fuzz testing for SSV Network v2 smart contracts using [Echidna](https://github.com/crytic/echidna). ## Quick Start (macOS) @@ -172,3 +172,149 @@ test/echidna/ | `echidna_commit_root_not_stale` | Commit block is newer than last committed | | `echidna_committed_block_monotonic` | Latest committed block is monotonic | | `echidna_oracle_mapping_consistent` | Oracle ID mappings remain consistent | + +--- + +## Planned Invariants (Not Yet Implemented) + +Evaluated from `ssv-review/planning/SSVNetwork — Enrich Invariant Suite.md` against the 73 existing invariants above. Only invariants that are **not already covered** are listed below. Grouped by priority. + +### Strengthen Existing (partial coverage → full) + +These existing invariants should be upgraded to catch more subtle bugs: + +| Existing Property | Upgrade | Ref | +|---|---|---| +| `echidna_network_fee_matches_expected` | Add explicit monotonicity: track `prevEthIndex` / `prevSsvIndex` in harness, assert never decreases | A8 | +| `echidna_cssv_supply_matches_users` | Add per-operation delta: on stake `amount`, assert cSSV supply increased by exactly `amount` | A11 | +| `echidna_user_index_leq_acc` | Strengthen to exact equality: after `_settle(user)`, assert `userIndex[user] == accEthPerShare` | A14 | +| `echidna_pool_matches_dao_balance` | Add per-claim delta: on successful claim of `payout`, assert both `stakingEthPoolBalance` and `ethDaoBalance` decreased by exactly `payout` | A16 | +| `echidna_accrued_within_pool` | Add cumulative tracking: wrap `claimEthRewards` to track `totalEthPaidOut`, assert `totalEthPaidOut <= totalEthCredited` | C2 | + +### High Priority — New Invariants + +Directly testable with current harness patterns. High bug-catching value. + +#### Oracle / EB Governance + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_finalized_weight_cleared` | Always | If `ebRoots[blockNum] == root != 0`, then `rootCommitments[key] == 0` — prevents re-finalization | A4 | +| `echidna_commitment_weight_lte_supply` | Always | For each tracked `commitmentKey`, `rootCommitments[key] <= cSSV.totalSupply()` — catches quorum overflow | A5 | +| `echidna_finalization_implies_quorum` | Conditional | At finalization time, accumulated weight >= `threshold(totalSupply, quorumBps)` — catches quorum bypass | B1 | + +#### DAO Accounting + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_dao_earnings_monotonic` | Always | `networkTotalEarnings()` (ETH) and `networkTotalEarningsSSV()` never decrease as `block.number` advances — catches settlement regression | A9 | +| `echidna_dao_index_block_lte_current` | Always | `ethDaoIndexBlockNumber <= block.number` and `daoIndexBlockNumber <= block.number` — catches "time-travel" indices | A10 | + +#### Staking Rewards Precision + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_cssv_transfer_settles_both` | Always | After `onCSSVTransfer(from, to, amount)`, both `userIndex[from]` and `userIndex[to]` equal `accEthPerShare` — catches reward smuggling via transfer | A15 | +| `echidna_claim_payout_precision` | Always | Any successful claim `payout` satisfies `payout % ETH_DEDUCTED_DIGITS == 0` — catches precision bypass | A17 | +| `echidna_no_free_rewards_on_transfer` | Candidate | cSSV transfer does not move already-accrued rewards from sender to receiver — catches reward smuggling (needs 2-actor before/after tracking) | C3 | + +#### EB Snapshot Safety + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_eb_snapshot_block_lte_current` | Always | `clusterEB[id].lastUpdateBlock <= block.number` — catches future-dated EB snapshots | A18 | +| `echidna_eb_snapshot_root_monotonic` | Always | `clusterEB[id].lastRootBlockNum` never decreases per cluster — catches stale proof replay | A19 | + +#### EB Update Correctness + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_eb_update_requires_root` | Conditional | `updateClusterBalance(blockNum, ...)` succeeds only if `ebRoots[blockNum] != 0` | B3 | +| `echidna_eb_update_frequency` | Conditional | Same cluster cannot update twice within `minBlocksBetweenUpdates` — second update reverts | B4 | +| `echidna_eb_update_staleness` | Conditional | Successful update requires `blockNum > lastRootBlockNum` for that cluster | B5 | + +#### Fee Settlement Correctness + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_fee_index_current_after_settle` | Conditional | After ETH cluster fee settlement, stored fee indices equal protocol "current" indices | B9 | +| `echidna_fee_uses_old_vunits_on_eb_change` | Conditional | When EB update changes vUnits, fees for elapsed period use old vUnits, not new | B11 | + +#### Liquidation Completeness + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_liquidation_clears_eb_snapshot` | Conditional | After liquidation, `clusterEB[clusterId].vUnits == 0` — catches stale EB after liquidation | B13 | +| `echidna_liquidation_pays_exact_balance` | Conditional | ETH paid to liquidator equals cluster balance at liquidation time — catches over/underpayment | B14 | + +### Medium Priority — New Invariants + +Requires more harness bookkeeping or complex setup (Merkle builder, multi-actor tracking). + +#### EB Proof Verification + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_eb_merkle_proof_verified` | Conditional | Successful EB update implies `MerkleProof.verify(proof, root, leaf) == true` for expected leaf encoding | B6 | +| `echidna_eb_bounds_enforced` | Conditional | Successful EB update has `effectiveBalance` within protocol bounds (min 32 ETH/validator, max 2048 ETH/validator) | B7 | +| `echidna_eb_snapshot_fields_exact` | Conditional | After successful update: `vUnits == ebToVUnits(effectiveBalance)`, `lastRootBlockNum == blockNum`, `lastUpdateBlock == block.number` | B8 | + +#### Operator Fee Governance + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_declare_fee_from_zero_reverts` | Conditional | If operator legacy fee = 0 and ETH fee = 0, declaring non-zero ETH fee reverts (if enforced) | B17 | +| `echidna_execute_rejects_legacy_declarations` | Conditional | `executeOperatorFee` rejects declarations timestamped before `UPGRADE_TIMESTAMP` | B19 | + +#### Legacy SSV + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_ssv_liquidation_resets_and_pays` | Conditional | `liquidateSSV()` success → cluster inactive, indexes zeroed, remaining SSV transferred to liquidator | B15 | + +#### DAO Earnings Formula + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_dao_earnings_matches_formula` | Candidate | `networkTotalEarnings()` equals `daoBalance + (blockDelta * ethNetworkFee * daoTotalEthVUnits / precision)` — catches packing/rounding/checkpoint errors | C4 | + +### Lower Priority — Heavy Harness Required + +Significant implementation effort. Requires custom delta-block simulators, per-cluster tracking arrays, or boundary-probing helpers. + +#### vUnit Aggregation + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_dao_vunits_equals_sum` | Candidate | `daoTotalEthVUnits == Σ(cluster baseline) ± Σ(cluster deviations)` — catches vUnit drift | C5 | +| `echidna_operator_vunits_matches_clusters` | Candidate | Per-operator vUnits equals sum of their cluster deviations — catches earnings misallocation | C6 | + +#### Migration + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_migration_one_way` | Candidate | After `migrateClusterToETH`: ETH mode active, SSV balance returned, legacy operations revert — catches partial migration / stuck funds | C7 | + +#### Overflow / Extreme Value + +| Planned Property | Type | Description | Ref | +|---|---|---|---| +| `echidna_eth_accrual_no_overflow` | Candidate | With max fee, max validators, max EB, simulating 5 years of blocks: all ETH balances + indices remain within type bounds | X4 | +| `echidna_ssv_accrual_no_overflow` | Candidate | Same as above for SSV scaling factor and fee math | X5 | +| `echidna_intermediate_mul_no_overflow` | Candidate | For worst-case params, `fee * vUnits * deltaBlocks` stays `< type(uint256).max` | X6 | +| `echidna_pack_reverts_on_overflow` | Candidate | Packing `uint256 → uint64` reverts (not truncates) when value exceeds range | X7 | + +### Harness Requirements for Planned Invariants + +To make the above invariants exercisable, the following harness features are needed: + +| Harness Feature | Required By | Description | +|---|---|---| +| **Prev-value tracking** | A8, A9, A18, A19 | Store `prevIndex`, `prevEarnings`, `prevBlock` in harness to assert monotonicity | +| **Touched-key arrays** | A4, A5, B1 | Track `bytes32[] touchedCommitmentKeys` since mappings aren't iterable | +| **Per-claim delta tracking** | A16, C2 | Wrap `claimEthRewards` to capture before/after pool balances | +| **2-actor reward tracking** | A15, C3 | Track accrued rewards for both sender/receiver around cSSV transfers | +| **Merkle tree builder** | B6, B7, B8 | Tiny in-harness Merkle builder for valid proof happy paths | +| **Delta-block simulator** | X4, X5, X6 | Test-only function that applies fee accrual math with explicit `deltaBlocks` input | +| **Per-cluster EB tracking** | C5, C6 | Arrays tracking baseline and deviation per cluster for global sum verification | +| **Max-param configurator** | X4, X5, X6, X7 | Helpers to set operator fee = max, validators = max, EB = max bound | diff --git a/test/helpers/gas-usage.ts b/test/helpers/gas-usage.ts index a00809af..0b06ffb9 100644 --- a/test/helpers/gas-usage.ts +++ b/test/helpers/gas-usage.ts @@ -127,20 +127,20 @@ export enum GasGroup { const MAX_GAS_PER_GROUP: any = { [GasGroup.REGISTER_OPERATOR]: 200000, - [GasGroup.REMOVE_OPERATOR]: 75000, - [GasGroup.REMOVE_OPERATOR_WITH_WITHDRAW]: 74000, - [GasGroup.SET_OPERATOR_WHITELISTING_CONTRACT]: 122500, - [GasGroup.UPDATE_OPERATOR_WHITELISTING_CONTRACT]: 79500, - [GasGroup.SET_OPERATOR_WHITELISTING_CONTRACT_10]: 316000, - [GasGroup.REMOVE_OPERATOR_WHITELISTING_CONTRACT]: 52000, - [GasGroup.REMOVE_OPERATOR_WHITELISTING_CONTRACT_10]: 109500, - [GasGroup.SET_MULTIPLE_OPERATOR_WHITELIST_10_10]: 137000, - [GasGroup.REMOVE_MULTIPLE_OPERATOR_WHITELIST_10_10]: 108000, - [GasGroup.SET_OPERATORS_PRIVATE_10]: 51000, - [GasGroup.SET_OPERATORS_PUBLIC_10]: 29000, + [GasGroup.REMOVE_OPERATOR]: 85000, + [GasGroup.REMOVE_OPERATOR_WITH_WITHDRAW]: 84000, + [GasGroup.SET_OPERATOR_WHITELISTING_CONTRACT]: 135000, + [GasGroup.UPDATE_OPERATOR_WHITELISTING_CONTRACT]: 90000, + [GasGroup.SET_OPERATOR_WHITELISTING_CONTRACT_10]: 354000, + [GasGroup.REMOVE_OPERATOR_WHITELISTING_CONTRACT]: 60000, + [GasGroup.REMOVE_OPERATOR_WHITELISTING_CONTRACT_10]: 133000, + [GasGroup.SET_MULTIPLE_OPERATOR_WHITELIST_10_10]: 166000, + [GasGroup.REMOVE_MULTIPLE_OPERATOR_WHITELIST_10_10]: 135000, + [GasGroup.SET_OPERATORS_PRIVATE_10]: 56000, + [GasGroup.SET_OPERATORS_PUBLIC_10]: 33000, [GasGroup.DECLARE_OPERATOR_FEE]: 76000, - [GasGroup.CANCEL_OPERATOR_FEE]: 38000, + [GasGroup.CANCEL_OPERATOR_FEE]: 42000, [GasGroup.EXECUTE_OPERATOR_FEE]: 61000, [GasGroup.REDUCE_OPERATOR_FEE]: 62000, diff --git a/test/integration/SSVNetwork.test.ts b/test/integration/SSVNetwork.test.ts index a31ac7bd..65f73452 100644 --- a/test/integration/SSVNetwork.test.ts +++ b/test/integration/SSVNetwork.test.ts @@ -2468,13 +2468,21 @@ describe("SSVNetwork full integration tests", () => { const cluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); + const networkAddress = await network.getAddress(); const callerBalanceBefore = await connection.ethers.provider.getBalance(clusterOwner.address); + const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress); - await expect(network.connect(clusterOwner).liquidate(clusterOwner.address, operatorIds, cluster)) - .to.emit(network, Events.CLUSTER_LIQUIDATED); + const tx = await network.connect(clusterOwner).liquidate(clusterOwner.address, operatorIds, cluster); + await expect(tx).to.emit(network, Events.CLUSTER_LIQUIDATED); + const receipt = await tx.wait(); + const gasCost = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice); const callerBalanceAfter = await connection.ethers.provider.getBalance(clusterOwner.address); - expect(callerBalanceAfter).to.be.greaterThan(callerBalanceBefore); + const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress); + + const payout = contractBalanceBefore - contractBalanceAfter; + expect(payout).to.be.greaterThan(0n); + expect(callerBalanceAfter - callerBalanceBefore + gasCost).to.equal(payout); const newClusterState = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); expect(await views.isLiquidated(clusterOwner.address, operatorIds, newClusterState)).to.be.equal(true); @@ -2498,13 +2506,21 @@ describe("SSVNetwork full integration tests", () => { const cluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); await network.updateLiquidationThresholdPeriod(1000000000); + const networkAddress = await network.getAddress(); const callerBalanceBefore = await connection.ethers.provider.getBalance(randomUser.address); + const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress); - await expect(network.connect(randomUser).liquidate(clusterOwner.address, operatorIds, cluster)) - .to.emit(network, Events.CLUSTER_LIQUIDATED); + const tx = await network.connect(randomUser).liquidate(clusterOwner.address, operatorIds, cluster); + await expect(tx).to.emit(network, Events.CLUSTER_LIQUIDATED); + const receipt = await tx.wait(); + const gasCost = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice); const callerBalanceAfter = await connection.ethers.provider.getBalance(randomUser.address); - expect(callerBalanceAfter).to.be.greaterThan(callerBalanceBefore); + const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress); + + const payout = contractBalanceBefore - contractBalanceAfter; + expect(payout).to.be.greaterThan(0n); + expect(callerBalanceAfter - callerBalanceBefore + gasCost).to.equal(payout); const newClusterState = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); expect(await views.isLiquidated(clusterOwner.address, operatorIds, newClusterState)).to.be.equal(true); diff --git a/test/integration/SSVNetwork/clusters.test.ts b/test/integration/SSVNetwork/clusters.test.ts index 40dc0f09..805d09df 100644 --- a/test/integration/SSVNetwork/clusters.test.ts +++ b/test/integration/SSVNetwork/clusters.test.ts @@ -135,9 +135,6 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => { it("liquidate: liquidator receives remaining cluster balance", async function() { const { network, views } = await networkHelpers.loadFixture(deployFullSSVNetworkFixture); - // Use high network fee for faster liquidation - await network.updateNetworkFee(NETWORK_FEE * 100n); - const validatorKey = makePublicKey(1); const operatorIds = await registerOperators(network, operatorOwner, 4); await whitelistAddresses(network, operatorOwner, operatorIds, [clusterOwner.address]); @@ -150,20 +147,16 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => { { value: DEFAULT_ETH_REGISTER_VALUE } ); - // Mine until liquidatable - let currentCluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); - let isLiquidatable = false; - let attempts = 0; - while (!isLiquidatable && attempts < 20) { - await connection.networkHelpers.mine(100000); - isLiquidatable = await views.isLiquidatable(clusterOwner.address, operatorIds, currentCluster); - attempts++; - } + const currentCluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); + + await network.updateMinimumLiquidationCollateral(DEFAULT_ETH_REGISTER_VALUE * 2n); + + const isLiquidatable = await views.isLiquidatable(clusterOwner.address, operatorIds, currentCluster); expect(isLiquidatable).to.be.true; - // Capture balances before liquidation + const networkAddress = await network.getAddress(); const liquidatorBalanceBefore = await connection.ethers.provider.getBalance(liquidator.address); - const contractBalanceBefore = await connection.ethers.provider.getBalance(await network.getAddress()); + const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress); const tx = await network.connect(liquidator).liquidate( clusterOwner.address, @@ -171,19 +164,18 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => { currentCluster ); const receipt = await tx.wait(); - const gasUsed = receipt!.gasUsed * receipt!.gasPrice; + const gasUsed = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice); const liquidatorBalanceAfter = await connection.ethers.provider.getBalance(liquidator.address); - const contractBalanceAfter = await connection.ethers.provider.getBalance(await network.getAddress()); + const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress); - // Liquidator should receive remaining cluster balance (contract balance decreased) + const payout = contractBalanceBefore - contractBalanceAfter; const liquidatorGain = liquidatorBalanceAfter + gasUsed - liquidatorBalanceBefore; - expect(liquidatorGain).to.be.greaterThanOrEqual(0n); - - // Contract balance should have decreased (funds went to liquidator) - expect(contractBalanceBefore).to.be.greaterThanOrEqual(contractBalanceAfter); - // Cluster is now liquidated + expect(payout).to.be.greaterThan(0n); + + expect(liquidatorGain).to.equal(payout); + const clusterAfter = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); expect(clusterAfter.active).to.equal(false); expect(await views.isLiquidated(clusterOwner.address, operatorIds, clusterAfter)).to.equal(true); @@ -287,6 +279,38 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => { // Burn rate should double with 2 validators expect(burnRateWith2Validators).to.equal(burnRateWith1Validator * 2n); }); + + it("removeValidator settles exact fee deduction from cluster balance", async function() { + const { network, views } = await networkHelpers.loadFixture(deployFullSSVNetworkFixture); + + const validatorKey = makePublicKey(1); + const operatorIds = await registerOperators(network, operatorOwner, 4); + await whitelistAddresses(network, operatorOwner, operatorIds, [clusterOwner.address]); + + await network.connect(clusterOwner).registerValidator( + validatorKey, + operatorIds, + DEFAULT_SHARES, + EMPTY_CLUSTER, + { value: DEFAULT_ETH_REGISTER_VALUE } + ); + + const clusterAfterReg = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); + const burnRatePerBlock = (MINIMAL_OPERATOR_ETH_FEE * 4n) + NETWORK_FEE; + const blocksToMine = 100n; + + await connection.networkHelpers.mine(blocksToMine); + + await network.connect(clusterOwner).removeValidator(validatorKey, operatorIds, clusterAfterReg); + const clusterAfterRemove = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); + + const totalFeeDeducted = (blocksToMine + 1n) * burnRatePerBlock; + const expectedBalance = DEFAULT_ETH_REGISTER_VALUE - totalFeeDeducted; + + const remainingBalance = await views.getBalance(clusterOwner.address, operatorIds, clusterAfterRemove); + expect(remainingBalance).to.equal(expectedBalance); + expect(clusterAfterRemove.validatorCount).to.equal(0n); + }); }); // ============================================================================ @@ -439,17 +463,28 @@ describe("SSVNetwork Integration - Clusters (Enhanced)", () => { const currentCluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); - // Cluster is not liquidatable by others const isLiquidatable = await views.isLiquidatable(clusterOwner.address, operatorIds, currentCluster); expect(isLiquidatable).to.equal(false); - // But owner can self-liquidate + const networkAddress = await network.getAddress(); + const ownerBalanceBefore = await connection.ethers.provider.getBalance(clusterOwner.address); + const contractBalanceBefore = await connection.ethers.provider.getBalance(networkAddress); + const tx = await network.connect(clusterOwner).liquidate( clusterOwner.address, operatorIds, currentCluster ); await expect(tx).to.emit(network, Events.CLUSTER_LIQUIDATED); + const receipt = await tx.wait(); + const gasCost = receipt!.gasUsed * (receipt!.effectiveGasPrice ?? receipt!.gasPrice); + + const ownerBalanceAfter = await connection.ethers.provider.getBalance(clusterOwner.address); + const contractBalanceAfter = await connection.ethers.provider.getBalance(networkAddress); + + const payout = contractBalanceBefore - contractBalanceAfter; + expect(payout).to.be.greaterThan(0n); + expect(ownerBalanceAfter - ownerBalanceBefore + gasCost).to.equal(payout); const clusterAfter = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); expect(clusterAfter.active).to.equal(false); diff --git a/test/integration/SSVNetwork/operators.test.ts b/test/integration/SSVNetwork/operators.test.ts index 931307a7..d987b9a5 100644 --- a/test/integration/SSVNetwork/operators.test.ts +++ b/test/integration/SSVNetwork/operators.test.ts @@ -367,6 +367,36 @@ describe("SSVNetwork Integration - Operators (Enhanced)", () => { expect(actualEarnings).to.equal(expectedTotal, "Earnings should scale with validator count"); }); + + it("removeValidator triggers exact settlement of operator earnings", async function() { + const { network, views } = await networkHelpers.loadFixture(deployFullSSVNetworkFixture); + + const validatorKey = makePublicKey(1); + const operatorIds = await registerOperators(network, operatorOwner, 4); + await whitelistAddresses(network, operatorOwner, operatorIds, [clusterOwner.address]); + + await network.connect(clusterOwner).registerValidator( + validatorKey, + operatorIds, + DEFAULT_SHARES, + EMPTY_CLUSTER, + { value: DEFAULT_ETH_REGISTER_VALUE } + ); + + const blocksToMine = 100n; + await connection.networkHelpers.mine(blocksToMine); + + const currentCluster = await getCurrentClusterState(connection, network, clusterOwner.address, operatorIds); + await network.connect(clusterOwner).removeValidator(validatorKey, operatorIds, currentCluster); + + // Fee settled over blocksToMine + 1 block (the removeValidator tx itself) + const expectedEarningsPerOperator = (blocksToMine + 1n) * MINIMAL_OPERATOR_ETH_FEE; + + for (const opId of operatorIds) { + const earnings = await views.getOperatorEarnings(opId); + expect(earnings).to.equal(expectedEarningsPerOperator); + } + }); }); // ============================================================================ diff --git a/test/unit/SSVClusters/liquidate.test.ts b/test/unit/SSVClusters/liquidate.test.ts index 5cf7fb66..729fe5e9 100644 --- a/test/unit/SSVClusters/liquidate.test.ts +++ b/test/unit/SSVClusters/liquidate.test.ts @@ -107,12 +107,65 @@ describe("SSVClusters function `liquidate()`", async () => { const harnessBalanceAfter = await connection.ethers.provider.getBalance(harnessAddress); const payout = harnessBalanceBefore - harnessBalanceAfter; - expect(payout).to.be.greaterThan(0n); + + expect(payout).to.equal(DEFAULT_ETH_REGISTER_VALUE); const gasCost = receipt!.gasUsed * (receipt.effectiveGasPrice ?? receipt!.gasPrice); expect(liquidatorBalanceAfter - liquidatorBalanceBefore + BigInt(gasCost)).to.equal(payout); }); + it("Transfers no ETH when cluster remaining balance is zero after fee accrual", async function () { + const { clusters, operatorIds } = + await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture); + + const registerTx = await clusters.registerValidator( + makePublicKey(1), + operatorIds, + DEFAULT_SHARES, + createCluster(), + { value: DEFAULT_ETH_REGISTER_VALUE } + ); + const registerReceipt = await registerTx.wait(); + const clusterAfterRegister = parseClusterFromEvent(clusters, registerReceipt, Events.VALIDATOR_ADDED); + + const drainFeeIndex = DEFAULT_ETH_REGISTER_VALUE / ETH_DEDUCTED_DIGITS; + await clusters.mockCurrentNetworkFeeIndex(drainFeeIndex); + + const harnessAddress = await clusters.getAddress(); + const harnessEthBefore = await connection.ethers.provider.getBalance(harnessAddress); + + await clusters.liquidate(clusterOwner.address, operatorIds, clusterAfterRegister); + + const harnessEthAfter = await connection.ethers.provider.getBalance(harnessAddress); + + expect(harnessEthAfter).to.equal(harnessEthBefore); + }); + + it("Self-liquidation returns remaining ETH balance to the cluster owner", async function () { + const { clusters, operatorIds } = + await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture); + + const registerTx = await clusters.registerValidator( + makePublicKey(1), + operatorIds, + DEFAULT_SHARES, + createCluster(), + { value: DEFAULT_ETH_REGISTER_VALUE } + ); + const registerReceipt = await registerTx.wait(); + const clusterAfterRegister = parseClusterFromEvent(clusters, registerReceipt, Events.VALIDATOR_ADDED); + + const ownerEthBefore = await connection.ethers.provider.getBalance(clusterOwner.address); + + const tx = await clusters.liquidate(clusterOwner.address, operatorIds, clusterAfterRegister); + const receipt: any = await tx.wait(); + + const ownerEthAfter = await connection.ethers.provider.getBalance(clusterOwner.address); + const gasCost = receipt!.gasUsed * (receipt.effectiveGasPrice ?? receipt!.gasPrice); + + expect(ownerEthAfter - ownerEthBefore + BigInt(gasCost)).to.equal(DEFAULT_ETH_REGISTER_VALUE); + }); + it("Updates operatorEthVUnits on liquidation even when cluster EB snapshot is not set", async function () { const { clusters, operatorIds } = await networkHelpers.loadFixture(deploySSVClustersAndPrepareOperatorsFixture); diff --git a/test/unit/SSVClusters/liquidateSSV.test.ts b/test/unit/SSVClusters/liquidateSSV.test.ts index e7a037f1..a4c0691d 100644 --- a/test/unit/SSVClusters/liquidateSSV.test.ts +++ b/test/unit/SSVClusters/liquidateSSV.test.ts @@ -140,6 +140,49 @@ describe("SSVClusters function `liquidateSSV()`", async () => { expect(harnessBalanceBefore - harnessBalanceAfter).to.equal(clusterBalance); }); + it("Transfers no SSV when cluster remaining balance is zero after fee accrual", async function () { + const { clusters, operatorIds, mockToken } = + await networkHelpers.loadFixture(deploySSVClustersFixture); + + const publicKey = makePublicKey(1); + const clusterBalance = 1_000_000_000n; + const cluster = createSSVClusterWithTokenBalance(clusterBalance); + + await clusters.mockRegisterSSVValidator(publicKey, operatorIds, clusterOwner.address, cluster); + await clusters.mockCurrentNetworkFeeIndex(100n); + await clusters.mockCurrentNetworkFeeIndexSSV(clusterBalance); + + const liquidatorTokenBefore = await mockToken.balanceOf(clusterOwner.address); + + await clusters.liquidateSSV(clusterOwner.address, operatorIds, cluster); + + const liquidatorTokenAfter = await mockToken.balanceOf(clusterOwner.address); + + expect(liquidatorTokenAfter).to.equal(liquidatorTokenBefore); + }); + + it("SSV self-liquidation returns remaining SSV balance to the cluster owner", async function () { + const { clusters, operatorIds, mockToken } = + await networkHelpers.loadFixture(deploySSVClustersFixture); + + const publicKey = makePublicKey(1); + const clusterBalance = connection.ethers.parseEther("1"); + const currentSSVFeeIndex = 2000n; + const cluster = createSSVClusterWithTokenBalance(clusterBalance, { networkFeeIndex: currentSSVFeeIndex }); + + await clusters.mockRegisterSSVValidator(publicKey, operatorIds, clusterOwner.address, cluster); + await clusters.mockCurrentNetworkFeeIndex(100n); + await clusters.mockCurrentNetworkFeeIndexSSV(currentSSVFeeIndex); + + const ownerTokenBefore = await mockToken.balanceOf(clusterOwner.address); + + await clusters.liquidateSSV(clusterOwner.address, operatorIds, cluster); + + const ownerTokenAfter = await mockToken.balanceOf(clusterOwner.address); + + expect(ownerTokenAfter - ownerTokenBefore).to.equal(clusterBalance); + }); + it("Does not change operatorEthVUnits or stored cluster EB snapshot when liquidating an SSV cluster", async function () { const { clusters, operatorIds } = await networkHelpers.loadFixture(deploySSVClustersFixture); diff --git a/test/unit/SSVStaking/requestUnstake.test.ts b/test/unit/SSVStaking/requestUnstake.test.ts index 06b5bdce..68943daf 100644 --- a/test/unit/SSVStaking/requestUnstake.test.ts +++ b/test/unit/SSVStaking/requestUnstake.test.ts @@ -187,6 +187,27 @@ describe("SSVStaking function `requestUnstake()`", async () => { expect(cssvBalance).to.equal(STAKE_AMOUNT - firstAmount - secondAmount); }); + it("Uses block.timestamp (seconds) for unlockTime, not block.number", async function () { + const { staking } = await networkHelpers.loadFixture(stakeFirst); + + const unstakeAmount = STAKE_AMOUNT / 2n; + const receipt = await trackGas( + staking.requestUnstake(unstakeAmount), + [GasGroup.REQUEST_UNSTAKE] + ); + + const block = await connection.ethers.provider.getBlock(receipt.blockNumber); + const [, unlockTime] = await staking.getWithdrawalRequest(staker.address, 0); + + // unlockTime must equal block.timestamp + cooldown (seconds-based) + const expectedFromTimestamp = BigInt(block!.timestamp) + DEFAULT_UNSTAKE_COOLDOWN; + expect(unlockTime).to.equal(expectedFromTimestamp); + + // unlockTime must NOT equal block.number + cooldown (blocks-based) + const incorrectFromBlockNumber = BigInt(block!.number) + DEFAULT_UNSTAKE_COOLDOWN; + expect(unlockTime).to.not.equal(incorrectFromBlockNumber); + }); + it("Settles pending rewards before unstaking when fees have accrued", async function () { const { staking, cssvToken } = await networkHelpers.loadFixture(stakeFirst); diff --git a/test/unit/SSVValidator/feeSettlement.test.ts b/test/unit/SSVValidator/feeSettlement.test.ts new file mode 100644 index 00000000..80ac5af8 --- /dev/null +++ b/test/unit/SSVValidator/feeSettlement.test.ts @@ -0,0 +1,221 @@ +import { expect } from "chai"; +import type { NetworkConnection } from "hardhat/types/network"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; +import { getTestConnection } from "../../setup/connection.ts"; +import { ssvValidatorsHarnessFixture } from "../../setup/fixtures.ts"; +import { deployHarnessModule } from "../../setup/deploy.ts"; +import { SSVModules } from "../../common/types.ts"; +import type { NetworkHelpersType } from "../../common/types.ts"; +import { makePublicKey, makePublicKeys, makeOperatorKey, createCluster, parseClusterFromEvent } from "../../common/helpers.ts"; +import { + DEFAULT_SHARES, + ETH_DEDUCTED_DIGITS, +} from "../../common/constants.ts"; +import { Events } from "../../common/events.ts"; +import { ethers } from "ethers"; + +const OPERATOR_FEE = 10_000_000_000n; +const DIFFERENT_FEES = [2_000_000_000n, 4_000_000_000n, 6_000_000_000n, 8_000_000_000n]; + +describe("Validator register/remove with non-zero ETH operator fees", async () => { + let connection: NetworkConnection<"generic">; + let networkHelpers: NetworkHelpersType; + + before(async function () { + ({ connection, networkHelpers } = await getTestConnection()); + }); + + const deployValidatorsWithFee = async () => { + return ssvValidatorsHarnessFixture(connection, 4, OPERATOR_FEE); + }; + + const deployValidatorsWithDifferentFees = async () => { + const validators = await deployHarnessModule(connection, SSVModules.SSVValidators); + await validators.waitForDeployment(); + await validators.mockValidatorsPerOperatorLimit(3000); + + const [owner] = await connection.ethers.getSigners(); + const operatorIds: bigint[] = []; + + for (let i = 0; i < DIFFERENT_FEES.length; i++) { + const id = await validators.mockOperator.staticCall( + makeOperatorKey(i), owner.address, DIFFERENT_FEES[i], false + ); + await validators.mockOperator(makeOperatorKey(i), owner.address, DIFFERENT_FEES[i], false); + operatorIds.push(id); + } + + return { validators, operatorIds }; + }; + + it("registers with 4 operators at different fees and deducts sum(fees) * blocksDelta from cluster balance", async function () { + const { validators, operatorIds } = await networkHelpers.loadFixture(deployValidatorsWithDifferentFees); + + const depositValue = ethers.parseEther("100"); + const regTx1 = await validators.registerValidator( + makePublicKey(1), + operatorIds, + DEFAULT_SHARES, + createCluster(), + { value: depositValue } + ); + const receipt1 = await regTx1.wait(); + const cluster1 = parseClusterFromEvent(validators, receipt1, Events.VALIDATOR_ADDED); + + const blockBeforeMine = await connection.ethers.provider.getBlockNumber(); + await networkHelpers.mine(50); + const blocksMined = (await connection.ethers.provider.getBlockNumber()) - blockBeforeMine; + + const regTx2 = await validators.registerValidator( + makePublicKey(2), + operatorIds, + DEFAULT_SHARES, + cluster1, + { value: 0n } + ); + const receipt2 = await regTx2.wait(); + const cluster2 = parseClusterFromEvent(validators, receipt2, Events.VALIDATOR_ADDED); + + const feeDeducted = cluster1.balance - cluster2.balance; + + // fee = sum(packedFees) * blocksDelta * validatorCount(1 baseline) * ETH_DEDUCTED_DIGITS + const sumPackedFees = DIFFERENT_FEES.reduce((acc, fee) => acc + fee / ETH_DEDUCTED_DIGITS, 0n); + const blocksDelta = BigInt(blocksMined + 1); + const expected = sumPackedFees * blocksDelta * 1n * ETH_DEDUCTED_DIGITS; + + expect(feeDeducted).to.equal(expected); + }); + + it("second registration after N blocks settles val1 fees; burn rate doubles when val2 is active", async function () { + const { validators, operatorIds } = await networkHelpers.loadFixture(deployValidatorsWithFee); + + const depositValue = ethers.parseEther("100"); + const regTx1 = await validators.registerValidator( + makePublicKey(1), + operatorIds, + DEFAULT_SHARES, + createCluster(), + { value: depositValue } + ); + const receipt1 = await regTx1.wait(); + const cluster1 = parseClusterFromEvent(validators, receipt1, Events.VALIDATOR_ADDED); + + const blockBefore1 = await connection.ethers.provider.getBlockNumber(); + await networkHelpers.mine(100); + const blocksMined1 = (await connection.ethers.provider.getBlockNumber()) - blockBefore1; + + const regTx2 = await validators.registerValidator( + makePublicKey(2), + operatorIds, + DEFAULT_SHARES, + cluster1, + { value: 0n } + ); + const receipt2 = await regTx2.wait(); + const cluster2 = parseClusterFromEvent(validators, receipt2, Events.VALIDATOR_ADDED); + + const packedFee = OPERATOR_FEE / ETH_DEDUCTED_DIGITS; + const blocksDelta1 = BigInt(blocksMined1 + 1); + const expectedFee1 = 4n * packedFee * blocksDelta1 * ETH_DEDUCTED_DIGITS; + + expect(cluster1.balance - cluster2.balance).to.equal(expectedFee1); + + const blockBefore2 = await connection.ethers.provider.getBlockNumber(); + await networkHelpers.mine(100); + const blocksMined2 = (await connection.ethers.provider.getBlockNumber()) - blockBefore2; + + const regTx3 = await validators.registerValidator( + makePublicKey(3), + operatorIds, + DEFAULT_SHARES, + cluster2, + { value: 0n } + ); + const receipt3 = await regTx3.wait(); + const cluster3 = parseClusterFromEvent(validators, receipt3, Events.VALIDATOR_ADDED); + + const blocksDelta2 = BigInt(blocksMined2 + 1); + const expectedFee2 = 4n * packedFee * blocksDelta2 * 2n * ETH_DEDUCTED_DIGITS; + + expect(cluster2.balance - cluster3.balance).to.equal(expectedFee2); + }); + + it("removeValidator settles accumulated fees and operator snapshot balance matches expected earnings", async function () { + const { validators, operatorIds } = await networkHelpers.loadFixture(deployValidatorsWithFee); + + const depositValue = ethers.parseEther("100"); + const regTx = await validators.registerValidator( + makePublicKey(1), + operatorIds, + DEFAULT_SHARES, + createCluster(), + { value: depositValue } + ); + const receipt = await regTx.wait(); + const clusterAfterReg = parseClusterFromEvent(validators, receipt, Events.VALIDATOR_ADDED); + + const blockBeforeMine = await connection.ethers.provider.getBlockNumber(); + await networkHelpers.mine(100); + const blocksMined = (await connection.ethers.provider.getBlockNumber()) - blockBeforeMine; + + const removeTx = await validators.removeValidator( + makePublicKey(1), + operatorIds, + clusterAfterReg + ); + const removeReceipt = await removeTx.wait(); + const clusterAfterRemove = parseClusterFromEvent(validators, removeReceipt, Events.VALIDATOR_REMOVED); + + const packedFee = OPERATOR_FEE / ETH_DEDUCTED_DIGITS; + const blocksDelta = BigInt(blocksMined + 1); + const expectedFee = 4n * packedFee * blocksDelta * 1n * ETH_DEDUCTED_DIGITS; + + expect(clusterAfterRemove.validatorCount).to.equal(0n); + expect(clusterAfterReg.balance - clusterAfterRemove.balance).to.equal(expectedFee); + + for (const operatorId of operatorIds) { + const [, , balance] = await validators.getOperatorEthSnapshot(operatorId); + expect(balance).to.equal(packedFee * blocksDelta); + } + }); + + it("bulkRegisterValidator deducts fees proportional to bulk validator count", async function () { + const { validators, operatorIds } = await networkHelpers.loadFixture(deployValidatorsWithFee); + + const publicKeys = makePublicKeys(10); + const shares = Array(10).fill(DEFAULT_SHARES); + + const depositValue = ethers.parseEther("100"); + const bulkTx = await validators.bulkRegisterValidator( + publicKeys, + operatorIds, + shares, + createCluster(), + { value: depositValue } + ); + const bulkReceipt = await bulkTx.wait(); + const clusterAfterBulk = parseClusterFromEvent(validators, bulkReceipt, Events.VALIDATOR_ADDED); + + expect(clusterAfterBulk.validatorCount).to.equal(10n); + + const blockBeforeMine = await connection.ethers.provider.getBlockNumber(); + await networkHelpers.mine(50); + const blocksMined = (await connection.ethers.provider.getBlockNumber()) - blockBeforeMine; + + const settleTx = await validators.registerValidator( + makePublicKey(11), + operatorIds, + DEFAULT_SHARES, + clusterAfterBulk, + { value: 0n } + ); + const settleReceipt = await settleTx.wait(); + const clusterAfterSettle = parseClusterFromEvent(validators, settleReceipt, Events.VALIDATOR_ADDED); + + const packedFee = OPERATOR_FEE / ETH_DEDUCTED_DIGITS; + const blocksDelta = BigInt(blocksMined + 1); + const expectedFee = 4n * packedFee * blocksDelta * 10n * ETH_DEDUCTED_DIGITS; + + expect(clusterAfterBulk.balance - clusterAfterSettle.balance).to.equal(expectedFee); + }); +}); diff --git a/test/unit/mainnet-config-validation.test.ts b/test/unit/mainnet-config-validation.test.ts new file mode 100644 index 00000000..c8e67e5a --- /dev/null +++ b/test/unit/mainnet-config-validation.test.ts @@ -0,0 +1,685 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect } from "chai"; +import type { NetworkConnection } from "hardhat/types/network"; +import type { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/types"; +import { getTestConnection } from "../setup/connection.ts"; +import { + ssvClustersHarnessFixture, + ssvDAOHarnessFixture, + ssvOperatorsHarnessFixture, + ssvStakingHarnessFixture, +} from "../setup/fixtures.ts"; +import type { NetworkHelpersType } from "../common/types.ts"; +import { makePublicKey, makeOperatorKey, parseClusterFromEvent } from "../common/helpers.ts"; +import { + DEFAULT_SHARES, + ETH_DEDUCTED_DIGITS, + MINIMAL_LIQUIDATION_THRESHOLD, + STAKE_AMOUNT, EMPTY_CLUSTER, +} from '../common/constants.ts'; +import { Events } from "../common/events.ts"; +import { Errors } from "../common/errors.ts"; +import { ethers } from "ethers"; + +/** + * Uses exact mainnet deployment parameters (from deployments/params-candidate.json) + * to validate system behavior at the boundaries implied by those values. + * + * To propose new governance parameters: edit deployments/params-candidate.json and re-run + * the test suite. No test source changes are needed unless burn-rate assertions must be updated. + * + * Deployment Config (exact on-chain values — all fees are already packable): + * | Param | Value | Raw | + * |--------------------------------|------------------------------------|-------------------| + * | networkFeeEth | 3,550,900,000 wei/block | 3,550,900,000 | + * | minimumLiquidationCollateralEth| 940,000,000,000,000 wei (0.00094) | 940,000,000,000,000| + * | liquidationThresholdPeriod | 35,800 blocks (~5 days) | 35,800 | + * | minOperatorEthFee | 1,065,200,000 wei/block | 1,065,200,000 | + * | maxOperatorEthFee | 5,326,300,000 wei/block | 5,326,300,000 | + * | defaultOperatorEthFee | 1,770,000,000 wei/block | 1,770,000,000 | + * | quorumBps | 75% | 7,500 | + * | cooldownDuration | 604,800 seconds (7 days) | 604,800 | + * + */ + +type ParamsCandidateJson = { + networkFeeEth: string; + minimumLiquidationCollateralEth: string; + liquidationThresholdPeriod: string; + minOperatorEthFee: string; + maxOperatorEthFee: string; + defaultOperatorEthFee: string; + quorumBps: number; + cooldownDuration: number; + defaultOracleIds: number[]; +}; + +const _raw = JSON.parse( + readFileSync(resolve(process.cwd(), "deployments/params-candidate.json"), "utf8") +) as ParamsCandidateJson; + +const CONFIG = { + networkFeeEth: BigInt(_raw.networkFeeEth), + minimumLiquidationCollateralEth: BigInt(_raw.minimumLiquidationCollateralEth), + liquidationThresholdPeriod: BigInt(_raw.liquidationThresholdPeriod), + minOperatorEthFee: BigInt(_raw.minOperatorEthFee), + maxOperatorEthFee: BigInt(_raw.maxOperatorEthFee), + defaultOperatorEthFee: BigInt(_raw.defaultOperatorEthFee), + quorumBps: BigInt(_raw.quorumBps), + cooldownDuration: BigInt(_raw.cooldownDuration), + defaultOracleIds: _raw.defaultOracleIds, +}; + +// Original values (raw wei, some NOT packable). Kept for the packability documentation test. +const RAW_VALUES = { + ethNetworkFee: 3_550_929_823n, + operatorMinFee: 1_065_278_947n, + operatorMaxFee: 5_326_394_735n, + defaultOperatorETHFee: 1_775_464_912n, + minimumLiquidationCollateral: 940_000_000_000_000n, +}; + +describe("Mainnet Governance Config Validation", async () => { + let connection: NetworkConnection<"generic">; + let networkHelpers: NetworkHelpersType; + + before(async function () { + ({ connection, networkHelpers } = await getTestConnection()); + }); + + describe("Config file (deployments/params-candidate.json)", () => { + const CONFIG_PATH = resolve(process.cwd(), "deployments/params-candidate.json"); + + it("exists and is readable from process.cwd()", () => { + expect(existsSync(CONFIG_PATH), `File not found: ${CONFIG_PATH}`).to.be.true; + }); + + it("contains all required fields", () => { + const required: (keyof ParamsCandidateJson)[] = [ + "networkFeeEth", + "minimumLiquidationCollateralEth", + "liquidationThresholdPeriod", + "minOperatorEthFee", + "maxOperatorEthFee", + "defaultOperatorEthFee", + "quorumBps", + "cooldownDuration", + "defaultOracleIds", + ]; + for (const field of required) { + expect(_raw[field], `Missing field: ${field}`).to.not.be.undefined; + } + }); + + it("fee fields are non-negative integer strings", () => { + const stringFields: (keyof ParamsCandidateJson)[] = [ + "networkFeeEth", + "minimumLiquidationCollateralEth", + "liquidationThresholdPeriod", + "minOperatorEthFee", + "maxOperatorEthFee", + "defaultOperatorEthFee", + ]; + for (const field of stringFields) { + const value = _raw[field]; + expect(typeof value, `${field} must be a string`).to.equal("string"); + expect(/^\d+$/.test(value as string)).to.be.true; + } + }); + + it("quorumBps is an integer in [1, 10000]", () => { + expect(Number.isInteger(_raw.quorumBps)).to.be.true; + expect(_raw.quorumBps).to.be.greaterThanOrEqual(1); + expect(_raw.quorumBps).to.be.lessThanOrEqual(10_000); + }); + + it("cooldownDuration is a positive integer", () => { + expect(Number.isInteger(_raw.cooldownDuration)).to.be.true; + expect(_raw.cooldownDuration).to.be.greaterThan(0); + }); + + it("defaultOracleIds is an array of 4 distinct valid oracle ids", () => { + expect(Array.isArray(_raw.defaultOracleIds)).to.be.true; + expect(_raw.defaultOracleIds.length).to.equal(4); + for (const id of _raw.defaultOracleIds) { + expect(Number.isInteger(id) && id > 0 && id <= 0xffffffff).to.be.true; + } + const unique = new Set(_raw.defaultOracleIds); + expect(unique.size).to.equal(4); + }); + + it("minOperatorEthFee <= defaultOperatorEthFee <= maxOperatorEthFee", () => { + const min = BigInt(_raw.minOperatorEthFee); + const def = BigInt(_raw.defaultOperatorEthFee); + const max = BigInt(_raw.maxOperatorEthFee); + expect(min <= def).to.be.true; + expect(def <= max).to.be.true; + }); + }); + + describe("Packability", () => { + let harness: any; + + const deployPackedLibFixture = async () => { + const contract = await connection.ethers.deployContract("PackedLibHarness"); + await contract.waitForDeployment(); + return { harness: contract }; + }; + + it("Confirms raw mainnet values are not packable (remainder ≠ 0 mod 100,000)", async function () { + // ethNetworkFee: 3,550,929,823 % 100,000 = 29,823 → NOT packable + expect(RAW_VALUES.ethNetworkFee % ETH_DEDUCTED_DIGITS).to.equal(29_823n); + // operatorMinFee: 1,065,278,947 % 100,000 = 78,947 → NOT packable + expect(RAW_VALUES.operatorMinFee % ETH_DEDUCTED_DIGITS).to.equal(78_947n); + // operatorMaxFee: 5,326,394,735 % 100,000 = 94,735 → NOT packable + expect(RAW_VALUES.operatorMaxFee % ETH_DEDUCTED_DIGITS).to.equal(94_735n); + // defaultOperatorETHFee: 1,775,464,912 % 100,000 = 64,912 → NOT packable + expect(RAW_VALUES.defaultOperatorETHFee % ETH_DEDUCTED_DIGITS).to.equal(64_912n); + }); + + it("Confirms all deployment config values are packable (divisible by 100,000)", async function () { + expect(CONFIG.networkFeeEth % ETH_DEDUCTED_DIGITS).to.equal(0n); + expect(CONFIG.minimumLiquidationCollateralEth % ETH_DEDUCTED_DIGITS).to.equal(0n); + expect(CONFIG.minOperatorEthFee % ETH_DEDUCTED_DIGITS).to.equal(0n); + expect(CONFIG.maxOperatorEthFee % ETH_DEDUCTED_DIGITS).to.equal(0n); + expect(CONFIG.defaultOperatorEthFee % ETH_DEDUCTED_DIGITS).to.equal(0n); + }); + + it("All packable config values survive pack/unpack round-trip", async function () { + ({ harness } = await networkHelpers.loadFixture(deployPackedLibFixture)); + + const packableValues: Record = { + networkFeeEth: CONFIG.networkFeeEth, + minimumLiquidationCollateralEth: CONFIG.minimumLiquidationCollateralEth, + minOperatorEthFee: CONFIG.minOperatorEthFee, + maxOperatorEthFee: CONFIG.maxOperatorEthFee, + packableDefaultOpFee: CONFIG.defaultOperatorEthFee, + }; + + for (const [key, value] of Object.entries(packableValues)) { + const packed = await harness.ethPack(value); + const unpacked = await harness.ethUnpack(packed); + expect(unpacked).to.equal(value, `${key}: pack/unpack round-trip failed`); + } + }); + + it("Is reverted with MaxPrecisionExceeded when packing a non-packable value", async function () { + ({ harness } = await networkHelpers.loadFixture(deployPackedLibFixture)); + + const nonPackable = [ + RAW_VALUES.ethNetworkFee, + RAW_VALUES.operatorMinFee, + RAW_VALUES.operatorMaxFee, + RAW_VALUES.defaultOperatorETHFee, + ]; + + for (const value of nonPackable) { + await expect(harness.ethPack(value)) + .to.be.revertedWithCustomError(harness, Errors.MAX_PRECISION_EXCEEDED); + } + }); + + it("Packs minimumLiquidationCollateralEth (940,000,000,000,000) without precision loss", async function () { + ({ harness } = await networkHelpers.loadFixture(deployPackedLibFixture)); + + const packed = await harness.ethPack(CONFIG.minimumLiquidationCollateralEth); + const unpacked = await harness.ethUnpack(packed); + expect(unpacked).to.equal(CONFIG.minimumLiquidationCollateralEth); + }); + }); + + + describe("Liquidation threshold math", () => { + const deployClustersFixture = async () => { + const result = await ssvClustersHarnessFixture(connection, 4, 0n); + const clusters = result.clusters; + + await clusters.mockMinimumBlocksBeforeLiquidation(CONFIG.liquidationThresholdPeriod); + await clusters.mockMinimumLiquidationCollateral( + CONFIG.minimumLiquidationCollateralEth / ETH_DEDUCTED_DIGITS + ); + + return result; + }; + + it("liquidationThresholdPeriod (35,800) is above the system minimum (21,480 blocks)", async function () { + expect(CONFIG.liquidationThresholdPeriod).to.be.greaterThanOrEqual(MINIMAL_LIQUIDATION_THRESHOLD); + }); + + it("Liquidation threshold is dominated by minimumLiquidationCollateral floor", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployClustersFixture); + const [owner, liquidator] = await connection.ethers.getSigners(); + + // Per-operator packed fee = 1,770,000,000 / 100,000 = 17,700 + // Total operator fee (packed, per validator) = 4 × 17,700 = 70,800 + // Network fee (packed) = 3,550,900,000 / 100,000 = 35,509 + // Burn rate per validator per block (packed) = 70,800 + 35,509 = 106,309 + // + // Liquidation threshold (wei) = 35,800 × 106,309 × 100,000 = 380,586,220,000,000 + // minimumLiquidationCollateral = 940,000,000,000,000 > threshold + // → the collateral floor dominates + + const perOperatorPacked = CONFIG.defaultOperatorEthFee / ETH_DEDUCTED_DIGITS; + const totalOperatorFeePacked = perOperatorPacked * 4n; + const networkFeePacked = CONFIG.networkFeeEth / ETH_DEDUCTED_DIGITS; + const burnRatePacked = totalOperatorFeePacked + networkFeePacked; + const thresholdPacked = CONFIG.liquidationThresholdPeriod * burnRatePacked; + const thresholdWei = thresholdPacked * ETH_DEDUCTED_DIGITS; + + expect(perOperatorPacked).to.equal(17_700n); + expect(totalOperatorFeePacked).to.equal(70_800n); + expect(networkFeePacked).to.equal(35_509n); + expect(burnRatePacked).to.equal(106_309n); + expect(thresholdWei).to.equal(380_586_220_000_000n); + + expect(CONFIG.minimumLiquidationCollateralEth).to.be.greaterThan(thresholdWei); + + const largeDeposit = CONFIG.minimumLiquidationCollateralEth * 3n; + const registerTx = await clusters.registerValidator( + makePublicKey(1), + operatorIds, + DEFAULT_SHARES, + EMPTY_CLUSTER, + { value: largeDeposit } + ); + const receipt = await registerTx.wait(); + const cluster = parseClusterFromEvent(clusters, receipt, Events.VALIDATOR_ADDED); + + await expect( + clusters.connect(liquidator).liquidate(owner.address, operatorIds, cluster) + ).to.be.revertedWithCustomError(clusters, Errors.CLUSTER_NOT_LIQUIDATABLE); + + const toConsume = largeDeposit - CONFIG.minimumLiquidationCollateralEth; + const netFeeIndexDelta = toConsume / ETH_DEDUCTED_DIGITS; + await clusters.mockCurrentNetworkFeeIndex(netFeeIndexDelta); + + await expect( + clusters.connect(liquidator).liquidate(owner.address, operatorIds, cluster) + ).to.be.revertedWithCustomError(clusters, Errors.CLUSTER_NOT_LIQUIDATABLE); + + await clusters.mockCurrentNetworkFeeIndex(netFeeIndexDelta + 1n); + + const liquidateTx = await clusters.connect(liquidator).liquidate( + owner.address, operatorIds, cluster + ); + const liquidateReceipt = await liquidateTx.wait(); + const liquidatedCluster = parseClusterFromEvent( + clusters, liquidateReceipt, Events.CLUSTER_LIQUIDATED + ); + expect(liquidatedCluster.active).to.equal(false); + }); + }); + + describe("Operator fee boundaries", () => { + const deployOperatorsFixture = async () => { + return ssvOperatorsHarnessFixture( + connection, + CONFIG.maxOperatorEthFee, // max fee + 604_800n, // declare period (7 days) + 604_800n, // execute period (7 days) + 10_000n // max increase 100% + ); + }; + + it("defaultOperatorEthFee (1,770,000,000) is within [minOperatorEthFee, maxOperatorEthFee]", async function () { + expect(CONFIG.defaultOperatorEthFee).to.be.greaterThanOrEqual(CONFIG.minOperatorEthFee); + expect(CONFIG.defaultOperatorEthFee).to.be.lessThanOrEqual(CONFIG.maxOperatorEthFee); + }); + + it("Accepts operator fee at minOperatorEthFee (1,065,200,000)", async function () { + const { operators } = await networkHelpers.loadFixture(deployOperatorsFixture); + await operators.mockSetMinimumOperatorEthFee(CONFIG.minOperatorEthFee); + + await expect( + operators.registerOperator(makeOperatorKey(1), Number(CONFIG.minOperatorEthFee), false) + ).to.emit(operators, Events.OPERATOR_ADDED); + }); + + it("Accepts operator fee at maxOperatorEthFee (5,326,300,000)", async function () { + const { operators } = await networkHelpers.loadFixture(deployOperatorsFixture); + await operators.mockSetMinimumOperatorEthFee(CONFIG.minOperatorEthFee); + + await expect( + operators.registerOperator(makeOperatorKey(1), Number(CONFIG.maxOperatorEthFee), false) + ).to.emit(operators, Events.OPERATOR_ADDED); + }); + + it("Is reverted with FeeTooLow when declaring fee one packable step below minimum", async function () { + const { operators } = await networkHelpers.loadFixture(deployOperatorsFixture); + await operators.mockSetMinimumOperatorEthFee(CONFIG.minOperatorEthFee); + + // 1,065,200,000 - 100,000 = 1,065,100,000 + const feeBelowMin = CONFIG.minOperatorEthFee - ETH_DEDUCTED_DIGITS; + + await operators.registerOperator(makeOperatorKey(1), Number(CONFIG.minOperatorEthFee), false); + + await expect( + operators.declareOperatorFee(1, Number(feeBelowMin)) + ).to.be.revertedWithCustomError(operators, Errors.FEE_TOO_LOW); + }); + + it("Is reverted with FeeTooHigh when declaring fee one packable step above maximum", async function () { + const { operators } = await networkHelpers.loadFixture(deployOperatorsFixture); + await operators.mockSetMinimumOperatorEthFee(CONFIG.minOperatorEthFee); + + // 5,326,300,000 + 100,000 = 5,326,400,000 + const feeAboveMax = CONFIG.maxOperatorEthFee + ETH_DEDUCTED_DIGITS; + + await operators.registerOperator( + makeOperatorKey(1), Number(CONFIG.maxOperatorEthFee), false + ); + + await expect( + operators.declareOperatorFee(1, Number(feeAboveMax)) + ).to.be.revertedWithCustomError(operators, Errors.FEE_TOO_HIGH); + }); + }); + + describe("Cluster burn rate", () => { + it("Computes correct burn rate for 1, 4, and 13 validators", async function () { + const perOperatorPacked = CONFIG.defaultOperatorEthFee / ETH_DEDUCTED_DIGITS; + const networkFeePacked = CONFIG.networkFeeEth / ETH_DEDUCTED_DIGITS; + const perValidatorBurnRate = (perOperatorPacked * 4n) + networkFeePacked; + + const N_BLOCKS = 1000n; + + for (const validatorCount of [1n, 4n, 13n]) { + // Total burn for N_BLOCKS (wei) = perValidatorBurnRate × validatorCount × N_BLOCKS × ETH_DEDUCTED_DIGITS + const expectedBurnWei = perValidatorBurnRate * validatorCount * N_BLOCKS * ETH_DEDUCTED_DIGITS; + + // 1 validator: 106,309 × 1 × 1,000 × 100,000 = 10,630,900,000,000 wei + // 4 validators: 106,309 × 4 × 1,000 × 100,000 = 42,523,600,000,000 wei + // 13 validators:106,309 × 13 × 1,000 × 100,000 = 138,201,700,000,000 wei + if (validatorCount === 1n) expect(expectedBurnWei).to.equal(10_630_900_000_000n); + if (validatorCount === 4n) expect(expectedBurnWei).to.equal(42_523_600_000_000n); + if (validatorCount === 13n) expect(expectedBurnWei).to.equal(138_201_700_000_000n); + } + }); + + it("Deducts networkFeeEth × N_BLOCKS from cluster balance after N blocks", async function () { + // ethNetworkFee left at 0 to avoid auto-accrual from block advancement. + const { clusters, operatorIds } = await ssvClustersHarnessFixture(connection, 4, 0n); + const networkFeePacked = CONFIG.networkFeeEth / ETH_DEDUCTED_DIGITS; + + const N_BLOCKS = 1000n; + const initialDeposit = ethers.parseEther("1"); + + const registerTx = await clusters.registerValidator( + makePublicKey(1), operatorIds, DEFAULT_SHARES, EMPTY_CLUSTER, + { value: initialDeposit } + ); + const receipt = await registerTx.wait(); + const cluster = parseClusterFromEvent(clusters, receipt, Events.VALIDATOR_ADDED); + expect(cluster.balance).to.equal(initialDeposit); + + const netFeeIndexDelta = networkFeePacked * N_BLOCKS; + await clusters.mockCurrentNetworkFeeIndex(netFeeIndexDelta); + + const withdrawAmount = 1n; + const withdrawTx = await clusters.withdraw(operatorIds, withdrawAmount, cluster); + const withdrawReceipt = await withdrawTx.wait(); + const clusterAfter = parseClusterFromEvent(clusters, withdrawReceipt, Events.CLUSTER_WITHDRAWN); + + const networkFeeBurn = netFeeIndexDelta * ETH_DEDUCTED_DIGITS; + // 35,509 × 1,000 × 100,000 = 3,550,900,000,000 wei + expect(networkFeeBurn).to.equal(3_550_900_000_000n); + + const expectedBalance = initialDeposit - networkFeeBurn - withdrawAmount; + expect(clusterAfter.balance).to.equal(expectedBalance); + }); + }); + + describe("Cooldown duration", () => { + const deployStakingFixture = async () => { + return ssvStakingHarnessFixture(connection, CONFIG.cooldownDuration); + }; + + it("Is reverted with NothingToWithdraw before cooldown expires (604,800 seconds)", async function () { + const { staking, ssvToken } = await networkHelpers.loadFixture(deployStakingFixture); + + await ssvToken.approve(await staking.getAddress(), STAKE_AMOUNT); + await staking.stake(STAKE_AMOUNT); + await staking.requestUnstake(STAKE_AMOUNT); + + await networkHelpers.time.increase(CONFIG.cooldownDuration / 2n); + + await expect(staking.withdrawUnlocked()) + .to.be.revertedWithCustomError(staking, Errors.NOTHING_TO_WITHDRAW); + }); + + it("Can claim after 604,800 seconds (7 days) elapse", async function () { + const { staking, ssvToken } = await networkHelpers.loadFixture(deployStakingFixture); + const [staker] = await connection.ethers.getSigners(); + + await ssvToken.approve(await staking.getAddress(), STAKE_AMOUNT); + await staking.stake(STAKE_AMOUNT); + await staking.requestUnstake(STAKE_AMOUNT); + + await networkHelpers.time.increase(CONFIG.cooldownDuration + 1n); + + const balanceBefore = await ssvToken.balanceOf(staker.address); + const tx = await staking.withdrawUnlocked(); + await expect(tx) + .to.emit(staking, Events.UNSTAKE_WITHDRAWN) + .withArgs(staker.address, STAKE_AMOUNT); + + const balanceAfter = await ssvToken.balanceOf(staker.address); + expect(balanceAfter - balanceBefore).to.equal(STAKE_AMOUNT); + }); + + it("Stores cooldownDuration as 604,800 seconds (not blocks)", async function () { + const { staking } = await networkHelpers.loadFixture(deployStakingFixture); + const storedCooldown = await staking.getCooldownDuration(); + expect(storedCooldown).to.equal(CONFIG.cooldownDuration); + }); + }); + + describe("Quorum", () => { + let oracle1: HardhatEthersSigner; + let oracle2: HardhatEthersSigner; + let oracle3: HardhatEthersSigner; + let oracle4: HardhatEthersSigner; + let owner: HardhatEthersSigner; + + const totalSupply = ethers.parseEther("1000"); + + before(async function () { + [owner, oracle1, oracle2, oracle3, oracle4] = await connection.ethers.getSigners(); + }); + + const deployDAOWithMainnetQuorumFixture = async () => { + const { dao, cssv } = await ssvDAOHarnessFixture(connection); + + await dao.mockSetOracle(1, oracle1.address); + await dao.mockSetOracle(2, oracle2.address); + await dao.mockSetOracle(3, oracle3.address); + await dao.mockSetOracle(4, oracle4.address); + await dao.mockSetQuorumBps(Number(CONFIG.quorumBps)); + + await cssv.mint(owner.address, totalSupply); + + return { dao, cssv }; + }; + + it("2 votes out of 4 should NOT reach quorum (50% < 75%)", async function () { + const { dao } = await networkHelpers.loadFixture(deployDAOWithMainnetQuorumFixture); + + const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes("mainnet-quorum-test")); + const blockNum = await connection.ethers.provider.getBlockNumber(); + + const tx1 = await dao.connect(oracle1).commitRoot(merkleRoot, blockNum); + await expect(tx1).to.emit(dao, Events.WEIGHTED_ROOT_PROPOSED); + await expect(tx1).to.not.emit(dao, Events.ROOT_COMMITTED); + + const tx2 = await dao.connect(oracle2).commitRoot(merkleRoot, blockNum); + await expect(tx2).to.emit(dao, Events.WEIGHTED_ROOT_PROPOSED); + await expect(tx2).to.not.emit(dao, Events.ROOT_COMMITTED); + + expect(await dao.getEBRoot(blockNum)).to.equal(ethers.ZeroHash); + }); + + it("3 votes out of 4 should reach quorum (75% >= 75%)", async function () { + const { dao } = await networkHelpers.loadFixture(deployDAOWithMainnetQuorumFixture); + + const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes("mainnet-quorum-test-2")); + const blockNum = await connection.ethers.provider.getBlockNumber(); + + await dao.connect(oracle1).commitRoot(merkleRoot, blockNum); + await dao.connect(oracle2).commitRoot(merkleRoot, blockNum); + + const tx3 = await dao.connect(oracle3).commitRoot(merkleRoot, blockNum); + await expect(tx3).to.emit(dao, Events.ROOT_COMMITTED).withArgs(merkleRoot, blockNum); + + expect(await dao.getEBRoot(blockNum)).to.equal(merkleRoot); + }); + }); + + describe("Liquidation collateral", () => { + const deployClustersFixture = async () => { + const result = await ssvClustersHarnessFixture(connection, 4, 0n); + const clusters = result.clusters; + + await clusters.mockMinimumBlocksBeforeLiquidation(CONFIG.liquidationThresholdPeriod); + await clusters.mockMinimumLiquidationCollateral( + CONFIG.minimumLiquidationCollateralEth / ETH_DEDUCTED_DIGITS + ); + + return result; + }; + + it("Is reverted when liquidating a cluster with balance above minimumLiquidationCollateral", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployClustersFixture); + const [clusterOwner, liquidator] = await connection.ethers.getSigners(); + + const depositAmount = CONFIG.minimumLiquidationCollateralEth * 2n; + + const registerTx = await clusters.registerValidator( + makePublicKey(1), operatorIds, DEFAULT_SHARES, EMPTY_CLUSTER, + { value: depositAmount } + ); + const receipt = await registerTx.wait(); + const cluster = parseClusterFromEvent(clusters, receipt, Events.VALIDATOR_ADDED); + + await expect( + clusters.connect(liquidator).liquidate(clusterOwner.address, operatorIds, cluster) + ).to.be.revertedWithCustomError(clusters, Errors.CLUSTER_NOT_LIQUIDATABLE); + }); + + it("Liquidates cluster when balance drops below minimumLiquidationCollateral", async function () { + const { clusters, operatorIds } = await networkHelpers.loadFixture(deployClustersFixture); + const [clusterOwner, liquidator] = await connection.ethers.getSigners(); + + const depositAmount = CONFIG.minimumLiquidationCollateralEth * 2n; + const registerTx = await clusters.registerValidator( + makePublicKey(1), operatorIds, DEFAULT_SHARES, EMPTY_CLUSTER, + { value: depositAmount } + ); + const receipt = await registerTx.wait(); + const cluster = parseClusterFromEvent(clusters, receipt, Events.VALIDATOR_ADDED); + + const balanceToConsume = depositAmount - CONFIG.minimumLiquidationCollateralEth + ETH_DEDUCTED_DIGITS; + const indexUnits = balanceToConsume / ETH_DEDUCTED_DIGITS; + await clusters.mockCurrentNetworkFeeIndex(indexUnits); + + const liquidateTx = await clusters.connect(liquidator).liquidate( + clusterOwner.address, operatorIds, cluster + ); + const liquidateReceipt = await liquidateTx.wait(); + const liquidatedCluster = parseClusterFromEvent( + clusters, liquidateReceipt, Events.CLUSTER_LIQUIDATED + ); + expect(liquidatedCluster.active).to.equal(false); + }); + }); + + + describe("Long-running clusters (1 year simulation)", () => { + it("Fee indices remain within uint64 bounds after 1 year (~2,628,000 blocks)", async function () { + const ONE_YEAR_BLOCKS = 2_628_000n; + const networkFeePacked = CONFIG.networkFeeEth / ETH_DEDUCTED_DIGITS; // 35,509 + const perOperatorPacked = CONFIG.defaultOperatorEthFee / ETH_DEDUCTED_DIGITS; // 17,700 + + const operatorIndexDelta = perOperatorPacked * ONE_YEAR_BLOCKS; + const networkFeeIndexDelta = networkFeePacked * ONE_YEAR_BLOCKS; + const maxUint64 = (1n << 64n) - 1n; + + // 17,700 × 2,628,000 = 46,515,600,000 + expect(operatorIndexDelta).to.equal(46_515_600_000n); + // 35,509 × 2,628,000 = 93,317,652,000 + expect(networkFeeIndexDelta).to.equal(93_317_652_000n); + expect(operatorIndexDelta).to.be.lessThan(maxUint64); + expect(networkFeeIndexDelta).to.be.lessThan(maxUint64); + + const totalBurnPacked = (perOperatorPacked * 4n + networkFeePacked) * ONE_YEAR_BLOCKS; + const totalBurnWei = totalBurnPacked * ETH_DEDUCTED_DIGITS; + + // (1,770,000,000 / 100,000 × 4 + 3,550,900,000 / 100,000) × 2,628,000 × 100,000 + // = (17,700 × 4 + 35,509) × 2,628,000 × 100,000 + // = 106,309 × 2,628,000 × 100,000 + // = 27,938,005,200,000,000 + expect(totalBurnWei).to.equal(27_938_005_200_000_000n); + + const { clusters, operatorIds } = await ssvClustersHarnessFixture(connection, 4, 0n); + + // Network fee burn (1 year) = 35,509 × 2,628,000 × 100,000 = 9,331,765,200,000,000 wei + const networkFeeBurnWei = networkFeeIndexDelta * ETH_DEDUCTED_DIGITS; + expect(networkFeeBurnWei).to.equal(9_331_765_200_000_000n); + + const initialDeposit = networkFeeBurnWei * 2n; + + const registerTx = await clusters.registerValidator( + makePublicKey(1), operatorIds, DEFAULT_SHARES, EMPTY_CLUSTER, + { value: initialDeposit } + ); + const receipt = await registerTx.wait(); + const cluster = parseClusterFromEvent(clusters, receipt, Events.VALIDATOR_ADDED); + + await clusters.mockCurrentNetworkFeeIndex(networkFeeIndexDelta); + + const withdrawAmount = 1n; + const withdrawTx = await clusters.withdraw(operatorIds, withdrawAmount, cluster); + const withdrawReceipt = await withdrawTx.wait(); + const clusterAfterYear = parseClusterFromEvent( + clusters, withdrawReceipt, Events.CLUSTER_WITHDRAWN + ); + + expect(clusterAfterYear.active).to.equal(true); + }); + + it("Balance accounting remains correct after 1 year", async function () { + const ONE_YEAR_BLOCKS = 2_628_000n; + const networkFeePacked = CONFIG.networkFeeEth / ETH_DEDUCTED_DIGITS; // 35,509 + + const { clusters, operatorIds } = await ssvClustersHarnessFixture(connection, 4, 0n); + + // = 35,509 × 2,628,000 × 100,000 = 9,331,765,200,000,000 wei ≈ 0.00933 ETH + const networkFeeBurn = networkFeePacked * ONE_YEAR_BLOCKS * ETH_DEDUCTED_DIGITS; + expect(networkFeeBurn).to.equal(9_331_765_200_000_000n); + + const initialDeposit = networkFeeBurn * 15n; + + const registerTx = await clusters.registerValidator( + makePublicKey(1), operatorIds, DEFAULT_SHARES, EMPTY_CLUSTER, + { value: initialDeposit } + ); + const receipt = await registerTx.wait(); + const cluster = parseClusterFromEvent(clusters, receipt, Events.VALIDATOR_ADDED); + expect(cluster.balance).to.equal(initialDeposit); + + const netFeeIndexDelta = networkFeePacked * ONE_YEAR_BLOCKS; + await clusters.mockCurrentNetworkFeeIndex(netFeeIndexDelta); + + const withdrawAmount = 1n; + const withdrawTx = await clusters.withdraw(operatorIds, withdrawAmount, cluster); + const withdrawReceipt = await withdrawTx.wait(); + const clusterAfter = parseClusterFromEvent( + clusters, withdrawReceipt, Events.CLUSTER_WITHDRAWN + ); + + const expectedBalance = initialDeposit - networkFeeBurn - withdrawAmount; + expect(clusterAfter.balance).to.equal(expectedBalance); + expect(clusterAfter.active).to.equal(true); + }); + }); +});