diff --git a/cryptarchia/cryptarchia.py b/cryptarchia/cryptarchia.py index 1a0e42c..ec4001c 100644 --- a/cryptarchia/cryptarchia.py +++ b/cryptarchia/cryptarchia.py @@ -79,8 +79,24 @@ class Slot: @dataclass class Coin: - pk: int + sk: int value: int + nonce: bytes = bytes(32) + + @property + def pk(self): + return self.sk + + def evolve(self) -> "Coin": + sk_bytes = int.to_bytes(self.sk, length=32, byteorder="little") + + h = blake2b(digest_size=32) + h.update(b"coin-evolve") + h.update(sk_bytes) + h.update(self.nonce) + evolved_nonce = h.digest() + + return Coin(nonce=evolved_nonce, sk=self.sk, value=self.value) def commitment(self) -> Id: # TODO: mocked until CL is understood @@ -88,6 +104,8 @@ class Coin: value_bytes = int.to_bytes(self.value, length=32, byteorder="little") h = sha256() + h.update(b"coin-commitment") + h.update(self.nonce) h.update(pk_bytes) h.update(value_bytes) return h.digest() @@ -98,9 +116,10 @@ class Coin: value_bytes = int.to_bytes(self.value, length=32, byteorder="little") h = sha256() + h.update(b"coin-nullifier") + h.update(self.nonce) h.update(pk_bytes) h.update(value_bytes) - h.update(b"\x00") # extra 0 byte to differentiate from commitment return h.digest() @@ -108,10 +127,17 @@ class Coin: class MockLeaderProof: commitment: Id nullifier: Id + evolved_commitment: Id @staticmethod def from_coin(coin: Coin): - return MockLeaderProof(commitment=coin.commitment(), nullifier=coin.nullifier()) + evolved_coin = coin.evolve() + + return MockLeaderProof( + commitment=coin.commitment(), + nullifier=coin.nullifier(), + evolved_commitment=evolved_coin.commitment(), + ) def verify(self, slot): # TODO: verification not implemented @@ -156,6 +182,8 @@ class BlockHeader: h.update(self.leader_proof.commitment) assert len(self.leader_proof.nullifier) == 32 h.update(self.leader_proof.nullifier) + assert len(self.leader_proof.evolved_commitment) == 32 + h.update(self.leader_proof.evolved_commitment) return h.digest() @@ -192,20 +220,31 @@ class LedgerState: # Note that this does not prevent nonce grinding at the last slot before the nonce snapshot 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 + + # set of commitments + commitments_spend: set[Id] = field(default_factory=set) + + # set of commitments eligible to lead + commitments_lead: set[Id] = field(default_factory=set) + + # set of nullified coins + nullifiers: set[Id] = field(default_factory=set) def copy(self): return LedgerState( block=self.block, nonce=self.nonce, total_stake=self.total_stake, - commitments=deepcopy(self.commitments), + commitments_spend=deepcopy(self.commitments_spend), + commitments_lead=deepcopy(self.commitments_lead), nullifiers=deepcopy(self.nullifiers), ) - def verify_committed(self, commitment: Id) -> bool: - return commitment in self.commitments + def verify_eligible_to_spend(self, commitment: Id) -> bool: + return commitment in self.commitments_spend + + def verify_eligible_to_lead(self, commitment: Id) -> bool: + return commitment in self.commitments_lead def verify_unspent(self, nullifier: Id) -> bool: return nullifier not in self.nullifiers @@ -221,6 +260,8 @@ class LedgerState: self.nonce = h.digest() self.block = block.id() self.nullifiers.add(block.leader_proof.nullifier) + self.commitments_spend.add(block.leader_proof.evolved_commitment) + self.commitments_lead.add(block.leader_proof.evolved_commitment) @dataclass @@ -234,8 +275,15 @@ 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 verify_eligible_to_lead_due_to_age(self, commitment: Id) -> bool: + # A coin is eligible to lead if it was committed to before the the stake + # distribution snapshot was taken or it was produced by a leader proof since the snapshot was taken. + # + # This verification is checking that first condition. + # + # NOTE: `ledger_state.commitments_spend` is a super-set of `ledger_state.commitments_lead` + + return self.stake_distribution_snapshot.verify_eligible_to_spend(commitment) def total_stake(self) -> int: """Returns the total stake that will be used to reletivize leadership proofs during this epoch""" @@ -271,7 +319,10 @@ class Follower: ) -> bool: return ( proof.verify(slot) # verify slot leader proof - and epoch_state.verify_commitment_is_old_enough_to_lead(proof.commitment) + and ( + ledger_state.verify_eligible_to_lead(proof.commitment) + or epoch_state.verify_eligible_to_lead_due_to_age(proof.commitment) + ) and ledger_state.verify_unspent(proof.nullifier) ) diff --git a/cryptarchia/messages.abnf b/cryptarchia/messages.abnf index fefd6a6..94aa013 100644 --- a/cryptarchia/messages.abnf +++ b/cryptarchia/messages.abnf @@ -9,7 +9,8 @@ CONTENT-SIZE = U32 BLOCK-DATE = BLOCK-SLOT BLOCK-SLOT = U64 PARENT-ID = HEADER-ID -MOCK-LEADER-PROOF = COMMITMENT NULLIFIER +MOCK-LEADER-PROOF = COMMITMENT NULLIFIER EVOLVE-COMMITMENT +EVOLVE-COMMITMENT = COMMITMENT ; ------------ CONTENT -------------------- CONTENT = *OCTET diff --git a/cryptarchia/test_fork_choice.py b/cryptarchia/test_fork_choice.py index 87dc379..93e5571 100644 --- a/cryptarchia/test_fork_choice.py +++ b/cryptarchia/test_fork_choice.py @@ -23,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.from_coin(Coin(pk=0, value=10)), + leader_proof=MockLeaderProof.from_coin(Coin(sk=0, value=10)), ) diff --git a/cryptarchia/test_leader.py b/cryptarchia/test_leader.py index ffb5554..43de3ac 100644 --- a/cryptarchia/test_leader.py +++ b/cryptarchia/test_leader.py @@ -23,7 +23,7 @@ class TestLeader(TestCase): epoch_period_nonce_stabilization=3, time=TimeConfig(slot_duration=1, chain_start_time=0), ) - l = Leader(config=config, coin=Coin(pk=0, value=10)) + l = Leader(config=config, coin=Coin(sk=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 6c8ec7e..c56e1b0 100644 --- a/cryptarchia/test_ledger_state_update.py +++ b/cryptarchia/test_ledger_state_update.py @@ -20,7 +20,8 @@ def mk_genesis_state(initial_stake_distribution: list[Coin]) -> 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}, + commitments_spend={c.commitment() for c in initial_stake_distribution}, + commitments_lead={c.commitment() for c in initial_stake_distribution}, nullifiers=set(), ) @@ -50,7 +51,7 @@ def config() -> Config: class TestLedgerStateUpdate(TestCase): def test_ledger_state_prevents_coin_reuse(self): - leader_coin = Coin(pk=0, value=100) + leader_coin = Coin(sk=0, value=100) genesis = mk_genesis_state([leader_coin]) follower = Follower(genesis, config()) @@ -76,9 +77,9 @@ class TestLedgerStateUpdate(TestCase): 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=2, value=100) + coin_1 = Coin(sk=0, value=100) + coin_2 = Coin(sk=1, value=100) + coin_3 = Coin(sk=2, value=100) genesis = mk_genesis_state([coin_1, coin_2, coin_3]) @@ -114,7 +115,7 @@ class TestLedgerStateUpdate(TestCase): assert follower.ledger_state[block_3.id()].verify_unspent(coin_1.nullifier()) def test_epoch_transition(self): - leader_coins = [Coin(pk=i, value=100) for i in range(4)] + leader_coins = [Coin(sk=i, value=100) for i in range(4)] genesis = mk_genesis_state(leader_coins) # An epoch will be 10 slots long, with stake distribution snapshot taken at the start of the epoch @@ -145,13 +146,108 @@ class TestLedgerStateUpdate(TestCase): # To ensure this is the case, we add a new coin just to the state associated with that slot, # so that the new block can be accepted only if that is the snapshot used # first, verify that if we don't change the state, the block is not accepted - block_4 = mk_block(slot=20, parent=block_3.id(), coin=Coin(pk=4, value=100)) + block_4 = mk_block(slot=20, parent=block_3.id(), coin=Coin(sk=4, value=100)) follower.on_block(block_4) assert follower.tip() == block_3 - # then we add the coin to the state associated with slot 9 - follower.ledger_state[block_2.id()].commitments.add( - Coin(pk=4, value=100).commitment() + # then we add the coin to "spendable commitments" associated with slot 9 + follower.ledger_state[block_2.id()].commitments_spend.add( + Coin(sk=4, value=100).commitment() ) follower.on_block(block_4) assert follower.tip() == block_4 assert follower.tip().slot.epoch(follower.config).epoch == 2 + + def test_evolved_coin_is_eligible_for_leadership(self): + coin = Coin(sk=0, value=100) + + genesis = mk_genesis_state([coin]) + + config = Config( + k=1, + active_slot_coeff=1, + epoch_stake_distribution_stabilization=4, + epoch_period_nonce_buffer=3, + epoch_period_nonce_stabilization=3, + time=TimeConfig(slot_duration=1, chain_start_time=0), + ) + + follower = Follower(genesis, config) + + # coin wins the first slot + block_1 = mk_block(slot=0, parent=genesis.block, coin=coin) + follower.on_block(block_1) + assert follower.tip_id() == block_1.id() + + # coin can't be reused to win following slots: + block_2_reuse = mk_block(slot=1, parent=block_1.id(), coin=coin) + follower.on_block(block_2_reuse) + assert follower.tip_id() == block_1.id() + + # but the evolved coin is eligible + block_2_evolve = mk_block(slot=1, parent=block_1.id(), coin=coin.evolve()) + follower.on_block(block_2_evolve) + assert follower.tip_id() == block_2_evolve.id() + + def test_new_coins_becoming_eligible_after_stake_distribution_stabilizes(self): + coin = Coin(sk=0, value=100) + genesis = mk_genesis_state([coin]) + + # An epoch will be 10 slots long, with stake distribution snapshot taken at the start of the epoch + # and nonce snapshot before slot 7 + config = Config( + k=1, + active_slot_coeff=1, + epoch_stake_distribution_stabilization=4, + epoch_period_nonce_buffer=3, + epoch_period_nonce_stabilization=3, + time=TimeConfig(slot_duration=1, chain_start_time=0), + ) + + follower = Follower(genesis, config) + + # ---- EPOCH 0 ---- + + block_0_0 = mk_block(slot=0, parent=genesis.block, coin=coin) + follower.on_block(block_0_0) + assert follower.tip() == block_0_0 + + # mint a new coin to be used for leader elections in upcoming epochs + coin_new = Coin(sk=1, value=10) + follower.ledger_state[block_0_0.id()].commitments_spend.add( + coin_new.commitment() + ) + + # the new coin is not yet eligible for elections + + block_0_1_attempt = mk_block(slot=1, parent=block_0_0.id(), coin=coin_new) + follower.on_block(block_0_1_attempt) + assert follower.tip() == block_0_0 + + # whereas the evolved coin from genesis can be spent immediately + + block_0_1 = mk_block(slot=1, parent=block_0_0.id(), coin=coin.evolve()) + follower.on_block(block_0_1) + assert follower.tip() == block_0_1 + + # ---- EPOCH 1 ---- + + # The newly minted coin is still not eligible in the following epoch since the + # stake distribution snapshot is taken at the beginning of the previous epoch + + block_1_0 = mk_block(slot=10, parent=block_0_1.id(), coin=coin_new) + follower.on_block(block_1_0) + assert follower.tip() == block_0_1 + + # ---- EPOCH 2 ---- + + # The coin is finally eligible 2 epochs after it was first minted + + block_2_0 = mk_block(slot=20, parent=block_0_1.id(), coin=coin_new) + follower.on_block(block_2_0) + assert follower.tip() == block_2_0 + + # And now the minted coin can freely use the evolved coin for subsequent blocks + + block_2_1 = mk_block(slot=20, parent=block_2_0.id(), coin=coin_new.evolve()) + follower.on_block(block_2_1) + assert follower.tip() == block_2_1