Skip to content
6 changes: 6 additions & 0 deletions src/withdrawals/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
56 changes: 56 additions & 0 deletions src/withdrawals/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading