From 47e90c749e6ce0b71d02b056be1e885ebd679724 Mon Sep 17 00:00:00 2001 From: HusseinAdeiza Date: Wed, 14 Oct 2026 11:26:54 +0200 Subject: [PATCH] fix(consensus): guard against zero total stake in VRF validator selection get_validators_for_transaction divided each validator's stake by the total stake without checking for zero. When all validators have stake=0 (e.g. internally-created validators in protocol_rpc/endpoints.py), this raised ZeroDivisionError and crashed the consensus worker, halting transaction processing. Fall back to a uniform probability distribution when the total stake is zero; non-zero totals are unaffected. Adds a unit test covering the zero-stake path. Closes #1732 Signed-off-by: HusseinAdeiza --- backend/consensus/vrf.py | 8 +++++++- tests/unit/test_vrf.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/consensus/vrf.py b/backend/consensus/vrf.py index 55c953600..c13daafcb 100644 --- a/backend/consensus/vrf.py +++ b/backend/consensus/vrf.py @@ -18,7 +18,13 @@ def get_validators_for_transaction( num_validators = min(num_validators, len(nodes)) total_stake = sum(validator["stake"] for validator in nodes) - probabilities = [validator["stake"] / total_stake for validator in nodes] + if total_stake == 0: + # All validators have zero stake (e.g. internally-created validators in + # protocol_rpc/endpoints.py). Dividing by zero crashed the consensus + # worker, so fall back to a uniform distribution across equal-stake nodes. + probabilities = [1 / len(nodes)] * len(nodes) + else: + probabilities = [validator["stake"] / total_stake for validator in nodes] selected_validators = rng.choice( nodes, diff --git a/tests/unit/test_vrf.py b/tests/unit/test_vrf.py index 95473770e..9c472438e 100644 --- a/tests/unit/test_vrf.py +++ b/tests/unit/test_vrf.py @@ -1,6 +1,8 @@ from backend.consensus.vrf import get_validators_for_transaction from unittest.mock import Mock +import pytest + def list_of_dicts_to_set(list_of_dicts: list[dict]) -> set: return set(map(lambda x: tuple(x.items()), list_of_dicts)) @@ -66,3 +68,27 @@ def choice_mock(a, p, size, replace): rng.choice.assert_called_once() assert validators == [{"stake": 3}, {"stake": 2}, {"stake": 1}] + + +def test_get_validators_for_transaction_all_zero_stake(): + """ + Tests that a uniform probability is used when the total stake is zero. + + Previously this divided by zero and crashed the consensus worker + (ZeroDivisionError). Internally-created validators with stake=0 make this a + reachable path, so equal-stake nodes must fall back to a uniform distribution. + """ + nodes = [{"stake": 0}, {"stake": 0}, {"stake": 0}] + + def choice_mock(a, p, size, replace): + assert p == [1 / 3, 1 / 3, 1 / 3] + assert sum(p) == pytest.approx(1.0) + return list(a) + + rng = Mock() + rng.choice.side_effect = choice_mock + + validators = get_validators_for_transaction(nodes, 3, rng=rng) + + rng.choice.assert_called_once() + assert validators == nodes