Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 335 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
### 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 or equal to the 32 ETH,
Copy link
Contributor

Choose a reason for hiding this comment

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

just below :)

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
Copy link
Contributor

Choose a reason for hiding this comment

The 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`).


---

#### 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)
Copy link
Contributor

Choose a reason for hiding this comment

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

The UpdateClusterBalance function signature has an inconsistency in units.

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 uint64 in gwei, not wei.

* @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_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.

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)` 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.