Skip to content

Add L1 multi-builder recipe#418

Draft
SozinM wants to merge 6 commits intomainfrom
msozin/l1-multi-builder
Draft

Add L1 multi-builder recipe#418
SozinM wants to merge 6 commits intomainfrom
msozin/l1-multi-builder

Conversation

@SozinM
Copy link
Copy Markdown
Contributor

@SozinM SozinM commented Apr 28, 2026

Summary

  • Add a separate l1-multi-builder base recipe that runs one L1 stack with configurable counts of rbuilder and mev-boost-relay services.
  • Wire Lighthouse through mev-boost to multiple local relays, and generate per-rbuilder TOML configs so each builder submits to every relay.
  • Make MevBoostRelay and Rbuilder reusable with distinct service names, preserve pid in generated YAML, and generate docs for the new recipe.

Validation

  • go test ./playground -run 'TestL1MultiBuilder|TestConvertServiceToYAML'
  • go test ./...
  • go run main.go generate l1-multi-builder --stdout
  • make generate-docs
  • make build

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 28, 2026

Claude finished @SozinM's task in 4m 7s —— View job


Review

Posted 5 inline comments. Summary of the most important issues:

  • All MevBoostRelay instances share one BLS pubkey. The relay CLI (mev-boost-relay/cmd/main.go) doesn't expose --api-secret-key, so every relay runs with mevboostrelay.DefaultSecretKey. localMevBoostRelayURL then encodes the same pubkey into every URL handed to mev-boost. Multiple relays therefore collapse to one from mev-boost's perspective, defeating the recipe's purpose. (components.go:730–749)
  • Embedded template vs configTOML() have diverged. The "if any field is set, regenerate; otherwise use the template" gate in Rbuilder.Apply switches between two non-equivalent TOMLs — live_builders is "mgp-ordering" in the template but "mp-ordering" in the generator (and the template has no [[builders]] block), coinbase_secret_key differs by 0x prefix, cl_node_url is a string vs an array. New fields will silently bypass the gate. (components.go:925–931)
  • Hand-rolled TOML emission and hardcoded beacon :3500. configTOML() is fragile and only strconv.Quotes some fields; prefer a TOML encoder. The hardcoded :3500 for cl_node_url will silently break if the lighthouse http port default changes. (components.go:869–918)
  • Per-builder internal port offsets are unnecessary (each rbuilder has its own network namespace), and RelaySecretKey = fmt.Sprintf("0x%064x", i) produces trivially weak BLS scalars (0x…0001, …). (recipe_l1_multi_builder.go:108–118)
  • normalizedBuilderCount / normalizedRelayCount / normalizedBlockTime silently turn 0 into a default, so --builders 0 runs 2 builders instead of failing or running zero. (recipe_l1_multi_builder.go:58–70)
    · branch: msozin/l1-multi-builder

