Skip to content

Conversation

naddison36
Copy link
Collaborator

@naddison36 naddison36 commented Jun 30, 2025

A new staking strategy that uses merkle proofs to verify the operations of validators running on the beacon chain.

Objectives

  • Consolidation of validators to reduce the operation costs.
  • Consolidation to a single staking strategy to reduce the Vault gas costs.
  • Better protection against front-running of deposits to a new validator.
  • On-chain withdrawals reduces the dependency on third party node operators managing validator private keys.
  • Merkle proofs of the beacon chain is much more secure than Oracles providing validator balances.

Links

Relevant Pectra Release changes

  • EIP-6110 On-Chain Validator Deposits
  • EIP-7002 Execution layer triggerable withdrawals
  • EIP-7251 Increase the MAX_EFFECTIVE_BALANCE
  • EIP-7685 General purpose execution layer requests

Contracts

The scope of the contracts of this build is

  • CompoundingStakingSSVStrategy is the new staking contract.
  • CompoundingValidatorManager is inherited by CompoundingStakingSSVStrategy
  • CompoundingStakingStrategyView has two view functions for CompoundingStakingSSVStrategy
  • BeaconProofs called from CompoundingValidatorManager

The following libraries have also be built

  • BeaconRoots
  • BeaconProofsLib
  • PartialWithdrawal
  • Merkle
  • Endian
oethContracts oethContracts

Hoodi

Contract Name Address
CompoundingStakingSSVStrategyProxy 0xE0c7B62f75c54Cc75573965C566f37B266F216FB
CompoundingStakingStrategyView 0x7476F56EB07e71CeD2fB417D1Aaa925549B2a7f5
BeaconProofs 0x7F3f8fD57c794e5AE83DADa5c1D9cE185c7bB33D
OETHVaultProxy 0xD0cC28bc8F4666286F3211e465ecF1fe5c72AC8B
OETHProxy 0xbebDca6eF7452953e0AB5cebE2A174B71208B13a
NativeStakingSSVStrategyProxy 0x95DdB082cf2b8Bb4a4A358A8817a848cE32e446b
NativeStakingFeeAccumulatorProxy 0x27281CE00322Ee8b7C078788Fb624D051F5F7689

States

stakingValueTransitions stakingValidatorStates

Beacon chain data

BeaconBlock
├── slot: Slot
├── proposer_index: ValidatorIndex
├── parent_root: Root
├── state_root: Root  # Merkle root of BeaconState
│   └── BeaconState  # Does not include all fields
│       ├── validators: List[Validator, MAX_VALIDATORS]
│       │   ├── pubkey: BLSPubkey
│       │   ├── withdrawal_credentials: Bytes32
│       │   ├── effective_balance: uint64
│       │   ├── slashed: boolean
│       │   ├── activation_epoch: Epoch
│       │   ├── exit_epoch: Epoch
│       │   └── withdrawable_epoch: Epoch
│       ├── balances: List[Gwei, MAX_VALIDATORS]
│       ├── eth1_data: Eth1Data
│       │   ├── deposit_root: Root
│       │   ├── deposit_count: uint64
│       │   └── block_hash: Hash32
│       ├── eth1_deposit_index: uint64
│       └── deposit_requests_start_index: uint64
│       └── deposit_balance_to_consume: Gwei
│       └── pending_deposits: List[PendingDeposit, PENDING_DEPOSITS_LIMIT]
└── body_root: Root
    └── BeaconBlockBody  # Does not include all fields
        ├── eth1_data: Eth1Data
        ├── attestations: List[Attestation, MAX_ATTESTATIONS]
        ├── slashings: List[Slashing, MAX_SLASHINGS]
        ├── sync_aggregate: SyncAggregate
        ├── deposits: List[Deposit, MAX_DEPOSITS]
        ├── execution_payload: Root # at index 9
        │   └── ExecutionPayload
        │       ├── block_number: uint64  # at index 6
        └── requests: List[Request, MAX_REQUESTS]
            ├── type: uint8 (deposit, exit, consolidation)
            ├── data: Bytes (e.g., DepositData, ExitData)
            └── source: Hash32 (EL block hash)

Beacon Container Specs

To get the consolidate beacon chain specification for the last Pectra release

git clone https://github.com/ethereum/consensus-specs.git
cd consensus-specs
make pyspec

build/lib/eth2spec/electra/mainnet.py will have the full, consolidated changes. This includes all the beacon chain container definitions. eg BeaconBlock, BeaconBlockBody, BeaconState, Validator...

Processes

Register a SSV validator

oethProcesses-register

Initial deposit to a new validator

oethProcesses-deposit-new

Deposit more to existing validator

oethProcesses-deposit-existing

Verify validator

oethProcesses-verify-validator

Verify deposit to validator

oethProcesses-verify-deposit

Update strategy balances

oethProcesses-verify-balances

Withdrawals

oethProcesses-withdraw

Admin

oethProcesses-admin

Build

Keeping the contract under 24Kb has been a problem during development. The following will show the contract size of the compile contracts including CompoundingStakingSSVStrategy.

export CONTRACT_SIZE=true
npx hardhat compile

