-
Notifications
You must be signed in to change notification settings - Fork 6
Add short checkpoint sync SIP #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
360d924
78339d4
eea50f4
4bf21c4
94c5abd
89d5207
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,336 @@ | ||
| | Author | Title | Category | Status | Dependency SIP | Date | | ||
| | ------ | ----- | -------- | ------ | -------------- | ---- | | ||
| | Diego Marin Santos ([@diegomrsantos](https://github.com/diegomrsantos)), Gal Rogozinski ([@GalRogozinski](https://github.com/GalRogozinski)) | Canonical State Root and Checkpoint Sync - Short Spec | Core | open-for-discussion | (none) | 2026-06-09 | | ||
|
|
||
| [Discussion](https://github.com/ssvlabs/SIPs/discussions/93) | ||
|
|
||
| **Summary** | ||
|
|
||
| Define a signed checkpoint format that lets a node import recent SSV registry state without replaying all historical SSV contract logs. The checkpoint commits to a finalized block, a canonical public state root, and an active encrypted share set root. | ||
|
|
||
| **Motivation** | ||
|
|
||
| SSV nodes currently reconstruct registry state by replaying contract logs from deployment. This becomes slower and more fragile as history grows and old logs become harder to access. Checkpoint sync gives new or recovering nodes a bounded startup path while keeping full replay as the audit fallback. | ||
|
|
||
| The checkpoint must still prove completeness of the imported state. A bundle that merely parses is not enough: it could omit operators, validators, clusters, or encrypted shares. Therefore importers accept only canonical roots signed by a trusted threshold of independent signers and bound to a finalized block. | ||
|
|
||
| **Specification** | ||
|
|
||
| **Canonical State** | ||
|
|
||
| A v1 checkpoint is valid only for `canonical_spec_version = 1`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pls, maybe let's first define |
||
|
|
||
| To compute canonical state at block `B`, apply all relevant SSV contract logs from deployment through finalized block `B`, in `(block_number, transaction_index, log_index)` order. | ||
|
|
||
| Relevant events: | ||
|
|
||
| - `OperatorAdded` | ||
| - `OperatorRemoved` | ||
| - `ValidatorAdded` | ||
| - `ValidatorRemoved` | ||
| - `ClusterLiquidated` | ||
| - `ClusterReactivated` | ||
| - `FeeRecipientAddressUpdated` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we clarify whether operator-fee and whitelist state is intentionally out of scope? If that's deliberate, worth saying so and noting how it's reconstructed; otherwise those events/fields may be missing. wdyt? |
||
|
|
||
| `ValidatorExited` MUST NOT change checkpoint state. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not? |
||
|
|
||
| The canonical public state MUST include: | ||
|
|
||
| - operators: `operator_id`, canonical public key, owner, removed status; | ||
| - validators: validator public key, owner, cluster id; | ||
| - clusters: cluster id, owner, sorted member operator ids, liquidation status; | ||
| - owners: owner address, fee recipient, next validator nonce. | ||
|
|
||
| The active encrypted share set MUST include one encrypted share record per active validator and cluster operator: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we nail down what "active" means here? I think this one's worth getting right before implementation, since removal and liquidation behave quite differently in the implementation:
So if "active" is read as "not liquidated", a cluster liquidated before Maybe we should define "active validator" as added and not removed, independent of liquidation, and say liquidated (non-removed) clusters' shares are included — then point the rule at line 292 at that definition? wdyt? |
||
|
|
||
| - owner; | ||
| - validator public key; | ||
| - cluster id; | ||
| - operator id; | ||
| - share public key; | ||
| - encrypted share bytes. | ||
|
|
||
| Removed validators and their encrypted shares MUST be omitted. Removed operators MUST be kept only while still referenced by an active cluster. | ||
|
|
||
| **Canonical Encoding** | ||
|
|
||
| V1 MUST use SSZ serialization for canonical containers and `keccak256(ssz_serialize(container))` for roots. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One small thing on keccak-over-serialization vs SSZ
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can't think of any existing use-case for which we need it right now but it's definitely a nice property that may come by free! Actually, one future use-case may be any app with ssv validators as backbone. These merkle proofs may be crucial. On the other hand, for that, couldn't one just read from the current Ethereum state (via eth_get for our contract's storage) 🤔 |
||
|
|
||
| Fixed domains are `Bytes32` values made by right-padding the ASCII domain label | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be worth stating that domain labels MUST be ≤ 32 ASCII bytes (and what happens if one isn't) — right now the right-padding rule doesn't bound the label length. |
||
| with zero bytes. | ||
|
|
||
| The checkpoint MUST define fixed SSZ limits for all lists. A client MUST reject data that cannot fit those limits. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we pin concrete values for the It might also be worth noting that, because roots are |
||
|
|
||
| Ordering MUST be deterministic: | ||
|
|
||
| - operators by ascending `operator_id`; | ||
| - validators by validator public key, then owner; | ||
| - clusters by `cluster_id`; | ||
| - owners by owner address; | ||
| - encrypted shares by validator public key, then owner, then `operator_id`; | ||
| - cluster members by ascending `operator_id`. | ||
|
|
||
| The state root preimage MUST include: | ||
|
|
||
| - fixed domain `STATE_ROOT_V1`; | ||
| - `canonical_spec_version`; | ||
| - `network_id`; | ||
| - `ssv_contract_address`; | ||
| - canonical operators, validators, clusters, and owners. | ||
|
|
||
| The share set root preimage MUST include: | ||
|
|
||
| - fixed domain `SHARE_SET_ROOT_V1`; | ||
| - `canonical_spec_version`; | ||
| - `network_id`; | ||
| - `ssv_contract_address`; | ||
| - active encrypted share records. | ||
|
|
||
| ```text | ||
| CanonicalStateV1 = Container[ | ||
| domain: Bytes32, | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think domains are kinda redundant because of SSZ typing and canonical encoding.. we can leave them for best practice
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree |
||
| canonical_spec_version: uint64, | ||
| network_id: uint64, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we clarify |
||
| ssv_contract_address: Vector[byte, 20], | ||
| operators: List[OperatorRecordV1, MAX_OPERATORS], | ||
| validators: List[ValidatorRecordV1, MAX_VALIDATORS], | ||
| clusters: List[ClusterRecordV1, MAX_CLUSTERS], | ||
| owners: List[OwnerRecordV1, MAX_OWNERS], | ||
| ] | ||
|
|
||
| OperatorRecordV1 = Container[ | ||
| operator_id: uint64, | ||
| canonical_public_key: List[byte, MAX_OPERATOR_PUBLIC_KEY_BYTES], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should define the canonical byte form of the operator key explicitly — this feels like one of the more important ones. On-chain operator keys are stored as a base64 string of an RSA public key. The field is named "canonical", and "operator public key decoding differences" shows up only as a test vector (line 303), but the exact canonical bytes aren't defined anywhere (raw on-chain base64 bytes? decoded DER/PKIX?). If two signers normalize differently they'd get different |
||
| owner: Vector[byte, 20], | ||
| removed: boolean, | ||
| ] | ||
|
|
||
| ValidatorRecordV1 = Container[ | ||
| validator_public_key: Vector[byte, 48], | ||
| owner: Vector[byte, 20], | ||
| cluster_id: Bytes32, | ||
| ] | ||
|
|
||
| ClusterRecordV1 = Container[ | ||
| cluster_id: Bytes32, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| owner: Vector[byte, 20], | ||
| operator_ids: List[uint64, MAX_CLUSTER_OPERATORS], | ||
| liquidated: boolean, | ||
| ] | ||
|
|
||
| OwnerRecordV1 = Container[ | ||
| owner: Vector[byte, 20], | ||
| fee_recipient: Vector[byte, 20], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we define the default It's probably common rather than edge: the nonce lives in the same |
||
| next_validator_nonce: uint64, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should spell out the nonce-advancement rule normatively — right now it's only hinted at in Tests. In the implementation the nonce is bumped before signature/shares validation, and a malformed |
||
| ] | ||
|
|
||
| ShareSetV1 = Container[ | ||
| domain: Bytes32, | ||
| canonical_spec_version: uint64, | ||
| network_id: uint64, | ||
| ssv_contract_address: Vector[byte, 20], | ||
| shares: List[EncryptedShareRecordV1, MAX_ENCRYPTED_SHARE_RECORDS], | ||
| ] | ||
|
|
||
| EncryptedShareRecordV1 = Container[ | ||
| owner: Vector[byte, 20], | ||
| validator_public_key: Vector[byte, 48], | ||
| cluster_id: Bytes32, | ||
| operator_id: uint64, | ||
| share_public_key: Vector[byte, 48], | ||
| encrypted_share_bytes: Vector[byte, 256], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ] | ||
| ``` | ||
|
|
||
| **Checkpoint Bundle** | ||
|
|
||
| The mandatory v1 interchange object is `CheckpointBundleV1`: | ||
|
|
||
| ```text | ||
| CheckpointBundleV1 = Container[ | ||
| certificate_message: CheckpointCertificateMessageV1, | ||
| signatures: List[CheckpointSignatureV1, MAX_CHECKPOINT_SIGNATURE_RECORDS], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we pin a canonical ordering for Sorting by |
||
| canonical_state: CanonicalStateV1, | ||
| share_set: ShareSetV1, | ||
| ] | ||
| ``` | ||
|
|
||
| Importers MUST verify only this SSZ bundle. Transport metadata, file names, side tables, indexes, mirrors, compression digests, and caches MUST NOT be trusted or imported as state. | ||
|
|
||
| **Certificate Message** | ||
|
|
||
| Signers sign: | ||
|
|
||
| ```text | ||
| certificate_message_hash = keccak256(ssz_serialize(CheckpointCertificateMessageV1)) | ||
| ``` | ||
|
|
||
| The certificate message MUST include: | ||
|
|
||
| - fixed domain `CHECKPOINT_CERT_V1`; | ||
| - schema version; | ||
| - canonical spec version; | ||
| - network id; | ||
| - SSV contract address; | ||
| - finalized block number; | ||
| - finalized block hash; | ||
| - parent checkpoint hash, or zero for an initial checkpoint; | ||
| - `state_root`; | ||
| - `share_set_root`; | ||
| - delta log set hash, or zero for an initial checkpoint; | ||
| - signature scheme version. | ||
|
|
||
| For a child checkpoint, `delta_log_set_hash` MUST equal | ||
| `keccak256(ssz_serialize(LogSetV1))` with domain `DELTA_LOG_SET_V1`, | ||
| `from_block_number = parent.block_number + 1`, and `to_block_number = B`. | ||
| The range is inclusive. `from_block_number` and `to_block_number` are part of | ||
| the preimage even when `events` is empty. | ||
|
Comment on lines
+183
to
+187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one feels worth a closer look: the delta-log / parent-chain machinery is fully specified but doesn't seem to be consumed anywhere. Maybe we should either wire verification into the import flow (and say who performs it and when), or drop it for now? Since this is meant to be the "short" spec, carrying a large unused mechanism might be working against that goal. wdyt? |
||
|
|
||
| `events` MUST contain the raw Ethereum logs for the relevant SSV events emitted | ||
| by `ssv_contract_address` in the range, ordered by ascending `(block_number, | ||
| transaction_index, log_index)`. Each event `address` MUST equal | ||
| `ssv_contract_address`; `topics` and `data` MUST be the exact bytes returned by | ||
| the execution data source. | ||
|
|
||
| ```text | ||
| CheckpointCertificateMessageV1 = Container[ | ||
| domain: Bytes32, | ||
| schema_version: uint64, | ||
| canonical_spec_version: uint64, | ||
| network_id: uint64, | ||
| ssv_contract_address: Vector[byte, 20], | ||
| block_number: uint64, | ||
| block_hash: Bytes32, | ||
| parent_checkpoint_hash: Bytes32, | ||
| state_root: Bytes32, | ||
| share_set_root: Bytes32, | ||
| delta_log_set_hash: Bytes32, | ||
| scheme_version: uint64, | ||
| ] | ||
|
|
||
| LogSetV1 = Container[ | ||
| domain: Bytes32, | ||
| canonical_spec_version: uint64, | ||
| network_id: uint64, | ||
| ssv_contract_address: Vector[byte, 20], | ||
| from_block_number: uint64, | ||
| to_block_number: uint64, | ||
| events: List[RelevantSsvLogV1, MAX_LOG_EVENTS], | ||
| ] | ||
|
|
||
| RelevantSsvLogV1 = Container[ | ||
| block_number: uint64, | ||
| block_hash: Bytes32, | ||
| transaction_index: uint64, | ||
| log_index: uint64, | ||
| address: Vector[byte, 20], | ||
| topics: List[Bytes32, MAX_LOG_TOPICS], | ||
| data: List[byte, MAX_LOG_DATA_BYTES], | ||
| ] | ||
| ``` | ||
|
|
||
| **Signatures** | ||
|
|
||
| A checkpoint is accepted only if enough unique locally trusted signer public keys sign the same `certificate_message_hash`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth considering an explicit story for signer-key rotation and revocation. Local trusted config is effectively the only revocation lever — might be worth saying so, plus a note on rotation. |
||
|
|
||
| V1 signature rules: | ||
|
|
||
| - use dedicated checkpoint signing keys; | ||
| - use the SSV RSA public key format; | ||
| - sign the 32 byte `certificate_message_hash`; | ||
| - use RSASSA-PKCS1-v1_5 with SHA-256; | ||
|
Comment on lines
+239
to
+241
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Regarding the RSA signing, it is being used here because in the discussion we said that maybe operators will sign. If we decide at the end that Operators won't sign we can switch to a more sensible scheme |
||
| - count each signer public key at most once; | ||
| - ignore unknown, duplicate, malformed, wrong-scheme, or invalid signatures. | ||
|
|
||
| Each SSV node MUST define its locally trusted checkpoint signer public keys and | ||
| the minimum number of valid signatures required for import. A signature counts | ||
| only if its public key is present in the node's local trusted configuration and | ||
| the signature verifies against `certificate_message_hash`. | ||
|
|
||
| ```text | ||
| CheckpointSignatureV1 = Container[ | ||
| public_key: List[byte, MAX_SIGNER_PUBLIC_KEY_BYTES], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we define the canonical byte form of this Pinning the exact form (DER / PKCS#1 / base64?) would avoid that. |
||
| signature: List[byte, MAX_CHECKPOINT_SIGNATURE_BYTES], | ||
| ] | ||
| ``` | ||
|
|
||
| **Import Flow** | ||
|
|
||
| A node importing a checkpoint MUST: | ||
|
|
||
| 1. Parse exactly one SSZ `CheckpointBundleV1`; reject malformed SSZ or trailing bytes. | ||
| 2. Reject unsupported `canonical_spec_version` or `scheme_version`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like |
||
| 3. Verify `network_id` and `ssv_contract_address` match local configuration. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to also check the embedded fields in The roots bind them only if the importer also requires those embedded fields to equal the certificate's — otherwise a bundle could carry state tagged for a different network and still pass once the roots match. Might be worth an explicit equality check here. |
||
| 4. Verify `block_hash` belongs to finalized block `B`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What happens if the importing node can't independently confirm finality here? Might be worth stating explicitly that the importer MUST have a synced, trusted EL+CL — checkpoint sync skips replay, not finality. |
||
| 5. Verify certificate signatures against locally trusted checkpoint signer public keys. | ||
| 6. Reject unless the number of unique valid trusted signer public keys meets the local threshold. | ||
| 7. Recompute `state_root` and `share_set_root` from the bundle contents. | ||
| 8. Reject if either recomputed root differs from the certificate message. | ||
| 9. Reject duplicate or inconsistent canonical records. | ||
| 10. Reject missing, extra, duplicate, nonmember, or cluster-mismatched encrypted shares. | ||
| 11. Reject checkpoints older than the node's maximum accepted age. | ||
| 12. Reject checkpoints at or before the node's current local state height. | ||
| 13. Populate storage only from verified `canonical_state` and `share_set`. | ||
| 14. Decrypt and validate the node's own share before using it. | ||
| 15. Continue normal sync from block `B + 1`. | ||
|
|
||
| **Bundle Consistency Rules** | ||
|
|
||
| An importer MUST reject: | ||
|
|
||
| - duplicate operators by `operator_id`; | ||
| - duplicate validators by `(validator_public_key, owner)`; | ||
| - duplicate clusters by `cluster_id`; | ||
| - duplicate owners by owner address; | ||
| - duplicate encrypted shares by `(validator_public_key, owner, operator_id)`; | ||
| - validators referencing missing clusters; | ||
| - validators whose owner differs from the cluster owner; | ||
| - clusters referencing missing operators; | ||
| - shares referencing missing validators; | ||
| - shares whose cluster id differs from the validator cluster id; | ||
| - shares for operators outside the validator's cluster; | ||
| - active validators without exactly one encrypted share per cluster operator. | ||
|
|
||
| **Fallback** | ||
|
|
||
| If checkpoint import fails, the node MUST NOT partially import checkpoint state. It MAY fall back to full log replay or another locally configured sync path. | ||
|
|
||
| **Tests** | ||
|
|
||
| Implementations SHOULD include conformance vectors for: | ||
|
|
||
| - canonical event folding and deterministic roots; | ||
| - operator public key decoding differences; | ||
| - validator identity by `(validator_public_key, owner)`; | ||
| - owner nonce advancement, including rejected `ValidatorAdded` events; | ||
| - removed validator, removed operator, and liquidated cluster behavior; | ||
| - valid and invalid bundle SSZ; | ||
| - root mismatch; | ||
| - duplicate and inconsistent canonical records; | ||
| - missing, extra, duplicate, nonmember, and mismatched encrypted shares; | ||
| - valid and invalid local signer configurations; | ||
| - invalid signatures, duplicate signer public keys, unknown signer public keys, and insufficient threshold; | ||
| - monotonicity and maximum age rejection. | ||
|
Comment on lines
+300
to
+313
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these will be spec tests. |
||
|
|
||
| **Security Requirements** | ||
|
|
||
| The checkpoint publisher is untrusted. Trust is only in finalized chain data, local importer rules, local trusted signer public keys, and valid threshold signatures over the certificate message. | ||
|
|
||
| Checkpoint sync MUST NOT include plaintext validator shares. | ||
|
|
||
| A checkpoint certificate proves agreement on the signed roots. It does not prove the canonical spec is correct, that all signers are honest, or that full historical audit is unnecessary. | ||
|
|
||
| **Open Questions** | ||
|
|
||
| - **Signature scheme.** V1 currently specifies the SSV RSA operator key format | ||
| with RSASSA-PKCS1-v1_5 / SHA-256. Which signing scheme should checkpoint | ||
| certificates actually use? | ||
| - Reuse the existing scheme operators already sign with (RSA, despite its | ||
| drawbacks): no new key management, but inherits RSA's weaknesses. | ||
| - Use GPG: mature tooling and key distribution, at the cost of a heavier | ||
| dependency and format. | ||
| - Choose an independent scheme of our own (e.g. an EC signature scheme): | ||
| smaller keys and signatures, but new key management for signers. | ||
| - Be post-quantum proof from the start (e.g. a lattice-based scheme): future | ||
| resistance against quantum attacks, at the cost of larger signatures, less | ||
| mature tooling, and added complexity now. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@GalRogozinski this gives me goose bumps on suddenly Greedy topic assignment becoming possible again 🫨