Comment thread playground/components.go Outdated
Comment on lines +730 to +749
func localMevBoostRelayURL(endpoint string) (string, bool) {
envSkBytes, err := hexutil.Decode(mevboostrelay.DefaultSecretKey)
if err != nil {
return "", false
}
secretKey, err := bls.SecretKeyFromBytes(envSkBytes[:])
if err != nil {
return "", false
}
blsPublicKey, err := bls.PublicKeyFromSecretKey(secretKey)
if err != nil {
return "", false
}
publicKey, err := utils.BlsPublicKeyToPublicKey(blsPublicKey)
if err != nil {
return "", false
}

return ConnectRaw(endpoint, "http", "http", publicKey.String()), true
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All MevBoostRelay instances in this repo run with mevboostrelay.DefaultSecretKey (see mev-boost-relay/cmd/main.go — the CLI exposes no --api-secret-key flag), so localMevBoostRelayURL returns the same BLS pubkey for every endpoint passed to it. In the new l1-multi-builder recipe this means mev-boost is configured with N URLs that all share one pubkey. mev-boost dedupes/aggregates by pubkey internally, so users will not actually see N independent relays — the multi-relay topology silently degenerates to one. Either expose --api-secret-key on the relay CLI and assign per-relay keys here (similar to how rbuilder gets a per-builder RelaySecretKey), or document this limitation prominently.

Comment thread playground/components.go Outdated
Comment on lines +925 to +931
// TODO: Handle error
ctx.Output.WriteFile("rbuilder-config.toml", rbuilderConfigToml)
config := defaultRbuilderConfigToml
if r.ServiceName != "" || len(r.RelayEndpoints) > 0 || r.BeaconNode != "" || r.ExecutionNode != "" ||
r.RelaySecretKey != "" || r.ExtraData != "" || r.JSONRPCPort != 0 || r.RedactedPort != 0 || r.FullMetricsPort != 0 {
config = r.configTOML()
}
ctx.Output.WriteFile(configArtifact, config)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "if any field was set, regenerate the TOML programmatically; otherwise use the embedded template" gate is fragile: the template and configTOML() are now divergent (e.g. live_builders = ["mgp-ordering"] in the template vs ["mp-ordering"] here, no [[builders]] block in the template, coinbase_secret_key with vs without 0x prefix, cl_node_url as a string vs an array), so equivalent inputs produce different rbuilder behavior depending on whether any field was set. Adding a new field also requires remembering to extend this || chain. Prefer one source of truth — either always go through configTOML() (with the template removed/inlined as defaults), or template both code paths. Also, the // TODO: Handle error comment at the top has no error to handle.

Comment thread playground/recipe_l1_multi_builder.go Outdated
Comment on lines +108 to +118
for i := 1; i <= l.normalizedBuilderCount(); i++ {
component.AddComponent(ctx, &Rbuilder{
ServiceName: fmt.Sprintf("rbuilder-%d", i),
RelayEndpoints: relayServices,
RelaySecretKey: fmt.Sprintf("0x%064x", i),
ExtraData: fmt.Sprintf("Playground Builder %d", i),
JSONRPCPort: 8645 + i - 1,
RedactedPort: 6061 + i - 1,
FullMetricsPort: 6060 + i - 1,
})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each rbuilder runs in its own container and therefore its own network namespace, so these per-instance internal port offsets (8645+i-1, 6061+i-1, 6060+i-1) aren't needed to avoid collisions and just create three more chances to misconfigure. You can leave the rbuilder defaults (Rbuilder.jsonRPCPort/redactedPort/fullMetricsPort) for every instance — they only collide if exposed to the host, and that should be expressed through the {{Port "name" default}} template rather than hardcoded ints.

Also, the RelaySecretKey is derived as fmt.Sprintf("0x%064x", i), producing trivial scalars 0x…0001, 0x…0002, …. Some BLS libraries reject low-entropy / boundary scalars; even where accepted these are recognizably weak keys. For a testing harness this is probably fine, but at minimum hash the index (e.g. keccak256(fmt.Sprintf("rbuilder-%d", i))) so the keys aren't visually trivial.

Comment thread playground/recipe_l1_multi_builder.go Outdated
Comment on lines +58 to +70
func (l *L1MultiBuilderRecipe) normalizedBuilderCount() int {
if l.builderCount > 0 {
return l.builderCount
}
return defaultL1MultiBuilderCount
}

func (l *L1MultiBuilderRecipe) normalizedRelayCount() int {
if l.relayCount > 0 {
return l.relayCount
}
return defaultL1MultiRelayCount
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizedBuilderCount/normalizedRelayCount silently rewrite 0 (and any negative value) to the default. Passing --builders 0 or --relays 0 will quietly run 2 of each instead of failing or running zero. Either validate the flag and return an error, or change the condition to >= 0 so explicit zero is honored. Same comment for normalizedBlockTime--block-time 0 is silently replaced.

Comment thread playground/components.go Outdated
Comment on lines +869 to +918
func (r *Rbuilder) configTOML() string {
relayEndpoints := r.relayEndpoints()
relayNames := make([]string, 0, len(relayEndpoints))
for _, relay := range relayEndpoints {
relayNames = append(relayNames, strconv.Quote(relay))
}

var b strings.Builder
fmt.Fprintf(&b, "log_json = false\n")
fmt.Fprintf(&b, "log_level = \"info,rbuilder=debug\"\n")
fmt.Fprintf(&b, "redacted_telemetry_server_port = %d\n", r.redactedPort())
fmt.Fprintf(&b, "redacted_telemetry_server_ip = \"0.0.0.0\"\n")
fmt.Fprintf(&b, "full_telemetry_server_port = %d\n", r.fullMetricsPort())
fmt.Fprintf(&b, "full_telemetry_server_ip = \"0.0.0.0\"\n\n")
fmt.Fprintf(&b, "chain = \"/data/genesis.json\"\n")
fmt.Fprintf(&b, "reth_datadir = \"/data_reth\"\n")
fmt.Fprintf(&b, "el_node_ipc_path = \"/data_reth/reth.ipc\"\n")
fmt.Fprintf(&b, "coinbase_secret_key = \"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80\"\n")
fmt.Fprintf(&b, "relay_secret_key = %s\n", strconv.Quote(r.relaySecretKey()))
fmt.Fprintf(&b, "cl_node_url = [%s]\n", strconv.Quote(fmt.Sprintf("http://%s:3500", r.beaconNode())))
fmt.Fprintf(&b, "jsonrpc_server_port = %d\n", r.jsonRPCPort())
fmt.Fprintf(&b, "jsonrpc_server_ip = \"0.0.0.0\"\n")
fmt.Fprintf(&b, "extra_data = %s\n\n", strconv.Quote(r.extraData()))
fmt.Fprintf(&b, "ignore_cancellable_orders = true\n")
fmt.Fprintf(&b, "root_hash_use_sparse_trie = true\n")
fmt.Fprintf(&b, "root_hash_compare_sparse_trie = false\n")
fmt.Fprintf(&b, "slot_delta_to_start_bidding_ms = -20000\n\n")
fmt.Fprintf(&b, "live_builders = [\"mp-ordering\"]\n")
fmt.Fprintf(&b, "enabled_relays = [%s]\n\n", strings.Join(relayNames, ", "))

for i, relay := range relayEndpoints {
fmt.Fprintf(&b, "[[relays]]\n")
fmt.Fprintf(&b, "name = %s\n", strconv.Quote(relay))
fmt.Fprintf(&b, "url = %s\n", strconv.Quote(fmt.Sprintf("http://%s:5555", relay)))
fmt.Fprintf(&b, "priority = %d\n", i)
fmt.Fprintf(&b, "use_ssz_for_submit = false\n")
fmt.Fprintf(&b, "use_gzip_for_submit = false\n")
fmt.Fprintf(&b, "mode = \"full\"\n\n")
}

fmt.Fprintf(&b, "[[builders]]\n")
fmt.Fprintf(&b, "name = \"mp-ordering\"\n")
fmt.Fprintf(&b, "algo = \"ordering-builder\"\n")
fmt.Fprintf(&b, "discard_txs = true\n")
fmt.Fprintf(&b, "sorting = \"max-profit\"\n")
fmt.Fprintf(&b, "failed_order_retries = 1\n")
fmt.Fprintf(&b, "drop_failed_orders = true\n")

return b.String()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building TOML by hand with fmt.Fprintf is fragile: anything in r.extraData() containing a " or backslash is only safe because strconv.Quote is used here, but other call sites (e.g. relay names directly interpolated into URLs on line 902) don't get the same treatment, and a future edit can easily inject a string field without quoting. Prefer encoding via github.com/pelletier/go-toml/v2 or BurntSushi/toml and a typed struct — that also makes the divergence with rbuilder-config.toml.tmpl visible. The hardcoded :3500 in cl_node_url (line 888) is also brittle: if the lighthouse beacon's http port template changes, this silently breaks.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 28, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 28, 2026

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 28, 2026

Claude finished @SozinM's task in 4m 20s —— View job


PR Review

Posted 6 inline comments. Most issues raised in the prior review are still present.

Most important:

  • Multi-relay topology silently degenerates to one relay. MevBoostRelay.Apply doesn't pass any per-relay key to the relay binary, so all instances run with mevboostrelay.DefaultSecretKey. localMevBoostRelayURL then encodes the same BLS pubkey into every URL, and mev-boost dedupes by pubkey — so --relays N looks like one relay to the boost layer. Defeats the recipe's purpose. (components.go:730–749)
  • Embedded template and configTOML() produce materially different rbuilder behavior (live_builders is ["mgp-ordering"] vs ["mp-ordering"], cl_node_url string vs array, relay_secret_key with/without 0x, no [[builders]] block in the template, telemetry/extra_data/etc. only in the generator). The "any field set → regenerate, otherwise template" gate is fragile — adding a new field can silently bypass the gate. Pick one source of truth. (components.go:925–931)
  • Hand-rolled TOML emission with hardcoded :3500 for beacon and :5555 for relay URLs (line 888, 902). BurntSushi/toml is already a transitive dep — use a typed struct + encoder, and Connect(node, "http") for service URLs so the playground's port/template machinery stays the source of truth. (components.go:869–918)
  • Per-builder port offsets unnecessary (each rbuilder is its own network namespace). RelaySecretKey = fmt.Sprintf("0x%064x", i) produces 0x…0001, … — trivial low-entropy BLS scalars. Hash the index instead. (recipe_l1_multi_builder.go:108–118)
  • normalize* silently rewrites 0 and negatives to a default so --builders 0 runs 2. Validate or honor explicit zero. (recipe_l1_multi_builder.go:51–70)
  • Unrelated regression in test_tx.go: removes buildernetSigningTransport, dropping the X-BuilderNet-Signature header from every playground test-tx request. This is independent of the multi-builder recipe and likely breaks BuilderNet/FlowProxy auth — split into its own PR. (test_tx.go:95)

Smaller nits:

  • The // TODO: Handle error comment on components.go:925 refers to nothing (defaultRbuilderConfigToml is an embedded string, no error to handle).
  • The test only exercises rbuilder-2's config — extending it to assert rbuilder-1/rbuilder-3 would catch off-by-one issues in the indexed JSONRPCPort/RelaySecretKey/ExtraData derivations if you keep them.
    · branch: msozin/l1-multi-builder

Comment thread playground/components.go Outdated
Comment on lines +730 to +749
func localMevBoostRelayURL(endpoint string) (string, bool) {
envSkBytes, err := hexutil.Decode(mevboostrelay.DefaultSecretKey)
if err != nil {
return "", false
}
secretKey, err := bls.SecretKeyFromBytes(envSkBytes[:])
if err != nil {
return "", false
}
blsPublicKey, err := bls.PublicKeyFromSecretKey(secretKey)
if err != nil {
return "", false
}
publicKey, err := utils.BlsPublicKeyToPublicKey(blsPublicKey)
if err != nil {
return "", false
}

return ConnectRaw(endpoint, "http", "http", publicKey.String()), true
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-relay topology silently degenerates to one relay. MevBoostRelay.Apply (line 647) doesn't pass any per-relay secret key to the relay binary, so every instance runs with mevboostrelay.DefaultSecretKey. localMevBoostRelayURL then encodes the same BLS pubkey into every URL passed to mev-boost, and mev-boost dedupes by pubkey — so --relays N ends up looking like one relay to the boost layer. This defeats the recipe's purpose.

This was raised in the previous review and is still unaddressed. Either:

  • Expose --api-secret-key on the relay CLI and assign per-relay keys (mirroring how rbuilder gets a per-builder RelaySecretKey), or
  • Document this limitation prominently and reduce the default relays to 1.

Comment thread playground/components.go Outdated
Comment on lines +925 to +931
// TODO: Handle error
ctx.Output.WriteFile("rbuilder-config.toml", rbuilderConfigToml)
config := defaultRbuilderConfigToml
if r.ServiceName != "" || len(r.RelayEndpoints) > 0 || r.BeaconNode != "" || r.ExecutionNode != "" ||
r.RelaySecretKey != "" || r.ExtraData != "" || r.JSONRPCPort != 0 || r.RedactedPort != 0 || r.FullMetricsPort != 0 {
config = r.configTOML()
}
ctx.Output.WriteFile(configArtifact, config)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two non-equivalent code paths for the same component. The "any field set → regenerate, otherwise use template" gate switches between TOMLs with materially different content:

field template configTOML()
live_builders ["mgp-ordering"] (note: mgp, no matching [[builders]]) ["mp-ordering"] (with [[builders]] name = "mp-ordering")
cl_node_url string "http://beacon:3500" array ["http://beacon:3500"]
relay_secret_key unprefixed 5eae... 0x... prefixed
enabled_relays ["playground"] named after the relay services
coinbase_secret_key no 0x 0x prefix
telemetry/extra_data/ignore_cancellable_orders absent present

So Rbuilder{} (no fields) and Rbuilder{ServiceName: "rbuilder"} produce two materially different rbuilder configurations. Adding any new field also requires extending this || chain — easy to miss.

Pick one source of truth: either always call configTOML() (delete the template, or inline its values as defaults), or template both code paths through the same generator. Also, the // TODO: Handle error on line 925 refers to nothing — defaultRbuilderConfigToml is an embedded string and WriteFile already returns nothing handle-able here.

Comment thread playground/components.go Outdated
Comment on lines +869 to +918
func (r *Rbuilder) configTOML() string {
relayEndpoints := r.relayEndpoints()
relayNames := make([]string, 0, len(relayEndpoints))
for _, relay := range relayEndpoints {
relayNames = append(relayNames, strconv.Quote(relay))
}

var b strings.Builder
fmt.Fprintf(&b, "log_json = false\n")
fmt.Fprintf(&b, "log_level = \"info,rbuilder=debug\"\n")
fmt.Fprintf(&b, "redacted_telemetry_server_port = %d\n", r.redactedPort())
fmt.Fprintf(&b, "redacted_telemetry_server_ip = \"0.0.0.0\"\n")
fmt.Fprintf(&b, "full_telemetry_server_port = %d\n", r.fullMetricsPort())
fmt.Fprintf(&b, "full_telemetry_server_ip = \"0.0.0.0\"\n\n")
fmt.Fprintf(&b, "chain = \"/data/genesis.json\"\n")
fmt.Fprintf(&b, "reth_datadir = \"/data_reth\"\n")
fmt.Fprintf(&b, "el_node_ipc_path = \"/data_reth/reth.ipc\"\n")
fmt.Fprintf(&b, "coinbase_secret_key = \"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80\"\n")
fmt.Fprintf(&b, "relay_secret_key = %s\n", strconv.Quote(r.relaySecretKey()))
fmt.Fprintf(&b, "cl_node_url = [%s]\n", strconv.Quote(fmt.Sprintf("http://%s:3500", r.beaconNode())))
fmt.Fprintf(&b, "jsonrpc_server_port = %d\n", r.jsonRPCPort())
fmt.Fprintf(&b, "jsonrpc_server_ip = \"0.0.0.0\"\n")
fmt.Fprintf(&b, "extra_data = %s\n\n", strconv.Quote(r.extraData()))
fmt.Fprintf(&b, "ignore_cancellable_orders = true\n")
fmt.Fprintf(&b, "root_hash_use_sparse_trie = true\n")
fmt.Fprintf(&b, "root_hash_compare_sparse_trie = false\n")
fmt.Fprintf(&b, "slot_delta_to_start_bidding_ms = -20000\n\n")
fmt.Fprintf(&b, "live_builders = [\"mp-ordering\"]\n")
fmt.Fprintf(&b, "enabled_relays = [%s]\n\n", strings.Join(relayNames, ", "))

for i, relay := range relayEndpoints {
fmt.Fprintf(&b, "[[relays]]\n")
fmt.Fprintf(&b, "name = %s\n", strconv.Quote(relay))
fmt.Fprintf(&b, "url = %s\n", strconv.Quote(fmt.Sprintf("http://%s:5555", relay)))
fmt.Fprintf(&b, "priority = %d\n", i)
fmt.Fprintf(&b, "use_ssz_for_submit = false\n")
fmt.Fprintf(&b, "use_gzip_for_submit = false\n")
fmt.Fprintf(&b, "mode = \"full\"\n\n")
}

fmt.Fprintf(&b, "[[builders]]\n")
fmt.Fprintf(&b, "name = \"mp-ordering\"\n")
fmt.Fprintf(&b, "algo = \"ordering-builder\"\n")
fmt.Fprintf(&b, "discard_txs = true\n")
fmt.Fprintf(&b, "sorting = \"max-profit\"\n")
fmt.Fprintf(&b, "failed_order_retries = 1\n")
fmt.Fprintf(&b, "drop_failed_orders = true\n")

return b.String()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hand-rolled TOML with quoting inconsistencies and a hidden coupling.

  • github.com/BurntSushi/toml is already a transitive dep (go.mod); use a TOML encoder + a typed config struct rather than fmt.Fprintf with mixed strconv.Quote / raw literals. As written, a future edit that interpolates an unquoted string field (e.g. extra_data, name) is a quoting bug waiting to happen.
  • Line 888 hardcodes :3500 for the beacon URL. The lighthouse beacon's http port is declared as {{Port "http" 3500}} (line 555), so a default change there silently breaks rbuilder. Use Connect(r.beaconNode(), "http") instead — the same template substitution that other components use.
  • Line 902 hardcodes :5555 for the relay URL the same way; prefer Connect(relay, "http").
  • Line 886 (coinbase_secret_key) hardcodes the first prefunded account secret. If the prefunded account list is ever reordered, this drifts silently.

Comment thread playground/recipe_l1_multi_builder.go Outdated
Comment thread playground/recipe_l1_multi_builder.go Outdated
Comment thread playground/test_tx.go
@SozinM SozinM changed the title [codex] Add L1 multi-builder recipe Add L1 multi-builder recipe Apr 29, 2026
- Per-relay BLS keys: expose --api-secret-key on mev-boost-relay,
  give each MevBoostRelay a distinct hash-derived scalar (reduced mod
  BLS12-381 order) and embed the matching pubkey in mev-boost's
  --relay URL. Multi-relay topology no longer collapses to one pubkey.
- Drop rbuilder-config.toml.tmpl; Rbuilder always emits config via a
  typed struct + BurntSushi/toml encoder. Single source of truth.
- Replace hardcoded :3500 / :5555 in TOML with port constants shared
  by lighthouse beacon and mev-boost-relay component declarations.
- Drop per-builder JSONRPCPort/RedactedPort/FullMetricsPort offsets;
  each rbuilder uses defaults in its own netns, host allocator handles
  host-side collisions.
- Replace 0x...0001 BLS scalars with keccak256-derived secrets reduced
  to the curve order so blst accepts them.
- Validate --builders / --relays / --block-time after flag parsing so
  explicit zero/negative values fail loudly instead of being silently
  rewritten to defaults.
- Restore buildernetSigningTransport in test_tx.go (unrelated to this
  recipe; the FlowProxy auth header should not be removed here).
- Pass GENESIS_TIMESTAMP to mev-boost so getHeader is not skipped as
  past lateInSlotTimeMs; without this the validator falls back to a
  reth-built block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 29, 2026

Claude finished @SozinM's task in 5m 23s —— View job


PR Review

Posted 5 inline comments. The latest commit (c443155) addresses the major previous review issues — per-relay BLS keys, single source of truth for rbuilder TOML, port constants, dropped per-builder offsets, hash-derived BLS scalars, and Validate() for --builders/--relays/--block-time. Remaining issues:

Most important

  • Validate() runs only on start, not generate. main.go:786 only invokes Validate() from runIt. playground generate l1-multi-builder --builders -1 --stdout still produces a 2-builder YAML because RecipeToYAML calls Apply, and builderCountOrDefault/relayCountOrDefault/blockTimeOrDefault silently substitute the default for <= 0. Either call Validate() from RecipeToYAML (or generateCmd.RunE), or drop the *OrDefault helpers — Validate() already guarantees positive values on the start path, and the flag.IntVar defaults handle the unset case. (recipe_l1_multi_builder.go:69–88)
  • Silent fallback in MevBoost.Apply hides bad-secret errors. When localMevBoostRelayURL returns ok=false, the code falls through to Connect(endpoint.Service, "http"), producing a URL with no 0xpubkey@ segment that mev-boost rejects far from the offending line. Better to propagate the error up. (components.go:812–822)

Smaller nits

  • MevBoostRelayEndpoint is documented as "exactly one form should be set" (URL xor Service) but nothing enforces it; if both are set, URL wins silently. (components.go:747–759)
  • RedactedTelemetryServerPt looks like a truncated Port — every other port field in rbuilderConfig is spelled …Port. (components.go:930)
  • The new in-container port constants are a real improvement, but they sidestep the {{Port …}} override layer: if a user overrides mev-boost-relay-1:http, the relay binds to the new port but the rbuilder TOML still emits :5555. Worth documenting (or — longer term — exposing in-container ports through the same template substitution that powers Connect()). (components.go:18–29)
    · branch: msozin/l1-multi-builder

Comment thread playground/recipe_l1_multi_builder.go Outdated
Comment on lines +69 to +88
func (l *L1MultiBuilderRecipe) builderCountOrDefault() int {
if l.builderCount > 0 {
return l.builderCount
}
return defaultL1MultiBuilderCount
}

func (l *L1MultiBuilderRecipe) relayCountOrDefault() int {
if l.relayCount > 0 {
return l.relayCount
}
return defaultL1MultiRelayCount
}

func (l *L1MultiBuilderRecipe) blockTimeOrDefault() time.Duration {
if l.blockTime > 0 {
return l.blockTime
}
return time.Duration(defaultL1BlockTimeSeconds) * time.Second
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate() runs only inside runIt, but generate and RecipeToYAML skip it.

main.go:786 does if v, ok := recipe.(interface{ Validate() error }); ok { ... } only on the start path. generate l1-multi-builder --stdout calls RecipeToYAMLrecipe.Apply(...) directly, which uses builderCountOrDefault/relayCountOrDefault/blockTimeOrDefault. Those silently rewrite 0 and any negative value back to the default — which is exactly the behavior Validate() was added to forbid.

Repro:

playground generate l1-multi-builder --builders -1 --stdout   # produces 2 builders
playground start    l1-multi-builder --builders -1            # errors, as expected

Either:

  • Call Validate() from RecipeToYAML (or wire it into generateCmd.RunE), or
  • Drop the *OrDefault helpers entirely and use l.builderCount / l.relayCount / l.blockTime directly — Validate() already guarantees they're > 0 on the start path, and the flag.IntVar defaults handle the unset case at flag-parse time.

The current shape leaves the helpers as defensive dead code that re-introduces the very behavior the validator forbids.

Comment thread playground/components.go Outdated
LogJSON bool `toml:"log_json"`
LogLevel string `toml:"log_level"`
RedactedTelemetryServerIP string `toml:"redacted_telemetry_server_ip"`
RedactedTelemetryServerPt int `toml:"redacted_telemetry_server_port"`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field name RedactedTelemetryServerPt looks like a truncated Port; every other port field in this struct is spelled …Port (FullTelemetryServerPort, JSONRPCServerPort). Not externally visible (the toml tag is right) but jarring for the next reader.

Suggested change
RedactedTelemetryServerPt int `toml:"redacted_telemetry_server_port"`
RedactedTelemetryServerPort int `toml:"redacted_telemetry_server_port"`

Comment thread playground/components.go
Comment on lines 812 to 822
for _, endpoint := range m.RelayEndpoints {
if endpoint == "mev-boost-relay" {
// creating relay url with public key since mev-boost requires it
envSkBytes, err := hexutil.Decode(mevboostrelay.DefaultSecretKey)
if err != nil {
continue
}
secretKey, err := bls.SecretKeyFromBytes(envSkBytes[:])
if err != nil {
continue
}
blsPublicKey, err := bls.PublicKeyFromSecretKey(secretKey)
if err != nil {
continue
}
publicKey, err := utils.BlsPublicKeyToPublicKey(blsPublicKey)
if err != nil {
continue
}

relayURL := ConnectRaw("mev-boost-relay", "http", "http", publicKey.String())
if endpoint.URL != "" {
args = append(args, "--relay", endpoint.URL)
continue
}
if relayURL, ok := localMevBoostRelayURL(endpoint.Service, endpoint.SecretKey); ok {
args = append(args, "--relay", relayURL)
} else {
args = append(args, "--relay", Connect(endpoint, "http"))
continue
}
args = append(args, "--relay", Connect(endpoint.Service, "http"))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent fallback hides bad-secret errors.

When localMevBoostRelayURL returns ok=false (the secret fails to decode or BLS rejects it), the code falls through to Connect(endpoint.Service, "http"), which produces a URL with no pubkey@host segment. mev-boost requires the 0xpubkey@ prefix and will reject the relay outright at startup — far from the line that built the bad URL.

Two cleaner options:

  1. Return the error from localMevBoostRelayURL and propagate it up (panic in Apply, like rbuilder already does on configTOML failure), so a malformed secret fails at manifest-build time with a clear message.
  2. Drop the fallback line and let an empty-URL/empty-Service endpoint be a hard error.

The current shape only matters if Service != "" and SecretKey is malformed — which is unreachable for the in-tree recipes today, but is a foot-gun for anyone adding a new caller.

Comment thread playground/components.go
Comment on lines +747 to +759
// MevBoostRelayEndpoint identifies a relay reachable by mev-boost.
//
// Exactly one form should be set:
// - URL: a fully-formed `scheme://pubkey@host:port` URL (used for external
// relays).
// - Service: the docker-compose service name of a local relay; SecretKey is
// the BLS secret used to derive the pubkey embedded in the URL (defaults
// to mevboostrelay.DefaultSecretKey).
type MevBoostRelayEndpoint struct {
URL string
Service string
SecretKey string
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc says "Exactly one form should be set", but nothing enforces it. If a caller sets both URL and Service, URL wins silently (Apply checks URL != "" first). For a struct documented as a discriminated union, either:

  • enforce it: panic / return an error if both are set, or
  • collapse the API — e.g. one constructor MevBoostRelayEndpointFromService(svc, secret) and another MevBoostRelayEndpointFromURL(url), instead of a flat struct.

This isn't urgent (current callers use one form), but the struct's contract is currently honor-system.

Comment thread playground/components.go
Comment on lines 18 to +29
latestPlaygroundUtilsTag = "cc6f172493d7ef6b88a5b7895f4b8619806c99f9"
)

// In-container default ports shared between component args and artifact files
// (e.g. rbuilder TOML). Components must keep their `{{Port "name" default}}`
// declarations in sync with these constants so URLs in artifact files resolve
// to the correct in-container port.
const (
lighthouseBeaconHTTPPort = 3500
mevBoostRelayHTTPPort = 5555
rbuilderJSONRPCPort = 8645
rbuilderRedactedPort = 6061
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constants line up the in-container default ports between the component args and the TOML/URL strings that other components hand out, but the link is one-way.

If a user overrides e.g. mev-boost-relay-1:http via the {{Port "name" default}} machinery (or via --override), the relay binds to the new port, but the rbuilder TOML still emits http://mev-boost-relay-1:5555 because configTOML uses the constant. Same for the lighthouse beacon URL.

Consider documenting that "in-container ports are not user-overridable for these components" (and adding a guard in the override layer if you want), or — long-term — exposing in-container ports to other components through the same template substitution that already powers Connect() for cross-service URLs. The constants are an improvement over the prior hardcoded literals; just be aware they sidestep the override layer.

- Run Validate() on every recipe entry point: RecipeToYAML and
  ValidateRecipe both invoke it now, so `playground generate
  l1-multi-builder --builders -1 --stdout` and `playground validate`
  fail loudly instead of silently substituting the default.
- Drop *OrDefault helpers from L1MultiBuilderRecipe; GetBaseRecipes
  calls Flags() once on each recipe so pflag's IntVar/DurationVar
  defaults populate the struct fields, and Validate() now guards every
  consumer path.
- Propagate localMevBoostRelayURL errors instead of silently falling
  back to a `Connect(...)` URL with no `0xpubkey@` prefix that
  mev-boost rejects far from the offending line.
- Enforce the URL xor Service contract on MevBoostRelayEndpoint with
  a validate() method called from MevBoost.Apply; tests in
  components_test.go cover all combinations.
- Rename rbuilderConfig.RedactedTelemetryServerPt to
  RedactedTelemetryServerPort to match every other …Port field.
- Document the in-container port constants' interaction with the
  manifest's port allocator and `--override`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 29, 2026

Claude finished @SozinM's task in 6m 28s —— View job


PR Review

The latest two commits (c443155, ce0d122) addressed the major points from the prior reviews — Validate() is now also wired into RecipeToYAML, the MevBoostRelayEndpoint xor contract is enforced, the typo on RedactedTelemetryServerPort is fixed, and the silent fallback in MevBoost.Apply now panics. Posted 4 inline comments on remaining issues.

Most important

  • YAMLRecipe doesn't propagate Validate() to its base recipe. runIt and RecipeToYAML probe the outer recipe via interface{ Validate() error }. A YAML file with base: l1-multi-builder and base_args: [--builders, -1] silently produces an empty manifest on both start and generate instead of failing. Since this is the first recipe to ship a Validate(), this gap is invisible everywhere else but real here. (recipe_l1_multi_builder.go:53–69)

Smaller

  • MevBoostRelay.SecretKey and MevBoostRelayEndpoint.SecretKey are kept in sync by the caller, not by the type system. Recipe pulls both from the same relaySecrets[i] slice, but nothing links them — a future caller setting one without the other produces a relay whose bid signatures fail mev-boost's pubkey check. Could be collapsed by building endpoints from MevBoostRelay instances. Also: --api-secret-key puts the BLS scalar in docker inspect / playground.yaml; fine for a test harness, worth knowing. (components.go:685–691)
  • builderCoinbaseSecretKey silently couples to staticPrefundedAccounts[0] — comment notes it, but a reorder would make the rbuilder coinbase unfunded with no signal. Either reference the slice directly or add a CI-tripping equality test. (components.go:858–866)
  • The new test asserts each relay URL contains 0x but not that the embedded pubkeys differ across relays — the property the per-relay key plumbing exists to provide. A NotEqual between extracted URLs would lock the invariant in. (recipe_l1_multi_builder_test.go:40–43)
    · branch: msozin/l1-multi-builder

Comment on lines +53 to +69
// Validate checks the recipe's flag-backed fields. Called from every entry
// point that consumes the recipe (cook/start, generate, validate) so that
// explicit zero/negative values fail loudly instead of being silently
// rewritten to defaults. GetBaseRecipes registers Flags() once on each
// instance so the struct zero never reaches Validate in normal usage.
func (l *L1MultiBuilderRecipe) Validate() error {
if l.builderCount < 1 {
return fmt.Errorf("--builders must be >= 1, got %d", l.builderCount)
}
if l.relayCount < 1 {
return fmt.Errorf("--relays must be >= 1, got %d", l.relayCount)
}
if l.blockTime <= 0 {
return fmt.Errorf("--block-time must be > 0, got %s", l.blockTime)
}
return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validate() is bypassed when this recipe is wrapped in a YAMLRecipe.

runIt (main.go:786) and RecipeToYAML (recipe_to_yaml.go:14–19) both probe the outer recipe via interface{ Validate() error }. *YAMLRecipe doesn't implement Validate(), so a YAML file like

base: l1-multi-builder
base_args: ["--builders", "-1"]

silently produces an empty manifest (no rbuilders, no relays) on both playground start and playground generate instead of erroring with --builders must be >= 1.

The structural fix lives in recipe_yaml.go — add a one-line forwarder:

func (y *YAMLRecipe) Validate() error {
    if v, ok := y.baseRecipe.(interface{ Validate() error }); ok {
        return v.Validate()
    }
    return nil
}

Worth doing in this PR since this is the first recipe to ship a Validate() method, so this gap was invisible before.

Comment thread playground/components.go
Comment on lines +858 to +866
// defaultRbuilderRelaySecretKey is the BLS secret rbuilder uses to sign bids
// when no override is given. Single-builder L1 setups use this; multi-builder
// recipes derive distinct keys per builder.
const defaultRbuilderRelaySecretKey = "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866"

// builderCoinbaseSecretKey is the EOA secret used as the rbuilder coinbase. It
// matches staticPrefundedAccounts[0] so the builder has funds to pay the
// proposer in playground genesis.
const builderCoinbaseSecretKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implicit, untested coupling between builderCoinbaseSecretKey and staticPrefundedAccounts[0].

The comment notes the coupling, but nothing enforces it: if staticPrefundedAccounts (artifacts.go:209) is ever reordered or the first entry replaced, the rbuilder coinbase silently becomes an unfunded account and bids never get included. There is no compile-time link and no test.

Two cheap options:

  • Replace the constant with staticPrefundedAccounts[0] directly (most direct — couples them at the source) and rename the constant to live next to that slice.
  • Add a TestBuilderCoinbaseMatchesPrefunded that asserts builderCoinbaseSecretKey == staticPrefundedAccounts[0], so reordering the prefunded list trips CI.

Comment thread playground/components.go
Comment on lines +685 to +691
"--api-listen-port", fmt.Sprintf(`{{Port "http" %d}}`, mevBoostRelayHTTPPort),
"--beacon-client-addr", Connect(m.BeaconClient, "http"),
)

if m.SecretKey != "" {
service.WithArgs("--api-secret-key", m.SecretKey)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The relay <-> mev-boost-endpoint pairing relies on callers manually keeping the two SecretKey fields in syncMevBoostRelay.SecretKey (passed via --api-secret-key here) and MevBoostRelayEndpoint.SecretKey (used to derive the pubkey embedded in the URL handed to mev-boost). The recipe assigns them from the same relaySecrets[i] slice, but nothing in the type system links them, so a future caller that sets one but not the other would silently produce a relay whose bid signatures fail mev-boost's pubkey check.

Since the only valid configuration is "both equal" or "both empty", consider collapsing the two: e.g. take the relay's resolved SecretKey (with the DefaultSecretKey fallback applied here) and have the recipe build endpoints from MevBoostRelay instances rather than from a parallel string slice. That removes the foot-gun and the duplicated relaySecrets[i] plumbing in recipe_l1_multi_builder.go:122–137.

Also: --api-secret-key lands the BLS scalar in docker inspect, the rendered playground.yaml, and any saved manifest. Fine for a deterministic test harness, just worth knowing.

Comment on lines +40 to +43
mevBoost := manifest.MustGetService("mev-boost")
mevBoostArgs := strings.Join(mevBoost.Args, " ")
require.Contains(t, mevBoostArgs, `{{Service "mev-boost-relay-1" "http" "http" "0x`)
require.Contains(t, mevBoostArgs, `{{Service "mev-boost-relay-2" "http" "http" "0x`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions verify each --relay URL has the 0x prefix, but not that the embedded pubkeys actually differ across relays. Since the whole point of plumbing per-relay --api-secret-key was so mev-boost sees distinct pubkeys, an assertion on that property would lock it in:

relay1URL := extractRelayArg(mevBoost.Args, "mev-boost-relay-1")
relay2URL := extractRelayArg(mevBoost.Args, "mev-boost-relay-2")
require.NotEqual(t, relay1URL, relay2URL, "mev-boost must see distinct relay pubkeys")

A regression that re-shared the secret (e.g. if someone later collapses relaySecrets[i]) would otherwise still pass this test as long as the URL format is intact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant