diff --git a/docker/localnode/scripts/step1_configure_init.sh b/docker/localnode/scripts/step1_configure_init.sh index 3141ba55d9..167ecc3cbd 100755 --- a/docker/localnode/scripts/step1_configure_init.sh +++ b/docker/localnode/scripts/step1_configure_init.sh @@ -33,12 +33,14 @@ cp docker/localnode/config/config.toml "$TENDERMINT_CONFIG_FILE" SEI_NODE_ID=$(seid tendermint show-node-id) NODE_IP=$(hostname -i | awk '{print $1}') P2P_PORT=26656 # Must match [p2p] laddr in config.toml +EVMRPC_PORT=8545 # Must match the EVM RPC HTTP port (evmrpc DefaultConfig HTTPPort). echo "$SEI_NODE_ID@$NODE_IP:$P2P_PORT" >> build/generated/persistent_peers.txt # Store autobahn-compatible pubkeys and address for config generation cp ~/.sei/config/validator_pubkey.txt "$NODE_DIR/" || { echo "ERROR: failed to copy validator_pubkey.txt"; exit 1; } cp ~/.sei/config/node_pubkey.txt "$NODE_DIR/" || { echo "ERROR: failed to copy node_pubkey.txt"; exit 1; } echo "$NODE_IP:$P2P_PORT" > "$NODE_DIR/autobahn_address.txt" +echo "http://$NODE_IP:$EVMRPC_PORT" > "$NODE_DIR/evmrpc_url.txt" # Create a new account ACCOUNT_NAME="node_admin" diff --git a/evmrpc/block_txcount_parity_test.go b/evmrpc/block_txcount_parity_test.go index cfe3ae1d9d..2b3c6b209e 100644 --- a/evmrpc/block_txcount_parity_test.go +++ b/evmrpc/block_txcount_parity_test.go @@ -3,6 +3,7 @@ package evmrpc_test import ( "context" "fmt" + "net/url" "sync" "testing" "time" @@ -37,6 +38,10 @@ func (*parityTxCountTMClient) EvmNextPendingNonce(common.Address) uint64 { return 0 } +func (*parityTxCountTMClient) EvmProxy(common.Address) (*url.URL, bool) { + return nil, false +} + func (c *parityTxCountTMClient) Block(_ context.Context, h *int64) (*coretypes.ResultBlock, error) { if h != nil && *h == parityTestHeight { return c.block, nil diff --git a/evmrpc/height_availability_test.go b/evmrpc/height_availability_test.go index 6b25334029..e82e7da9e0 100644 --- a/evmrpc/height_availability_test.go +++ b/evmrpc/height_availability_test.go @@ -3,6 +3,7 @@ package evmrpc import ( "context" "encoding/hex" + "net/url" "testing" "github.com/ethereum/go-ethereum/common" @@ -33,6 +34,10 @@ func (*heightTestClient) EvmNextPendingNonce(common.Address) uint64 { return 0 } +func (*heightTestClient) EvmProxy(common.Address) (*url.URL, bool) { + return nil, false +} + func newHeightTestClient(highHeight, earliest, latest int64) *heightTestClient { return &heightTestClient{ Client: mock.Client{}, diff --git a/evmrpc/metrics.go b/evmrpc/metrics.go index d475d8aa6d..77e989ca69 100644 --- a/evmrpc/metrics.go +++ b/evmrpc/metrics.go @@ -40,8 +40,9 @@ var ( rpcTelemetryMeter = otel.Meter("evmrpc") metrics = struct { - requestLatencySeconds metric.Float64Histogram - wsConnectionCount metric.Int64Counter + requestLatencySeconds metric.Float64Histogram + wsConnectionCount metric.Int64Counter + redirectedRequestCount metric.Int64Counter }{ requestLatencySeconds: must(rpcTelemetryMeter.Float64Histogram( "evmrpc_request_latency_seconds", @@ -57,6 +58,11 @@ var ( metric.WithDescription("Number of new websocket connections"), metric.WithUnit("{count}"), )), + redirectedRequestCount: must(rpcTelemetryMeter.Int64Counter( + "evmrpc_redirected_requests_total", + metric.WithDescription("Number of EVM RPC requests redirected to another validator"), + metric.WithUnit("{count}"), + )), } ) @@ -125,3 +131,12 @@ func recordRPCLatency(ctx context.Context, endpoint, connection string, success func recordWebsocketConnect(ctx context.Context) { metrics.wsConnectionCount.Add(ctx, 1) } + +func recordRedirectedRequest(ctx context.Context, endpoint, connection string) { + metrics.redirectedRequestCount.Add(ctx, 1, + metric.WithAttributes( + attribute.String(endpointKey, endpoint), + attribute.String(connectionKey, connection), + ), + ) +} diff --git a/evmrpc/metrics_test.go b/evmrpc/metrics_test.go index c91fcb3587..c6969bc347 100644 --- a/evmrpc/metrics_test.go +++ b/evmrpc/metrics_test.go @@ -14,6 +14,7 @@ func TestRecordRPCMetricsNoPanic(t *testing.T) { endpoint := "eth_smoke_" + t.Name() recordRPCLatency(ctx, endpoint, "http", true, nil, false, time.Now().Add(-2*time.Millisecond)) recordWebsocketConnect(ctx) + recordRedirectedRequest(ctx, endpoint, "http") } func TestClassifyRPCMetricError(t *testing.T) { diff --git a/evmrpc/send.go b/evmrpc/send.go index 9f09118f0f..e3b20c3803 100644 --- a/evmrpc/send.go +++ b/evmrpc/send.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math/big" "sync" "time" @@ -77,25 +78,46 @@ func (s *SendAPI) SendRawTransaction(ctx context.Context, input hexutil.Bytes) ( return } hash = tx.Hash() - var txData ethtx.TxData - txData, err = ethtx.NewTxDataFromTx(tx) - if err != nil { - return + // getSender fails for AccessListTx, in which case we are not able to proxy or simulate, + // but we still need to handle it. + sender, senderErr := getSender(tx, s.keeper.ChainID(s.ctxProvider(LatestCtxHeight))) + if senderErr == nil { + if url, ok := s.tmClient.EvmProxy(sender); ok { + recordRedirectedRequest(ctx, "eth_sendRawTransaction", string(s.connectionType)) + // HTTP transport pooling already happens globally underneath net/http, so + // creating a fresh RPC client per proxied request is fine here. If we + // start proxying over WebSocket, we'll need explicit custom pooling since + // the underlying TCP connection lifecycle is strictly bound to Dial -> Close calls. + client, err := rpc.DialContext(ctx, url.String()) + if err != nil { + return hash, fmt.Errorf("rpc.DialContext(%q): %w", url.String(), err) + } + defer client.Close() + + if err := client.CallContext(ctx, &hash, "eth_sendRawTransaction", input); err != nil { + return hash, fmt.Errorf("eth_sendRawTransaction(%q): %w", url.String(), err) + } + return hash, nil + } } - var msg *types.MsgEVMTransaction - msg, err = types.NewMsgEVMTransaction(txData) + + txData, err := ethtx.NewTxDataFromTx(tx) if err != nil { - return + return hash, err } - var gasUsedEstimate uint64 - gasUsedEstimate, err = s.simulateTx(ctx, tx) + msg, err := types.NewMsgEVMTransaction(txData) if err != nil { - tx, _ = msg.AsTransaction() - gasUsedEstimate = tx.Gas() // if issue simulating, fallback to gas limit + return hash, err + } + gasUsedEstimate := tx.Gas() // if issue simulating, fallback to gas limit + if senderErr == nil { // simulation requires sender. + if gas, err := s.simulateTx(ctx, sender, tx); err == nil { + gasUsedEstimate = gas + } } txBuilder := s.txConfigProvider(LatestCtxHeight).NewTxBuilder() - if err = txBuilder.SetMsgs(msg); err != nil { - return + if err := txBuilder.SetMsgs(msg); err != nil { + return hash, err } txBuilder.SetGasEstimate(gasUsedEstimate) txbz, encodeErr := s.txConfigProvider(LatestCtxHeight).TxEncoder()(txBuilder.GetTx()) @@ -125,30 +147,30 @@ func (s *SendAPI) SendRawTransaction(ctx context.Context, input hexutil.Bytes) ( return } -func (s *SendAPI) simulateTx(ctx context.Context, tx *ethtypes.Transaction) (estimate uint64, err error) { - var from common.Address - if tx.Type() == ethtypes.DynamicFeeTxType { - signer := ethtypes.NewLondonSigner(s.keeper.ChainID(s.ctxProvider(LatestCtxHeight))) - from, err = signer.Sender(tx) +func getSender(tx *ethtypes.Transaction, chainID *big.Int) (common.Address, error) { + switch { + case tx.Type() == ethtypes.DynamicFeeTxType: + from, err := ethtypes.NewLondonSigner(chainID).Sender(tx) if err != nil { - err = fmt.Errorf("failed to get sender for dynamic fee tx: %w", err) - return + return common.Address{}, fmt.Errorf("failed to get sender for dynamic fee tx: %w", err) } - } else if tx.Protected() { - signer := ethtypes.NewEIP155Signer(s.keeper.ChainID(s.ctxProvider(LatestCtxHeight))) - from, err = signer.Sender(tx) + return from, nil + case tx.Protected(): + from, err := ethtypes.NewEIP155Signer(chainID).Sender(tx) if err != nil { - err = fmt.Errorf("failed to get sender for protected tx: %w", err) - return + return common.Address{}, fmt.Errorf("failed to get sender for protected tx: %w", err) } - } else { - signer := ethtypes.HomesteadSigner{} - from, err = signer.Sender(tx) + return from, nil + default: + from, err := ethtypes.HomesteadSigner{}.Sender(tx) if err != nil { - err = fmt.Errorf("failed to get sender for homestead tx: %w", err) - return + return common.Address{}, fmt.Errorf("failed to get sender for homestead tx: %w", err) } + return from, nil } +} + +func (s *SendAPI) simulateTx(ctx context.Context, sender common.Address, tx *ethtypes.Transaction) (estimate uint64, err error) { input_ := (hexutil.Bytes)(tx.Data()) gas_ := hexutil.Uint64(tx.Gas()) nonce_ := hexutil.Uint64(tx.Nonce()) @@ -165,7 +187,7 @@ func (s *SendAPI) simulateTx(ctx context.Context, tx *ethtypes.Transaction) (est gp = nil } txArgs := export.TransactionArgs{ - From: &from, + From: &sender, To: tx.To(), Gas: &gas_, GasPrice: (*hexutil.Big)(gp), diff --git a/evmrpc/send_test.go b/evmrpc/send_test.go index da6558b845..cef0634aa1 100644 --- a/evmrpc/send_test.go +++ b/evmrpc/send_test.go @@ -1,19 +1,40 @@ package evmrpc_test import ( + "context" "encoding/hex" + "encoding/json" "math/big" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" + legacyabci "github.com/sei-protocol/sei-chain/app/legacyabci" + "github.com/sei-protocol/sei-chain/evmrpc" + "github.com/sei-protocol/sei-chain/sei-cosmos/client" "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/hd" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" "github.com/sei-protocol/sei-chain/x/evm/types" "github.com/stretchr/testify/require" ) +type sendProxyClient struct { + *MockClient + proxyURL *url.URL +} + +func (c *sendProxyClient) EvmProxy(common.Address) (*url.URL, bool) { + if c.proxyURL == nil { + return nil, false + } + return c.proxyURL, true +} + func TestMnemonicToPrivateKey(t *testing.T) { mnemonic := "mushroom lamp kingdom obscure sun advice puzzle ancient crystal service beef have zone true chimney act situate laundry guess vacuum razor virus wink enforce" hdp := hd.CreateHDPath(sdk.GetConfig().GetCoinType(), 0, 0).String() @@ -63,3 +84,67 @@ func TestSendRawTransaction(t *testing.T) { errMap = resObj["error"].(map[string]interface{}) require.Equal(t, ": invalid sequence", errMap["message"].(string)) } + +func TestSendRawTransactionUsesProxy(t *testing.T) { + to := common.HexToAddress("010203") + _, tx := buildTx(ethtypes.DynamicFeeTx{ + Nonce: 1, + GasFeeCap: big.NewInt(10), + Gas: 1000, + To: &to, + Value: big.NewInt(1000), + Data: []byte("abc"), + ChainID: EVMKeeper.ChainID(Ctx), + }) + ethTxBytes, err := tx.MarshalBinary() + require.NoError(t, err) + + var gotMethod string + var gotPayload string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + require.Len(t, req.Params, 1) + gotMethod = req.Method + require.NoError(t, json.Unmarshal(req.Params[0], &gotPayload)) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": tx.Hash().Hex(), + })) + })) + defer server.Close() + + proxyURL, err := url.Parse(server.URL) + require.NoError(t, err) + + sendAPI := evmrpc.NewSendAPI( + &sendProxyClient{MockClient: &MockClient{}, proxyURL: proxyURL}, + func(int64) client.TxConfig { return TxConfig }, + &evmrpc.SendConfig{}, + EVMKeeper, + legacyabci.BeginBlockKeepers{}, + func(int64) sdk.Context { return Ctx }, + "", + nil, + nil, + nil, + evmrpc.ConnectionTypeHTTP, + evmrpc.NewBlockCache(1), + nil, + nil, + ) + + hash, err := sendAPI.SendRawTransaction(context.Background(), hexutil.Bytes(ethTxBytes)) + require.NoError(t, err) + require.Equal(t, tx.Hash(), hash) + require.Equal(t, "eth_sendRawTransaction", gotMethod) + require.Equal(t, hexutil.Encode(ethTxBytes), gotPayload) +} diff --git a/evmrpc/setup_test.go b/evmrpc/setup_test.go index c611f8945a..a2e04bc776 100644 --- a/evmrpc/setup_test.go +++ b/evmrpc/setup_test.go @@ -12,6 +12,7 @@ import ( "math/big" "net" "net/http" + "net/url" "strconv" "strings" "testing" @@ -123,6 +124,10 @@ func (*MockClient) EvmNextPendingNonce(common.Address) uint64 { return 0 } +func (*MockClient) EvmProxy(common.Address) (*url.URL, bool) { + return nil, false +} + func NewMockClientWithLatest(latest int64) *MockClient { return &MockClient{latestOverride: latest} } diff --git a/evmrpc/simulate_test.go b/evmrpc/simulate_test.go index d7674b103e..00bb7637aa 100644 --- a/evmrpc/simulate_test.go +++ b/evmrpc/simulate_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "math/big" + "net/url" "os" "strings" "sync" @@ -871,6 +872,10 @@ func (c *fixedBlockClient) EvmNextPendingNonce(common.Address) uint64 { return 0 } +func (c *fixedBlockClient) EvmProxy(common.Address) (*url.URL, bool) { + return nil, false +} + func (c *fixedBlockClient) Block(_ context.Context, _ *int64) (*coretypes.ResultBlock, error) { return c.block, nil } diff --git a/evmrpc/tests/mock_client.go b/evmrpc/tests/mock_client.go index 75257b47d3..b232e50dc1 100644 --- a/evmrpc/tests/mock_client.go +++ b/evmrpc/tests/mock_client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "strconv" "strings" "time" @@ -40,6 +41,10 @@ func (c *MockClient) EvmNextPendingNonce(_ common.Address) uint64 { return 0 } +func (c *MockClient) EvmProxy(common.Address) (*url.URL, bool) { + return nil, false +} + func (c *MockClient) Block(_ context.Context, h *int64) (*coretypes.ResultBlock, error) { if c.mockedBlockResults != nil { blockNum := int64(-1) diff --git a/evmrpc/tx.go b/evmrpc/tx.go index b18c2f1074..44b8c91bc6 100644 --- a/evmrpc/tx.go +++ b/evmrpc/tx.go @@ -323,6 +323,26 @@ func (t *TransactionAPI) GetTransactionCount(ctx context.Context, address common }() if blockNrOrHash.BlockHash == nil && *blockNrOrHash.BlockNumber == rpc.PendingBlockNumber { + if url, ok := t.tmClient.EvmProxy(address); ok { + recordRedirectedRequest(ctx, "eth_getTransactionCount", string(t.connectionType)) + + // HTTP transport pooling already happens globally underneath net/http, so + // creating a fresh RPC client per proxied request is fine here. If we + // start proxying over WebSocket, we'll need explicit custom pooling since + // the underlying TCP connection lifecycle is strictly bound to Dial -> Close calls. + client, err := rpc.DialContext(ctx, url.String()) + if err != nil { + return nil, fmt.Errorf("rpc.DialContext(%q): %w", url.String(), err) + } + defer client.Close() + + var nonce hexutil.Uint64 + if err := client.CallContext(ctx, &nonce, "eth_getTransactionCount", address, rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber)); err != nil { + return nil, fmt.Errorf("eth_getTransactionCount(%q): %w", url.String(), err) + } + return &nonce, nil + } + nonce := t.tmClient.EvmNextPendingNonce(address) return (*hexutil.Uint64)(&nonce), nil } diff --git a/evmrpc/tx_test.go b/evmrpc/tx_test.go index 0d0367bcc5..5e0e0bab5c 100644 --- a/evmrpc/tx_test.go +++ b/evmrpc/tx_test.go @@ -1,10 +1,13 @@ package evmrpc_test import ( + "context" "encoding/json" "fmt" "io" "net/http" + "net/http/httptest" + "net/url" "strings" "sync" "testing" @@ -14,7 +17,9 @@ import ( "github.com/cosmos/go-bip39" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/rpc" "github.com/sei-protocol/sei-chain/evmrpc" "github.com/sei-protocol/sei-chain/sei-cosmos/client" "github.com/sei-protocol/sei-chain/sei-cosmos/client/config" @@ -37,6 +42,24 @@ func waitForReceipt(t *testing.T, ctx sdk.Context, txHash common.Hash) *types.Re }, 2*time.Second, 10*time.Millisecond) return receipt } + +type pendingNonceClient struct { + *MockClient + nextNonce uint64 + proxyURL *url.URL +} + +func (c *pendingNonceClient) EvmNextPendingNonce(common.Address) uint64 { + return c.nextNonce +} + +func (c *pendingNonceClient) EvmProxy(common.Address) (*url.URL, bool) { + if c.proxyURL == nil { + return nil, false + } + return c.proxyURL, true +} + func TestGetTransactionCount(t *testing.T) { originalCtx := Ctx defer func() { Ctx = originalCtx }() @@ -777,6 +800,77 @@ func TestGetTransactionCountPending(t *testing.T) { require.NotNil(t, resObj["result"]) } +func TestGetTransactionCountPendingUsesProxy(t *testing.T) { + address := common.HexToAddress("0x1234567890123456789012345678901234567890") + var gotMethod string + var gotAddress string + var gotBlockTag map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + require.Len(t, req.Params, 2) + + gotMethod = req.Method + require.NoError(t, json.Unmarshal(req.Params[0], &gotAddress)) + require.NoError(t, json.Unmarshal(req.Params[1], &gotBlockTag)) + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "result": "0x2a", + })) + })) + defer server.Close() + + proxyURL, err := url.Parse(server.URL) + require.NoError(t, err) + + api := evmrpc.NewTransactionAPI( + &pendingNonceClient{MockClient: &MockClient{}, nextNonce: 7, proxyURL: proxyURL}, + nil, + nil, + nil, + "", + evmrpc.ConnectionTypeHTTP, + &evmrpc.WatermarkManager{}, + evmrpc.NewBlockCache(1), + &sync.Mutex{}, + ) + + nonce, err := api.GetTransactionCount(context.Background(), address, rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber)) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(42), *nonce) + require.Equal(t, "eth_getTransactionCount", gotMethod) + require.Equal(t, address.Hex(), gotAddress) + require.Equal(t, map[string]string{"blockNumber": "pending"}, gotBlockTag) +} + +func TestGetTransactionCountPendingFallsBackToLocalNonce(t *testing.T) { + address := common.HexToAddress("0x1234567890123456789012345678901234567890") + api := evmrpc.NewTransactionAPI( + &pendingNonceClient{MockClient: &MockClient{}, nextNonce: 7}, + nil, + nil, + nil, + "", + evmrpc.ConnectionTypeHTTP, + &evmrpc.WatermarkManager{}, + evmrpc.NewBlockCache(1), + &sync.Mutex{}, + ) + + nonce, err := api.GetTransactionCount(context.Background(), address, rpc.BlockNumberOrHashWithNumber(rpc.PendingBlockNumber)) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(7), *nonce) +} + func TestGetTransactionCountWithBlockNumber(t *testing.T) { // Test coverage for lines 290-295: specific block number body := `{"jsonrpc": "2.0","method": "eth_getTransactionCount","params":["0x1234567890123456789012345678901234567890","0x5"],"id":"test"}` diff --git a/evmrpc/watermark_manager_test.go b/evmrpc/watermark_manager_test.go index 6a9fbec102..09dcbf57ec 100644 --- a/evmrpc/watermark_manager_test.go +++ b/evmrpc/watermark_manager_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "net/url" "testing" "github.com/ethereum/go-ethereum/common" @@ -166,6 +167,10 @@ func (*fakeTMClient) EvmNextPendingNonce(common.Address) uint64 { return 0 } +func (*fakeTMClient) EvmProxy(common.Address) (*url.URL, bool) { + return nil, false +} + func (f *fakeTMClient) Status(context.Context) (*coretypes.ResultStatus, error) { if f.statusErr != nil { return nil, f.statusErr diff --git a/sei-cosmos/client/context.go b/sei-cosmos/client/context.go index d16fecf3ba..b0e6887d14 100644 --- a/sei-cosmos/client/context.go +++ b/sei-cosmos/client/context.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "github.com/ethereum/go-ethereum/common" @@ -26,6 +27,7 @@ type Client = rpcclient.Client type LocalClient interface { Client EvmNextPendingNonce(addr common.Address) uint64 + EvmProxy(sender common.Address) (*url.URL, bool) } type Context struct { diff --git a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go index 9297fa7a19..6712c784d1 100644 --- a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go +++ b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go @@ -18,7 +18,8 @@ import ( ) // MakeGenAutobahnConfigCommand creates a cobra command that generates an autobahn JSON config file. -// Each node directory must contain validator_pubkey.txt, node_pubkey.txt, and autobahn_address.txt. +// Each node directory must contain validator_pubkey.txt, node_pubkey.txt, +// autobahn_address.txt, and evmrpc_url.txt. func MakeGenAutobahnConfigCommand() *cobra.Command { cmd := &cobra.Command{ Use: "gen-autobahn-config [node-dirs...]", @@ -62,10 +63,20 @@ Output is written to the file specified by --output.`, return fmt.Errorf("parsing address from %s: %w", dir, err) } + evmRPCRaw, err := os.ReadFile(filepath.Join(dir, "evmrpc_url.txt")) //nolint:gosec // G304: dir comes from command args; filepath.Join already calls Clean + if err != nil { + return fmt.Errorf("reading evmrpc_url.txt from %s: %w", dir, err) + } + var evmRPC config.URL + if err := evmRPC.UnmarshalText([]byte(strings.TrimSpace(string(evmRPCRaw)))); err != nil { + return fmt.Errorf("parsing evmrpc URL from %s: %w", dir, err) + } + validators = append(validators, config.AutobahnValidator{ ValidatorKey: valKey, NodeKey: nodeKey, Address: addr, + EVMRPC: utils.Some(evmRPC), }) } diff --git a/sei-tendermint/config/autobahn.go b/sei-tendermint/config/autobahn.go index d27866828b..ea92919f86 100644 --- a/sei-tendermint/config/autobahn.go +++ b/sei-tendermint/config/autobahn.go @@ -2,6 +2,7 @@ package config import ( "errors" + "net/url" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p" @@ -9,11 +10,34 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/tcp" ) +type URL struct{ *url.URL } + +func (u URL) MarshalText() ([]byte, error) { return []byte(u.String()), nil } +func (u *URL) UnmarshalText(text []byte) error { + url, err := url.Parse(string(text)) + if err != nil { + return err + } + u.URL = url + return nil +} + // AutobahnValidator represents a validator entry in the autobahn config file. type AutobahnValidator struct { ValidatorKey atypes.PublicKey `json:"validator_key"` NodeKey p2p.NodePublicKey `json:"node_key"` Address tcp.HostPort `json:"address"` + // Each validator is assigned a shard of EVM address space. + // Upon receiving an EVM transaction, a node needs to proxy it + // to validator owning the shard. + EVMRPC utils.Option[URL] `json:"evmrpc"` +} + +func (av *AutobahnValidator) GetEVMRPC() utils.Option[*url.URL] { + if u, ok := av.EVMRPC.Get(); ok { + return utils.Some(u.URL) + } + return utils.None[*url.URL]() } // AutobahnFileConfig is the JSON structure of the autobahn config file. diff --git a/sei-tendermint/config/autobahn_test.go b/sei-tendermint/config/autobahn_test.go new file mode 100644 index 0000000000..92bf883f2a --- /dev/null +++ b/sei-tendermint/config/autobahn_test.go @@ -0,0 +1,26 @@ +package config + +import ( + "encoding/json" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestURLJSONReencode(t *testing.T) { + want := URL{URL: &url.URL{ + Scheme: "https", + Host: "example.com:8545", + Path: "/rpc", + RawQuery: "foo=bar&baz=qux", + }} + + encoded, err := json.Marshal(want) + require.NoError(t, err) + + var got URL + require.NoError(t, json.Unmarshal(encoded, &got)) + require.NotNil(t, got.URL) + require.Equal(t, want.String(), got.String()) +} diff --git a/sei-tendermint/internal/autobahn/types/committee.go b/sei-tendermint/internal/autobahn/types/committee.go index 1d156253fb..68c804b07e 100644 --- a/sei-tendermint/internal/autobahn/types/committee.go +++ b/sei-tendermint/internal/autobahn/types/committee.go @@ -9,6 +9,8 @@ import ( "slices" "sort" "time" + + "github.com/ethereum/go-ethereum/common" ) // SortedSet is an immutable set of elements. @@ -87,6 +89,13 @@ func (c *Committee) FirstBlock() GlobalBlockNumber { return c.firstBlock } // GenesisTimestamp is the timestamp at genesis. func (c *Committee) GenesisTimestamp() time.Time { return c.genesisTimestamp } +func (c *Committee) EvmShard(addr common.Address) PublicKey { + h := sha256.Sum256(addr[:]) + x := new(big.Int).SetBytes(h[:]) + i := int(x.Mod(x, big.NewInt(int64(c.replicas.Len()))).Int64()) + return c.replicas.At(i) +} + // Leader for the consensus round with the given index. func (c *Committee) Leader(view View) PublicKey { d := binary.BigEndian.AppendUint64(nil, uint64(view.Index)) diff --git a/sei-tendermint/internal/p2p/giga_router.go b/sei-tendermint/internal/p2p/giga_router.go index 5434979ab0..a81fbf14eb 100644 --- a/sei-tendermint/internal/p2p/giga_router.go +++ b/sei-tendermint/internal/p2p/giga_router.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "maps" + "net/url" "slices" "time" + "github.com/ethereum/go-ethereum/common" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" @@ -29,6 +31,7 @@ import ( type GigaNodeAddr struct { Key NodePublicKey HostPort tcp.HostPort + EVMRPC utils.Option[*url.URL] } func (a GigaNodeAddr) String() string { @@ -455,3 +458,11 @@ func (r *GigaRouter) RunInboundConn(ctx context.Context, hConn *handshakedConn) }) }) } + +func (r *GigaRouter) EvmProxy(sender common.Address) (*url.URL, bool) { + shardValidator := r.data.Committee().EvmShard(sender) + if r.cfg.Consensus.Key.Public() == shardValidator { + return nil, false + } + return r.cfg.ValidatorAddrs[shardValidator].EVMRPC.Get() +} diff --git a/sei-tendermint/internal/p2p/giga_router_test.go b/sei-tendermint/internal/p2p/giga_router_test.go index 698f3e398d..1cd283ddeb 100644 --- a/sei-tendermint/internal/p2p/giga_router_test.go +++ b/sei-tendermint/internal/p2p/giga_router_test.go @@ -6,10 +6,12 @@ import ( "encoding/json" "fmt" "net/netip" + "net/url" "slices" "testing" "time" + "github.com/ethereum/go-ethereum/common" dbm "github.com/tendermint/tm-db" "golang.org/x/time/rate" @@ -415,3 +417,92 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { }) require.NoError(t, err) } + +func TestGigaRouter_EvmProxy(t *testing.T) { + rng := utils.TestRng() + _, validatorKeys := atypes.GenCommittee(rng, 10) + var nodeKeys []NodeSecretKey + addrs := map[atypes.PublicKey]GigaNodeAddr{} + urlByValidator := map[atypes.PublicKey]*url.URL{} + for i, validatorKey := range validatorKeys { + nodeKey := makeKey(rng) + nodeKeys = append(nodeKeys, nodeKey) + addr := GigaNodeAddr{ + Key: nodeKey.Public(), + HostPort: tcp.HostPort{Hostname: "127.0.0.1", Port: 26657}, + } + if i < 7 { + rpcURL, err := url.Parse(fmt.Sprintf("http://validator-%d.example.com:8545", i)) + require.NoError(t, err) + addr.EVMRPC = utils.Some(rpcURL) + urlByValidator[validatorKey.Public()] = rpcURL + } + addrs[validatorKey.Public()] = addr + } + genDoc := &types.GenesisDoc{ + ChainID: "giga-router-proxy-test", + InitialHeight: 1, + AppState: testAppStateJSON(rng), + } + require.NoError(t, genDoc.ValidateAndComplete()) + + txMempool := mempool.NewTxMempool(mempool.TestConfig(), proxy.New(newTestApp(), proxy.NopMetrics()), mempool.NopMetrics(), mempool.NopTxConstraintsFetcher) + router, err := NewGigaRouter(&GigaRouterConfig{ + DialInterval: time.Second, + ValidatorAddrs: addrs, + Consensus: &consensus.Config{ + Key: validatorKeys[0], + ViewTimeout: func(atypes.View) time.Duration { return time.Second }, + PersistentStateDir: utils.None[string](), + }, + Producer: &producer.Config{ + MaxGasPerBlock: 1, + MaxTxsPerBlock: 1, + MaxTxsPerSecond: utils.None[uint64](), + MempoolSize: 1, + BlockInterval: time.Second, + AllowEmptyBlocks: false, + }, + TxMempool: txMempool, + GenDoc: genDoc, + }, nodeKeys[0]) + require.NoError(t, err) + + localValidator := validatorKeys[0].Public() + localURL, ok := urlByValidator[localValidator] + require.True(t, ok) + + expectedRemoteURLs := map[string]struct{}{} + for validator, rpcURL := range urlByValidator { + if validator == localValidator { + continue + } + expectedRemoteURLs[rpcURL.String()] = struct{}{} + } + returnedRemoteURLs := map[string]struct{}{} + + for range 200 { + sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) + shardValidator := router.data.Committee().EvmShard(sender) + + proxyURL, ok := router.EvmProxy(sender) + expectedURL, hasURL := urlByValidator[shardValidator] + + switch { + case shardValidator == localValidator: + require.False(t, ok) + require.Nil(t, proxyURL) + case hasURL: + require.True(t, ok) + require.NotNil(t, proxyURL) + require.Equal(t, expectedURL.String(), proxyURL.String()) + require.NotEqual(t, localURL.String(), proxyURL.String()) + returnedRemoteURLs[proxyURL.String()] = struct{}{} + default: + require.False(t, ok) + require.Nil(t, proxyURL) + } + } + + require.Equal(t, expectedRemoteURLs, returnedRemoteURLs) +} diff --git a/sei-tendermint/internal/rpc/core/mempool.go b/sei-tendermint/internal/rpc/core/mempool.go index d4dac73096..bbf6be8476 100644 --- a/sei-tendermint/internal/rpc/core/mempool.go +++ b/sei-tendermint/internal/rpc/core/mempool.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "math/rand" + "net/url" "time" + "github.com/ethereum/go-ethereum/common" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/state/indexer" @@ -14,6 +16,16 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" ) +// EvmProxy returns the EVM RPC URL of the autobahn validator that owns the +// sender shard. If the sender maps to the local validator, or if no EVM RPC +// endpoint is configured for the shard owner, it returns (nil, false). +func (env *Environment) EvmProxy(sender common.Address) (*url.URL, bool) { + if r, ok := env.gigaRouter().Get(); ok { + return r.EvmProxy(sender) + } + return nil, false +} + //----------------------------------------------------------------------------- // NOTE: tx should be signed, but this is only checked at the app level (not by Tendermint!) diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 142b9d2961..cfd55f7f19 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -219,6 +219,7 @@ func buildGigaConfig( validatorAddrs[entry.ValidatorKey] = p2p.GigaNodeAddr{ Key: entry.NodeKey, HostPort: entry.Address, + EVMRPC: entry.GetEVMRPC(), } } diff --git a/sei-tendermint/rpc/client/local/local.go b/sei-tendermint/rpc/client/local/local.go index 4e66771039..2aab8bffa0 100644 --- a/sei-tendermint/rpc/client/local/local.go +++ b/sei-tendermint/rpc/client/local/local.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/url" "time" "github.com/ethereum/go-ethereum/common" @@ -109,6 +110,10 @@ func (c *Local) EvmNextPendingNonce(addr common.Address) uint64 { return c.Mempool.EvmNextPendingNonce(addr) } +func (c *Local) EvmProxy(sender common.Address) (*url.URL, bool) { + return c.Environment.EvmProxy(sender) +} + func (c *Local) ConsensusState(ctx context.Context) (*coretypes.ResultConsensusState, error) { return c.GetConsensusState(ctx) }