diff --git a/evmrpc/block.go b/evmrpc/block.go index 22d31bc49b..f052542911 100644 --- a/evmrpc/block.go +++ b/evmrpc/block.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "math/big" "strings" @@ -195,10 +196,18 @@ func (a *BlockAPI) GetBlockByHash(ctx context.Context, blockHash common.Hash, fu func (a *BlockAPI) getBlockByHash(ctx context.Context, blockHash common.Hash, fullTx bool, includeSyntheticTxs bool, isPanicTx func(ctx context.Context, hash common.Hash) (bool, error)) (result map[string]interface{}, returnErr error) { startTime := time.Now() defer recordMetricsWithError(fmt.Sprintf("%s_getBlockByHash", a.namespace), a.connectionType, startTime, returnErr) + + // Ethereum spec: empty or non-existent block hash returns result=null, not error. + if blockHash == (common.Hash{}) { + return nil, nil + } if blockHash == genesisBlockHash { return encodeGenesisBlock(), nil } block, err := blockByHashRespectingWatermarks(ctx, a.tmClient, a.watermarks, blockHash[:], 1) + if errors.Is(err, ErrBlockNotFoundByHash) { + return nil, nil + } if err != nil { return nil, err } @@ -260,8 +269,15 @@ func (a *BlockAPI) getBlockByNumber( func (a *BlockAPI) GetBlockReceipts(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (result []map[string]interface{}, returnErr error) { startTime := time.Now() defer recordMetricsWithError(fmt.Sprintf("%s_getBlockReceipts", a.namespace), a.connectionType, startTime, returnErr) + // Ethereum spec: empty or non-existent block hash returns result=null, not error. + if blockNrOrHash.BlockHash != nil && *blockNrOrHash.BlockHash == (common.Hash{}) { + return nil, nil + } // Get height from params heightPtr, err := GetBlockNumberByNrOrHash(ctx, a.tmClient, a.watermarks, blockNrOrHash) + if errors.Is(err, ErrBlockNotFoundByHash) { + return nil, nil + } if err != nil { return nil, err } diff --git a/evmrpc/height_availability_test.go b/evmrpc/height_availability_test.go index f51ee8f310..d599d7bd5d 100644 --- a/evmrpc/height_availability_test.go +++ b/evmrpc/height_availability_test.go @@ -73,6 +73,19 @@ func (c *heightTestClient) Status(context.Context) (*coretypes.ResultStatus, err }, nil } +// blockNotFoundTestClient returns ResultBlock{Block: nil} for a specific hash to simulate Tendermint "block not found". +type blockNotFoundTestClient struct { + *heightTestClient + notFoundHash bytes.HexBytes +} + +func (c *blockNotFoundTestClient) BlockByHash(ctx context.Context, hash bytes.HexBytes) (*coretypes.ResultBlock, error) { + if hash.String() == c.notFoundHash.String() { + return &coretypes.ResultBlock{Block: nil}, nil + } + return c.heightTestClient.BlockByHash(ctx, hash) +} + func mustDecodeHex(h string) []byte { bz, err := hex.DecodeString(h) if err != nil { @@ -100,6 +113,62 @@ func TestBlockAPIEnsureHeightUnavailable(t *testing.T) { require.Contains(t, err.Error(), "requested height") } +// TestGetBlockByHashNotFoundReturnsNull verifies Ethereum-compatible behavior: empty or non-existent block hash +// returns (nil, nil) so RPC responds with result: null, not an error (see get-block-by-empty-hash.iox, get-block-by-notfound-hash.iox). +func TestGetBlockByHashNotFoundReturnsNull(t *testing.T) { + t.Parallel() + + earliest := int64(1) + latest := int64(100) + base := newHeightTestClient(latest+5, earliest, latest) + notFoundHashHex := "0x00000000000000000000000000000000000000000000000000000000deadbeef" + client := &blockNotFoundTestClient{ + heightTestClient: base, + notFoundHash: bytes.HexBytes(mustDecodeHex(notFoundHashHex[2:])), + } + watermarks := NewWatermarkManager(client, testCtxProvider, nil, nil) + api := NewBlockAPI(client, nil, testCtxProvider, testTxConfigProvider, ConnectionTypeHTTP, watermarks, nil, nil) + ctx := context.Background() + + // Empty hash: short-circuit, result null + result, err := api.GetBlockByHash(ctx, common.Hash{}, false) + require.NoError(t, err) + require.Nil(t, result) + + // Non-existent hash (client returns Block: nil): result null + result, err = api.GetBlockByHash(ctx, common.HexToHash(notFoundHashHex), false) + require.NoError(t, err) + require.Nil(t, result) +} + +// TestGetBlockReceiptsNotFoundReturnsNull verifies Ethereum-compatible behavior: empty or non-existent block hash +// returns (nil, nil) so RPC responds with result: null (see get-block-receipts-empty.iox, get-block-receipts-not-found.iox). +func TestGetBlockReceiptsNotFoundReturnsNull(t *testing.T) { + t.Parallel() + + earliest := int64(1) + latest := int64(100) + base := newHeightTestClient(latest+5, earliest, latest) + notFoundHashHex := "0x00000000000000000000000000000000000000000000000000000000deadbeef" + client := &blockNotFoundTestClient{ + heightTestClient: base, + notFoundHash: bytes.HexBytes(mustDecodeHex(notFoundHashHex[2:])), + } + watermarks := NewWatermarkManager(client, testCtxProvider, nil, nil) + api := NewBlockAPI(client, nil, testCtxProvider, testTxConfigProvider, ConnectionTypeHTTP, watermarks, nil, nil) + ctx := context.Background() + + // Empty hash: short-circuit, result null + receipts, err := api.GetBlockReceipts(ctx, rpc.BlockNumberOrHashWithHash(common.Hash{}, true)) + require.NoError(t, err) + require.Nil(t, receipts) + + // Non-existent hash (client returns Block: nil): result null + receipts, err = api.GetBlockReceipts(ctx, rpc.BlockNumberOrHashWithHash(common.HexToHash(notFoundHashHex), true)) + require.NoError(t, err) + require.Nil(t, receipts) +} + // TestGetBlockTransactionCountByHashGenesis verifies that the genesis block hash returned by // eth_getBlockByNumber("0x0") is accepted by eth_getBlockTransactionCountByHash (consistency). func TestGetBlockTransactionCountByHashGenesis(t *testing.T) { diff --git a/evmrpc/utils.go b/evmrpc/utils.go index 25042e763a..a6bc0dd8a4 100644 --- a/evmrpc/utils.go +++ b/evmrpc/utils.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "crypto/sha256" "encoding/hex" + "errors" "fmt" "math/big" "runtime/debug" @@ -41,6 +42,10 @@ const LatestCtxHeight int64 = -1 // EVM launch block heights for different chains const Pacific1EVMLaunchHeight int64 = 79123881 +// ErrBlockNotFoundByHash is returned when no block exists for the given hash (e.g. empty or unknown hash). +// Ethereum-compatible RPCs should return result: null for this case instead of an error. +var ErrBlockNotFoundByHash = errors.New("block not found by hash") + // GetBlockNumberByNrOrHash returns the height of the block with the given number or hash. func GetBlockNumberByNrOrHash(ctx context.Context, tmClient rpcclient.Client, wm *WatermarkManager, blockNrOrHash rpc.BlockNumberOrHash) (*int64, error) { if blockNrOrHash.BlockHash != nil { @@ -186,7 +191,7 @@ func blockByHashWithRetry(ctx context.Context, client rpcclient.Client, hash byt return nil, err } if blockRes.Block == nil { - return nil, fmt.Errorf("could not find block for hash %s", hash.String()) + return nil, ErrBlockNotFoundByHash } TraceTendermintIfApplicable(ctx, "BlockByHash", []string{hash.String()}, blockRes) return blockRes, err