Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions KIPs/kip-227.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,20 @@ The framework promotes consistent uptime and reliability. Validators have an inc

Starting from `FORK_BLOCK`, the block header includes a new field `VRank`. Its payload depends on the block position within the epoch:

| Block position | `header.VRank` |
| :---------------------- | :---------------------------------------------- |
| `N % EPOCH_LENGTH != 0` | `RLPEncode(cfReport(N))`, or `nil` if empty |
| `N % EPOCH_LENGTH == 0` | `RLPEncode(CandTesting(N))` (MUST NOT be `nil`) |
`header.VRank` is `RLPEncode(VRankPayload)`, or `nil` when every member is empty.

```go
type VRankPayload struct {
Report []common.Address // cfReport(N), or CandTesting(N) at an epoch start
ParentRound uint8 // canonical round of block N-1
ParentCommittedSeal [][]byte // committed seals proving that round; empty when ParentRound is 0
}
```

| Block position | `Report` | `ParentRound` / `ParentCommittedSeal` |
| :---------------------- | :--------------- | :------------------------------------------- |
| `N % EPOCH_LENGTH != 0` | `cfReport(N)` | the round block `N-1` committed at, and its proof |
| `N % EPOCH_LENGTH == 0` | `CandTesting(N)` | `0` and empty |

> **Note**: The index `N` of `cfReport(N)` refers to the block in which the report is **recorded**, not the block it evaluates. A `cfReport` is written about the writer's own most recent prior proposal:
>
Expand All @@ -65,6 +75,8 @@ Starting from `FORK_BLOCK`, the block header includes a new field `VRank`. Its p
>
> In short: `evaluate(T) → cfReport(N) → header(N).VRank`, where `T` is the writer's own most recent prior proposal in the epoch.

> **Note**: `ParentRound(N)` follows the same convention. The index `N` is the block that **records** it; the round it names is the one block `N-1` committed at. The round is not part of a block's identity, so two nodes may hold block `N-1` with different rounds; recording it in block `N` gives every node one value, agreed by the quorum that committed block `N`.

