From 5889253bfbd4c6b289237382362b592e4ab6a637 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 2 Dec 2025 13:44:27 +0200 Subject: [PATCH 01/23] Add offchain client specification for SSV clusters This document specifies the offchain oracle client for SSV clusters, detailing its scope, timing configuration, finalization requirements, data sources, Merkle tree construction, and client architecture. --- spec.MD | 359 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 spec.MD diff --git a/spec.MD b/spec.MD new file mode 100644 index 0000000..124a12e --- /dev/null +++ b/spec.MD @@ -0,0 +1,359 @@ +# SSV Cluster Effective Balance Oracle – Offchain Client Spec + +## 1. Scope + +This document specifies the **offchain oracle client** that periodically publishes a Merkle root of **effective balances of all SSV clusters** to an onchain oracle contract. + +Out of scope: onchain logic for thresholds, weighted majority, and fee distribution. + +The client must: + +- Read **timing configuration** (`startEpoch`, `epoch_interval`) from the contract. +- Determine the **current target epoch** and corresponding **roundId**. +- Ensure the **target epoch is finalized**. +- Fetch effective balances changes of all clusters from the previous target epoch to the new one. +- Build a **deterministic Merkle root** with an empty leaf rule. +- Submit a **commit transaction** and ensure it is successful (with retries). + +--- + +## 2. Timing & Rounds + +### 2.1 Onchain Timing Configuration + +The oracle contract exposes timing configuration: + +```solidity +function getOracleTimingConfig() + external + view + returns (uint64 startEpoch, uint64 epochInterval); +``` + +- `startEpoch` – first epoch at which oracle commitments are defined. +- `epochInterval` – how many epochs between oracle rounds (must be > 0). + +The client reads these values from the contract at startup when it picks up a `ConfigUpdate` event to account for possible DAO updates. + +### 2.2 Round & Target Epoch + +The client maintains a `currentRound` variable. To compute the target epoch for this round, use: + +```go +function getTargetEpoch(round) { + return startEpoch + round * epochInterval +} +``` + +The client should use this formula to find the `targetEpoch` associated with whatever `currentRound` it is currently working on. + +--- + +## 3. Finalization & Data Source + +### 3.1 Finalization Requirement + +Every time data is polled for an `epoch` it must be finalized. Finality check can be done with a simple beacon api check: `/eth/v1/beacon/states/finalized/finality_checkpoints`. + +If `epoch <= finalizedEpoch` then it is eligible for data polling. + +### 3.2 Data Sources + +The client obtains `(clusterId, effectiveBalance)` for `epoch` from: + +1. **SSV node API (primary)** + e.g. a call conceptually like: + ```text + getEffectiveBalanceForEachClusters(targetEpoch) + ``` + returning: + ```json + [ + { "clusterId": "0x...", "effectiveBalance": "123456" }, + ... + ] + ``` + +2. **Ethereum node (optional / verification / fallback)** + - Reads SSV protocol contracts for: + - Cluster registry. + - Cluster effective balances at `targetEpoch` (or corresponding `referenceBlock`). + - Can be used to verify a random subset of entries to detect misbehavior of SSV nodes. + +Primary source of truth is **onchain SSV state**; SSV node is an index. + +--- + +## 4. Data Model + +### 4.1 Cluster Effective Balance + +For each cluster `c`: + +- `clusterId` – `bytes32` (canonical cluster identifier). +- `effectiveBalance` – integer (e.g. `uint64` / `uint256`), with agreed units (e.g. Gwei or ETH @marco ?). + +--- + +## 5. Merkle Tree Construction + +### 5.1 Leaf Encoding + + +For each cluster: + +`leaf_c = keccak256(abi.encode(clusterId, effectiveBalance))` + +- `clusterId` encoded as `bytes32`. It should be caluclated like 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. + +### 5.2 Ordering + +- Collect all leaves. +- Sort by `clusterId` ascending (as `bytes32`). +- Construct `leaves[]` in that order. + +This guarantees deterministic leaf ordering. + +### 5.3 Odd-Leaf Handling (Empty Leaf) + +Simply duplicate the last leaf. Ensure OpenZepplin compatibility like in [the following library]( https://github.com/cbergoon/merkletree). + +### 5.4 Parent Computation + +Build a binary Merkle tree: + +```text +parent = keccak256(left || right) +``` + +where `left` and `right` are 32-byte child hashes. The final single hash is `merkleRoot`. + +--- + +## 6. Contract Interface + +### 6.1 Commit Root + +The oracle client calls the oracle contract: + +```solidity +function commitRoot{ + uint64 round + bytes32 merkleRoot, + uint64 blockNum, +) external; +``` + +- `merkleRoot` – Merkle root of all cluster effective balances for `targetEpoch`. +- `blockNum` – The blockNumber that maps to the first present block since the start of the `targetEpoch`. + +**Edge condition** - If all blocks in the epoch are missing, then skip the epoch by passing `merkleRoot = 0` and `blockNum = 0` + + +Contract responsibilities (out of scope for client): + +- Require a **threshold** of oracle commits per `roundId`. +- Perform **weighted majority** to decide the canonical root. +- Handle storage and further use of that root. +- Verifies that `targetEpoch` is as expected for round + +--- + +## 7. Client Architecture + +### 7.1 Components + +1. **Scheduler** + - Triggers the main loop at a fixed wall-clock interval (e.g. every N seconds). + - Ensures no overlapping runs. + +2. **Config & Timing Manager** + - Reads `startEpoch` and `epoch_interval` from the oracle contract. + - Caches the values and refreshes them periodically or upon error. + - Computes `(roundId, targetEpoch)` based on `finalizedEpoch` and config. + +3. **Finalization & Epoch Manager** + - Queries beacon/consensus RPC to get `finalizedEpoch`. + - Checks `isFinalized(targetEpoch)` (or verifies `targetEpoch <= finalizedEpoch`). + - Resolves `referenceBlock` (or slot) corresponding to `targetEpoch`. + +4. **Data Fetcher** + - Calls SSV node API to obtain `(clusterId, effectiveBalance)` for `targetEpoch`. + - Optionally verifies a subset directly from SSV contracts via Ethereum RPC. + +5. **Merkle Builder** + - Normalizes, sorts, and encodes cluster data. + - Applies empty-leaf rule. + - Produces `merkleRoot`, and optionally a structure for proof generation. + +6. **Onchain Client** + - Ethereum RPC/websocket client. + - ABI bindings for: + - `getOracleTimingConfig` + - `commitRoot` + - Manages nonces, gas price (EIP-1559), chain ID, etc. + - Tracks TX lifecycle and implements retry logic. + +7. **Wallet / Key Management** + - Local keystore / HSM / KMS / remote signer. + - Signs EIP-1559 transactions. + - Ensures private key is never exposed raw in logs/config. + +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` + - `oracle_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` +- Behavior: + - `skip_if_root_unchanged` (optional optimization) + +--- + +## 8. Protocol Flow (Per Loop) + +1. **Fetch timing config** + - Call `getOracleTimingConfig()` → `(startEpoch, epochInterval)`. + - If `epochInterval == 0`, log error and abort (misconfiguration). + +2. **Get latest round that was committed by oracle**: + - Normally can be fetched from memory. + - If not available: + a. Finding `latestFinalizedEpoch` from beacon node. + b. calculate `round = RoundUp((latestFinalized-initialEpoch)/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?)** + - From local DB and/or onchain state, check: + - If this oracle address already has a successful commit for `roundId`. + - If yes, abort this cycle (nothing to do). + +5. **Fetch cluster balances** + - For `targetEpoch` (finalized and calculated per round): + - Get full list of `(clusterId, effectiveBalance)`. + + +6. **Build Merkle root** + - Encode leaves as in section 5 + - Sort by `clusterId`. + - If number of leaves is odd, append `emptyLeaf`. + - Build Merkle tree and compute `merkleRoot`. + + +7. **Construct and sign TX** + - Encode: + ```solidity + commitRoot(roundId, merkleRoot, targetEpoch, referenceBlock) + ``` + - Estimate gas and set EIP-1559 parameters (maxFee, maxPriorityFee). + - Sign with the oracle key. + +8. **Broadcast TX** + - Send TX to Ethereum node. + - Persist `{roundId, 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 `(roundId, targetEpoch)` as successfully committed. + - Else (reverted, dropped, or timeout): + 1. Check onchain if a commit for this oracle and `roundId` 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. Wait for manual intervention. + +--- + +## 9. Security & Correctness Notes + +- **Determinism** + - `startEpoch`, `epoch_interval` are read from the same contract for all oracles. + - `targetEpoch`, `round`, leaf encoding, sorting rule, and empty leaf definition must be globally agreed. +- **Finalization safety** + - Using only epochs derived from `finalizedEpoch` avoids reorg issues. +- **Data correctness** + - Onchain SSV contracts are ultimate source of truth. + - SSV node data should be sanity-checked regularly. +- **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. + + + +# Cluster Updater + +Cluster Updater is a separate role from the Effective Balance Oracle. + +It has the following flow: + +1. Builds merkle trees like the oracle. +2. Listen to `RootCommitted(round, merkleRoot, blockNum, block.timestamp)` event and validate the correct `merkleRoot` is constructed for `round`. +3. Call one of the following contract functions: + 1. Call `updateClusterBalance` per cluster in internal configuration. + 2. Call `BulkClustersBalancesUpdate` depending on internal configuration. + +## Contract interface + +### UpdateClusterBalance + +The contract may support per-cluster updates: + +```solidity +/** + * @notice Update a cluster's effective balance and trigger index updates + * @param blockNum RBlock 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 EB in wei (sum of all validators) + * @param merkleProof Merkle proof validating the EB value + */ +function UpdateClusterBalance( + uint64 blockNum, + address clusterOwner, + uint64[] operatorIds, + Cluster cluster, + uint256 effectiveBalance, + bytes32[] calldata merkleProof +) external; +``` + +The oracle must provide the contract with cluster data. +The contract is able to independently validate it. + +The oracle client may optionally have tooling to generate Merkle proofs but is not required to do so. This feature will be useful for fee collectors. + +## BulkClustersBalancesUpdate + +TBD +``` From ca8933b87bcbeb5e87d14cb381d820a0770e0ef9 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 2 Dec 2025 17:10:51 +0200 Subject: [PATCH 02/23] Revise timing configuration and remove round from contract Updated timing configuration section and adjusted logic for handling epochs and rounds. Modified function signatures and clarified client responsibilities. --- spec.MD => SPEC.md | 78 ++++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 31 deletions(-) rename spec.MD => SPEC.md (84%) diff --git a/spec.MD b/SPEC.md similarity index 84% rename from spec.MD rename to SPEC.md index 124a12e..d4c23a1 100644 --- a/spec.MD +++ b/SPEC.md @@ -19,25 +19,44 @@ The client must: ## 2. Timing & Rounds -### 2.1 Onchain Timing Configuration +### 2.1 Timing Configuration -The oracle contract exposes timing configuration: +Timing Configuration: -```solidity -function getOracleTimingConfig() - external - view +```go +function getOracleTimingConfig(uint64 referenceEpoch) returns (uint64 startEpoch, uint64 epochInterval); ``` - `startEpoch` – first epoch at which oracle commitments are defined. - `epochInterval` – how many epochs between oracle rounds (must be > 0). -The client reads these values from the contract at startup when it picks up a `ConfigUpdate` event to account for possible DAO updates. +The client reads these values at startup from a configuration JSON. +The client should support a dynamic transition of configuration changes. + +So given a configuration: +```json +{ + firstStartEpoch: x, + firstInterval: a, + secondStartEpoch: y, + secondInterval: b +} +``` + +Then the following logic should be performed: +```python +if referenceEpoch >= y: + return (y,b) +else: + return (x,a) +``` + + ### 2.2 Round & Target Epoch -The client maintains a `currentRound` variable. To compute the target epoch for this round, use: +The client maintains a `round` variable. To compute the target epoch for this round, use: ```go function getTargetEpoch(round) { @@ -141,24 +160,22 @@ The oracle client calls the oracle contract: ```solidity function commitRoot{ - uint64 round bytes32 merkleRoot, uint64 blockNum, ) external; ``` - `merkleRoot` – Merkle root of all cluster effective balances for `targetEpoch`. -- `blockNum` – The blockNumber that maps to the first present block since the start of the `targetEpoch`. +- `blockNum` – The blockNumber that maps to the checkpoint of the `targetEpoch`. **Edge condition** - If all blocks in the epoch are missing, then skip the epoch by passing `merkleRoot = 0` and `blockNum = 0` Contract responsibilities (out of scope for client): -- Require a **threshold** of oracle commits per `roundId`. +- Require a **threshold** of oracle commits per `blockNum`. - Perform **weighted majority** to decide the canonical root. - Handle storage and further use of that root. -- Verifies that `targetEpoch` is as expected for round --- @@ -231,60 +248,59 @@ Contract responsibilities (out of scope for client): ## 8. Protocol Flow (Per Loop) 1. **Fetch timing config** - - Call `getOracleTimingConfig()` → `(startEpoch, epochInterval)`. + - Call `getOracleTimingConfig(lastTargetEpoch)` → `(startEpoch, epochInterval)`. - If `epochInterval == 0`, log error and abort (misconfiguration). -2. **Get latest round that was committed by oracle**: - - Normally can be fetched from memory. - - If not available: +2. **Calculate Current Round**: a. Finding `latestFinalizedEpoch` from beacon node. - b. calculate `round = RoundUp((latestFinalized-initialEpoch)/epochInterval)` - + b. `if LatestFinalized<=initialEpoch: round = 0` + c. Calculate `round = RoundUp((latestFinalized-initialEpoch)/epochInterval)`. -3. **Compute targetEpoch & roundId** +4. **Compute targetEpoch & roundId** - Compute: ```text targetEpoch = startEpoch + round * epochInterval ``` - Check if `targetEpoch` is finalized via consensus node before proceeding. -4. **Idempotency check (already committed?)** +5. **Idempotency check (already committed?)** - From local DB and/or onchain state, check: - - If this oracle address already has a successful commit for `roundId`. + - If this oracle address already has a successful commit for `targetEpoch` (or corresponding `blockNum`). - If yes, abort this cycle (nothing to do). -5. **Fetch cluster balances** +6. **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** +7. **Build Merkle root** - Encode leaves as in section 5 - Sort by `clusterId`. - If number of leaves is odd, append `emptyLeaf`. - Build Merkle tree and compute `merkleRoot`. -7. **Construct and sign TX** +8. **Construct and sign TX** - Encode: ```solidity - commitRoot(roundId, merkleRoot, targetEpoch, referenceBlock) + commitRoot(merkleRoot, targetEpoch, referenceBlockNum) ``` - Estimate gas and set EIP-1559 parameters (maxFee, maxPriorityFee). - Sign with the oracle key. -8. **Broadcast TX** +9. **Broadcast TX** - Send TX to Ethereum node. - - Persist `{roundId, targetEpoch, merkleRoot, referenceBlock, txHash, retryCount=0}` locally. + - Persist `{round, targetEpoch, merkleRoot, referenceBlock, txHash, retryCount=0}` locally. -9. **Track TX and ensure success** +10. **Track TX and ensure success** - Poll for receipt until: - TX is mined, or - `tx_inclusion_timeout_blocks` reached. - If **status == 1** (success): - - Mark `(roundId, targetEpoch)` as successfully committed. + - Mark `(blocknum, targetEpoch)` as successfully committed. - Else (reverted, dropped, or timeout): - 1. Check onchain if a commit for this oracle and `roundId` already exists (in case the first TX was replaced by another one). + 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`. @@ -317,7 +333,7 @@ Cluster Updater is a separate role from the Effective Balance Oracle. It has the following flow: 1. Builds merkle trees like the oracle. -2. Listen to `RootCommitted(round, merkleRoot, blockNum, block.timestamp)` event and validate the correct `merkleRoot` is constructed for `round`. +2. Listen to `RootCommitted(merkleRoot, blockNum, block.timestamp)` event and validate the correct `merkleRoot` is constructed for `blockNum`. 3. Call one of the following contract functions: 1. Call `updateClusterBalance` per cluster in internal configuration. 2. Call `BulkClustersBalancesUpdate` depending on internal configuration. From 1989a0fee396eaf538e0fa51e9a88617b4de5e02 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 2 Dec 2025 17:16:24 +0200 Subject: [PATCH 03/23] Change configuration format from JSON to YAML Updated configuration format from JSON to YAML in documentation. --- SPEC.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index d4c23a1..20b6da1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -31,17 +31,17 @@ function getOracleTimingConfig(uint64 referenceEpoch) - `startEpoch` – first epoch at which oracle commitments are defined. - `epochInterval` – how many epochs between oracle rounds (must be > 0). -The client reads these values at startup from a configuration JSON. +The client reads these values at startup from a configuration YAML. The client should support a dynamic transition of configuration changes. So given a configuration: -```json -{ - firstStartEpoch: x, - firstInterval: a, - secondStartEpoch: y, - secondInterval: b -} +```yml +# Do not edit default values +- timing-config: + - firstStartEpoch: x + - firstInterval: a + - secondStartEpoch: y + - secondInterval: b ``` Then the following logic should be performed: From a04b18b2d56c55ad989af3ec4c85b907a496e361 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 2 Dec 2025 18:13:54 +0200 Subject: [PATCH 04/23] do not commit for past epochs --- SPEC.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index 20b6da1..2347c31 100644 --- a/SPEC.md +++ b/SPEC.md @@ -253,7 +253,7 @@ Contract responsibilities (out of scope for client): 2. **Calculate Current Round**: a. Finding `latestFinalizedEpoch` from beacon node. - b. `if LatestFinalized<=initialEpoch: round = 0` + b. `if LatestFinalized<=initialEpoch: round = 0` c. Calculate `round = RoundUp((latestFinalized-initialEpoch)/epochInterval)`. 4. **Compute targetEpoch & roundId** @@ -264,10 +264,15 @@ Contract responsibilities (out of scope for client): - Check if `targetEpoch` is finalized via consensus node before proceeding. 5. **Idempotency check (already committed?)** + - If epoch finalized, find the checkpoint's `BlockNum` - From local DB and/or onchain state, check: - - If this oracle address already has a successful commit for `targetEpoch` (or corresponding `blockNum`). + - 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 + ``` + 6. **Fetch cluster balances** - For `targetEpoch` (finalized and calculated per round): - Get full list of `(clusterId, effectiveBalance)`. From 67f5d932213e88e8a927acc0586b6b7da324c4df Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Thu, 4 Dec 2025 16:57:38 +0200 Subject: [PATCH 05/23] review --- SPEC.md | 71 +++++++++++++++------------------------------------------ 1 file changed, 19 insertions(+), 52 deletions(-) diff --git a/SPEC.md b/SPEC.md index 2347c31..97c554f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -23,15 +23,14 @@ The client must: Timing Configuration: -```go -function getOracleTimingConfig(uint64 referenceEpoch) - returns (uint64 startEpoch, uint64 epochInterval); +``` +Procedure getOracleTimingConfig(referenceEpoch) returns (startEpoch, epochInterval); ``` - `startEpoch` – first epoch at which oracle commitments are defined. - `epochInterval` – how many epochs between oracle rounds (must be > 0). -The client reads these values at startup from a configuration YAML. +The client reads these values at startup from a configuration. The client should support a dynamic transition of configuration changes. So given a configuration: @@ -58,8 +57,8 @@ else: The client maintains a `round` variable. To compute the target epoch for this round, use: -```go -function getTargetEpoch(round) { +``` +Procedure getTargetEpoch(round) { return startEpoch + round * epochInterval } ``` @@ -78,28 +77,12 @@ If `epoch <= finalizedEpoch` then it is eligible for data polling. ### 3.2 Data Sources -The client obtains `(clusterId, effectiveBalance)` for `epoch` from: - -1. **SSV node API (primary)** - e.g. a call conceptually like: - ```text - getEffectiveBalanceForEachClusters(targetEpoch) - ``` - returning: - ```json - [ - { "clusterId": "0x...", "effectiveBalance": "123456" }, - ... - ] - ``` - -2. **Ethereum node (optional / verification / fallback)** +The client obtains `(clusterId, effectiveBalance)` for `epoch` from Ethereum Node: - Reads SSV protocol contracts for: - Cluster registry. - Cluster effective balances at `targetEpoch` (or corresponding `referenceBlock`). - - Can be used to verify a random subset of entries to detect misbehavior of SSV nodes. -Primary source of truth is **onchain SSV state**; SSV node is an index. +Primary source of truth is **onchain SSV state**; --- @@ -110,20 +93,21 @@ Primary source of truth is **onchain SSV state**; SSV node is an index. For each cluster `c`: - `clusterId` – `bytes32` (canonical cluster identifier). -- `effectiveBalance` – integer (e.g. `uint64` / `uint256`), with agreed units (e.g. Gwei or ETH @marco ?). +- `effectiveBalance` – integer `uint64` representing units in gwei. --- ## 5. Merkle Tree Construction -### 5.1 Leaf Encoding +A merkle tree must be constructed so it will be 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 caluclated like in the contract: `clusterId = keccak256(abi.encodePacked(msg.sender, operatorIds));` +- `clusterId` encoded as `bytes32`. It should be calculated like 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. @@ -136,19 +120,11 @@ The exact encoding (types & order) is **part of the protocol** and must be ident This guarantees deterministic leaf ordering. -### 5.3 Odd-Leaf Handling (Empty Leaf) +### 5.3 Empty Tree +If there are zero clusters, the Merkle root is defined as: +`merkleRoot = keccak256("")` +(i.e., keccak256 of zero bytes, resulting in 0xc5d2...bf8b) -Simply duplicate the last leaf. Ensure OpenZepplin compatibility like in [the following library]( https://github.com/cbergoon/merkletree). - -### 5.4 Parent Computation - -Build a binary Merkle tree: - -```text -parent = keccak256(left || right) -``` - -where `left` and `right` are 32-byte child hashes. The final single hash is `merkleRoot`. --- @@ -159,7 +135,7 @@ where `left` and `right` are 32-byte child hashes. The final single hash is `mer The oracle client calls the oracle contract: ```solidity -function commitRoot{ +function commitRoot( bytes32 merkleRoot, uint64 blockNum, ) external; @@ -168,8 +144,6 @@ function commitRoot{ - `merkleRoot` – Merkle root of all cluster effective balances for `targetEpoch`. - `blockNum` – The blockNumber that maps to the checkpoint of the `targetEpoch`. -**Edge condition** - If all blocks in the epoch are missing, then skip the epoch by passing `merkleRoot = 0` and `blockNum = 0` - Contract responsibilities (out of scope for client): @@ -280,17 +254,10 @@ Contract responsibilities (out of scope for client): 7. **Build Merkle root** - - Encode leaves as in section 5 - - Sort by `clusterId`. - - If number of leaves is odd, append `emptyLeaf`. - - Build Merkle tree and compute `merkleRoot`. - + - Encode and sort as in section 5 8. **Construct and sign TX** - - Encode: - ```solidity - commitRoot(merkleRoot, targetEpoch, referenceBlockNum) - ``` + - Call `commitRoot`. - Estimate gas and set EIP-1559 parameters (maxFee, maxPriorityFee). - Sign with the oracle key. @@ -364,7 +331,7 @@ function UpdateClusterBalance( address clusterOwner, uint64[] operatorIds, Cluster cluster, - uint256 effectiveBalance, + uint64 effectiveBalance, bytes32[] calldata merkleProof ) external; ``` From 27dd13d36c3ecb5fd1498b4f6a507898910a075a Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Sun, 7 Dec 2025 14:28:44 +0200 Subject: [PATCH 06/23] review --- SPEC.md | 77 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/SPEC.md b/SPEC.md index 97c554f..df795fb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,19 +1,21 @@ # SSV Cluster Effective Balance Oracle – Offchain Client Spec -## 1. Scope +## 1. Summary This document specifies the **offchain oracle client** that periodically publishes a Merkle root of **effective balances of all SSV clusters** to an onchain oracle contract. -Out of scope: onchain logic for thresholds, weighted majority, and fee distribution. -The client must: +**TBD** Out of scope: onchain logic for thresholds, weighted majority, and fee distribution. -- Read **timing configuration** (`startEpoch`, `epoch_interval`) from the contract. -- Determine the **current target epoch** and corresponding **roundId**. +The client will: + +- Read **timing configuration** (`startEpoch`, `epoch_interval`) from a universal configuration shared on github. +- Determine the **current target epoch** and corresponding **round**. - Ensure the **target epoch is finalized**. - Fetch effective balances changes of all clusters from the previous target epoch to the new one. - 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 balance** directly to contract. --- @@ -33,7 +35,7 @@ Procedure getOracleTimingConfig(referenceEpoch) returns (startEpoch, epochInterv The client reads these values at startup from a configuration. The client should support a dynamic transition of configuration changes. -So given a configuration: +So given a configuration with algebraic value placeholders: ```yml # Do not edit default values - timing-config: @@ -58,12 +60,21 @@ else: The client maintains a `round` variable. To compute the target epoch for this round, use: ``` -Procedure getTargetEpoch(round) { - return startEpoch + round * epochInterval +Procedure getTargetEpoch() { + targetEpoch = startEpoch + round * epochInterval + if targetEpoch >= secondStartEpoch: + targetEpoch = secondStartEpoch + round = 0 + secondStartEpoch = inf + + return targetEpoch } ``` -The client should use this formula to find the `targetEpoch` associated with whatever `currentRound` it is currently working on. +The client should use this formula to find the `targetEpoch` associated with whatever `round` it is currently working on. +Round keeps on incrementing by one after each commit has been performed. + +However, once the calculated `targetEpoch` becomes greater to or equal to `secondStartEpoch` reset `round=0` and `targetEpoch = secondStartEpoch`. --- @@ -71,18 +82,16 @@ The client should use this formula to find the `targetEpoch` associated with wha ### 3.1 Finalization Requirement -Every time data is polled for an `epoch` it must be finalized. Finality check can be done with a simple beacon api check: `/eth/v1/beacon/states/finalized/finality_checkpoints`. +Every time data is polled for an `epoch` it must be finalized. Finality check can be done with a simple beacon api check: `/eth/v1/beacon/states/head/finality_checkpoints`. -If `epoch <= finalizedEpoch` then it is eligible for data polling. +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 Ethereum Node: - - Reads SSV protocol contracts for: - - Cluster registry. - - Cluster effective balances at `targetEpoch` (or corresponding `referenceBlock`). - -Primary source of truth is **onchain SSV state**; + - Syncs SSV network events to build a mapping of validators to clusters. + - Fetch the effective balance for SSV validators via "TBD codex" --- @@ -110,7 +119,7 @@ For each cluster: - `clusterId` encoded as `bytes32`. It should be calculated like 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. +The exact encoding (types & order) is **part of the protocol** and must be identical across implementations and contract. ### 5.2 Ordering @@ -122,8 +131,8 @@ This guarantees deterministic leaf ordering. ### 5.3 Empty Tree If there are zero clusters, the Merkle root is defined as: -`merkleRoot = keccak256("")` -(i.e., keccak256 of zero bytes, resulting in 0xc5d2...bf8b) +`merkleRoot = keccak256([]byte{})` +(i.e., keccak256 of zero bytes, resulting in 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470) --- @@ -145,10 +154,11 @@ function commitRoot( - `blockNum` – The blockNumber that maps to the checkpoint of the `targetEpoch`. +TBD: maybe delete when there is an oracle section. Contract responsibilities (out of scope for client): - Require a **threshold** of oracle commits per `blockNum`. -- Perform **weighted majority** to decide the canonical root. +- Perform **weighted majority** to decide the canonical root (TBD which one? Wait for Lior for clarifications). - Handle storage and further use of that root. --- @@ -158,25 +168,25 @@ Contract responsibilities (out of scope for client): ### 7.1 Components 1. **Scheduler** - - Triggers the main loop at a fixed wall-clock interval (e.g. every N seconds). + - Triggers the main loop at a fixed wall-clock interval. - Ensures no overlapping runs. 2. **Config & Timing Manager** - Reads `startEpoch` and `epoch_interval` from the oracle contract. - Caches the values and refreshes them periodically or upon error. - - Computes `(roundId, targetEpoch)` based on `finalizedEpoch` and config. + - Computes `(round, targetEpoch)` according to configuarations. 3. **Finalization & Epoch Manager** - Queries beacon/consensus RPC to get `finalizedEpoch`. - Checks `isFinalized(targetEpoch)` (or verifies `targetEpoch <= finalizedEpoch`). - - Resolves `referenceBlock` (or slot) corresponding to `targetEpoch`. + - Resolves `referenceBlock` (or slot) corresponding to the Ethereum checkpoint of `targetEpoch`. 4. **Data Fetcher** - Calls SSV node API to obtain `(clusterId, effectiveBalance)` for `targetEpoch`. - Optionally verifies a subset directly from SSV contracts via Ethereum RPC. 5. **Merkle Builder** - - Normalizes, sorts, and encodes cluster data. + - Sorts and encodes cluster data. - Applies empty-leaf rule. - Produces `merkleRoot`, and optionally a structure for proof generation. @@ -206,7 +216,7 @@ Contract responsibilities (out of scope for client): - `eth_rpc_url` - `beacon_rpc_url` - `ssv_node_rpc_url` - - `oracle_contract_address` + - `ssv_network_contract_address` - Wallet: - `keystore_path` or `private_key_env` - TX policy: @@ -214,8 +224,7 @@ Contract responsibilities (out of scope for client): - `max_retry_attempts` - `gas_bump_factor` (e.g. 1.1) - `max_gas_price` -- Behavior: - - `skip_if_root_unchanged` (optional optimization) + --- @@ -227,7 +236,7 @@ Contract responsibilities (out of scope for client): 2. **Calculate Current Round**: a. Finding `latestFinalizedEpoch` from beacon node. - b. `if LatestFinalized<=initialEpoch: round = 0` + b. `if latestFinalizedEpoch<=initialEpoch: round = 0` c. Calculate `round = RoundUp((latestFinalized-initialEpoch)/epochInterval)`. 4. **Compute targetEpoch & roundId** @@ -287,10 +296,9 @@ Contract responsibilities (out of scope for client): - `startEpoch`, `epoch_interval` are read from the same contract for all oracles. - `targetEpoch`, `round`, leaf encoding, sorting rule, and empty leaf definition must be globally agreed. - **Finalization safety** - - Using only epochs derived from `finalizedEpoch` avoids reorg issues. + - Using only finalized epochs avoids reorg issues. - **Data correctness** - - Onchain SSV contracts are ultimate source of truth. - - SSV node data should be sanity-checked regularly. + - Ultimate source of truth: Onchain vaidator balances alongside SSV contract data and events are ultimate source of truth. - **Key management** - Keys should be stored and used via secure mechanisms (HSM, KMS, or encrypted keystores). - **Liveness** @@ -339,9 +347,4 @@ function UpdateClusterBalance( The oracle must provide the contract with cluster data. The contract is able to independently validate it. -The oracle client may optionally have tooling to generate Merkle proofs but is not required to do so. This feature will be useful for fee collectors. - -## BulkClustersBalancesUpdate - -TBD -``` +The oracle client may optionally have tooling to generate Merkle proofs but is not required to do so. This feature will be useful for fee collectors. \ No newline at end of file From 0eca3a9abbed0753d755f18c50fad44798e8e893 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Sun, 7 Dec 2025 14:32:07 +0200 Subject: [PATCH 07/23] --amend --- SPEC.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index df795fb..ec94806 100644 --- a/SPEC.md +++ b/SPEC.md @@ -235,9 +235,9 @@ Contract responsibilities (out of scope for client): - If `epochInterval == 0`, log error and abort (misconfiguration). 2. **Calculate Current Round**: + - Only if not in memory a. Finding `latestFinalizedEpoch` from beacon node. - b. `if latestFinalizedEpoch<=initialEpoch: round = 0` - c. Calculate `round = RoundUp((latestFinalized-initialEpoch)/epochInterval)`. + b. Calculate `round = RoundUp((latestFinalized-initialEpoch)/epochInterval)`. 4. **Compute targetEpoch & roundId** - Compute: From e412f9cdc1e9566d55b49dda39d8eca43f9f2910 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Sun, 7 Dec 2025 14:53:42 +0200 Subject: [PATCH 08/23] remove TBDs --- SPEC.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/SPEC.md b/SPEC.md index ec94806..12cb468 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,9 +4,6 @@ This document specifies the **offchain oracle client** that periodically publishes a Merkle root of **effective balances of all SSV clusters** to an onchain oracle contract. - -**TBD** Out of scope: onchain logic for thresholds, weighted majority, and fee distribution. - The client will: - Read **timing configuration** (`startEpoch`, `epoch_interval`) from a universal configuration shared on github. @@ -91,7 +88,7 @@ Only epochs calculated as targets will be polled. The client obtains `(clusterId, effectiveBalance)` for `epoch` from Ethereum Node: - Syncs SSV network events to build a mapping of validators to clusters. - - Fetch the effective balance for SSV validators via "TBD codex" + - Fetch the effective balance for SSV validators via `GET /eth/v1/beacon/states/{target_epoch_checkpoint_hash}/validators` api call. --- @@ -154,11 +151,10 @@ function commitRoot( - `blockNum` – The blockNumber that maps to the checkpoint of the `targetEpoch`. -TBD: maybe delete when there is an oracle section. +(TBD: maybe move when there is a contract section) Contract responsibilities (out of scope for client): - Require a **threshold** of oracle commits per `blockNum`. -- Perform **weighted majority** to decide the canonical root (TBD which one? Wait for Lior for clarifications). - Handle storage and further use of that root. --- From d526cedd7e1a0e2d847fcccf44e4d3882d5b1264 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Sun, 7 Dec 2025 23:05:01 +0200 Subject: [PATCH 09/23] fix section 7 --- SPEC.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SPEC.md b/SPEC.md index 12cb468..312c88a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -168,7 +168,7 @@ Contract responsibilities (out of scope for client): - Ensures no overlapping runs. 2. **Config & Timing Manager** - - Reads `startEpoch` and `epoch_interval` from the oracle contract. + - Reads `startEpoch` and `epoch_interval` from the configurations. - Caches the values and refreshes them periodically or upon error. - Computes `(round, targetEpoch)` according to configuarations. @@ -178,8 +178,8 @@ Contract responsibilities (out of scope for client): - Resolves `referenceBlock` (or slot) corresponding to the Ethereum checkpoint of `targetEpoch`. 4. **Data Fetcher** - - Calls SSV node API to obtain `(clusterId, effectiveBalance)` for `targetEpoch`. - - Optionally verifies a subset directly from SSV contracts via Ethereum RPC. + - Syncs SSV contracts events in order reconstruct cluster data. + - Calls beacon node API to enable calculations `(clusterId, effectiveBalance)` for `targetEpoch`. 5. **Merkle Builder** - Sorts and encodes cluster data. From 28c9c82b38889f37a4a6d295d42d0f9b528a7bb4 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Sun, 7 Dec 2025 23:25:06 +0200 Subject: [PATCH 10/23] move cluster updater --- SPEC.md | 91 ++++++++++++++++++++++++++------------------------------- 1 file changed, 42 insertions(+), 49 deletions(-) diff --git a/SPEC.md b/SPEC.md index 312c88a..2e7776b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -146,17 +146,50 @@ function commitRoot( uint64 blockNum, ) external; ``` +It fires upon success: +```solidity +event RootCommitted(merkleRoot, blockNum) +``` - `merkleRoot` – Merkle root of all cluster effective balances for `targetEpoch`. - `blockNum` – The blockNumber that maps to the checkpoint of the `targetEpoch`. -(TBD: maybe move when there is a contract section) -Contract responsibilities (out of scope for client): +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 RBlock 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 EB in wei (sum of all validators) + * @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 @@ -264,7 +297,7 @@ Contract responsibilities (out of scope for client): 8. **Construct and sign TX** - Call `commitRoot`. - Estimate gas and set EIP-1559 parameters (maxFee, maxPriorityFee). - - Sign with the oracle key. + - Sign with the oracle key. 9. **Broadcast TX** - Send TX to Ethereum node. @@ -284,6 +317,11 @@ Contract responsibilities (out of scope for client): 3. If still not committed after max retries: - Log permanent failure for this round. Wait for manual intervention. +11. **Optional: update cluster balance**: + - Listen to `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 8-10 to ensure a successful transaction. + --- ## 9. Security & Correctness Notes @@ -298,49 +336,4 @@ Contract responsibilities (out of scope for client): - **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. - - - -# Cluster Updater - -Cluster Updater is a separate role from the Effective Balance Oracle. - -It has the following flow: - -1. Builds merkle trees like the oracle. -2. Listen to `RootCommitted(merkleRoot, blockNum, block.timestamp)` event and validate the correct `merkleRoot` is constructed for `blockNum`. -3. Call one of the following contract functions: - 1. Call `updateClusterBalance` per cluster in internal configuration. - 2. Call `BulkClustersBalancesUpdate` depending on internal configuration. - -## Contract interface - -### UpdateClusterBalance - -The contract may support per-cluster updates: - -```solidity -/** - * @notice Update a cluster's effective balance and trigger index updates - * @param blockNum RBlock 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 EB in wei (sum of all validators) - * @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 oracle must provide the contract with cluster data. -The contract is able to independently validate it. - -The oracle client may optionally have tooling to generate Merkle proofs but is not required to do so. This feature will be useful for fee collectors. \ No newline at end of file + - Retry + gas bump policy should be tuned so at least one TX from each oracle is likely to make it onchain per round. \ No newline at end of file From 9b725bdbab695c0c7bf4e3d1f7cc5d97ad7a3ffb Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Sun, 7 Dec 2025 23:34:50 +0200 Subject: [PATCH 11/23] polish --- SPEC.md | 104 +++++++++++++++++++++++++++----------------------------- 1 file changed, 50 insertions(+), 54 deletions(-) diff --git a/SPEC.md b/SPEC.md index 2e7776b..507fb21 100644 --- a/SPEC.md +++ b/SPEC.md @@ -6,13 +6,13 @@ This document specifies the **offchain oracle client** that periodically publish The client will: -- Read **timing configuration** (`startEpoch`, `epoch_interval`) from a universal configuration shared on github. +- Read **timing configuration** (`startEpoch`, `epochInterval`) from a shared oracle timing configuration source. - Determine the **current target epoch** and corresponding **round**. - Ensure the **target epoch is finalized**. -- Fetch effective balances changes of all clusters from the previous target epoch to the new one. -- Build a **deterministic Merkle root** with an empty leaf rule. +- 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 balance** directly to contract. +- Optionally **submit cluster effective balances** directly to the contract. --- @@ -27,12 +27,11 @@ Procedure getOracleTimingConfig(referenceEpoch) returns (startEpoch, epochInterv ``` - `startEpoch` – first epoch at which oracle commitments are defined. -- `epochInterval` – how many epochs between oracle rounds (must be > 0). +- `epochInterval` – number of epochs between oracle rounds (must be > 0). -The client reads these values at startup from a configuration. -The client should support a dynamic transition of configuration changes. +The client obtains these values (for example, from an onchain contract or shared configuration) and MUST support dynamic configuration transitions. -So given a configuration with algebraic value placeholders: +For example, given a configuration with algebraic value placeholders: ```yml # Do not edit default values - timing-config: @@ -42,7 +41,7 @@ So given a configuration with algebraic value placeholders: - secondInterval: b ``` -Then the following logic should be performed: +the configuration function behaves as: ```python if referenceEpoch >= y: return (y,b) @@ -56,7 +55,7 @@ else: 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: @@ -68,10 +67,7 @@ Procedure getTargetEpoch() { } ``` -The client should use this formula to find the `targetEpoch` associated with whatever `round` it is currently working on. -Round keeps on incrementing by one after each commit has been performed. - -However, once the calculated `targetEpoch` becomes greater to or equal to `secondStartEpoch` reset `round=0` and `targetEpoch = secondStartEpoch`. +After each successful commit for a given `round`, the client increments `round` by 1. --- @@ -79,16 +75,16 @@ However, once the calculated `targetEpoch` becomes greater to or equal to `secon ### 3.1 Finalization Requirement -Every time data is polled for an `epoch` it must be finalized. Finality check can be done with a simple beacon api check: `/eth/v1/beacon/states/head/finality_checkpoints`. +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 Ethereum Node: - - Syncs SSV network events to build a mapping of validators to clusters. - - Fetch the effective balance for SSV validators via `GET /eth/v1/beacon/states/{target_epoch_checkpoint_hash}/validators` api call. +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. --- @@ -105,7 +101,7 @@ For each cluster `c`: ## 5. Merkle Tree Construction -A merkle tree must be constructed so it will be compatible with the logic used by [OpenZeppelin Merkle Tree](https://docs.openzeppelin.com/contracts-cairo/alpha/api/merkle-tree) +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 @@ -113,10 +109,10 @@ For each cluster: `leaf_c = keccak256(abi.encode(clusterId, effectiveBalance))` -- `clusterId` encoded as `bytes32`. It should be calculated like in the contract: `clusterId = keccak256(abi.encodePacked(msg.sender, operatorIds));` +- `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 contract. +The exact encoding (types & order) is **part of the protocol** and MUST be identical across implementations and the onchain contract. ### 5.2 Ordering @@ -124,12 +120,12 @@ The exact encoding (types & order) is **part of the protocol** and must be ident - Sort by `clusterId` ascending (as `bytes32`). - Construct `leaves[]` in that order. -This guarantees deterministic leaf ordering. +This ordering guarantees a deterministic Merkle tree. -### 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) +### 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`). --- @@ -143,12 +139,12 @@ The oracle client calls the oracle contract: ```solidity function commitRoot( bytes32 merkleRoot, - uint64 blockNum, + uint64 blockNum ) external; ``` It fires upon success: ```solidity -event RootCommitted(merkleRoot, blockNum) +event RootCommitted(bytes32 merkleRoot, uint64 blockNum); ``` - `merkleRoot` – Merkle root of all cluster effective balances for `targetEpoch`. @@ -168,11 +164,11 @@ The contract supports per-cluster updates: ```solidity /** * @notice Update a cluster's effective balance and trigger index updates - * @param blockNum RBlock number that matches a committed root + * @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 EB in wei (sum of all validators) + * @param effectiveBalance Total cluster effective balance in wei (sum of all validators) * @param merkleProof Merkle proof validating the EB value */ function UpdateClusterBalance( @@ -201,9 +197,9 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - Ensures no overlapping runs. 2. **Config & Timing Manager** - - Reads `startEpoch` and `epoch_interval` from the configurations. + - Reads `startEpoch` and `epochInterval` from the configuration source. - Caches the values and refreshes them periodically or upon error. - - Computes `(round, targetEpoch)` according to configuarations. + - Computes `(round, targetEpoch)` according to the configured timing rules. 3. **Finalization & Epoch Manager** - Queries beacon/consensus RPC to get `finalizedEpoch`. @@ -211,8 +207,8 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - Resolves `referenceBlock` (or slot) corresponding to the Ethereum checkpoint of `targetEpoch`. 4. **Data Fetcher** - - Syncs SSV contracts events in order reconstruct cluster data. - - Calls beacon node API to enable calculations `(clusterId, effectiveBalance)` for `targetEpoch`. + - Syncs SSV contract events in order to reconstruct cluster data. + - Calls the beacon node API to calculate `(clusterId, effectiveBalance)` for `targetEpoch`. 5. **Merkle Builder** - Sorts and encodes cluster data. @@ -230,7 +226,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us 7. **Wallet / Key Management** - Local keystore / HSM / KMS / remote signer. - Signs EIP-1559 transactions. - - Ensures private key is never exposed raw in logs/config. + - Ensures the private key is never exposed in raw form in logs or configuration. 8. **Persistence & Monitoring** - Local DB or KV store: @@ -261,21 +257,21 @@ The client shall have tooling to generate Merkle proofs. This feature will be us 1. **Fetch timing config** - Call `getOracleTimingConfig(lastTargetEpoch)` → `(startEpoch, epochInterval)`. - - If `epochInterval == 0`, log error and abort (misconfiguration). + - If `epochInterval == 0`, log an error and abort (misconfiguration). -2. **Calculate Current Round**: - - Only if not in memory - a. Finding `latestFinalizedEpoch` from beacon node. - b. Calculate `round = RoundUp((latestFinalized-initialEpoch)/epochInterval)`. +2. **Calculate current round** + - If can not be fetched from memory: + - Obtain `latestFinalizedEpoch` from the beacon node. + - Compute `round = ceil((latestFinalizedEpoch - startEpoch) / epochInterval)`. -4. **Compute targetEpoch & roundId** +3. **Compute targetEpoch & roundId** - Compute: ```text targetEpoch = startEpoch + round * epochInterval ``` - Check if `targetEpoch` is finalized via consensus node before proceeding. -5. **Idempotency check (already committed?)** +4. **Idempotency check (already committed?)** - If epoch 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`. @@ -285,25 +281,25 @@ The client shall have tooling to generate Merkle proofs. This feature will be us if targetEpoch.Checkpoint.BlockNum <= committedBlockNum: return ``` -6. **Fetch cluster balances** +5. **Fetch cluster balances** - For `targetEpoch` (finalized and calculated per round): - Get full list of `(clusterId, effectiveBalance)`. - Get `BlockNum` of finalized checkpoint. -7. **Build Merkle root** +6. **Build Merkle root** - Encode and sort as in section 5 -8. **Construct and sign TX** +7. **Construct and sign TX** - Call `commitRoot`. - Estimate gas and set EIP-1559 parameters (maxFee, maxPriorityFee). - Sign with the oracle key. -9. **Broadcast TX** +8. **Broadcast TX** - Send TX to Ethereum node. - Persist `{round, targetEpoch, merkleRoot, referenceBlock, txHash, retryCount=0}` locally. -10. **Track TX and ensure success** +9. **Track TX and ensure success** - Poll for receipt until: - TX is mined, or - `tx_inclusion_timeout_blocks` reached. @@ -317,23 +313,23 @@ The client shall have tooling to generate Merkle proofs. This feature will be us 3. If still not committed after max retries: - Log permanent failure for this round. Wait for manual intervention. -11. **Optional: update cluster balance**: - - Listen to `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 8-10 to ensure a successful transaction. +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`, `epoch_interval` are read from the same contract for all oracles. + - `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 vaidator balances alongside SSV contract data and events are ultimate source of truth. + - 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. \ No newline at end of file + - Retry + gas bump policy should be tuned so at least one TX from each oracle is likely to make it onchain per round. From 951b7015daf1fbb80883ca43258aa4f7a0866f35 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Mon, 8 Dec 2025 13:16:43 +0200 Subject: [PATCH 12/23] add summary --- SPEC.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 507fb21..c5ed158 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,4 +1,27 @@ -# SSV Cluster Effective Balance Oracle – Offchain Client Spec +# SSV Staking + +We introduce SSV staking V1, a brand new feature that will allow SSV stakers to perform useful work for the SSV network in exchange for rewards. +Stakers will run oracle services that will report the correct effective balance (EB) of each SSV cluster to the SSV Network contract. +This will ensure that all fees are calculated proportionally to the expected rewards of the cluster. + +Since updating all clusters is a costly operation, we divide the work between 2 actors: +1. *SSV Oracles* - that can only participate in behalf of stakers. They post a single small commitment of the effective balances of **all** clusters in each phase. +2. *Cluster Updaters* - Permissionless parties that post the actual verifiable EBs. Any data that won't be verified against the commitment will be rejected. + +Cluster updaters will only be able to vote on commits that gained some threshold of votes. +The parties will be incentivized to act in a honest manner. The incentives will come from network fees collected from cluster owners and pooled in the SSV Network contract. +Each staker will be able to withdraw its relative part according to the weight of its correct votes. + +## V1 + +In the first release we focus on simplicity: + - There will only be 4 SSV oracles. + - One of the oracles will volunteer to be a Cluster Updater. + - Stakers will delegate stake to all of them at once. Meaning all oracles will have the same weight. + - A threshold of 75% of the weight will allow the commitment to be accepted. + - Stakers will be able to withdraw amount proportional to their stake. + + ## 1. Summary From 0d3276bfc1629ca3d3e044084ed6a529cb558610 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Mon, 8 Dec 2025 13:19:23 +0200 Subject: [PATCH 13/23] timing -> config phase --- SPEC.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/SPEC.md b/SPEC.md index 507fb21..1ea4596 100644 --- a/SPEC.md +++ b/SPEC.md @@ -6,7 +6,7 @@ This document specifies the **offchain oracle client** that periodically publish The client will: -- Read **timing configuration** (`startEpoch`, `epochInterval`) from a shared oracle timing configuration source. +- 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**. @@ -16,14 +16,14 @@ The client will: --- -## 2. Timing & Rounds +## 2. Commit Phase & Rounds -### 2.1 Timing Configuration +### 2.1 Commit Phase Configuration -Timing Configuration: +Commit Phase Configuration: ``` -Procedure getOracleTimingConfig(referenceEpoch) returns (startEpoch, epochInterval); +Procedure getOracleCommitPhaseConfig(referenceEpoch) returns (startEpoch, epochInterval); ``` - `startEpoch` – first epoch at which oracle commitments are defined. @@ -34,7 +34,7 @@ The client obtains these values (for example, from an onchain contract or shared For example, given a configuration with algebraic value placeholders: ```yml # Do not edit default values -- timing-config: +- commit-phase-config: - firstStartEpoch: x - firstInterval: a - secondStartEpoch: y @@ -196,10 +196,10 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - Triggers the main loop at a fixed wall-clock interval. - Ensures no overlapping runs. -2. **Config & Timing Manager** - - Reads `startEpoch` and `epochInterval` from the configuration source. +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 timing rules. + - Computes `(round, targetEpoch)` according to the configured commit phase rules. 3. **Finalization & Epoch Manager** - Queries beacon/consensus RPC to get `finalizedEpoch`. @@ -218,7 +218,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us 6. **Onchain Client** - Ethereum RPC/websocket client. - ABI bindings for: - - `getOracleTimingConfig` + - `getOracleCommitPhaseConfig` - `commitRoot` - Manages nonces, gas price (EIP-1559), chain ID, etc. - Tracks TX lifecycle and implements retry logic. @@ -255,8 +255,8 @@ The client shall have tooling to generate Merkle proofs. This feature will be us ## 8. Protocol Flow (Per Loop) -1. **Fetch timing config** - - Call `getOracleTimingConfig(lastTargetEpoch)` → `(startEpoch, epochInterval)`. +1. **Fetch commit phase config** + - Call `getOracleCommitPhaseConfig(lastTargetEpoch)` → `(startEpoch, epochInterval)`. - If `epochInterval == 0`, log an error and abort (misconfiguration). 2. **Calculate current round** From 934ce5a4c03db450a6730932d39cbd2b910c0630 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Mon, 8 Dec 2025 13:36:25 +0200 Subject: [PATCH 14/23] delete line --- SPEC.md | 1 - 1 file changed, 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 1ea4596..096f0a8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -224,7 +224,6 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - Tracks TX lifecycle and implements retry logic. 7. **Wallet / Key Management** - - Local keystore / HSM / KMS / remote signer. - Signs EIP-1559 transactions. - Ensures the private key is never exposed in raw form in logs or configuration. From ffc4b31eabcfbb2b83af9a18b71605b5b21aa478 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Mon, 8 Dec 2025 13:51:38 +0200 Subject: [PATCH 15/23] fix wordings --- SPEC.md | 56 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4be07a5..54e4b09 100644 --- a/SPEC.md +++ b/SPEC.md @@ -14,18 +14,20 @@ Each staker will be able to withdraw its relative part according to the weight o ## V1 -In the first release we focus on simplicity: - - There will only be 4 SSV oracles. +In the first release phase we focus on simplicity: + - There will be 4 permissioned SSV oracles. - One of the oracles will volunteer to be a Cluster Updater. - - Stakers will delegate stake to all of them at once. Meaning all oracles will have the same weight. + - It is possible to stake, but all oracles will have the same weight. - A threshold of 75% of the weight will allow the commitment to be accepted. - Stakers will be able to withdraw amount proportional to their stake. +### SSV Oracle -## 1. Summary +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 -This document specifies the **offchain oracle client** that periodically publishes a Merkle root of **effective balances of all SSV clusters** to an onchain oracle contract. The client will: @@ -39,9 +41,9 @@ The client will: --- -## 2. Commit Phase & Rounds +#### 2. Commit Phase & Rounds -### 2.1 Commit Phase Configuration +##### 2.1 Commit Phase Configuration Commit Phase Configuration: @@ -74,7 +76,7 @@ else: -### 2.2 Round & Target Epoch +##### 2.2 Round & Target Epoch The client maintains a `round` variable. To compute the target epoch for this round, use: @@ -94,16 +96,16 @@ After each successful commit for a given `round`, the client increments `round` --- -## 3. Finalization & Data Source +#### 3. Finalization & Data Source -### 3.1 Finalization Requirement +##### 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 +##### 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. @@ -111,9 +113,9 @@ The client obtains `(clusterId, effectiveBalance)` for `epoch` from an Ethereum --- -## 4. Data Model +#### 4. Data Model -### 4.1 Cluster Effective Balance +##### 4.1 Cluster Effective Balance For each cluster `c`: @@ -122,11 +124,11 @@ For each cluster `c`: --- -## 5. Merkle Tree Construction +#### 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 +##### 5.1 Leaf Encoding For each cluster: @@ -137,7 +139,7 @@ For each cluster: The exact encoding (types & order) is **part of the protocol** and MUST be identical across implementations and the onchain contract. -### 5.2 Ordering +##### 5.2 Ordering - Collect all leaves. - Sort by `clusterId` ascending (as `bytes32`). @@ -145,7 +147,7 @@ The exact encoding (types & order) is **part of the protocol** and MUST be ident This ordering guarantees a deterministic Merkle tree. -### 5.3 Empty Tree +##### 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`). @@ -153,9 +155,9 @@ If there are zero clusters, the Merkle root is defined as: --- -## 6. Contract Interface +#### 6. Contract Interface -### 6.1 Commit Root +##### 6.1 Commit Root The oracle client calls the oracle contract: @@ -165,7 +167,7 @@ function commitRoot( uint64 blockNum ) external; ``` -It fires upon success: +When a committed root is accepted with a threshold of weight: ```solidity event RootCommitted(bytes32 merkleRoot, uint64 blockNum); ``` @@ -180,7 +182,7 @@ Contract responsibilities: - Handle storage and further use of that root. -### 6.2 UpdateClusterBalance +##### 6.2 UpdateClusterBalance The contract supports per-cluster updates: @@ -211,9 +213,9 @@ The client shall have tooling to generate Merkle proofs. This feature will be us --- -## 7. Client Architecture +#### 7. Client Architecture -### 7.1 Components +##### 7.1 Components 1. **Scheduler** - Triggers the main loop at a fixed wall-clock interval. @@ -257,7 +259,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - Logging + metrics: - Commit attempts, successes, failures, RPC errors, data mismatches. -### 7.2 Example Configuration +##### 7.2 Example Configuration - Network: - `eth_rpc_url` @@ -275,7 +277,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us --- -## 8. Protocol Flow (Per Loop) +#### 8. Protocol Flow (Per Loop) 1. **Fetch commit phase config** - Call `getOracleCommitPhaseConfig(lastTargetEpoch)` → `(startEpoch, epochInterval)`. @@ -342,7 +344,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us --- -## 9. Security & Correctness Notes +#### 9. Security & Correctness Notes - **Determinism** - `startEpoch`, `epochInterval` are read from the same source for all oracles. @@ -355,3 +357,5 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - 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. + +### SSV Contract Changes \ No newline at end of file From 8dd3b40b6aed13546d569b6051fb2d3cb6325476 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Mon, 8 Dec 2025 14:02:36 +0200 Subject: [PATCH 16/23] add contracts TBD --- SPEC.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 54e4b09..33159aa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -358,4 +358,12 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - **Liveness** - Retry + gas bump policy should be tuned so at least one TX from each oracle is likely to make it onchain per round. -### SSV Contract Changes \ No newline at end of file +### SSV Contract Changes + +TBD + +#### Add Staking module + +#### Define Unstaking Window + +#### Enable Reward Withdrawals \ No newline at end of file From 4ce88f0aff5187f023071460aa144e134d3178b4 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Mon, 8 Dec 2025 14:07:07 +0200 Subject: [PATCH 17/23] fix grammar --- SPEC.md | 56 +++++++++++++++++++++++++++----------------------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/SPEC.md b/SPEC.md index 33159aa..cf60c3a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,34 +1,32 @@ # SSV Staking -We introduce SSV staking V1, a brand new feature that will allow SSV stakers to perform useful work for the SSV network in exchange for rewards. -Stakers will run oracle services that will report the correct effective balance (EB) of each SSV cluster to the SSV Network contract. -This will ensure that all fees are calculated proportionally to the expected rewards of the cluster. +We introduce SSV staking V1, a brand-new feature that will allow SSV stakers to perform useful work for the SSV network in exchange for rewards. +Stakers will run oracle services that report the correct effective balance (EB) of each SSV cluster to the SSV Network contract. +This ensures that all fees are calculated proportionally to the expected rewards of the cluster. -Since updating all clusters is a costly operation, we divide the work between 2 actors: -1. *SSV Oracles* - that can only participate in behalf of stakers. They post a single small commitment of the effective balances of **all** clusters in each phase. -2. *Cluster Updaters* - Permissionless parties that post the actual verifiable EBs. Any data that won't be verified against the commitment will be rejected. +Since updating all clusters is a costly operation, we divide the work between two actors: +1. *SSV Oracles* – which can only participate on behalf of stakers. They post a single small commitment of the effective balances of **all** clusters in each phase. +2. *Cluster Updaters* – permissionless parties that post the actual verifiable EBs. Any data that cannot be verified against the commitment will be rejected. -Cluster updaters will only be able to vote on commits that gained some threshold of votes. -The parties will be incentivized to act in a honest manner. The incentives will come from network fees collected from cluster owners and pooled in the SSV Network contract. -Each staker will be able to withdraw its relative part according to the weight of its correct votes. +Cluster updaters will only be able to vote on commits that have reached a predefined voting threshold. +The parties will be incentivized to act in an honest manner. The incentives will come from network fees collected from cluster owners and pooled in the SSV Network contract. +Each staker will be able to withdraw its proportional share according to the weight of its correct votes. ## V1 -In the first release phase we focus on simplicity: - - There will be 4 permissioned SSV oracles. +In the first release phase, we focus on simplicity: + - There will be four permissioned SSV oracles. - One of the oracles will volunteer to be a Cluster Updater. - It is possible to stake, but all oracles will have the same weight. - A threshold of 75% of the weight will allow the commitment to be accepted. - - Stakers will be able to withdraw amount proportional to their stake. + - Stakers will be able to withdraw an amount proportional to their stake. -### SSV Oracle +### SSV Oracle 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. @@ -45,7 +43,7 @@ The client will: ##### 2.1 Commit Phase Configuration -Commit Phase Configuration: +Commit phase configuration: ``` Procedure getOracleCommitPhaseConfig(referenceEpoch) returns (startEpoch, epochInterval); @@ -56,7 +54,7 @@ Procedure getOracleCommitPhaseConfig(referenceEpoch) returns (startEpoch, epochI 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 value placeholders: +For example, given a configuration with algebraic placeholders: ```yml # Do not edit default values - commit-phase-config: @@ -66,7 +64,7 @@ For example, given a configuration with algebraic value placeholders: - secondInterval: b ``` -the configuration function behaves as: +the configuration function behaves as follows: ```python if referenceEpoch >= y: return (y,b) @@ -102,7 +100,7 @@ After each successful commit for a given `round`, the client increments `round` 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. +If `epoch <= finalizedEpoch`, then it is eligible for data polling. Only epochs calculated as targets will be polled. ##### 3.2 Data Sources @@ -167,13 +165,13 @@ function commitRoot( uint64 blockNum ) external; ``` -When a committed root is accepted with a threshold of weight: +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 blockNumber that maps to the checkpoint of the `targetEpoch`. +- `blockNum` – The block number that maps to the checkpoint of the `targetEpoch`. Contract responsibilities: @@ -284,9 +282,9 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - If `epochInterval == 0`, log an error and abort (misconfiguration). 2. **Calculate current round** - - If can not be fetched from memory: - - Obtain `latestFinalizedEpoch` from the beacon node. - - Compute `round = ceil((latestFinalizedEpoch - startEpoch) / epochInterval)`. + - If it cannot be fetched from memory: + - Obtain `latestFinalizedEpoch` from the beacon node. + - Compute `round = ceil((latestFinalizedEpoch - startEpoch) / epochInterval)`. 3. **Compute targetEpoch & roundId** - Compute: @@ -296,7 +294,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - Check if `targetEpoch` is finalized via consensus node before proceeding. 4. **Idempotency check (already committed?)** - - If epoch finalized, find the checkpoint's `BlockNum` + - 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). @@ -312,7 +310,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us 6. **Build Merkle root** - - Encode and sort as in section 5 + - Encode and sort as in section 5. 7. **Construct and sign TX** - Call `commitRoot`. @@ -335,7 +333,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - 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. Wait for manual intervention. + - 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`. @@ -358,7 +356,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - **Liveness** - Retry + gas bump policy should be tuned so at least one TX from each oracle is likely to make it onchain per round. -### SSV Contract Changes +### SSV Contract Changes TBD @@ -366,4 +364,4 @@ TBD #### Define Unstaking Window -#### Enable Reward Withdrawals \ No newline at end of file +#### Enable Reward Withdrawals From 31bd1d9684f1df3506ff9ce6f23ceab020ab546f Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Mon, 8 Dec 2025 14:15:24 +0200 Subject: [PATCH 18/23] delete unneeded things --- SPEC.md | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/SPEC.md b/SPEC.md index cf60c3a..f6fe685 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,28 +1,4 @@ -# SSV Staking - -We introduce SSV staking V1, a brand-new feature that will allow SSV stakers to perform useful work for the SSV network in exchange for rewards. -Stakers will run oracle services that report the correct effective balance (EB) of each SSV cluster to the SSV Network contract. -This ensures that all fees are calculated proportionally to the expected rewards of the cluster. - -Since updating all clusters is a costly operation, we divide the work between two actors: -1. *SSV Oracles* – which can only participate on behalf of stakers. They post a single small commitment of the effective balances of **all** clusters in each phase. -2. *Cluster Updaters* – permissionless parties that post the actual verifiable EBs. Any data that cannot be verified against the commitment will be rejected. - -Cluster updaters will only be able to vote on commits that have reached a predefined voting threshold. -The parties will be incentivized to act in an honest manner. The incentives will come from network fees collected from cluster owners and pooled in the SSV Network contract. -Each staker will be able to withdraw its proportional share according to the weight of its correct votes. - -## V1 - -In the first release phase, we focus on simplicity: - - There will be four permissioned SSV oracles. - - One of the oracles will volunteer to be a Cluster Updater. - - It is possible to stake, but all oracles will have the same weight. - - A threshold of 75% of the weight will allow the commitment to be accepted. - - Stakers will be able to withdraw an amount proportional to their stake. - - -### SSV Oracle +### 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. @@ -355,13 +331,3 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - 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. - -### SSV Contract Changes - -TBD - -#### Add Staking module - -#### Define Unstaking Window - -#### Enable Reward Withdrawals From 478e4ce3b1eca23ab30cab40eddd3b0852e75f73 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 9 Dec 2025 13:26:07 +0200 Subject: [PATCH 19/23] no zero balances --- SPEC.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index f6fe685..e0ce727 100644 --- a/SPEC.md +++ b/SPEC.md @@ -96,6 +96,10 @@ 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 @@ -207,11 +211,11 @@ The client shall have tooling to generate Merkle proofs. This feature will be us 4. **Data Fetcher** - Syncs SSV contract events in order to reconstruct cluster data. - - Calls the beacon node API to calculate `(clusterId, effectiveBalance)` for `targetEpoch`. + - Calls the beacon node API to get effective balances of SSV validators for `targetEpoch`. 5. **Merkle Builder** - Sorts and encodes cluster data. - - Applies empty-leaf rule. + - 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** From c3860fe809253867ab41266fbf344d63c9b7ad93 Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 9 Dec 2025 17:55:42 +0200 Subject: [PATCH 20/23] or equal to --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index e0ce727..88d3a11 100644 --- a/SPEC.md +++ b/SPEC.md @@ -97,7 +97,7 @@ For each cluster `c`: - `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, +If the `effectiveBalance` of a validator is below or equal to 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. --- From 67e2af023c7c8eb5b03b211f311b117efd137cfb Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 9 Dec 2025 18:15:48 +0200 Subject: [PATCH 21/23] small fixes --- SPEC.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SPEC.md b/SPEC.md index 88d3a11..530f682 100644 --- a/SPEC.md +++ b/SPEC.md @@ -242,7 +242,6 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - Network: - `eth_rpc_url` - `beacon_rpc_url` - - `ssv_node_rpc_url` - `ssv_network_contract_address` - Wallet: - `keystore_path` or `private_key_env` @@ -316,7 +315,7 @@ The client shall have tooling to generate Merkle proofs. This feature will be us - 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`. + - Listen to the `RootCommitted(merkleRoot, blockNum)` 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. From c7f3eb5a410d9ee3d42f3aada21b2bd91c1c72ac Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Tue, 9 Dec 2025 18:28:02 +0200 Subject: [PATCH 22/23] small fixes --- SPEC.md | 1 - 1 file changed, 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 530f682..7ae7079 100644 --- a/SPEC.md +++ b/SPEC.md @@ -298,7 +298,6 @@ The client shall have tooling to generate Merkle proofs. This feature will be us 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: From abccff82e1066fe94ba157b1b1091c9b0fc3a53c Mon Sep 17 00:00:00 2001 From: Gal Rogozinski Date: Wed, 10 Dec 2025 16:22:08 +0200 Subject: [PATCH 23/23] 32 ETH --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index 7ae7079..aa7f20b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -97,7 +97,7 @@ For each cluster `c`: - `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 or equal to the (EJECTION_BALANCE)(https://eth2book.info/capella/annotated-spec/#validator-cycle) of 16 ETH, +If the `effectiveBalance` of a validator is below or equal to the 32 ETH, then round it up to 32 ETH for cluster sum calculations. ---