Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/recipes/l1-multi-builder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# l1-multi-builder Recipe

Deploy a full L1 stack with multiple builders and relays.

## Flags

- `block-time` (duration): Block time to use for the L1. Default to '12s'.
- `builders` (int): number of rbuilder instances to run. Default to '2'.
- `latest-fork` (bool): use the latest fork. Default to 'false'.
- `relays` (int): number of mev-boost-relay instances to run. Default to '2'.
- `use-reth-for-validation` (bool): use reth for validation. Default to 'false'.

## Architecture Diagram

```mermaid
graph LR
el["el<br/>rpc:30303<br/>http:8545<br/>ws:8546<br/>authrpc:8551<br/>metrics:9090"]
el_healthmon["el_healthmon"]
beacon["beacon<br/>p2p:9000<br/>p2p:9000<br/>quic-p2p:9100<br/>http:3500"]
beacon_healthmon["beacon_healthmon"]
validator["validator"]
mev_boost_relay_1["mev-boost-relay-1<br/>http:5555"]
mev_boost_relay_2["mev-boost-relay-2<br/>http:5555"]
mev_boost["mev-boost<br/>http:18550"]
rbuilder_1["rbuilder-1"]
rbuilder_2["rbuilder-2"]

el_healthmon -->|http| el
beacon -->|authrpc| el
beacon -->|http| mev_boost
beacon_healthmon -->|http| beacon
validator -->|http| beacon
mev_boost_relay_1 -->|http| beacon
mev_boost_relay_2 -->|http| beacon
mev_boost -->|http| mev_boost_relay_1
mev_boost -->|http| mev_boost_relay_2
mev_boost_relay_1 -.->|depends_on| beacon
mev_boost_relay_2 -.->|depends_on| beacon
rbuilder_1 -.->|depends_on| el
rbuilder_1 -.->|depends_on| beacon
rbuilder_2 -.->|depends_on| el
rbuilder_2 -.->|depends_on| beacon
```

218 changes: 188 additions & 30 deletions playground/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -632,14 +632,23 @@ func (c *ClProxy) Apply(ctx *ExContext) *Component {
}

type MevBoostRelay struct {
ServiceName string
BeaconClient string
ValidationServer string
}

func (m *MevBoostRelay) serviceName() string {
if m.ServiceName != "" {
return m.ServiceName
}
return "mev-boost-relay"
}

func (m *MevBoostRelay) Apply(ctx *ExContext) *Component {
component := NewComponent("mev-boost-relay")
serviceName := m.serviceName()
component := NewComponent(serviceName)

service := component.NewService("mev-boost-relay").
service := component.NewService(serviceName).
WithImage("docker.io/flashbots/playground-utils").
WithTag(latestPlaygroundUtilsTag).
WithEnv("ALLOW_SYNCING_BEACON_NODE", "1").
Expand Down Expand Up @@ -718,6 +727,27 @@ type MevBoost struct {
RelayEndpoints []string
}

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.

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.


func (m *MevBoost) Apply(ctx *ExContext) *Component {
component := NewComponent("mev-boost")

Expand All @@ -727,26 +757,9 @@ func (m *MevBoost) Apply(ctx *ExContext) *Component {
}

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 strings.Contains(endpoint, "://") {
args = append(args, "--relay", endpoint)
} else if relayURL, ok := localMevBoostRelayURL(endpoint); ok {
args = append(args, "--relay", relayURL)
} else {
args = append(args, "--relay", Connect(endpoint, "http"))
Expand All @@ -762,28 +775,173 @@ func (m *MevBoost) Apply(ctx *ExContext) *Component {
return component
}

const defaultRbuilderRelaySecretKey = "0x25295f0d1d592a90b333e26e85149708208e9f8e8bc18f6c77bd62f8ad7a6866"

//go:embed utils/rbuilder-config.toml.tmpl
var rbuilderConfigToml string
var defaultRbuilderConfigToml string

type Rbuilder struct {
ServiceName string
BeaconNode string
ExecutionNode string
RelayEndpoints []string
RelaySecretKey string
ConfigArtifact string
ExtraData string
JSONRPCPort int
RedactedPort int
FullMetricsPort int
}

func (r *Rbuilder) serviceName() string {
if r.ServiceName != "" {
return r.ServiceName
}
return "rbuilder"
}

func (r *Rbuilder) beaconNode() string {
if r.BeaconNode != "" {
return r.BeaconNode
}
return "beacon"
}

func (r *Rbuilder) executionNode() string {
if r.ExecutionNode != "" {
return r.ExecutionNode
}
return "el"
}

func (r *Rbuilder) configArtifact() string {
if r.ConfigArtifact != "" {
return r.ConfigArtifact
}
if r.ServiceName != "" {
return r.ServiceName + "-config.toml"
}
return "rbuilder-config.toml"
}

type Rbuilder struct{}
func (r *Rbuilder) relaySecretKey() string {
if r.RelaySecretKey != "" {
return r.RelaySecretKey
}
return defaultRbuilderRelaySecretKey
}

func (r *Rbuilder) relayEndpoints() []string {
if len(r.RelayEndpoints) > 0 {
return r.RelayEndpoints
}
return []string{"mev-boost-relay"}
}

func (r *Rbuilder) extraData() string {
if r.ExtraData != "" {
return r.ExtraData
}
return "Playground Builder"
}

func (r *Rbuilder) jsonRPCPort() int {
if r.JSONRPCPort != 0 {
return r.JSONRPCPort
}
return 8645
}

func (r *Rbuilder) redactedPort() int {
if r.RedactedPort != 0 {
return r.RedactedPort
}
return 6061
}

func (r *Rbuilder) fullMetricsPort() int {
if r.FullMetricsPort != 0 {
return r.FullMetricsPort
}
return 6060
}

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.

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.


func (r *Rbuilder) Apply(ctx *ExContext) *Component {
component := NewComponent("rbuilder")
serviceName := r.serviceName()
configArtifact := r.configArtifact()
component := NewComponent(serviceName)

// 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.

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.


component.NewService("rbuilder").
service := component.NewService(serviceName).
WithImage("ghcr.io/flashbots/rbuilder").
WithTag("sha-7efdc0b").
WithArtifact("/data/rbuilder-config.toml", "rbuilder-config.toml").
WithArtifact("/data/rbuilder-config.toml", configArtifact).
WithArtifact("/data/genesis.json", "genesis.json").
WithVolume("shared:el-data", "/data_reth", true).
DependsOnHealthy("el").
DependsOnHealthy("beacon").
DependsOnHealthy(r.executionNode()).
DependsOnHealthy(r.beaconNode()).
WithArgs(
"run", "/data/rbuilder-config.toml",
)
service.Pid = "service:" + r.executionNode()

return component
}
Expand Down
1 change: 1 addition & 0 deletions playground/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Recipe interface {
func GetBaseRecipes() []Recipe {
return []Recipe{
&L1Recipe{},
&L1MultiBuilderRecipe{},
&OpRecipe{},
&BuilderNetRecipe{},
}
Expand Down
Loading