Skip to main content
Version: v1.10

Validator Pre-Deploy Workflow

A customer opting into staking at an unknown time, is a key trigger for enterprise staking deployments. There are two primary ways these ad-hoc demands are programatically fulfilled, using smart contracts or using/generating the private keys.

  • Smart contracts such as an Obol Validator Manager (OVM) can re-assign their (beneficial) ownership; allowing a customer to activate a pre-created deposit for this smart contract.
  • A fresh DV cluster can be created, a DKG invite can be created for waiting DV-pods ready to partake, a charon add-validators command could be triggered to add extra keys to a running cluster, or charon deposit sign could be used to alter an unused validator's withdrawal address.

This guide will focus on the former, managing validators using Obol smart contracts, and their role based access control. This approach requires less coordination for multi-operator setups, and is simpler than creating or interacting with private key material on the fly by a remote trigger.

This guide will demonstrate the key steps in preparing a DV cluster for this type of scenario. A blank OVM will be created and assigned validator keys, along with a splitter contract for distributing rewards. Adjust the number of OVMs, splitters, and their key counts for your use case. Administratorship of the OVMs and splitters will be given to a private key that will sit in a secure back end API server, and when a customer triggers an allocation of an OVM, the API server private key will make the necessary updates to an unallocated OVM, and then revoke its control over the smart contracts, leaving them ready for the customer's deposit.

The Hoodi testnet will be used for all examples.

Diagram of the on-demand Obol Validator Manager pre-deploy workflow.

warning

The following code snippets are minimal examples for the purpose of achieving the desired functionality. These should not be run in production without thorough testing and review.

OVM roles at a glance​

The OVM uses a bitwise role system for permissioned actions. The owner (set in the constructor) holds all powers and can grantRoles() to other addresses. The table is an operational summary; see Obol Validator Manager for the full conceptual reference.

RoleHexAuthorizes
owner—Every gated method below, plus grantRoles, revokeRoles, transferOwnership, renounceOwnership, and the combined transfer(newBeneficiary, newOwner) shortcut.
WITHDRAWAL_ROLE0x01withdraw() — initiate partial or full validator withdrawals (EIP-7002).
CONSOLIDATION_ROLE0x02consolidate() — merge validators or upgrade a 0x01 validator to 0x02 via self-consolidation.
SET_BENEFICIARY_ROLE0x04setBeneficiary() (change principal recipient) and setAmountOfPrincipalStake() (correct principal accounting).
RECOVER_FUNDS_ROLE0x08recoverFunds() — sweep stuck ERC20 balances. ETH cannot be recovered through this method.
SET_REWARD_ROLE0x10setRewardRecipient() — change the reward destination.
DEPOSIT_ROLE0x20deposit() — fund a validator through the OVM so principal accounting updates.

distributeFunds(), distributeFundsPull(), and withdrawPullBalance() are permissionless. principalThreshold is set at deployment and is immutable — if a different threshold is needed, deploy a fresh OVM.

warning

DEPOSIT_ROLE only gates OVM.deposit(). The canonical Ethereum deposit contract (0x00000000219ab540356cBB839Cbe05303d7705Fa) is permissionless — anyone holding the validator pubkey can submit a deposit directly to it with the OVM as withdrawal credentials, bypassing the OVM's principal accounting. Use setAmountOfPrincipalStake() to correct the recorded principal in that case.

Pre-requisites​

To keep the cast examples neat, we'll declare the key addresses upfront here, and refer to them as environment variables in each cast command.

# You need an RPC for your commands to reach the Ethereum network. Use one for the correct chain.
export RPC_URL=https://ethereum-hoodi-rpc.publicnode.com
#export RPC_URL=https://ethereum-rpc.publicnode.com

# This address will be the in case of emergency break glass address for all OVMs.
# This address has custody of the funds and can modify all roles.
# Consider if this address should be the end user, burned outright,
# or a trusted, high threshold SAFE account in case of issue.
export ADMIN_SAFE_ADDRESS=0xFallbackSafeAddressHere

