From c4c52fbff43088dab5d0a41c96b3f3724d46a440 Mon Sep 17 00:00:00 2001 From: Giacomo Pasini Date: Wed, 24 Jan 2024 12:52:30 +0100 Subject: [PATCH 1/4] TMP --- cryptarchia/cryptarchia.py | 154 +++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 cryptarchia/cryptarchia.py diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py new file mode 100644 index 0000000..296e311 --- /dev/null +++ b/cryptarchia/cryptarchia.py @@ -0,0 +1,154 @@ + + +# Please note this is still a work in progress +from dataclasses import dataclass + +Id: TypeAlias = bytes + +@dataclass +class Epoch: + # identifier of the epoch, counting incrementally from 0 + epoch: int + +# An absolute unique indentifier of a slot, counting incrementally from 0 +@dataclass +class Slot: + absolute_slot: int + + def from_unix_timestamp_s(config: TimeConfig, timestamp_s: int) -> Date: + absolute_slot = timestamp_s // config.slot_duration + return Slot(absolute_slot) + + def epoch(self, config: TimeConfig) -> Epoch: + return self.absolute_slot // config.slots_per_epoch + + +@dataclass +class TimeConfig: + # How many slots in a epoch, all epochs will have the same number of slots + slots_per_epoch: int + # How long a slot lasts in seconds + slot_duration: int + # Start of the first epoch, in unix timestamp second precision + chain_start_time: int + + +@dataclass +class Config: + k: int + time: TimeConfig + +@dataclass +class BlockHeader: + slot: Slot + parent: Id + _id: Id # this is an abstration over the block id + + def parent(self) -> Id: + return self.parent + + def id(self) -> Id: + return self._id + + +@dataclass +class Chain: + blocks: List[BlockHeader] + + def tip(self) -> BlockHeader: + return self.blocks[-1] + + def length(self) -> int: + return len(self.blocks) + + def contains_block(self, block: BlockHeader) -> bool: + return block in self.blocks + + def block_position(self, block: BlockHeader) -> int: + assert self.contains_block(block) + for (i, b) in enumerate(self.blocks): + if b == block: + return i + +class Follower: + def __init__(self, genesis: BlockHeader, config: Config): + self.config = config + self.forks = [] + self.local_chain = Chain([genesis]) + + # We don't do any validation in the current version + def validate_header(block: BlockHeader) -> bool: + True + + # 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(): + self.local_chain.blocks.append(block) + return True + + for chain in self.forks: + if chain.tip().id() == block.parent(): + chain.blocks.append(block) + return True + + return False + + + def try_create_fork(self, block: BlockHeader) -> Option[Chain]: + chains = self.forks + [self.local_chain] + for chain in chains: + if self.chain.contains_block(block): + block_position = chain.block_position(block) + return Chain(blocks = chain.blocks[:block_position] + [block]) + + return None + + def on_block(self, block: BlockHeader): + if not self.validate_header(block): + return + + # check if the new block extends an existing chain + if self.try_extend_chains(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 + + + # 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]) -> BlockHeader: + pass + + + def tip(self) -> BlockHeader: + return self.fork_choice() + + +@dataclass +class Coin: + value: int + +class Leader: + def init(self, genesis: BlockHeader, config: TimeConfig, coins: List[Coin]): + self.config = config + self.tip = genesis + self.coins = coins + + def is_leader_at(self, slot: Slot) -> bool: + for coin in self.coins: + if lottery(slot, coin): + return True + return False + + def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader: + assert self.is_leader_at(slot) + return BlockHeader(parent=parent.id(), slot=slot, _id=Id(b"TODO")) + + +if __name__ == "__main__": + pass \ No newline at end of file From b8966762e0455a8291b8cb81a2750ead5ee3aa51 Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 25 Jan 2024 02:04:35 +0400 Subject: [PATCH 2/4] feat(lottery): spec out basic leader slot check --- .gitignore | 3 + README.md | 15 ++++ cryptarchia/__init__.py | 0 cryptarchia/cryptarchia.py | 148 +++++++++++++++++++++++++++---------- cryptarchia/test_leader.py | 33 +++++++++ requirements.txt | 1 + 6 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 .gitignore create mode 100644 cryptarchia/__init__.py create mode 100644 cryptarchia/test_leader.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95439a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv + +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md index 6e1b4fb..28f4791 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,17 @@ # nomos-specs Nomos related specification and documentation + + +## Running Tests + +To run all tests, run the following from the project root + +```bash +python -m unittest -v +``` + +To test a specific module + +```bash +python -m unittest -v cryptarchia.test_leader +``` diff --git a/cryptarchia/__init__.py b/cryptarchia/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 296e311..dc73d9f 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -1,27 +1,17 @@ - +from typing import TypeAlias, List, Optional +from hashlib import sha256 # Please note this is still a work in progress from dataclasses import dataclass Id: TypeAlias = bytes + @dataclass class Epoch: # identifier of the epoch, counting incrementally from 0 epoch: int -# An absolute unique indentifier of a slot, counting incrementally from 0 -@dataclass -class Slot: - absolute_slot: int - - def from_unix_timestamp_s(config: TimeConfig, timestamp_s: int) -> Date: - absolute_slot = timestamp_s // config.slot_duration - return Slot(absolute_slot) - - def epoch(self, config: TimeConfig) -> Epoch: - return self.absolute_slot // config.slots_per_epoch - @dataclass class TimeConfig: @@ -33,25 +23,39 @@ class TimeConfig: chain_start_time: int -@dataclass +# An absolute unique indentifier of a slot, counting incrementally from 0 +@dataclass +class Slot: + absolute_slot: int + + def from_unix_timestamp_s(config: TimeConfig, timestamp_s: int) -> "Slot": + absolute_slot = timestamp_s // config.slot_duration + return Slot(absolute_slot) + + def epoch(self, config: TimeConfig) -> Epoch: + return self.absolute_slot // config.slots_per_epoch + + +@dataclass class Config: k: int time: TimeConfig + @dataclass class BlockHeader: slot: Slot parent: Id - _id: Id # this is an abstration over the block id def parent(self) -> Id: return self.parent def id(self) -> Id: - return self._id + # TODO: spec out the block id + raise NotImplemented() -@dataclass +@dataclass class Chain: blocks: List[BlockHeader] @@ -63,13 +67,14 @@ class Chain: def contains_block(self, block: BlockHeader) -> bool: return block in self.blocks - + def block_position(self, block: BlockHeader) -> int: assert self.contains_block(block) - for (i, b) in enumerate(self.blocks): + for i, b in enumerate(self.blocks): if b == block: return i + class Follower: def __init__(self, genesis: BlockHeader, config: Config): self.config = config @@ -78,7 +83,7 @@ class Follower: # We don't do any validation in the current version def validate_header(block: BlockHeader) -> bool: - True + return True # Try appending this block to an existing chain and return whether # the operation was successful @@ -86,7 +91,7 @@ class Follower: if self.local_chain.tip().id() == block.parent(): self.local_chain.blocks.append(block) return True - + for chain in self.forks: if chain.tip().id() == block.parent(): chain.blocks.append(block) @@ -94,14 +99,13 @@ class Follower: return False - - def try_create_fork(self, block: BlockHeader) -> Option[Chain]: + def try_create_fork(self, block: BlockHeader) -> Optional[Chain]: chains = self.forks + [self.local_chain] for chain in chains: if self.chain.contains_block(block): block_position = chain.block_position(block) - return Chain(blocks = chain.blocks[:block_position] + [block]) - + return Chain(blocks=chain.blocks[:block_position] + [block]) + return None def on_block(self, block: BlockHeader): @@ -118,12 +122,10 @@ class Follower: self.forks.append(new_chain) # otherwise, we're missing the parent block # in that case, just ignore the block - # 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]) -> BlockHeader: pass - def tip(self) -> BlockHeader: return self.fork_choice() @@ -131,24 +133,92 @@ class Follower: @dataclass class Coin: + pk: int value: int -class Leader: - def init(self, genesis: BlockHeader, config: TimeConfig, coins: List[Coin]): - self.config = config - self.tip = genesis - self.coins = coins - def is_leader_at(self, slot: Slot) -> bool: - for coin in self.coins: - if lottery(slot, coin): - return True - return False - +@dataclass +class LedgerState: + """ + A snapshot of the ledger state up to some height + """ + + height: int = None + nonce: bytes = None + total_stake: int = None + + +@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 + + # 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 stake_distribution(self) -> int: + """Returns the total stake that will be used to reletivize leadership proofs during this epoch""" + return self.stake_distribution_snapshot.total_stake + + def nonce(self) -> bytes: + 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: + f: 'active slot coefficient' - the rate of occupied slots + alpha: relative stake held by the validator + + returns: the probability that this validator should win the slot lottery + """ + return 1 - (1 - f) ** alpha + + +class MOCK_LEADER_VRF: + """NOT SECURE: A mock VRF function where the sk and pk are assummed to be the same""" + + ORDER = 2**256 + + @classmethod + def vrf(cls, sk: int, nonce: bytes, slot: int) -> int: + h = sha256() + h.update(int.to_bytes(sk, length=32)) + h.update(nonce) + h.update(int.to_bytes(slot, length=16)) # 64bit slots + return int.from_bytes(h.digest()) + + @classmethod + def verify(cls, r, pk, nonce, slot): + raise NotImplemented() + + +@dataclass +class Leader: + config: LeaderConfig + coin: Coin + + def is_slot_leader(self, epoch: EpochState, slot: Slot) -> bool: + f = self.config.active_slot_coeff + total_stake = epoch.stake_distribution() + relative_stake = self.coin.value / 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: assert self.is_leader_at(slot) return BlockHeader(parent=parent.id(), slot=slot, _id=Id(b"TODO")) if __name__ == "__main__": - pass \ No newline at end of file + pass diff --git a/cryptarchia/test_leader.py b/cryptarchia/test_leader.py new file mode 100644 index 0000000..6ea01f1 --- /dev/null +++ b/cryptarchia/test_leader.py @@ -0,0 +1,33 @@ +from unittest import TestCase + +import numpy as np + +from .cryptarchia import Leader, LeaderConfig, EpochState, LedgerState, Coin, phi + + +class TestLeader(TestCase): + def test_slot_leader_statistics(self): + epoch_state = EpochState( + stake_distribution_snapshot=LedgerState( + total_stake=1000, + ), + nonce_snapshot=LedgerState(nonce=b"1010101010"), + ) + + f = 0.05 + leader_config = LeaderConfig(active_slot_coeff=f) + l = Leader(config=leader_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 + margin_of_error = 1e-4 + p = phi(f=f, alpha=10 / 1000) + std = np.sqrt(p * (1 - p)) + Z = 3 # we want 3 std from the mean to be within the margin of error + 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 + assert ( + abs(leader_rate - p) < margin_of_error + ), f"{leader_rate} != {p}, err={abs(leader_rate - p)} > {margin_of_error}" diff --git a/requirements.txt b/requirements.txt index 81d737e..0dbb02c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ scipy==1.11.4 setuptools==69.0.3 timeout-decorator==0.5.0 wheel==0.42.0 +black==23.12.1 From 1420117e9a40f1742b4d54225363f20b4cce1ef8 Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 25 Jan 2024 14:25:37 +0400 Subject: [PATCH 3/4] rename LedgerState.head to LedgerState.block --- .gitignore | 2 +- cryptarchia/cryptarchia.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 95439a9..d4b47f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .venv -__pycache__ \ No newline at end of file +__pycache__ diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index dc73d9f..da16c7d 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -143,7 +143,7 @@ class LedgerState: A snapshot of the ledger state up to some height """ - height: int = None + block: Id = None nonce: bytes = None total_stake: int = None @@ -216,8 +216,7 @@ class Leader: return r < MOCK_LEADER_VRF.ORDER * phi(f, relative_stake) def propose_block(self, slot: Slot, parent: BlockHeader) -> BlockHeader: - assert self.is_leader_at(slot) - return BlockHeader(parent=parent.id(), slot=slot, _id=Id(b"TODO")) + return BlockHeader(parent=parent.id(), slot=slot) if __name__ == "__main__": From 94f97caab02d0442a4ae5cc42c40cfbeb22738b5 Mon Sep 17 00:00:00 2001 From: David Rusu Date: Thu, 25 Jan 2024 15:26:54 +0400 Subject: [PATCH 4/4] rename EpochState.stake_distribution() to EpochState.total_stake() --- cryptarchia/cryptarchia.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index da16c7d..5d94d05 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -159,7 +159,7 @@ class EpochState: # The nonce snapshot is taken 7k/f slots into the previous epoch nonce_snapshot: LedgerState - def stake_distribution(self) -> int: + 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 @@ -208,8 +208,7 @@ class Leader: def is_slot_leader(self, epoch: EpochState, slot: Slot) -> bool: f = self.config.active_slot_coeff - total_stake = epoch.stake_distribution() - relative_stake = self.coin.value / total_stake + relative_stake = self.coin.value / epoch.total_stake() r = MOCK_LEADER_VRF.vrf(self.coin.pk, epoch.nonce(), slot)