Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,29 @@ jobs:

- name: Fill test fixtures
run: uv run fill --fork=Devnet --clean -n auto

interop-tests:
name: Interop tests - Multi-node consensus
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout leanSpec
uses: actions/checkout@v4

- name: Install uv and Python 3.12
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"
python-version: "3.12"

- name: Run interop tests
run: |
uv run pytest tests/interop/ \
-v \
--timeout=120 \
-x \
--tb=short \
--log-cli-level=INFO
env:
LEAN_ENV: test
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,19 @@ addopts = [
# These are only run via the 'fill' command
"--ignore=tests/consensus",
"--ignore=tests/execution",
# Exclude interop tests from regular test runs
# Run explicitly with: uv run pytest tests/interop/ -v
"--ignore=tests/interop",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"valid_until: marks tests as valid until a specific fork version",
"interop: integration tests for multiple leanSpec nodes",
"num_validators: number of validators for interop test cluster",
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
timeout = 300

[tool.coverage.run]
source = ["src"]
Expand All @@ -128,6 +136,8 @@ test = [
"pytest>=8.3.3,<9",
"pytest-cov>=6.0.0,<7",
"pytest-xdist>=3.6.1,<4",
"pytest-asyncio>=0.24.0,<1",
"pytest-timeout>=2.2.0,<3",
"hypothesis>=6.138.14",
"lean-ethereum-testing",
"lean-multisig-py>=0.1.0",
Expand Down
2 changes: 1 addition & 1 deletion src/lean_spec/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ async def run_node(
elif validator_registry is not None:
logger.warning("No validators assigned to node %s", node_id)

event_source = LiveNetworkEventSource.create()
event_source = await LiveNetworkEventSource.create()

# Subscribe to gossip topics.
#
Expand Down
73 changes: 43 additions & 30 deletions src/lean_spec/subspecs/containers/state/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def process_slots(self, target_slot: Slot) -> "State":
# Work on a local variable. Do not mutate self.
state = self

# Step through each missing slot:
# Step through each missing slot.
while state.slot < target_slot:
# Per-Slot Housekeeping & Slot Increment
#
Expand All @@ -169,13 +169,18 @@ def process_slots(self, target_slot: Slot) -> "State":
#
# 2. Slot Increment:
# It always increments the slot number by one.
needs_state_root = state.latest_block_header.state_root == Bytes32.zero()
cached_state_root = (
hash_tree_root(state) if needs_state_root else state.latest_block_header.state_root
)

state = state.model_copy(
update={
"latest_block_header": (
state.latest_block_header.model_copy(
update={"state_root": hash_tree_root(state)}
update={"state_root": cached_state_root}
)
if state.latest_block_header.state_root == Bytes32.zero()
if needs_state_root
else state.latest_block_header
),
"slot": Slot(state.slot + Slot(1)),
Expand Down Expand Up @@ -435,25 +440,24 @@ def process_attestations(
if root not in root_to_slot or slot > root_to_slot[root]:
root_to_slot[root] = slot

# Process each attestation independently
# Process each attestation independently.
#
# Every attestation is a claim:
#
# "I vote to extend the chain from SOURCE to TARGET."
#
# The rules below filter out invalid or irrelevant votes.
for attestation in attestations:
source = attestation.data.source
target = attestation.data.target

# Check that the source is already trusted
# Check that the source is already trusted.
#
# A vote may only originate from a point in history that is already justified.
# A source that lacks existing justification cannot be used to anchor a new vote.
if not justified_slots.is_slot_justified(finalized_slot, source.slot):
continue

# Ignore votes for targets that have already reached consensus
# Ignore votes for targets that have already reached consensus.
#
# If a block is already justified, additional votes do not change anything.
# We simply skip them.
Expand All @@ -464,20 +468,30 @@ def process_attestations(
if source.root == ZERO_HASH or target.root == ZERO_HASH:
continue

# Ensure the vote refers to blocks that actually exist on our chain
# Ensure the vote refers to blocks that actually exist on our chain.
#
# The attestation must match our canonical chain.
# Both the source root and target root must equal the recorded block roots
# stored for those slots in history.
#
# This prevents votes about unknown or conflicting forks.
if (
source.root != self.historical_block_hashes[source.slot]
or target.root != self.historical_block_hashes[target.slot]
):
source_slot_int = int(source.slot)
target_slot_int = int(target.slot)
source_matches = (
source.root == self.historical_block_hashes[source_slot_int]
if source_slot_int < len(self.historical_block_hashes)
else False
)
target_matches = (
target.root == self.historical_block_hashes[target_slot_int]
if target_slot_int < len(self.historical_block_hashes)
else False
)

if not source_matches or not target_matches:
continue

# Ensure time flows forward
# Ensure time flows forward.
#
# A target must always lie strictly after its source slot.
# Otherwise the vote makes no chronological sense.
Expand All @@ -500,7 +514,7 @@ def process_attestations(
if not target.slot.is_justifiable_after(self.latest_finalized.slot):
continue

# Record the vote
# Record the vote.
#
# If this is the first vote for the target block, create a fresh tally sheet:
# - one boolean per validator, all initially False.
Expand All @@ -515,7 +529,7 @@ def process_attestations(
if not justifications[target.root][validator_id]:
justifications[target.root][validator_id] = Boolean(True)

# Check whether the vote count crosses the supermajority threshold
# Check whether the vote count crosses the supermajority threshold.
#
# A block becomes justified when more than two-thirds of validators
# have voted for it.
Expand Down Expand Up @@ -689,12 +703,12 @@ def build_block(
# Initialize empty attestation set for iterative collection.
attestations = list(attestations or [])

# Iteratively collect valid attestations using fixed-point algorithm
# Iteratively collect valid attestations using fixed-point algorithm.
#
# Continue until no new attestations can be added to the block.
# This ensures we include the maximal valid attestation set.
while True:
# Create candidate block with current attestation set
# Create candidate block with current attestation set.
candidate_block = Block(
slot=slot,
proposer_index=proposer_index,
Expand All @@ -707,14 +721,15 @@ def build_block(
),
)

# Apply state transition to get the post-block state
post_state = self.process_slots(slot).process_block(candidate_block)
# Apply state transition to get the post-block state.
slots_state = self.process_slots(slot)
post_state = slots_state.process_block(candidate_block)

# No attestation source provided: done after computing post_state
# No attestation source provided: done after computing post_state.
if available_attestations is None or known_block_roots is None:
break

# Find new valid attestations matching post-state justification
# Find new valid attestations matching post-state justification.
new_attestations: list[Attestation] = []

for attestation in available_attestations:
Expand All @@ -723,15 +738,15 @@ def build_block(
data_root = data.data_root_bytes()
sig_key = SignatureKey(validator_id, data_root)

# Skip if target block is unknown
# Skip if target block is unknown.
if data.head.root not in known_block_roots:
continue

# Skip if attestation source does not match post-state's latest justified
# Skip if attestation source does not match post-state's latest justified.
if data.source != post_state.latest_justified:
continue

# Avoid adding duplicates of attestations already in the candidate set
# Avoid adding duplicates of attestations already in the candidate set.
if attestation in attestations:
continue

Expand All @@ -746,22 +761,21 @@ def build_block(
if has_gossip_sig or has_block_proof:
new_attestations.append(attestation)

# Fixed point reached: no new attestations found
# Fixed point reached: no new attestations found.
if not new_attestations:
break

# Add new attestations and continue iteration
# Add new attestations and continue iteration.
attestations.extend(new_attestations)

# Compute the aggregated signatures for the attestations.
# If the attestations cannot be aggregated, split it in a greedy way.
aggregated_attestations, aggregated_signatures = self.compute_aggregated_signatures(
attestations,
gossip_signatures,
aggregated_payloads,
)

# Create the final block with aggregated attestations
# Create the final block with aggregated attestations.
final_block = Block(
slot=slot,
proposer_index=proposer_index,
Expand All @@ -774,9 +788,8 @@ def build_block(
),
)

# Recompute state from the final block
# Recompute state from the final block.
post_state = self.process_slots(slot).process_block(final_block)

final_block = final_block.model_copy(update={"state_root": hash_tree_root(post_state)})

return final_block, post_state, aggregated_attestations, aggregated_signatures
Expand Down
3 changes: 2 additions & 1 deletion src/lean_spec/subspecs/forkchoice/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ def update_safe_target(self) -> "Store":
# Calculate 2/3 majority threshold (ceiling division)
min_target_score = -(-num_validators * 2 // 3)

# Find head with minimum attestation threshold
# Find head with minimum attestation threshold.
safe_target = self._compute_lmd_ghost_head(
start_root=self.latest_justified.root,
attestations=self.latest_new_attestations,
Expand Down Expand Up @@ -986,6 +986,7 @@ def get_attestation_target(self) -> Checkpoint:

# Create checkpoint from selected target block
target_block = self.blocks[target_block_root]

return Checkpoint(root=hash_tree_root(target_block), slot=target_block.slot)

def produce_attestation_data(self, slot: Slot) -> AttestationData:
Expand Down
6 changes: 3 additions & 3 deletions src/lean_spec/subspecs/genesis/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ class GenesisConfig(StrictBaseModel):

num_validators: int | None = Field(default=None, alias="NUM_VALIDATORS")
"""
Number of validators (optional, for ream compatibility).
Number of validators (optional).

This field is informational and may be included in ream config files.
The actual validator count is derived from the genesis_validators list.
This field is informational and may be included in config files.
The actual validator count is derived from the genesis validator list.
"""

genesis_validators: list[Bytes52] = Field(alias="GENESIS_VALIDATORS")
Expand Down
Loading
Loading