Part 2: Technical Overview

The Building Blocks

Shuffling

First cut \checkmark Revision TODO
  • Shuffling is used to randomly assign validators to committees and choose block proposers.
  • Ethereum 2 uses a "swap-or-not" shuffle.
  • Swap-or-not is an oblivious shuffle: it can be applied to single list elements and subsets.
  • This makes it ideal for supporting light clients.

Introduction

Shuffling is used to randomly assign validators to committees, both attestation committees and sync committees. It is also used to select the block proposer at each slot.

Although there are pitfalls to be aware of, shuffling is a well understood problem in computer science. The gold standard is probably the Fisher–Yates shuffle. So why aren't we using that for Eth2? In short: light clients.

Other shuffles rely on processing the entire list of elements to find the final ordering. We wish to spare light clients this burden. Ideally, they should deal with only the subsets of lists that they are interested in. Therefore, rather than Fisher–Yates, we are using a construction called a "swap-or-not" shuffle. The swap-or-not shuffle can tell you the destination index (or, conversely, the origin index) of a single list element, so is ideal when dealing with subsets of the whole validator set.

For example, formally committees are assigned by shuffling the full validator list and then taking contiguous slices of the resulting permutation. If I only need to know the members of committee kk, then this is very inefficient. Instead, I can run the swap-or-not shuffle backwards for only the indices in slice kk to find out which of the whole set of validators would be shuffled into kk. This is much more efficient.

Swap-or-not Specification

The algorithm for shuffling in the specification deals with only a single index at a time.