```go
type Header struct {
ParentHash common.Hash
Expand All @@ -83,7 +95,7 @@ type Header struct {

Both `pfReport` and `cfReport` are per-block data structures. A node's presence in either report is undesirable: it indicates a failure, and the node may be penalized in future epoch evaluations.

**pfReport(N)** (Proposal Failure Report): For the mined block `N`, `pfReport(N) = [ GetProposer(N, R) : R ∈ [0, r) ]` (an ordered list, one entry per round) where `r` is the round that reached consensus for block `N`. Extractable from `header(N).Extra`.
**pfReport(N)** (Proposal Failure Report): Covers the proposal rounds that failed for block `N-1`. Recorded in block `N`. `pfReport(N) = [ GetProposer(N-1, R) : R ∈ [0, r) ]` (an ordered list, one entry per round) where `r = ParentRound(N)`, read from `header(N).VRank`. It is empty when `r` is `0`, and at an epoch start, where no parent round is recorded.

Format: `pfReport(N) -> [proposerAddrRound0, proposerAddrRound1, ...]` with one entry per failed round; a validator may appear more than once if it is the proposer for multiple rounds.

Expand Down Expand Up @@ -124,12 +136,12 @@ type VRankCandidate struct {

### Consensus Protocol Integration

VRank runs in parallel with consensus. `pfReport(N)` is produced during block `N`'s consensus and committed in the next block header. A `cfReport` for a proposer's own block is produced from that block's consensus but committed later, in that proposer's next proposal.
VRank runs in parallel with consensus. The round block `N` commits at is recorded in the next block header, and `pfReport(N+1)` is derived from it. A `cfReport` for a proposer's own block is produced from that block's consensus but committed later, in that proposer's next proposal.

#### Proposer of block N

1. After having sent Istanbul Preprepare messages to consensus participants, the proposer MUST send `VRankPreprepare` to all candidates in `CandTesting`.
2. The round information is recorded in `header.Extra` as part of the existing consensus. If the proposer fails to propose and a round change occurs, the failed proposer's address is recorded in `pfReport(N)`.
2. The round that block `N-1` committed at is recorded in `header(N).VRank` as `ParentRound(N)`, together with the committed seals proving it. If a proposer failed to propose and a round change occurred, that proposer's address is derived from `ParentRound(N)` into `pfReport(N)`.

#### Proposer collecting VRankCandidate for block N

Expand All @@ -153,6 +165,14 @@ When a proposer produces block `N`, it reports the candidate evaluation for its
4. Candidates in `cfReport` are counted as failures for CFS aggregation. The epoch-start candidate list is informational only and does not contribute to CFS.
5. If block `N` is an epoch-start block (`N % EPOCH_LENGTH == 0`), the proposer MUST NOT include a `cfReport`; instead, `header(N).VRank` carries `CandTesting(N)` as specified in the encoding table.

#### Proposer recording the parent round

1. The proposer of block `N` MUST read the round byte and the committed seals from its own stored `header(N-1)`, and set `ParentRound(N)` and `ParentCommittedSeal(N)` from them. When the round is `0` it MUST leave both empty, since a zero round claims no proposal failure and needs no proof.
2. The proposer MUST NOT use seals obtained from anywhere else, and MUST NOT record `0` when its stored parent says otherwise. Recording a round penalizes the validators scheduled before it, so the proof of that round is the parent this proposer accepted.
3. At an epoch start (`N % EPOCH_LENGTH == 0`) and at `FORK_BLOCK`, the proposer MUST leave both empty. An epoch start's parent belongs to the previous epoch, whose failures MUST NOT score into this one, and `FORK_BLOCK`'s parent predates the round-bound committed seal.

Rule 2 is a norm for honest proposers, not a protocol check. A validator can confirm that the seals prove the claimed round, but cannot tell whether the proposer used its own stored parent. See [Bounding what a proposer can claim](#bounding-what-a-proposer-can-claim).

#### Block Validation

Before `FORK_BLOCK`, `header.VRank` MUST be empty (zero-length bytes).
Expand All @@ -162,12 +182,14 @@ After `FORK_BLOCK`, validators MUST validate `header.VRank` per the encoding tab
- At epoch-start (`N % EPOCH_LENGTH == 0`), the decoded list MUST exactly equal `CandTesting(N)` resolved at block `N` (preserving the order returned by the valset module, with no duplicates).
- At a non-epoch block with a non-empty payload, the failed-candidate list MUST be sorted in ascending byte order, contain at most one entry per candidate ID, and each entry MUST be a candidate address from `CandTesting(N-1)` (equivalently `CandTesting(T)`, since `CandTesting` is stable within an epoch).
- At a non-epoch block, an empty payload (`nil` or zero-length) is permitted and represents no candidate failures for that block.
- If `ParentRound(N)` is `0`, `ParentCommittedSeal(N)` MUST be empty. At an epoch start and at `FORK_BLOCK`, `ParentRound(N)` MUST be `0`.
- If `ParentRound(N)` is non-zero, `ParentCommittedSeal(N)` MUST hold no more entries than the committee of block `N-1` at that round, and each MUST recover, over the round-bound committed-seal preimage of `header(N).ParentHash`, to a distinct member of that committee. The count MUST reach the quorum for that committee. Validators MUST NOT compare `ParentRound(N)` against the round in their own stored `header(N-1)`: two honest nodes may hold that block with different rounds, so comparing would reject honest headers.

### Failure Scores (PFS, CFS)

Each score is per epoch, computed from `pfReport` and `cfReport` in epoch blocks. Higher values indicate worse performance; zero indicates no failures.

**Proposal Failure Score (PFS)**: For a given block number `N`, PFS MUST be computed from `pfReport(b)` for blocks `x ∈ [epochStart(N), N]`. For each validator, count how many times the validator appears across all `pfReport`s in the epoch (each round change adds one entry). PFS maps each validator address to its total proposal failure count.
**Proposal Failure Score (PFS)**: For a given block number `N`, PFS MUST be computed from `pfReport(b)` for blocks `b ∈ [epochStart(N), N]` (note that `pfReport(epochStart(N))` is empty). For each validator, count how many times the validator appears across all `pfReport`s in the epoch (each round change adds one entry). PFS maps each validator address to its total proposal failure count.

Format: `pfs(N) -> map[proposerAddr]score`

Expand Down Expand Up @@ -302,9 +324,19 @@ However, if the proposer is an accomplice of the candidate, they could collude t

Given that signatures cannot fully prevent manipulation in either direction, and that signatures add significant size to the report, we decided to simplify: `cfReport` is a list of candidate addresses only (no signatures). The Byzantine filtering in CFS (excluding the highest `F` reporter totals) mitigates the impact of malicious proposers.

### The exclusion of pfReport from header.VRank
### Recording the parent round in the next header

`pfReport` cannot be derived from the round byte in `header.Extra`. That byte is cleared when the block hash is computed, so it is outside the block's identity, and a validator locked on a proposal re-votes in every round it reaches. Several rounds can therefore each gather a quorum of committed seals for one block, and two nodes can hold that block, with the same hash, at different rounds. Deriving `pfReport` from each node's own copy makes the resulting node-state transition node-dependent.

Recording it in the next header resolves this without updating a header mid-consensus: block `N` is built after block `N-1` is committed, so its round is already known when the proposer fills `header(N).VRank`.

### Bounding what a proposer can claim

`pfReport` is extracted from `header.Extra` rather than stored in `header.VRank`. Round-change information is recorded during consensus, before the block is finalized. If `pfReport` were written into `header.VRank` upon each round change, the header would need to be updated mid-consensus. Supporting such updates would require substantial changes to the current implementation. The `Extra` field is already populated during consensus with round-change data, so `pfReport` is derived from there instead.
Verification proves that a committee quorum committed the parent at the claimed round. It does not prove that no higher round also reached a quorum, because absence cannot be signed. A proposer can therefore name a lower round that really happened, and the failures above it go unrecorded.

The asymmetry is deliberate: recording a round accuses the validators scheduled before it, so the burden of proof lies with the proposer making the accusation, while a zero round accuses no one and needs none. In short, `pfReport` may undercount, but no proposer can inflate it.

This holds only while committed seals are issued for rounds that actually occurred. A node answering a request for an already-committed block MUST sign the round it stored, not the round the request names; otherwise a caller could gather a quorum certificate for a round at which no quorum ever formed.

### `header.VRank` at `k*EPOCH_LENGTH`

Expand All @@ -313,6 +345,8 @@ Those new validators did not participate in consensus for block `k*EPOCH_LENGTH
The proposer of block `k*EPOCH_LENGTH` may be such a new validator, so they cannot produce a valid `cfReport(k*EPOCH_LENGTH)`.
Instead of leaving the field empty, the proposer MUST embed `CandTesting(k*EPOCH_LENGTH)` — the full candidate list for the new epoch — into `header.VRank`. This anchors the epoch's candidate set into the consensus-validated header, giving all nodes a single authoritative reference for who the candidates are when CFS aggregation begins.

`ParentRound` is likewise omitted at `k*EPOCH_LENGTH`. Its parent is the last block of the previous epoch, and each score is per epoch, so counting that block's failures here would carry them across the boundary. They cannot be counted in their own epoch either, since the round is only recorded one block later, by which point the new epoch has begun. One block per epoch therefore goes unscored, which is accepted rather than allowing a previous epoch's failure to demote a validator in this one.

## Backward Compatibility

The introduction of VRank does not affect existing nodes before `FORK_BLOCK`. Nodes operating prior to `FORK_BLOCK` will continue to function as before. After `FORK_BLOCK`, the new `vrank` field and associated validation processes come into effect.
Expand Down
Loading