cryptarchia: remove orphan proofs from block headers

This commit is contained in:
David Rusu 2025-03-21 00:57:54 +04:00
parent 058376987e
commit 236a677c86
6 changed files with 58 additions and 442 deletions

View File

@ -13,9 +13,6 @@ import numpy as np
logger = logging.getLogger(__name__)
Id: TypeAlias = bytes
class Hash(bytes):
def __new__(cls, dst, *data):
assert isinstance(dst, bytes)
@ -178,7 +175,7 @@ class Note:
self.zone_id,
)
def nullifier(self) -> Id:
def nullifier(self) -> Hash:
return Hash(b"NOMOS_NOTE_NF", self.commitment(), self.encode_sk())
@ -186,7 +183,7 @@ class Note:
class MockLeaderProof:
note: Note
slot: Slot
parent: Id
parent: Hash
@property
def commitment(self):
@ -200,7 +197,7 @@ class MockLeaderProof:
def evolved_commitment(self):
return self.note.evolve().commitment()
def verify(self, slot: Slot, parent: Id):
def verify(self, slot: Slot, parent: Hash):
# TODO: verification not implemented
return slot == self.slot and parent == self.parent
@ -208,52 +205,29 @@ class MockLeaderProof:
@dataclass
class BlockHeader:
slot: Slot
parent: Id
parent: Hash
content_size: int
content_id: Id
content_id: Hash
leader_proof: MockLeaderProof
orphaned_proofs: List["BlockHeader"] = field(default_factory=list)
def update_header_hash(self, h):
# version byte
h.update(b"\x01")
# 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(self.slot.encode())
# 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)
assert len(self.leader_proof.evolved_commitment) == 32
h.update(self.leader_proof.evolved_commitment)
# orphaned proofs
h.update(int.to_bytes(len(self.orphaned_proofs), length=4, byteorder="big"))
for proof in self.orphaned_proofs:
proof.update_header_hash(h)
# **Attention**:
# The ID of a block header is defined as the 32byte blake2b hash of its fields
# The ID of a block header is defined as the hash of its fields
# as serialized in the format specified by the 'HEADER' rule in 'messages.abnf'.
#
# The following code is to be considered as a reference implementation, mostly to be used for testing.
def id(self) -> Id:
h = blake2b(digest_size=32)
self.update_header_hash(h)
return h.digest()
def id(self) -> Hash:
return Hash(
b"BLOCK_ID",
b"\x01", # version
int.to_bytes(self.content_size, length=4, byteorder="big"), # content size
self.content_id, # content id
self.slot.encode(), # slot
self.parent, # parent
# leader proof
self.leader_proof.commitment,
self.leader_proof.nullifier,
self.leader_proof.evolved_commitment,
)
def __hash__(self):
return hash(self.id())
@ -273,19 +247,19 @@ class LedgerState:
#
# NOTE that this does not prevent nonce grinding at the last slot
# when the nonce snapshot is taken
nonce: Id = None
nonce: Hash = None
# set of commitments
commitments_spend: set[Id] = field(default_factory=set)
commitments_spend: set[Hash] = field(default_factory=set)
# set of commitments eligible to lead
commitments_lead: set[Id] = field(default_factory=set)
commitments_lead: set[Hash] = field(default_factory=set)
# set of nullified notes
nullifiers: set[Id] = field(default_factory=set)
nullifiers: set[Hash] = field(default_factory=set)
# -- Stake Relativization State
# The number of observed leaders (blocks + orphans), this measurement is
# The number of observed leaders, this measurement is
# used in inferring total active stake in the network.
leader_count: int = 0
@ -302,13 +276,13 @@ class LedgerState:
def replace(self, **kwarg) -> "LedgerState":
return replace(self, **kwarg)
def verify_eligible_to_spend(self, commitment: Id) -> bool:
def verify_eligible_to_spend(self, commitment: Hash) -> bool:
return commitment in self.commitments_spend
def verify_eligible_to_lead(self, commitment: Id) -> bool:
def verify_eligible_to_lead(self, commitment: Hash) -> bool:
return commitment in self.commitments_lead
def verify_unspent(self, nullifier: Id) -> bool:
def verify_unspent(self, nullifier: Hash) -> bool:
return nullifier not in self.nullifiers
def apply(self, block: BlockHeader):
@ -320,9 +294,8 @@ class LedgerState:
block.leader_proof.nullifier,
block.slot.encode(),
)
self.apply_leader_proof(block.leader_proof)
self.block = block
for proof in itertools.chain(block.orphaned_proofs, [block]):
self.apply_leader_proof(proof.leader_proof)
def apply_leader_proof(self, proof: MockLeaderProof):
self.nullifiers.add(proof.nullifier)
@ -347,7 +320,7 @@ class EpochState:
# leadership lottery.
inferred_total_active_stake: int
def verify_eligible_to_lead_due_to_age(self, commitment: Id) -> bool:
def verify_eligible_to_lead_due_to_age(self, commitment: Hash) -> bool:
# A note 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.
@ -371,7 +344,7 @@ class EpochState:
class Follower:
def __init__(self, genesis_state: LedgerState, config: Config):
self.config = config
self.forks: list[Id] = []
self.forks: list[Hash] = []
self.local_chain = genesis_state.block.id()
self.genesis_state = genesis_state
self.ledger_state = {genesis_state.block.id(): genesis_state.copy()}
@ -384,39 +357,10 @@ class Follower:
current_state = self.ledger_state[block.parent].copy()
# We use the proposed block epoch state to validate orphans as well.
# For very old orphans, these states may be different.
epoch_state = self.compute_epoch_state(
block.slot.epoch(self.config), block.parent
)
# first, we verify adopted leadership transactions
for orphan in block.orphaned_proofs:
# orphan proofs are checked in two ways
# 1. ensure they are valid locally in their original branch
# 2. ensure it does not conflict with current state
# We take a shortcut for (1.) by restricting orphans to proofs we've
# already processed in other branches.
if orphan.id() not in self.ledger_state:
raise MissingOrphanProof
# (2.) is satisfied by verifying the proof against current state ensuring:
# - it is a valid proof
# - and the nullifier has not already been spent
if not self.verify_slot_leader(
orphan.slot,
orphan.parent,
orphan.leader_proof,
epoch_state,
current_state,
):
raise InvalidOrphanProof
# if an adopted leadership proof is valid we need to apply its
# effects to the ledger state
current_state.apply_leader_proof(orphan.leader_proof)
# TODO: this is not the full block validation spec, only slot leader is verified
if not self.verify_slot_leader(
block.slot,
@ -430,7 +374,7 @@ class Follower:
def verify_slot_leader(
self,
slot: Slot,
parent: Id,
parent: Hash,
proof: MockLeaderProof,
# notes are old enough if their commitment is in the stake distribution snapshot
epoch_state: EpochState,
@ -491,29 +435,8 @@ class Follower:
self.forks.remove(checkpoint_block_id)
self.local_chain = checkpoint_block_id
def unimported_orphans(self) -> list[BlockHeader]:
"""
Returns all unimported orphans w.r.t. the given tip's state.
Orphans are returned in the order that they should be imported.
"""
tip_state = self.tip_state().copy()
tip = tip_state.block.id()
orphans = []
for fork in self.forks:
_, _, fork_depth, fork_suffix = common_prefix_depth(
tip, fork, self.ledger_state
)
for b in fork_suffix:
if b.leader_proof.nullifier not in tip_state.nullifiers:
tip_state.nullifiers.add(b.leader_proof.nullifier)
orphans += [b]
return orphans
# Evaluate the fork choice rule and return the chain we should be following
def fork_choice(self) -> Id:
def fork_choice(self) -> Hash:
return maxvalid_bg(
self.local_chain,
self.forks,
@ -525,13 +448,13 @@ class Follower:
def tip(self) -> BlockHeader:
return self.tip_state().block
def tip_id(self) -> Id:
def tip_id(self) -> Hash:
return self.local_chain
def tip_state(self) -> LedgerState:
return self.ledger_state[self.tip_id()]
def state_at_slot_beginning(self, tip: Id, slot: Slot) -> LedgerState:
def state_at_slot_beginning(self, tip: Hash, slot: Slot) -> LedgerState:
for state in iter_chain(tip, self.ledger_state):
if state.block.slot < slot:
return state
@ -540,7 +463,7 @@ class Follower:
def epoch_start_slot(self, epoch) -> Slot:
return Slot(epoch.epoch * self.config.epoch_length)
def stake_distribution_snapshot(self, epoch, tip: Id):
def stake_distribution_snapshot(self, epoch, tip: Hash):
# stake distribution snapshot happens at the beginning of the previous epoch,
# i.e. for epoch e, the snapshot is taken at the last block of epoch e-2
slot = Slot(epoch.prev().epoch * self.config.epoch_length)
@ -555,7 +478,7 @@ class Follower:
)
return self.state_at_slot_beginning(tip, slot)
def compute_epoch_state(self, epoch: Epoch, tip: Id) -> EpochState:
def compute_epoch_state(self, epoch: Epoch, tip: Hash) -> EpochState:
if epoch.epoch == 0:
return EpochState(
stake_distribution_snapshot=self.genesis_state,
@ -663,7 +586,7 @@ class Leader:
note: Note
def try_prove_slot_leader(
self, epoch: EpochState, slot: Slot, parent: Id
self, epoch: EpochState, slot: Slot, parent: Hash
) -> MockLeaderProof | None:
if self._is_slot_leader(epoch, slot):
return MockLeaderProof(self.note, slot, parent)
@ -679,7 +602,7 @@ class Leader:
def iter_chain(
tip: Id, states: Dict[Id, LedgerState]
tip: Hash, states: Dict[Hash, LedgerState]
) -> Generator[LedgerState, None, None]:
while tip in states:
yield states[tip]
@ -687,14 +610,14 @@ def iter_chain(
def iter_chain_blocks(
tip: Id, states: Dict[Id, LedgerState]
tip: Hash, states: Dict[Hash, LedgerState]
) -> Generator[BlockHeader, None, None]:
for state in iter_chain(tip, states):
yield state.block
def common_prefix_depth(
a: Id, b: Id, states: Dict[Id, LedgerState]
a: Hash, b: Hash, states: Dict[Hash, LedgerState]
) -> tuple[int, list[BlockHeader], int, list[BlockHeader]]:
return common_prefix_depth_from_chains(
iter_chain_blocks(a, states), iter_chain_blocks(b, states)
@ -752,7 +675,7 @@ def chain_density(chain: list[BlockHeader], slot: Slot) -> int:
return sum(1 for b in chain if b.slot < slot)
def block_children(states: Dict[Id, LedgerState]) -> Dict[Id, set[Id]]:
def block_children(states: Dict[Hash, LedgerState]) -> Dict[Hash, set[Hash]]:
children = defaultdict(set)
for c, state in states.items():
children[state.block.parent].add(c)
@ -768,14 +691,14 @@ def block_children(states: Dict[Id, LedgerState]) -> Dict[Id, set[Id]]:
# k defines the forking depth of a chain at which point we switch phases.
# s defines the length of time (unit of slots) after the fork happened we will inspect for chain density
def maxvalid_bg(
local_chain: Id,
forks: List[Id],
local_chain: Hash,
forks: List[Hash],
k: int,
s: int,
states: Dict[Id, LedgerState],
) -> Id:
assert type(local_chain) == Id
assert all(type(f) == Id for f in forks)
states: Dict[Hash, LedgerState],
) -> Hash:
assert type(local_chain) == Hash, type(local_chain)
assert all(type(f) == Hash for f in forks)
cmax = local_chain
for fork in forks:
@ -806,16 +729,6 @@ class ParentNotFound(Exception):
return "Parent not found"
class MissingOrphanProof(Exception):
def __str__(self):
return "Missing orphan proof"
class InvalidOrphanProof(Exception):
def __str__(self):
return "Invalid orphan proof"
class InvalidLeaderProof(Exception):
def __str__(self):
return "Invalid leader proof"

View File

@ -4,7 +4,7 @@ from typing import Generator
from cryptarchia.cryptarchia import (
BlockHeader,
Follower,
Id,
Hash,
ParentNotFound,
Slot,
common_prefix_depth_from_chains,
@ -19,7 +19,7 @@ def sync(local: Follower, peers: list[Follower]):
# Repeat the sync process until no peer has a tip ahead of the local tip,
# because peers' tips may advance during the sync process.
block_fetcher = BlockFetcher(peers)
rejected_blocks: set[Id] = set()
rejected_blocks: set[Hash] = set()
while True:
# Fetch blocks from the peers in the range of slots from the local tip to the latest tip.
# Gather orphaned blocks, which are blocks from forks that are absent in the local block tree.
@ -145,7 +145,7 @@ class BlockFetcher:
continue
def fetch_chain_backward(
self, tip: Id, local: Follower
self, tip: Hash, local: Follower
) -> Generator[BlockHeader, None, None]:
# Fetches a chain of blocks from the peers, starting from the given tip to the genesis.
# Attempts to extend the chain as much as possible by querying multiple peers,

View File

@ -7,6 +7,7 @@ from .cryptarchia import (
MockLeaderProof,
Leader,
Follower,
Hash,
)
@ -29,7 +30,6 @@ class TestNode:
return BlockHeader(
parent=parent,
slot=slot,
orphaned_proofs=self.follower.unimported_orphans(),
leader_proof=leader_proof,
content_size=0,
content_id=bytes(32),
@ -67,19 +67,17 @@ def mk_genesis_state(initial_stake_distribution: list[Note]) -> LedgerState:
def mk_block(
parent: BlockHeader, slot: int, note: Note, content=bytes(32), orphaned_proofs=[]
parent: BlockHeader, slot: int, note: Note, content=bytes(32)
) -> BlockHeader:
assert type(parent) == BlockHeader, type(parent)
assert type(slot) == int, type(slot)
from hashlib import sha256
return BlockHeader(
slot=Slot(slot),
parent=parent.id(),
content_size=len(content),
content_id=sha256(content).digest(),
content_id=Hash(b"CONTENT_ID", content),
leader_proof=MockLeaderProof(note, Slot(slot), parent=parent.id()),
orphaned_proofs=orphaned_proofs,
)

View File

@ -4,7 +4,6 @@ from .cryptarchia import (
Note,
Follower,
InvalidLeaderProof,
MissingOrphanProof,
ParentNotFound,
iter_chain,
)
@ -263,46 +262,3 @@ class TestLedgerStateUpdate(TestCase):
block_2_1 = mk_block(slot=40, parent=block_2_0, note=note_new.evolve())
follower.on_block(block_2_1)
assert follower.tip() == block_2_1
def test_orphaned_proofs(self):
note, note_orphan = Note(sk=0, value=100), Note(sk=1, value=100)
genesis = mk_genesis_state([note, note_orphan])
follower = Follower(genesis, mk_config([note, note_orphan]))
block_0_0 = mk_block(slot=0, parent=genesis.block, note=note)
follower.on_block(block_0_0)
assert follower.tip() == block_0_0
note_new = note.evolve()
note_new_new = note_new.evolve()
block_0_1 = mk_block(slot=1, parent=block_0_0, note=note_new_new)
with self.assertRaises(InvalidLeaderProof):
follower.on_block(block_0_1)
# the note evolved twice should not be accepted as it is not in the lead commitments
assert follower.tip() == block_0_0
# An orphaned proof will not be accepted until a node first sees the corresponding block.
#
# Also, notice that the block is using the evolved orphan note which is not present on the main
# branch. The evolved orphan commitment is added from the orphan prior to validating the block
# header as part of orphan importing process
orphan = mk_block(parent=genesis.block, slot=0, note=note_orphan)
block_0_1 = mk_block(
slot=1,
parent=block_0_0,
note=note_orphan.evolve(),
orphaned_proofs=[orphan],
)
with self.assertRaises(MissingOrphanProof):
follower.on_block(block_0_1)
# since follower had not seen this orphan prior to being included as
# an orphan proof, it will be rejected
assert follower.tip() == block_0_0
# but all is fine if the follower first sees the orphan block, and then
# is imported into the main chain
follower.on_block(orphan)
follower.on_block(block_0_1)
assert follower.tip() == block_0_1

View File

@ -1,251 +0,0 @@
from unittest import TestCase
from cryptarchia.cryptarchia import Note, Follower
from .test_common import mk_config, mk_genesis_state, mk_block
class TestOrphanedProofs(TestCase):
def test_simple_orphan_import(self):
n_a, n_b = Note(sk=0, value=10), Note(sk=1, value=10)
coins = [n_a, n_b]
config = mk_config(coins)
genesis = mk_genesis_state(coins)
follower = Follower(genesis, config)
# -- fork --
#
# b2 == tip
# /
# b1
# \
# b3
#
b1, n_a = mk_block(genesis.block, 1, n_a), n_a.evolve()
b2, n_a = mk_block(b1, 2, n_a), n_a.evolve()
b3, n_b = mk_block(b1, 2, n_b), n_b.evolve()
for b in [b1, b2, b3]:
follower.on_block(b)
assert follower.tip() == b2
assert [f for f in follower.forks] == [b3.id()]
assert follower.unimported_orphans() == [b3]
# -- extend with import --
#
# b2 - b4
# / /
# b1 /
# \ /
# b3
#
b4, n_a = mk_block(b2, 3, n_a, orphaned_proofs=[b3]), n_a.evolve()
follower.on_block(b4)
assert follower.tip() == b4
assert [f for f in follower.forks] == [b3.id()]
assert follower.unimported_orphans() == []
def test_orphan_proof_import_from_long_running_fork(self):
n_a, n_b = Note(sk=0, value=10), Note(sk=1, value=10)
coins = [n_a, n_b]
config = mk_config(coins)
genesis = mk_genesis_state(coins)
follower = Follower(genesis, config)
# -- fork --
#
# b2 - b3 == tip
# /
# b1
# \
# b4 - b5
#
b1, n_a = mk_block(genesis.block, 1, n_a), n_a.evolve()
b2, n_a = mk_block(b1, 2, n_a), n_a.evolve()
b3, n_a = mk_block(b2, 3, n_a), n_a.evolve()
b4, n_b = mk_block(b1, 2, n_b), n_b.evolve()
b5, n_b = mk_block(b4, 3, n_b), n_b.evolve()
for b in [b1, b2, b3, b4, b5]:
follower.on_block(b)
assert follower.tip() == b3
assert [f for f in follower.forks] == [b5.id()]
assert follower.unimported_orphans() == [b4, b5]
# -- extend b3, importing the fork --
#
# b2 - b3 - b6 == tip
# / ___/
# b1 ___/ /
# \ / /
# b4 - b5
b6, n_a = mk_block(b3, 4, n_a, orphaned_proofs=[b4, b5]), n_a.evolve()
follower.on_block(b6)
assert follower.tip() == b6
assert [f for f in follower.forks] == [b5.id()]
def test_orphan_proof_import_from_fork_without_direct_shared_parent(self):
coins = [Note(sk=i, value=10) for i in range(2)]
n_a, n_b = coins
config = mk_config(coins)
genesis = mk_genesis_state(coins)
follower = Follower(genesis, config)
# -- forks --
#
# b2 - b3 - b4 == tip
# /
# b1
# \
# b5 - b6 - b7
b1, n_a = mk_block(genesis.block, 1, n_a), n_a.evolve()
b2, n_a = mk_block(b1, 2, n_a), n_a.evolve()
b3, n_a = mk_block(b2, 3, n_a), n_a.evolve()
b4, n_a = mk_block(b3, 4, n_a), n_a.evolve()
b5, n_b = mk_block(b1, 2, n_b), n_b.evolve()
b6, n_b = mk_block(b5, 3, n_b), n_b.evolve()
b7, n_b = mk_block(b6, 4, n_b), n_b.evolve()
for b in [b1, b2, b3, b4, b5, b6, b7]:
follower.on_block(b)
assert follower.tip() == b4
assert [f for f in follower.forks] == [b7.id()]
assert follower.unimported_orphans() == [b5, b6, b7]
# -- extend b4, importing the forks --
#
# b2 - b3 - b4 - b8 == tip
# / _______/
# b1 ____/______/
# \ / / /
# b5 - b6 - b7
#
# Earlier implementations of orphan proof validation failed to
# validate b7 as an orphan here.
b8, n_a = mk_block(b4, 5, n_a, orphaned_proofs=[b5, b6, b7]), n_a.evolve()
follower.on_block(b8)
assert follower.tip() == b8
assert [f for f in follower.forks] == [b7.id()]
assert follower.unimported_orphans() == []
def test_unimported_orphans(self):
# Given the following fork graph:
#
# b2 - b3
# /
# b1
# \
# b4 - b5
# \
# -- b6
#
# Orphans w.r.t. to b3 are b4..6, thus extending from b3 with b7 would
# give the following fork graph
#
# b2 - b3 --- b7== tip
# / ____/
# b1 ____/ __/
# \ / / /
# b4 - b5 /
# \ /
# -- b6
#
coins = [Note(sk=i, value=10) for i in range(3)]
n_a, n_b, n_c = coins
config = mk_config(coins)
genesis = mk_genesis_state(coins)
follower = Follower(genesis, config)
b1, n_a = mk_block(genesis.block, 1, n_a), n_a.evolve()
b2, n_a = mk_block(b1, 2, n_a), n_a.evolve()
b3, n_a = mk_block(b2, 3, n_a), n_a.evolve()
b4, n_b = mk_block(b1, 2, n_b), n_b.evolve()
b5, n_b = mk_block(b4, 3, n_b), n_b.evolve()
b6, n_c = mk_block(b4, 3, n_c), n_c.evolve()
for b in [b1, b2, b3, b4, b5, b6]:
follower.on_block(b)
assert follower.tip() == b3
assert [f for f in follower.forks] == [b5.id(), b6.id()]
assert follower.unimported_orphans() == [b4, b5, b6]
b7, n_a = mk_block(b3, 4, n_a, orphaned_proofs=[b4, b5, b6]), n_a.evolve()
follower.on_block(b7)
assert follower.tip() == b7
def test_transitive_orphan_reimports(self):
# Two forks, one after the other, with some complicated orphan imports.
# I don't have different line colors to differentiate orphans from parents
# so I've added o=XX to differentiate orphans from parents.
#
# - The first fork at b3(a) is not too interesting.
# - The second fork at b4(b) has both b6 and b7 importing b5
# - crucially b7 uses the evolved commitment from b5
# - Then finally b8 imports b7.
#
# proper orphan proof importing will be able to deal with the fact that
# b7's commitment was produced outside of the main branch AND the commitment
# is not part of the current list of orphans in b8
# (b5 had already been imported, therefore it is not included as an orphan in b8)
#
# b1(a) - b2(a) - b3(a) - b4(b) - b6(b, o=b5) - b8(b, o=b7)
# \ \___ __/ __/
# \ _x_ __/
# \ / \_ /
# -b5(a)-----\-b7(a, o=b5)
coins = [Note(sk=i, value=10) for i in range(2)]
n_a, n_b = coins
config = mk_config(coins)
genesis = mk_genesis_state(coins)
follower = Follower(genesis, config)
b1, n_a = mk_block(genesis.block, 1, n_a), n_a.evolve()
b2, n_a = mk_block(b1, 2, n_a), n_a.evolve()
b3, n_a = mk_block(b2, 3, n_a), n_a.evolve()
b4, n_b = mk_block(b3, 4, n_b), n_b.evolve()
b5, n_a = mk_block(b3, 4, n_a), n_a.evolve()
b6, n_b = mk_block(b4, 5, n_b, orphaned_proofs=[b5]), n_b.evolve()
b7, n_a = mk_block(b4, 5, n_a, orphaned_proofs=[b5]), n_a.evolve()
b8, n_b = mk_block(b6, 6, n_b, orphaned_proofs=[b7]), n_b.evolve()
for b in [b1, b2, b3, b4, b5]:
follower.on_block(b)
assert follower.tip() == b4
assert follower.unimported_orphans() == [b5]
for b in [b6, b7]:
follower.on_block(b)
assert follower.tip() == b6
assert follower.unimported_orphans() == [b7]
follower.on_block(b8)
assert follower.tip() == b8
assert follower.unimported_orphans() == []

View File

@ -26,16 +26,16 @@ class TestStakeRelativization(TestCase):
assert follower.tip_state().leader_count == 1
# on fork, tip state is not updated
orphan = mk_block(genesis.block, slot=1, note=n_b)
follower.on_block(orphan)
fork = mk_block(genesis.block, slot=1, note=n_b)
follower.on_block(fork)
assert follower.tip_state().block == b1
assert follower.tip_state().leader_count == 1
# after orphan is adopted, leader count should jumpy by 2 (each orphan counts as a leader)
b2 = mk_block(b1, slot=2, note=n_a.evolve(), orphaned_proofs=[orphan])
# continuing the chain increments the leader count
b2 = mk_block(b1, slot=2, note=n_a.evolve())
follower.on_block(b2)
assert follower.tip_state().block == b2
assert follower.tip_state().leader_count == 3
assert follower.tip_state().leader_count == 2
def test_inference_on_empty_genesis_epoch(self):
note = Note(sk=0, value=10)