diff --git a/packages/taiko-client/cmd/flags/proposer.go b/packages/taiko-client/cmd/flags/proposer.go index 33d530f1cc..07c1d1053b 100644 --- a/packages/taiko-client/cmd/flags/proposer.go +++ b/packages/taiko-client/cmd/flags/proposer.go @@ -146,6 +146,13 @@ var ( Category: proposerCategory, EnvVars: []string{"PRECONFIRMATION_RPC"}, } + ProposerHTTPServerPort = &cli.Uint64Flag{ + Name: "proposer.port", + Usage: "Port to expose for http server", + Category: proverCategory, + Value: 9871, + EnvVars: []string{"PROPOSER_PORT"}, + } ) // ProposerFlags All proposer flags. @@ -173,4 +180,5 @@ var ProposerFlags = MergeFlags(CommonFlags, []cli.Flag{ BlobAllowed, L1BlockBuilderTip, PreconfirmationRPC, + ProposerHTTPServerPort, }, TxmgrFlags) diff --git a/packages/taiko-client/proposer/config.go b/packages/taiko-client/proposer/config.go index f2fb4c94af..f24027ea6b 100644 --- a/packages/taiko-client/proposer/config.go +++ b/packages/taiko-client/proposer/config.go @@ -45,6 +45,7 @@ type Config struct { TxmgrConfigs *txmgr.CLIConfig L1BlockBuilderTip *big.Int PreconfirmationRPC string + HTTPServerPort uint64 } // NewConfigFromCliContext initializes a Config instance from @@ -136,5 +137,6 @@ func NewConfigFromCliContext(c *cli.Context) (*Config, error) { l1ProposerPrivKey, c, ), + HTTPServerPort: c.Uint64(flags.ProposerHTTPServerPort.Name), }, nil } diff --git a/packages/taiko-client/proposer/config_test.go b/packages/taiko-client/proposer/config_test.go index 6fd9b587a5..a4a37f5694 100644 --- a/packages/taiko-client/proposer/config_test.go +++ b/packages/taiko-client/proposer/config_test.go @@ -26,6 +26,7 @@ var ( tierFee = 100.0 proposeInterval = "10s" rpcTimeout = "5s" + httpPort = "9344" ) func (s *ProposerTestSuite) TestNewConfigFromCliContext() { @@ -55,6 +56,7 @@ func (s *ProposerTestSuite) TestNewConfigFromCliContext() { s.Equal(uint64(15), c.TierFeePriceBump.Uint64()) s.Equal(uint64(5), c.MaxTierFeePriceBumps) s.Equal(true, c.IncludeParentMetaHash) + s.Equal(uint64(9344), c.HTTPServerPort) for i, e := range strings.Split(proverEndpoints, ",") { s.Equal(c.ProverEndpoints[i].String(), e) @@ -83,6 +85,7 @@ func (s *ProposerTestSuite) TestNewConfigFromCliContext() { "--" + flags.TierFeePriceBump.Name, "15", "--" + flags.MaxTierFeePriceBumps.Name, "5", "--" + flags.ProposeBlockIncludeParentMetaHash.Name, "true", + "--" + flags.ProposerHTTPServerPort.Name, httpPort, })) } @@ -143,6 +146,7 @@ func (s *ProposerTestSuite) SetupApp() *cli.App { &cli.Uint64Flag{Name: flags.TierFeePriceBump.Name}, &cli.Uint64Flag{Name: flags.MaxTierFeePriceBumps.Name}, &cli.BoolFlag{Name: flags.ProposeBlockIncludeParentMetaHash.Name}, + &cli.StringFlag{Name: flags.ProposerHTTPServerPort.Name}, } app.Flags = append(app.Flags, flags.TxmgrFlags...) app.Action = func(ctx *cli.Context) error { diff --git a/packages/taiko-client/proposer/proposer.go b/packages/taiko-client/proposer/proposer.go index f75ced3320..9e42d65e4b 100644 --- a/packages/taiko-client/proposer/proposer.go +++ b/packages/taiko-client/proposer/proposer.go @@ -3,9 +3,11 @@ package proposer import ( "bytes" "context" + "errors" "fmt" "math/big" "math/rand" + "net/http" "sync" "time" @@ -25,6 +27,7 @@ import ( "github.com/taikoxyz/taiko-mono/packages/taiko-client/internal/utils" "github.com/taikoxyz/taiko-mono/packages/taiko-client/pkg/rpc" selector "github.com/taikoxyz/taiko-mono/packages/taiko-client/proposer/prover_selector" + "github.com/taikoxyz/taiko-mono/packages/taiko-client/proposer/server" builder "github.com/taikoxyz/taiko-mono/packages/taiko-client/proposer/transaction_builder" ) @@ -53,6 +56,9 @@ type Proposer struct { // Protocol configurations protocolConfigs *bindings.TaikoDataConfig + // Proposer API for block builders + server *server.ProposerServer + lastProposedAt time.Time txmgr *txmgr.SimpleTxManager @@ -112,18 +118,21 @@ func (p *Proposer) InitFromConfig(ctx context.Context, cfg *Config, txMgr *txmgr } } - if p.proverSelector, err = selector.NewETHFeeEOASelector( - &protocolConfigs, - p.rpc, - p.proposerAddress, - cfg.TaikoL1Address, - cfg.ProverSetAddress, - p.tierFees, - cfg.TierFeePriceBump, - cfg.ProverEndpoints, - cfg.MaxTierFeePriceBumps, - ); err != nil { - return err + // prover selector is optional + if cfg.ProverSetAddress.Hex() != rpc.ZeroAddress.Hex() { + if p.proverSelector, err = selector.NewETHFeeEOASelector( + &protocolConfigs, + p.rpc, + p.proposerAddress, + cfg.TaikoL1Address, + cfg.ProverSetAddress, + p.tierFees, + cfg.TierFeePriceBump, + cfg.ProverEndpoints, + cfg.MaxTierFeePriceBumps, + ); err != nil { + return err + } } if cfg.BlobAllowed { @@ -154,6 +163,14 @@ func (p *Proposer) InitFromConfig(ctx context.Context, cfg *Config, txMgr *txmgr ) } + // Prover server + if p.server, err = server.New(&server.NewProposerServerOpts{ + RPC: p.rpc, + ProtocolConfigs: &protocolConfigs, + }); err != nil { + return err + } + return nil } @@ -161,6 +178,12 @@ func (p *Proposer) InitFromConfig(ctx context.Context, cfg *Config, txMgr *txmgr func (p *Proposer) Start() error { p.wg.Add(1) go p.eventLoop() + + go func() { + if err := p.server.Start(fmt.Sprintf(":%v", p.HTTPServerPort)); !errors.Is(err, http.ErrServerClosed) { + log.Crit("Failed to start http server", "error", err) + } + }() return nil } @@ -191,7 +214,11 @@ func (p *Proposer) eventLoop() { } // Close closes the proposer instance. -func (p *Proposer) Close(_ context.Context) { +func (p *Proposer) Close(ctx context.Context) { + if err := p.server.Shutdown(ctx); err != nil { + log.Error("Failed to shut down prover server", "error", err) + } + p.wg.Wait() } diff --git a/packages/taiko-client/proposer/server/api.go b/packages/taiko-client/proposer/server/api.go new file mode 100644 index 0000000000..0871ae16a3 --- /dev/null +++ b/packages/taiko-client/proposer/server/api.go @@ -0,0 +1,118 @@ +package server + +import ( + "bytes" + "encoding/hex" + "log" + "net/http" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rlp" + "github.com/labstack/echo/v4" + "github.com/taikoxyz/taiko-mono/packages/taiko-client/pkg/rpc" +) + +// @title Taiko Proposer Server API +// @version 1.0 +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url https://community.taiko.xyz/ +// @contact.email info@taiko.xyz + +// @license.name MIT +// @license.url https://github.com/taikoxyz/taiko-mono/packages/taiko-client/blob/main/LICENSE.md + +// Status represents the current proposer server status. +type Status struct { +} + +// GetStatus handles a query to the current proposer server status. +// +// @Summary Get current proposer server status +// @ID get-status +// @Accept json +// @Produce json +// @Success 200 {object} Status +// @Router /status [get] +func (s *ProposerServer) GetStatus(c echo.Context) error { + return c.JSON(http.StatusOK, &Status{}) +} + +type buildBlockRequest struct { + L1StateBlockNumber uint32 `json:"l1StateBlockNumber"` + Timestamp uint64 `json:"timestamp"` + SignedTransactions []string `json:"signedTransactions"` + IncludeParentMetaHash bool `json:"includeParentMetaHash"` + Coinbase string `json:"coinbase"` + ExtraData string `json:"extraData"` +} + +type buildBlockResponse struct { + RLPEncodedTx string `json:"rlpEncodedTx"` +} + +// BuildBlock handles a query to build a block according to our protocol, given the inputs, +// and returns an unsigned transaction to `taikol1.ProposeBlock`. +// +// @Summary Build a block and return an unsigned `taikol1.ProposeBlock` transaction +// @ID build +// @Accept json +// @Produce json +// @Success 200 {object} BuildBlockResponse +// @Router /block/build [get] +func (s *ProposerServer) BuildBlock(c echo.Context) error { + req := &buildBlockRequest{} + if err := c.Bind(req); err != nil { + return c.JSON(http.StatusUnprocessableEntity, err) + } + + var transactions types.Transactions + + for _, signedTxHex := range req.SignedTransactions { + if strings.HasPrefix(signedTxHex, "0x") { + signedTxHex = signedTxHex[2:] + } + + rlpEncodedBytes, err := hex.DecodeString(signedTxHex) + if err != nil { + return c.JSON(http.StatusUnprocessableEntity, err) + } + + var tx types.Transaction + if err := rlp.DecodeBytes(rlpEncodedBytes, &tx); err != nil { + return c.JSON(http.StatusUnprocessableEntity, err) + } + + transactions = append(transactions, &tx) + } + + txListBytes, err := rlp.EncodeToBytes(transactions) + if err != nil { + log.Fatalf("Failed to RLP encode transactions: %v", err) + } + + tx, err := s.txBuilder.BuildUnsigned( + c.Request().Context(), + txListBytes, + req.L1StateBlockNumber, + req.Timestamp, + common.HexToAddress(req.Coinbase), + rpc.StringToBytes32(req.ExtraData), + ) + if err != nil { + return c.JSON(http.StatusInternalServerError, err) + } + + // RLP encode the transaction + var rlpEncodedTx bytes.Buffer + if err := rlp.Encode(&rlpEncodedTx, tx); err != nil { + log.Fatalf("Failed to RLP encode the transaction: %v", err) + } + + hexEncodedTx := hex.EncodeToString(rlpEncodedTx.Bytes()) + + return c.JSON(http.StatusOK, buildBlockResponse{RLPEncodedTx: hexEncodedTx}) +} diff --git a/packages/taiko-client/proposer/server/api_test.go b/packages/taiko-client/proposer/server/api_test.go new file mode 100644 index 0000000000..916fd19e3f --- /dev/null +++ b/packages/taiko-client/proposer/server/api_test.go @@ -0,0 +1,19 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" +) + +func (s *ProposerServerTestSuite) TestGetStatusSuccess() { + res := s.sendReq("/status") + s.Equal(http.StatusOK, res.StatusCode) + + status := new(Status) + + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + s.Nil(err) + s.Nil(json.Unmarshal(b, &status)) +} diff --git a/packages/taiko-client/proposer/server/server.go b/packages/taiko-client/proposer/server/server.go new file mode 100644 index 0000000000..036d1cafc0 --- /dev/null +++ b/packages/taiko-client/proposer/server/server.go @@ -0,0 +1,106 @@ +package server + +import ( + "context" + "net/http" + "os" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + + "github.com/taikoxyz/taiko-mono/packages/taiko-client/bindings" + "github.com/taikoxyz/taiko-mono/packages/taiko-client/bindings/encoding" + "github.com/taikoxyz/taiko-mono/packages/taiko-client/pkg/rpc" + builder "github.com/taikoxyz/taiko-mono/packages/taiko-client/proposer/transaction_builder" +) + +// @title Taiko Proposer Server API +// @version 1.0 +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url https://community.taiko.xyz/ +// @contact.email info@taiko.xyz + +// @license.name MIT +// @license.url https://github.com/taikoxyz/taiko-mono/blob/main/LICENSE.md +// ProposerServer represents a proposer server instance. +type ProposerServer struct { + echo *echo.Echo + rpc *rpc.Client + protocolConfigs *bindings.TaikoDataConfig + txBuilder builder.ProposeBlockTransactionBuilder + tierFees []encoding.TierFee +} + +// NewProposerServerOpts contains all configurations for creating a prover server instance. +type NewProposerServerOpts struct { + RPC *rpc.Client + ProtocolConfigs *bindings.TaikoDataConfig + TxBuilder builder.ProposeBlockTransactionBuilder + TierFees []encoding.TierFee +} + +// New creates a new prover server instance. +func New(opts *NewProposerServerOpts) (*ProposerServer, error) { + srv := &ProposerServer{ + echo: echo.New(), + rpc: opts.RPC, + protocolConfigs: opts.ProtocolConfigs, + txBuilder: opts.TxBuilder, + tierFees: opts.TierFees, + } + + srv.echo.HideBanner = true + srv.configureMiddleware() + srv.configureRoutes() + + return srv, nil +} + +// Start starts the HTTP server. +func (s *ProposerServer) Start(address string) error { + return s.echo.Start(address) +} + +// Shutdown shuts down the HTTP server. +func (s *ProposerServer) Shutdown(ctx context.Context) error { + return s.echo.Shutdown(ctx) +} + +// Health endpoints for probes. +func (s *ProposerServer) Health(c echo.Context) error { + return c.NoContent(http.StatusOK) +} + +// LogSkipper implements the `middleware.Skipper` interface. +func LogSkipper(c echo.Context) bool { + switch c.Request().URL.Path { + case "/healthz": + return true + default: + return true + } +} + +// configureMiddleware configures the server middlewares. +func (s *ProposerServer) configureMiddleware() { + s.echo.Use(middleware.RequestID()) + + s.echo.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + Skipper: LogSkipper, + Format: `{"time":"${time_rfc3339_nano}","level":"INFO","message":{"id":"${id}","remote_ip":"${remote_ip}",` + + `"host":"${host}","method":"${method}","uri":"${uri}","user_agent":"${user_agent}",` + + `"response_status":${status},"error":"${error}","latency":${latency},"latency_human":"${latency_human}",` + + `"bytes_in":${bytes_in},"bytes_out":${bytes_out}}}` + "\n", + Output: os.Stdout, + })) +} + +// configureRoutes contains all routes which will be used by prover server. +func (s *ProposerServer) configureRoutes() { + s.echo.GET("/", s.Health) + s.echo.GET("/healthz", s.Health) + s.echo.GET("/status", s.GetStatus) + s.echo.GET("/block/build", s.BuildBlock) +} diff --git a/packages/taiko-client/proposer/server/server_test.go b/packages/taiko-client/proposer/server/server_test.go new file mode 100644 index 0000000000..80873cd971 --- /dev/null +++ b/packages/taiko-client/proposer/server/server_test.go @@ -0,0 +1,111 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/go-resty/resty/v2" + "github.com/phayes/freeport" + "github.com/stretchr/testify/suite" + + "github.com/taikoxyz/taiko-mono/packages/taiko-client/pkg/rpc" +) + +type ProposerServerTestSuite struct { + suite.Suite + s *ProposerServer + testServer *httptest.Server +} + +func (s *ProposerServerTestSuite) SetupTest() { + rpcClient, err := rpc.NewClient(context.Background(), &rpc.ClientConfig{ + L1Endpoint: os.Getenv("L1_NODE_WS_ENDPOINT"), + L2Endpoint: os.Getenv("L2_EXECUTION_ENGINE_WS_ENDPOINT"), + TaikoL1Address: common.HexToAddress(os.Getenv("TAIKO_L1_ADDRESS")), + TaikoL2Address: common.HexToAddress(os.Getenv("TAIKO_L2_ADDRESS")), + TaikoTokenAddress: common.HexToAddress(os.Getenv("TAIKO_TOKEN_ADDRESS")), + L2EngineEndpoint: os.Getenv("L2_EXECUTION_ENGINE_AUTH_ENDPOINT"), + JwtSecret: os.Getenv("JWT_SECRET"), + Timeout: 5 * time.Second, + }) + s.Nil(err) + + configs, err := rpcClient.TaikoL1.GetConfig(nil) + s.Nil(err) + + p, err := New(&NewProposerServerOpts{ + RPC: rpcClient, + ProtocolConfigs: &configs, + }) + s.Nil(err) + + p.echo.HideBanner = true + p.configureMiddleware() + p.configureRoutes() + s.s = p + s.testServer = httptest.NewServer(p.echo) +} + +func (s *ProposerServerTestSuite) TestHealth() { + resp := s.sendReq("/healthz") + defer resp.Body.Close() + s.Equal(http.StatusOK, resp.StatusCode) +} + +func (s *ProposerServerTestSuite) TestRoot() { + resp := s.sendReq("/") + defer resp.Body.Close() + s.Equal(http.StatusOK, resp.StatusCode) +} + +func (s *ProposerServerTestSuite) TestStartShutdown() { + port, err := freeport.GetFreePort() + s.Nil(err) + + url, err := url.Parse(fmt.Sprintf("http://localhost:%v", port)) + s.Nil(err) + + go func() { + if err := s.s.Start(fmt.Sprintf(":%v", port)); err != nil { + log.Error("Failed to start prover server", "error", err) + } + }() + + // Wait till the server fully started. + s.Nil(backoff.Retry(func() error { + res, err := resty.New().R().Get(url.String() + "/healthz") + if err != nil { + return err + } + if !res.IsSuccess() { + return fmt.Errorf("invalid response status code: %d", res.StatusCode()) + } + + return nil + }, backoff.NewExponentialBackOff())) + + s.Nil(s.s.Shutdown(context.Background())) +} + +func (s *ProposerServerTestSuite) TearDownTest() { + s.testServer.Close() +} + +func TestProposerServerTestSuite(t *testing.T) { + suite.Run(t, new(ProposerServerTestSuite)) +} + +func (s *ProposerServerTestSuite) sendReq(path string) *http.Response { + res, err := http.Get(s.testServer.URL + path) + s.Nil(err) + return res +} diff --git a/packages/taiko-client/proposer/transaction_builder/blob.go b/packages/taiko-client/proposer/transaction_builder/blob.go index 0994e6f57a..bf50cd53eb 100644 --- a/packages/taiko-client/proposer/transaction_builder/blob.go +++ b/packages/taiko-client/proposer/transaction_builder/blob.go @@ -9,10 +9,12 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/txmgr" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/taikoxyz/taiko-mono/packages/taiko-client/bindings/encoding" + "github.com/taikoxyz/taiko-mono/packages/taiko-client/internal/utils" "github.com/taikoxyz/taiko-mono/packages/taiko-client/pkg/rpc" selector "github.com/taikoxyz/taiko-mono/packages/taiko-client/proposer/prover_selector" ) @@ -92,6 +94,7 @@ func (b *BlobTransactionBuilder) Build( if err != nil { return nil, err } + signature[64] = uint8(uint(signature[64])) + 27 var ( @@ -135,3 +138,65 @@ func (b *BlobTransactionBuilder) Build( Value: maxFee, }, nil } + +// BuildUnsigned implements the ProposeBlockTransactionBuilder interface to +// return an unsigned transaction, intended for preconfirmations. +func (b *BlobTransactionBuilder) BuildUnsigned( + ctx context.Context, + txListBytes []byte, + l1StateBlockNumber uint32, + timestamp uint64, + coinbase common.Address, + extraData [32]byte, +) (*types.Transaction, error) { + compressedTxListBytes, err := utils.Compress(txListBytes) + if err != nil { + return nil, err + } + + var blob = ð.Blob{} + if err := blob.FromData(compressedTxListBytes); err != nil { + return nil, err + } + + var ( + to = &b.taikoL1Address + data []byte + ) + + // ABI encode the TaikoL1.proposeBlock parameters. + encodedParams, err := encoding.EncodeBlockParams(&encoding.BlockParams{ + ExtraData: extraData, + Coinbase: coinbase, + Signature: []byte{}, // no longer checked + L1StateBlockNumber: l1StateBlockNumber, + Timestamp: timestamp, + }) + if err != nil { + return nil, err + } + + data, err = encoding.TaikoL1ABI.Pack("proposeBlock", encodedParams, []byte{}) + if err != nil { + return nil, err + } + + sidecar, blobHashes, err := txmgr.MakeSidecar([]*eth.Blob{blob}) + if err != nil { + return nil, err + } + + blobTx := &types.BlobTx{ + To: *to, + Value: nil, // maxFee / prover selecting no longer happens + Gas: b.gasLimit, + Data: data, + Sidecar: sidecar, + BlobHashes: blobHashes, + } + + tx := types.NewTx(blobTx) + + return tx, nil + +} diff --git a/packages/taiko-client/proposer/transaction_builder/calldata.go b/packages/taiko-client/proposer/transaction_builder/calldata.go index 23fdf5ea0a..f3fbfb2c9a 100644 --- a/packages/taiko-client/proposer/transaction_builder/calldata.go +++ b/packages/taiko-client/proposer/transaction_builder/calldata.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/txmgr" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/taikoxyz/taiko-mono/packages/taiko-client/bindings/encoding" @@ -121,3 +122,16 @@ func (b *CalldataTransactionBuilder) Build( Value: maxFee, }, nil } + +// BuildUnsigned implements the ProposeBlockTransactionBuilder interface. +// TODO: we wont be using this in the upcoming first iteration of the testnet. Leave empty. +func (b *CalldataTransactionBuilder) BuildUnsigned( + ctx context.Context, + txListBytes []byte, + l1StateBlockNumber uint32, + timestamp uint64, + coinbase common.Address, + extraData [32]byte, +) (*types.Transaction, error) { + return &types.Transaction{}, nil +} diff --git a/packages/taiko-client/proposer/transaction_builder/interface.go b/packages/taiko-client/proposer/transaction_builder/interface.go index 6d7badbf4f..5e98095d42 100644 --- a/packages/taiko-client/proposer/transaction_builder/interface.go +++ b/packages/taiko-client/proposer/transaction_builder/interface.go @@ -4,6 +4,8 @@ import ( "context" "github.com/ethereum-optimism/optimism/op-service/txmgr" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/taikoxyz/taiko-mono/packages/taiko-client/bindings/encoding" ) @@ -18,4 +20,12 @@ type ProposeBlockTransactionBuilder interface { timestamp uint64, parentMetaHash [32]byte, ) (*txmgr.TxCandidate, error) + BuildUnsigned( + ctx context.Context, + txListBytes []byte, + l1StateBlockNumber uint32, + timestamp uint64, + coinbase common.Address, + extraData [32]byte, + ) (*types.Transaction, error) }