# The private key corresponding to this address should run in your API service
# This address will have temporary control over the OVM until a User requests it
# Create a keypair with `cast wallet new` and send it some Ether for transaction fees.
export BACKEND_API_ADDRESS=0xPublicAddressForAPIWallet
# The corresponding private key. (Make sure you don't commit it to version control!)
export BACKEND_API_PRIVATE_KEY=0xPrivateKeyForAnAPIWallet

# The address of the OVM factory on Hoodi
export OBOL_VALIDATOR_MANAGER_FACTORY_ADDRESS=0x5754C8665B7e7BF15E83fCdF6d9636684B782b12
# The address of the OVM factory address on **Mainnet**
#export OBOL_VALIDATOR_MANAGER_FACTORY_ADDRESS=0x2c26B5A373294CaccBd3DE817D9B7C6aea7De584

# Pull Split Factory Address
export PULL_SPLIT_FACTORY_ADDRESS=0x6B9118074aB15142d7524E8c4ea8f62A3Bdb98f1
# https://etherscan.io/address/0x6B9118074aB15142d7524E8c4ea8f62A3Bdb98f1#code

Fee Splitting​

A key decision when it comes to preparing a Distributed Validator is how Node Operators and Service providers can be non-custodially compensated for their services. Obol Validator Managers are built to leverage Splits.org split contracts. For this demo, split contracts will be pre-created with the unallocated OVMs, and edited for customers as they appear. You may want to consider smoothing MEV across your customers using a pair of nested splitters. This is described in more detail at the end of the guide.

Contract Deployment​

A safe and convenient way to deploy an OVM contract is through the existing contract factory. A splitter contract can be deployed in a similar fashion.

# Create a PullSplit owned by the backend API
cast send $PULL_SPLIT_FACTORY_ADDRESS \
"createSplit((address[],uint256[],uint256,uint16),address,address)" \
"([$BACKEND_API_ADDRESS],[1000000],1000000,0)" $BACKEND_API_ADDRESS $BACKEND_API_ADDRESS \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

# Create an OVM owned by the backend API, with placeholder beneficiary
cast send $OBOL_VALIDATOR_MANAGER_FACTORY_ADDRESS \
"createObolValidatorManager(address,address,address,uint64)" \
$BACKEND_API_ADDRESS $BACKEND_API_ADDRESS 0xYourRecentlyDeployedPullSplit 16000000000 \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

After you have deployed an Obol Validator Manager contract, let's save its address and an example customer address as environment variables to make the rest of the cast demo easier.

# The created OVM from the factory
export EXAMPLE_OVM_ADDRESS=0xYourRecentlyDeployedOVM

# The created PullSplit from the factory
export EXAMPLE_PULL_SPLIT_ADDRESS=0xYourRecentlyDeployedPullSplit

# An address of a hypothetical new customer
export EXAMPLE_CUSTOMER_ADDRESS=0xCustomerAddress

# A private key of a new customer. (Demo Only. Don't use raw customer private keys in practice)
export EXAMPLE_CUSTOMER_PRIVATE_KEY=0xCustomerPrivateKey

Create the DV Cluster​

At this point, you can prepare a DV cluster pointed at these OVMs and split contracts. Use the charon create cluster ... --publish command if you are controlling the validator keys centrally, or charon create dkg ... ---publish if you have a group of operators taking part in the cluster. Comma separate the --withdrawal-addresses and --fee-recipient-addresses flags with your created OVMs and Pull Splits. Once you complete the key creation, you can load these artifacts into your nodes and get the cluster online and ready for deposits. At this point the last remaining action will be with the API key, which will change the ownership of an OVM to make it ready for deposits.

Assigning the Contracts to Customers​