Testing

Unit Tests

Are main strategy unit tests in contracts/test/strategies/compoundingSSVStaking.js

There are also unit tests of the beacon merkle proofs in contracts/test/beacon/beaconProofs.js

yarn test

Fork Tests

There are no fork tests of the strategy as its hard to mock the beacon chain. But there are fork tests of various beacon chain contracts and libraries. These includes:

  • contracts/test/beacon/beaconConsolidation.mainnet.fork-test.js
  • contracts/test/beacon/beaconProofs.mainnet.fork-test.js
  • contracts/test/beacon/beaconRoots.mainnet.fork-test.js
  • contracts/test/beacon/partialWithdrawal.mainnet.fork-test.js

Hoodi Testnet

Hoodi faucet: https://hoodi-faucet.pk910.de/
SSV faucet: https://faucet.ssv.network/

Contracts have been deployed to Hoodi with deployment scripts in contracts/deploy/hoodi

 export DEPLOYER_PK=
yarn run deploy:hoodi
npx hardhat etherscan-verify --network hoodi --api-url https://api-hoodi.etherscan.io

Hoodi testing using Hardhat tasks

# Set `DEFENDER_API_KEY` and `DEFENDER_API_SECRET` in your `.env` file for the Hoodi Defender Relayer

# Get some WETH
npx hardhat depositWETH --amount 64 --network hoodi

# Mint OETH with approval
npx hardhat mint --asset WETH --amount 64 --symbol OETH --network hoodi

# Deposit old to native staking strategy
npx hardhat depositToStrategy --symbol OETH --strategy NativeStakingSSVStrategyProxy --assets WETH --amounts 64 --network hoodi

# Check the WETH was deposited
npx hardhat snapStaking --network hoodi

# Set the amount of Ether than can be staked before needing a reset
npx hardhat setStakeETHThreshold --amount 500 --network hoodi
# Reset the stakeETHTally if needed
npx hardhat resetStakeETHTally --network hoodi

# Register an old sweeping validator
npx hardhat registerValidators --days 30 --validators 1 --network hoodi

# stake to the old sweeping validator
npx hardhat stakeValidators --network hoodi

# Check the validator is active
npx hardhat snapStaking --network hoodi

# Deposit to new compounding staking strategy
npx hardhat depositToStrategy --symbol OETH --strategy CompoundingStakingSSVStrategyProxy --assets WETH --amounts 64 --network hoodi

# Check WETH in the new strategy
npx hardhat snapStakingStrat --network hoodi

# Call P2P API to request a new compounding SSV validator
npx hardhat requestNewValidator --network hoodi

# Request the validator via the staking contract
npx hardhat registerValidator --uuid 76be0ee1-de4e-4e4c-8904-9981e25e42a4 --operatorids 127,125,129,131 --network hoodi

# Deposit to a new validator
npx hardhat stakeValidator --pubkey 0x90244afab93283dcf1175c1eaa5c5eeab3acc3f70462a79ff59d06386a3c16690359aaa8905d3266420cda47a753af17 --sig 0x87a78e679db819ec982ea8b033f7aedcac19e2c9dae5c7db30b542969c380712da326d9dc88a3c5541c2e09e963522a112bc04b4ab1915feefcc9cea3c6cb33541df64130ccf436dac4ad24f165006dcb7abc1f65296074ec11d34ecb1c6fb7e --amount 32 --network hoodi

# Verify the validator
# make sure BEACON_PROVIDER_URL is pointing to the Hoodi beacon chain
npx hardhat verifyValidator --index 1187295 --slot 968894 --network hoodi
npx hardhat getValidator --index 1187295 --network hoodi

# Verify the deposit
npx hardhat verifyDeposit --slot 969405 --root 0x53bcd3650a5d66c668e92c34ccfb56f60adcf27180c44655ee199616af220309 --network hoodi

# Snap the ETH balance
npx hardhat snapBalances --network hoodi

# Verify the balances
npx hardhat verifyBalances --root 0x07929f2af14fafc0de4e1f93d429844eef3d5d8685816fcb33bef76569de5515 --network hoodi

# Partial withdrawal from a validator
npx hardhat withdrawValidator --amount 5 --pubkey 0x8f4429ea2bd4228f0ca74b4e232b03f6e0ef5bd2420951240dfe2acc423944aab40530b5f3165f231600e1a90fed3d4e --network hoodi

Deployment

Hoodi

 export DEPLOYER_PK=
yarn run deploy:hoodi

npx hardhat etherscan-verify --contract-name CompoundingStakingSSVStrategy --network hoodi --api-url https://api-hoodi.etherscan.io
npx hardhat etherscan-verify --contract-name BeaconProofs --network hoodi --api-url https://api-hoodi.etherscan.io

npx hardhat tenderlyUpload --name CompoundingStakingSSVStrategy --network hoodi
npx hardhat tenderlyUpload --name BeaconProofs --network hoodi

Code Change Checklist

To be completed before internal review begins:

  • The contract code is complete
  • Executable deployment file
  • Fork tests that test after the deployment file runs
  • Unit tests *if needed
  • The owner has done a full checklist review of the code + tests

