diff --git a/tests/core/pyspec/eth2spec/test/capella/unittests/__init__.py b/tests/core/pyspec/eth2spec/test/capella/unittests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/core/pyspec/eth2spec/test/capella/unittests/test_get_expected_withdrawals.py b/tests/core/pyspec/eth2spec/test/capella/unittests/test_get_expected_withdrawals.py new file mode 100644 index 0000000000..20ae7bb671 --- /dev/null +++ b/tests/core/pyspec/eth2spec/test/capella/unittests/test_get_expected_withdrawals.py @@ -0,0 +1,335 @@ +from eth2spec.test.context import ( + spec_state_test, + with_capella_and_later, +) +from eth2spec.test.helpers.withdrawals import ( + set_eth1_withdrawal_credential_with_balance, + set_validator_fully_withdrawable, +) +from tests.infra.helpers.withdrawals import ( + get_expected_withdrawals, + prepare_withdrawals, +) + +# +# Basic Tests +# + + +@with_capella_and_later +@spec_state_test +def test_no_withdrawals_no_withdrawal_credentials(spec, state): + """Validators with BLS_WITHDRAWAL_PREFIX credentials should not withdraw even with excess balance""" + + current_epoch = spec.get_current_epoch(state) + for i in range(len(state.validators)): + state.validators[i].withdrawable_epoch = current_epoch + state.validators[i].exit_epoch = current_epoch + state.balances[i] = spec.MAX_EFFECTIVE_BALANCE + spec.Gwei(10_000_000_000) + + assert state.validators[i].withdrawal_credentials[0:1] == spec.BLS_WITHDRAWAL_PREFIX + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 0 + + +@with_capella_and_later +@spec_state_test +def test_single_full_withdrawal(spec, state): + """One validator fully withdrawable should return one full withdrawal""" + validator_index = 0 + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[validator_index], + full_withdrawable_offsets=[0], # Immediate withdrawal + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 1 + assert withdrawals[0].validator_index == validator_index + assert withdrawals[0].amount == state.balances[validator_index] + + +@with_capella_and_later +@spec_state_test +def test_single_partial_withdrawal(spec, state): + """One validator with excess balance should return partial withdrawal""" + validator_index = 0 + excess_balance = spec.Gwei(1_000_000_000) # 1 ETH + + prepare_withdrawals( + spec, + state, + partial_withdrawal_indices=[validator_index], + partial_excess_balances=[excess_balance], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 1 + assert withdrawals[0].validator_index == validator_index + assert withdrawals[0].amount == excess_balance + + +@with_capella_and_later +@spec_state_test +def test_max_withdrawals_per_payload(spec, state): + """Should return exactly MAX_WITHDRAWALS_PER_PAYLOAD when more are eligible""" + num_withdrawals = 20 + + assert len(state.validators) >= num_withdrawals, ( + f"Test requires at least {num_withdrawals} validators" + ) + + withdrawal_indices = list(range(num_withdrawals)) + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=withdrawal_indices, + full_withdrawable_offsets=[0] * num_withdrawals, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == spec.MAX_WITHDRAWALS_PER_PAYLOAD + + +@with_capella_and_later +@spec_state_test +def test_withdrawal_index_wraparound(spec, state): + """Withdrawal validator index should wrap around to 0""" + assert len(state.validators) >= 3, "Test requires at least 3 validators for wraparound" + + state.next_withdrawal_validator_index = len(state.validators) - 2 + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[len(state.validators) - 1, 0, 1], + full_withdrawable_offsets=[0, 0, 0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 3 + validator_indices = [w.validator_index for w in withdrawals] + assert validator_indices == [len(state.validators) - 1, 0, 1], ( + "Should process validators in wraparound order" + ) + + +@with_capella_and_later +@spec_state_test +def test_validator_sweep_limit(spec, state): + """Should stop sweep at MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP validators""" + num_validators_to_setup = spec.MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP + 10 + + assert len(state.validators) >= num_validators_to_setup, ( + f"Test requires at least {num_validators_to_setup} validators" + ) + + state.next_withdrawal_validator_index = 0 + + withdrawal_indices = list(range(num_validators_to_setup)) + prepare_withdrawals( + spec, + state, + partial_withdrawal_indices=withdrawal_indices, + partial_excess_balances=[spec.Gwei(1_000_000_000)] * num_validators_to_setup, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + swept_indices = set(w.validator_index for w in withdrawals) + + for idx in range(spec.MAX_VALIDATORS_PER_WITHDRAWALS_SWEEP, num_validators_to_setup): + assert idx not in swept_indices, f"Validator {idx} should not be swept (beyond sweep limit)" + + +@with_capella_and_later +@spec_state_test +def test_mixed_full_and_partial_withdrawals(spec, state): + """Mix of full and partial withdrawals should process both correctly""" + full_indices = [0, 1] + partial_indices = [2, 3] + + required_validators = max(full_indices + partial_indices) + 1 + assert len(state.validators) >= required_validators, ( + f"Test requires at least {required_validators} validators" + ) + + state.next_withdrawal_validator_index = 0 + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=full_indices, + partial_withdrawal_indices=partial_indices, + full_withdrawable_offsets=[0, 0], + partial_excess_balances=[spec.Gwei(1_000_000_000)] * 2, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 4 + + full_withdrawals = [w for w in withdrawals if w.validator_index in full_indices] + partial_withdrawals = [w for w in withdrawals if w.validator_index in partial_indices] + + assert len(full_withdrawals) == 2 + assert len(partial_withdrawals) == 2 + + validator_indices = [w.validator_index for w in withdrawals] + assert validator_indices == sorted(validator_indices) + + +# Corner Cases Tests + + +@with_capella_and_later +@spec_state_test +def test_zero_balance_full_withdrawal(spec, state): + """Withdrawable validator with balance = 0 should be skipped""" + validator_index = 0 + + set_validator_fully_withdrawable(spec, state, validator_index) + state.balances[validator_index] = spec.Gwei(0) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 0 + + +@with_capella_and_later +@spec_state_test +def test_exact_max_effective_balance(spec, state): + """Balance exactly equals MAX_EFFECTIVE_BALANCE, no partial withdrawal""" + validator_index = 0 + + set_eth1_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE, + balance=spec.MAX_EFFECTIVE_BALANCE, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 0 + + +@with_capella_and_later +@spec_state_test +def test_one_gwei_excess_partial(spec, state): + """Balance = MAX_EFFECTIVE_BALANCE + 1 Gwei should withdraw exactly 1 Gwei""" + validator_index = 0 + + set_eth1_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE, + balance=spec.MAX_EFFECTIVE_BALANCE + spec.Gwei(1), + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 1 + assert withdrawals[0].validator_index == validator_index + assert withdrawals[0].amount == spec.Gwei(1) + + +@with_capella_and_later +@spec_state_test +def test_all_validators_withdrawable(spec, state): + """Every validator eligible should process only first MAX_WITHDRAWALS_PER_PAYLOAD""" + num_validators = min(len(state.validators), spec.MAX_WITHDRAWALS_PER_PAYLOAD + 5) + + assert len(state.validators) >= spec.MAX_WITHDRAWALS_PER_PAYLOAD + 1, ( + f"Test requires at least {spec.MAX_WITHDRAWALS_PER_PAYLOAD + 1} validators" + ) + + withdrawal_indices = list(range(num_validators)) + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=withdrawal_indices, + full_withdrawable_offsets=[0] * num_validators, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == spec.MAX_WITHDRAWALS_PER_PAYLOAD + + +@with_capella_and_later +@spec_state_test +def test_withdrawal_index_at_validator_set_boundary(spec, state): + """next_withdrawal_validator_index at len(validators) - 1 should wrap to 0""" + assert len(state.validators) >= 3, "Test requires at least 3 validators" + + state.next_withdrawal_validator_index = len(state.validators) - 1 + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[0, 1, 2], + full_withdrawable_offsets=[0, 0, 0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 3 + validator_indices = [w.validator_index for w in withdrawals] + assert validator_indices == [0, 1, 2] + + +# Edge Cases by Processing Phase - Validator Sweep + + +@with_capella_and_later +@spec_state_test +def test_partial_validator_sweep_index_update(spec, state): + """Process some validators, hit limit, verify index updates""" + mid_index = min(10, len(state.validators) - 1) + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[mid_index], + full_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 1 + assert withdrawals[0].validator_index == mid_index + + +@with_capella_and_later +@spec_state_test +def test_skip_validators_wrong_credentials(spec, state): + """Mix of BLS_WITHDRAWAL_PREFIX and ETH1_ADDRESS_WITHDRAWAL_PREFIX credentials, only ETH1 processed""" + assert len(state.validators) >= 2, "Test requires at least 2 validators" + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[1], + full_withdrawable_offsets=[0], + ) + + assert state.validators[0].withdrawal_credentials[0:1] == spec.BLS_WITHDRAWAL_PREFIX + assert state.validators[1].withdrawal_credentials[0:1] == spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 1 + assert withdrawals[0].validator_index == 1 diff --git a/tests/core/pyspec/eth2spec/test/electra/unittests/test_get_expected_withdrawals.py b/tests/core/pyspec/eth2spec/test/electra/unittests/test_get_expected_withdrawals.py new file mode 100644 index 0000000000..49bc0d4730 --- /dev/null +++ b/tests/core/pyspec/eth2spec/test/electra/unittests/test_get_expected_withdrawals.py @@ -0,0 +1,497 @@ +import pytest + +from eth2spec.test.context import ( + spec_state_test, + with_electra_and_later, +) +from eth2spec.test.helpers.withdrawals import ( + set_compounding_withdrawal_credential_with_balance, +) +from tests.infra.helpers.withdrawals import ( + get_expected_withdrawals, + prepare_withdrawals, +) + +# +# Pending Partial Withdrawals Tests +# + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_withdrawals_basic(spec, state): + """Pending partial withdrawals should be processed before validator sweep""" + assert len(state.validators) >= 2, "Test requires at least 2 validators" + + pending_index = 1 + sweep_index = 0 + pending_amount = spec.Gwei(1_000_000_000) + + state.next_withdrawal_validator_index = sweep_index + + prepare_withdrawals( + spec, + state, + pending_partial_indices=[pending_index], + pending_partial_amounts=[pending_amount], + pending_partial_withdrawable_offsets=[0], + partial_withdrawal_indices=[sweep_index], + partial_excess_balances=[spec.Gwei(2_000_000_000)], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 2 + assert withdrawals[0].validator_index == pending_index + assert withdrawals[0].amount == pending_amount + assert withdrawals[1].validator_index == sweep_index + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_max_per_sweep(spec, state): + """Should process only MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP pending partials per sweep""" + num_pending = spec.MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP + 4 + + assert len(state.validators) >= num_pending, f"Test requires at least {num_pending} validators" + + pending_indices = list(range(num_pending)) + + prepare_withdrawals( + spec, + state, + pending_partial_indices=pending_indices, + pending_partial_amounts=[spec.Gwei(1_000_000_000)] * num_pending, + pending_partial_withdrawable_offsets=[0] * num_pending, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + pending_withdrawals = [w for w in withdrawals if w.validator_index in pending_indices] + assert len(pending_withdrawals) == spec.MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP + + +@with_electra_and_later +@spec_state_test +def test_compounding_credentials(spec, state): + """Validator with COMPOUNDING_WITHDRAWAL_PREFIX credentials and max balance""" + validator_index = 0 + excess_balance = spec.Gwei(10_000_000_000) # 10 ETH excess + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + excess_balance, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 1 + assert withdrawals[0].validator_index == validator_index + assert withdrawals[0].amount == excess_balance + + +@with_electra_and_later +@spec_state_test +def test_multiple_withdrawals_same_validator(spec, state): + """Same validator appears multiple times in pending queue""" + validator_index = 0 + amount_1 = spec.Gwei(1_000_000_000) # 1 ETH + amount_2 = spec.Gwei(2_000_000_000) # 2 ETH + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + amount_1 + amount_2, + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index, + amount=amount_1, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index, + amount=amount_2, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + validator_withdrawals = [w for w in withdrawals if w.validator_index == validator_index] + assert len(validator_withdrawals) == 2 + total_withdrawn = sum(w.amount for w in validator_withdrawals) + assert total_withdrawn == amount_1 + amount_2 + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_exiting_validator_skipped(spec, state): + """Pending partial for exiting validator should be skipped""" + validator_index = 0 + withdrawal_amount = spec.Gwei(1_000_000_000) + + prepare_withdrawals( + spec, + state, + pending_partial_indices=[validator_index], + pending_partial_amounts=[withdrawal_amount], + pending_partial_withdrawable_offsets=[0], + ) + + state.validators[validator_index].exit_epoch = spec.get_current_epoch(state) + 1 + + withdrawals = get_expected_withdrawals(spec, state) + + validator_withdrawals = [w for w in withdrawals if w.validator_index == validator_index] + assert len(validator_withdrawals) == 0 + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_insufficient_balance(spec, state): + """Pending partial but balance < MIN_ACTIVATION_BALANCE should be skipped""" + validator_index = 0 + withdrawal_amount = spec.Gwei(1_000_000_000) + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MIN_ACTIVATION_BALANCE, + balance=spec.MIN_ACTIVATION_BALANCE - spec.Gwei(1), # Just below minimum + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index, + amount=withdrawal_amount, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + validator_withdrawals = [w for w in withdrawals if w.validator_index == validator_index] + assert len(validator_withdrawals) == 0 + + +# Corner Cases - Electra+ + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_exact_min_activation_balance(spec, state): + """Validator balance exactly MIN_ACTIVATION_BALANCE, withdrawable amount should be 0""" + validator_index = 0 + withdrawal_amount = spec.Gwei(1_000_000_000) + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MIN_ACTIVATION_BALANCE, + balance=spec.MIN_ACTIVATION_BALANCE, + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index, + amount=withdrawal_amount, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 0, ( + "No partial withdrawals should be processed when balance equals MIN_ACTIVATION_BALANCE" + ) + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_amount_exceeds_available(spec, state): + """Request 10 ETH, only 5 ETH available, should withdraw only 5 ETH""" + validator_index = 0 + requested_amount = spec.Gwei(10_000_000_000) # 10 ETH + available_amount = spec.Gwei(5_000_000_000) # 5 ETH + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MIN_ACTIVATION_BALANCE, + balance=spec.MIN_ACTIVATION_BALANCE + available_amount, + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index, + amount=requested_amount, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + validator_withdrawals = [w for w in withdrawals if w.validator_index == validator_index] + assert len(validator_withdrawals) == 1 + assert validator_withdrawals[0].amount == available_amount + + +@with_electra_and_later +@spec_state_test +def test_all_pending_partials_invalid(spec, state): + """All pending partials fail conditions, pending queue should not process them""" + assert len(state.validators) >= 3, "Test requires at least 3 validators" + + for i in range(3): + set_compounding_withdrawal_credential_with_balance( + spec, + state, + i, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + ) + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=i, + amount=spec.Gwei(1_000_000_000), + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + state.validators[i].exit_epoch = spec.get_current_epoch(state) + 1 + + withdrawals = get_expected_withdrawals(spec, state) + + for i in range(3): + assert not any(w.validator_index == i for w in withdrawals), ( + f"Validator {i} with pending partial and exit_epoch set should not withdraw" + ) + + +@with_electra_and_later +@spec_state_test +def test_pending_partials_and_sweep_together(spec, state): + """Pending partials processed first, then regular sweep fills remaining slots""" + num_pending = spec.MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP + sweep_validator_index = num_pending + + assert len(state.validators) >= num_pending + 1, ( + f"Test requires at least {num_pending + 1} validators" + ) + + state.next_withdrawal_validator_index = sweep_validator_index + + pending_indices = list(range(num_pending)) + pending_amounts = [spec.Gwei(1_000_000_000)] * num_pending + + prepare_withdrawals( + spec, + state, + pending_partial_indices=pending_indices, + pending_partial_amounts=pending_amounts, + pending_partial_withdrawable_offsets=[0] * num_pending, + partial_withdrawal_indices=[sweep_validator_index], + partial_excess_balances=[spec.Gwei(2_000_000_000)], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + pending_count = sum(1 for w in withdrawals if w.validator_index in pending_indices) + assert pending_count == spec.MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP, ( + "Should process all pending partials up to limit" + ) + + if pending_count < spec.MAX_WITHDRAWALS_PER_PAYLOAD: + assert sweep_validator_index in [w.validator_index for w in withdrawals], ( + "Regular sweep should fill remaining slots after pending partials" + ) + + +@with_electra_and_later +@spec_state_test +def test_validator_depleted_by_multiple_partials(spec, state): + """Multiple pending partials drain validator balance, later ones get reduced""" + validator_index = 0 + total_excess = spec.Gwei(5_000_000_000) # 5 ETH excess + amount_per_request = spec.Gwei(3_000_000_000) # 3 ETH each + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index, + effective_balance=spec.MIN_ACTIVATION_BALANCE, + balance=spec.MIN_ACTIVATION_BALANCE + total_excess, + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index, + amount=amount_per_request, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index, + amount=amount_per_request, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + validator_withdrawals = [w for w in withdrawals if w.validator_index == validator_index] + assert len(validator_withdrawals) == 2 + + assert validator_withdrawals[0].amount == amount_per_request + assert validator_withdrawals[1].amount == total_excess - amount_per_request + + total_withdrawn = sum(w.amount for w in validator_withdrawals) + assert total_withdrawn == total_excess + + +# Edge Cases + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_future_epoch(spec, state): + """withdrawable_epoch > current_epoch should break processing pending queue""" + validator_index_current = 10 + validator_index_future = 11 + validator_index_after = 12 + + assert len(state.validators) >= validator_index_after + 1, ( + f"Test requires at least {validator_index_after + 1} validators" + ) + + withdrawal_amount = spec.Gwei(1_000_000_000) + future_epoch = spec.get_current_epoch(state) + 10 + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index_current, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + withdrawal_amount, + ) + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index_future, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, # No excess + ) + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + validator_index_after, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, # No excess + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index_current, + amount=withdrawal_amount, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index_future, + amount=withdrawal_amount, + withdrawable_epoch=future_epoch, + ) + ) + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=validator_index_after, + amount=withdrawal_amount, + withdrawable_epoch=spec.get_current_epoch(state), # Current epoch, but after break + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + validator_withdrawals_current = [ + w for w in withdrawals if w.validator_index == validator_index_current + ] + validator_withdrawals_future = [ + w for w in withdrawals if w.validator_index == validator_index_future + ] + validator_withdrawals_after = [ + w for w in withdrawals if w.validator_index == validator_index_after + ] + + assert len(validator_withdrawals_current) == 1 # Should be processed + assert len(validator_withdrawals_future) == 0 # Future, should be skipped + assert len(validator_withdrawals_after) == 0 # After break, should be skipped + + +@with_electra_and_later +@spec_state_test +def test_pending_partial_invalid_validator_index(spec, state): + """Invalid validator index should raise IndexError""" + invalid_index = len(state.validators) + 10 + + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=invalid_index, + amount=spec.Gwei(1_000_000_000), + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + with pytest.raises(IndexError): + get_expected_withdrawals(spec, state) + + +@with_electra_and_later +@spec_state_test +def test_pending_queue_fifo_order(spec, state): + """Multiple pending entries should process in FIFO order""" + indices = [2, 0, 1] + required_validators = max(indices) + 1 + assert len(state.validators) >= required_validators, ( + f"Test requires at least {required_validators} validators" + ) + + for idx in indices: + withdrawal_amount = spec.Gwei(1_000_000_000) # 1 ETH + set_compounding_withdrawal_credential_with_balance( + spec, + state, + idx, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + withdrawal_amount, # Exact amount + ) + state.pending_partial_withdrawals.append( + spec.PendingPartialWithdrawal( + validator_index=idx, + amount=withdrawal_amount, + withdrawable_epoch=spec.get_current_epoch(state), + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + pending_withdrawals = withdrawals[: len(indices)] + withdrawal_indices = [w.validator_index for w in pending_withdrawals] + + assert withdrawal_indices == indices diff --git a/tests/core/pyspec/eth2spec/test/gloas/unittests/test_get_expected_withdrawals.py b/tests/core/pyspec/eth2spec/test/gloas/unittests/test_get_expected_withdrawals.py new file mode 100644 index 0000000000..b5970ed4c2 --- /dev/null +++ b/tests/core/pyspec/eth2spec/test/gloas/unittests/test_get_expected_withdrawals.py @@ -0,0 +1,594 @@ +from eth2spec.test.context import ( + spec_state_test, + with_gloas_and_later, +) +from eth2spec.test.helpers.withdrawals import ( + set_builder_withdrawal_credential_with_balance, + set_compounding_withdrawal_credential_with_balance, +) +from tests.infra.helpers.withdrawals import ( + get_expected_withdrawals, + prepare_withdrawals, +) + +# +# Builder Withdrawals Tests +# + + +@with_gloas_and_later +@spec_state_test +def test_builder_withdrawals_processed_first(spec, state): + """Builder withdrawals should be processed before pending/regular""" + builder_index = 0 + regular_index = 1 + + set_builder_withdrawal_credential_with_balance( + spec, + state, + builder_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + ) + + prepare_withdrawals( + spec, + state, + builder_indices=[builder_index], + builder_withdrawal_amounts=[spec.MIN_ACTIVATION_BALANCE], + builder_withdrawable_offsets=[0], + full_withdrawal_indices=[regular_index], + full_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 2 + assert withdrawals[0].validator_index == builder_index + assert withdrawals[0].amount == spec.MIN_ACTIVATION_BALANCE + assert withdrawals[1].validator_index == regular_index + + +@with_gloas_and_later +@spec_state_test +def test_builder_withdrawal_slashed_calculation(spec, state): + """Slashed builder vs non-slashed builder withdrawal calculation""" + slashed_index = 0 + non_slashed_index = 1 + withdrawal_amount = spec.Gwei(10_000_000_000) # 10 ETH + + set_builder_withdrawal_credential_with_balance( + spec, + state, + slashed_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MIN_ACTIVATION_BALANCE + withdrawal_amount, + ) + state.validators[slashed_index].slashed = True + + set_builder_withdrawal_credential_with_balance( + spec, + state, + non_slashed_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MIN_ACTIVATION_BALANCE + withdrawal_amount, + ) + state.validators[non_slashed_index].slashed = False + + prepare_withdrawals( + spec, + state, + builder_indices=[slashed_index, non_slashed_index], + builder_withdrawal_amounts=[withdrawal_amount, withdrawal_amount], + builder_withdrawable_offsets=[0, 0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 2 + + slashed_withdrawal = None + non_slashed_withdrawal = None + for w in withdrawals: + if w.validator_index == slashed_index: + slashed_withdrawal = w + elif w.validator_index == non_slashed_index: + non_slashed_withdrawal = w + + assert slashed_withdrawal is not None + assert non_slashed_withdrawal is not None + + assert slashed_withdrawal.amount == withdrawal_amount + + assert non_slashed_withdrawal.amount == withdrawal_amount + + +@with_gloas_and_later +@spec_state_test +def test_builder_withdrawal_insufficient_balance(spec, state): + """Builder with balance < MIN_ACTIVATION_BALANCE should skip""" + builder_index = 0 + withdrawal_amount = spec.Gwei(10_000_000_000) # 10 ETH + + set_builder_withdrawal_credential_with_balance( + spec, + state, + builder_index, + effective_balance=spec.MIN_ACTIVATION_BALANCE, + balance=spec.MIN_ACTIVATION_BALANCE - spec.Gwei(1), # Just below minimum + ) + + current_epoch = spec.get_current_epoch(state) + address = state.validators[builder_index].withdrawal_credentials[12:] + state.builder_pending_withdrawals.append( + spec.BuilderPendingWithdrawal( + fee_recipient=address, + amount=withdrawal_amount, + builder_index=builder_index, + withdrawable_epoch=current_epoch, + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + builder_withdrawals = [w for w in withdrawals if w.validator_index == builder_index] + assert len(builder_withdrawals) == 0 + + +@with_gloas_and_later +@spec_state_test +def test_builder_withdrawals_respect_max_limit(spec, state): + """When more builder withdrawals than MAX exist, should not exceed MAX total""" + num_builders = spec.MAX_WITHDRAWALS_PER_PAYLOAD * 2 + assert len(state.validators) >= num_builders, ( + f"Test requires at least {num_builders} validators" + ) + + for i in range(num_builders): + set_builder_withdrawal_credential_with_balance( + spec, + state, + i, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + ) + + prepare_withdrawals( + spec, + state, + builder_indices=list(range(num_builders)), + builder_withdrawal_amounts=[spec.MIN_ACTIVATION_BALANCE] * num_builders, + builder_withdrawable_offsets=[0] * num_builders, + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) <= spec.MAX_WITHDRAWALS_PER_PAYLOAD + + builder_withdrawals = [w for w in withdrawals if w.validator_index < num_builders] + assert len(builder_withdrawals) < num_builders + + +@with_gloas_and_later +@spec_state_test +def test_builder_uses_fee_recipient_address(spec, state): + """Builder withdrawal should use fee_recipient address""" + builder_index = 0 + custom_address = b"\xab" * 20 + + set_builder_withdrawal_credential_with_balance( + spec, + state, + builder_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + address=custom_address, + ) + + prepare_withdrawals( + spec, + state, + builder_indices=[builder_index], + builder_withdrawal_amounts=[spec.MIN_ACTIVATION_BALANCE], + builder_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + builder_withdrawal = next((w for w in withdrawals if w.validator_index == builder_index), None) + assert builder_withdrawal is not None + + assert builder_withdrawal.address == custom_address + + +# Corner Cases - Gloas Only + + +@with_gloas_and_later +@spec_state_test +def test_builder_and_pending_leave_room_for_sweep(spec, state): + """Builders + pending = MAX-1, exactly 1 slot remains for sweep""" + assert spec.MAX_WITHDRAWALS_PER_PAYLOAD >= 3, ( + "Test requires MAX_WITHDRAWALS_PER_PAYLOAD to be at least 3" + ) + + num_builders = 2 if spec.MAX_WITHDRAWALS_PER_PAYLOAD >= 4 else 1 + num_pending = min( + spec.MAX_PENDING_PARTIALS_PER_WITHDRAWALS_SWEEP, + spec.MAX_WITHDRAWALS_PER_PAYLOAD - num_builders - 1, + ) + + for i in range(num_builders): + set_builder_withdrawal_credential_with_balance( + spec, + state, + i, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + ) + + if num_builders > 0: + prepare_withdrawals( + spec, + state, + builder_indices=list(range(num_builders)), + builder_withdrawal_amounts=[spec.MIN_ACTIVATION_BALANCE] * num_builders, + builder_withdrawable_offsets=[0] * num_builders, + ) + + if num_pending > 0: + pending_indices = list(range(num_builders, num_builders + num_pending)) + prepare_withdrawals( + spec, + state, + pending_partial_indices=pending_indices, + pending_partial_amounts=[spec.Gwei(1_000_000_000)] * num_pending, + pending_partial_withdrawable_offsets=[0] * num_pending, + ) + + regular_index = num_builders + num_pending + 1 + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[regular_index], + full_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == num_builders + num_pending + 1, ( + "Should process all builders, all pending, and exactly 1 sweep withdrawal" + ) + + regular_withdrawals = [w for w in withdrawals if w.validator_index == regular_index] + assert len(regular_withdrawals) == 1, "Exactly 1 slot should remain for sweep withdrawal" + + +@with_gloas_and_later +@spec_state_test +def test_all_builder_withdrawals_invalid(spec, state): + """All builders have insufficient balance, should process pending/regular instead""" + current_epoch = spec.get_current_epoch(state) + + for i in range(2): + set_builder_withdrawal_credential_with_balance( + spec, + state, + i, + effective_balance=spec.MIN_ACTIVATION_BALANCE, + balance=spec.MIN_ACTIVATION_BALANCE - spec.Gwei(1), + ) + address = state.validators[i].withdrawal_credentials[12:] + state.builder_pending_withdrawals.append( + spec.BuilderPendingWithdrawal( + fee_recipient=address, + amount=spec.MIN_ACTIVATION_BALANCE, + builder_index=i, + withdrawable_epoch=current_epoch, + ) + ) + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[5], + full_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert any(w.validator_index == 5 for w in withdrawals), ( + "Regular sweep withdrawal should be processed" + ) + + for i in range(2): + assert not any(w.validator_index == i for w in withdrawals), ( + f"Builder {i} with insufficient balance should not withdraw" + ) + + +@with_gloas_and_later +@spec_state_test +def test_builder_slashed_zero_balance(spec, state): + """Slashed builder with 0 balance should skip""" + builder_index = 0 + current_epoch = spec.get_current_epoch(state) + + set_builder_withdrawal_credential_with_balance( + spec, + state, + builder_index, + effective_balance=spec.Gwei(0), + balance=spec.Gwei(0), + ) + state.validators[builder_index].slashed = True + + address = state.validators[builder_index].withdrawal_credentials[12:] + state.builder_pending_withdrawals.append( + spec.BuilderPendingWithdrawal( + fee_recipient=address, + amount=spec.MIN_ACTIVATION_BALANCE, + builder_index=builder_index, + withdrawable_epoch=current_epoch, + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + builder_withdrawals = [w for w in withdrawals if w.validator_index == builder_index] + assert len(builder_withdrawals) == 0 + + +@with_gloas_and_later +@spec_state_test +def test_mixed_all_three_withdrawal_types(spec, state): + """Builder + pending + regular, verify priority order""" + builder_index = 0 + pending_index = 1 + regular_index = 2 + + set_builder_withdrawal_credential_with_balance( + spec, + state, + builder_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + ) + + prepare_withdrawals( + spec, + state, + builder_indices=[builder_index], + builder_withdrawal_amounts=[spec.MIN_ACTIVATION_BALANCE], + builder_withdrawable_offsets=[0], + ) + + prepare_withdrawals( + spec, + state, + pending_partial_indices=[pending_index], + pending_partial_amounts=[spec.Gwei(1_000_000_000)], + pending_partial_withdrawable_offsets=[0], + ) + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[regular_index], + full_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == 3 + + builder_pos = next( + (i for i, w in enumerate(withdrawals) if w.validator_index == builder_index), None + ) + pending_pos = next( + (i for i, w in enumerate(withdrawals) if w.validator_index == pending_index), None + ) + regular_pos = next( + (i for i, w in enumerate(withdrawals) if w.validator_index == regular_index), None + ) + + assert builder_pos is not None + assert pending_pos is not None + assert regular_pos is not None + + assert builder_pos < pending_pos + assert pending_pos < regular_pos + + +@with_gloas_and_later +@spec_state_test +def test_builder_max_minus_one_plus_one_regular(spec, state): + """Exactly MAX-1 builder withdrawals should add exactly 1 regular withdrawal""" + num_builders = spec.MAX_WITHDRAWALS_PER_PAYLOAD - 1 + + for i in range(num_builders): + set_builder_withdrawal_credential_with_balance( + spec, + state, + i, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + ) + + prepare_withdrawals( + spec, + state, + builder_indices=list(range(num_builders)), + builder_withdrawal_amounts=[spec.MIN_ACTIVATION_BALANCE] * num_builders, + builder_withdrawable_offsets=[0] * num_builders, + ) + + regular_indices = [num_builders + 1, num_builders + 2, num_builders + 3] + assert len(state.validators) >= max(regular_indices) + 1, ( + f"Test requires at least {max(regular_indices) + 1} validators" + ) + + state.next_withdrawal_validator_index = regular_indices[0] + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=regular_indices, + full_withdrawable_offsets=[0] * len(regular_indices), + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == spec.MAX_WITHDRAWALS_PER_PAYLOAD + + builder_withdrawals = [w for w in withdrawals if w.validator_index < num_builders] + assert len(builder_withdrawals) == num_builders + + regular_withdrawals = [w for w in withdrawals if w.validator_index in regular_indices] + assert len(regular_withdrawals) == 1, ( + "Should process exactly 1 regular withdrawal when builders fill MAX-1 slots" + ) + assert regular_withdrawals[0].validator_index == regular_indices[0], ( + "Should process the first regular withdrawal in sweep order" + ) + + +# Builder Processing Edge Cases + + +@with_gloas_and_later +@spec_state_test +def test_builder_wrong_credentials_still_processes(spec, state): + """Builder pending withdrawal processes even with non-BUILDER_WITHDRAWAL_PREFIX (no validation in get_expected_withdrawals)""" + builder_index = 0 + regular_index = 1 + current_epoch = spec.get_current_epoch(state) + + set_compounding_withdrawal_credential_with_balance( + spec, + state, + builder_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + ) + + assert ( + state.validators[builder_index].withdrawal_credentials[0:1] + != spec.BUILDER_WITHDRAWAL_PREFIX + ), "Validator should have non-builder credentials (0x02 compounding) for this test" + + address = state.validators[builder_index].withdrawal_credentials[12:] + state.builder_pending_withdrawals.append( + spec.BuilderPendingWithdrawal( + fee_recipient=address, + amount=spec.MIN_ACTIVATION_BALANCE, + builder_index=builder_index, + withdrawable_epoch=current_epoch, + ) + ) + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[regular_index], + full_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + builder_withdrawals = [w for w in withdrawals if w.validator_index == builder_index] + regular_withdrawals = [w for w in withdrawals if w.validator_index == regular_index] + + assert len(builder_withdrawals) == 1, ( + f"Builder withdrawal IS processed even with wrong credentials (0x{state.validators[builder_index].withdrawal_credentials[0:1].hex()}) - no validation in get_expected_withdrawals" + ) + assert len(regular_withdrawals) == 1, "Regular withdrawal should also be processed" + + +@with_gloas_and_later +@spec_state_test +def test_builder_zero_withdrawal_amount(spec, state): + """Builder withdrawal with amount = 0 should skip""" + builder_index = 0 + current_epoch = spec.get_current_epoch(state) + + set_builder_withdrawal_credential_with_balance( + spec, + state, + builder_index, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, # No excess + ) + + address = state.validators[builder_index].withdrawal_credentials[12:] + state.builder_pending_withdrawals.append( + spec.BuilderPendingWithdrawal( + fee_recipient=address, + amount=spec.Gwei(0), + builder_index=builder_index, + withdrawable_epoch=current_epoch, + ) + ) + + withdrawals = get_expected_withdrawals(spec, state) + + builder_withdrawals = [w for w in withdrawals if w.validator_index == builder_index] + assert len(builder_withdrawals) == 0 + + +@with_gloas_and_later +@spec_state_test +def test_builder_max_capacity_no_room_others(spec, state): + """Exactly MAX builder withdrawals should fill all slots, no room for pending/regular""" + num_builders = spec.MAX_WITHDRAWALS_PER_PAYLOAD + + assert len(state.validators) >= num_builders + 2, ( + f"Test requires at least {num_builders + 2} validators" + ) + + for i in range(num_builders): + set_builder_withdrawal_credential_with_balance( + spec, + state, + i, + effective_balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA, + balance=spec.MAX_EFFECTIVE_BALANCE_ELECTRA + spec.MIN_ACTIVATION_BALANCE, + ) + + prepare_withdrawals( + spec, + state, + builder_indices=list(range(num_builders)), + builder_withdrawal_amounts=[spec.MIN_ACTIVATION_BALANCE] * num_builders, + builder_withdrawable_offsets=[0] * num_builders, + ) + + prepare_withdrawals( + spec, + state, + pending_partial_indices=[num_builders], + pending_partial_amounts=[spec.Gwei(1_000_000_000)], + pending_partial_withdrawable_offsets=[0], + ) + + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[num_builders + 1], + full_withdrawable_offsets=[0], + ) + + withdrawals = get_expected_withdrawals(spec, state) + + assert len(withdrawals) == spec.MAX_WITHDRAWALS_PER_PAYLOAD, ( + "Should return exactly MAX withdrawals when builders fill all slots" + ) + + builder_withdrawals = [w for w in withdrawals if w.validator_index < num_builders] + assert len(builder_withdrawals) == num_builders, "All withdrawals should be builders only" + + pending_withdrawals = [w for w in withdrawals if w.validator_index == num_builders] + regular_withdrawals = [w for w in withdrawals if w.validator_index == num_builders + 1] + + assert len(pending_withdrawals) == 0, "No room for pending partials when builders fill MAX" + assert len(regular_withdrawals) == 0, "No room for regular withdrawals when builders fill MAX" diff --git a/tests/infra/helpers/test_withdrawals.py b/tests/infra/helpers/test_withdrawals.py index 85e5eab588..c0ad9c2547 100644 --- a/tests/infra/helpers/test_withdrawals.py +++ b/tests/infra/helpers/test_withdrawals.py @@ -2,7 +2,7 @@ import pytest -from tests.infra.helpers.withdrawals import verify_withdrawals_post_state +from tests.infra.helpers.withdrawals import prepare_withdrawals, verify_withdrawals_post_state class TestVerifyWithdrawalsPostState: @@ -188,3 +188,242 @@ def test_withdrawal_with_balance_verification(self): partial_withdrawals_indices=partial_withdrawals_indices, pending_withdrawal_requests=None, ) + + +class TestPrepareWithdrawals: + """Test suite for prepare_withdrawals function""" + + def test_prepare_builder_withdrawals(self): + """Test setting up builder pending withdrawals""" + # Mock spec + spec = MagicMock() + spec.MIN_ACTIVATION_BALANCE = 32_000_000_000 + spec.Gwei = int + spec.get_current_epoch.return_value = 100 + spec.fork = "gloas" + spec.has_builder_withdrawal_credential.return_value = True + spec.BuilderPendingWithdrawal = MagicMock(side_effect=lambda **kwargs: kwargs) + + # Mock state + state = MagicMock() + state.builder_pending_withdrawals = [] + state.validators = [MagicMock() for _ in range(10)] + state.validators[0].withdrawal_credentials = b"\x03" + b"\x00" * 11 + b"\x42" * 20 + state.validators[1].withdrawal_credentials = b"\x03" + b"\x00" * 11 + b"\x43" * 20 + state.balances = [100_000_000_000] * 10 # Plenty of balance + + with patch("tests.infra.helpers.withdrawals.is_post_gloas", return_value=True): + with patch("tests.infra.helpers.withdrawals.is_post_electra", return_value=True): + prepare_withdrawals( + spec, + state, + builder_indices=[0, 1], + builder_withdrawal_amounts=[1_000_000_000, 2_000_000_000], + ) + + # Verify two builder pending withdrawals were added + assert len(state.builder_pending_withdrawals) == 2 + assert state.builder_pending_withdrawals[0]["amount"] == 1_000_000_000 + assert state.builder_pending_withdrawals[0]["builder_index"] == 0 + assert state.builder_pending_withdrawals[1]["amount"] == 2_000_000_000 + assert state.builder_pending_withdrawals[1]["builder_index"] == 1 + + def test_prepare_pending_partial_withdrawals(self): + """Test setting up pending partial withdrawals""" + # Mock spec + spec = MagicMock() + spec.Gwei = int + spec.fork = "electra" + spec.get_current_epoch.return_value = 100 + spec.COMPOUNDING_WITHDRAWAL_PREFIX = b"\x02" + spec.MAX_EFFECTIVE_BALANCE_ELECTRA = 2048_000_000_000 + spec.EFFECTIVE_BALANCE_INCREMENT = 1_000_000_000 + spec.has_compounding_withdrawal_credential.return_value = True + spec.PendingPartialWithdrawal = MagicMock(side_effect=lambda **kwargs: kwargs) + + # Mock state + state = MagicMock() + state.pending_partial_withdrawals = [] + state.validators = [MagicMock() for _ in range(10)] + state.balances = [0] * 10 + + # Mock validators with compounding credentials + for v in state.validators: + v.withdrawal_credentials = b"\x02" + b"\x00" * 31 + v.effective_balance = 0 + + with patch("tests.infra.helpers.withdrawals.is_post_gloas", return_value=False): + with patch("tests.infra.helpers.withdrawals.is_post_electra", return_value=True): + prepare_withdrawals( + spec, + state, + pending_partial_indices=[2, 3], + pending_partial_amounts=[500_000_000, 600_000_000], + ) + + # Verify two pending partial withdrawals were added to state + assert len(state.pending_partial_withdrawals) == 2 + + # Verify withdrawal details + assert state.pending_partial_withdrawals[0]["validator_index"] == 2 + assert state.pending_partial_withdrawals[0]["amount"] == 500_000_000 + assert state.pending_partial_withdrawals[0]["withdrawable_epoch"] == 100 + + assert state.pending_partial_withdrawals[1]["validator_index"] == 3 + assert state.pending_partial_withdrawals[1]["amount"] == 600_000_000 + assert state.pending_partial_withdrawals[1]["withdrawable_epoch"] == 100 + + # Verify validator balances were set correctly + assert state.balances[2] == 32_000_000_000 + 500_000_000 # effective_balance + amount + assert state.balances[3] == 32_000_000_000 + 600_000_000 + + def test_prepare_full_and_partial_withdrawals(self): + """Test setting up full and partial withdrawals""" + # Mock spec + spec = MagicMock() + spec.Gwei = int + spec.fork = "capella" + spec.get_current_epoch.return_value = 100 + spec.BLS_WITHDRAWAL_PREFIX = b"\x00" + spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX = b"\x01" + spec.MAX_EFFECTIVE_BALANCE = 32_000_000_000 + spec.EFFECTIVE_BALANCE_INCREMENT = 1_000_000_000 + spec.FAR_FUTURE_EPOCH = 2**64 - 1 + spec.is_fully_withdrawable_validator.return_value = True + spec.is_partially_withdrawable_validator.return_value = True + spec.has_compounding_withdrawal_credential.return_value = False + + # Mock state + state = MagicMock() + state.validators = [MagicMock() for _ in range(10)] + state.balances = [0] * 10 + + # Set up validators with BLS credentials (will be converted to ETH1) + for v in state.validators: + v.withdrawal_credentials = b"\x00" + b"\x00" * 31 + v.effective_balance = 0 + v.exit_epoch = spec.FAR_FUTURE_EPOCH + v.withdrawable_epoch = spec.FAR_FUTURE_EPOCH + + with patch("tests.infra.helpers.withdrawals.is_post_gloas", return_value=False): + with patch("tests.infra.helpers.withdrawals.is_post_electra", return_value=False): + prepare_withdrawals( + spec, + state, + full_withdrawal_indices=[4, 5], + partial_withdrawal_indices=[6, 7], + partial_excess_balances=[1_000_000_000, 2_000_000_000], + ) + + # Verify full withdrawals: validators should be withdrawable + assert state.validators[4].withdrawable_epoch == 100 + assert state.validators[4].exit_epoch <= 100 + assert state.validators[4].withdrawal_credentials[0:1] == b"\x01" # ETH1 prefix + assert state.balances[4] == 10_000_000_000 # Default balance set + + assert state.validators[5].withdrawable_epoch == 100 + assert state.validators[5].exit_epoch <= 100 + assert state.validators[5].withdrawal_credentials[0:1] == b"\x01" + assert state.balances[5] == 10_000_000_000 + + # Verify partial withdrawals: validators should have excess balance + assert state.validators[6].withdrawal_credentials[0:1] == b"\x01" # ETH1 prefix + assert state.balances[6] == spec.MAX_EFFECTIVE_BALANCE + 1_000_000_000 + assert state.validators[6].effective_balance == spec.MAX_EFFECTIVE_BALANCE + + assert state.validators[7].withdrawal_credentials[0:1] == b"\x01" + assert state.balances[7] == spec.MAX_EFFECTIVE_BALANCE + 2_000_000_000 + assert state.validators[7].effective_balance == spec.MAX_EFFECTIVE_BALANCE + + def test_prepare_withdrawals_builder_insufficient_balance(self): + """Test that insufficient balance for builder raises assertion""" + # Mock spec + spec = MagicMock() + spec.MIN_ACTIVATION_BALANCE = 32_000_000_000 + spec.Gwei = int + spec.get_current_epoch.return_value = 100 + spec.has_builder_withdrawal_credential.return_value = True + + # Mock state with insufficient balance + state = MagicMock() + state.builder_pending_withdrawals = [] + state.validators = [MagicMock() for _ in range(10)] + state.validators[0].withdrawal_credentials = b"\x03" + b"\x00" * 11 + b"\x42" * 20 + state.balances = [1_000_000_000] * 10 # Insufficient balance + + with patch("tests.infra.helpers.withdrawals.is_post_gloas", return_value=True): + with pytest.raises(AssertionError, match="needs balance"): + prepare_withdrawals( + spec, + state, + builder_indices=[0], + builder_withdrawal_amounts=[10_000_000_000], + ) + + def test_prepare_withdrawals_with_future_epochs(self): + """Test setting up withdrawals with future withdrawable epochs""" + # Mock spec + spec = MagicMock() + spec.MIN_ACTIVATION_BALANCE = 32_000_000_000 + spec.Gwei = int + spec.get_current_epoch.return_value = 100 + spec.has_builder_withdrawal_credential.return_value = True + spec.BuilderPendingWithdrawal = MagicMock(side_effect=lambda **kwargs: kwargs) + spec.BLS_WITHDRAWAL_PREFIX = b"\x00" + spec.ETH1_ADDRESS_WITHDRAWAL_PREFIX = b"\x01" + spec.MAX_EFFECTIVE_BALANCE = 32_000_000_000 + spec.EFFECTIVE_BALANCE_INCREMENT = 1_000_000_000 + spec.FAR_FUTURE_EPOCH = 2**64 - 1 + spec.COMPOUNDING_WITHDRAWAL_PREFIX = b"\x02" + spec.MAX_EFFECTIVE_BALANCE_ELECTRA = 2048_000_000_000 + spec.has_compounding_withdrawal_credential.return_value = True + spec.PendingPartialWithdrawal = MagicMock(side_effect=lambda **kwargs: kwargs) + spec.is_fully_withdrawable_validator.return_value = True + spec.fork = "gloas" # Set the fork to gloas + + # Mock state + state = MagicMock() + state.builder_pending_withdrawals = [] + state.pending_partial_withdrawals = [] + state.validators = [MagicMock() for _ in range(10)] + state.balances = [100_000_000_000] * 10 # Plenty of balance + + # Set up validators with appropriate credentials + for i, v in enumerate(state.validators): + if i < 3: # First 3 for builder withdrawals + v.withdrawal_credentials = b"\x03" + b"\x00" * 11 + bytes([0x42 + i]) + b"\x00" * 19 + else: # Others for partial withdrawals + v.withdrawal_credentials = b"\x02" + b"\x00" * 31 + v.effective_balance = 0 + v.exit_epoch = spec.FAR_FUTURE_EPOCH + v.withdrawable_epoch = spec.FAR_FUTURE_EPOCH + + with patch("tests.infra.helpers.withdrawals.is_post_gloas", return_value=True): + with patch("tests.infra.helpers.withdrawals.is_post_electra", return_value=True): + prepare_withdrawals( + spec, + state, + builder_indices=[0, 1, 2], + builder_withdrawal_amounts=[1_000_000_000, 2_000_000_000, 3_000_000_000], + builder_withdrawable_offsets=[0, 5, 10], # Current, +5 epochs, +10 epochs + pending_partial_indices=[3, 4], + pending_partial_amounts=[500_000_000, 600_000_000], + pending_partial_withdrawable_offsets=[2, 7], # +2 epochs, +7 epochs + full_withdrawal_indices=[5, 6], + full_withdrawable_offsets=[3, 15], # +3 epochs, +15 epochs + ) + + # Verify builder pending withdrawals with future epochs + assert len(state.builder_pending_withdrawals) == 3 + assert state.builder_pending_withdrawals[0]["withdrawable_epoch"] == 100 # Current epoch + assert state.builder_pending_withdrawals[1]["withdrawable_epoch"] == 105 # +5 epochs + assert state.builder_pending_withdrawals[2]["withdrawable_epoch"] == 110 # +10 epochs + + # Verify pending partial withdrawals with future epochs + assert len(state.pending_partial_withdrawals) == 2 + assert state.pending_partial_withdrawals[0]["withdrawable_epoch"] == 102 # +2 epochs + assert state.pending_partial_withdrawals[1]["withdrawable_epoch"] == 107 # +7 epochs + + # Verify full withdrawals with future epochs + assert state.validators[5].withdrawable_epoch == 103 # +3 epochs + assert state.validators[6].withdrawable_epoch == 115 # +15 epochs diff --git a/tests/infra/helpers/withdrawals.py b/tests/infra/helpers/withdrawals.py index d55d3f35a3..609a8b3783 100644 --- a/tests/infra/helpers/withdrawals.py +++ b/tests/infra/helpers/withdrawals.py @@ -1,4 +1,150 @@ -from tests.core.pyspec.eth2spec.test.helpers.forks import is_post_electra, is_post_gloas +from eth2spec.test.helpers.forks import is_post_electra, is_post_gloas + + +# Import needed helper functions from test helpers (avoiding circular import) +def _import_test_helpers(): + from tests.core.pyspec.eth2spec.test.helpers.withdrawals import ( # noqa: PLC0415 + prepare_pending_withdrawal, + set_validator_fully_withdrawable, + set_validator_partially_withdrawable, + ) + + return ( + prepare_pending_withdrawal, + set_validator_fully_withdrawable, + set_validator_partially_withdrawable, + ) + + +def prepare_withdrawals( + spec, + state, + builder_indices=[], + pending_partial_indices=[], + full_withdrawal_indices=[], + partial_withdrawal_indices=[], + builder_withdrawal_amounts=None, + pending_partial_amounts=None, + partial_excess_balances=None, + builder_withdrawable_offsets=None, + pending_partial_withdrawable_offsets=None, + full_withdrawable_offsets=None, +): + """ + Populate the state with all three types of withdrawals based on configuration. + + Note: For builder withdrawals, validators must already have builder withdrawal credentials + set (0x03 prefix). Use set_builder_withdrawal_credential() or + set_builder_withdrawal_credential_with_balance() before calling this function. + + Args: + spec: The spec object + state: The beacon state to modify + builder_indices: List of validator indices (must already have builder credentials) + to add builder pending withdrawals for + pending_partial_indices: List of validator indices to set up with pending partial withdrawals + full_withdrawal_indices: List of validator indices to set up as fully withdrawable + partial_withdrawal_indices: List of validator indices to set up as partially withdrawable + builder_withdrawal_amounts: Single amount or list of amounts for builder withdrawals + (default: MIN_ACTIVATION_BALANCE for each) + pending_partial_amounts: Single amount or list of amounts for pending partial withdrawals + (default: 1_000_000_000 for each) + partial_excess_balances: Single amount or list of excess balances for partial withdrawals + (default: 1_000_000_000 for each) + builder_withdrawable_offsets: Single offset or list of epoch offsets for builder withdrawals + (default: 0, i.e., withdrawable immediately) + pending_partial_withdrawable_offsets: Single offset or list of epoch offsets for pending partial + withdrawals (default: 0, i.e., withdrawable immediately) + full_withdrawable_offsets: Single offset or list of epoch offsets for full withdrawals + (default: 0, i.e., withdrawable immediately) + """ + current_epoch = spec.get_current_epoch(state) + + # Helper to get parameter value from single value, list, or None + def get_param_value(param, index, default): + """ + Extract a value from a parameter that can be: + - None: returns the default value + - A single value: returns that value for all indices + - A list: returns the value at the given index (or default if out of bounds) + """ + if param is None: + return default + if isinstance(param, (int, type(spec.Gwei(0)))): + return param + return param[index] if index < len(param) else default + + # 1. Set up builder pending withdrawals (Gloas only) + if is_post_gloas(spec) and builder_indices: + for i, validator_index in enumerate(builder_indices): + amount = get_param_value(builder_withdrawal_amounts, i, spec.MIN_ACTIVATION_BALANCE) + epoch_offset = get_param_value(builder_withdrawable_offsets, i, 0) + + # Verify the validator is already set up as a builder + validator = state.validators[validator_index] + assert spec.has_builder_withdrawal_credential(validator), ( + f"Validator {validator_index} must have builder withdrawal credentials. " + f"Use set_builder_withdrawal_credential() or set_builder_withdrawal_credential_with_balance() first." + ) + + # Verify the builder has sufficient balance + assert state.balances[validator_index] >= amount + spec.MIN_ACTIVATION_BALANCE, ( + f"Validator {validator_index} needs balance >= {amount + spec.MIN_ACTIVATION_BALANCE}, " + f"but has {state.balances[validator_index]}" + ) + + # Add builder pending withdrawal + address = validator.withdrawal_credentials[12:] + state.builder_pending_withdrawals.append( + spec.BuilderPendingWithdrawal( + fee_recipient=address, + amount=amount, + builder_index=validator_index, + withdrawable_epoch=current_epoch + epoch_offset, # Can be in the future + ) + ) + + # Import helpers lazily to avoid circular imports + ( + prepare_pending_withdrawal, + set_validator_fully_withdrawable, + set_validator_partially_withdrawable, + ) = _import_test_helpers() + + # 2. Set up pending partial withdrawals (Electra+) + if is_post_electra(spec) and pending_partial_indices: + for i, validator_index in enumerate(pending_partial_indices): + amount = get_param_value(pending_partial_amounts, i, 1_000_000_000) + epoch_offset = get_param_value(pending_partial_withdrawable_offsets, i, 0) + + prepare_pending_withdrawal( + spec, + state, + validator_index, + amount=amount, + withdrawable_epoch=current_epoch + epoch_offset, # Can be in the future + ) + + # 3. Set up full withdrawals + for i, validator_index in enumerate(full_withdrawal_indices): + epoch_offset = get_param_value(full_withdrawable_offsets, i, 0) + set_validator_fully_withdrawable( + spec, + state, + validator_index, + withdrawable_epoch=current_epoch + epoch_offset, # Can be in the future + ) + + # 4. Set up partial withdrawals + for i, validator_index in enumerate(partial_withdrawal_indices): + excess_balance = get_param_value(partial_excess_balances, i, 1_000_000_000) + + set_validator_partially_withdrawable( + spec, + state, + validator_index, + excess_balance=excess_balance, + ) def get_expected_withdrawals(spec, state):