From 20600bd6fc20093230223ef3c6ffa9125021e642 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 24 Jul 2026 23:46:31 +0200 Subject: [PATCH 1/4] add recurring action plan rules 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. --- CLAUDE.md | 69 ++- pkg/action_plan/codec.go | 39 ++ pkg/action_plan/rules.go | 152 +++++++ pkg/action_plan/rules_test.go | 321 ++++++++++++++ pkg/action_plan/service.go | 228 +++++++++- pkg/action_plan/types.go | 31 +- pkg/webui/handlers/api/action_plan.go | 91 ++++ pkg/webui/handlers/api/action_plan_test.go | 77 ++++ pkg/webui/handlers/api/events.go | 23 + pkg/webui/handlers/docs/docs.go | 180 ++++++++ pkg/webui/handlers/docs/swagger.json | 180 ++++++++ pkg/webui/handlers/docs/swagger.yaml | 142 +++++++ .../components/actionplan/ActionPlanView.tsx | 18 + .../components/actionplan/RuleEditModal.tsx | 396 ++++++++++++++++++ .../src/components/actionplan/RulesPanel.tsx | 241 +++++++++++ .../src/components/actionplan/SlotCell.tsx | 16 +- .../components/actionplan/SlotEditModal.tsx | 378 +++-------------- .../src/components/actionplan/planForms.tsx | 311 ++++++++++++++ pkg/webui/src/hooks/useActionPlan.ts | 12 + pkg/webui/src/hooks/useActionPlanRules.ts | 108 +++++ pkg/webui/src/styles.css | 12 + pkg/webui/src/types.ts | 28 ++ pkg/webui/webui.go | 2 + 23 files changed, 2706 insertions(+), 349 deletions(-) create mode 100644 pkg/action_plan/rules.go create mode 100644 pkg/action_plan/rules_test.go create mode 100644 pkg/webui/src/components/actionplan/RuleEditModal.tsx create mode 100644 pkg/webui/src/components/actionplan/RulesPanel.tsx create mode 100644 pkg/webui/src/components/actionplan/planForms.tsx create mode 100644 pkg/webui/src/hooks/useActionPlanRules.ts diff --git a/CLAUDE.md b/CLAUDE.md index 57a53ac..ce872d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,21 @@ npm run clean sample_slot → runs the exact production gojq against a captured artifact, else the latest buffered one, else an illustrative template; bid/envelope reduced to `.message`). + - **Recurring rules** (`rules.go`) cover what sparse plans cannot: a scenario + that must repeat for a whole devnet run. A `SlotRule` matches slot INDICES + within the epoch (`slots_in_epoch`, optionally bounded by `from_epoch`/ + `to_epoch`) and carries the very same categories as a `SlotPlan` — e.g. + win slot 31 of every epoch (`bid: custom` + value override) and withhold + its payload (`reveal: disabled`), the ePBS analogue of "run a devnet with + slot 31 missing". Precedence is WHOLESALE, never merged: an explicit plan + for a slot replaces any rule, and `SlotPlan.IgnoreRules` opts a single slot + out entirely (which is why such a plan is not "empty"). Rules resolve at + freeze/read time (`PlanForSlot`), so a rule change never rewrites an + already frozen slot, and the materialized plan is marked with `rule_id` — + it is synthesized on every read, never persisted per slot. Several matching + rules: lowest id wins. The set is replaced atomically (`SetRules`, max 32), + persisted via the `kv_store` `slot_rules` namespace, and fires + `RuleChangeEvent` - **Freeze semantics**: `Freeze(slot)` resolves the immutable `FrozenPlan` (raw plan + effective settings merged from the live global config + target-slot fork + the COMPLETE build decision incl. schedule modes and next_n accounting) the first time @@ -178,7 +193,10 @@ npm run clean fine-grained `set` paths (`"bid.bid_min_amount": 5000`); overlapping updates apply in request order; committed changes fire `PlanChangeEvent`. - Persisted via the `kv_store` `slot_plans` namespace; past plans prune to - `slot-result-retention-epochs`, future plans never. + `slot-result-retention-epochs`, future plans never. `GetRange` returns the + EFFECTIVE plans of a range: stored plans plus the rule-derived plans of the + still-open slots (`> currentSlot`, not yet frozen) — past slots keep their + recorded history instead of being described by today's rules. 1. **Builder Service** (`pkg/payload_builder/`) - Main orchestrator for payload building @@ -340,8 +358,11 @@ npm run clean - **Action Plan View**: epoch × slot timetable (rows = epochs, columns = slots) rendering plan chips + result status per cell; click-to-edit modal for future slots, full outcome detail + SSZ/JSON artifact downloads for - past slots; multi-slot/range bulk editing; live via the - `action_plan_updated`/`slot_result_updated` SSE events + past slots; multi-slot/range bulk editing; a Recurring Rules panel + (list/toggle/edit/delete + scenario presets like "win the last slot of + every epoch and withhold the payload"); rule-derived cells render dashed + chips; live via the `action_plan_updated`/`action_plan_rules_updated`/ + `slot_result_updated` SSE events - **Bids Won View**: Paginated table of our blocks included on chain - Tab navigation: Dashboard / Bids Won / Action Plan - Real-time updates when new blocks are included (bid_won SSE from the @@ -453,7 +474,8 @@ The optional **state-db** (`pkg/db`, mirrors spamoor: `glebarez/go-sqlite` + `sq SSZ), `validator_registrations` (Builder API registrations, SSZ; codec in `builderapi/legacy`), `builder_preferences` (max_execution_payment, LE uint64; codec in `builderapi/epbs`), `slot_plans` (per-slot action plans, JSON; codec in - `action_plan`), `slot_results` (per-slot outcome summaries, JSON; codec in + `action_plan`), `slot_rules` (recurring per-slot-index plan templates, JSON; + codec in `action_plan`), `slot_results` (per-slot outcome summaries, JSON; codec in `slot_results`; the legacy `won_blocks` namespace is migrated into it once on startup and then deleted) - `slot_artifacts` — dedicated table (NOT a kv namespace: blobs are too large for @@ -518,9 +540,11 @@ buildoor/ ├── cmd/ # CLI commands (root, run, deposit, exit) ├── pkg/ │ ├── action_plan/ # per-slot scheduling authority: sparse SlotPlan store, +│ │ # recurring SlotRules (slot-index-in-epoch templates, +│ │ # wholesale precedence under explicit plans), │ │ # freeze semantics (FrozenPlan = raw plan + resolved │ │ # effective settings + complete build decision), atomic -│ │ # bulk updates w/ path-based partial edits, kv codec +│ │ # bulk updates w/ path-based partial edits, kv codecs │ ├── slot_results/ # generic per-slot outcome history: attempt-aware │ │ # SlotResult tracker (blocking subscriptions, baseline │ │ # slot clock, won-block view, won_blocks migration) + @@ -623,13 +647,21 @@ To make frontend changes: - Query params: `offset` (default: 0), `limit` (default: 20, max: 100) - Returns: `{ bids_won: [], total: number, offset: number, limit: number }` - `GET /api/buildoor/builder-api-status` - Builder API configuration and validator count -- `GET /api/buildoor/action-plan?min_slot=&max_slot=` - Per-slot action plans in the - inclusive range (max span 320 epochs) +- `GET /api/buildoor/action-plan?min_slot=&max_slot=` - EFFECTIVE per-slot action + plans in the inclusive range (max span 320 epochs): stored plans plus the + rule-derived plans of the still-open slots (those carry `rule_id`) - `POST /api/buildoor/action-plan` - Atomic bulk plan mutation (auth + audit). Body `{updates: [...]}`; each update targets `slots` and/or `from_slot..to_slot`, with three-state category members (absent/null/object) and fine-grained `set` - paths (`{"bid.bid_min_amount": 5000}`). Past/frozen slots → 409. Returns the + paths (`{"bid.bid_min_amount": 5000}`) plus the top-level `ignore_rules` + member (per-slot recurring-rule opt-out). Past/frozen slots → 409. Returns the authoritative normalized plans +- `GET /api/buildoor/action-plan/rules` - Recurring rules, id-ascending (= match + order) +- `POST /api/buildoor/action-plan/rules` - Atomically replaces the WHOLE rule set + (auth + audit). Body `{rules: [...]}`; each rule has an id slug, `enabled`, + `slots_in_epoch`, optional `from_epoch`/`to_epoch` and the same categories as a + slot plan. All-or-nothing validation (400 on any bad rule); max 32 rules - `GET /api/buildoor/slot-results?min_slot=&max_slot=` - Attempt-level outcome history per slot (build, bids, submissions, reveals, inclusion, applied plan) - `GET /api/buildoor/slot-results/{slot}/payload|envelope|bids|bids/{index}` - Raw @@ -670,6 +702,9 @@ To make frontend changes: - Logged in event stream for debugging - `action_plan_updated` - Fired on committed plan mutations; data is the authoritative `{slots: [...], plans: [...]}` change set (null plan = deleted) +- `action_plan_rules_updated` - Fired on committed recurring-rule mutations; data + is the authoritative `{rules: [...]}` set. The plan grid refetches its range + because rules change the effective plan of every uncovered slot - `slot_result_updated` - Fired when a slot's result record changes (coalesced to ~1/s per slot); data is the full SlotResult. SSE is an invalidation channel — the REST range endpoints are the source of truth @@ -787,6 +822,12 @@ happened afterwards, down to the exact SSZ objects. past slots reject edits with 409. Operators should plan ≥2 slots ahead - At-least-once semantics across restarts: an in-flight future slot may be re-frozen after a restart +- **Recurring rules** apply a plan template to the same slot index of every epoch + (`slots_in_epoch`, optional `from_epoch`/`to_epoch`) — the tool for scenarios + that must hold for a whole devnet run rather than a bounded slot list. An + explicit plan for a slot replaces the rule wholesale (never merged); a plan + with `ignore_rules` opts a single slot out. Rules resolve at freeze time, so + editing them never rewrites a frozen slot **Outcome inspection** (see `pkg/slot_results/`): every active slot gets an attempt-level `SlotResult` (REST range queries + `slot_result_updated` SSE) and @@ -797,8 +838,10 @@ artifact kind). **WebUI**: the Action Plan tab renders an epoch × slot timetable (rows = epochs, columns = slots) with plan chips + result status per cell, click-to-edit modal -(future slots) / full outcome detail with artifact downloads (past slots), and -multi-slot/range bulk editing. +(future slots) / full outcome detail with artifact downloads (past slots), +multi-slot/range bulk editing, and a Recurring Rules panel with scenario presets. +Rule-derived cells draw dashed chips; opening one pre-fills the rule's values and +saving detaches that slot into an explicit plan. **Testing the feature:** 1. Plan a future slot: `curl -X POST .../api/buildoor/action-plan -d @@ -810,6 +853,12 @@ multi-slot/range bulk editing. 4. Test a withheld reveal: `{"set":{"reveal.mode":"disabled"}}` on a future slot → the reveal attempt records `suppressed`/`plan_disabled`, the envelope artifact still exists +5. Run it for every epoch instead (the "slot 31 missing" devnet scenario): + `curl -X POST .../api/buildoor/action-plan/rules -d '{"rules":[{ + "id":"slot31-withhold","enabled":true,"slots_in_epoch":[31], + "bid":{"mode":"custom","bid_value_gwei":1000000,"ignore_missing_prefs":true}, + "reveal":{"mode":"disabled"}}]}'` → every slot 31 is bid for and, once won, + never revealed: the block exists, its execution payload does not ## Common Issues diff --git a/pkg/action_plan/codec.go b/pkg/action_plan/codec.go index f0d0d3b..b3ccbd9 100644 --- a/pkg/action_plan/codec.go +++ b/pkg/action_plan/codec.go @@ -50,3 +50,42 @@ func (PlanCodec) DecodeValue(value []byte) (*SlotPlan, error) { return plan, nil } + +// RuleCodec translates recurring slot rules to their persisted form in the +// kv_store: the rule id as key, JSON-encoded values. +type RuleCodec struct{} + +var _ db.KVCodec[string, *SlotRule] = RuleCodec{} + +// EncodeKey uses the rule id verbatim. +func (RuleCodec) EncodeKey(id string) string { + return id +} + +// DecodeKey uses the stored key verbatim. +func (RuleCodec) DecodeKey(key string) (string, error) { + if key == "" { + return "", fmt.Errorf("empty slot rule key") + } + + return key, nil +} + +// EncodeValue JSON-encodes a slot rule. +func (RuleCodec) EncodeValue(rule *SlotRule) ([]byte, error) { + if rule == nil { + return nil, fmt.Errorf("cannot encode nil slot rule") + } + + return json.Marshal(rule) +} + +// DecodeValue JSON-decodes a slot rule. +func (RuleCodec) DecodeValue(value []byte) (*SlotRule, error) { + rule := &SlotRule{} + if err := json.Unmarshal(value, rule); err != nil { + return nil, fmt.Errorf("failed to decode slot rule: %w", err) + } + + return rule, nil +} diff --git a/pkg/action_plan/rules.go b/pkg/action_plan/rules.go new file mode 100644 index 0000000..e01a931 --- /dev/null +++ b/pkg/action_plan/rules.go @@ -0,0 +1,152 @@ +package action_plan + +import ( + "fmt" + "regexp" + "slices" + "time" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" +) + +// RulesNamespace is the kv_store namespace holding the persisted recurring +// rules. +const RulesNamespace = "slot_rules" + +// MaxRules bounds the persisted rule set. Rules are evaluated per slot, and a +// long list is a sign that explicit plans are the better tool. +const MaxRules = 32 + +// ruleIDPattern constrains rule ids to stable, url/kv-safe slugs. +var ruleIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + +// SlotRule is a recurring plan template: every slot whose index within the +// epoch matches gets the rule's categories as its plan. It exists for +// scenarios that must repeat for a whole devnet run — e.g. "win slot 31 of +// every epoch and withhold the payload" — which explicit per-slot plans can +// only express for a bounded horizon. +// +// Precedence is wholesale, never merged: an explicit plan for a slot replaces +// any rule, and a plan with IgnoreRules opts the slot out entirely. Rules are +// resolved at freeze time from the live rule set, exactly like the global +// config baseline, so a rule change never rewrites an already frozen slot. +type SlotRule struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Description string `json:"description,omitempty"` + + // SlotsInEpoch are the matched slot indices within an epoch (0-based, so + // 31 is the last slot of a 32-slot epoch). + SlotsInEpoch []uint64 `json:"slots_in_epoch"` + + // FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is + // unbounded on that side. + FromEpoch *uint64 `json:"from_epoch,omitempty"` + ToEpoch *uint64 `json:"to_epoch,omitempty"` + + Bid *BidPlan `json:"bid,omitempty"` + BuilderAPI *BuilderAPIPlan `json:"builder_api,omitempty"` + Reveal *RevealPlan `json:"reveal,omitempty"` + Build *BuildPlan `json:"build,omitempty"` + Transforms *TransformPlan `json:"transforms,omitempty"` + + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +// Clone returns a deep copy. Every rule crossing the package boundary is a +// clone, so callers can never mutate stored state. +func (r *SlotRule) Clone() *SlotRule { + if r == nil { + return nil + } + + c := *r + c.SlotsInEpoch = slices.Clone(r.SlotsInEpoch) + c.FromEpoch = cloneScalar(r.FromEpoch) + c.ToEpoch = cloneScalar(r.ToEpoch) + c.Bid = r.Bid.clone() + c.BuilderAPI = r.BuilderAPI.clone() + c.Reveal = r.Reveal.clone() + c.Build = r.Build.clone() + c.Transforms = r.Transforms.clone() + + return &c +} + +// Matches reports whether the rule applies to the slot. A disabled rule never +// matches. +func (r *SlotRule) Matches(slot phase0.Slot, slotsPerEpoch uint64) bool { + if r == nil || !r.Enabled || slotsPerEpoch == 0 { + return false + } + + epoch := uint64(slot) / slotsPerEpoch + if r.FromEpoch != nil && epoch < *r.FromEpoch { + return false + } + + if r.ToEpoch != nil && epoch > *r.ToEpoch { + return false + } + + return slices.Contains(r.SlotsInEpoch, uint64(slot)%slotsPerEpoch) +} + +// planFor materializes the rule as the slot's plan. The result is marked with +// the rule id and is never persisted — it is synthesized on every read. +func (r *SlotRule) planFor(slot phase0.Slot) *SlotPlan { + return &SlotPlan{ + Slot: slot, + Bid: r.Bid.clone(), + BuilderAPI: r.BuilderAPI.clone(), + Reveal: r.Reveal.clone(), + Build: r.Build.clone(), + Transforms: r.Transforms.clone(), + RuleID: r.ID, + UpdatedAt: r.UpdatedAt, + UpdatedBy: r.UpdatedBy, + } +} + +// Validate checks the matcher and, through a materialized plan, every category +// against the chain spec. +func (r *SlotRule) Validate(slotsPerEpoch uint64, secondsPerSlot time.Duration) error { + if !ruleIDPattern.MatchString(r.ID) { + return fmt.Errorf("rule id %q must match %s", r.ID, ruleIDPattern.String()) + } + + if len(r.SlotsInEpoch) == 0 { + return fmt.Errorf("rule %q: slots_in_epoch must not be empty", r.ID) + } + + seen := make(map[uint64]struct{}, len(r.SlotsInEpoch)) + + for _, index := range r.SlotsInEpoch { + if slotsPerEpoch > 0 && index >= slotsPerEpoch { + return fmt.Errorf("rule %q: slot index %d out of range for %d slots per epoch", + r.ID, index, slotsPerEpoch) + } + + if _, dup := seen[index]; dup { + return fmt.Errorf("rule %q: duplicate slot index %d", r.ID, index) + } + + seen[index] = struct{}{} + } + + if r.FromEpoch != nil && r.ToEpoch != nil && *r.ToEpoch < *r.FromEpoch { + return fmt.Errorf("rule %q: invalid epoch range %d..%d", r.ID, *r.FromEpoch, *r.ToEpoch) + } + + plan := r.planFor(0) + if plan.IsEmpty() { + return fmt.Errorf("rule %q carries no instruction", r.ID) + } + + if err := plan.Validate(secondsPerSlot); err != nil { + return fmt.Errorf("rule %q: %w", r.ID, err) + } + + return nil +} diff --git a/pkg/action_plan/rules_test.go b/pkg/action_plan/rules_test.go new file mode 100644 index 0000000..e263592 --- /dev/null +++ b/pkg/action_plan/rules_test.go @@ -0,0 +1,321 @@ +package action_plan + +import ( + "encoding/json" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" +) + +// slot31Rule is the motivating scenario: win the last slot of every epoch and +// never reveal its payload. +func slot31Rule() *SlotRule { + value := uint64(1_000_000) + + return &SlotRule{ + ID: "slot31-withhold", + Enabled: true, + Description: "win slot 31 and withhold the payload", + SlotsInEpoch: []uint64{31}, + Bid: &BidPlan{ + Mode: ModeCustom, + BidValueGwei: &value, + IgnoreMissingPrefs: true, + }, + Reveal: &RevealPlan{Mode: ModeDisabled}, + } +} + +func TestSlotRuleMatches(t *testing.T) { + rule := slot31Rule() + + require.True(t, rule.Matches(31, 32)) + require.True(t, rule.Matches(32*500+31, 32)) + require.False(t, rule.Matches(30, 32)) + require.False(t, rule.Matches(32, 32)) + + // A disabled rule never matches, and an unknown epoch length cannot match. + rule.Enabled = false + require.False(t, rule.Matches(31, 32)) + + rule.Enabled = true + require.False(t, rule.Matches(31, 0)) +} + +func TestSlotRuleEpochBounds(t *testing.T) { + rule := slot31Rule() + from, to := uint64(10), uint64(12) + rule.FromEpoch = &from + rule.ToEpoch = &to + + require.False(t, rule.Matches(9*32+31, 32)) + require.True(t, rule.Matches(10*32+31, 32)) + require.True(t, rule.Matches(12*32+31, 32)) + require.False(t, rule.Matches(13*32+31, 32)) +} + +func TestSlotRuleValidate(t *testing.T) { + chainSvc := newStubChain() + spec := chainSvc.spec + + require.NoError(t, slot31Rule().Validate(spec.SlotsPerEpoch, spec.SecondsPerSlot)) + + tests := []struct { + name string + mutate func(*SlotRule) + errMsg string + }{ + {"bad id", func(r *SlotRule) { r.ID = "Not A Slug" }, "must match"}, + {"no slots", func(r *SlotRule) { r.SlotsInEpoch = nil }, "must not be empty"}, + {"slot out of range", func(r *SlotRule) { r.SlotsInEpoch = []uint64{32} }, "out of range"}, + {"duplicate slot", func(r *SlotRule) { r.SlotsInEpoch = []uint64{31, 31} }, "duplicate slot index"}, + { + "inverted epoch range", + func(r *SlotRule) { + from, to := uint64(9), uint64(5) + r.FromEpoch, r.ToEpoch = &from, &to + }, + "invalid epoch range", + }, + { + "no instruction", + func(r *SlotRule) { r.Bid, r.Reveal = nil, nil }, + "carries no instruction", + }, + { + "invalid category", + func(r *SlotRule) { r.Reveal = &RevealPlan{Mode: "bogus"} }, + "invalid mode", + }, + { + "overrides on a disabled category", + func(r *SlotRule) { + start := int64(100) + r.Bid = &BidPlan{Mode: ModeDisabled, BidStartTime: &start} + }, + "overrides are only allowed in custom mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule := slot31Rule() + tt.mutate(rule) + require.ErrorContains(t, rule.Validate(spec.SlotsPerEpoch, spec.SecondsPerSlot), tt.errMsg) + }) + } +} + +func TestSetRulesAtomicAndPersistedShape(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + rules, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + require.Len(t, rules, 1) + require.Equal(t, "tester", rules[0].UpdatedBy) + require.False(t, rules[0].UpdatedAt.IsZero()) + + // A rejected batch leaves the committed set untouched. + broken := slot31Rule() + broken.ID = "second" + broken.SlotsInEpoch = []uint64{99} + + _, err = svc.SetRules([]*SlotRule{slot31Rule(), broken}, "tester") + require.ErrorContains(t, err, "out of range") + require.Len(t, svc.Rules(), 1) + + // Duplicate ids are rejected before anything is committed. + _, err = svc.SetRules([]*SlotRule{slot31Rule(), slot31Rule()}, "tester") + require.ErrorContains(t, err, "duplicate rule id") + + // The set is replaced wholesale: the old rule is gone. + replacement := slot31Rule() + replacement.ID = "other" + + rules, err = svc.SetRules([]*SlotRule{replacement}, "tester") + require.NoError(t, err) + require.Len(t, rules, 1) + require.Equal(t, "other", rules[0].ID) + + // Clearing works, and callers never hold a reference into the store. + rules, err = svc.SetRules(nil, "tester") + require.NoError(t, err) + require.Empty(t, rules) +} + +func TestRulesAreCloned(t *testing.T) { + svc := newTestService(newStubChain(), nil) + rule := slot31Rule() + + _, err := svc.SetRules([]*SlotRule{rule}, "tester") + require.NoError(t, err) + + // Mutating the caller's rule must not reach the store. + rule.SlotsInEpoch[0] = 7 + rule.Enabled = false + *rule.Bid.BidValueGwei = 1 + + stored := svc.Rules() + require.Equal(t, []uint64{31}, stored[0].SlotsInEpoch) + require.True(t, stored[0].Enabled) + require.Equal(t, uint64(1_000_000), *stored[0].Bid.BidValueGwei) + + // Nor does mutating a returned clone. + stored[0].SlotsInEpoch[0] = 7 + require.Equal(t, []uint64{31}, svc.Rules()[0].SlotsInEpoch) +} + +func TestPlanForSlotResolvesRules(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + matched := svc.PlanForSlot(2047) // 2047 = epoch 63, slot 31 + require.NotNil(t, matched) + require.Equal(t, "slot31-withhold", matched.RuleID) + require.Equal(t, phase0.Slot(2047), matched.Slot) + require.Equal(t, ModeDisabled, matched.Reveal.Mode) + + require.Nil(t, svc.PlanForSlot(2046)) + + // Explicit plans win wholesale — the rule's reveal instruction is gone. + _, err = svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2047}, + Bid: json.RawMessage(`{"mode":"custom","bid_min_amount":5}`), + }}, "tester") + require.NoError(t, err) + + explicit := svc.PlanForSlot(2047) + require.Empty(t, explicit.RuleID) + require.Nil(t, explicit.Reveal) + require.Equal(t, uint64(5), *explicit.Bid.BidMinAmount) + + // Get only ever reports the stored plan. + require.Nil(t, svc.Get(2079)) +} + +func TestIgnoreRulesOptsSlotOut(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + ignore := true + + event, err := svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2047}, + IgnoreRules: &ignore, + }}, "tester") + require.NoError(t, err) + require.NotNil(t, event.Plans[0], "an opt-out is an instruction, not an empty plan") + require.True(t, event.Plans[0].IgnoreRules) + + plan := svc.PlanForSlot(2047) + require.NotNil(t, plan) + require.Empty(t, plan.RuleID) + require.Nil(t, plan.Bid) + require.Nil(t, plan.Reveal) + + // The slot runs on the plain global baseline again. + frozen := svc.Freeze(2047) + require.NotNil(t, frozen.Reveal) + require.False(t, frozen.Reveal.Suppressed) +} + +func TestFreezeAppliesRule(t *testing.T) { + chainSvc := newStubChain() + svc := newTestService(chainSvc, nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + frozen := svc.Freeze(2047) + require.NotNil(t, frozen.Plan) + require.Equal(t, "slot31-withhold", frozen.Plan.RuleID) + + // The rule forces the build past the schedule and suppresses the reveal. + require.True(t, frozen.Build.Build) + require.True(t, frozen.Build.Forced) + require.NotNil(t, frozen.Bid) + require.Equal(t, uint64(1_000_000), *frozen.Bid.ValueGwei) + require.True(t, frozen.Bid.IgnoreMissingPrefs) + require.True(t, frozen.Reveal.Suppressed) + + // A frozen slot keeps its snapshot even when the rule set changes. + _, err = svc.SetRules(nil, "tester") + require.NoError(t, err) + require.True(t, svc.Freeze(2047).Reveal.Suppressed) +} + +func TestGetRangeIncludesRulePlans(t *testing.T) { + chainSvc := newStubChain() + chainSvc.currentSlot = 2020 + svc := newTestService(chainSvc, nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + // Epochs 62..64 cover slots 1984..2079, so 2015, 2047 and 2079 match the + // rule — but 2015 has already passed. + plans := svc.GetRange(1984, 2079) + require.Len(t, plans, 2, "past slots keep their recorded history, not the current rule") + require.Equal(t, phase0.Slot(2047), plans[0].Slot) + require.Equal(t, phase0.Slot(2079), plans[1].Slot) + require.Equal(t, "slot31-withhold", plans[0].RuleID) + + // A frozen slot is no longer open for the rule set to describe. + svc.Freeze(2047) + plans = svc.GetRange(1984, 2079) + require.Len(t, plans, 1) + require.Equal(t, phase0.Slot(2079), plans[0].Slot) + + // Explicit plans replace the rule-derived entry rather than duplicating it. + _, err = svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2079}, + Bid: json.RawMessage(`{"mode":"disabled"}`), + }}, "tester") + require.NoError(t, err) + + plans = svc.GetRange(1984, 2079) + require.Len(t, plans, 1) + require.Equal(t, phase0.Slot(2079), plans[0].Slot) + require.Empty(t, plans[0].RuleID) + require.Equal(t, ModeDisabled, plans[0].Bid.Mode) +} + +func TestMatchRuleOrderIsDeterministic(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + first := slot31Rule() + first.ID = "aaa-first" + + second := slot31Rule() + second.ID = "zzz-second" + second.Reveal = &RevealPlan{Mode: ModeCustom} + + _, err := svc.SetRules([]*SlotRule{second, first}, "tester") + require.NoError(t, err) + + require.Equal(t, "aaa-first", svc.PlanForSlot(2047).RuleID) +} + +func TestRuleCodecRoundTrip(t *testing.T) { + codec := RuleCodec{} + + encoded, err := codec.EncodeValue(slot31Rule()) + require.NoError(t, err) + + decoded, err := codec.DecodeValue(encoded) + require.NoError(t, err) + require.Equal(t, slot31Rule().SlotsInEpoch, decoded.SlotsInEpoch) + require.Equal(t, ModeDisabled, decoded.Reveal.Mode) + + key, err := codec.DecodeKey(codec.EncodeKey("slot31-withhold")) + require.NoError(t, err) + require.Equal(t, "slot31-withhold", key) + + _, err = codec.DecodeKey("") + require.Error(t, err) +} diff --git a/pkg/action_plan/service.go b/pkg/action_plan/service.go index ddb6527..6e9b058 100644 --- a/pkg/action_plan/service.go +++ b/pkg/action_plan/service.go @@ -38,12 +38,19 @@ type PlanChangeEvent struct { Plans []*SlotPlan `json:"plans"` } -// PlanService owns the sparse per-slot action plan store, its freeze state and -// its persistence. It is the single writer; all reads return deep copies. +// RuleChangeEvent carries the authoritative rule set after a committed change. +type RuleChangeEvent struct { + Rules []*SlotRule `json:"rules"` +} + +// PlanService owns the sparse per-slot action plan store, the recurring rules +// that fill the slots it does not cover, their freeze state and their +// persistence. It is the single writer; all reads return deep copies. type PlanService struct { cfg *config.Config chainSvc chain.Service store *memstore.Store[phase0.Slot, *SlotPlan] + rules *memstore.Store[string, *SlotRule] mu sync.Mutex frozen map[phase0.Slot]*FrozenPlan @@ -52,7 +59,8 @@ type PlanService struct { // builds are exempt). Guarded by mu. slotsBuilt uint64 - changes utils.Dispatcher[*PlanChangeEvent] + changes utils.Dispatcher[*PlanChangeEvent] + ruleChanges utils.Dispatcher[*RuleChangeEvent] ctx context.Context cancel context.CancelFunc @@ -68,15 +76,18 @@ func NewPlanService(cfg *config.Config, chainSvc chain.Service, log logrus.Field cfg: cfg, chainSvc: chainSvc, store: memstore.New[phase0.Slot, *SlotPlan](), + rules: memstore.New[string, *SlotRule](), frozen: make(map[phase0.Slot]*FrozenPlan, 64), log: log.WithField("component", "action-plan"), } } -// SetPersistence attaches the state-db backed persistence (kv_store namespace -// "slot_plans") and rehydrates previously stored plans. +// SetPersistence attaches the state-db backed persistence (kv_store namespaces +// "slot_plans" and "slot_rules") and rehydrates previously stored plans and +// recurring rules. func (s *PlanService) SetPersistence(ctx context.Context, stateDB *db.Database) { s.store.SetPersistence(ctx, db.NewKVPersistence(stateDB, Namespace, PlanCodec{}), s.log) + s.rules.SetPersistence(ctx, db.NewKVPersistence(stateDB, RulesNamespace, RuleCodec{}), s.log) } // Start launches the pruning loop (driven by epoch transitions). @@ -103,6 +114,7 @@ func (s *PlanService) Stop() { s.wg.Wait() s.store.Stop() + s.rules.Stop() s.log.Info("Action plan service stopped") } @@ -125,7 +137,9 @@ func (s *PlanService) run(epochSub *utils.Subscription[*chain.EpochStats]) { } } -// Get returns a deep copy of the slot's plan, or nil when none exists. +// Get returns a deep copy of the slot's explicitly stored plan, or nil when +// none exists. Recurring rules are deliberately not consulted — use +// PlanForSlot for the effective plan. func (s *PlanService) Get(slot phase0.Slot) *SlotPlan { plan, ok := s.store.Get(slot) if !ok { @@ -135,8 +149,26 @@ func (s *PlanService) Get(slot phase0.Slot) *SlotPlan { return plan.Clone() } -// GetRange returns deep copies of all plans within [minSlot, maxSlot], -// slot-ascending. +// PlanForSlot returns the effective plan for a slot: the explicitly stored +// plan if there is one, otherwise the first matching recurring rule +// materialized for the slot (marked with its rule id). Precedence is +// wholesale — an explicit plan is never merged with a rule. +func (s *PlanService) PlanForSlot(slot phase0.Slot) *SlotPlan { + if plan, ok := s.store.Get(slot); ok { + return plan.Clone() + } + + rule := matchRule(s.sortedRules(), slot, s.slotsPerEpoch()) + if rule == nil { + return nil + } + + return rule.planFor(slot) +} + +// GetRange returns deep copies of all effective plans within +// [minSlot, maxSlot], slot-ascending: every explicitly stored plan plus the +// plans recurring rules contribute to the still-open slots of the range. func (s *PlanService) GetRange(minSlot, maxSlot phase0.Slot) []*SlotPlan { entries := s.store.Entries() plans := make([]*SlotPlan, 0, len(entries)) @@ -147,15 +179,78 @@ func (s *PlanService) GetRange(minSlot, maxSlot phase0.Slot) []*SlotPlan { } } + plans = append(plans, s.rulePlansInRange(minSlot, maxSlot, entries)...) + sortPlansBySlot(plans) return plans } +// rulePlansInRange materializes the rule-derived plans of a range. Only slots +// that can still execute under the CURRENT rule set are covered: past and +// already frozen slots ran under whatever was configured back then, and their +// slot result carries the authoritative applied plan. +func (s *PlanService) rulePlansInRange(minSlot, maxSlot phase0.Slot, + explicit map[phase0.Slot]*SlotPlan) []*SlotPlan { + rules := s.sortedRules() + slotsPerEpoch := s.slotsPerEpoch() + + if len(rules) == 0 || slotsPerEpoch == 0 { + return nil + } + + if firstOpen := s.chainSvc.GetCurrentSlot() + 1; firstOpen > minSlot { + minSlot = firstOpen + } + + if maxSlot < minSlot { + return nil + } + + frozen := s.frozenSlotsInRange(minSlot, maxSlot) + + var plans []*SlotPlan + + for slot := minSlot; ; slot++ { + _, hasPlan := explicit[slot] + _, isFrozen := frozen[slot] + + if !hasPlan && !isFrozen { + if rule := matchRule(rules, slot, slotsPerEpoch); rule != nil { + plans = append(plans, rule.planFor(slot)) + } + } + + if slot == maxSlot { + break + } + } + + return plans +} + +// frozenSlotsInRange snapshots the freeze markers within the range under one +// lock; the marker map is pruned to roughly an epoch, so this stays cheap. +func (s *PlanService) frozenSlotsInRange(minSlot, maxSlot phase0.Slot) map[phase0.Slot]struct{} { + s.mu.Lock() + defer s.mu.Unlock() + + frozen := make(map[phase0.Slot]struct{}, len(s.frozen)) + + for slot := range s.frozen { + if slot >= minSlot && slot <= maxSlot { + frozen[slot] = struct{}{} + } + } + + return frozen +} + // Freeze resolves and records the slot's immutable execution snapshot. The // first caller wins; every later caller (and every other decision point of // the same slot) receives the identical snapshot. From this moment on the -// slot's plan can no longer be edited. +// slot's plan can no longer be edited, and later rule changes no longer +// affect it. func (s *PlanService) Freeze(slot phase0.Slot) *FrozenPlan { s.mu.Lock() defer s.mu.Unlock() @@ -164,10 +259,8 @@ func (s *PlanService) Freeze(slot phase0.Slot) *FrozenPlan { return frozen } - var plan *SlotPlan - if stored, ok := s.store.Get(slot); ok { - plan = stored.Clone() - } + // Rule resolution reads the plan/rule stores only — it must never take s.mu. + plan := s.PlanForSlot(slot) fork := s.chainSvc.ActiveForkAtEpoch(s.chainSvc.GetEpochOfSlot(slot)) frozen := resolveFrozenPlan(slot, plan, s.cfg, fork, time.Now(), s.slotsBuilt) @@ -338,6 +431,115 @@ func (s *PlanService) SubscribeChanges(capacity int) *utils.Subscription[*PlanCh return s.changes.Subscribe(capacity, false) } +// SubscribeRuleChanges subscribes to committed recurring-rule mutations +// (non-blocking delivery; intended for the SSE bridge). +func (s *PlanService) SubscribeRuleChanges(capacity int) *utils.Subscription[*RuleChangeEvent] { + return s.ruleChanges.Subscribe(capacity, false) +} + +// Rules returns deep copies of all recurring rules, id-ascending — which is +// also the order in which they are matched. +func (s *PlanService) Rules() []*SlotRule { + stored := s.sortedRules() + rules := make([]*SlotRule, len(stored)) + + for i, rule := range stored { + rules[i] = rule.Clone() + } + + return rules +} + +// SetRules atomically replaces the whole recurring rule set: either every rule +// validates and is committed, or nothing changes. Already frozen slots keep +// the plan they froze with; every later slot resolves against the new set. +func (s *PlanService) SetRules(rules []*SlotRule, actor string) ([]*SlotRule, error) { + if len(rules) > MaxRules { + return nil, fmt.Errorf("too many rules: %d (max %d)", len(rules), MaxRules) + } + + spec := s.chainSvc.GetChainSpec() + now := time.Now() + + staged := make(map[string]*SlotRule, len(rules)) + + for i, rule := range rules { + if rule == nil { + return nil, fmt.Errorf("rule %d: must not be null", i) + } + + if err := rule.Validate(spec.SlotsPerEpoch, spec.SecondsPerSlot); err != nil { + return nil, err + } + + if _, dup := staged[rule.ID]; dup { + return nil, fmt.Errorf("duplicate rule id %q", rule.ID) + } + + stored := rule.Clone() + stored.UpdatedAt = now + stored.UpdatedBy = actor + staged[rule.ID] = stored + } + + s.mu.Lock() + defer s.mu.Unlock() + + for id := range s.rules.Entries() { + if _, keep := staged[id]; !keep { + s.rules.Delete(id) + } + } + + for id, rule := range staged { + s.rules.Put(id, rule) + } + + event := &RuleChangeEvent{Rules: s.Rules()} + + s.log.WithFields(logrus.Fields{ + "rules": len(event.Rules), + "actor": actor, + }).Info("Updated action plan rules") + + s.ruleChanges.Fire(event) + + return event.Rules, nil +} + +// sortedRules returns the stored rules in match order (id-ascending). The +// returned values are the stored pointers — package-internal read-only use. +func (s *PlanService) sortedRules() []*SlotRule { + rules := s.rules.Values() + + sort.Slice(rules, func(i, j int) bool { + return rules[i].ID < rules[j].ID + }) + + return rules +} + +func (s *PlanService) slotsPerEpoch() uint64 { + spec := s.chainSvc.GetChainSpec() + if spec == nil { + return 0 + } + + return spec.SlotsPerEpoch +} + +// matchRule returns the first rule of the (id-ordered) set that applies to the +// slot, so the id decides which rule wins when several match. +func matchRule(rules []*SlotRule, slot phase0.Slot, slotsPerEpoch uint64) *SlotRule { + for _, rule := range rules { + if rule.Matches(slot, slotsPerEpoch) { + return rule + } + } + + return nil +} + // pruneForEpoch drops past plans outside the retention window and stale // freeze markers. Future plans never match the cutoff and are never pruned. func (s *PlanService) pruneForEpoch(epoch phase0.Epoch) { diff --git a/pkg/action_plan/types.go b/pkg/action_plan/types.go index c0f91df..22c1218 100644 --- a/pkg/action_plan/types.go +++ b/pkg/action_plan/types.go @@ -330,8 +330,18 @@ type SlotPlan struct { Reveal *RevealPlan `json:"reveal,omitempty"` Build *BuildPlan `json:"build,omitempty"` Transforms *TransformPlan `json:"transforms,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by"` + + // IgnoreRules opts the slot out of every recurring rule, so it runs on the + // plain global baseline. Without it an empty plan is dropped, which would + // hand the slot straight back to the matching rule. + IgnoreRules bool `json:"ignore_rules,omitempty"` + + // RuleID marks a plan synthesized from a recurring rule. Synthesized plans + // are never stored — the field is empty on every persisted plan. + RuleID string `json:"rule_id,omitempty"` + + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by"` } // Clone returns a deep copy. All plan values crossing the package boundary @@ -351,10 +361,11 @@ func (p *SlotPlan) Clone() *SlotPlan { return &c } -// IsEmpty reports whether the plan carries no instruction at all. +// IsEmpty reports whether the plan carries no instruction at all. An explicit +// rule opt-out is an instruction, so such a plan is kept. func (p *SlotPlan) IsEmpty() bool { return p == nil || (p.Bid == nil && p.BuilderAPI == nil && p.Reveal == nil && - p.Build.isZero() && p.Transforms.isZero()) + p.Build.isZero() && p.Transforms.isZero() && !p.IgnoreRules) } // BidOverride returns the per-slot enable override for p2p bidding: @@ -459,6 +470,10 @@ type PlanUpdate struct { Build json.RawMessage `json:"build,omitempty"` Transforms json.RawMessage `json:"transforms,omitempty"` + // IgnoreRules is two-state on purpose: absent = unchanged, true/false = + // set the slot's recurring-rule opt-out. + IgnoreRules *bool `json:"ignore_rules,omitempty"` + Set map[string]json.RawMessage `json:"set,omitempty"` } @@ -560,6 +575,10 @@ func ApplyUpdateToPlan(existing *SlotPlan, u *PlanUpdate) (*SlotPlan, error) { result.Transforms = transforms } + if u.IgnoreRules != nil { + result.IgnoreRules = *u.IgnoreRules + } + if err := applySetPaths(result, u.Set); err != nil { return nil, err } @@ -636,8 +655,8 @@ func applySetPaths(plan *SlotPlan, set map[string]json.RawMessage) error { plan.Transforms = patched default: - return fmt.Errorf( - "set: unknown path %q (categories: bid, builder_api, reveal, build, transforms)", path) + return fmt.Errorf("set: unknown path %q (categories: bid, builder_api, reveal, "+ + "build, transforms; the rule opt-out is the top-level \"ignore_rules\" member)", path) } } diff --git a/pkg/webui/handlers/api/action_plan.go b/pkg/webui/handlers/api/action_plan.go index 3e6bf23..ffa8ec5 100644 --- a/pkg/webui/handlers/api/action_plan.go +++ b/pkg/webui/handlers/api/action_plan.go @@ -43,6 +43,16 @@ type UpdateActionPlanResponse struct { Plans []*action_plan.SlotPlan `json:"plans"` } +// ActionPlanRulesRequest replaces the whole recurring rule set atomically. +type ActionPlanRulesRequest struct { + Rules []*action_plan.SlotRule `json:"rules"` +} + +// ActionPlanRulesResponse returns the authoritative recurring rule set. +type ActionPlanRulesResponse struct { + Rules []*action_plan.SlotRule `json:"rules"` +} + // SlotResultsResponse is the response of the slot results range query. type SlotResultsResponse struct { Results []*slot_results.SlotResult `json:"results"` @@ -195,6 +205,87 @@ func (h *APIHandler) UpdateActionPlan(w http.ResponseWriter, r *http.Request) { }) } +// GetActionPlanRules godoc +// @Id getActionPlanRules +// @Summary Get recurring action plan rules +// @Tags ActionPlan +// @Description Returns the recurring slot rules, id-ascending (which is also +// @Description their match order). A rule applies its categories to every slot +// @Description whose index within the epoch matches, unless the slot carries +// @Description an explicit plan. +// @Produce json +// @Success 200 {object} ActionPlanRulesResponse +// @Failure 503 {object} map[string]string "Plan service unavailable" +// @Router /api/buildoor/action-plan/rules [get] +func (h *APIHandler) GetActionPlanRules(w http.ResponseWriter, r *http.Request) { + if h.planSvc == nil { + writeError(w, http.StatusServiceUnavailable, "action plan service not available") + return + } + + rules := h.planSvc.Rules() + if rules == nil { + rules = []*action_plan.SlotRule{} + } + + writeJSON(w, http.StatusOK, &ActionPlanRulesResponse{Rules: rules}) +} + +// UpdateActionPlanRules godoc +// @Id updateActionPlanRules +// @Summary Replace the recurring action plan rules +// @Tags ActionPlan +// @Description Atomically replaces the whole recurring rule set (every rule +// @Description validates or nothing changes). Each rule matches slot indices +// @Description within an epoch, optionally bounded to an epoch window, and +// @Description carries the same categories as a slot plan — e.g. win slot 31 +// @Description of every epoch and withhold its payload. Already frozen slots +// @Description keep the plan they froze with. Requires authentication. +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer token" +// @Param request body ActionPlanRulesRequest true "Full rule set" +// @Success 200 {object} ActionPlanRulesResponse "Authoritative rule set" +// @Failure 400 {object} map[string]string "Validation error" +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 503 {object} map[string]string "Plan service unavailable" +// @Router /api/buildoor/action-plan/rules [post] +func (h *APIHandler) UpdateActionPlanRules(w http.ResponseWriter, r *http.Request) { + token := h.authHandler.CheckAuthToken(r.Header.Get("Authorization")) + if token == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + if h.planSvc == nil { + writeError(w, http.StatusServiceUnavailable, "action plan service not available") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxPlanUpdateBodyBytes) + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + var req ActionPlanRulesRequest + if err := decoder.Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + rules, err := h.planSvc.SetRules(req.Rules, actorFromToken(token)) + if err != nil { + h.audit(r, token, "action_plan.rules", "", req, "error: "+err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) + + return + } + + h.audit(r, token, "action_plan.rules", "", req, "ok") + + writeJSON(w, http.StatusOK, &ActionPlanRulesResponse{Rules: rules}) +} + // GetSlotResults godoc // @Id getSlotResults // @Summary Get per-slot results diff --git a/pkg/webui/handlers/api/action_plan_test.go b/pkg/webui/handlers/api/action_plan_test.go index 5b4e0a6..caca3df 100644 --- a/pkg/webui/handlers/api/action_plan_test.go +++ b/pkg/webui/handlers/api/action_plan_test.go @@ -215,6 +215,83 @@ func TestUpdateActionPlanErrorMapping(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) } +func postActionPlanRules(t *testing.T, env *planAPITestEnv, body string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/api/buildoor/action-plan/rules", + bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + env.handler.UpdateActionPlanRules(rec, req) + + return rec +} + +func TestActionPlanRulesRoundTrip(t *testing.T) { + env := newPlanAPITestEnv(t) + + // The motivating scenario: win the last slot of every epoch, never reveal. + rec := postActionPlanRules(t, env, `{"rules":[{ + "id":"slot31-withhold", + "enabled":true, + "description":"win slot 31 and withhold the payload", + "slots_in_epoch":[31], + "bid":{"mode":"custom","bid_value_gwei":1000000,"ignore_missing_prefs":true}, + "reveal":{"mode":"disabled"} + }]}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp ActionPlanRulesResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Rules, 1) + assert.Equal(t, "slot31-withhold", resp.Rules[0].ID) + + rec = httptest.NewRecorder() + env.handler.GetActionPlanRules(rec, + httptest.NewRequest(http.MethodGet, "/api/buildoor/action-plan/rules", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Rules, 1) + assert.Equal(t, action_plan.ModeDisabled, resp.Rules[0].Reveal.Mode) + + // Rule-derived plans surface in the range query, marked with their rule. + rec = httptest.NewRecorder() + env.handler.GetActionPlan(rec, + httptest.NewRequest(http.MethodGet, "/api/buildoor/action-plan?min_slot=1024&max_slot=1055", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + var plans ActionPlanResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &plans)) + require.Len(t, plans.Plans, 1) + assert.Equal(t, phase0.Slot(1055), plans.Plans[0].Slot) + assert.Equal(t, "slot31-withhold", plans.Plans[0].RuleID) +} + +func TestActionPlanRulesValidation(t *testing.T) { + env := newPlanAPITestEnv(t) + + tests := []struct { + name string + body string + }{ + {"bad id", `{"rules":[{"id":"Bad Id","enabled":true,"slots_in_epoch":[31],"reveal":{"mode":"disabled"}}]}`}, + {"slot out of range", `{"rules":[{"id":"r","enabled":true,"slots_in_epoch":[32],"reveal":{"mode":"disabled"}}]}`}, + {"no instruction", `{"rules":[{"id":"r","enabled":true,"slots_in_epoch":[31]}]}`}, + {"unknown field", `{"rules":[{"id":"r","enabled":true,"slots_in_epoch":[31],"nope":1}]}`}, + {"malformed", `{`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, http.StatusBadRequest, postActionPlanRules(t, env, tt.body).Code) + }) + } + + assert.Empty(t, env.planSvc.Rules(), "no rejected rule may be committed") +} + func TestGetSlotResultsRange(t *testing.T) { env := newPlanAPITestEnv(t) diff --git a/pkg/webui/handlers/api/events.go b/pkg/webui/handlers/api/events.go index 47cfcae..4c047bd 100644 --- a/pkg/webui/handlers/api/events.go +++ b/pkg/webui/handlers/api/events.go @@ -58,6 +58,7 @@ const ( EventTypeBuilderAPISubmitBlockDlvd EventType = "builder_api_submit_block_delivered" EventTypeServiceStatus EventType = "service_status" EventTypeActionPlanUpdated EventType = "action_plan_updated" + EventTypeActionPlanRulesUpdated EventType = "action_plan_rules_updated" EventTypeSlotResultUpdated EventType = "slot_result_updated" EventTypeLifecycle EventType = "lifecycle" EventTypeBidIncluded EventType = "bid_included" @@ -626,9 +627,15 @@ func (m *EventStreamManager) Start() { var planChangeChan <-chan *action_plan.PlanChangeEvent + var ruleChangeSub *utils.Subscription[*action_plan.RuleChangeEvent] + + var ruleChangeChan <-chan *action_plan.RuleChangeEvent + if m.planSvc != nil { planChangeSub = m.planSvc.SubscribeChanges(16) planChangeChan = planChangeSub.Channel() + ruleChangeSub = m.planSvc.SubscribeRuleChanges(16) + ruleChangeChan = ruleChangeSub.Channel() } // Subscribe to slot result updates (if results tracker available). SSE is @@ -711,6 +718,10 @@ func (m *EventStreamManager) Start() { defer planChangeSub.Unsubscribe() } + if ruleChangeSub != nil { + defer ruleChangeSub.Unsubscribe() + } + if resultUpdateSub != nil { defer resultUpdateSub.Unsubscribe() } @@ -796,6 +807,18 @@ func (m *EventStreamManager) Start() { Data: event, }) + case event, ok := <-ruleChangeChan: + if !ok { + ruleChangeChan = nil + continue + } + + m.Broadcast(&StreamEvent{ + Type: EventTypeActionPlanRulesUpdated, + Timestamp: time.Now().UnixMilli(), + Data: event, + }) + case event, ok := <-resultUpdateChan: if !ok { resultUpdateChan = nil diff --git a/pkg/webui/handlers/docs/docs.go b/pkg/webui/handlers/docs/docs.go index b0456e0..e6f4830 100644 --- a/pkg/webui/handlers/docs/docs.go +++ b/pkg/webui/handlers/docs/docs.go @@ -146,6 +146,103 @@ const docTemplate = `{ } } }, + "/api/buildoor/action-plan/rules": { + "get": { + "description": "Returns the recurring slot rules, id-ascending (which is also\ntheir match order). A rule applies its categories to every slot\nwhose index within the epoch matches, unless the slot carries\nan explicit plan.", + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Get recurring action plan rules", + "operationId": "getActionPlanRules", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "description": "Atomically replaces the whole recurring rule set (every rule\nvalidates or nothing changes). Each rule matches slot indices\nwithin an epoch, optionally bounded to an epoch window, and\ncarries the same categories as a slot plan — e.g. win slot 31\nof every epoch and withhold its payload. Already frozen slots\nkeep the plan they froze with. Requires authentication.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Replace the recurring action plan rules", + "operationId": "updateActionPlanRules", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Full rule set", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesRequest" + } + } + ], + "responses": { + "200": { + "description": "Authoritative rule set", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/buildoor/action-plan/test-transform": { "post": { "description": "Runs an operator jq expression against a sample payload / bid /\nenvelope (a captured artifact when sample_slot is given and\navailable, otherwise a zero-value template of the object) and\nreturns the transformed JSON, so expressions can be built and\ntested live before being saved to a slot plan. Bid and envelope\ntransforms operate on the MESSAGE, matching production.", @@ -1531,6 +1628,10 @@ const docTemplate = `{ "from_slot": { "type": "integer" }, + "ignore_rules": { + "description": "IgnoreRules is two-state on purpose: absent = unchanged, true/false =\nset the slot's recurring-rule opt-out.", + "type": "boolean" + }, "reveal": { "type": "array", "items": { @@ -1730,9 +1831,17 @@ const docTemplate = `{ "builder_api": { "$ref": "#/definitions/action_plan.BuilderAPIPlan" }, + "ignore_rules": { + "description": "IgnoreRules opts the slot out of every recurring rule, so it runs on the\nplain global baseline. Without it an empty plan is dropped, which would\nhand the slot straight back to the matching rule.", + "type": "boolean" + }, "reveal": { "$ref": "#/definitions/action_plan.RevealPlan" }, + "rule_id": { + "description": "RuleID marks a plan synthesized from a recurring rule. Synthesized plans\nare never stored — the field is empty on every persisted plan.", + "type": "string" + }, "slot": { "type": "integer" }, @@ -1747,6 +1856,55 @@ const docTemplate = `{ } } }, + "action_plan.SlotRule": { + "type": "object", + "properties": { + "bid": { + "$ref": "#/definitions/action_plan.BidPlan" + }, + "build": { + "$ref": "#/definitions/action_plan.BuildPlan" + }, + "builder_api": { + "$ref": "#/definitions/action_plan.BuilderAPIPlan" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_epoch": { + "description": "FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is\nunbounded on that side.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "reveal": { + "$ref": "#/definitions/action_plan.RevealPlan" + }, + "slots_in_epoch": { + "description": "SlotsInEpoch are the matched slot indices within an epoch (0-based, so\n31 is the last slot of a 32-slot epoch).", + "type": "array", + "items": { + "type": "integer" + } + }, + "to_epoch": { + "type": "integer" + }, + "transforms": { + "$ref": "#/definitions/action_plan.TransformPlan" + }, + "updated_at": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + } + }, "action_plan.TransformPlan": { "type": "object", "properties": { @@ -1778,6 +1936,28 @@ const docTemplate = `{ } } }, + "api.ActionPlanRulesRequest": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, + "api.ActionPlanRulesResponse": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, "api.AuditLogResponse": { "type": "object", "properties": { diff --git a/pkg/webui/handlers/docs/swagger.json b/pkg/webui/handlers/docs/swagger.json index d673c28..5b1ed56 100644 --- a/pkg/webui/handlers/docs/swagger.json +++ b/pkg/webui/handlers/docs/swagger.json @@ -135,6 +135,103 @@ } } }, + "/api/buildoor/action-plan/rules": { + "get": { + "description": "Returns the recurring slot rules, id-ascending (which is also\ntheir match order). A rule applies its categories to every slot\nwhose index within the epoch matches, unless the slot carries\nan explicit plan.", + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Get recurring action plan rules", + "operationId": "getActionPlanRules", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "description": "Atomically replaces the whole recurring rule set (every rule\nvalidates or nothing changes). Each rule matches slot indices\nwithin an epoch, optionally bounded to an epoch window, and\ncarries the same categories as a slot plan — e.g. win slot 31\nof every epoch and withhold its payload. Already frozen slots\nkeep the plan they froze with. Requires authentication.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Replace the recurring action plan rules", + "operationId": "updateActionPlanRules", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Full rule set", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesRequest" + } + } + ], + "responses": { + "200": { + "description": "Authoritative rule set", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/buildoor/action-plan/test-transform": { "post": { "description": "Runs an operator jq expression against a sample payload / bid /\nenvelope (a captured artifact when sample_slot is given and\navailable, otherwise a zero-value template of the object) and\nreturns the transformed JSON, so expressions can be built and\ntested live before being saved to a slot plan. Bid and envelope\ntransforms operate on the MESSAGE, matching production.", @@ -1520,6 +1617,10 @@ "from_slot": { "type": "integer" }, + "ignore_rules": { + "description": "IgnoreRules is two-state on purpose: absent = unchanged, true/false =\nset the slot's recurring-rule opt-out.", + "type": "boolean" + }, "reveal": { "type": "array", "items": { @@ -1719,9 +1820,17 @@ "builder_api": { "$ref": "#/definitions/action_plan.BuilderAPIPlan" }, + "ignore_rules": { + "description": "IgnoreRules opts the slot out of every recurring rule, so it runs on the\nplain global baseline. Without it an empty plan is dropped, which would\nhand the slot straight back to the matching rule.", + "type": "boolean" + }, "reveal": { "$ref": "#/definitions/action_plan.RevealPlan" }, + "rule_id": { + "description": "RuleID marks a plan synthesized from a recurring rule. Synthesized plans\nare never stored — the field is empty on every persisted plan.", + "type": "string" + }, "slot": { "type": "integer" }, @@ -1736,6 +1845,55 @@ } } }, + "action_plan.SlotRule": { + "type": "object", + "properties": { + "bid": { + "$ref": "#/definitions/action_plan.BidPlan" + }, + "build": { + "$ref": "#/definitions/action_plan.BuildPlan" + }, + "builder_api": { + "$ref": "#/definitions/action_plan.BuilderAPIPlan" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_epoch": { + "description": "FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is\nunbounded on that side.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "reveal": { + "$ref": "#/definitions/action_plan.RevealPlan" + }, + "slots_in_epoch": { + "description": "SlotsInEpoch are the matched slot indices within an epoch (0-based, so\n31 is the last slot of a 32-slot epoch).", + "type": "array", + "items": { + "type": "integer" + } + }, + "to_epoch": { + "type": "integer" + }, + "transforms": { + "$ref": "#/definitions/action_plan.TransformPlan" + }, + "updated_at": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + } + }, "action_plan.TransformPlan": { "type": "object", "properties": { @@ -1767,6 +1925,28 @@ } } }, + "api.ActionPlanRulesRequest": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, + "api.ActionPlanRulesResponse": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, "api.AuditLogResponse": { "type": "object", "properties": { diff --git a/pkg/webui/handlers/docs/swagger.yaml b/pkg/webui/handlers/docs/swagger.yaml index 8e76371..43d7804 100644 --- a/pkg/webui/handlers/docs/swagger.yaml +++ b/pkg/webui/handlers/docs/swagger.yaml @@ -126,6 +126,11 @@ definitions: type: boolean from_slot: type: integer + ignore_rules: + description: |- + IgnoreRules is two-state on purpose: absent = unchanged, true/false = + set the slot's recurring-rule opt-out. + type: boolean reveal: items: type: integer @@ -298,8 +303,19 @@ definitions: $ref: '#/definitions/action_plan.BuildPlan' builder_api: $ref: '#/definitions/action_plan.BuilderAPIPlan' + ignore_rules: + description: |- + IgnoreRules opts the slot out of every recurring rule, so it runs on the + plain global baseline. Without it an empty plan is dropped, which would + hand the slot straight back to the matching rule. + type: boolean reveal: $ref: '#/definitions/action_plan.RevealPlan' + rule_id: + description: |- + RuleID marks a plan synthesized from a recurring rule. Synthesized plans + are never stored — the field is empty on every persisted plan. + type: string slot: type: integer transforms: @@ -309,6 +325,43 @@ definitions: updated_by: type: string type: object + action_plan.SlotRule: + properties: + bid: + $ref: '#/definitions/action_plan.BidPlan' + build: + $ref: '#/definitions/action_plan.BuildPlan' + builder_api: + $ref: '#/definitions/action_plan.BuilderAPIPlan' + description: + type: string + enabled: + type: boolean + from_epoch: + description: |- + FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is + unbounded on that side. + type: integer + id: + type: string + reveal: + $ref: '#/definitions/action_plan.RevealPlan' + slots_in_epoch: + description: |- + SlotsInEpoch are the matched slot indices within an epoch (0-based, so + 31 is the last slot of a 32-slot epoch). + items: + type: integer + type: array + to_epoch: + type: integer + transforms: + $ref: '#/definitions/action_plan.TransformPlan' + updated_at: + type: string + updated_by: + type: string + type: object action_plan.TransformPlan: properties: bid: @@ -329,6 +382,20 @@ definitions: $ref: '#/definitions/action_plan.SlotPlan' type: array type: object + api.ActionPlanRulesRequest: + properties: + rules: + items: + $ref: '#/definitions/action_plan.SlotRule' + type: array + type: object + api.ActionPlanRulesResponse: + properties: + rules: + items: + $ref: '#/definitions/action_plan.SlotRule' + type: array + type: object api.AuditLogResponse: properties: entries: @@ -1167,6 +1234,81 @@ paths: summary: Update per-slot action plans tags: - ActionPlan + /api/buildoor/action-plan/rules: + get: + description: |- + Returns the recurring slot rules, id-ascending (which is also + their match order). A rule applies its categories to every slot + whose index within the epoch matches, unless the slot carries + an explicit plan. + operationId: getActionPlanRules + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ActionPlanRulesResponse' + "503": + description: Plan service unavailable + schema: + additionalProperties: + type: string + type: object + summary: Get recurring action plan rules + tags: + - ActionPlan + post: + consumes: + - application/json + description: |- + Atomically replaces the whole recurring rule set (every rule + validates or nothing changes). Each rule matches slot indices + within an epoch, optionally bounded to an epoch window, and + carries the same categories as a slot plan — e.g. win slot 31 + of every epoch and withhold its payload. Already frozen slots + keep the plan they froze with. Requires authentication. + operationId: updateActionPlanRules + parameters: + - description: Bearer token + in: header + name: Authorization + required: true + type: string + - description: Full rule set + in: body + name: request + required: true + schema: + $ref: '#/definitions/api.ActionPlanRulesRequest' + produces: + - application/json + responses: + "200": + description: Authoritative rule set + schema: + $ref: '#/definitions/api.ActionPlanRulesResponse' + "400": + description: Validation error + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "503": + description: Plan service unavailable + schema: + additionalProperties: + type: string + type: object + summary: Replace the recurring action plan rules + tags: + - ActionPlan /api/buildoor/action-plan/test-transform: post: consumes: diff --git a/pkg/webui/src/components/actionplan/ActionPlanView.tsx b/pkg/webui/src/components/actionplan/ActionPlanView.tsx index da96ca9..2410c8f 100644 --- a/pkg/webui/src/components/actionplan/ActionPlanView.tsx +++ b/pkg/webui/src/components/actionplan/ActionPlanView.tsx @@ -1,9 +1,11 @@ import React, { useCallback, useRef, useState } from 'react'; import { useEventStream } from '../../hooks/useEventStream'; import { useActionPlan } from '../../hooks/useActionPlan'; +import { useActionPlanRules } from '../../hooks/useActionPlanRules'; import { useAuthContext } from '../../context/AuthContext'; import { ActionPlanGrid } from './ActionPlanGrid'; import { BulkEditBar } from './BulkEditBar'; +import { RulesPanel } from './RulesPanel'; import { SlotEditModal, type ModalTarget } from './SlotEditModal'; // Default epoch window around the current epoch. @@ -43,6 +45,12 @@ export const ActionPlanView: React.FC = () => { const maxSlot = window ? (window.end + 1) * slotsPerEpoch - 1 : -1; const { plans, results, loading, error, refetch, applyUpdates } = useActionPlan(minSlot, maxSlot); + const { + rules, + loading: rulesLoading, + error: rulesError, + saveRules, + } = useActionPlanRules(); // Selection (future slots only) + range anchor for shift-click. const [selection, setSelection] = useState>(new Set()); @@ -158,6 +166,15 @@ export const ActionPlanView: React.FC = () => {
{error &&
{error}
} + + { P reorg parent jq transform B disabled + B from a recurring rule Bid (left dot): included bid, not included diff --git a/pkg/webui/src/components/actionplan/RuleEditModal.tsx b/pkg/webui/src/components/actionplan/RuleEditModal.tsx new file mode 100644 index 0000000..52daadd --- /dev/null +++ b/pkg/webui/src/components/actionplan/RuleEditModal.tsx @@ -0,0 +1,396 @@ +import React, { useState } from 'react'; +import type { BidPlan, BuilderAPIPlan, RevealPlan, SlotRule } from '../../types'; +import { TransformEditor, type TransformState } from './TransformEditor'; +import { + BID_FIELDS, + BUILDER_API_FIELDS, + REVEAL_FIELDS, + BuildForm, + CategoryForm, + initCategoryState, + resolveCategory, + type BuildFlagMode, + type CategoryFormState, +} from './planForms'; + +// Ready-made scenarios; picking one fills the form, which stays editable. +interface Preset { + id: string; + label: string; + hint: string; + apply: (slotsPerEpoch: number) => Partial & { slots_in_epoch: number[] }; +} + +const PRESETS: Preset[] = [ + { + id: 'win-and-withhold', + label: 'Win the last slot of every epoch, withhold the payload', + hint: 'Bids an absolute value (tune it to outbid the other builders), then never reveals the envelope — the block exists, its execution payload does not.', + apply: (slotsPerEpoch) => ({ + id: 'slot-last-withhold', + description: 'win the last slot of every epoch and withhold the payload', + slots_in_epoch: [Math.max(0, slotsPerEpoch - 1)], + bid: { mode: 'custom', bid_value_gwei: 1000000, ignore_missing_prefs: true }, + reveal: { mode: 'disabled' }, + }), + }, + { + id: 'withhold-only', + label: 'Withhold the payload of the last slot of every epoch', + hint: 'Leaves bidding on the global config and only suppresses the reveal when the slot is won.', + apply: (slotsPerEpoch) => ({ + id: 'slot-last-no-reveal', + description: 'withhold the payload of the last slot of every epoch', + slots_in_epoch: [Math.max(0, slotsPerEpoch - 1)], + reveal: { mode: 'disabled' }, + }), + }, +]; + +// parseSlotList reads a "31" / "0,15,31" / "28-31" slot-index list. +function parseSlotList(raw: string, slotsPerEpoch: number): { slots: number[]; error: string | null } { + const slots: number[] = []; + + for (const part of raw.split(',')) { + const token = part.trim(); + if (token === '') continue; + + const range = token.match(/^(\d+)\s*-\s*(\d+)$/); + const bounds = range ? [Number(range[1]), Number(range[2])] : [Number(token), Number(token)]; + + if (!bounds.every((n) => Number.isInteger(n) && n >= 0)) { + return { slots, error: `"${token}" is not a slot index or range` }; + } + if (bounds[1] < bounds[0]) { + return { slots, error: `invalid range "${token}"` }; + } + if (slotsPerEpoch > 0 && bounds[1] >= slotsPerEpoch) { + return { slots, error: `slot index ${bounds[1]} is out of range (0–${slotsPerEpoch - 1})` }; + } + + for (let s = bounds[0]; s <= bounds[1]; s++) { + if (!slots.includes(s)) slots.push(s); + } + } + + if (slots.length === 0) return { slots, error: 'at least one slot index is required' }; + + return { slots: slots.sort((a, b) => a - b), error: null }; +} + +function parseOptionalEpoch(raw: string, label: string): { value?: number; error: string | null } { + const token = raw.trim(); + if (token === '') return { error: null }; + + const value = Number(token); + if (!Number.isInteger(value) || value < 0) { + return { error: `${label} must be a non-negative integer` }; + } + + return { value, error: null }; +} + +interface RuleEditModalProps { + /** The rule being edited, or null for a new one. */ + rule: SlotRule | null; + slotsPerEpoch: number; + canEdit: boolean; + saving: boolean; + error: string | null; + onSave: (rule: SlotRule) => void; + onClose: () => void; +} + +export const RuleEditModal: React.FC = ({ + rule, + slotsPerEpoch, + canEdit, + saving, + error, + onSave, + onClose, +}) => { + const [id, setId] = useState(rule?.id ?? ''); + const [description, setDescription] = useState(rule?.description ?? ''); + const [enabled, setEnabled] = useState(rule?.enabled ?? true); + const [slotList, setSlotList] = useState((rule?.slots_in_epoch ?? []).join(',')); + const [fromEpoch, setFromEpoch] = useState(rule?.from_epoch?.toString() ?? ''); + const [toEpoch, setToEpoch] = useState(rule?.to_epoch?.toString() ?? ''); + + const [bidState, setBidState] = useState(() => + initCategoryState(rule?.bid, BID_FIELDS, false) + ); + const [apiState, setApiState] = useState(() => + initCategoryState(rule?.builder_api, BUILDER_API_FIELDS, false) + ); + const [revealState, setRevealState] = useState(() => + initCategoryState(rule?.reveal, REVEAL_FIELDS, false) + ); + const [buildReorg, setBuildReorg] = useState( + rule?.build?.reorg_parent_payload ? 'on' : 'off' + ); + const [transforms, setTransforms] = useState({ + payload: rule?.transforms?.payload ?? '', + bid: rule?.transforms?.bid ?? '', + envelope: rule?.transforms?.envelope ?? '', + }); + + const [formError, setFormError] = useState(null); + + const applyPreset = (preset: Preset) => { + const filled = preset.apply(slotsPerEpoch); + setId(filled.id ?? ''); + setDescription(filled.description ?? ''); + setEnabled(true); + setSlotList(filled.slots_in_epoch.join(',')); + setFromEpoch(''); + setToEpoch(''); + setBidState(initCategoryState(filled.bid, BID_FIELDS, false)); + setApiState(initCategoryState(filled.builder_api, BUILDER_API_FIELDS, false)); + setRevealState(initCategoryState(filled.reveal, REVEAL_FIELDS, false)); + setBuildReorg(filled.build?.reorg_parent_payload ? 'on' : 'off'); + setTransforms({ payload: '', bid: '', envelope: '' }); + setFormError(null); + }; + + // Rules are authored wholesale, so a category resolves to the object itself + // (custom/disabled) or to "absent" for inherit. + const resolveRuleCategory = ( + name: string, + fields: typeof BID_FIELDS, + state: CategoryFormState, + withIgnorePrefs: boolean + ): { value?: T; error?: string } => { + const outcome = resolveCategory(name, fields, state, undefined, false, withIgnorePrefs); + if (outcome.kind === 'error') return { error: outcome.error }; + if (outcome.kind === 'replace') return { value: outcome.obj as T }; + + return {}; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + const { slots, error: slotError } = parseSlotList(slotList, slotsPerEpoch); + if (slotError) { + setFormError(slotError); + return; + } + + const from = parseOptionalEpoch(fromEpoch, 'From epoch'); + const to = parseOptionalEpoch(toEpoch, 'To epoch'); + if (from.error || to.error) { + setFormError(from.error || to.error); + return; + } + + const bid = resolveRuleCategory('bid', BID_FIELDS, bidState, true); + const api = resolveRuleCategory('builder_api', BUILDER_API_FIELDS, apiState, false); + const reveal = resolveRuleCategory('reveal', REVEAL_FIELDS, revealState, false); + + const categoryError = bid.error || api.error || reveal.error; + if (categoryError) { + setFormError(categoryError); + return; + } + + const next: SlotRule = { + id: id.trim(), + enabled, + slots_in_epoch: slots, + ...(description.trim() ? { description: description.trim() } : {}), + ...(from.value !== undefined ? { from_epoch: from.value } : {}), + ...(to.value !== undefined ? { to_epoch: to.value } : {}), + ...(bid.value ? { bid: bid.value } : {}), + ...(api.value ? { builder_api: api.value } : {}), + ...(reveal.value ? { reveal: reveal.value } : {}), + ...(buildReorg === 'on' ? { build: { reorg_parent_payload: true } } : {}), + }; + + const transformPlan = { + ...(transforms.payload.trim() ? { payload: transforms.payload.trim() } : {}), + ...(transforms.bid.trim() ? { bid: transforms.bid.trim() } : {}), + ...(transforms.envelope.trim() ? { envelope: transforms.envelope.trim() } : {}), + }; + if (Object.keys(transformPlan).length > 0) { + next.transforms = transformPlan; + } + + onSave(next); + }; + + const disabled = !canEdit || saving; + + return ( + <> +
+
+
+
+
+ {rule ? `Recurring Rule — ${rule.id}` : 'New Recurring Rule'} +
+ +
+ +
+ {!rule && ( +
+
Scenario presets
+ {PRESETS.map((preset) => ( + + ))} +
+ )} + +
+
+ + setId(e.target.value)} + /> +
+ Lowercase slug; identifies the rule and decides match order when several apply. +
+
+
+ + 0 ? String(slotsPerEpoch - 1) : '31'} + value={slotList} + disabled={disabled} + onChange={(e) => setSlotList(e.target.value)} + /> +
+ 0-based indices within the epoch: 31, 0,15,31 or{' '} + 28-31. +
+
+
+ + setDescription(e.target.value)} + /> +
+
+ + setFromEpoch(e.target.value)} + /> +
+
+ + setToEpoch(e.target.value)} + /> +
+
+
+ setEnabled(e.target.checked)} + /> + +
+
+
+ + + + + + + +
+ The rule applies to every matching slot that has no explicit plan of its own. Slots + that already froze keep the plan they froze with. +
+ + {(formError || error) && ( +
{formError || error}
+ )} +
+ +
+ + +
+
+
+
+
+ + ); +}; diff --git a/pkg/webui/src/components/actionplan/RulesPanel.tsx b/pkg/webui/src/components/actionplan/RulesPanel.tsx new file mode 100644 index 0000000..5edf8f2 --- /dev/null +++ b/pkg/webui/src/components/actionplan/RulesPanel.tsx @@ -0,0 +1,241 @@ +import React, { useState } from 'react'; +import type { SlotRule } from '../../types'; +import type { SaveRulesResult } from '../../hooks/useActionPlanRules'; +import { RuleEditModal } from './RuleEditModal'; + +// summarizeCategories renders the same chips the grid uses, so a rule reads +// like the cells it will produce. +const CategoryChips: React.FC<{ rule: SlotRule }> = ({ rule }) => { + const chip = (label: string, mode?: 'custom' | 'disabled', title?: string) => ( + + {label} + + ); + + const t = rule.transforms; + const hasTransform = !!(t && (t.payload || t.bid || t.envelope)); + + return ( + + {rule.bid && chip('B', rule.bid.mode, `bid: ${rule.bid.mode}`)} + {rule.builder_api && chip('A', rule.builder_api.mode, `builder api: ${rule.builder_api.mode}`)} + {rule.reveal && chip('R', rule.reveal.mode, `reveal: ${rule.reveal.mode}`)} + {rule.build?.reorg_parent_payload && ( + + P + + )} + {hasTransform && ( + + jq + + )} + + ); +}; + +const epochWindow = (rule: SlotRule): string => { + if (rule.from_epoch === undefined && rule.to_epoch === undefined) return 'every epoch'; + if (rule.to_epoch === undefined) return `from epoch ${rule.from_epoch}`; + if (rule.from_epoch === undefined) return `until epoch ${rule.to_epoch}`; + + return `epochs ${rule.from_epoch}–${rule.to_epoch}`; +}; + +interface RulesPanelProps { + rules: SlotRule[]; + slotsPerEpoch: number; + canEdit: boolean; + loading: boolean; + error: string | null; + saveRules: (rules: SlotRule[]) => Promise; +} + +/** + * Lists the recurring rules and edits them through the atomic replace-all + * endpoint: every mutation sends the full authoritative set. + */ +export const RulesPanel: React.FC = ({ + rules, + slotsPerEpoch, + canEdit, + loading, + error, + saveRules, +}) => { + const [expanded, setExpanded] = useState(false); + const [editing, setEditing] = useState<{ rule: SlotRule | null } | null>(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const commit = async (next: SlotRule[], onDone?: () => void) => { + setSaving(true); + setSaveError(null); + + const result = await saveRules(next); + + setSaving(false); + + if (!result.ok) { + setSaveError(result.error || 'Failed to save rules'); + return; + } + + onDone?.(); + }; + + const handleSave = (rule: SlotRule) => { + const next = rules.some((r) => r.id === rule.id) + ? rules.map((r) => (r.id === rule.id ? rule : r)) + : [...rules, rule]; + + commit(next, () => setEditing(null)); + }; + + const handleToggle = (rule: SlotRule) => + commit(rules.map((r) => (r.id === rule.id ? { ...r, enabled: !r.enabled } : r))); + + const handleDelete = (rule: SlotRule) => + commit(rules.filter((r) => r.id !== rule.id)); + + const activeCount = rules.filter((r) => r.enabled).length; + + return ( +
+
+ + {loading && } + {expanded && canEdit && ( + + )} +
+ + {expanded && ( +
+ {(error || saveError) && ( +
{error || saveError}
+ )} + + {rules.length === 0 ? ( +
+ No recurring rules. A rule repeats a plan on the same slot index of every epoch — for + example win slot {Math.max(0, slotsPerEpoch - 1)} and withhold its payload for a whole + devnet run. Explicit per-slot plans always win over rules. +
+ ) : ( +
+ + + + + + + + + + + + {rules.map((rule) => ( + + + + + + + + ))} + +
RuleSlots in epochWindowPlanActions
+
+ + {rule.enabled ? 'on' : 'off'} + +
+
{rule.id}
+ {rule.description && ( +
{rule.description}
+ )} +
+
+
{rule.slots_in_epoch.join(', ')}{epochWindow(rule)} + + +
+ + + +
+
+
+ )} +
+ )} + + {editing && ( + setEditing(null)} + /> + )} +
+ ); +}; diff --git a/pkg/webui/src/components/actionplan/SlotCell.tsx b/pkg/webui/src/components/actionplan/SlotCell.tsx index ddc327b..440bc40 100644 --- a/pkg/webui/src/components/actionplan/SlotCell.tsx +++ b/pkg/webui/src/components/actionplan/SlotCell.tsx @@ -85,8 +85,10 @@ interface SlotCellProps { onCellClick: (slot: number, shiftKey: boolean) => void; } -const chipClass = (mode?: 'custom' | 'disabled'): string => - mode === 'disabled' ? 'ap-chip ap-chip-disabled' : 'ap-chip ap-chip-custom'; +// Rule-derived plans are drawn dashed: they are not stored for the slot and a +// rule change (or an explicit plan) can still replace them. +const chipClass = (mode: 'custom' | 'disabled' | undefined, fromRule: boolean): string => + `ap-chip ${mode === 'disabled' ? 'ap-chip-disabled' : 'ap-chip-custom'}${fromRule ? ' ap-chip-rule' : ''}`; const SlotCellInner: React.FC = ({ slot, @@ -103,7 +105,11 @@ const SlotCellInner: React.FC = ({ const t = plan?.transforms; const hasTransform = !!(t && (t.payload || t.bid || t.envelope)); + const fromRule = !!plan?.rule_id; + const titleParts = [`Slot ${slot}`]; + if (fromRule) titleParts.push(`rule: ${plan?.rule_id}`); + if (plan?.ignore_rules) titleParts.push('recurring rules ignored'); if (plan?.bid) titleParts.push(`bid: ${plan.bid.mode}`); if (plan?.builder_api) titleParts.push(`builder api: ${plan.builder_api.mode}`); if (plan?.reveal) titleParts.push(`reveal: ${plan.reveal.mode}`); @@ -127,9 +133,9 @@ const SlotCellInner: React.FC = ({ onClick={(e) => onCellClick(slot, e.shiftKey)} > - {plan?.bid && B} - {plan?.builder_api && A} - {plan?.reveal && R} + {plan?.bid && B} + {plan?.builder_api && A} + {plan?.reveal && R} {reorgParent && P} {hasTransform && jq} diff --git a/pkg/webui/src/components/actionplan/SlotEditModal.tsx b/pkg/webui/src/components/actionplan/SlotEditModal.tsx index 27591f9..4587c1c 100644 --- a/pkg/webui/src/components/actionplan/SlotEditModal.tsx +++ b/pkg/webui/src/components/actionplan/SlotEditModal.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import type { - ActionMode, FrozenPlan, PlanUpdate, SlotPlan, @@ -8,6 +7,18 @@ import type { } from '../../types'; import type { ApplyUpdatesResult } from '../../hooks/useActionPlan'; import { TransformEditor, type TransformState } from './TransformEditor'; +import { + BID_FIELDS, + BUILDER_API_FIELDS, + REVEAL_FIELDS, + BuildForm, + CategoryForm, + initCategoryState, + resolveCategory, + type BuildFlagMode, + type CategoryFormState, + type CategoryOutcome, +} from './planForms'; // Target of the modal: either an explicit slot list (single slot or grid // selection) or an inclusive from/to range (may extend beyond the grid). @@ -130,312 +141,6 @@ const ArtifactLinks: React.FC<{ ); -// --------------------------------------------------------------------------- -// Edit form model -// --------------------------------------------------------------------------- - -// 'unchanged' exists only in bulk mode (leave targeted slots as they are); -// 'inherit' clears the category back to the global baseline. -type FormMode = 'unchanged' | 'inherit' | 'custom' | 'disabled'; - -interface FieldDef { - key: string; - label: string; - unit: 'ms' | 'gwei' | '%' | ''; - // Enum fields render a select instead of a number input; the raw string - // value is sent as the override. - options?: string[]; -} - -const BID_FIELDS: FieldDef[] = [ - { key: 'bid_start_time', label: 'Bid Start (rel. slot)', unit: 'ms' }, - { key: 'bid_end_time', label: 'Bid End (rel. slot)', unit: 'ms' }, - { key: 'bid_min_amount', label: 'Bid Min', unit: 'gwei' }, - { key: 'bid_increase', label: 'Bid Increase', unit: 'gwei' }, - { key: 'bid_interval', label: 'Bid Interval', unit: 'ms' }, - { key: 'bid_subsidy', label: 'Bid Subsidy', unit: 'gwei' }, - { key: 'bid_value_gwei', label: 'Bid Value Override', unit: 'gwei' }, -]; - -const BUILDER_API_FIELDS: FieldDef[] = [ - { key: 'value_subsidy_gwei', label: 'Value Subsidy', unit: 'gwei' }, - { key: 'total_value_override_gwei', label: 'Total Value Override', unit: 'gwei' }, - { key: 'response_delay_ms', label: 'Response Delay', unit: 'ms' }, -]; - -const REVEAL_FIELDS: FieldDef[] = [ - { key: 'reveal_time_ms', label: 'Reveal Time (rel. slot)', unit: 'ms' }, - { - key: 'gate_mode', label: 'Gate Mode', unit: '', - options: ['time', 'vote', 'vote_or_time', 'vote_and_time'], - }, - { key: 'vote_threshold_pct', label: 'Vote Threshold', unit: '%' }, - { - key: 'broadcast_validation', label: 'Broadcast Validation', unit: '', - options: ['gossip', 'consensus', 'consensus_and_equivocation'], - }, -]; - -interface CategoryFormState { - mode: FormMode; - fields: Record; // raw input values; empty = inherit - ignoreMissingPrefs: boolean; -} - -function initCategoryState( - category: { mode: ActionMode } | undefined, - defs: FieldDef[], - bulk: boolean -): CategoryFormState { - if (bulk || !category) { - return { mode: bulk ? 'unchanged' : 'inherit', fields: {}, ignoreMissingPrefs: false }; - } - - const raw = category as unknown as Record; - const fields: Record = {}; - for (const def of defs) { - const value = raw[def.key]; - if (typeof value === 'number' || typeof value === 'string') { - fields[def.key] = String(value); - } - } - - return { - mode: category.mode, - fields, - ignoreMissingPrefs: raw['ignore_missing_prefs'] === true, - }; -} - -function parseCategoryFields( - name: string, - defs: FieldDef[], - state: CategoryFormState -): { values: Record; error: string | null } { - const values: Record = {}; - - for (const def of defs) { - const raw = (state.fields[def.key] ?? '').trim(); - if (raw === '') continue; - - if (def.options) { - if (!def.options.includes(raw)) { - return { values, error: `${name}: ${def.label} must be one of ${def.options.join(', ')}` }; - } - values[def.key] = raw; - continue; - } - - const num = Number(raw); - if (!Number.isFinite(num) || !Number.isInteger(num)) { - return { values, error: `${name}: ${def.label} must be an integer (${def.unit})` }; - } - values[def.key] = num; - } - - return { values, error: null }; -} - -type CategoryOutcome = - | { kind: 'none' } - | { kind: 'clear' } - | { kind: 'replace'; obj: Record } - | { kind: 'set'; paths: Record } - | { kind: 'error'; error: string }; - -// resolveCategory turns one category form into its PlanUpdate contribution. -// Single-slot edits of an existing custom category use fine-grained `set` -// paths so unchanged sibling fields are never clobbered; mode switches send -// the full category object. -function resolveCategory( - name: string, - defs: FieldDef[], - state: CategoryFormState, - initial: Record | undefined, - single: boolean, - withIgnorePrefs: boolean -): CategoryOutcome { - switch (state.mode) { - case 'unchanged': - return { kind: 'none' }; - - case 'inherit': - if (single && !initial) return { kind: 'none' }; - return { kind: 'clear' }; - - case 'disabled': - if (single && initial && initial['mode'] === 'disabled') return { kind: 'none' }; - return { kind: 'replace', obj: { mode: 'disabled' } }; - - case 'custom': { - const { values, error } = parseCategoryFields(name, defs, state); - if (error) return { kind: 'error', error }; - - if (single && initial && initial['mode'] === 'custom') { - const paths: Record = {}; - - for (const def of defs) { - const rawOld = initial[def.key]; - const oldValue = typeof rawOld === 'number' || typeof rawOld === 'string' - ? (rawOld as number | string) - : undefined; - const newValue = values[def.key]; - - if (newValue === undefined && oldValue !== undefined) { - paths[`${name}.${def.key}`] = null; // clear one override - } else if (newValue !== undefined && newValue !== oldValue) { - paths[`${name}.${def.key}`] = newValue; - } - } - - if (withIgnorePrefs) { - const oldFlag = initial['ignore_missing_prefs'] === true; - if (state.ignoreMissingPrefs !== oldFlag) { - paths[`${name}.ignore_missing_prefs`] = state.ignoreMissingPrefs; - } - } - - if (Object.keys(paths).length === 0) return { kind: 'none' }; - return { kind: 'set', paths }; - } - - const obj: Record = { mode: 'custom', ...values }; - if (withIgnorePrefs && state.ignoreMissingPrefs) { - obj['ignore_missing_prefs'] = true; - } - return { kind: 'replace', obj }; - } - } -} - -// --------------------------------------------------------------------------- -// Category form section -// --------------------------------------------------------------------------- - -const CategoryForm: React.FC<{ - title: string; - bulk: boolean; - state: CategoryFormState; - fields: FieldDef[]; - disabled: boolean; - showIgnorePrefs?: boolean; - onChange: (next: CategoryFormState) => void; -}> = ({ title, bulk, state, fields, disabled, showIgnorePrefs, onChange }) => ( -
-
-
{title}
- -
- - {state.mode === 'custom' && ( -
- {fields.map((def) => ( -
- - {def.options ? ( - - ) : ( -
- - onChange({ ...state, fields: { ...state.fields, [def.key]: e.target.value } }) - } - /> - {def.unit} -
- )} -
- ))} - {showIgnorePrefs && ( -
-
- onChange({ ...state, ignoreMissingPrefs: e.target.checked })} - /> - -
-
- )} -
- Empty fields inherit the global config. Timing values are signed ms relative to slot start. -
-
- )} -
-); - -// --------------------------------------------------------------------------- -// Build category (modeless single flag) -// --------------------------------------------------------------------------- - -// 'unchanged' only exists in bulk mode; 'off' = normal parent, 'on' = reorg. -type BuildFlagMode = 'unchanged' | 'off' | 'on'; - -const BuildForm: React.FC<{ - bulk: boolean; - value: BuildFlagMode; - disabled: boolean; - onChange: (next: BuildFlagMode) => void; -}> = ({ bulk, value, disabled, onChange }) => ( -
-
-
Build
- -
- {value === 'on' && ( -
- Builds the payload on the grandparent (n-2) execution payload instead of the immediate - parent — parent hash, parent number and withdrawals come from the parent slot, everything - else from this slot. A deliberate parent-payload reorg attempt; rejected by mainnet - forkchoice, useful for testing. -
- )} -
-); - // --------------------------------------------------------------------------- // Read-only frozen plan + result views // --------------------------------------------------------------------------- @@ -980,30 +685,39 @@ export const SlotEditModal: React.FC = ({ const isPastSingle = isSingle && singleSlot <= currentSlot; const bulk = !isSingle; - const initialPlan = isSingle ? plans[singleSlot] : undefined; + // The effective plan seeds the form; only a STORED plan is a diff base. A + // rule-derived plan lives nowhere, so saving it writes full categories and + // detaches the slot from the rule. + const effectivePlan = isSingle ? plans[singleSlot] : undefined; + const rulePlan = effectivePlan?.rule_id ? effectivePlan : undefined; + const initialPlan = rulePlan ? undefined : effectivePlan; const [bidState, setBidState] = useState(() => - initCategoryState(initialPlan?.bid, BID_FIELDS, bulk) + initCategoryState(effectivePlan?.bid, BID_FIELDS, bulk) ); const [apiState, setApiState] = useState(() => - initCategoryState(initialPlan?.builder_api, BUILDER_API_FIELDS, bulk) + initCategoryState(effectivePlan?.builder_api, BUILDER_API_FIELDS, bulk) ); const [revealState, setRevealState] = useState(() => - initCategoryState(initialPlan?.reveal, REVEAL_FIELDS, bulk) + initCategoryState(effectivePlan?.reveal, REVEAL_FIELDS, bulk) ); // Build is a modeless single-flag category: tri-state in bulk, on/off single. const [buildReorg, setBuildReorg] = useState(() => - bulk ? 'unchanged' : initialPlan?.build?.reorg_parent_payload ? 'on' : 'off' + bulk ? 'unchanged' : effectivePlan?.build?.reorg_parent_payload ? 'on' : 'off' ); // Transforms (modeless jq expressions). In bulk mode we start empty and only // send the ones the operator fills in. const [transforms, setTransforms] = useState(() => ({ - payload: (!bulk && initialPlan?.transforms?.payload) || '', - bid: (!bulk && initialPlan?.transforms?.bid) || '', - envelope: (!bulk && initialPlan?.transforms?.envelope) || '', + payload: (!bulk && effectivePlan?.transforms?.payload) || '', + bid: (!bulk && effectivePlan?.transforms?.bid) || '', + envelope: (!bulk && effectivePlan?.transforms?.envelope) || '', })); + // Per-slot opt-out from every recurring rule. In bulk mode it is write-only + // (checked applies it to all targets, unchecked leaves each slot as it is). + const [ignoreRules, setIgnoreRules] = useState(initialPlan?.ignore_rules === true); + const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); const [conflictError, setConflictError] = useState(null); @@ -1112,6 +826,11 @@ export const SlotEditModal: React.FC = ({ } }); + if (bulk ? ignoreRules : ignoreRules !== (initialPlan?.ignore_rules === true)) { + update.ignore_rules = ignoreRules; + hasChange = true; + } + if (Object.keys(setPaths).length > 0) { update.set = setPaths; } @@ -1187,6 +906,14 @@ export const SlotEditModal: React.FC = ({ category as it is per slot; "inherit (clear)" removes it.
)} + {rulePlan && ( +
+ + This slot has no plan of its own — it follows recurring rule{' '} + {rulePlan.rule_id}, whose values are pre-filled below. Saving + stores them as an explicit plan for this slot, detaching it from the rule. +
+ )} = ({ disabled={formDisabled} onChange={setBuildReorg} /> +
+
Recurring rules
+
+ setIgnoreRules(e.target.checked)} + /> + +
+
+ Runs the slot on the plain global baseline even when a rule matches it. Any + category set above already overrides the rule on its own. +
+
; // raw input values; empty = inherit + ignoreMissingPrefs: boolean; +} + +export function initCategoryState( + category: { mode: ActionMode } | undefined, + defs: FieldDef[], + bulk: boolean +): CategoryFormState { + if (bulk || !category) { + return { mode: bulk ? 'unchanged' : 'inherit', fields: {}, ignoreMissingPrefs: false }; + } + + const raw = category as unknown as Record; + const fields: Record = {}; + for (const def of defs) { + const value = raw[def.key]; + if (typeof value === 'number' || typeof value === 'string') { + fields[def.key] = String(value); + } + } + + return { + mode: category.mode, + fields, + ignoreMissingPrefs: raw['ignore_missing_prefs'] === true, + }; +} + +export function parseCategoryFields( + name: string, + defs: FieldDef[], + state: CategoryFormState +): { values: Record; error: string | null } { + const values: Record = {}; + + for (const def of defs) { + const raw = (state.fields[def.key] ?? '').trim(); + if (raw === '') continue; + + if (def.options) { + if (!def.options.includes(raw)) { + return { values, error: `${name}: ${def.label} must be one of ${def.options.join(', ')}` }; + } + values[def.key] = raw; + continue; + } + + const num = Number(raw); + if (!Number.isFinite(num) || !Number.isInteger(num)) { + return { values, error: `${name}: ${def.label} must be an integer (${def.unit})` }; + } + values[def.key] = num; + } + + return { values, error: null }; +} + +export type CategoryOutcome = + | { kind: 'none' } + | { kind: 'clear' } + | { kind: 'replace'; obj: Record } + | { kind: 'set'; paths: Record } + | { kind: 'error'; error: string }; + +// resolveCategory turns one category form into its PlanUpdate contribution. +// Single-slot edits of an existing custom category use fine-grained `set` +// paths so unchanged sibling fields are never clobbered; mode switches send +// the full category object. +export function resolveCategory( + name: string, + defs: FieldDef[], + state: CategoryFormState, + initial: Record | undefined, + single: boolean, + withIgnorePrefs: boolean +): CategoryOutcome { + switch (state.mode) { + case 'unchanged': + return { kind: 'none' }; + + case 'inherit': + if (single && !initial) return { kind: 'none' }; + return { kind: 'clear' }; + + case 'disabled': + if (single && initial && initial['mode'] === 'disabled') return { kind: 'none' }; + return { kind: 'replace', obj: { mode: 'disabled' } }; + + case 'custom': { + const { values, error } = parseCategoryFields(name, defs, state); + if (error) return { kind: 'error', error }; + + if (single && initial && initial['mode'] === 'custom') { + const paths: Record = {}; + + for (const def of defs) { + const rawOld = initial[def.key]; + const oldValue = typeof rawOld === 'number' || typeof rawOld === 'string' + ? (rawOld as number | string) + : undefined; + const newValue = values[def.key]; + + if (newValue === undefined && oldValue !== undefined) { + paths[`${name}.${def.key}`] = null; // clear one override + } else if (newValue !== undefined && newValue !== oldValue) { + paths[`${name}.${def.key}`] = newValue; + } + } + + if (withIgnorePrefs) { + const oldFlag = initial['ignore_missing_prefs'] === true; + if (state.ignoreMissingPrefs !== oldFlag) { + paths[`${name}.ignore_missing_prefs`] = state.ignoreMissingPrefs; + } + } + + if (Object.keys(paths).length === 0) return { kind: 'none' }; + return { kind: 'set', paths }; + } + + const obj: Record = { mode: 'custom', ...values }; + if (withIgnorePrefs && state.ignoreMissingPrefs) { + obj['ignore_missing_prefs'] = true; + } + return { kind: 'replace', obj }; + } + } +} + +// --------------------------------------------------------------------------- +// Category form section +// --------------------------------------------------------------------------- + +export const CategoryForm: React.FC<{ + title: string; + bulk: boolean; + state: CategoryFormState; + fields: FieldDef[]; + disabled: boolean; + showIgnorePrefs?: boolean; + onChange: (next: CategoryFormState) => void; +}> = ({ title, bulk, state, fields, disabled, showIgnorePrefs, onChange }) => ( +
+
+
{title}
+ +
+ + {state.mode === 'custom' && ( +
+ {fields.map((def) => ( +
+ + {def.options ? ( + + ) : ( +
+ + onChange({ ...state, fields: { ...state.fields, [def.key]: e.target.value } }) + } + /> + {def.unit} +
+ )} +
+ ))} + {showIgnorePrefs && ( +
+
+ onChange({ ...state, ignoreMissingPrefs: e.target.checked })} + /> + +
+
+ )} +
+ Empty fields inherit the global config. Timing values are signed ms relative to slot start. +
+
+ )} +
+); + +// --------------------------------------------------------------------------- +// Build category (modeless single flag) +// --------------------------------------------------------------------------- + +// 'unchanged' only exists in bulk mode; 'off' = normal parent, 'on' = reorg. +export type BuildFlagMode = 'unchanged' | 'off' | 'on'; + +export const BuildForm: React.FC<{ + bulk: boolean; + value: BuildFlagMode; + disabled: boolean; + onChange: (next: BuildFlagMode) => void; +}> = ({ bulk, value, disabled, onChange }) => ( +
+
+
Build
+ +
+ {value === 'on' && ( +
+ Builds the payload on the grandparent (n-2) execution payload instead of the immediate + parent — parent hash, parent number and withdrawals come from the parent slot, everything + else from this slot. A deliberate parent-payload reorg attempt; rejected by mainnet + forkchoice, useful for testing. +
+ )} +
+); diff --git a/pkg/webui/src/hooks/useActionPlan.ts b/pkg/webui/src/hooks/useActionPlan.ts index d54b652..92529b7 100644 --- a/pkg/webui/src/hooks/useActionPlan.ts +++ b/pkg/webui/src/hooks/useActionPlan.ts @@ -119,6 +119,11 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe } }, [minSlot, maxSlot]); + // Stable handle for the SSE handlers, which must not resubscribe whenever + // the visible range changes. + const fetchAllRef = useRef(fetchAll); + fetchAllRef.current = fetchAll; + useEffect(() => { fetchAll(); }, [fetchAll]); @@ -131,6 +136,12 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe mergePlanChange(change.slots, change.plans || []); }); + // Rule changes rewrite the effective plan of every uncovered slot, so the + // whole visible range has to come back from the authoritative endpoint. + const offRules = onStreamEvent('action_plan_rules_updated', () => { + fetchAllRef.current(); + }); + const offResult = onStreamEvent('slot_result_updated', (data) => { const result = data as SlotResult; const slot = toSlotNumber(result?.slot); @@ -141,6 +152,7 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe return () => { offPlan(); + offRules(); offResult(); }; }, [mergePlanChange]); diff --git a/pkg/webui/src/hooks/useActionPlanRules.ts b/pkg/webui/src/hooks/useActionPlanRules.ts new file mode 100644 index 0000000..4738fa2 --- /dev/null +++ b/pkg/webui/src/hooks/useActionPlanRules.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import type { ActionPlanRulesResponse, SlotRule } from '../types'; +import { authStore } from '../stores/authStore'; +import { + onStreamEvent, + getConnectionGeneration, + subscribeConnectionGeneration, +} from './useEventStream'; + +export interface SaveRulesResult { + ok: boolean; + error?: string; +} + +interface UseActionPlanRulesResult { + rules: SlotRule[]; + loading: boolean; + error: string | null; + refetch: () => void; + /** Replaces the whole rule set atomically (the backend validates all-or-nothing). */ + saveRules: (rules: SlotRule[]) => Promise; +} + +/** + * Fetches the recurring action plan rules and keeps them live via the shared + * SSE stream (action_plan_rules_updated carries the authoritative set). + */ +export function useActionPlanRules(): UseActionPlanRulesResult { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchRules = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/buildoor/action-plan/rules'); + if (!response.ok) { + throw new Error(`Failed to fetch action plan rules: ${response.statusText}`); + } + + const data: ActionPlanRulesResponse = await response.json(); + setRules(data.rules || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setRules([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchRules(); + }, [fetchRules]); + + useEffect( + () => + onStreamEvent('action_plan_rules_updated', (data) => { + const change = data as { rules?: SlotRule[] }; + if (change?.rules) setRules(change.rules); + }), + [] + ); + + // Refetch after an SSE reconnect (changes during the gap were never delivered). + const generation = useSyncExternalStore(subscribeConnectionGeneration, getConnectionGeneration); + const lastGenerationRef = useRef(generation); + useEffect(() => { + if (generation !== lastGenerationRef.current) { + lastGenerationRef.current = generation; + fetchRules(); + } + }, [generation, fetchRules]); + + const saveRules = useCallback(async (next: SlotRule[]): Promise => { + const headers: HeadersInit = { 'Content-Type': 'application/json' }; + const authToken = await authStore.getAuthHeader(); + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + try { + const response = await fetch('/api/buildoor/action-plan/rules', { + method: 'POST', + headers, + body: JSON.stringify({ rules: next }), + }); + + const body = await response.json().catch(() => null); + + if (!response.ok) { + const message = + (body as { error?: string } | null)?.error || `Request failed: ${response.statusText}`; + return { ok: false, error: message }; + } + + // Adopt the authoritative set — never our own optimistic version. + setRules((body as ActionPlanRulesResponse)?.rules || []); + + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } + }, []); + + return { rules, loading, error, refetch: fetchRules, saveRules }; +} diff --git a/pkg/webui/src/styles.css b/pkg/webui/src/styles.css index 5f1c9ae..bc91efd 100644 --- a/pkg/webui/src/styles.css +++ b/pkg/webui/src/styles.css @@ -1203,6 +1203,18 @@ body:has(#panda-menu-root) header.header-bar > nav.navbar { text-decoration: line-through; } +/* Chips derived from a recurring rule: hollow, so a stored plan for the slot + stays visually distinct from a rule that merely applies to it. */ +.ap-chip-rule { + background: transparent; + outline: 1px dashed currentColor; + outline-offset: -1px; +} + +.ap-chip-custom.ap-chip-rule { + color: var(--bs-primary); +} + /* Build-modifier chip (parent-payload reorg): amber to read as "adverse". */ .ap-chip-reorg { background: #fd7e14; diff --git a/pkg/webui/src/types.ts b/pkg/webui/src/types.ts index b2b3262..cc2a35f 100644 --- a/pkg/webui/src/types.ts +++ b/pkg/webui/src/types.ts @@ -536,10 +536,36 @@ export interface SlotPlan { reveal?: RevealPlan; build?: BuildPlan; transforms?: TransformPlan; + // Opts the slot out of every recurring rule (runs on the global baseline). + ignore_rules?: boolean; + // Set on plans synthesized from a recurring rule; never on stored plans. + rule_id?: string; updated_at: string; updated_by: string; } +// A recurring plan template: its categories apply to every slot whose index +// within the epoch matches, unless the slot carries an explicit plan. +export interface SlotRule { + id: string; + enabled: boolean; + description?: string; + slots_in_epoch: number[]; + from_epoch?: number; + to_epoch?: number; + bid?: BidPlan; + builder_api?: BuilderAPIPlan; + reveal?: RevealPlan; + build?: BuildPlan; + transforms?: TransformPlan; + updated_at?: string; + updated_by?: string; +} + +export interface ActionPlanRulesResponse { + rules: SlotRule[]; +} + // One mutation unit of the bulk plan update API. Category members are // three-state: absent = unchanged, null = clear (back to inherit), object = // replace. `set` applies fine-grained path updates (e.g. "bid.bid_min_amount") @@ -554,6 +580,8 @@ export interface PlanUpdate { reveal?: RevealPlan | null; build?: BuildPlan | null; transforms?: TransformPlan | null; + // Two-state: absent = unchanged, true/false = set the rule opt-out. + ignore_rules?: boolean; set?: Record; } diff --git a/pkg/webui/webui.go b/pkg/webui/webui.go index e55e14d..998801e 100644 --- a/pkg/webui/webui.go +++ b/pkg/webui/webui.go @@ -90,6 +90,8 @@ func StartHttpServer(frontendConfig *types.FrontendConfig, settingsSvc *config.S // Per-slot action plan + results endpoints apiRouter.HandleFunc("/buildoor/action-plan", apiHandler.GetActionPlan).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/action-plan", apiHandler.UpdateActionPlan).Methods(http.MethodPost) + apiRouter.HandleFunc("/buildoor/action-plan/rules", apiHandler.GetActionPlanRules).Methods(http.MethodGet) + apiRouter.HandleFunc("/buildoor/action-plan/rules", apiHandler.UpdateActionPlanRules).Methods(http.MethodPost) apiRouter.HandleFunc("/buildoor/action-plan/test-transform", apiHandler.TestTransform).Methods(http.MethodPost) apiRouter.HandleFunc("/buildoor/slot-results", apiHandler.GetSlotResults).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/slot-results/{slot}/payload", apiHandler.GetSlotPayloadArtifact).Methods(http.MethodGet) From 911481fe823db1bcb358a3dd0802fae7e40af988 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sat, 25 Jul 2026 13:04:31 +0200 Subject: [PATCH 2/4] fix review findings on recurring rules - 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. --- pkg/action_plan/rules_test.go | 53 ++++++++++++++++ pkg/action_plan/service.go | 45 ++++++++++++++ .../components/actionplan/RuleEditModal.tsx | 10 +++ .../src/components/actionplan/RulesPanel.tsx | 3 + pkg/webui/src/hooks/useActionPlan.ts | 62 +++++++++++-------- 5 files changed, 147 insertions(+), 26 deletions(-) diff --git a/pkg/action_plan/rules_test.go b/pkg/action_plan/rules_test.go index e263592..ebee954 100644 --- a/pkg/action_plan/rules_test.go +++ b/pkg/action_plan/rules_test.go @@ -6,6 +6,8 @@ import ( "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/config" ) // slot31Rule is the motivating scenario: win the last slot of every epoch and @@ -285,6 +287,57 @@ func TestGetRangeIncludesRulePlans(t *testing.T) { require.Equal(t, ModeDisabled, plans[0].Bid.Mode) } +func TestRuleForcedBuildDoesNotConsumeNextN(t *testing.T) { + cfg := config.DefaultConfig() + cfg.EPBSEnabled = true + cfg.BuilderAPIEnabled = true + cfg.APIPort = 8080 + cfg.Schedule.Mode = config.ScheduleModeNextN + cfg.Schedule.NextN = 1 + + svc := newTestService(newStubChain(), cfg) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + // A rule forces the build past the schedule, so it must not spend budget. + forced := svc.Freeze(2047) + require.True(t, forced.Build.Build) + require.True(t, forced.Build.Forced) + + svc.OnSlotBuilt(2047) + require.Equal(t, uint64(0), svc.GetSlotsBuilt()) + require.Equal(t, 1, svc.GetSlotsRemaining()) + + // A scheduled build on an unmatched slot still consumes it. + scheduled := svc.Freeze(2048) + require.True(t, scheduled.Build.Build) + require.False(t, scheduled.Build.Forced) + + svc.OnSlotBuilt(2048) + require.Equal(t, uint64(1), svc.GetSlotsBuilt()) + require.Equal(t, 0, svc.GetSlotsRemaining()) + + // With the budget spent the rule keeps forcing its own slots. + require.True(t, svc.Freeze(2079).Build.Build) + require.False(t, svc.Freeze(2080).Build.Build) +} + +func TestUnmatchableRulesAfterSpecChange(t *testing.T) { + chainSvc := newStubChain() + svc := newTestService(chainSvc, nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + require.Empty(t, svc.unmatchableRules()) + + // Same state-db, network with a shorter epoch: slot index 31 cannot occur. + chainSvc.spec.SlotsPerEpoch = 8 + + require.Equal(t, []string{"slot31-withhold"}, svc.unmatchableRules()) + require.Nil(t, svc.PlanForSlot(2047), "an unmatchable rule must not resolve") +} + func TestMatchRuleOrderIsDeterministic(t *testing.T) { svc := newTestService(newStubChain(), nil) diff --git a/pkg/action_plan/service.go b/pkg/action_plan/service.go index 6e9b058..52ca812 100644 --- a/pkg/action_plan/service.go +++ b/pkg/action_plan/service.go @@ -52,6 +52,10 @@ type PlanService struct { store *memstore.Store[phase0.Slot, *SlotPlan] rules *memstore.Store[string, *SlotRule] + // mu guards frozen and slotsBuilt, and additionally serializes recurring + // rule swaps against Freeze: both stores have their own locks, but only + // this mutex guarantees that no slot freezes against a half-replaced rule + // set. Do not drop it from SetRules. mu sync.Mutex frozen map[phase0.Slot]*FrozenPlan @@ -100,11 +104,52 @@ func (s *PlanService) Start(ctx context.Context) error { go s.run(epochSub) + // Rehydrated rules are validated against the spec they were written under, + // not the active one — a state-db carried to a network with a different + // SLOTS_PER_EPOCH would otherwise leave them silently dead. + for _, id := range s.unmatchableRules() { + s.log.WithFields(logrus.Fields{ + "rule": id, + "slots_per_epoch": s.slotsPerEpoch(), + }).Warn("Recurring rule can never match: slot index out of range for the active chain spec") + } + s.log.Info("Action plan service started") return nil } +// unmatchableRules returns the ids of rules carrying a slot index the active +// chain spec can never produce. Writes are validated against the spec, so this +// only ever fires for rules rehydrated from another network's state-db. +func (s *PlanService) unmatchableRules() []string { + rules := s.sortedRules() + if len(rules) == 0 { + return nil + } + + slotsPerEpoch := s.slotsPerEpoch() + if slotsPerEpoch == 0 { + s.log.Warn("Cannot verify recurring rules: chain spec unavailable") + + return nil + } + + var unmatchable []string + + for _, rule := range rules { + for _, index := range rule.SlotsInEpoch { + if index >= slotsPerEpoch { + unmatchable = append(unmatchable, rule.ID) + + break + } + } + } + + return unmatchable +} + // Stop terminates the pruning loop and flushes the store. Must be called // before the state-db closes. func (s *PlanService) Stop() { diff --git a/pkg/webui/src/components/actionplan/RuleEditModal.tsx b/pkg/webui/src/components/actionplan/RuleEditModal.tsx index 52daadd..5f702b2 100644 --- a/pkg/webui/src/components/actionplan/RuleEditModal.tsx +++ b/pkg/webui/src/components/actionplan/RuleEditModal.tsx @@ -93,6 +93,8 @@ function parseOptionalEpoch(raw: string, label: string): { value?: number; error interface RuleEditModalProps { /** The rule being edited, or null for a new one. */ rule: SlotRule | null; + /** Ids already in use — a new rule must not silently replace one of them. */ + existingIds: string[]; slotsPerEpoch: number; canEdit: boolean; saving: boolean; @@ -103,6 +105,7 @@ interface RuleEditModalProps { export const RuleEditModal: React.FC = ({ rule, + existingIds, slotsPerEpoch, canEdit, saving, @@ -172,6 +175,13 @@ export const RuleEditModal: React.FC = ({ e.preventDefault(); setFormError(null); + // Saving upserts by id, so a new rule reusing one would replace it without + // a trace — the server only sees the collapsed set and cannot catch it. + if (!rule && existingIds.includes(id.trim())) { + setFormError(`A rule with id "${id.trim()}" already exists — edit it or pick another id.`); + return; + } + const { slots, error: slotError } = parseSlotList(slotList, slotsPerEpoch); if (slotError) { setFormError(slotError); diff --git a/pkg/webui/src/components/actionplan/RulesPanel.tsx b/pkg/webui/src/components/actionplan/RulesPanel.tsx index 5edf8f2..636e13f 100644 --- a/pkg/webui/src/components/actionplan/RulesPanel.tsx +++ b/pkg/webui/src/components/actionplan/RulesPanel.tsx @@ -88,6 +88,8 @@ export const RulesPanel: React.FC = ({ onDone?.(); }; + // Upsert by id. The modal rejects a new rule reusing an existing id, so this + // only ever replaces the rule the operator opened for editing. const handleSave = (rule: SlotRule) => { const next = rules.some((r) => r.id === rule.id) ? rules.map((r) => (r.id === rule.id ? rule : r)) @@ -228,6 +230,7 @@ export const RulesPanel: React.FC = ({ r.id)} slotsPerEpoch={slotsPerEpoch} canEdit={canEdit} saving={saving} diff --git a/pkg/webui/src/hooks/useActionPlan.ts b/pkg/webui/src/hooks/useActionPlan.ts index 92529b7..403dca3 100644 --- a/pkg/webui/src/hooks/useActionPlan.ts +++ b/pkg/webui/src/hooks/useActionPlan.ts @@ -53,28 +53,6 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe const rangeRef = useRef({ min: minSlot, max: maxSlot }); rangeRef.current = { min: minSlot, max: maxSlot }; - // Merge an authoritative {slots, plans} change set (POST response or SSE - // event) into the in-range plan state; a null plan deletes the slot's entry. - const mergePlanChange = useCallback((slots: number[], changed: (SlotPlan | null)[]) => { - setPlans((prev) => { - let next: Record | null = null; - for (let i = 0; i < slots.length; i++) { - const slot = toSlotNumber(slots[i]); - if (slot < rangeRef.current.min || slot > rangeRef.current.max) continue; - const plan = changed[i] ?? null; - if (plan === null) { - if (prev[slot] === undefined && next === null) continue; - next = next ?? { ...prev }; - delete next[slot]; - } else { - next = next ?? { ...prev }; - next[slot] = plan; - } - } - return next ?? prev; - }); - }, []); - const fetchAll = useCallback(async () => { if (maxSlot < minSlot) return; @@ -124,6 +102,38 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe const fetchAllRef = useRef(fetchAll); fetchAllRef.current = fetchAll; + // Apply an authoritative {slots, plans} change set (POST response or SSE + // event) to the in-range plan state. A stored plan always wins over a + // recurring rule, so setting one can be merged in place — but DELETING one + // hands the slot back to whatever rule matches it, and only the server knows + // that. Any in-range deletion therefore refetches instead of merging. + const applyPlanChange = useCallback((slots: number[], changed: (SlotPlan | null)[]) => { + const { min, max } = rangeRef.current; + const inRange: number[] = []; + + for (let i = 0; i < slots.length; i++) { + const slot = toSlotNumber(slots[i]); + if (slot < min || slot > max) continue; + + if ((changed[i] ?? null) === null) { + fetchAllRef.current(); + return; + } + + inRange.push(i); + } + + if (inRange.length === 0) return; + + setPlans((prev) => { + const next = { ...prev }; + for (const i of inRange) { + next[toSlotNumber(slots[i])] = changed[i] as SlotPlan; + } + return next; + }); + }, []); + useEffect(() => { fetchAll(); }, [fetchAll]); @@ -133,7 +143,7 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe const offPlan = onStreamEvent('action_plan_updated', (data) => { const change = data as { slots?: number[]; plans?: (SlotPlan | null)[] }; if (!change?.slots?.length) return; - mergePlanChange(change.slots, change.plans || []); + applyPlanChange(change.slots, change.plans || []); }); // Rule changes rewrite the effective plan of every uncovered slot, so the @@ -155,7 +165,7 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe offRules(); offResult(); }; - }, [mergePlanChange]); + }, [applyPlanChange]); // Refetch the visible range after an SSE reconnect (updates during the gap // were never delivered). @@ -195,7 +205,7 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe // from our own patch. const change = body as UpdateActionPlanResponse; if (change?.slots?.length) { - mergePlanChange(change.slots, change.plans || []); + applyPlanChange(change.slots, change.plans || []); } return { ok: true }; @@ -203,7 +213,7 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }; } }, - [mergePlanChange] + [applyPlanChange] ); return { plans, results, loading, error, refetch: fetchAll, applyUpdates }; From 8f8c1e9a13560979b957cc15009809ce5c713f55 Mon Sep 17 00:00:00 2001 From: Stefan Date: Mon, 27 Jul 2026 14:25:49 +0200 Subject: [PATCH 3/4] use a bid subsidy in the win-and-withhold preset 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. --- CLAUDE.md | 8 ++++++-- pkg/webui/src/components/actionplan/RuleEditModal.tsx | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ce872d2..da086b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -856,9 +856,13 @@ saving detaches that slot into an explicit plan. 5. Run it for every epoch instead (the "slot 31 missing" devnet scenario): `curl -X POST .../api/buildoor/action-plan/rules -d '{"rules":[{ "id":"slot31-withhold","enabled":true,"slots_in_epoch":[31], - "bid":{"mode":"custom","bid_value_gwei":1000000,"ignore_missing_prefs":true}, + "bid":{"mode":"custom","bid_subsidy":1000000000,"ignore_missing_prefs":true}, "reveal":{"mode":"disabled"}}]}'` → every slot 31 is bid for and, once won, - never revealed: the block exists, its execution payload does not + never revealed: the block exists, its execution payload does not. Use + `bid_subsidy` (added on top of the block value) rather than the absolute + `bid_value_gwei` to outbid competitors: the absolute value replaces the block + value entirely and silently loses whenever it is set below what the block is + worth on that network ## Common Issues diff --git a/pkg/webui/src/components/actionplan/RuleEditModal.tsx b/pkg/webui/src/components/actionplan/RuleEditModal.tsx index 5f702b2..eaca9f0 100644 --- a/pkg/webui/src/components/actionplan/RuleEditModal.tsx +++ b/pkg/webui/src/components/actionplan/RuleEditModal.tsx @@ -25,12 +25,15 @@ const PRESETS: Preset[] = [ { id: 'win-and-withhold', label: 'Win the last slot of every epoch, withhold the payload', - hint: 'Bids an absolute value (tune it to outbid the other builders), then never reveals the envelope — the block exists, its execution payload does not.', + hint: 'Outbids the other builders with a large subsidy on top of the block value, then never reveals the envelope — the block exists, its execution payload does not.', apply: (slotsPerEpoch) => ({ id: 'slot-last-withhold', description: 'win the last slot of every epoch and withhold the payload', slots_in_epoch: [Math.max(0, slotsPerEpoch - 1)], - bid: { mode: 'custom', bid_value_gwei: 1000000, ignore_missing_prefs: true }, + // A subsidy adds to the block value, so it outbids competitors whatever + // the block is worth — an absolute bid_value_gwei would have to be + // re-tuned per network and silently loses when it guesses too low. + bid: { mode: 'custom', bid_subsidy: 1000000000, ignore_missing_prefs: true }, reveal: { mode: 'disabled' }, }), }, From 98b2fb4d12ac838bae9e561e771bc5f15bff31bf Mon Sep 17 00:00:00 2001 From: Stefan Date: Mon, 27 Jul 2026 15:41:36 +0200 Subject: [PATCH 4/4] size the win-and-withhold preset subsidy against real competition 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. --- CLAUDE.md | 6 ++++-- pkg/webui/src/components/actionplan/RuleEditModal.tsx | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index da086b9..d216920 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -856,13 +856,15 @@ saving detaches that slot into an explicit plan. 5. Run it for every epoch instead (the "slot 31 missing" devnet scenario): `curl -X POST .../api/buildoor/action-plan/rules -d '{"rules":[{ "id":"slot31-withhold","enabled":true,"slots_in_epoch":[31], - "bid":{"mode":"custom","bid_subsidy":1000000000,"ignore_missing_prefs":true}, + "bid":{"mode":"custom","bid_subsidy":200000000}, "reveal":{"mode":"disabled"}}]}'` → every slot 31 is bid for and, once won, never revealed: the block exists, its execution payload does not. Use `bid_subsidy` (added on top of the block value) rather than the absolute `bid_value_gwei` to outbid competitors: the absolute value replaces the block value entirely and silently loses whenever it is set below what the block is - worth on that network + worth on that network. A rule that loses the auction withholds nothing, and + the only symptom is the grid's "bid, not included" dot — size the subsidy + against the other builders' bids, not against a round number ## Common Issues diff --git a/pkg/webui/src/components/actionplan/RuleEditModal.tsx b/pkg/webui/src/components/actionplan/RuleEditModal.tsx index eaca9f0..8135290 100644 --- a/pkg/webui/src/components/actionplan/RuleEditModal.tsx +++ b/pkg/webui/src/components/actionplan/RuleEditModal.tsx @@ -33,7 +33,9 @@ const PRESETS: Preset[] = [ // A subsidy adds to the block value, so it outbids competitors whatever // the block is worth — an absolute bid_value_gwei would have to be // re-tuned per network and silently loses when it guesses too low. - bid: { mode: 'custom', bid_subsidy: 1000000000, ignore_missing_prefs: true }, + // Raise it if another builder still wins the slot: the grid's bid dot + // shows "bid, not included" for exactly that case. + bid: { mode: 'custom', bid_subsidy: 200000000 }, reveal: { mode: 'disabled' }, }), },