When a capital allocator (customer) is onboarding, the pre-created contracts can be assigned to that entity. The principal beneficiary address is updated to the entity's preferred address, permissions are allocated to the customer and backend's addresses as needed, and then ownership of the OVMs are transferred or burned.

# Set the beneficiary address to the customer
cast send $EXAMPLE_OVM_ADDRESS \
"setBeneficiary(address)" \
$EXAMPLE_CUSTOMER_ADDRESS \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

# Modify the splitter to include the customer and service providers (example is 90/10 split customer/admin address)
cast send $EXAMPLE_PULL_SPLIT_ADDRESS \
"updateSplit(address[],uint256[],uint256,uint16)" \
"[$EXAMPLE_CUSTOMER_ADDRESS,$ADMIN_SAFE_ADDRESS]" "[900000,100000]" 1000000 0 \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

# Grant the customer the DEPOSIT_ROLE and WITHDRAWAL_ROLE
# 0x21 = DEPOSIT_ROLE (0x20) | WITHDRAWAL_ROLE (0x01) = 33 in decimal
cast send $EXAMPLE_OVM_ADDRESS \
"grantRoles(address,uint256)" \
$EXAMPLE_CUSTOMER_ADDRESS 0x21 \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

# [OPTIONAL] If the backend service needs the ability to trigger
# partial (or full) withdrawals, grant it the WITHDRAWAL_ROLE.
# Warning; This allows this address to selectively charge fees on principal
cast send $EXAMPLE_OVM_ADDRESS \
"grantRoles(address,uint256)" \
$BACKEND_API_ADDRESS 1 \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

Transferring Ownership​

warning

This is a crucial step, and failure to adequately secure the ownership of an OVM could lead to a loss or theft of funds. Ensure you trust the owner() address of an OVM before making a deposit.

The last step before the OVM is ready for activation is to transfer the ownership of the OVM away from the backend, to either the customer, or an extremely well secured administrative multi-sig wallet like a SAFE that can intervene to update key values in future if needed. Consider that the owner of an OVM has custodial control over it.

cast send $EXAMPLE_OVM_ADDRESS \
"transferOwnership(address)" \
$ADMIN_SAFE_ADDRESS \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

Handling Deposits​

The capital allocator can now deposit the validators that point to this withdrawal address. The validator keys are held by the provisioned DV cluster operators and the deposit data was created during cluster creation.

This step would normally be through a wallet and web interface. This example using raw private keys is for demo purposes only.

info

To accurately differentiate reward from principal in an OVM, the OVM contract needs to be invoked during the deposit call. Each OVM has a deposit() function exactly matching and wrapping the official deposit smart contract, and should be used for that purpose.

If a deposit is made not through the OVM, the OVM can be updated with the setAmountOfPrincipalStake() method by the owner or an address with the SET_BENEFICIARY_ROLE.

cast send $EXAMPLE_OVM_ADDRESS \
"deposit(bytes,bytes,bytes,bytes32)" \
0x<pubkey_48_bytes> \
0x<withdrawal_credentials_32_bytes> \
0x<signature_96_bytes> \
0x<deposit_data_root_32_bytes> \
--value 32ether \
--rpc-url $RPC_URL \
--private-key $EXAMPLE_CUSTOMER_PRIVATE_KEY

The validator(s) will enter the activation queue and the amountOfPrincipalStake value on the contract will track how much of the balance is considered the principal (owed to the beneficiary). The EL and CL rewards from any targeting validators will be sent to the OVM contract and Pull Split.

Upgrading a 0x01 validator to 0x02 (self-consolidation)​

If a validator pointing at the OVM was deposited with 0x01 withdrawal credentials, its effective-balance ceiling is 32 ETH. Calling consolidate() with the validator's pubkey as both the source and the target upgrades it to 0x02 in place, raising the effective-balance ceiling to 2048 ETH and enabling reward compounding. The validator continues attesting through the upgrade — it is not exited.

info