def compute_shuffled_index(index: uint64, index_count: uint64, seed: Bytes32) -> uint64:
    """
    Return the shuffled index corresponding to ``seed`` (and ``index_count``).
    """
    assert index < index_count

    # Swap or not (https://link.springer.com/content/pdf/10.1007%2F978-3-642-32009-5_1.pdf)
    # See the 'generalized domain' algorithm on page 3
    for current_round in range(SHUFFLE_ROUND_COUNT):
        pivot = bytes_to_uint64(hash(seed + uint_to_bytes(uint8(current_round)))[0:8]) % index_count
        flip = (pivot + index_count - index) % index_count
        position = max(index, flip)
        source = hash(
            seed
            + uint_to_bytes(uint8(current_round))
            + uint_to_bytes(uint32(position // 256))
        )
        byte = uint8(source[(position % 256) // 8])
        bit = (byte >> (position % 8)) % 2
        index = flip if bit else index

    return index

An index position in the list to be shuffled, index, is provided, along with the total number of indices, index_count, and a seed value. The output is the index that the initial index gets shuffled to.

The hash functions used to calculate pivot and source are deterministic, and are used to generate pseudo-random output from the inputs: given the same input, they will generate the same output. So we can see that, for given values of index, index_count, and seed, the routine will always return the same output.

The shuffling proceeds in rounds. In each round, a pivot index is pseudo-randomly chosen somewhere in the list, based only on the seed value and the round number.

Next, an index flip is found, which is pivot - index, after accounting for wrap-around due to the modulo function. The important points are that, given pivot, every index maps to a unique flip, and that the calculation is symmetrical, so that flip maps to index.

  • With index_count = 100, pivot = 70, index = 45, we get flip = 25.
  • With index_count = 100, pivot = 70, index = 82, we get flip = 88.

Finally in the round, a decision is made as to whether to keep the index as-is, or to update it to flip. This decision is pseudo-randomly made based on the values of seed, the round number, and the higher of index and flip.

Note that basing the swap-or-not decision on the higher of index and flip brings a symmetry to the algorithm. Whether we are considering the element at index or the element at flip, the decision as to whether to swap the elements or not will be the same. This is the key to seeing the that full algorithm delivers a shuffling (permutation) of the original set.

The algorithm proceeds with the next iteration based on the updated index.

It may not be immediately obvious, but since we are deterministically calculating flip based only on the round number, the shuffle can be run in reverse simply by running from SHUFFLE_ROUND_COUNT - 1 to 0. The same swap-or-not decisions will be made in reverse. As described above, this reverse shuffle is perfect for finding which validators ended up in a particular committee.

A full shuffle

To get an intuition for how this single-index shuffle can deliver a full shuffling of a list of indices, we can consider how the algorithm is typically implemented in clients when shuffling an entire list at once.

As an optimisation, the loop over the indices to be shuffled is brought inside the loop over rounds. This hugely reduces the amount of hashing required since the pivot is fixed for the round (it does not depend on the index) and the bits of source can be reused for 256 consecutive indices, since the hash has a 256-bit output.

For each round, we do the following.

1. Choose a pivot and find the first mirror index

First, we pick a pivot index pp. This is pseudo-randomly chosen, based on the round number and some other seed data. The pivot is fixed for the rest of the round.

With this pivot, we then pick the mirror index m1m_1 halfway between pp and 00. That is, m1=p/2m_1 = p / 2. (We will simplify by ignoring off-by-one rounding issues for the purposes of this explanation.)

A diagram showing the pivot and the first mirror index

The pivot and the first mirror index.

2. Traverse first mirror to pivot, swapping or not

For each index between the mirror index m1m_1 and the pivot index pp, we decide whether we are going to swap the element or not.

Consider the element at index ii. If we choose not to swap it, we just move on to consider the next index.

If we do decide to swap, then we exchange the list element at ii with that at ii', its image in the mirror index. That is, ii is swapped with i=m1(im1)i' = m_1 - (i - m_1), so that ii and ii' are equidistant from m1m_1. In practice we don't exchange the elements at this point, we just update the indices iii \rightarrow i', and iii' \rightarrow i.

We make the same swap-or-not decision for each index between m1m_1 and pp.

A diagram showing swapping or not from the first mirror up to the pivot

Swapping or not from the first mirror up to the pivot.

The decision as to whether to swap or not is based on hashing together the random seed, the round number, and some position data. A single bit is extracted from this hash for each index, and the swap is made or not according to whether this bit is one or zero.

3. Calculate the second mirror index

After considering all the indices ii from m1m_1 to pp, mirroring in m1m_1, we now find a second mirror index at m2m_2, which is the point equidistant between pp and the end of the list: m2=m1+n/2m_2 = m_1 + n / 2.

A diagram showing the second mirror index

The second mirror index.

4. Traverse pivot to second mirror, swapping or not

Finally, we repeat the swap-or-not process, considering all the points jj from the pivot, pp to the second mirror m2m_2. If we choose not to swap, we just move on. If we choose to swap then we exchange the element at jj with its image at jj' in the mirror index m2m_2. Here, j=m2+(m2j)j' = m_2 + (m_2 - j).

A diagram showing swapping or not from the pivot to the second mirror

Swapping or not from the pivot to the second mirror.

Putting it all together

At the end of the round, we have considered all the indices between m1m_1 and m2m_2, which, by construction, is half of the total indices. For each index considered, we have either left the element in place, or swapped the element at a distinct index in the other half. Thus, all of the indices have been considered exactly once for swapping.

The next round begins by incrementing (or decrementing for a reverse shuffle) the round number, which gives us a new pivot index, and off we go again.

A diagram showing the whole process running from one mirror to the other in a single round

The whole process running from one mirror to the other in a single round.

Discussion

A key insight

When deciding whether to swap or not for each index, the algorithm cleverly bases its decision on the higher of the candidate index or its image in the mirror. That is, ii rather than ii' (when below the pivot), and jj' rather than jj (when above the pivot). This means that we have flexibility when running through the indices of the list: we could do 00 to m1m_1 and pp to m2m_2 as two separate loops, or do it with a single loop from m1m_1 to m2m_2 as I outlined above. The result will be the same: it doesn't matter if we are considering ii or its image ii'; the decision as to whether to swap or not has the same outcome.

The number of rounds

In Ethereum 2.0 we do 90 rounds of the algorithm per shuffle, set by the constant SHUFFLE_ROUND_COUNT [TODO - link]. The original paper on which this technique is based suggests that 6lgN6\lg{N} rounds is required "to start to see a good bound on CCA-security", where NN is the list length. In his annotated spec Vitalik says "Expert cryptographer advice told us ~4log2N4\log_2{N} is sufficient for safety". The absolute maximum number of validators in Eth2, thus the maximum size of the list we would ever need to shuffle, is about 2222^{22} (4.2 million). On Vitalik's estimate that gives us 88 rounds required, on the paper's estimate, 92 rounds (assuming that lg\lg is the natural logarithm). So we are in the right ballpark, especially as we are very, very unlikely to end up with that many active validators.

It might be interesting to make the number of rounds adaptive based on list length. But we don't do that; it's probably an optimisation too far.

Fun fact: when Least Authority audited the beacon chain specification, they initially found bias in the shuffling used for selecting block proposers (see Issue F in their report). This turned out to be due to mistakenly using a configuration that had only 10 rounds of shuffling. When they increased it to the 90 we use for mainnet, the bias no longer appeared.

(Pseudo) randomness

The algorithm requires that we select a pivot point randomly in each round, and randomly choose whether to swap each element or not in each round.

In Eth2, we deterministically generate the "randomness" from a seed value, such that the same seed will always generate the same shuffling.

The pivot index is generated from eight bytes of a SHA256 hash of the seed concatenated with the round number, so it usually changes each round.

The decision bits used to determine whether or not to swap elements are bits drawn from SHA256 hashes of the seed, the round number, and the index of the element within the list.

Efficiency

This shuffling algorithm is much slower than Fisher–Yates. That algorithm requires NN swaps. Our algorithm will require 90N/490N/4 swaps on average to shuffle NN elements.

We should also consider the generation of pseudo-randomness, which is the most expensive part of the algorithm. Fisher–Yates needs something like Nlog2NN\log_2{N} bits of randomness, and we need 90(log2N+N/2)90(\log_2{N} + N/2) bits, which, for the range of NN we need in Eth2, is many more bits (about twice as many when NN is a million).

Why swap-or-not?

Why would we use such an inefficient implementation?

Shuffling single elements

The brilliance is that, if we are interested in only a few indices, we do not need to compute the shuffling of the whole list. In fact, we can apply the algorithm to a single index to find out which index it will be swapped with.

So, if we want to know where the element with index 217 gets shuffled to, we can run the algorithm with only that index; we do not need to shuffle the whole list. Moreover, if we want to know the converse, which element gets shuffled into index 217, we can just run the algorithm backwards for element 217 (backwards means running the round number from high to low rather than low to high).

In summary, we can compute the destination of element ii in O(1)O(1) operations, and the source of element ii' (the inverse operation) also in O(1)O(1), not dependent on the length of the list. Shuffles like the Fisher–Yates shuffle do not have this property and cannot work with single indices, they always need to iterate the whole list. The technical term for a shuffle having this property is that it is oblivious (to all the other elements in the list).

Keeping light clients light

This property is important for light clients. Light clients are observers of the Eth2 beacon and shard chains that do not store the entire state, but do wish to be able to securely access data on the chains. As part of verifying that they have the correct data – that no-one has lied to them – it is necessary to compute the committees that attested to that data. This means shuffling, and we don't want light clients to have to hold and shuffle the entire list of validators. By using the swap-or-not shuffle, light clients need only to consider the small subset of validators that they are interested in, which is vastly more efficient overall.

See also

  • The initial discussion about the search for a good shuffling algorithm is Issue 323 on the specs repo.
  • The winning algorithm was announced in Issue 563.
  • The original paper describing the swap-or-not shuffle is Hoang, Morris, and Rogaway, 2012, "An Enciphering Scheme Based on a Card Shuffle". See the "generalized domain" algorithm on page 3.

Created by Ben Edgington. Licensed under CC BY-SA 4.0. Published 2023-07-01 13:17 UTC. Commit 8fa708b.