From 89003d3555b8bcbf597dbdf7f162fa25a4bf2ac9 Mon Sep 17 00:00:00 2001 From: Ferran Borreguero Date: Mon, 4 Dec 2023 11:39:00 +0100 Subject: [PATCH] Add stateless builder --- suave/builder/api/api.go | 14 +++ suave/builder/api/api_client.go | 44 +++++++ suave/builder/api/api_server.go | 38 +++++++ suave/builder/api/api_test.go | 55 +++++++++ suave/builder/builder.go | 68 +++++++++++ suave/builder/builder_test.go | 190 +++++++++++++++++++++++++++++++ suave/builder/session_manager.go | 139 ++++++++++++++++++++++ 7 files changed, 548 insertions(+) create mode 100644 suave/builder/api/api.go create mode 100644 suave/builder/api/api_client.go create mode 100644 suave/builder/api/api_server.go create mode 100644 suave/builder/api/api_test.go create mode 100644 suave/builder/builder.go create mode 100644 suave/builder/builder_test.go create mode 100644 suave/builder/session_manager.go diff --git a/suave/builder/api/api.go b/suave/builder/api/api.go new file mode 100644 index 000000000..4043a8c1b --- /dev/null +++ b/suave/builder/api/api.go @@ -0,0 +1,14 @@ +package api + +import ( + "context" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/core/types" +) + +type API interface { + NewSession(ctx context.Context) (string, error) + AddTransaction(ctx context.Context, sessionId string, tx *types.Transaction) error + Finalize(ctx context.Context, sessionId string) (*engine.ExecutionPayloadEnvelope, error) +} diff --git a/suave/builder/api/api_client.go b/suave/builder/api/api_client.go new file mode 100644 index 000000000..d5560ecbc --- /dev/null +++ b/suave/builder/api/api_client.go @@ -0,0 +1,44 @@ +package api + +import ( + "context" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" +) + +var _ API = (*APIClient)(nil) + +type APIClient struct { + rpc *rpc.Client +} + +func NewClient(endpoint string) (*APIClient, error) { + clt, err := rpc.Dial(endpoint) + if err != nil { + return nil, err + } + return NewClientFromRPC(clt), nil +} + +func NewClientFromRPC(rpc *rpc.Client) *APIClient { + return &APIClient{rpc: rpc} +} + +func (a *APIClient) NewSession(ctx context.Context) (string, error) { + var id string + err := a.rpc.CallContext(ctx, &id, "builder_newSession") + return id, err +} + +func (a *APIClient) AddTransaction(ctx context.Context, sessionId string, tx *types.Transaction) error { + err := a.rpc.CallContext(ctx, nil, "builder_addTransaction", sessionId, tx) + return err +} + +func (a *APIClient) Finalize(ctx context.Context, sessionId string) (*engine.ExecutionPayloadEnvelope, error) { + var res *engine.ExecutionPayloadEnvelope + err := a.rpc.CallContext(ctx, &res, "builder_finalize", sessionId) + return res, err +} diff --git a/suave/builder/api/api_server.go b/suave/builder/api/api_server.go new file mode 100644 index 000000000..a28bcf41a --- /dev/null +++ b/suave/builder/api/api_server.go @@ -0,0 +1,38 @@ +package api + +import ( + "context" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/core/types" +) + +// sessionManager is the backend that manages the session state of the builder API. +type sessionManager interface { + NewSession() (string, error) + AddTransaction(sessionId string, tx *types.Transaction) error + Finalize(sessionId string) (*engine.ExecutionPayloadEnvelope, error) +} + +func NewServer(s sessionManager) *Server { + api := &Server{ + sessionMngr: s, + } + return api +} + +type Server struct { + sessionMngr sessionManager +} + +func (s *Server) NewSession(ctx context.Context) (string, error) { + return s.sessionMngr.NewSession() +} + +func (s *Server) AddTransaction(ctx context.Context, sessionId string, tx *types.Transaction) error { + return s.sessionMngr.AddTransaction(sessionId, tx) +} + +func (s *Server) Finalize(ctx context.Context, sessionId string) (*engine.ExecutionPayloadEnvelope, error) { + return s.sessionMngr.Finalize(sessionId) +} diff --git a/suave/builder/api/api_test.go b/suave/builder/api/api_test.go new file mode 100644 index 000000000..92fee6ddd --- /dev/null +++ b/suave/builder/api/api_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" +) + +func TestAPI(t *testing.T) { + srv := rpc.NewServer() + + builderAPI := NewServer(&nullSessionManager{}) + srv.RegisterName("builder", builderAPI) + + c := NewClientFromRPC(rpc.DialInProc(srv)) + + res0, err := c.NewSession(context.Background()) + require.NoError(t, err) + require.Equal(t, res0, "1") + + txn := types.NewTransaction(0, common.Address{}, big.NewInt(1), 1, big.NewInt(1), []byte{}) + err = c.AddTransaction(context.Background(), "1", txn) + require.NoError(t, err) + + res1, err := c.Finalize(context.Background(), "1") + require.NoError(t, err) + require.Equal(t, res1.BlockValue, big.NewInt(1)) +} + +type nullSessionManager struct{} + +func (n *nullSessionManager) NewSession() (string, error) { + return "1", nil +} + +func (n *nullSessionManager) AddTransaction(sessionId string, tx *types.Transaction) error { + return nil +} + +func (n *nullSessionManager) Finalize(sessionId string) (*engine.ExecutionPayloadEnvelope, error) { + return &engine.ExecutionPayloadEnvelope{ + BlockValue: big.NewInt(1), + ExecutionPayload: &engine.ExecutableData{ + Number: 1, + BaseFeePerGas: big.NewInt(1), + Transactions: [][]byte{}, + }, + }, nil +} diff --git a/suave/builder/builder.go b/suave/builder/builder.go new file mode 100644 index 000000000..f71ababad --- /dev/null +++ b/suave/builder/builder.go @@ -0,0 +1,68 @@ +package builder + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/trie" +) + +type builder struct { + config *builderConfig + txns []*types.Transaction + receipts []*types.Receipt + state *state.StateDB + gasPool *core.GasPool + gasUsed *uint64 +} + +type builderConfig struct { + preState *state.StateDB + header *types.Header + config *params.ChainConfig + context core.ChainContext + vmConfig vm.Config +} + +func newBuilder(config *builderConfig) *builder { + gp := core.GasPool(config.header.GasLimit) + var gasUsed uint64 + + return &builder{ + config: config, + state: config.preState.Copy(), + gasPool: &gp, + gasUsed: &gasUsed, + } +} + +func (b *builder) AddTransaction(txn *types.Transaction) error { + dummyAuthor := common.Address{} + + receipt, err := core.ApplyTransaction(b.config.config, b.config.context, &dummyAuthor, b.gasPool, b.state, b.config.header, txn, b.gasUsed, b.config.vmConfig) + if err != nil { + return err + } + + b.txns = append(b.txns, txn) + b.receipts = append(b.receipts, receipt) + + return nil +} + +func (b *builder) Finalize() (*types.Block, error) { + root, err := b.state.Commit(true) + if err != nil { + return nil, err + } + + header := b.config.header + header.Root = root + header.GasUsed = *b.gasUsed + + block := types.NewBlock(header, b.txns, nil, b.receipts, trie.NewStackTrie(nil)) + return block, nil +} diff --git a/suave/builder/builder_test.go b/suave/builder/builder_test.go new file mode 100644 index 000000000..6c87cb65f --- /dev/null +++ b/suave/builder/builder_test.go @@ -0,0 +1,190 @@ +package builder + +import ( + "crypto/ecdsa" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/stretchr/testify/require" +) + +func TestBuilder_AddTxn_Simple(t *testing.T) { + to := common.Address{0x01, 0x10, 0xab} + + mock := newMockBuilder(t) + txn := mock.newTransfer(t, to, big.NewInt(1)) + + err := mock.builder.AddTransaction(txn) + require.NoError(t, err) + + mock.expect(t, expectedResult{ + txns: []*types.Transaction{ + txn, + }, + balances: map[common.Address]*big.Int{ + to: big.NewInt(1), + }, + }) + + block, err := mock.builder.Finalize() + require.NoError(t, err) + + require.Equal(t, uint64(21000), block.GasUsed()) + require.Len(t, block.Transactions(), 1) + require.Equal(t, txn.Hash(), block.Transactions()[0].Hash()) +} + +func newMockBuilder(t *testing.T) *mockBuilder { + // create a dummy header at 0 + header := &types.Header{ + Number: big.NewInt(0), + GasLimit: 1000000000000, + Time: 1000, + Difficulty: big.NewInt(1), + } + + var stateRef *state.StateDB + + premineKey, _ := crypto.GenerateKey() // TODO: it would be nice to have it deterministic + premineKeyAddr := crypto.PubkeyToAddress(premineKey.PublicKey) + + // create a state reference with at least one premined account + // In order to test the statedb in isolation, we are going + // to commit this pre-state to a memory database + { + db := state.NewDatabase(rawdb.NewMemoryDatabase()) + preState, err := state.New(types.EmptyRootHash, db, nil) + require.NoError(t, err) + + preState.AddBalance(premineKeyAddr, big.NewInt(1000000000000000000)) + + root, err := preState.Commit(true) + require.NoError(t, err) + + stateRef, err = state.New(root, db, nil) + require.NoError(t, err) + } + + // for the sake of this test, we only need all the forks enabled + chainConfig := params.SuaveChainConfig + + // Disable london so that we do not check gasFeeCap (TODO: Fix) + chainConfig.LondonBlock = big.NewInt(100) + + m := &mockBuilder{ + premineKey: premineKey, + premineKeyAddr: premineKeyAddr, + signer: types.NewEIP155Signer(chainConfig.ChainID), + } + + config := &builderConfig{ + header: header, + preState: stateRef, + config: chainConfig, + context: m, // m implements ChainContext with panics + vmConfig: vm.Config{}, + } + m.builder = newBuilder(config) + + return m +} + +type mockBuilder struct { + builder *builder + + // builtin private keys + premineKey *ecdsa.PrivateKey + premineKeyAddr common.Address + + nextNonce uint64 // figure out a better way + signer types.Signer +} + +func (m *mockBuilder) Engine() consensus.Engine { + panic("TODO") +} + +func (m *mockBuilder) GetHeader(common.Hash, uint64) *types.Header { + panic("TODO") +} + +func (m *mockBuilder) getNonce() uint64 { + next := m.nextNonce + m.nextNonce++ + return next +} + +func (m *mockBuilder) newTransfer(t *testing.T, to common.Address, amount *big.Int) *types.Transaction { + tx := types.NewTransaction(m.getNonce(), to, amount, 1000000, big.NewInt(1), nil) + return m.newTxn(t, tx) +} + +func (m *mockBuilder) newTxn(t *testing.T, tx *types.Transaction) *types.Transaction { + // sign the transaction + signature, err := crypto.Sign(m.signer.Hash(tx).Bytes(), m.premineKey) + require.NoError(t, err) + + // include the signature in the transaction + tx, err = tx.WithSignature(m.signer, signature) + require.NoError(t, err) + + return tx +} + +type expectedResult struct { + txns []*types.Transaction + balances map[common.Address]*big.Int +} + +func (m *mockBuilder) expect(t *testing.T, res expectedResult) { + // validate txns + if len(res.txns) != len(m.builder.txns) { + t.Fatalf("expected %d txns, got %d", len(res.txns), len(m.builder.txns)) + } + for indx, txn := range res.txns { + if txn.Hash() != m.builder.txns[indx].Hash() { + t.Fatalf("expected txn %d to be %s, got %s", indx, txn.Hash(), m.builder.txns[indx].Hash()) + } + } + + // The receipts must be the same as the txns + if len(res.txns) != len(m.builder.receipts) { + t.Fatalf("expected %d receipts, got %d", len(res.txns), len(m.builder.receipts)) + } + for indx, txn := range res.txns { + if txn.Hash() != m.builder.receipts[indx].TxHash { + t.Fatalf("expected receipt %d to be %s, got %s", indx, txn.Hash(), m.builder.receipts[indx].TxHash) + } + } + + // The gas left in the pool must be the header gas limit minus + // the total gas consumed by all the transactions in the block. + totalGasConsumed := uint64(0) + for _, receipt := range m.builder.receipts { + totalGasConsumed += receipt.GasUsed + } + if m.builder.gasPool.Gas() != m.builder.config.header.GasLimit-totalGasConsumed { + t.Fatalf("expected gas pool to be %d, got %d", m.builder.config.header.GasLimit-totalGasConsumed, m.builder.gasPool.Gas()) + } + + // The 'gasUsed' must match the total gas consumed by all the transactions + if *m.builder.gasUsed != totalGasConsumed { + t.Fatalf("expected gas used to be %d, got %d", totalGasConsumed, m.builder.gasUsed) + } + + // The state must match the expected balances + for addr, expectedBalance := range res.balances { + balance := m.builder.state.GetBalance(addr) + if balance.Cmp(expectedBalance) != 0 { + t.Fatalf("expected balance of %s to be %d, got %d", addr, expectedBalance, balance) + } + } +} diff --git a/suave/builder/session_manager.go b/suave/builder/session_manager.go new file mode 100644 index 000000000..6c278df97 --- /dev/null +++ b/suave/builder/session_manager.go @@ -0,0 +1,139 @@ +package builder + +import ( + "fmt" + "math/big" + "sync" + + "github.com/ethereum/go-ethereum/beacon/engine" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/google/uuid" +) + +// blockchain is the minimum interface to the blockchain +// required to build a block +type blockchain interface { + // Header returns the current tip of the chain + Header() *types.Header + + // StateAt returns the state at the given root + StateAt(root common.Hash) (*state.StateDB, error) +} + +type Config struct { + GasCeil uint64 +} + +type SessionManager struct { + sessions map[string]*builder + sessionsLock sync.RWMutex + blockchain blockchain + config *Config +} + +func NewSessionManager(blockchain blockchain, config *Config) *SessionManager { + if config.GasCeil == 0 { + config.GasCeil = 1000000000000000000 + } + + s := &SessionManager{ + sessions: make(map[string]*builder), + blockchain: blockchain, + config: config, + } + return s +} + +// NewSession creates a new builder session and returns the session id +func (s *SessionManager) NewSession() (string, error) { + s.sessionsLock.Lock() + defer s.sessionsLock.Unlock() + + parent := s.blockchain.Header() + + header := &types.Header{ + ParentHash: parent.Hash(), + Number: new(big.Int).Add(parent.Number, common.Big1), + GasLimit: core.CalcGasLimit(parent.GasLimit, s.config.GasCeil), + Time: 1000, // TODO: fix this + Coinbase: common.Address{}, // TODO: fix this + } + + stateRef, err := s.blockchain.StateAt(parent.Root) + if err != nil { + return "", err + } + + cfg := &builderConfig{ + preState: stateRef, + header: header, + } + + id := uuid.New().String()[:7] + s.sessions[id] = newBuilder(cfg) + + return id, nil +} + +func (s *SessionManager) getSession(sessionId string) (*builder, error) { + s.sessionsLock.RLock() + defer s.sessionsLock.RUnlock() + + session, ok := s.sessions[sessionId] + if !ok { + return nil, fmt.Errorf("session %s not found", sessionId) + } + return session, nil +} + +func (s *SessionManager) AddTransaction(sessionId string, tx *types.Transaction) error { + builder, err := s.getSession(sessionId) + if err != nil { + return err + } + return builder.AddTransaction(tx) +} + +func (s *SessionManager) Finalize(sessionId string) (*engine.ExecutionPayloadEnvelope, error) { + builder, err := s.getSession(sessionId) + if err != nil { + return nil, err + } + + block, err := builder.Finalize() + if err != nil { + return nil, err + } + data := &engine.ExecutableData{ + ParentHash: block.ParentHash(), + Number: block.Number().Uint64(), + GasLimit: block.GasLimit(), + GasUsed: block.GasUsed(), + LogsBloom: block.Bloom().Bytes(), + ReceiptsRoot: block.ReceiptHash(), + BlockHash: block.Hash(), + StateRoot: block.Root(), + Timestamp: block.Time(), + ExtraData: block.Extra(), + BaseFeePerGas: &big.Int{}, // TODO + Transactions: [][]byte{}, + } + + // convert transactions to bytes + for _, txn := range block.Transactions() { + txnData, err := txn.MarshalBinary() + if err != nil { + return nil, err + } + data.Transactions = append(data.Transactions, txnData) + } + + payload := &engine.ExecutionPayloadEnvelope{ + BlockValue: big.NewInt(0), // TODO + ExecutionPayload: data, + } + return payload, nil +}