ECIP 1060: Cliquey proof-of-authority consensus protocol Source

AuthorAidan Hyman, Talha Cross
Discussions-Tohttps://github.com/goerli/eips-poa/issues/12
StatusDraft
TypeStandards Track
CategoryCore
Created2019-04-19
Requires 1048

Simple Summary

This document proposes a new proof-of-authority consensus engine that could be used by Ethereum testing and development networks in the future.

Abstract

Cliquey is the second iteration of the Clique proof-of-authority consensus protocol, previously discussed as “Clique v2”. It comes with some usability and stability optimizations gained from creating the Görli and Kotti Classic cross-client proof-of-authority networks that were implemented in Geth, Parity Ethereum, Pantheon, Nethermind, and various other clients.

Motivation

The Kotti Classic and Görli testnets running different implementations of the Clique engine got stuck multiple times due to minor issues discovered. These issues were partially addressed on the mono-client Rinkeby network by optimizing the Geth code.

However, optimizations across multiple clients should be adequately specified and discussed. This working document is a result of a couple of months testing and running cross-client Clique networks, especially with the feedback gathered by several Pantheon, Nethermind, Parity Ethereum, and Geth engineers on different channels.

The overall goal is to simplify the setup and configuration of proof-of-authority networks, ensure testnets avoid getting stuck and mimicking mainnet conditions.

For a general motivation on proof-of-authority testnets, please refer to the exhaustive introduction in EIP-225 which this proposal is based on.

Specification

This section specifies the Cliquey proof-of-authority engine. To quickly understand the differences from Clique, please refer to the Rationale further below.

Constants

We define the following constants:

  • EPOCH_LENGTH: The number of blocks after which to checkpoint and reset the pending votes. It is suggested to remain analogous to the mainnet ethash proof-of-work epoch (30_000).
  • BLOCK_PERIOD: The minimum difference between two consecutive block’s timestamps. It is suggested to remain analogous to the mainnet ethash proof-of-work block time target (15 seconds).
  • EXTRA_VANITY: The fixed number of extra-data prefix bytes reserved for signer vanity. It is suggested to retain the current extra-data allowance and use (32 bytes).
  • EXTRA_SEAL: The fixed number of extra-data suffix bytes reserved for signer seal: 65 bytes fixed as signatures are based on the standard secp256k1 curve.
  • NONCE_AUTH: Magic nonce number 0xffffffffffffffff to vote on adding a new signer.
  • NONCE_DROP: Magic nonce number 0x0000000000000000 to vote on removing a signer.
  • UNCLE_HASH: Always Keccak256(RLP([])) as uncles are meaningless outside of proof-of-work.
  • DIFF_NOTURN: Block score (difficulty) for blocks containing out-of-turn signatures. It should be set to 1 since it just needs to be an arbitrary baseline constant.
  • DIFF_INTURN: Block score (difficulty) for blocks containing in-turn signatures. It should be 7 to show preference over out-of-turn signatures.
  • MIN_WAIT: The minimum time to wait for an out-of-turn block to be published. It is suggested to set it to BLOCK_PERIOD / 2.

We also define the following per-block constants:

  • BLOCK_NUMBER: The block height in the chain, where the height of the genesis is block 0.
  • SIGNER_COUNT: The number of authorized signers valid at a particular instance in the chain.
  • SIGNER_INDEX: The index of the block signer in the sorted list of currently authorized signers.
  • SIGNER_LIMIT: The number of signers required to govern the list of authorities. It must be floor(SIGNER_COUNT / 2) + 1 to enforce majority consensus on a proof-of-authority chain.

We repurpose the ethash header fields as follows:

  • beneficiary: The address to propose modifying the list of authorized signers with.
    • Should be filled with zeroes normally, modified only while voting.
    • Arbitrary values are permitted nonetheless (even meaningless ones such as voting out non-signers) to avoid extra complexity in implementations around voting mechanics.
    • It must be filled with zeroes on checkpoint (i.e., epoch transition) blocks.
    • Transaction execution must use the actual block signer (see extraData) for the COINBASE opcode.
  • nonce: The signer proposal regarding the account defined by the beneficiary field.
    • It should be NONCE_DROP to propose deauthorizing beneficiary as an existing signer.
    • It should be NONCE_AUTH to propose authorizing beneficiary as a new signer.
    • It must be filled with zeroes on checkpoint (i.e., on epoch transition) blocks.
    • It must not take up any other value apart from the two above.
  • extraData: Combined field for signer vanity, checkpointing and signer signatures.
    • The first EXTRA_VANITY fixed bytes may contain arbitrary signer vanity data.
    • The last EXTRA_SEAL fixed bytes are the signer’s signature sealing the header.
    • Checkpoint blocks must contain a list of signers (SIGNER_COUNT*20 bytes) in between, omitted otherwise.
    • The list of signers in checkpoint block extra-data sections must be sorted in ascending order.
  • mixHash: Reserved for fork protection logic, similar to the extra-data during the DAO.
    • It must be filled with zeroes during regular operation.
  • ommersHash: It must be UNCLE_HASH as uncles are meaningless outside of proof-of-work.
  • timestamp: It must be greater than the parent timestamp + BLOCK_PERIOD.
  • difficulty: It contains the standalone score of the block to derive the quality of a chain.
    • It must be DIFF_NOTURN if BLOCK_NUMBER % SIGNER_COUNT != SIGNER_INDEX
    • It must be DIFF_INTURN if BLOCK_NUMBER % SIGNER_COUNT == SIGNER_INDEX

