diff --git a/src/withdrawals/tasks.py b/src/withdrawals/tasks.py index 390f3d82..5174321b 100644 --- a/src/withdrawals/tasks.py +++ b/src/withdrawals/tasks.py @@ -282,6 +282,12 @@ async def _get_withdrawals( if queued_assets <= 0: break + # A validator already assigned a partial this batch must never be converted + # to a full exit -- doing so would silently drop the partial amount and + # double-count queued_assets against both the partial and the full balance. + if validator.public_key in withdrawals: + continue + withdrawals[validator.public_key] = Gwei(0) # full withdrawal queued_assets = Gwei(max(0, queued_assets - validator.balance)) diff --git a/src/withdrawals/tests/test_tasks.py b/src/withdrawals/tests/test_tasks.py index a979b8b6..61ad1bb6 100644 --- a/src/withdrawals/tests/test_tasks.py +++ b/src/withdrawals/tests/test_tasks.py @@ -912,6 +912,62 @@ async def test_get_withdrawals_excludes_consolidation_sources(data_dir): assert result == expected +async def test_get_withdrawals_full_exit_does_not_overwrite_partial_assignment(data_dir): + settings.set(vault=None, vault_dir=data_dir, network=HOODI) + + # Real inputs can't trigger the guarded overwrite today: partial_capacity is + # exact, so a mid-loop top-up always saturates queued_assets. Mock + # _get_partial_withdrawals to under-saturate it instead: v1 (cheapest) is fully + # exited, v2 gets a 4 ETH partial, and the loop then reaches v2 again as the + # only remaining exitable validator. + chain_head = create_chain_head(epoch=500) + queued_assets = ether_to_gwei(50) + v1 = create_consensus_validator( + public_key='0x1', + index=1, + balance=ether_to_gwei(40), + status=ValidatorStatus.ACTIVE_ONGOING, + activation_epoch=200, + is_compounding=False, + ) + v2 = create_consensus_validator( + public_key='0x2', + index=2, + balance=ether_to_gwei(50), + status=ValidatorStatus.ACTIVE_ONGOING, + activation_epoch=200, + ) + consensus_validators = [v1, v2] + + def _under_saturating_partial_withdrawals( + partial_validators, validator_partial_withdrawals, queued_assets + ): + if any(v.public_key == '0x2' for v in partial_validators): + return {'0x2': ether_to_gwei(4)} + return {} + + with mock.patch( + 'src.withdrawals.tasks._get_partial_withdrawals', + side_effect=_under_saturating_partial_withdrawals, + ): + result = await _get_withdrawals( + chain_head=chain_head, + queued_assets=queued_assets, + consensus_validators=consensus_validators, + pending_partial_withdrawals=[], + validator_min_active_epochs=10, + oracle_exit_indexes=set(), + consolidation_target_indexes=set(), + consolidation_source_indexes=set(), + pending_deposits={}, + ) + + # v2 must keep its mid-loop partial (4 ETH); it must not be converted into a + # full exit (Gwei(0)) when the loop later reaches it in exitable_validators. + expected = {'0x1': ether_to_gwei(0), '0x2': ether_to_gwei(4)} + assert result == expected + + async def test_get_withdrawals_boundary_activation_epoch_prefers_partial_over_full_exit(data_dir): settings.set(vault=None, vault_dir=data_dir, network=HOODI)