Add short checkpoint sync SIP - #97
Conversation
|
|
||
| ```text | ||
| CanonicalStateV1 = Container[ | ||
| domain: Bytes32, |
There was a problem hiding this comment.
I think domains are kinda redundant because of SSZ typing and canonical encoding.. we can leave them for best practice
0895be1 to
cc7282f
Compare
cc7282f to
360d924
Compare
| - use the SSV RSA public key format; | ||
| - sign the 32 byte `certificate_message_hash`; | ||
| - use RSASSA-PKCS1-v1_5 with SHA-256; |
There was a problem hiding this comment.
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
| 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 signer sets; | ||
| - invalid signatures, duplicate signer ids, retired signers, and missing implementation quorum; | ||
| - monotonicity and maximum age rejection. |
There was a problem hiding this comment.
these will be spec tests.
We never defined them in SIP before, but Diego added it now and I find it useful.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iurii-ssv
left a comment
There was a problem hiding this comment.
Nice work on the shorter spec. I left some inline notes on specific lines; most are around canonical-encoding determinism and completeness, which I think matter for a signed-checkpoint scheme: if two honest signers serialize the same logical state a little differently, they'd sign different roots and no checkpoint would reach threshold.
None of this is meant to block the discussion — mostly things I think we'd want to pin down before implementation. The determinism ones (operator-key encoding, default fee recipient, the nonce rule) feel the most important to me.
| - 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.
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:
ValidatorRemoveddeletes the share (irreversible).ClusterLiquidatedonly flipsshare.Liquidatedand re-saves (handler); the shares are kept soClusterReactivatedcan flip them back (it finds them viaByClusterIDHash).
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?
|
|
||
| **Canonical Encoding** | ||
|
|
||
| V1 MUST use SSZ serialization for canonical containers and `keccak256(ssz_serialize(container))` for roots. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 | ||
| 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.
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.
| CanonicalStateV1 = Container[ | ||
| domain: Bytes32, | ||
| canonical_spec_version: uint64, | ||
| network_id: uint64, |
There was a problem hiding this comment.
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.
|
|
||
| OperatorRecordV1 = Container[ | ||
| operator_id: uint64, | ||
| canonical_public_key: List[byte, MAX_OPERATOR_PUBLIC_KEY_BYTES], |
There was a problem hiding this comment.
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?
|
|
||
| OwnerRecordV1 = Container[ | ||
| owner: Vector[byte, 20], | ||
| fee_recipient: Vector[byte, 20], |
There was a problem hiding this comment.
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.
| OwnerRecordV1 = Container[ | ||
| owner: Vector[byte, 20], | ||
| fee_recipient: Vector[byte, 20], | ||
| next_validator_nonce: uint64, |
There was a problem hiding this comment.
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?
| 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.
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?
| 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. |
There was a problem hiding this comment.
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?
| 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.
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?
| 1. Parse exactly one SSZ `CheckpointBundleV1`; reject malformed SSZ or trailing bytes. | ||
| 2. Reject unsupported `canonical_spec_version` or `scheme_version`. | ||
| 3. Verify `network_id` and `ssv_contract_address` match local configuration. | ||
| 4. Verify `block_hash` belongs to finalized block `B`. |
There was a problem hiding this comment.
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.
|
|
||
| **Signatures** | ||
|
|
||
| A checkpoint is accepted only if enough unique locally trusted signer public keys sign the same `certificate_message_hash`. |
There was a problem hiding this comment.
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.
| ```text | ||
| CheckpointBundleV1 = Container[ | ||
| certificate_message: CheckpointCertificateMessageV1, | ||
| signatures: List[CheckpointSignatureV1, MAX_CHECKPOINT_SIGNATURE_RECORDS], |
There was a problem hiding this comment.
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.
|
|
||
| ```text | ||
| CheckpointSignatureV1 = Container[ | ||
| public_key: List[byte, MAX_SIGNER_PUBLIC_KEY_BYTES], |
There was a problem hiding this comment.
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.
|
|
||
| 1. Parse exactly one SSZ `CheckpointBundleV1`; reject malformed SSZ or trailing bytes. | ||
| 2. Reject unsupported `canonical_spec_version` or `scheme_version`. | ||
| 3. Verify `network_id` and `ssv_contract_address` match local configuration. |
There was a problem hiding this comment.
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.
| - `ValidatorRemoved` | ||
| - `ClusterLiquidated` | ||
| - `ClusterReactivated` | ||
| - `FeeRecipientAddressUpdated` |
There was a problem hiding this comment.
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?
|
|
||
| V1 MUST use SSZ serialization for canonical containers and `keccak256(ssz_serialize(container))` for roots. | ||
|
|
||
| Fixed domains are `Bytes32` values made by right-padding the ASCII domain label |
There was a problem hiding this comment.
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.
|
|
||
| **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. |
There was a problem hiding this comment.
@GalRogozinski this gives me goose bumps on suddenly Greedy topic assignment becoming possible again 🫨
|
|
||
| **Canonical State** | ||
|
|
||
| A v1 checkpoint is valid only for `canonical_spec_version = 1`. |
There was a problem hiding this comment.
Pls, maybe let's first define canonical_spec_version before defining something that depends on it, just for readability
| - `ClusterReactivated` | ||
| - `FeeRecipientAddressUpdated` | ||
|
|
||
| `ValidatorExited` MUST NOT change checkpoint state. |
|
|
||
| **Canonical Encoding** | ||
|
|
||
| V1 MUST use SSZ serialization for canonical containers and `keccak256(ssz_serialize(container))` for roots. |
There was a problem hiding this comment.
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) 🤔
|
|
||
| ```text | ||
| CanonicalStateV1 = Container[ | ||
| domain: Bytes32, |
Summary
Adds
sips/short_checkpoint_sync.md, a shorter checkpoint sync SIP.This PR is a more concise version of #96.
Validation