From e16f7d93e5575fb9cf474c7b62964602c7640aed Mon Sep 17 00:00:00 2001 From: Solar Mithril Date: Tue, 28 Apr 2026 12:34:19 +0400 Subject: [PATCH 1/6] Add L1 multi-builder recipe --- docs/recipes/l1-multi-builder.md | 44 +++++ playground/components.go | 218 ++++++++++++++++++--- playground/manifest.go | 1 + playground/recipe_l1_multi_builder.go | 126 ++++++++++++ playground/recipe_l1_multi_builder_test.go | 52 +++++ playground/recipe_to_yaml.go | 3 + playground/recipe_to_yaml_test.go | 13 ++ 7 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 docs/recipes/l1-multi-builder.md create mode 100644 playground/recipe_l1_multi_builder.go create mode 100644 playground/recipe_l1_multi_builder_test.go diff --git a/docs/recipes/l1-multi-builder.md b/docs/recipes/l1-multi-builder.md new file mode 100644 index 0000000..7c2a972 --- /dev/null +++ b/docs/recipes/l1-multi-builder.md @@ -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
rpc:30303
http:8545
ws:8546
authrpc:8551
metrics:9090"] + el_healthmon["el_healthmon"] + beacon["beacon
p2p:9000
p2p:9000
quic-p2p:9100
http:3500"] + beacon_healthmon["beacon_healthmon"] + validator["validator"] + mev_boost_relay_1["mev-boost-relay-1
http:5555"] + mev_boost_relay_2["mev-boost-relay-2
http:5555"] + mev_boost["mev-boost
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 +``` + diff --git a/playground/components.go b/playground/components.go index db51bf3..c1b296e 100644 --- a/playground/components.go +++ b/playground/components.go @@ -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"). @@ -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 +} + func (m *MevBoost) Apply(ctx *ExContext) *Component { component := NewComponent("mev-boost") @@ -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")) @@ -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() +} 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) - 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 } diff --git a/playground/manifest.go b/playground/manifest.go index f0732f5..8d3c977 100644 --- a/playground/manifest.go +++ b/playground/manifest.go @@ -31,6 +31,7 @@ type Recipe interface { func GetBaseRecipes() []Recipe { return []Recipe{ &L1Recipe{}, + &L1MultiBuilderRecipe{}, &OpRecipe{}, &BuilderNetRecipe{}, } diff --git a/playground/recipe_l1_multi_builder.go b/playground/recipe_l1_multi_builder.go new file mode 100644 index 0000000..dc56907 --- /dev/null +++ b/playground/recipe_l1_multi_builder.go @@ -0,0 +1,126 @@ +package playground + +import ( + "fmt" + "time" + + flag "github.com/spf13/pflag" +) + +var _ Recipe = &L1MultiBuilderRecipe{} + +const ( + defaultL1MultiBuilderCount = 2 + defaultL1MultiRelayCount = 2 +) + +type L1MultiBuilderRecipe struct { + latestFork bool + blockTime time.Duration + useRethForValidation bool + builderCount int + relayCount int +} + +func (l *L1MultiBuilderRecipe) Name() string { + return "l1-multi-builder" +} + +func (l *L1MultiBuilderRecipe) Description() string { + return "Deploy a full L1 stack with multiple builders and relays" +} + +func (l *L1MultiBuilderRecipe) Flags() *flag.FlagSet { + flags := flag.NewFlagSet("l1-multi-builder", flag.ContinueOnError) + flags.BoolVar(&l.latestFork, "latest-fork", false, "use the latest fork") + flags.DurationVar(&l.blockTime, "block-time", time.Duration(defaultL1BlockTimeSeconds)*time.Second, "Block time to use for the L1") + flags.BoolVar(&l.useRethForValidation, "use-reth-for-validation", false, "use reth for validation") + flags.IntVar(&l.builderCount, "builders", defaultL1MultiBuilderCount, "number of rbuilder instances to run") + flags.IntVar(&l.relayCount, "relays", defaultL1MultiRelayCount, "number of mev-boost-relay instances to run") + return flags +} + +func (l *L1MultiBuilderRecipe) Artifacts() *ArtifactsBuilder { + builder := NewArtifactsBuilder() + builder.ApplyLatestL1Fork(l.latestFork) + builder.L1BlockTime(max(1, uint64(l.normalizedBlockTime().Seconds()))) + + return builder +} + +func (l *L1MultiBuilderRecipe) normalizedBlockTime() time.Duration { + if l.blockTime > 0 { + return l.blockTime + } + return time.Duration(defaultL1BlockTimeSeconds) * time.Second +} + +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 +} + +func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { + component := NewComponent("l1-multi-builder-recipe") + + component.AddComponent(ctx, &RethEL{ + UseRethForValidation: l.useRethForValidation, + }) + + relayServices := make([]string, 0, l.normalizedRelayCount()) + for i := 1; i <= l.normalizedRelayCount(); i++ { + relayServices = append(relayServices, fmt.Sprintf("mev-boost-relay-%d", i)) + } + + component.AddComponent(ctx, &LighthouseBeaconNode{ + ExecutionNode: "el", + MevBoostNode: "mev-boost", + }) + component.AddComponent(ctx, &LighthouseValidator{ + BeaconNode: "beacon", + }) + + mevBoostValidationServer := "" + if l.useRethForValidation { + mevBoostValidationServer = "el" + } + for _, relayService := range relayServices { + component.AddComponent(ctx, &MevBoostRelay{ + ServiceName: relayService, + BeaconClient: "beacon", + ValidationServer: mevBoostValidationServer, + }) + } + + component.AddComponent(ctx, &MevBoost{ + RelayEndpoints: relayServices, + }) + + 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, + }) + } + + component.RunContenderIfEnabled(ctx) + return component +} + +func (l *L1MultiBuilderRecipe) Output(manifest *Manifest) map[string]interface{} { + return map[string]interface{}{} +} diff --git a/playground/recipe_l1_multi_builder_test.go b/playground/recipe_l1_multi_builder_test.go new file mode 100644 index 0000000..87ca208 --- /dev/null +++ b/playground/recipe_l1_multi_builder_test.go @@ -0,0 +1,52 @@ +package playground + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestL1MultiBuilderRecipeWiresBuildersToRelays(t *testing.T) { + out, err := NewOutput("test-l1-multi-builder", filepath.Join(t.TempDir(), "out")) + require.NoError(t, err) + + recipe := &L1MultiBuilderRecipe{ + blockTime: 12 * time.Second, + builderCount: 3, + relayCount: 2, + } + component := recipe.Apply(&ExContext{ + Output: out, + Contender: &ContenderContext{}, + }) + manifest := NewManifest("test-l1-multi-builder", component) + + require.NotNil(t, manifest.MustGetService("mev-boost-relay-1")) + require.NotNil(t, manifest.MustGetService("mev-boost-relay-2")) + require.NotNil(t, manifest.MustGetService("rbuilder-1")) + require.NotNil(t, manifest.MustGetService("rbuilder-2")) + require.NotNil(t, manifest.MustGetService("rbuilder-3")) + + 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`) + + rbuilder := manifest.MustGetService("rbuilder-2") + require.Equal(t, "service:el", rbuilder.Pid) + require.ElementsMatch(t, []*DependsOn{ + {Name: "el", Condition: DependsOnConditionHealthy}, + {Name: "beacon", Condition: DependsOnConditionHealthy}, + }, rbuilder.DependsOn) + + config, err := out.Read("rbuilder-2-config.toml") + require.NoError(t, err) + require.Contains(t, config, `enabled_relays = ["mev-boost-relay-1", "mev-boost-relay-2"]`) + require.Contains(t, config, `url = "http://mev-boost-relay-1:5555"`) + require.Contains(t, config, `url = "http://mev-boost-relay-2:5555"`) + require.Contains(t, config, `relay_secret_key = "0x0000000000000000000000000000000000000000000000000000000000000002"`) + require.Contains(t, config, `extra_data = "Playground Builder 2"`) +} diff --git a/playground/recipe_to_yaml.go b/playground/recipe_to_yaml.go index eae2f64..bccfaf9 100644 --- a/playground/recipe_to_yaml.go +++ b/playground/recipe_to_yaml.go @@ -139,6 +139,9 @@ func convertServiceToYAML(svc *Service) *YAMLServiceConfig { if svc.HostPath != "" { config.HostPath = svc.HostPath } + if svc.Pid != "" { + config.Pid = svc.Pid + } if len(svc.Labels) > 0 { config.Labels = svc.Labels } diff --git a/playground/recipe_to_yaml_test.go b/playground/recipe_to_yaml_test.go index bf8de82..d975918 100644 --- a/playground/recipe_to_yaml_test.go +++ b/playground/recipe_to_yaml_test.go @@ -136,6 +136,18 @@ func TestConvertServiceToYAML(t *testing.T) { HostPath: "/usr/local/bin/myapp", }, }, + { + name: "service with pid", + service: &Service{ + Name: "rbuilder", + Image: "ghcr.io/flashbots/rbuilder", + Pid: "service:el", + }, + expected: &YAMLServiceConfig{ + Image: "ghcr.io/flashbots/rbuilder", + Pid: "service:el", + }, + }, { name: "service with ready_check", service: &Service{ @@ -174,6 +186,7 @@ func TestConvertServiceToYAML(t *testing.T) { require.Equal(t, tt.expected.Tag, result.Tag) require.Equal(t, tt.expected.Entrypoint, result.Entrypoint) require.Equal(t, tt.expected.HostPath, result.HostPath) + require.Equal(t, tt.expected.Pid, result.Pid) require.Equal(t, tt.expected.ReadyCheck, result.ReadyCheck) require.Equal(t, tt.expected.Labels, result.Labels) require.Len(t, result.Args, len(tt.expected.Args)) From 7cb7e022dc38a5a0af5ac554e28948c80b698bea Mon Sep 17 00:00:00 2001 From: Solar Mithril Date: Tue, 28 Apr 2026 14:24:01 +0400 Subject: [PATCH 2/6] Expose rbuilder RPC ports --- docs/recipes/l1-multi-builder.md | 4 ++-- playground/components.go | 1 + playground/recipe_l1_multi_builder_test.go | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/recipes/l1-multi-builder.md b/docs/recipes/l1-multi-builder.md index 7c2a972..b76a595 100644 --- a/docs/recipes/l1-multi-builder.md +++ b/docs/recipes/l1-multi-builder.md @@ -22,8 +22,8 @@ graph LR mev_boost_relay_1["mev-boost-relay-1
http:5555"] mev_boost_relay_2["mev-boost-relay-2
http:5555"] mev_boost["mev-boost
http:18550"] - rbuilder_1["rbuilder-1"] - rbuilder_2["rbuilder-2"] + rbuilder_1["rbuilder-1
rpc:8645"] + rbuilder_2["rbuilder-2
rpc:8646"] el_healthmon -->|http| el beacon -->|authrpc| el diff --git a/playground/components.go b/playground/components.go index c1b296e..870e4f4 100644 --- a/playground/components.go +++ b/playground/components.go @@ -935,6 +935,7 @@ func (r *Rbuilder) Apply(ctx *ExContext) *Component { WithTag("sha-7efdc0b"). WithArtifact("/data/rbuilder-config.toml", configArtifact). WithArtifact("/data/genesis.json", "genesis.json"). + WithPort("rpc", r.jsonRPCPort()). WithVolume("shared:el-data", "/data_reth", true). DependsOnHealthy(r.executionNode()). DependsOnHealthy(r.beaconNode()). diff --git a/playground/recipe_l1_multi_builder_test.go b/playground/recipe_l1_multi_builder_test.go index 87ca208..1761d0f 100644 --- a/playground/recipe_l1_multi_builder_test.go +++ b/playground/recipe_l1_multi_builder_test.go @@ -37,6 +37,7 @@ func TestL1MultiBuilderRecipeWiresBuildersToRelays(t *testing.T) { rbuilder := manifest.MustGetService("rbuilder-2") require.Equal(t, "service:el", rbuilder.Pid) + require.Equal(t, 8646, rbuilder.MustGetPort("rpc").Port) require.ElementsMatch(t, []*DependsOn{ {Name: "el", Condition: DependsOnConditionHealthy}, {Name: "beacon", Condition: DependsOnConditionHealthy}, From 4b265f9a872d5faa71409cf400dbf2f6ef218691 Mon Sep 17 00:00:00 2001 From: Solar Mithril Date: Tue, 28 Apr 2026 15:28:30 +0400 Subject: [PATCH 3/6] Add FlowProxy to L1 multi-builder --- docs/recipes/l1-multi-builder.md | 12 ++- playground/catalog.go | 1 + playground/components.go | 91 +++++++++++++++++++++- playground/recipe_l1_multi_builder.go | 11 ++- playground/recipe_l1_multi_builder_test.go | 21 +++++ playground/recipe_to_yaml.go | 9 +++ playground/recipe_to_yaml_test.go | 29 +++++++ 7 files changed, 170 insertions(+), 4 deletions(-) diff --git a/docs/recipes/l1-multi-builder.md b/docs/recipes/l1-multi-builder.md index b76a595..dc7d3fc 100644 --- a/docs/recipes/l1-multi-builder.md +++ b/docs/recipes/l1-multi-builder.md @@ -22,8 +22,10 @@ graph LR mev_boost_relay_1["mev-boost-relay-1
http:5555"] mev_boost_relay_2["mev-boost-relay-2
http:5555"] mev_boost["mev-boost
http:18550"] - rbuilder_1["rbuilder-1
rpc:8645"] - rbuilder_2["rbuilder-2
rpc:8646"] + rbuilder_1["rbuilder-1
rpc:8645
redacted:6061
full-metrics:6060"] + flowproxy_1["flowproxy-1
http:28545
system:29545
metrics:29090"] + rbuilder_2["rbuilder-2
rpc:8646
redacted:6062
full-metrics:6061"] + flowproxy_2["flowproxy-2
http:28546
system:29546
metrics:29091"] el_healthmon -->|http| el beacon -->|authrpc| el @@ -34,11 +36,17 @@ graph LR mev_boost_relay_2 -->|http| beacon mev_boost -->|http| mev_boost_relay_1 mev_boost -->|http| mev_boost_relay_2 + flowproxy_1 -->|rpc| rbuilder_1 + flowproxy_1 -->|redacted| rbuilder_1 + flowproxy_2 -->|rpc| rbuilder_2 + flowproxy_2 -->|redacted| rbuilder_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 + flowproxy_1 -.->|depends_on| rbuilder_1 rbuilder_2 -.->|depends_on| el rbuilder_2 -.->|depends_on| beacon + flowproxy_2 -.->|depends_on| rbuilder_2 ``` diff --git a/playground/catalog.go b/playground/catalog.go index 55fecf4..4ff37c3 100644 --- a/playground/catalog.go +++ b/playground/catalog.go @@ -16,6 +16,7 @@ func init() { register(&ClProxy{}) register(&MevBoostRelay{}) register(&MevBoost{}) + register(&FlowProxy{}) register(&RollupBoost{}) register(&OpReth{}) register(&nullService{}) diff --git a/playground/components.go b/playground/components.go index 870e4f4..d0c16fe 100644 --- a/playground/components.go +++ b/playground/components.go @@ -489,7 +489,7 @@ func (r *RethEL) Apply(ctx *ExContext) *Component { // http config "--http", "--http.addr", "0.0.0.0", - "--http.api", "admin,eth,web3,net,rpc,mev,flashbots", + "--http.api", "admin,eth,web3,net,txpool,rpc,mev,flashbots", "--http.port", `{{Port "http" 8545}}`, // websocket config "--ws", @@ -936,6 +936,8 @@ func (r *Rbuilder) Apply(ctx *ExContext) *Component { WithArtifact("/data/rbuilder-config.toml", configArtifact). WithArtifact("/data/genesis.json", "genesis.json"). WithPort("rpc", r.jsonRPCPort()). + WithPort("redacted", r.redactedPort()). + WithPort("full-metrics", r.fullMetricsPort()). WithVolume("shared:el-data", "/data_reth", true). DependsOnHealthy(r.executionNode()). DependsOnHealthy(r.beaconNode()). @@ -947,6 +949,93 @@ func (r *Rbuilder) Apply(ctx *ExContext) *Component { return component } +var flowProxyRelease = &release{ + Name: "flowproxy", + Repo: "FlowProxy", + Org: "BuilderNet", + Version: "v2.1.2", + Arch: func(goos, goarch string) string { + return "" + }, + Format: "binary", +} + +type FlowProxy struct { + ServiceName string + BuilderService string + BuilderName string + UserPort int + SystemPort int + MetricsPort int +} + +func (f *FlowProxy) serviceName() string { + if f.ServiceName != "" { + return f.ServiceName + } + return "flowproxy" +} + +func (f *FlowProxy) builderService() string { + if f.BuilderService != "" { + return f.BuilderService + } + return "rbuilder" +} + +func (f *FlowProxy) builderName() string { + if f.BuilderName != "" { + return f.BuilderName + } + return f.builderService() +} + +func (f *FlowProxy) userPort() int { + if f.UserPort != 0 { + return f.UserPort + } + return 28545 +} + +func (f *FlowProxy) systemPort() int { + if f.SystemPort != 0 { + return f.SystemPort + } + return 29545 +} + +func (f *FlowProxy) metricsPort() int { + if f.MetricsPort != 0 { + return f.MetricsPort + } + return 29090 +} + +func (f *FlowProxy) Apply(ctx *ExContext) *Component { + serviceName := f.serviceName() + builderService := f.builderService() + component := NewComponent(serviceName) + + component.NewService(serviceName). + WithRelease(flowProxyRelease). + UseHostExecution(). + WithArgs( + "--user-listen-addr", fmt.Sprintf(`0.0.0.0:{{Port "http" %d}}`, f.userPort()), + "--system-listen-addr", fmt.Sprintf(`0.0.0.0:{{Port "system" %d}}`, f.systemPort()), + "--builder-name", f.builderName(), + "--builder-url", Connect(builderService, "rpc"), + "--builder-ready-endpoint", Connect(builderService, "redacted"), + "--metrics", fmt.Sprintf(`0.0.0.0:{{Port "metrics" %d}}`, f.metricsPort()), + "--disable-forwarding", + ). + WithPort("http", f.userPort()). + WithPort("system", f.systemPort()). + WithPort("metrics", f.metricsPort()). + DependsOnRunning(builderService) + + return component +} + type nullService struct{} func (n *nullService) Apply(ctx *ExContext) *Component { diff --git a/playground/recipe_l1_multi_builder.go b/playground/recipe_l1_multi_builder.go index dc56907..2f88ee0 100644 --- a/playground/recipe_l1_multi_builder.go +++ b/playground/recipe_l1_multi_builder.go @@ -106,8 +106,9 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { }) for i := 1; i <= l.normalizedBuilderCount(); i++ { + builderService := fmt.Sprintf("rbuilder-%d", i) component.AddComponent(ctx, &Rbuilder{ - ServiceName: fmt.Sprintf("rbuilder-%d", i), + ServiceName: builderService, RelayEndpoints: relayServices, RelaySecretKey: fmt.Sprintf("0x%064x", i), ExtraData: fmt.Sprintf("Playground Builder %d", i), @@ -115,6 +116,14 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { RedactedPort: 6061 + i - 1, FullMetricsPort: 6060 + i - 1, }) + component.AddComponent(ctx, &FlowProxy{ + ServiceName: fmt.Sprintf("flowproxy-%d", i), + BuilderService: builderService, + BuilderName: fmt.Sprintf("Playground Builder %d", i), + UserPort: 28545 + i - 1, + SystemPort: 29545 + i - 1, + MetricsPort: 29090 + i - 1, + }) } component.RunContenderIfEnabled(ctx) diff --git a/playground/recipe_l1_multi_builder_test.go b/playground/recipe_l1_multi_builder_test.go index 1761d0f..34a8c20 100644 --- a/playground/recipe_l1_multi_builder_test.go +++ b/playground/recipe_l1_multi_builder_test.go @@ -29,6 +29,12 @@ func TestL1MultiBuilderRecipeWiresBuildersToRelays(t *testing.T) { require.NotNil(t, manifest.MustGetService("rbuilder-1")) require.NotNil(t, manifest.MustGetService("rbuilder-2")) require.NotNil(t, manifest.MustGetService("rbuilder-3")) + require.NotNil(t, manifest.MustGetService("flowproxy-1")) + require.NotNil(t, manifest.MustGetService("flowproxy-2")) + require.NotNil(t, manifest.MustGetService("flowproxy-3")) + + el := manifest.MustGetService("el") + require.Contains(t, strings.Join(el.Args, " "), "admin,eth,web3,net,txpool,rpc,mev,flashbots") mevBoost := manifest.MustGetService("mev-boost") mevBoostArgs := strings.Join(mevBoost.Args, " ") @@ -38,11 +44,26 @@ func TestL1MultiBuilderRecipeWiresBuildersToRelays(t *testing.T) { rbuilder := manifest.MustGetService("rbuilder-2") require.Equal(t, "service:el", rbuilder.Pid) require.Equal(t, 8646, rbuilder.MustGetPort("rpc").Port) + require.Equal(t, 6062, rbuilder.MustGetPort("redacted").Port) + require.Equal(t, 6061, rbuilder.MustGetPort("full-metrics").Port) require.ElementsMatch(t, []*DependsOn{ {Name: "el", Condition: DependsOnConditionHealthy}, {Name: "beacon", Condition: DependsOnConditionHealthy}, }, rbuilder.DependsOn) + flowProxy := manifest.MustGetService("flowproxy-2") + require.Equal(t, 28546, flowProxy.MustGetPort("http").Port) + require.Equal(t, 29546, flowProxy.MustGetPort("system").Port) + require.Equal(t, 29091, flowProxy.MustGetPort("metrics").Port) + require.Equal(t, "true", flowProxy.Labels[useHostExecutionLabel]) + require.Contains(t, flowProxy.Args, "--disable-forwarding") + require.Contains(t, flowProxy.Args, "Playground Builder 2") + require.Contains(t, flowProxy.Args, `{{Service "rbuilder-2" "rpc" "http" ""}}`) + require.Contains(t, flowProxy.Args, `{{Service "rbuilder-2" "redacted" "http" ""}}`) + require.ElementsMatch(t, []*DependsOn{ + {Name: "rbuilder-2", Condition: DependsOnConditionRunning}, + }, flowProxy.DependsOn) + config, err := out.Read("rbuilder-2-config.toml") require.NoError(t, err) require.Contains(t, config, `enabled_relays = ["mev-boost-relay-1", "mev-boost-relay-2"]`) diff --git a/playground/recipe_to_yaml.go b/playground/recipe_to_yaml.go index bccfaf9..80745b3 100644 --- a/playground/recipe_to_yaml.go +++ b/playground/recipe_to_yaml.go @@ -139,6 +139,15 @@ func convertServiceToYAML(svc *Service) *YAMLServiceConfig { if svc.HostPath != "" { config.HostPath = svc.HostPath } + if svc.release != nil && svc.Labels[useHostExecutionLabel] == "true" { + config.Release = &YAMLReleaseConfig{ + Name: svc.release.Name, + Org: svc.release.Org, + Repo: svc.release.Repo, + Version: svc.release.Version, + Format: svc.release.Format, + } + } if svc.Pid != "" { config.Pid = svc.Pid } diff --git a/playground/recipe_to_yaml_test.go b/playground/recipe_to_yaml_test.go index d975918..6c6f613 100644 --- a/playground/recipe_to_yaml_test.go +++ b/playground/recipe_to_yaml_test.go @@ -136,6 +136,34 @@ func TestConvertServiceToYAML(t *testing.T) { HostPath: "/usr/local/bin/myapp", }, }, + { + name: "service with release", + service: &Service{ + Name: "flowproxy", + Labels: map[string]string{ + useHostExecutionLabel: "true", + }, + release: &release{ + Name: "flowproxy", + Org: "BuilderNet", + Repo: "FlowProxy", + Version: "v2.1.2", + Format: "binary", + }, + }, + expected: &YAMLServiceConfig{ + Labels: map[string]string{ + useHostExecutionLabel: "true", + }, + Release: &YAMLReleaseConfig{ + Name: "flowproxy", + Org: "BuilderNet", + Repo: "FlowProxy", + Version: "v2.1.2", + Format: "binary", + }, + }, + }, { name: "service with pid", service: &Service{ @@ -186,6 +214,7 @@ func TestConvertServiceToYAML(t *testing.T) { require.Equal(t, tt.expected.Tag, result.Tag) require.Equal(t, tt.expected.Entrypoint, result.Entrypoint) require.Equal(t, tt.expected.HostPath, result.HostPath) + require.Equal(t, tt.expected.Release, result.Release) require.Equal(t, tt.expected.Pid, result.Pid) require.Equal(t, tt.expected.ReadyCheck, result.ReadyCheck) require.Equal(t, tt.expected.Labels, result.Labels) From 25c8a40115ce06ae40b3c5992d8028753bdb690a Mon Sep 17 00:00:00 2001 From: Solar Mithril Date: Tue, 28 Apr 2026 19:22:54 +0400 Subject: [PATCH 4/6] Remove FlowProxy from L1 multi-builder --- docs/recipes/l1-multi-builder.md | 12 +-- playground/catalog.go | 1 - playground/components.go | 89 ---------------------- playground/recipe_l1_multi_builder.go | 11 +-- playground/recipe_l1_multi_builder_test.go | 18 ----- playground/recipe_to_yaml.go | 9 --- playground/recipe_to_yaml_test.go | 29 ------- playground/test_tx.go | 62 ++------------- 8 files changed, 9 insertions(+), 222 deletions(-) diff --git a/docs/recipes/l1-multi-builder.md b/docs/recipes/l1-multi-builder.md index dc7d3fc..b76a595 100644 --- a/docs/recipes/l1-multi-builder.md +++ b/docs/recipes/l1-multi-builder.md @@ -22,10 +22,8 @@ graph LR mev_boost_relay_1["mev-boost-relay-1
http:5555"] mev_boost_relay_2["mev-boost-relay-2
http:5555"] mev_boost["mev-boost
http:18550"] - rbuilder_1["rbuilder-1
rpc:8645
redacted:6061
full-metrics:6060"] - flowproxy_1["flowproxy-1
http:28545
system:29545
metrics:29090"] - rbuilder_2["rbuilder-2
rpc:8646
redacted:6062
full-metrics:6061"] - flowproxy_2["flowproxy-2
http:28546
system:29546
metrics:29091"] + rbuilder_1["rbuilder-1
rpc:8645"] + rbuilder_2["rbuilder-2
rpc:8646"] el_healthmon -->|http| el beacon -->|authrpc| el @@ -36,17 +34,11 @@ graph LR mev_boost_relay_2 -->|http| beacon mev_boost -->|http| mev_boost_relay_1 mev_boost -->|http| mev_boost_relay_2 - flowproxy_1 -->|rpc| rbuilder_1 - flowproxy_1 -->|redacted| rbuilder_1 - flowproxy_2 -->|rpc| rbuilder_2 - flowproxy_2 -->|redacted| rbuilder_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 - flowproxy_1 -.->|depends_on| rbuilder_1 rbuilder_2 -.->|depends_on| el rbuilder_2 -.->|depends_on| beacon - flowproxy_2 -.->|depends_on| rbuilder_2 ``` diff --git a/playground/catalog.go b/playground/catalog.go index 4ff37c3..55fecf4 100644 --- a/playground/catalog.go +++ b/playground/catalog.go @@ -16,7 +16,6 @@ func init() { register(&ClProxy{}) register(&MevBoostRelay{}) register(&MevBoost{}) - register(&FlowProxy{}) register(&RollupBoost{}) register(&OpReth{}) register(&nullService{}) diff --git a/playground/components.go b/playground/components.go index d0c16fe..69a7919 100644 --- a/playground/components.go +++ b/playground/components.go @@ -936,8 +936,6 @@ func (r *Rbuilder) Apply(ctx *ExContext) *Component { WithArtifact("/data/rbuilder-config.toml", configArtifact). WithArtifact("/data/genesis.json", "genesis.json"). WithPort("rpc", r.jsonRPCPort()). - WithPort("redacted", r.redactedPort()). - WithPort("full-metrics", r.fullMetricsPort()). WithVolume("shared:el-data", "/data_reth", true). DependsOnHealthy(r.executionNode()). DependsOnHealthy(r.beaconNode()). @@ -949,93 +947,6 @@ func (r *Rbuilder) Apply(ctx *ExContext) *Component { return component } -var flowProxyRelease = &release{ - Name: "flowproxy", - Repo: "FlowProxy", - Org: "BuilderNet", - Version: "v2.1.2", - Arch: func(goos, goarch string) string { - return "" - }, - Format: "binary", -} - -type FlowProxy struct { - ServiceName string - BuilderService string - BuilderName string - UserPort int - SystemPort int - MetricsPort int -} - -func (f *FlowProxy) serviceName() string { - if f.ServiceName != "" { - return f.ServiceName - } - return "flowproxy" -} - -func (f *FlowProxy) builderService() string { - if f.BuilderService != "" { - return f.BuilderService - } - return "rbuilder" -} - -func (f *FlowProxy) builderName() string { - if f.BuilderName != "" { - return f.BuilderName - } - return f.builderService() -} - -func (f *FlowProxy) userPort() int { - if f.UserPort != 0 { - return f.UserPort - } - return 28545 -} - -func (f *FlowProxy) systemPort() int { - if f.SystemPort != 0 { - return f.SystemPort - } - return 29545 -} - -func (f *FlowProxy) metricsPort() int { - if f.MetricsPort != 0 { - return f.MetricsPort - } - return 29090 -} - -func (f *FlowProxy) Apply(ctx *ExContext) *Component { - serviceName := f.serviceName() - builderService := f.builderService() - component := NewComponent(serviceName) - - component.NewService(serviceName). - WithRelease(flowProxyRelease). - UseHostExecution(). - WithArgs( - "--user-listen-addr", fmt.Sprintf(`0.0.0.0:{{Port "http" %d}}`, f.userPort()), - "--system-listen-addr", fmt.Sprintf(`0.0.0.0:{{Port "system" %d}}`, f.systemPort()), - "--builder-name", f.builderName(), - "--builder-url", Connect(builderService, "rpc"), - "--builder-ready-endpoint", Connect(builderService, "redacted"), - "--metrics", fmt.Sprintf(`0.0.0.0:{{Port "metrics" %d}}`, f.metricsPort()), - "--disable-forwarding", - ). - WithPort("http", f.userPort()). - WithPort("system", f.systemPort()). - WithPort("metrics", f.metricsPort()). - DependsOnRunning(builderService) - - return component -} - type nullService struct{} func (n *nullService) Apply(ctx *ExContext) *Component { diff --git a/playground/recipe_l1_multi_builder.go b/playground/recipe_l1_multi_builder.go index 2f88ee0..dc56907 100644 --- a/playground/recipe_l1_multi_builder.go +++ b/playground/recipe_l1_multi_builder.go @@ -106,9 +106,8 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { }) for i := 1; i <= l.normalizedBuilderCount(); i++ { - builderService := fmt.Sprintf("rbuilder-%d", i) component.AddComponent(ctx, &Rbuilder{ - ServiceName: builderService, + ServiceName: fmt.Sprintf("rbuilder-%d", i), RelayEndpoints: relayServices, RelaySecretKey: fmt.Sprintf("0x%064x", i), ExtraData: fmt.Sprintf("Playground Builder %d", i), @@ -116,14 +115,6 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { RedactedPort: 6061 + i - 1, FullMetricsPort: 6060 + i - 1, }) - component.AddComponent(ctx, &FlowProxy{ - ServiceName: fmt.Sprintf("flowproxy-%d", i), - BuilderService: builderService, - BuilderName: fmt.Sprintf("Playground Builder %d", i), - UserPort: 28545 + i - 1, - SystemPort: 29545 + i - 1, - MetricsPort: 29090 + i - 1, - }) } component.RunContenderIfEnabled(ctx) diff --git a/playground/recipe_l1_multi_builder_test.go b/playground/recipe_l1_multi_builder_test.go index 34a8c20..4b08023 100644 --- a/playground/recipe_l1_multi_builder_test.go +++ b/playground/recipe_l1_multi_builder_test.go @@ -29,9 +29,6 @@ func TestL1MultiBuilderRecipeWiresBuildersToRelays(t *testing.T) { require.NotNil(t, manifest.MustGetService("rbuilder-1")) require.NotNil(t, manifest.MustGetService("rbuilder-2")) require.NotNil(t, manifest.MustGetService("rbuilder-3")) - require.NotNil(t, manifest.MustGetService("flowproxy-1")) - require.NotNil(t, manifest.MustGetService("flowproxy-2")) - require.NotNil(t, manifest.MustGetService("flowproxy-3")) el := manifest.MustGetService("el") require.Contains(t, strings.Join(el.Args, " "), "admin,eth,web3,net,txpool,rpc,mev,flashbots") @@ -44,26 +41,11 @@ func TestL1MultiBuilderRecipeWiresBuildersToRelays(t *testing.T) { rbuilder := manifest.MustGetService("rbuilder-2") require.Equal(t, "service:el", rbuilder.Pid) require.Equal(t, 8646, rbuilder.MustGetPort("rpc").Port) - require.Equal(t, 6062, rbuilder.MustGetPort("redacted").Port) - require.Equal(t, 6061, rbuilder.MustGetPort("full-metrics").Port) require.ElementsMatch(t, []*DependsOn{ {Name: "el", Condition: DependsOnConditionHealthy}, {Name: "beacon", Condition: DependsOnConditionHealthy}, }, rbuilder.DependsOn) - flowProxy := manifest.MustGetService("flowproxy-2") - require.Equal(t, 28546, flowProxy.MustGetPort("http").Port) - require.Equal(t, 29546, flowProxy.MustGetPort("system").Port) - require.Equal(t, 29091, flowProxy.MustGetPort("metrics").Port) - require.Equal(t, "true", flowProxy.Labels[useHostExecutionLabel]) - require.Contains(t, flowProxy.Args, "--disable-forwarding") - require.Contains(t, flowProxy.Args, "Playground Builder 2") - require.Contains(t, flowProxy.Args, `{{Service "rbuilder-2" "rpc" "http" ""}}`) - require.Contains(t, flowProxy.Args, `{{Service "rbuilder-2" "redacted" "http" ""}}`) - require.ElementsMatch(t, []*DependsOn{ - {Name: "rbuilder-2", Condition: DependsOnConditionRunning}, - }, flowProxy.DependsOn) - config, err := out.Read("rbuilder-2-config.toml") require.NoError(t, err) require.Contains(t, config, `enabled_relays = ["mev-boost-relay-1", "mev-boost-relay-2"]`) diff --git a/playground/recipe_to_yaml.go b/playground/recipe_to_yaml.go index 80745b3..bccfaf9 100644 --- a/playground/recipe_to_yaml.go +++ b/playground/recipe_to_yaml.go @@ -139,15 +139,6 @@ func convertServiceToYAML(svc *Service) *YAMLServiceConfig { if svc.HostPath != "" { config.HostPath = svc.HostPath } - if svc.release != nil && svc.Labels[useHostExecutionLabel] == "true" { - config.Release = &YAMLReleaseConfig{ - Name: svc.release.Name, - Org: svc.release.Org, - Repo: svc.release.Repo, - Version: svc.release.Version, - Format: svc.release.Format, - } - } if svc.Pid != "" { config.Pid = svc.Pid } diff --git a/playground/recipe_to_yaml_test.go b/playground/recipe_to_yaml_test.go index 6c6f613..d975918 100644 --- a/playground/recipe_to_yaml_test.go +++ b/playground/recipe_to_yaml_test.go @@ -136,34 +136,6 @@ func TestConvertServiceToYAML(t *testing.T) { HostPath: "/usr/local/bin/myapp", }, }, - { - name: "service with release", - service: &Service{ - Name: "flowproxy", - Labels: map[string]string{ - useHostExecutionLabel: "true", - }, - release: &release{ - Name: "flowproxy", - Org: "BuilderNet", - Repo: "FlowProxy", - Version: "v2.1.2", - Format: "binary", - }, - }, - expected: &YAMLServiceConfig{ - Labels: map[string]string{ - useHostExecutionLabel: "true", - }, - Release: &YAMLReleaseConfig{ - Name: "flowproxy", - Org: "BuilderNet", - Repo: "FlowProxy", - Version: "v2.1.2", - Format: "binary", - }, - }, - }, { name: "service with pid", service: &Service{ @@ -214,7 +186,6 @@ func TestConvertServiceToYAML(t *testing.T) { require.Equal(t, tt.expected.Tag, result.Tag) require.Equal(t, tt.expected.Entrypoint, result.Entrypoint) require.Equal(t, tt.expected.HostPath, result.HostPath) - require.Equal(t, tt.expected.Release, result.Release) require.Equal(t, tt.expected.Pid, result.Pid) require.Equal(t, tt.expected.ReadyCheck, result.ReadyCheck) require.Equal(t, tt.expected.Labels, result.Labels) diff --git a/playground/test_tx.go b/playground/test_tx.go index 662bb32..ae4fe47 100644 --- a/playground/test_tx.go +++ b/playground/test_tx.go @@ -3,11 +3,8 @@ package playground import ( "bytes" "context" - "crypto/ecdsa" "crypto/tls" - "encoding/hex" "fmt" - "io" "math/big" "net/http" "time" @@ -19,45 +16,6 @@ import ( "github.com/ethereum/go-ethereum/rpc" ) -// buildernetSigningTransport is an http.RoundTripper that adds the X-BuilderNet-Signature -// header to every request. FlowProxy requires this header for orderflow authentication. -// The signature is: keccak256(body) → format as hex → EIP-191 sign → header. -// Any valid key pair works — it's an identity tag, not access control. -type buildernetSigningTransport struct { - base http.RoundTripper - privateKey *ecdsa.PrivateKey - address common.Address -} - -func (t *buildernetSigningTransport) RoundTrip(req *http.Request) (*http.Response, error) { - defer req.Body.Close() - body, err := io.ReadAll(req.Body) - if err != nil { - return nil, err - } - - // Sign: keccak256(body) → hex string → EIP-191 hash → ECDSA sign - bodyHash := crypto.Keccak256(body) - hashHex := "0x" + hex.EncodeToString(bodyHash) - - // EIP-191: "\x19Ethereum Signed Message:\n" + len + message - prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(hashHex)) - msgHash := crypto.Keccak256(append([]byte(prefix), []byte(hashHex)...)) - - sig, err := crypto.Sign(msgHash, t.privateKey) - if err != nil { - return nil, fmt.Errorf("buildernet signing failed: %w", err) - } - sig[64] += 27 // V: 0/1 → 27/28 - - req.Header.Set("X-BuilderNet-Signature", - fmt.Sprintf("%s:0x%s", t.address.Hex(), hex.EncodeToString(sig))) - - req.Body = io.NopCloser(bytes.NewReader(body)) - req.ContentLength = int64(len(body)) - return t.base.RoundTrip(req) -} - // TestTxConfig holds configuration for the test transaction type TestTxConfig struct { RPCURL string // Target RPC URL for sending transactions (e.g., rbuilder) @@ -117,29 +75,21 @@ func SendTestTransaction(ctx context.Context, cfg *TestTxConfig) error { elRPCURL = cfg.RPCURL } - // Parse private key (used for both tx signing and BuilderNet header) + // Parse private key used for transaction signing. privateKey, err := crypto.HexToECDSA(cfg.PrivateKey) if err != nil { return fmt.Errorf("failed to parse private key: %w", err) } fromAddress := crypto.PubkeyToAddress(privateKey.PublicKey) - // dialRPC connects to an RPC endpoint, adding BuilderNet signature header - // and optionally skipping TLS verification + // dialRPC connects to an RPC endpoint and optionally skips TLS verification. dialRPC := func(url string) (*ethclient.Client, error) { - var base http.RoundTripper - if cfg.Insecure { - base = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - } else { - base = http.DefaultTransport + if !cfg.Insecure { + return ethclient.DialContext(ctx, url) } httpClient := &http.Client{ - Transport: &buildernetSigningTransport{ - base: base, - privateKey: privateKey, - address: fromAddress, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } rpcClient, err := rpc.DialOptions(ctx, url, rpc.WithHTTPClient(httpClient)) From c443155a0cf6ce0f46125fbd4349198af08d7b18 Mon Sep 17 00:00:00 2001 From: Solar Mithril Date: Wed, 29 Apr 2026 20:05:00 +0400 Subject: [PATCH 5/6] Address PR review for l1-multi-builder - 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) --- docs/recipes/l1-multi-builder.md | 6 +- main.go | 7 + mev-boost-relay/cmd/main.go | 5 + playground/artifacts.go | 7 + playground/components.go | 278 ++++++++++++++------- playground/manifest.go | 2 + playground/recipe_l1.go | 4 +- playground/recipe_l1_multi_builder.go | 107 +++++--- playground/recipe_l1_multi_builder_test.go | 74 ++++-- playground/test_tx.go | 62 ++++- playground/utils/rbuilder-config.toml.tmpl | 19 -- 11 files changed, 400 insertions(+), 171 deletions(-) delete mode 100644 playground/utils/rbuilder-config.toml.tmpl diff --git a/docs/recipes/l1-multi-builder.md b/docs/recipes/l1-multi-builder.md index b76a595..64841a2 100644 --- a/docs/recipes/l1-multi-builder.md +++ b/docs/recipes/l1-multi-builder.md @@ -5,9 +5,9 @@ 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'. +- `builders` (int): number of rbuilder instances to run; must be >= 1. 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'. +- `relays` (int): number of mev-boost-relay instances to run; must be >= 1. Default to '2'. - `use-reth-for-validation` (bool): use reth for validation. Default to 'false'. ## Architecture Diagram @@ -23,7 +23,7 @@ graph LR mev_boost_relay_2["mev-boost-relay-2
http:5555"] mev_boost["mev-boost
http:18550"] rbuilder_1["rbuilder-1
rpc:8645"] - rbuilder_2["rbuilder-2
rpc:8646"] + rbuilder_2["rbuilder-2
rpc:8645"] el_healthmon -->|http| el beacon -->|authrpc| el diff --git a/main.go b/main.go index 54fa093..8acfa2e 100644 --- a/main.go +++ b/main.go @@ -783,6 +783,12 @@ func runIt(recipe playground.Recipe) error { overrides[parts[0]] = parts[1] } + if v, ok := recipe.(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return err + } + } + slog.Debug("Building artifacts...") builder := recipe.Artifacts() builder.GenesisDelay(genesisDelayFlag) @@ -800,6 +806,7 @@ func runIt(recipe playground.Recipe) error { ExtraArgs: contenderArgs, TargetChain: contenderTarget, }, + GenesisTimestamp: uint64(builder.GenesisTime().Unix()), } components := recipe.Apply(exCtx) diff --git a/mev-boost-relay/cmd/main.go b/mev-boost-relay/cmd/main.go index 84a71ed..5943190 100644 --- a/mev-boost-relay/cmd/main.go +++ b/mev-boost-relay/cmd/main.go @@ -11,6 +11,7 @@ import ( var ( apiListenAddr string apiListenPort uint64 + apiSecretKey string beaconClientAddr string validationServerAddr string ) @@ -27,6 +28,7 @@ var rootCmd = &cobra.Command{ func main() { rootCmd.Flags().StringVar(&apiListenAddr, "api-listen-addr", "127.0.0.1", "") rootCmd.Flags().Uint64Var(&apiListenPort, "api-listen-port", 5555, "") + rootCmd.Flags().StringVar(&apiSecretKey, "api-secret-key", "", "BLS secret key (hex) used to sign relay bids; defaults to the playground-wide default") rootCmd.Flags().StringVar(&beaconClientAddr, "beacon-client-addr", "http://localhost:3500", "") rootCmd.Flags().StringVar(&validationServerAddr, "validation-server-addr", "", "") @@ -42,6 +44,9 @@ func runMevBoostRelay() error { cfg.ApiListenPort = apiListenPort cfg.BeaconClientAddr = beaconClientAddr cfg.ValidationServerAddr = validationServerAddr + if apiSecretKey != "" { + cfg.ApiSecretKey = apiSecretKey + } relay, err := mevboostrelay.New(cfg) if err != nil { diff --git a/playground/artifacts.go b/playground/artifacts.go index 8850b7b..295fceb 100644 --- a/playground/artifacts.go +++ b/playground/artifacts.go @@ -125,6 +125,12 @@ type ArtifactsBuilder struct { // Extra files to copy to artifacts (artifactName -> sourcePath) extraFiles map[string]string predeploysFile string + + genesisTime time.Time +} + +func (b *ArtifactsBuilder) GenesisTime() time.Time { + return b.genesisTime } func NewArtifactsBuilder() *ArtifactsBuilder { @@ -249,6 +255,7 @@ func (b *ArtifactsBuilder) Build(out *output) error { } genesisTime := time.Now().Add(time.Duration(b.genesisDelay) * time.Second) + b.genesisTime = genesisTime config := params.BeaconConfig() config.ElectraForkEpoch = 0 diff --git a/playground/components.go b/playground/components.go index 69a7919..db21b14 100644 --- a/playground/components.go +++ b/playground/components.go @@ -1,12 +1,12 @@ package playground import ( - _ "embed" "fmt" "strconv" "strings" "time" + "github.com/BurntSushi/toml" "github.com/ethereum/go-ethereum/common/hexutil" mevboostrelay "github.com/flashbots/builder-playground/mev-boost-relay" "github.com/flashbots/go-boost-utils/bls" @@ -18,6 +18,18 @@ var ( 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 + rbuilderFullMetricsPort = 6060 +) + type RollupBoost struct { ELNode string Builder string @@ -552,7 +564,7 @@ func (l *LighthouseBeaconNode) Apply(ctx *ExContext) *Component { "--port", `{{Port "p2p" 9000}}`, "--quic-port", `{{Port "quic-p2p" 9100}}`, "--http", - "--http-port", `{{Port "http" 3500}}`, + "--http-port", fmt.Sprintf(`{{Port "http" %d}}`, lighthouseBeaconHTTPPort), "--http-address", "0.0.0.0", "--http-allow-origin", "*", "--disable-packet-filter", @@ -635,6 +647,11 @@ type MevBoostRelay struct { ServiceName string BeaconClient string ValidationServer string + + // SecretKey is the BLS secret key (hex, 0x-prefixed or raw) the relay uses to + // sign bids. Empty = use mevboostrelay.DefaultSecretKey (single-relay setups). + // Multi-relay setups should set distinct keys so mev-boost sees distinct pubkeys. + SecretKey string } func (m *MevBoostRelay) serviceName() string { @@ -656,10 +673,14 @@ func (m *MevBoostRelay) Apply(ctx *ExContext) *Component { DependsOnHealthy(m.BeaconClient). WithArgs( "--api-listen-addr", "0.0.0.0", - "--api-listen-port", `{{Port "http" 5555}}`, + "--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) + } + if m.ValidationServer != "" { service.WithArgs("--validation-server-addr", Connect(m.ValidationServer, "http")) } @@ -723,29 +744,61 @@ func (o *OpReth) Apply(ctx *ExContext) *Component { return component } +// 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 +} + type MevBoost struct { - RelayEndpoints []string + RelayEndpoints []MevBoostRelayEndpoint } -func localMevBoostRelayURL(endpoint string) (string, bool) { - envSkBytes, err := hexutil.Decode(mevboostrelay.DefaultSecretKey) +// blsPublicKeyHex derives the BLS public key (0x-prefixed hex) for the given +// secret key. secretKeyHex may be 0x-prefixed or raw. +func blsPublicKeyHex(secretKeyHex string) (string, error) { + if !strings.HasPrefix(secretKeyHex, "0x") && !strings.HasPrefix(secretKeyHex, "0X") { + secretKeyHex = "0x" + secretKeyHex + } + skBytes, err := hexutil.Decode(secretKeyHex) if err != nil { - return "", false + return "", fmt.Errorf("decode secret key: %w", err) } - secretKey, err := bls.SecretKeyFromBytes(envSkBytes[:]) + secretKey, err := bls.SecretKeyFromBytes(skBytes[:]) if err != nil { - return "", false + return "", fmt.Errorf("parse secret key: %w", err) } - blsPublicKey, err := bls.PublicKeyFromSecretKey(secretKey) + blsPub, err := bls.PublicKeyFromSecretKey(secretKey) if err != nil { - return "", false + return "", fmt.Errorf("derive public key: %w", err) } - publicKey, err := utils.BlsPublicKeyToPublicKey(blsPublicKey) + pub, err := utils.BlsPublicKeyToPublicKey(blsPub) if err != nil { - return "", false + return "", fmt.Errorf("convert public key: %w", err) } + return pub.String(), nil +} - return ConnectRaw(endpoint, "http", "http", publicKey.String()), true +// localMevBoostRelayURL returns the playground relay URL for endpoint, embedding +// the BLS pubkey derived from the relay's secret. Empty secretKeyHex falls back +// to the playground-wide default key. +func localMevBoostRelayURL(endpoint, secretKeyHex string) (string, bool) { + if secretKeyHex == "" { + secretKeyHex = mevboostrelay.DefaultSecretKey + } + pubkey, err := blsPublicKeyHex(secretKeyHex) + if err != nil { + return "", false + } + return ConnectRaw(endpoint, "http", "http", pubkey), true } func (m *MevBoost) Apply(ctx *ExContext) *Component { @@ -757,40 +810,45 @@ func (m *MevBoost) Apply(ctx *ExContext) *Component { } for _, endpoint := range m.RelayEndpoints { - if strings.Contains(endpoint, "://") { - args = append(args, "--relay", endpoint) - } else if relayURL, ok := localMevBoostRelayURL(endpoint); ok { + 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")) } component.NewService("mev-boost"). WithImage("flashbots/mev-boost"). WithTag("latest"). WithArgs(args...). - WithEnv("GENESIS_FORK_VERSION", "0x20000089") + WithEnv("GENESIS_FORK_VERSION", "0x20000089"). + WithEnv("GENESIS_TIMESTAMP", strconv.FormatUint(ctx.GenesisTimestamp, 10)) return component } +// 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" -//go:embed utils/rbuilder-config.toml.tmpl -var defaultRbuilderConfigToml string +// 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" type Rbuilder struct { - ServiceName string - BeaconNode string - ExecutionNode string - RelayEndpoints []string - RelaySecretKey string - ConfigArtifact string - ExtraData string - JSONRPCPort int - RedactedPort int - FullMetricsPort int + ServiceName string + BeaconNode string + ExecutionNode string + RelayEndpoints []string + RelaySecretKey string + ConfigArtifact string + ExtraData string } func (r *Rbuilder) serviceName() string { @@ -845,76 +903,102 @@ func (r *Rbuilder) extraData() string { return "Playground Builder" } -func (r *Rbuilder) jsonRPCPort() int { - if r.JSONRPCPort != 0 { - return r.JSONRPCPort - } - return 8645 +// rbuilderRelayConfig matches the [[relays]] table rbuilder reads. +type rbuilderRelayConfig struct { + Name string `toml:"name"` + URL string `toml:"url"` + UseSSZForSubmit bool `toml:"use_ssz_for_submit"` + UseGzipForSubmit bool `toml:"use_gzip_for_submit"` + Mode string `toml:"mode"` } -func (r *Rbuilder) redactedPort() int { - if r.RedactedPort != 0 { - return r.RedactedPort - } - return 6061 +// rbuilderBuilderConfig matches the [[builders]] table rbuilder reads. +type rbuilderBuilderConfig struct { + Name string `toml:"name"` + Algo string `toml:"algo"` + DiscardTxs bool `toml:"discard_txs"` + Sorting string `toml:"sorting"` + FailedOrderRetries int `toml:"failed_order_retries"` + DropFailedOrders bool `toml:"drop_failed_orders"` } -func (r *Rbuilder) fullMetricsPort() int { - if r.FullMetricsPort != 0 { - return r.FullMetricsPort - } - return 6060 +// rbuilderConfig is the typed shape of the TOML config rbuilder consumes. +type rbuilderConfig struct { + LogJSON bool `toml:"log_json"` + LogLevel string `toml:"log_level"` + RedactedTelemetryServerIP string `toml:"redacted_telemetry_server_ip"` + RedactedTelemetryServerPt int `toml:"redacted_telemetry_server_port"` + FullTelemetryServerIP string `toml:"full_telemetry_server_ip"` + FullTelemetryServerPort int `toml:"full_telemetry_server_port"` + Chain string `toml:"chain"` + RethDatadir string `toml:"reth_datadir"` + ELNodeIPCPath string `toml:"el_node_ipc_path"` + CoinbaseSecretKey string `toml:"coinbase_secret_key"` + RelaySecretKey string `toml:"relay_secret_key"` + CLNodeURL []string `toml:"cl_node_url"` + JSONRPCServerIP string `toml:"jsonrpc_server_ip"` + JSONRPCServerPort int `toml:"jsonrpc_server_port"` + ExtraData string `toml:"extra_data"` + IgnoreCancellableOrders bool `toml:"ignore_cancellable_orders"` + RootHashUseSparseTrie bool `toml:"root_hash_use_sparse_trie"` + RootHashCompareSparseTrie bool `toml:"root_hash_compare_sparse_trie"` + SlotDeltaToStartBiddingMS int `toml:"slot_delta_to_start_bidding_ms"` + LiveBuilders []string `toml:"live_builders"` + EnabledRelays []string `toml:"enabled_relays"` + + Relays []rbuilderRelayConfig `toml:"relays"` + Builders []rbuilderBuilderConfig `toml:"builders"` } -func (r *Rbuilder) configTOML() string { +func (r *Rbuilder) configTOML() (string, error) { relayEndpoints := r.relayEndpoints() - relayNames := make([]string, 0, len(relayEndpoints)) + relays := make([]rbuilderRelayConfig, 0, len(relayEndpoints)) for _, relay := range relayEndpoints { - relayNames = append(relayNames, strconv.Quote(relay)) + relays = append(relays, rbuilderRelayConfig{ + Name: relay, + URL: fmt.Sprintf("http://%s:%d", relay, mevBoostRelayHTTPPort), + Mode: "full", + }) } - 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") + cfg := rbuilderConfig{ + LogJSON: false, + LogLevel: "info,rbuilder=debug", + RedactedTelemetryServerIP: "0.0.0.0", + RedactedTelemetryServerPt: rbuilderRedactedPort, + FullTelemetryServerIP: "0.0.0.0", + FullTelemetryServerPort: rbuilderFullMetricsPort, + Chain: "/data/genesis.json", + RethDatadir: "/data_reth", + ELNodeIPCPath: "/data_reth/reth.ipc", + CoinbaseSecretKey: builderCoinbaseSecretKey, + RelaySecretKey: r.relaySecretKey(), + CLNodeURL: []string{fmt.Sprintf("http://%s:%d", r.beaconNode(), lighthouseBeaconHTTPPort)}, + JSONRPCServerIP: "0.0.0.0", + JSONRPCServerPort: rbuilderJSONRPCPort, + ExtraData: r.extraData(), + IgnoreCancellableOrders: true, + RootHashUseSparseTrie: true, + RootHashCompareSparseTrie: false, + SlotDeltaToStartBiddingMS: -20000, + LiveBuilders: []string{"mp-ordering"}, + EnabledRelays: relayEndpoints, + Relays: relays, + Builders: []rbuilderBuilderConfig{{ + Name: "mp-ordering", + Algo: "ordering-builder", + DiscardTxs: true, + Sorting: "max-profit", + FailedOrderRetries: 1, + DropFailedOrders: true, + }}, } - 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() + var b strings.Builder + if err := toml.NewEncoder(&b).Encode(cfg); err != nil { + return "", fmt.Errorf("encode rbuilder config: %w", err) + } + return b.String(), nil } func (r *Rbuilder) Apply(ctx *ExContext) *Component { @@ -922,20 +1006,20 @@ func (r *Rbuilder) Apply(ctx *ExContext) *Component { configArtifact := r.configArtifact() component := NewComponent(serviceName) - // TODO: Handle error - 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() + config, err := r.configTOML() + if err != nil { + panic(fmt.Errorf("rbuilder %s: %w", serviceName, err)) + } + if err := ctx.Output.WriteFile(configArtifact, config); err != nil { + panic(fmt.Errorf("rbuilder %s: write config: %w", serviceName, err)) } - ctx.Output.WriteFile(configArtifact, config) service := component.NewService(serviceName). WithImage("ghcr.io/flashbots/rbuilder"). WithTag("sha-7efdc0b"). WithArtifact("/data/rbuilder-config.toml", configArtifact). WithArtifact("/data/genesis.json", "genesis.json"). - WithPort("rpc", r.jsonRPCPort()). + WithPort("rpc", rbuilderJSONRPCPort). WithVolume("shared:el-data", "/data_reth", true). DependsOnHealthy(r.executionNode()). DependsOnHealthy(r.beaconNode()). diff --git a/playground/manifest.go b/playground/manifest.go index 8d3c977..b105a4d 100644 --- a/playground/manifest.go +++ b/playground/manifest.go @@ -221,6 +221,8 @@ type ExContext struct { Bootnode *BootnodeRef Contender *ContenderContext + + GenesisTimestamp uint64 } type BootnodeRef struct { diff --git a/playground/recipe_l1.go b/playground/recipe_l1.go index 4da0138..ebc0656 100644 --- a/playground/recipe_l1.go +++ b/playground/recipe_l1.go @@ -123,7 +123,9 @@ func (l *L1Recipe) Apply(ctx *ExContext) *Component { }) component.AddComponent(ctx, &MevBoost{ - RelayEndpoints: []string{"mev-boost-relay"}, + RelayEndpoints: []MevBoostRelayEndpoint{ + {Service: "mev-boost-relay"}, + }, }) } else { // single-service setup diff --git a/playground/recipe_l1_multi_builder.go b/playground/recipe_l1_multi_builder.go index dc56907..5d3d1f8 100644 --- a/playground/recipe_l1_multi_builder.go +++ b/playground/recipe_l1_multi_builder.go @@ -2,11 +2,21 @@ package playground import ( "fmt" + "math/big" "time" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" flag "github.com/spf13/pflag" ) +// blsCurveOrder is the order r of the BLS12-381 scalar field. A valid BLS +// secret key is a non-zero scalar in [1, r-1]; blst (the Rust implementation +// rbuilder uses) rejects raw bytes whose big-endian value is >= r. +var blsCurveOrder, _ = new(big.Int).SetString( + "73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001", 16, +) + var _ Recipe = &L1MultiBuilderRecipe{} const ( @@ -35,40 +45,73 @@ func (l *L1MultiBuilderRecipe) Flags() *flag.FlagSet { flags.BoolVar(&l.latestFork, "latest-fork", false, "use the latest fork") flags.DurationVar(&l.blockTime, "block-time", time.Duration(defaultL1BlockTimeSeconds)*time.Second, "Block time to use for the L1") flags.BoolVar(&l.useRethForValidation, "use-reth-for-validation", false, "use reth for validation") - flags.IntVar(&l.builderCount, "builders", defaultL1MultiBuilderCount, "number of rbuilder instances to run") - flags.IntVar(&l.relayCount, "relays", defaultL1MultiRelayCount, "number of mev-boost-relay instances to run") + flags.IntVar(&l.builderCount, "builders", defaultL1MultiBuilderCount, "number of rbuilder instances to run; must be >= 1") + flags.IntVar(&l.relayCount, "relays", defaultL1MultiRelayCount, "number of mev-boost-relay instances to run; must be >= 1") return flags } -func (l *L1MultiBuilderRecipe) Artifacts() *ArtifactsBuilder { - builder := NewArtifactsBuilder() - builder.ApplyLatestL1Fork(l.latestFork) - builder.L1BlockTime(max(1, uint64(l.normalizedBlockTime().Seconds()))) - - return builder -} - -func (l *L1MultiBuilderRecipe) normalizedBlockTime() time.Duration { - if l.blockTime > 0 { - return l.blockTime +// Validate checks the recipe's CLI flag values. Called by main.go after flag +// parsing so that explicit zero/negative values fail loudly instead of being +// silently rewritten to defaults. +func (l *L1MultiBuilderRecipe) Validate() error { + if l.builderCount < 1 { + return fmt.Errorf("--builders must be >= 1, got %d", l.builderCount) } - return time.Duration(defaultL1BlockTimeSeconds) * time.Second + 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 } -func (l *L1MultiBuilderRecipe) normalizedBuilderCount() int { +func (l *L1MultiBuilderRecipe) builderCountOrDefault() int { if l.builderCount > 0 { return l.builderCount } return defaultL1MultiBuilderCount } -func (l *L1MultiBuilderRecipe) normalizedRelayCount() int { +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 +} + +func (l *L1MultiBuilderRecipe) Artifacts() *ArtifactsBuilder { + builder := NewArtifactsBuilder() + builder.ApplyLatestL1Fork(l.latestFork) + builder.L1BlockTime(max(1, uint64(l.blockTimeOrDefault().Seconds()))) + return builder +} + +// indexedBLSSecret derives a deterministic BLS secret in hex (0x-prefixed) by +// hashing a domain-separated label and reducing modulo the BLS12-381 scalar +// field order so blst-based consumers (rbuilder, mev-boost-relay) accept it. +// Avoids visually trivial scalars like 0x...0001 that hint at low-entropy keys. +func indexedBLSSecret(label string, i int) string { + h := crypto.Keccak256([]byte(fmt.Sprintf("%s-%d", label, i))) + n := new(big.Int).SetBytes(h) + n.Mod(n, blsCurveOrder) + if n.Sign() == 0 { + // Reduction landed on zero; bump to 1 so the scalar is non-zero. With a + // uniform 256-bit input this branch is astronomically unlikely. + n.SetInt64(1) + } + out := make([]byte, 32) + n.FillBytes(out) + return hexutil.Encode(out) +} + func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { component := NewComponent("l1-multi-builder-recipe") @@ -76,9 +119,12 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { UseRethForValidation: l.useRethForValidation, }) - relayServices := make([]string, 0, l.normalizedRelayCount()) - for i := 1; i <= l.normalizedRelayCount(); i++ { + relayCount := l.relayCountOrDefault() + relayServices := make([]string, 0, relayCount) + relaySecrets := make([]string, 0, relayCount) + for i := 1; i <= relayCount; i++ { relayServices = append(relayServices, fmt.Sprintf("mev-boost-relay-%d", i)) + relaySecrets = append(relaySecrets, indexedBLSSecret("playground-relay", i)) } component.AddComponent(ctx, &LighthouseBeaconNode{ @@ -93,27 +139,32 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { if l.useRethForValidation { mevBoostValidationServer = "el" } - for _, relayService := range relayServices { + for i, relayService := range relayServices { component.AddComponent(ctx, &MevBoostRelay{ ServiceName: relayService, BeaconClient: "beacon", ValidationServer: mevBoostValidationServer, + SecretKey: relaySecrets[i], }) } + mevBoostRelays := make([]MevBoostRelayEndpoint, 0, len(relayServices)) + for i, relayService := range relayServices { + mevBoostRelays = append(mevBoostRelays, MevBoostRelayEndpoint{ + Service: relayService, + SecretKey: relaySecrets[i], + }) + } component.AddComponent(ctx, &MevBoost{ - RelayEndpoints: relayServices, + RelayEndpoints: mevBoostRelays, }) - for i := 1; i <= l.normalizedBuilderCount(); i++ { + for i := 1; i <= l.builderCountOrDefault(); 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, + ServiceName: fmt.Sprintf("rbuilder-%d", i), + RelayEndpoints: relayServices, + RelaySecretKey: indexedBLSSecret("playground-rbuilder", i), + ExtraData: fmt.Sprintf("Playground Builder %d", i), }) } diff --git a/playground/recipe_l1_multi_builder_test.go b/playground/recipe_l1_multi_builder_test.go index 4b08023..9ccb03b 100644 --- a/playground/recipe_l1_multi_builder_test.go +++ b/playground/recipe_l1_multi_builder_test.go @@ -1,6 +1,7 @@ package playground import ( + "fmt" "path/filepath" "strings" "testing" @@ -26,31 +27,70 @@ func TestL1MultiBuilderRecipeWiresBuildersToRelays(t *testing.T) { require.NotNil(t, manifest.MustGetService("mev-boost-relay-1")) require.NotNil(t, manifest.MustGetService("mev-boost-relay-2")) - require.NotNil(t, manifest.MustGetService("rbuilder-1")) - require.NotNil(t, manifest.MustGetService("rbuilder-2")) - require.NotNil(t, manifest.MustGetService("rbuilder-3")) el := manifest.MustGetService("el") require.Contains(t, strings.Join(el.Args, " "), "admin,eth,web3,net,txpool,rpc,mev,flashbots") + relay1Args := strings.Join(manifest.MustGetService("mev-boost-relay-1").Args, " ") + relay2Args := strings.Join(manifest.MustGetService("mev-boost-relay-2").Args, " ") + require.Contains(t, relay1Args, "--api-secret-key") + require.Contains(t, relay2Args, "--api-secret-key") + require.NotEqual(t, relay1Args, relay2Args, "relays must run with distinct api secret keys") + 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`) - rbuilder := manifest.MustGetService("rbuilder-2") - require.Equal(t, "service:el", rbuilder.Pid) - require.Equal(t, 8646, rbuilder.MustGetPort("rpc").Port) - require.ElementsMatch(t, []*DependsOn{ - {Name: "el", Condition: DependsOnConditionHealthy}, - {Name: "beacon", Condition: DependsOnConditionHealthy}, - }, rbuilder.DependsOn) + for _, builder := range []struct { + index int + name string + }{ + {1, "rbuilder-1"}, + {2, "rbuilder-2"}, + {3, "rbuilder-3"}, + } { + svc := manifest.MustGetService(builder.name) + require.Equal(t, "service:el", svc.Pid) + require.Equal(t, rbuilderJSONRPCPort, svc.MustGetPort("rpc").Port, + "all rbuilders use the same in-container rpc port — host port allocator handles host-side collisions") + require.ElementsMatch(t, []*DependsOn{ + {Name: "el", Condition: DependsOnConditionHealthy}, + {Name: "beacon", Condition: DependsOnConditionHealthy}, + }, svc.DependsOn) - config, err := out.Read("rbuilder-2-config.toml") - require.NoError(t, err) - require.Contains(t, config, `enabled_relays = ["mev-boost-relay-1", "mev-boost-relay-2"]`) - require.Contains(t, config, `url = "http://mev-boost-relay-1:5555"`) - require.Contains(t, config, `url = "http://mev-boost-relay-2:5555"`) - require.Contains(t, config, `relay_secret_key = "0x0000000000000000000000000000000000000000000000000000000000000002"`) - require.Contains(t, config, `extra_data = "Playground Builder 2"`) + config, err := out.Read(builder.name + "-config.toml") + require.NoError(t, err) + require.Contains(t, config, `enabled_relays = ["mev-boost-relay-1", "mev-boost-relay-2"]`) + require.Contains(t, config, fmt.Sprintf(`url = "http://mev-boost-relay-1:%d"`, mevBoostRelayHTTPPort)) + require.Contains(t, config, fmt.Sprintf(`url = "http://mev-boost-relay-2:%d"`, mevBoostRelayHTTPPort)) + require.Contains(t, config, fmt.Sprintf(`extra_data = "Playground Builder %d"`, builder.index)) + require.Contains(t, config, fmt.Sprintf(`relay_secret_key = "%s"`, indexedBLSSecret("playground-rbuilder", builder.index))) + } +} + +func TestL1MultiBuilderRecipeValidateRejectsZeroAndNegative(t *testing.T) { + cases := []struct { + name string + recipe *L1MultiBuilderRecipe + }{ + {"zero builders", &L1MultiBuilderRecipe{blockTime: 12 * time.Second, builderCount: 0, relayCount: 1}}, + {"negative builders", &L1MultiBuilderRecipe{blockTime: 12 * time.Second, builderCount: -1, relayCount: 1}}, + {"zero relays", &L1MultiBuilderRecipe{blockTime: 12 * time.Second, builderCount: 1, relayCount: 0}}, + {"zero block time", &L1MultiBuilderRecipe{blockTime: 0, builderCount: 1, relayCount: 1}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Error(t, tc.recipe.Validate()) + }) + } +} + +func TestL1MultiBuilderRecipeValidateAcceptsDefaults(t *testing.T) { + recipe := &L1MultiBuilderRecipe{ + blockTime: 12 * time.Second, + builderCount: defaultL1MultiBuilderCount, + relayCount: defaultL1MultiRelayCount, + } + require.NoError(t, recipe.Validate()) } diff --git a/playground/test_tx.go b/playground/test_tx.go index ae4fe47..662bb32 100644 --- a/playground/test_tx.go +++ b/playground/test_tx.go @@ -3,8 +3,11 @@ package playground import ( "bytes" "context" + "crypto/ecdsa" "crypto/tls" + "encoding/hex" "fmt" + "io" "math/big" "net/http" "time" @@ -16,6 +19,45 @@ import ( "github.com/ethereum/go-ethereum/rpc" ) +// buildernetSigningTransport is an http.RoundTripper that adds the X-BuilderNet-Signature +// header to every request. FlowProxy requires this header for orderflow authentication. +// The signature is: keccak256(body) → format as hex → EIP-191 sign → header. +// Any valid key pair works — it's an identity tag, not access control. +type buildernetSigningTransport struct { + base http.RoundTripper + privateKey *ecdsa.PrivateKey + address common.Address +} + +func (t *buildernetSigningTransport) RoundTrip(req *http.Request) (*http.Response, error) { + defer req.Body.Close() + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + // Sign: keccak256(body) → hex string → EIP-191 hash → ECDSA sign + bodyHash := crypto.Keccak256(body) + hashHex := "0x" + hex.EncodeToString(bodyHash) + + // EIP-191: "\x19Ethereum Signed Message:\n" + len + message + prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(hashHex)) + msgHash := crypto.Keccak256(append([]byte(prefix), []byte(hashHex)...)) + + sig, err := crypto.Sign(msgHash, t.privateKey) + if err != nil { + return nil, fmt.Errorf("buildernet signing failed: %w", err) + } + sig[64] += 27 // V: 0/1 → 27/28 + + req.Header.Set("X-BuilderNet-Signature", + fmt.Sprintf("%s:0x%s", t.address.Hex(), hex.EncodeToString(sig))) + + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + return t.base.RoundTrip(req) +} + // TestTxConfig holds configuration for the test transaction type TestTxConfig struct { RPCURL string // Target RPC URL for sending transactions (e.g., rbuilder) @@ -75,21 +117,29 @@ func SendTestTransaction(ctx context.Context, cfg *TestTxConfig) error { elRPCURL = cfg.RPCURL } - // Parse private key used for transaction signing. + // Parse private key (used for both tx signing and BuilderNet header) privateKey, err := crypto.HexToECDSA(cfg.PrivateKey) if err != nil { return fmt.Errorf("failed to parse private key: %w", err) } fromAddress := crypto.PubkeyToAddress(privateKey.PublicKey) - // dialRPC connects to an RPC endpoint and optionally skips TLS verification. + // dialRPC connects to an RPC endpoint, adding BuilderNet signature header + // and optionally skipping TLS verification dialRPC := func(url string) (*ethclient.Client, error) { - if !cfg.Insecure { - return ethclient.DialContext(ctx, url) + var base http.RoundTripper + if cfg.Insecure { + base = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } else { + base = http.DefaultTransport } httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + Transport: &buildernetSigningTransport{ + base: base, + privateKey: privateKey, + address: fromAddress, }, } rpcClient, err := rpc.DialOptions(ctx, url, rpc.WithHTTPClient(httpClient)) diff --git a/playground/utils/rbuilder-config.toml.tmpl b/playground/utils/rbuilder-config.toml.tmpl deleted file mode 100644 index ef4acf4..0000000 --- a/playground/utils/rbuilder-config.toml.tmpl +++ /dev/null @@ -1,19 +0,0 @@ - -chain = "/data/genesis.json" -reth_datadir = "/data_reth" -relay_secret_key = "5eae315483f028b5cdd5d1090ff0c7618b18737ea9bf3c35047189db22835c48" -el_node_ipc_path = "/data_reth/reth.ipc" -live_builders = ["mgp-ordering"] -enabled_relays = ["playground"] -log_level = "info,rbuilder=debug" -coinbase_secret_key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" -cl_node_url = "http://beacon:3500" - -root_hash_use_sparse_trie=true -root_hash_compare_sparse_trie=false -slot_delta_to_start_bidding_ms = -20000 - -[[relays]] -name = "playground" -url = "http://mev-boost-relay:5555" -mode = "full" From ce0d122fe7741a80ce1845c269a9a10ab8984a5c Mon Sep 17 00:00:00 2001 From: Solar Mithril Date: Wed, 29 Apr 2026 20:20:03 +0400 Subject: [PATCH 6/6] Address PR review round 2 for l1-multi-builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- playground/cmd_validate.go | 8 ++ playground/components.go | 128 ++++++++++++--------- playground/components_test.go | 58 ++++++++++ playground/manifest.go | 12 +- playground/recipe_l1_multi_builder.go | 40 ++----- playground/recipe_l1_multi_builder_test.go | 14 +++ playground/recipe_to_yaml.go | 9 +- 7 files changed, 184 insertions(+), 85 deletions(-) create mode 100644 playground/components_test.go diff --git a/playground/cmd_validate.go b/playground/cmd_validate.go index 57eb756..79fff63 100644 --- a/playground/cmd_validate.go +++ b/playground/cmd_validate.go @@ -52,6 +52,14 @@ func ValidateRecipe(recipe Recipe, baseRecipes []Recipe) *ValidationResult { return result } + // Run recipe-level validation before Apply (e.g. flag-value checks). + if v, ok := recipe.(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + result.AddError("recipe validation failed: %v", err) + return result + } + } + // Build a minimal manifest to validate structure exCtx := &ExContext{ LogLevel: LevelInfo, diff --git a/playground/components.go b/playground/components.go index db21b14..fa2c48f 100644 --- a/playground/components.go +++ b/playground/components.go @@ -22,6 +22,15 @@ var ( // (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. +// +// Caveat: artifact files are written by Go before the manifest's port +// allocator runs and don't pass through the `{{Port}}`/`{{Service}}` +// substitution that args/env get in local_runner.go. A user `--override` that +// changes a service's host-side port still works (the allocator only touches +// the host side), but an override that rebinds the in-container port — or a +// future change to a default here — leaves the rbuilder TOML pointing at the +// stale value. Long-term fix: thread artifact files through the same template +// substitution so `Connect("beacon", "http")` can be embedded directly. const ( lighthouseBeaconHTTPPort = 3500 mevBoostRelayHTTPPort = 5555 @@ -746,9 +755,9 @@ func (o *OpReth) Apply(ctx *ExContext) *Component { // MevBoostRelayEndpoint identifies a relay reachable by mev-boost. // -// Exactly one form should be set: +// Exactly one of URL or Service must be set: // - URL: a fully-formed `scheme://pubkey@host:port` URL (used for external -// relays). +// relays). SecretKey is ignored. // - 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). @@ -758,6 +767,18 @@ type MevBoostRelayEndpoint struct { SecretKey string } +func (e MevBoostRelayEndpoint) validate() error { + switch { + case e.URL == "" && e.Service == "": + return fmt.Errorf("relay endpoint requires URL or Service") + case e.URL != "" && e.Service != "": + return fmt.Errorf("relay endpoint sets both URL (%q) and Service (%q); pick one", e.URL, e.Service) + case e.URL != "" && e.SecretKey != "": + return fmt.Errorf("relay endpoint URL %q is precomputed; SecretKey must be empty", e.URL) + } + return nil +} + type MevBoost struct { RelayEndpoints []MevBoostRelayEndpoint } @@ -790,15 +811,15 @@ func blsPublicKeyHex(secretKeyHex string) (string, error) { // localMevBoostRelayURL returns the playground relay URL for endpoint, embedding // the BLS pubkey derived from the relay's secret. Empty secretKeyHex falls back // to the playground-wide default key. -func localMevBoostRelayURL(endpoint, secretKeyHex string) (string, bool) { +func localMevBoostRelayURL(endpoint, secretKeyHex string) (string, error) { if secretKeyHex == "" { secretKeyHex = mevboostrelay.DefaultSecretKey } pubkey, err := blsPublicKeyHex(secretKeyHex) if err != nil { - return "", false + return "", err } - return ConnectRaw(endpoint, "http", "http", pubkey), true + return ConnectRaw(endpoint, "http", "http", pubkey), nil } func (m *MevBoost) Apply(ctx *ExContext) *Component { @@ -810,15 +831,18 @@ func (m *MevBoost) Apply(ctx *ExContext) *Component { } for _, endpoint := range m.RelayEndpoints { + if err := endpoint.validate(); err != nil { + panic(fmt.Errorf("mev-boost: %w", err)) + } if endpoint.URL != "" { args = append(args, "--relay", endpoint.URL) continue } - if relayURL, ok := localMevBoostRelayURL(endpoint.Service, endpoint.SecretKey); ok { - args = append(args, "--relay", relayURL) - continue + relayURL, err := localMevBoostRelayURL(endpoint.Service, endpoint.SecretKey) + if err != nil { + panic(fmt.Errorf("mev-boost: derive relay URL for service %q: %w", endpoint.Service, err)) } - args = append(args, "--relay", Connect(endpoint.Service, "http")) + args = append(args, "--relay", relayURL) } component.NewService("mev-boost"). @@ -924,27 +948,27 @@ type rbuilderBuilderConfig struct { // rbuilderConfig is the typed shape of the TOML config rbuilder consumes. type rbuilderConfig struct { - LogJSON bool `toml:"log_json"` - LogLevel string `toml:"log_level"` - RedactedTelemetryServerIP string `toml:"redacted_telemetry_server_ip"` - RedactedTelemetryServerPt int `toml:"redacted_telemetry_server_port"` - FullTelemetryServerIP string `toml:"full_telemetry_server_ip"` - FullTelemetryServerPort int `toml:"full_telemetry_server_port"` - Chain string `toml:"chain"` - RethDatadir string `toml:"reth_datadir"` - ELNodeIPCPath string `toml:"el_node_ipc_path"` - CoinbaseSecretKey string `toml:"coinbase_secret_key"` - RelaySecretKey string `toml:"relay_secret_key"` - CLNodeURL []string `toml:"cl_node_url"` - JSONRPCServerIP string `toml:"jsonrpc_server_ip"` - JSONRPCServerPort int `toml:"jsonrpc_server_port"` - ExtraData string `toml:"extra_data"` - IgnoreCancellableOrders bool `toml:"ignore_cancellable_orders"` - RootHashUseSparseTrie bool `toml:"root_hash_use_sparse_trie"` - RootHashCompareSparseTrie bool `toml:"root_hash_compare_sparse_trie"` - SlotDeltaToStartBiddingMS int `toml:"slot_delta_to_start_bidding_ms"` - LiveBuilders []string `toml:"live_builders"` - EnabledRelays []string `toml:"enabled_relays"` + LogJSON bool `toml:"log_json"` + LogLevel string `toml:"log_level"` + RedactedTelemetryServerIP string `toml:"redacted_telemetry_server_ip"` + RedactedTelemetryServerPort int `toml:"redacted_telemetry_server_port"` + FullTelemetryServerIP string `toml:"full_telemetry_server_ip"` + FullTelemetryServerPort int `toml:"full_telemetry_server_port"` + Chain string `toml:"chain"` + RethDatadir string `toml:"reth_datadir"` + ELNodeIPCPath string `toml:"el_node_ipc_path"` + CoinbaseSecretKey string `toml:"coinbase_secret_key"` + RelaySecretKey string `toml:"relay_secret_key"` + CLNodeURL []string `toml:"cl_node_url"` + JSONRPCServerIP string `toml:"jsonrpc_server_ip"` + JSONRPCServerPort int `toml:"jsonrpc_server_port"` + ExtraData string `toml:"extra_data"` + IgnoreCancellableOrders bool `toml:"ignore_cancellable_orders"` + RootHashUseSparseTrie bool `toml:"root_hash_use_sparse_trie"` + RootHashCompareSparseTrie bool `toml:"root_hash_compare_sparse_trie"` + SlotDeltaToStartBiddingMS int `toml:"slot_delta_to_start_bidding_ms"` + LiveBuilders []string `toml:"live_builders"` + EnabledRelays []string `toml:"enabled_relays"` Relays []rbuilderRelayConfig `toml:"relays"` Builders []rbuilderBuilderConfig `toml:"builders"` @@ -962,28 +986,28 @@ func (r *Rbuilder) configTOML() (string, error) { } cfg := rbuilderConfig{ - LogJSON: false, - LogLevel: "info,rbuilder=debug", - RedactedTelemetryServerIP: "0.0.0.0", - RedactedTelemetryServerPt: rbuilderRedactedPort, - FullTelemetryServerIP: "0.0.0.0", - FullTelemetryServerPort: rbuilderFullMetricsPort, - Chain: "/data/genesis.json", - RethDatadir: "/data_reth", - ELNodeIPCPath: "/data_reth/reth.ipc", - CoinbaseSecretKey: builderCoinbaseSecretKey, - RelaySecretKey: r.relaySecretKey(), - CLNodeURL: []string{fmt.Sprintf("http://%s:%d", r.beaconNode(), lighthouseBeaconHTTPPort)}, - JSONRPCServerIP: "0.0.0.0", - JSONRPCServerPort: rbuilderJSONRPCPort, - ExtraData: r.extraData(), - IgnoreCancellableOrders: true, - RootHashUseSparseTrie: true, - RootHashCompareSparseTrie: false, - SlotDeltaToStartBiddingMS: -20000, - LiveBuilders: []string{"mp-ordering"}, - EnabledRelays: relayEndpoints, - Relays: relays, + LogJSON: false, + LogLevel: "info,rbuilder=debug", + RedactedTelemetryServerIP: "0.0.0.0", + RedactedTelemetryServerPort: rbuilderRedactedPort, + FullTelemetryServerIP: "0.0.0.0", + FullTelemetryServerPort: rbuilderFullMetricsPort, + Chain: "/data/genesis.json", + RethDatadir: "/data_reth", + ELNodeIPCPath: "/data_reth/reth.ipc", + CoinbaseSecretKey: builderCoinbaseSecretKey, + RelaySecretKey: r.relaySecretKey(), + CLNodeURL: []string{fmt.Sprintf("http://%s:%d", r.beaconNode(), lighthouseBeaconHTTPPort)}, + JSONRPCServerIP: "0.0.0.0", + JSONRPCServerPort: rbuilderJSONRPCPort, + ExtraData: r.extraData(), + IgnoreCancellableOrders: true, + RootHashUseSparseTrie: true, + RootHashCompareSparseTrie: false, + SlotDeltaToStartBiddingMS: -20000, + LiveBuilders: []string{"mp-ordering"}, + EnabledRelays: relayEndpoints, + Relays: relays, Builders: []rbuilderBuilderConfig{{ Name: "mp-ordering", Algo: "ordering-builder", diff --git a/playground/components_test.go b/playground/components_test.go new file mode 100644 index 0000000..2149ffb --- /dev/null +++ b/playground/components_test.go @@ -0,0 +1,58 @@ +package playground + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMevBoostRelayEndpointValidate(t *testing.T) { + cases := []struct { + name string + endpoint MevBoostRelayEndpoint + ok bool + errMatch string + }{ + { + name: "service only", + endpoint: MevBoostRelayEndpoint{Service: "mev-boost-relay-1"}, + ok: true, + }, + { + name: "service with secret", + endpoint: MevBoostRelayEndpoint{Service: "mev-boost-relay-1", SecretKey: "0xdeadbeef"}, + ok: true, + }, + { + name: "url only", + endpoint: MevBoostRelayEndpoint{URL: "http://0xpub@host:5555"}, + ok: true, + }, + { + name: "neither", + endpoint: MevBoostRelayEndpoint{}, + errMatch: "URL or Service", + }, + { + name: "both url and service", + endpoint: MevBoostRelayEndpoint{URL: "http://x", Service: "mev-boost-relay-1"}, + errMatch: "pick one", + }, + { + name: "url and secret", + endpoint: MevBoostRelayEndpoint{URL: "http://x", SecretKey: "0x01"}, + errMatch: "SecretKey must be empty", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.endpoint.validate() + if tc.ok { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.errMatch) + }) + } +} diff --git a/playground/manifest.go b/playground/manifest.go index b105a4d..cfd9d7c 100644 --- a/playground/manifest.go +++ b/playground/manifest.go @@ -27,14 +27,22 @@ type Recipe interface { Output(manifest *Manifest) map[string]interface{} } -// GetBaseRecipes returns all available base recipes +// GetBaseRecipes returns all available base recipes. Calling Flags() once on +// each instance has the side effect of populating the recipe's flag-backed +// fields with their CLI defaults (pflag's *Var helpers write defaults at +// registration), so callers that don't run a flag parse — e.g. ValidateRecipe, +// programmatic uses — still see sane values rather than the struct zero. func GetBaseRecipes() []Recipe { - return []Recipe{ + recipes := []Recipe{ &L1Recipe{}, &L1MultiBuilderRecipe{}, &OpRecipe{}, &BuilderNetRecipe{}, } + for _, r := range recipes { + r.Flags() + } + return recipes } // Manifest describes a list of services and their dependencies diff --git a/playground/recipe_l1_multi_builder.go b/playground/recipe_l1_multi_builder.go index 5d3d1f8..b0e4f6a 100644 --- a/playground/recipe_l1_multi_builder.go +++ b/playground/recipe_l1_multi_builder.go @@ -50,9 +50,11 @@ func (l *L1MultiBuilderRecipe) Flags() *flag.FlagSet { return flags } -// Validate checks the recipe's CLI flag values. Called by main.go after flag -// parsing so that explicit zero/negative values fail loudly instead of being -// silently rewritten to defaults. +// 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) @@ -66,31 +68,10 @@ func (l *L1MultiBuilderRecipe) Validate() error { return nil } -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 -} - func (l *L1MultiBuilderRecipe) Artifacts() *ArtifactsBuilder { builder := NewArtifactsBuilder() builder.ApplyLatestL1Fork(l.latestFork) - builder.L1BlockTime(max(1, uint64(l.blockTimeOrDefault().Seconds()))) + builder.L1BlockTime(max(1, uint64(l.blockTime.Seconds()))) return builder } @@ -119,10 +100,9 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { UseRethForValidation: l.useRethForValidation, }) - relayCount := l.relayCountOrDefault() - relayServices := make([]string, 0, relayCount) - relaySecrets := make([]string, 0, relayCount) - for i := 1; i <= relayCount; i++ { + relayServices := make([]string, 0, l.relayCount) + relaySecrets := make([]string, 0, l.relayCount) + for i := 1; i <= l.relayCount; i++ { relayServices = append(relayServices, fmt.Sprintf("mev-boost-relay-%d", i)) relaySecrets = append(relaySecrets, indexedBLSSecret("playground-relay", i)) } @@ -159,7 +139,7 @@ func (l *L1MultiBuilderRecipe) Apply(ctx *ExContext) *Component { RelayEndpoints: mevBoostRelays, }) - for i := 1; i <= l.builderCountOrDefault(); i++ { + for i := 1; i <= l.builderCount; i++ { component.AddComponent(ctx, &Rbuilder{ ServiceName: fmt.Sprintf("rbuilder-%d", i), RelayEndpoints: relayServices, diff --git a/playground/recipe_l1_multi_builder_test.go b/playground/recipe_l1_multi_builder_test.go index 9ccb03b..cd90b59 100644 --- a/playground/recipe_l1_multi_builder_test.go +++ b/playground/recipe_l1_multi_builder_test.go @@ -94,3 +94,17 @@ func TestL1MultiBuilderRecipeValidateAcceptsDefaults(t *testing.T) { } require.NoError(t, recipe.Validate()) } + +// TestRecipeToYAMLRunsValidate guards the `playground generate` path: a recipe +// configured with an explicitly invalid flag value must fail there too, not +// just on `playground cook`. +func TestRecipeToYAMLRunsValidate(t *testing.T) { + recipe := &L1MultiBuilderRecipe{ + blockTime: 12 * time.Second, + builderCount: -1, + relayCount: defaultL1MultiRelayCount, + } + _, err := RecipeToYAML(recipe) + require.Error(t, err) + require.Contains(t, err.Error(), "--builders must be >= 1") +} diff --git a/playground/recipe_to_yaml.go b/playground/recipe_to_yaml.go index bccfaf9..8ca8e4f 100644 --- a/playground/recipe_to_yaml.go +++ b/playground/recipe_to_yaml.go @@ -8,8 +8,15 @@ import ( "gopkg.in/yaml.v3" ) -// RecipeToYAML converts a recipe to a playground.yaml format +// RecipeToYAML converts a recipe to a playground.yaml format. If the recipe +// implements `Validate() error`, it runs first so explicit invalid flag values +// fail loudly rather than getting silently rewritten by per-recipe defaults. func RecipeToYAML(recipe Recipe) (string, error) { + if v, ok := recipe.(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return "", err + } + } // Create a minimal output for the context (needed by some components) out := &output{ sessionDir: "/tmp/playground-generate",