From 66239b3913fc557aa71b43e7a858ea448036161a Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 1 Feb 2024 12:19:43 +0400 Subject: [PATCH 1/6] checkpoint work on ledger-state --- cryptarchia/cryptarchia.py | 75 ++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index b6d081f..8bce2a3 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -155,16 +155,51 @@ 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 LedgerState: """ - A snapshot of the ledger state up to some height + A snapshot of the ledger state up to some block """ block: Id = None nonce: bytes = None total_stake: int = None + commitments: set[Id] = set() # set of commitments + nullifiers: set[Id] = set() # set of nullified + + +@dataclass +class LeaderProof: + epoch: int + slot: Slot + commitment: Id + nullifier: Id + + def verify(self): + # TODO: verification not implemented + return True @dataclass @@ -172,12 +207,20 @@ class EpochState: # for details of snapshot schedule please see: # https://github.com/IntersectMBO/ouroboros-consensus/blob/fe245ac1d8dbfb563ede2fdb6585055e12ce9738/docs/website/contents/for-developers/Glossary.md#epoch-structure + epoch_number: int + # The stake distribution snapshot is taken at the beginning of the previous epoch stake_distribution_snapshot: LedgerState # The nonce snapshot is taken 7k/f slots into the previous epoch nonce_snapshot: LedgerState + def is_coin_old_enough_to_lead(self, coin: Coin) -> bool: + return coin in self.stake_distribution.commitments + + def is_nullified(self, nullifier: Id) -> bool: + return nullifier in self.stake_distribution.nullifiers + 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 @@ -220,18 +263,36 @@ class MOCK_LEADER_VRF: raise NotImplemented() +def is_slot_leader( + config: LeaderConfig, coin: Coin, epoch: EpochState, slot: Slot +) -> bool: + relative_stake = coin.value / epoch.total_stake() + + r = MOCK_LEADER_VRF.vrf(coin.pk, epoch.nonce(), slot) + + return r < MOCK_LEADER_VRF.ORDER * phi(config.active_slot_coeff, relative_stake) + + @dataclass class Leader: config: LeaderConfig coin: Coin - def is_slot_leader(self, epoch: EpochState, slot: Slot) -> bool: - f = self.config.active_slot_coeff - relative_stake = self.coin.value / epoch.total_stake() + def try_slot_leader_proof( + self, epoch: EpochState, slot: Slot + ) -> LeaderProof | None: + if is_slot_leader(self.config, self.coin, epoch, slot): + return LeaderProof(epoch.epoch_number, slot, self.coin) - r = MOCK_LEADER_VRF.vrf(self.coin.pk, epoch.nonce(), slot) - - return r < MOCK_LEADER_VRF.ORDER * phi(f, relative_stake) + def verify_slot_leader_proof( + self, epoch: EpochState, slot: Slot, proof: LeaderProof + ) -> bool: + return ( + proof.verify() + and epoch.is_coin_old_enough_to_lead(proof.coin) + and not epoch.is_coin_nullified(proof.nullifier) + and is_slot_leader(self.config, proof.coin, epoch, slot) + ) def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader: return BlockHeader(parent=parent.id(), slot=slot) From 7d8e4d72d998900e4924046792223120ae5458dd Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 1 Feb 2024 13:56:49 +0400 Subject: [PATCH 2/6] setup for ledger state update --- cryptarchia/cryptarchia.py | 128 +++++++++++++++++--------------- cryptarchia/test_fork_choice.py | 15 +++- cryptarchia/test_leader.py | 7 +- 3 files changed, 87 insertions(+), 63 deletions(-) diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 8bce2a3..cb0f6e5 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -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 @@ -42,12 +42,29 @@ class Config: time: TimeConfig +@dataclass +class MockLeaderProof: + commitment: Id + nullifier: Id + + def verify(self): + # TODO: verification not implemented + return True + + def _id_update(self, hasher): + commitment_bytes = int.to_bytes(self.commitment, length=32, byteorder="little") + nullifier_bytes = int.to_bytes(self.nullifier, length=32, byteorder="little") + hasher.update(commitment_bytes) + hasher.update(nullifier_bytes) + + @dataclass class BlockHeader: slot: Slot 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 @@ -70,6 +87,10 @@ class BlockHeader: # parent assert len(self.parent) == 32 h.update(self.parent) + + # TODO: Leader proof component of block id is mocked here until CL is understood + self.leader_proof._id_update(h) + return h.digest() @@ -93,15 +114,43 @@ class Chain: return i +@dataclass +class LedgerState: + """ + A snapshot of the ledger state up to some block + """ + + block: Id = None + nonce: bytes = 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 is_coin_nullified(self, nullifier: Id) -> bool: + return nullifier in self.nullifiers + + 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.ledger_state = genesis_state - # We don't do any validation in the current version def validate_header(block: BlockHeader) -> bool: - return True + # 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.is_coin_old_enough_to_lead(proof.coin) # verify coin was not recently created + and not self.ledger_state.is_coin_nullified(proof.nullifier) # verify the coin has not already been spent + ) # Try appending this block to an existing chain and return whether # the operation was successful @@ -177,38 +226,11 @@ class Coin: return h.digest() -@dataclass -class LedgerState: - """ - A snapshot of the ledger state up to some block - """ - - block: Id = None - nonce: bytes = None - total_stake: int = None - commitments: set[Id] = set() # set of commitments - nullifiers: set[Id] = set() # set of nullified - - -@dataclass -class LeaderProof: - epoch: int - slot: Slot - commitment: Id - nullifier: Id - - def verify(self): - # TODO: verification not implemented - return True - - @dataclass class EpochState: # for details of snapshot schedule please see: # https://github.com/IntersectMBO/ouroboros-consensus/blob/fe245ac1d8dbfb563ede2fdb6585055e12ce9738/docs/website/contents/for-developers/Glossary.md#epoch-structure - epoch_number: int - # The stake distribution snapshot is taken at the beginning of the previous epoch stake_distribution_snapshot: LedgerState @@ -218,9 +240,6 @@ class EpochState: def is_coin_old_enough_to_lead(self, coin: Coin) -> bool: return coin in self.stake_distribution.commitments - def is_nullified(self, nullifier: Id) -> bool: - return nullifier in self.stake_distribution.nullifiers - 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 @@ -263,40 +282,31 @@ class MOCK_LEADER_VRF: raise NotImplemented() -def is_slot_leader( - config: LeaderConfig, coin: Coin, epoch: EpochState, slot: Slot -) -> bool: - relative_stake = coin.value / epoch.total_stake() - - r = MOCK_LEADER_VRF.vrf(coin.pk, epoch.nonce(), slot) - - return r < MOCK_LEADER_VRF.ORDER * phi(config.active_slot_coeff, relative_stake) - - @dataclass class Leader: config: LeaderConfig coin: Coin - def try_slot_leader_proof( + def try_prove_slot_leader( self, epoch: EpochState, slot: Slot - ) -> LeaderProof | None: - if is_slot_leader(self.config, self.coin, epoch, slot): - return LeaderProof(epoch.epoch_number, slot, self.coin) - - def verify_slot_leader_proof( - self, epoch: EpochState, slot: Slot, proof: LeaderProof - ) -> bool: - return ( - proof.verify() - and epoch.is_coin_old_enough_to_lead(proof.coin) - and not epoch.is_coin_nullified(proof.nullifier) - and is_slot_leader(self.config, proof.coin, epoch, slot) - ) + ) -> MockLeaderProof | None: + if self._is_slot_leader(epoch, slot): + return MockLeaderProof( + commitment=self.coin.commitment(), nullifier=self.coin.nullifier() + ) 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( + self.config.active_slot_coeff, relative_stake + ) + def common_prefix_len(a: Chain, b: Chain) -> int: for i, (x, y) in enumerate(zip(a.blocks, b.blocks)): diff --git a/cryptarchia/test_fork_choice.py b/cryptarchia/test_fork_choice.py index 3f4f8b4..b281dee 100644 --- a/cryptarchia/test_fork_choice.py +++ b/cryptarchia/test_fork_choice.py @@ -4,14 +4,25 @@ 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, +) 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(commitment=0, nullifier=0), ) diff --git a/cryptarchia/test_leader.py b/cryptarchia/test_leader.py index 6ea01f1..ffa5e43 100644 --- a/cryptarchia/test_leader.py +++ b/cryptarchia/test_leader.py @@ -7,7 +7,7 @@ from .cryptarchia import Leader, LeaderConfig, EpochState, LedgerState, Coin, ph class TestLeader(TestCase): def test_slot_leader_statistics(self): - epoch_state = EpochState( + epoch = EpochState( stake_distribution_snapshot=LedgerState( total_stake=1000, ), @@ -27,7 +27,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}" From 45bddc0e2135c7442b488b7f003a6865467e27cb Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 1 Feb 2024 14:53:59 +0400 Subject: [PATCH 3/6] Follower maintains ledger state as it follows the chain --- cryptarchia/cryptarchia.py | 149 +++++++++++++++--------- cryptarchia/test_fork_choice.py | 3 +- cryptarchia/test_ledger_state_update.py | 67 +++++++++++ 3 files changed, 160 insertions(+), 59 deletions(-) create mode 100644 cryptarchia/test_ledger_state_update.py diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index cb0f6e5..47ec032 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -23,6 +23,12 @@ class TimeConfig: chain_start_time: int +@dataclass +class Config: + k: int + time: TimeConfig + + # An absolute unique indentifier of a slot, counting incrementally from 0 @dataclass class Slot: @@ -37,9 +43,30 @@ 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 @@ -47,15 +74,17 @@ class MockLeaderProof: commitment: Id nullifier: Id - def verify(self): + @staticmethod + def from_coin(coin: Coin): + return MockLeaderProof(commitment=coin.commitment(), nullifier=coin.nullifier()) + + def verify(self, slot): # TODO: verification not implemented return True def _id_update(self, hasher): - commitment_bytes = int.to_bytes(self.commitment, length=32, byteorder="little") - nullifier_bytes = int.to_bytes(self.nullifier, length=32, byteorder="little") - hasher.update(commitment_bytes) - hasher.update(nullifier_bytes) + hasher.update(self.commitment) + hasher.update(self.nullifier) @dataclass @@ -121,13 +150,21 @@ class LedgerState: """ block: Id = None - nonce: bytes = 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 is_coin_nullified(self, nullifier: Id) -> bool: - return nullifier in self.nullifiers + 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: @@ -139,23 +176,24 @@ class Follower: stake_distribution_snapshot=genesis_state, nonce_snapshot=genesis_state, ) + self.ledger_state_snapshot = genesis_state self.ledger_state = genesis_state - def validate_header(block: BlockHeader) -> bool: + 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.is_coin_old_enough_to_lead(proof.coin) # verify coin was not recently created - and not self.ledger_state.is_coin_nullified(proof.nullifier) # verify the coin has not already been spent + 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 @@ -180,50 +218,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 = Follower.fork_choice(self.local_chain, self.forks) + + 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.ledger_state_snapshot.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 + @staticmethod def fork_choice(local_chain: Chain, forks: List[Chain]) -> Chain: # TODO: define k and s return maxvalid_bg(local_chain, forks, 0, 0) - def tip(self) -> BlockHeader: - return self.fork_choice() - - -@dataclass -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() + def tip_id(self) -> Id: + if self.local_chain.length() > 0: + return self.local_chain.tip().id + else: + return self.ledger_state.block @dataclass @@ -237,8 +272,8 @@ class EpochState: # The nonce snapshot is taken 7k/f slots into the previous epoch nonce_snapshot: LedgerState - def is_coin_old_enough_to_lead(self, coin: Coin) -> bool: - return coin in self.stake_distribution.commitments + 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""" @@ -291,9 +326,7 @@ class Leader: self, epoch: EpochState, slot: Slot ) -> MockLeaderProof | None: if self._is_slot_leader(epoch, slot): - return MockLeaderProof( - commitment=self.coin.commitment(), nullifier=self.coin.nullifier() - ) + return MockLeaderProof.from_coin(self.coin) def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader: return BlockHeader(parent=parent.id(), slot=slot) diff --git a/cryptarchia/test_fork_choice.py b/cryptarchia/test_fork_choice.py index b281dee..87dc379 100644 --- a/cryptarchia/test_fork_choice.py +++ b/cryptarchia/test_fork_choice.py @@ -11,6 +11,7 @@ from cryptarchia.cryptarchia import ( Slot, Id, MockLeaderProof, + Coin, ) @@ -22,7 +23,7 @@ def make_block(parent_id: Id, slot: Slot, content: bytes) -> BlockHeader: content_size=1, slot=slot, content_id=content_id, - leader_proof=MockLeaderProof(commitment=0, nullifier=0), + leader_proof=MockLeaderProof.from_coin(Coin(pk=0, value=10)), ) diff --git a/cryptarchia/test_ledger_state_update.py b/cryptarchia/test_ledger_state_update.py new file mode 100644 index 0000000..49cef7a --- /dev/null +++ b/cryptarchia/test_ledger_state_update.py @@ -0,0 +1,67 @@ +from unittest import TestCase + +import numpy as np + +from .cryptarchia import ( + Follower, + TimeConfig, + BlockHeader, + Config, + Coin, + LedgerState, + MockLeaderProof, + Slot, +) + + +def config() -> Config: + return Config( + k=10, 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_state = LedgerState( + block=bytes(32), + nonce=bytes(32), + total_stake=leader_coin.value, + commitments={leader_coin.commitment()}, + nullifiers=set(), + ) + + follower = Follower(genesis_state, config()) + + block = BlockHeader( + slot=Slot(0), + parent=genesis_state.block, + content_size=1, + content_id=bytes(32), + leader_proof=MockLeaderProof.from_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 = BlockHeader( + slot=Slot(0), + parent=block.id(), + content_size=1, + content_id=bytes(32), + leader_proof=MockLeaderProof( + commitment=leader_coin.commitment(), + nullifier=leader_coin.nullifier(), + ), + ) + follower.on_block(block) + + # Follower should *not* have accepted the block + assert follower.local_chain.length() == 1 + assert follower.local_chain.tip() == block From 62ea40ba5e7b3e1620f2fff9be8f753707e5cbd7 Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 1 Feb 2024 20:25:49 +0400 Subject: [PATCH 4/6] address CR --- cryptarchia/cryptarchia.py | 11 +++++++---- cryptarchia/messages.abnf | 3 ++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 47ec032..736af21 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -82,7 +82,10 @@ class MockLeaderProof: # TODO: verification not implemented return True - def _id_update(self, hasher): + def _block_id_update(self, hasher): + # TODO: this is used to contribute to the block id, to ensure the id is dependent + # on the leader proof, but the details here are not specified yet, we're waiting on + # CL specification before we nail this down hasher.update(self.commitment) hasher.update(self.nullifier) @@ -118,7 +121,7 @@ class BlockHeader: h.update(self.parent) # TODO: Leader proof component of block id is mocked here until CL is understood - self.leader_proof._id_update(h) + self.leader_proof._block_id_update(h) return h.digest() @@ -176,7 +179,7 @@ class Follower: stake_distribution_snapshot=genesis_state, nonce_snapshot=genesis_state, ) - self.ledger_state_snapshot = genesis_state + self.genesis_state = genesis_state self.ledger_state = genesis_state def validate_header(self, block: BlockHeader) -> bool: @@ -241,7 +244,7 @@ class Follower: 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.ledger_state_snapshot.copy() + ledger_state = self.genesis_state.copy() for block in new_chain.blocks: ledger_state.apply(block) diff --git a/cryptarchia/messages.abnf b/cryptarchia/messages.abnf index 1f828fe..ef3e7c5 100644 --- a/cryptarchia/messages.abnf +++ b/cryptarchia/messages.abnf @@ -3,13 +3,14 @@ BLOCK = HEADER CONTENT ; ------------ HEADER --------------------- VERSION = %x01 -HEADER = VERSION HEADER-UNSIGNED +HEADER = VERSION HEADER-UNSIGNED LEADER-PROOF 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 +LEADER-PROOF = ; ------------ CONTENT -------------------- CONTENT = *OCTET From 9345af0614ad8d96a27a52aab5168d4f60ce7a48 Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 1 Feb 2024 21:33:37 +0400 Subject: [PATCH 5/6] test ledger state is properly updated on re-org --- cryptarchia/cryptarchia.py | 43 +++++++---- cryptarchia/test_leader.py | 10 ++- cryptarchia/test_ledger_state_update.py | 95 ++++++++++++++++++------- 3 files changed, 103 insertions(+), 45 deletions(-) diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 736af21..8ad59ca 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -26,8 +26,13 @@ class TimeConfig: @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 @@ -158,6 +163,15 @@ class LedgerState: 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 @@ -180,7 +194,7 @@ class Follower: nonce_snapshot=genesis_state, ) self.genesis_state = genesis_state - self.ledger_state = genesis_state + self.ledger_state = genesis_state.copy() def validate_header(self, block: BlockHeader) -> bool: # TODO: this is not the full block validation spec, only slot leader is verified @@ -201,16 +215,20 @@ class Follower: 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]) @@ -234,7 +252,7 @@ class Follower: return # We may need to switch forks, lets run the fork choice rule to check. - new_chain = Follower.fork_choice(self.local_chain, self.forks) + 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 @@ -252,14 +270,14 @@ class Follower: 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 - @staticmethod - 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_id(self) -> Id: if self.local_chain.length() > 0: - return self.local_chain.tip().id + return self.local_chain.tip().id() else: return self.ledger_state.block @@ -286,11 +304,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: @@ -322,7 +335,7 @@ class MOCK_LEADER_VRF: @dataclass class Leader: - config: LeaderConfig + config: Config coin: Coin def try_prove_slot_leader( diff --git a/cryptarchia/test_leader.py b/cryptarchia/test_leader.py index ffa5e43..59cc44c 100644 --- a/cryptarchia/test_leader.py +++ b/cryptarchia/test_leader.py @@ -2,7 +2,7 @@ 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): @@ -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 diff --git a/cryptarchia/test_ledger_state_update.py b/cryptarchia/test_ledger_state_update.py index 49cef7a..2c848bd 100644 --- a/cryptarchia/test_ledger_state_update.py +++ b/cryptarchia/test_ledger_state_update.py @@ -11,36 +11,48 @@ from .cryptarchia import ( 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, time=TimeConfig(slots_per_epoch=1000, slot_duration=1, chain_start_time=0) + 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_state = LedgerState( - block=bytes(32), - nonce=bytes(32), - total_stake=leader_coin.value, - commitments={leader_coin.commitment()}, - nullifiers=set(), - ) + genesis = mk_genesis_state([leader_coin]) - follower = Follower(genesis_state, config()) - - block = BlockHeader( - slot=Slot(0), - parent=genesis_state.block, - content_size=1, - content_id=bytes(32), - leader_proof=MockLeaderProof.from_coin(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 @@ -50,18 +62,47 @@ class TestLedgerStateUpdate(TestCase): # 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 = BlockHeader( - slot=Slot(0), - parent=block.id(), - content_size=1, - content_id=bytes(32), - leader_proof=MockLeaderProof( - commitment=leader_coin.commitment(), - nullifier=leader_coin.nullifier(), - ), - ) + 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()) From 9f6b9eb2421520326bb9f314ca7e68166560091b Mon Sep 17 00:00:00 2001 From: David Rusu Date: Fri, 2 Feb 2024 01:16:14 +0400 Subject: [PATCH 6/6] Specify mock-leader-proof in message spec --- README.md | 6 ++++++ cryptarchia/cryptarchia.py | 23 +++++++++++------------ cryptarchia/messages.abnf | 19 ++++++++++--------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 28f4791..783e2f5 100644 --- a/README.md +++ b/README.md @@ -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_* +``` diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 8ad59ca..64defba 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -87,13 +87,6 @@ class MockLeaderProof: # TODO: verification not implemented return True - def _block_id_update(self, hasher): - # TODO: this is used to contribute to the block id, to ensure the id is dependent - # on the leader proof, but the details here are not specified yet, we're waiting on - # CL specification before we nail this down - hasher.update(self.commitment) - hasher.update(self.nullifier) - @dataclass class BlockHeader: @@ -109,24 +102,30 @@ 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) - # TODO: Leader proof component of block id is mocked here until CL is understood - self.leader_proof._block_id_update(h) + # 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() diff --git a/cryptarchia/messages.abnf b/cryptarchia/messages.abnf index ef3e7c5..fefd6a6 100644 --- a/cryptarchia/messages.abnf +++ b/cryptarchia/messages.abnf @@ -2,15 +2,14 @@ ; ------------ BLOCK ---------------------- BLOCK = HEADER CONTENT ; ------------ HEADER --------------------- -VERSION = %x01 -HEADER = VERSION HEADER-UNSIGNED LEADER-PROOF -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 -LEADER-PROOF = +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 @@ -20,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