-
Notifications
You must be signed in to change notification settings - Fork 0
Add offchain client specification for SSV clusters #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 20 commits
5889253
ca8933b
1989a0f
a04b18b
67f5d93
27dd13d
0eca3a9
e412f9c
d526ced
28c9c82
9b725bd
951b701
0d3276b
934ce5a
55e331a
ffc4b31
8dd3b40
4ce88f0
31bd1d9
478e4ce
c3860fe
67e2af0
c7f3eb5
abccff8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,337 @@ | ||
| ### SSV Oracle Spec | ||
|
|
||
| This section specifies the **offchain oracle client** that periodically publishes a Merkle root of **effective balances of all SSV clusters** to an onchain oracle contract. | ||
|
|
||
| #### 1. Summary | ||
| The client will: | ||
|
|
||
| - Read **commit phase configuration** (`startEpoch`, `epochInterval`) from a shared oracle commit phase configuration source. | ||
| - Determine the **current target epoch** and corresponding **round**. | ||
| - Ensure the **target epoch is finalized**. | ||
| - Fetch effective balances for all clusters at the **target epoch**. | ||
| - Build a **deterministic Merkle root** with an empty-leaf rule. | ||
| - Submit a **commit transaction** and ensure it is successful (with retries). | ||
| - Optionally **submit cluster effective balances** directly to the contract. | ||
|
|
||
| --- | ||
|
|
||
| #### 2. Commit Phase & Rounds | ||
|
|
||
| ##### 2.1 Commit Phase Configuration | ||
|
|
||
| Commit phase configuration: | ||
|
|
||
| ``` | ||
| Procedure getOracleCommitPhaseConfig(referenceEpoch) returns (startEpoch, epochInterval); | ||
| ``` | ||
|
|
||
| - `startEpoch` – first epoch at which oracle commitments are defined. | ||
| - `epochInterval` – number of epochs between oracle rounds (must be > 0). | ||
|
|
||
| The client obtains these values (for example, from an onchain contract or shared configuration) and MUST support dynamic configuration transitions. | ||
|
|
||
| For example, given a configuration with algebraic placeholders: | ||
| ```yml | ||
| # Do not edit default values | ||
| - commit-phase-config: | ||
| - firstStartEpoch: x | ||
| - firstInterval: a | ||
| - secondStartEpoch: y | ||
| - secondInterval: b | ||
| ``` | ||
|
|
||
| the configuration function behaves as follows: | ||
| ```python | ||
| if referenceEpoch >= y: | ||
| return (y,b) | ||
| else: | ||
| return (x,a) | ||
| ``` | ||
|
|
||
|
|
||
|
|
||
| ##### 2.2 Round & Target Epoch | ||
|
|
||
| The client maintains a `round` variable. To compute the target epoch for this round, use: | ||
|
|
||
| ```text | ||
| Procedure getTargetEpoch() { | ||
| targetEpoch = startEpoch + round * epochInterval | ||
| if targetEpoch >= secondStartEpoch: | ||
| targetEpoch = secondStartEpoch | ||
| round = 0 | ||
| secondStartEpoch = inf | ||
|
|
||
| return targetEpoch | ||
| } | ||
| ``` | ||
|
|
||
| After each successful commit for a given `round`, the client increments `round` by 1. | ||
|
|
||
| --- | ||
|
|
||
| #### 3. Finalization & Data Source | ||
|
|
||
| ##### 3.1 Finalization Requirement | ||
|
|
||
| Every time data is polled for an `epoch` it MUST be finalized. Finality can be checked via the beacon API: `/eth/v1/beacon/states/head/finality_checkpoints`. | ||
|
|
||
| If `epoch <= finalizedEpoch`, then it is eligible for data polling. | ||
| Only epochs calculated as targets will be polled. | ||
|
|
||
| ##### 3.2 Data Sources | ||
|
|
||
| The client obtains `(clusterId, effectiveBalance)` for `epoch` from an Ethereum node: | ||
| - Syncs SSV network events to build the mapping from validators to clusters. | ||
| - Fetches the effective balance for SSV validators via `GET /eth/v1/beacon/states/{target_epoch_checkpoint_hash}/validators` API call. | ||
|
|
||
| --- | ||
|
|
||
| #### 4. Data Model | ||
|
|
||
| ##### 4.1 Cluster Effective Balance | ||
|
|
||
| For each cluster `c`: | ||
|
|
||
| - `clusterId` – `bytes32` (canonical cluster identifier). | ||
| - `effectiveBalance` – integer `uint64` representing units in gwei. | ||
|
|
||
| The `effectiveBalance` of the cluster is the sum of all its SSV validators. | ||
| If the `effectiveBalance` of a validator is below the (EJECTION_BALANCE)(https://eth2book.info/capella/annotated-spec/#validator-cycle) of 16 ETH, | ||
| then round it up to 32 ETH for cluster sum calculations. | ||
|
|
||
| --- | ||
|
|
||
| #### 5. Merkle Tree Construction | ||
|
|
||
| A Merkle tree must be constructed so it is compatible with the logic used by [OpenZeppelin Merkle Tree](https://docs.openzeppelin.com/contracts-cairo/alpha/api/merkle-tree). | ||
|
|
||
| ##### 5.1 Leaf Encoding | ||
|
|
||
| For each cluster: | ||
|
|
||
| `leaf_c = keccak256(abi.encode(clusterId, effectiveBalance))` | ||
|
|
||
| - `clusterId` encoded as `bytes32`. It should be calculated as in the contract: `clusterId = keccak256(abi.encodePacked(msg.sender, operatorIds));` | ||
| - `effectiveBalance` encoded as fixed-width integer (`uint64`). | ||
|
|
||
| The exact encoding (types & order) is **part of the protocol** and MUST be identical across implementations and the onchain contract. | ||
|
|
||
| ##### 5.2 Ordering | ||
|
|
||
| - Collect all leaves. | ||
| - Sort by `clusterId` ascending (as `bytes32`). | ||
| - Construct `leaves[]` in that order. | ||
|
|
||
| This ordering guarantees a deterministic Merkle tree. | ||
|
Comment on lines
+120
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorting by leaf hash (instead of clusterID) is equally deterministic and provides compatibility with OpenZeppelin's StandardMerkleTree tooling. This allows external verification using their JavaScript library without custom implementations. |
||
|
|
||
| ##### 5.3 Empty Tree | ||
| If there are zero clusters, the Merkle root is defined as: | ||
| `merkleRoot = keccak256([]byte{})` | ||
| (i.e., keccak256 of zero bytes, resulting in `0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470`). | ||
|
|
||
|
|
||
olegshmuelov marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| --- | ||
|
|
||
| #### 6. Contract Interface | ||
|
|
||
| ##### 6.1 Commit Root | ||
|
|
||
| The oracle client calls the oracle contract: | ||
|
|
||
| ```solidity | ||
| function commitRoot( | ||
| bytes32 merkleRoot, | ||
| uint64 blockNum | ||
| ) external; | ||
| ``` | ||
| When a committed root is accepted with a sufficient threshold of weight: | ||
| ```solidity | ||
| event RootCommitted(bytes32 merkleRoot, uint64 blockNum); | ||
| ``` | ||
|
|
||
| - `merkleRoot` – Merkle root of all cluster effective balances for `targetEpoch`. | ||
| - `blockNum` – The block number that maps to the checkpoint of the `targetEpoch`. | ||
|
|
||
|
|
||
| Contract responsibilities: | ||
|
|
||
| - Require a **threshold** of oracle commits per `blockNum`. | ||
| - Handle storage and further use of that root. | ||
|
|
||
|
|
||
| ##### 6.2 UpdateClusterBalance | ||
|
|
||
| The contract supports per-cluster updates: | ||
|
|
||
| ```solidity | ||
| /** | ||
| * @notice Update a cluster's effective balance and trigger index updates | ||
| * @param blockNum Block number that matches a committed root | ||
| * @param clusterOwner Owner address of the cluster | ||
| * @param operatorIds Array of operator IDs in the cluster | ||
| * @param cluster Current cluster state (provided by oracle) | ||
| * @param effectiveBalance Total cluster effective balance in wei (sum of all validators) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The The NatSpec comment says: * @param effectiveBalance Total cluster effective balance in wei (sum of all validators)But Section 4.1 and 5.1 define effectiveBalance as |
||
| * @param merkleProof Merkle proof validating the EB value | ||
| */ | ||
| function UpdateClusterBalance( | ||
| uint64 blockNum, | ||
| address clusterOwner, | ||
| uint64[] operatorIds, | ||
| Cluster cluster, | ||
| uint64 effectiveBalance, | ||
| bytes32[] calldata merkleProof | ||
| ) external; | ||
| ``` | ||
|
|
||
| The client must provide the contract with cluster data. | ||
| The contract is able to independently validate it. | ||
|
|
||
| The client shall have tooling to generate Merkle proofs. This feature will be useful for fee collectors, but not all oracle clients will use it. | ||
|
|
||
| --- | ||
|
|
||
| #### 7. Client Architecture | ||
|
|
||
| ##### 7.1 Components | ||
|
|
||
| 1. **Scheduler** | ||
| - Triggers the main loop at a fixed wall-clock interval. | ||
| - Ensures no overlapping runs. | ||
|
|
||
| 2. **Config & Commit Phase Manager** | ||
| - Reads `startEpoch` and `epochInterval` from the commit phase configuration source. | ||
| - Caches the values and refreshes them periodically or upon error. | ||
| - Computes `(round, targetEpoch)` according to the configured commit phase rules. | ||
|
|
||
| 3. **Finalization & Epoch Manager** | ||
| - Queries beacon/consensus RPC to get `finalizedEpoch`. | ||
| - Checks `isFinalized(targetEpoch)` (or verifies `targetEpoch <= finalizedEpoch`). | ||
| - Resolves `referenceBlock` (or slot) corresponding to the Ethereum checkpoint of `targetEpoch`. | ||
|
|
||
| 4. **Data Fetcher** | ||
| - Syncs SSV contract events in order to reconstruct cluster data. | ||
| - Calls the beacon node API to get effective balances of SSV validators for `targetEpoch`. | ||
|
|
||
| 5. **Merkle Builder** | ||
| - Sorts and encodes cluster data. | ||
| - Computes for each cluster the total effective balance as defined in section 4.1. | ||
| - Produces `merkleRoot`, and optionally a structure for proof generation. | ||
|
|
||
| 6. **Onchain Client** | ||
| - Ethereum RPC/websocket client. | ||
| - ABI bindings for: | ||
| - `getOracleCommitPhaseConfig` | ||
| - `commitRoot` | ||
| - Manages nonces, gas price (EIP-1559), chain ID, etc. | ||
| - Tracks TX lifecycle and implements retry logic. | ||
|
|
||
| 7. **Wallet / Key Management** | ||
| - Signs EIP-1559 transactions. | ||
| - Ensures the private key is never exposed in raw form in logs or configuration. | ||
|
|
||
| 8. **Persistence & Monitoring** | ||
| - Local DB or KV store: | ||
| - Last successfully committed `(roundId, targetEpoch, merkleRoot)`. | ||
| - TX hashes and final statuses. | ||
| - Logging + metrics: | ||
| - Commit attempts, successes, failures, RPC errors, data mismatches. | ||
|
|
||
| ##### 7.2 Example Configuration | ||
|
|
||
| - Network: | ||
| - `eth_rpc_url` | ||
| - `beacon_rpc_url` | ||
| - `ssv_node_rpc_url` | ||
olegshmuelov marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| - `ssv_network_contract_address` | ||
| - Wallet: | ||
| - `keystore_path` or `private_key_env` | ||
| - TX policy: | ||
| - `tx_inclusion_timeout_blocks` | ||
| - `max_retry_attempts` | ||
| - `gas_bump_factor` (e.g. 1.1) | ||
| - `max_gas_price` | ||
|
|
||
|
|
||
| --- | ||
|
|
||
| #### 8. Protocol Flow (Per Loop) | ||
|
|
||
| 1. **Fetch commit phase config** | ||
| - Call `getOracleCommitPhaseConfig(lastTargetEpoch)` → `(startEpoch, epochInterval)`. | ||
| - If `epochInterval == 0`, log an error and abort (misconfiguration). | ||
|
|
||
| 2. **Calculate current round** | ||
| - If it cannot be fetched from memory: | ||
| - Obtain `latestFinalizedEpoch` from the beacon node. | ||
| - Compute `round = ceil((latestFinalizedEpoch - startEpoch) / epochInterval)`. | ||
|
|
||
| 3. **Compute targetEpoch & roundId** | ||
| - Compute: | ||
| ```text | ||
| targetEpoch = startEpoch + round * epochInterval | ||
| ``` | ||
| - Check if `targetEpoch` is finalized via consensus node before proceeding. | ||
|
|
||
| 4. **Idempotency check (already committed?)** | ||
| - If the epoch is finalized, find the checkpoint's `BlockNum`. | ||
| - From local DB and/or onchain state, check: | ||
| - If this oracle address already has a successful commit for a block number greater than or equal to `blockNum`. | ||
| - If yes, abort this cycle (nothing to do). | ||
|
|
||
| ```python | ||
| if targetEpoch.Checkpoint.BlockNum <= committedBlockNum: return | ||
| ``` | ||
|
|
||
| 5. **Fetch cluster balances** | ||
| - For `targetEpoch` (finalized and calculated per round): | ||
| - Get full list of `(clusterId, effectiveBalance)`. | ||
| - Get `BlockNum` of finalized checkpoint. | ||
|
|
||
|
|
||
| 6. **Build Merkle root** | ||
| - Encode and sort as in section 5. | ||
|
|
||
| 7. **Construct and sign TX** | ||
| - Call `commitRoot`. | ||
| - Estimate gas and set EIP-1559 parameters (maxFee, maxPriorityFee). | ||
| - Sign with the oracle key. | ||
|
|
||
| 8. **Broadcast TX** | ||
| - Send TX to Ethereum node. | ||
| - Persist `{round, targetEpoch, merkleRoot, referenceBlock, txHash, retryCount=0}` locally. | ||
|
|
||
| 9. **Track TX and ensure success** | ||
| - Poll for receipt until: | ||
| - TX is mined, or | ||
| - `tx_inclusion_timeout_blocks` reached. | ||
| - If **status == 1** (success): | ||
| - Mark `(blocknum, targetEpoch)` as successfully committed. | ||
| - Else (reverted, dropped, or timeout): | ||
| 1. Check onchain if a commit for this oracle and `blockNum` already exists (in case the first TX was replaced by another one). | ||
| 2. If not committed and `retryCount < max_retry_attempts`: | ||
| - Bump gas (e.g. multiply `maxFee` and/or `maxPriorityFee` by `gas_bump_factor`). | ||
| - Resubmit a new TX and update `retryCount`. | ||
| 3. If still not committed after max retries: | ||
| - Log permanent failure for this round and wait for manual intervention. | ||
|
|
||
| 10. **Optional: update cluster balance** | ||
| - Listen to the `RootCommitted(merkleRoot, blockNum, block.timestamp)` event and validate the correct `merkleRoot` is constructed for `blockNum`. | ||
|
||
| - Call `UpdateClusterBalance` per cluster in internal configuration. | ||
| - Use the same practices as in steps 7–9 to ensure a successful transaction. | ||
|
|
||
| --- | ||
|
|
||
| #### 9. Security & Correctness Notes | ||
|
|
||
| - **Determinism** | ||
| - `startEpoch`, `epochInterval` are read from the same source for all oracles. | ||
| - `targetEpoch`, `round`, leaf encoding, sorting rule, and empty leaf definition must be globally agreed. | ||
| - **Finalization safety** | ||
| - Using only finalized epochs avoids reorg issues. | ||
| - **Data correctness** | ||
| - Ultimate source of truth: onchain validator balances alongside SSV contract data and events. | ||
| - **Key management** | ||
| - Keys should be stored and used via secure mechanisms (HSM, KMS, or encrypted keystores). | ||
| - **Liveness** | ||
| - Retry + gas bump policy should be tuned so at least one TX from each oracle is likely to make it onchain per round. | ||
Uh oh!
There was an error while loading. Please reload this page.