364 lines
13 KiB
Python
364 lines
13 KiB
Python
# beacon_chain
|
|
# Copyright (c) 2018 Status Research & Development GmbH
|
|
# Licensed and distributed under either of
|
|
# * MIT license (license terms in the root directory or at http://opensource.org/licenses/MIT).
|
|
# * Apache v2 license (license terms in the root directory or at http://www.apache.org/licenses/LICENSE-2.0).
|
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
|
|
|
# This file implements a test vectors generator for the shuffling algorithm described in the Ethereum
|
|
# specs as of https://github.com/ethereum/eth2.0-specs/blob/2983e68f0305551083fac7fcf9330c1fc9da3411/specs/core/0_beacon-chain.md#get_new_shuffling
|
|
|
|
# Reference picture: http://vitalik.ca/files/ShuffleAndAssign.png
|
|
# and description from Py-EVM: https://github.com/ethereum/py-evm/blob/f2d0d5d187400ba46a6b8f5b1f1c9997dc7fbb5a/eth/beacon/helpers.py#L272-L344
|
|
#
|
|
# validators:
|
|
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
|
# After shuffling:
|
|
# [6, 0, 2, 12, 14, 8, 10, 4, 9, 1, 5, 13, 15, 7, 3, 11]
|
|
# Split by slot:
|
|
# [
|
|
# [6, 0, 2, 12, 14], [8, 10, 4, 9, 1], [5, 13, 15, 7, 3, 11]
|
|
# ]
|
|
# Split by shard:
|
|
# [
|
|
# [6, 0], [2, 12, 14], [8, 10], [4, 9, 1], [5, 13, 15] ,[7, 3, 11]
|
|
# ]
|
|
# Fill to output:
|
|
# [
|
|
# # slot 0
|
|
# [
|
|
# ShardAndCommittee(shard_id=0, committee=[6, 0]),
|
|
# ShardAndCommittee(shard_id=1, committee=[2, 12, 14]),
|
|
# ],
|
|
# # slot 1
|
|
# [
|
|
# ShardAndCommittee(shard_id=2, committee=[8, 10]),
|
|
# ShardAndCommittee(shard_id=3, committee=[4, 9, 1]),
|
|
# ],
|
|
# # slot 2
|
|
# [
|
|
# ShardAndCommittee(shard_id=4, committee=[5, 13, 15]),
|
|
# ShardAndCommittee(shard_id=5, committee=[7, 3, 11]),
|
|
# ],
|
|
# ]
|
|
|
|
# Note that as of 2018-12-03, several implementations are outdated
|
|
# as they are still using dynasty or min_committee_size that are not in the specs
|
|
|
|
# ################################################################
|
|
#
|
|
# YAML config
|
|
#
|
|
# ################################################################
|
|
|
|
import yaml # Requires pyyaml
|
|
|
|
# Prevent !!str or !!binary tags
|
|
def noop(self, *args, **kw):
|
|
pass
|
|
yaml.emitter.Emitter.process_tag = noop
|
|
|
|
# ################################################################
|
|
#
|
|
# Imports and simplified types
|
|
#
|
|
# ################################################################
|
|
|
|
from typing import(
|
|
List, Any, Dict, NewType
|
|
)
|
|
|
|
from enum import IntEnum
|
|
import random
|
|
|
|
Hash32 = NewType('Hash32', bytes)
|
|
|
|
## See https://github.com/ethereum/eth2.0-specs/pull/227
|
|
## and https://github.com/ethereum/eth2.0-specs/issues/151
|
|
## Hashing as been changed from Blake2b-512[:32] to Keccak256
|
|
|
|
# from hashlib import blake2b
|
|
# def hash(x):
|
|
# return blake2b(x).digest()[:32]
|
|
|
|
from eth_utils import keccak
|
|
def hash(x):
|
|
return keccak(x)
|
|
|
|
class ValidatorStatus(IntEnum):
|
|
PENDING_ACTIVATION = 0
|
|
ACTIVE = 1
|
|
EXITED_WITHOUT_PENALTY = 2
|
|
EXITED_WITH_PENALTY = 3
|
|
# Not in specs anymore - https://github.com/ethereum/eth2.0-specs/issues/216
|
|
PENDING_EXIT = 4
|
|
|
|
class ValidatorRecord(yaml.YAMLObject):
|
|
fields = {
|
|
# Status code
|
|
'status': 'ValidatorStatus',
|
|
# Extra index field to ease testing/debugging
|
|
'original_index': 'uint64'
|
|
}
|
|
|
|
def __init__(self, **kwargs):
|
|
for k in self.fields.keys():
|
|
setattr(self, k, kwargs.get(k))
|
|
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
super().__setattr__(name, value)
|
|
|
|
def __getattribute__(self, name: str) -> Any:
|
|
return super().__getattribute__(name)
|
|
|
|
class ShardAndCommittee(yaml.YAMLObject):
|
|
fields = {
|
|
# Shard number
|
|
'shard': 'uint64',
|
|
# Validator indices
|
|
'committee': ['uint24'],
|
|
# Total validator count (for proofs of custody)
|
|
'total_validator_count': 'uint64',
|
|
}
|
|
|
|
def __init__(self, **kwargs):
|
|
for k in self.fields.keys():
|
|
setattr(self, k, kwargs.get(k))
|
|
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
super().__setattr__(name, value)
|
|
|
|
def __getattribute__(self, name: str) -> Any:
|
|
return super().__getattribute__(name)
|
|
|
|
# ################################################################
|
|
#
|
|
# Environment variables
|
|
#
|
|
# ################################################################
|
|
|
|
SHARD_COUNT = 2**10 # 1024
|
|
EPOCH_LENGTH = 2**6 # 64 slots, 6.4 minutes
|
|
TARGET_COMMITTEE_SIZE = 2**8 # 256 validators
|
|
|
|
# ################################################################
|
|
#
|
|
# Procedures (copy-pasted from specs)
|
|
#
|
|
# ################################################################
|
|
|
|
def get_active_validator_indices(validators: [ValidatorRecord]) -> List[int]:
|
|
"""
|
|
Gets indices of active validators from ``validators``.
|
|
"""
|
|
return [i for i, v in enumerate(validators) if v.status in [ValidatorStatus.ACTIVE, ValidatorStatus.PENDING_EXIT]]
|
|
|
|
def shuffle(values: List[Any], seed: Hash32) -> List[Any]:
|
|
"""
|
|
Returns the shuffled ``values`` with ``seed`` as entropy.
|
|
"""
|
|
values_count = len(values)
|
|
|
|
# Entropy is consumed from the seed in 3-byte (24 bit) chunks.
|
|
rand_bytes = 3
|
|
# The highest possible result of the RNG.
|
|
rand_max = 2 ** (rand_bytes * 8) - 1
|
|
|
|
# The range of the RNG places an upper-bound on the size of the list that
|
|
# may be shuffled. It is a logic error to supply an oversized list.
|
|
assert values_count < rand_max
|
|
|
|
output = [x for x in values]
|
|
source = seed
|
|
index = 0
|
|
while index < values_count - 1:
|
|
# Re-hash the `source` to obtain a new pattern of bytes.
|
|
source = hash(source)
|
|
# Iterate through the `source` bytes in 3-byte chunks.
|
|
for position in range(0, 32 - (32 % rand_bytes), rand_bytes):
|
|
# Determine the number of indices remaining in `values` and exit
|
|
# once the last index is reached.
|
|
remaining = values_count - index
|
|
if remaining == 1:
|
|
break
|
|
|
|
# Read 3-bytes of `source` as a 24-bit big-endian integer.
|
|
sample_from_source = int.from_bytes(source[position:position + rand_bytes], 'big')
|
|
|
|
# Sample values greater than or equal to `sample_max` will cause
|
|
# modulo bias when mapped into the `remaining` range.
|
|
sample_max = rand_max - rand_max % remaining
|
|
|
|
# Perform a swap if the consumed entropy will not cause modulo bias.
|
|
if sample_from_source < sample_max:
|
|
# Select a replacement index for the current index.
|
|
replacement_position = (sample_from_source % remaining) + index
|
|
# Swap the current index with the replacement index.
|
|
output[index], output[replacement_position] = output[replacement_position], output[index]
|
|
index += 1
|
|
else:
|
|
# The sample causes modulo bias. A new sample should be read.
|
|
pass
|
|
|
|
return output
|
|
|
|
def split(values: List[Any], split_count: int) -> List[Any]:
|
|
"""
|
|
Splits ``values`` into ``split_count`` pieces.
|
|
"""
|
|
list_length = len(values)
|
|
return [
|
|
values[(list_length * i // split_count): (list_length * (i + 1) // split_count)]
|
|
for i in range(split_count)
|
|
]
|
|
|
|
def clamp(minval: int, maxval: int, x: int) -> int:
|
|
"""
|
|
Clamps ``x`` between ``minval`` and ``maxval``.
|
|
"""
|
|
if x <= minval:
|
|
return minval
|
|
elif x >= maxval:
|
|
return maxval
|
|
else:
|
|
return x
|
|
|
|
def get_new_shuffling(seed: Hash32,
|
|
validators: List[ValidatorRecord],
|
|
crosslinking_start_shard: int) -> List[List[ShardAndCommittee]]:
|
|
"""
|
|
Shuffles ``validators`` into shard committees using ``seed`` as entropy.
|
|
"""
|
|
active_validator_indices = get_active_validator_indices(validators)
|
|
|
|
committees_per_slot = clamp(
|
|
1,
|
|
SHARD_COUNT // EPOCH_LENGTH,
|
|
len(active_validator_indices) // EPOCH_LENGTH // TARGET_COMMITTEE_SIZE,
|
|
)
|
|
|
|
# Shuffle with seed
|
|
shuffled_active_validator_indices = shuffle(active_validator_indices, seed)
|
|
|
|
# Split the shuffled list into epoch_length pieces
|
|
validators_per_slot = split(shuffled_active_validator_indices, EPOCH_LENGTH)
|
|
|
|
output = []
|
|
for slot, slot_indices in enumerate(validators_per_slot):
|
|
# Split the shuffled list into committees_per_slot pieces
|
|
shard_indices = split(slot_indices, committees_per_slot)
|
|
|
|
shard_id_start = crosslinking_start_shard + slot * committees_per_slot
|
|
|
|
shards_and_committees_for_slot = [
|
|
ShardAndCommittee(
|
|
shard=(shard_id_start + shard_position) % SHARD_COUNT,
|
|
committee=indices,
|
|
total_validator_count=len(active_validator_indices),
|
|
)
|
|
for shard_position, indices in enumerate(shard_indices)
|
|
]
|
|
output.append(shards_and_committees_for_slot)
|
|
|
|
return output
|
|
|
|
# ################################################################
|
|
#
|
|
# Print helpers
|
|
#
|
|
# ################################################################
|
|
|
|
def toStrValidator(validators: [ValidatorRecord]) -> str:
|
|
return ', '.join(
|
|
f'Val(idx: {val.original_index}, status: {val.status})' for val in validators
|
|
)
|
|
|
|
def toStrShardComs(shard_comms: List[List[ShardAndCommittee]]) -> str:
|
|
|
|
def strSlot(slot_id: int, sacs: List[ShardAndCommittee]) -> str:
|
|
result = ', '.join(
|
|
f'SaC(shard: {sac.shard}, comm: {sac.committee})'
|
|
for sac in sacs if sac.committee
|
|
)
|
|
if result != '': # Only return non-empty slots
|
|
return f'[Slot {slot_id}: ' + result
|
|
else:
|
|
return ''
|
|
|
|
return '\n\t'.join(
|
|
strSlot(slot_id, sacs) for slot_id, sacs in enumerate(shard_comms) if strSlot(slot_id, sacs)
|
|
)
|
|
|
|
# ################################################################
|
|
#
|
|
# Testing
|
|
#
|
|
# ################################################################
|
|
# if __name__ == '__main__':
|
|
#
|
|
# # Config
|
|
# random.seed(int("0xEF00BEAC", 16))
|
|
# num_val = 256 # Number of validators
|
|
#
|
|
#
|
|
# seedhash = bytes(random.randint(0, 255) for byte in range(32))
|
|
# list_val_state = list(ValidatorStatus)
|
|
# validators = [ValidatorRecord(status=random.choice(list_val_state), original_index=num_val) for num_val in range(num_val)]
|
|
# crosslinking_start_shard = random.randint(0, SHARD_COUNT)
|
|
#
|
|
# print(f"Hash: 0x{seedhash.hex()}")
|
|
# print(f"validators: {toStrValidator(validators)}")
|
|
# print(f"crosslinking_start_shard: {crosslinking_start_shard}")
|
|
#
|
|
# shuffle = get_new_shuffling(seedhash, validators, crosslinking_start_shard)
|
|
# print(f"shuffling: {toStrShardComs(shuffle)}")
|
|
|
|
# ################################################################
|
|
#
|
|
# YAML Generator
|
|
#
|
|
# ################################################################
|
|
|
|
## Try to deal with enums - otherwise for "ValidatorStatus.Active" you get [1], instead of 1
|
|
def yaml_ValidatorStatus(dumper, data):
|
|
return dumper.represent_data(data.value)
|
|
yaml.add_representer(ValidatorStatus, yaml_ValidatorStatus)
|
|
|
|
if __name__ == '__main__':
|
|
import sys, random
|
|
|
|
# Order not preserved - https://github.com/yaml/pyyaml/issues/110
|
|
metadata = {
|
|
'title': 'Shuffling Algorithm Tests',
|
|
'summary': 'Test vectors for shuffling a list based upon a seed using `shuffle`',
|
|
'test_suite': 'shuffle',
|
|
'fork': 'tchaikovsky',
|
|
'version': 1.0
|
|
}
|
|
|
|
# Config
|
|
random.seed(int("0xEF00BEAC", 16))
|
|
num_cases = 10
|
|
list_val_state = list(ValidatorStatus)
|
|
test_cases = []
|
|
|
|
for case in range(num_cases):
|
|
seedhash = bytes(random.randint(0, 255) for byte in range(32))
|
|
num_val = random.randint(128, 512)
|
|
input = {
|
|
'validators': [ValidatorRecord(status=random.choice(list_val_state), original_index=num_val) for num_val in range(num_val)],
|
|
'crosslinking_start_shard': random.randint(0, SHARD_COUNT)
|
|
}
|
|
output = get_new_shuffling(seedhash, input['validators'], input['crosslinking_start_shard'])
|
|
|
|
test_cases.append({
|
|
'seed': '0x' + seedhash.hex(), 'input': input, 'output': output
|
|
})
|
|
|
|
## Debug
|
|
# yaml.dump(metadata, sys.stdout)
|
|
# yaml.dump(test_cases, sys.stdout)
|
|
with open('test_vector_shuffling.yml', 'w') as outfile:
|
|
yaml.dump(metadata, outfile, default_flow_style=False) # Dump at top level
|
|
yaml.dump({'test_cases': test_cases}, outfile) # default_flow_style will unravel "ValidatorRecord" and "committee" line, exploding file size
|