Validator List

The initial validator list can be specified in the configuration at genesis, i.e., by appending it to the Clique config in Geth:

"clique":{
  "period": 15,
  "epoch": 30000,
  "validators": [
    "0x7d577a597b2742b498cb5cf0c26cdcd726d39e6e",
    "0x82a978b3f5962a5b0957d9ee9eef472ee55b42f1"
  ]
}

By using this list, for convenience, the extraData field of the genesis block only has to contain the 32 bytes of EXTRA_VANITY. The client automatically converts and appends the list of signers to the block 0 extra-data as specified in EIP-225 at checkpoint blocks (appending SIGNER_COUNT*20 bytes).

Sealing

For a detailed specification of the block authorization logic, please refer to EIP-225 by honoring the constants defined above. However, the following changes should be highlighted:

  • Each singer is allowed to sign any number of consecutive blocks. The order is not fixed, but in-turn signing weighs more (DIFF_INTURN) than out-of-turn one (DIFF_NOTURN). In case an out-of-turn block is received, an in-turn signer should continue to publish their block to ensure the chain always prefers in-turn blocks in any case. This strategy prevents in-turn validators from being hindered from publishing their block and potential network halting.

  • If a signer is allowed to sign a block, i.e., is on the authorized list:

    • Calculate the Gaussian random signing time of the next block: parent_timestamp + BLOCK_PERIOD + r, where r is a uniform random value in rand(-BLOCK_PERIOD/4, BLOCK_PERIOD/4).
    • If the signer is in-turn, wait for the exact time to arrive, sign and broadcast immediately.
    • If the signer is out-of-turn, delay signing by MIN_WAIT + rand(0, SIGNER_COUNT * 500ms).

This strategy will always ensure that an in-turn signer has a substantial advantage to sign and propagate versus the out-of-turn signers.

Voting

The voting logic is unchanged and can be adapted straight from EIP-225.

Rationale

The following changes were introduced over Clique EIP-225 and should be discussed briefly.

  • Cliquey introduces a MIN_WAIT period for out-of-turn block to be published which is not present for Clique. This addresses the issue of out-of-turn blocks often getting pushed into the network too fast causing a lot of short reorganizations and in some rare cases causing the network to come to a halt. By holding back out-of-turn blocks, Cliquey allows in-turn validators to seal blocks even under non-optimal network conditions, such as high network latency or validators with unsynchronized clocks.
  • To further strengthen the role of in-turn blocks, an authority should continue to publish in-turn blocks even if an out-of-turn block was already received on the network. This prevents in-turn validators being hindered from publishing their block and potential network problems, such as reorganizations or the network getting stuck.
  • Additionally, the DIFF_INTURN was increased from 2 to 7 to avoid situations where two different chain heads have the same total difficulty. This prevents the network from getting stuck by making in-turn blocks significantly more heavy than out-of-turn blocks.
  • The SIGNER_LIMIT was removed from block sealing logic and is only required for voting. This allows the network to continue sealing blocks even if all but one of the validators are offline. The voting governance is not affected and still requires signer majority.
  • The block period should be less strict and slightly randomized to mimic mainnet conditions. Therefore, it is slightly randomized in the uniform range of [-BLOCK_PERIOD/4, BLOCK_PERIOD/4]. With this, the average block time will still hover around BLOCK_PERIOD.

Finally, without changing any consensus logic, we propose the ability to specify an initial list of validators at genesis configuration without tampering with the extraData.

Test Cases

