Skip to main content
Version: v1.9

Pre-Create a DV with an OVM

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.

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.

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
cast send $EXAMPLE_OVM_ADDRESS \
"grantRoles(address,uint256)" \
$EXAMPLE_CUSTOMER_ADDRESS 21 \
--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.

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' behaviour 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.