diff --git a/cmd/geth/main.go b/cmd/geth/main.go index 4438cef560..f55bc8ed29 100644 --- a/cmd/geth/main.go +++ b/cmd/geth/main.go @@ -196,6 +196,9 @@ var ( utils.MetricsInfluxDBBucketFlag, utils.MetricsInfluxDBOrganizationFlag, } + suaveApiFlags = []cli.Flag{ + utils.SuaveEnabled, + } ) var app = flags.NewApp("the go-ethereum command line interface") @@ -245,6 +248,7 @@ func init() { consoleFlags, debug.Flags, metricsFlags, + suaveApiFlags, ) flags.AutoEnvVars(app.Flags, "GETH") diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go index 159c47ca01..5d9f9fa555 100644 --- a/cmd/utils/flags.go +++ b/cmd/utils/flags.go @@ -69,6 +69,7 @@ import ( "github.com/ethereum/go-ethereum/p2p/netutil" "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethereum/go-ethereum/suave" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum/go-ethereum/trie/triedb/hashdb" "github.com/ethereum/go-ethereum/trie/triedb/pathdb" @@ -908,6 +909,13 @@ Please note that --` + MetricsHTTPFlag.Name + ` must be set to start the server. Value: metrics.DefaultConfig.InfluxDBOrganization, Category: flags.MetricsCategory, } + + // SUAVE namespace rpc settings + SuaveEnabled = &cli.BoolFlag{ + Name: "suave", + Usage: "Enable the suave", + Category: flags.SuaveCategory, + } ) var ( @@ -1344,6 +1352,10 @@ func SetP2PConfig(ctx *cli.Context, cfg *p2p.Config) { } } +func SetSuaveConfig(ctx *cli.Context, cfg *suave.Config) { + cfg.Enabled = ctx.IsSet(SuaveEnabled.Name) +} + // SetNodeConfig applies node-related command line flags to the config. func SetNodeConfig(ctx *cli.Context, cfg *node.Config) { SetP2PConfig(ctx, &cfg.P2P) @@ -1859,11 +1871,18 @@ func SetDNSDiscoveryDefaults(cfg *ethconfig.Config, genesis common.Hash) { // RegisterEthService adds an Ethereum client to the stack. // The second return value is the full node instance. -func RegisterEthService(stack *node.Node, cfg *ethconfig.Config) (ethapi.Backend, *eth.Ethereum) { +func RegisterEthService(stack *node.Node, cfg *ethconfig.Config, suaveConfig *suave.Config) (ethapi.Backend, *eth.Ethereum) { backend, err := eth.New(stack, cfg) if err != nil { Fatalf("Failed to register the Ethereum service: %v", err) } + if suaveConfig.Enabled { + log.Info("Enable suave service") + if err := suave.Register(stack, backend, suaveConfig); err != nil { + Fatalf("Failed to register the suave service: %v", err) + } + } + stack.RegisterAPIs(tracers.APIs(backend.APIBackend)) return backend.APIBackend, backend } diff --git a/core/types/sbundle.go b/core/types/sbundle.go new file mode 100644 index 0000000000..c88dd4f3bc --- /dev/null +++ b/core/types/sbundle.go @@ -0,0 +1,95 @@ +package types + +import ( + "encoding/json" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// Simplified Share Bundle Type for PoC + +type SBundle struct { + BlockNumber *big.Int `json:"blockNumber,omitempty"` // if BlockNumber is set it must match DecryptionCondition! + MaxBlock *big.Int `json:"maxBlock,omitempty"` + Txs Transactions `json:"txs"` + RevertingHashes []common.Hash `json:"revertingHashes,omitempty"` + RefundPercent *int `json:"percent,omitempty"` +} + +type RpcSBundle struct { + BlockNumber *hexutil.Big `json:"blockNumber,omitempty"` + MaxBlock *hexutil.Big `json:"maxBlock,omitempty"` + Txs []hexutil.Bytes `json:"txs"` + RevertingHashes []common.Hash `json:"revertingHashes,omitempty"` + RefundPercent *int `json:"percent,omitempty"` +} + +func (s *SBundle) MarshalJSON() ([]byte, error) { + txs := []hexutil.Bytes{} + for _, tx := range s.Txs { + txBytes, err := tx.MarshalBinary() + if err != nil { + return nil, err + } + txs = append(txs, txBytes) + } + + var blockNumber *hexutil.Big + if s.BlockNumber != nil { + blockNumber = new(hexutil.Big) + *blockNumber = hexutil.Big(*s.BlockNumber) + } + + return json.Marshal(&RpcSBundle{ + BlockNumber: blockNumber, + Txs: txs, + RevertingHashes: s.RevertingHashes, + RefundPercent: s.RefundPercent, + }) +} + +func (s *SBundle) UnmarshalJSON(data []byte) error { + var rpcSBundle RpcSBundle + if err := json.Unmarshal(data, &rpcSBundle); err != nil { + return err + } + + var txs Transactions + for _, txBytes := range rpcSBundle.Txs { + var tx Transaction + err := tx.UnmarshalBinary(txBytes) + if err != nil { + return err + } + + txs = append(txs, &tx) + } + + s.BlockNumber = (*big.Int)(rpcSBundle.BlockNumber) + s.MaxBlock = (*big.Int)(rpcSBundle.MaxBlock) + s.Txs = txs + s.RevertingHashes = rpcSBundle.RevertingHashes + s.RefundPercent = rpcSBundle.RefundPercent + + return nil +} + +type RPCMevShareBundle struct { + Version string `json:"version"` + Inclusion struct { + Block string `json:"block"` + MaxBlock string `json:"maxBlock"` + } `json:"inclusion"` + Body []struct { + Tx string `json:"tx"` + CanRevert bool `json:"canRevert"` + } `json:"body"` + Validity struct { + Refund []struct { + BodyIdx int `json:"bodyIdx"` + Percent int `json:"percent"` + } `json:"refund"` + } `json:"validity"` +} diff --git a/core/types/suave_structs.go b/core/types/suave_structs.go new file mode 100644 index 0000000000..0d9bab5fec --- /dev/null +++ b/core/types/suave_structs.go @@ -0,0 +1,32 @@ +// Code generated by suave/gen in https://github.com/flashbots/suave-geth. +// DO NOT EDIT. +// Hash: c60f303834fbdbbd940aae7cb3679cf3755a25f7384f1052c20bf6c38d9a0451 +package types + +import "github.com/ethereum/go-ethereum/common" + +type DataId [16]byte + +// Structs + +type BuildBlockArgs struct { + Slot uint64 + ProposerPubkey []byte + Parent common.Hash + Timestamp uint64 + FeeRecipient common.Address + GasLimit uint64 + Random common.Hash + Withdrawals []*Withdrawal + Extra []byte + FillPending bool +} + +type DataRecord struct { + Id DataId + Salt DataId + DecryptionCondition uint64 + AllowedPeekers []common.Address + AllowedStores []common.Address + Version string +} diff --git a/eth/api_backend.go b/eth/api_backend.go index bc8398d217..d4148ee11a 100644 --- a/eth/api_backend.go +++ b/eth/api_backend.go @@ -415,3 +415,11 @@ func (b *EthAPIBackend) StateAtBlock(ctx context.Context, block *types.Block, re func (b *EthAPIBackend) StateAtTransaction(ctx context.Context, block *types.Block, txIndex int, reexec uint64) (*core.Message, vm.BlockContext, *state.StateDB, tracers.StateReleaseFunc, error) { return b.eth.stateAtTransaction(ctx, block, txIndex, reexec) } + +func (b *EthAPIBackend) BuildBlockFromTxs(ctx context.Context, buildArgs *types.BuildBlockArgs, txs types.Transactions) (*types.Block, *big.Int, error) { + return b.eth.Miner().BuildBlockFromTxs(ctx, buildArgs, txs) +} + +func (b *EthAPIBackend) BuildBlockFromBundles(ctx context.Context, buildArgs *types.BuildBlockArgs, bundles []types.SBundle) (*types.Block, *big.Int, error) { + return b.eth.Miner().BuildBlockFromBundles(ctx, buildArgs, bundles) +} diff --git a/eth/backend.go b/eth/backend.go index 774ffaf248..cb410d3655 100644 --- a/eth/backend.go +++ b/eth/backend.go @@ -57,6 +57,8 @@ import ( "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/rpc" + suave_builder "github.com/ethereum/go-ethereum/suave/builder" + suave_builder_api "github.com/ethereum/go-ethereum/suave/builder/api" ) // Config contains the configuration options of the ETH protocol. @@ -309,6 +311,19 @@ func makeExtraData(extra []byte) []byte { func (s *Ethereum) APIs() []rpc.API { apis := ethapi.GetAPIs(s.APIBackend) + // Append SUAVE-enabled node backend + apis = append(apis, rpc.API{ + Namespace: "suavex", + Service: backends.NewEthBackendServer(s.APIBackend), + }) + + sessionManager := suave_builder.NewSessionManager(s.blockchain, &suave_builder.Config{}) + + apis = append(apis, rpc.API{ + Namespace: "suavex", + Service: suave_builder_api.NewServer(sessionManager), + }) + // Append any APIs exposed explicitly by the consensus engine apis = append(apis, s.engine.APIs(s.BlockChain())...) diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go index c2490ac703..831b865a03 100644 --- a/internal/ethapi/api_test.go +++ b/internal/ethapi/api_test.go @@ -48,6 +48,7 @@ import ( "github.com/ethereum/go-ethereum/internal/blocktest" "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/rpc" + "github.com/ethereum/go-ethereum/trie" "github.com/holiman/uint256" "github.com/stretchr/testify/require" "golang.org/x/exp/slices" @@ -597,6 +598,24 @@ func (b testBackend) ServiceFilter(ctx context.Context, session *bloombits.Match panic("implement me") } +func (n *testBackend) BuildBlockFromTxs(ctx context.Context, buildArgs *types.BuildBlockArgs, txs types.Transactions) (*types.Block, *big.Int, error) { + block := types.NewBlock(&types.Header{GasUsed: 1000, BaseFee: big.NewInt(1)}, txs, nil, nil, trie.NewStackTrie(nil)) + return block, big.NewInt(11000), nil +} + +func (n *testBackend) BuildBlockFromBundles(ctx context.Context, buildArgs *types.BuildBlockArgs, bundles []types.SBundle) (*types.Block, *big.Int, error) { + var txs types.Transactions + for _, bundle := range bundles { + txs = append(txs, bundle.Txs...) + } + block := types.NewBlock(&types.Header{GasUsed: 1000, BaseFee: big.NewInt(1)}, txs, nil, nil, trie.NewStackTrie(nil)) + return block, big.NewInt(11000), nil +} + +func (n *testBackend) Call(ctx context.Context, contractAddr common.Address, input []byte) ([]byte, error) { + return []byte{0x1}, nil +} + func TestEstimateGas(t *testing.T) { t.Parallel() // Initialize test accounts diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index 50f338f5ca..48ba0e7fd7 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -97,6 +97,10 @@ type Backend interface { SubscribePendingLogsEvent(ch chan<- []*types.Log) event.Subscription BloomStatus() (uint64, uint64) ServiceFilter(ctx context.Context, session *bloombits.MatcherSession) + + // SUAVE Execution Methods + BuildBlockFromTxs(ctx context.Context, buildArgs *types.BuildBlockArgs, txs types.Transactions) (*types.Block, *big.Int, error) + BuildBlockFromBundles(ctx context.Context, buildArgs *types.BuildBlockArgs, bundles []types.SBundle) (*types.Block, *big.Int, error) } func GetAPIs(apiBackend Backend) []rpc.API { diff --git a/internal/ethapi/transaction_args_test.go b/internal/ethapi/transaction_args_test.go index ab7c2f70ed..7c99cff148 100644 --- a/internal/ethapi/transaction_args_test.go +++ b/internal/ethapi/transaction_args_test.go @@ -365,3 +365,15 @@ func (b *backendMock) SubscribeRemovedLogsEvent(ch chan<- core.RemovedLogsEvent) } func (b *backendMock) Engine() consensus.Engine { return nil } + +func (n *backendMock) BuildBlockFromTxs(ctx context.Context, buildArgs *types.BuildBlockArgs, txs types.Transactions) (*types.Block, *big.Int, error) { + return nil, nil, nil +} + +func (n *backendMock) BuildBlockFromBundles(ctx context.Context, buildArgs *types.BuildBlockArgs, bundles []types.SBundle) (*types.Block, *big.Int, error) { + return nil, nil, nil +} + +func (n *backendMock) Call(ctx context.Context, contractAddr common.Address, input []byte) ([]byte, error) { + return nil, nil +} diff --git a/internal/flags/categories.go b/internal/flags/categories.go index 3ff0767921..4373f7e974 100644 --- a/internal/flags/categories.go +++ b/internal/flags/categories.go @@ -37,6 +37,7 @@ const ( MiscCategory = "MISC" TestingCategory = "TESTING" DeprecatedCategory = "ALIASED (deprecated)" + SuaveCategory = "SUAVE" ) func init() { diff --git a/miner/miner.go b/miner/miner.go index b7273948f5..20bade3da2 100644 --- a/miner/miner.go +++ b/miner/miner.go @@ -18,6 +18,7 @@ package miner import ( + "context" "fmt" "math/big" "sync" @@ -244,3 +245,11 @@ func (miner *Miner) SubscribePendingLogs(ch chan<- []*types.Log) event.Subscript func (miner *Miner) BuildPayload(args *BuildPayloadArgs) (*Payload, error) { return miner.worker.buildPayload(args) } + +func (miner *Miner) BuildBlockFromTxs(ctx context.Context, buildArgs *types.BuildBlockArgs, txs types.Transactions) (*types.Block, *big.Int, error) { + return miner.worker.buildBlockFromTxs(ctx, buildArgs, txs) +} + +func (miner *Miner) BuildBlockFromBundles(ctx context.Context, buildArgs *types.BuildBlockArgs, bundles []types.SBundle) (*types.Block, *big.Int, error) { + return miner.worker.buildBlockFromBundles(ctx, buildArgs, bundles) +} diff --git a/miner/worker.go b/miner/worker.go index 2ed91cc187..6282aecc5b 100644 --- a/miner/worker.go +++ b/miner/worker.go @@ -17,6 +17,7 @@ package miner import ( + "context" "errors" "fmt" "math/big" @@ -33,6 +34,7 @@ import ( "github.com/ethereum/go-ethereum/core/txpool" "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/event" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" @@ -888,11 +890,13 @@ func (w *worker) commitTransactions(env *environment, txs *transactionsByPriceAn // generateParams wraps various of settings for generating sealing task. type generateParams struct { - timestamp uint64 // The timstamp for sealing task - forceTime bool // Flag whether the given timestamp is immutable or not - parentHash common.Hash // Parent block hash, empty means the latest chain head - coinbase common.Address // The fee recipient address for including transaction + timestamp uint64 // The timstamp for sealing task + forceTime bool // Flag whether the given timestamp is immutable or not + parentHash common.Hash // Parent block hash, empty means the latest chain head + coinbase common.Address // The fee recipient address for including transaction + gasLimit uint64 random common.Hash // The randomness generated by beacon chain, empty before the merge + extra []byte // The extra data to include in the block header withdrawals types.Withdrawals // List of withdrawals to include in block. beaconRoot *common.Hash // The beacon root (cancun field). noTxs bool // Flag whether an empty block without any transaction is expected @@ -1212,3 +1216,226 @@ func signalToErr(signal int32) error { panic(fmt.Errorf("undefined signal %d", signal)) } } + +// SUAVE + +func (w *worker) rawCommitTransactions(env *environment, txs types.Transactions) error { + gasLimit := env.header.GasLimit + if env.gasPool == nil { + env.gasPool = new(core.GasPool).AddGas(gasLimit) + } + + // TODO: logs should be a part of the env and returned to whoever requested the block + // var coalescedLogs []*types.Log + + for _, tx := range txs { + // If we don't have enough gas for any further transactions then we're done. + if env.gasPool.Gas() < params.TxGas { + log.Trace("Not enough gas for further transactions", "have", env.gasPool, "want", params.TxGas) + break + } + // Error may be ignored here. The error has already been checked + // during transaction acceptance is the transaction pool. + from, _ := types.Sender(env.signer, tx) + + // Check whether the tx is replay protected. If we're not in the EIP155 hf + // phase, start ignoring the sender until we do. + if tx.Protected() && !w.chainConfig.IsEIP155(env.header.Number) { + log.Trace("Ignoring reply protected transaction", "hash", tx.Hash(), "eip155", w.chainConfig.EIP155Block) + + return fmt.Errorf("invalid reply protected tx %s", tx.Hash()) + } + + // Start executing the transaction + env.state.SetTxContext(tx.Hash(), env.tcount) + + // logs, err := w.commitTransaction(env, tx) + _, err := w.commitTransaction(env, tx) + + switch { + case errors.Is(err, core.ErrNonceTooLow): + log.Debug("Skipping transaction with low nonce", "hash", tx.Hash(), "sender", from, "nonce", tx.Nonce()) + return err + case errors.Is(err, nil): + // coalescedLogs = append(coalescedLogs, logs...) + env.tcount++ + default: + // Transaction is regarded as invalid, drop all consecutive transactions from + // the same sender because of `nonce-too-high` clause. + log.Debug("Transaction failed, account skipped", "hash", tx.Hash(), "err", err) + return err + } + } + return nil +} + +func (w *worker) commitPendingTxs(work *environment) error { + interrupt := new(atomic.Int32) + timer := time.AfterFunc(w.newpayloadTimeout, func() { + interrupt.Store(commitInterruptTimeout) + }) + defer timer.Stop() + if err := w.fillTransactions(nil, work); err != nil { + return err + } + return nil +} + +func (w *worker) buildBlockFromTxs(ctx context.Context, args *types.BuildBlockArgs, txs types.Transactions) (*types.Block, *big.Int, error) { + params := &generateParams{ + timestamp: args.Timestamp, + forceTime: true, + parentHash: args.Parent, + coinbase: args.FeeRecipient, + gasLimit: args.GasLimit, + random: args.Random, + extra: args.Extra, + withdrawals: args.Withdrawals, + // noUncle: true, + noTxs: false, + } + + work, err := w.prepareWork(params) + if err != nil { + return nil, nil, err + } + defer work.discard() + + profitPre := work.state.GetBalance(args.FeeRecipient) + + if err := w.rawCommitTransactions(work, txs); err != nil { + return nil, nil, err + } + if args.FillPending { + if err := w.commitPendingTxs(work); err != nil { + return nil, nil, err + } + } + + profitPost := work.state.GetBalance(args.FeeRecipient) + // TODO : Is it okay to set Uncle List to nil? + block, err := w.engine.FinalizeAndAssemble(w.chain, work.header, work.state, work.txs, nil, work.receipts, params.withdrawals) + if err != nil { + return nil, nil, err + } + blockProfit := new(big.Int).Sub(profitPost, profitPre) + return block, blockProfit, nil +} + +func (w *worker) buildBlockFromBundles(ctx context.Context, args *types.BuildBlockArgs, bundles []types.SBundle) (*types.Block, *big.Int, error) { + // create ephemeral addr and private key for payment txn + ephemeralPrivKey, err := crypto.GenerateKey() + if err != nil { + return nil, nil, err + } + ephemeralAddr := crypto.PubkeyToAddress(ephemeralPrivKey.PublicKey) + + params := &generateParams{ + timestamp: args.Timestamp, + forceTime: true, + parentHash: args.Parent, + coinbase: ephemeralAddr, // NOTE : overriding BuildBlockArgs.FeeRecipient TODO : make customizable + gasLimit: args.GasLimit, + random: args.Random, + extra: args.Extra, + withdrawals: args.Withdrawals, + // noUncle: true, + noTxs: false, + } + + work, err := w.prepareWork(params) + if err != nil { + return nil, nil, err + } + defer work.discard() + + // Assume static 28000 gas transfers for both mev-share and proposer payments + refundTransferCost := new(big.Int).Mul(big.NewInt(28000), work.header.BaseFee) + + profitPre := work.state.GetBalance(params.coinbase) + + for _, bundle := range bundles { + // NOTE: failing bundles will cause the block to not be built! + + // apply bundle + profitPreBundle := work.state.GetBalance(params.coinbase) + if err := w.rawCommitTransactions(work, bundle.Txs); err != nil { + return nil, nil, err + } + profitPostBundle := work.state.GetBalance(params.coinbase) + + // calc & refund user if bundle has multiple txns and wants refund + if len(bundle.Txs) > 1 && bundle.RefundPercent != nil { + // Note: PoC logic, this could be gamed by not sending any eth to coinbase + refundPrct := *bundle.RefundPercent + if refundPrct == 0 { + // default refund + refundPrct = 10 + } + bundleProfit := new(big.Int).Sub(profitPostBundle, profitPreBundle) + refundAmt := new(big.Int).Div(bundleProfit, big.NewInt(int64(refundPrct))) + // subtract payment txn transfer costs + refundAmt = new(big.Int).Sub(refundAmt, refundTransferCost) + + currNonce := work.state.GetNonce(ephemeralAddr) + // HACK to include payment txn + // multi refund block untested + userTx := bundle.Txs[0] // NOTE : assumes first txn is refund recipient + refundAddr, err := types.Sender(types.LatestSignerForChainID(userTx.ChainId()), userTx) + if err != nil { + return nil, nil, err + } + paymentTx, err := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: currNonce, + To: &refundAddr, + Value: refundAmt, + Gas: 28000, + GasPrice: work.header.BaseFee, + }), work.signer, ephemeralPrivKey) + + if err != nil { + return nil, nil, err + } + + // commit payment txn + if err := w.rawCommitTransactions(work, types.Transactions{paymentTx}); err != nil { + return nil, nil, err + } + } + } + if args.FillPending { + if err := w.commitPendingTxs(work); err != nil { + return nil, nil, err + } + } + + profitPost := work.state.GetBalance(params.coinbase) + proposerProfit := new(big.Int).Set(profitPost) // = post-pre-transfer_cost + proposerProfit = proposerProfit.Sub(profitPost, profitPre) + proposerProfit = proposerProfit.Sub(proposerProfit, refundTransferCost) + + currNonce := work.state.GetNonce(ephemeralAddr) + paymentTx, err := types.SignTx(types.NewTx(&types.LegacyTx{ + Nonce: currNonce, + To: &args.FeeRecipient, + Value: proposerProfit, + Gas: 28000, + GasPrice: work.header.BaseFee, + }), work.signer, ephemeralPrivKey) + if err != nil { + return nil, nil, fmt.Errorf("could not sign proposer payment: %w", err) + } + + // commit payment txn + if err := w.rawCommitTransactions(work, types.Transactions{paymentTx}); err != nil { + return nil, nil, fmt.Errorf("could not sign proposer payment: %w", err) + } + + log.Info("buildBlockFromBundles", "num_bundles", len(bundles), "num_txns", len(work.txs), "profit", proposerProfit) + // TODO : Is it okay to set Uncle List to nil? + block, err := w.engine.FinalizeAndAssemble(w.chain, work.header, work.state, work.txs, nil, work.receipts, params.withdrawals) + if err != nil { + return nil, nil, err + } + return block, proposerProfit, nil +} diff --git a/suave/builder/api/api.go b/suave/builder/api/api.go new file mode 100644 index 0000000000..b11d237ba2 --- /dev/null +++ b/suave/builder/api/api.go @@ -0,0 +1,12 @@ +package api + +import ( + "context" + + "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) (*types.SimulateTransactionResult, error) +} diff --git a/suave/builder/api/api_client.go b/suave/builder/api/api_client.go new file mode 100644 index 0000000000..3baefde531 --- /dev/null +++ b/suave/builder/api/api_client.go @@ -0,0 +1,42 @@ +package api + +import ( + "context" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" +) + +var _ API = (*APIClient)(nil) + +type APIClient struct { + rpc rpcClient +} + +func NewClient(endpoint string) (*APIClient, error) { + clt, err := rpc.Dial(endpoint) + if err != nil { + return nil, err + } + return NewClientFromRPC(clt), nil +} + +type rpcClient interface { + CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error +} + +func NewClientFromRPC(rpc rpcClient) *APIClient { + return &APIClient{rpc: rpc} +} + +func (a *APIClient) NewSession(ctx context.Context) (string, error) { + var id string + err := a.rpc.CallContext(ctx, &id, "suavex_newSession") + return id, err +} + +func (a *APIClient) AddTransaction(ctx context.Context, sessionId string, tx *types.Transaction) (*types.SimulateTransactionResult, error) { + var receipt *types.SimulateTransactionResult + err := a.rpc.CallContext(ctx, &receipt, "suavex_addTransaction", sessionId, tx) + return receipt, err +} diff --git a/suave/builder/api/api_server.go b/suave/builder/api/api_server.go new file mode 100644 index 0000000000..7eb6daf795 --- /dev/null +++ b/suave/builder/api/api_server.go @@ -0,0 +1,43 @@ +package api + +import ( + "context" + + "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) (*types.SimulateTransactionResult, 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) (*types.SimulateTransactionResult, error) { + return s.sessionMngr.AddTransaction(sessionId, tx) +} + +type MockServer struct { +} + +func (s *MockServer) NewSession(ctx context.Context) (string, error) { + return "", nil +} + +func (s *MockServer) AddTransaction(ctx context.Context, sessionId string, tx *types.Transaction) (*types.SimulateTransactionResult, error) { + return &types.SimulateTransactionResult{}, nil +} diff --git a/suave/builder/api/api_test.go b/suave/builder/api/api_test.go new file mode 100644 index 0000000000..51122eef02 --- /dev/null +++ b/suave/builder/api/api_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "context" + "math/big" + "testing" + + "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("suavex", 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) +} + +type nullSessionManager struct{} + +func (n *nullSessionManager) NewSession() (string, error) { + return "1", nil +} + +func (n *nullSessionManager) AddTransaction(sessionId string, tx *types.Transaction) (*types.SimulateTransactionResult, error) { + return &types.SimulateTransactionResult{Logs: []*types.SimulatedLog{}}, nil +} diff --git a/suave/builder/builder.go b/suave/builder/builder.go new file mode 100644 index 0000000000..0d5c30ce9e --- /dev/null +++ b/suave/builder/builder.go @@ -0,0 +1,77 @@ +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" +) + +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 +} + +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) (*types.SimulateTransactionResult, error) { + dummyAuthor := common.Address{} + + vmConfig := vm.Config{ + NoBaseFee: true, + } + + snap := b.state.Snapshot() + + b.state.SetTxContext(txn.Hash(), len(b.txns)) + receipt, err := core.ApplyTransaction(b.config.config, b.config.context, &dummyAuthor, b.gasPool, b.state, b.config.header, txn, b.gasUsed, vmConfig) + if err != nil { + b.state.RevertToSnapshot(snap) + + result := &types.SimulateTransactionResult{ + Success: false, + Error: err.Error(), + } + return result, nil + } + + b.txns = append(b.txns, txn) + b.receipts = append(b.receipts, receipt) + + result := &types.SimulateTransactionResult{ + Success: true, + Logs: []*types.SimulatedLog{}, + } + for _, log := range receipt.Logs { + result.Logs = append(result.Logs, &types.SimulatedLog{ + Addr: log.Address, + Topics: log.Topics, + Data: log.Data, + }) + } + + return result, nil +} diff --git a/suave/builder/builder_test.go b/suave/builder/builder_test.go new file mode 100644 index 0000000000..0ceae209ae --- /dev/null +++ b/suave/builder/builder_test.go @@ -0,0 +1,122 @@ +package builder + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" +) + +func TestBuilder_AddTxn_Simple(t *testing.T) { + to := common.Address{0x01, 0x10, 0xab} + + mock := newMockBuilder(t) + txn := mock.state.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), + }, + }) +} + +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), + } + + mState := newMockState(t) + + m := &mockBuilder{ + state: mState, + } + + stateRef, err := mState.stateAt(mState.stateRoot) + require.NoError(t, err) + + config := &builderConfig{ + header: header, + preState: stateRef, + config: mState.chainConfig, + context: m, // m implements ChainContext with panics + } + m.builder = newBuilder(config) + + return m +} + +type mockBuilder struct { + builder *builder + state *mockState +} + +func (m *mockBuilder) Engine() consensus.Engine { + panic("TODO") +} + +func (m *mockBuilder) GetHeader(common.Hash, uint64) *types.Header { + panic("TODO") +} + +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 0000000000..b3e740cbd4 --- /dev/null +++ b/suave/builder/session_manager.go @@ -0,0 +1,138 @@ +package builder + +import ( + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/misc" + "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/params" + "github.com/google/uuid" +) + +// blockchain is the minimum interface to the blockchain +// required to build a block +type blockchain interface { + core.ChainContext + + // Header returns the current tip of the chain + CurrentHeader() *types.Header + + // StateAt returns the state at the given root + StateAt(root common.Hash) (*state.StateDB, error) + + // Config returns the chain config + Config() *params.ChainConfig +} + +type Config struct { + GasCeil uint64 + SessionIdleTimeout time.Duration +} + +type SessionManager struct { + sessions map[string]*builder + sessionTimers map[string]*time.Timer + sessionsLock sync.RWMutex + blockchain blockchain + config *Config +} + +func NewSessionManager(blockchain blockchain, config *Config) *SessionManager { + if config.GasCeil == 0 { + config.GasCeil = 1000000000000000000 + } + if config.SessionIdleTimeout == 0 { + config.SessionIdleTimeout = 5 * time.Second + } + + s := &SessionManager{ + sessions: make(map[string]*builder), + sessionTimers: make(map[string]*time.Timer), + 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.CurrentHeader() + + chainConfig := s.blockchain.Config() + + 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 + Difficulty: big.NewInt(1), + } + + // Set baseFee and GasLimit if we are on an EIP-1559 chain + if chainConfig.IsLondon(header.Number) { + header.BaseFee = misc.CalcBaseFee(chainConfig, parent) + if !chainConfig.IsLondon(parent.Number) { + parentGasLimit := parent.GasLimit * chainConfig.ElasticityMultiplier() + header.GasLimit = core.CalcGasLimit(parentGasLimit, s.config.GasCeil) + } + } + + stateRef, err := s.blockchain.StateAt(parent.Root) + if err != nil { + return "", err + } + + cfg := &builderConfig{ + preState: stateRef, + header: header, + config: s.blockchain.Config(), + context: s.blockchain, + } + + id := uuid.New().String()[:7] + s.sessions[id] = newBuilder(cfg) + + // start session timer + s.sessionTimers[id] = time.AfterFunc(s.config.SessionIdleTimeout, func() { + s.sessionsLock.Lock() + defer s.sessionsLock.Unlock() + + delete(s.sessions, id) + delete(s.sessionTimers, id) + }) + + 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) + } + + // reset session timer + s.sessionTimers[sessionId].Reset(s.config.SessionIdleTimeout) + + return session, nil +} + +func (s *SessionManager) AddTransaction(sessionId string, tx *types.Transaction) (*types.SimulateTransactionResult, error) { + builder, err := s.getSession(sessionId) + if err != nil { + return nil, err + } + return builder.AddTransaction(tx) +} diff --git a/suave/builder/session_manager_test.go b/suave/builder/session_manager_test.go new file mode 100644 index 0000000000..8d9dbaef4f --- /dev/null +++ b/suave/builder/session_manager_test.go @@ -0,0 +1,183 @@ +package builder + +import ( + "crypto/ecdsa" + "math/big" + "testing" + "time" + + "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/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/stretchr/testify/require" +) + +func TestSessionManager_SessionTimeout(t *testing.T) { + mngr, _ := newSessionManager(t, &Config{ + SessionIdleTimeout: 500 * time.Millisecond, + }) + + id, err := mngr.NewSession() + require.NoError(t, err) + + time.Sleep(1 * time.Second) + + _, err = mngr.getSession(id) + require.Error(t, err) +} + +func TestSessionManager_SessionRefresh(t *testing.T) { + mngr, _ := newSessionManager(t, &Config{ + SessionIdleTimeout: 500 * time.Millisecond, + }) + + id, err := mngr.NewSession() + require.NoError(t, err) + + // if we query the session under the idle timeout, + // we should be able to refresh it + for i := 0; i < 5; i++ { + time.Sleep(250 * time.Millisecond) + + _, err = mngr.getSession(id) + require.NoError(t, err) + } + + // if we query the session after the idle timeout, + // we should get an error + + time.Sleep(1 * time.Second) + + _, err = mngr.getSession(id) + require.Error(t, err) +} + +func TestSessionManager_StartSession(t *testing.T) { + // test that the session starts and it can simulate transactions + mngr, bMock := newSessionManager(t, &Config{}) + + id, err := mngr.NewSession() + require.NoError(t, err) + + txn := bMock.state.newTransfer(t, common.Address{}, big.NewInt(1)) + receipt, err := mngr.AddTransaction(id, txn) + require.NoError(t, err) + require.NotNil(t, receipt) +} + +func newSessionManager(t *testing.T, cfg *Config) (*SessionManager, *blockchainMock) { + if cfg == nil { + cfg = &Config{} + } + + state := newMockState(t) + + bMock := &blockchainMock{ + state: state, + } + return NewSessionManager(bMock, cfg), bMock +} + +type blockchainMock struct { + state *mockState +} + +func (b *blockchainMock) Engine() consensus.Engine { + panic("TODO") +} + +func (b *blockchainMock) GetHeader(common.Hash, uint64) *types.Header { + panic("TODO") +} + +func (b *blockchainMock) Config() *params.ChainConfig { + return b.state.chainConfig +} + +func (b *blockchainMock) CurrentHeader() *types.Header { + return &types.Header{ + Number: big.NewInt(1), + Difficulty: big.NewInt(1), + Root: b.state.stateRoot, + } +} + +func (b *blockchainMock) StateAt(root common.Hash) (*state.StateDB, error) { + return b.state.stateAt(root) +} + +type mockState struct { + stateRoot common.Hash + statedb state.Database + + premineKey *ecdsa.PrivateKey + premineKeyAdd common.Address + + nextNonce uint64 // figure out a better way + signer types.Signer + + chainConfig *params.ChainConfig +} + +func newMockState(t *testing.T) *mockState { + 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) + + // 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) + + return &mockState{ + statedb: db, + stateRoot: root, + premineKey: premineKey, + premineKeyAdd: premineKeyAddr, + signer: types.NewEIP155Signer(chainConfig.ChainID), + chainConfig: chainConfig, + } +} + +func (m *mockState) stateAt(root common.Hash) (*state.StateDB, error) { + return state.New(root, m.statedb, nil) +} + +func (m *mockState) getNonce() uint64 { + next := m.nextNonce + m.nextNonce++ + return next +} + +func (m *mockState) 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 *mockState) 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 +} diff --git a/suave/config.go b/suave/config.go new file mode 100644 index 0000000000..2490c60c61 --- /dev/null +++ b/suave/config.go @@ -0,0 +1,9 @@ +package suave + +type Config struct { + Enabled bool +} + +var DefaultConfig = Config{ + Enabled: false, +}