diff --git a/cmd/rpc/README.md b/cmd/rpc/README.md index bed4bd52c7..958c5b176a 100644 --- a/cmd/rpc/README.md +++ b/cmd/rpc/README.md @@ -3442,6 +3442,29 @@ $ curl -X POST http://localhost:50002/v1/eth \ } ``` +**Custom Methods**: + +- `canopy_getStake(address, blockTag?)` - returns the validator's `stakedAmount` as an Ethereum-style hex balance with 18 decimals. Missing validators return `0x0`. +- `canopy_getPool(id, blockTag?)` - returns the pool `amount` as an Ethereum-style hex balance with 18 decimals. +- `eth_getBalance("0x000000000000000000000000000000000001ffff", blockTag?)` - returns the DAO pool balance using a reserved read-only pseudo-address for centralized-exchange compatibility. + +``` +$ curl -X POST http://localhost:50002/v1/eth \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "method":"canopy_getPool", + "params":["0x1ffff","latest"], + "id":1 + }' + +> { + "id": 1, + "jsonrpc": "2.0", + "result": "0x3bdca9b8cea006ef98000" +} +``` + # Admin 🚨**Important: All admin commands assume secure https connection** diff --git a/cmd/rpc/eth.go b/cmd/rpc/eth.go index 0e1b5da9f4..b7c6b2b314 100644 --- a/cmd/rpc/eth.go +++ b/cmd/rpc/eth.go @@ -30,6 +30,8 @@ import ( /* This file wraps Canopy with the Ethereum JSON-RPC interface as specified here: https://ethereum.org/en/developers/docs/apis/json-rpc */ +const daoPoolPseudoAddress = "0x000000000000000000000000000000000001ffff" + // EthereumHandler is a helper function that abstracts common workflows of ethereum calls using the JSON rpc 2.0 specification func (s *Server) EthereumHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { var raw json.RawMessage @@ -95,6 +97,10 @@ func (s *Server) handleEthereumRPCRequest(ptr *ethRPCRequest) ethRPCResponse { ethResponse, err = s.EthBlockNumber(args) case `eth_getBalance`: ethResponse, err = s.EthGetBalance(args) + case `canopy_getStake`: + ethResponse, err = s.CanopyGetStake(args) + case `canopy_getPool`: + ethResponse, err = s.CanopyGetPool(args) case `eth_getTransactionCount`: ethResponse, err = s.EthGetTransactionCount(args) case `eth_getBlockTransactionCountByHash`: @@ -273,10 +279,21 @@ func (s *Server) EthGetBalance(args []any) (result any, err error) { } // create a read-only state for the block tag err = s.readOnlyState(height, func(state *fsm.StateMachine) (e lib.ErrorI) { - // get the balance for the address - balance, e := state.GetAccountSpendableBalance(address) - if e != nil { - return + addressString := "0x" + strings.ToLower(address.String()) + var balance uint64 + switch addressString { + case daoPoolPseudoAddress: + pool, poolErr := state.GetPool(lib.DAOPoolID) + if poolErr != nil { + return poolErr + } + balance = pool.Amount + default: + // get the balance for the address + balance, e = state.GetAccountSpendableBalance(address) + if e != nil { + return + } } // upscale to 18 dec in hex string format result = hexutil.Big(*fsm.UpscaleTo18Decimals(balance)) @@ -286,7 +303,59 @@ func (s *Server) EthGetBalance(args []any) (result any, err error) { return } -// EthGetTransactionCount() returns the next nonce for Ethereum accounts and a pseudo-nonce fallback for read-only addresses. +// CanopyGetStake returns the validator stake record for an address. +func (s *Server) CanopyGetStake(args []any) (result any, err error) { + address, err := addressFromArgs(args) + if err != nil { + return nil, err + } + height, err := blockTagFromArgs(args) + if err != nil { + return nil, err + } + err = s.readOnlyState(height, func(state *fsm.StateMachine) (e lib.ErrorI) { + balance := uint64(0) + validator, e := state.GetValidator(address) + if e != nil { + if e.Code() == lib.CodeValidatorNotExists { + result = hexutil.Big(*fsm.UpscaleTo18Decimals(balance)) + return nil + } + return e + } + balance = validator.StakedAmount + result = hexutil.Big(*fsm.UpscaleTo18Decimals(balance)) + return + }) + return +} + +// CanopyGetPool returns the pool balance for a pool id. +func (s *Server) CanopyGetPool(args []any) (result any, err error) { + idStr, err := strFromArgs(args, 0) + if err != nil { + return nil, err + } + id, err := strconv.ParseUint(idStr, 0, 64) + if err != nil { + return nil, ethInvalidParams(err.Error()) + } + height, err := blockTagFromArgs(args) + if err != nil { + return nil, err + } + err = s.readOnlyState(height, func(state *fsm.StateMachine) (e lib.ErrorI) { + pool, e := state.GetPool(id) + if e != nil { + return e + } + result = hexutil.Big(*fsm.UpscaleTo18Decimals(pool.Amount)) + return + }) + return +} + +// EthGetTransactionCount() returns the next replay-safe nonce using current height plus local pending txs. func (s *Server) EthGetTransactionCount(args []any) (any, error) { address, err := addressFromArgs(args) if err != nil { @@ -300,47 +369,45 @@ func (s *Server) EthGetTransactionCount(args []any) (any, error) { } } return s.withStore(func(st *store.Store) (any, error) { - base := s.currentEthBlockNumber() - hasMinedHistory := false - if minedNonce, ok, nonceErr := s.latestMinedNonceForAddress(st, address); nonceErr != nil { - return nil, nonceErr - } else if ok { - base = minedNonce - hasMinedHistory = true - } switch blockTag { - case pendingBlockTag: - pending := base - maxNonce := s.maximumAcceptedEthereumNonce() - if highestPending, ok, pendingErr := s.highestPendingNonceForAddress(st, address.String()); pendingErr != nil { - return nil, pendingErr - } else if ok && highestPending >= pending { - if highestPending >= maxNonce { - return nil, ethInvalidParams("no acceptable pending nonce available") - } - if highestPending == math.MaxUint64 { - pending = math.MaxUint64 - } else { - pending = highestPending + 1 - } - } - if pending > maxNonce { - pending = maxNonce - } - return hexutil.Uint64(pending), nil - case latestBlockTag, safeBlockTag, finalizedBlockTag: - return hexutil.Uint64(base), nil case earliestBlockTag: return hexutil.Uint64(0), nil + case latestBlockTag: + nonce, nonceErr := s.nextLatestReplayProtectedNonce(st, address, s.currentEthBlockNumber()) + if nonceErr != nil { + return nil, nonceErr + } + return hexutil.Uint64(nonce), nil + case safeBlockTag, finalizedBlockTag: + nonce, nonceErr := s.nextConfirmedReplayProtectedNonce(st, address, s.currentEthBlockNumber()) + if nonceErr != nil { + return nil, nonceErr + } + return hexutil.Uint64(nonce), nil + case pendingBlockTag: + nonce, nonceErr := s.nextPendingReplayProtectedNonce(st, address) + if nonceErr != nil { + return nil, nonceErr + } + return hexutil.Uint64(nonce), nil default: - // Explicit historical block-number queries are compatibility-oriented: validate the tag but - // return the latest confirmed nonce for Ethereum-derived accounts and the height fallback otherwise. height, parseErr := parseBlockTag(blockTag) if parseErr != nil { return nil, parseErr } - if hasMinedHistory { - return hexutil.Uint64(base), nil + // Wallets may reconcile a just-submitted tx against an explicit current-head block number + // before its receipt becomes visible. For current/future numeric tags, keep that confirmed + // view from jumping ahead of the prior head unless a confirmed sender floor requires it. + if height >= s.currentEthBlockNumber() { + base := uint64(0) + if height != 0 { + base = height - 1 + } + nonce, nonceErr := s.nextLatestReplayProtectedNonce(st, address, base) + if nonceErr != nil { + return nil, nonceErr + } + return hexutil.Uint64(nonce), nil } return hexutil.Uint64(height), nil } @@ -684,8 +751,10 @@ func (s *Server) EthGetTransactionByHash(args []any) (any, error) { return nil, err } if tx != nil { - clearPendingEthTx(hashString) - return s.txToEthTransaction(block, tx, false) + if block != nil { + clearPendingEthTx(hashString) + } + return s.txToEthTransaction(block, tx, block == nil) } if pending := s.findPendingEthTx(hashString); pending != nil { return s.pendingTxToEthTransaction(pending), nil @@ -751,7 +820,7 @@ func (s *Server) EthGetTransactionReceipt(args []any) (any, error) { if err != nil { return nil, err } - if tx == nil { + if tx == nil || block == nil { return ethNullResult(), nil } clearPendingEthTx(hashString) @@ -1555,8 +1624,10 @@ func clearPendingEthTx(hash string) { pseudoPendingTxsMap.Delete(hash) } -// highestPendingNonceForAddress() returns the highest still-live pending nonce for the sender. -func (s *Server) highestPendingNonceForAddress(st *store.Store, address string) (highest uint64, ok bool, err error) { +// pendingNonceRangeForAddress() returns the lowest and highest still-live pending nonces for the sender. +// Entries below minNonce are ignored unless they are the immediate predecessor of minNonce and are +// still in the partial-index state (tx row visible, block row not yet visible). +func (s *Server) pendingNonceRangeForAddress(st *store.Store, address string, minNonce uint64) (lowest, highest uint64, ok bool, err error) { address = strings.ToLower(address) pseudoPendingTxsMap.Range(func(key, value any) bool { hash := strings.ToLower(key.(string)) @@ -1565,10 +1636,12 @@ func (s *Server) highestPendingNonceForAddress(st *store.Store, address string) clearPendingEthTx(hash) return true } - if tx, _, lookupErr := s.findIndexedTxByHash(st, hash); lookupErr != nil { + tx, block, lookupErr := s.findIndexedTxByHash(st, hash) + if lookupErr != nil { err = lookupErr return false - } else if tx != nil { + } + if tx != nil && block != nil { clearPendingEthTx(hash) return true } @@ -1576,13 +1649,23 @@ func (s *Server) highestPendingNonceForAddress(st *store.Store, address string) return true } nonce := pending.Tx.CreatedHeight - if !ok || nonce > highest { - highest, ok = nonce, true + if minNonce != 0 && nonce < minNonce && !(tx != nil && block == nil && nonce+1 == minNonce) { + return true + } + if !ok { + lowest, highest, ok = nonce, nonce, true + return true + } + if nonce < lowest { + lowest = nonce + } + if nonce > highest { + highest = nonce } return true }) if err != nil { - return 0, false, err + return 0, 0, false, err } s.controller.Mempool.L.Lock() defer s.controller.Mempool.L.Unlock() @@ -1595,8 +1678,18 @@ func (s *Server) highestPendingNonceForAddress(st *store.Store, address string) continue } nonce := tx.CreatedHeight - if !ok || nonce > highest { - highest, ok = nonce, true + if nonce < minNonce { + continue + } + if !ok { + lowest, highest, ok = nonce, nonce, true + continue + } + if nonce < lowest { + lowest = nonce + } + if nonce > highest { + highest = nonce } } return @@ -1664,17 +1757,70 @@ func (s *Server) maximumAcceptedEthereumNonce() uint64 { return height + fsm.BlockAcceptanceRange } -// latestMinedNonceForAddress() returns the next confirmed nonce for an address when it has mined Ethereum-backed tx history. -func (s *Server) latestMinedNonceForAddress(st *store.Store, address crypto.AddressI) (uint64, bool, error) { - nonce, ok, err := st.GetLatestMinedEthereumNonce(address) - if err != nil || !ok { - return 0, ok, err +// nextConfirmedReplayProtectedNonce() returns the next confirmed replay-safe nonce for an address from the supplied base view. +func (s *Server) nextConfirmedReplayProtectedNonce(st *store.Store, address crypto.AddressI, nonce uint64) (uint64, error) { + maxNonce := s.maximumAcceptedEthereumNonce() + if confirmedNonce, ok, err := st.GetHighestConfirmedEthereumReplayNonce(address); err != nil { + return 0, err + } else if ok { + if confirmedNonce >= maxNonce { + return 0, fmt.Errorf("no replay-safe nonce available within accepted window") + } + if confirmedNonce == math.MaxUint64 { + return math.MaxUint64, nil + } + confirmedFloor := confirmedNonce + 1 + if confirmedFloor > nonce { + nonce = confirmedFloor + } + } + if nonce > maxNonce { + nonce = maxNonce + } + return nonce, nil +} + +// nextLatestReplayProtectedNonce returns the confirmed replay-safe nonce, clamped by the earliest unresolved local pending nonce. +func (s *Server) nextLatestReplayProtectedNonce(st *store.Store, address crypto.AddressI, base uint64) (uint64, error) { + nonce, err := s.nextConfirmedReplayProtectedNonce(st, address, base) + if err != nil { + return 0, err + } + confirmedFloor := uint64(0) + if confirmedNonce, ok, err := st.GetHighestConfirmedEthereumReplayNonce(address); err != nil { + return 0, err + } else if ok { + confirmedFloor = confirmedNonce + 1 + } + lowestPending, _, ok, err := s.pendingNonceRangeForAddress(st, address.String(), confirmedFloor) + if err != nil { + return 0, err + } + if ok && lowestPending < nonce { + return lowestPending, nil + } + return nonce, nil +} + +// nextPendingReplayProtectedNonce() returns the next replay-safe nonce including local pending state. +func (s *Server) nextPendingReplayProtectedNonce(st *store.Store, address crypto.AddressI) (uint64, error) { + nonce, err := s.nextConfirmedReplayProtectedNonce(st, address, s.currentEthBlockNumber()) + if err != nil { + return 0, err } - nextNonce := nonce + 1 - if currentNonce := s.currentEthBlockNumber(); nextNonce < currentNonce { - nextNonce = currentNonce + maxNonce := s.maximumAcceptedEthereumNonce() + if _, highestPending, ok, err := s.pendingNonceRangeForAddress(st, address.String(), 0); err != nil { + return 0, err + } else if ok && highestPending >= nonce { + if highestPending >= maxNonce { + return 0, fmt.Errorf("no replay-safe nonce available within accepted window") + } + nonce = highestPending + 1 } - return nextNonce, true, nil + if nonce > maxNonce { + nonce = maxNonce + } + return nonce, nil } // blockHeightFromNumberArg() resolves block-number method arguments to indexed Canopy block heights. @@ -1693,19 +1839,25 @@ func (s *Server) blockHeightFromNumberArg(args []any, position int) (uint64, err } // findIndexedTxByHash() resolves a mined tx by stored Canopy hash or persisted Ethereum-hash alias. +// Cached blocks are only trusted to complete an already-indexed tx while block rows are catching up. func (s *Server) findIndexedTxByHash(st *store.Store, hash string) (*lib.TxResult, *lib.BlockResult, error) { txHash, err := lib.StringToBytes(cleanHex(hash)) if err == nil { tx, txErr := st.GetTxByHash(txHash) if txErr == nil && tx.TxHash != "" { block, blockErr := st.GetBlockByHeight(tx.Height) - if blockErr == nil && !isNilBlock(block) { + if blockErr == nil && !isNilBlock(block) && blockContainsTxHash(block, hash) { return tx, block, nil } - // Treat a partially indexed mined tx as unresolved so callers can fall back to pending/null - // instead of surfacing a transient backend error during MetaMask polling. + if cachedTx, cachedBlock, cacheErr := st.FindCachedTxByHash(txHash); cacheErr != nil { + return nil, nil, cacheErr + } else if cachedTx != nil && cachedBlock != nil { + return cachedTx, cachedBlock, nil + } + // Surface a partially indexed tx as pending-shaped instead of null so wallet pollers + // don't misclassify a mined tx as dropped while block rows are catching up. if blockErr == nil || isNotFoundErr(blockErr) { - return nil, nil, nil + return tx, nil, nil } return nil, nil, blockErr } @@ -1713,6 +1865,25 @@ func (s *Server) findIndexedTxByHash(st *store.Store, hash string) (*lib.TxResul return nil, nil, nil } +func blockContainsTxHash(block *lib.BlockResult, hash string) bool { + if isNilBlock(block) { + return false + } + normalizedHash := strings.ToLower(hash) + for _, tx := range block.Transactions { + if tx == nil { + continue + } + if ethHash := ethHashStringFromTxResult(tx); ethHash != "" && ethHash == normalizedHash { + return true + } + if canopyHash := cleanHex(tx.TxHash); canopyHash != "" && "0x"+strings.ToLower(canopyHash) == normalizedHash { + return true + } + } + return false +} + // txToEthReceipt() converts a Canopy tx result into an Ethereum receipt response object. func (s *Server) txToEthReceipt(block *lib.BlockResult, tx *lib.TxResult) (ethRPCReceipt, error) { logs, err := s.txToGetLogsResp(block.BlockHeader.Hash, tx) diff --git a/cmd/rpc/eth_test.go b/cmd/rpc/eth_test.go new file mode 100644 index 0000000000..338a3d87eb --- /dev/null +++ b/cmd/rpc/eth_test.go @@ -0,0 +1,498 @@ +package rpc + +import ( + "crypto/ecdsa" + "encoding/json" + "math/big" + "strings" + "sync" + "testing" + "time" + + "github.com/canopy-network/canopy/controller" + "github.com/canopy-network/canopy/fsm" + "github.com/canopy-network/canopy/lib" + "github.com/canopy-network/canopy/store" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + ethCrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" +) + +func TestEthGetTransactionByHashReturnsPendingShapeWhenBlockMissing(t *testing.T) { + server, txResult, ethHash := newTestEthServerWithPartiallyIndexedTx(t) + + got, err := server.EthGetTransactionByHash([]any{ethHash.Hex()}) + require.NoError(t, err) + + txMap, ok := got.(map[string]any) + require.True(t, ok) + require.Equal(t, ethHash.Hex(), txMap["hash"]) + require.Equal(t, common.BytesToAddress(txResult.Sender).Hex(), txMap["from"]) + require.Nil(t, txMap["blockHash"]) + require.Nil(t, txMap["blockNumber"]) + require.Nil(t, txMap["transactionIndex"]) +} + +func TestEthGetTransactionReceiptReturnsNullWhenBlockMissing(t *testing.T) { + server, _, ethHash := newTestEthServerWithPartiallyIndexedTx(t) + + got, err := server.EthGetTransactionReceipt([]any{ethHash.Hex()}) + require.NoError(t, err) + require.Equal(t, "null", string(got.(json.RawMessage))) +} + +func TestEthGetTransactionByHashReturnsNullWhenHashIndexMissingDespiteWarmBlockCache(t *testing.T) { + server, _, ethHash := newTestEthServerWithDeletedButCachedTx(t) + + got, err := server.EthGetTransactionByHash([]any{ethHash.Hex()}) + require.NoError(t, err) + require.Equal(t, "null", string(got.(json.RawMessage))) +} + +func TestEthGetTransactionReceiptReturnsNullWhenHashIndexMissingDespiteWarmBlockCache(t *testing.T) { + server, _, ethHash := newTestEthServerWithDeletedButCachedTx(t) + + got, err := server.EthGetTransactionReceipt([]any{ethHash.Hex()}) + require.NoError(t, err) + require.Equal(t, "null", string(got.(json.RawMessage))) +} + +func TestEthGetTransactionCountUsesReplayHeight(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_000) + address := "0xCb8EC4ee2540ecD077Ce57e4b151CD7848dF9beF" + + gotLatest, err := server.EthGetTransactionCount([]any{address, latestBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotLatest) + + gotSafe, err := server.EthGetTransactionCount([]any{address, safeBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotSafe) + + gotFinalized, err := server.EthGetTransactionCount([]any{address, finalizedBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotFinalized) + + gotPending, err := server.EthGetTransactionCount([]any{address, pendingBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotPending) +} + +func TestEthGetTransactionCountUsesPreviousReplayHeightForExplicitCurrentBlock(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_001) + address := "0xCb8EC4ee2540ecD077Ce57e4b151CD7848dF9beF" + + got, err := server.EthGetTransactionCount([]any{address, hexutil.EncodeUint64(5_000)}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), got) +} + +func TestEthGetTransactionCountAdvancesPastPendingNonceForPendingOnly(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_000) + tx := newTestPendingRLPTransaction(t, 4_999) + address := "0x" + senderFromTransaction(tx) + hash := ethHashStringFromTransaction(tx) + registerPendingEthTx(hash, tx) + t.Cleanup(func() { clearPendingEthTx(hash) }) + + gotLatest, err := server.EthGetTransactionCount([]any{address, latestBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotLatest) + + gotSafe, err := server.EthGetTransactionCount([]any{address, safeBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotSafe) + + gotFinalized, err := server.EthGetTransactionCount([]any{address, finalizedBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotFinalized) + + gotPending, err := server.EthGetTransactionCount([]any{address, pendingBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_000), gotPending) +} + +func TestEthGetTransactionCountLatestDoesNotAdvancePastLocalPendingNonce(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_002) + tx := newTestPendingRLPTransaction(t, 5_000) + address := "0x" + senderFromTransaction(tx) + hash := ethHashStringFromTransaction(tx) + registerPendingEthTx(hash, tx) + t.Cleanup(func() { clearPendingEthTx(hash) }) + + gotLatest, err := server.EthGetTransactionCount([]any{address, latestBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_000), gotLatest) + + gotPending, err := server.EthGetTransactionCount([]any{address, pendingBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_001), gotPending) +} + +func TestEthGetTransactionCountUsesConfirmedReplayFloorForMinedTxs(t *testing.T) { + server, db := newTestEthServerAndStoreAtHeight(t, 5_000) + tx := newTestPendingRLPTransaction(t, 5_005) + address := "0x" + senderFromTransaction(tx) + + require.NoError(t, db.IndexTx(newTestIndexedTxResultFromPendingTx(t, tx, 1))) + _, commitErr := db.Commit() + require.NoError(t, commitErr) + + gotLatest, err := server.EthGetTransactionCount([]any{address, latestBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_006), gotLatest) + + gotSafe, err := server.EthGetTransactionCount([]any{address, safeBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_006), gotSafe) + + gotFinalized, err := server.EthGetTransactionCount([]any{address, finalizedBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_006), gotFinalized) + + gotPending, err := server.EthGetTransactionCount([]any{address, pendingBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_006), gotPending) +} + +func TestEthGetTransactionCountExplicitCurrentBlockRespectsConfirmedReplayFloor(t *testing.T) { + server, db := newTestEthServerAndStoreAtHeight(t, 5_008) + tx := newTestPendingRLPTransaction(t, 5_006) + address := "0x" + senderFromTransaction(tx) + + require.NoError(t, db.IndexTx(newTestIndexedTxResultFromPendingTx(t, tx, 1))) + _, commitErr := db.Commit() + require.NoError(t, commitErr) + + got, err := server.EthGetTransactionCount([]any{address, hexutil.EncodeUint64(5_007)}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_007), got) +} + +func TestEthGetTransactionCountDoesNotClampBelowConfirmedReplayFloor(t *testing.T) { + server, address := newTestEthServerWithConfirmedFloorAndStalePending(t) + for _, blockTag := range []string{latestBlockTag, hexutil.EncodeUint64(5_007)} { + got, err := server.EthGetTransactionCount([]any{address, blockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_007), got) + } +} + +func TestEthGetTransactionCountExplicitCurrentBlockDoesNotAdvancePastLocalPendingNonce(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_003) + tx := newTestPendingRLPTransaction(t, 5_001) + address := "0x" + senderFromTransaction(tx) + hash := ethHashStringFromTransaction(tx) + registerPendingEthTx(hash, tx) + t.Cleanup(func() { clearPendingEthTx(hash) }) + + got, err := server.EthGetTransactionCount([]any{address, hexutil.EncodeUint64(5_002)}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_001), got) +} + +func TestEthGetTransactionCountLatestClampsToLowestLocalPendingNonce(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_004) + txA := newTestPendingRLPTransaction(t, 5_001) + txB := newTestPendingRLPTransaction(t, 5_002) + address := "0x" + senderFromTransaction(txA) + hashA := ethHashStringFromTransaction(txA) + hashB := ethHashStringFromTransaction(txB) + registerPendingEthTx(hashA, txA) + registerPendingEthTx(hashB, txB) + t.Cleanup(func() { + clearPendingEthTx(hashA) + clearPendingEthTx(hashB) + }) + + gotLatest, err := server.EthGetTransactionCount([]any{address, latestBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_001), gotLatest) + + gotPending, err := server.EthGetTransactionCount([]any{address, pendingBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_003), gotPending) +} + +func TestEthGetTransactionCountExplicitCurrentBlockClampsToLowestLocalPendingNonce(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_004) + txA := newTestPendingRLPTransaction(t, 5_001) + txB := newTestPendingRLPTransaction(t, 5_002) + address := "0x" + senderFromTransaction(txA) + hashA := ethHashStringFromTransaction(txA) + hashB := ethHashStringFromTransaction(txB) + registerPendingEthTx(hashA, txA) + registerPendingEthTx(hashB, txB) + t.Cleanup(func() { + clearPendingEthTx(hashA) + clearPendingEthTx(hashB) + }) + + got, err := server.EthGetTransactionCount([]any{address, hexutil.EncodeUint64(5_003)}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_001), got) +} + +func TestEthGetTransactionCountKeepsPendingNonceReservedWhenBlockMissing(t *testing.T) { + server, db := newTestEthServerAndStoreAtHeight(t, 5_000) + tx := newTestPendingRLPTransaction(t, 4_999) + address := "0x" + senderFromTransaction(tx) + hash := ethHashStringFromTransaction(tx) + registerPendingEthTx(hash, tx) + t.Cleanup(func() { clearPendingEthTx(hash) }) + + require.NoError(t, db.IndexTx(newTestIndexedTxResultFromPendingTx(t, tx, 1))) + _, commitErr := db.Commit() + require.NoError(t, commitErr) + + gotByHash, err := server.EthGetTransactionByHash([]any{hash}) + require.NoError(t, err) + txMap, ok := gotByHash.(map[string]any) + require.True(t, ok) + require.Nil(t, txMap["blockHash"]) + require.Nil(t, txMap["blockNumber"]) + + gotLatest, err := server.EthGetTransactionCount([]any{address, latestBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotLatest) + + gotPending, err := server.EthGetTransactionCount([]any{address, pendingBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(5_000), gotPending) +} + +func TestEthGetTransactionCountPendingErrorsWhenPendingNonceHitsAcceptanceCeiling(t *testing.T) { + server := newTestEthServerAtHeight(t, 5_000) + nonce := server.maximumAcceptedEthereumNonce() + tx := newTestPendingRLPTransaction(t, nonce) + address := "0x" + senderFromTransaction(tx) + hash := ethHashStringFromTransaction(tx) + registerPendingEthTx(hash, tx) + t.Cleanup(func() { clearPendingEthTx(hash) }) + + gotLatest, err := server.EthGetTransactionCount([]any{address, latestBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotLatest) + + gotSafe, err := server.EthGetTransactionCount([]any{address, safeBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotSafe) + + gotFinalized, err := server.EthGetTransactionCount([]any{address, finalizedBlockTag}) + require.NoError(t, err) + require.Equal(t, hexutil.Uint64(4_999), gotFinalized) + + _, err = server.EthGetTransactionCount([]any{address, pendingBlockTag}) + require.ErrorContains(t, err, "no replay-safe nonce available within accepted window") +} + +func newTestEthServerWithPartiallyIndexedTx(t *testing.T) (*Server, *lib.TxResult, common.Hash) { + t.Helper() + + log := lib.NewDefaultLogger() + storeI, err := store.NewStoreInMemory(log) + require.NoError(t, err) + db := storeI.(*store.Store) + + sm := newTestRPCStateMachine(t, db, log) + txResult, ethHash := newTestRLPBackedTxResult(t) + require.NoError(t, db.IndexTx(txResult)) + _, err = db.Commit() + require.NoError(t, err) + + setFSMHeight(t, sm, db.Version()) + + return &Server{ + controller: newTestRPCController(sm), + config: lib.DefaultConfig(), + indexerBlobCache: newIndexerBlobCache(8), + logger: log, + }, txResult, ethHash +} + +func newTestEthServerWithDeletedButCachedTx(t *testing.T) (*Server, *lib.TxResult, common.Hash) { + t.Helper() + + log := lib.NewDefaultLogger() + storeI, err := store.NewStoreInMemory(log) + require.NoError(t, err) + db := storeI.(*store.Store) + + sm := newTestRPCStateMachine(t, db, log) + txResult, ethHash := newTestRLPBackedTxResult(t) + block := &lib.BlockResult{ + BlockHeader: &lib.BlockHeader{ + Height: 1, + Hash: ethCrypto.Keccak256([]byte("block-cached")), + Time: uint64(time.Now().UnixMicro()), + }, + Transactions: []*lib.TxResult{txResult}, + } + + require.NoError(t, db.IndexBlock(block)) + _, err = db.Commit() + require.NoError(t, err) + _, err = db.GetBlockByHeight(block.BlockHeader.Height) + require.NoError(t, err) + require.NoError(t, db.DeleteTxsForHeight(block.BlockHeader.Height)) + _, err = db.Commit() + require.NoError(t, err) + + setFSMHeight(t, sm, db.Version()) + + return &Server{ + controller: newTestRPCController(sm), + config: lib.DefaultConfig(), + indexerBlobCache: newIndexerBlobCache(8), + logger: log, + }, txResult, ethHash +} + +func newTestEthServerAtHeight(t *testing.T, height uint64) *Server { + t.Helper() + + server, _ := newTestEthServerAndStoreAtHeight(t, height) + return server +} + +func newTestEthServerAndStoreAtHeight(t *testing.T, height uint64) (*Server, *store.Store) { + t.Helper() + + log := lib.NewDefaultLogger() + storeI, err := store.NewStoreInMemory(log) + require.NoError(t, err) + db := storeI.(*store.Store) + + sm := newTestRPCStateMachine(t, db, log) + setFSMHeight(t, sm, height) + + return &Server{ + controller: newTestRPCController(sm), + config: lib.DefaultConfig(), + indexerBlobCache: newIndexerBlobCache(8), + logger: log, + }, db +} + +func newTestRPCController(sm *fsm.StateMachine) *controller.Controller { + return &controller.Controller{ + FSM: sm, + Mempool: &controller.Mempool{ + Mempool: lib.NewMempool(lib.DefaultMempoolConfig()), + L: &sync.Mutex{}, + }, + } +} + +func newTestRLPBackedTxResult(t *testing.T) (*lib.TxResult, common.Hash) { + t.Helper() + + key, err := ethCrypto.GenerateKey() + require.NoError(t, err) + + recipient := common.HexToAddress("0x0000000000000000000000000000000000000011") + sender := common.HexToAddress("0x0000000000000000000000000000000000000002") + ethTx := types.MustSignNewTx(key, types.LatestSignerForChainID(big.NewInt(1)), &types.DynamicFeeTx{ + ChainID: big.NewInt(1), + Nonce: 7, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10_000_000_000), + Gas: 21_000, + To: &recipient, + Value: new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), + }) + rawEthTx, err := ethTx.MarshalBinary() + require.NoError(t, err) + + return &lib.TxResult{ + Sender: sender.Bytes(), + Recipient: recipient.Bytes(), + Height: 1, + Index: 0, + Transaction: &lib.Transaction{ + Memo: "RLP", + Signature: &lib.Signature{Signature: rawEthTx}, + }, + TxHash: "1111111111111111111111111111111111111111111111111111111111111111", + }, ethTx.Hash() +} + +func newTestPendingRLPTransaction(t *testing.T, nonce uint64) *lib.Transaction { + t.Helper() + + key, err := ethCrypto.GenerateKey() + require.NoError(t, err) + return newTestPendingRLPTransactionFromKey(t, key, nonce) +} + +func newTestEthServerWithConfirmedFloorAndStalePending(t *testing.T) (*Server, string) { + t.Helper() + + server, db := newTestEthServerAndStoreAtHeight(t, 5_008) + key, err := ethCrypto.GenerateKey() + require.NoError(t, err) + minedTx := newTestPendingRLPTransactionFromKey(t, key, 5_006) + address := "0x" + senderFromTransaction(minedTx) + + require.NoError(t, db.IndexTx(newTestIndexedTxResultFromPendingTx(t, minedTx, 1))) + _, commitErr := db.Commit() + require.NoError(t, commitErr) + + stalePendingTx := newTestPendingRLPTransactionFromKey(t, key, 5_001) + stalePendingHash := ethHashStringFromTransaction(stalePendingTx) + registerPendingEthTx(stalePendingHash, stalePendingTx) + t.Cleanup(func() { clearPendingEthTx(stalePendingHash) }) + + return server, address +} + +func newTestPendingRLPTransactionFromKey(t *testing.T, key *ecdsa.PrivateKey, nonce uint64) *lib.Transaction { + t.Helper() + + recipient := common.HexToAddress("0x0000000000000000000000000000000000000011") + ethTx := types.MustSignNewTx(key, types.LatestSignerForChainID(big.NewInt(4_294_967_297)), &types.DynamicFeeTx{ + ChainID: big.NewInt(4_294_967_297), + Nonce: nonce, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(10_000_000_000), + Gas: 21_000, + To: &recipient, + Value: new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil), + }) + rawEthTx, err := ethTx.MarshalBinary() + require.NoError(t, err) + + tx, err := fsm.RLPToCanopyTransaction(rawEthTx) + require.NoError(t, err) + return tx +} + +func newTestIndexedTxResultFromPendingTx(t *testing.T, tx *lib.Transaction, height uint64) *lib.TxResult { + t.Helper() + + ethTx, ok := ethTransactionFromCanopyTx(tx) + require.True(t, ok) + + sender := common.HexToAddress("0x" + senderFromTransaction(tx)) + var recipient []byte + if to := ethTx.To(); to != nil { + recipient = to.Bytes() + } + + return &lib.TxResult{ + Sender: sender.Bytes(), + Recipient: recipient, + Height: height, + Index: 0, + Transaction: &lib.Transaction{ + Memo: tx.Memo, + CreatedHeight: tx.CreatedHeight, + Fee: tx.Fee, + Msg: tx.Msg, + Signature: tx.Signature, + }, + TxHash: strings.Repeat("2", 64), + } +} diff --git a/fsm/ethereum.md b/fsm/ethereum.md index ce7e796e42..43ab29bd1d 100644 --- a/fsm/ethereum.md +++ b/fsm/ethereum.md @@ -40,12 +40,16 @@ It is **not** a claim of full EVM equivalence. - `eth_call` only supports Canopy's fixed pseudo-contract surface. - Logs are synthesized for Canopy's supported token-style transfer model, not for arbitrary contract events. - Nonce handling is compatibility-oriented and intentionally lighter than a full Ethereum account-history subsystem. +- Custom ETH-RPC reads are available for native Canopy state that does not map cleanly to Ethereum accounts: + - `canopy_getStake(address, blockTag?)` returns validator `stakedAmount` as an `eth_getBalance`-style 18-decimal hex amount. + - `canopy_getPool(id, blockTag?)` returns pool `amount` as an `eth_getBalance`-style 18-decimal hex amount. ### Address Model Canopy's ETH RPC intentionally exposes a mixed address model: - Any Canopy account address that fits the standard 20-byte hex format can be queried through Ethereum-style read APIs such as `eth_getBalance`, transaction lookups, and supported log queries. +- The reserved read-only pseudo-address `0x000000000000000000000000000000000001ffff` maps `eth_getBalance` to the DAO pool balance for exchange compatibility. - Only Ethereum-derived `secp256k1` accounts are writable through Ethereum tooling such as MetaMask, `eth_sendRawTransaction`, and Ethereum-style nonce handling. Implications: @@ -71,6 +75,8 @@ RPC - [x] eth_accounts - [x] eth_blockNumber - [x] eth_getBalance +- [x] canopy_getStake +- [x] canopy_getPool - [ ] eth_getStorageAt - [x] eth_getTransactionCount - [x] eth_getBlockTransactionCountByHash @@ -373,17 +379,68 @@ Pending visibility is node-local, just like Ethereum mempool visibility is node- *Implementation:* -- For Ethereum-derived accounts with mined ETH-backed tx history, `eth_getTransactionCount(..., "latest")` returns the sender's highest mined Ethereum nonce plus one. -- `eth_getTransactionCount(..., "pending")` returns the same confirmed base plus a local pending offset derived from the node's in-memory pending view. -- For addresses without mined Ethereum-backed tx history, the RPC falls back to Canopy's height-based pseudo-nonce behavior for compatibility. +- The RPC persists a **confirmed replay nonce floor** for senders with mined RLP-backed transactions. + - This is the sender's highest indexed mined replay nonce. + - It prevents `eth_getTransactionCount` from moving backwards after pending state clears. +- The RPC also tracks a short-lived **local pending set** for Ethereum-submitted transactions accepted by this node. + - These pending entries exist before full block indexing catches up. + - They are cleared once the tx is observable as mined, or after expiry. + +*Tag behavior:* + +- `latest` + - Starts from the confirmed replay-safe value: the higher of: + - the current Ethereum-facing block height, or + - the sender's highest confirmed replay nonce plus one. + - Then may clamp that value down to the **earliest unresolved local pending nonce** for the sender, but only when that does not move below the confirmed replay floor. + - Pending nonces below the confirmed replay floor are ignored, except for the immediate-predecessor partial-index case where the tx row is visible but the mined block row is not yet visible. + - This is intentional wallet-compatibility behavior: it prevents a wallet from concluding that an earlier tx was dropped just because the node accepted later txs before receipts became visible. +- `pending` + - Starts from the confirmed replay-safe value. + - Then advances past the **highest unresolved local pending nonce** for the sender. + - In other words, `pending` means "what nonce should I use for the next new tx on this node?" +- `safe` and `finalized` + - Use only the confirmed replay-safe value. + - They do **not** use the local pending clamp. +- Explicit numeric block tags + - Current-head and future-facing numeric tags are treated as compatibility views, not strict archival Ethereum nonce history. + - They use the same confirmed replay-safe logic as `latest`, including the same confirmed-floor-aware local pending clamp. + - Historical numeric tags are still compatibility values; the RPC does not reconstruct the exact historical Ethereum account nonce for that address at that height. + +*Why `latest` sees local pending state:* + +- Pure Ethereum semantics would treat `latest` as confirmed-chain state only. +- That was not sufficient for wallet compatibility on Canopy. +- A wallet such as MetaMask may: + - submit tx `A`, + - fail to see a receipt for `A` yet, + - ask `eth_getTransactionCount(..., "latest")` or `eth_getTransactionCount(..., "")`, + - and infer that `A` was dropped if the returned value has already advanced past `A`. +- Clamping `latest` to the earliest still-relevant unresolved local pending nonce avoids that false-drop heuristic while the transaction is still unresolved on this node. +- The clamp is intentionally narrower than "any lowest pending nonce": once a pending nonce is already below confirmed mined history, it is ignored unless it is the immediate predecessor still stuck in the partial-index window. + +*Mental model:* + +- The **confirmed floor** answers: "what has definitely mined historically?" +- The **earliest still-relevant unresolved local pending nonce** answers: "what is the earliest tx this node still cannot prove resolved without contradicting confirmed mined history?" +- The **highest unresolved local pending nonce** answers: "what nonce has this node already accepted up through?" + +Together they produce: + +- `latest`: "confirmed-compatible, but do not jump ahead of the earliest still-relevant unresolved local tx" +- `pending`: "next usable nonce after everything this node already accepted" *Purpose in RPC compatibility:* - `eth_getTransactionCount` is expected by many Ethereum tools and wallets to return a usable nonce. -- The implementation is intended to be wallet-compatible for active Ethereum-derived accounts without introducing a full persistent Ethereum nonce subsystem into Canopy. +- The implementation is intended to be wallet-compatible without introducing a full persistent Ethereum nonce subsystem into Canopy. - Canopy tolerates nonce gaps for Ethereum-submitted transactions; the values only need to remain distinct within the local pending set for a given block-building window. *Limitations:* +- `latest` is intentionally not a pure archival Ethereum-confirmed nonce view while unresolved local pending txs exist. +- The local pending clamp does not treat every stale pending entry as authoritative; below-floor entries are ignored except for the immediate-predecessor partial-index case. +- Because the pending clamp is node-local, the behavior is best when a wallet reads and writes through the same RPC node. +- A client that expects `latest` to ignore local pending state completely may observe lower values than on Ethereum during unresolved local tx windows. - Explicit historical block-number queries are **not** canonical Ethereum account-history semantics. If a caller asks for `eth_getTransactionCount(address, "0x...")`, the RPC validates the tag but serves a compatibility value instead of reconstructing the exact historical nonce for that address at that block. - Pending nonce visibility is local to the node's mempool and pending cache. - The method should be treated as compatibility-oriented rather than a full Ethereum archival nonce implementation. diff --git a/store/indexer.go b/store/indexer.go index 21b1ed8860..6dbf26ad01 100644 --- a/store/indexer.go +++ b/store/indexer.go @@ -16,20 +16,20 @@ import ( var _ lib.RWIndexerI = &Indexer{} var ( - txHashPrefix = []byte{1} // store key prefix for transaction by hash - txHeightPrefix = []byte{2} // store key prefix for transactions by height - txSenderPrefix = []byte{3} // store key prefix for transactions from sender - txRecipientPrefix = []byte{4} // store key prefix for transaction by recipient - blockHashPrefix = []byte{5} // store key prefix for block by hash - blockHeightPrefix = []byte{6} // store key prefix for block by height - qcHeightPrefix = []byte{7} // store key prefix for quorum certificate by height - doubleSignerPrefix = []byte{8} // store key prefix for double signers by height - checkPointPrefix = []byte{9} // store key prefix for checkpoints for committee chains - eventAddressPrefix = []byte{10} // store key prefix for events by address - eventHeightPrefix = []byte{11} // store key prefix for events by block height - eventChainIdPrefix = []byte{12} // store key prefix for events by chainId - eventHashPrefix = []byte{13} // store key prefix for events by event hash (concept just used for indexing) - ethNoncePrefix = []byte{14} // store key prefix for latest mined Ethereum nonce by sender + txHashPrefix = []byte{1} // store key prefix for transaction by hash + txHeightPrefix = []byte{2} // store key prefix for transactions by height + txSenderPrefix = []byte{3} // store key prefix for transactions from sender + txRecipientPrefix = []byte{4} // store key prefix for transaction by recipient + blockHashPrefix = []byte{5} // store key prefix for block by hash + blockHeightPrefix = []byte{6} // store key prefix for block by height + qcHeightPrefix = []byte{7} // store key prefix for quorum certificate by height + doubleSignerPrefix = []byte{8} // store key prefix for double signers by height + checkPointPrefix = []byte{9} // store key prefix for checkpoints for committee chains + eventAddressPrefix = []byte{10} // store key prefix for events by address + eventHeightPrefix = []byte{11} // store key prefix for events by block height + eventChainIdPrefix = []byte{12} // store key prefix for events by chainId + eventHashPrefix = []byte{13} // store key prefix for events by event hash (concept just used for indexing) + ethReplayNoncePrefix = []byte{14} // store key prefix for highest confirmed replay nonce by RLP-backed sender // create indexer cache blockCache, _ = lru.New[uint64, *lib.BlockResult](64) //qcCache, _ = lru.New[uint64, *lib.QuorumCertificate](4) TODO add back @@ -285,7 +285,7 @@ func (t *Indexer) IndexTx(result *lib.TxResult) lib.ErrorI { return err } } - if err = t.indexLatestEthereumNonce(result); err != nil { + if err = t.indexHighestConfirmedEthereumReplayNonce(result); err != nil { return err } @@ -322,6 +322,27 @@ func (t *Indexer) GetTxByHash(hash []byte) (*lib.TxResult, lib.ErrorI) { return t.getTx(t.txHashKey(hash)) } +// FindCachedTxByHash() searches the recent in-memory block cache for a tx hash during live indexing races. +func (t *Indexer) FindCachedTxByHash(hash []byte) (*lib.TxResult, *lib.BlockResult, lib.ErrorI) { + for _, block := range blockCache.Values() { + if block == nil { + continue + } + for _, tx := range block.Transactions { + hashes, err := indexedTxHashes(tx) + if err != nil { + return nil, nil, err + } + for _, candidate := range hashes { + if bytes.Equal(candidate, hash) { + return tx, block, nil + } + } + } + } + return nil, nil, nil +} + // GetTxsByHeight() returns a page of transactions for a height func (t *Indexer) GetTxsByHeight(height uint64, newestToOldest bool, p lib.PageParams) (*lib.Page, lib.ErrorI) { return t.getTxs(t.txHeightKey(height), newestToOldest, p) @@ -342,9 +363,9 @@ func (t *Indexer) GetTxsByRecipient(address crypto.AddressI, newestToOldest bool return t.getTxs(t.txRecipientKey(address.Bytes(), nil), newestToOldest, p) } -// GetLatestMinedEthereumNonce() returns the highest mined Ethereum nonce recorded for the sender. -func (t *Indexer) GetLatestMinedEthereumNonce(address crypto.AddressI) (nonce uint64, ok bool, err lib.ErrorI) { - bz, err := t.db.Get(t.ethNonceKey(address.Bytes())) +// GetHighestConfirmedEthereumReplayNonce() returns the highest confirmed replay nonce recorded for an RLP-backed sender. +func (t *Indexer) GetHighestConfirmedEthereumReplayNonce(address crypto.AddressI) (nonce uint64, ok bool, err lib.ErrorI) { + bz, err := t.db.Get(t.ethReplayNonceKey(address.Bytes())) if err != nil { return 0, false, err } @@ -356,6 +377,8 @@ func (t *Indexer) GetLatestMinedEthereumNonce(address crypto.AddressI) (nonce ui // DeleteTxsForHeight() deletes the transaction object for a specific height func (t *Indexer) DeleteTxsForHeight(height uint64) lib.ErrorI { + // Evict any warmed block snapshot so deleted tx rows cannot still leak through recent cache lookups. + blockCache.Remove(height) txs, err := t.GetTxsByHeightNonPaginated(height, false) if err != nil { return err @@ -391,7 +414,7 @@ func (t *Indexer) DeleteTxsForHeight(height uint64) lib.ErrorI { return err } for _, sender := range affectedSenders { - if err = t.reindexLatestEthereumNonce(sender); err != nil { + if err = t.reindexHighestConfirmedEthereumReplayNonce(sender); err != nil { return err } } @@ -845,25 +868,25 @@ func (t *Indexer) indexTxByRecipient(recipient, heightAndIndexKey []byte, bz []b return t.db.Set(t.txRecipientKey(recipient, heightAndIndexKey), bz) } -// indexLatestEthereumNonce() persists the highest mined Ethereum nonce seen for an RLP-backed sender. -func (t *Indexer) indexLatestEthereumNonce(result *lib.TxResult) lib.ErrorI { +// indexHighestConfirmedEthereumReplayNonce() persists the highest confirmed replay nonce seen for an RLP-backed sender. +func (t *Indexer) indexHighestConfirmedEthereumReplayNonce(result *lib.TxResult) lib.ErrorI { if result == nil || result.Transaction == nil || len(result.Sender) == 0 || ethTxHash(result.Transaction) == nil { return nil } - current, ok, err := t.GetLatestMinedEthereumNonce(crypto.NewAddress(result.Sender)) + current, ok, err := t.GetHighestConfirmedEthereumReplayNonce(crypto.NewAddress(result.Sender)) if err != nil { return err } if ok && current >= result.Transaction.CreatedHeight { return nil } - return t.db.Set(t.ethNonceKey(result.Sender), t.encodeBigEndian(result.Transaction.CreatedHeight)) + return t.db.Set(t.ethReplayNonceKey(result.Sender), t.encodeBigEndian(result.Transaction.CreatedHeight)) } -// reindexLatestEthereumNonce() recomputes the latest mined Ethereum nonce for a sender after index deletions. -func (t *Indexer) reindexLatestEthereumNonce(address crypto.AddressI) lib.ErrorI { +// reindexHighestConfirmedEthereumReplayNonce() recomputes the confirmed replay nonce floor for a sender after index deletions. +func (t *Indexer) reindexHighestConfirmedEthereumReplayNonce(address crypto.AddressI) lib.ErrorI { if !t.config.IndexByAccount { - return t.db.Delete(t.ethNonceKey(address.Bytes())) + return t.db.Delete(t.ethReplayNonceKey(address.Bytes())) } it, err := t.db.RevIterator(t.txSenderKey(address.Bytes(), nil)) if err != nil { @@ -875,9 +898,9 @@ func (t *Indexer) reindexLatestEthereumNonce(address crypto.AddressI) lib.ErrorI if e != nil || tx == nil || tx.Transaction == nil || ethTxHash(tx.Transaction) == nil { continue } - return t.db.Set(t.ethNonceKey(address.Bytes()), t.encodeBigEndian(tx.Transaction.CreatedHeight)) + return t.db.Set(t.ethReplayNonceKey(address.Bytes()), t.encodeBigEndian(tx.Transaction.CreatedHeight)) } - return t.db.Delete(t.ethNonceKey(address.Bytes())) + return t.db.Delete(t.ethReplayNonceKey(address.Bytes())) } func (t *Indexer) indexQCByHeight(height uint64, bz []byte) lib.ErrorI { @@ -929,9 +952,8 @@ func (t *Indexer) txRecipientKey(address, heightAndIndexKey []byte) []byte { return t.key(txRecipientPrefix, address, heightAndIndexKey) } -// ethNonceKey() stores the latest mined Ethereum nonce for a sender address. -func (t *Indexer) ethNonceKey(address []byte) []byte { - return t.key(ethNoncePrefix, address, nil) +func (t *Indexer) ethReplayNonceKey(address []byte) []byte { + return t.key(ethReplayNoncePrefix, address, nil) } func (t *Indexer) blockHashKey(hash []byte) []byte { diff --git a/store/indexer_test.go b/store/indexer_test.go index 43abee1721..cf1cf2fd6b 100644 --- a/store/indexer_test.go +++ b/store/indexer_test.go @@ -81,19 +81,68 @@ func TestDeleteTxsForHeightRemovesEthereumHashAlias(t *testing.T) { require.True(t, got == nil || got.TxHash == "") } -func TestGetLatestMinedEthereumNonce(t *testing.T) { +func TestDeleteTxsForHeightEvictsCachedBlock(t *testing.T) { + store, _, cleanup := testStore(t) + defer cleanup() + + txResult, ethHash := newRLPBackedTxResult(t) + block := &lib.BlockResult{ + BlockHeader: &lib.BlockHeader{ + Height: testHeight, + Hash: bytes.Repeat([]byte{0x33}, 32), + Time: uint64(time.Now().UnixMicro()), + }, + Transactions: []*lib.TxResult{txResult}, + } + require.NoError(t, store.IndexBlock(block)) + _, err := store.Commit() + require.NoError(t, err) + + _, err = store.GetBlockByHeight(testHeight) + require.NoError(t, err) + require.NoError(t, store.DeleteTxsForHeight(testHeight)) + + gotTx, gotBlock, err := store.FindCachedTxByHash(ethHash.Bytes()) + require.NoError(t, err) + require.Nil(t, gotTx) + require.Nil(t, gotBlock) +} + +func TestGetHighestConfirmedEthereumReplayNonce(t *testing.T) { store, _, cleanup := testStore(t) defer cleanup() txResult, _ := newRLPBackedTxResult(t) require.NoError(t, store.IndexTx(txResult)) - nonce, ok, err := store.GetLatestMinedEthereumNonce(crypto.NewAddress(txResult.Sender)) + nonce, ok, err := store.GetHighestConfirmedEthereumReplayNonce(crypto.NewAddress(txResult.Sender)) require.NoError(t, err) require.True(t, ok) require.Equal(t, txResult.Transaction.CreatedHeight, nonce) } +func TestFindCachedTxByHash(t *testing.T) { + store, _, cleanup := testStore(t) + defer cleanup() + + txResult, ethHash := newRLPBackedTxResult(t) + block := &lib.BlockResult{ + BlockHeader: &lib.BlockHeader{ + Height: testHeight, + Hash: bytes.Repeat([]byte{0x22}, 32), + Time: uint64(time.Now().UnixMicro()), + }, + Transactions: []*lib.TxResult{txResult}, + } + blockCache.Add(block.BlockHeader.Height, block) + t.Cleanup(blockCache.Purge) + + gotTx, gotBlock, err := store.FindCachedTxByHash(ethHash.Bytes()) + require.NoError(t, err) + require.Equal(t, txResult.TxHash, gotTx.TxHash) + require.Equal(t, block.BlockHeader.Height, gotBlock.BlockHeader.Height) +} + const ethGasPriceTestValue = 10_000_000_000 func ptrAddress(address common.Address) *common.Address { return &address }