diff --git a/architecture/CACHING.md b/architecture/CACHING.md index f463973..0d456be 100644 --- a/architecture/CACHING.md +++ b/architecture/CACHING.md @@ -34,7 +34,7 @@ package provides two different middlewares: ## What requests are cached? -As of now we have 3 different groups of cacheable EVM methods: +As of now we have 4 different groups of cacheable EVM methods: - cacheable by block number (for ex.: `eth_getBlockByNumber`) - cacheable by block hash (for ex.: `eth_getBlockByHash`) - static methods (for ex.: `eth_chainId`, `net_version`) @@ -95,6 +95,54 @@ Cacheable by tx hash means that for specific: - tx hash (which is part of params) response won't change over time, so we can cache it indefinitely +`NOTE`: `eth_getTransactionByHash` has an unexpected behaviour, responses for `tx in mempool` and `tx in block` are different: + +`tx in mempool` example: +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": null, + "blockNumber": null, + "transactionIndex": null, + "from": "0x57852ef74abc9f0da78b49d16604bbf2d81c559e", + "gas": "0x5208", + ... + } +} +``` + +`tx in block` example +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "blockHash": "0xcc62755636e265e1f40cc0ea757477a79a233b6a417e3a8813be2ffe6859c0aa", + "blockNumber": "0x7e8e5e", + "transactionIndex": "0x0", + "from": "0x57852ef74abc9f0da78b49d16604bbf2d81c559e", + "gas": "0x5208", + ... + } +} +``` + +we can't cache `txs which is in mempool` (because response will change after `tx will be included in block`), so in source code we check if `tx is already in block`, and only if this is the case we cache the response + +example how to check if tx is in a block: +```go +func (tx *tx) IsIncludedInBlock() bool { + return tx.BlockHash != nil && + tx.BlockHash != "" && + tx.BlockNumber != nil && + tx.BlockNumber != "" && + tx.TransactionIndex != nil && + tx.TransactionIndex != "" +} +``` + ### Where to find list of methods for every group? It can be found in source code: https://github.com/Kava-Labs/kava-proxy-service/blob/main/decode/evm_rpc.go diff --git a/docker/kava-validator/kava-validator-entrypoint.sh b/docker/kava-validator/kava-validator-entrypoint.sh index df1d310..b67df2a 100755 --- a/docker/kava-validator/kava-validator-entrypoint.sh +++ b/docker/kava-validator/kava-validator-entrypoint.sh @@ -3,6 +3,10 @@ # log all commands to stdout and stop the script on the first error set -ex +evmFaucetMnemonic='canvas category slow immune screen van spirit ring blossom vanish mail pencil resource scan razor online gap void time marine topic swarm exhaust oak' +# Private Key in hex: 296da4e8defa5691077b310e10f0ed0ee4993e6418a0df86b155be5d24ae1b7c +# EVM Address in hex: 0x661C3ECC5bf3cdB64FC14c9fE9Fb64a21D24c51c + # exit early if geneis.json already exists # which will happen if the kava docker container is stopped and later restarted if test -f "/root/.kava/config/genesis.json"; then @@ -14,6 +18,12 @@ else # ensure evm api listens on all addresses sed -i 's/address = "127.0.0.1:8545"/address = "0.0.0.0:8545"/g' /root/.kava/config/app.toml + # Replace stake with ukava + sed -in-place='' 's/stake/ukava/g' /root/.kava/config/genesis.json + # Replace the default evm denom of aphoton with ukava + sed -in-place='' 's/aphoton/akava/g' /root/.kava/config/genesis.json + sed -in-place='' 's/"max_gas": "-1"/"max_gas": "20000000"/' /root/.kava/config/genesis.json + # use the test backend to avoid prompts when storing and accessing keys kava config keyring-backend test @@ -21,10 +31,16 @@ else kava keys add kava-localnet-delegator # add the delegator account to the default genesis - kava add-genesis-account $(kava keys show kava-localnet-delegator -a) 1000000000stake + kava add-genesis-account $(kava keys show kava-localnet-delegator -a) 1000000000ukava + + # create an account for the evm faucet + echo $evmFaucetMnemonic | kava keys add evm --eth --recover + + # add the evm faucet account to the default genesis + kava add-genesis-account $(kava keys show evm -a) 1000000000ukava # create genesis info for a validator staked by the delegator above - kava gentx kava-localnet-delegator 500000000stake \ + kava gentx kava-localnet-delegator 500000000ukava \ --chain-id=localnet_7777-1 \ --moniker="kava-localnet-validator" diff --git a/go.mod b/go.mod index 8bc47b3..ed731e2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( cosmossdk.io/math v1.0.0 + github.com/cenkalti/backoff v2.2.1+incompatible github.com/ethereum/go-ethereum v1.11.2 github.com/google/uuid v1.3.0 github.com/redis/go-redis/v9 v9.2.1 diff --git a/go.sum b/go.sum index 55e2f8e..2d01f39 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k= github.com/btcsuite/btcd/btcec/v2 v2.2.0/go.mod h1:U7MHm051Al6XmscBQ0BoNydpOTsFAn707034b5nY8zU= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/errors v1.9.1 h1:yFVvsI0VxmRShfawbt/laCIDy/mtTqqnvoNgiy5bEV8= diff --git a/main_test.go b/main_test.go index 670be52..a9aa635 100644 --- a/main_test.go +++ b/main_test.go @@ -3,10 +3,12 @@ package main_test import ( "bytes" "context" + "crypto/ecdsa" "encoding/json" "errors" "fmt" "io" + "log" "math/big" "net/http" "os" @@ -14,8 +16,11 @@ import ( "testing" "time" + "github.com/cenkalti/backoff" "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/redis/go-redis/v9" @@ -33,6 +38,9 @@ const ( EthClientUserAgent = "Go-http-client/1.1" accessControlAllowOriginHeaderName = "Access-Control-Allow-Origin" + + evmFaucetPrivateKeyHex = "296da4e8defa5691077b310e10f0ed0ee4993e6418a0df86b155be5d24ae1b7c" + evmFaucetAddressHex = "0x661C3ECC5bf3cdB64FC14c9fE9Fb64a21D24c51c" ) var ( @@ -545,12 +553,23 @@ func equalHeaders(t *testing.T, headersMap1, headersMap2 http.Header) { } // containsHeaders checks that headersMap1 contains all headers from headersMap2 and that values for headers are the same -// NOTE: it completely ignores presence/absence of cachemdw.CacheHeaderKey, +// it completely ignores presence/absence of cachemdw.CacheHeaderKey, // it's done in that way to allow comparison of headers for cache miss and cache hit cases -// also it ignores presence/absence of CORS headers +// it ignores presence/absence of CORS headers, +// it's because caching layer forcefully sets CORS headers in cache hit scenario, even if they didn't exist before +// we skip Date header, because time between requests can change a bit, and we don't want random test fails due to this +// we skip Server header because it's not included in our allow list for headers, consult .env.WHITELISTED_HEADERS for allow list func containsHeaders(t *testing.T, headersMap1, headersMap2 http.Header) { + headersToSkip := map[string]struct{}{ + cachemdw.CacheHeaderKey: {}, + accessControlAllowOriginHeaderName: {}, + "Date": {}, + "Server": {}, + } + for name, value := range headersMap1 { - if name == cachemdw.CacheHeaderKey || name == "Server" || name == accessControlAllowOriginHeaderName { + _, skip := headersToSkip[name] + if skip { continue } @@ -793,6 +812,186 @@ func TestE2ETestCachingMdwWithBlockNumberParam_EmptyResult(t *testing.T) { cleanUpRedis(t, redisClient) } +func TestE2ETestCachingMdwWithBlockNumberParam_ErrorResult(t *testing.T) { + testRandomAddressHex := "0x6767114FFAA17C6439D7AEA480738B982CE63A02" + testAddress := common.HexToAddress(testRandomAddressHex) + + redisClient := redis.NewClient(&redis.Options{ + Addr: redisURL, + Password: redisPassword, + DB: 0, + }) + cleanUpRedis(t, redisClient) + expectKeysNum(t, redisClient, 0) + + for _, tc := range []struct { + desc string + method string + params []interface{} + keysNum int + }{ + { + desc: "test case #1", + method: "eth_getBalance", + params: []interface{}{testAddress, "0x3B9ACA00"}, // block # 1000_000_000, which doesn't exist + keysNum: 0, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + // both calls should lead to cache MISS scenario, because error results aren't cached + // check corresponding values in cachemdw.CacheHeaderKey HTTP header + // + // cache MISS + resp1 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheMissHeaderValue, resp1.Header[cachemdw.CacheHeaderKey][0]) + body1, err := io.ReadAll(resp1.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body1) + require.Error(t, err) + expectKeysNum(t, redisClient, tc.keysNum) + + // cache MISS again (error results aren't cached) + resp2 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheMissHeaderValue, resp2.Header[cachemdw.CacheHeaderKey][0]) + body2, err := io.ReadAll(resp2.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body2) + require.Error(t, err) + expectKeysNum(t, redisClient, tc.keysNum) + }) + } + + cleanUpRedis(t, redisClient) +} + +func TestE2ETestCachingMdwWithBlockNumberParam_FutureBlocks(t *testing.T) { + futureBlockNumber := "0x3B9ACA00" // block # 1000_000_000, which doesn't exist + testRandomAddressHex := "0x6767114FFAA17C6439D7AEA480738B982CE63A02" + testAddress := common.HexToAddress(testRandomAddressHex) + + redisClient := redis.NewClient(&redis.Options{ + Addr: redisURL, + Password: redisPassword, + DB: 0, + }) + cleanUpRedis(t, redisClient) + expectKeysNum(t, redisClient, 0) + + for _, tc := range []struct { + desc string + method string + params []interface{} + keysNum int + errorMsg string + }{ + { + desc: "test case #1", + method: "eth_getBalance", + params: []interface{}{testAddress, futureBlockNumber}, + keysNum: 0, + errorMsg: "height 1000000000 must be less than or equal to the current blockchain height", + }, + { + desc: "test case #2", + method: "eth_getStorageAt", + params: []interface{}{testAddress, "0x6661e9d6d8b923d5bbaab1b96e1dd51ff6ea2a93520fdc9eb75d059238b8c5e9", futureBlockNumber}, + keysNum: 0, + errorMsg: "invalid height: cannot query with height in the future; please provide a valid height", + }, + { + desc: "test case #3", + method: "eth_getTransactionCount", + params: []interface{}{testAddress, futureBlockNumber}, + keysNum: 0, + errorMsg: "", + }, + { + desc: "test case #4", + method: "eth_getBlockTransactionCountByNumber", + params: []interface{}{futureBlockNumber}, + keysNum: 0, + errorMsg: "", + }, + { + desc: "test case #5", + method: "eth_getUncleCountByBlockNumber", + params: []interface{}{futureBlockNumber}, + keysNum: 0, + errorMsg: "", + }, + { + desc: "test case #6", + method: "eth_getCode", + params: []interface{}{testAddress, futureBlockNumber}, + keysNum: 0, + errorMsg: "invalid height: cannot query with height in the future; please provide a valid height", + }, + { + desc: "test case #7", + method: "eth_getBlockByNumber", + params: []interface{}{futureBlockNumber, false}, + keysNum: 0, + errorMsg: "", + }, + { + desc: "test case #8", + method: "eth_getTransactionByBlockNumberAndIndex", + params: []interface{}{futureBlockNumber, "0x0"}, + keysNum: 0, + errorMsg: "", + }, + { + desc: "test case #9", + method: "eth_getUncleByBlockNumberAndIndex", + params: []interface{}{futureBlockNumber, "0x0"}, + keysNum: 0, + errorMsg: "", + }, + { + desc: "test case #10", + method: "eth_call", + params: []interface{}{struct{}{}, futureBlockNumber}, + keysNum: 0, + errorMsg: "header not found", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + // both calls should lead to cache MISS scenario, because error results aren't cached + // check corresponding values in cachemdw.CacheHeaderKey HTTP header + // + // cache MISS + resp1 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheMissHeaderValue, resp1.Header[cachemdw.CacheHeaderKey][0]) + body1, err := io.ReadAll(resp1.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body1) + if tc.errorMsg == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorMsg) + } + expectKeysNum(t, redisClient, tc.keysNum) + + // cache MISS again (error results aren't cached) + resp2 := mkJsonRpcRequest(t, proxyServiceURL, 1, tc.method, tc.params) + require.Equal(t, cachemdw.CacheMissHeaderValue, resp2.Header[cachemdw.CacheHeaderKey][0]) + body2, err := io.ReadAll(resp2.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body2) + if tc.errorMsg == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorMsg) + } + expectKeysNum(t, redisClient, tc.keysNum) + }) + } + + cleanUpRedis(t, redisClient) +} + func TestE2ETestCachingMdwWithBlockNumberParam_DiffJsonRpcReqIDs(t *testing.T) { redisClient := redis.NewClient(&redis.Options{ Addr: redisURL, @@ -920,10 +1119,20 @@ func newJsonRpcRequest(id int, method string, params []interface{}) *jsonRpcRequ } type jsonRpcResponse struct { - Jsonrpc string `json:"jsonrpc"` - Id int `json:"id"` - Result interface{} `json:"result"` - Error string `json:"error"` + Jsonrpc string `json:"jsonrpc"` + Id int `json:"id"` + Result interface{} `json:"result"` + JsonRpcError *jsonRpcError `json:"error,omitempty"` +} + +type jsonRpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// String returns the string representation of the error +func (e *jsonRpcError) String() string { + return fmt.Sprintf("%s (code: %d)", e.Message, e.Code) } func checkJsonRpcErr(body []byte) error { @@ -933,8 +1142,8 @@ func checkJsonRpcErr(body []byte) error { return err } - if resp.Error != "" { - return errors.New(resp.Error) + if resp.JsonRpcError != nil { + return errors.New(resp.JsonRpcError.String()) } if resp.Result == "" { @@ -1039,3 +1248,307 @@ func TestE2ETestCachingMdwForStaticMethods(t *testing.T) { cleanUpRedis(t, redisClient) } + +func TestE2ETestCachingMdwForGetTxByHashMethod(t *testing.T) { + // create api and database clients + evmClient, err := ethclient.Dial(proxyServiceURL) + if err != nil { + t.Fatal(err) + } + + redisClient := redis.NewClient(&redis.Options{ + Addr: redisURL, + Password: redisPassword, + DB: 0, + }) + cleanUpRedis(t, redisClient) + expectKeysNum(t, redisClient, 0) + + addr := common.HexToAddress(evmFaucetAddressHex) + balance, err := evmClient.BalanceAt(testContext, addr, nil) + if err != nil { + log.Fatalf("can't get balance for evm faucet: %v\n", err) + } + require.NotEqual(t, "0", balance.String()) + + addressToFund := common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d") + // submit eth tx + tx := fundEVMAddress(t, evmClient, addressToFund) + cleanUpRedis(t, redisClient) + expectKeysNum(t, redisClient, 0) + + expectedKey := "local-chain:evm-request:eth_getTransactionByHash:sha256:*" + // getting tx by hash in the loop until JSON-RPC response result won't be null + // NOTE: it's Cache Miss scenario, because we don't cache null responses + waitUntilTxAppearsInMempool(t, tx.Hash()) + expectKeysNum(t, redisClient, 0) + // getting tx by hash in the loop until JSON-RPC response result won't indicate that tx included in block + // NOTE: it's Cache Miss scenario, because we don't cache txs which is in mempool + cacheMissBody, cacheMissHeaders := getTxByHashFromBlock(t, tx.Hash(), cachemdw.CacheMissHeaderValue) + expectKeysNum(t, redisClient, 1) + containsKey(t, redisClient, expectedKey) + // on previous step we already got tx which is included in block, so calling this again triggers Cache Hit scenario + cacheHitBody, cacheHitHeaders := getTxByHashFromBlock(t, tx.Hash(), cachemdw.CacheHitHeaderValue) + expectKeysNum(t, redisClient, 1) + containsKey(t, redisClient, expectedKey) + + // check that response bodies are the same + require.JSONEq(t, string(cacheMissBody), string(cacheHitBody), "blocks should be the same") + + // check that response headers are the same + equalHeaders(t, cacheMissHeaders, cacheHitHeaders) + + // check that CORS headers are present for cache hit scenario + require.Equal(t, cacheHitHeaders[accessControlAllowOriginHeaderName], []string{"*"}) +} + +// waitUntilTxAppearsInMempool gets tx by hash in the loop until JSON-RPC response result won't be null +// also it checks that it always cache miss scenario +func waitUntilTxAppearsInMempool(t *testing.T, hash common.Hash) { + err := backoff.Retry(func() error { + method := "eth_getTransactionByHash" + params := []interface{}{hash} + cacheMissResp := mkJsonRpcRequest(t, proxyServiceURL, 1, method, params) + require.Equal(t, cachemdw.CacheMissHeaderValue, cacheMissResp.Header[cachemdw.CacheHeaderKey][0]) + body, err := io.ReadAll(cacheMissResp.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body) + require.NoError(t, err) + + var tx getTxByHashResponse + err = json.Unmarshal(body, &tx) + require.NoError(t, err) + + if tx.Result == nil { + return errors.New("tx is not found") + } + + return nil + }, backoff.NewConstantBackOff(time.Millisecond*10)) + require.NoError(t, err) +} + +// getTxByHashFromBlock gets tx by hash in the loop until JSON-RPC response result won't indicate that tx included in block +func getTxByHashFromBlock(t *testing.T, hash common.Hash, expectedCacheHeaderValue string) ([]byte, http.Header) { + var ( + body []byte + resp *http.Response + ) + err := backoff.Retry(func() error { + method := "eth_getTransactionByHash" + params := []interface{}{hash} + resp = mkJsonRpcRequest(t, proxyServiceURL, 1, method, params) + require.Equal(t, expectedCacheHeaderValue, resp.Header[cachemdw.CacheHeaderKey][0]) + var err error + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body) + require.NoError(t, err) + + var tx getTxByHashResponse + err = json.Unmarshal(body, &tx) + require.NoError(t, err) + + if !tx.IsIncludedInBlock() { + return errors.New("tx is not included in block yet") + } + + return nil + }, backoff.NewConstantBackOff(time.Millisecond*10)) + require.NoError(t, err) + + return body, resp.Header +} + +type getTxByHashResponse struct { + Jsonrpc string `json:"jsonrpc"` + Id int `json:"id"` + Result *struct { + BlockHash interface{} `json:"blockHash"` + BlockNumber interface{} `json:"blockNumber"` + From string `json:"from"` + Gas string `json:"gas"` + GasPrice string `json:"gasPrice"` + Hash string `json:"hash"` + Input string `json:"input"` + Nonce string `json:"nonce"` + To string `json:"to"` + TransactionIndex interface{} `json:"transactionIndex"` + Value string `json:"value"` + Type string `json:"type"` + ChainId string `json:"chainId"` + V string `json:"v"` + R string `json:"r"` + S string `json:"s"` + } `json:"result"` +} + +// IsIncludedInBlock checks if transaction included in block +// transaction included in block if block hash, block number, and tx index are not null +func (tx *getTxByHashResponse) IsIncludedInBlock() bool { + if tx.Result == nil { + return false + } + + return tx.Result.BlockHash != nil && + tx.Result.BlockHash != "" && + tx.Result.BlockNumber != nil && + tx.Result.BlockNumber != "" && + tx.Result.TransactionIndex != nil && + tx.Result.TransactionIndex != "" +} + +// fundEVMAddress sends money from evm faucet to provided address +func fundEVMAddress(t *testing.T, evmClient *ethclient.Client, addressToFund common.Address) *types.Transaction { + privateKey, err := crypto.HexToECDSA(evmFaucetPrivateKeyHex) + require.NoError(t, err) + + publicKey := privateKey.Public() + publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey) + if !ok { + t.Fatal(t, "cannot assert type: publicKey is not of type *ecdsa.PublicKey") + } + + fromAddress := crypto.PubkeyToAddress(*publicKeyECDSA) + require.Equal(t, common.HexToAddress(evmFaucetAddressHex), fromAddress) + nonce, err := evmClient.PendingNonceAt(testContext, fromAddress) + require.NoError(t, err) + + value := big.NewInt(1000000000000000000) // in wei (1 eth) + gasLimit := uint64(21000) // in units + gasPrice, err := evmClient.SuggestGasPrice(testContext) + require.NoError(t, err) + + var data []byte + tx := types.NewTransaction(nonce, addressToFund, value, gasLimit, gasPrice, data) + + chainID, err := evmClient.NetworkID(testContext) + require.NoError(t, err) + + signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), privateKey) + require.NoError(t, err) + + err = evmClient.SendTransaction(testContext, signedTx) + require.NoErrorf(t, err, "can't send tx") + + return signedTx +} + +func TestE2ETestCachingMdwForGetTxReceiptByHashMethod(t *testing.T) { + // create api and database clients + evmClient, err := ethclient.Dial(proxyServiceURL) + if err != nil { + t.Fatal(err) + } + + redisClient := redis.NewClient(&redis.Options{ + Addr: redisURL, + Password: redisPassword, + DB: 0, + }) + cleanUpRedis(t, redisClient) + expectKeysNum(t, redisClient, 0) + + addr := common.HexToAddress(evmFaucetAddressHex) + balance, err := evmClient.BalanceAt(testContext, addr, nil) + if err != nil { + log.Fatalf("can't get balance for evm faucet: %v\n", err) + } + require.NotEqual(t, "0", balance.String()) + + addressToFund := common.HexToAddress("0x4592d8f8d7b001e72cb26a73e4fa1806a51ac79d") + // submit eth tx + tx := fundEVMAddress(t, evmClient, addressToFund) + cleanUpRedis(t, redisClient) + expectKeysNum(t, redisClient, 0) + + expectedKey := "local-chain:evm-request:eth_getTransactionReceipt:sha256:*" + // getting tx receipt by hash in the loop until JSON-RPC response result won't be null + // it's Cache Miss scenario, because we don't cache null responses + // NOTE: eth_getTransactionReceipt returns null JSON-RPC response result for txs in mempool, so at this point + // tx already included in block + cacheMissBody, cacheMissHeaders := getTxReceiptByHash(t, tx.Hash(), cachemdw.CacheMissHeaderValue) + expectKeysNum(t, redisClient, 1) + containsKey(t, redisClient, expectedKey) + // on previous step we already got tx which is included in block, so calling this again triggers Cache Hit scenario + cacheHitBody, cacheHitHeaders := getTxReceiptByHash(t, tx.Hash(), cachemdw.CacheHitHeaderValue) + expectKeysNum(t, redisClient, 1) + containsKey(t, redisClient, expectedKey) + + // check that response bodies are the same + require.JSONEq(t, string(cacheMissBody), string(cacheHitBody), "blocks should be the same") + + // check that response headers are the same + equalHeaders(t, cacheMissHeaders, cacheHitHeaders) + + // check that CORS headers are present for cache hit scenario + require.Equal(t, cacheHitHeaders[accessControlAllowOriginHeaderName], []string{"*"}) +} + +// getting tx receipt by hash in the loop until JSON-RPC response result won't be null +// NOTE: eth_getTransactionReceipt returns null JSON-RPC response result for txs in mempool, so returned tx will be included in block +func getTxReceiptByHash(t *testing.T, hash common.Hash, expectedCacheHeaderValue string) ([]byte, http.Header) { + var ( + body []byte + resp *http.Response + txReceipt getTxReceiptByHashResponse + ) + err := backoff.Retry(func() error { + method := "eth_getTransactionReceipt" + params := []interface{}{hash} + resp = mkJsonRpcRequest(t, proxyServiceURL, 1, method, params) + require.Equal(t, expectedCacheHeaderValue, resp.Header[cachemdw.CacheHeaderKey][0]) + var err error + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + err = checkJsonRpcErr(body) + require.NoError(t, err) + + err = json.Unmarshal(body, &txReceipt) + require.NoError(t, err) + + if txReceipt.Result == nil { + return errors.New("tx is not found") + } + + return nil + }, backoff.NewConstantBackOff(time.Millisecond*10)) + require.NoError(t, err) + + // NOTE: eth_getTransactionReceipt returns null JSON-RPC response result for txs in mempool, so returned tx must be included in block + require.True(t, txReceipt.IsIncludedInBlock()) + + return body, resp.Header +} + +type getTxReceiptByHashResponse struct { + Jsonrpc string `json:"jsonrpc"` + Id int `json:"id"` + Result *struct { + BlockHash string `json:"blockHash"` + BlockNumber string `json:"blockNumber"` + ContractAddress interface{} `json:"contractAddress"` + CumulativeGasUsed string `json:"cumulativeGasUsed"` + From string `json:"from"` + GasUsed string `json:"gasUsed"` + Logs []interface{} `json:"logs"` + LogsBloom string `json:"logsBloom"` + Status string `json:"status"` + To string `json:"to"` + TransactionHash string `json:"transactionHash"` + TransactionIndex string `json:"transactionIndex"` + Type string `json:"type"` + } `json:"result"` +} + +// IsIncludedInBlock checks if transaction included in block +// transaction included in block if block hash, block number, and tx index are not empty +func (tx *getTxReceiptByHashResponse) IsIncludedInBlock() bool { + if tx.Result == nil { + return false + } + + return tx.Result.BlockHash != "" && + tx.Result.BlockNumber != "" && + tx.Result.TransactionIndex != "" +} diff --git a/service/cachemdw/cache.go b/service/cachemdw/cache.go index 23c3e1e..274de4e 100644 --- a/service/cachemdw/cache.go +++ b/service/cachemdw/cache.go @@ -217,6 +217,9 @@ func (c *ServiceCache) CacheQueryResponse( if !response.IsCacheable() { return ErrResponseIsNotCacheable } + if !response.IsFinal(req.Method) { + return ErrResponseIsNotFinal + } key, err := GetQueryKey(c.cachePrefix, req) if err != nil { diff --git a/service/cachemdw/errors.go b/service/cachemdw/errors.go index 72158b2..6c9a32d 100644 --- a/service/cachemdw/errors.go +++ b/service/cachemdw/errors.go @@ -5,4 +5,5 @@ import "errors" var ( ErrRequestIsNotCacheable = errors.New("request is not cacheable") ErrResponseIsNotCacheable = errors.New("response is not cacheable") + ErrResponseIsNotFinal = errors.New("response is not final") ) diff --git a/service/cachemdw/response.go b/service/cachemdw/response.go index d4f9dcd..40f6017 100644 --- a/service/cachemdw/response.go +++ b/service/cachemdw/response.go @@ -97,3 +97,45 @@ func (resp *JsonRpcResponse) IsCacheable() bool { return true } + +func (resp *JsonRpcResponse) IsFinal(method string) bool { + switch method { + case "eth_getTransactionByHash": + var tx tx + if err := json.Unmarshal(resp.Result, &tx); err != nil { + return false + } + + return tx.IsIncludedInBlock() + default: + return true + } +} + +type tx struct { + BlockHash interface{} `json:"blockHash"` + BlockNumber interface{} `json:"blockNumber"` + From string `json:"from"` + Gas string `json:"gas"` + GasPrice string `json:"gasPrice"` + Hash string `json:"hash"` + Input string `json:"input"` + Nonce string `json:"nonce"` + To string `json:"to"` + TransactionIndex interface{} `json:"transactionIndex"` + Value string `json:"value"` + Type string `json:"type"` + ChainId string `json:"chainId"` + V string `json:"v"` + R string `json:"r"` + S string `json:"s"` +} + +func (tx *tx) IsIncludedInBlock() bool { + return tx.BlockHash != nil && + tx.BlockHash != "" && + tx.BlockNumber != nil && + tx.BlockNumber != "" && + tx.TransactionIndex != nil && + tx.TransactionIndex != "" +}