From 6c1dee6b067a449d299259f96fd11351afadb06d Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Tue, 26 May 2026 11:55:29 +0530 Subject: [PATCH 01/22] add support to verify request auth --- go.mod | 4 +- go.sum | 32 +----- pkg/builderapi/gloas/auth.go | 62 ++++++++++ pkg/builderapi/gloas/auth_test.go | 181 ++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 32 deletions(-) create mode 100644 pkg/builderapi/gloas/auth.go create mode 100644 pkg/builderapi/gloas/auth_test.go diff --git a/go.mod b/go.mod index 331b382b..97a0fd56 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,6 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/emicklei/dot v1.6.2 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect - github.com/fatih/color v1.16.0 // indirect github.com/ferranbt/fastssz v0.1.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -104,8 +103,9 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.40.0 // indirect - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) + +replace github.com/ethpandaops/go-eth2-client => github.com/bharath-123/go-eth2-client v0.0.0-20260526061017-3669047a9bd8 diff --git a/go.sum b/go.sum index 6cdac05d..b0d912f3 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMG github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bharath-123/go-eth2-client v0.0.0-20260526061017-3669047a9bd8 h1:0e0RyemTxgbk4vtwTwArgL8B8Dmx49xTY/9GvTn2+Go= +github.com/bharath-123/go-eth2-client v0.0.0-20260526061017-3669047a9bd8/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= @@ -61,15 +63,8 @@ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJ github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.3-0.20260425183644-2249bebf5e88 h1:sBEzWvAO5Wy6m5+XA0vghTunDS8zgQ6bvjrVXPlCDUM= github.com/ethereum/go-ethereum v1.17.3-0.20260425183644-2249bebf5e88/go.mod h1:X4hqPRObAqgvkwPFWZ7X0tI80JdoWbO9yohCS7w8f9c= -github.com/ethpandaops/go-eth2-client v0.1.2-0.20260501105559-03ed5eacf583 h1:hELKAXI1fVVhrsm61rgNKtKHynxSlnHcpCXXF8mS4Yc= -github.com/ethpandaops/go-eth2-client v0.1.2-0.20260501105559-03ed5eacf583/go.mod h1:qoj9ZVKydI99IuIyWt4EvGq4rGgMYfOy6q8zTtwYdy4= -github.com/ethpandaops/go-eth2-client v0.1.3-0.20260521102012-54a5ac9554c5 h1:c8ml/jzxE6UNmfwIRyiXWT89aN9TYDFcBK5rC7ynOPc= -github.com/ethpandaops/go-eth2-client v0.1.3-0.20260521102012-54a5ac9554c5/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= github.com/ethpandaops/service-authenticatoor v0.0.0-20260430215651-f6b6f69d6711 h1:3z0D0oX/tY5HntWyAMxFhHTxNabqPz6cDpEuyd6N2MY= github.com/ethpandaops/service-authenticatoor v0.0.0-20260430215651-f6b6f69d6711/go.mod h1:+C6sQMBxY2E3u6BITSZakQrGRovNnB1CamuQh/RIaXI= -github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -94,17 +89,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= -github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-yaml v1.9.2 h1:2Njwzw+0+pjU2gb805ZC1B/uBuAs2VcZ3K+ZgHwDs7w= -github.com/goccy/go-yaml v1.9.2/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= @@ -168,14 +154,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= -github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= -github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= @@ -200,8 +182,6 @@ github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQp github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pk910/dynamic-ssz v1.3.1 h1:S/no7kRA5FSORmfybG4Cs49CjPgP94fePKPxt8uKkdI= -github.com/pk910/dynamic-ssz v1.3.1/go.mod h1:ARK5qDyrJ/MHpaZHGJYvCKElvaMYTE9pXOQbvPDeE0U= github.com/pk910/dynamic-ssz v1.3.2-0.20260505131440-111bcb265c8f h1:k1Dn2h+msg+T0c25hoiJ739ckCNt1dqKErLylois7ss= github.com/pk910/dynamic-ssz v1.3.2-0.20260505131440-111bcb265c8f/go.mod h1:ARK5qDyrJ/MHpaZHGJYvCKElvaMYTE9pXOQbvPDeE0U= github.com/pk910/hashtree-bindings v0.1.0 h1:w7NyRWFi2OaYEFvo9ADcE/QU6PMuVLl3hBgx92KiH9c= @@ -301,7 +281,6 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= @@ -313,7 +292,6 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -331,10 +309,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -355,7 +330,6 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -374,8 +348,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= diff --git a/pkg/builderapi/gloas/auth.go b/pkg/builderapi/gloas/auth.go new file mode 100644 index 00000000..40886108 --- /dev/null +++ b/pkg/builderapi/gloas/auth.go @@ -0,0 +1,62 @@ +// Package gloas implements the Gloas-fork Builder API handlers and helpers. +// See https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/specs/gloas/builder.md +package gloas + +import ( + "errors" + "fmt" + + eth2gloas "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + + "github.com/ethpandaops/buildoor/pkg/signer" +) + +// DomainRequestAuth is the DomainType used to sign SignedRequestAuth messages. +// Defined in builder-specs as DOMAIN_REQUEST_AUTH = DomainType('0x0B000001'). +var DomainRequestAuth = phase0.DomainType{0x0B, 0x00, 0x00, 0x01} + +var ( + // ErrNilSignedRequestAuth is returned when the SignedRequestAuth wrapper is nil. + ErrNilSignedRequestAuth = errors.New("signed request auth is nil") + // ErrNilRequestAuthMessage is returned when the inner RequestAuth message is nil. + ErrNilRequestAuthMessage = errors.New("request auth message is nil") + // ErrInvalidRequestAuthSignature is returned when BLS verification fails. + ErrInvalidRequestAuthSignature = errors.New("invalid request auth signature") +) + +// VerifyRequestAuth verifies the BLS signature on a SignedRequestAuth against +// the supplied validator public key. +// +// Per the Gloas builder-specs validator.md, the signing domain is +// compute_domain(DOMAIN_REQUEST_AUTH), which defaults to +// (GENESIS_FORK_VERSION, zero genesis_validators_root). Callers pass the +// chain's genesis fork version; the genesis_validators_root is always zero per +// spec. +// +// Returns nil on success, or one of the package's sentinel errors on failure. +func VerifyRequestAuth( + signed *eth2gloas.SignedRequestAuth, + validatorPubkey phase0.BLSPubKey, + genesisForkVersion phase0.Version, +) error { + if signed == nil { + return ErrNilSignedRequestAuth + } + if signed.Message == nil { + return ErrNilRequestAuthMessage + } + + msgRoot, err := signed.Message.HashTreeRoot() + if err != nil { + return fmt.Errorf("failed to compute request auth hash tree root: %w", err) + } + + domain := signer.ComputeDomain(DomainRequestAuth, genesisForkVersion, phase0.Root{}) + signingRoot := signer.ComputeSigningRoot(msgRoot, domain) + + if !signer.VerifyBLSSignature(validatorPubkey, signingRoot[:], signed.Signature) { + return ErrInvalidRequestAuthSignature + } + return nil +} diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go new file mode 100644 index 00000000..f8c1764f --- /dev/null +++ b/pkg/builderapi/gloas/auth_test.go @@ -0,0 +1,181 @@ +package gloas_test + +import ( + "strings" + "testing" + + eth2gloas "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" + "github.com/ethpandaops/buildoor/pkg/signer" +) + +const ( + validatorPrivkeyHex = "1111111111111111111111111111111111111111111111111111111111111111" + otherPrivkeyHex = "2222222222222222222222222222222222222222222222222222222222222222" + builderPrivkeyHex = "3333333333333333333333333333333333333333333333333333333333333333" +) + +// signRequestAuth builds and BLS-signs a RequestAuth with the given signer using +// DomainRequestAuth at the supplied genesis fork version (GVR=zero per spec). +func signRequestAuth( + t *testing.T, + s *signer.BLSSigner, + builderPubkey phase0.BLSPubKey, + slot phase0.Slot, + genesisForkVersion phase0.Version, +) *eth2gloas.SignedRequestAuth { + t.Helper() + + msg := ð2gloas.RequestAuth{ + BuilderPubkey: builderPubkey, + Slot: slot, + } + + root, err := msg.HashTreeRoot() + require.NoError(t, err) + + domain := signer.ComputeDomain(gloas.DomainRequestAuth, genesisForkVersion, phase0.Root{}) + + sig, err := s.SignWithDomain(root, domain) + require.NoError(t, err) + + return ð2gloas.SignedRequestAuth{ + Message: msg, + Signature: sig, + } +} + +func TestDomainRequestAuthValue(t *testing.T) { + // Spec: DOMAIN_REQUEST_AUTH = DomainType('0x0B000001') + require.Equal(t, phase0.DomainType{0x0B, 0x00, 0x00, 0x01}, gloas.DomainRequestAuth) +} + +func TestVerifyRequestAuth_RoundTrip(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + builder, err := signer.NewBLSSigner(builderPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} // mainnet-style zero version + + signed := signRequestAuth(t, validator, builder.PublicKey(), 1234, genesisForkVersion) + + require.NoError(t, gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion)) +} + +func TestVerifyRequestAuth_WrongValidatorPubkey(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + other, err := signer.NewBLSSigner(otherPrivkeyHex) + require.NoError(t, err) + + builder, err := signer.NewBLSSigner(builderPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + signed := signRequestAuth(t, validator, builder.PublicKey(), 42, genesisForkVersion) + + err = gloas.VerifyRequestAuth(signed, other.PublicKey(), genesisForkVersion) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_TamperedSlot(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + builder, err := signer.NewBLSSigner(builderPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + signed := signRequestAuth(t, validator, builder.PublicKey(), 100, genesisForkVersion) + signed.Message.Slot = 101 // tamper + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_TamperedBuilderPubkey(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + builder, err := signer.NewBLSSigner(builderPrivkeyHex) + require.NoError(t, err) + + other, err := signer.NewBLSSigner(otherPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + signed := signRequestAuth(t, validator, builder.PublicKey(), 7, genesisForkVersion) + signed.Message.BuilderPubkey = other.PublicKey() // tamper + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_WrongGenesisForkVersion(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + builder, err := signer.NewBLSSigner(builderPrivkeyHex) + require.NoError(t, err) + + signingFork := phase0.Version{0x00, 0x00, 0x00, 0x00} + verifyFork := phase0.Version{0x90, 0x00, 0x00, 0x69} // e.g. Sepolia-style + + signed := signRequestAuth(t, validator, builder.PublicKey(), 7, signingFork) + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), verifyFork) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) +} + +func TestVerifyRequestAuth_NilSigned(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + err = gloas.VerifyRequestAuth(nil, validator.PublicKey(), phase0.Version{}) + require.ErrorIs(t, err, gloas.ErrNilSignedRequestAuth) +} + +func TestVerifyRequestAuth_NilMessage(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + signed := ð2gloas.SignedRequestAuth{ + Message: nil, + Signature: phase0.BLSSignature{}, + } + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) + require.ErrorIs(t, err, gloas.ErrNilRequestAuthMessage) +} + +func TestVerifyRequestAuth_GarbageSignature(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + builder, err := signer.NewBLSSigner(builderPrivkeyHex) + require.NoError(t, err) + + signed := ð2gloas.SignedRequestAuth{ + Message: ð2gloas.RequestAuth{ + BuilderPubkey: builder.PublicKey(), + Slot: 1, + }, + // All-zero signature is not a valid BLS signature. + Signature: phase0.BLSSignature{}, + } + + err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) + require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) + + // Sanity check: the error message hasn't drifted (loosely). + require.True(t, strings.Contains(err.Error(), "signature")) +} From 417e16d114f4c92ba3df7f37fa87f0f39cc9b3f5 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Tue, 26 May 2026 13:05:14 +0530 Subject: [PATCH 02/22] initial builder api impl --- cmd/run.go | 7 + pkg/builderapi/server.go | 399 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 394 insertions(+), 12 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index 11862c71..fa267114 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -259,6 +259,7 @@ and begins building blocks according to configuration.`, builderAPISrv = builderapi.NewServer(&cfg.BuilderAPI, logger, builderSvc, blsSigner, validatorStore, genesisForkVersion, forkVersion, genesisValidatorsRoot) builderAPISrv.SetFuluPublisher(clClient) + builderAPISrv.SetCLClient(clClient) builderAPISrv.SetEnabled(cfg.BuilderAPIEnabled) if err := builderAPISrv.Start(ctx); err != nil { return fmt.Errorf("failed to start Builder API server: %w", err) @@ -272,6 +273,9 @@ and begins building blocks according to configuration.`, if epbsAvailable { propPrefSvc = proposerpreferences.NewService(clClient, logger) builderSvc.SetProposerPreferencesCache(propPrefSvc.GetCache()) + if builderAPISrv != nil { + builderAPISrv.SetProposerPreferencesCache(propPrefSvc.GetCache()) + } } // Initialize and start validator ranges resolver. @@ -312,6 +316,9 @@ and begins building blocks according to configuration.`, }) lifecycleMgr.SetRegistrationCallback(func(index uint64) { epbsSvc.SetBuilderRegistered(index) + if builderAPISrv != nil { + builderAPISrv.SetBuilderIndex(index) + } }) } diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 2c674cc4..b139057b 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -21,6 +21,9 @@ import ( apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" apiv1fulu "github.com/ethpandaops/go-eth2-client/api/v1/fulu" + "github.com/ethpandaops/go-eth2-client/spec/deneb" + "github.com/ethpandaops/go-eth2-client/spec/electra" + "github.com/ethpandaops/go-eth2-client/spec/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/gorilla/mux" "github.com/sirupsen/logrus" @@ -29,9 +32,15 @@ import ( "github.com/ethpandaops/buildoor/pkg/builderapi/fulu" "github.com/ethpandaops/buildoor/pkg/builderapi/validators" "github.com/ethpandaops/buildoor/pkg/config" + "github.com/ethpandaops/buildoor/pkg/proposerpreferences" + "github.com/ethpandaops/buildoor/pkg/rpc/beacon" "github.com/ethpandaops/buildoor/pkg/signer" ) +// domainBeaconBuilder is DOMAIN_BEACON_BUILDER from the Gloas consensus spec, +// used to sign ExecutionPayloadBid and ExecutionPayloadEnvelope messages. +var domainBeaconBuilder = phase0.DomainType{0x0B, 0x00, 0x00, 0x00} + // PayloadCacheProvider provides access to the payload cache (e.g. *builder.Service). // Used so tests can inject a mock without full builder deps. type PayloadCacheProvider interface { @@ -66,18 +75,21 @@ type Server struct { log *logrus.Logger server *http.Server router *mux.Router - builderSvc PayloadCacheProvider // optional: for buildoor debug APIs and Fulu getHeader/submitBlindedBlockV2 - validatorsStore *validators.Store // in-memory validator registrations - blsSigner *signer.BLSSigner // optional: for signing Fulu builder bids (getHeader) - fuluPublisher FuluBlockPublisher // optional: for publishing unblinded blocks (submitBlindedBlockV2) - eventBroadcaster EventBroadcaster // optional: for broadcasting API events to WebUI - bidsWonStore *BidsWonStore // in-memory store of successfully delivered blocks - enabled atomic.Bool // runtime toggle for enabling/disabling the builder API - headersRequested atomic.Uint64 // count of getHeader requests received - blocksPublished atomic.Uint64 // count of successfully published blocks - genesisForkVersion phase0.Version // genesis fork version for builder domain (mev-boost-relay style) - forkVersion phase0.Version // current fork version for chain-specific verification - genesisValidatorsRoot phase0.Root // genesis validators root for chain-specific verification + builderSvc PayloadCacheProvider // optional: for buildoor debug APIs and Fulu getHeader/submitBlindedBlockV2 + validatorsStore *validators.Store // in-memory validator registrations + blsSigner *signer.BLSSigner // optional: for signing Fulu builder bids (getHeader) + fuluPublisher FuluBlockPublisher // optional: for publishing unblinded blocks (submitBlindedBlockV2) + clClient *beacon.Client // optional: beacon client used to publish Gloas execution payload envelopes + eventBroadcaster EventBroadcaster // optional: for broadcasting API events to WebUI + bidsWonStore *BidsWonStore // in-memory store of successfully delivered blocks + propPrefsCache *proposerpreferences.Cache // optional: per-slot proposer preferences for Gloas bid construction + builderIndex atomic.Uint64 // builder index used in Gloas bids; set after lifecycle registration + enabled atomic.Bool // runtime toggle for enabling/disabling the builder API + headersRequested atomic.Uint64 // count of getHeader requests received + blocksPublished atomic.Uint64 // count of successfully published blocks + genesisForkVersion phase0.Version // genesis fork version for builder domain (mev-boost-relay style) + forkVersion phase0.Version // current fork version for chain-specific verification + genesisValidatorsRoot phase0.Root // genesis validators root for chain-specific verification } // NewServer creates a new server. builderSvc may be nil; if set, buildoor-specific @@ -124,11 +136,29 @@ func (s *Server) SetFuluPublisher(p FuluBlockPublisher) { s.fuluPublisher = p } +// SetCLClient wires the beacon client used to publish Gloas execution payload +// envelopes after a SignedBeaconBlock is submitted via the Builder API. +func (s *Server) SetCLClient(c *beacon.Client) { + s.clClient = c +} + // SetEventBroadcaster sets the optional event broadcaster for WebUI events. func (s *Server) SetEventBroadcaster(b EventBroadcaster) { s.eventBroadcaster = b } +// SetProposerPreferencesCache wires the proposer preferences cache used to +// resolve fee recipient and gas limit when building Gloas execution payload bids. +func (s *Server) SetProposerPreferencesCache(cache *proposerpreferences.Cache) { + s.propPrefsCache = cache +} + +// SetBuilderIndex sets the on-chain builder index inserted into Gloas bids. +// Called from the lifecycle manager once registration is observed. +func (s *Server) SetBuilderIndex(index uint64) { + s.builderIndex.Store(index) +} + // GetBidsWonStore returns the bids won store. func (s *Server) GetBidsWonStore() *BidsWonStore { return s.bidsWonStore @@ -161,6 +191,15 @@ func (s *Server) registerRoutes() { builderAPIv2 := s.router.PathPrefix("/eth/v2/builder").Subrouter() builderAPIv2.HandleFunc("/blinded_blocks", s.handleSubmitBlindedBlockV2).Methods(http.MethodPost) + // --- Builder API (Gloas) --- + // https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/apis/builder/execution_payload_bid.yaml + builderAPI.HandleFunc( + "/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}", + s.handleGetExecutionPayloadBid, + ).Methods(http.MethodPost) + // https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/apis/builder/beacon_block.yaml + builderAPI.HandleFunc("/beacon_block", s.handleSubmitSignedBeaconBlock).Methods(http.MethodPost) + // --- Buildoor API (debug / tooling) --- buildoorAPI := s.router.PathPrefix("/buildoor/v1").Subrouter() buildoorAPI.HandleFunc("/payloads/{slot}", s.handleGetPayloadBySlot).Methods(http.MethodGet) @@ -452,6 +491,342 @@ func (s *Server) handleGetHeader(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } +// GetExecutionPayloadBidResponse is the JSON envelope returned by +// POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}. +type GetExecutionPayloadBidResponse struct { + Version string `json:"version"` + Data *gloas.SignedExecutionPayloadBid `json:"data"` +} + +// handleGetExecutionPayloadBid handles +// POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}. +// +// Looks up the cached payload for the requested slot, validates the supplied +// parent_hash and parent_root against it, then constructs and signs a Gloas +// SignedExecutionPayloadBid using the proposer's fee recipient from the +// ProposerPreferences cache. Returns 204 if no payload is cached, 400 if the +// inputs do not match the cached payload or proposer preferences are missing. +// +// Note: RequestAuth verification and BuilderPreferences (max_execution_payment) +// are not yet wired — both value and execution_payment are set to zero. +func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Request) { + log := s.log.WithField("path", "/eth/v1/builder/execution_payload_bid/...") + + if !s.enabled.Load() || s.builderSvc == nil || s.blsSigner == nil { + log.Warn("getExecutionPayloadBid: returning 204 — builder service disabled or signer/payload cache unavailable") + w.WriteHeader(http.StatusNoContent) + return + } + + if s.propPrefsCache == nil { + log.Warn("getExecutionPayloadBid: proposer preferences cache not configured") + writeValidatorError(w, http.StatusInternalServerError, "proposer preferences cache not configured") + return + } + + vars := mux.Vars(r) + slotStr := vars["slot"] + parentHashStr := vars["parent_hash"] + parentRootStr := vars["parent_root"] + proposerPubkeyStr := vars["proposer_pubkey"] + + log = log.WithFields(logrus.Fields{ + "slot": slotStr, + "parent_hash": parentHashStr, + "parent_root": parentRootStr, + "proposer_pubkey": proposerPubkeyStr, + }) + log.Debug("getExecutionPayloadBid request received") + + slotU64, err := strconv.ParseUint(slotStr, 10, 64) + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: invalid slot") + writeValidatorError(w, http.StatusBadRequest, "invalid slot: must be a number") + return + } + slot := phase0.Slot(slotU64) + + parentHashBytes, err := hex.DecodeString(trimHex(parentHashStr)) + if err != nil || len(parentHashBytes) != 32 { + log.WithError(err).Warn("getExecutionPayloadBid: invalid parent_hash") + writeValidatorError(w, http.StatusBadRequest, "invalid parent_hash: must be 32 bytes hex") + return + } + var parentHash phase0.Hash32 + copy(parentHash[:], parentHashBytes) + + parentRootBytes, err := hex.DecodeString(trimHex(parentRootStr)) + if err != nil || len(parentRootBytes) != 32 { + log.WithError(err).Warn("getExecutionPayloadBid: invalid parent_root") + writeValidatorError(w, http.StatusBadRequest, "invalid parent_root: must be 32 bytes hex") + return + } + var parentRoot phase0.Root + copy(parentRoot[:], parentRootBytes) + + event := s.builderSvc.GetPayloadCache().Get(slot) + if event == nil { + log.Info("getExecutionPayloadBid: returning 204 — no cached payload for slot") + w.WriteHeader(http.StatusNoContent) + return + } + + if event.ParentBlockHash != parentHash { + log.WithFields(logrus.Fields{ + "request_parent_hash": "0x" + hex.EncodeToString(parentHash[:]), + "cached_parent_hash": "0x" + hex.EncodeToString(event.ParentBlockHash[:]), + }).Info("getExecutionPayloadBid: 400 — parent_hash does not match cached payload") + writeValidatorError(w, http.StatusBadRequest, "parent_hash does not match cached payload") + return + } + + if event.ParentBlockRoot != parentRoot { + log.WithFields(logrus.Fields{ + "request_parent_root": "0x" + hex.EncodeToString(parentRoot[:]), + "cached_parent_root": "0x" + hex.EncodeToString(event.ParentBlockRoot[:]), + }).Info("getExecutionPayloadBid: 400 — parent_root does not match cached payload") + writeValidatorError(w, http.StatusBadRequest, "parent_root does not match cached payload") + return + } + + signedPrefs, ok := s.propPrefsCache.Get(slot) + if !ok || signedPrefs == nil || signedPrefs.Message == nil { + log.Info("getExecutionPayloadBid: 400 — no proposer preferences cached for slot") + writeValidatorError(w, http.StatusBadRequest, "no proposer preferences cached for slot") + return + } + prefs := signedPrefs.Message + + execRequests := &electra.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + } + if len(event.ExecutionRequests) > 0 { + parsed, parseErr := fulu.ParseExecutionRequests(event.ExecutionRequests) + if parseErr != nil { + log.WithError(parseErr).Warn("getExecutionPayloadBid: failed to parse execution requests") + writeValidatorError(w, http.StatusInternalServerError, "failed to parse execution requests") + return + } + execRequests = parsed + } + execRequestsRoot, err := execRequests.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: failed to compute execution requests root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute execution requests root") + return + } + + bid := &gloas.ExecutionPayloadBid{ + ParentBlockHash: event.ParentBlockHash, + ParentBlockRoot: event.ParentBlockRoot, + BlockHash: event.BlockHash, + PrevRandao: event.PrevRandao, + FeeRecipient: prefs.FeeRecipient, + GasLimit: event.GasLimit, + BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), + Slot: slot, + Value: 0, + ExecutionPayment: 0, + BlobKZGCommitments: []deneb.KZGCommitment{}, + ExecutionRequestsRoot: execRequestsRoot, + } + if event.BlobsBundle != nil { + bid.BlobKZGCommitments = make([]deneb.KZGCommitment, len(event.BlobsBundle.Commitments)) + for i, c := range event.BlobsBundle.Commitments { + copy(bid.BlobKZGCommitments[i][:], c) + } + } + + bidRoot, err := bid.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: failed to compute bid hash tree root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute bid root") + return + } + var root phase0.Root + copy(root[:], bidRoot[:]) + + domain := signer.ComputeDomain(domainBeaconBuilder, s.forkVersion, s.genesisValidatorsRoot) + sig, err := s.blsSigner.SignWithDomain(root, domain) + if err != nil { + log.WithError(err).Warn("getExecutionPayloadBid: failed to sign bid") + writeValidatorError(w, http.StatusInternalServerError, "failed to sign bid") + return + } + + signedBid := &gloas.SignedExecutionPayloadBid{ + Message: bid, + Signature: sig, + } + + log.WithFields(logrus.Fields{ + "block_hash": "0x" + hex.EncodeToString(event.BlockHash[:]), + "builder_index": bid.BuilderIndex, + "fee_recipient": prefs.FeeRecipient.String(), + "gas_limit": bid.GasLimit, + }).Info("getExecutionPayloadBid: delivered Gloas SignedExecutionPayloadBid") + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Eth-Consensus-Version", "gloas") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(GetExecutionPayloadBidResponse{ + Version: "gloas", + Data: signedBid, + }) +} + +// handleSubmitSignedBeaconBlock handles POST /eth/v1/builder/beacon_block. +// +// The proposer submits a full Gloas SignedBeaconBlock that binds them to the +// builder's bid. If the builder still holds the payload referenced by the +// bid's block_hash, it constructs the corresponding SignedExecutionPayloadEnvelope +// and publishes it (along with blobs / KZG cell proofs) to the beacon node. +// +// Returns 202 on success, 400 on a malformed block or missing payload, +// 415 on wrong Content-Type, 500 on internal errors, and 503 if the server is +// not fully configured. +func (s *Server) handleSubmitSignedBeaconBlock(w http.ResponseWriter, r *http.Request) { + log := s.log.WithField("path", "/eth/v1/builder/beacon_block") + + if !s.enabled.Load() || s.builderSvc == nil || s.blsSigner == nil || s.clClient == nil { + log.Warn("submitSignedBeaconBlock: 503 — builder not fully configured (disabled, payload cache, signer, or CL client missing)") + writeValidatorError(w, http.StatusServiceUnavailable, "builder not ready") + return + } + + if r.Header.Get("Content-Type") != "application/json" { + log.WithField("content_type", r.Header.Get("Content-Type")).Warn("submitSignedBeaconBlock: rejected — Content-Type must be application/json") + writeValidatorError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to read body") + writeValidatorError(w, http.StatusBadRequest, "failed to read body") + return + } + + var block gloas.SignedBeaconBlock + if err := json.Unmarshal(body, &block); err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: invalid JSON body") + writeValidatorError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if block.Message == nil || block.Message.Body == nil || + block.Message.Body.SignedExecutionPayloadBid == nil || + block.Message.Body.SignedExecutionPayloadBid.Message == nil { + log.Warn("submitSignedBeaconBlock: missing signed_execution_payload_bid in block body") + writeValidatorError(w, http.StatusBadRequest, "missing signed_execution_payload_bid in block body") + return + } + + bid := block.Message.Body.SignedExecutionPayloadBid.Message + blockHashHex := "0x" + hex.EncodeToString(bid.BlockHash[:]) + log = log.WithFields(logrus.Fields{ + "slot": bid.Slot, + "block_hash": blockHashHex, + }) + log.Debug("submitSignedBeaconBlock request received") + + event := s.builderSvc.GetPayloadCache().GetByBlockHash(bid.BlockHash) + if event == nil { + log.Info("submitSignedBeaconBlock: 400 — no cached payload for bid block hash") + writeValidatorError(w, http.StatusBadRequest, "no cached payload for bid block hash") + return + } + + gloasPayload, err := fulu.ExecutionPayloadToGloas(event.Payload) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to convert payload to gloas format") + writeValidatorError(w, http.StatusInternalServerError, "failed to convert payload") + return + } + + execRequests := &electra.ExecutionRequests{ + Deposits: []*electra.DepositRequest{}, + Withdrawals: []*electra.WithdrawalRequest{}, + Consolidations: []*electra.ConsolidationRequest{}, + } + if len(event.ExecutionRequests) > 0 { + parsed, parseErr := fulu.ParseExecutionRequests(event.ExecutionRequests) + if parseErr != nil { + log.WithError(parseErr).Warn("submitSignedBeaconBlock: failed to parse execution requests") + writeValidatorError(w, http.StatusInternalServerError, "failed to parse execution requests") + return + } + execRequests = parsed + } + + beaconBlockRoot, err := block.Message.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to compute beacon block hash tree root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute beacon block root") + return + } + var blockRoot phase0.Root + copy(blockRoot[:], beaconBlockRoot[:]) + + envelope := &gloas.ExecutionPayloadEnvelope{ + Payload: gloasPayload, + ExecutionRequests: execRequests, + BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), + BeaconBlockRoot: blockRoot, + ParentBeaconBlockRoot: block.Message.ParentRoot, + } + + envelopeRoot, err := envelope.HashTreeRoot() + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to compute envelope hash tree root") + writeValidatorError(w, http.StatusInternalServerError, "failed to compute envelope root") + return + } + var root phase0.Root + copy(root[:], envelopeRoot[:]) + + domain := signer.ComputeDomain(domainBeaconBuilder, s.forkVersion, s.genesisValidatorsRoot) + sig, err := s.blsSigner.SignWithDomain(root, domain) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to sign envelope") + writeValidatorError(w, http.StatusInternalServerError, "failed to sign envelope") + return + } + + signedEnvelope := &gloas.SignedExecutionPayloadEnvelope{ + Message: envelope, + Signature: sig, + } + + envelopeJSON, err := json.Marshal(signedEnvelope) + if err != nil { + log.WithError(err).Warn("submitSignedBeaconBlock: failed to marshal signed envelope") + writeValidatorError(w, http.StatusInternalServerError, "failed to marshal envelope") + return + } + + var blobs, kzgProofs [][]byte + if event.BlobsBundle != nil && len(event.BlobsBundle.Blobs) > 0 { + blobs = event.BlobsBundle.Blobs + kzgProofs = event.BlobsBundle.Proofs + } + + if err := s.clClient.SubmitExecutionPayloadEnvelope(r.Context(), envelopeJSON, blobs, kzgProofs); err != nil { + log.WithError(err).Error("submitSignedBeaconBlock: failed to publish execution payload envelope") + writeValidatorError(w, http.StatusInternalServerError, "failed to publish envelope: "+err.Error()) + return + } + + log.WithFields(logrus.Fields{ + "beacon_block_root": "0x" + hex.EncodeToString(blockRoot[:]), + "blobs": len(blobs), + }).Info("submitSignedBeaconBlock: published execution payload envelope") + + w.WriteHeader(http.StatusAccepted) +} + func trimHex(s string) string { if len(s) >= 2 && (s[0:2] == "0x" || s[0:2] == "0X") { return s[2:] From fc4c76fba25acbbec1c4841add880cee40a6fc02 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Mon, 1 Jun 2026 12:56:02 +0530 Subject: [PATCH 03/22] builder-api impl work --- cmd/root.go | 6 ++-- cmd/run.go | 1 + go.mod | 2 +- go.sum | 4 +-- pkg/builderapi/gloas/auth_test.go | 49 ++++++++++--------------------- pkg/builderapi/server.go | 19 +++++++++++- pkg/chain/constants.go | 9 ++++++ pkg/config/default.go | 5 ++-- pkg/config/types.go | 4 +++ pkg/epbs/scheduler.go | 13 +++++--- pkg/epbs/service.go | 15 +++------- 11 files changed, 70 insertions(+), 57 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 3b39365e..a4fa8d31 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,6 +59,7 @@ func init() { rootCmd.PersistentFlags().Bool("builder-api-enabled", defaults.BuilderAPIEnabled, "Enable traditional Builder API at startup (requires --builder-api-port > 0)") rootCmd.PersistentFlags().Int("builder-api-port", defaults.BuilderAPI.Port, "Builder API HTTP port") rootCmd.PersistentFlags().Uint64("builder-api-subsidy", defaults.BuilderAPI.BlockValueSubsidyGwei, "Block value subsidy added to bids in Gwei") + rootCmd.PersistentFlags().Uint64("gloas-builder-api-subsidy", defaults.BuilderAPI.GloasBuilderApiSubsidy, "Gwei added to block value to form ExecutionPayment in Gloas Builder API bids") rootCmd.PersistentFlags().Uint64("deposit-amount", defaults.DepositAmount, "Builder deposit amount in Gwei") rootCmd.PersistentFlags().Uint64("topup-threshold", defaults.TopupThreshold, "Balance threshold for auto top-up in Gwei") rootCmd.PersistentFlags().Uint64("topup-amount", defaults.TopupAmount, "Amount to top-up in Gwei") @@ -157,8 +158,9 @@ func initConfig() error { EPBSEnabled: v.GetBool("epbs-enabled"), BuilderAPIEnabled: v.GetBool("builder-api-enabled"), BuilderAPI: builder.BuilderAPIConfig{ - Port: v.GetInt("builder-api-port"), - BlockValueSubsidyGwei: v.GetUint64("builder-api-subsidy"), + Port: v.GetInt("builder-api-port"), + BlockValueSubsidyGwei: v.GetUint64("builder-api-subsidy"), + GloasBuilderApiSubsidy: v.GetUint64("gloas-builder-api-subsidy"), }, DepositAmount: v.GetUint64("deposit-amount"), TopupThreshold: v.GetUint64("topup-threshold"), diff --git a/cmd/run.go b/cmd/run.go index fa267114..ecf26a8c 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -261,6 +261,7 @@ and begins building blocks according to configuration.`, builderAPISrv.SetFuluPublisher(clClient) builderAPISrv.SetCLClient(clClient) builderAPISrv.SetEnabled(cfg.BuilderAPIEnabled) + builderAPISrv.SetChainService(chainSvc) if err := builderAPISrv.Start(ctx); err != nil { return fmt.Errorf("failed to start Builder API server: %w", err) } diff --git a/go.mod b/go.mod index 97a0fd56..9c7bcb53 100644 --- a/go.mod +++ b/go.mod @@ -108,4 +108,4 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect ) -replace github.com/ethpandaops/go-eth2-client => github.com/bharath-123/go-eth2-client v0.0.0-20260526061017-3669047a9bd8 +replace github.com/ethpandaops/go-eth2-client => github.com/bharath-123/go-eth2-client v0.0.0-20260601061241-d632e50dbf2e diff --git a/go.sum b/go.sum index b0d912f3..c2e3b336 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMG github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bharath-123/go-eth2-client v0.0.0-20260526061017-3669047a9bd8 h1:0e0RyemTxgbk4vtwTwArgL8B8Dmx49xTY/9GvTn2+Go= -github.com/bharath-123/go-eth2-client v0.0.0-20260526061017-3669047a9bd8/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= +github.com/bharath-123/go-eth2-client v0.0.0-20260601061241-d632e50dbf2e h1:s7E7j1NTy0829CsLVVks0ItJYhdMlBCLR3RKy+rM87o= +github.com/bharath-123/go-eth2-client v0.0.0-20260601061241-d632e50dbf2e/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go index f8c1764f..6149d0b9 100644 --- a/pkg/builderapi/gloas/auth_test.go +++ b/pkg/builderapi/gloas/auth_test.go @@ -15,7 +15,9 @@ import ( const ( validatorPrivkeyHex = "1111111111111111111111111111111111111111111111111111111111111111" otherPrivkeyHex = "2222222222222222222222222222222222222222222222222222222222222222" - builderPrivkeyHex = "3333333333333333333333333333333333333333333333333333333333333333" + + testBuilderURL = "https://builder.example.com" + otherBuilderURL = "https://other-builder.example.com" ) // signRequestAuth builds and BLS-signs a RequestAuth with the given signer using @@ -23,15 +25,15 @@ const ( func signRequestAuth( t *testing.T, s *signer.BLSSigner, - builderPubkey phase0.BLSPubKey, + builderURL []byte, slot phase0.Slot, genesisForkVersion phase0.Version, ) *eth2gloas.SignedRequestAuth { t.Helper() msg := ð2gloas.RequestAuth{ - BuilderPubkey: builderPubkey, - Slot: slot, + BuilderURL: builderURL, + Slot: slot, } root, err := msg.HashTreeRoot() @@ -57,12 +59,9 @@ func TestVerifyRequestAuth_RoundTrip(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - builder, err := signer.NewBLSSigner(builderPrivkeyHex) - require.NoError(t, err) - genesisForkVersion := phase0.Version{} // mainnet-style zero version - signed := signRequestAuth(t, validator, builder.PublicKey(), 1234, genesisForkVersion) + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 1234, genesisForkVersion) require.NoError(t, gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion)) } @@ -74,12 +73,9 @@ func TestVerifyRequestAuth_WrongValidatorPubkey(t *testing.T) { other, err := signer.NewBLSSigner(otherPrivkeyHex) require.NoError(t, err) - builder, err := signer.NewBLSSigner(builderPrivkeyHex) - require.NoError(t, err) - genesisForkVersion := phase0.Version{} - signed := signRequestAuth(t, validator, builder.PublicKey(), 42, genesisForkVersion) + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 42, genesisForkVersion) err = gloas.VerifyRequestAuth(signed, other.PublicKey(), genesisForkVersion) require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) @@ -89,32 +85,23 @@ func TestVerifyRequestAuth_TamperedSlot(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - builder, err := signer.NewBLSSigner(builderPrivkeyHex) - require.NoError(t, err) - genesisForkVersion := phase0.Version{} - signed := signRequestAuth(t, validator, builder.PublicKey(), 100, genesisForkVersion) + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 100, genesisForkVersion) signed.Message.Slot = 101 // tamper err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion) require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) } -func TestVerifyRequestAuth_TamperedBuilderPubkey(t *testing.T) { +func TestVerifyRequestAuth_TamperedBuilderURL(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - builder, err := signer.NewBLSSigner(builderPrivkeyHex) - require.NoError(t, err) - - other, err := signer.NewBLSSigner(otherPrivkeyHex) - require.NoError(t, err) - genesisForkVersion := phase0.Version{} - signed := signRequestAuth(t, validator, builder.PublicKey(), 7, genesisForkVersion) - signed.Message.BuilderPubkey = other.PublicKey() // tamper + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 7, genesisForkVersion) + signed.Message.BuilderURL = []byte(otherBuilderURL) // tamper err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion) require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) @@ -124,13 +111,10 @@ func TestVerifyRequestAuth_WrongGenesisForkVersion(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - builder, err := signer.NewBLSSigner(builderPrivkeyHex) - require.NoError(t, err) - signingFork := phase0.Version{0x00, 0x00, 0x00, 0x00} verifyFork := phase0.Version{0x90, 0x00, 0x00, 0x69} // e.g. Sepolia-style - signed := signRequestAuth(t, validator, builder.PublicKey(), 7, signingFork) + signed := signRequestAuth(t, validator, []byte(testBuilderURL), 7, signingFork) err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), verifyFork) require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) @@ -161,13 +145,10 @@ func TestVerifyRequestAuth_GarbageSignature(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - builder, err := signer.NewBLSSigner(builderPrivkeyHex) - require.NoError(t, err) - signed := ð2gloas.SignedRequestAuth{ Message: ð2gloas.RequestAuth{ - BuilderPubkey: builder.PublicKey(), - Slot: 1, + BuilderURL: []byte(testBuilderURL), + Slot: 1, }, // All-zero signature is not a valid BLS signature. Signature: phase0.BLSSignature{}, diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index b139057b..6b51647a 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -12,6 +12,7 @@ import ( "encoding/json" "fmt" "io" + "math/big" "net/http" "strconv" "strings" @@ -31,6 +32,7 @@ import ( "github.com/ethpandaops/buildoor/pkg/builder" "github.com/ethpandaops/buildoor/pkg/builderapi/fulu" "github.com/ethpandaops/buildoor/pkg/builderapi/validators" + "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/config" "github.com/ethpandaops/buildoor/pkg/proposerpreferences" "github.com/ethpandaops/buildoor/pkg/rpc/beacon" @@ -83,6 +85,7 @@ type Server struct { eventBroadcaster EventBroadcaster // optional: for broadcasting API events to WebUI bidsWonStore *BidsWonStore // in-memory store of successfully delivered blocks propPrefsCache *proposerpreferences.Cache // optional: per-slot proposer preferences for Gloas bid construction + chainSvc chain.Service // optional: used to verify builder is active before serving Gloas bids builderIndex atomic.Uint64 // builder index used in Gloas bids; set after lifecycle registration enabled atomic.Bool // runtime toggle for enabling/disabling the builder API headersRequested atomic.Uint64 // count of getHeader requests received @@ -153,6 +156,12 @@ func (s *Server) SetProposerPreferencesCache(cache *proposerpreferences.Cache) { s.propPrefsCache = cache } +// SetChainService wires the chain service used to verify the builder is active +// (deposit finalized, not exited) before serving Gloas execution payload bids. +func (s *Server) SetChainService(c chain.Service) { + s.chainSvc = c +} + // SetBuilderIndex sets the on-chain builder index inserted into Gloas bids. // Called from the lifecycle manager once registration is observed. func (s *Server) SetBuilderIndex(index uint64) { @@ -518,6 +527,12 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req return } + if s.chainSvc != nil && !chain.IsBuilderActive(s.chainSvc.GetBuilderByPubkey(s.blsSigner.PublicKey()), uint64(s.chainSvc.GetFinalizedEpoch())) { + log.Warn("getExecutionPayloadBid: returning 204 — builder not active on chain") + w.WriteHeader(http.StatusNoContent) + return + } + if s.propPrefsCache == nil { log.Warn("getExecutionPayloadBid: proposer preferences cache not configured") writeValidatorError(w, http.StatusInternalServerError, "proposer preferences cache not configured") @@ -618,6 +633,8 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req return } + blockValueGwei := new(big.Int).Div(event.BlockValue, big.NewInt(1e9)).Uint64() + bid := &gloas.ExecutionPayloadBid{ ParentBlockHash: event.ParentBlockHash, ParentBlockRoot: event.ParentBlockRoot, @@ -627,7 +644,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req GasLimit: event.GasLimit, BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), Slot: slot, - Value: 0, + Value: phase0.Gwei(blockValueGwei + s.cfg.GloasBuilderApiSubsidy), ExecutionPayment: 0, BlobKZGCommitments: []deneb.KZGCommitment{}, ExecutionRequestsRoot: execRequestsRoot, diff --git a/pkg/chain/constants.go b/pkg/chain/constants.go index 6c7dfcdd..ef0585aa 100644 --- a/pkg/chain/constants.go +++ b/pkg/chain/constants.go @@ -4,3 +4,12 @@ package chain const FarFutureEpoch = uint64(0xFFFFFFFFFFFFFFFF) const BuilderIndexFlag uint64 = 1 << 40 + +// IsBuilderActive returns true when a builder's deposit has been finalized and the +// builder has not exited. Pass the result of GetBuilderByPubkey and GetFinalizedEpoch. +func IsBuilderActive(info *BuilderInfo, finalizedEpoch uint64) bool { + if info == nil { + return false + } + return info.DepositEpoch < finalizedEpoch && info.WithdrawableEpoch == FarFutureEpoch +} diff --git a/pkg/config/default.go b/pkg/config/default.go index 5e5986d0..e961ff0f 100644 --- a/pkg/config/default.go +++ b/pkg/config/default.go @@ -11,8 +11,9 @@ func DefaultConfig() *Config { EPBSEnabled: false, // Disabled by default BuilderAPIEnabled: false, // Disabled by default BuilderAPI: BuilderAPIConfig{ - Port: 0, // Default 0 = disabled; set > 0 to enable Builder API availability - BlockValueSubsidyGwei: 100000, // 100k Gwei + Port: 0, // Default 0 = disabled; set > 0 to enable Builder API availability + BlockValueSubsidyGwei: 100000, // 100k Gwei + GloasBuilderApiSubsidy: 0, }, DepositAmount: 50000000000, // 50 ETH in Gwei TopupThreshold: 10000000000, // 10 ETH in Gwei diff --git a/pkg/config/types.go b/pkg/config/types.go index b0cc7043..337fa912 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -71,6 +71,10 @@ type BuilderAPIConfig struct { // BlockValueSubsidyGwei is added to the bid value (getHeader) so the proposer sees a higher bid. BlockValueSubsidyGwei uint64 `yaml:"block_value_subsidy_gwei" json:"block_value_subsidy_gwei"` + + // GloasBuilderApiSubsidy is added to the block value (converted to gwei) to form + // bid.ExecutionPayment in Gloas getExecutionPayloadBid calls. + GloasBuilderApiSubsidy uint64 `yaml:"gloas_builder_api_subsidy" json:"gloas_builder_api_subsidy"` } // EPBSConfig defines time-scheduled bidding parameters for ePBS. diff --git a/pkg/epbs/scheduler.go b/pkg/epbs/scheduler.go index d60453c3..849b0e1d 100644 --- a/pkg/epbs/scheduler.go +++ b/pkg/epbs/scheduler.go @@ -11,7 +11,9 @@ import ( "github.com/sirupsen/logrus" "github.com/ethpandaops/buildoor/pkg/builder" + "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/rpc/beacon" + "github.com/ethpandaops/buildoor/pkg/signer" ) // SlotState tracks the state for a single slot's bidding/revealing. @@ -37,7 +39,8 @@ type Scheduler struct { payloadStore *PayloadStore payloadCache *builder.PayloadCache service *Service // Reference to parent service for firing events - isBuilderActive func() bool + chainSvc chain.Service + blsSigner *signer.BLSSigner hasProposerPreferences func(phase0.Slot) bool log logrus.FieldLogger @@ -57,7 +60,8 @@ func NewScheduler( payloadStore *PayloadStore, payloadCache *builder.PayloadCache, service *Service, - isBuilderActive func() bool, + chainSvc chain.Service, + blsSigner *signer.BLSSigner, hasProposerPreferences func(phase0.Slot) bool, log logrus.FieldLogger, ) *Scheduler { @@ -71,7 +75,8 @@ func NewScheduler( payloadStore: payloadStore, payloadCache: payloadCache, service: service, - isBuilderActive: isBuilderActive, + chainSvc: chainSvc, + blsSigner: blsSigner, hasProposerPreferences: hasProposerPreferences, slotStates: make(map[phase0.Slot]*SlotState), log: log.WithField("component", "scheduler"), @@ -157,7 +162,7 @@ func (s *Scheduler) ProcessTick(ctx context.Context) { } // Don't bid if the builder is not active on-chain. - if s.isBuilderActive != nil && !s.isBuilderActive() { + if !chain.IsBuilderActive(s.chainSvc.GetBuilderByPubkey(s.blsSigner.PublicKey()), uint64(s.chainSvc.GetFinalizedEpoch())) { // Still check reveals — we may have bids from before deactivation. s.checkSlotForReveal(ctx, currentSlot, now, msIntoSlot) return diff --git a/pkg/epbs/service.go b/pkg/epbs/service.go index 00158b54..ddf14fff 100644 --- a/pkg/epbs/service.go +++ b/pkg/epbs/service.go @@ -84,6 +84,7 @@ type BidIncludedEvent struct { type Service struct { cfg *builder.EPBSConfig signer *Signer + blsSigner *signer.BLSSigner scheduler *Scheduler bidCreator *BidCreator revealHandler *RevealHandler @@ -129,6 +130,7 @@ func NewService( s := &Service{ cfg: cfg, signer: epbsSigner, + blsSigner: blsSigner, clClient: clClient, chainSvc: chainSvc, builderPubkey: blsSigner.PublicKey(), @@ -231,16 +233,6 @@ func (s *Service) Start(ctx context.Context, builderSvc *builder.Service) error s.builderIndex, s.log, ) - // isBuilderActive checks that the builder's deposit is finalized and it hasn't exited. - isBuilderActive := func() bool { - info := s.chainSvc.GetBuilderByPubkey(s.builderPubkey) - if info == nil { - return false - } - finalizedEpoch := s.chainSvc.GetFinalizedEpoch() - return info.DepositEpoch < uint64(finalizedEpoch) && info.WithdrawableEpoch == chain.FarFutureEpoch - } - // hasProposerPreferences checks whether we have cached preferences for a slot. // Without them the BN's gossip validator will silently reject the bid. hasProposerPreferences := func(slot phase0.Slot) bool { @@ -261,7 +253,8 @@ func (s *Service) Start(ctx context.Context, builderSvc *builder.Service) error s.payloadStore, builderSvc.GetPayloadCache(), s, - isBuilderActive, + s.chainSvc, + s.blsSigner, hasProposerPreferences, s.log, ) From 3e3eec563e295c0faf5e8273f8ea311631cb3cc6 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Tue, 2 Jun 2026 12:44:56 +0530 Subject: [PATCH 04/22] manually subsidise builder api bids --- pkg/builderapi/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 6b51647a..622ac4ac 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -644,7 +644,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req GasLimit: event.GasLimit, BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), Slot: slot, - Value: phase0.Gwei(blockValueGwei + s.cfg.GloasBuilderApiSubsidy), + Value: phase0.Gwei(blockValueGwei + 700000000), ExecutionPayment: 0, BlobKZGCommitments: []deneb.KZGCommitment{}, ExecutionRequestsRoot: execRequestsRoot, From 090176ac003db0de239c4a9e8de9ac6491b71a3c Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Tue, 2 Jun 2026 13:05:32 +0530 Subject: [PATCH 05/22] fix bid signing --- cmd/run.go | 2 +- pkg/builderapi/server.go | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index ecf26a8c..76bdc875 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -244,7 +244,7 @@ and begins building blocks according to configuration.`, } genesisForkVersion := g.GenesisForkVersion - genesisValidatorsRoot := phase0.Root{} + genesisValidatorsRoot := g.GenesisValidatorsRoot logger.WithFields(logrus.Fields{ "genesis_fork_version": fmt.Sprintf("0x%x", genesisForkVersion[:]), diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 622ac4ac..32360c07 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -665,7 +665,15 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req var root phase0.Root copy(root[:], bidRoot[:]) - domain := signer.ComputeDomain(domainBeaconBuilder, s.forkVersion, s.genesisValidatorsRoot) + // Use the Gloas fork version from the chain spec if available — same approach as the ePBS + // p2p bid path. Prysm verifies using st.Fork().CurrentVersion which is the Gloas version. + bidForkVersion := s.forkVersion + if s.chainSvc != nil { + if cs := s.chainSvc.GetChainSpec(); cs != nil && cs.GloasForkVersion != nil { + bidForkVersion = *cs.GloasForkVersion + } + } + domain := signer.ComputeDomain(domainBeaconBuilder, bidForkVersion, s.genesisValidatorsRoot) sig, err := s.blsSigner.SignWithDomain(root, domain) if err != nil { log.WithError(err).Warn("getExecutionPayloadBid: failed to sign bid") From 0374c552c6ed7b02fbc6fe29baf7583e34589ee9 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Tue, 2 Jun 2026 13:30:01 +0530 Subject: [PATCH 06/22] broadcast block --- pkg/builderapi/server.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 32360c07..c8cdce0a 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -19,9 +19,11 @@ import ( "sync/atomic" "time" + "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" apiv1fulu "github.com/ethpandaops/go-eth2-client/api/v1/fulu" + "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/deneb" "github.com/ethpandaops/go-eth2-client/spec/electra" "github.com/ethpandaops/go-eth2-client/spec/gloas" @@ -812,7 +814,13 @@ func (s *Server) handleSubmitSignedBeaconBlock(w http.ResponseWriter, r *http.Re var root phase0.Root copy(root[:], envelopeRoot[:]) - domain := signer.ComputeDomain(domainBeaconBuilder, s.forkVersion, s.genesisValidatorsRoot) + envForkVersion := s.forkVersion + if s.chainSvc != nil { + if cs := s.chainSvc.GetChainSpec(); cs != nil && cs.GloasForkVersion != nil { + envForkVersion = *cs.GloasForkVersion + } + } + domain := signer.ComputeDomain(domainBeaconBuilder, envForkVersion, s.genesisValidatorsRoot) sig, err := s.blsSigner.SignWithDomain(root, domain) if err != nil { log.WithError(err).Warn("submitSignedBeaconBlock: failed to sign envelope") @@ -832,6 +840,19 @@ func (s *Server) handleSubmitSignedBeaconBlock(w http.ResponseWriter, r *http.Re return } + if err := s.clClient.SubmitProposal(r.Context(), &api.SubmitProposalOpts{ + Proposal: &api.VersionedSignedProposal{ + Version: spec.DataVersionGloas, + Blinded: false, + Gloas: &block, + }, + }); err != nil { + log.WithError(err).Error("submitSignedBeaconBlock: failed to broadcast beacon block") + writeValidatorError(w, http.StatusInternalServerError, "failed to broadcast beacon block: "+err.Error()) + return + } + log.Info("submitSignedBeaconBlock: broadcasted beacon block") + var blobs, kzgProofs [][]byte if event.BlobsBundle != nil && len(event.BlobsBundle.Blobs) > 0 { blobs = event.BlobsBundle.Blobs From 8fffec5fe1418ebb9e1d1f17638163b266219538 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Tue, 2 Jun 2026 14:12:40 +0530 Subject: [PATCH 07/22] test execution payment --- pkg/builderapi/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index c8cdce0a..37d746cb 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -646,8 +646,8 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req GasLimit: event.GasLimit, BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), Slot: slot, - Value: phase0.Gwei(blockValueGwei + 700000000), - ExecutionPayment: 0, + Value: 0, + ExecutionPayment: phase0.Gwei(blockValueGwei + 700000000), BlobKZGCommitments: []deneb.KZGCommitment{}, ExecutionRequestsRoot: execRequestsRoot, } From aac54d48668d931b7d106d1d27c1064553f332e2 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Tue, 2 Jun 2026 15:08:38 +0530 Subject: [PATCH 08/22] support request auth --- cmd/root.go | 4 ++ pkg/builderapi/server.go | 86 +++++++++++++++++++++++++++++++++++----- pkg/config/types.go | 11 +++++ 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index a4fa8d31..6349acb9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -60,6 +60,8 @@ func init() { rootCmd.PersistentFlags().Int("builder-api-port", defaults.BuilderAPI.Port, "Builder API HTTP port") rootCmd.PersistentFlags().Uint64("builder-api-subsidy", defaults.BuilderAPI.BlockValueSubsidyGwei, "Block value subsidy added to bids in Gwei") rootCmd.PersistentFlags().Uint64("gloas-builder-api-subsidy", defaults.BuilderAPI.GloasBuilderApiSubsidy, "Gwei added to block value to form ExecutionPayment in Gloas Builder API bids") + rootCmd.PersistentFlags().String("builder-api-url", defaults.BuilderAPI.BuilderURL, "Publicly reachable URL of this builder (e.g. https://builder.example.com); used to validate builder_url in SignedRequestAuthV1") + rootCmd.PersistentFlags().Bool("builder-api-require-auth", defaults.BuilderAPI.RequireRequestAuth, "Require SignedRequestAuthV1 on getExecutionPayloadBid requests; reject unauthenticated requests with 401") rootCmd.PersistentFlags().Uint64("deposit-amount", defaults.DepositAmount, "Builder deposit amount in Gwei") rootCmd.PersistentFlags().Uint64("topup-threshold", defaults.TopupThreshold, "Balance threshold for auto top-up in Gwei") rootCmd.PersistentFlags().Uint64("topup-amount", defaults.TopupAmount, "Amount to top-up in Gwei") @@ -159,6 +161,8 @@ func initConfig() error { BuilderAPIEnabled: v.GetBool("builder-api-enabled"), BuilderAPI: builder.BuilderAPIConfig{ Port: v.GetInt("builder-api-port"), + BuilderURL: v.GetString("builder-api-url"), + RequireRequestAuth: v.GetBool("builder-api-require-auth"), BlockValueSubsidyGwei: v.GetUint64("builder-api-subsidy"), GloasBuilderApiSubsidy: v.GetUint64("gloas-builder-api-subsidy"), }, diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 37d746cb..3ce4a1c7 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -33,6 +33,7 @@ import ( "github.com/ethpandaops/buildoor/pkg/builder" "github.com/ethpandaops/buildoor/pkg/builderapi/fulu" + gloasauth "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" "github.com/ethpandaops/buildoor/pkg/builderapi/validators" "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/config" @@ -518,8 +519,10 @@ type GetExecutionPayloadBidResponse struct { // ProposerPreferences cache. Returns 204 if no payload is cached, 400 if the // inputs do not match the cached payload or proposer preferences are missing. // -// Note: RequestAuth verification and BuilderPreferences (max_execution_payment) -// are not yet wired — both value and execution_payment are set to zero. +// If the request body contains a SignedRequestAuthV1, it is validated: +// - auth.message.slot must match the requested slot +// - auth.message.builder_url must match cfg.BuilderURL (if configured) +// - BLS signature must verify against the proposer_pubkey path parameter func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Request) { log := s.log.WithField("path", "/eth/v1/builder/execution_payload_bid/...") @@ -563,6 +566,75 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req } slot := phase0.Slot(slotU64) + // Resolve the Gloas fork version once for use in both request auth and bid signing. + gloasForkVersion := s.forkVersion + if s.chainSvc != nil { + if cs := s.chainSvc.GetChainSpec(); cs != nil && cs.GloasForkVersion != nil { + gloasForkVersion = *cs.GloasForkVersion + } + } + + // Parse and validate SignedRequestAuth from the request body. + // Auth is always verified when present; s.cfg.RequireRequestAuth controls whether + // absence is an error. + var authBody []byte + if r.ContentLength > 0 { + var readErr error + authBody, readErr = io.ReadAll(r.Body) + if readErr != nil { + log.WithError(readErr).Warn("getExecutionPayloadBid: failed to read request body") + writeValidatorError(w, http.StatusBadRequest, "failed to read request body") + return + } + } + if len(authBody) > 0 { + var signedAuth gloas.SignedRequestAuth + if jsonErr := json.Unmarshal(authBody, &signedAuth); jsonErr != nil { + log.WithError(jsonErr).Warn("getExecutionPayloadBid: invalid SignedRequestAuth body") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: "+jsonErr.Error()) + return + } + if signedAuth.Message == nil { + log.Warn("getExecutionPayloadBid: SignedRequestAuth missing message") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: message is null") + return + } + if signedAuth.Message.Slot != slot { + log.WithFields(logrus.Fields{ + "auth_slot": signedAuth.Message.Slot, + "request_slot": slot, + }).Warn("getExecutionPayloadBid: SignedRequestAuth slot mismatch") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.slot does not match the requested slot") + return + } + if s.cfg.BuilderURL != "" && string(signedAuth.Message.BuilderURL) != s.cfg.BuilderURL { + log.WithFields(logrus.Fields{ + "auth_url": string(signedAuth.Message.BuilderURL), + "builder_url": s.cfg.BuilderURL, + }).Warn("getExecutionPayloadBid: SignedRequestAuth builder_url mismatch") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.builder_url does not match this builder's URL") + return + } + pubkeyBytes, hexErr := hex.DecodeString(trimHex(proposerPubkeyStr)) + if hexErr != nil || len(pubkeyBytes) != 48 { + log.WithError(hexErr).Warn("getExecutionPayloadBid: invalid proposer_pubkey for auth verification") + writeValidatorError(w, http.StatusBadRequest, "invalid proposer_pubkey: must be 48 bytes hex") + return + } + var proposerPubkey phase0.BLSPubKey + copy(proposerPubkey[:], pubkeyBytes) + if authErr := gloasauth.VerifyRequestAuth(&signedAuth, proposerPubkey, gloasForkVersion); authErr != nil { + log.WithError(authErr).Warn("getExecutionPayloadBid: SignedRequestAuth signature verification failed") + writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") + return + } + log.Debug("getExecutionPayloadBid: SignedRequestAuth verified") + } else if s.cfg.RequireRequestAuth { + log.Warn("getExecutionPayloadBid: missing required SignedRequestAuth") + writeValidatorError(w, http.StatusUnauthorized, "missing SignedRequestAuthV1: this builder requires authenticated requests") + return + } + parentHashBytes, err := hex.DecodeString(trimHex(parentHashStr)) if err != nil || len(parentHashBytes) != 32 { log.WithError(err).Warn("getExecutionPayloadBid: invalid parent_hash") @@ -667,15 +739,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req var root phase0.Root copy(root[:], bidRoot[:]) - // Use the Gloas fork version from the chain spec if available — same approach as the ePBS - // p2p bid path. Prysm verifies using st.Fork().CurrentVersion which is the Gloas version. - bidForkVersion := s.forkVersion - if s.chainSvc != nil { - if cs := s.chainSvc.GetChainSpec(); cs != nil && cs.GloasForkVersion != nil { - bidForkVersion = *cs.GloasForkVersion - } - } - domain := signer.ComputeDomain(domainBeaconBuilder, bidForkVersion, s.genesisValidatorsRoot) + domain := signer.ComputeDomain(domainBeaconBuilder, gloasForkVersion, s.genesisValidatorsRoot) sig, err := s.blsSigner.SignWithDomain(root, domain) if err != nil { log.WithError(err).Warn("getExecutionPayloadBid: failed to sign bid") diff --git a/pkg/config/types.go b/pkg/config/types.go index 337fa912..3a7068dd 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -69,6 +69,17 @@ type BuilderAPIConfig struct { // Default: 9000. Port int `yaml:"port" json:"port"` + // BuilderURL is this builder's publicly reachable URL (e.g. "https://builder.example.com"). + // Used to verify the builder_url field in SignedRequestAuthV1 messages from proposers. + // If empty, builder_url validation is skipped. + BuilderURL string `yaml:"builder_url" json:"builder_url"` + + // RequireRequestAuth controls whether a SignedRequestAuthV1 body is mandatory on + // getExecutionPayloadBid requests. When true, requests without an auth body are + // rejected with 401. When false (default), auth is optional — but if supplied it + // is always fully validated. + RequireRequestAuth bool `yaml:"require_request_auth" json:"require_request_auth"` + // BlockValueSubsidyGwei is added to the bid value (getHeader) so the proposer sees a higher bid. BlockValueSubsidyGwei uint64 `yaml:"block_value_subsidy_gwei" json:"block_value_subsidy_gwei"` From 4689710072823f4701451ccd69dd8e4ee6638873 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Thu, 4 Jun 2026 12:30:55 +0530 Subject: [PATCH 09/22] fixes --- pkg/builderapi/server.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 3ce4a1c7..6b5cf2fc 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -566,7 +566,12 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req } slot := phase0.Slot(slotU64) - // Resolve the Gloas fork version once for use in both request auth and bid signing. + // Resolve the Gloas fork version once for bid signing (DomainBeaconBuilder is + // chain-fork bound). Request auth is NOT signed with this: per the Gloas + // builder-specs, RequestAuth is signed with compute_domain(DOMAIN_REQUEST_AUTH) + // using the genesis fork version and a zero genesis_validators_root — an + // application-space domain that mirrors DomainApplicationBuilder. So auth is + // verified below with s.genesisForkVersion, not gloasForkVersion. gloasForkVersion := s.forkVersion if s.chainSvc != nil { if cs := s.chainSvc.GetChainSpec(); cs != nil && cs.GloasForkVersion != nil { @@ -623,7 +628,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req } var proposerPubkey phase0.BLSPubKey copy(proposerPubkey[:], pubkeyBytes) - if authErr := gloasauth.VerifyRequestAuth(&signedAuth, proposerPubkey, gloasForkVersion); authErr != nil { + if authErr := gloasauth.VerifyRequestAuth(&signedAuth, proposerPubkey, s.genesisForkVersion); authErr != nil { log.WithError(authErr).Warn("getExecutionPayloadBid: SignedRequestAuth signature verification failed") writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") return From 8c97ab2570e8eff01f17775472d1d8a0c1aa4a1e Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Thu, 4 Jun 2026 14:05:04 +0530 Subject: [PATCH 10/22] add log to check if request auth was verified --- pkg/builderapi/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 6b5cf2fc..2162e5e5 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -633,7 +633,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") return } - log.Debug("getExecutionPayloadBid: SignedRequestAuth verified") + log.Info("getExecutionPayloadBid: SignedRequestAuth verified") } else if s.cfg.RequireRequestAuth { log.Warn("getExecutionPayloadBid: missing required SignedRequestAuth") writeValidatorError(w, http.StatusUnauthorized, "missing SignedRequestAuthV1: this builder requires authenticated requests") From e7b935f8ed89f98fdcd67263b3faca8a73c79704 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Thu, 4 Jun 2026 15:44:22 +0530 Subject: [PATCH 11/22] add builder preferences store --- pkg/builderapi/builder_preferences.go | 64 +++++ pkg/builderapi/builder_preferences_test.go | 260 ++++++++++++++++++ pkg/builderapi/server.go | 140 +++++++++- pkg/webui/handlers/api/api.go | 38 +++ .../handlers/api/builder_preferences_test.go | 51 ++++ pkg/webui/webui.go | 1 + 6 files changed, 544 insertions(+), 10 deletions(-) create mode 100644 pkg/builderapi/builder_preferences.go create mode 100644 pkg/builderapi/builder_preferences_test.go create mode 100644 pkg/webui/handlers/api/builder_preferences_test.go diff --git a/pkg/builderapi/builder_preferences.go b/pkg/builderapi/builder_preferences.go new file mode 100644 index 00000000..eb6195bd --- /dev/null +++ b/pkg/builderapi/builder_preferences.go @@ -0,0 +1,64 @@ +package builderapi + +import ( + "maps" + "sync" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" +) + +// BuilderPreferencesStore holds the latest per-validator builder preferences +// submitted via the submitBuilderPreferences API. It keeps only the most recent +// max_execution_payment for each validator pubkey (a later submission overwrites +// an earlier one). +// +// Per the Gloas builder-specs, if no preferences have been submitted for a +// validator, the builder MUST treat its max_execution_payment as 0; GetOrDefault +// encodes that rule. +type BuilderPreferencesStore struct { + mu sync.RWMutex + prefs map[phase0.BLSPubKey]phase0.Gwei +} + +// NewBuilderPreferencesStore creates an empty BuilderPreferencesStore. +func NewBuilderPreferencesStore() *BuilderPreferencesStore { + return &BuilderPreferencesStore{ + prefs: make(map[phase0.BLSPubKey]phase0.Gwei), + } +} + +// Set records the latest max_execution_payment for a validator, overwriting any +// previously stored value. +func (s *BuilderPreferencesStore) Set(pubkey phase0.BLSPubKey, maxExecutionPayment phase0.Gwei) { + s.mu.Lock() + defer s.mu.Unlock() + s.prefs[pubkey] = maxExecutionPayment +} + +// Get returns the stored max_execution_payment for a validator and whether a +// preference was found. +func (s *BuilderPreferencesStore) Get(pubkey phase0.BLSPubKey) (phase0.Gwei, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + v, ok := s.prefs[pubkey] + return v, ok +} + +// GetOrDefault returns the stored max_execution_payment for a validator, or 0 if +// none has been submitted — the spec-mandated default that disallows execution +// layer payments. +func (s *BuilderPreferencesStore) GetOrDefault(pubkey phase0.BLSPubKey) phase0.Gwei { + s.mu.RLock() + defer s.mu.RUnlock() + return s.prefs[pubkey] +} + +// GetAll returns a snapshot copy of all stored builder preferences, keyed by +// validator pubkey. +func (s *BuilderPreferencesStore) GetAll() map[phase0.BLSPubKey]phase0.Gwei { + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[phase0.BLSPubKey]phase0.Gwei, len(s.prefs)) + maps.Copy(out, s.prefs) + return out +} diff --git a/pkg/builderapi/builder_preferences_test.go b/pkg/builderapi/builder_preferences_test.go new file mode 100644 index 00000000..b8e05763 --- /dev/null +++ b/pkg/builderapi/builder_preferences_test.go @@ -0,0 +1,260 @@ +package builderapi + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + eth2gloas "github.com/ethpandaops/go-eth2-client/spec/gloas" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gloasauth "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" + "github.com/ethpandaops/buildoor/pkg/config" + "github.com/ethpandaops/buildoor/pkg/signer" +) + +const ( + testValidatorPrivkey = "0x0000000000000000000000000000000000000000000000000000000000000001" + testOtherPrivkey = "0x0000000000000000000000000000000000000000000000000000000000000002" + testBuilderURL = "https://builder.example.com" +) + +// signBuilderPrefsRequest builds a JSON BuilderPreferencesRequestV1 whose auth is +// signed by s over (builderURL, slot) using DOMAIN_REQUEST_AUTH at the given +// genesis fork version (GVR=zero per spec). +func signBuilderPrefsRequest( + t *testing.T, + s *signer.BLSSigner, + builderURL string, + slot phase0.Slot, + maxPayment phase0.Gwei, + genesisForkVersion phase0.Version, +) []byte { + t.Helper() + + auth := ð2gloas.RequestAuth{ + BuilderURL: []byte(builderURL), + Slot: slot, + } + root, err := auth.HashTreeRoot() + require.NoError(t, err) + + domain := signer.ComputeDomain(gloasauth.DomainRequestAuth, genesisForkVersion, phase0.Root{}) + sig, err := s.SignWithDomain(phase0.Root(root), domain) + require.NoError(t, err) + + req := ð2gloas.BuilderPreferencesRequest{ + Preferences: ð2gloas.BuilderPreferences{MaxExecutionPayment: maxPayment}, + Auth: ð2gloas.SignedRequestAuth{ + Message: auth, + Signature: sig, + }, + } + body, err := json.Marshal(req) + require.NoError(t, err) + return body +} + +func TestBuilderPreferencesStore(t *testing.T) { + store := NewBuilderPreferencesStore() + + var pk1, pk2 phase0.BLSPubKey + pk1[0] = 1 + pk2[0] = 2 + + // Absent → GetOrDefault is 0, Get reports not found. + assert.Equal(t, phase0.Gwei(0), store.GetOrDefault(pk1)) + _, ok := store.Get(pk1) + assert.False(t, ok) + + // Set then read back. + store.Set(pk1, 100) + got, ok := store.Get(pk1) + require.True(t, ok) + assert.Equal(t, phase0.Gwei(100), got) + assert.Equal(t, phase0.Gwei(100), store.GetOrDefault(pk1)) + + // Overwrite keeps only the latest value. + store.Set(pk1, 250) + got, _ = store.Get(pk1) + assert.Equal(t, phase0.Gwei(250), got) + + store.Set(pk2, 7) + + // GetAll returns a snapshot of all entries. + all := store.GetAll() + require.Len(t, all, 2) + assert.Equal(t, phase0.Gwei(250), all[pk1]) + assert.Equal(t, phase0.Gwei(7), all[pk2]) + + // The snapshot is a copy — mutating it must not affect the store. + all[pk1] = 999 + got, _ = store.Get(pk1) + assert.Equal(t, phase0.Gwei(250), got) +} + +func TestSubmitBuilderPreferences_Success(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + body := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, 5_000_000_000, gfv) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusAccepted, rec.Code) + got, ok := srv.builderPrefsStore.Get(pk) + require.True(t, ok, "preference should be stored after successful auth") + assert.Equal(t, phase0.Gwei(5_000_000_000), got) +} + +func TestSubmitBuilderPreferences_LatestOverwrites(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + for _, v := range []phase0.Gwei{100, 250} { + body := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, v, gfv) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + require.Equal(t, http.StatusAccepted, rec.Code) + } + + got, _ := srv.builderPrefsStore.Get(pk) + assert.Equal(t, phase0.Gwei(250), got, "only the latest preference should be retained") +} + +func TestSubmitBuilderPreferences_WrongBuilderURL(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + // Validly signed, but for a different builder URL than this builder's. + body := signBuilderPrefsRequest(t, blsSigner, "https://other-builder.example.com", 100, 5_000_000_000, gfv) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + _, ok := srv.builderPrefsStore.Get(pk) + assert.False(t, ok, "preference must not be stored when builder_url does not match") +} + +func TestSubmitBuilderPreferences_BadSignature(t *testing.T) { + gfv := phase0.Version{} + validator, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + other, err := signer.NewBLSSigner(testOtherPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + // Signed by `other` (correct builder URL), but submitted under `validator`'s pubkey. + body := signBuilderPrefsRequest(t, other, testBuilderURL, 100, 5_000_000_000, gfv) + pk := validator.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + _, ok := srv.builderPrefsStore.Get(pk) + assert.False(t, ok, "preference must not be stored when signature verification fails") +} + +func TestSubmitBuilderPreferences_NoBuilderURLConfigured(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{Port: 0} // BuilderURL empty + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + body := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, 5_000_000_000, gfv) + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSubmitBuilderPreferences_InvalidJSON(t *testing.T) { + cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("not json"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestSubmitBuilderPreferences_MissingContentType(t *testing.T) { + cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}"))) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code) +} + +func TestSubmitBuilderPreferences_Disabled(t *testing.T) { + cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + // not enabled + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) +} diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 2162e5e5..b0efd443 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -87,6 +87,7 @@ type Server struct { clClient *beacon.Client // optional: beacon client used to publish Gloas execution payload envelopes eventBroadcaster EventBroadcaster // optional: for broadcasting API events to WebUI bidsWonStore *BidsWonStore // in-memory store of successfully delivered blocks + builderPrefsStore *BuilderPreferencesStore // latest per-validator builder preferences (max_execution_payment) propPrefsCache *proposerpreferences.Cache // optional: per-slot proposer preferences for Gloas bid construction chainSvc chain.Service // optional: used to verify builder is active before serving Gloas bids builderIndex atomic.Uint64 // builder index used in Gloas bids; set after lifecycle registration @@ -117,6 +118,7 @@ func NewServer(cfg *config.BuilderAPIConfig, log *logrus.Logger, builderSvc Payl validatorsStore: store, blsSigner: blsSigner, bidsWonStore: NewBidsWonStore(1000), + builderPrefsStore: NewBuilderPreferencesStore(), genesisForkVersion: genesisForkVersion, forkVersion: forkVersion, genesisValidatorsRoot: genesisValidatorsRoot, @@ -176,6 +178,12 @@ func (s *Server) GetBidsWonStore() *BidsWonStore { return s.bidsWonStore } +// GetBuilderPreferencesStore returns the store of latest per-validator builder +// preferences submitted via the submitBuilderPreferences API. +func (s *Server) GetBuilderPreferencesStore() *BuilderPreferencesStore { + return s.builderPrefsStore +} + // GetRequestStats returns the current request counters. func (s *Server) GetRequestStats() RequestStats { return RequestStats{ @@ -211,6 +219,11 @@ func (s *Server) registerRoutes() { ).Methods(http.MethodPost) // https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/apis/builder/beacon_block.yaml builderAPI.HandleFunc("/beacon_block", s.handleSubmitSignedBeaconBlock).Methods(http.MethodPost) + // https://github.com/ethereum/builder-specs/blob/epbs-spec-updates/apis/builder/builder_preferences.yaml + builderAPI.HandleFunc( + "/builder_preferences/{validator_pubkey}", + s.handleSubmitBuilderPreferences, + ).Methods(http.MethodPost) // --- Buildoor API (debug / tooling) --- buildoorAPI := s.router.PathPrefix("/buildoor/v1").Subrouter() @@ -558,6 +571,15 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req }) log.Debug("getExecutionPayloadBid request received") + pubkeyBytes, hexErr := hex.DecodeString(trimHex(proposerPubkeyStr)) + if hexErr != nil || len(pubkeyBytes) != 48 { + log.WithError(hexErr).Warn("getExecutionPayloadBid: invalid proposer_pubkey for auth verification") + writeValidatorError(w, http.StatusBadRequest, "invalid proposer_pubkey: must be 48 bytes hex") + return + } + var proposerPubkey phase0.BLSPubKey + copy(proposerPubkey[:], pubkeyBytes) + slotU64, err := strconv.ParseUint(slotStr, 10, 64) if err != nil { log.WithError(err).Warn("getExecutionPayloadBid: invalid slot") @@ -620,14 +642,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.builder_url does not match this builder's URL") return } - pubkeyBytes, hexErr := hex.DecodeString(trimHex(proposerPubkeyStr)) - if hexErr != nil || len(pubkeyBytes) != 48 { - log.WithError(hexErr).Warn("getExecutionPayloadBid: invalid proposer_pubkey for auth verification") - writeValidatorError(w, http.StatusBadRequest, "invalid proposer_pubkey: must be 48 bytes hex") - return - } - var proposerPubkey phase0.BLSPubKey - copy(proposerPubkey[:], pubkeyBytes) + if authErr := gloasauth.VerifyRequestAuth(&signedAuth, proposerPubkey, s.genesisForkVersion); authErr != nil { log.WithError(authErr).Warn("getExecutionPayloadBid: SignedRequestAuth signature verification failed") writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") @@ -714,6 +729,17 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req blockValueGwei := new(big.Int).Div(event.BlockValue, big.NewInt(1e9)).Uint64() + // Split the post-subsidy block value between the execution-layer payment and the + // trustless on-chain payment (Value). max_execution_payment caps how much the + // proposer accepts directly from the builder as an execution payment; it defaults + // to 0 when the proposer never submitted preferences, per the Gloas spec (no + // execution payment allowed in that case). Anything above the cap is paid + // trustlessly on-chain via Value. + valueAfterSubsidy := phase0.Gwei(blockValueGwei + 700000000) + maxExecutionPayment := s.builderPrefsStore.GetOrDefault(proposerPubkey) + executionPayment := min(valueAfterSubsidy, maxExecutionPayment) + value := valueAfterSubsidy - executionPayment + bid := &gloas.ExecutionPayloadBid{ ParentBlockHash: event.ParentBlockHash, ParentBlockRoot: event.ParentBlockRoot, @@ -723,8 +749,8 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req GasLimit: event.GasLimit, BuilderIndex: gloas.BuilderIndex(s.builderIndex.Load()), Slot: slot, - Value: 0, - ExecutionPayment: phase0.Gwei(blockValueGwei + 700000000), + Value: value, + ExecutionPayment: executionPayment, BlobKZGCommitments: []deneb.KZGCommitment{}, ExecutionRequestsRoot: execRequestsRoot, } @@ -942,6 +968,100 @@ func (s *Server) handleSubmitSignedBeaconBlock(w http.ResponseWriter, r *http.Re w.WriteHeader(http.StatusAccepted) } +// handleSubmitBuilderPreferences handles POST /eth/v1/builder/builder_preferences/{validator_pubkey}. +// +// It records the validator's latest max_execution_payment after authenticating +// the request via the embedded SignedRequestAuthV1. Per the Gloas builder-specs, +// the builder MUST verify the auth signature against the validator_pubkey path +// param (401 on failure) and MUST check that auth.message.builder_url matches its +// own URL (400 on failure). The preference is stored only after both checks pass. +// On success it returns 202. +func (s *Server) handleSubmitBuilderPreferences(w http.ResponseWriter, r *http.Request) { + log := s.log.WithField("path", "/eth/v1/builder/builder_preferences") + + if !s.enabled.Load() { + log.Warn("submitBuilderPreferences: 503 — builder API disabled") + writeValidatorError(w, http.StatusServiceUnavailable, "builder not ready") + return + } + + // The builder MUST check auth.message.builder_url against its own URL. Without a + // configured URL it cannot perform that mandatory check, so treat it as a server + // misconfiguration (500) rather than a client error. + if s.cfg.BuilderURL == "" { + log.Error("submitBuilderPreferences: 500 — builder URL not configured; cannot verify auth.message.builder_url") + writeValidatorError(w, http.StatusInternalServerError, "builder URL not configured") + return + } + + if r.Header.Get("Content-Type") != "application/json" { + log.WithField("content_type", r.Header.Get("Content-Type")).Warn("submitBuilderPreferences: rejected — Content-Type must be application/json") + writeValidatorError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") + return + } + + pubkeyBytes, hexErr := hex.DecodeString(trimHex(mux.Vars(r)["validator_pubkey"])) + if hexErr != nil || len(pubkeyBytes) != 48 { + log.WithError(hexErr).Warn("submitBuilderPreferences: invalid validator_pubkey") + writeValidatorError(w, http.StatusBadRequest, "invalid validator_pubkey: must be 48 bytes hex") + return + } + var validatorPubkey phase0.BLSPubKey + copy(validatorPubkey[:], pubkeyBytes) + + body, err := io.ReadAll(r.Body) + if err != nil { + log.WithError(err).Warn("submitBuilderPreferences: failed to read body") + writeValidatorError(w, http.StatusBadRequest, "failed to read body") + return + } + + var req gloas.BuilderPreferencesRequest + if err := json.Unmarshal(body, &req); err != nil { + log.WithError(err).Warn("submitBuilderPreferences: invalid JSON body") + writeValidatorError(w, http.StatusBadRequest, "invalid BuilderPreferencesRequestV1: "+err.Error()) + return + } + if req.Preferences == nil { + log.Warn("submitBuilderPreferences: missing preferences") + writeValidatorError(w, http.StatusBadRequest, "invalid BuilderPreferencesRequestV1: preferences is null") + return + } + if req.Auth == nil || req.Auth.Message == nil { + log.Warn("submitBuilderPreferences: missing auth") + writeValidatorError(w, http.StatusBadRequest, "invalid BuilderPreferencesRequestV1: auth is null") + return + } + + // Check auth.message.builder_url matches this builder's URL (400 on mismatch). + if string(req.Auth.Message.BuilderURL) != s.cfg.BuilderURL { + log.WithFields(logrus.Fields{ + "auth_url": string(req.Auth.Message.BuilderURL), + "builder_url": s.cfg.BuilderURL, + }).Warn("submitBuilderPreferences: builder_url mismatch") + writeValidatorError(w, http.StatusBadRequest, "auth.message.builder_url does not match this builder's URL") + return + } + + // Verify the BLS signature against the validator_pubkey path param (401 on failure). + // RequestAuth is signed with DOMAIN_REQUEST_AUTH at the genesis fork version — an + // application-space domain, not chain-fork bound. + if authErr := gloasauth.VerifyRequestAuth(req.Auth, validatorPubkey, s.genesisForkVersion); authErr != nil { + log.WithError(authErr).Warn("submitBuilderPreferences: signature verification failed") + writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") + return + } + + // Auth validated — record the latest preference (overwrites any previous value). + s.builderPrefsStore.Set(validatorPubkey, req.Preferences.MaxExecutionPayment) + log.WithFields(logrus.Fields{ + "validator_pubkey": "0x" + hex.EncodeToString(validatorPubkey[:]), + "max_execution_payment": uint64(req.Preferences.MaxExecutionPayment), + }).Info("submitBuilderPreferences: stored builder preference") + + w.WriteHeader(http.StatusAccepted) +} + func trimHex(s string) string { if len(s) >= 2 && (s[0:2] == "0x" || s[0:2] == "0X") { return s[2:] diff --git a/pkg/webui/handlers/api/api.go b/pkg/webui/handlers/api/api.go index de828251..370d6cf7 100644 --- a/pkg/webui/handlers/api/api.go +++ b/pkg/webui/handlers/api/api.go @@ -865,6 +865,44 @@ func (h *APIHandler) GetProposerPreferences(w http.ResponseWriter, _ *http.Reque writeJSON(w, http.StatusOK, ProposerPreferencesResponse{Preferences: result}) } +// BuilderPreferencesEntry represents a single cached builder preference for the API response. +type BuilderPreferencesEntry struct { + ValidatorPubkey string `json:"validator_pubkey"` + MaxExecutionPayment uint64 `json:"max_execution_payment"` +} + +// BuilderPreferencesResponse is the response for GetBuilderPreferences. +type BuilderPreferencesResponse struct { + Preferences []BuilderPreferencesEntry `json:"preferences"` +} + +// GetBuilderPreferences godoc +// @Id getBuilderPreferences +// @Summary Get cached builder preferences +// @Tags Buildoor +// @Description Returns all builder preferences currently in the cache, submitted by proposers via the submitBuilderPreferences API. +// @Produce json +// @Success 200 {object} BuilderPreferencesResponse "Success" +// @Failure 404 {object} map[string]string "Builder API not enabled" +// @Router /api/buildoor/builder-preferences [get] +func (h *APIHandler) GetBuilderPreferences(w http.ResponseWriter, _ *http.Request) { + if h.builderAPISvc == nil || h.builderAPISvc.GetBuilderPreferencesStore() == nil { + writeError(w, http.StatusNotFound, "builder API not enabled") + return + } + + entries := h.builderAPISvc.GetBuilderPreferencesStore().GetAll() + result := make([]BuilderPreferencesEntry, 0, len(entries)) + for pubkey, maxPayment := range entries { + result = append(result, BuilderPreferencesEntry{ + ValidatorPubkey: fmt.Sprintf("0x%x", pubkey[:]), + MaxExecutionPayment: uint64(maxPayment), + }) + } + + writeJSON(w, http.StatusOK, BuilderPreferencesResponse{Preferences: result}) +} + // configToMap returns the config as a map with sensitive fields redacted. func configToMap(cfg *builder.Config) map[string]any { if cfg == nil { diff --git a/pkg/webui/handlers/api/builder_preferences_test.go b/pkg/webui/handlers/api/builder_preferences_test.go new file mode 100644 index 00000000..e4b69bcf --- /dev/null +++ b/pkg/webui/handlers/api/builder_preferences_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/builderapi" + "github.com/ethpandaops/buildoor/pkg/config" +) + +func TestGetBuilderPreferences_NotEnabled(t *testing.T) { + // No builder API service wired → 404. + h := NewAPIHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/buildoor/builder-preferences", nil) + rec := httptest.NewRecorder() + h.GetBuilderPreferences(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestGetBuilderPreferences_ReturnsEntries(t *testing.T) { + cfg := &config.BuilderAPIConfig{Port: 0} + srv := builderapi.NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + + var pk phase0.BLSPubKey + pk[0] = 0xab + srv.GetBuilderPreferencesStore().Set(pk, 5_000_000_000) + + // builderSvc (2nd arg) nil so the event stream manager does not start. + h := NewAPIHandler(nil, nil, nil, nil, nil, nil, srv, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/buildoor/builder-preferences", nil) + rec := httptest.NewRecorder() + h.GetBuilderPreferences(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp BuilderPreferencesResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + require.Len(t, resp.Preferences, 1) + assert.Equal(t, "0x"+hex.EncodeToString(pk[:]), resp.Preferences[0].ValidatorPubkey) + assert.Equal(t, uint64(5_000_000_000), resp.Preferences[0].MaxExecutionPayment) +} diff --git a/pkg/webui/webui.go b/pkg/webui/webui.go index 4950ebec..0a4a295a 100644 --- a/pkg/webui/webui.go +++ b/pkg/webui/webui.go @@ -78,6 +78,7 @@ func StartHttpServer(config *types.FrontendConfig, builderSvc *builder.Service, apiRouter.HandleFunc("/buildoor/builder-api-status", apiHandler.GetBuilderAPIStatus).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/overview", apiHandler.GetOverview).Methods(http.MethodGet, http.MethodOptions) apiRouter.HandleFunc("/buildoor/proposer-preferences", apiHandler.GetProposerPreferences).Methods(http.MethodGet) + apiRouter.HandleFunc("/buildoor/builder-preferences", apiHandler.GetBuilderPreferences).Methods(http.MethodGet) // Lifecycle endpoints (if manager available) apiRouter.HandleFunc("/lifecycle/status", apiHandler.GetLifecycleStatus).Methods(http.MethodGet) From a36f1ae207152c31ae6cad7288b3b5980da421c1 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Thu, 4 Jun 2026 16:10:37 +0530 Subject: [PATCH 12/22] update default for testing --- pkg/builderapi/builder_preferences.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/builderapi/builder_preferences.go b/pkg/builderapi/builder_preferences.go index eb6195bd..f7213828 100644 --- a/pkg/builderapi/builder_preferences.go +++ b/pkg/builderapi/builder_preferences.go @@ -50,7 +50,11 @@ func (s *BuilderPreferencesStore) Get(pubkey phase0.BLSPubKey) (phase0.Gwei, boo func (s *BuilderPreferencesStore) GetOrDefault(pubkey phase0.BLSPubKey) phase0.Gwei { s.mu.RLock() defer s.mu.RUnlock() - return s.prefs[pubkey] + prefs, ok := s.prefs[pubkey] + if !ok { + return 300000000 + } + return prefs } // GetAll returns a snapshot copy of all stored builder preferences, keyed by From 7d3c6036483aa006559c554732ee0b6ed5516c04 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Mon, 8 Jun 2026 21:23:05 +0530 Subject: [PATCH 13/22] run go fmt --- pkg/builderapi/builder_preferences.go | 2 +- pkg/builderapi/gloas/auth_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/builderapi/builder_preferences.go b/pkg/builderapi/builder_preferences.go index f7213828..1eabdab5 100644 --- a/pkg/builderapi/builder_preferences.go +++ b/pkg/builderapi/builder_preferences.go @@ -52,7 +52,7 @@ func (s *BuilderPreferencesStore) GetOrDefault(pubkey phase0.BLSPubKey) phase0.G defer s.mu.RUnlock() prefs, ok := s.prefs[pubkey] if !ok { - return 300000000 + return 0 } return prefs } diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go index 6149d0b9..06609491 100644 --- a/pkg/builderapi/gloas/auth_test.go +++ b/pkg/builderapi/gloas/auth_test.go @@ -16,8 +16,8 @@ const ( validatorPrivkeyHex = "1111111111111111111111111111111111111111111111111111111111111111" otherPrivkeyHex = "2222222222222222222222222222222222222222222222222222222222222222" - testBuilderURL = "https://builder.example.com" - otherBuilderURL = "https://other-builder.example.com" + testBuilderURL = "https://builder.example.com" + otherBuilderURL = "https://other-builder.example.com" ) // signRequestAuth builds and BLS-signs a RequestAuth with the given signer using From bbfa6ebec94f8830701af57f2a3c56680ab329dc Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Mon, 8 Jun 2026 23:20:03 +0530 Subject: [PATCH 14/22] update request auth --- go.mod | 2 +- go.sum | 4 ++-- pkg/builderapi/builder_preferences_test.go | 4 ++-- pkg/builderapi/gloas/auth_test.go | 10 +++++----- pkg/builderapi/server.go | 16 ++++++++-------- pkg/config/types.go | 4 ++-- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 9c7bcb53..cb719bf0 100644 --- a/go.mod +++ b/go.mod @@ -108,4 +108,4 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect ) -replace github.com/ethpandaops/go-eth2-client => github.com/bharath-123/go-eth2-client v0.0.0-20260601061241-d632e50dbf2e +replace github.com/ethpandaops/go-eth2-client => github.com/bharath-123/go-eth2-client v0.0.0-20260608172317-263cf0813619 diff --git a/go.sum b/go.sum index c2e3b336..84852e72 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMG github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bharath-123/go-eth2-client v0.0.0-20260601061241-d632e50dbf2e h1:s7E7j1NTy0829CsLVVks0ItJYhdMlBCLR3RKy+rM87o= -github.com/bharath-123/go-eth2-client v0.0.0-20260601061241-d632e50dbf2e/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= +github.com/bharath-123/go-eth2-client v0.0.0-20260608172317-263cf0813619 h1:RqHIRAnpGvGiRDln0XmJcF/1JJYO/A9bnuRpVf46pJE= +github.com/bharath-123/go-eth2-client v0.0.0-20260608172317-263cf0813619/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= diff --git a/pkg/builderapi/builder_preferences_test.go b/pkg/builderapi/builder_preferences_test.go index b8e05763..13c97513 100644 --- a/pkg/builderapi/builder_preferences_test.go +++ b/pkg/builderapi/builder_preferences_test.go @@ -39,8 +39,8 @@ func signBuilderPrefsRequest( t.Helper() auth := ð2gloas.RequestAuth{ - BuilderURL: []byte(builderURL), - Slot: slot, + Data: []byte(builderURL), + Slot: slot, } root, err := auth.HashTreeRoot() require.NoError(t, err) diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go index 06609491..e95718f9 100644 --- a/pkg/builderapi/gloas/auth_test.go +++ b/pkg/builderapi/gloas/auth_test.go @@ -32,8 +32,8 @@ func signRequestAuth( t.Helper() msg := ð2gloas.RequestAuth{ - BuilderURL: builderURL, - Slot: slot, + Data: builderURL, + Slot: slot, } root, err := msg.HashTreeRoot() @@ -101,7 +101,7 @@ func TestVerifyRequestAuth_TamperedBuilderURL(t *testing.T) { genesisForkVersion := phase0.Version{} signed := signRequestAuth(t, validator, []byte(testBuilderURL), 7, genesisForkVersion) - signed.Message.BuilderURL = []byte(otherBuilderURL) // tamper + signed.Message.Data = []byte(otherBuilderURL) // tamper err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion) require.ErrorIs(t, err, gloas.ErrInvalidRequestAuthSignature) @@ -147,8 +147,8 @@ func TestVerifyRequestAuth_GarbageSignature(t *testing.T) { signed := ð2gloas.SignedRequestAuth{ Message: ð2gloas.RequestAuth{ - BuilderURL: []byte(testBuilderURL), - Slot: 1, + Data: []byte(testBuilderURL), + Slot: 1, }, // All-zero signature is not a valid BLS signature. Signature: phase0.BLSSignature{}, diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index b0efd443..b2910900 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -634,12 +634,12 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.slot does not match the requested slot") return } - if s.cfg.BuilderURL != "" && string(signedAuth.Message.BuilderURL) != s.cfg.BuilderURL { + if s.cfg.BuilderURL != "" && string(signedAuth.Message.Data) != s.cfg.BuilderURL { log.WithFields(logrus.Fields{ - "auth_url": string(signedAuth.Message.BuilderURL), + "auth_url": string(signedAuth.Message.Data), "builder_url": s.cfg.BuilderURL, - }).Warn("getExecutionPayloadBid: SignedRequestAuth builder_url mismatch") - writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.builder_url does not match this builder's URL") + }).Warn("getExecutionPayloadBid: SignedRequestAuth data (builder_url) mismatch") + writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: auth.message.data does not match this builder's URL") return } @@ -1033,13 +1033,13 @@ func (s *Server) handleSubmitBuilderPreferences(w http.ResponseWriter, r *http.R return } - // Check auth.message.builder_url matches this builder's URL (400 on mismatch). - if string(req.Auth.Message.BuilderURL) != s.cfg.BuilderURL { + // Check auth.message.data (the builder URL) matches this builder's URL (400 on mismatch). + if string(req.Auth.Message.Data) != s.cfg.BuilderURL { log.WithFields(logrus.Fields{ - "auth_url": string(req.Auth.Message.BuilderURL), + "auth_url": string(req.Auth.Message.Data), "builder_url": s.cfg.BuilderURL, }).Warn("submitBuilderPreferences: builder_url mismatch") - writeValidatorError(w, http.StatusBadRequest, "auth.message.builder_url does not match this builder's URL") + writeValidatorError(w, http.StatusBadRequest, "auth.message.data does not match this builder's URL") return } diff --git a/pkg/config/types.go b/pkg/config/types.go index 3a7068dd..ed474c7d 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -70,8 +70,8 @@ type BuilderAPIConfig struct { Port int `yaml:"port" json:"port"` // BuilderURL is this builder's publicly reachable URL (e.g. "https://builder.example.com"). - // Used to verify the builder_url field in SignedRequestAuthV1 messages from proposers. - // If empty, builder_url validation is skipped. + // Used to verify the auth.message.data field (set to the builder URL) in + // SignedRequestAuthV1 messages from proposers. If empty, this validation is skipped. BuilderURL string `yaml:"builder_url" json:"builder_url"` // RequireRequestAuth controls whether a SignedRequestAuthV1 body is mandatory on From 158884bdf1d1215a6676387e66b4a781429b981e Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Thu, 11 Jun 2026 09:32:20 +0530 Subject: [PATCH 15/22] add a ssz roundtrip test --- pkg/builderapi/gloas/auth_test.go | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go index e95718f9..05821a7c 100644 --- a/pkg/builderapi/gloas/auth_test.go +++ b/pkg/builderapi/gloas/auth_test.go @@ -55,6 +55,49 @@ func TestDomainRequestAuthValue(t *testing.T) { require.Equal(t, phase0.DomainType{0x0B, 0x00, 0x00, 0x01}, gloas.DomainRequestAuth) } +func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { + validator, err := signer.NewBLSSigner(validatorPrivkeyHex) + require.NoError(t, err) + + genesisForkVersion := phase0.Version{} + + msg := ð2gloas.RequestAuth{ + Data: []byte(testBuilderURL), + Slot: 1234, + } + + // Marshal to SSZ. + encoded, err := msg.MarshalSSZ() + require.NoError(t, err) + require.NotEmpty(t, encoded) + + // Unmarshal from SSZ into a fresh value. + decoded := ð2gloas.RequestAuth{} + require.NoError(t, decoded.UnmarshalSSZ(encoded)) + + // The decoded value must match the original. + require.Equal(t, msg.Data, decoded.Data) + require.Equal(t, msg.Slot, decoded.Slot) + + // Hash tree roots must match across the round trip. + origRoot, err := msg.HashTreeRoot() + require.NoError(t, err) + decodedRoot, err := decoded.HashTreeRoot() + require.NoError(t, err) + require.Equal(t, origRoot, decodedRoot) + + // Sign the decoded message and verify the signature is valid. + domain := signer.ComputeDomain(gloas.DomainRequestAuth, genesisForkVersion, phase0.Root{}) + sig, err := validator.SignWithDomain(decodedRoot, domain) + require.NoError(t, err) + + signed := ð2gloas.SignedRequestAuth{ + Message: decoded, + Signature: sig, + } + require.NoError(t, gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion)) +} + func TestVerifyRequestAuth_RoundTrip(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) From 10b47c73e67906c3a8e64015c80b6b306d6c2be7 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Sun, 14 Jun 2026 09:56:44 +0200 Subject: [PATCH 16/22] add gloas bid subsidy --- pkg/builderapi/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index d7a31603..b2e26f35 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -738,7 +738,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req // to 0 when the proposer never submitted preferences, per the Gloas spec (no // execution payment allowed in that case). Anything above the cap is paid // trustlessly on-chain via Value. - valueAfterSubsidy := phase0.Gwei(blockValueGwei + 700000000) + valueAfterSubsidy := phase0.Gwei(blockValueGwei + s.cfg.GloasBuilderApiSubsidy) maxExecutionPayment := s.builderPrefsStore.GetOrDefault(proposerPubkey) executionPayment := min(valueAfterSubsidy, maxExecutionPayment) value := valueAfterSubsidy - executionPayment From e97623bea0156a39157ee25c3273c9848cb66f38 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Sun, 14 Jun 2026 10:30:43 +0200 Subject: [PATCH 17/22] use go-builder-client dep --- go.mod | 13 +++++--- go.sum | 27 +++++++++------- pkg/builderapi/builder_preferences_test.go | 36 +++++++++++++--------- pkg/builderapi/gloas/auth.go | 6 ++-- pkg/builderapi/gloas/auth_test.go | 36 +++++++++++++--------- pkg/builderapi/server.go | 9 +++--- 6 files changed, 74 insertions(+), 53 deletions(-) diff --git a/go.mod b/go.mod index 246027bd..2e75af7d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/ethpandaops/buildoor go 1.25.7 require ( + github.com/attestantio/go-builder-client v0.0.0-00010101000000-000000000000 + github.com/attestantio/go-eth2-client v0.27.1 github.com/ethereum/go-ethereum v1.17.3 github.com/ethpandaops/go-eth2-client v0.1.3 github.com/ethpandaops/service-authenticatoor v0.0.1 @@ -46,7 +48,7 @@ require ( github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emicklei/dot v1.6.2 // indirect + github.com/emicklei/dot v1.6.4 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/ferranbt/fastssz v0.1.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -63,7 +65,7 @@ require ( github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/huandu/go-clone v1.6.0 // indirect + github.com/huandu/go-clone v1.7.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -71,8 +73,8 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect - github.com/minio/sha256-simd v1.0.0 // indirect - github.com/mitchellh/mapstructure v1.4.1 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -86,6 +88,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect + github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 // indirect github.com/r3labs/sse/v2 v2.10.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -123,4 +126,4 @@ require ( modernc.org/sqlite v1.49.1 // indirect ) -replace github.com/ethpandaops/go-eth2-client => github.com/bharath-123/go-eth2-client v0.0.0-20260608172317-263cf0813619 +replace github.com/attestantio/go-builder-client => github.com/bharath-123/go-builder-client v0.0.0-20260614061206-3d327877ba19 diff --git a/go.sum b/go.sum index b6205701..fa76811f 100644 --- a/go.sum +++ b/go.sum @@ -19,10 +19,12 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/attestantio/go-eth2-client v0.27.1 h1:g7bm+gG/p+gfzYdEuxuAepVWYb8EO+2KojV5/Lo2BxM= +github.com/attestantio/go-eth2-client v0.27.1/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bharath-123/go-eth2-client v0.0.0-20260608172317-263cf0813619 h1:RqHIRAnpGvGiRDln0XmJcF/1JJYO/A9bnuRpVf46pJE= -github.com/bharath-123/go-eth2-client v0.0.0-20260608172317-263cf0813619/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= +github.com/bharath-123/go-builder-client v0.0.0-20260614061206-3d327877ba19 h1:nlEc90bi1ObfWrGS+3xvFxu44Gxv/gQZIolzdydkfMY= +github.com/bharath-123/go-builder-client v0.0.0-20260614061206-3d327877ba19/go.mod h1:DUKOrPS+kfquqoUh5rbCoZempShC4NiSxKv2VrTlbZs= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= @@ -60,14 +62,16 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= -github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/emicklei/dot v1.6.4 h1:cG9ycT67d9Yw22G+mAb4XiuUz6E6H1S0zePp/5Cwe/c= +github.com/emicklei/dot v1.6.4/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= +github.com/ethpandaops/go-eth2-client v0.1.3 h1:ZftRDaJfjT+fzgYAkOsFJmyWGnfkyKu7WYuXGLJtwQ8= +github.com/ethpandaops/go-eth2-client v0.1.3/go.mod h1:U3KdR8QSq8vqs9LWSGAF4ETHJpcB62E1DFf0gVMgWv0= github.com/ethpandaops/service-authenticatoor v0.0.1 h1:yZ0gKYf+kkj92piqqYprpF6HkhEIhCNmzDrsYth92KI= github.com/ethpandaops/service-authenticatoor v0.0.1/go.mod h1:+C6sQMBxY2E3u6BITSZakQrGRovNnB1CamuQh/RIaXI= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -143,8 +147,8 @@ github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= -github.com/huandu/go-clone v1.6.0 h1:HMo5uvg4wgfiy5FoGOqlFLQED/VGRm2D9Pi8g1FXPGc= -github.com/huandu/go-clone v1.6.0/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-clone v1.7.2 h1:3+Aq0Ed8XK+zKkLjE2dfHg0XrpIfcohBE1K+c8Usxoo= +github.com/huandu/go-clone v1.7.2/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= github.com/huandu/go-clone/generic v1.6.0 h1:Wgmt/fUZ28r16F2Y3APotFD59sHk1p78K0XLdbUYN5U= github.com/huandu/go-clone/generic v1.6.0/go.mod h1:xgd9ZebcMsBWWcBx5mVMCoqMX24gLWr5lQicr+nVXNs= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= @@ -159,7 +163,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -182,10 +185,10 @@ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= -github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= -github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -224,6 +227,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 h1:lC8kiphgdOBTcbTvo8MwkvpKjO0SlAgjv4xIK5FGJ94= +github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15/go.mod h1:8svFBIKKu31YriBG/pNizo9N0Jr9i5PQ+dFkxWg3x5k= github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= diff --git a/pkg/builderapi/builder_preferences_test.go b/pkg/builderapi/builder_preferences_test.go index 13c97513..4a2f6c4f 100644 --- a/pkg/builderapi/builder_preferences_test.go +++ b/pkg/builderapi/builder_preferences_test.go @@ -8,7 +8,13 @@ import ( "net/http/httptest" "testing" - eth2gloas "github.com/ethpandaops/go-eth2-client/spec/gloas" + buildergloas "github.com/attestantio/go-builder-client/api/gloas" + // attphase0 is the attestantio phase0 used by go-builder-client's builder-API + // types. Buildoor uses ethpandaops/go-eth2-client everywhere; this import exists + // ONLY so tests can populate buildergloas struct fields (Slot/Gwei/Signature), + // which are attestantio-typed. Production code converts at the boundary and never + // imports attestantio. Prefer ethpandaops/go-eth2-client outside of this. + attphase0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -38,9 +44,9 @@ func signBuilderPrefsRequest( ) []byte { t.Helper() - auth := ð2gloas.RequestAuth{ + auth := &buildergloas.RequestAuthV1{ Data: []byte(builderURL), - Slot: slot, + Slot: attphase0.Slot(slot), } root, err := auth.HashTreeRoot() require.NoError(t, err) @@ -49,11 +55,11 @@ func signBuilderPrefsRequest( sig, err := s.SignWithDomain(phase0.Root(root), domain) require.NoError(t, err) - req := ð2gloas.BuilderPreferencesRequest{ - Preferences: ð2gloas.BuilderPreferences{MaxExecutionPayment: maxPayment}, - Auth: ð2gloas.SignedRequestAuth{ + req := &buildergloas.BuilderPreferencesRequestV1{ + Preferences: &buildergloas.BuilderPreferencesV1{MaxExecutionPayment: attphase0.Gwei(maxPayment)}, + Auth: &buildergloas.SignedRequestAuthV1{ Message: auth, - Signature: sig, + Signature: attphase0.BLSSignature(sig), }, } body, err := json.Marshal(req) @@ -104,7 +110,7 @@ func TestSubmitBuilderPreferences_Success(t *testing.T) { blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) require.NoError(t, err) - cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) srv.SetEnabled(true) @@ -128,7 +134,7 @@ func TestSubmitBuilderPreferences_LatestOverwrites(t *testing.T) { blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) require.NoError(t, err) - cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) srv.SetEnabled(true) pk := blsSigner.PublicKey() @@ -152,7 +158,7 @@ func TestSubmitBuilderPreferences_WrongBuilderURL(t *testing.T) { blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) require.NoError(t, err) - cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) srv.SetEnabled(true) @@ -178,7 +184,7 @@ func TestSubmitBuilderPreferences_BadSignature(t *testing.T) { other, err := signer.NewBLSSigner(testOtherPrivkey) require.NoError(t, err) - cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) srv.SetEnabled(true) @@ -202,7 +208,7 @@ func TestSubmitBuilderPreferences_NoBuilderURLConfigured(t *testing.T) { blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) require.NoError(t, err) - cfg := &config.BuilderAPIConfig{Port: 0} // BuilderURL empty + cfg := &config.BuilderAPIConfig{} // BuilderURL empty srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) srv.SetEnabled(true) @@ -219,7 +225,7 @@ func TestSubmitBuilderPreferences_NoBuilderURLConfigured(t *testing.T) { } func TestSubmitBuilderPreferences_InvalidJSON(t *testing.T) { - cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) srv.SetEnabled(true) @@ -233,7 +239,7 @@ func TestSubmitBuilderPreferences_InvalidJSON(t *testing.T) { } func TestSubmitBuilderPreferences_MissingContentType(t *testing.T) { - cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) srv.SetEnabled(true) @@ -246,7 +252,7 @@ func TestSubmitBuilderPreferences_MissingContentType(t *testing.T) { } func TestSubmitBuilderPreferences_Disabled(t *testing.T) { - cfg := &config.BuilderAPIConfig{Port: 0, BuilderURL: testBuilderURL} + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) // not enabled diff --git a/pkg/builderapi/gloas/auth.go b/pkg/builderapi/gloas/auth.go index 40886108..193c227e 100644 --- a/pkg/builderapi/gloas/auth.go +++ b/pkg/builderapi/gloas/auth.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - eth2gloas "github.com/ethpandaops/go-eth2-client/spec/gloas" + buildergloas "github.com/attestantio/go-builder-client/api/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/ethpandaops/buildoor/pkg/signer" @@ -36,7 +36,7 @@ var ( // // Returns nil on success, or one of the package's sentinel errors on failure. func VerifyRequestAuth( - signed *eth2gloas.SignedRequestAuth, + signed *buildergloas.SignedRequestAuthV1, validatorPubkey phase0.BLSPubKey, genesisForkVersion phase0.Version, ) error { @@ -55,7 +55,7 @@ func VerifyRequestAuth( domain := signer.ComputeDomain(DomainRequestAuth, genesisForkVersion, phase0.Root{}) signingRoot := signer.ComputeSigningRoot(msgRoot, domain) - if !signer.VerifyBLSSignature(validatorPubkey, signingRoot[:], signed.Signature) { + if !signer.VerifyBLSSignature(validatorPubkey, signingRoot[:], phase0.BLSSignature(signed.Signature)) { return ErrInvalidRequestAuthSignature } return nil diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go index 05821a7c..bb8fbff6 100644 --- a/pkg/builderapi/gloas/auth_test.go +++ b/pkg/builderapi/gloas/auth_test.go @@ -4,7 +4,13 @@ import ( "strings" "testing" - eth2gloas "github.com/ethpandaops/go-eth2-client/spec/gloas" + buildergloas "github.com/attestantio/go-builder-client/api/gloas" + // attphase0 is the attestantio phase0 used by go-builder-client's builder-API + // types. Buildoor uses ethpandaops/go-eth2-client everywhere; this import exists + // ONLY so tests can populate buildergloas struct fields (Slot/Signature), which + // are attestantio-typed. Production code converts at the boundary and never + // imports attestantio. Prefer ethpandaops/go-eth2-client outside of this. + attphase0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" @@ -28,12 +34,12 @@ func signRequestAuth( builderURL []byte, slot phase0.Slot, genesisForkVersion phase0.Version, -) *eth2gloas.SignedRequestAuth { +) *buildergloas.SignedRequestAuthV1 { t.Helper() - msg := ð2gloas.RequestAuth{ + msg := &buildergloas.RequestAuthV1{ Data: builderURL, - Slot: slot, + Slot: attphase0.Slot(slot), } root, err := msg.HashTreeRoot() @@ -44,9 +50,9 @@ func signRequestAuth( sig, err := s.SignWithDomain(root, domain) require.NoError(t, err) - return ð2gloas.SignedRequestAuth{ + return &buildergloas.SignedRequestAuthV1{ Message: msg, - Signature: sig, + Signature: attphase0.BLSSignature(sig), } } @@ -61,7 +67,7 @@ func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { genesisForkVersion := phase0.Version{} - msg := ð2gloas.RequestAuth{ + msg := &buildergloas.RequestAuthV1{ Data: []byte(testBuilderURL), Slot: 1234, } @@ -72,7 +78,7 @@ func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { require.NotEmpty(t, encoded) // Unmarshal from SSZ into a fresh value. - decoded := ð2gloas.RequestAuth{} + decoded := &buildergloas.RequestAuthV1{} require.NoError(t, decoded.UnmarshalSSZ(encoded)) // The decoded value must match the original. @@ -91,9 +97,9 @@ func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { sig, err := validator.SignWithDomain(decodedRoot, domain) require.NoError(t, err) - signed := ð2gloas.SignedRequestAuth{ + signed := &buildergloas.SignedRequestAuthV1{ Message: decoded, - Signature: sig, + Signature: attphase0.BLSSignature(sig), } require.NoError(t, gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion)) } @@ -175,9 +181,9 @@ func TestVerifyRequestAuth_NilMessage(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - signed := ð2gloas.SignedRequestAuth{ + signed := &buildergloas.SignedRequestAuthV1{ Message: nil, - Signature: phase0.BLSSignature{}, + Signature: attphase0.BLSSignature{}, } err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) @@ -188,13 +194,13 @@ func TestVerifyRequestAuth_GarbageSignature(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - signed := ð2gloas.SignedRequestAuth{ - Message: ð2gloas.RequestAuth{ + signed := &buildergloas.SignedRequestAuthV1{ + Message: &buildergloas.RequestAuthV1{ Data: []byte(testBuilderURL), Slot: 1, }, // All-zero signature is not a valid BLS signature. - Signature: phase0.BLSSignature{}, + Signature: attphase0.BLSSignature{}, } err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index b2e26f35..960f73bf 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -22,6 +22,7 @@ import ( "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" + buildergloas "github.com/attestantio/go-builder-client/api/gloas" apiv1fulu "github.com/ethpandaops/go-eth2-client/api/v1/fulu" "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/deneb" @@ -618,7 +619,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req } } if len(authBody) > 0 { - var signedAuth gloas.SignedRequestAuth + var signedAuth buildergloas.SignedRequestAuthV1 if jsonErr := json.Unmarshal(authBody, &signedAuth); jsonErr != nil { log.WithError(jsonErr).Warn("getExecutionPayloadBid: invalid SignedRequestAuth body") writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: "+jsonErr.Error()) @@ -629,7 +630,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: message is null") return } - if signedAuth.Message.Slot != slot { + if phase0.Slot(signedAuth.Message.Slot) != slot { log.WithFields(logrus.Fields{ "auth_slot": signedAuth.Message.Slot, "request_slot": slot, @@ -1019,7 +1020,7 @@ func (s *Server) handleSubmitBuilderPreferences(w http.ResponseWriter, r *http.R return } - var req gloas.BuilderPreferencesRequest + var req buildergloas.BuilderPreferencesRequestV1 if err := json.Unmarshal(body, &req); err != nil { log.WithError(err).Warn("submitBuilderPreferences: invalid JSON body") writeValidatorError(w, http.StatusBadRequest, "invalid BuilderPreferencesRequestV1: "+err.Error()) @@ -1056,7 +1057,7 @@ func (s *Server) handleSubmitBuilderPreferences(w http.ResponseWriter, r *http.R } // Auth validated — record the latest preference (overwrites any previous value). - s.builderPrefsStore.Set(validatorPubkey, req.Preferences.MaxExecutionPayment) + s.builderPrefsStore.Set(validatorPubkey, phase0.Gwei(req.Preferences.MaxExecutionPayment)) log.WithFields(logrus.Fields{ "validator_pubkey": "0x" + hex.EncodeToString(validatorPubkey[:]), "max_execution_payment": uint64(req.Preferences.MaxExecutionPayment), From d95c76f72b500f4e7091fc65ea629eb91194f05a Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Sun, 14 Jun 2026 10:37:06 +0200 Subject: [PATCH 18/22] builder preferences UI --- pkg/webui/src/App.tsx | 2 + .../src/components/BuilderPreferencesList.tsx | 154 ++++++++++++++++++ pkg/webui/src/components/HeaderNav.tsx | 1 + pkg/webui/src/hooks/useBuilderPreferences.ts | 48 ++++++ .../src/pages/BuilderPreferencesPage.tsx | 13 ++ pkg/webui/src/stores/viewStore.ts | 10 +- pkg/webui/src/types.ts | 10 ++ 7 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 pkg/webui/src/components/BuilderPreferencesList.tsx create mode 100644 pkg/webui/src/hooks/useBuilderPreferences.ts create mode 100644 pkg/webui/src/pages/BuilderPreferencesPage.tsx diff --git a/pkg/webui/src/App.tsx b/pkg/webui/src/App.tsx index b3cd03ee..ebf6e38e 100644 --- a/pkg/webui/src/App.tsx +++ b/pkg/webui/src/App.tsx @@ -6,6 +6,7 @@ const DashboardPage = React.lazy(() => import('./pages/DashboardPage')); const ValidatorsPage = React.lazy(() => import('./pages/ValidatorsPage')); const BidsWonPage = React.lazy(() => import('./pages/BidsWonPage')); const ProposerPreferencesPage = React.lazy(() => import('./pages/ProposerPreferencesPage')); +const BuilderPreferencesPage = React.lazy(() => import('./pages/BuilderPreferencesPage')); const AuditLogPage = React.lazy(() => import('./pages/AuditLogPage')); const ApiDocsPage = React.lazy(() => import('./pages/ApiDocsPage')); @@ -21,6 +22,7 @@ export const App: React.FC = () => { {currentView === 'validators' && } {currentView === 'bids-won' && } {currentView === 'proposer-preferences' && } + {currentView === 'builder-preferences' && } {currentView === 'audit-log' && } {currentView === 'api-docs' && } diff --git a/pkg/webui/src/components/BuilderPreferencesList.tsx b/pkg/webui/src/components/BuilderPreferencesList.tsx new file mode 100644 index 00000000..84542d74 --- /dev/null +++ b/pkg/webui/src/components/BuilderPreferencesList.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import type { BuilderPreference } from '../types'; +import { Pagination } from './Pagination'; + +interface BuilderPreferencesListProps { + preferences: BuilderPreference[]; + loading?: boolean; + error?: string | null; +} + +function copyToClipboard(text: string) { + navigator.clipboard.writeText(text).catch((err) => { + console.error('Failed to copy:', err); + }); +} + +function formatEth(gwei: number): string { + // 1 ETH = 1e9 Gwei + return (gwei / 1e9).toLocaleString(undefined, { maximumFractionDigits: 9 }); +} + +export const BuilderPreferencesList: React.FC = ({ + preferences, + loading, + error, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [offset, setOffset] = useState(0); + const limit = 50; + + const filtered = useMemo(() => { + if (!searchTerm) return preferences; + const term = searchTerm.toLowerCase(); + return preferences.filter( + (p) => + p.validator_pubkey.toLowerCase().includes(term) || + String(p.max_execution_payment).includes(term), + ); + }, [preferences, searchTerm]); + + const total = filtered.length; + const paged = useMemo(() => filtered.slice(offset, offset + limit), [filtered, offset, limit]); + + useEffect(() => { + setOffset(0); + }, [searchTerm]); + + useEffect(() => { + if (total === 0) { + if (offset !== 0) setOffset(0); + return; + } + const maxOffset = Math.floor((total - 1) / limit) * limit; + if (offset > maxOffset) setOffset(maxOffset); + }, [total, offset, limit]); + + if (loading) { + return ( +
+
+
Builder Preferences
+
+
+
Loading...
+
+
+ ); + } + + if (error && error.includes('not enabled')) { + return ( +
+
+
Builder Preferences
+
+
+
+ Builder API not enabled. Run with --builder-api-enabled to receive proposer submissions. +
+
+
+ ); + } + + return ( +
+
+
Builder Preferences
+ {preferences.length} +
+
+ {preferences.length === 0 ? ( +
No builder preferences received yet
+ ) : ( + <> +
+ setSearchTerm(e.target.value)} + /> +
+ +
+ + + + + + + + + + {paged.length === 0 ? ( + + + + ) : ( + paged.map((pref, idx) => ( + + + + + + )) + )} + +
Validator PubkeyMax Execution Payment (Gwei)ETH
+ No preferences match your search +
+ copyToClipboard(pref.validator_pubkey)} + title="Click to copy" + > + {pref.validator_pubkey} + + + {pref.max_execution_payment.toLocaleString()} + + {formatEth(pref.max_execution_payment)} +
+
+ + + + )} +
+
+ ); +}; diff --git a/pkg/webui/src/components/HeaderNav.tsx b/pkg/webui/src/components/HeaderNav.tsx index feeb344f..22d59cb4 100644 --- a/pkg/webui/src/components/HeaderNav.tsx +++ b/pkg/webui/src/components/HeaderNav.tsx @@ -10,6 +10,7 @@ const NAV_ITEMS: Array<{ view: ViewType; label: string; requiresAuth?: boolean } { view: 'bids-won', label: 'Bids Won' }, { view: 'validators', label: 'Validators' }, { view: 'proposer-preferences', label: 'Proposer Prefs' }, + { view: 'builder-preferences', label: 'Builder Prefs' }, { view: 'audit-log', label: 'Audit Log', requiresAuth: true }, { view: 'api-docs', label: 'API' }, ]; diff --git a/pkg/webui/src/hooks/useBuilderPreferences.ts b/pkg/webui/src/hooks/useBuilderPreferences.ts new file mode 100644 index 00000000..fdcb9a28 --- /dev/null +++ b/pkg/webui/src/hooks/useBuilderPreferences.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react'; +import type { BuilderPreference, BuilderPreferencesResponse } from '../types'; + +export function useBuilderPreferences() { + const [preferences, setPreferences] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const initialFetchDone = useRef(false); + + const fetchPreferences = async () => { + try { + const response = await fetch('/api/buildoor/builder-preferences'); + + if (response.status === 404) { + setPreferences([]); + setError('builder API not enabled'); + return; + } + + if (!response.ok) { + throw new Error(`Failed to fetch builder preferences: ${response.statusText}`); + } + + const data: BuilderPreferencesResponse = await response.json(); + const sorted = (data.preferences || []) + .slice() + .sort((a, b) => b.max_execution_payment - a.max_execution_payment); + setPreferences(sorted); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + if (!initialFetchDone.current) { + initialFetchDone.current = true; + setLoading(false); + } + } + }; + + useEffect(() => { + fetchPreferences(); + // Poll every 12 seconds (one slot) — proposers submit preferences ahead of their slot + const interval = setInterval(fetchPreferences, 12000); + return () => clearInterval(interval); + }, []); + + return { preferences, loading, error, refetch: fetchPreferences }; +} diff --git a/pkg/webui/src/pages/BuilderPreferencesPage.tsx b/pkg/webui/src/pages/BuilderPreferencesPage.tsx new file mode 100644 index 00000000..0b0264f5 --- /dev/null +++ b/pkg/webui/src/pages/BuilderPreferencesPage.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { useBuilderPreferences } from '../hooks/useBuilderPreferences'; +import { BuilderPreferencesList } from '../components/BuilderPreferencesList'; + +const BuilderPreferencesPage: React.FC = () => { + const { preferences, loading, error } = useBuilderPreferences(); + + return ( + + ); +}; + +export default BuilderPreferencesPage; diff --git a/pkg/webui/src/stores/viewStore.ts b/pkg/webui/src/stores/viewStore.ts index abb72349..7ed05ada 100644 --- a/pkg/webui/src/stores/viewStore.ts +++ b/pkg/webui/src/stores/viewStore.ts @@ -1,12 +1,20 @@ import { useSyncExternalStore } from 'react'; -export type ViewType = 'dashboard' | 'bids-won' | 'validators' | 'proposer-preferences' | 'audit-log' | 'api-docs'; +export type ViewType = + | 'dashboard' + | 'bids-won' + | 'validators' + | 'proposer-preferences' + | 'builder-preferences' + | 'audit-log' + | 'api-docs'; const VIEW_PATHS: Record = { dashboard: '/', 'bids-won': '/bids-won', validators: '/validators', 'proposer-preferences': '/proposer-preferences', + 'builder-preferences': '/builder-preferences', 'audit-log': '/audit-log', 'api-docs': '/api-docs', }; diff --git a/pkg/webui/src/types.ts b/pkg/webui/src/types.ts index 8d1a919c..b36d3a24 100644 --- a/pkg/webui/src/types.ts +++ b/pkg/webui/src/types.ts @@ -308,3 +308,13 @@ export interface ProposerPreference { export interface ProposerPreferencesResponse { preferences: ProposerPreference[]; } + +// Builder preferences types +export interface BuilderPreference { + validator_pubkey: string; + max_execution_payment: number; // Gwei +} + +export interface BuilderPreferencesResponse { + preferences: BuilderPreference[]; +} From 0b2a51b053db92d5c3a2bc30e953d73af4816811 Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Sun, 14 Jun 2026 11:30:48 +0200 Subject: [PATCH 19/22] fix go vet --- pkg/webui/handlers/api/builder_preferences_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/webui/handlers/api/builder_preferences_test.go b/pkg/webui/handlers/api/builder_preferences_test.go index e4b69bcf..28512ded 100644 --- a/pkg/webui/handlers/api/builder_preferences_test.go +++ b/pkg/webui/handlers/api/builder_preferences_test.go @@ -18,7 +18,7 @@ import ( func TestGetBuilderPreferences_NotEnabled(t *testing.T) { // No builder API service wired → 404. - h := NewAPIHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil) + h := NewAPIHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/buildoor/builder-preferences", nil) rec := httptest.NewRecorder() @@ -28,15 +28,16 @@ func TestGetBuilderPreferences_NotEnabled(t *testing.T) { } func TestGetBuilderPreferences_ReturnsEntries(t *testing.T) { - cfg := &config.BuilderAPIConfig{Port: 0} + cfg := &config.BuilderAPIConfig{} srv := builderapi.NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) var pk phase0.BLSPubKey pk[0] = 0xab srv.GetBuilderPreferencesStore().Set(pk, 5_000_000_000) - // builderSvc (2nd arg) nil so the event stream manager does not start. - h := NewAPIHandler(nil, nil, nil, nil, nil, nil, srv, nil, nil) + // builderSvc (4th arg) nil so the event stream manager does not start; + // srv is passed as builderAPISvc (9th arg). + h := NewAPIHandler(nil, nil, nil, nil, nil, nil, nil, nil, srv, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/buildoor/builder-preferences", nil) rec := httptest.NewRecorder() From 8c33faba5dfb793c2976ee48a43cd56c534c51fc Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Sun, 14 Jun 2026 11:35:09 +0200 Subject: [PATCH 20/22] run go fmt --- pkg/builderapi/server.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 960f73bf..912acbdd 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -19,10 +19,10 @@ import ( "sync/atomic" "time" + buildergloas "github.com/attestantio/go-builder-client/api/gloas" "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" - buildergloas "github.com/attestantio/go-builder-client/api/gloas" apiv1fulu "github.com/ethpandaops/go-eth2-client/api/v1/fulu" "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/deneb" @@ -38,9 +38,9 @@ import ( "github.com/ethpandaops/buildoor/pkg/builderapi/validators" "github.com/ethpandaops/buildoor/pkg/chain" "github.com/ethpandaops/buildoor/pkg/config" + "github.com/ethpandaops/buildoor/pkg/db" "github.com/ethpandaops/buildoor/pkg/proposerpreferences" "github.com/ethpandaops/buildoor/pkg/rpc/beacon" - "github.com/ethpandaops/buildoor/pkg/db" "github.com/ethpandaops/buildoor/pkg/signer" ) @@ -97,7 +97,7 @@ type Server struct { genesisForkVersion phase0.Version // genesis fork version for builder domain (mev-boost-relay style) forkVersion phase0.Version // current fork version for chain-specific verification genesisValidatorsRoot phase0.Root // genesis validators root for chain-specific verification - stateDB *db.Database // optional: persistent won-block store (may be nil/disabled) + stateDB *db.Database // optional: persistent won-block store (may be nil/disabled) } // NewServer creates a new server. builderSvc may be nil; if set, buildoor-specific From 1d2e9b6bfd704386cef9ff390cf552902b6ff38b Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Sun, 14 Jun 2026 18:34:22 +0200 Subject: [PATCH 21/22] support ssz --- pkg/builderapi/builder_preferences_test.go | 57 ++++++++++++++ pkg/builderapi/encoding.go | 71 ++++++++++++++++++ pkg/builderapi/encoding_test.go | 86 ++++++++++++++++++++++ pkg/builderapi/server.go | 34 +++++---- 4 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 pkg/builderapi/encoding.go create mode 100644 pkg/builderapi/encoding_test.go diff --git a/pkg/builderapi/builder_preferences_test.go b/pkg/builderapi/builder_preferences_test.go index 4a2f6c4f..0a0bcaa4 100644 --- a/pkg/builderapi/builder_preferences_test.go +++ b/pkg/builderapi/builder_preferences_test.go @@ -129,6 +129,63 @@ func TestSubmitBuilderPreferences_Success(t *testing.T) { assert.Equal(t, phase0.Gwei(5_000_000_000), got) } +func TestSubmitBuilderPreferences_SuccessSSZ(t *testing.T) { + gfv := phase0.Version{} + blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) + require.NoError(t, err) + + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, gfv, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + // Build the same signed request as the JSON path, but submit it SSZ-encoded. + jsonBody := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, 5_000_000_000, gfv) + var prefsReq buildergloas.BuilderPreferencesRequestV1 + require.NoError(t, json.Unmarshal(jsonBody, &prefsReq)) + sszBody, err := prefsReq.MarshalSSZ() + require.NoError(t, err) + + pk := blsSigner.PublicKey() + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(pk[:]) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(sszBody)) + req.Header.Set("Content-Type", "application/octet-stream") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + require.Equal(t, http.StatusAccepted, rec.Code) + got, ok := srv.builderPrefsStore.Get(pk) + require.True(t, ok, "preference should be stored after successful SSZ-decoded auth") + assert.Equal(t, phase0.Gwei(5_000_000_000), got) +} + +func TestSubmitBuilderPreferences_MalformedSSZ(t *testing.T) { + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte{0x01, 0x02, 0x03})) + req.Header.Set("Content-Type", "application/octet-stream") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestSubmitBuilderPreferences_UnknownContentType(t *testing.T) { + cfg := &config.BuilderAPIConfig{BuilderURL: testBuilderURL} + srv := NewServer(cfg, logrus.New(), nil, nil, nil, phase0.Version{}, phase0.Version{}, phase0.Root{}) + srv.SetEnabled(true) + + url := "/eth/v1/builder/builder_preferences/0x" + hex.EncodeToString(make([]byte, 48)) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "text/plain") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code) +} + func TestSubmitBuilderPreferences_LatestOverwrites(t *testing.T) { gfv := phase0.Version{} blsSigner, err := signer.NewBLSSigner(testValidatorPrivkey) diff --git a/pkg/builderapi/encoding.go b/pkg/builderapi/encoding.go new file mode 100644 index 00000000..8cf796a3 --- /dev/null +++ b/pkg/builderapi/encoding.go @@ -0,0 +1,71 @@ +package builderapi + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + buildergloas "github.com/attestantio/go-builder-client/api/gloas" +) + +// Wire formats accepted on Gloas builder-API request bodies. The builder-spec +// permits either JSON or SSZ, selected via the Content-Type header. +const ( + contentTypeJSON = "application/json" + contentTypeSSZ = "application/octet-stream" +) + +// errUnsupportedContentType lets handlers map an unknown Content-Type to a 415 +// response, distinct from a decode failure (which is a 400). +var errUnsupportedContentType = errors.New("unsupported content type") + +// normalizeContentType lowercases the media type and drops any parameters +// (e.g. "application/json; charset=utf-8" -> "application/json"). +func normalizeContentType(ct string) string { + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + return strings.ToLower(strings.TrimSpace(ct)) +} + +// parseSignedRequestAuth decodes a SignedRequestAuthV1 from JSON or SSZ selected +// by contentType. The Content-Type must be set explicitly to one of the two +// supported media types; an empty or unrecognized type returns +// errUnsupportedContentType (which handlers map to 415). +func parseSignedRequestAuth(data []byte, contentType string) (*buildergloas.SignedRequestAuthV1, error) { + var v buildergloas.SignedRequestAuthV1 + switch normalizeContentType(contentType) { + case contentTypeSSZ: + if err := v.UnmarshalSSZ(data); err != nil { + return nil, fmt.Errorf("invalid SSZ SignedRequestAuthV1: %w", err) + } + case contentTypeJSON: + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("invalid SignedRequestAuthV1: %w", err) + } + default: + return nil, errUnsupportedContentType + } + return &v, nil +} + +// parseBuilderPreferencesRequest decodes a BuilderPreferencesRequestV1 from JSON +// or SSZ selected by contentType, following the same rules as +// parseSignedRequestAuth. +func parseBuilderPreferencesRequest(data []byte, contentType string) (*buildergloas.BuilderPreferencesRequestV1, error) { + var v buildergloas.BuilderPreferencesRequestV1 + switch normalizeContentType(contentType) { + case contentTypeSSZ: + if err := v.UnmarshalSSZ(data); err != nil { + return nil, fmt.Errorf("invalid SSZ BuilderPreferencesRequestV1: %w", err) + } + case contentTypeJSON: + if err := json.Unmarshal(data, &v); err != nil { + return nil, fmt.Errorf("invalid BuilderPreferencesRequestV1: %w", err) + } + default: + return nil, errUnsupportedContentType + } + return &v, nil +} diff --git a/pkg/builderapi/encoding_test.go b/pkg/builderapi/encoding_test.go new file mode 100644 index 00000000..f2ef9865 --- /dev/null +++ b/pkg/builderapi/encoding_test.go @@ -0,0 +1,86 @@ +package builderapi + +import ( + "encoding/json" + "testing" + + buildergloas "github.com/attestantio/go-builder-client/api/gloas" + // attphase0 is the attestantio phase0 used by go-builder-client's builder-API + // types; imported here ONLY to populate buildergloas struct fields in tests. + attphase0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleSignedRequestAuth() *buildergloas.SignedRequestAuthV1 { + var sig attphase0.BLSSignature + sig[0] = 0xaa + return &buildergloas.SignedRequestAuthV1{ + Message: &buildergloas.RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: 42}, + Signature: sig, + } +} + +func TestParseSignedRequestAuth_JSONAndSSZRoundTrip(t *testing.T) { + orig := sampleSignedRequestAuth() + + jsonBody, err := json.Marshal(orig) + require.NoError(t, err) + sszBody, err := orig.MarshalSSZ() + require.NoError(t, err) + + fromJSON, err := parseSignedRequestAuth(jsonBody, "application/json") + require.NoError(t, err) + fromSSZ, err := parseSignedRequestAuth(sszBody, "application/octet-stream") + require.NoError(t, err) + + // Content-Type parameters are tolerated. + fromJSONParam, err := parseSignedRequestAuth(jsonBody, "application/json; charset=utf-8") + require.NoError(t, err) + + for _, got := range []*buildergloas.SignedRequestAuthV1{fromJSON, fromSSZ, fromJSONParam} { + require.NotNil(t, got.Message) + assert.Equal(t, orig.Message.Slot, got.Message.Slot) + assert.Equal(t, orig.Message.Data, got.Message.Data) + assert.Equal(t, orig.Signature, got.Signature) + } +} + +func TestParseSignedRequestAuth_Errors(t *testing.T) { + ssz, err := sampleSignedRequestAuth().MarshalSSZ() + require.NoError(t, err) + + // Unknown / empty Content-Type -> errUnsupportedContentType. + _, err = parseSignedRequestAuth(ssz, "text/plain") + require.ErrorIs(t, err, errUnsupportedContentType) + _, err = parseSignedRequestAuth(ssz, "") + require.ErrorIs(t, err, errUnsupportedContentType) + + // Malformed bodies -> decode error (not the content-type sentinel). + _, err = parseSignedRequestAuth([]byte("not json"), "application/json") + require.Error(t, err) + assert.NotErrorIs(t, err, errUnsupportedContentType) + _, err = parseSignedRequestAuth([]byte{0x01}, "application/octet-stream") + require.Error(t, err) + assert.NotErrorIs(t, err, errUnsupportedContentType) +} + +func TestParseBuilderPreferencesRequest_SSZRoundTrip(t *testing.T) { + orig := &buildergloas.BuilderPreferencesRequestV1{ + Preferences: &buildergloas.BuilderPreferencesV1{MaxExecutionPayment: 5_000_000_000}, + Auth: sampleSignedRequestAuth(), + } + + ssz, err := orig.MarshalSSZ() + require.NoError(t, err) + + got, err := parseBuilderPreferencesRequest(ssz, "application/octet-stream") + require.NoError(t, err) + require.NotNil(t, got.Preferences) + assert.Equal(t, orig.Preferences.MaxExecutionPayment, got.Preferences.MaxExecutionPayment) + require.NotNil(t, got.Auth) + assert.Equal(t, orig.Auth.Message.Slot, got.Auth.Message.Slot) + + _, err = parseBuilderPreferencesRequest(ssz, "") + require.ErrorIs(t, err, errUnsupportedContentType) +} diff --git a/pkg/builderapi/server.go b/pkg/builderapi/server.go index 912acbdd..52fbc306 100644 --- a/pkg/builderapi/server.go +++ b/pkg/builderapi/server.go @@ -10,6 +10,7 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "math/big" @@ -19,7 +20,6 @@ import ( "sync/atomic" "time" - buildergloas "github.com/attestantio/go-builder-client/api/gloas" "github.com/ethpandaops/go-eth2-client/api" apiv1 "github.com/ethpandaops/go-eth2-client/api/v1" apiv1electra "github.com/ethpandaops/go-eth2-client/api/v1/electra" @@ -619,10 +619,14 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req } } if len(authBody) > 0 { - var signedAuth buildergloas.SignedRequestAuthV1 - if jsonErr := json.Unmarshal(authBody, &signedAuth); jsonErr != nil { - log.WithError(jsonErr).Warn("getExecutionPayloadBid: invalid SignedRequestAuth body") - writeValidatorError(w, http.StatusBadRequest, "invalid SignedRequestAuthV1: "+jsonErr.Error()) + signedAuth, parseErr := parseSignedRequestAuth(authBody, r.Header.Get("Content-Type")) + if parseErr != nil { + code := http.StatusBadRequest + if errors.Is(parseErr, errUnsupportedContentType) { + code = http.StatusUnsupportedMediaType + } + log.WithError(parseErr).Warn("getExecutionPayloadBid: invalid SignedRequestAuth body") + writeValidatorError(w, code, "invalid SignedRequestAuthV1: "+parseErr.Error()) return } if signedAuth.Message == nil { @@ -647,7 +651,7 @@ func (s *Server) handleGetExecutionPayloadBid(w http.ResponseWriter, r *http.Req return } - if authErr := gloasauth.VerifyRequestAuth(&signedAuth, proposerPubkey, s.genesisForkVersion); authErr != nil { + if authErr := gloasauth.VerifyRequestAuth(signedAuth, proposerPubkey, s.genesisForkVersion); authErr != nil { log.WithError(authErr).Warn("getExecutionPayloadBid: SignedRequestAuth signature verification failed") writeValidatorError(w, http.StatusUnauthorized, "invalid SignedRequestAuthV1: signature verification failed") return @@ -998,12 +1002,6 @@ func (s *Server) handleSubmitBuilderPreferences(w http.ResponseWriter, r *http.R return } - if r.Header.Get("Content-Type") != "application/json" { - log.WithField("content_type", r.Header.Get("Content-Type")).Warn("submitBuilderPreferences: rejected — Content-Type must be application/json") - writeValidatorError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json") - return - } - pubkeyBytes, hexErr := hex.DecodeString(trimHex(mux.Vars(r)["validator_pubkey"])) if hexErr != nil || len(pubkeyBytes) != 48 { log.WithError(hexErr).Warn("submitBuilderPreferences: invalid validator_pubkey") @@ -1020,10 +1018,14 @@ func (s *Server) handleSubmitBuilderPreferences(w http.ResponseWriter, r *http.R return } - var req buildergloas.BuilderPreferencesRequestV1 - if err := json.Unmarshal(body, &req); err != nil { - log.WithError(err).Warn("submitBuilderPreferences: invalid JSON body") - writeValidatorError(w, http.StatusBadRequest, "invalid BuilderPreferencesRequestV1: "+err.Error()) + req, parseErr := parseBuilderPreferencesRequest(body, r.Header.Get("Content-Type")) + if parseErr != nil { + code := http.StatusBadRequest + if errors.Is(parseErr, errUnsupportedContentType) { + code = http.StatusUnsupportedMediaType + } + log.WithError(parseErr).Warn("submitBuilderPreferences: invalid request body") + writeValidatorError(w, code, "invalid BuilderPreferencesRequestV1: "+parseErr.Error()) return } if req.Preferences == nil { From 7ae2d62132035153c3f7da08624d9bd1b180e6ab Mon Sep 17 00:00:00 2001 From: Bharath Vedartham Date: Mon, 15 Jun 2026 17:15:26 +0200 Subject: [PATCH 22/22] move builder api types to buildoor --- go.mod | 9 +- go.sum | 6 - pkg/builderapi/builder_preferences_test.go | 22 ++- pkg/builderapi/encoding.go | 10 +- pkg/builderapi/encoding_test.go | 21 ++- pkg/builderapi/gloas/auth.go | 4 +- pkg/builderapi/gloas/auth_test.go | 36 ++--- .../types/builderpreferencesrequestv1.go | 39 +++++ .../types/builderpreferencesrequestv1_json.go | 54 +++++++ .../types/builderpreferencesrequestv1_ssz.go | 136 ++++++++++++++++++ .../types/builderpreferencesrequestv1_yaml.go | 49 +++++++ .../gloas/types/builderpreferencesv1.go | 40 ++++++ .../gloas/types/builderpreferencesv1_json.go | 54 +++++++ .../gloas/types/builderpreferencesv1_ssz.go | 75 ++++++++++ .../gloas/types/builderpreferencesv1_yaml.go | 49 +++++++ pkg/builderapi/gloas/types/generate.go | 17 +++ pkg/builderapi/gloas/types/generate.yaml | 13 ++ pkg/builderapi/gloas/types/requestauthv1.go | 46 ++++++ .../gloas/types/requestauthv1_json.go | 73 ++++++++++ .../gloas/types/requestauthv1_ssz.go | 117 +++++++++++++++ .../gloas/types/requestauthv1_yaml.go | 50 +++++++ .../gloas/types/signedrequestauthv1.go | 40 ++++++ .../gloas/types/signedrequestauthv1_json.go | 65 +++++++++ .../gloas/types/signedrequestauthv1_ssz.go | 119 +++++++++++++++ .../gloas/types/signedrequestauthv1_yaml.go | 50 +++++++ 25 files changed, 1129 insertions(+), 65 deletions(-) create mode 100644 pkg/builderapi/gloas/types/builderpreferencesrequestv1.go create mode 100644 pkg/builderapi/gloas/types/builderpreferencesrequestv1_json.go create mode 100644 pkg/builderapi/gloas/types/builderpreferencesrequestv1_ssz.go create mode 100644 pkg/builderapi/gloas/types/builderpreferencesrequestv1_yaml.go create mode 100644 pkg/builderapi/gloas/types/builderpreferencesv1.go create mode 100644 pkg/builderapi/gloas/types/builderpreferencesv1_json.go create mode 100644 pkg/builderapi/gloas/types/builderpreferencesv1_ssz.go create mode 100644 pkg/builderapi/gloas/types/builderpreferencesv1_yaml.go create mode 100644 pkg/builderapi/gloas/types/generate.go create mode 100644 pkg/builderapi/gloas/types/generate.yaml create mode 100644 pkg/builderapi/gloas/types/requestauthv1.go create mode 100644 pkg/builderapi/gloas/types/requestauthv1_json.go create mode 100644 pkg/builderapi/gloas/types/requestauthv1_ssz.go create mode 100644 pkg/builderapi/gloas/types/requestauthv1_yaml.go create mode 100644 pkg/builderapi/gloas/types/signedrequestauthv1.go create mode 100644 pkg/builderapi/gloas/types/signedrequestauthv1_json.go create mode 100644 pkg/builderapi/gloas/types/signedrequestauthv1_ssz.go create mode 100644 pkg/builderapi/gloas/types/signedrequestauthv1_yaml.go diff --git a/go.mod b/go.mod index 2e75af7d..47d52a51 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,18 @@ module github.com/ethpandaops/buildoor go 1.25.7 require ( - github.com/attestantio/go-builder-client v0.0.0-00010101000000-000000000000 - github.com/attestantio/go-eth2-client v0.27.1 github.com/ethereum/go-ethereum v1.17.3 github.com/ethpandaops/go-eth2-client v0.1.3 github.com/ethpandaops/service-authenticatoor v0.0.1 github.com/glebarez/go-sqlite v1.22.0 + github.com/goccy/go-yaml v1.19.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/mux v1.8.1 github.com/herumi/bls-eth-go-binary v1.37.0 github.com/holiman/uint256 v1.3.2 github.com/jmoiron/sqlx v1.4.0 github.com/pk910/dynamic-ssz v1.3.2-0.20260505131440-111bcb265c8f + github.com/pkg/errors v0.9.1 github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 github.com/rs/zerolog v1.35.1 @@ -60,7 +60,6 @@ require ( github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -83,12 +82,10 @@ require ( github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pk910/hashtree-bindings v0.1.0 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 // indirect github.com/r3labs/sse/v2 v2.10.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -126,4 +123,4 @@ require ( modernc.org/sqlite v1.49.1 // indirect ) -replace github.com/attestantio/go-builder-client => github.com/bharath-123/go-builder-client v0.0.0-20260614061206-3d327877ba19 +tool github.com/pk910/dynamic-ssz/dynssz-gen diff --git a/go.sum b/go.sum index fa76811f..c191cdad 100644 --- a/go.sum +++ b/go.sum @@ -19,12 +19,8 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= -github.com/attestantio/go-eth2-client v0.27.1 h1:g7bm+gG/p+gfzYdEuxuAepVWYb8EO+2KojV5/Lo2BxM= -github.com/attestantio/go-eth2-client v0.27.1/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bharath-123/go-builder-client v0.0.0-20260614061206-3d327877ba19 h1:nlEc90bi1ObfWrGS+3xvFxu44Gxv/gQZIolzdydkfMY= -github.com/bharath-123/go-builder-client v0.0.0-20260614061206-3d327877ba19/go.mod h1:DUKOrPS+kfquqoUh5rbCoZempShC4NiSxKv2VrTlbZs= github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= @@ -227,8 +223,6 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= -github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 h1:lC8kiphgdOBTcbTvo8MwkvpKjO0SlAgjv4xIK5FGJ94= -github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15/go.mod h1:8svFBIKKu31YriBG/pNizo9N0Jr9i5PQ+dFkxWg3x5k= github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= diff --git a/pkg/builderapi/builder_preferences_test.go b/pkg/builderapi/builder_preferences_test.go index 0a0bcaa4..86a7fb6e 100644 --- a/pkg/builderapi/builder_preferences_test.go +++ b/pkg/builderapi/builder_preferences_test.go @@ -8,19 +8,13 @@ import ( "net/http/httptest" "testing" - buildergloas "github.com/attestantio/go-builder-client/api/gloas" - // attphase0 is the attestantio phase0 used by go-builder-client's builder-API - // types. Buildoor uses ethpandaops/go-eth2-client everywhere; this import exists - // ONLY so tests can populate buildergloas struct fields (Slot/Gwei/Signature), - // which are attestantio-typed. Production code converts at the boundary and never - // imports attestantio. Prefer ethpandaops/go-eth2-client outside of this. - attphase0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" gloasauth "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" "github.com/ethpandaops/buildoor/pkg/config" "github.com/ethpandaops/buildoor/pkg/signer" ) @@ -44,9 +38,9 @@ func signBuilderPrefsRequest( ) []byte { t.Helper() - auth := &buildergloas.RequestAuthV1{ + auth := &gloastypes.RequestAuthV1{ Data: []byte(builderURL), - Slot: attphase0.Slot(slot), + Slot: slot, } root, err := auth.HashTreeRoot() require.NoError(t, err) @@ -55,11 +49,11 @@ func signBuilderPrefsRequest( sig, err := s.SignWithDomain(phase0.Root(root), domain) require.NoError(t, err) - req := &buildergloas.BuilderPreferencesRequestV1{ - Preferences: &buildergloas.BuilderPreferencesV1{MaxExecutionPayment: attphase0.Gwei(maxPayment)}, - Auth: &buildergloas.SignedRequestAuthV1{ + req := &gloastypes.BuilderPreferencesRequestV1{ + Preferences: &gloastypes.BuilderPreferencesV1{MaxExecutionPayment: maxPayment}, + Auth: &gloastypes.SignedRequestAuthV1{ Message: auth, - Signature: attphase0.BLSSignature(sig), + Signature: sig, }, } body, err := json.Marshal(req) @@ -140,7 +134,7 @@ func TestSubmitBuilderPreferences_SuccessSSZ(t *testing.T) { // Build the same signed request as the JSON path, but submit it SSZ-encoded. jsonBody := signBuilderPrefsRequest(t, blsSigner, testBuilderURL, 100, 5_000_000_000, gfv) - var prefsReq buildergloas.BuilderPreferencesRequestV1 + var prefsReq gloastypes.BuilderPreferencesRequestV1 require.NoError(t, json.Unmarshal(jsonBody, &prefsReq)) sszBody, err := prefsReq.MarshalSSZ() require.NoError(t, err) diff --git a/pkg/builderapi/encoding.go b/pkg/builderapi/encoding.go index 8cf796a3..d646962d 100644 --- a/pkg/builderapi/encoding.go +++ b/pkg/builderapi/encoding.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - buildergloas "github.com/attestantio/go-builder-client/api/gloas" + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" ) // Wire formats accepted on Gloas builder-API request bodies. The builder-spec @@ -33,8 +33,8 @@ func normalizeContentType(ct string) string { // by contentType. The Content-Type must be set explicitly to one of the two // supported media types; an empty or unrecognized type returns // errUnsupportedContentType (which handlers map to 415). -func parseSignedRequestAuth(data []byte, contentType string) (*buildergloas.SignedRequestAuthV1, error) { - var v buildergloas.SignedRequestAuthV1 +func parseSignedRequestAuth(data []byte, contentType string) (*gloastypes.SignedRequestAuthV1, error) { + var v gloastypes.SignedRequestAuthV1 switch normalizeContentType(contentType) { case contentTypeSSZ: if err := v.UnmarshalSSZ(data); err != nil { @@ -53,8 +53,8 @@ func parseSignedRequestAuth(data []byte, contentType string) (*buildergloas.Sign // parseBuilderPreferencesRequest decodes a BuilderPreferencesRequestV1 from JSON // or SSZ selected by contentType, following the same rules as // parseSignedRequestAuth. -func parseBuilderPreferencesRequest(data []byte, contentType string) (*buildergloas.BuilderPreferencesRequestV1, error) { - var v buildergloas.BuilderPreferencesRequestV1 +func parseBuilderPreferencesRequest(data []byte, contentType string) (*gloastypes.BuilderPreferencesRequestV1, error) { + var v gloastypes.BuilderPreferencesRequestV1 switch normalizeContentType(contentType) { case contentTypeSSZ: if err := v.UnmarshalSSZ(data); err != nil { diff --git a/pkg/builderapi/encoding_test.go b/pkg/builderapi/encoding_test.go index f2ef9865..0daf79c5 100644 --- a/pkg/builderapi/encoding_test.go +++ b/pkg/builderapi/encoding_test.go @@ -4,19 +4,18 @@ import ( "encoding/json" "testing" - buildergloas "github.com/attestantio/go-builder-client/api/gloas" - // attphase0 is the attestantio phase0 used by go-builder-client's builder-API - // types; imported here ONLY to populate buildergloas struct fields in tests. - attphase0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" ) -func sampleSignedRequestAuth() *buildergloas.SignedRequestAuthV1 { - var sig attphase0.BLSSignature +func sampleSignedRequestAuth() *gloastypes.SignedRequestAuthV1 { + var sig phase0.BLSSignature sig[0] = 0xaa - return &buildergloas.SignedRequestAuthV1{ - Message: &buildergloas.RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: 42}, + return &gloastypes.SignedRequestAuthV1{ + Message: &gloastypes.RequestAuthV1{Data: []byte("https://builder.example.com"), Slot: 42}, Signature: sig, } } @@ -38,7 +37,7 @@ func TestParseSignedRequestAuth_JSONAndSSZRoundTrip(t *testing.T) { fromJSONParam, err := parseSignedRequestAuth(jsonBody, "application/json; charset=utf-8") require.NoError(t, err) - for _, got := range []*buildergloas.SignedRequestAuthV1{fromJSON, fromSSZ, fromJSONParam} { + for _, got := range []*gloastypes.SignedRequestAuthV1{fromJSON, fromSSZ, fromJSONParam} { require.NotNil(t, got.Message) assert.Equal(t, orig.Message.Slot, got.Message.Slot) assert.Equal(t, orig.Message.Data, got.Message.Data) @@ -66,8 +65,8 @@ func TestParseSignedRequestAuth_Errors(t *testing.T) { } func TestParseBuilderPreferencesRequest_SSZRoundTrip(t *testing.T) { - orig := &buildergloas.BuilderPreferencesRequestV1{ - Preferences: &buildergloas.BuilderPreferencesV1{MaxExecutionPayment: 5_000_000_000}, + orig := &gloastypes.BuilderPreferencesRequestV1{ + Preferences: &gloastypes.BuilderPreferencesV1{MaxExecutionPayment: 5_000_000_000}, Auth: sampleSignedRequestAuth(), } diff --git a/pkg/builderapi/gloas/auth.go b/pkg/builderapi/gloas/auth.go index 193c227e..8991f7fe 100644 --- a/pkg/builderapi/gloas/auth.go +++ b/pkg/builderapi/gloas/auth.go @@ -6,9 +6,9 @@ import ( "errors" "fmt" - buildergloas "github.com/attestantio/go-builder-client/api/gloas" "github.com/ethpandaops/go-eth2-client/spec/phase0" + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" "github.com/ethpandaops/buildoor/pkg/signer" ) @@ -36,7 +36,7 @@ var ( // // Returns nil on success, or one of the package's sentinel errors on failure. func VerifyRequestAuth( - signed *buildergloas.SignedRequestAuthV1, + signed *gloastypes.SignedRequestAuthV1, validatorPubkey phase0.BLSPubKey, genesisForkVersion phase0.Version, ) error { diff --git a/pkg/builderapi/gloas/auth_test.go b/pkg/builderapi/gloas/auth_test.go index bb8fbff6..4971c688 100644 --- a/pkg/builderapi/gloas/auth_test.go +++ b/pkg/builderapi/gloas/auth_test.go @@ -4,17 +4,11 @@ import ( "strings" "testing" - buildergloas "github.com/attestantio/go-builder-client/api/gloas" - // attphase0 is the attestantio phase0 used by go-builder-client's builder-API - // types. Buildoor uses ethpandaops/go-eth2-client everywhere; this import exists - // ONLY so tests can populate buildergloas struct fields (Slot/Signature), which - // are attestantio-typed. Production code converts at the boundary and never - // imports attestantio. Prefer ethpandaops/go-eth2-client outside of this. - attphase0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/ethpandaops/go-eth2-client/spec/phase0" "github.com/stretchr/testify/require" "github.com/ethpandaops/buildoor/pkg/builderapi/gloas" + gloastypes "github.com/ethpandaops/buildoor/pkg/builderapi/gloas/types" "github.com/ethpandaops/buildoor/pkg/signer" ) @@ -34,12 +28,12 @@ func signRequestAuth( builderURL []byte, slot phase0.Slot, genesisForkVersion phase0.Version, -) *buildergloas.SignedRequestAuthV1 { +) *gloastypes.SignedRequestAuthV1 { t.Helper() - msg := &buildergloas.RequestAuthV1{ + msg := &gloastypes.RequestAuthV1{ Data: builderURL, - Slot: attphase0.Slot(slot), + Slot: slot, } root, err := msg.HashTreeRoot() @@ -50,9 +44,9 @@ func signRequestAuth( sig, err := s.SignWithDomain(root, domain) require.NoError(t, err) - return &buildergloas.SignedRequestAuthV1{ + return &gloastypes.SignedRequestAuthV1{ Message: msg, - Signature: attphase0.BLSSignature(sig), + Signature: sig, } } @@ -67,7 +61,7 @@ func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { genesisForkVersion := phase0.Version{} - msg := &buildergloas.RequestAuthV1{ + msg := &gloastypes.RequestAuthV1{ Data: []byte(testBuilderURL), Slot: 1234, } @@ -78,7 +72,7 @@ func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { require.NotEmpty(t, encoded) // Unmarshal from SSZ into a fresh value. - decoded := &buildergloas.RequestAuthV1{} + decoded := &gloastypes.RequestAuthV1{} require.NoError(t, decoded.UnmarshalSSZ(encoded)) // The decoded value must match the original. @@ -97,9 +91,9 @@ func TestRequestAuth_SSZRoundTripAndSign(t *testing.T) { sig, err := validator.SignWithDomain(decodedRoot, domain) require.NoError(t, err) - signed := &buildergloas.SignedRequestAuthV1{ + signed := &gloastypes.SignedRequestAuthV1{ Message: decoded, - Signature: attphase0.BLSSignature(sig), + Signature: sig, } require.NoError(t, gloas.VerifyRequestAuth(signed, validator.PublicKey(), genesisForkVersion)) } @@ -181,9 +175,9 @@ func TestVerifyRequestAuth_NilMessage(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - signed := &buildergloas.SignedRequestAuthV1{ + signed := &gloastypes.SignedRequestAuthV1{ Message: nil, - Signature: attphase0.BLSSignature{}, + Signature: phase0.BLSSignature{}, } err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) @@ -194,13 +188,13 @@ func TestVerifyRequestAuth_GarbageSignature(t *testing.T) { validator, err := signer.NewBLSSigner(validatorPrivkeyHex) require.NoError(t, err) - signed := &buildergloas.SignedRequestAuthV1{ - Message: &buildergloas.RequestAuthV1{ + signed := &gloastypes.SignedRequestAuthV1{ + Message: &gloastypes.RequestAuthV1{ Data: []byte(testBuilderURL), Slot: 1, }, // All-zero signature is not a valid BLS signature. - Signature: attphase0.BLSSignature{}, + Signature: phase0.BLSSignature{}, } err = gloas.VerifyRequestAuth(signed, validator.PublicKey(), phase0.Version{}) diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1.go new file mode 100644 index 00000000..2361d48a --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1.go @@ -0,0 +1,39 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + + "github.com/goccy/go-yaml" +) + +// BuilderPreferencesRequestV1 is the body submitted to a builder via the +// submitBuilderPreferences API. The Auth.Message.Data identifies the +// intended builder so the builder can reject preferences that were not +// destined for it. +type BuilderPreferencesRequestV1 struct { + Preferences *BuilderPreferencesV1 + Auth *SignedRequestAuthV1 +} + +// String returns a string version of the structure. +func (b *BuilderPreferencesRequestV1) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1_json.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_json.go new file mode 100644 index 00000000..68c207d7 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_json.go @@ -0,0 +1,54 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + + "github.com/pkg/errors" +) + +// builderPreferencesRequestV1JSON is the spec representation of the struct. +type builderPreferencesRequestV1JSON struct { + Preferences *BuilderPreferencesV1 `json:"preferences"` + Auth *SignedRequestAuthV1 `json:"auth"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderPreferencesRequestV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderPreferencesRequestV1JSON{ + Preferences: b.Preferences, + Auth: b.Auth, + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderPreferencesRequestV1) UnmarshalJSON(input []byte) error { + var data builderPreferencesRequestV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Preferences == nil { + return errors.New("preferences missing") + } + b.Preferences = data.Preferences + + if data.Auth == nil { + return errors.New("auth missing") + } + b.Auth = data.Auth + + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1_ssz.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_ssz.go new file mode 100644 index 00000000..d6a6b12e --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_ssz.go @@ -0,0 +1,136 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 73051b06cc63fbd730494da90db480932cf6120fd2b1e8914804a555bdb3a416 +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *BuilderPreferencesRequestV1 to SSZ-encoded bytes. +func (t *BuilderPreferencesRequestV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPreferencesRequestV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPreferencesRequestV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPreferencesRequestV1) + } + dstlen := len(dst) + { // Static Field #0 'Preferences' + t := t.Preferences + if t == nil { + t = new(BuilderPreferencesV1) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Preferences") + } + } + // Offset Field #1 'Auth' + dst = append(dst, 0, 0, 0, 0) + { // Dynamic Field #1 'Auth' + binary.LittleEndian.PutUint32(dst[dstlen+8:], uint32(len(dst)-dstlen)) + t := t.Auth + if t == nil { + t = new(SignedRequestAuthV1) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Auth") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPreferencesRequestV1 from SSZ-encoded bytes. +func (t *BuilderPreferencesRequestV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + { // Field #0 'Preferences' (static) + buf := buf[0:8] + if t.Preferences == nil { + t.Preferences = new(BuilderPreferencesV1) + } + if err = t.Preferences.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Preferences") + } + } + // Field #1 'Auth' (offset) + offset1 := int(binary.LittleEndian.Uint32(buf[8:12])) + if offset1 != 12 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset1, 12), "Auth:o") + } + { // Field #1 'Auth' (dynamic) + buf := buf[offset1:] + if t.Auth == nil { + t.Auth = new(SignedRequestAuthV1) + } + if err = t.Auth.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Auth") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPreferencesRequestV1. +func (t *BuilderPreferencesRequestV1) SizeSSZ() (size int) { + if t == nil { + t = new(BuilderPreferencesRequestV1) + } + // Field #0 'Preferences' static (8 bytes) + // Field #1 'Auth' offset (4 bytes) + size += 12 + { // Dynamic field #1 'Auth' + size += t.Auth.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPreferencesRequestV1. +func (t *BuilderPreferencesRequestV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPreferencesRequestV1 using the given hash walker. +func (t *BuilderPreferencesRequestV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPreferencesRequestV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Preferences' + t := t.Preferences + if t == nil { + t = new(BuilderPreferencesV1) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Preferences") + } + } + { // Field #1 'Auth' + t := t.Auth + if t == nil { + t = new(SignedRequestAuthV1) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Auth") + } + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesrequestv1_yaml.go b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_yaml.go new file mode 100644 index 00000000..4b401904 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesrequestv1_yaml.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderPreferencesRequestV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderPreferencesRequestV1JSON{ + Preferences: b.Preferences, + Auth: b.Auth, + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderPreferencesRequestV1) UnmarshalYAML(input []byte) error { + var data builderPreferencesRequestV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1.go b/pkg/builderapi/gloas/types/builderpreferencesv1.go new file mode 100644 index 00000000..81cc63a4 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// BuilderPreferencesV1 communicates a proposer's per-builder preferences to a +// specific builder ahead of the bid request. A value of zero for +// MaxExecutionPayment indicates the proposer does not accept any execution +// layer payments from this builder, requiring the use of the on-chain +// trustless payment mechanism instead. +type BuilderPreferencesV1 struct { + MaxExecutionPayment phase0.Gwei +} + +// String returns a string version of the structure. +func (b *BuilderPreferencesV1) String() string { + data, err := yaml.Marshal(b) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1_json.go b/pkg/builderapi/gloas/types/builderpreferencesv1_json.go new file mode 100644 index 00000000..bb28cccf --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1_json.go @@ -0,0 +1,54 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// builderPreferencesV1JSON is the spec representation of the struct. +type builderPreferencesV1JSON struct { + MaxExecutionPayment string `json:"max_execution_payment"` +} + +// MarshalJSON implements json.Marshaler. +func (b *BuilderPreferencesV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&builderPreferencesV1JSON{ + MaxExecutionPayment: fmt.Sprintf("%d", b.MaxExecutionPayment), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (b *BuilderPreferencesV1) UnmarshalJSON(input []byte) error { + var data builderPreferencesV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.MaxExecutionPayment == "" { + return errors.New("max execution payment missing") + } + maxExecutionPayment, err := strconv.ParseUint(data.MaxExecutionPayment, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid max execution payment") + } + b.MaxExecutionPayment = phase0.Gwei(maxExecutionPayment) + + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1_ssz.go b/pkg/builderapi/gloas/types/builderpreferencesv1_ssz.go new file mode 100644 index 00000000..6dfcc229 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1_ssz.go @@ -0,0 +1,75 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: b88f4577f809d21495b23ade5b57ee353a8cb5f9310f34f1e02e865c3ed09955 +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *BuilderPreferencesV1 to SSZ-encoded bytes. +func (t *BuilderPreferencesV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *BuilderPreferencesV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *BuilderPreferencesV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(BuilderPreferencesV1) + } + { // Static Field #0 'MaxExecutionPayment' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.MaxExecutionPayment)) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *BuilderPreferencesV1 from SSZ-encoded bytes. +func (t *BuilderPreferencesV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 8 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 8) + } + { // Field #0 'MaxExecutionPayment' (static) + buf := buf[0:8] + t.MaxExecutionPayment = phase0.Gwei(binary.LittleEndian.Uint64(buf)) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *BuilderPreferencesV1. +func (t *BuilderPreferencesV1) SizeSSZ() (size int) { + return 8 +} + +// HashTreeRoot computes the SSZ hash tree root of the *BuilderPreferencesV1. +func (t *BuilderPreferencesV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *BuilderPreferencesV1 using the given hash walker. +func (t *BuilderPreferencesV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(BuilderPreferencesV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'MaxExecutionPayment' + hh.PutUint64(uint64(t.MaxExecutionPayment)) + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/builderpreferencesv1_yaml.go b/pkg/builderapi/gloas/types/builderpreferencesv1_yaml.go new file mode 100644 index 00000000..e134ef89 --- /dev/null +++ b/pkg/builderapi/gloas/types/builderpreferencesv1_yaml.go @@ -0,0 +1,49 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (b *BuilderPreferencesV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&builderPreferencesV1JSON{ + MaxExecutionPayment: fmt.Sprintf("%d", b.MaxExecutionPayment), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (b *BuilderPreferencesV1) UnmarshalYAML(input []byte) error { + var data builderPreferencesV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return b.UnmarshalJSON(marshaled) +} diff --git a/pkg/builderapi/gloas/types/generate.go b/pkg/builderapi/gloas/types/generate.go new file mode 100644 index 00000000..d01517f8 --- /dev/null +++ b/pkg/builderapi/gloas/types/generate.go @@ -0,0 +1,17 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +//go:generate rm -f *_ssz.go +//go:generate go tool dynssz-gen -config generate.yaml diff --git a/pkg/builderapi/gloas/types/generate.yaml b/pkg/builderapi/gloas/types/generate.yaml new file mode 100644 index 00000000..1bf46482 --- /dev/null +++ b/pkg/builderapi/gloas/types/generate.yaml @@ -0,0 +1,13 @@ +package: . +legacy: true +without-dynamic-expressions: true + +types: + - name: RequestAuthV1 + output: requestauthv1_ssz.go + - name: SignedRequestAuthV1 + output: signedrequestauthv1_ssz.go + - name: BuilderPreferencesV1 + output: builderpreferencesv1_ssz.go + - name: BuilderPreferencesRequestV1 + output: builderpreferencesrequestv1_ssz.go diff --git a/pkg/builderapi/gloas/types/requestauthv1.go b/pkg/builderapi/gloas/types/requestauthv1.go new file mode 100644 index 00000000..92a786fe --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1.go @@ -0,0 +1,46 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package types holds the Gloas-fork Builder API request/response types +// (RequestAuth, SignedRequestAuth, BuilderPreferences, BuilderPreferencesRequest). +// They were vendored from go-builder-client so buildoor owns them directly, and +// use buildoor's ethpandaops/go-eth2-client phase0 types. The _ssz.go files are +// generated; see generate.go. +package types + +import ( + "fmt" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// RequestAuthV1 is used by a proposer to authenticate a bid request to a specific +// builder. The proposer signs over a generic data field (set to the builder's +// URL) and the slot to prevent other builders from replaying the request to +// learn the builder's valuation, and to prevent DOS attempts from competing +// parties. +type RequestAuthV1 struct { + Data []byte `ssz-max:"4096"` + Slot phase0.Slot +} + +// String returns a string version of the structure. +func (r *RequestAuthV1) String() string { + data, err := yaml.Marshal(r) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/requestauthv1_json.go b/pkg/builderapi/gloas/types/requestauthv1_json.go new file mode 100644 index 00000000..6e03ed4b --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1_json.go @@ -0,0 +1,73 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// MaxDataSize is the maximum number of bytes in the request auth data field. +const MaxDataSize = 4096 + +// requestAuthV1JSON is the spec representation of the struct. +type requestAuthV1JSON struct { + Data string `json:"data"` + Slot string `json:"slot"` +} + +// MarshalJSON implements json.Marshaler. +func (r *RequestAuthV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&requestAuthV1JSON{ + Data: fmt.Sprintf("%#x", r.Data), + Slot: fmt.Sprintf("%d", r.Slot), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (r *RequestAuthV1) UnmarshalJSON(input []byte) error { + var data requestAuthV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Data == "" { + return errors.New("data missing") + } + dataBytes, err := hex.DecodeString(strings.TrimPrefix(data.Data, "0x")) + if err != nil { + return errors.Wrap(err, "invalid data") + } + if len(dataBytes) > MaxDataSize { + return errors.New("data too long") + } + r.Data = dataBytes + + if data.Slot == "" { + return errors.New("slot missing") + } + slot, err := strconv.ParseUint(data.Slot, 10, 64) + if err != nil { + return errors.Wrap(err, "invalid slot") + } + r.Slot = phase0.Slot(slot) + + return nil +} diff --git a/pkg/builderapi/gloas/types/requestauthv1_ssz.go b/pkg/builderapi/gloas/types/requestauthv1_ssz.go new file mode 100644 index 00000000..d8b8cb9b --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1_ssz.go @@ -0,0 +1,117 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 597218dd9b058462ddcddfb47bf27558f66616c2efb1e93c89d8f2eb8d6e3421 +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *RequestAuthV1 to SSZ-encoded bytes. +func (t *RequestAuthV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *RequestAuthV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *RequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(RequestAuthV1) + } + dstlen := len(dst) + // Offset Field #0 'Data' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Slot' + dst = binary.LittleEndian.AppendUint64(dst, uint64(t.Slot)) + } + { // Dynamic Field #0 'Data' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + vlen := len(t.Data) + if vlen > 4096 { + return nil, sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Data") + } + dst = append(dst, t.Data[:]...) + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *RequestAuthV1 from SSZ-encoded bytes. +func (t *RequestAuthV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 12 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 12) + } + // Field #0 'Data' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 12 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 12), "Data:o") + } + { // Field #1 'Slot' (static) + buf := buf[4:12] + t.Slot = phase0.Slot(binary.LittleEndian.Uint64(buf)) + } + { // Field #0 'Data' (dynamic) + buf := buf[offset0:] + if len(buf) > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(len(buf), 4096), "Data") + } + t.Data = sszutils.ExpandSlice(t.Data, len(buf)) + copy(t.Data[:], buf) + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *RequestAuthV1. +func (t *RequestAuthV1) SizeSSZ() (size int) { + if t == nil { + t = new(RequestAuthV1) + } + // Field #0 'Data' offset (4 bytes) + // Field #1 'Slot' static (8 bytes) + size += 12 + { // Dynamic field #0 'Data' + size += len(t.Data) + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *RequestAuthV1. +func (t *RequestAuthV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *RequestAuthV1 using the given hash walker. +func (t *RequestAuthV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(RequestAuthV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Data' + vlen := uint64(len(t.Data)) + if vlen > 4096 { + return sszutils.ErrorWithPath(sszutils.ErrListLengthFn(vlen, 4096), "Data") + } + idx := hh.StartTree(sszutils.TreeTypeBinary) + hh.AppendBytes32(t.Data[:]) + hh.MerkleizeWithMixin(idx, vlen, sszutils.CalculateLimit(4096, vlen, 1)) + } + { // Field #1 'Slot' + hh.PutUint64(uint64(t.Slot)) + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/requestauthv1_yaml.go b/pkg/builderapi/gloas/types/requestauthv1_yaml.go new file mode 100644 index 00000000..16999c48 --- /dev/null +++ b/pkg/builderapi/gloas/types/requestauthv1_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (r *RequestAuthV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&requestAuthV1JSON{ + Data: fmt.Sprintf("%#x", r.Data), + Slot: fmt.Sprintf("%d", r.Slot), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (r *RequestAuthV1) UnmarshalYAML(input []byte) error { + var data requestAuthV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return r.UnmarshalJSON(marshaled) +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1.go b/pkg/builderapi/gloas/types/signedrequestauthv1.go new file mode 100644 index 00000000..8ff20dba --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1.go @@ -0,0 +1,40 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/goccy/go-yaml" +) + +// SignedRequestAuthV1 wraps a RequestAuthV1 with the proposer's signature over the +// hash tree root of the message. It is sent in the body of getExecutionPayloadBid +// and submitBuilderPreferences requests so that builders can authenticate the +// requesting validator. +type SignedRequestAuthV1 struct { + Message *RequestAuthV1 + Signature phase0.BLSSignature `ssz-size:"96"` +} + +// String returns a string version of the structure. +func (s *SignedRequestAuthV1) String() string { + data, err := yaml.Marshal(s) + if err != nil { + return fmt.Sprintf("ERR: %v", err) + } + + return string(data) +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1_json.go b/pkg/builderapi/gloas/types/signedrequestauthv1_json.go new file mode 100644 index 00000000..13defc79 --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1_json.go @@ -0,0 +1,65 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/pkg/errors" +) + +// signedRequestAuthV1JSON is the spec representation of the struct. +type signedRequestAuthV1JSON struct { + Message *RequestAuthV1 `json:"message"` + Signature string `json:"signature"` +} + +// MarshalJSON implements json.Marshaler. +func (s *SignedRequestAuthV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&signedRequestAuthV1JSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (s *SignedRequestAuthV1) UnmarshalJSON(input []byte) error { + var data signedRequestAuthV1JSON + if err := json.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "invalid JSON") + } + + if data.Message == nil { + return errors.New("message missing") + } + s.Message = data.Message + + if data.Signature == "" { + return errors.New("signature missing") + } + signature, err := hex.DecodeString(strings.TrimPrefix(data.Signature, "0x")) + if err != nil { + return errors.Wrap(err, "invalid signature") + } + if len(signature) != phase0.SignatureLength { + return errors.New("incorrect length for signature") + } + copy(s.Signature[:], signature) + + return nil +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1_ssz.go b/pkg/builderapi/gloas/types/signedrequestauthv1_ssz.go new file mode 100644 index 00000000..94ac7a5e --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1_ssz.go @@ -0,0 +1,119 @@ +// Code generated by dynamic-ssz. DO NOT EDIT. +// Hash: 90a5f069bf853b5db64738807deada0a571106725fdeb43e4414702f018bc23f +// Version: v1.3.1 (https://github.com/pk910/dynamic-ssz) +package types + +import ( + "encoding/binary" + + dynssz "github.com/pk910/dynamic-ssz" + "github.com/pk910/dynamic-ssz/hasher" + "github.com/pk910/dynamic-ssz/sszutils" +) + +var _ = sszutils.ErrListTooBig + +// MarshalSSZ marshals the *SignedRequestAuthV1 to SSZ-encoded bytes. +func (t *SignedRequestAuthV1) MarshalSSZ() ([]byte, error) { + return dynssz.GetGlobalDynSsz().MarshalSSZ(t) +} + +// MarshalSSZTo marshals the *SignedRequestAuthV1 to SSZ-encoded bytes, appending to the provided buffer. +func (t *SignedRequestAuthV1) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + if t == nil { + t = new(SignedRequestAuthV1) + } + dstlen := len(dst) + // Offset Field #0 'Message' + dst = append(dst, 0, 0, 0, 0) + { // Static Field #1 'Signature' + dst = append(dst, t.Signature[:96]...) + } + { // Dynamic Field #0 'Message' + binary.LittleEndian.PutUint32(dst[dstlen:], uint32(len(dst)-dstlen)) + t := t.Message + if t == nil { + t = new(RequestAuthV1) + } + if dst, err = t.MarshalSSZTo(dst); err != nil { + return nil, sszutils.ErrorWithPath(err, "Message") + } + } + return dst, nil +} + +// UnmarshalSSZ unmarshals the *SignedRequestAuthV1 from SSZ-encoded bytes. +func (t *SignedRequestAuthV1) UnmarshalSSZ(buf []byte) (err error) { + buflen := len(buf) + if buflen < 100 { + return sszutils.ErrFixedFieldsEOFFn(buflen, 100) + } + // Field #0 'Message' (offset) + offset0 := int(binary.LittleEndian.Uint32(buf[0:4])) + if offset0 != 100 { + return sszutils.ErrorWithPath(sszutils.ErrFirstOffsetMismatchFn(offset0, 100), "Message:o") + } + { // Field #1 'Signature' (static) + buf := buf[4:100] + copy(t.Signature[:], buf) + } + { // Field #0 'Message' (dynamic) + buf := buf[offset0:] + if t.Message == nil { + t.Message = new(RequestAuthV1) + } + if err = t.Message.UnmarshalSSZ(buf); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + return nil +} + +// SizeSSZ returns the SSZ encoded size of the *SignedRequestAuthV1. +func (t *SignedRequestAuthV1) SizeSSZ() (size int) { + if t == nil { + t = new(SignedRequestAuthV1) + } + // Field #0 'Message' offset (4 bytes) + // Field #1 'Signature' static (96 bytes) + size += 100 + { // Dynamic field #0 'Message' + size += t.Message.SizeSSZ() + } + return size +} + +// HashTreeRoot computes the SSZ hash tree root of the *SignedRequestAuthV1. +func (t *SignedRequestAuthV1) HashTreeRoot() (root [32]byte, err error) { + err = hasher.WithDefaultHasher(func(hh sszutils.HashWalker) (err error) { + err = t.HashTreeRootWith(hh) + if err == nil { + root, err = hh.HashRoot() + } + return + }) + return +} + +// HashTreeRootWith computes the SSZ hash tree root of the *SignedRequestAuthV1 using the given hash walker. +func (t *SignedRequestAuthV1) HashTreeRootWith(hh sszutils.HashWalker) error { + if t == nil { + t = new(SignedRequestAuthV1) + } + idx := hh.StartTree(sszutils.TreeTypeNone) + { // Field #0 'Message' + t := t.Message + if t == nil { + t = new(RequestAuthV1) + } + if err := t.HashTreeRootWith(hh); err != nil { + return sszutils.ErrorWithPath(err, "Message") + } + } + { // Field #1 'Signature' + hh.PutBytes(t.Signature[:96]) + } + hh.Merkleize(idx) + return nil +} diff --git a/pkg/builderapi/gloas/types/signedrequestauthv1_yaml.go b/pkg/builderapi/gloas/types/signedrequestauthv1_yaml.go new file mode 100644 index 00000000..5cd4cdf8 --- /dev/null +++ b/pkg/builderapi/gloas/types/signedrequestauthv1_yaml.go @@ -0,0 +1,50 @@ +// Copyright © 2026 Attestant Limited. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// MarshalYAML implements yaml.Marshaler. +func (s *SignedRequestAuthV1) MarshalYAML() ([]byte, error) { + yamlBytes, err := yaml.MarshalWithOptions(&signedRequestAuthV1JSON{ + Message: s.Message, + Signature: fmt.Sprintf("%#x", s.Signature), + }, yaml.Flow(true)) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll(yamlBytes, []byte(`"`), []byte(`'`)), nil +} + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *SignedRequestAuthV1) UnmarshalYAML(input []byte) error { + var data signedRequestAuthV1JSON + if err := yaml.Unmarshal(input, &data); err != nil { + return errors.Wrap(err, "failed to unmarshal YAML") + } + marshaled, err := json.Marshal(&data) + if err != nil { + return errors.Wrap(err, "failed to marshal JSON") + } + + return s.UnmarshalJSON(marshaled) +}