Internal review:

  • Two approvals by internal reviewers

Copy link

github-actions bot commented Jun 30, 2025

Warnings
⚠️ 👀 This PR needs at least 2 reviewers

Generated by 🚫 dangerJS against 2b7ea1d

Copy link

codecov bot commented Jun 30, 2025

Codecov Report

❌ Patch coverage is 89.41799% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 41.82%. Comparing base (a8be73b) to head (2b7ea1d).

Files with missing lines Patch % Lines
...gies/NativeStaking/CompoundingValidatorManager.sol 84.73% 29 Missing ⚠️
...trategies/NativeStaking/CompoundingStakingView.sol 60.00% 6 Missing ⚠️
...es/NativeStaking/CompoundingStakingSSVStrategy.sol 89.79% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2559      +/-   ##
==========================================
+ Coverage   38.26%   41.82%   +3.55%     
==========================================
  Files         112      122      +10     
  Lines        5331     5691     +360     
  Branches     1412     1509      +97     
==========================================
+ Hits         2040     2380     +340     
- Misses       3289     3309      +20     
  Partials        2        2              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Renamed HH tasks copyBeaconRoot and added mockBeaconRoot
Restored extra check of timestamp in future in BeaconRoots lib
Added unit tests for verifyDeposit
/// @notice Mapping of the root of a deposit (depositDataRoot) to its data
mapping(bytes32 => DepositData) public deposits;
/// @notice List of deposit roots that are still to be verified as processed on the beacon chain
bytes32[] public depositsRoots;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be useful to have a function that returns the length of this list.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately adding a function that returns the number of deposits will make the contract too big

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it on my invariant repo and it didn't make the contract too big, I mean, the deployment didn't revert.
Maybe foundry optimize it a bit by default, which reduce the size of the code in comparison with Hardhat or something.

// Delete the last deposit from the list
depositsRoots.pop();

emit DepositVerified(depositDataRoot, deposit.amountGwei * 1 gwei);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deposit.amountGwei * 1 gwei

It will overflow when amountGwei is above 18 gwei, it could be fixed with uint256(deposit.amountGwei) * 1 gwei.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice pickup. I've fixed

Comment on lines 671 to 674
require(
consolidationLastPubKeyHash == bytes32(0),
"Consolidation in progress"
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this require is not reachable (i.e. it will never revert).

We are looking for a situation where consolidationLastPubKeyHash != bytes32(0).
This can happen only in the situation where requestConsolidation() has been called. But when requestConsolidation() is called, it pause the contract which prevent the snapBalance to be called.

But still not 100% sure. What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true and it takes 0.078 Kb off the contract size!
I've removed now pause is exclusively used for consolidations.

validator[depositData.pubKeyHash].state ==
ValidatorState.EXITED
) {
_removeDeposit(depositID, depositData);
Copy link
Member

@sparrowDom sparrowDom Aug 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reading the beacon spec it seems to me that deposits to exited_validators - which in beacon context means they have stopped performing their tasks but are not yet withdrawable. As the safety period needs to pass, which is needed to apply any slashing penalties, they arponed (as we already know). Once a validator becomes withdrawable the postponed deposit is applied (increases the balance) of the said validator without consuming churn. That is done by just increasing its balance.

Depending on the validator id and where the beacon chain sweep cycle is & also on the size of deposit queue + deposited amounts what can happen is:

  • deposit to a validator is done
  • validator is being slashed unknown to strategy contract
  • the deposit becomes a pending deposit being postponed
  • validator has become withdrawable
  • beacon chain sweep cycle processes it and sets it to 0 balance (the postponed deposit is still in the beacon chain)
  • we create a snapBalances and verifyBalances and assume that the validator's balance + the pending deposit has been credited back to the strategy contract. In reality only the validator's balance has been credited back. Our strategy would show an ETH deficit until the postponed deposit is processed
  • the beacon chain processes the deposit and adds it to the validator balance
  • the beacon chain sweep cycle processes that validator again, crediting the strategy contract with the deposit value
  • only once we create a snapBalances and verifyBalances again will we show the correct balances.

I think there is nothing we should do to address this issue, since it is (almost) not possible to prove non existence of a pending deposit (or postponed deposit) in a deposit queue. Also the strategy contract would not double count the ETH balance but rather just show a deficit for the time it takes the beacon chain to process the deposit and sweep it again. It would be helpful to have this recorded as a comment though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh now I see OZ has somewhat pointed to this option with M-01

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our fix for M-01 does prevent this from happening if we trigger an exit. But not if the validator is slashed.

naddison36 and others added 29 commits August 24, 2025 12:32
* simplify snapping balances

* type simplification
* Updated Natspec of verifyDeposit

* Moved 3rd party contract interactions to after storage variables are updated
* improve comments

* add CEI pattern

* add comment

* 70b contract size optimisation

* add comment

* prettier
…as the first pending deposit if the deposit queue is empty
#2639)

* add the support to verify BLS signature before depositing to validator

* prettier
* verifyDeposit removed check if the validator of the first deposit was exiting

* Fixed the verifyDeposit unit test data
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants