diff --git a/changelog/syjn99_builder-auth-data-hostname.md b/changelog/syjn99_builder-auth-data-hostname.md new file mode 100644 index 000000000000..1ce1c693478d --- /dev/null +++ b/changelog/syjn99_builder-auth-data-hostname.md @@ -0,0 +1,4 @@ +### Changed + +- Derive the default builder `auth_data` from the builder URL's hostname instead of the full URL bytes, per builder-specs#168. +- Reject builder URLs without a hostname (`https://:8080`) or with a non-ASCII one. Internationalized hostnames must be punycode-encoded. diff --git a/config/proposer/settings.go b/config/proposer/settings.go index 4c1383d15ccc..e338ba99f6be 100644 --- a/config/proposer/settings.go +++ b/config/proposer/settings.go @@ -1,11 +1,14 @@ package proposer import ( + "encoding/binary" "fmt" + "net/netip" "net/url" "slices" "strings" "sync/atomic" + "unicode" "github.com/OffchainLabs/prysm/v7/config" fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" @@ -145,13 +148,42 @@ type BuilderEntry struct { BuilderBoostFactor *validator.Uint64 `json:"builder_boost_factor,omitempty" yaml:"builder_boost_factor,omitempty"` } -// EffectiveAuthData resolves omitted auth_data to the spec convention: -// the UTF-8 bytes of the builder's URL. +// EffectiveAuthData resolves omitted auth_data to the spec default. func (be *BuilderEntry) EffectiveAuthData() []byte { if len(be.AuthData) != 0 { return be.AuthData } - return []byte(be.URL) + u, err := url.Parse(be.URL) + if err != nil { + return nil + } + host := strings.ToLower(u.Hostname()) + addr, err := netip.ParseAddr(host) + if err != nil || addr.Is4() { + // a name or an IPv4 literal is used as-is + return []byte(host) + } + if addr.Is4In6() { + // IPv4-mapped IPv6 address. Hand-wired here to match with the spec. + // Go's netip renders these in mixed notation (::ffff:192.0.2.1) + // while spec wants ::ffff:c000:201 (hex groups only). + // + // Their 16 bytes are always shaped like this: + // + // bytes 0 .. 9 10, 11 12, 13 14, 15 + // 00 x 10 ff ff |<-- IPv4 4 bytes -->| + // groups g1..g5=0 g6=ffff g7 g8 + // + // so 192.0.2.1 (c0 00 02 01) gives g7=0xc000, g8=0x0201 -> "::ffff:c000:201". + b := addr.As16() + return fmt.Appendf( + nil, + "[::ffff:%x:%x]", + binary.BigEndian.Uint16(b[12:14]), // g7 + binary.BigEndian.Uint16(b[14:16]), // g8 + ) + } + return []byte("[" + addr.String() + "]") } // Spec limits for builder configuration payloads. @@ -171,9 +203,21 @@ func (be *BuilderEntry) Validate() error { if len(be.URL) > MaxBuilderURLSize { return errors.Errorf("url exceeds %d bytes", MaxBuilderURLSize) } - if u, err := url.Parse(be.URL); err != nil || u.Scheme == "" || u.Host == "" { + u, err := url.Parse(be.URL) + if err != nil || u.Scheme == "" || u.Host == "" { return errors.New("url is not a valid URL") } + + // Check whether hostname is empty. + host := u.Hostname() + if host == "" { + return errors.New("url is missing a hostname") + } + + // Check punycode: host must be ASCII. + if strings.IndexFunc(host, func(r rune) bool { return r > unicode.MaxASCII }) >= 0 { + return errors.New("url hostname must be ASCII; encode internationalized names as punycode") + } if len(be.Pubkeys) > MaxBuilderPubkeys { return errors.Errorf("builder_pubkeys exceeds %d keys", MaxBuilderPubkeys) } diff --git a/config/proposer/settings_test.go b/config/proposer/settings_test.go index 21d7d2e72f98..cdfe0bdee8b0 100644 --- a/config/proposer/settings_test.go +++ b/config/proposer/settings_test.go @@ -823,7 +823,7 @@ func TestSettings_TargetGasLimit_Schedule(t *testing.T) { func TestSettingFromConsensus(t *testing.T) { // Persisted payloads may predate url-required and (url, auth_data) uniqueness: // url-less entries drop, (url, auth) duplicates keep the first, and an omitted - // auth_data compares as its derived value (the url's UTF-8 bytes). + // auth_data compares as its derived value (the url's hostname). t.Run("dedups builders", func(t *testing.T) { payload := &validatorpb.ProposerSettingsPayload{ Version: SchemaV2, @@ -834,7 +834,7 @@ func TestSettingFromConsensus(t *testing.T) { {Url: "https://b.example", AuthData: []byte("second")}, {Url: "https://b.example", AuthData: []byte("first")}, {Url: "https://other.example"}, - {Url: "https://other.example", AuthData: []byte("https://other.example")}, + {Url: "https://other.example", AuthData: []byte("other.example")}, {AuthData: []byte("url-less")}, }, }, @@ -1233,3 +1233,61 @@ func TestUpgradeToV2_DropsBuilderContent(t *testing.T) { require.IsNil(t, ps.ProposeConfig[key].BuilderConfig) require.Equal(t, false, ps.UpgradeToV2()) } + +func TestBuilderEntry_EffectiveAuthData(t *testing.T) { + t.Run("derives the spec (builder-specs) default from the url hostname", func(t *testing.T) { + cases := map[string]string{ + "https://builder.example.com/": "builder.example.com", + "HTTPS://Builder.Example.com:443/bids?x=1": "builder.example.com", + "https://builder.example.com:8080": "builder.example.com", + "https://user:pw@builder.example.com/": "builder.example.com", + "https://10.0.0.5:18550/eth/v1/builder": "10.0.0.5", + "https://[0:0:0:0:0:0:0:1]:8443/": "[::1]", + "https://[::ffff:192.0.2.1]/": "[::ffff:c000:201]", + } + for u, want := range cases { + t.Run(u, func(t *testing.T) { + require.Equal(t, want, string((&BuilderEntry{URL: u}).EffectiveAuthData())) + }) + } + }) + + t.Run("explicit auth_data wins untouched", func(t *testing.T) { + be := &BuilderEntry{URL: "https://builder.example.com/", AuthData: []byte("custom")} + require.DeepEqual(t, []byte("custom"), be.EffectiveAuthData()) + }) +} + +func TestBuilderEntry_Validate_Hostname(t *testing.T) { + tests := []struct { + name string + url string + wantErr string + }{ + { + name: "no hostname", + url: "https://:8080", + wantErr: "url is missing a hostname", + }, + { + name: "non-ASCII hostname", + url: "https://bü.example", + wantErr: "must be ASCII", + }, + { + name: "valid punycode hostname", + url: "https://xn--b-eha.example", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := (&BuilderEntry{URL: tt.url}).Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, tt.wantErr, err) + }) + } +} diff --git a/validator/rpc/handlers_validator_config_test.go b/validator/rpc/handlers_validator_config_test.go index 311b22085c26..f8d130c0b050 100644 --- a/validator/rpc/handlers_validator_config_test.go +++ b/validator/rpc/handlers_validator_config_test.go @@ -257,7 +257,7 @@ func TestServer_SetBuilderConfig(t *testing.T) { "entry without url": {`{"builders":[{"min_bid":"1"}]}`, "url is required"}, "pubkey-only entry": {`{"builders":[{"builder_pubkeys":["` + bpk + `"]}]}`, "url is required"}, "same url and auth_data": {`{"builders":[{"url":"https://a"},{"url":"https://a"}]}`, "share the same url and auth_data"}, - "omitted auth_data collides with its derived value": {`{"builders":[{"url":"https://a"},{"url":"https://a","auth_data":"` + hexutil.Encode([]byte("https://a")) + `"}]}`, "share the same url and auth_data"}, + "omitted auth_data collides with its derived value": {`{"builders":[{"url":"https://a"},{"url":"https://a","auth_data":"` + hexutil.Encode([]byte("a")) + `"}]}`, "share the same url and auth_data"}, "invalid url": {`{"builders":[{"url":"not a url"}]}`, "url is not a valid URL"}, "url too long": {`{"builders":[{"url":"` + longURL + `"}]}`, "url exceeds 2048 bytes"}, "invalid builder_pubkeys entry": {`{"builders":[{"url":"https://a","builder_pubkeys":["0x1234"]}]}`, "builder_pubkeys contains an invalid BLS public key"}, @@ -309,7 +309,7 @@ func TestServer_SetBuilderConfig(t *testing.T) { } func TestServer_GetBuilderConfig(t *testing.T) { - // GET is fully resolved: omitted auth_data becomes the url's UTF-8 bytes, and + // GET is fully resolved: omitted auth_data becomes the url's hostname, and // unset values become the runtime fallbacks (no floor, neutral boost, trustless-only). t.Run("nil proposer settings resolve to runtime defaults", func(t *testing.T) { srv, keys := setupConfigServer(t, 1) @@ -333,7 +333,7 @@ func TestServer_GetBuilderConfig(t *testing.T) { require.Equal(t, "0", *cfg.MinBid) require.Equal(t, "100", *cfg.BuilderBoostFactor) require.Equal(t, 1, len(cfg.Builders)) - require.Equal(t, hexutil.Encode([]byte("https://a.example")), *cfg.Builders[0].AuthData) + require.Equal(t, hexutil.Encode([]byte("a.example")), *cfg.Builders[0].AuthData) require.Equal(t, "0", *cfg.Builders[0].MinBid) require.Equal(t, "100", *cfg.Builders[0].BuilderBoostFactor) require.Equal(t, "0", *cfg.Builders[0].MaxExecutionPayment) diff --git a/validator/rpc/structs.go b/validator/rpc/structs.go index 180c57962e57..39c632d2087b 100644 --- a/validator/rpc/structs.go +++ b/validator/rpc/structs.go @@ -188,7 +188,8 @@ func (in *BuilderConfig) ToConsensus() (*proposer.BuilderConfig, error) { return nil, errors.Errorf("builders exceeds %d entries", proposer.MaxBuilderEntries) } // Non-nil (possibly empty) list means "use exactly these builders", not "inherit". - // Omitted auth_data compares as its derived value, so it collides with the explicit form. + // Omitted auth_data compares as its derived value (the url's hostname), so it + // collides with the explicit form. bc.Builders = make([]*proposer.BuilderEntry, 0, len(in.Builders)) seen := make(map[proposer.EntryIdentity]bool, len(in.Builders)) for i, entry := range in.Builders {