Skip to content

Commit 0c68a7e

Browse files
authored
Reject bids from builders exited by the parent's payload (#5580)
Bid gossip validation accepts a bid that block processing rejects, which risks a missed proposal. It validates against the parent block's post-state, which does not reflect the parent payload's execution requests, so a builder that the full parent's payload exits still looks active. Block processing applies that exit before it validates the bid. The fix is to ignore bids from builders which *might* exit; ie there exists a builder exit request in which `pubkey` and `source_address` match the builder. Note, builder exits where the builder still has a pending withdrawal are also ignored, even though they wouldn't actually be exited. Thanks to @bshastry for pointing this out!
1 parent 8ca466d commit 0c68a7e

4 files changed

Lines changed: 415 additions & 9 deletions

File tree

specs/gloas/p2p-interface.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,25 +1023,34 @@ def validate_execution_payload_bid_gossip(
10231023
if bid.prev_randao != get_randao_mix(state, get_current_epoch(state)):
10241024
raise GossipReject("bid's previous randao is incorrect")
10251025

1026-
# Advance state
10271026
state = state.copy()
10281027
process_slots(state, bid.slot)
10291028

10301029
# [REJECT] The builder index is valid
10311030
if bid.builder_index >= len(state.builders):
10321031
raise GossipReject("builder index out of range")
10331032

1034-
# [IGNORE] The builder can cover the bid
1035-
if not can_builder_cover_bid(state, bid.builder_index, bid.value):
1036-
raise GossipIgnore("builder cannot cover bid value")
1033+
builder = state.builders[bid.builder_index]
1034+
1035+
# [REJECT] The builder is a payload builder
1036+
if builder.version != PAYLOAD_BUILDER_VERSION:
1037+
raise GossipReject("builder is not a payload builder")
10371038

10381039
# [REJECT] The builder is active
10391040
if not is_active_builder(state, bid.builder_index):
10401041
raise GossipReject("builder is not active")
10411042

1042-
# [REJECT] The builder is a payload builder
1043-
if state.builders[bid.builder_index].version != PAYLOAD_BUILDER_VERSION:
1044-
raise GossipReject("builder is not a payload builder")
1043+
# [IGNORE] The builder can cover the bid
1044+
if not can_builder_cover_bid(state, bid.builder_index, bid.value):
1045+
raise GossipIgnore("builder cannot cover bid value")
1046+
1047+
# [IGNORE] The parent's payload does not try to exit the builder
1048+
if bid.parent_block_hash == state.latest_execution_payload_bid.block_hash:
1049+
envelope = store.payloads[bid.parent_block_root]
1050+
for request in envelope.execution_requests.builder_exits:
1051+
if request.pubkey == builder.pubkey:
1052+
if request.source_address == builder.execution_address:
1053+
raise GossipIgnore("builder may exit")
10451054

10461055
# [REJECT] The bid signature is valid
10471056
if not verify_execution_payload_bid_signature(state, signed_execution_payload_bid):

tests/core/pyspec/eth_consensus_specs/test/gloas/networking/test_gossip_execution_payload_bid.py

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
)
99
from eth_consensus_specs.test.helpers.gloas.bid import (
1010
activate_builders,
11+
append_head_with_requests,
1112
build_signed_bid,
1213
get_blocks_meta,
1314
record_block_in_store,
1415
record_head_payload,
1516
setup_store_advanced_for_bid,
17+
setup_store_finalized_with_head_payment,
1618
setup_store_finalized_with_pending_payment,
1719
)
1820
from eth_consensus_specs.test.helpers.gloas.proposer_preferences import (
@@ -1323,6 +1325,317 @@ def test_gossip_execution_payload_bid__reject_builder_not_payload_version(spec,
13231325
yield "messages", "meta", messages
13241326

13251327

1328+
@with_gloas_and_later
1329+
@spec_state_test_with_matching_config
1330+
def test_gossip_execution_payload_bid__ignore_builder_exit_in_parent_payload(spec, state):
1331+
"""A bid from a builder that the full parent's payload exits is ignored.
1332+
1333+
The parent's execution requests are applied by its descendant block, so the
1334+
parent block's post-state still shows the builder as active. Block
1335+
processing applies the exit before validating the bid, so a bid that
1336+
validation accepts here sinks the proposal that includes it.
1337+
"""
1338+
anchor_state = state.copy()
1339+
yield "topic", "meta", "execution_payload_bid"
1340+
1341+
builder_index = spec.BuilderIndex(0)
1342+
store, blocks, _ = setup_store_advanced_for_bid(spec, state)
1343+
builder = state.builders[builder_index]
1344+
requests = spec.ExecutionRequests(
1345+
builder_exits=spec.BuilderExitRequests.of(
1346+
spec.BuilderExitRequest(
1347+
source_address=builder.execution_address,
1348+
pubkey=builder.pubkey,
1349+
)
1350+
),
1351+
)
1352+
parent_root = append_head_with_requests(spec, state, store, blocks, requests)
1353+
finalized_checkpoint_meta = activate_builders(spec, state, store, blocks)
1354+
assert spec.is_active_builder(state, builder_index)
1355+
head_payload = record_head_payload(spec, state, store, blocks, execution_requests=requests)
1356+
yield "state", anchor_state
1357+
for signed in blocks:
1358+
yield get_filename(signed), signed
1359+
yield "blocks", "meta", get_blocks_meta(blocks, head_payload)
1360+
yield "finalized_checkpoint", "meta", finalized_checkpoint_meta
1361+
1362+
time_ms = spec.compute_time_at_slot_ms(store, state.slot)
1363+
yield "current_time_ms", "meta", int(time_ms)
1364+
messages = []
1365+
seen, common_fee, parent_gas_limit, proposal_slot, parent_block_hash, time_ms = yield from (
1366+
_seed_bid_context(spec, state, store, head_payload, messages, time_ms)
1367+
)
1368+
1369+
signed_bid = build_signed_bid(
1370+
spec,
1371+
state,
1372+
builder_index=builder_index,
1373+
slot=proposal_slot,
1374+
parent_block_hash=parent_block_hash,
1375+
parent_block_root=parent_root,
1376+
fee_recipient=common_fee,
1377+
gas_limit=parent_gas_limit,
1378+
value=spec.Gwei(1),
1379+
)
1380+
yield get_filename(signed_bid), signed_bid
1381+
1382+
time_ms += 40
1383+
result, reason = run_validate_gossip(
1384+
spec,
1385+
seen=seen,
1386+
store=store,
1387+
signed_execution_payload_bid=signed_bid,
1388+
current_time_ms=time_ms,
1389+
)
1390+
assert result == "ignore"
1391+
assert reason == "builder may exit"
1392+
messages.append(
1393+
{
1394+
"current_time_ms": int(time_ms),
1395+
"message": get_filename(signed_bid),
1396+
"expected": result,
1397+
"reason": reason,
1398+
}
1399+
)
1400+
1401+
yield "messages", "meta", messages
1402+
1403+
1404+
@with_gloas_and_later
1405+
@spec_state_test_with_matching_config
1406+
def test_gossip_execution_payload_bid__valid_parent_exit_unknown_pubkey(spec, state):
1407+
"""A bid is valid when the full parent's exit request names no known builder.
1408+
1409+
The request's pubkey does not match the bid's builder, so the exit check
1410+
does not flag the bid.
1411+
"""
1412+
anchor_state = state.copy()
1413+
yield "topic", "meta", "execution_payload_bid"
1414+
1415+
builder_index = spec.BuilderIndex(0)
1416+
store, blocks, _ = setup_store_advanced_for_bid(spec, state)
1417+
builder = state.builders[builder_index]
1418+
unknown_pubkey = spec.BLSPubkey(b"\xab" * 48)
1419+
assert unknown_pubkey not in [b.pubkey for b in state.builders]
1420+
requests = spec.ExecutionRequests(
1421+
builder_exits=spec.BuilderExitRequests.of(
1422+
spec.BuilderExitRequest(
1423+
source_address=builder.execution_address,
1424+
pubkey=unknown_pubkey,
1425+
)
1426+
),
1427+
)
1428+
parent_root = append_head_with_requests(spec, state, store, blocks, requests)
1429+
finalized_checkpoint_meta = activate_builders(spec, state, store, blocks)
1430+
assert spec.is_active_builder(state, builder_index)
1431+
head_payload = record_head_payload(spec, state, store, blocks, execution_requests=requests)
1432+
yield "state", anchor_state
1433+
for signed in blocks:
1434+
yield get_filename(signed), signed
1435+
yield "blocks", "meta", get_blocks_meta(blocks, head_payload)
1436+
yield "finalized_checkpoint", "meta", finalized_checkpoint_meta
1437+
1438+
time_ms = spec.compute_time_at_slot_ms(store, state.slot)
1439+
yield "current_time_ms", "meta", int(time_ms)
1440+
messages = []
1441+
seen, common_fee, parent_gas_limit, proposal_slot, parent_block_hash, time_ms = yield from (
1442+
_seed_bid_context(spec, state, store, head_payload, messages, time_ms)
1443+
)
1444+
1445+
signed_bid = build_signed_bid(
1446+
spec,
1447+
state,
1448+
builder_index=builder_index,
1449+
slot=proposal_slot,
1450+
parent_block_hash=parent_block_hash,
1451+
parent_block_root=parent_root,
1452+
fee_recipient=common_fee,
1453+
gas_limit=parent_gas_limit,
1454+
value=spec.Gwei(1),
1455+
)
1456+
yield get_filename(signed_bid), signed_bid
1457+
1458+
time_ms += 40
1459+
result, reason = run_validate_gossip(
1460+
spec,
1461+
seen=seen,
1462+
store=store,
1463+
signed_execution_payload_bid=signed_bid,
1464+
current_time_ms=time_ms,
1465+
)
1466+
assert result == "valid"
1467+
assert reason is None
1468+
messages.append(
1469+
{
1470+
"current_time_ms": int(time_ms),
1471+
"message": get_filename(signed_bid),
1472+
"expected": result,
1473+
}
1474+
)
1475+
1476+
yield "messages", "meta", messages
1477+
1478+
1479+
@with_gloas_and_later
1480+
@spec_state_test_with_matching_config
1481+
def test_gossip_execution_payload_bid__valid_parent_exit_wrong_source_address(spec, state):
1482+
"""A bid is valid when the full parent's exit request is not authorized.
1483+
1484+
The request's source address is not the builder's execution address, so the
1485+
exit check does not flag the bid.
1486+
"""
1487+
anchor_state = state.copy()
1488+
yield "topic", "meta", "execution_payload_bid"
1489+
1490+
builder_index = spec.BuilderIndex(0)
1491+
store, blocks, _ = setup_store_advanced_for_bid(spec, state)
1492+
builder = state.builders[builder_index]
1493+
wrong_address = spec.ExecutionAddress(b"\xff" * 20)
1494+
assert builder.execution_address != wrong_address
1495+
requests = spec.ExecutionRequests(
1496+
builder_exits=spec.BuilderExitRequests.of(
1497+
spec.BuilderExitRequest(
1498+
source_address=wrong_address,
1499+
pubkey=builder.pubkey,
1500+
)
1501+
),
1502+
)
1503+
parent_root = append_head_with_requests(spec, state, store, blocks, requests)
1504+
finalized_checkpoint_meta = activate_builders(spec, state, store, blocks)
1505+
assert spec.is_active_builder(state, builder_index)
1506+
head_payload = record_head_payload(spec, state, store, blocks, execution_requests=requests)
1507+
yield "state", anchor_state
1508+
for signed in blocks:
1509+
yield get_filename(signed), signed
1510+
yield "blocks", "meta", get_blocks_meta(blocks, head_payload)
1511+
yield "finalized_checkpoint", "meta", finalized_checkpoint_meta
1512+
1513+
time_ms = spec.compute_time_at_slot_ms(store, state.slot)
1514+
yield "current_time_ms", "meta", int(time_ms)
1515+
messages = []
1516+
seen, common_fee, parent_gas_limit, proposal_slot, parent_block_hash, time_ms = yield from (
1517+
_seed_bid_context(spec, state, store, head_payload, messages, time_ms)
1518+
)
1519+
1520+
signed_bid = build_signed_bid(
1521+
spec,
1522+
state,
1523+
builder_index=builder_index,
1524+
slot=proposal_slot,
1525+
parent_block_hash=parent_block_hash,
1526+
parent_block_root=parent_root,
1527+
fee_recipient=common_fee,
1528+
gas_limit=parent_gas_limit,
1529+
value=spec.Gwei(1),
1530+
)
1531+
yield get_filename(signed_bid), signed_bid
1532+
1533+
time_ms += 40
1534+
result, reason = run_validate_gossip(
1535+
spec,
1536+
seen=seen,
1537+
store=store,
1538+
signed_execution_payload_bid=signed_bid,
1539+
current_time_ms=time_ms,
1540+
)
1541+
assert result == "valid"
1542+
assert reason is None
1543+
messages.append(
1544+
{
1545+
"current_time_ms": int(time_ms),
1546+
"message": get_filename(signed_bid),
1547+
"expected": result,
1548+
}
1549+
)
1550+
1551+
yield "messages", "meta", messages
1552+
1553+
1554+
@with_gloas_and_later
1555+
@spec_state_test_with_matching_config
1556+
def test_gossip_execution_payload_bid__ignore_builder_exit_with_pending_balance(spec, state):
1557+
"""A bid is ignored when a pending balance would block the requested exit.
1558+
1559+
The full parent's bid pays builder 0, and only a descendant block settles a
1560+
payment, so the builder has a pending balance when the parent's authorized
1561+
exit request is applied. That blocks the exit, so block processing keeps the
1562+
builder active and accepts the bid. Validation does not model the pending
1563+
balance, so it ignores the bid regardless.
1564+
"""
1565+
anchor_state = state.copy()
1566+
yield "topic", "meta", "execution_payload_bid"
1567+
1568+
builder_index = spec.BuilderIndex(0)
1569+
builder = state.builders[builder_index]
1570+
requests = spec.ExecutionRequests(
1571+
builder_exits=spec.BuilderExitRequests.of(
1572+
spec.BuilderExitRequest(
1573+
source_address=builder.execution_address,
1574+
pubkey=builder.pubkey,
1575+
)
1576+
),
1577+
)
1578+
store, blocks, parent_root, builder_index, pending_value = (
1579+
setup_store_finalized_with_head_payment(spec, state, requests)
1580+
)
1581+
assert pending_value > 0
1582+
assert spec.is_active_builder(state, builder_index)
1583+
head_payload = record_head_payload(spec, state, store, blocks, execution_requests=requests)
1584+
yield "state", anchor_state
1585+
for signed in blocks:
1586+
yield get_filename(signed), signed
1587+
yield "blocks", "meta", get_blocks_meta(blocks, head_payload)
1588+
1589+
time_ms = spec.compute_time_at_slot_ms(store, state.slot)
1590+
yield "current_time_ms", "meta", int(time_ms)
1591+
messages = []
1592+
seen, common_fee, parent_gas_limit, proposal_slot, parent_block_hash, time_ms = yield from (
1593+
_seed_bid_context(spec, state, store, head_payload, messages, time_ms)
1594+
)
1595+
# The pending payment survives the advance to the bid's slot, which is what
1596+
# blocks the exit that validation assumes.
1597+
advanced_state = store.block_states[parent_root].copy()
1598+
spec.process_slots(advanced_state, proposal_slot)
1599+
assert (
1600+
spec.get_pending_balance_to_withdraw_for_builder(advanced_state, builder_index)
1601+
== pending_value
1602+
)
1603+
1604+
signed_bid = build_signed_bid(
1605+
spec,
1606+
state,
1607+
builder_index=builder_index,
1608+
slot=proposal_slot,
1609+
parent_block_hash=parent_block_hash,
1610+
parent_block_root=parent_root,
1611+
fee_recipient=common_fee,
1612+
gas_limit=parent_gas_limit,
1613+
value=spec.Gwei(1),
1614+
)
1615+
yield get_filename(signed_bid), signed_bid
1616+
1617+
time_ms += 40
1618+
result, reason = run_validate_gossip(
1619+
spec,
1620+
seen=seen,
1621+
store=store,
1622+
signed_execution_payload_bid=signed_bid,
1623+
current_time_ms=time_ms,
1624+
)
1625+
assert result == "ignore"
1626+
assert reason == "builder may exit"
1627+
messages.append(
1628+
{
1629+
"current_time_ms": int(time_ms),
1630+
"message": get_filename(signed_bid),
1631+
"expected": result,
1632+
"reason": reason,
1633+
}
1634+
)
1635+
1636+
yield "messages", "meta", messages
1637+
1638+
13261639
@with_gloas_and_later
13271640
@spec_state_test_with_matching_config
13281641
def test_gossip_execution_payload_bid__reject_too_many_blobs(spec, state):

tests/core/pyspec/eth_consensus_specs/test/helpers/execution_payload_bid.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def prepare_signed_execution_payload_bid(
3838
blob_kzg_commitments=None,
3939
prev_randao=None,
4040
inclusion_list_bits=None,
41+
execution_requests_root=None,
4142
valid_signature=True,
4243
valid_amount=True,
4344
):
@@ -95,6 +96,8 @@ def prepare_signed_execution_payload_bid(
9596
"value": value,
9697
"blob_kzg_commitments": blob_kzg_commitments,
9798
}
99+
if execution_requests_root is not None:
100+
bid_kwargs["execution_requests_root"] = execution_requests_root
98101
if is_post_heze(spec) and inclusion_list_bits is not None:
99102
bid_kwargs["inclusion_list_bits"] = inclusion_list_bits
100103

0 commit comments

Comments
 (0)