status-go/services/wallet/transfer/commands_sequential_test.go

807 lines
21 KiB
Go

package transfer
import (
"context"
"math/big"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/event"
"github.com/ethereum/go-ethereum/rpc"
"github.com/status-im/status-go/contracts/ethscan"
"github.com/status-im/status-go/contracts/ierc20"
"github.com/status-im/status-go/rpc/chain"
"github.com/status-im/status-go/services/wallet/async"
"github.com/status-im/status-go/services/wallet/balance"
"github.com/status-im/status-go/t/helpers"
"github.com/status-im/status-go/params"
statusRpc "github.com/status-im/status-go/rpc"
"github.com/status-im/status-go/rpc/network"
"github.com/status-im/status-go/services/wallet/token"
"github.com/status-im/status-go/walletdatabase"
)
type TestClient struct {
t *testing.T
// [][block, newBalance, nonceDiff]
balances [][]int
outgoingERC20Transfers []testERC20Transfer
incomingERC20Transfers []testERC20Transfer
balanceHistory map[uint64]*big.Int
tokenBalanceHistory map[common.Address]map[uint64]*big.Int
nonceHistory map[uint64]uint64
traceAPICalls bool
printPreparedData bool
rw sync.RWMutex
callsCounter map[string]int
}
func (tc *TestClient) incCounter(method string) {
tc.rw.Lock()
defer tc.rw.Unlock()
tc.callsCounter[method] = tc.callsCounter[method] + 1
}
func (tc *TestClient) getCounter() int {
tc.rw.RLock()
defer tc.rw.RUnlock()
cnt := 0
for _, v := range tc.callsCounter {
cnt += v
}
return cnt
}
func (tc *TestClient) printCounter() {
total := tc.getCounter()
tc.rw.RLock()
defer tc.rw.RUnlock()
tc.t.Log("========================================= Total calls", total)
for k, v := range tc.callsCounter {
tc.t.Log(k, v)
}
tc.t.Log("=========================================")
}
func (tc *TestClient) BatchCallContext(ctx context.Context, b []rpc.BatchElem) error {
if tc.traceAPICalls {
tc.t.Log("BatchCallContext")
}
return nil
}
func (tc *TestClient) HeaderByHash(ctx context.Context, hash common.Hash) (*types.Header, error) {
tc.incCounter("HeaderByHash")
if tc.traceAPICalls {
tc.t.Log("HeaderByHash")
}
return nil, nil
}
func (tc *TestClient) BlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error) {
tc.incCounter("BlockByHash")
if tc.traceAPICalls {
tc.t.Log("BlockByHash")
}
return nil, nil
}
func (tc *TestClient) BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) {
tc.incCounter("BlockByNumber")
if tc.traceAPICalls {
tc.t.Log("BlockByNumber")
}
return nil, nil
}
func (tc *TestClient) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) {
tc.incCounter("NonceAt")
nonce := tc.nonceHistory[blockNumber.Uint64()]
if tc.traceAPICalls {
tc.t.Log("NonceAt", blockNumber, "result:", nonce)
}
return nonce, nil
}
func (tc *TestClient) FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) {
tc.incCounter("FilterLogs")
if tc.traceAPICalls {
tc.t.Log("FilterLogs")
}
//checking only ERC20 for now
incomingAddress := q.Topics[len(q.Topics)-1]
allTransfers := tc.incomingERC20Transfers
if len(incomingAddress) == 0 {
allTransfers = tc.outgoingERC20Transfers
}
logs := []types.Log{}
for _, transfer := range allTransfers {
if transfer.block.Cmp(q.FromBlock) >= 0 && transfer.block.Cmp(q.ToBlock) <= 0 {
logs = append(logs, types.Log{
BlockNumber: transfer.block.Uint64(),
BlockHash: common.BigToHash(transfer.block),
})
}
}
return logs, nil
}
func (tc *TestClient) BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) {
tc.incCounter("BalanceAt")
balance := tc.balanceHistory[blockNumber.Uint64()]
if tc.traceAPICalls {
tc.t.Log("BalanceAt", blockNumber, "result:", balance)
}
return balance, nil
}
func (tc *TestClient) tokenBalanceAt(token common.Address, blockNumber *big.Int) *big.Int {
balance := tc.tokenBalanceHistory[token][blockNumber.Uint64()]
if balance == nil {
balance = big.NewInt(0)
}
if tc.traceAPICalls {
tc.t.Log("tokenBalanceAt", token, blockNumber, "result:", balance)
}
return balance
}
func (tc *TestClient) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) {
tc.incCounter("HeaderByNumber")
if tc.traceAPICalls {
tc.t.Log("HeaderByNumber", number)
}
header := &types.Header{
Number: number,
Time: 0,
}
return header, nil
}
func (tc *TestClient) FullTransactionByBlockNumberAndIndex(ctx context.Context, blockNumber *big.Int, index uint) (*chain.FullTransaction, error) {
tc.incCounter("FullTransactionByBlockNumberAndIndex")
if tc.traceAPICalls {
tc.t.Log("FullTransactionByBlockNumberAndIndex")
}
blockHash := common.BigToHash(blockNumber)
tx := &chain.FullTransaction{
Tx: &types.Transaction{},
TxExtraInfo: chain.TxExtraInfo{
BlockNumber: (*hexutil.Big)(big.NewInt(0)),
BlockHash: &blockHash,
},
}
return tx, nil
}
func (tc *TestClient) GetBaseFeeFromBlock(blockNumber *big.Int) (string, error) {
tc.incCounter("GetBaseFeeFromBlock")
if tc.traceAPICalls {
tc.t.Log("GetBaseFeeFromBlock")
}
return "", nil
}
func (tc *TestClient) NetworkID() uint64 {
return 777333
}
func (tc *TestClient) ToBigInt() *big.Int {
if tc.traceAPICalls {
tc.t.Log("ToBigInt")
}
return nil
}
var ethscanAddress = common.HexToAddress("0x0000000000000000000000000000000000777333")
func (tc *TestClient) CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error) {
tc.incCounter("CodeAt")
if tc.traceAPICalls {
tc.t.Log("CodeAt", contract, blockNumber)
}
if ethscanAddress == contract {
return []byte{1}, nil
}
return nil, nil
}
func (tc *TestClient) CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) {
tc.incCounter("CallContract")
if tc.traceAPICalls {
tc.t.Log("CallContract", call, blockNumber, call.To)
}
if *call.To == ethscanAddress {
parsed, err := abi.JSON(strings.NewReader(ethscan.BalanceScannerABI))
if err != nil {
return nil, err
}
method := parsed.Methods["tokensBalance"]
params := call.Data[len(method.ID):]
args, err := method.Inputs.Unpack(params)
if err != nil {
tc.t.Log("ERROR on unpacking", err)
return nil, err
}
tokens := args[1].([]common.Address)
balances := []*big.Int{}
for _, token := range tokens {
balances = append(balances, tc.tokenBalanceAt(token, blockNumber))
}
results := []ethscan.BalanceScannerResult{}
for _, balance := range balances {
results = append(results, ethscan.BalanceScannerResult{
Success: true,
Data: balance.Bytes(),
})
}
output, err := method.Outputs.Pack(results)
if err != nil {
tc.t.Log("ERROR on packing", err)
return nil, err
}
return output, nil
}
if *call.To == tokenTXXAddress || *call.To == tokenTXYAddress {
balance := tc.tokenBalanceAt(*call.To, blockNumber)
parsed, err := abi.JSON(strings.NewReader(ierc20.IERC20ABI))
if err != nil {
return nil, err
}
method := parsed.Methods["balanceOf"]
output, err := method.Outputs.Pack(balance)
if err != nil {
tc.t.Log("ERROR on packing ERC20 balance", err)
return nil, err
}
return output, nil
}
return nil, nil
}
func (tc *TestClient) prepareBalanceHistory(toBlock int) {
var currentBlock, currentBalance, currentNonce int
tc.balanceHistory = map[uint64]*big.Int{}
tc.nonceHistory = map[uint64]uint64{}
if len(tc.balances) == 0 {
tc.balances = append(tc.balances, []int{toBlock + 1, 0, 0})
} else {
lastBlock := tc.balances[len(tc.balances)-1]
tc.balances = append(tc.balances, []int{toBlock + 1, lastBlock[1], 0})
}
for _, change := range tc.balances {
for blockN := currentBlock; blockN < change[0]; blockN++ {
tc.balanceHistory[uint64(blockN)] = big.NewInt(int64(currentBalance))
tc.nonceHistory[uint64(blockN)] = uint64(currentNonce)
}
currentBlock = change[0]
currentBalance = change[1]
currentNonce += change[2]
}
if tc.printPreparedData {
tc.t.Log("========================================= ETH BALANCES")
tc.t.Log(tc.balanceHistory)
tc.t.Log(tc.nonceHistory)
tc.t.Log(tc.tokenBalanceHistory)
tc.t.Log("=========================================")
}
}
func (tc *TestClient) prepareTokenBalanceHistory(toBlock int) {
transfersPerToken := map[common.Address][]testERC20Transfer{}
for _, transfer := range tc.outgoingERC20Transfers {
transfer.amount = new(big.Int).Neg(transfer.amount)
transfersPerToken[transfer.address] = append(transfersPerToken[transfer.address], transfer)
}
for _, transfer := range tc.incomingERC20Transfers {
transfersPerToken[transfer.address] = append(transfersPerToken[transfer.address], transfer)
}
tc.tokenBalanceHistory = map[common.Address]map[uint64]*big.Int{}
for token, transfers := range transfersPerToken {
sort.Slice(transfers, func(i, j int) bool {
return transfers[i].block.Cmp(transfers[j].block) < 0
})
currentBlock := uint64(0)
currentBalance := big.NewInt(0)
tc.tokenBalanceHistory[token] = map[uint64]*big.Int{}
transfers = append(transfers, testERC20Transfer{big.NewInt(int64(toBlock + 1)), token, big.NewInt(0)})
for _, transfer := range transfers {
for blockN := currentBlock; blockN < transfer.block.Uint64(); blockN++ {
tc.tokenBalanceHistory[token][blockN] = new(big.Int).Set(currentBalance)
}
currentBlock = transfer.block.Uint64()
currentBalance = new(big.Int).Add(currentBalance, transfer.amount)
}
}
if tc.printPreparedData {
tc.t.Log("========================================= ERC20 BALANCES")
tc.t.Log(tc.tokenBalanceHistory)
tc.t.Log("=========================================")
}
}
func (tc *TestClient) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error {
tc.incCounter("CallContext")
if tc.traceAPICalls {
tc.t.Log("CallContext")
}
return nil
}
func (tc *TestClient) GetWalletNotifier() func(chainId uint64, message string) {
if tc.traceAPICalls {
tc.t.Log("GetWalletNotifier")
}
return nil
}
func (tc *TestClient) SetWalletNotifier(notifier func(chainId uint64, message string)) {
if tc.traceAPICalls {
tc.t.Log("SetWalletNotifier")
}
}
func (tc *TestClient) EstimateGas(ctx context.Context, call ethereum.CallMsg) (gas uint64, err error) {
tc.incCounter("EstimateGas")
if tc.traceAPICalls {
tc.t.Log("EstimateGas")
}
return 0, nil
}
func (tc *TestClient) PendingCodeAt(ctx context.Context, account common.Address) ([]byte, error) {
tc.incCounter("PendingCodeAt")
if tc.traceAPICalls {
tc.t.Log("PendingCodeAt")
}
return nil, nil
}
func (tc *TestClient) PendingCallContract(ctx context.Context, call ethereum.CallMsg) ([]byte, error) {
tc.incCounter("PendingCallContract")
if tc.traceAPICalls {
tc.t.Log("PendingCallContract")
}
return nil, nil
}
func (tc *TestClient) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) {
tc.incCounter("PendingNonceAt")
if tc.traceAPICalls {
tc.t.Log("PendingNonceAt")
}
return 0, nil
}
func (tc *TestClient) SuggestGasPrice(ctx context.Context) (*big.Int, error) {
tc.incCounter("SuggestGasPrice")
if tc.traceAPICalls {
tc.t.Log("SuggestGasPrice")
}
return nil, nil
}
func (tc *TestClient) SendTransaction(ctx context.Context, tx *types.Transaction) error {
tc.incCounter("SendTransaction")
if tc.traceAPICalls {
tc.t.Log("SendTransaction")
}
return nil
}
func (tc *TestClient) SuggestGasTipCap(ctx context.Context) (*big.Int, error) {
tc.incCounter("SuggestGasTipCap")
if tc.traceAPICalls {
tc.t.Log("SuggestGasTipCap")
}
return nil, nil
}
func (tc *TestClient) BatchCallContextIgnoringLocalHandlers(ctx context.Context, b []rpc.BatchElem) error {
tc.incCounter("BatchCallContextIgnoringLocalHandlers")
if tc.traceAPICalls {
tc.t.Log("BatchCallContextIgnoringLocalHandlers")
}
return nil
}
func (tc *TestClient) CallContextIgnoringLocalHandlers(ctx context.Context, result interface{}, method string, args ...interface{}) error {
tc.incCounter("CallContextIgnoringLocalHandlers")
if tc.traceAPICalls {
tc.t.Log("CallContextIgnoringLocalHandlers")
}
return nil
}
func (tc *TestClient) CallRaw(data string) string {
tc.incCounter("CallRaw")
if tc.traceAPICalls {
tc.t.Log("CallRaw")
}
return ""
}
func (tc *TestClient) GetChainID() *big.Int {
return big.NewInt(1)
}
func (tc *TestClient) SubscribeFilterLogs(ctx context.Context, q ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error) {
tc.incCounter("SubscribeFilterLogs")
if tc.traceAPICalls {
tc.t.Log("SubscribeFilterLogs")
}
return nil, nil
}
func (tc *TestClient) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) {
tc.incCounter("TransactionReceipt")
if tc.traceAPICalls {
tc.t.Log("TransactionReceipt")
}
return nil, nil
}
func (tc *TestClient) TransactionByHash(ctx context.Context, txHash common.Hash) (*types.Transaction, bool, error) {
tc.incCounter("TransactionByHash")
if tc.traceAPICalls {
tc.t.Log("TransactionByHash")
}
return nil, false, nil
}
func (tc *TestClient) BlockNumber(ctx context.Context) (uint64, error) {
tc.incCounter("BlockNumber")
if tc.traceAPICalls {
tc.t.Log("BlockNumber")
}
return 0, nil
}
func (tc *TestClient) SetIsConnected(value bool) {
if tc.traceAPICalls {
tc.t.Log("SetIsConnected")
}
}
func (tc *TestClient) GetIsConnected() bool {
if tc.traceAPICalls {
tc.t.Log("GetIsConnected")
}
return true
}
type testERC20Transfer struct {
block *big.Int
address common.Address
amount *big.Int
}
type findBlockCase struct {
balanceChanges [][]int
ERC20BalanceChanges [][]int
fromBlock int64
toBlock int64
rangeSize int
expectedBlocksFound int
outgoingERC20Transfers []testERC20Transfer
incomingERC20Transfers []testERC20Transfer
label string
expectedCalls map[string]int
}
func transferInEachBlock() [][]int {
res := [][]int{}
for i := 1; i < 101; i++ {
res = append(res, []int{i, i, i})
}
return res
}
func getCases() []findBlockCase {
cases := []findBlockCase{}
case1 := findBlockCase{
balanceChanges: [][]int{
{5, 1, 0},
{20, 2, 0},
{45, 1, 1},
{46, 50, 0},
{75, 0, 1},
},
outgoingERC20Transfers: []testERC20Transfer{
{big.NewInt(6), tokenTXXAddress, big.NewInt(1)},
},
toBlock: 100,
expectedBlocksFound: 6,
expectedCalls: map[string]int{
"BalanceAt": 27,
//TODO(rasom) NonceAt is flaky, sometimes it's called 18 times, sometimes 17
//to be investigated
//"NonceAt": 18,
"FilterLogs": 10,
"HeaderByNumber": 5,
},
}
case100transfers := findBlockCase{
balanceChanges: transferInEachBlock(),
toBlock: 100,
expectedBlocksFound: 100,
expectedCalls: map[string]int{
"BalanceAt": 101,
"NonceAt": 0,
"FilterLogs": 10,
"HeaderByNumber": 100,
},
}
case3 := findBlockCase{
balanceChanges: [][]int{
{1, 1, 1},
{2, 2, 2},
{45, 1, 1},
{46, 50, 0},
{75, 0, 1},
},
toBlock: 100,
expectedBlocksFound: 5,
}
case4 := findBlockCase{
balanceChanges: [][]int{
{20, 1, 0},
},
toBlock: 100,
fromBlock: 10,
expectedBlocksFound: 1,
label: "single block",
}
case5 := findBlockCase{
balanceChanges: [][]int{},
toBlock: 100,
fromBlock: 20,
expectedBlocksFound: 0,
}
case6 := findBlockCase{
balanceChanges: [][]int{
{20, 1, 0},
{45, 1, 1},
},
toBlock: 100,
fromBlock: 30,
expectedBlocksFound: 1,
rangeSize: 20,
label: "single block in range",
}
case7emptyHistoryWithOneERC20Transfer := findBlockCase{
balanceChanges: [][]int{},
toBlock: 100,
rangeSize: 20,
expectedBlocksFound: 1,
incomingERC20Transfers: []testERC20Transfer{
{big.NewInt(6), tokenTXXAddress, big.NewInt(1)},
},
}
case8emptyHistoryWithERC20Transfers := findBlockCase{
balanceChanges: [][]int{},
toBlock: 100,
rangeSize: 20,
expectedBlocksFound: 2,
incomingERC20Transfers: []testERC20Transfer{
// edge case when a regular scan will find transfer at 80,
// but erc20 tail scan should only find transfer at block 6
{big.NewInt(80), tokenTXXAddress, big.NewInt(1)},
{big.NewInt(6), tokenTXXAddress, big.NewInt(1)},
},
expectedCalls: map[string]int{
"FilterLogs": 3,
"CallContract": 3,
},
}
case9emptyHistoryWithERC20Transfers := findBlockCase{
balanceChanges: [][]int{},
toBlock: 100,
rangeSize: 20,
// we expect only a single eth_getLogs to be executed here for both erc20 transfers,
// thus only 2 blocks found
expectedBlocksFound: 2,
incomingERC20Transfers: []testERC20Transfer{
{big.NewInt(7), tokenTXYAddress, big.NewInt(1)},
{big.NewInt(6), tokenTXXAddress, big.NewInt(1)},
},
expectedCalls: map[string]int{
"FilterLogs": 3,
"CallContract": 5,
},
}
case10 := findBlockCase{
balanceChanges: [][]int{},
toBlock: 100,
fromBlock: 99,
expectedBlocksFound: 0,
label: "single block range, no transactions",
expectedCalls: map[string]int{
// only two requests to check the range for incoming ERC20
"FilterLogs": 2,
// no contract calls as ERC20 is not checked
"CallContract": 0,
},
}
cases = append(cases, case1)
cases = append(cases, case100transfers)
cases = append(cases, case3)
cases = append(cases, case4)
cases = append(cases, case5)
cases = append(cases, case6)
cases = append(cases, case7emptyHistoryWithOneERC20Transfer)
cases = append(cases, case8emptyHistoryWithERC20Transfers)
cases = append(cases, case9emptyHistoryWithERC20Transfers)
cases = append(cases, case10)
//cases = append([]findBlockCase{}, case10)
return cases
}
var tokenTXXAddress = common.HexToAddress("0x53211")
var tokenTXYAddress = common.HexToAddress("0x73211")
func TestFindBlocksCommand(t *testing.T) {
for idx, testCase := range getCases() {
t.Log("case #", idx)
ctx := context.Background()
group := async.NewGroup(ctx)
db, err := helpers.SetupTestMemorySQLDB(walletdatabase.DbInitializer{})
require.NoError(t, err)
tm := &TransactionManager{db, nil, nil, nil, nil, nil, nil}
wdb := NewDB(db)
tc := &TestClient{
t: t,
balances: testCase.balanceChanges,
outgoingERC20Transfers: testCase.outgoingERC20Transfers,
incomingERC20Transfers: testCase.incomingERC20Transfers,
callsCounter: map[string]int{},
}
//tc.traceAPICalls = true
//tc.printPreparedData = true
tc.prepareBalanceHistory(100)
tc.prepareTokenBalanceHistory(100)
blockChannel := make(chan []*DBHeader, 100)
rangeSize := 20
if testCase.rangeSize != 0 {
rangeSize = testCase.rangeSize
}
client, _ := statusRpc.NewClient(nil, 1, params.UpstreamRPCConfig{Enabled: false, URL: ""}, []params.Network{}, db)
client.SetClient(tc.NetworkID(), tc)
tokenManager := token.NewTokenManager(db, client, network.NewManager(db))
tokenManager.SetTokens([]*token.Token{
{
Address: tokenTXXAddress,
Symbol: "TXX",
Decimals: 18,
ChainID: tc.NetworkID(),
Name: "Test Token 1",
Verified: true,
},
{
Address: tokenTXYAddress,
Symbol: "TXY",
Decimals: 18,
ChainID: tc.NetworkID(),
Name: "Test Token 2",
Verified: true,
},
})
fbc := &findBlocksCommand{
account: common.HexToAddress("0x1234"),
db: wdb,
blockRangeDAO: &BlockRangeSequentialDAO{wdb.client},
chainClient: tc,
balanceCacher: balance.NewCacherWithTTL(5 * time.Minute),
feed: &event.Feed{},
noLimit: false,
fromBlockNumber: big.NewInt(testCase.fromBlock),
toBlockNumber: big.NewInt(testCase.toBlock),
transactionManager: tm,
blocksLoadedCh: blockChannel,
defaultNodeBlockChunkSize: rangeSize,
tokenManager: tokenManager,
}
group.Add(fbc.Command())
foundBlocks := []*DBHeader{}
select {
case <-ctx.Done():
t.Log("ERROR")
case <-group.WaitAsync():
close(blockChannel)
for {
bloks, ok := <-blockChannel
if !ok {
break
}
foundBlocks = append(foundBlocks, bloks...)
}
numbers := []int64{}
for _, block := range foundBlocks {
numbers = append(numbers, block.Number.Int64())
}
if tc.traceAPICalls {
tc.printCounter()
}
for name, cnt := range testCase.expectedCalls {
require.Equal(t, cnt, tc.callsCounter[name], "calls to "+name)
}
sort.Slice(numbers, func(i, j int) bool { return numbers[i] < numbers[j] })
require.Equal(t, testCase.expectedBlocksFound, len(foundBlocks), testCase.label, "found blocks", numbers)
}
}
}