The validator must be active with a balance greater than 32 ETH for the consolidation to succeed. The call must come from the OVM owner or an address holding CONSOLIDATION_ROLE (0x02). Self-consolidation is the only path that converts an existing 0x01 validator to 0x02 — a fresh deposit cannot do it.

EIP-7251 consolidation requests carry a small fee that scales with mempool pressure. Send enough ETH as msg.value to cover the fee; any excess is refunded to the excessFeeRecipient address.

cast send $EXAMPLE_OVM_ADDRESS \
"consolidate((bytes[],bytes)[],uint256,address)" \
"[([0x<validator_pubkey_48_bytes>],0x<validator_pubkey_48_bytes>)]" \
1000000000000000 \
$BACKEND_API_ADDRESS \
--value 0.001ether \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

Once the beacon chain processes the request, the validator's withdrawal type flips to 0x02 and its effective-balance ceiling rises. Subsequent top-up deposits up to 2048 ETH are then retained on the validator rather than being swept as partial withdrawals.

Withdrawing Validator Balance​

Compounding validators (0x02 type) can have part of their principal withdrawn from active stake, or be fully exited, via the same withdraw() call. Specifying a nonzero value for amounts will initiate a partial withdrawal, while 0 will fully exit the validator. You cannot specify an amount that will leave the validator with less than 32 ether in active stake remaining.

warning

There is an important nuance when it comes to partial withdrawals. With an OVM (on the default settings), it will treat a withdrawal of less than 16 ether as rewards rather than principal. A customer should not withdraw less than this amount of principal or they may be charged fees on it. Similarly, care must be taken with the WITHDRAWAL_ROLE; although it does not allow the changing of who gets rewards, it can cause this 'over-charging' behavior by doing repeated small withdrawals.

cast send $EXAMPLE_OVM_ADDRESS \
"withdraw(bytes[],uint64[],uint256,address)" \
"[0x<validator_pubkey_48_bytes>]" \
"[16000000000]" \
1000000000000000 \
0xYourRefundAddress \
--value 0.001ether \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

Reward Distribution and Splitters​

When withdrawals requested eventually exit the beacon chain, they appear on the OVM contract, and should be distributed to the rewardRecipient or principalRecipient (depending on if they amount to above or below the principalThreshold of 16 eth). Calling distributeFunds() will push the Ether to the correct address. Split contracts as principal or reward addresses will also need to be distributed from for the funds to land in their ultimate recipients addresses.

cast send $EXAMPLE_OVM_ADDRESS \
"distributeFunds()" \
--rpc-url $RPC_URL \
--private-key $BACKEND_API_PRIVATE_KEY

Appendix: MEV Smoothing​

If you setup validators where every customer gets their own fee recipient address (and underlying splitter), they will each get proposals rarely (approximately twice per year for a 32 ETH validator). Due to MEV being unequally distributed, only a small number of proposals in the year contain most of the MEV. This means that most of your users will get the median amount of Ether as MEV rather than the average, and may notice a lower APR versus setups that pool and distribute their variable rewards across their users. It may be beneficial for you to instead smooth the MEV being accrued through block proposals across all depositors in the cluster. This can be achieved through two nested split contracts as follows:

  • First create an editable PullSplit we'll refer to as the Child Split. The owner of this split should be the $BACKEND_API_ADDRESS.
  • Next create a second PullSplit we'll refer to as the Parent Split. It can be immutable if preferred. It should send the majority of its inflow to the Child Split, and some amount to a set of addresses that receive operating fees for the cluster.
  • Set the parent split as the --fee-recipient-address for all validators in the cluster. This means all proposal rewards for the cluster will go to this address.
  • When a customer makes a deposit, use the $BACKEND_API_PRIVATE_KEY to update the Child Split to proportionally reflect the eth provided by all customers to the cluster.
  • As proposals by the validators earn tips and MEV, this collects on the Split Contracts. Distributing these rewards sends the ether to the fee recipients and customers.