From 9702d457ceb64c8b56e93be0b31325120bb00489 Mon Sep 17 00:00:00 2001 From: Alice Date: Wed, 9 Sep 2026 05:33:06 +0700 Subject: [PATCH 1/4] fix: guard against zero stakes and mutable default rng in VRF Two bugs in get_validators_for_transaction(): 1. ZeroDivisionError when all stakes are zero (#1732): Added explicit check for total_stake == 0 with a descriptive ValueError before the probability calculation. 2. Mutable default argument for rng (#1733): Changed rng=np.random.default_rng(seed=...) to rng=None with lazy initialization inside the function. The previous default was evaluated once at import time and shared across all calls, making validator selection predictable. Fixes #1732 Fixes #1733 --- backend/consensus/vrf.py | 10 +++++++++- tests/unit/test_vrf.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/consensus/vrf.py b/backend/consensus/vrf.py index 55c953600..6135e0d2d 100644 --- a/backend/consensus/vrf.py +++ b/backend/consensus/vrf.py @@ -6,18 +6,26 @@ def get_validators_for_transaction( nodes: list[dict], num_validators: int | None = None, - rng=np.random.default_rng(seed=int(datetime.now().timestamp())), + rng: np.random.Generator | None = None, ) -> list[dict]: """ Returns subset of validators for a transaction. The selelction and order is given by a random sampling based on the stake of the validators. """ + if rng is None: + rng = np.random.default_rng(seed=int(datetime.now().timestamp())) + if num_validators is None: num_validators = DEFAULT_VALIDATORS_COUNT num_validators = min(num_validators, len(nodes)) total_stake = sum(validator["stake"] for validator in nodes) + if total_stake == 0: + raise ValueError( + "Cannot select validators: all stakes are zero. " + "At least one validator must have a positive stake." + ) probabilities = [validator["stake"] / total_stake for validator in nodes] selected_validators = rng.choice( diff --git a/tests/unit/test_vrf.py b/tests/unit/test_vrf.py index 95473770e..4760b3d35 100644 --- a/tests/unit/test_vrf.py +++ b/tests/unit/test_vrf.py @@ -1,5 +1,6 @@ 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: @@ -66,3 +67,21 @@ def choice_mock(a, p, size, replace): rng.choice.assert_called_once() assert validators == [{"stake": 3}, {"stake": 2}, {"stake": 1}] + + +def test_get_validators_zero_stake_raises(): + """When all stakes are zero, selection should raise ValueError.""" + nodes = [{"stake": 0}, {"stake": 0}] + with pytest.raises(ValueError, match="all stakes are zero"): + get_validators_for_transaction(nodes, 1) + + +def test_get_validators_rng_not_shared_between_calls(): + """Each call without an explicit rng should get a fresh generator.""" + nodes = [{"stake": 1}, {"stake": 2}, {"stake": 3}] + # Call twice without passing rng — the default must be created fresh each time. + v1 = get_validators_for_transaction(nodes, 1) + v2 = get_validators_for_transaction(nodes, 1) + # Both should succeed (no shared mutable state corruption). + assert len(v1) == 1 + assert len(v2) == 1 From c4d0666b7ad0ec482219f3ceb2ea58a47ec071eb Mon Sep 17 00:00:00 2001 From: Alice Date: Sat, 12 Sep 2026 04:40:50 +0700 Subject: [PATCH 2/4] fix: handle invalid address in consensus contract snapshot creation to_checksum_address() could throw on malformed addresses. The bare except Exception: pass silently swallowed the error, causing the function to continue with an invalid contract_address. This could lead to missed ContractSnapshot creation for valid deployments. Changed to catch only (ValueError, TypeError) and return None early, so callers can handle the invalid address case explicitly. --- backend/consensus/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/consensus/base.py b/backend/consensus/base.py index 846dd9279..240414635 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -337,8 +337,8 @@ def contract_snapshot_factory( """ try: contract_address = to_checksum_address(contract_address) - except Exception: - pass + except (ValueError, TypeError): + return None # Check if the transaction is a contract deployment and the contract address matches the transaction's to address if ( transaction.type == TransactionType.DEPLOY_CONTRACT From ebefab0a2fa4c2e793814d2590867cdd0c25ec03 Mon Sep 17 00:00:00 2001 From: Alice Date: Sat, 12 Sep 2026 07:10:59 +0700 Subject: [PATCH 3/4] fix: bare except catches SystemExit; silent address fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. worker_service.py:591 — bare 'except:' catches SystemExit and KeyboardInterrupt, preventing clean process termination. Changed to 'except Exception:'. 2. accounts_manager.py:47,69 — to_checksum_address() failures were silently swallowed, causing non-checksummed addresses to be used for DB lookups. This creates inconsistent address casing and potential duplicate entries. Now raises ValueError on failure. --- backend/consensus/worker_service.py | 2 +- backend/database_handler/accounts_manager.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/consensus/worker_service.py b/backend/consensus/worker_service.py index 6b41ea5e9..47cada199 100644 --- a/backend/consensus/worker_service.py +++ b/backend/consensus/worker_service.py @@ -588,7 +588,7 @@ def health_check(): "cpu_percent": round(process.cpu_percent(), 2), "memory_percent": round(process.memory_percent(), 2), } - except: + except Exception: pass # Probe local GenVM manager responsiveness. diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 6169b5171..c15dd06a2 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -45,7 +45,7 @@ def create_new_account_with_address(self, address: str) -> Account: try: address = to_checksum_address(address) except Exception: - pass + raise ValueError(f"Failed to checksum address: {address}") existing_account = ( self.session.query(CurrentState).filter(CurrentState.id == address).first() @@ -67,7 +67,7 @@ def get_account(self, account_address: str) -> CurrentState | None: try: normalized = to_checksum_address(account_address) except Exception: - normalized = account_address + raise ValueError(f"Failed to checksum address: {account_address}") account = ( self.session.query(CurrentState) .filter(CurrentState.id == normalized) From 7813b03db518580757a9f676c0b613735913d441 Mon Sep 17 00:00:00 2001 From: Alice Date: Sat, 12 Sep 2026 08:08:03 +0700 Subject: [PATCH 4/4] fix: RLP parser fails on calldata >= 255 bytes decode_method_call_data() assumed the RLP long list header is always 2 bytes (prefix + 1 byte length). When payload >= 256 bytes, the header becomes 3+ bytes (0xF8 + N where N = byte count of length). Fixed by calculating the actual number of length bytes from the prefix byte: num_length_bytes = raw_bytes[0] - 0xF7. Also added guard for empty input to prevent IndexError. Fixes #1745 --- backend/protocol_rpc/transactions_parser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 1a0f85126..517f407b9 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -604,13 +604,17 @@ def decode_method_send_data(self, data: str) -> DecodedMethodSendData: def decode_method_call_data(self, data: str) -> DecodedMethodCallData: raw_bytes = eth_utils.hexadecimal.decode_hex(data) + if len(raw_bytes) == 0: + return DecodedMethodCallData(b"") + # Remove the null byte if raw_bytes[-1] == 0: raw_bytes = raw_bytes[:-1] # Try to decode the outer list first if raw_bytes[0] >= 0xF8: # Long list - raw_bytes = raw_bytes[2:] # Skip list prefix and length + num_length_bytes = raw_bytes[0] - 0xF7 + raw_bytes = raw_bytes[1 + num_length_bytes:] # Skip prefix + length bytes elif raw_bytes[0] >= 0xC0: # Short list raw_bytes = raw_bytes[1:] # Skip list prefix