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
336 changes: 336 additions & 0 deletions sips/short_checkpoint_sync.md
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.

Copy link
Copy Markdown
Contributor

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 🫨


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pls, maybe let's first define canonical_spec_version before defining something that depends on it, just for readability


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`

@momosh-ssv momosh-ssv Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?
The event list and OperatorRecordV1 carry no fee or whitelist data, so a checkpoint-synced node would start without the fee state it needs for liquidation math (and would have skipped the declarations before B).

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 B and reactivated after B would have no shares in the checkpoint, and the later ClusterReactivated would have nothing to restore. Line 53 only omits removed validators, and the canonical public state keeps liquidated clusters (the liquidated flag at line 118), so the share set might end up inconsistent with it.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One small thing on keccak-over-serialization vs SSZ hash_tree_root — this looks like a deliberate and reasonable choice, but it does give up SSZ Merkle inclusion proofs (a node can't prove a single record against the root without re-deriving the whole container). Might be worth a sentence making that tradeoff explicit?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should we pin concrete values for the MAX_* limits? MAX_OPERATORS, MAX_VALIDATORS, MAX_CLUSTERS, MAX_CLUSTER_OPERATORS (SSV uses 4/7/10/13), MAX_LOG_DATA_BYTES, etc. are referenced but never given values.

It might also be worth noting that, because roots are keccak256(ssz_serialize(...)) rather than SSZ hash_tree_root, these limits don't actually affect root determinism (serialization doesn't encode capacity) — but they're still interop/DoS-relevant, so we'd probably want them fixed before implementation.


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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Agree

canonical_spec_version: uint64,
network_id: uint64,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could we clarify network_id a little — is it the execution-layer chain id? Might be worth spelling out. It could also help to mention that the SSV DomainType (which shares are actually bound to) is local config and intentionally not committed here, since it's load-bearing for share usage.

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],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 state_roots, so it'd probably be good to pin this down in the spec body. wdyt?

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

cluster_id matches the implementation's ComputeClusterIDHash = keccak256(owner + sorted operator IDs) ✅. As an optional bit of defense-in-depth, maybe the importer could recompute cluster_id from (owner, sorted operator_ids) and reject on mismatch, rather than trusting the bundle's stored bytes? Just a thought.

owner: Vector[byte, 20],
operator_ids: List[uint64, MAX_CLUSTER_OPERATORS],
liquidated: boolean,
]

OwnerRecordV1 = Container[
owner: Vector[byte, 20],
fee_recipient: Vector[byte, 20],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could we define the default fee_recipient and the owner-inclusion criterion? I think this one matters for determinism. GetFeeRecipient returns not-found when no FeeRecipientAddressUpdated was ever emitted — the owner→owner-address default is applied at duty time, not stored. So it'd help to spell out (a) what fee_recipient holds for such owners and (b) exactly when an owner shows up in owners at all.

It's probably common rather than edge: the nonce lives in the same RecipientData record, so an owner can have a non-zero next_validator_nonce while never having set a fee recipient — and both feed the state root.

next_validator_nonce: uint64,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 ValidatorAdded is swallowed so the block still commits, so rejected registrations still advance the nonce. Since that currently only surfaces as a test vector at line 305, maybe we should make it a normative rule in the canonical-state computation — otherwise implementations could diverge on next_validator_nonce. wdyt?

]

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],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Vector[byte, 256] matches today's contract-enforced encryptedKeyLength = 256 ✅. One thing to keep in mind: the repo already has ecies_share_encryption.md, and a migration there would change both this fixed size and the "SSV RSA public key format" assumption (line 239). Might be worth revisiting "Dependency SIP: (none)" and adding a short forward-compat note — feels like part of what canonical_spec_version is there for. wdyt?

]
```

**Checkpoint Bundle**

The mandatory v1 interchange object is `CheckpointBundleV1`:

```text
CheckpointBundleV1 = Container[
certificate_message: CheckpointCertificateMessageV1,
signatures: List[CheckpointSignatureV1, MAX_CHECKPOINT_SIGNATURE_RECORDS],

@momosh-ssv momosh-ssv Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we pin a canonical ordering for signatures?
Every canonical list gets a deterministic order (lines 64-71) but this one doesn't, so two aggregators with the same valid signature set would serialize different CheckpointBundleV1 bytes — which breaks any dedup or caching keyed on the bundle hash.

Sorting by public_key and deduping would make the bundle byte-deterministic.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. delta_log_set_hash (here), parent_checkpoint_hash, LogSetV1, and RelevantSsvLogV1 all get detailed definitions, but no Import Flow step (lines 259–275) recomputes delta_log_set_hash or validates parent_checkpoint_hash — so an importer would accept a checkpoint while effectively ignoring all of it, and a fresh node has no parent to chain from anyway.

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

@momosh-ssv momosh-ssv Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth considering an explicit story for signer-key rotation and revocation.
Cross-network/contract replay is already prevented since the certificate binds network_id and ssv_contract_address — but there's no validity window or signer-set epoch, so a compromised signer key makes every certificate it ever signed acceptable to any node still listing it, with no way to revoke a single bad checkpoint.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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],

@momosh-ssv momosh-ssv Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we define the canonical byte form of this public_key?
The same concern raised for operator keys in canonical state applies here: the local-config match below (line 247) is byte equality, so a legitimate signer whose key is stored in a different encoding than the bundle carries wouldn't count.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks like schema_version is committed but never validated — this step checks canonical_spec_version and scheme_version only. With three version fields in the certificate (schema_version at line 198, canonical_spec_version, scheme_version), maybe we could define how they relate and either validate schema_version here or drop it? wdyt?

3. Verify `network_id` and `ssv_contract_address` match local configuration.

@momosh-ssv momosh-ssv Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to also check the embedded fields in canonical_state and share_set?
They each carry their own network_id / ssv_contract_address / canonical_spec_version, but this step validates only the certificate's.

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

@momosh-ssv momosh-ssv Jun 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What happens if the importing node can't independently confirm finality here?
A fresh node has no trusted way to verify block_hash is the canonical finalized block at this height without a finality-aware execution/consensus source — and this check is what the whole trust model rests on. If that precondition isn't met, step 4 becomes a no-op and a signer quorum could attest to any (block_number, block_hash).

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

these will be spec tests.
We never defined them in SIP before, but Diego added it now and I find it useful.


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