Recurring action plan rules (win slot 31 every epoch, withhold the payload) - #147
Conversation
Sparse per-slot plans can only express a scenario for a bounded horizon,
but devnet testing needs scenarios that hold for the whole run - e.g.
"win slot 31 of every epoch and never reveal its payload", the ePBS
analogue of running a devnet with slot 31 missing.
A SlotRule matches slot indices within the epoch (slots_in_epoch, with
optional from_epoch/to_epoch bounds) and carries the same categories as
a SlotPlan:
POST /api/buildoor/action-plan/rules
{"rules":[{"id":"slot31-withhold","enabled":true,"slots_in_epoch":[31],
"bid":{"mode":"custom","bid_value_gwei":1000000,"ignore_missing_prefs":true},
"reveal":{"mode":"disabled"}}]}
Semantics:
- precedence is wholesale, never merged: an explicit plan for a slot
replaces the rule, and SlotPlan.IgnoreRules opts a single slot out
(such a plan is no longer "empty" and is stored)
- rules resolve at freeze time like the global config baseline, so a
rule change never rewrites an already frozen slot; the materialized
plan carries rule_id and reaches the slot result's applied_plan
- lowest id wins when several rules match; the set is replaced
atomically (max 32) and persists in the kv_store "slot_rules"
namespace
- GetRange returns effective plans (stored + rule-derived for the
still-open slots), so the backend stays the only matcher
WebUI: a Recurring Rules panel with scenario presets, dashed chips on
rule-derived cells, and an "ignore recurring rules" opt-out in the slot
modal. The plan category form widgets move to planForms.tsx so the slot
and rule editors share one implementation.
- useActionPlan: deleting a stored plan hands the slot back to whatever recurring rule matches it, which only the server can resolve. Merging the null entry dropped the cell's chips until the next refetch (and a bulk delete blanked a whole range). Any in-range deletion now refetches instead of merging; setting a plan still merges, since a stored plan always wins over a rule. - RuleEditModal: saving upserts by id, so creating a rule that reuses an existing id replaced it silently - both presets carry fixed ids, so applying one twice was enough. Create mode now rejects a colliding id; the server cannot catch this because it only sees the collapsed set. - PlanService.Start: warn about rehydrated rules whose slot indices can never match the active chain spec (a state-db carried to a network with a different SLOTS_PER_EPOCH), instead of leaving them silently dead. - Document that s.mu is what makes a rule swap atomic against Freeze. - Test that rule-forced builds do not consume the next_n budget while scheduled builds do.
|
Review findings addressed in 911481f:
Not changed, deliberately: bulk
|
An absolute bid_value_gwei REPLACES the block value, so the preset's 1000000 gwei silently underbid every competitor on devnet-7, where block values run 0.25-0.43 ETH and bids land at 0.4-0.5 ETH. A rule that never wins the slot can never withhold it - the failure is invisible because the bid is submitted successfully, it just loses. A subsidy adds to the block value instead, so it outbids competitors whatever the block is worth and needs no per-network tuning.
|
Deployed to devnet-7 as a single-host canary: Host choice matters here: the scenario needs a builder that actually wins. lodestar-ethrex and prysm-ethrex win on devnet-7; lighthouse-geth currently does not (its bids are rejected with Verified on the host: container running, Deploying the image arms nothing. The rule set starts empty and persists in the state-db ( curl -X POST https://api-buildoor-lodestar-ethrex-1.srv.glamsterdam-devnet-7.ethpandaops.io/api/buildoor/action-plan/rules \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"rules":[{
"id":"slot31-withhold","enabled":true,"slots_in_epoch":[31],
"bid":{"mode":"custom","bid_subsidy":1000000000,"ignore_missing_prefs":true},
"reveal":{"mode":"disabled"}}]}'
Also pushed in 8f8c1e9: the win-and-withhold preset now uses |
Measured on devnet-7: competing builders bid 0.17-0.29 ETH, so +0.2 ETH on top of the block value wins the auction. The previous 1 ETH was a round number with no basis, and an oversized bid bought nothing. Also drop ignore_missing_prefs from the preset - proposer preferences arrive reliably over gossip, and bidding without them risks a fee_recipient/gas_limit mismatch that CLs ignore silently. Note in the docs that a rule losing the auction withholds nothing and shows up only as the grid's "bid, not included" dot.
devnet-7 validation: 17 slot-31s observedThe scenario works end-to-end on a live multi-client devnet. Every slot-31 got a bid with the frozen rule’s economics, and every slot we won had its reveal suppressed — 17/17 rule correctness, 6/6 withholds. On-chain signature each time: beacon block present with our builder’s bid committed, execution payload absent, PTC voting
Win rate, excluding teku proposers: 6/7 = 86% of slot-31s that produced a block. All four self-build losses after the config fix had a teku proposer — four different teku nodes on four different ELs (reth, nimbusel, ethrex, geth), so it is the client, not a node or EL interaction. No collateral damageMeasured against an 8-epoch pre-arming baseline: miss rate 18.4% → 16.3%, participation 85–89% → 86–90%, finality tracking normally throughout, Two corrections to earlier claims in this PR
Feature behaviour confirmed in production
|
Why
The per-slot action plan can already express "win this slot and don't reveal it" — but only slot by slot, for a bounded horizon. Devnet testing wants the scenario to hold for the whole run: the idea came out of a client-team discussion about testing an entire devnet with slot 31 missing, to exercise how clients attest when a slot at the epoch boundary produces no execution payload.
This adds recurring rules: a plan template matched against the slot index within the epoch.
What
Slot 31 of every epoch is now bid for (build forced past the schedule, exempt from the
next_nbudget) and, once won, never revealed — the beacon block exists, its execution payload does not. Set-and-forget, persisted in the state-db.A
SlotRulecarries the same categories as aSlotPlan(bid,builder_api,reveal,build,transforms) plus a matcher:slots_in_epochand optionalfrom_epoch/to_epochbounds.Semantics
SlotPlan.IgnoreRulesopts a single slot out completely — without it there is no way to make one rule-covered slot behave normally, since an otherwise empty plan is dropped and hands the slot straight back to the rule. Such a plan therefore counts as an instruction and is stored.rule_id, is synthesized on every read and never persisted per slot — so it flows intoFrozenPlan.Planand into each slot result'sapplied_plan.GetRangereturns effective plans (stored + rule-derived), so the UI needs no matching logic. Rule plans are only synthesized for still-open slots (> currentSlot, not yet frozen) — past slots keep their recorded history instead of being described by today's rules.kv_storeslot_rulesnamespace.API
GET /api/buildoor/action-plan/rulesPOST /api/buildoor/action-plan/rulesaction_plan_rules_updatedPOST /api/buildoor/action-plangains a top-levelignore_rulesmember.GET /api/buildoor/action-plannow also returns rule-derived plans (markedrule_id) for the open slots of the range.WebUI
Action Plan tab gets a Recurring Rules panel (list / toggle / edit / delete) with two scenario presets, dashed chips on rule-derived cells, and an "ignore recurring rules" checkbox in the slot modal. Opening a rule-covered slot pre-fills the rule's values and explains that saving detaches the slot into an explicit plan. The category form widgets moved to
planForms.tsxso the slot and rule editors share one implementation.Notes
bid_value_gwei(which replaces it). Measured on devnet-7: block values run 0.25–0.43 ETH and bids land at 0.4–0.5 ETH, so the original absolute 1,000,000 gwei would have underbid every competitor — and a rule that never wins the slot can never withhold it, silently, because the bid submits fine and just loses.Testing
go build ./...andgo test ./...pass; gofmt and golangci-lint clean on the touched packages. New coverage inpkg/action_plan/rules_test.go(matching, epoch bounds, validation, atomic replace, clone isolation, wholesale precedence, the opt-out, freeze snapshotting, range synthesis, deterministic order, codec round-trip) andpkg/webui/handlers/api/action_plan_test.go(endpoint round-trip incl.rule_idin the range query, validation → 400 with nothing committed). Swagger regenerated, CLAUDE.md updated.