From 7b3493ef795812440df2d6a9ef85ba43512a9856 Mon Sep 17 00:00:00 2001 From: diegomrsantos Date: Wed, 10 Jun 2026 19:32:20 +0200 Subject: [PATCH 01/13] Add checkpoint sync SIP draft --- all.md | 1 + sips/checkpoint_sync.md | 270 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 sips/checkpoint_sync.md diff --git a/all.md b/all.md index 5aeaa9e..e644cc5 100644 --- a/all.md +++ b/all.md @@ -19,3 +19,4 @@ | [15](./sips/network_topics_minhash.md) | Network Topics MinHash | networking | spec-merged | 2024-03-06 | | [16](./sips/epoch_aware_round_robin_proposer.md) | Epoch-Aware Round-Robin Proposer | core | spec-merged | 2025-12-13 | | [17](./sips/aggregator_committee_consensus.md) | Aggregator Committee Duties | core | spec-merged | 2025-05-12 | +| [18](./sips/checkpoint_sync.md) | Canonical State Root and Checkpoint Sync | core | open-for-discussion | 2026-06-09 | diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md new file mode 100644 index 0000000..72c57cc --- /dev/null +++ b/sips/checkpoint_sync.md @@ -0,0 +1,270 @@ +| Author | Title | Category | Status | Dependency SIP | Date | +| ------ | ----- | -------- | ------ | -------------- | ---- | +| Diego Marin Santos ([@diegomrsantos](https://github.com/diegomrsantos)) | Canonical State Root and Checkpoint Sync | Core | open-for-discussion | (none) | 2026-06-09 | + +[Discussion](https://github.com/ssvlabs/SIPs/discussions/93) + +**Summary** + +Every SSV node keeps a copy of the network state: the list of operators, validators, and clusters. A node builds that state by folding the SSV smart contract's events in order, starting at the deployment block. The fold needs only the event log and no contract storage reads at old blocks. + +On mainnet, however, that means obtaining and replaying the full log history back to the 2023 deployment block. A checkpoint lets a node start from a recent finalized block without doing that replay, while reconstruction remains available as the audit fallback for anyone who can still obtain the logs. + +The design is the Candidate A path from the discussion: a signed checkpoint that changes no contract. Candidate B would instead publish the state root on the SSV contract itself. It is stronger but needs a contract change and is not specified here. + +A trusted checkpoint has one hard problem. A payload can parse cleanly and still leave out records or describe them wrongly, and the node has no way to notice the missing records unless it checks a commitment to the complete state. This SIP calls that the completeness problem. Spot checks with eth_call can only validate records the node already knows about; they cannot prove that nothing was left out. + +To catch missing public records this SIP uses a canonical state root. A state root is one hash that stands for the entire network state at a block. Canonical means every correct client computes the exact same hash from the same logical input. If two clients, built by two different teams, compute the same root from the same correct event scope and canonical spec, then one implementation cannot silently drop a record without changing the hash. Matching roots prove agreement on the committed record set; they do not prove the event scope or canonical spec is correct, so the full fold audit remains the backstop for shared mistakes. Independent signers compute `state_root` and `share_set_root` from retained state, from a historical materializer, or from a parent checkpoint plus new logs, and sign a certificate message containing those roots. + +The core protocol surface has three parts: (1) the canonical network state and how to encode it, (2) the checkpoint payload format for public state and encrypted shares, and (3) the certificate format and what a certificate is required to mean. The SIP also specifies the client import and verification flow and a conformance test that guards the canonical spec. + +Throughout this SIP, the term logs means the SSV smart contract's event log, which any full node serves over eth_getLogs. + +**Rationale & Design Goals** + +Two independent implementations that fold the same logs should produce the same bytes. Today they do not always agree. Anchor issue [#972](https://github.com/sigp/anchor/issues/972) is a live example: Anchor and go-ssv decode an OperatorAdded operator public key differently, which causes Anchor to silently skip an operator and diverge from the other client. Bugs of this kind are invisible until two implementations are compared byte for byte at the same block. + +The design goals follow from that observation. + +- A node that skips the fold must still be protected against an incomplete or misrepresented state. The protection is agreement across implementations on a canonical root, not trust in whoever published the file. +- The canonical state must be specified down to encoding, ordering, and application semantics, so that agreement is meaningful and so that the encoding choices where two clients can silently disagree (such as the one behind #972) are pinned down in one place rather than rediscovered by each client team. +- Checkpoint sync removes the need to replay all history at startup while preserving reconstruction from the deployment block as the audit path for anyone who can still obtain the logs. +- The format must allow the signature scheme to be replaced later without a format break. +- The mechanism must commit at blocks that are permanent, so that no reorg can invalidate a published checkpoint. + +The sequencing matters. The canonical state spec is the precondition for everything else. The differential conformance check enforces it. Checkpoints and certificates sit on top. + +**Specification** + +This version defines a closed v1 canonical profile. A checkpoint accepted under `canonical_spec_version = 1` is valid only under the rules below. Any alternative root shape, operator identity rule, removal representation, operator public key decoding rule, or nonce transition is a new canonical spec version, not an implementation choice inside v1. + +**1. Canonical Global State** + +To build the canonical global state at block B, start with empty state and apply every relevant SSV event in block order, from the deployment block through block B. The result is the canonical global state at B. The relevant events are OperatorAdded, OperatorRemoved, ValidatorAdded, ValidatorRemoved, ClusterLiquidated, ClusterReactivated, and FeeRecipientAddressUpdated. ValidatorExited is deliberately not in this set; see Excluded from the state root. + +The canonical state root is a pure function of this fold. It is defined over the record set included in scope in canonical form, not over any client's database or representation in memory. A conforming implementation must be able to materialize the full canonical record set before hashing, no matter what it keeps at runtime. Storage choices are explicitly outside the hashed state: whether a client retains only its own shares, whether it deletes a removed record or keeps a flagged tombstone, and similar optimizations must not change the root. + +The block number is a reference point that records when the state was taken. It is not part of the hashed state itself. + +**Records in scope.** The following records are derived from the logs and are identical for all conforming nodes. + +| Record | Fields | +| ------ | ------ | +| Operator | operator_id (sequential, assigned by the contract), canonical_public_key, owner address, removed status | +| Validator | validator public key (BLS), cluster it belongs to | +| Cluster | cluster_id (derived, see below), owner address, sorted member operator_ids, liquidated flag | +| Owner | owner address, fee_recipient, next_validator_nonce | + +**Counters are not a substitute for records.** The canonical root must commit to the full set of records included in scope by content. It must not substitute any monotonic counter, count, or high water mark for the record set. A counter such as a highest operator id seen value can advance on both clients while one of them is missing an operator's record, which defeats completeness. #972 is exactly this trap: the omitted operator changes the operator record set, and the root catches it only because the root commits to records, not to the operator id counter. + +**Application semantics are part of the canonical state.** The canonical state is defined by the encoding and ordering below, plus the application semantics: which events are accepted versus ignored, the signature and nonce validation rules, and the effect of a rejected event. A root mismatch on identical logs can therefore come from a decoding bug or from a difference in application semantics, and both are in scope for this spec. V1 pins those rules in this section; a client must not substitute its local storage behavior for these canonical fold rules. + +**Owner state and validator nonces.** The canonical Owner record stores `next_validator_nonce`, the nonce value to use for the next ValidatorAdded by that owner. It does not store a client's local representation of the last consumed nonce, and it does not depend on whether a client stores that value as nullable. This distinguishes an owner that only changed its fee recipient, whose next validator nonce is still 0, from an owner that already consumed nonce 0. + +An absent Owner record means the effective defaults: `fee_recipient = owner` and `next_validator_nonce = 0`. A FeeRecipientAddressUpdated event changes the effective `fee_recipient` and leaves `next_validator_nonce` unchanged, using 0 if no previous owner nonce state exists. A ValidatorAdded event is evaluated against the current `next_validator_nonce` for that owner and then advances `next_validator_nonce` by one before the rest of the event is validated. If the ValidatorAdded event is rejected after this point, the nonce advance remains and no validator record, cluster update, or encrypted share records are created. An Owner record is included in the canonical record set when either `fee_recipient != owner` or `next_validator_nonce != 0`; otherwise it is omitted because it is identical to the default state. + +**Excluded from the state root.** The validator index on the beacon chain is fetched from the beacon node, not derived from the logs, so it is not a deterministic function of the logs and is not part of the v1 state root. A future canonical spec version may add a field derived from beacon data, but v1 excludes it. + +ValidatorExited is also excluded. It is a beacon chain signal: it announces that a validator is exiting the beacon chain, not that its SSV registry record has changed. A validator's registry record is removed only by ValidatorRemoved; ValidatorExited leaves the operator, validator, and cluster records untouched. It therefore changes no canonical record and no root. This is distinct from the operator removed status above, which is set only by OperatorRemoved. + +The operator fee and the Cluster accounting fields carried by each event (balance, validatorCount, networkFeeIndex, index, and the active flag) are also excluded: they are mutable contract bookkeeping that varies over time, not part of the canonical state for validator clients, and committing to accumulators that advance on nearly every interaction would change the root far more often than the logical membership state does. Cluster liquidation status is derived from the ClusterLiquidated and ClusterReactivated events, not from the Cluster.active field. In v1, those events update only an included cluster record. If no cluster record is included for that owner and operator set, the event changes no canonical record. A cluster created later starts with `liquidated = false` unless a later ClusterLiquidated event changes it. + +**Removal representation.** V1 pins removal per entity. A ValidatorRemoved event removes the validator record from `state_root` and removes the validator's encrypted share records from `share_set_root`. If that was the last active validator in a cluster, the cluster record is also omitted. An OperatorRemoved event sets `removed = true` for that operator while any active cluster still references it. A removed operator with no active cluster reference is omitted from the canonical record set. Active operators are included even if they are not members of an active cluster. + +**Canonical encoding and ordering.** The following choices are pinned. + +- Operator identity. Operators are keyed by `operator_id` only. The canonical fold must not deduplicate operators by raw event bytes, decoded RSA key bytes, PEM text, owner address, or any other key. If two OperatorAdded events assign two different operator ids but decode to the same RSA public key, v1 still has two operator records. +- Operator public key. OperatorAdded publicKey bytes have appeared in more than one layout, so v1 defines `canonical_public_key` explicitly. First, try to decode the field as the SSV operator public key wrapper, equivalent to ABI decoding one dynamic `bytes` value. If that succeeds and the decoded bytes parse as the expected base64 PEM RSA public key payload, those decoded bytes are the canonical bytes. If wrapper decoding fails, the raw event bytes are accepted only if they parse directly as the same base64 PEM RSA public key payload. Otherwise the OperatorAdded event is rejected as malformed. The canonical bytes are hashed exactly as bytes; clients must not reserialize the key into a different PEM, DER, JSON, or text layout before hashing. If a client cannot decode the key into this canonical form, it must stop or mark the event malformed according to the v1 fold, not silently skip the operator. +- Cluster member set. Operator ids in a cluster are an unordered set in the contract. They are serialized in ascending operator_id order. +- Addresses. All addresses are encoded as their 20 raw bytes when hashed. Any text rendering uses lowercase hex. EIP-55 checksum casing is not used in the hashed form. +- Fee recipient fallback. If no Owner record exists for an owner, the fee recipient defaults to the owner address. +- Owner nonce. Owner records serialize `next_validator_nonce`, the nonce value to use for the next ValidatorAdded by that owner. It starts at 0. Every ValidatorAdded log for the owner advances it exactly once before validation, including a malformed or rejected ValidatorAdded. +- BLS public key. Validator public keys are encoded as their raw compressed bytes when hashed. +- Root domain. The root serialization begins with a domain header containing `STATE_ROOT_V1`, `canonical_spec_version = 1`, `network_id`, and `ssv_contract_address` from the certificate message. This prevents a state root from one network or SSV contract from being reused under another. + +**Cluster id derivation.** The cluster id is derived, not stored, and must match across implementations: + +```text +cluster_id = keccak256( + owner_address_bytes (20 bytes) + ++ for each operator_id in ascending sorted order: + ( 24 zero bytes ++ operator_id as 8-byte big-endian ) +) +``` + +**2. State Root Construction** + +V1 `state_root` is `keccak256` over the v1 root domain header followed by the canonical serialization of all records from section 1. This is the only `state_root` construction accepted under `canonical_spec_version = 1`. keccak256 is chosen to align with Ethereum tooling and with the cluster_id derivation already defined in section 1. + +Record ordering for the root is fully defined so the serialization is deterministic. These orderings at the top level are normative for v1 and must be covered by the conformance tests in section 6. + +- Operators are ordered by ascending operator_id. +- Validators are ordered by their canonical BLS public key bytes. +- Clusters are ordered by their derived cluster_id bytes. +- Owners are ordered by their address bytes. +- Within a cluster, the member operator_ids are in ascending order, as in the cluster_id derivation. + +Two framing rules are normative for any construction below, because keccak256 over a bare concatenation of heterogeneous records is ambiguous: an address with 20 bytes followed by an id with 8 bytes is identical at the byte level to a single field with 28 bytes. The rules: + +- Length prefixes. Every field with variable length and every record is prefixed with its length so its boundaries are unambiguous. Fields with fixed width (the address with 20 bytes, the 8 byte big endian operator_id, the compressed BLS key) may be written raw because their width is fixed and documented. +- Domain separation by record kind. Each record kind (Operator, Validator, Cluster, Owner) carries a fixed kind tag in its serialization, so bytes of one kind can never be interpreted again as another kind at the same root. + +These are not tree shape details; they are required even for the flat hash. A Merkle tree for inclusion proofs is a future canonical spec version because its exact shape must be specified before it can produce a protocol root. + +**3. Checkpoint Format** + +A checkpoint is a certificate plus one or more untrusted payloads. The certificate signs canonical commitments to public state and active encrypted shares. The payloads carry the preimages needed by an importing node to rebuild those commitments without replaying old logs. + +The certificate does not sign one specific snapshot file, compression format, chunking scheme, mirror, or JSON or SSZ layout. Any file layout is acceptable if the importing node can parse it and recompute the signed canonical commitments from its contents. + +**Snapshot payload.** The snapshot carries the canonical global state at block B, serialized in the canonical form of section 1, so that an importing node can populate its storage without folding the logs. + +The snapshot also carries the full active encrypted share set. Each ValidatorAdded event publishes, for every operator in the cluster, a share public key and an encrypted key share. Those ciphertexts are public event data, while the plaintext share remains protected by encryption to the operator's key. A fresh operator bootstrap needs its encrypted share, and client retention differs, so this set is not left to local storage. Therefore the certificate includes a mandatory `share_set_root`, separate from `state_root`. + +`state_root` commits to the public validator client registry state. `share_set_root` commits to the encrypted share records for validators active at block B. It does not commit to encrypted shares for validators that were added and removed before B, because those shares are not needed to bootstrap the current state. + +The `share_set_root` uses the same framing rules as section 2, with its own root domain header and record kind tag. Its domain header contains `SHARE_SET_ROOT_V1`, `canonical_spec_version = 1`, `network_id`, and `ssv_contract_address` from the certificate message. V1 encrypted share records are ordered by validator public key bytes and then by ascending operator_id. Each record contains: + +```text +ENCRYPTED_SHARE_RECORD_V1 +owner +validator_public_key +cluster_id +operator_id +share_public_key +encrypted_share_bytes +``` + +The `cluster_id` is derived from owner and the sorted operator set as defined in section 1. The `encrypted_share_bytes` field carries a length prefix because its size is variable. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. + +**Checkpoint production paths.** The protocol is defined by the records and roots, not by a particular client's database. A process can produce a checkpoint only if it can enumerate the canonical public state and active encrypted share records at block B. That process may be a normal client that retained those records, a client with added retention, a dedicated materializer, or an archive indexer. + +For the initial checkpoint, where `parent_checkpoint_hash` is zero, there are two valid production paths: + +1. Retained state path. The producer already has a complete current registry snapshot at B, including active encrypted share ciphertexts for every validator and operator pair. It exports that state and computes `state_root` and `share_set_root` directly from the canonical records. +2. Historical materializer path. The producer does not have retained encrypted shares. It folds historical registry events through B, materializes the same active public state and encrypted share set, discards encrypted shares for validators removed before B, and computes the same roots. + +A client whose current state does not retain all active encrypted share ciphertexts cannot produce the encrypted share part of the initial checkpoint from that state alone. It must use a materializer, an archive source, or a previous checkpoint that already contains the share set. + +For every checkpoint with a nonzero `parent_checkpoint_hash`, producers start from the parent checkpoint state and apply only the relevant SSV logs from the block after the parent through B. They update the public records and active encrypted share records, compute the new roots, and include `delta_log_set_hash` for that bounded event range. + +The checkpoint bundle may include an optional `snapshot_digest` outside the signed certificate for download integrity, caching, or mirror comparison. That digest is not a trust anchor; import step 4 treats it only as a transport check. + +**Certificate message.** The certificate message is the small object signers sign. + +| Field | Meaning | +| ----- | ------- | +| schema_version | Version of the certificate message format | +| canonical_spec_version | Version of the canonical state definition: the included event set and the application and encoding semantics of section 1 that determine the root | +| network_id | Network and chain id the state belongs to | +| ssv_contract_address | Address of the SSV contract whose registry state is represented | +| block_number | Block B the state was taken at | +| block_hash | Hash of block B | +| parent_checkpoint_hash | Hash of the parent checkpoint certificate message, or zero for an initial checkpoint | +| state_root | Canonical state root from section 2 | +| share_set_root | Canonical root of the active encrypted share set | +| delta_log_set_hash | Hash of the exact ordered list of relevant SSV events consumed after the parent checkpoint, or zero for an initial checkpoint | +| signer_set_id | Identifier of the signer set that may certify this message | +| scheme_version | Identifier of the signature scheme used by certificates | + +For a checkpoint with a parent, `delta_log_set_hash` binds the certificate message to the exact events consumed between the parent checkpoint and B, so that a later comparison or audit can confirm both parties read the same delta logs. For an initial checkpoint, `delta_log_set_hash` is zero. A producer may publish optional audit metadata that records a full historical log hash, but importers do not require it and it is not part of the certificate message. + +The canonical_spec_version is distinct from schema_version: schema_version versions the certificate message format, while canonical_spec_version versions the canonical state definition that determines the root. The root is meaningful only under the definition that produced it, so a future SSV contract upgrade that adds an event that changes state or changes application semantics is a new canonical state generation that bumps canonical_spec_version and requires regenerating the conformance vectors of section 6. An importer rejects a canonical_spec_version it does not implement (import step 1) rather than comparing roots across generations. + +**4. Certificates** + +A certificate is a signature over the certificate message by a member of the signer set. + +**Required meaning.** A certificate attests that the signer independently materialized the canonical public state and active encrypted share set at block B, without trusting the payload being signed, and computed this same `state_root` and `share_set_root` using one of the production paths in section 3. For a child checkpoint, it also attests that the signer consumed the exact ordered event delta identified by `delta_log_set_hash`. A certificate is not an attestation that a downloaded checkpoint file parsed or that a particular payload encoding or hosting path is trustworthy. + +**Acceptance rule.** A checkpoint is accepted only when at least X of the Y signers in the signer set produce matching certificates over the same certificate message. X and Y are a parameter of the signer set; this SIP does not fix them here (for example, 3 of 4). At least one matching certificate must come from a signer running client A, and at least one from a signer running client B. Today those clients are Anchor and go-ssv. This mitigates a shared reconstruction bug in one implementation, such as #972. When implementations disagree on the roots for a block, no checkpoint is published and the disagreement is investigated. + +This rule makes the mechanism intentionally inert until at least two implementations independently compute the same canonical root and validate it against the shared conformance vectors of section 6 at a common block. When signer infrastructure and a second conforming implementation are ready is an onboarding matter (see Out of Scope). + +**Swapping the signature scheme later.** The certificate message records `scheme_version` and `signer_set_id` so a future version can change the signature scheme or rotate the signer set without changing the certificate message format. This includes a move to a post-quantum signature such as ML-DSA. Post-quantum signatures are not mandated for v1. Agility needs a floor; see the downgrade discussion in Security Considerations. + +**Dedicated keys.** The signer keys must be separate keys created only for signing checkpoints. They must not reuse an operator's existing key. Reusing operator keys saves no distribution step, because a bootstrapping node needs the signer public keys before it has any state, so those keys ship with the client or its configuration regardless. They must also use a distinct signing context (domain separation) so a checkpoint signature can never be mistaken for, or replayed as, any other kind of signature. + +**5. Client Import and Verification Flow** + +When a node imports a checkpoint it performs the following checks. A first syncing node, or a node whose last sync is older than the maximum acceptable age, trusts the state itself; it cannot verify completeness without folding the logs, which is the work it is trying to skip. The checks below are the cheap bindings it can still enforce. + +1. Confirm the certificate message network_id and ssv_contract_address match the node's configured network and contract. Reject on mismatch. Reject if canonical_spec_version is one the node does not implement, since a root is only meaningful under the canonical state definition that produced it. +2. Confirm block B is finalized according to the node's consensus data source. Reject if finality cannot be established. A finalized block can no longer be reverted by a chain reorganization (reorg), so the state at B is permanent. Confirm block_hash matches the finalized block. +3. Verify the certificates: each is a valid signature over this certificate message under scheme_version, the signers belong to a signer_set_id the node currently trusts, scheme_version is at or above the node's minimum accepted version, there are at least X matching certificates over the same certificate message, and the agreeing set spans both client implementations. +4. Parse the received payloads. If the bundle includes an optional snapshot_digest, confirm it for download integrity, but do not treat it as a signed trust anchor. +5. Reconstruct the canonical record set and active encrypted share set from the payloads. Recompute `state_root` and `share_set_root` using their canonical serializations. Confirm both roots match the certificate message. +6. Freshness: reject if block B is older than the maximum acceptable age (see below). +7. Monotonicity: reject if B is not newer than the node's current state. A node never imports a checkpoint older than what it already has. +8. On success, populate storage from the snapshot, locating and decrypting the node's own shares from the active encrypted share set, and continue normal sync after block B. + +**Freshness and monotonicity.** Signatures cannot stop replay of an old but valid checkpoint. The defenses are a maximum acceptable age, enforced by the importing node, a finality requirement on B, and the rule that a node never imports a checkpoint older than its current state. Monotonicity protects only a resync, where the node already has state to compare against. A first syncing node has no current state, so monotonicity gives it nothing; its protections are the maximum age, the finality requirement, and the X of Y threshold across implementations. + +**6. Conformance and Testing** + +The canonical spec is only useful if it is enforced. The following checks do that, and they would have caught #972. + +**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, ValidatorAdded rejection after nonce advancement, ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted. + +**Full replay audit run.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point (every N blocks) a tuple: + +```text +(block_number, full_log_set_hash, state_root, share_set_root) +``` + +Generating the tuples is automatic for a client or materializer that has the required historical logs and encrypted share material. Comparing the streams is a separate step performed by a small diff tool. Starting from the deployment block catches a historical bug such as #972, which only appears once the affected event is processed. This is the audit path, not the normal checkpoint production path. + +The `full_log_set_hash` in the tuple is essential to this comparison. Without it, two clients could disagree only because their RPC providers served different events: providers differ in indexing, retention, and gaps. The diff tool compares `full_log_set_hash` first. If the log hashes differ, it is a data source problem, not a reconstruction bug. Only matching log hashes with differing roots indicate a real divergence. `full_log_set_hash` is audit evidence and is not a certificate field. + +**Checkpoint production run.** For a checkpoint with a parent, each signer emits: + +```text +(parent_checkpoint_hash, block_number, delta_log_set_hash, state_root, share_set_root) +``` + +The diff tool first compares the parent checkpoint hash and `delta_log_set_hash`, then compares the roots. This is the normal production comparison for later checkpoints and it does not require logs before the parent checkpoint. For an initial checkpoint, signers compare `(block_number, state_root, share_set_root)` and may also compare optional full replay audit evidence when they have it. + +**Honest limit and complementarity.** Agreement across clients proves that the two clients reconstructed the same state. It does not prove the state is correct; both could share a spec mistake. eth_call spot checks are complementary because they compare known records against contract storage outside both clients, while root agreement catches omitted records that eth_call cannot ask about. Neither check catches a shared mistake in the event signatures, topics, contract address, deployment block, parent checkpoint, or delta range. The full fold from the deployment block remains the audit path for that class of error. + +**Open Questions and Future Versions** + +The following questions are outside the v1 acceptance path. A v1 importer does not choose among these options; it follows the closed profile in sections 1 and 2. + +- O1. Should the checkpoint payload be one combined file with state and shares, or separate payloads for public state and encrypted shares? This is a distribution and file format question only. Acceptance always verifies the mandatory `state_root` and `share_set_root` from the certificate message. Open. +- O2. A future canonical spec version may replace the flat hash with a Merkle tree for inclusion proofs or Candidate B. That version must pin the exact tree shape, including leaf grouping, domain separation for leaves and internal nodes, and odd leaf handling. +- O3. A future canonical spec version may include data derived from beacon state, such as validator index. V1 excludes it because it is not a deterministic function of the SSV logs. +- O4. Post-quantum signature scheme for v1 or later. V1 keeps this optional behind the versioned scheme_version. Open. +- O5. A future canonical spec version may revisit operator identity or public key normalization. V1 keys operators by `operator_id`, does not deduplicate by public key, and hashes the canonical public key bytes defined in section 1. +- O6. A future canonical spec version may revisit tombstone policy. V1 omits removed validators, retains removed operators only while active clusters reference them, and omits clusters with no active validators. + +**Out of Scope** + +The following are operational, not protocol, and are out of scope for this SIP: where checkpoints are hosted and how they are distributed, how signer parties are onboarded, the process for resolving disputes when implementations disagree, and the production cadence of how often checkpoints are generated. How a producer stores, indexes, or retains encrypted share material is also out of scope, as long as the producer can enumerate the canonical records required by this SIP. The maximum age a node will accept is a validation rule enforced by the node and is in scope, as specified in section 5. + +**Security Considerations** + +**Trust assumption.** An importing node that skips the fold trusts the signer set, not whoever published the checkpoint payloads. The publisher is untrusted; a malicious publisher can at most serve payloads that fail to reconstruct the signed `state_root` or `share_set_root`. The acceptance rule in section 4 assumes enough honest signers to prevent a dishonest quorum from reaching X while spanning both implementations. + +**Completeness versus correctness.** Agreement across implementations catches omitted records only because the root commits to the record set itself, not to counters. It does not prove the shared event scope or canonical spec is correct; section 6 describes why eth_call and the full fold audit remain complementary. + +**Reconstruction bug risk.** Two signers running the same client share that client's reconstruction bugs and would sign the same wrong roots. The acceptance rule across implementations mitigates this by requiring at least one certificate from each implementation, and the canonical application semantics in section 1 reduce the space for legitimate root differences. + +**Equivocation and signer dishonesty.** If enough signers collude to reach X and span both implementations, they can sign wrong roots that are internally consistent. Nothing in the format binds a block number B to a single certificate message, so a dishonest quorum or publisher could serve two well formed certificate messages for the same block B and partition nodes onto divergent state. Equivocation is detectable out of band but is not prevented by the format alone. A recommended mitigation is for signers to publish each (block_number, certificate message) into an append only transparency record, so two conflicting signatures over the same block are publicly attributable. + +**Replay of stale checkpoints.** Signatures over an old but valid certificate message stay valid forever, so signatures alone cannot stop replay. Section 5 defines the defenses: maximum acceptable age, finality, and monotonicity where a node already has current state. + +**Downgrade and version attacks.** Cryptographic agility introduces several valid scheme_version, signer_set_id, and schema_version values over time. Without a floor, that enables downgrade: after a post-quantum migration an attacker could replay a checkpoint certified under the old, now weak classical scheme; after key rotation an attacker could replay certificates from a retired signer set. The maximum age rule only partly helps, since an old certificate message under a weak scheme within the age window would still pass. The importing node must therefore enforce a minimum acceptable scheme_version and reject retired signer_set_ids, so a checkpoint signed under a deprecated scheme or a retired signer set is rejected even when its signatures are cryptographically valid and it is within the age window. Agility without a floor is a downgrade vector. + +**Signer key compromise.** A compromised signing key can sign a wrong certificate message. The true threshold is governed by both X and the signer counts for each implementation, so those values must be sized together. Rotation is not instantaneous protection: a node keeps trusting a signer set until it receives a client or configuration update, so revocation latency is bounded by the client release and update cadence. + +**Supply chain dependency.** Signer public keys ship with the client or its configuration, because a bootstrapping node needs them before it has any state. The real root of trust is therefore client release integrity: the release channel delivers both the importer logic and the signer public keys, so a compromised release could defeat every check across implementations at once. This adds no new trust surface beyond trusting the client binary, but it raises the requirements on the release channel: reproducible builds, signed releases, and pinned, auditable signer set ids. + +**Establishing finality.** The block_hash and finality checks in import step 2 are only as trustworthy as the node's beacon and execution data source. A node trusting a malicious RPC for finality could be shown a chain that is not canonical where B and block_hash look consistent. This is an existing trust assumption inherited from normal operation, not one introduced here; a consensus light client that would remove it is out of scope. Normal v1 acceptance requires finality; a node that cannot establish finality rejects the checkpoint. + +**Unsafe recovery mode.** An implementation may offer an operator controlled recovery mode that imports a checkpoint at a block that is not finalized but is buried under a locally configured depth. This mode is outside normal v1 acceptance, must be disabled by default, and must be presented as unsafe because a later reorg can invalidate the imported state. If that happens, the node must discard the checkpoint state and resync from a finalized checkpoint or from logs. + +**Share set correctness.** A malicious publisher cannot omit, corrupt, or replace a joining operator's encrypted share without changing `share_set_root`. A joining node must still decrypt its own share and validate it against the share public key before relying on it, because the root proves the ciphertext and share public key match the certified state, not that the local operator can successfully use the decrypted result. + +**Encrypted share exposure.** The checkpoint must never include plaintext validator shares. It includes encrypted share ciphertexts that are already public in ValidatorAdded event data and transaction calldata. This preserves the existing long term security assumption: the encryption to operator keys and the threshold share scheme must remain sound. If enough operator decryption keys are compromised for the same validator, the validator can be compromised whether the ciphertexts come from historical logs or from a checkpoint. + +**Trustless fallback and its limit.** The signer layer accelerates startup; it does not replace independent reconstruction. Its limit is log availability: as old logs stop being retained by default, the audit increasingly depends on a node or provider that still has them. From 6d1f570d725e9d9754a57920ee8cfde24703ce3a Mon Sep 17 00:00:00 2001 From: diego Date: Wed, 10 Jun 2026 21:01:29 +0200 Subject: [PATCH 02/13] Specify SSZ checkpoint root encoding --- sips/checkpoint_sync.md | 122 ++++++++++++++++++++++++++++++++++------ 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 72c57cc..5f9bcfc 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -51,7 +51,7 @@ The block number is a reference point that records when the state was taken. It | Record | Fields | | ------ | ------ | | Operator | operator_id (sequential, assigned by the contract), canonical_public_key, owner address, removed status | -| Validator | validator public key (BLS), cluster it belongs to | +| Validator | validator public key (BLS), owner address, cluster it belongs to | | Cluster | cluster_id (derived, see below), owner address, sorted member operator_ids, liquidated flag | | Owner | owner address, fee_recipient, next_validator_nonce | @@ -80,7 +80,7 @@ The operator fee and the Cluster accounting fields carried by each event (balanc - Fee recipient fallback. If no Owner record exists for an owner, the fee recipient defaults to the owner address. - Owner nonce. Owner records serialize `next_validator_nonce`, the nonce value to use for the next ValidatorAdded by that owner. It starts at 0. Every ValidatorAdded log for the owner advances it exactly once before validation, including a malformed or rejected ValidatorAdded. - BLS public key. Validator public keys are encoded as their raw compressed bytes when hashed. -- Root domain. The root serialization begins with a domain header containing `STATE_ROOT_V1`, `canonical_spec_version = 1`, `network_id`, and `ssv_contract_address` from the certificate message. This prevents a state root from one network or SSV contract from being reused under another. +- Root domain. The SSZ containers below include a fixed domain field, `canonical_spec_version = 1`, `network_id`, and `ssv_contract_address` from the certificate message. This prevents a state root from one network or SSV contract from being reused under another. **Cluster id derivation.** The cluster id is derived, not stored, and must match across implementations: @@ -94,36 +94,103 @@ cluster_id = keccak256( **2. State Root Construction** -V1 `state_root` is `keccak256` over the v1 root domain header followed by the canonical serialization of all records from section 1. This is the only `state_root` construction accepted under `canonical_spec_version = 1`. keccak256 is chosen to align with Ethereum tooling and with the cluster_id derivation already defined in section 1. +V1 uses SimpleSerialize (SSZ) serialization as the canonical byte encoding for root inputs and certificate messages. V1 roots are still computed with keccak256 over those SSZ bytes, rather than SSZ `hash_tree_root`, because inclusion proofs are not part of v1. A future canonical spec version may replace this with a tree root, but v1 accepts only the constructions below. -Record ordering for the root is fully defined so the serialization is deterministic. These orderings at the top level are normative for v1 and must be covered by the conformance tests in section 6. +```text +state_root = keccak256(ssz_serialize(CanonicalStateV1)) +share_set_root = keccak256(ssz_serialize(ShareSetV1)) +certificate_message_hash = keccak256(ssz_serialize(CheckpointCertificateMessageV1)) +``` + +SSZ container field order is the order shown in this section. The fixed domains are 32 byte values derived by right padding the ASCII labels `STATE_ROOT_V1`, `SHARE_SET_ROOT_V1`, and `CHECKPOINT_CERT_V1` with zero bytes. `ssv_contract_address` is `Vector[byte, 20]`. All roots, hashes, and block hashes are `Bytes32`. All numeric fields are SSZ `uint64` unless this SIP explicitly gives a narrower or wider type. + +Record ordering for the root is fully defined so the SSZ serialization is deterministic. These orderings at the top level are normative for v1 and must be covered by the conformance tests in section 6. - Operators are ordered by ascending operator_id. -- Validators are ordered by their canonical BLS public key bytes. +- Validators are ordered by their canonical BLS public key bytes, then by owner address bytes. - Clusters are ordered by their derived cluster_id bytes. - Owners are ordered by their address bytes. - Within a cluster, the member operator_ids are in ascending order, as in the cluster_id derivation. -Two framing rules are normative for any construction below, because keccak256 over a bare concatenation of heterogeneous records is ambiguous: an address with 20 bytes followed by an id with 8 bytes is identical at the byte level to a single field with 28 bytes. The rules: +The v1 SSZ containers for the public state root are: + +```text +CanonicalStateV1 = Container[ + domain: Bytes32, + canonical_spec_version: uint64, + network_id: uint64, + 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], + 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, + owner: Vector[byte, 20], + operator_ids: List[uint64, MAX_CLUSTER_OPERATORS], + liquidated: boolean, +] + +OwnerRecordV1 = Container[ + owner: Vector[byte, 20], + fee_recipient: Vector[byte, 20], + next_validator_nonce: uint64, +] +``` + +`MAX_OPERATORS`, `MAX_VALIDATORS`, `MAX_CLUSTERS`, `MAX_OWNERS`, `MAX_OPERATOR_PUBLIC_KEY_BYTES`, and `MAX_CLUSTER_OPERATORS` are consensus constants for `canonical_spec_version = 1`. They must be high enough to cover every state that can exist under the SSV contract and must be identical across implementations. A client rejects a v1 state that cannot be represented within these limits. The concrete values are part of the v1 conformance vectors. + +The v1 SSZ container for the active encrypted share set is: -- Length prefixes. Every field with variable length and every record is prefixed with its length so its boundaries are unambiguous. Fields with fixed width (the address with 20 bytes, the 8 byte big endian operator_id, the compressed BLS key) may be written raw because their width is fixed and documented. -- Domain separation by record kind. Each record kind (Operator, Validator, Cluster, Owner) carries a fixed kind tag in its serialization, so bytes of one kind can never be interpreted again as another kind at the same root. +```text +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: List[byte, MAX_SHARE_PUBLIC_KEY_BYTES], + encrypted_share_bytes: List[byte, MAX_ENCRYPTED_SHARE_BYTES], +] +``` -These are not tree shape details; they are required even for the flat hash. A Merkle tree for inclusion proofs is a future canonical spec version because its exact shape must be specified before it can produce a protocol root. +`MAX_ENCRYPTED_SHARE_RECORDS`, `MAX_SHARE_PUBLIC_KEY_BYTES`, and `MAX_ENCRYPTED_SHARE_BYTES` are also consensus constants for v1. Encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. **3. Checkpoint Format** -A checkpoint is a certificate plus one or more untrusted payloads. The certificate signs canonical commitments to public state and active encrypted shares. The payloads carry the preimages needed by an importing node to rebuild those commitments without replaying old logs. +A checkpoint is a certificate plus one or more untrusted payloads. The certificate signs canonical SSZ commitments to public state and active encrypted shares. The payloads carry the preimages needed by an importing node to rebuild those commitments without replaying old logs. -The certificate does not sign one specific snapshot file, compression format, chunking scheme, mirror, or JSON or SSZ layout. Any file layout is acceptable if the importing node can parse it and recompute the signed canonical commitments from its contents. +The certificate does not sign one specific snapshot file, compression format, chunking scheme, or mirror. Any file layout is acceptable if the importing node can parse it, materialize `CanonicalStateV1` and `ShareSetV1`, and recompute the signed commitments from their SSZ serialization. -**Snapshot payload.** The snapshot carries the canonical global state at block B, serialized in the canonical form of section 1, so that an importing node can populate its storage without folding the logs. +**Snapshot payload.** The snapshot carries the canonical global state at block B, with enough information to materialize `CanonicalStateV1`, so that an importing node can populate its storage without folding the logs. The snapshot also carries the full active encrypted share set. Each ValidatorAdded event publishes, for every operator in the cluster, a share public key and an encrypted key share. Those ciphertexts are public event data, while the plaintext share remains protected by encryption to the operator's key. A fresh operator bootstrap needs its encrypted share, and client retention differs, so this set is not left to local storage. Therefore the certificate includes a mandatory `share_set_root`, separate from `state_root`. `state_root` commits to the public validator client registry state. `share_set_root` commits to the encrypted share records for validators active at block B. It does not commit to encrypted shares for validators that were added and removed before B, because those shares are not needed to bootstrap the current state. -The `share_set_root` uses the same framing rules as section 2, with its own root domain header and record kind tag. Its domain header contains `SHARE_SET_ROOT_V1`, `canonical_spec_version = 1`, `network_id`, and `ssv_contract_address` from the certificate message. V1 encrypted share records are ordered by validator public key bytes and then by ascending operator_id. Each record contains: +The `share_set_root` uses the `ShareSetV1` SSZ container from section 2. Its domain field is `SHARE_SET_ROOT_V1`, and the container includes `canonical_spec_version = 1`, `network_id`, and `ssv_contract_address` from the certificate message. V1 encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. Each record contains: ```text ENCRYPTED_SHARE_RECORD_V1 @@ -135,7 +202,7 @@ share_public_key encrypted_share_bytes ``` -The `cluster_id` is derived from owner and the sorted operator set as defined in section 1. The `encrypted_share_bytes` field carries a length prefix because its size is variable. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. +The `cluster_id` is derived from owner and the sorted operator set as defined in section 1. SSZ list framing defines the boundary of `encrypted_share_bytes`, whose size is variable. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. **Checkpoint production paths.** The protocol is defined by the records and roots, not by a particular client's database. A process can produce a checkpoint only if it can enumerate the canonical public state and active encrypted share records at block B. That process may be a normal client that retained those records, a client with added retention, a dedicated materializer, or an archive indexer. @@ -150,10 +217,11 @@ For every checkpoint with a nonzero `parent_checkpoint_hash`, producers start fr The checkpoint bundle may include an optional `snapshot_digest` outside the signed certificate for download integrity, caching, or mirror comparison. That digest is not a trust anchor; import step 4 treats it only as a transport check. -**Certificate message.** The certificate message is the small object signers sign. +**Certificate message.** The certificate message is the small object signers sign. In v1 the object is the SSZ container `CheckpointCertificateMessageV1`, and signers sign `certificate_message_hash = keccak256(ssz_serialize(CheckpointCertificateMessageV1))` under the signature scheme identified by `scheme_version`. | Field | Meaning | | ----- | ------- | +| domain | Fixed domain `CHECKPOINT_CERT_V1` | | schema_version | Version of the certificate message format | | canonical_spec_version | Version of the canonical state definition: the included event set and the application and encoding semantics of section 1 that determine the root | | network_id | Network and chain id the state belongs to | @@ -167,13 +235,31 @@ The checkpoint bundle may include an optional `snapshot_digest` outside the sign | signer_set_id | Identifier of the signer set that may certify this message | | scheme_version | Identifier of the signature scheme used by certificates | +```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, + signer_set_id: uint64, + scheme_version: uint64, +] +``` + For a checkpoint with a parent, `delta_log_set_hash` binds the certificate message to the exact events consumed between the parent checkpoint and B, so that a later comparison or audit can confirm both parties read the same delta logs. For an initial checkpoint, `delta_log_set_hash` is zero. A producer may publish optional audit metadata that records a full historical log hash, but importers do not require it and it is not part of the certificate message. The canonical_spec_version is distinct from schema_version: schema_version versions the certificate message format, while canonical_spec_version versions the canonical state definition that determines the root. The root is meaningful only under the definition that produced it, so a future SSV contract upgrade that adds an event that changes state or changes application semantics is a new canonical state generation that bumps canonical_spec_version and requires regenerating the conformance vectors of section 6. An importer rejects a canonical_spec_version it does not implement (import step 1) rather than comparing roots across generations. **4. Certificates** -A certificate is a signature over the certificate message by a member of the signer set. +A certificate is a signature over `certificate_message_hash` by a member of the signer set. **Required meaning.** A certificate attests that the signer independently materialized the canonical public state and active encrypted share set at block B, without trusting the payload being signed, and computed this same `state_root` and `share_set_root` using one of the production paths in section 3. For a child checkpoint, it also attests that the signer consumed the exact ordered event delta identified by `delta_log_set_hash`. A certificate is not an attestation that a downloaded checkpoint file parsed or that a particular payload encoding or hosting path is trustworthy. @@ -191,9 +277,9 @@ When a node imports a checkpoint it performs the following checks. A first synci 1. Confirm the certificate message network_id and ssv_contract_address match the node's configured network and contract. Reject on mismatch. Reject if canonical_spec_version is one the node does not implement, since a root is only meaningful under the canonical state definition that produced it. 2. Confirm block B is finalized according to the node's consensus data source. Reject if finality cannot be established. A finalized block can no longer be reverted by a chain reorganization (reorg), so the state at B is permanent. Confirm block_hash matches the finalized block. -3. Verify the certificates: each is a valid signature over this certificate message under scheme_version, the signers belong to a signer_set_id the node currently trusts, scheme_version is at or above the node's minimum accepted version, there are at least X matching certificates over the same certificate message, and the agreeing set spans both client implementations. +3. Verify the certificates: each is a valid signature over this SSZ encoded certificate message under scheme_version, the signers belong to a signer_set_id the node currently trusts, scheme_version is at or above the node's minimum accepted version, there are at least X matching certificates over the same certificate message, and the agreeing set spans both client implementations. 4. Parse the received payloads. If the bundle includes an optional snapshot_digest, confirm it for download integrity, but do not treat it as a signed trust anchor. -5. Reconstruct the canonical record set and active encrypted share set from the payloads. Recompute `state_root` and `share_set_root` using their canonical serializations. Confirm both roots match the certificate message. +5. Reconstruct the canonical record set and active encrypted share set from the payloads. Materialize `CanonicalStateV1` and `ShareSetV1`, recompute `state_root` and `share_set_root` from their SSZ serializations, and confirm both roots match the certificate message. 6. Freshness: reject if block B is older than the maximum acceptable age (see below). 7. Monotonicity: reject if B is not newer than the node's current state. A node never imports a checkpoint older than what it already has. 8. On success, populate storage from the snapshot, locating and decrypting the node's own shares from the active encrypted share set, and continue normal sync after block B. From 3cf4edaf7576a013f4d5f3a52274952cdbc03585 Mon Sep 17 00:00:00 2001 From: diego Date: Wed, 10 Jun 2026 21:06:43 +0200 Subject: [PATCH 03/13] Specify checkpoint log set hashes --- sips/checkpoint_sync.md | 46 +++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 5f9bcfc..a450017 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -100,9 +100,11 @@ V1 uses SimpleSerialize (SSZ) serialization as the canonical byte encoding for r state_root = keccak256(ssz_serialize(CanonicalStateV1)) share_set_root = keccak256(ssz_serialize(ShareSetV1)) certificate_message_hash = keccak256(ssz_serialize(CheckpointCertificateMessageV1)) +delta_log_set_hash = keccak256(ssz_serialize(LogSetV1 with domain DELTA_LOG_SET_V1)) +full_log_set_hash = keccak256(ssz_serialize(LogSetV1 with domain FULL_LOG_SET_V1)) ``` -SSZ container field order is the order shown in this section. The fixed domains are 32 byte values derived by right padding the ASCII labels `STATE_ROOT_V1`, `SHARE_SET_ROOT_V1`, and `CHECKPOINT_CERT_V1` with zero bytes. `ssv_contract_address` is `Vector[byte, 20]`. All roots, hashes, and block hashes are `Bytes32`. All numeric fields are SSZ `uint64` unless this SIP explicitly gives a narrower or wider type. +SSZ container field order is the order shown in this section. The fixed domains are 32 byte values derived by right padding the ASCII labels `STATE_ROOT_V1`, `SHARE_SET_ROOT_V1`, `CHECKPOINT_CERT_V1`, `DELTA_LOG_SET_V1`, and `FULL_LOG_SET_V1` with zero bytes. `ssv_contract_address` and log addresses are `Vector[byte, 20]`. All roots, hashes, topics, and block hashes are `Bytes32`. All numeric fields are SSZ `uint64` unless this SIP explicitly gives a narrower or wider type. An all zero `Bytes32` value is exactly 32 zero bytes. Record ordering for the root is fully defined so the SSZ serialization is deterministic. These orderings at the top level are normative for v1 and must be covered by the conformance tests in section 6. @@ -178,6 +180,32 @@ EncryptedShareRecordV1 = Container[ `MAX_ENCRYPTED_SHARE_RECORDS`, `MAX_SHARE_PUBLIC_KEY_BYTES`, and `MAX_ENCRYPTED_SHARE_BYTES` are also consensus constants for v1. Encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. +The v1 SSZ container for log set hashes is: + +```text +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], +] +``` + +The log set range is inclusive. `from_block_number` and `to_block_number` are part of the hash preimage even when the `events` list is empty. The `events` list contains the raw Ethereum logs for the relevant SSV events from section 1, emitted by `ssv_contract_address`, ordered by ascending `(block_number, transaction_index, log_index)`. `address` must equal `ssv_contract_address`, and `topics` and `data` are the exact bytes returned by the execution data source for that log. `MAX_LOG_EVENTS`, `MAX_LOG_TOPICS`, and `MAX_LOG_DATA_BYTES` are consensus constants for v1. + **3. Checkpoint Format** A checkpoint is a certificate plus one or more untrusted payloads. The certificate signs canonical SSZ commitments to public state and active encrypted shares. The payloads carry the preimages needed by an importing node to rebuild those commitments without replaying old logs. @@ -206,14 +234,14 @@ The `cluster_id` is derived from owner and the sorted operator set as defined in **Checkpoint production paths.** The protocol is defined by the records and roots, not by a particular client's database. A process can produce a checkpoint only if it can enumerate the canonical public state and active encrypted share records at block B. That process may be a normal client that retained those records, a client with added retention, a dedicated materializer, or an archive indexer. -For the initial checkpoint, where `parent_checkpoint_hash` is zero, there are two valid production paths: +For the initial checkpoint, where `parent_checkpoint_hash` is the all zero `Bytes32` value, there are two valid production paths: 1. Retained state path. The producer already has a complete current registry snapshot at B, including active encrypted share ciphertexts for every validator and operator pair. It exports that state and computes `state_root` and `share_set_root` directly from the canonical records. 2. Historical materializer path. The producer does not have retained encrypted shares. It folds historical registry events through B, materializes the same active public state and encrypted share set, discards encrypted shares for validators removed before B, and computes the same roots. A client whose current state does not retain all active encrypted share ciphertexts cannot produce the encrypted share part of the initial checkpoint from that state alone. It must use a materializer, an archive source, or a previous checkpoint that already contains the share set. -For every checkpoint with a nonzero `parent_checkpoint_hash`, producers start from the parent checkpoint state and apply only the relevant SSV logs from the block after the parent through B. They update the public records and active encrypted share records, compute the new roots, and include `delta_log_set_hash` for that bounded event range. +For every checkpoint with a nonzero `parent_checkpoint_hash`, producers start from the parent checkpoint state and apply only the relevant SSV logs from the block after the parent through B. They update the public records and active encrypted share records, compute the new roots, and include `delta_log_set_hash` for that bounded event range. The `delta_log_set_hash` is computed from `LogSetV1` with domain `DELTA_LOG_SET_V1`, `from_block_number = parent.block_number + 1`, and `to_block_number = B`. The checkpoint bundle may include an optional `snapshot_digest` outside the signed certificate for download integrity, caching, or mirror comparison. That digest is not a trust anchor; import step 4 treats it only as a transport check. @@ -228,10 +256,10 @@ The checkpoint bundle may include an optional `snapshot_digest` outside the sign | ssv_contract_address | Address of the SSV contract whose registry state is represented | | block_number | Block B the state was taken at | | block_hash | Hash of block B | -| parent_checkpoint_hash | Hash of the parent checkpoint certificate message, or zero for an initial checkpoint | +| parent_checkpoint_hash | `certificate_message_hash` of the parent checkpoint, or the all zero `Bytes32` value for an initial checkpoint | | state_root | Canonical state root from section 2 | | share_set_root | Canonical root of the active encrypted share set | -| delta_log_set_hash | Hash of the exact ordered list of relevant SSV events consumed after the parent checkpoint, or zero for an initial checkpoint | +| delta_log_set_hash | `LogSetV1` hash of the exact ordered list of relevant SSV events consumed after the parent checkpoint, or the all zero `Bytes32` value for an initial checkpoint | | signer_set_id | Identifier of the signer set that may certify this message | | scheme_version | Identifier of the signature scheme used by certificates | @@ -253,7 +281,7 @@ CheckpointCertificateMessageV1 = Container[ ] ``` -For a checkpoint with a parent, `delta_log_set_hash` binds the certificate message to the exact events consumed between the parent checkpoint and B, so that a later comparison or audit can confirm both parties read the same delta logs. For an initial checkpoint, `delta_log_set_hash` is zero. A producer may publish optional audit metadata that records a full historical log hash, but importers do not require it and it is not part of the certificate message. +For a checkpoint with a parent, `delta_log_set_hash` binds the certificate message to the exact events consumed between the parent checkpoint and B, so that a later comparison or audit can confirm both parties read the same delta logs. For an initial checkpoint, `delta_log_set_hash` is the all zero `Bytes32` value. A producer may publish optional audit metadata that records a full historical log hash computed from `LogSetV1` with domain `FULL_LOG_SET_V1`, `from_block_number` equal to the SSV contract deployment block, and `to_block_number = B`, but importers do not require it and it is not part of the certificate message. The canonical_spec_version is distinct from schema_version: schema_version versions the certificate message format, while canonical_spec_version versions the canonical state definition that determines the root. The root is meaningful only under the definition that produced it, so a future SSV contract upgrade that adds an event that changes state or changes application semantics is a new canonical state generation that bumps canonical_spec_version and requires regenerating the conformance vectors of section 6. An importer rejects a canonical_spec_version it does not implement (import step 1) rather than comparing roots across generations. @@ -277,7 +305,7 @@ When a node imports a checkpoint it performs the following checks. A first synci 1. Confirm the certificate message network_id and ssv_contract_address match the node's configured network and contract. Reject on mismatch. Reject if canonical_spec_version is one the node does not implement, since a root is only meaningful under the canonical state definition that produced it. 2. Confirm block B is finalized according to the node's consensus data source. Reject if finality cannot be established. A finalized block can no longer be reverted by a chain reorganization (reorg), so the state at B is permanent. Confirm block_hash matches the finalized block. -3. Verify the certificates: each is a valid signature over this SSZ encoded certificate message under scheme_version, the signers belong to a signer_set_id the node currently trusts, scheme_version is at or above the node's minimum accepted version, there are at least X matching certificates over the same certificate message, and the agreeing set spans both client implementations. +3. Verify the certificates: each is a valid signature over `certificate_message_hash` under scheme_version, the signers belong to a signer_set_id the node currently trusts, scheme_version is at or above the node's minimum accepted version, there are at least X matching certificates over the same certificate message, and the agreeing set spans both client implementations. 4. Parse the received payloads. If the bundle includes an optional snapshot_digest, confirm it for download integrity, but do not treat it as a signed trust anchor. 5. Reconstruct the canonical record set and active encrypted share set from the payloads. Materialize `CanonicalStateV1` and `ShareSetV1`, recompute `state_root` and `share_set_root` from their SSZ serializations, and confirm both roots match the certificate message. 6. Freshness: reject if block B is older than the maximum acceptable age (see below). @@ -300,7 +328,7 @@ The canonical spec is only useful if it is enforced. The following checks do tha Generating the tuples is automatic for a client or materializer that has the required historical logs and encrypted share material. Comparing the streams is a separate step performed by a small diff tool. Starting from the deployment block catches a historical bug such as #972, which only appears once the affected event is processed. This is the audit path, not the normal checkpoint production path. -The `full_log_set_hash` in the tuple is essential to this comparison. Without it, two clients could disagree only because their RPC providers served different events: providers differ in indexing, retention, and gaps. The diff tool compares `full_log_set_hash` first. If the log hashes differ, it is a data source problem, not a reconstruction bug. Only matching log hashes with differing roots indicate a real divergence. `full_log_set_hash` is audit evidence and is not a certificate field. +The `full_log_set_hash` in the tuple is essential to this comparison. It is computed from `LogSetV1` with domain `FULL_LOG_SET_V1`, `from_block_number` equal to the SSV contract deployment block, and `to_block_number` equal to the tuple's `block_number`. Without it, two clients could disagree only because their RPC providers served different events: providers differ in indexing, retention, and gaps. The diff tool compares `full_log_set_hash` first. If the log hashes differ, it is a data source problem, not a reconstruction bug. Only matching log hashes with differing roots indicate a real divergence. `full_log_set_hash` is audit evidence and is not a certificate field. **Checkpoint production run.** For a checkpoint with a parent, each signer emits: @@ -308,7 +336,7 @@ The `full_log_set_hash` in the tuple is essential to this comparison. Without it (parent_checkpoint_hash, block_number, delta_log_set_hash, state_root, share_set_root) ``` -The diff tool first compares the parent checkpoint hash and `delta_log_set_hash`, then compares the roots. This is the normal production comparison for later checkpoints and it does not require logs before the parent checkpoint. For an initial checkpoint, signers compare `(block_number, state_root, share_set_root)` and may also compare optional full replay audit evidence when they have it. +The diff tool first compares the parent checkpoint hash and `delta_log_set_hash`, then compares the roots. The parent checkpoint hash is the parent `certificate_message_hash`, and `delta_log_set_hash` is computed from `LogSetV1` with domain `DELTA_LOG_SET_V1`. This is the normal production comparison for later checkpoints and it does not require logs before the parent checkpoint. For an initial checkpoint, signers compare `(block_number, state_root, share_set_root)` and may also compare optional full replay audit evidence when they have it. **Honest limit and complementarity.** Agreement across clients proves that the two clients reconstructed the same state. It does not prove the state is correct; both could share a spec mistake. eth_call spot checks are complementary because they compare known records against contract storage outside both clients, while root agreement catches omitted records that eth_call cannot ask about. Neither check catches a shared mistake in the event signatures, topics, contract address, deployment block, parent checkpoint, or delta range. The full fold from the deployment block remains the audit path for that class of error. From a629e5bfda98634a83d4b437571fc946ac34c79a Mon Sep 17 00:00:00 2001 From: diego Date: Wed, 10 Jun 2026 21:14:15 +0200 Subject: [PATCH 04/13] Specify validator identity by owner --- sips/checkpoint_sync.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index a450017..13e4e82 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -51,7 +51,7 @@ The block number is a reference point that records when the state was taken. It | Record | Fields | | ------ | ------ | | Operator | operator_id (sequential, assigned by the contract), canonical_public_key, owner address, removed status | -| Validator | validator public key (BLS), owner address, cluster it belongs to | +| Validator | validator public key (BLS), owner address; the pair is the validator identity, cluster_id | | Cluster | cluster_id (derived, see below), owner address, sorted member operator_ids, liquidated flag | | Owner | owner address, fee_recipient, next_validator_nonce | @@ -69,12 +69,13 @@ ValidatorExited is also excluded. It is a beacon chain signal: it announces that The operator fee and the Cluster accounting fields carried by each event (balance, validatorCount, networkFeeIndex, index, and the active flag) are also excluded: they are mutable contract bookkeeping that varies over time, not part of the canonical state for validator clients, and committing to accumulators that advance on nearly every interaction would change the root far more often than the logical membership state does. Cluster liquidation status is derived from the ClusterLiquidated and ClusterReactivated events, not from the Cluster.active field. In v1, those events update only an included cluster record. If no cluster record is included for that owner and operator set, the event changes no canonical record. A cluster created later starts with `liquidated = false` unless a later ClusterLiquidated event changes it. -**Removal representation.** V1 pins removal per entity. A ValidatorRemoved event removes the validator record from `state_root` and removes the validator's encrypted share records from `share_set_root`. If that was the last active validator in a cluster, the cluster record is also omitted. An OperatorRemoved event sets `removed = true` for that operator while any active cluster still references it. A removed operator with no active cluster reference is omitted from the canonical record set. Active operators are included even if they are not members of an active cluster. +**Removal representation.** V1 pins removal per entity. A ValidatorRemoved event removes the validator record identified by the event owner and validator public key from `state_root` and removes the encrypted share records for that same owner and validator public key from `share_set_root`. If that was the last active validator in a cluster, the cluster record is also omitted. An OperatorRemoved event sets `removed = true` for that operator while any active cluster still references it. A removed operator with no active cluster reference is omitted from the canonical record set. Active operators are included even if they are not members of an active cluster. **Canonical encoding and ordering.** The following choices are pinned. - Operator identity. Operators are keyed by `operator_id` only. The canonical fold must not deduplicate operators by raw event bytes, decoded RSA key bytes, PEM text, owner address, or any other key. If two OperatorAdded events assign two different operator ids but decode to the same RSA public key, v1 still has two operator records. - Operator public key. OperatorAdded publicKey bytes have appeared in more than one layout, so v1 defines `canonical_public_key` explicitly. First, try to decode the field as the SSV operator public key wrapper, equivalent to ABI decoding one dynamic `bytes` value. If that succeeds and the decoded bytes parse as the expected base64 PEM RSA public key payload, those decoded bytes are the canonical bytes. If wrapper decoding fails, the raw event bytes are accepted only if they parse directly as the same base64 PEM RSA public key payload. Otherwise the OperatorAdded event is rejected as malformed. The canonical bytes are hashed exactly as bytes; clients must not reserialize the key into a different PEM, DER, JSON, or text layout before hashing. If a client cannot decode the key into this canonical form, it must stop or mark the event malformed according to the v1 fold, not silently skip the operator. +- Validator identity. Validators are keyed by the pair `(validator_public_key, owner)`, matching the contract registration key. The canonical fold must not deduplicate validators by validator public key alone. If two accepted ValidatorAdded events use the same validator public key with different owners, v1 includes two validator records, and their encrypted share records remain separate by owner. - Cluster member set. Operator ids in a cluster are an unordered set in the contract. They are serialized in ascending operator_id order. - Addresses. All addresses are encoded as their 20 raw bytes when hashed. Any text rendering uses lowercase hex. EIP-55 checksum casing is not used in the hashed form. - Fee recipient fallback. If no Owner record exists for an owner, the fee recipient defaults to the owner address. @@ -109,7 +110,7 @@ SSZ container field order is the order shown in this section. The fixed domains Record ordering for the root is fully defined so the SSZ serialization is deterministic. These orderings at the top level are normative for v1 and must be covered by the conformance tests in section 6. - Operators are ordered by ascending operator_id. -- Validators are ordered by their canonical BLS public key bytes, then by owner address bytes. +- Validators are ordered by their canonical BLS public key bytes, then by owner address bytes; the owner comparison is the tie breaker when public keys match. - Clusters are ordered by their derived cluster_id bytes. - Owners are ordered by their address bytes. - Within a cluster, the member operator_ids are in ascending order, as in the cluster_id derivation. @@ -318,7 +319,7 @@ When a node imports a checkpoint it performs the following checks. A first synci The canonical spec is only useful if it is enforced. The following checks do that, and they would have caught #972. -**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, ValidatorAdded rejection after nonce advancement, ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted. +**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted. **Full replay audit run.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point (every N blocks) a tuple: From c422da9cb937ce8afe755b5458dafb116a431781 Mon Sep 17 00:00:00 2001 From: diego Date: Wed, 10 Jun 2026 21:35:48 +0200 Subject: [PATCH 05/13] Specify validator share blob grammar --- sips/checkpoint_sync.md | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 13e4e82..0483bb2 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -76,7 +76,7 @@ The operator fee and the Cluster accounting fields carried by each event (balanc - Operator identity. Operators are keyed by `operator_id` only. The canonical fold must not deduplicate operators by raw event bytes, decoded RSA key bytes, PEM text, owner address, or any other key. If two OperatorAdded events assign two different operator ids but decode to the same RSA public key, v1 still has two operator records. - Operator public key. OperatorAdded publicKey bytes have appeared in more than one layout, so v1 defines `canonical_public_key` explicitly. First, try to decode the field as the SSV operator public key wrapper, equivalent to ABI decoding one dynamic `bytes` value. If that succeeds and the decoded bytes parse as the expected base64 PEM RSA public key payload, those decoded bytes are the canonical bytes. If wrapper decoding fails, the raw event bytes are accepted only if they parse directly as the same base64 PEM RSA public key payload. Otherwise the OperatorAdded event is rejected as malformed. The canonical bytes are hashed exactly as bytes; clients must not reserialize the key into a different PEM, DER, JSON, or text layout before hashing. If a client cannot decode the key into this canonical form, it must stop or mark the event malformed according to the v1 fold, not silently skip the operator. - Validator identity. Validators are keyed by the pair `(validator_public_key, owner)`, matching the contract registration key. The canonical fold must not deduplicate validators by validator public key alone. If two accepted ValidatorAdded events use the same validator public key with different owners, v1 includes two validator records, and their encrypted share records remain separate by owner. -- Cluster member set. Operator ids in a cluster are an unordered set in the contract. They are serialized in ascending operator_id order. +- Cluster member set. A ValidatorAdded event uses the `operatorIds` order to pair share bytes with operators, as defined in section 2. After parsing, the cluster membership is represented as a set and serialized in ascending operator_id order. - Addresses. All addresses are encoded as their 20 raw bytes when hashed. Any text rendering uses lowercase hex. EIP-55 checksum casing is not used in the hashed form. - Fee recipient fallback. If no Owner record exists for an owner, the fee recipient defaults to the owner address. - Owner nonce. Owner records serialize `next_validator_nonce`, the nonce value to use for the next ValidatorAdded by that owner. It starts at 0. Every ValidatorAdded log for the owner advances it exactly once before validation, including a malformed or rejected ValidatorAdded. @@ -174,12 +174,36 @@ EncryptedShareRecordV1 = Container[ validator_public_key: Vector[byte, 48], cluster_id: Bytes32, operator_id: uint64, - share_public_key: List[byte, MAX_SHARE_PUBLIC_KEY_BYTES], - encrypted_share_bytes: List[byte, MAX_ENCRYPTED_SHARE_BYTES], + share_public_key: Vector[byte, 48], + encrypted_share_bytes: Vector[byte, 256], ] ``` -`MAX_ENCRYPTED_SHARE_RECORDS`, `MAX_SHARE_PUBLIC_KEY_BYTES`, and `MAX_ENCRYPTED_SHARE_BYTES` are also consensus constants for v1. Encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. +`MAX_ENCRYPTED_SHARE_RECORDS` is also a consensus constant for v1. Encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. + +A ValidatorAdded log derives encrypted share records from the event `shares` bytes using the v1 share blob grammar below. Let `n = len(operatorIds)`. The `operatorIds` array in a ValidatorAdded log must be nonempty and strictly ascending, and every operator id must refer to an operator record present in the fold before the event. Duplicate ids, descending ids, or missing operators make the ValidatorAdded event malformed after the owner nonce transition defined in section 1. + +```text +ValidatorAddedSharesV1 = + validator_signature: Vector[byte, 96] + share_public_keys: Vector[byte, 48] * n + encrypted_share_bytes: Vector[byte, 256] * n +``` + +The expected `shares` length is `96 + n * 48 + n * 256` bytes. The `share_public_keys` entry at index i and the `encrypted_share_bytes` entry at index i are assigned to the `operatorIds` entry at index i from the event. This event order mapping is used only to form records; the final `ShareSetV1.shares` list is then sorted by the v1 record ordering above. No Snappy decompression, ABI decode inside the `shares` value, JSON decode, or local database layout is part of this grammar. + +The `validator_signature` is checked before any validator record, cluster update, or encrypted share record is created. The signature is a 96 byte BLS signature by `validator_public_key` over: + +```text +validator_registration_message_v1 = + keccak256(utf8(owner_eip55_hex ++ ":" ++ decimal_next_validator_nonce)) +``` + +`owner_eip55_hex` is the EIP-55 address text with `0x` prefix derived from the event owner address. `decimal_next_validator_nonce` is the base 10 nonce text with no leading zero, except that zero is encoded as `0`. This text form is used only for the existing validator registration signature. Addresses in the SSZ records and roots still use raw 20 byte values. + +After the owner nonce is read and advanced, a ValidatorAdded event is malformed if the validator public key is not a 48 byte BLS public key, the operator id array is invalid under the rules above, the `shares` length does not match the v1 formula, or `validator_signature` does not verify. A malformed ValidatorAdded event leaves the nonce advance in place and creates no validator record, cluster update, or encrypted share records. + +V1 does not decrypt `encrypted_share_bytes`, verify plaintext shares, or check that a ciphertext matches its `share_public_key`. The canonical fold commits to the public ciphertext bytes from the accepted event. A client may later fail to decrypt its own encrypted share and decide that the share is unusable locally, but that local outcome is outside the canonical fold and must not change `state_root` or `share_set_root`. The v1 SSZ container for log set hashes is: @@ -231,7 +255,7 @@ share_public_key encrypted_share_bytes ``` -The `cluster_id` is derived from owner and the sorted operator set as defined in section 1. SSZ list framing defines the boundary of `encrypted_share_bytes`, whose size is variable. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. +The `cluster_id` is derived from owner and the sorted operator set as defined in section 1. The share blob grammar in section 2 defines the boundary of each encrypted share before it is inserted into `EncryptedShareRecordV1`. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. **Checkpoint production paths.** The protocol is defined by the records and roots, not by a particular client's database. A process can produce a checkpoint only if it can enumerate the canonical public state and active encrypted share records at block B. That process may be a normal client that retained those records, a client with added retention, a dedicated materializer, or an archive indexer. @@ -319,7 +343,7 @@ When a node imports a checkpoint it performs the following checks. A first synci The canonical spec is only useful if it is enforced. The following checks do that, and they would have caught #972. -**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted. +**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorAdded with 4 operators and with 7 operators, ValidatorAdded with unsorted or duplicate operator ids, ValidatorAdded with a bad share blob length, ValidatorAdded with a bad validator signature, ValidatorAdded with ciphertext bytes that are structurally valid but do not decrypt for one operator, ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted. **Full replay audit run.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point (every N blocks) a tuple: From 606785f025304dd61b55380dad7f5a7ae5c30450 Mon Sep 17 00:00:00 2001 From: diego Date: Wed, 10 Jun 2026 22:30:02 +0200 Subject: [PATCH 06/13] Specify checkpoint signer metadata --- sips/checkpoint_sync.md | 75 ++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 0483bb2..4c0698f 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -286,6 +286,7 @@ The checkpoint bundle may include an optional `snapshot_digest` outside the sign | share_set_root | Canonical root of the active encrypted share set | | delta_log_set_hash | `LogSetV1` hash of the exact ordered list of relevant SSV events consumed after the parent checkpoint, or the all zero `Bytes32` value for an initial checkpoint | | signer_set_id | Identifier of the signer set that may certify this message | +| signer_set_hash | Hash of the trusted signer metadata for signer_set_id | | scheme_version | Identifier of the signature scheme used by certificates | ```text @@ -302,6 +303,7 @@ CheckpointCertificateMessageV1 = Container[ share_set_root: Bytes32, delta_log_set_hash: Bytes32, signer_set_id: uint64, + signer_set_hash: Bytes32, scheme_version: uint64, ] ``` @@ -312,15 +314,64 @@ The canonical_spec_version is distinct from schema_version: schema_version versi **4. Certificates** -A certificate is a signature over `certificate_message_hash` by a member of the signer set. +A checkpoint carries a certificate message and one or more signature records. A signature record identifies the signer and carries a signature over `certificate_message_hash` by that signer. + +```text +CheckpointSignatureV1 = Container[ + signer_id: uint64, + signature: List[byte, MAX_CHECKPOINT_SIGNATURE_BYTES], +] +``` + +`MAX_CHECKPOINT_SIGNATURE_BYTES` is a consensus constant for `scheme_version = 1`. + +`scheme_version = 1` uses individual signature records. Any future aggregate signature scheme must still expose the signer ids covered by the aggregate before applying the same counting rules. + +**Trusted signer set.** `signer_set_id` is not trusted by itself. An importer resolves it to a locally trusted `TrustedSignerSetV1`, shipped with the client or operator configuration before checkpoint import. The certificate message also commits to `signer_set_hash = keccak256(ssz_serialize(TrustedSignerSetV1))`, so signer set rotation cannot reuse an id with different metadata without changing the signed message. + +```text +TrustedSignerSetV1 = Container[ + signer_set_id: uint64, + scheme_version: uint64, + threshold: uint64, + min_controller_count: uint64, + implementation_quorums: List[ImplementationQuorumV1, MAX_CHECKPOINT_IMPLEMENTATIONS], + signers: List[TrustedSignerV1, MAX_CHECKPOINT_SIGNERS], +] + +ImplementationQuorumV1 = Container[ + implementation_id: uint64, + min_signers: uint64, +] + +TrustedSignerV1 = Container[ + signer_id: uint64, + implementation_id: uint64, + controller_id: uint64, + public_key: List[byte, MAX_SIGNER_PUBLIC_KEY_BYTES], + valid_from_block: uint64, + valid_to_block: uint64, +] +``` + +`MAX_CHECKPOINT_IMPLEMENTATIONS`, `MAX_CHECKPOINT_SIGNERS`, and `MAX_SIGNER_PUBLIC_KEY_BYTES` are consensus constants for `scheme_version = 1`. `implementation_quorums` are ordered by ascending implementation_id, and signers are ordered by ascending signer_id before computing `signer_set_hash`. Duplicate implementation ids, duplicate signer ids, duplicate public keys, `threshold = 0`, or an implementation quorum with `min_signers = 0` make the trusted signer set invalid. `valid_to_block = 0` means the signer has no configured retirement block. Otherwise, a signer is active for block B only when `valid_from_block <= B <= valid_to_block`. `threshold` is the minimum number of unique active signer ids required. `min_controller_count = 0` disables controller diversity; otherwise the counted signer set must contain at least that many distinct `controller_id` values. + +V1 defines these implementation ids: + +```text +1 = Anchor +2 = go-ssv +``` + +For v1, importers reject a `TrustedSignerSetV1` whose `implementation_quorums` do not include `(implementation_id = 1, min_signers = 1)` and `(implementation_id = 2, min_signers = 1)`. A future `scheme_version` may add more implementations or change these minimums, but `scheme_version = 1` keeps both minima. **Required meaning.** A certificate attests that the signer independently materialized the canonical public state and active encrypted share set at block B, without trusting the payload being signed, and computed this same `state_root` and `share_set_root` using one of the production paths in section 3. For a child checkpoint, it also attests that the signer consumed the exact ordered event delta identified by `delta_log_set_hash`. A certificate is not an attestation that a downloaded checkpoint file parsed or that a particular payload encoding or hosting path is trustworthy. -**Acceptance rule.** A checkpoint is accepted only when at least X of the Y signers in the signer set produce matching certificates over the same certificate message. X and Y are a parameter of the signer set; this SIP does not fix them here (for example, 3 of 4). At least one matching certificate must come from a signer running client A, and at least one from a signer running client B. Today those clients are Anchor and go-ssv. This mitigates a shared reconstruction bug in one implementation, such as #972. When implementations disagree on the roots for a block, no checkpoint is published and the disagreement is investigated. +**Acceptance rule.** A checkpoint is accepted only when one set of unique active signer ids signs the same certificate message and satisfies every rule in `TrustedSignerSetV1`: at least `threshold` unique signers, every required implementation quorum, and `min_controller_count` when nonzero. Duplicate signature records for the same signer_id count once. A signature record whose signer_id is unknown, retired for block B, associated with a different scheme, or invalid for the configured public key is ignored. This mitigates a shared reconstruction bug in one implementation, such as #972, because the same counted signer set must include both Anchor and go-ssv signers. When implementations disagree on the roots for a block, no checkpoint is published and the disagreement is investigated. This rule makes the mechanism intentionally inert until at least two implementations independently compute the same canonical root and validate it against the shared conformance vectors of section 6 at a common block. When signer infrastructure and a second conforming implementation are ready is an onboarding matter (see Out of Scope). -**Swapping the signature scheme later.** The certificate message records `scheme_version` and `signer_set_id` so a future version can change the signature scheme or rotate the signer set without changing the certificate message format. This includes a move to a post-quantum signature such as ML-DSA. Post-quantum signatures are not mandated for v1. Agility needs a floor; see the downgrade discussion in Security Considerations. +**Swapping the signature scheme later.** The certificate message records `scheme_version`, `signer_set_id`, and `signer_set_hash` so a future version can change the signature scheme or rotate the signer set without ambiguity. This includes a move to a post-quantum signature such as ML-DSA. Post-quantum signatures are not mandated for v1. Agility needs a floor; see the downgrade discussion in Security Considerations. **Dedicated keys.** The signer keys must be separate keys created only for signing checkpoints. They must not reuse an operator's existing key. Reusing operator keys saves no distribution step, because a bootstrapping node needs the signer public keys before it has any state, so those keys ship with the client or its configuration regardless. They must also use a distinct signing context (domain separation) so a checkpoint signature can never be mistaken for, or replayed as, any other kind of signature. @@ -330,20 +381,20 @@ When a node imports a checkpoint it performs the following checks. A first synci 1. Confirm the certificate message network_id and ssv_contract_address match the node's configured network and contract. Reject on mismatch. Reject if canonical_spec_version is one the node does not implement, since a root is only meaningful under the canonical state definition that produced it. 2. Confirm block B is finalized according to the node's consensus data source. Reject if finality cannot be established. A finalized block can no longer be reverted by a chain reorganization (reorg), so the state at B is permanent. Confirm block_hash matches the finalized block. -3. Verify the certificates: each is a valid signature over `certificate_message_hash` under scheme_version, the signers belong to a signer_set_id the node currently trusts, scheme_version is at or above the node's minimum accepted version, there are at least X matching certificates over the same certificate message, and the agreeing set spans both client implementations. +3. Resolve `signer_set_id` to a locally trusted `TrustedSignerSetV1`. Reject if the set is unknown, if its `scheme_version` differs from the certificate message, if `scheme_version` is below the node's minimum accepted version, or if `keccak256(ssz_serialize(TrustedSignerSetV1))` differs from `signer_set_hash`. Verify each signature record against the public key for its signer_id and count each valid active signer_id at most once. Reject unless the counted set satisfies `threshold`, every configured implementation quorum, and `min_controller_count` when nonzero. 4. Parse the received payloads. If the bundle includes an optional snapshot_digest, confirm it for download integrity, but do not treat it as a signed trust anchor. 5. Reconstruct the canonical record set and active encrypted share set from the payloads. Materialize `CanonicalStateV1` and `ShareSetV1`, recompute `state_root` and `share_set_root` from their SSZ serializations, and confirm both roots match the certificate message. 6. Freshness: reject if block B is older than the maximum acceptable age (see below). 7. Monotonicity: reject if B is not newer than the node's current state. A node never imports a checkpoint older than what it already has. 8. On success, populate storage from the snapshot, locating and decrypting the node's own shares from the active encrypted share set, and continue normal sync after block B. -**Freshness and monotonicity.** Signatures cannot stop replay of an old but valid checkpoint. The defenses are a maximum acceptable age, enforced by the importing node, a finality requirement on B, and the rule that a node never imports a checkpoint older than its current state. Monotonicity protects only a resync, where the node already has state to compare against. A first syncing node has no current state, so monotonicity gives it nothing; its protections are the maximum age, the finality requirement, and the X of Y threshold across implementations. +**Freshness and monotonicity.** Signatures cannot stop replay of an old but valid checkpoint. The defenses are a maximum acceptable age, enforced by the importing node, a finality requirement on B, and the rule that a node never imports a checkpoint older than its current state. Monotonicity protects only a resync, where the node already has state to compare against. A first syncing node has no current state, so monotonicity gives it nothing; its protections are the maximum age, the finality requirement, and the trusted signer set threshold plus implementation quorums. **6. Conformance and Testing** The canonical spec is only useful if it is enforced. The following checks do that, and they would have caught #972. -**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorAdded with 4 operators and with 7 operators, ValidatorAdded with unsorted or duplicate operator ids, ValidatorAdded with a bad share blob length, ValidatorAdded with a bad validator signature, ValidatorAdded with ciphertext bytes that are structurally valid but do not decrypt for one operator, ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted. +**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorAdded with 4 operators and with 7 operators, ValidatorAdded with unsorted or duplicate operator ids, ValidatorAdded with a bad share blob length, ValidatorAdded with a bad validator signature, ValidatorAdded with ciphertext bytes that are structurally valid but do not decrypt for one operator, ValidatorRemoved omission, removed operators that remain referenced by active membership, removed operators that become unreferenced and are omitted, and certificate acceptance cases for duplicate signer ids, missing implementation quorum, retired signer, signer_set_hash mismatch, and controller diversity when enabled. **Full replay audit run.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point (every N blocks) a tuple: @@ -382,21 +433,21 @@ The following are operational, not protocol, and are out of scope for this SIP: **Security Considerations** -**Trust assumption.** An importing node that skips the fold trusts the signer set, not whoever published the checkpoint payloads. The publisher is untrusted; a malicious publisher can at most serve payloads that fail to reconstruct the signed `state_root` or `share_set_root`. The acceptance rule in section 4 assumes enough honest signers to prevent a dishonest quorum from reaching X while spanning both implementations. +**Trust assumption.** An importing node that skips the fold trusts the signer set, not whoever published the checkpoint payloads. The publisher is untrusted; a malicious publisher can at most serve payloads that fail to reconstruct the signed `state_root` or `share_set_root`. The acceptance rule in section 4 assumes enough honest signers to prevent a dishonest counted set from satisfying the threshold, implementation quorum, and controller rules. **Completeness versus correctness.** Agreement across implementations catches omitted records only because the root commits to the record set itself, not to counters. It does not prove the shared event scope or canonical spec is correct; section 6 describes why eth_call and the full fold audit remain complementary. -**Reconstruction bug risk.** Two signers running the same client share that client's reconstruction bugs and would sign the same wrong roots. The acceptance rule across implementations mitigates this by requiring at least one certificate from each implementation, and the canonical application semantics in section 1 reduce the space for legitimate root differences. +**Reconstruction bug risk.** Two signers running the same client share that client's reconstruction bugs and would sign the same wrong roots. The acceptance rule across implementations mitigates this by requiring the counted signer set to satisfy the implementation quorums, and the canonical application semantics in section 1 reduce the space for legitimate root differences. -**Equivocation and signer dishonesty.** If enough signers collude to reach X and span both implementations, they can sign wrong roots that are internally consistent. Nothing in the format binds a block number B to a single certificate message, so a dishonest quorum or publisher could serve two well formed certificate messages for the same block B and partition nodes onto divergent state. Equivocation is detectable out of band but is not prevented by the format alone. A recommended mitigation is for signers to publish each (block_number, certificate message) into an append only transparency record, so two conflicting signatures over the same block are publicly attributable. +**Equivocation and signer dishonesty.** If enough signers collude to satisfy the threshold, implementation quorum, and controller rules, they can sign wrong roots that are internally consistent. Nothing in the format binds a block number B to a single certificate message, so a dishonest quorum or publisher could serve two well formed certificate messages for the same block B and partition nodes onto divergent state. Equivocation is detectable out of band but is not prevented by the format alone. A recommended mitigation is for signers to publish each (block_number, certificate message) into an append only transparency record, so two conflicting signatures over the same block are publicly attributable. **Replay of stale checkpoints.** Signatures over an old but valid certificate message stay valid forever, so signatures alone cannot stop replay. Section 5 defines the defenses: maximum acceptable age, finality, and monotonicity where a node already has current state. -**Downgrade and version attacks.** Cryptographic agility introduces several valid scheme_version, signer_set_id, and schema_version values over time. Without a floor, that enables downgrade: after a post-quantum migration an attacker could replay a checkpoint certified under the old, now weak classical scheme; after key rotation an attacker could replay certificates from a retired signer set. The maximum age rule only partly helps, since an old certificate message under a weak scheme within the age window would still pass. The importing node must therefore enforce a minimum acceptable scheme_version and reject retired signer_set_ids, so a checkpoint signed under a deprecated scheme or a retired signer set is rejected even when its signatures are cryptographically valid and it is within the age window. Agility without a floor is a downgrade vector. +**Downgrade and version attacks.** Cryptographic agility introduces several valid scheme_version, signer_set_id, signer_set_hash, and schema_version values over time. Without a floor, that enables downgrade: after a post-quantum migration an attacker could replay a checkpoint certified under the old, now weak classical scheme; after key rotation an attacker could replay certificates from a retired signer set. The maximum age rule only partly helps, since an old certificate message under a weak scheme within the age window would still pass. The importing node must therefore enforce a minimum acceptable scheme_version and reject retired signer sets, so a checkpoint signed under a deprecated scheme or a retired signer set is rejected even when its signatures are cryptographically valid and it is within the age window. Agility without a floor is a downgrade vector. -**Signer key compromise.** A compromised signing key can sign a wrong certificate message. The true threshold is governed by both X and the signer counts for each implementation, so those values must be sized together. Rotation is not instantaneous protection: a node keeps trusting a signer set until it receives a client or configuration update, so revocation latency is bounded by the client release and update cadence. +**Signer key compromise.** A compromised signing key can sign a wrong certificate message. The true threshold is governed by the threshold, implementation quorums, and controller diversity rule, so those values must be sized together. Rotation is not instantaneous protection: a node keeps trusting a signer set until it receives a client or configuration update, so revocation latency is bounded by the client release and update cadence. -**Supply chain dependency.** Signer public keys ship with the client or its configuration, because a bootstrapping node needs them before it has any state. The real root of trust is therefore client release integrity: the release channel delivers both the importer logic and the signer public keys, so a compromised release could defeat every check across implementations at once. This adds no new trust surface beyond trusting the client binary, but it raises the requirements on the release channel: reproducible builds, signed releases, and pinned, auditable signer set ids. +**Supply chain dependency.** Signer public keys and signer metadata ship with the client or its configuration, because a bootstrapping node needs them before it has any state. The real root of trust is therefore client release integrity: the release channel delivers both the importer logic and the trusted signer sets, so a compromised release could defeat every check across implementations at once. This adds no new trust surface beyond trusting the client binary, but it raises the requirements on the release channel: reproducible builds, signed releases, and pinned, auditable signer set ids and hashes. **Establishing finality.** The block_hash and finality checks in import step 2 are only as trustworthy as the node's beacon and execution data source. A node trusting a malicious RPC for finality could be shown a chain that is not canonical where B and block_hash look consistent. This is an existing trust assumption inherited from normal operation, not one introduced here; a consensus light client that would remove it is out of scope. Normal v1 acceptance requires finality; a node that cannot establish finality rejects the checkpoint. From 8db859f6206af8cb1891b4b31f68747625b3dcf2 Mon Sep 17 00:00:00 2001 From: diego Date: Wed, 10 Jun 2026 22:50:03 +0200 Subject: [PATCH 07/13] Specify checkpoint bundle format --- sips/checkpoint_sync.md | 104 ++++++++++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 4c0698f..14db2b2 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -233,13 +233,24 @@ The log set range is inclusive. `from_block_number` and `to_block_number` are pa **3. Checkpoint Format** -A checkpoint is a certificate plus one or more untrusted payloads. The certificate signs canonical SSZ commitments to public state and active encrypted shares. The payloads carry the preimages needed by an importing node to rebuild those commitments without replaying old logs. +A checkpoint distribution is untrusted. In v1 the mandatory interchange object is `CheckpointBundleV1`, which contains the certificate message, signature records, canonical public state, and active encrypted share set. The certificate signs canonical SSZ commitments to public state and active encrypted shares. The bundle carries the preimages needed by an importing node to rebuild those commitments without replaying old logs. -The certificate does not sign one specific snapshot file, compression format, chunking scheme, or mirror. Any file layout is acceptable if the importing node can parse it, materialize `CanonicalStateV1` and `ShareSetV1`, and recompute the signed commitments from their SSZ serialization. +Every conforming v1 importer must accept and verify the SSZ bundle format below: -**Snapshot payload.** The snapshot carries the canonical global state at block B, with enough information to materialize `CanonicalStateV1`, so that an importing node can populate its storage without folding the logs. +```text +CheckpointBundleV1 = Container[ + certificate_message: CheckpointCertificateMessageV1, + signatures: List[CheckpointSignatureV1, MAX_CHECKPOINT_SIGNATURE_RECORDS], + canonical_state: CanonicalStateV1, + share_set: ShareSetV1, +] +``` + +`MAX_CHECKPOINT_SIGNATURE_RECORDS` is a consensus constant for `scheme_version = 1`. The mandatory bundle is serialized with SSZ. Signature records are untrusted transport data until the importer verifies them against `certificate_message`. Other transport encodings may be offered, but they are not sufficient for v1 conformance unless they translate into the exact SSZ bytes of `CheckpointBundleV1` before any certificate, root, or storage decision. Compression, chunking, mirrors, and transport digests are outside the trust path. + +**Bundle contents.** The bundle carries the canonical global state at block B, with enough information to materialize `CanonicalStateV1`, so that an importing node can populate its storage without folding the logs. -The snapshot also carries the full active encrypted share set. Each ValidatorAdded event publishes, for every operator in the cluster, a share public key and an encrypted key share. Those ciphertexts are public event data, while the plaintext share remains protected by encryption to the operator's key. A fresh operator bootstrap needs its encrypted share, and client retention differs, so this set is not left to local storage. Therefore the certificate includes a mandatory `share_set_root`, separate from `state_root`. +The bundle also carries the full active encrypted share set. Each ValidatorAdded event publishes, for every operator in the cluster, a share public key and an encrypted key share. Those ciphertexts are public event data, while the plaintext share remains protected by encryption to the operator's key. A fresh operator bootstrap needs its encrypted share, and client retention differs, so this set is not left to local storage. Therefore the certificate includes a mandatory `share_set_root`, separate from `state_root`. `state_root` commits to the public validator client registry state. `share_set_root` commits to the encrypted share records for validators active at block B. It does not commit to encrypted shares for validators that were added and removed before B, because those shares are not needed to bootstrap the current state. @@ -257,6 +268,23 @@ encrypted_share_bytes The `cluster_id` is derived from owner and the sorted operator set as defined in section 1. The share blob grammar in section 2 defines the boundary of each encrypted share before it is inserted into `EncryptedShareRecordV1`. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. +**Bundle validation.** A v1 importer validates `CheckpointBundleV1` as canonical data before any storage mutation. It parses exactly one `certificate_message`, one `canonical_state`, and one `share_set`, recomputes `state_root` and `share_set_root` from those SSZ containers, and rejects if either root differs from the certificate message. After the roots match, the importer still rejects the bundle if the canonical sets are not internally consistent: + +- duplicate operator records by operator_id; +- duplicate validator records by `(validator_public_key, owner)`; +- duplicate cluster records by cluster_id; +- duplicate owner records by owner address; +- duplicate encrypted share records by `(validator_public_key, owner, operator_id)`; +- any validator whose cluster_id does not resolve to a cluster record; +- any validator whose owner differs from the referenced cluster owner; +- any cluster member operator_id that does not resolve to an operator record; +- any encrypted share whose `(validator_public_key, owner)` does not resolve to a validator record; +- any encrypted share whose cluster_id differs from the referenced validator's cluster_id; +- any encrypted share whose operator_id is not a member of the referenced cluster; +- any active validator that does not have exactly one encrypted share for each operator_id in its cluster. + +These checks make extra shares for unknown validators, removed validators, and nonmember operators invalid. They also make conflicting duplicate records invalid even if one duplicate alone would hash to a valid root. A v1 importer must populate storage only from the verified `CanonicalStateV1` and `ShareSetV1` records in `CheckpointBundleV1`. It must not populate storage from transport metadata, side tables, file names, indexes, caches, or any alternate representation that is not part of those verified SSZ containers. + **Checkpoint production paths.** The protocol is defined by the records and roots, not by a particular client's database. A process can produce a checkpoint only if it can enumerate the canonical public state and active encrypted share records at block B. That process may be a normal client that retained those records, a client with added retention, a dedicated materializer, or an archive indexer. For the initial checkpoint, where `parent_checkpoint_hash` is the all zero `Bytes32` value, there are two valid production paths: @@ -268,7 +296,7 @@ A client whose current state does not retain all active encrypted share cipherte For every checkpoint with a nonzero `parent_checkpoint_hash`, producers start from the parent checkpoint state and apply only the relevant SSV logs from the block after the parent through B. They update the public records and active encrypted share records, compute the new roots, and include `delta_log_set_hash` for that bounded event range. The `delta_log_set_hash` is computed from `LogSetV1` with domain `DELTA_LOG_SET_V1`, `from_block_number = parent.block_number + 1`, and `to_block_number = B`. -The checkpoint bundle may include an optional `snapshot_digest` outside the signed certificate for download integrity, caching, or mirror comparison. That digest is not a trust anchor; import step 4 treats it only as a transport check. +Checkpoint distribution may include an optional `snapshot_digest` outside `CheckpointBundleV1` for download integrity, caching, or mirror comparison. That digest is not a trust anchor; import step 1 treats it only as a transport check. **Certificate message.** The certificate message is the small object signers sign. In v1 the object is the SSZ container `CheckpointCertificateMessageV1`, and signers sign `certificate_message_hash = keccak256(ssz_serialize(CheckpointCertificateMessageV1))` under the signature scheme identified by `scheme_version`. @@ -365,7 +393,7 @@ V1 defines these implementation ids: For v1, importers reject a `TrustedSignerSetV1` whose `implementation_quorums` do not include `(implementation_id = 1, min_signers = 1)` and `(implementation_id = 2, min_signers = 1)`. A future `scheme_version` may add more implementations or change these minimums, but `scheme_version = 1` keeps both minima. -**Required meaning.** A certificate attests that the signer independently materialized the canonical public state and active encrypted share set at block B, without trusting the payload being signed, and computed this same `state_root` and `share_set_root` using one of the production paths in section 3. For a child checkpoint, it also attests that the signer consumed the exact ordered event delta identified by `delta_log_set_hash`. A certificate is not an attestation that a downloaded checkpoint file parsed or that a particular payload encoding or hosting path is trustworthy. +**Required meaning.** A certificate attests that the signer independently materialized the canonical public state and active encrypted share set at block B, without trusting the bundle being signed, and computed this same `state_root` and `share_set_root` using one of the production paths in section 3. For a child checkpoint, it also attests that the signer consumed the exact ordered event delta identified by `delta_log_set_hash`. A certificate is not an attestation that a downloaded checkpoint file parsed or that a particular bundle encoding or hosting path is trustworthy. **Acceptance rule.** A checkpoint is accepted only when one set of unique active signer ids signs the same certificate message and satisfies every rule in `TrustedSignerSetV1`: at least `threshold` unique signers, every required implementation quorum, and `min_controller_count` when nonzero. Duplicate signature records for the same signer_id count once. A signature record whose signer_id is unknown, retired for block B, associated with a different scheme, or invalid for the configured public key is ignored. This mitigates a shared reconstruction bug in one implementation, such as #972, because the same counted signer set must include both Anchor and go-ssv signers. When implementations disagree on the roots for a block, no checkpoint is published and the disagreement is investigated. @@ -379,14 +407,14 @@ This rule makes the mechanism intentionally inert until at least two implementat When a node imports a checkpoint it performs the following checks. A first syncing node, or a node whose last sync is older than the maximum acceptable age, trusts the state itself; it cannot verify completeness without folding the logs, which is the work it is trying to skip. The checks below are the cheap bindings it can still enforce. -1. Confirm the certificate message network_id and ssv_contract_address match the node's configured network and contract. Reject on mismatch. Reject if canonical_spec_version is one the node does not implement, since a root is only meaningful under the canonical state definition that produced it. -2. Confirm block B is finalized according to the node's consensus data source. Reject if finality cannot be established. A finalized block can no longer be reverted by a chain reorganization (reorg), so the state at B is permanent. Confirm block_hash matches the finalized block. -3. Resolve `signer_set_id` to a locally trusted `TrustedSignerSetV1`. Reject if the set is unknown, if its `scheme_version` differs from the certificate message, if `scheme_version` is below the node's minimum accepted version, or if `keccak256(ssz_serialize(TrustedSignerSetV1))` differs from `signer_set_hash`. Verify each signature record against the public key for its signer_id and count each valid active signer_id at most once. Reject unless the counted set satisfies `threshold`, every configured implementation quorum, and `min_controller_count` when nonzero. -4. Parse the received payloads. If the bundle includes an optional snapshot_digest, confirm it for download integrity, but do not treat it as a signed trust anchor. -5. Reconstruct the canonical record set and active encrypted share set from the payloads. Materialize `CanonicalStateV1` and `ShareSetV1`, recompute `state_root` and `share_set_root` from their SSZ serializations, and confirm both roots match the certificate message. +1. Parse exactly one `CheckpointBundleV1` from the received SSZ bytes. Reject malformed SSZ, trailing bytes, or any representation that cannot materialize that single container. If checkpoint distribution includes an optional snapshot_digest, confirm it for download integrity, but do not treat it as a signed trust anchor. +2. Confirm the certificate message network_id and ssv_contract_address match the node's configured network and contract. Reject on mismatch. Reject if canonical_spec_version is one the node does not implement, since a root is only meaningful under the canonical state definition that produced it. +3. Confirm block B is finalized according to the node's consensus data source. Reject if finality cannot be established. A finalized block can no longer be reverted by a chain reorganization (reorg), so the state at B is permanent. Confirm block_hash matches the finalized block. +4. Resolve `signer_set_id` to a locally trusted `TrustedSignerSetV1`. Reject if the set is unknown, if its `scheme_version` differs from the certificate message, if `scheme_version` is below the node's minimum accepted version, or if `keccak256(ssz_serialize(TrustedSignerSetV1))` differs from `signer_set_hash`. Verify each signature record against the public key for its signer_id and count each valid active signer_id at most once. Reject unless the counted set satisfies `threshold`, every configured implementation quorum, and `min_controller_count` when nonzero. +5. Validate `canonical_state` and `share_set` as the canonical record set and active encrypted share set. Recompute `state_root` and `share_set_root` from their SSZ serializations, confirm both roots match the certificate message, and enforce the bundle validation rules from section 3. 6. Freshness: reject if block B is older than the maximum acceptable age (see below). 7. Monotonicity: reject if B is not newer than the node's current state. A node never imports a checkpoint older than what it already has. -8. On success, populate storage from the snapshot, locating and decrypting the node's own shares from the active encrypted share set, and continue normal sync after block B. +8. On success, populate storage only from the verified `canonical_state` and `share_set`, locating and decrypting the node's own shares from the active encrypted share set, and continue normal sync after block B. **Freshness and monotonicity.** Signatures cannot stop replay of an old but valid checkpoint. The defenses are a maximum acceptable age, enforced by the importing node, a finality requirement on B, and the rule that a node never imports a checkpoint older than its current state. Monotonicity protects only a resync, where the node already has state to compare against. A first syncing node has no current state, so monotonicity gives it nothing; its protections are the maximum age, the finality requirement, and the trusted signer set threshold plus implementation quorums. @@ -394,7 +422,42 @@ When a node imports a checkpoint it performs the following checks. A first synci The canonical spec is only useful if it is enforced. The following checks do that, and they would have caught #972. -**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the state roots the canonical spec says it must produce. The expected roots are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice and asserts that its computed roots equal the recorded ones. This is fully automated. It covers only the history built into the test. The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorAdded with 4 operators and with 7 operators, ValidatorAdded with unsorted or duplicate operator ids, ValidatorAdded with a bad share blob length, ValidatorAdded with a bad validator signature, ValidatorAdded with ciphertext bytes that are structurally valid but do not decrypt for one operator, ValidatorRemoved omission, removed operators that remain referenced by active membership, removed operators that become unreferenced and are omitted, and certificate acceptance cases for duplicate signer ids, missing implementation quorum, retired signer, signer_set_hash mismatch, and controller diversity when enabled. +**CI conformance vectors.** A conformance vector is a recorded slice of real chain history paired with the roots and import result the canonical spec says it must produce. The expected roots and expected import result are derived from the canonical spec, not from any one client. The vector file ships in the repository. Every conforming client replays the slice, computes the roots, parses the import vector, imports only the vector's bundle bytes, and asserts that its computed roots and import decision equal the recorded values. This is fully automated. It covers only the history built into the test. + +V1 import conformance vectors use this mandatory SSZ wrapper: + +```text +CheckpointImportVectorV1 = Container[ + case_id: List[byte, MAX_VECTOR_CASE_ID_BYTES], + expected_state_root: Bytes32, + expected_share_set_root: Bytes32, + expected_result: uint64, + trusted_signer_sets: List[TrustedSignerSetV1, MAX_VECTOR_SIGNER_SETS], + bundle_bytes: List[byte, MAX_VECTOR_BUNDLE_BYTES], + untrusted_side_data: List[byte, MAX_VECTOR_SIDE_DATA_BYTES], +] +``` + +`MAX_VECTOR_CASE_ID_BYTES`, `MAX_VECTOR_SIGNER_SETS`, `MAX_VECTOR_BUNDLE_BYTES`, and `MAX_VECTOR_SIDE_DATA_BYTES` are consensus constants for the v1 test format. `bundle_bytes` is the exact byte input passed to the importer. Accepted cases encode one valid `CheckpointBundleV1`. Rejection cases may contain malformed SSZ, duplicate records, records outside canonical membership, bad certificate fields, or bad signatures. `expected_result` uses these v1 codes: + +```text +0 = ACCEPT +1 = REJECT_BAD_BUNDLE_SSZ +2 = REJECT_BAD_CERTIFICATE +3 = REJECT_UNKNOWN_SIGNER_SET +4 = REJECT_SIGNER_SET_HASH_MISMATCH +5 = REJECT_MISSING_IMPLEMENTATION_QUORUM +6 = REJECT_DUPLICATE_CANONICAL_RECORD +7 = REJECT_EXTRA_ENCRYPTED_SHARE +8 = REJECT_MISSING_ENCRYPTED_SHARE +9 = REJECT_NONMEMBER_ENCRYPTED_SHARE +10 = REJECT_CLUSTER_MISMATCH +11 = REJECT_UNTRUSTED_SIDE_DATA_USED +``` + +`untrusted_side_data` is never part of `CheckpointBundleV1`; it exists only to test that implementations do not populate storage from bytes outside the verified bundle. A conformance harness reports code 11 if an implementation reads or stores from `untrusted_side_data`; a production importer must never receive that field. + +The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorAdded with 4 operators and with 7 operators, ValidatorAdded with unsorted or duplicate operator ids, ValidatorAdded with a bad share blob length, ValidatorAdded with a bad validator signature, ValidatorAdded with ciphertext bytes that are structurally valid but do not decrypt for one operator, ValidatorRemoved omission, removed operators that remain referenced by active membership, removed operators that become unreferenced and are omitted, malformed `bundle_bytes`, duplicate operator, validator, cluster, owner, and encrypted share records in `CheckpointBundleV1`, extra shares for unknown or removed validators, shares for nonmember operators, cluster_id mismatch between validator and share records, missing share for a cluster member, conflicting duplicate entries, bundle bytes with correct roots and untrusted side data that must not be written to storage, and certificate acceptance cases for duplicate signer ids, missing implementation quorum, retired signer, signer_set_hash mismatch, and controller diversity when enabled. **Full replay audit run.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point (every N blocks) a tuple: @@ -418,14 +481,13 @@ The diff tool first compares the parent checkpoint hash and `delta_log_set_hash` **Open Questions and Future Versions** -The following questions are outside the v1 acceptance path. A v1 importer does not choose among these options; it follows the closed profile in sections 1 and 2. +The following questions are outside the v1 acceptance path. A v1 importer does not choose among these options; it follows the closed profile in sections 1 through 5. -- O1. Should the checkpoint payload be one combined file with state and shares, or separate payloads for public state and encrypted shares? This is a distribution and file format question only. Acceptance always verifies the mandatory `state_root` and `share_set_root` from the certificate message. Open. -- O2. A future canonical spec version may replace the flat hash with a Merkle tree for inclusion proofs or Candidate B. That version must pin the exact tree shape, including leaf grouping, domain separation for leaves and internal nodes, and odd leaf handling. -- O3. A future canonical spec version may include data derived from beacon state, such as validator index. V1 excludes it because it is not a deterministic function of the SSV logs. -- O4. Post-quantum signature scheme for v1 or later. V1 keeps this optional behind the versioned scheme_version. Open. -- O5. A future canonical spec version may revisit operator identity or public key normalization. V1 keys operators by `operator_id`, does not deduplicate by public key, and hashes the canonical public key bytes defined in section 1. -- O6. A future canonical spec version may revisit tombstone policy. V1 omits removed validators, retains removed operators only while active clusters reference them, and omits clusters with no active validators. +- O1. A future canonical spec version may replace the flat hash with a Merkle tree for inclusion proofs or Candidate B. That version must pin the exact tree shape, including leaf grouping, domain separation for leaves and internal nodes, and odd leaf handling. +- O2. A future canonical spec version may include data derived from beacon state, such as validator index. V1 excludes it because it is not a deterministic function of the SSV logs. +- O3. Post-quantum signature scheme for v1 or later. V1 keeps this optional behind the versioned scheme_version. Open. +- O4. A future canonical spec version may revisit operator identity or public key normalization. V1 keys operators by `operator_id`, does not deduplicate by public key, and hashes the canonical public key bytes defined in section 1. +- O5. A future canonical spec version may revisit tombstone policy. V1 omits removed validators, retains removed operators only while active clusters reference them, and omits clusters with no active validators. **Out of Scope** @@ -433,7 +495,7 @@ The following are operational, not protocol, and are out of scope for this SIP: **Security Considerations** -**Trust assumption.** An importing node that skips the fold trusts the signer set, not whoever published the checkpoint payloads. The publisher is untrusted; a malicious publisher can at most serve payloads that fail to reconstruct the signed `state_root` or `share_set_root`. The acceptance rule in section 4 assumes enough honest signers to prevent a dishonest counted set from satisfying the threshold, implementation quorum, and controller rules. +**Trust assumption.** An importing node that skips the fold trusts the signer set, not whoever published the checkpoint bundle. The publisher is untrusted; a malicious publisher can at most serve a bundle that fails to reconstruct the signed `state_root` or `share_set_root`. The acceptance rule in section 4 assumes enough honest signers to prevent a dishonest counted set from satisfying the threshold, implementation quorum, and controller rules. **Completeness versus correctness.** Agreement across implementations catches omitted records only because the root commits to the record set itself, not to counters. It does not prove the shared event scope or canonical spec is correct; section 6 describes why eth_call and the full fold audit remain complementary. From 25826205efade3b586ef8c1bbf62aa816b962f80 Mon Sep 17 00:00:00 2001 From: diego Date: Wed, 10 Jun 2026 22:59:35 +0200 Subject: [PATCH 08/13] Clarify checkpoint format constants --- sips/checkpoint_sync.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 14db2b2..3b9a833 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -156,7 +156,7 @@ OwnerRecordV1 = Container[ ] ``` -`MAX_OPERATORS`, `MAX_VALIDATORS`, `MAX_CLUSTERS`, `MAX_OWNERS`, `MAX_OPERATOR_PUBLIC_KEY_BYTES`, and `MAX_CLUSTER_OPERATORS` are consensus constants for `canonical_spec_version = 1`. They must be high enough to cover every state that can exist under the SSV contract and must be identical across implementations. A client rejects a v1 state that cannot be represented within these limits. The concrete values are part of the v1 conformance vectors. +`MAX_OPERATORS`, `MAX_VALIDATORS`, `MAX_CLUSTERS`, `MAX_OWNERS`, `MAX_OPERATOR_PUBLIC_KEY_BYTES`, and `MAX_CLUSTER_OPERATORS` are fixed SIP constants for `canonical_spec_version = 1`. They set checkpoint encoding limits and must be high enough to cover every state that can exist under the SSV contract. They must be identical across implementations. A client rejects a v1 state that cannot be represented within these limits. The concrete values are part of the v1 conformance vectors. The v1 SSZ container for the active encrypted share set is: @@ -179,7 +179,7 @@ EncryptedShareRecordV1 = Container[ ] ``` -`MAX_ENCRYPTED_SHARE_RECORDS` is also a consensus constant for v1. Encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. +`MAX_ENCRYPTED_SHARE_RECORDS` is also a fixed SIP constant for v1. Encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. A ValidatorAdded log derives encrypted share records from the event `shares` bytes using the v1 share blob grammar below. Let `n = len(operatorIds)`. The `operatorIds` array in a ValidatorAdded log must be nonempty and strictly ascending, and every operator id must refer to an operator record present in the fold before the event. Duplicate ids, descending ids, or missing operators make the ValidatorAdded event malformed after the owner nonce transition defined in section 1. @@ -229,7 +229,7 @@ RelevantSsvLogV1 = Container[ ] ``` -The log set range is inclusive. `from_block_number` and `to_block_number` are part of the hash preimage even when the `events` list is empty. The `events` list contains the raw Ethereum logs for the relevant SSV events from section 1, emitted by `ssv_contract_address`, ordered by ascending `(block_number, transaction_index, log_index)`. `address` must equal `ssv_contract_address`, and `topics` and `data` are the exact bytes returned by the execution data source for that log. `MAX_LOG_EVENTS`, `MAX_LOG_TOPICS`, and `MAX_LOG_DATA_BYTES` are consensus constants for v1. +The log set range is inclusive. `from_block_number` and `to_block_number` are part of the hash preimage even when the `events` list is empty. The `events` list contains the raw Ethereum logs for the relevant SSV events from section 1, emitted by `ssv_contract_address`, ordered by ascending `(block_number, transaction_index, log_index)`. `address` must equal `ssv_contract_address`, and `topics` and `data` are the exact bytes returned by the execution data source for that log. `MAX_LOG_EVENTS`, `MAX_LOG_TOPICS`, and `MAX_LOG_DATA_BYTES` are fixed SIP constants for v1. **3. Checkpoint Format** @@ -246,7 +246,7 @@ CheckpointBundleV1 = Container[ ] ``` -`MAX_CHECKPOINT_SIGNATURE_RECORDS` is a consensus constant for `scheme_version = 1`. The mandatory bundle is serialized with SSZ. Signature records are untrusted transport data until the importer verifies them against `certificate_message`. Other transport encodings may be offered, but they are not sufficient for v1 conformance unless they translate into the exact SSZ bytes of `CheckpointBundleV1` before any certificate, root, or storage decision. Compression, chunking, mirrors, and transport digests are outside the trust path. +`MAX_CHECKPOINT_SIGNATURE_RECORDS` is a fixed SIP constant for `scheme_version = 1`. The mandatory bundle is serialized with SSZ. Signature records are untrusted transport data until the importer verifies them against `certificate_message`. Other transport encodings may be offered, but they are not sufficient for v1 conformance unless they translate into the exact SSZ bytes of `CheckpointBundleV1` before any certificate, root, or storage decision. Compression, chunking, mirrors, and transport digests are outside the trust path. **Bundle contents.** The bundle carries the canonical global state at block B, with enough information to materialize `CanonicalStateV1`, so that an importing node can populate its storage without folding the logs. @@ -351,7 +351,7 @@ CheckpointSignatureV1 = Container[ ] ``` -`MAX_CHECKPOINT_SIGNATURE_BYTES` is a consensus constant for `scheme_version = 1`. +`MAX_CHECKPOINT_SIGNATURE_BYTES` is a fixed SIP constant for `scheme_version = 1`. `scheme_version = 1` uses individual signature records. Any future aggregate signature scheme must still expose the signer ids covered by the aggregate before applying the same counting rules. @@ -382,7 +382,7 @@ TrustedSignerV1 = Container[ ] ``` -`MAX_CHECKPOINT_IMPLEMENTATIONS`, `MAX_CHECKPOINT_SIGNERS`, and `MAX_SIGNER_PUBLIC_KEY_BYTES` are consensus constants for `scheme_version = 1`. `implementation_quorums` are ordered by ascending implementation_id, and signers are ordered by ascending signer_id before computing `signer_set_hash`. Duplicate implementation ids, duplicate signer ids, duplicate public keys, `threshold = 0`, or an implementation quorum with `min_signers = 0` make the trusted signer set invalid. `valid_to_block = 0` means the signer has no configured retirement block. Otherwise, a signer is active for block B only when `valid_from_block <= B <= valid_to_block`. `threshold` is the minimum number of unique active signer ids required. `min_controller_count = 0` disables controller diversity; otherwise the counted signer set must contain at least that many distinct `controller_id` values. +`MAX_CHECKPOINT_IMPLEMENTATIONS`, `MAX_CHECKPOINT_SIGNERS`, and `MAX_SIGNER_PUBLIC_KEY_BYTES` are fixed SIP constants for `scheme_version = 1`. `implementation_quorums` are ordered by ascending implementation_id, and signers are ordered by ascending signer_id before computing `signer_set_hash`. Duplicate implementation ids, duplicate signer ids, duplicate public keys, `threshold = 0`, or an implementation quorum with `min_signers = 0` make the trusted signer set invalid. `valid_to_block = 0` means the signer has no configured retirement block. Otherwise, a signer is active for block B only when `valid_from_block <= B <= valid_to_block`. `threshold` is the minimum number of unique active signer ids required. `min_controller_count = 0` disables controller diversity; otherwise the counted signer set must contain at least that many distinct `controller_id` values. V1 defines these implementation ids: @@ -438,7 +438,7 @@ CheckpointImportVectorV1 = Container[ ] ``` -`MAX_VECTOR_CASE_ID_BYTES`, `MAX_VECTOR_SIGNER_SETS`, `MAX_VECTOR_BUNDLE_BYTES`, and `MAX_VECTOR_SIDE_DATA_BYTES` are consensus constants for the v1 test format. `bundle_bytes` is the exact byte input passed to the importer. Accepted cases encode one valid `CheckpointBundleV1`. Rejection cases may contain malformed SSZ, duplicate records, records outside canonical membership, bad certificate fields, or bad signatures. `expected_result` uses these v1 codes: +`MAX_VECTOR_CASE_ID_BYTES`, `MAX_VECTOR_SIGNER_SETS`, `MAX_VECTOR_BUNDLE_BYTES`, and `MAX_VECTOR_SIDE_DATA_BYTES` are fixed test format constants for v1. `bundle_bytes` is the exact byte input passed to the importer. Accepted cases encode one valid `CheckpointBundleV1`. Rejection cases may contain malformed SSZ, duplicate records, records outside canonical membership, bad certificate fields, or bad signatures. `expected_result` uses these v1 codes: ```text 0 = ACCEPT From ad296711f1c8de503b583e280b99df22fb993d40 Mon Sep 17 00:00:00 2001 From: diegomrsantos Date: Thu, 11 Jun 2026 03:04:55 +0200 Subject: [PATCH 09/13] Condense checkpoint sync SIP --- sips/checkpoint_sync.md | 123 +++++++++++++++++----------------------- 1 file changed, 53 insertions(+), 70 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 3b9a833..bb3950b 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -6,31 +6,29 @@ **Summary** -Every SSV node keeps a copy of the network state: the list of operators, validators, and clusters. A node builds that state by folding the SSV smart contract's events in order, starting at the deployment block. The fold needs only the event log and no contract storage reads at old blocks. +Every SSV node keeps a copy of the network state: the list of operators, validators, and clusters. Today a node builds that state by folding the SSV smart contract's event log from the deployment block. -On mainnet, however, that means obtaining and replaying the full log history back to the 2023 deployment block. A checkpoint lets a node start from a recent finalized block without doing that replay, while reconstruction remains available as the audit fallback for anyone who can still obtain the logs. +On mainnet, that means replaying the full log history back to 2023. A checkpoint lets a node start from a recent finalized block, while full reconstruction remains the audit fallback for anyone who can still obtain the logs. -The design is the Candidate A path from the discussion: a signed checkpoint that changes no contract. Candidate B would instead publish the state root on the SSV contract itself. It is stronger but needs a contract change and is not specified here. +This SIP specifies the Candidate A path from the discussion: a signed checkpoint that requires no contract change. -A trusted checkpoint has one hard problem. A payload can parse cleanly and still leave out records or describe them wrongly, and the node has no way to notice the missing records unless it checks a commitment to the complete state. This SIP calls that the completeness problem. Spot checks with eth_call can only validate records the node already knows about; they cannot prove that nothing was left out. +A checkpoint bundle can parse cleanly while omitting records or describing them wrongly. V1 addresses that completeness problem by certifying a canonical `state_root` for public state and a `share_set_root` for active encrypted shares. The certificate binds those roots to a finalized block, network, SSV contract, canonical spec version, signer set, and signature scheme. -To catch missing public records this SIP uses a canonical state root. A state root is one hash that stands for the entire network state at a block. Canonical means every correct client computes the exact same hash from the same logical input. If two clients, built by two different teams, compute the same root from the same correct event scope and canonical spec, then one implementation cannot silently drop a record without changing the hash. Matching roots prove agreement on the committed record set; they do not prove the event scope or canonical spec is correct, so the full fold audit remains the backstop for shared mistakes. Independent signers compute `state_root` and `share_set_root` from retained state, from a historical materializer, or from a parent checkpoint plus new logs, and sign a certificate message containing those roots. +The SIP defines the canonical network state, checkpoint bundle format, certificate format, import verification flow, and conformance tests. Throughout this SIP, logs means the SSV smart contract's event log, served by full nodes over eth_getLogs. -The core protocol surface has three parts: (1) the canonical network state and how to encode it, (2) the checkpoint payload format for public state and encrypted shares, and (3) the certificate format and what a certificate is required to mean. The SIP also specifies the client import and verification flow and a conformance test that guards the canonical spec. - -Throughout this SIP, the term logs means the SSV smart contract's event log, which any full node serves over eth_getLogs. +Candidate B, which would publish the state root on the SSV contract, is stronger but requires a contract change and is not specified here. **Rationale & Design Goals** -Two independent implementations that fold the same logs should produce the same bytes. Today they do not always agree. Anchor issue [#972](https://github.com/sigp/anchor/issues/972) is a live example: Anchor and go-ssv decode an OperatorAdded operator public key differently, which causes Anchor to silently skip an operator and diverge from the other client. Bugs of this kind are invisible until two implementations are compared byte for byte at the same block. +Two independent implementations that fold the same logs should produce the same bytes. Today they do not always agree. Anchor issue [#972](https://github.com/sigp/anchor/issues/972) is a live example: Anchor and go-ssv decode an OperatorAdded operator public key differently, which causes Anchor to silently skip an operator and diverge from the other client. The design goals follow from that observation. -- A node that skips the fold must still be protected against an incomplete or misrepresented state. The protection is agreement across implementations on a canonical root, not trust in whoever published the file. -- The canonical state must be specified down to encoding, ordering, and application semantics, so that agreement is meaningful and so that the encoding choices where two clients can silently disagree (such as the one behind #972) are pinned down in one place rather than rediscovered by each client team. -- Checkpoint sync removes the need to replay all history at startup while preserving reconstruction from the deployment block as the audit path for anyone who can still obtain the logs. -- The format must allow the signature scheme to be replaced later without a format break. -- The mechanism must commit at blocks that are permanent, so that no reorg can invalidate a published checkpoint. +- A node that skips the fold must still be protected against incomplete or misrepresented state by agreement on canonical roots, not by trust in the publisher. +- The canonical state must pin encoding, ordering, and application semantics so independent clients can compare roots meaningfully. +- Checkpoint sync should remove full replay from startup while preserving replay from the deployment block as the audit path. +- The format must allow signer rotation and later signature scheme changes without a bundle format break. +- The mechanism must commit at finalized blocks so a reorg cannot invalidate a published checkpoint. The sequencing matters. The canonical state spec is the precondition for everything else. The differential conformance check enforces it. Checkpoints and certificates sit on top. @@ -55,19 +53,20 @@ The block number is a reference point that records when the state was taken. It | Cluster | cluster_id (derived, see below), owner address, sorted member operator_ids, liquidated flag | | Owner | owner address, fee_recipient, next_validator_nonce | -**Counters are not a substitute for records.** The canonical root must commit to the full set of records included in scope by content. It must not substitute any monotonic counter, count, or high water mark for the record set. A counter such as a highest operator id seen value can advance on both clients while one of them is missing an operator's record, which defeats completeness. #972 is exactly this trap: the omitted operator changes the operator record set, and the root catches it only because the root commits to records, not to the operator id counter. +**Counters are not a substitute for records.** The canonical root must commit to the full record set by content. It must not substitute any monotonic counter, count, or high water mark for the records, because counters can agree while a client is missing a record. -**Application semantics are part of the canonical state.** The canonical state is defined by the encoding and ordering below, plus the application semantics: which events are accepted versus ignored, the signature and nonce validation rules, and the effect of a rejected event. A root mismatch on identical logs can therefore come from a decoding bug or from a difference in application semantics, and both are in scope for this spec. V1 pins those rules in this section; a client must not substitute its local storage behavior for these canonical fold rules. +**Application semantics are part of the canonical state.** The canonical state is defined by the encoding and ordering below plus the rules for accepting, ignoring, and rejecting events. A client must not substitute local storage behavior for these canonical fold rules. **Owner state and validator nonces.** The canonical Owner record stores `next_validator_nonce`, the nonce value to use for the next ValidatorAdded by that owner. It does not store a client's local representation of the last consumed nonce, and it does not depend on whether a client stores that value as nullable. This distinguishes an owner that only changed its fee recipient, whose next validator nonce is still 0, from an owner that already consumed nonce 0. An absent Owner record means the effective defaults: `fee_recipient = owner` and `next_validator_nonce = 0`. A FeeRecipientAddressUpdated event changes the effective `fee_recipient` and leaves `next_validator_nonce` unchanged, using 0 if no previous owner nonce state exists. A ValidatorAdded event is evaluated against the current `next_validator_nonce` for that owner and then advances `next_validator_nonce` by one before the rest of the event is validated. If the ValidatorAdded event is rejected after this point, the nonce advance remains and no validator record, cluster update, or encrypted share records are created. An Owner record is included in the canonical record set when either `fee_recipient != owner` or `next_validator_nonce != 0`; otherwise it is omitted because it is identical to the default state. -**Excluded from the state root.** The validator index on the beacon chain is fetched from the beacon node, not derived from the logs, so it is not a deterministic function of the logs and is not part of the v1 state root. A future canonical spec version may add a field derived from beacon data, but v1 excludes it. - -ValidatorExited is also excluded. It is a beacon chain signal: it announces that a validator is exiting the beacon chain, not that its SSV registry record has changed. A validator's registry record is removed only by ValidatorRemoved; ValidatorExited leaves the operator, validator, and cluster records untouched. It therefore changes no canonical record and no root. This is distinct from the operator removed status above, which is set only by OperatorRemoved. +**Excluded from the state root.** V1 excludes data that is not derived from SSV logs or is not part of validator client membership: -The operator fee and the Cluster accounting fields carried by each event (balance, validatorCount, networkFeeIndex, index, and the active flag) are also excluded: they are mutable contract bookkeeping that varies over time, not part of the canonical state for validator clients, and committing to accumulators that advance on nearly every interaction would change the root far more often than the logical membership state does. Cluster liquidation status is derived from the ClusterLiquidated and ClusterReactivated events, not from the Cluster.active field. In v1, those events update only an included cluster record. If no cluster record is included for that owner and operator set, the event changes no canonical record. A cluster created later starts with `liquidated = false` unless a later ClusterLiquidated event changes it. +- Beacon validator index is fetched from the beacon node, not derived from SSV logs. +- ValidatorExited is a beacon chain signal and does not change SSV registry records. A validator registry record is removed only by ValidatorRemoved. +- Operator fee and Cluster accounting fields carried by events are contract bookkeeping, not validator client membership state. +- Cluster liquidation status is derived from ClusterLiquidated and ClusterReactivated, not from the Cluster.active field. Those events update only an included cluster record. If no cluster record is included for that owner and operator set, the event changes no canonical record. A cluster created later starts with `liquidated = false` unless a later ClusterLiquidated event changes it. **Removal representation.** V1 pins removal per entity. A ValidatorRemoved event removes the validator record identified by the event owner and validator public key from `state_root` and removes the encrypted share records for that same owner and validator public key from `share_set_root`. If that was the last active validator in a cluster, the cluster record is also omitted. An OperatorRemoved event sets `removed = true` for that operator while any active cluster still references it. A removed operator with no active cluster reference is omitted from the canonical record set. Active operators are included even if they are not members of an active cluster. @@ -248,25 +247,13 @@ CheckpointBundleV1 = Container[ `MAX_CHECKPOINT_SIGNATURE_RECORDS` is a fixed SIP constant for `scheme_version = 1`. The mandatory bundle is serialized with SSZ. Signature records are untrusted transport data until the importer verifies them against `certificate_message`. Other transport encodings may be offered, but they are not sufficient for v1 conformance unless they translate into the exact SSZ bytes of `CheckpointBundleV1` before any certificate, root, or storage decision. Compression, chunking, mirrors, and transport digests are outside the trust path. -**Bundle contents.** The bundle carries the canonical global state at block B, with enough information to materialize `CanonicalStateV1`, so that an importing node can populate its storage without folding the logs. - -The bundle also carries the full active encrypted share set. Each ValidatorAdded event publishes, for every operator in the cluster, a share public key and an encrypted key share. Those ciphertexts are public event data, while the plaintext share remains protected by encryption to the operator's key. A fresh operator bootstrap needs its encrypted share, and client retention differs, so this set is not left to local storage. Therefore the certificate includes a mandatory `share_set_root`, separate from `state_root`. +**Bundle contents.** The bundle carries `CanonicalStateV1` and `ShareSetV1` at block B. These are the preimages for the signed `state_root` and `share_set_root`, and an importer populates storage only from those verified containers. -`state_root` commits to the public validator client registry state. `share_set_root` commits to the encrypted share records for validators active at block B. It does not commit to encrypted shares for validators that were added and removed before B, because those shares are not needed to bootstrap the current state. +The bundle carries the full active encrypted share set because a fresh operator bootstrap needs its encrypted share and clients may not retain every ciphertext locally. The ciphertexts are public event data; plaintext shares are not part of the checkpoint. -The `share_set_root` uses the `ShareSetV1` SSZ container from section 2. Its domain field is `SHARE_SET_ROOT_V1`, and the container includes `canonical_spec_version = 1`, `network_id`, and `ssv_contract_address` from the certificate message. V1 encrypted share records are ordered by validator public key bytes, owner address bytes, and then by ascending operator_id. Each record contains: - -```text -ENCRYPTED_SHARE_RECORD_V1 -owner -validator_public_key -cluster_id -operator_id -share_public_key -encrypted_share_bytes -``` +`state_root` commits to the public validator client registry state. `share_set_root` commits to encrypted share records for validators active at block B. It excludes encrypted shares for validators removed before B because those shares are not needed to bootstrap the current state. -The `cluster_id` is derived from owner and the sorted operator set as defined in section 1. The share blob grammar in section 2 defines the boundary of each encrypted share before it is inserted into `EncryptedShareRecordV1`. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. +`share_set_root` uses `ShareSetV1` and `EncryptedShareRecordV1` from section 2. The share blob grammar defines the boundary of each encrypted share before it is inserted into `EncryptedShareRecordV1`. This root proves share completeness and byte integrity for bootstrap; it does not require signers to decrypt shares. **Bundle validation.** A v1 importer validates `CheckpointBundleV1` as canonical data before any storage mutation. It parses exactly one `certificate_message`, one `canonical_state`, and one `share_set`, recomputes `state_root` and `share_set_root` from those SSZ containers, and rejects if either root differs from the certificate message. After the roots match, the importer still rejects the bundle if the canonical sets are not internally consistent: @@ -285,16 +272,11 @@ The `cluster_id` is derived from owner and the sorted operator set as defined in These checks make extra shares for unknown validators, removed validators, and nonmember operators invalid. They also make conflicting duplicate records invalid even if one duplicate alone would hash to a valid root. A v1 importer must populate storage only from the verified `CanonicalStateV1` and `ShareSetV1` records in `CheckpointBundleV1`. It must not populate storage from transport metadata, side tables, file names, indexes, caches, or any alternate representation that is not part of those verified SSZ containers. -**Checkpoint production paths.** The protocol is defined by the records and roots, not by a particular client's database. A process can produce a checkpoint only if it can enumerate the canonical public state and active encrypted share records at block B. That process may be a normal client that retained those records, a client with added retention, a dedicated materializer, or an archive indexer. +**Checkpoint production paths.** The protocol is defined by records and roots, not by a particular database. A producer may materialize records from retained state, an archive source, a historical materializer, or a parent checkpoint plus new logs through B. -For the initial checkpoint, where `parent_checkpoint_hash` is the all zero `Bytes32` value, there are two valid production paths: +For an initial checkpoint, where `parent_checkpoint_hash` is the all zero `Bytes32` value, the producer must enumerate the complete public state and active encrypted share set at B before computing the roots. A client that does not retain all active encrypted share ciphertexts must use a materializer, an archive source, or a previous checkpoint that already contains the share set. -1. Retained state path. The producer already has a complete current registry snapshot at B, including active encrypted share ciphertexts for every validator and operator pair. It exports that state and computes `state_root` and `share_set_root` directly from the canonical records. -2. Historical materializer path. The producer does not have retained encrypted shares. It folds historical registry events through B, materializes the same active public state and encrypted share set, discards encrypted shares for validators removed before B, and computes the same roots. - -A client whose current state does not retain all active encrypted share ciphertexts cannot produce the encrypted share part of the initial checkpoint from that state alone. It must use a materializer, an archive source, or a previous checkpoint that already contains the share set. - -For every checkpoint with a nonzero `parent_checkpoint_hash`, producers start from the parent checkpoint state and apply only the relevant SSV logs from the block after the parent through B. They update the public records and active encrypted share records, compute the new roots, and include `delta_log_set_hash` for that bounded event range. The `delta_log_set_hash` is computed from `LogSetV1` with domain `DELTA_LOG_SET_V1`, `from_block_number = parent.block_number + 1`, and `to_block_number = B`. +For every checkpoint with a nonzero `parent_checkpoint_hash`, producers start from the parent checkpoint state and apply only the relevant SSV logs from the block after the parent through B. The certificate includes `delta_log_set_hash` for that range, computed from `LogSetV1` with domain `DELTA_LOG_SET_V1`, `from_block_number = parent.block_number + 1`, and `to_block_number = B`. Checkpoint distribution may include an optional `snapshot_digest` outside `CheckpointBundleV1` for download integrity, caching, or mirror comparison. That digest is not a trust anchor; import step 1 treats it only as a transport check. @@ -393,15 +375,15 @@ V1 defines these implementation ids: For v1, importers reject a `TrustedSignerSetV1` whose `implementation_quorums` do not include `(implementation_id = 1, min_signers = 1)` and `(implementation_id = 2, min_signers = 1)`. A future `scheme_version` may add more implementations or change these minimums, but `scheme_version = 1` keeps both minima. -**Required meaning.** A certificate attests that the signer independently materialized the canonical public state and active encrypted share set at block B, without trusting the bundle being signed, and computed this same `state_root` and `share_set_root` using one of the production paths in section 3. For a child checkpoint, it also attests that the signer consumed the exact ordered event delta identified by `delta_log_set_hash`. A certificate is not an attestation that a downloaded checkpoint file parsed or that a particular bundle encoding or hosting path is trustworthy. +**Required meaning.** A certificate attests that the signer independently materialized the canonical public state and active encrypted share set at block B, without trusting the bundle being signed, and computed the signed roots. For a child checkpoint, it also attests that the signer consumed the exact ordered event delta identified by `delta_log_set_hash`. A certificate is not an attestation that a downloaded checkpoint file parsed or that a hosting path is trustworthy. -**Acceptance rule.** A checkpoint is accepted only when one set of unique active signer ids signs the same certificate message and satisfies every rule in `TrustedSignerSetV1`: at least `threshold` unique signers, every required implementation quorum, and `min_controller_count` when nonzero. Duplicate signature records for the same signer_id count once. A signature record whose signer_id is unknown, retired for block B, associated with a different scheme, or invalid for the configured public key is ignored. This mitigates a shared reconstruction bug in one implementation, such as #972, because the same counted signer set must include both Anchor and go-ssv signers. When implementations disagree on the roots for a block, no checkpoint is published and the disagreement is investigated. +**Acceptance rule.** A checkpoint is accepted only when one set of unique active signer ids signs the same certificate message and satisfies `TrustedSignerSetV1`: at least `threshold` unique signers, every required implementation quorum, and `min_controller_count` when nonzero. Duplicate signer ids count once. Unknown, retired, wrong scheme, and invalid signature records are ignored before checking the threshold rules. For v1, the counted set must include both Anchor and go-ssv signers. -This rule makes the mechanism intentionally inert until at least two implementations independently compute the same canonical root and validate it against the shared conformance vectors of section 6 at a common block. When signer infrastructure and a second conforming implementation are ready is an onboarding matter (see Out of Scope). +This keeps the mechanism inert until at least two implementations independently compute the same roots and validate them against the conformance vectors. -**Swapping the signature scheme later.** The certificate message records `scheme_version`, `signer_set_id`, and `signer_set_hash` so a future version can change the signature scheme or rotate the signer set without ambiguity. This includes a move to a post-quantum signature such as ML-DSA. Post-quantum signatures are not mandated for v1. Agility needs a floor; see the downgrade discussion in Security Considerations. +**Swapping the signature scheme later.** The certificate message records `scheme_version`, `signer_set_id`, and `signer_set_hash` so a future version can change the signature scheme or rotate the signer set without ambiguity. Post-quantum signatures are not mandated for v1. Agility needs a floor; see Security Considerations. -**Dedicated keys.** The signer keys must be separate keys created only for signing checkpoints. They must not reuse an operator's existing key. Reusing operator keys saves no distribution step, because a bootstrapping node needs the signer public keys before it has any state, so those keys ship with the client or its configuration regardless. They must also use a distinct signing context (domain separation) so a checkpoint signature can never be mistaken for, or replayed as, any other kind of signature. +**Dedicated keys.** Signers must use keys created only for checkpoint signing, not existing operator keys. Bootstrapping nodes need signer public keys before they have state, so those keys ship with the client or configuration regardless. Signatures must also use a distinct signing context. **5. Client Import and Verification Flow** @@ -457,25 +439,30 @@ CheckpointImportVectorV1 = Container[ `untrusted_side_data` is never part of `CheckpointBundleV1`; it exists only to test that implementations do not populate storage from bytes outside the verified bundle. A conformance harness reports code 11 if an implementation reads or stores from `untrusted_side_data`; a production importer must never receive that field. -The v1 vector set must include cases for the #972 operator public key layouts, two operator ids with the same canonical public key bytes, the same validator public key registered by two different owners, ValidatorAdded rejection after nonce advancement, ValidatorAdded with 4 operators and with 7 operators, ValidatorAdded with unsorted or duplicate operator ids, ValidatorAdded with a bad share blob length, ValidatorAdded with a bad validator signature, ValidatorAdded with ciphertext bytes that are structurally valid but do not decrypt for one operator, ValidatorRemoved omission, removed operators that remain referenced by active membership, removed operators that become unreferenced and are omitted, malformed `bundle_bytes`, duplicate operator, validator, cluster, owner, and encrypted share records in `CheckpointBundleV1`, extra shares for unknown or removed validators, shares for nonmember operators, cluster_id mismatch between validator and share records, missing share for a cluster member, conflicting duplicate entries, bundle bytes with correct roots and untrusted side data that must not be written to storage, and certificate acceptance cases for duplicate signer ids, missing implementation quorum, retired signer, signer_set_hash mismatch, and controller diversity when enabled. +The v1 vector set must cover these categories: + +- operator identity and key decoding: #972 operator public key layouts, two operator ids with the same canonical public key bytes, and the same validator public key registered by two different owners; +- ValidatorAdded parsing and validation: nonce advancement before rejection, 4 and 7 operator committees, unsorted or duplicate operator ids, bad share blob length, bad validator signature, and ciphertext bytes that are structurally valid but do not decrypt for one operator; +- removal representation: ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted; +- bundle consistency: malformed `bundle_bytes`, duplicate canonical records, duplicate encrypted share records, extra shares for unknown or removed validators, shares for nonmember operators, cluster_id mismatch, missing shares for cluster members, and conflicting duplicate entries; +- storage safety: bundle bytes with correct roots plus untrusted side data that must not be written to storage; +- certificate acceptance: duplicate signer ids, missing implementation quorum, retired signer, signer_set_hash mismatch, and controller diversity when enabled. -**Full replay audit run.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point (every N blocks) a tuple: +**Production and audit evidence.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point: ```text (block_number, full_log_set_hash, state_root, share_set_root) ``` -Generating the tuples is automatic for a client or materializer that has the required historical logs and encrypted share material. Comparing the streams is a separate step performed by a small diff tool. Starting from the deployment block catches a historical bug such as #972, which only appears once the affected event is processed. This is the audit path, not the normal checkpoint production path. +The diff tool compares `full_log_set_hash` first. If log hashes differ, clients read different events. Only matching log hashes with differing roots indicate a reconstruction divergence. `full_log_set_hash` is audit evidence and is not a certificate field. -The `full_log_set_hash` in the tuple is essential to this comparison. It is computed from `LogSetV1` with domain `FULL_LOG_SET_V1`, `from_block_number` equal to the SSV contract deployment block, and `to_block_number` equal to the tuple's `block_number`. Without it, two clients could disagree only because their RPC providers served different events: providers differ in indexing, retention, and gaps. The diff tool compares `full_log_set_hash` first. If the log hashes differ, it is a data source problem, not a reconstruction bug. Only matching log hashes with differing roots indicate a real divergence. `full_log_set_hash` is audit evidence and is not a certificate field. - -**Checkpoint production run.** For a checkpoint with a parent, each signer emits: +For a checkpoint with a parent, each signer emits: ```text (parent_checkpoint_hash, block_number, delta_log_set_hash, state_root, share_set_root) ``` -The diff tool first compares the parent checkpoint hash and `delta_log_set_hash`, then compares the roots. The parent checkpoint hash is the parent `certificate_message_hash`, and `delta_log_set_hash` is computed from `LogSetV1` with domain `DELTA_LOG_SET_V1`. This is the normal production comparison for later checkpoints and it does not require logs before the parent checkpoint. For an initial checkpoint, signers compare `(block_number, state_root, share_set_root)` and may also compare optional full replay audit evidence when they have it. +The diff tool compares the parent checkpoint hash and `delta_log_set_hash`, then the roots. This is the normal production comparison for later checkpoints and does not require logs before the parent checkpoint. For an initial checkpoint, signers compare `(block_number, state_root, share_set_root)` and may also compare optional full replay audit evidence when they have it. **Honest limit and complementarity.** Agreement across clients proves that the two clients reconstructed the same state. It does not prove the state is correct; both could share a spec mistake. eth_call spot checks are complementary because they compare known records against contract storage outside both clients, while root agreement catches omitted records that eth_call cannot ask about. Neither check catches a shared mistake in the event signatures, topics, contract address, deployment block, parent checkpoint, or delta range. The full fold from the deployment block remains the audit path for that class of error. @@ -495,28 +482,24 @@ The following are operational, not protocol, and are out of scope for this SIP: **Security Considerations** -**Trust assumption.** An importing node that skips the fold trusts the signer set, not whoever published the checkpoint bundle. The publisher is untrusted; a malicious publisher can at most serve a bundle that fails to reconstruct the signed `state_root` or `share_set_root`. The acceptance rule in section 4 assumes enough honest signers to prevent a dishonest counted set from satisfying the threshold, implementation quorum, and controller rules. - -**Completeness versus correctness.** Agreement across implementations catches omitted records only because the root commits to the record set itself, not to counters. It does not prove the shared event scope or canonical spec is correct; section 6 describes why eth_call and the full fold audit remain complementary. - -**Reconstruction bug risk.** Two signers running the same client share that client's reconstruction bugs and would sign the same wrong roots. The acceptance rule across implementations mitigates this by requiring the counted signer set to satisfy the implementation quorums, and the canonical application semantics in section 1 reduce the space for legitimate root differences. +**Trust assumption.** An importing node that skips the fold trusts the signer set, not the checkpoint publisher. The publisher is untrusted and can at most serve a bundle that fails to reconstruct the signed roots. -**Equivocation and signer dishonesty.** If enough signers collude to satisfy the threshold, implementation quorum, and controller rules, they can sign wrong roots that are internally consistent. Nothing in the format binds a block number B to a single certificate message, so a dishonest quorum or publisher could serve two well formed certificate messages for the same block B and partition nodes onto divergent state. Equivocation is detectable out of band but is not prevented by the format alone. A recommended mitigation is for signers to publish each (block_number, certificate message) into an append only transparency record, so two conflicting signatures over the same block are publicly attributable. +**Completeness versus correctness.** Agreement across implementations catches omitted records only because the root commits to records, not counters. It does not prove the shared event scope or canonical spec is correct; eth_call spot checks and the full fold audit remain complementary. -**Replay of stale checkpoints.** Signatures over an old but valid certificate message stay valid forever, so signatures alone cannot stop replay. Section 5 defines the defenses: maximum acceptable age, finality, and monotonicity where a node already has current state. +**Reconstruction bug risk.** Signers running the same client share that client's reconstruction bugs. Implementation quorums mitigate this by requiring the counted signer set to include independent client implementations. -**Downgrade and version attacks.** Cryptographic agility introduces several valid scheme_version, signer_set_id, signer_set_hash, and schema_version values over time. Without a floor, that enables downgrade: after a post-quantum migration an attacker could replay a checkpoint certified under the old, now weak classical scheme; after key rotation an attacker could replay certificates from a retired signer set. The maximum age rule only partly helps, since an old certificate message under a weak scheme within the age window would still pass. The importing node must therefore enforce a minimum acceptable scheme_version and reject retired signer sets, so a checkpoint signed under a deprecated scheme or a retired signer set is rejected even when its signatures are cryptographically valid and it is within the age window. Agility without a floor is a downgrade vector. +**Equivocation and signer dishonesty.** A dishonest quorum can sign wrong roots or two well formed certificate messages for the same block B. Equivocation is detectable out of band but not prevented by the format. A recommended mitigation is an append only transparency record for each `(block_number, certificate message)`. -**Signer key compromise.** A compromised signing key can sign a wrong certificate message. The true threshold is governed by the threshold, implementation quorums, and controller diversity rule, so those values must be sized together. Rotation is not instantaneous protection: a node keeps trusting a signer set until it receives a client or configuration update, so revocation latency is bounded by the client release and update cadence. +**Replay and downgrade.** Old signatures remain valid, and cryptographic agility creates downgrade risk. Importers mitigate this with maximum acceptable age, finality, monotonicity when local state exists, a minimum `scheme_version`, and rejection of retired signer sets. -**Supply chain dependency.** Signer public keys and signer metadata ship with the client or its configuration, because a bootstrapping node needs them before it has any state. The real root of trust is therefore client release integrity: the release channel delivers both the importer logic and the trusted signer sets, so a compromised release could defeat every check across implementations at once. This adds no new trust surface beyond trusting the client binary, but it raises the requirements on the release channel: reproducible builds, signed releases, and pinned, auditable signer set ids and hashes. +**Signer key compromise.** A compromised signer key can sign a wrong certificate message. The threshold, implementation quorums, and controller diversity rule must be sized together. Revocation latency is bounded by client release and configuration update cadence. -**Establishing finality.** The block_hash and finality checks in import step 2 are only as trustworthy as the node's beacon and execution data source. A node trusting a malicious RPC for finality could be shown a chain that is not canonical where B and block_hash look consistent. This is an existing trust assumption inherited from normal operation, not one introduced here; a consensus light client that would remove it is out of scope. Normal v1 acceptance requires finality; a node that cannot establish finality rejects the checkpoint. +**Supply chain dependency.** Signer public keys and metadata ship with the client or configuration because a bootstrapping node needs them before it has state. The release channel therefore delivers both importer logic and trusted signer sets, so reproducible builds, signed releases, and auditable signer set hashes matter. -**Unsafe recovery mode.** An implementation may offer an operator controlled recovery mode that imports a checkpoint at a block that is not finalized but is buried under a locally configured depth. This mode is outside normal v1 acceptance, must be disabled by default, and must be presented as unsafe because a later reorg can invalidate the imported state. If that happens, the node must discard the checkpoint state and resync from a finalized checkpoint or from logs. +**Establishing finality.** The block_hash and finality checks are only as trustworthy as the node's beacon and execution data sources. This is inherited from normal operation. Normal v1 acceptance requires finality; a node that cannot establish finality rejects the checkpoint. -**Share set correctness.** A malicious publisher cannot omit, corrupt, or replace a joining operator's encrypted share without changing `share_set_root`. A joining node must still decrypt its own share and validate it against the share public key before relying on it, because the root proves the ciphertext and share public key match the certified state, not that the local operator can successfully use the decrypted result. +**Unsafe recovery mode.** An implementation may offer an operator controlled recovery mode that imports a checkpoint before finality using a local depth setting. This mode is outside normal v1 acceptance, must be disabled by default, and must discard checkpoint state if a later reorg invalidates it. -**Encrypted share exposure.** The checkpoint must never include plaintext validator shares. It includes encrypted share ciphertexts that are already public in ValidatorAdded event data and transaction calldata. This preserves the existing long term security assumption: the encryption to operator keys and the threshold share scheme must remain sound. If enough operator decryption keys are compromised for the same validator, the validator can be compromised whether the ciphertexts come from historical logs or from a checkpoint. +**Encrypted shares.** A malicious publisher cannot omit, corrupt, or replace a joining operator's encrypted share without changing `share_set_root`. A joining node must still decrypt its own share and validate it before relying on it. The checkpoint must never include plaintext validator shares. **Trustless fallback and its limit.** The signer layer accelerates startup; it does not replace independent reconstruction. Its limit is log availability: as old logs stop being retained by default, the audit increasingly depends on a node or provider that still has them. From 69096d41f3a2b9014a7e889d5162cb035c6053bc Mon Sep 17 00:00:00 2001 From: diegomrsantos Date: Thu, 11 Jun 2026 12:49:16 +0200 Subject: [PATCH 10/13] Specify checkpoint signature scheme --- sips/checkpoint_sync.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index bb3950b..6057d49 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -337,6 +337,16 @@ CheckpointSignatureV1 = Container[ `scheme_version = 1` uses individual signature records. Any future aggregate signature scheme must still expose the signer ids covered by the aggregate before applying the same counting rules. +**Scheme version 1 signatures.** `scheme_version = 1` uses the same RSA public key format and RSA with SHA-256 signature procedure used for SSV operator messages. + +For v1, `TrustedSignerV1.public_key` is the ASCII byte string of the SSV operator public key format: the base64 encoding of a PEM RSA public key, with the same header and footer normalization used for OperatorAdded `canonical_public_key`. The decoded key must parse as a 2048 bit RSA public key. The key format is reused; the signing key itself must still be dedicated to checkpoint signing. + +For v1, `MAX_CHECKPOINT_SIGNATURE_BYTES = 256`, and every `CheckpointSignatureV1.signature` must contain exactly 256 bytes. A different length makes the signature record invalid and ignored for signer counting. + +The signed message bytes are exactly the 32 bytes of `certificate_message_hash`. The v1 signature is RSASSA-PKCS1-v1_5 with SHA-256 over those bytes. Equivalently, an implementation signs and verifies the byte string `certificate_message_hash` with the same RSA and SHA-256 procedure used for existing SSV signed messages. No text encoding, hex encoding, EIP-191 wrapper, or additional prefix is applied. + +An importer verifies a signature by resolving the signer id to an active `TrustedSignerV1`, decoding `public_key` under the v1 key rules above, and verifying the signature over the certificate message hash. If public key decoding fails, the signer is not usable. If signature verification fails, that signature record is ignored. A signer id is counted only once, even if the bundle contains several valid signature records for it. + **Trusted signer set.** `signer_set_id` is not trusted by itself. An importer resolves it to a locally trusted `TrustedSignerSetV1`, shipped with the client or operator configuration before checkpoint import. The certificate message also commits to `signer_set_hash = keccak256(ssz_serialize(TrustedSignerSetV1))`, so signer set rotation cannot reuse an id with different metadata without changing the signed message. ```text @@ -446,7 +456,7 @@ The v1 vector set must cover these categories: - removal representation: ValidatorRemoved omission, removed operators that remain referenced by active membership, and removed operators that become unreferenced and are omitted; - bundle consistency: malformed `bundle_bytes`, duplicate canonical records, duplicate encrypted share records, extra shares for unknown or removed validators, shares for nonmember operators, cluster_id mismatch, missing shares for cluster members, and conflicting duplicate entries; - storage safety: bundle bytes with correct roots plus untrusted side data that must not be written to storage; -- certificate acceptance: duplicate signer ids, missing implementation quorum, retired signer, signer_set_hash mismatch, and controller diversity when enabled. +- certificate acceptance: valid v1 RSA signatures, invalid signer public key encoding, wrong signature length, signatures over the wrong message bytes, duplicate signer ids, missing implementation quorum, retired signer, signer_set_hash mismatch, and controller diversity when enabled. **Production and audit evidence.** An audit implementation can start at the deployment block, fold all history, and emit at every sampling point: From 0a01e2b61ff85b75b048f98883dcc26800a18851 Mon Sep 17 00:00:00 2001 From: diegomrsantos Date: Thu, 11 Jun 2026 14:27:16 +0200 Subject: [PATCH 11/13] Clarify checkpoint state transitions --- sips/checkpoint_sync.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 6057d49..c4e8b17 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -57,6 +57,19 @@ The block number is a reference point that records when the state was taken. It **Application semantics are part of the canonical state.** The canonical state is defined by the encoding and ordering below plus the rules for accepting, ignoring, and rejecting events. A client must not substitute local storage behavior for these canonical fold rules. +**State fold transition table.** A log for an event in this table that cannot be decoded as its ABI event is malformed and changes no record. Other malformed events change no record except where a transition below explicitly happens before validation. + +| Event | Accept when | Transition | +| ----- | ----------- | ---------- | +| OperatorAdded | `operator_id` is absent and `publicKey` decodes to `canonical_public_key` under the operator key rule below | Insert Operator with `removed = false`. | +| OperatorRemoved | Operator exists | If an included cluster still references the operator, set `removed = true`; otherwise omit the Operator record. | +| ValidatorAdded | After the owner nonce transition, `validator_public_key` is valid, the `(validator_public_key, owner)` identity is absent, `operatorIds` is nonempty, sorted, unique, has a valid `3f+1` size, every operator exists with `removed = false`, the cluster is absent or present with `liquidated = false`, the share blob has the expected length, and `validator_signature` verifies | First read and advance `next_validator_nonce`. If later validation fails, keep only that nonce change. If validation succeeds, insert Validator, insert or keep Cluster with `liquidated = false`, and insert one encrypted share record per operator id. | +| ValidatorRemoved | Validator exists for `(validator_public_key, owner)` and the event operator ids derive the validator's cluster id | Remove the Validator and its encrypted share records. If the cluster has no remaining validators, omit the Cluster. Then omit any removed Operator that no included cluster references. | +| ClusterLiquidated | Cluster exists for the event owner and operator ids | Set `liquidated = true` for that Cluster. | +| ClusterReactivated | Cluster exists for the event owner and operator ids | Set `liquidated = false` for that Cluster. | +| FeeRecipientAddressUpdated | Event decodes | Set the Owner fee recipient and leave `next_validator_nonce` unchanged. | +| ValidatorExited | Event decodes | No checkpoint state transition. | + **Owner state and validator nonces.** The canonical Owner record stores `next_validator_nonce`, the nonce value to use for the next ValidatorAdded by that owner. It does not store a client's local representation of the last consumed nonce, and it does not depend on whether a client stores that value as nullable. This distinguishes an owner that only changed its fee recipient, whose next validator nonce is still 0, from an owner that already consumed nonce 0. An absent Owner record means the effective defaults: `fee_recipient = owner` and `next_validator_nonce = 0`. A FeeRecipientAddressUpdated event changes the effective `fee_recipient` and leaves `next_validator_nonce` unchanged, using 0 if no previous owner nonce state exists. A ValidatorAdded event is evaluated against the current `next_validator_nonce` for that owner and then advances `next_validator_nonce` by one before the rest of the event is validated. If the ValidatorAdded event is rejected after this point, the nonce advance remains and no validator record, cluster update, or encrypted share records are created. An Owner record is included in the canonical record set when either `fee_recipient != owner` or `next_validator_nonce != 0`; otherwise it is omitted because it is identical to the default state. @@ -75,6 +88,7 @@ An absent Owner record means the effective defaults: `fee_recipient = owner` and - Operator identity. Operators are keyed by `operator_id` only. The canonical fold must not deduplicate operators by raw event bytes, decoded RSA key bytes, PEM text, owner address, or any other key. If two OperatorAdded events assign two different operator ids but decode to the same RSA public key, v1 still has two operator records. - Operator public key. OperatorAdded publicKey bytes have appeared in more than one layout, so v1 defines `canonical_public_key` explicitly. First, try to decode the field as the SSV operator public key wrapper, equivalent to ABI decoding one dynamic `bytes` value. If that succeeds and the decoded bytes parse as the expected base64 PEM RSA public key payload, those decoded bytes are the canonical bytes. If wrapper decoding fails, the raw event bytes are accepted only if they parse directly as the same base64 PEM RSA public key payload. Otherwise the OperatorAdded event is rejected as malformed. The canonical bytes are hashed exactly as bytes; clients must not reserialize the key into a different PEM, DER, JSON, or text layout before hashing. If a client cannot decode the key into this canonical form, it must stop or mark the event malformed according to the v1 fold, not silently skip the operator. - Validator identity. Validators are keyed by the pair `(validator_public_key, owner)`, matching the contract registration key. The canonical fold must not deduplicate validators by validator public key alone. If two accepted ValidatorAdded events use the same validator public key with different owners, v1 includes two validator records, and their encrypted share records remain separate by owner. +- Client storage note. A client may store validator data under `validator_public_key` for local runtime behavior, but checkpoint materialization must recover the owner field and use the contract identity above. The root must not collapse two records that share a validator public key and have different owners. - Cluster member set. A ValidatorAdded event uses the `operatorIds` order to pair share bytes with operators, as defined in section 2. After parsing, the cluster membership is represented as a set and serialized in ascending operator_id order. - Addresses. All addresses are encoded as their 20 raw bytes when hashed. Any text rendering uses lowercase hex. EIP-55 checksum casing is not used in the hashed form. - Fee recipient fallback. If no Owner record exists for an owner, the fee recipient defaults to the owner address. From 502b3a6eb01b8e4b81adf61beeddf705748ee879 Mon Sep 17 00:00:00 2001 From: diegomrsantos Date: Thu, 11 Jun 2026 14:48:01 +0200 Subject: [PATCH 12/13] Clarify checkpoint event identity --- sips/checkpoint_sync.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index c4e8b17..0e2914e 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -40,6 +40,8 @@ This version defines a closed v1 canonical profile. A checkpoint accepted under To build the canonical global state at block B, start with empty state and apply every relevant SSV event in block order, from the deployment block through block B. The result is the canonical global state at B. The relevant events are OperatorAdded, OperatorRemoved, ValidatorAdded, ValidatorRemoved, ClusterLiquidated, ClusterReactivated, and FeeRecipientAddressUpdated. ValidatorExited is deliberately not in this set; see Excluded from the state root. +**Event identity.** V1 uses the SSV Network registry ABI for the configured `network_id` and `ssv_contract_address`. A log is in scope only when its `address` equals `ssv_contract_address`, its event name is in the relevant set above, and its `topic0` equals `keccak256` of that event's canonical Solidity signature. Indexed fields are interpreted exactly as defined by that ABI. Network contract addresses, deployment blocks, and ABI source commits are conformance profile inputs for a network, not checkpoint bundle fields. + The canonical state root is a pure function of this fold. It is defined over the record set included in scope in canonical form, not over any client's database or representation in memory. A conforming implementation must be able to materialize the full canonical record set before hashing, no matter what it keeps at runtime. Storage choices are explicitly outside the hashed state: whether a client retains only its own shares, whether it deletes a removed record or keeps a flagged tombstone, and similar optimizations must not change the root. The block number is a reference point that records when the state was taken. It is not part of the hashed state itself. From 815e46067292d8225fb291c849d85e4d6f55faa2 Mon Sep 17 00:00:00 2001 From: diegomrsantos Date: Thu, 11 Jun 2026 16:30:41 +0200 Subject: [PATCH 13/13] Clarify ValidatorAdded BLS boundaries --- sips/checkpoint_sync.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sips/checkpoint_sync.md b/sips/checkpoint_sync.md index 0e2914e..c63fa1b 100644 --- a/sips/checkpoint_sync.md +++ b/sips/checkpoint_sync.md @@ -207,7 +207,7 @@ ValidatorAddedSharesV1 = The expected `shares` length is `96 + n * 48 + n * 256` bytes. The `share_public_keys` entry at index i and the `encrypted_share_bytes` entry at index i are assigned to the `operatorIds` entry at index i from the event. This event order mapping is used only to form records; the final `ShareSetV1.shares` list is then sorted by the v1 record ordering above. No Snappy decompression, ABI decode inside the `shares` value, JSON decode, or local database layout is part of this grammar. -The `validator_signature` is checked before any validator record, cluster update, or encrypted share record is created. The signature is a 96 byte BLS signature by `validator_public_key` over: +The `validator_signature` is checked before any validator record, cluster update, or encrypted share record is created. The validator public key and signature must pass the existing SSV validator registration BLS procedure: canonical compressed BLS12-381 G1 public key and G2 signature encodings, correct subgroups, not the point at infinity, and no new DST, hash mode, wrapper, derivation rule, or domain separator. The 96 byte signature is by `validator_public_key` over: ```text validator_registration_message_v1 = @@ -216,9 +216,9 @@ validator_registration_message_v1 = `owner_eip55_hex` is the EIP-55 address text with `0x` prefix derived from the event owner address. `decimal_next_validator_nonce` is the base 10 nonce text with no leading zero, except that zero is encoded as `0`. This text form is used only for the existing validator registration signature. Addresses in the SSZ records and roots still use raw 20 byte values. -After the owner nonce is read and advanced, a ValidatorAdded event is malformed if the validator public key is not a 48 byte BLS public key, the operator id array is invalid under the rules above, the `shares` length does not match the v1 formula, or `validator_signature` does not verify. A malformed ValidatorAdded event leaves the nonce advance in place and creates no validator record, cluster update, or encrypted share records. +After the owner nonce is read and advanced, a ValidatorAdded event is malformed if the validator public key is not valid under the BLS rules above, the operator id array is invalid under the rules above, the `shares` length does not match the v1 formula, or `validator_signature` does not verify. A malformed ValidatorAdded event leaves the nonce advance in place and creates no validator record, cluster update, or encrypted share records. -V1 does not decrypt `encrypted_share_bytes`, verify plaintext shares, or check that a ciphertext matches its `share_public_key`. The canonical fold commits to the public ciphertext bytes from the accepted event. A client may later fail to decrypt its own encrypted share and decide that the share is unusable locally, but that local outcome is outside the canonical fold and must not change `state_root` or `share_set_root`. +V1 does not decrypt `encrypted_share_bytes`, verify plaintext shares, check that a ciphertext matches its `share_public_key`, require share public keys to pass BLS validity checks, or require them to reconstruct `validator_public_key`. The fold commits to the `share_public_key` bytes and ciphertext bytes from the accepted event. A client may later fail to decrypt its own encrypted share and decide that the share is unusable locally, but that outcome is outside the fold and must not change `state_root` or `share_set_root`. The v1 SSZ container for log set hashes is: