Merge pull request #62 from logos-co/epoch-state-spec

Follower maintains ledger state as it follows the blockchain
This commit is contained in:
davidrusu 2024-02-02 13:29:41 +04:00 committed by GitHub
commit d7b5e0b529
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 326 additions and 72 deletions

View File

@ -15,3 +15,9 @@ To test a specific module
```bash
python -m unittest -v cryptarchia.test_leader
```
Or all test modules in a directory
```bash
python -m unittest -v cryptarchia/test_*
```

View File

@ -2,7 +2,7 @@ from typing import TypeAlias, List, Optional
from hashlib import sha256, blake2b
# Please note this is still a work in progress
from dataclasses import dataclass
from dataclasses import dataclass, field
Id: TypeAlias = bytes
@ -23,6 +23,17 @@ class TimeConfig:
chain_start_time: int
@dataclass
class Config:
k: int
active_slot_coeff: float # 'f', the rate of occupied slots
time: TimeConfig
@property
def s(self):
return int(3 * self.k / self.active_slot_coeff)
# An absolute unique indentifier of a slot, counting incrementally from 0
@dataclass
class Slot:
@ -37,9 +48,44 @@ class Slot:
@dataclass
class Config:
k: int
time: TimeConfig
class Coin:
pk: int
value: int
def commitment(self) -> Id:
# TODO: mocked until CL is understood
pk_bytes = int.to_bytes(self.pk, length=32, byteorder="little")
value_bytes = int.to_bytes(self.value, length=32, byteorder="little")
h = sha256()
h.update(pk_bytes)
h.update(value_bytes)
return h.digest()
def nullifier(self) -> Id:
# TODO: mocked until CL is understood
pk_bytes = int.to_bytes(self.pk, length=32, byteorder="little")
value_bytes = int.to_bytes(self.value, length=32, byteorder="little")
h = sha256()
h.update(pk_bytes)
h.update(value_bytes)
h.update(b"\x00") # extra 0 byte to differentiate from commitment
return h.digest()
@dataclass
class MockLeaderProof:
commitment: Id
nullifier: Id
@staticmethod
def from_coin(coin: Coin):
return MockLeaderProof(commitment=coin.commitment(), nullifier=coin.nullifier())
def verify(self, slot):
# TODO: verification not implemented
return True
@dataclass
@ -48,6 +94,7 @@ class BlockHeader:
parent: Id
content_size: int
content_id: Id
leader_proof: MockLeaderProof
# **Attention**:
# The ID of a block header is defined as the 32byte blake2b hash of its fields
@ -55,21 +102,31 @@ class BlockHeader:
#
# The following code is to be considered as a reference implementation, mostly to be used for testing.
def id(self) -> Id:
# version byte
h = blake2b(digest_size=32)
# version byte
h.update(b"\x01")
# header type
h.update(b"\x00")
# content size
h.update(int.to_bytes(self.content_size, length=4, byteorder="big"))
# content id
assert len(self.content_id) == 32
h.update(self.content_id)
# slot
h.update(int.to_bytes(self.slot.absolute_slot, length=8, byteorder="big"))
# parent
assert len(self.parent) == 32
h.update(self.parent)
# leader proof
assert len(self.leader_proof.commitment) == 32
h.update(self.leader_proof.commitment)
assert len(self.leader_proof.nullifier) == 32
h.update(self.leader_proof.nullifier)
return h.digest()
@ -93,34 +150,84 @@ class Chain:
return i
@dataclass
class LedgerState:
"""
A snapshot of the ledger state up to some block
"""
block: Id = None
nonce: Id = None
total_stake: int = None
commitments: set[Id] = field(default_factory=set) # set of commitments
nullifiers: set[Id] = field(default_factory=set) # set of nullified
def copy(self):
return LedgerState(
block=self.block,
nonce=self.nonce,
total_stake=self.total_stake,
commitments=self.commitments.copy(),
nullifiers=self.nullifiers.copy(),
)
def verify_committed(self, commitment: Id) -> bool:
return commitment in self.commitments
def verify_unspent(self, nullifier: Id) -> bool:
return nullifier not in self.nullifiers
def apply(self, block: BlockHeader):
assert block.parent == self.block
self.block = block.id()
self.nullifiers.add(block.leader_proof.nullifier)
class Follower:
def __init__(self, genesis: BlockHeader, config: Config):
def __init__(self, genesis_state: LedgerState, config: Config):
self.config = config
self.forks = []
self.local_chain = Chain([genesis])
self.local_chain = Chain([])
self.epoch = EpochState(
stake_distribution_snapshot=genesis_state,
nonce_snapshot=genesis_state,
)
self.genesis_state = genesis_state
self.ledger_state = genesis_state.copy()
# We don't do any validation in the current version
def validate_header(block: BlockHeader) -> bool:
return True
def validate_header(self, block: BlockHeader) -> bool:
# TODO: this is not the full block validation spec, only slot leader is verified
return self.verify_slot_leader(block.slot, block.leader_proof)
def verify_slot_leader(self, slot: Slot, proof: MockLeaderProof) -> bool:
return (
proof.verify(slot) # verify slot leader proof
and self.epoch.verify_commitment_is_old_enough_to_lead(proof.commitment)
and self.ledger_state.verify_unspent(proof.nullifier)
)
# Try appending this block to an existing chain and return whether
# the operation was successful
def try_extend_chains(self, block: BlockHeader) -> bool:
if self.local_chain.tip().id() == block.parent():
if self.tip_id() == block.parent:
self.local_chain.blocks.append(block)
return True
for chain in self.forks:
if chain.tip().id() == block.parent():
if chain.tip().id() == block.parent:
chain.blocks.append(block)
return True
return False
def try_create_fork(self, block: BlockHeader) -> Optional[Chain]:
if self.genesis_state.block == block.parent:
# this block is forking off the genesis state
return Chain(blocks=[block])
chains = self.forks + [self.local_chain]
for chain in chains:
if self.chain.contains_block(block):
if chain.contains_block(block):
block_position = chain.block_position(block)
return Chain(blocks=chain.blocks[:block_position] + [block])
@ -131,40 +238,47 @@ class Follower:
return
# check if the new block extends an existing chain
if self.try_extend_chains(block):
return
succeeded_in_extending_a_chain = self.try_extend_chains(block)
if not succeeded_in_extending_a_chain:
# we failed to extend one of the existing chains,
# therefore we might need to create a new fork
new_chain = self.try_create_fork(block)
if new_chain is not None:
self.forks.append(new_chain)
else:
# otherwise, we're missing the parent block
# in that case, just ignore the block
return
# if we get here, we might need to create a fork
new_chain = self.try_create_fork(block)
if new_chain is not None:
self.forks.append(new_chain)
# otherwise, we're missing the parent block
# in that case, just ignore the block
# We may need to switch forks, lets run the fork choice rule to check.
new_chain = self.fork_choice()
if new_chain == self.local_chain:
# we have not re-org'd therefore we can simply update our ledger state
# if this block extend our local chain
if self.local_chain.tip() == block:
self.ledger_state.apply(block)
else:
# we have re-org'd, therefore we must roll back out ledger state and
# re-apply blocks from the new chain
ledger_state = self.genesis_state.copy()
for block in new_chain.blocks:
ledger_state.apply(block)
self.ledger_state = ledger_state
self.local_chain = new_chain
# Evaluate the fork choice rule and return the block header of the block that should be the head of the chain
def fork_choice(local_chain: Chain, forks: List[Chain]) -> Chain:
# TODO: define k and s
return maxvalid_bg(local_chain, forks, 0, 0)
def fork_choice(self) -> Chain:
return maxvalid_bg(
self.local_chain, self.forks, k=self.config.k, s=self.config.s
)
def tip(self) -> BlockHeader:
return self.fork_choice()
@dataclass
class Coin:
pk: int
value: int
@dataclass
class LedgerState:
"""
A snapshot of the ledger state up to some height
"""
block: Id = None
nonce: bytes = None
total_stake: int = None
def tip_id(self) -> Id:
if self.local_chain.length() > 0:
return self.local_chain.tip().id()
else:
return self.ledger_state.block
@dataclass
@ -178,6 +292,9 @@ class EpochState:
# The nonce snapshot is taken 7k/f slots into the previous epoch
nonce_snapshot: LedgerState
def verify_commitment_is_old_enough_to_lead(self, commitment: Id) -> bool:
return self.stake_distribution_snapshot.verify_committed(commitment)
def total_stake(self) -> int:
"""Returns the total stake that will be used to reletivize leadership proofs during this epoch"""
return self.stake_distribution_snapshot.total_stake
@ -186,11 +303,6 @@ class EpochState:
return self.nonce_snapshot.nonce
@dataclass
class LeaderConfig:
active_slot_coeff: float = 0.05 # 'f', the rate of occupied slots
def phi(f: float, alpha: float) -> float:
"""
params:
@ -222,19 +334,26 @@ class MOCK_LEADER_VRF:
@dataclass
class Leader:
config: LeaderConfig
config: Config
coin: Coin
def is_slot_leader(self, epoch: EpochState, slot: Slot) -> bool:
f = self.config.active_slot_coeff
def try_prove_slot_leader(
self, epoch: EpochState, slot: Slot
) -> MockLeaderProof | None:
if self._is_slot_leader(epoch, slot):
return MockLeaderProof.from_coin(self.coin)
def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader:
return BlockHeader(parent=parent.id(), slot=slot)
def _is_slot_leader(self, epoch: EpochState, slot: Slot):
relative_stake = self.coin.value / epoch.total_stake()
r = MOCK_LEADER_VRF.vrf(self.coin.pk, epoch.nonce(), slot)
return r < MOCK_LEADER_VRF.ORDER * phi(f, relative_stake)
def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader:
return BlockHeader(parent=parent.id(), slot=slot)
return r < MOCK_LEADER_VRF.ORDER * phi(
self.config.active_slot_coeff, relative_stake
)
def common_prefix_len(a: Chain, b: Chain) -> int:

View File

@ -2,14 +2,14 @@
; ------------ BLOCK ----------------------
BLOCK = HEADER CONTENT
; ------------ HEADER ---------------------
VERSION = %x01
HEADER = VERSION HEADER-UNSIGNED
HEADER-UNSIGNED = %x00 HEADER-COMMON
HEADER-COMMON = CONTENT-SIZE CONTENT-ID BLOCK-DATE PARENT-ID
CONTENT-SIZE = U32
BLOCK-DATE = BLOCK-SLOT
BLOCK-SLOT = U64
PARENT-ID = HEADER-ID
VERSION = %x01
HEADER = VERSION HEADER-FIELDS MOCK-LEADER-PROOF
HEADER-FIELDS = CONTENT-SIZE CONTENT-ID BLOCK-DATE PARENT-ID
CONTENT-SIZE = U32
BLOCK-DATE = BLOCK-SLOT
BLOCK-SLOT = U64
PARENT-ID = HEADER-ID
MOCK-LEADER-PROOF = COMMITMENT NULLIFIER
; ------------ CONTENT --------------------
CONTENT = *OCTET
@ -19,3 +19,5 @@ U32 = 4OCTET ; unsigned integer 32 bit (BE)
U64 = 8OCTET ; unsigned integer 32 bit (BE)
HEADER-ID = 32OCTET
CONTENT-ID = 32OCTET
COMMITMENT = 32OCTET
NULLIFIER = 32OCTET

View File

@ -4,14 +4,26 @@ import numpy as np
import hashlib
from copy import deepcopy
from cryptarchia.cryptarchia import maxvalid_bg, Chain, BlockHeader, Slot, Id
from cryptarchia.cryptarchia import (
maxvalid_bg,
Chain,
BlockHeader,
Slot,
Id,
MockLeaderProof,
Coin,
)
def make_block(parent_id: Id, slot: Slot, content: bytes) -> BlockHeader:
assert len(parent_id) == 32
content_id = hashlib.sha256(content).digest()
return BlockHeader(
parent=parent_id, content_size=1, slot=slot, content_id=content_id
parent=parent_id,
content_size=1,
slot=slot,
content_id=content_id,
leader_proof=MockLeaderProof.from_coin(Coin(pk=0, value=10)),
)

View File

@ -2,12 +2,12 @@ from unittest import TestCase
import numpy as np
from .cryptarchia import Leader, LeaderConfig, EpochState, LedgerState, Coin, phi
from .cryptarchia import Leader, Config, EpochState, LedgerState, Coin, phi, TimeConfig
class TestLeader(TestCase):
def test_slot_leader_statistics(self):
epoch_state = EpochState(
epoch = EpochState(
stake_distribution_snapshot=LedgerState(
total_stake=1000,
),
@ -15,8 +15,12 @@ class TestLeader(TestCase):
)
f = 0.05
leader_config = LeaderConfig(active_slot_coeff=f)
l = Leader(config=leader_config, coin=Coin(pk=0, value=10))
config = Config(
k=10,
active_slot_coeff=f,
time=TimeConfig(slots_per_epoch=1000, slot_duration=1, chain_start_time=0),
)
l = Leader(config=config, coin=Coin(pk=0, value=10))
# We'll use the Margin of Error equation to decide how many samples we need.
# https://en.wikipedia.org/wiki/Margin_of_error
@ -27,7 +31,10 @@ class TestLeader(TestCase):
N = int((Z * std / margin_of_error) ** 2)
# After N slots, the measured leader rate should be within the interval `p +- margin_of_error` with high probabiltiy
leader_rate = sum(l.is_slot_leader(epoch_state, slot) for slot in range(N)) / N
leader_rate = (
sum(l.try_prove_slot_leader(epoch, slot) is not None for slot in range(N))
/ N
)
assert (
abs(leader_rate - p) < margin_of_error
), f"{leader_rate} != {p}, err={abs(leader_rate - p)} > {margin_of_error}"

View File

@ -0,0 +1,108 @@
from unittest import TestCase
import numpy as np
from .cryptarchia import (
Follower,
TimeConfig,
BlockHeader,
Config,
Coin,
LedgerState,
MockLeaderProof,
Slot,
Id,
)
def mk_genesis_state(initial_stake_distribution: list[Coin]) -> LedgerState:
return LedgerState(
block=bytes(32),
nonce=bytes(32),
total_stake=sum(c.value for c in initial_stake_distribution),
commitments={c.commitment() for c in initial_stake_distribution},
nullifiers=set(),
)
def mk_block(parent: Id, slot: int, coin: Coin, content=bytes(32)) -> BlockHeader:
from hashlib import sha256
return BlockHeader(
slot=Slot(slot),
parent=parent,
content_size=len(content),
content_id=sha256(content).digest(),
leader_proof=MockLeaderProof.from_coin(coin),
)
def config() -> Config:
return Config(
k=10,
active_slot_coeff=0.05,
time=TimeConfig(slots_per_epoch=1000, slot_duration=1, chain_start_time=0),
)
class TestLedgerStateUpdate(TestCase):
def test_ledger_state_prevents_coin_reuse(self):
leader_coin = Coin(pk=0, value=100)
genesis = mk_genesis_state([leader_coin])
follower = Follower(genesis, config())
block = mk_block(slot=0, parent=genesis.block, coin=leader_coin)
follower.on_block(block)
# Follower should have accepted the block
assert follower.local_chain.length() == 1
assert follower.local_chain.tip() == block
# Follower should have updated their ledger state to mark the leader coin as spent
assert follower.ledger_state.verify_unspent(leader_coin.nullifier()) == False
reuse_coin_block = mk_block(slot=1, parent=block.id, coin=leader_coin)
follower.on_block(block)
# Follower should *not* have accepted the block
assert follower.local_chain.length() == 1
assert follower.local_chain.tip() == block
def test_ledger_state_is_properly_updated_on_reorg(self):
coin_1 = Coin(pk=0, value=100)
coin_2 = Coin(pk=1, value=100)
coin_3 = Coin(pk=1, value=100)
genesis = mk_genesis_state([coin_1, coin_2, coin_3])
follower = Follower(genesis, config())
# 1) coin_1 & coin_2 both concurrently win slot 0
block_1 = mk_block(parent=genesis.block, slot=0, coin=coin_1)
block_2 = mk_block(parent=genesis.block, slot=0, coin=coin_2)
# 2) follower sees block 1 first
follower.on_block(block_1)
assert follower.tip_id() == block_1.id()
assert not follower.ledger_state.verify_unspent(coin_1.nullifier())
# 3) then sees block 2, but sticks with block_1 as the tip
follower.on_block(block_2)
assert follower.tip_id() == block_1.id()
assert len(follower.forks) == 1, f"{len(follower.forks)}"
# 4) then coin_3 wins slot 1 and chooses to extend from block_2
block_3 = mk_block(parent=block_2.id(), slot=0, coin=coin_3)
follower.on_block(block_3)
# the follower should have switched over to the block_2 fork
assert follower.tip_id() == block_3.id()
# and the original coin_1 should now be removed from the spent pool
assert follower.ledger_state.verify_unspent(coin_1.nullifier())