diff --git a/giga/evmonly/rpc/balance.go b/giga/evmonly/rpc/balance.go new file mode 100644 index 0000000000..79237d972e --- /dev/null +++ b/giga/evmonly/rpc/balance.go @@ -0,0 +1,38 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethrpc "github.com/ethereum/go-ethereum/rpc" +) + +var errHistoricalStateUnsupported = errors.New("historical state is not supported by EVM-only RPC") + +type balanceAPI struct { + backend Backend +} + +// GetBalance returns the address balance from the current committed EVM state. +func (api *balanceAPI) GetBalance(_ context.Context, address common.Address, block ethrpc.BlockNumberOrHash) (*hexutil.Big, error) { + if err := requireCurrentState(block); err != nil { + return nil, err + } + balance := api.backend.EvmBalance(address) + return (*hexutil.Big)(balance.ToBig()), nil +} + +func requireCurrentState(block ethrpc.BlockNumberOrHash) error { + number, ok := block.Number() + if !ok { + return errHistoricalStateUnsupported + } + switch number { + case ethrpc.LatestBlockNumber, ethrpc.SafeBlockNumber, ethrpc.FinalizedBlockNumber, ethrpc.PendingBlockNumber: + return nil + default: + return errHistoricalStateUnsupported + } +} diff --git a/giga/evmonly/rpc/balance_test.go b/giga/evmonly/rpc/balance_test.go new file mode 100644 index 0000000000..304ac5266e --- /dev/null +++ b/giga/evmonly/rpc/balance_test.go @@ -0,0 +1,80 @@ +package rpc + +import ( + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/giga/evmonly" +) + +func TestGetBalance(t *testing.T) { + address := common.HexToAddress("0x1000000000000000000000000000000000000001") + want := uint256.NewInt(123456789) + backend := &testBackend{ + balance: func(got common.Address) uint256.Int { + require.Equal(t, address, got) + return *want + }, + } + api := &balanceAPI{backend: backend} + + for _, tag := range []ethrpc.BlockNumber{ + ethrpc.LatestBlockNumber, + ethrpc.SafeBlockNumber, + ethrpc.FinalizedBlockNumber, + ethrpc.PendingBlockNumber, + } { + got, err := api.GetBalance(t.Context(), address, ethrpc.BlockNumberOrHashWithNumber(tag)) + require.NoError(t, err) + require.Equal(t, want.ToBig(), got.ToInt()) + } +} + +func TestGetBalanceRejectsHistoricalState(t *testing.T) { + backend := &testBackend{ + balance: func(common.Address) uint256.Int { + t.Fatal("historical request read the current balance") + return uint256.Int{} + }, + } + api := &balanceAPI{backend: backend} + address := common.Address{1} + + for _, block := range []ethrpc.BlockNumberOrHash{ + ethrpc.BlockNumberOrHashWithNumber(ethrpc.EarliestBlockNumber), + ethrpc.BlockNumberOrHashWithNumber(7), + ethrpc.BlockNumberOrHashWithHash(common.Hash{2}, true), + {}, + } { + got, err := api.GetBalance(t.Context(), address, block) + require.ErrorIs(t, err, errHistoricalStateUnsupported) + require.Nil(t, got) + } +} + +func TestHandlerServesGetBalance(t *testing.T) { + address := common.HexToAddress("0x2000000000000000000000000000000000000002") + backend := &testBackend{ + balance: func(common.Address) uint256.Int { + return *uint256.NewInt(42) + }, + } + handler, err := newHandler(backend, evmonly.NewMemoryReceiptStore()) + require.NoError(t, err) + t.Cleanup(handler.Stop) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := ethrpc.DialHTTP(server.URL) + require.NoError(t, err) + t.Cleanup(client.Close) + + var got hexutil.Big + require.NoError(t, client.CallContext(t.Context(), &got, "eth_getBalance", address, "latest")) + require.Equal(t, "0x2a", got.String()) +} diff --git a/giga/evmonly/rpc/server.go b/giga/evmonly/rpc/server.go index e19863a414..079292d872 100644 --- a/giga/evmonly/rpc/server.go +++ b/giga/evmonly/rpc/server.go @@ -13,6 +13,7 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" ethtypes "github.com/ethereum/go-ethereum/core/types" ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" @@ -29,11 +30,12 @@ const ( var logger = seilog.NewLogger("giga", "evmonly", "rpc") -// Backend submits transactions, reads finalized blocks, and returns the RPC -// client for an Autobahn shard owner. +// Backend submits transactions, reads committed EVM state and finalized +// blocks, and returns the RPC client for an Autobahn shard owner. type Backend interface { BroadcastTx(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) Block(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) + EvmBalance(common.Address) uint256.Int EvmProxy(common.Address) utils.Option[*ethrpc.Client] } @@ -117,6 +119,9 @@ func newHandler(backend Backend, receiptStore receipt.ReceiptStore) (*ethrpc.Ser if err := rpcServer.RegisterName("eth", &receiptAPI{backend: backend, store: receiptStore}); err != nil { return nil, fmt.Errorf("register EVM-only receipt RPC: %w", err) } + if err := rpcServer.RegisterName("eth", &balanceAPI{backend: backend}); err != nil { + return nil, fmt.Errorf("register EVM-only balance RPC: %w", err) + } return rpcServer, nil } diff --git a/giga/evmonly/rpc/server_test.go b/giga/evmonly/rpc/server_test.go index 192f37692d..811fb635ad 100644 --- a/giga/evmonly/rpc/server_test.go +++ b/giga/evmonly/rpc/server_test.go @@ -11,6 +11,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" "github.com/stretchr/testify/require" "github.com/sei-protocol/sei-chain/giga/evmonly" @@ -21,6 +22,7 @@ import ( type testBackend struct { broadcast func(context.Context, *coretypes.RequestBroadcastTx) (*coretypes.ResultBroadcastTx, error) block func(context.Context, *coretypes.RequestBlockInfo) (*coretypes.ResultBlock, error) + balance func(common.Address) uint256.Int proxy utils.Option[*ethrpc.Client] } @@ -32,6 +34,10 @@ func (b *testBackend) Block(ctx context.Context, req *coretypes.RequestBlockInfo return b.block(ctx, req) } +func (b *testBackend) EvmBalance(address common.Address) uint256.Int { + return b.balance(address) +} + func (b *testBackend) EvmProxy(common.Address) utils.Option[*ethrpc.Client] { return b.proxy } diff --git a/integration_test/autobahn/README.md b/integration_test/autobahn/README.md index 647890dc77..db139f77be 100644 --- a/integration_test/autobahn/README.md +++ b/integration_test/autobahn/README.md @@ -13,8 +13,8 @@ passed to `deploy`, pass the same name to `list`, `forward`, and `teardown`. Both targets require Go 1.25.6 and `make`. Local deployment also requires a running Docker engine with Docker Compose v2. AWS deployment requires the AWS CLI, `git`, and `ssh`, plus credentials allowed to manage EC2 instances, -security groups, and key pairs. The inspection and receipt examples also use -`jq` and Foundry's `cast`. +security groups, and key pairs. The inspection, balance, and receipt examples +also use `jq` and Foundry's `cast`. Build the manager once: @@ -297,11 +297,27 @@ tail -f build/generated/logs/seid-0.log The public EVM JSON-RPC surface intentionally contains only: - `eth_sendRawTransaction`, used by `sei-load` and `cast publish`; -- `eth_getTransactionReceipt`, for finalized receipts. +- `eth_getTransactionReceipt`, for finalized receipts; +- `eth_getBalance`, for the current committed EVM balance. All other `eth_*` methods currently return JSON-RPC method-not-found. A lookup for a pending or unknown hash returns `null`. +### Fetch balances with `cast` + +`eth_getBalance` accepts `latest`, `safe`, `finalized`, and `pending`; all four +read the current committed state because Sei has instant finality. Explicit +block numbers and hashes return an error because historical EVM-only state is +not wired yet. + +Every previously unseen address starts with the test-only `2^200` wei balance: + +```sh +cast balance \ + --rpc-url http://127.0.0.1:8545 \ + 0x000000000000000000000000000000000000dEaD +``` + ### Fetch receipts with `cast` `cast receipt` works for a known finalized transaction hash. Contract @@ -350,10 +366,10 @@ correct. The remaining `cast` gaps are RPC gaps, not receipt-decoding gaps. There is no `eth_getTransactionByHash` or block API to discover a `sei-load` transfer hash, and `sei-load` does not currently print every submitted hash. There are also no -chain ID, balance, nonce, fee-estimation, gas-estimation, call, log, or -WebSocket subscription methods. Commands that depend on those queries cannot -operate normally; raw transactions must provide chain ID, nonce, gas limit, -and gas price offline as in the example above. +chain ID, nonce, fee-estimation, gas-estimation, call, log, or WebSocket +subscription methods. Commands that depend on those queries cannot operate +normally; raw transactions must provide chain ID, nonce, gas limit, and gas +price offline as in the example above. ## Tear down diff --git a/integration_test/autobahn/autobahn_test.go b/integration_test/autobahn/autobahn_test.go index 2d990db080..15ab5c55b3 100644 --- a/integration_test/autobahn/autobahn_test.go +++ b/integration_test/autobahn/autobahn_test.go @@ -714,12 +714,31 @@ func testEVMOnlyLoad(t *testing.T) { lastHeight, included := waitForEVMOnlyTxs(t, ctx, listRunningNodes(t), len(block.Txs)) assertEVMOnlyReceipts(t, ctx, clients, block.Txs) + assertEVMOnlyBalances(t, ctx, clients, block.Txs) elapsed := time.Since(started) t.Logf("Autobahn finalized %d raw EVM transfers through %d validators in %s (%.0f tx/s)", included, clusterSize, elapsed.Round(time.Millisecond), float64(included)/elapsed.Seconds()) t.Logf("all validators executed through at least height %d", lastHeight) } +func assertEVMOnlyBalances(t *testing.T, ctx context.Context, clients []*ethrpc.Client, txs [][]byte) { + t.Helper() + want := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 200), big.NewInt(1)) + for nodeIndex, client := range clients { + tx := new(ethtypes.Transaction) + if err := tx.UnmarshalBinary(txs[nodeIndex]); err != nil { + t.Fatalf("decode EVM-only transaction %d: %v", nodeIndex, err) + } + var got hexutil.Big + if err := client.CallContext(ctx, &got, "eth_getBalance", tx.To(), "latest"); err != nil { + t.Fatalf("read EVM-only balance %s from node %d: %v", tx.To(), nodeIndex, err) + } + if got.ToInt().Cmp(want) != 0 { + t.Fatalf("node %d returned balance %s for %s, want %s", nodeIndex, got.ToInt(), tx.To(), want) + } + } +} + func assertEVMOnlyReceipts(t *testing.T, ctx context.Context, clients []*ethrpc.Client, txs [][]byte) { t.Helper() for nodeIndex, client := range clients { diff --git a/sei-tendermint/internal/rpc/core/mempool.go b/sei-tendermint/internal/rpc/core/mempool.go index a11a802ef1..d70aa64faf 100644 --- a/sei-tendermint/internal/rpc/core/mempool.go +++ b/sei-tendermint/internal/rpc/core/mempool.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum/go-ethereum/common" ethrpc "github.com/ethereum/go-ethereum/rpc" + "github.com/holiman/uint256" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/state/indexer" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -26,6 +27,11 @@ func (env *Environment) EvmProxy(sender common.Address) utils.Option[*ethrpc.Cli return utils.None[*ethrpc.Client]() } +// EvmBalance returns the address balance from the current committed EVM state. +func (env *Environment) EvmBalance(address common.Address) uint256.Int { + return env.App.EvmBalance(address, nil) +} + func (env *Environment) EvmTxByHash(hash common.Hash) (types.Tx, bool) { if giga, ok := env.gigaRouter().Get(); ok { if v, ok := giga.Mempool().Get(); ok {