// block represents a single block signed by a particular account, where
// the account may or may not have cast a Clique vote.
type block struct {
  signer     string   // Account that signed this particular block
  voted      string   // Optional value if the signer voted on adding/removing someone
  auth       bool     // Whether the vote was to authorize (or deauthorize)
  checkpoint []string // List of authorized signers if this is an epoch block
}
// Define the various voting scenarios to test
tests := []struct {
  epoch   uint64   // Number of blocks in an epoch (unset = 30000)
  signers []string // Initial list of authorized signers in the genesis
  blocks  []block  // Chain of signed blocks, potentially influencing auths
  results []string // Final list of authorized signers after all blocks
  failure error    // Failure if some block is invalid according to the rules
}{
  {
    // Single signer, no votes cast
    signers: []string{"A"},
    blocks:  []block,
    results: []string{"A"},
  }, {
    // Single signer, voting to add two others (only accept first, second needs 2 votes)
    signers: []string{"A"},
    blocks:  []block{
      {signer: "A", voted: "B", auth: true},
      {signer: "B"},
      {signer: "A", voted: "C", auth: true},
    },
    results: []string{"A", "B"},
  }, {
    // Two signers, voting to add three others (only accept first two, third needs 3 votes already)
    signers: []string{"A", "B"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: true},
      {signer: "B", voted: "C", auth: true},
      {signer: "A", voted: "D", auth: true},
      {signer: "B", voted: "D", auth: true},
      {signer: "C"},
      {signer: "A", voted: "E", auth: true},
      {signer: "B", voted: "E", auth: true},
    },
    results: []string{"A", "B", "C", "D"},
  }, {
    // Single signer, dropping itself (weird, but one less cornercase by explicitly allowing this)
    signers: []string{"A"},
    blocks:  []block{
      {signer: "A", voted: "A", auth: false},
    },
    results: []string{},
  }, {
    // Two signers, actually needing mutual consent to drop either of them (not fulfilled)
    signers: []string{"A", "B"},
    blocks:  []block{
      {signer: "A", voted: "B", auth: false},
    },
    results: []string{"A", "B"},
  }, {
    // Two signers, actually needing mutual consent to drop either of them (fulfilled)
    signers: []string{"A", "B"},
    blocks:  []block{
      {signer: "A", voted: "B", auth: false},
      {signer: "B", voted: "B", auth: false},
    },
    results: []string{"A"},
  }, {
    // Three signers, two of them deciding to drop the third
    signers: []string{"A", "B", "C"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: false},
      {signer: "B", voted: "C", auth: false},
    },
    results: []string{"A", "B"},
  }, {
    // Four signers, consensus of two not being enough to drop anyone
    signers: []string{"A", "B", "C", "D"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: false},
      {signer: "B", voted: "C", auth: false},
    },
    results: []string{"A", "B", "C", "D"},
  }, {
    // Four signers, consensus of three already being enough to drop someone
    signers: []string{"A", "B", "C", "D"},
    blocks:  []block{
      {signer: "A", voted: "D", auth: false},
      {signer: "B", voted: "D", auth: false},
      {signer: "C", voted: "D", auth: false},
    },
    results: []string{"A", "B", "C"},
  }, {
    // Authorizations are counted once per signer per target
    signers: []string{"A", "B"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: true},
      {signer: "B"},
      {signer: "A", voted: "C", auth: true},
      {signer: "B"},
      {signer: "A", voted: "C", auth: true},
    },
    results: []string{"A", "B"},
  }, {
    // Authorizing multiple accounts concurrently is permitted
    signers: []string{"A", "B"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: true},
      {signer: "B"},
      {signer: "A", voted: "D", auth: true},
      {signer: "B"},
      {signer: "A"},
      {signer: "B", voted: "D", auth: true},
      {signer: "A"},
      {signer: "B", voted: "C", auth: true},
    },
    results: []string{"A", "B", "C", "D"},
  }, {
    // Deauthorizations are counted once per signer per target
    signers: []string{"A", "B"},
    blocks:  []block{
      {signer: "A", voted: "B", auth: false},
      {signer: "B"},
      {signer: "A", voted: "B", auth: false},
      {signer: "B"},
      {signer: "A", voted: "B", auth: false},
    },
    results: []string{"A", "B"},
  }, {
    // Deauthorizing multiple accounts concurrently is permitted
    signers: []string{"A", "B", "C", "D"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: false},
      {signer: "B"},
      {signer: "C"},
      {signer: "A", voted: "D", auth: false},
      {signer: "B"},
      {signer: "C"},
      {signer: "A"},
      {signer: "B", voted: "D", auth: false},
      {signer: "C", voted: "D", auth: false},
      {signer: "A"},
      {signer: "B", voted: "C", auth: false},
    },
    results: []string{"A", "B"},
  }, {
    // Votes from deauthorized signers are discarded immediately (deauth votes)
    signers: []string{"A", "B", "C"},
    blocks:  []block{
      {signer: "C", voted: "B", auth: false},
      {signer: "A", voted: "C", auth: false},
      {signer: "B", voted: "C", auth: false},
      {signer: "A", voted: "B", auth: false},
    },
    results: []string{"A", "B"},
  }, {
    // Votes from deauthorized signers are discarded immediately (auth votes)
    signers: []string{"A", "B", "C"},
    blocks:  []block{
      {signer: "C", voted: "D", auth: true},
      {signer: "A", voted: "C", auth: false},
      {signer: "B", voted: "C", auth: false},
      {signer: "A", voted: "D", auth: true},
    },
    results: []string{"A", "B"},
  }, {
    // Cascading changes are not allowed, only the account being voted on may change
    signers: []string{"A", "B", "C", "D"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: false},
      {signer: "B"},
      {signer: "C"},
      {signer: "A", voted: "D", auth: false},
      {signer: "B", voted: "C", auth: false},
      {signer: "C"},
      {signer: "A"},
      {signer: "B", voted: "D", auth: false},
      {signer: "C", voted: "D", auth: false},
    },
    results: []string{"A", "B", "C"},
  }, {
    // Changes reaching consensus out of bounds (via a deauth) execute on touch
    signers: []string{"A", "B", "C", "D"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: false},
      {signer: "B"},
      {signer: "C"},
      {signer: "A", voted: "D", auth: false},
      {signer: "B", voted: "C", auth: false},
      {signer: "C"},
      {signer: "A"},
      {signer: "B", voted: "D", auth: false},
      {signer: "C", voted: "D", auth: false},
      {signer: "A"},
      {signer: "C", voted: "C", auth: true},
    },
    results: []string{"A", "B"},
  }, {
    // Changes reaching consensus out of bounds (via a deauth) may go out of consensus on first touch
    signers: []string{"A", "B", "C", "D"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: false},
      {signer: "B"},
      {signer: "C"},
      {signer: "A", voted: "D", auth: false},
      {signer: "B", voted: "C", auth: false},
      {signer: "C"},
      {signer: "A"},
      {signer: "B", voted: "D", auth: false},
      {signer: "C", voted: "D", auth: false},
      {signer: "A"},
      {signer: "B", voted: "C", auth: true},
    },
    results: []string{"A", "B", "C"},
  }, {
    // Ensure that pending votes don't survive authorization status changes. This
    // corner case can only appear if a signer is quickly added, removed and then
    // readded (or the inverse), while one of the original voters dropped. If a
    // past vote is left cached in the system somewhere, this will interfere with
    // the final signer outcome.
    signers: []string{"A", "B", "C", "D", "E"},
    blocks:  []block{
      {signer: "A", voted: "F", auth: true}, // Authorize F, 3 votes needed
      {signer: "B", voted: "F", auth: true},
      {signer: "C", voted: "F", auth: true},
      {signer: "D", voted: "F", auth: false}, // Deauthorize F, 4 votes needed (leave A's previous vote "unchanged")
      {signer: "E", voted: "F", auth: false},
      {signer: "B", voted: "F", auth: false},
      {signer: "C", voted: "F", auth: false},
      {signer: "D", voted: "F", auth: true}, // Almost authorize F, 2/3 votes needed
      {signer: "E", voted: "F", auth: true},
      {signer: "B", voted: "A", auth: false}, // Deauthorize A, 3 votes needed
      {signer: "C", voted: "A", auth: false},
      {signer: "D", voted: "A", auth: false},
      {signer: "B", voted: "F", auth: true}, // Finish authorizing F, 3/3 votes needed
    },
    results: []string{"B", "C", "D", "E", "F"},
  }, {
    // Epoch transitions reset all votes to allow chain checkpointing
    epoch:   3,
    signers: []string{"A", "B"},
    blocks:  []block{
      {signer: "A", voted: "C", auth: true},
      {signer: "B"},
      {signer: "A", checkpoint: []string{"A", "B"}},
      {signer: "B", voted: "C", auth: true},
    },
    results: []string{"A", "B"},
  }, {
    // An unauthorized signer should not be able to sign blocks
    signers: []string{"A"},
    blocks:  []block{
      {signer: "B"},
    },
    failure: errUnauthorizedSigner,
  }, {
    // An authorized signer that signed recenty should not be able to sign again
    signers: []string{"A", "B"},
  blocks []block{
      {signer: "A"},
      {signer: "A"},
    },
    failure: errRecentlySigned,
  }, {
    // Recent signatures should not reset on checkpoint blocks imported in a batch
    epoch:   3,
    signers: []string{"A", "B", "C"},
    blocks:  []block{
      {signer: "A"},
      {signer: "B"},
      {signer: "A", checkpoint: []string{"A", "B", "C"}},
      {signer: "A"},
    },
    failure: errRecentlySigned,
  },,
}

Implementation

A proof-of-concept implementation is being worked on at #ETHCapeTown.

Copyright and related rights waived via CC0.