mirror of
https://github.com/OffchainLabs/prysm.git
synced 2026-01-08 23:18:15 -05:00
* Adds a wrapper package * Gazelle * Add in Empty Span * Revert It Back * Add back reference * Set It As Empty * fix missing import * remove redundant alias * remove unused --------- Co-authored-by: Sammy Rosso <15244892+saolyn@users.noreply.github.com> Co-authored-by: Saolyn <sammy@rosso.red>
393 lines
14 KiB
Go
393 lines
14 KiB
Go
package builder
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/prysmaticlabs/prysm/v5/api"
|
|
"github.com/prysmaticlabs/prysm/v5/api/server/structs"
|
|
"github.com/prysmaticlabs/prysm/v5/consensus-types/blocks"
|
|
"github.com/prysmaticlabs/prysm/v5/consensus-types/interfaces"
|
|
"github.com/prysmaticlabs/prysm/v5/consensus-types/primitives"
|
|
"github.com/prysmaticlabs/prysm/v5/monitoring/tracing"
|
|
"github.com/prysmaticlabs/prysm/v5/monitoring/tracing/trace"
|
|
v1 "github.com/prysmaticlabs/prysm/v5/proto/engine/v1"
|
|
ethpb "github.com/prysmaticlabs/prysm/v5/proto/prysm/v1alpha1"
|
|
"github.com/prysmaticlabs/prysm/v5/runtime/version"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
getExecHeaderPath = "/eth/v1/builder/header/{{.Slot}}/{{.ParentHash}}/{{.Pubkey}}"
|
|
getStatus = "/eth/v1/builder/status"
|
|
postBlindedBeaconBlockPath = "/eth/v1/builder/blinded_blocks"
|
|
postRegisterValidatorPath = "/eth/v1/builder/validators"
|
|
)
|
|
|
|
var errMalformedHostname = errors.New("hostname must include port, separated by one colon, like example.com:3500")
|
|
var errMalformedRequest = errors.New("required request data are missing")
|
|
var errNotBlinded = errors.New("submitted block is not blinded")
|
|
|
|
// ClientOpt is a functional option for the Client type (http.Client wrapper)
|
|
type ClientOpt func(*Client)
|
|
|
|
type observer interface {
|
|
observe(r *http.Request) error
|
|
}
|
|
|
|
func WithObserver(m observer) ClientOpt {
|
|
return func(c *Client) {
|
|
c.obvs = append(c.obvs, m)
|
|
}
|
|
}
|
|
|
|
type requestLogger struct{}
|
|
|
|
func (*requestLogger) observe(r *http.Request) (e error) {
|
|
b := bytes.NewBuffer(nil)
|
|
if r.Body == nil {
|
|
log.WithFields(log.Fields{
|
|
"bodyBase64": "(nil value)",
|
|
"url": r.URL.String(),
|
|
}).Info("builder http request")
|
|
return nil
|
|
}
|
|
t := io.TeeReader(r.Body, b)
|
|
defer func() {
|
|
if r.Body != nil {
|
|
e = r.Body.Close()
|
|
}
|
|
}()
|
|
body, err := io.ReadAll(t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.Body = io.NopCloser(b)
|
|
log.WithFields(log.Fields{
|
|
"bodyBase64": string(body),
|
|
"url": r.URL.String(),
|
|
}).Info("builder http request")
|
|
|
|
return nil
|
|
}
|
|
|
|
var _ observer = &requestLogger{}
|
|
|
|
// BuilderClient provides a collection of helper methods for calling Builder API endpoints.
|
|
type BuilderClient interface {
|
|
NodeURL() string
|
|
GetHeader(ctx context.Context, slot primitives.Slot, parentHash [32]byte, pubkey [48]byte) (SignedBid, error)
|
|
RegisterValidator(ctx context.Context, svr []*ethpb.SignedValidatorRegistrationV1) error
|
|
SubmitBlindedBlock(ctx context.Context, sb interfaces.ReadOnlySignedBeaconBlock) (interfaces.ExecutionData, *v1.BlobsBundle, error)
|
|
Status(ctx context.Context) error
|
|
}
|
|
|
|
// Client provides a collection of helper methods for calling Builder API endpoints.
|
|
type Client struct {
|
|
hc *http.Client
|
|
baseURL *url.URL
|
|
obvs []observer
|
|
}
|
|
|
|
// NewClient constructs a new client with the provided options (ex WithTimeout).
|
|
// `host` is the base host + port used to construct request urls. This value can be
|
|
// a URL string, or NewClient will assume an http endpoint if just `host:port` is used.
|
|
func NewClient(host string, opts ...ClientOpt) (*Client, error) {
|
|
u, err := urlForHost(host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c := &Client{
|
|
hc: &http.Client{},
|
|
baseURL: u,
|
|
}
|
|
for _, o := range opts {
|
|
o(c)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func urlForHost(h string) (*url.URL, error) {
|
|
// try to parse as url (being permissive)
|
|
if u, err := url.Parse(h); err == nil && u.Host != "" {
|
|
return u, nil
|
|
}
|
|
// try to parse as host:port
|
|
host, port, err := net.SplitHostPort(h)
|
|
if err != nil {
|
|
return nil, errMalformedHostname
|
|
}
|
|
return &url.URL{Host: net.JoinHostPort(host, port), Scheme: "http"}, nil
|
|
}
|
|
|
|
// NodeURL returns a human-readable string representation of the beacon node base url.
|
|
func (c *Client) NodeURL() string {
|
|
return c.baseURL.String()
|
|
}
|
|
|
|
type reqOption func(*http.Request)
|
|
|
|
// do is a generic, opinionated request function to reduce boilerplate amongst the methods in this package api/client/builder.
|
|
func (c *Client) do(ctx context.Context, method string, path string, body io.Reader, opts ...reqOption) (res []byte, err error) {
|
|
ctx, span := trace.StartSpan(ctx, "builder.client.do")
|
|
defer func() {
|
|
tracing.AnnotateError(span, err)
|
|
span.End()
|
|
}()
|
|
|
|
u := c.baseURL.ResolveReference(&url.URL{Path: path})
|
|
|
|
span.AddAttributes(trace.StringAttribute("url", u.String()),
|
|
trace.StringAttribute("method", method))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Add("User-Agent", version.BuildData())
|
|
for _, o := range opts {
|
|
o(req)
|
|
}
|
|
for _, o := range c.obvs {
|
|
if err = o.observe(req); err != nil {
|
|
return
|
|
}
|
|
}
|
|
r, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() {
|
|
closeErr := r.Body.Close()
|
|
if closeErr != nil {
|
|
log.WithError(closeErr).Error("Failed to close response body")
|
|
}
|
|
}()
|
|
if r.StatusCode != http.StatusOK {
|
|
err = non200Err(r)
|
|
return
|
|
}
|
|
res, err = io.ReadAll(r.Body)
|
|
if err != nil {
|
|
err = errors.Wrap(err, "error reading http response body from builder server")
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
var execHeaderTemplate = template.Must(template.New("").Parse(getExecHeaderPath))
|
|
|
|
func execHeaderPath(slot primitives.Slot, parentHash [32]byte, pubkey [48]byte) (string, error) {
|
|
v := struct {
|
|
Slot primitives.Slot
|
|
ParentHash string
|
|
Pubkey string
|
|
}{
|
|
Slot: slot,
|
|
ParentHash: fmt.Sprintf("%#x", parentHash),
|
|
Pubkey: fmt.Sprintf("%#x", pubkey),
|
|
}
|
|
b := bytes.NewBuffer(nil)
|
|
err := execHeaderTemplate.Execute(b, v)
|
|
if err != nil {
|
|
return "", errors.Wrapf(err, "error rendering exec header template with slot=%d, parentHash=%#x, pubkey=%#x", slot, parentHash, pubkey)
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// GetHeader is used by a proposing validator to request an execution payload header from the Builder node.
|
|
func (c *Client) GetHeader(ctx context.Context, slot primitives.Slot, parentHash [32]byte, pubkey [48]byte) (SignedBid, error) {
|
|
path, err := execHeaderPath(slot, parentHash, pubkey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hb, err := c.do(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v := &VersionResponse{}
|
|
if err := json.Unmarshal(hb, v); err != nil {
|
|
return nil, errors.Wrapf(err, "error unmarshaling the builder GetHeader response, using slot=%d, parentHash=%#x, pubkey=%#x", slot, parentHash, pubkey)
|
|
}
|
|
switch strings.ToLower(v.Version) {
|
|
case strings.ToLower(version.String(version.Deneb)):
|
|
hr := &ExecHeaderResponseDeneb{}
|
|
if err := json.Unmarshal(hb, hr); err != nil {
|
|
return nil, errors.Wrapf(err, "error unmarshaling the builder GetHeader response, using slot=%d, parentHash=%#x, pubkey=%#x", slot, parentHash, pubkey)
|
|
}
|
|
p, err := hr.ToProto()
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "could not extract proto message from header")
|
|
}
|
|
return WrappedSignedBuilderBidDeneb(p)
|
|
case strings.ToLower(version.String(version.Capella)):
|
|
hr := &ExecHeaderResponseCapella{}
|
|
if err := json.Unmarshal(hb, hr); err != nil {
|
|
return nil, errors.Wrapf(err, "error unmarshaling the builder GetHeader response, using slot=%d, parentHash=%#x, pubkey=%#x", slot, parentHash, pubkey)
|
|
}
|
|
p, err := hr.ToProto()
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "could not extract proto message from header")
|
|
}
|
|
return WrappedSignedBuilderBidCapella(p)
|
|
case strings.ToLower(version.String(version.Bellatrix)):
|
|
hr := &ExecHeaderResponse{}
|
|
if err := json.Unmarshal(hb, hr); err != nil {
|
|
return nil, errors.Wrapf(err, "error unmarshaling the builder GetHeader response, using slot=%d, parentHash=%#x, pubkey=%#x", slot, parentHash, pubkey)
|
|
}
|
|
p, err := hr.ToProto()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "could not extract proto message from header")
|
|
}
|
|
return WrappedSignedBuilderBid(p)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported header version %s", strings.ToLower(v.Version))
|
|
}
|
|
}
|
|
|
|
// RegisterValidator encodes the SignedValidatorRegistrationV1 message to json (including hex-encoding the byte
|
|
// fields with 0x prefixes) and posts to the builder validator registration endpoint.
|
|
func (c *Client) RegisterValidator(ctx context.Context, svr []*ethpb.SignedValidatorRegistrationV1) error {
|
|
ctx, span := trace.StartSpan(ctx, "builder.client.RegisterValidator")
|
|
defer span.End()
|
|
span.AddAttributes(trace.Int64Attribute("num_reqs", int64(len(svr))))
|
|
|
|
if len(svr) == 0 {
|
|
err := errors.Wrap(errMalformedRequest, "empty validator registration list")
|
|
tracing.AnnotateError(span, err)
|
|
return err
|
|
}
|
|
vs := make([]*structs.SignedValidatorRegistration, len(svr))
|
|
for i := 0; i < len(svr); i++ {
|
|
vs[i] = structs.SignedValidatorRegistrationFromConsensus(svr[i])
|
|
}
|
|
body, err := json.Marshal(vs)
|
|
if err != nil {
|
|
err := errors.Wrap(err, "error encoding the SignedValidatorRegistration value body in RegisterValidator")
|
|
tracing.AnnotateError(span, err)
|
|
return err
|
|
}
|
|
|
|
_, err = c.do(ctx, http.MethodPost, postRegisterValidatorPath, bytes.NewBuffer(body))
|
|
return err
|
|
}
|
|
|
|
var errResponseVersionMismatch = errors.New("builder API response uses a different version than requested in " + api.VersionHeader + " header")
|
|
|
|
// SubmitBlindedBlock calls the builder API endpoint that binds the validator to the builder and submits the block.
|
|
// The response is the full execution payload used to create the blinded block.
|
|
func (c *Client) SubmitBlindedBlock(ctx context.Context, sb interfaces.ReadOnlySignedBeaconBlock) (interfaces.ExecutionData, *v1.BlobsBundle, error) {
|
|
if !sb.IsBlinded() {
|
|
return nil, nil, errNotBlinded
|
|
}
|
|
|
|
// massage the proto struct type data into the api response type.
|
|
mj, err := structs.SignedBeaconBlockMessageJsoner(sb)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "error generating blinded beacon block post request")
|
|
}
|
|
|
|
body, err := json.Marshal(mj)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "error marshaling blinded block post request to json")
|
|
}
|
|
postOpts := func(r *http.Request) {
|
|
r.Header.Add("Eth-Consensus-Version", version.String(sb.Version()))
|
|
r.Header.Set("Content-Type", api.JsonMediaType)
|
|
r.Header.Set("Accept", api.JsonMediaType)
|
|
}
|
|
// post the blinded block - the execution payload response should contain the unblinded payload, along with the
|
|
// blobs bundle if it is post deneb.
|
|
rb, err := c.do(ctx, http.MethodPost, postBlindedBeaconBlockPath, bytes.NewBuffer(body), postOpts)
|
|
if err != nil {
|
|
return nil, nil, errors.Wrap(err, "error posting the blinded block to the builder api")
|
|
}
|
|
// ExecutionPayloadResponse parses just the outer container and the Value key, enabling it to use the .Value
|
|
// key to determine which underlying data type to use to finish the unmarshaling.
|
|
ep := &ExecutionPayloadResponse{}
|
|
if err := json.Unmarshal(rb, ep); err != nil {
|
|
return nil, nil, errors.Wrap(err, "error unmarshaling the builder ExecutionPayloadResponse")
|
|
}
|
|
if strings.ToLower(ep.Version) != version.String(sb.Version()) {
|
|
return nil, nil, errors.Wrapf(errResponseVersionMismatch, "req=%s, recv=%s", strings.ToLower(ep.Version), version.String(sb.Version()))
|
|
}
|
|
// This parses the rest of the response and returns the inner data field.
|
|
pp, err := ep.ParsePayload()
|
|
if err != nil {
|
|
return nil, nil, errors.Wrapf(err, "failed to parse execution payload from builder with version=%s", ep.Version)
|
|
}
|
|
// Get the payload as a proto.Message so it can be wrapped as an execution payload interface.
|
|
pb, err := pp.PayloadProto()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
ed, err := blocks.NewWrappedExecutionData(pb)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
bb, ok := pp.(BlobBundler)
|
|
if ok {
|
|
bbpb, err := bb.BundleProto()
|
|
if err != nil {
|
|
return nil, nil, errors.Wrapf(err, "failed to extract blobs bundle from builder response with version=%s", ep.Version)
|
|
}
|
|
return ed, bbpb, nil
|
|
}
|
|
return ed, nil, nil
|
|
}
|
|
|
|
// Status asks the remote builder server for a health check. A response of 200 with an empty body is the success/healthy
|
|
// response, and an error response may have an error message. This method will return a nil value for error in the
|
|
// happy path, and an error with information about the server response body for a non-200 response.
|
|
func (c *Client) Status(ctx context.Context) error {
|
|
_, err := c.do(ctx, http.MethodGet, getStatus, nil)
|
|
return err
|
|
}
|
|
|
|
func non200Err(response *http.Response) error {
|
|
bodyBytes, err := io.ReadAll(response.Body)
|
|
var errMessage ErrorMessage
|
|
var body string
|
|
if err != nil {
|
|
body = "(Unable to read response body.)"
|
|
} else {
|
|
body = "response body:\n" + string(bodyBytes)
|
|
}
|
|
msg := fmt.Sprintf("code=%d, url=%s, body=%s", response.StatusCode, response.Request.URL, body)
|
|
switch response.StatusCode {
|
|
case http.StatusNoContent:
|
|
log.WithError(ErrNoContent).Debug(msg)
|
|
return ErrNoContent
|
|
case http.StatusBadRequest:
|
|
log.WithError(ErrBadRequest).Debug(msg)
|
|
if jsonErr := json.Unmarshal(bodyBytes, &errMessage); jsonErr != nil {
|
|
return errors.Wrap(jsonErr, "unable to read response body")
|
|
}
|
|
return errors.Wrap(ErrBadRequest, errMessage.Message)
|
|
case http.StatusNotFound:
|
|
log.WithError(ErrNotFound).Debug(msg)
|
|
if jsonErr := json.Unmarshal(bodyBytes, &errMessage); jsonErr != nil {
|
|
return errors.Wrap(jsonErr, "unable to read response body")
|
|
}
|
|
return errors.Wrap(ErrNotFound, errMessage.Message)
|
|
case http.StatusInternalServerError:
|
|
log.WithError(ErrNotOK).Debug(msg)
|
|
if jsonErr := json.Unmarshal(bodyBytes, &errMessage); jsonErr != nil {
|
|
return errors.Wrap(jsonErr, "unable to read response body")
|
|
}
|
|
return errors.Wrap(ErrNotOK, errMessage.Message)
|
|
default:
|
|
log.WithError(ErrNotOK).Debug(msg)
|
|
return errors.Wrap(ErrNotOK, fmt.Sprintf("unsupported error code: %d", response.StatusCode))
|
|
}
|
|
}
|