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.
| 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 |
- Cluster Flows
- Migration Flows
- Effective Balance Flows
- Operator Flows
- Staking Flows
- DAO Governance Flows
contract.ETH_balance ≈ Σ(current ETH cluster balances) + Σ(current operator ETH earnings) + ProtocolLib.networkTotalEarnings()
Where “current” means:
- Cluster balances computed like
SSVViews.getBalance(it applies pending fees before returning). File:contracts/modules/SSVViews.sol - Operator earnings computed like
SSVViews.getOperatorEarnings(it updates snapshots before returning). File:contracts/modules/SSVViews.sol - DAO/staking pool uses
ProtocolLib.networkTotalEarnings(). File:contracts/libraries/ProtocolLib.sol
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.
Caller: Cluster owner (or new cluster creator) Payable: Yes (msg.value = ETH to deposit)
- 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)
- For each operator:
- Update ETH snapshot (accumulate earnings)
- Increment
operator.ethValidatorCount - If first ETH interaction:
ensureETHDefaults()sets ethFee and ethSnapshot.block
- Store validator:
validatorPKs[hash(pubkey, owner)] = hash(operatorIds | active=true) - Update cluster state:
cluster.validatorCount++cluster.balance += msg.valuecluster.index = current cumulative operator ETH indexcluster.networkFeeIndex = current ETH network fee index
- Update DAO:
ethDaoValidatorCount++,daoTotalEthVUnits += BPS_DENOMINATOR— baseline EB of 32 ETH per validator is always applied here for all ETH clusters - If cluster has explicit EB (oracle has previously submitted an EB update): also update
ebSnapshot.vUnitsto 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 - Store cluster hash in
ethClusters - Liquidation check: cluster must not be liquidatable after registration
- Check uses projected vUnits (post-registration) not stale storage
- Explicit EB:
storedVUnits + validatorCountDelta * BPS_DENOMINATOR - Implicit EB:
cluster.validatorCount * BPS_DENOMINATOR
emit ValidatorAdded(owner, operatorIds, publicKey, shares, cluster);contract.balance == previous_contract_balance + msg.valueoperator.ethValidatorCount == previous + 1for each operatorethDaoValidatorCount == previous + 1- Cluster is not liquidatable
- Validator is retrievable via
getValidator(owner, publicKey)
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).
contract.balance == previous + msg.value(single ETH deposit)operator.ethValidatorCount == previous + Nfor each operator (N = number of validators)ethDaoValidatorCount == previous + N
Caller: Cluster owner
- Validator must exist and be owned by caller
- Cluster must exist as ETH cluster (VERSION_ETH) or legacy SSV cluster (VERSION_SSV)
- Operator IDs must match the registered operator set
- Update operator ETH snapshots
- Decrement
operator.ethValidatorCount - Delete validator record
- Update cluster:
cluster.validatorCount--- Settle fees up to current block
- Update indices
- Update DAO:
ethDaoValidatorCount--, reduce vUnits - If last validator removed: cluster balance remains (can withdraw later)
- Delete validator record
- If cluster is active:
- Update operator SSV snapshots and counts via
updateClusterOperatorsSSV(..., validatorsRemoved=1, ...) - Settle cluster balance and indices with SSV fee index (
currentNetworkFeeIndexSSV) - Decrement DAO SSV validator count via
updateDAOSSV(false, 1)
- Update operator SSV snapshots and counts via
- If cluster is liquidated:
- Skip SSV operator/DAO settlement in remove path (counts were already updated at liquidation time)
- Decrement
cluster.validatorCount - Persist updated legacy cluster in
s.clusters[hashedCluster]
emit ValidatorRemoved(owner, operatorIds, publicKey, cluster);operator.ethValidatorCount == previous - 1ethDaoValidatorCount == previous - 1- Validator no longer retrievable
- Cluster balance reflects settled fees
- If cluster was active: operator/DAO SSV counts decrease by 1
- If cluster was liquidated: remove does not decrement counts again
cluster.validatorCount == previous - 1- Validator no longer retrievable
- No EB (
clusterEB/operatorEthVUnits) cleanup is performed in SSV branch
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.
operator.ethValidatorCount == previous - Nfor each operator (N = validators removed)ethDaoValidatorCount == previous - Ncluster.validatorCount == previous - N- If cluster had explicit EB tracking (
ebSnapshot.vUnits > 0):ebSnapshot.vUnits -= N * BPS_DENOMINATOR - If
cluster.validatorCountreaches 0 and cluster is active: any remaining deviation vUnits are cleaned fromoperatorEthVUnitsand DAO
- If cluster was active:
operator.validatorCount == previous - NanddaoValidatorCount == previous - N - If cluster was liquidated: remove does not decrement SSV operator/DAO counts again
cluster.validatorCount == previous - Nin all cases- Operation is atomic: if any validator in the batch is invalid, no validator is removed
Caller: Cluster owner nonReentrant: No Payable: No
- Validator must exist and be owned by caller
- Validator must be registered with the given operator set (state check via
validateCorrectState)
None — exitValidator is a pure signal (event emission). No on-chain state is modified.
emit ValidatorExited(owner, operatorIds, publicKey);- 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
removeValidatoris called
Note: Exit is a two-step off-chain process.
exitValidatorsignals intent; the actual beacon-chain exit is performed by the SSV nodes network upon observing the event. The cluster continues to accrue fees untilremoveValidatoris called.
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.
publicKeys.length > 0(empty list reverts withValidatorDoesNotExist)- Each validator must exist and be owned by caller with the given operator set
None — pure signal, identical to 1.5 per validator.
// emitted once per validator
emit ValidatorExited(owner, operatorIds, publicKeys[i]);- No storage state changes
- N
ValidatorExitedevents emitted (one per validator) - All validator records remain in storage until
bulkRemoveValidatoris called
Caller: Anyone (on behalf of cluster owner) Payable: Yes
- Cluster must exist as ETH cluster (VERSION_ETH)
Note — deposits allowed on liquidated clusters:
depositdoes 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.
cluster.balance += msg.value- Update stored cluster hash
emit ClusterDeposited(owner, operatorIds, msg.value, cluster);contract.balance == previous_contract_balance + msg.valuecluster.balance == previous_settled_balance + msg.value- Cluster state hash is updated
Caller: Cluster owner nonReentrant: Yes
- 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
Cluster must be active— liquidated clusters are allowed (see note below)
Note — withdrawal allowed on liquidated clusters:
withdrawdoes not require the cluster to be active. A liquidated cluster may have received deposits (viadeposit) in preparation for reactivation. If the owner decides not to reactivate, they can recover those funds viawithdraw. Fee settlement and the post-withdrawal liquidatability check are skipped for inactive clusters (no burn rate applies).
cluster.balance -= amount- If cluster is active and has validators: liquidation check
- Update stored cluster hash
- Transfer
amountETH to caller
emit ClusterWithdrawn(owner, operatorIds, amount, cluster);cluster.balance == previous_settled_balance - amountowner.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.
Caller: Anyone (self-liquidation always allowed; third-party only if cluster is liquidatable) nonReentrant: Yes
- Cluster must exist as ETH cluster (VERSION_ETH)
- Cluster must be active
- If caller != owner: cluster must be liquidatable (balance below threshold)
- Update operator snapshots with fee settlement
- Decrement
operator.ethValidatorCountfor each operator - Reduce operators' effective balance (EB) tracking: decrement
operator.vUnitsby cluster's vUnits - Compute liquidation bounty = remaining cluster balance
- Set cluster state:
active = false, balance = 0, index = 0, networkFeeIndex = 0 - Update DAO:
ethDaoValidatorCount -= cluster.validatorCount, reduce DAO vUnits and EB tracking - Update stored cluster hash
- Transfer bounty ETH to caller (liquidator)
emit ClusterLiquidated(owner, operatorIds, cluster);cluster.active == falsecluster.balance == 0operator.ethValidatorCountdecreased by cluster's validator countethDaoValidatorCountdecreased- Liquidator received bounty ETH
contract.balance == previous - bounty
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).
Caller: Cluster owner Payable: Yes (msg.value = ETH deposit)
- Cluster must exist as ETH cluster
- Cluster must be liquidated (
active == false)
Note — Stale EB risk: The solvency check uses the stored
clusterEB.vUnitssnapshot, which may be stale if the beacon-chain EB changed during liquidation. Under the current oracle behavior, inactive / liquidated clusters are omitted from the Merkle root, so their on-chain EB snapshot usually cannot be refreshed before reactivation. In practice, deposit sizing should rely on off-chain beacon-chain-aware tooling plus a conservative buffer, not only on the on-chain snapshot. Ref: SPEC §2 "Stale EB Risk on Reactivation" for full analysis and mitigation options.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 duringupdateClusterOperatorsOnReactivation(seeOperatorLib.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.
- Update operator ETH snapshots
- Increment
operator.ethValidatorCountfor each operator - Increase operators' effective balance (EB) tracking: increment
operator.vUnitsby cluster's vUnits - Set cluster:
active = true, balance += msg.value, index = current, networkFeeIndex = current - Update DAO:
ethDaoValidatorCount += cluster.validatorCount, add DAO vUnits and increase EB tracking - Liquidation check: must not be immediately liquidatable (uses stored
clusterEB.vUnits) - Update stored cluster hash
emit ClusterReactivated(owner, operatorIds, cluster);cluster.active == truecluster.balance += msg.valuecontract.balance == previous + msg.value- Cluster is not liquidatable
Caller: Cluster owner Payable: Yes (msg.value = ETH for new cluster balance)
- 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
-
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
-
Settle SSV balance:
- Compute remaining SSV balance after fees
- Store as
ssvClusterBalancefor refund
-
Set up ETH cluster:
cluster.balance = msg.valuecluster.active = truecluster.index = cumulative ETH operator indexcluster.networkFeeIndex = current ETH network fee index
-
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)
- If NOT previously liquidated:
-
Liquidation check: Verify ETH cluster is not liquidatable
-
Store & delete:
s.ethClusters[key] = cluster.hashClusterData()delete s.clusters[key]
-
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]
- Add deviation to
- If cluster had explicit EB snapshot with vUnits > baseline:
-
Refund SSV: Transfer remaining SSV balance to owner
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);s.clusters[key]is deleted (no longer exists as SSV cluster)s.ethClusters[key]exists with new ETH cluster datacluster.active == truecluster.balance == msg.valuecontract.balance == previous_contract_balance + msg.valueowner SSV balance == previous + ssvRefundedoperator.validatorCountdecreased (SSV),operator.ethValidatorCountincreased (ETH) — net zero change in total validatorsethDaoValidatorCountincreased,daoValidatorCountdecreased (unless was liquidated)- Cluster is not liquidatable under ETH rules
- SSV cluster record is completely removed
Caller: Registered oracle only
oracleIdOf[msg.sender] != 0blockNum > latestCommittedBlock(strictly monotonic)blockNum <= block.number(not future)- Raw
cSSV.totalSupply() > 0and its truncated voting supply is also non-zero; otherwise revert withZeroCSSVSupplyorInsufficientCSSVSupply - Oracle has not already voted for this
(blockNum, merkleRoot)pair
- Mark oracle as voted:
hasVoted[commitmentKey][oracleId] = true - On the first vote only, read raw
cSSV.totalSupply(), truncate it tofrozenVotingSupply = rawSupply - (rawSupply % defaultOracleIds.length), and store that truncated value inroundFrozenSupply[commitmentKey] - Compute weight from stored voting supply:
weight = roundFrozenSupply[commitmentKey] / defaultOracleIds.length - Accumulate:
rootCommitments[commitmentKey] += weight - Compute threshold from the same stored voting supply:
threshold = (roundFrozenSupply[commitmentKey] * quorumBps) / 10_000 - If quorum reached (
accumulatedWeight >= threshold):- Store root:
ebRoots[blockNum] = merkleRoot - Update:
latestCommittedBlock = blockNum - Cleanup:
delete rootCommitments[commitmentKey] - Note:
hasVotedmappings are intentionally NOT deleted to prevent re-voting on the same key
- Store root:
- If quorum not reached: no root storage, no cleanup — see SPEC §4 "Failed Quorum Behavior" for full persistence rules
The truncated remainder (rawSupply % defaultOracleIds.length) is treated as non-voting dust for the round. roundFrozenSupply therefore represents frozen voting supply, not the exact raw total supply snapshot.
// If quorum reached:
emit RootCommitted(merkleRoot, blockNum);
// If quorum not reached:
emit WeightedRootProposed(merkleRoot, blockNum, accumulatedWeight, quorum, oracleId, oracle);- 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 sameblockNumwith different root - Total votes for this commitment <= oracle count
Caller: Anyone (permissionless) nonReentrant: Yes
- Committed root exists for
blockNum:ebRoots[blockNum] != bytes32(0) - Latest-root enforcement:
blockNum == latestCommittedBlock(reverts withMustUseLatestRootif stale) - Update frequency check:
block.number >= lastUpdateBlock + minBlocksBetweenUpdates(configured viaupdateMinBlocksBetweenUpdates(uint32)) - 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 contract supports updating the EB snapshot while
cluster.active == falseif a valid proof exists, and still skips fee/accounting steps in that case. In production, oracle roots exclude inactive / liquidated clusters, soupdateClusterBalancefor a liquidated cluster is usually not available through the live oracle flow. This means the on-chain EB snapshot may diverge from the real beacon-chain EB until the cluster is active again and re-included in a later root. Ref: SPEC §4 "Behavior on liquidated clusters" for full rules and use cases.
- Convert
effectiveBalancetonewVUnits = ebToVUnits(effectiveBalance) - Compute
effectiveOldVUnits:- If
storedVUnits == 0:validatorCount * BPS_DENOMINATOR - Else:
storedVUnits
- If
- If cluster active: settle operator and network fees using OLD vUnits
- If
newVUnits != effectiveOldVUnitsAND cluster active:- For each operator:
operatorEthVUnits[opId] += (newVUnits - effectiveOldVUnits)— full delta applied to every operator, no division by operator count daoTotalEthVUnits += (newVUnits - effectiveOldVUnits)
- For each operator:
- Update EB snapshot:
{vUnits: newVUnits, lastRootBlockNum: blockNum, lastUpdateBlock: block.number} - Auto-liquidation check (active clusters only): if cluster now undercollateralized:
- Liquidate immediately (same as liquidate flow)
- Bounty goes to
msg.sender(updater)
- If not liquidated: store updated cluster hash
- 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)
emit ClusterBalanceUpdated(owner, operatorIds, blockNum, effectiveBalance, cluster);
// If auto-liquidated:
emit ClusterLiquidated(owner, operatorIds, cluster);clusterEB[clusterId].vUnits == newVUnitsclusterEB[clusterId].lastRootBlockNum == blockNumclusterEB[clusterId].lastUpdateBlock == block.number- If EB increased: future fee accrual is higher
- If EB decreased: future fee accrual is lower
- Sum of all
operatorEthVUnitsdeviations + baselines ==daoTotalEthVUnits - If auto-liquidated:
cluster.active == false, bounty transferred to caller
Caller: Anyone
- Public key must not already be registered
- Fee must be divisible by ETH_DEDUCTED_DIGITS (100,000)
- Fee must be
0(public operator) OR within[minimumOperatorEthFee, operatorMaxFee]
- Increment
lastOperatorId - Store operator:
{owner: msg.sender, ethFee: packed(fee), ethSnapshot: {block: block.number, index: 0, balance: 0}} - Store public key mapping
- If
setPrivate: mark operator as whitelisted
emit OperatorAdded(operatorId, msg.sender, publicKey, fee);
emit OperatorPrivacyStatusUpdated([operatorId], setPrivate);lastOperatorId == previous + 1operators[id].owner == msg.senderoperators[id].ethFee == packed(fee)operators[id].validatorCount == 0(SSV)operators[id].ethValidatorCount == 0(ETH)
Caller: Operator owner nonReentrant: Yes
- Operator must exist (
snapshot.block != 0 || ethSnapshot.block != 0) - Caller must be operator owner
- Update SSV snapshot (final earnings)
- Update ETH snapshot (final earnings)
- Reset operator state via
_resetOperatorState:- Zeros
ethSnapshot.block,ethSnapshot.balance,snapshot.block,snapshot.balance,ethFee,fee,ethValidatorCount,validatorCount - Keeps
ethSnapshot.index,snapshot.index
- Zeros
operator.owneris intentionally preserved — allows off-chain systems (explorer,getOperatorById) to query the original owner after removal- Withdraw all SSV earnings to owner (if any)
- Withdraw all ETH earnings to owner (if any)
- Delete whitelist mapping
- Delete fee change request (if any)
if (ssvEarnings > 0) emit OperatorWithdrawnSSV(owner, operatorId, ssvEarnings);
if (ethEarnings > 0) emit OperatorWithdrawn(owner, operatorId, ethEarnings);
emit OperatorRemoved(operatorId);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) |
operators[id].ownerpreserves 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
Caller: Operator owner
- 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_TIMESTAMPinSSVOperators) are rejected when executing the fee update viaexecuteOperatorFee. The operator owner can declare a new fee at any time.
Note — Multiple declarations: Calling
declareOperatorFeemultiple 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.
- Call
ensureETHDefaults(operatorId)ifethSnapshot.block == 0:- Initializes
ethSnapshot.block = block.number - Assigns
ethFee = DEFAULT_OPERATOR_ETH_FEEonly ifethFee == 0 && SSV fee > 0 - Emits
OperatorFeeExecuted(owner, operatorId, block.number, DEFAULT_OPERATOR_ETH_FEE)if default is assigned - See SPEC §1 "Operator Fee Transition" for complete behavior
- Initializes
- Store
OperatorFeeChangeRequest{fee: packed(newFee), approvalBeginTime: now + declarePeriod, approvalEndTime: now + declarePeriod + executePeriod}(overwrites any existing pending request)
// If ensureETHDefaults assigned default (legacy SSV operator):
emit OperatorFeeExecuted(owner, operatorId, block.number, DEFAULT_OPERATOR_ETH_FEE);
// Always:
emit OperatorFeeDeclared(owner, operatorId, block.number, fee);Caller: Operator owner
- Pending fee change request exists
approvalBeginTime > UPGRADE_TIMESTAMP(reject pre-migration declarations)- Current time within
[approvalBeginTime, approvalEndTime] - Fee still within
operatorMaxFee
- 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
- Set
operator.ethFee = request.fee(packed) - Delete fee change request
emit OperatorFeeExecuted(owner, operatorId, block.number, fee);operator.ethFee == request.fee(packed)- No pending fee change request
- ETH snapshot block updated to current
Caller: Operator owner (immediate, no timelock)
- New fee within
[minimumOperatorEthFee, currentFee)(or 0) - New fee strictly less than current
- Fee must be 0 OR >=
minimumOperatorEthFee
- Call
ensureETHDefaults(operatorId)ifethSnapshot.block == 0:- Initializes
ethSnapshot.block = block.number - Assigns
ethFee = DEFAULT_OPERATOR_ETH_FEEonly ifethFee == 0 && SSV fee > 0 - Emits
OperatorFeeExecuted(owner, operatorId, block.number, DEFAULT_OPERATOR_ETH_FEE)if default is assigned - See SPEC §1 "Operator Fee Transition" for complete behavior
- Initializes
- Update operator ETH snapshot — ref SPEC §10 "Fee Settlement Rule": settles at old fee (or default if just assigned) up to this block; new fee applies only to future blocks
- Set
operator.ethFee = packed(newFee) - Delete any pending fee change request
// If ensureETHDefaults assigned default (legacy SSV operator):
emit OperatorFeeExecuted(owner, operatorId, block.number, DEFAULT_OPERATOR_ETH_FEE);
// Always:
emit OperatorFeeExecuted(owner, operatorId, block.number, fee);- Legacy SSV operator (
ethSnapshot.block == 0,SSV fee > 0): GetsDEFAULT_OPERATOR_ETH_FEEassigned, then reduced tonewFee - Explicit zero fee: After
ethSnapshot.block > 0, operator can setethFee = 0viareduceOperatorFee(operatorId, 0). This explicit zero is preserved during cluster migration. - Zero-fee operator (
SSV fee == 0): No default assigned, stays atethFee = 0
operator.ethFee < previous ethFee(strictly less)ethSnapshot.block > 0(always initialized after this call)- No pending fee change request
Caller: Operator owner
- Operator must exist
- Caller must be operator owner
- A pending fee change request must exist (
approvalBeginTime != 0)
- Delete the pending
OperatorFeeChangeRequestfor this operator
emit OperatorFeeDeclarationCancelled(owner, operatorId);- No pending fee change request for this operator
- Operator's current fee is unchanged
Caller: Operator owner nonReentrant: Yes
- Operator must exist
operator.ethSnapshot.block != 0(operator must be ETH-initialized; legacy SSV-only operators revert withInsufficientBalance)amount <= accumulated ETH earnings
- Update ETH snapshot (accumulate latest earnings)
- Deduct
amountfrom snapshot balance - Transfer
amountETH to operator owner
emit OperatorWithdrawn(owner, operatorId, amount);operator.ethSnapshot.balance == previous_settled - amountowner.balance == previous + amountcontract.balance == previous - amountoperator.ethSnapshot.blockunchanged (remains non-zero)operator.snapshot.blockunchanged (SSV state untouched)
Same as 4.7 but for SSV-denominated earnings. SSV token transferred instead of ETH.
- Operator must exist
operator.snapshot.block != 0(operator must be SSV-initialized; ETH-only operators revert withInsufficientBalance)amount <= accumulated SSV earnings
emit OperatorWithdrawnSSV(owner, operatorId, amount);operator.snapshot.balance == previous_settled - amountoperator.ethSnapshot.blockunchanged (ETH state untouched)
Caller: Operator owner nonReentrant: Yes
- Operator must exist
Each version branch is evaluated independently:
- If
operator.snapshot.block != 0: update SSV snapshot, capture and zerosnapshot.balance - If
operator.ethSnapshot.block != 0: update ETH snapshot, capture and zeroethSnapshot.balance - Transfer ETH earnings to operator owner (if captured amount non-zero)
- Transfer SSV token earnings to operator owner (if captured amount non-zero)
A legacy SSV-only operator (snapshot.block != 0, ethSnapshot.block == 0) runs only the SSV branch — ethSnapshot.block is never written, preserving the legacy state. An ETH-only operator runs only the ETH branch for the same reason.
emit OperatorWithdrawn(owner, operatorId, ethAmount); // ETH portion, only if ethAmount > 0
emit OperatorWithdrawnSSV(owner, operatorId, ssvAmount); // SSV portion, only if ssvAmount > 0operator.ethSnapshot.balance == 0(if ETH branch ran)operator.snapshot.balance == 0(if SSV branch ran)operator.ethSnapshot.blockunchanged ifethSnapshot.blockwas0before calloperator.snapshot.blockunchanged ifsnapshot.blockwas0before callowner.balance == previous + ethEarningsowner.ssvBalance == previous + ssvEarningscontract.balance == previous - ethEarnings
Caller: Anyone with SSV tokens nonReentrant: Yes
amount > 0amount >= MINIMAL_STAKING_AMOUNT(1,000,000,000)- User has approved SSV token transfer to contract
Note — cSSV supply cap:
cSSV.totalSupplycan never exceedSSV.totalSupplyby construction.mint(amount)is only called aftertransferFromsucceeds, so cSSV is always backed 1:1 by SSV already held in the contract. No explicit supply cap check is needed.
_syncFees(): UpdateaccEthPerSharewith latest DAO ETH earnings_settle(msg.sender): Settle pending rewards for user- Transfer
amountSSV tokens from user to contract - Mint
amountcSSV to user
emit FeesSynced(newFeesWei, accEthPerShare);
emit RewardsSettled(user, pending, accrued, userIndex);
emit Staked(user, amount);cSSV.totalSupply() == previous + amountcSSV.balanceOf(user) == previous + amountssvToken.balanceOf(contract) == previous + amountssvToken.balanceOf(user) == previous - amountuserIndex[user] == accEthPerShare(freshly settled)- User begins earning pro-rata rewards immediately
Caller: cSSV holder nonReentrant: Yes
Overview: Multi-request unstaking with per-request cooldown. Ref: SPEC §3 "Unstaking (Two-Step)" for full semantics.
amount > 0amount <= cSSV.balanceOf(msg.sender)- Pending unstake requests < MAX_PENDING_REQUESTS (2000)
_syncFees(): UpdateaccEthPerShare_settleWithBalance(user, balance): Settle rewards using CURRENT cSSV balance (before burn)- Push
UnstakeRequest{amount, unlockTime: block.timestamp + cooldownDuration} - Burn
amountcSSV from user
emit FeesSynced(newFeesWei, accEthPerShare);
emit RewardsSettled(user, pending, accrued, userIndex);
emit UnstakeRequested(user, amount, unlockTime);cSSV.totalSupply() == previous - amountcSSV.balanceOf(user) == previous - amountwithdrawalRequests[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)
When user calls requestUnstake(amount):
- Settlement happens BEFORE cSSV burn -> pending rewards added to s.accrued
- cSSV is burned -> balanceOf decreases
If user then calls claimEthRewards:
- If they unstaked ALL cSSV: balanceOf == 0 -> dust forfeited
- If they unstaked PARTIAL cSSV: balanceOf > 0 -> remainder preserved
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.
- At least one
UnstakeRequestwhereunlockTime <= block.timestamp— reverts withNothingToWithdrawif none exist or all are still within cooldown
- 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)
- Sum total unlocked amount across all removed entries (
totalAmount = Σ matured request amounts) - Transfer
totalAmountSSV tokens to user
Note: Immature requests (where
unlockTime > block.timestamp) remain untouched in the array and will be processed in a futurewithdrawUnlockedcall after their lock period expires.
emit UnstakedWithdrawn(user, totalAmount);ssvToken.balanceOf(user) == previous + totalAmountssvToken.balanceOf(contract) == previous - totalAmount- All matured requests removed from array
- Immature requests preserved
Caller: cSSV holder nonReentrant: Yes
- User has s.accrued[user] > 0 OR pending rewards from s.accEthPerShare growth
_syncFees(): UpdateaccEthPerShare_settle(user): Settle latest rewards- Read
claimable = accrued[user]; ifclaimable == 0revertNothingToClaim - Compute payout:
payout = claimable - (claimable % 100_000)(precision truncation) - Read
userBalance = cSSV.balanceOf(user) - If
payout == 0:- If
userBalance == 0: setaccrued[user] = 0, emitRewardsClaimed(user, 0), return - If
userBalance > 0: revertNothingToClaim(state unchanged due revert)
- If
- If
payout > 0: setremainder = claimable - payout - Set
accrued[user]:- If
remainder > 0 && userBalance == 0: zero remainder (forfeit dust) - Else: store
remainder
- If
- Deduct
packed(payout)fromstakingEthPoolBalance - Deduct
packed(payout)fromsp.ethDaoBalance - Transfer
payoutETH to user
emit FeesSynced(newFeesWei, accEthPerShare);
emit RewardsSettled(user, pending, accrued, userIndex);
emit RewardsClaimed(user, payout);- If
payout > 0:user.balance == previous + payout - If
payout > 0:contract.balance == previous - payout - If
payout > 0:stakingEthPoolBalancedecreased by packed(payout) - If
payout > 0:ethDaoBalancedecreased by packed(payout) - If
payout > 0andcSSV.balanceOf(user) > 0:accrued[user] == remainder - If
payout > 0andcSSV.balanceOf(user) == 0:accrued[user] == 0(dust forfeited) - If
payout == 0andcSSV.balanceOf(user) == 0:accrued[user] == 0,RewardsClaimed(user, 0)emitted, no pool/DAO deductions - If
payout == 0andcSSV.balanceOf(user) > 0: call reverts withNothingToClaim(no state changes)
Caller: Anyone nonReentrant: Yes
Publicly callable function to update the global accEthPerShare without settling any specific user. Useful for keeping the accumulator current.
- Compute current DAO ETH earnings
- If new fees since last sync: update
accEthPerShareandstakingEthPoolBalance
emit FeesSynced(newFeesWei, accEthPerShare);Caller: Any cSSV holder (triggered automatically on ERC-20 transfer) nonReentrant: No (hook is called from within the cSSV token contract)
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.
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 duringstakeandrequestUnstake
_syncFees(): Update globalaccEthPerSharewith latest DAO ETH earnings_settle(from): Snapshot sender's accrued rewards at currentaccEthPerShareusing their pre-transfer balance_settle(to): Snapshot receiver's accrued rewards at currentaccEthPerShareusing 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.
None emitted by the hook itself. The ERC-20 Transfer event is emitted by the token contract after the hook.
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 blockaccrued[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 viaclaimEthRewards()— rewards are stored inaccruedindependently of cSSV balance
Caller: Owner only
- Settle current ETH DAO earnings up to current block
- Update
ethNetworkFeeto new value - Update
ethNetworkFeeIndexto current - Update
ethNetworkFeeIndexBlockNumberto current block
emit NetworkFeeUpdated(oldFee, newFee);- 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)
Caller: Owner only
- Clear old oracle's
oracleIdOfmapping - Set new oracle's
oracleIdOfmapping - Update
oracles[oracleId]to new address
emit OracleReplaced(oracleId, oldOracle, newOracle);- Old oracle can no longer call
commitRoot - New oracle can call
commitRoot - Outstanding votes by old oracle for pending commitments remain counted
These invariants should be verified across all flows:
-
ETH conservation:
contract.ETH_balance ≈ Σ(current ETH cluster balances) + Σ(current operator ETH earnings) + ProtocolLib.networkTotalEarnings() -
SSV conservation:
contract.SSV_balance ≈ Σ(current SSV cluster balances) + Σ(current operator SSV earnings) + networkTotalEarningsSSV() + stakingHeldSSVWhere:- “current” means the view‑computed balances that apply pending fees (see
contracts/modules/SSVViews.sol). stakingHeldSSV= total SSV still locked in theSSVNetworkcontract, including pending unstake requests.cSSV.totalSupply()is only equal tostakingHeldSSVwhen there are no pending unstake requests.
- “current” means the view‑computed balances that apply pending fees (see
-
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 -
vUnit consistency:
daoTotalEthVUnits == ethDaoValidatorCount * BPS_DENOMINATOR + Σ(cluster_deviations) -
Cluster hash integrity: Every cluster operation must end with
s.ethClusters[key] = cluster.hashClusterData()matching the actual cluster state -
cSSV supply:
cSSV.totalSupply() == Σ(all staked SSV that has not been unstake-requested) -
Rewards conservation:
accEthPerShareonly increases, never decreases -
Oracle monotonicity:
latestCommittedBlockonly increases -
Cluster version exclusivity: A cluster key exists in EITHER
s.clustersORs.ethClusters, never both -
Operator dual tracking: SSV validatorCount + ETH validatorCount == total validators using this operator