Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/syjn99_builder-auth-data-hostname.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 48 additions & 4 deletions config/proposer/settings.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should this be some kind of helper?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Maybe not, I'm not sure whether we will use this logic in other places.

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.
Expand All @@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should it include the url?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Hmm logging the reasons for this validation is the only consumer of this error message, and I think we should consider to redact the log when we include URL. So I believe we don't need it right now.

}
if len(be.Pubkeys) > MaxBuilderPubkeys {
return errors.Errorf("builder_pubkeys exceeds %d keys", MaxBuilderPubkeys)
}
Expand Down
62 changes: 60 additions & 2 deletions config/proposer/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")},
},
},
Expand Down Expand Up @@ -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)
})
}
}
6 changes: 3 additions & 3 deletions validator/rpc/handlers_validator_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion validator/rpc/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading