From cb77ca6b9da0300a81b5e5eb494fd280facf3413 Mon Sep 17 00:00:00 2001 From: Conor Leary Date: Fri, 17 Dec 2021 13:35:24 -0700 Subject: [PATCH] ERC-20/ERC-721 Support (#50) * caching layer first pass * wire up evm_log_client * start of mapper * .gitignore * update readme * rough draft * address.go * refactor filenames * fix map init * update gitignore * contractInfo cache layer * new op types * cleanup + start of test framework * fix tests + linting errors * fix linter nits * update comments + readme * nits * feedback * construction api * fix linting errors * small bug fixes * naming refactor * data api endpoint * linting fix * edge case cleanup * remove tylersmith lib * start of EnableERC20 config * nits * update config var name * update serviceConfig init * data structure feedback * bug squashing * burn address fix * start of different modes * refactor modes * standard and analytic modes implemented for data ingestion * mapper helper.go file * update formatting * update service config * update address check to toLower * linter clean up * linter clean up * nit cleanup * refactor token whitelist name * refactor indexdefaulttokens * refactor ConvertEVMTopicHashToAddress * continued clean up * increased granularity on erc721 * further cleanup * linter clean up * reorder operations * final clean up * bug fix * linter cleanup * code review feedback * update read.me --- .gitignore | 2 + README.md | 26 ++- client/client.go | 22 ++- client/contract.go | 70 ++++++++ client/contractInfoToken.go | 242 +++++++++++++++++++++++++++ client/evm_logs.go | 72 ++++++++ client/types.go | 13 ++ cmd/server/config.go | 25 ++- cmd/server/main.go | 15 +- contractInfo.abi | 28 ++++ go.mod | 1 - go.sum | 4 +- mapper/address.go | 14 ++ mapper/address_test.go | 19 +++ mapper/amount.go | 27 +++ mapper/helper.go | 13 ++ mapper/transaction.go | 182 +++++++++++++++++++- mapper/types.go | 42 +++-- mocks/client/client.go | 65 +++++++ rosetta-cli-conf/testnet/config.json | 1 - service/config.go | 34 +++- service/service_account.go | 58 ++++++- service/service_block.go | 12 +- service/types.go | 9 +- 24 files changed, 950 insertions(+), 46 deletions(-) create mode 100644 client/contract.go create mode 100644 client/contractInfoToken.go create mode 100644 client/evm_logs.go create mode 100644 contractInfo.abi create mode 100644 mapper/address.go create mode 100644 mapper/address_test.go create mode 100644 mapper/helper.go diff --git a/.gitignore b/.gitignore index 64d7410a..e3f30673 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ rosetta-data .avalanchego *.swp data +.vscode* +__debug_bin* \ No newline at end of file diff --git a/README.md b/README.md index 6e0c43d0..dc3cb594 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ Before you start running the server you need to create a configuration file: { "rpc_endpoint": "https://api.avax-test.network", "mode": "online", - "listen_addr": "0.0.0.0:8080" + "listen_addr": "0.0.0.0:8080", + "genesis_block_hash" :"0x31ced5b9beb7f8782b014660da0cb18cc409f121f408186886e1ca3e8eeca96b", } ``` @@ -55,7 +56,11 @@ Full configuration example: "listen_addr": "0.0.0.0:8080", "network_name": "Fuji", "chain_id": 43113, - "log_requests": true + "log_requests": true, + "genesis_block_hash" :"0x31ced5b9beb7f8782b014660da0cb18cc409f121f408186886e1ca3e8eeca96b", + "index_unknown_tokens": false, + "ingestion_mode" : "standard", + "token_whitelist" : [] } ``` @@ -66,9 +71,14 @@ Where: | mode | string | `online` | Mode of operations. One of: `online`, `offline` | rpc_endpoint | string | `http://localhost:9650` | Avalanche RPC endpoint | listen_addr | string | `http://localhost:8080` | Rosetta server listen address (host/port) -| network_name | string | - | Avalanche network name -| chain_id | integer | - | Avalanche C-Chain ID -| log_requests | bool | `false` | Enable request body logging +| network_name | string | - | Avalanche network name +| chain_id | integer | - | Avalanche C-Chain ID +| genesis_block_hash | string | - | The block hash for the genesis block +| index_unknown_tokens | bool | `false` | Enables ingesting tokens that don't have a public symbol or decimal variable= +| ingestion_mode | string | `standard`| Toggles between standard and analytics ingesting modes +| token_whitelist |[]string | [] | Enables ingesting for the provided ERC20 contract addresses in standard mode. + +The token whitelist only supports tokens that emit evm transfer logs for all minting (from should be 0x000---), burning (to address should be 0x0000) and transfer events are supported. All other tokens will break cause ingestion to fail. ### RPC Endpoints @@ -136,6 +146,12 @@ Run the construction check: make check-testnet-construction ``` +## Rebuild the ContractInfoToken.go autogen file. + +```bash +abigen --abi contractInfo.abi --pkg main --type ContractInfoToken --out client/contractInfoToken.go +``` + ## License BSD 3-Clause diff --git a/client/client.go b/client/client.go index 2b0ce548..064363df 100644 --- a/client/client.go +++ b/client/client.go @@ -4,6 +4,7 @@ import ( "context" "math/big" + "github.com/ava-labs/coreth/core/types" ethtypes "github.com/ava-labs/coreth/core/types" "github.com/ava-labs/coreth/interfaces" ethcommon "github.com/ethereum/go-ethereum/common" @@ -24,6 +25,7 @@ type Client interface { TransactionByHash(context.Context, ethcommon.Hash) (*ethtypes.Transaction, bool, error) TransactionReceipt(context.Context, ethcommon.Hash) (*ethtypes.Receipt, error) TraceTransaction(context.Context, string) (*Call, []*FlatCall, error) + EvmTransferLogs(ctx context.Context, blockHash ethcommon.Hash, transactionHash ethcommon.Hash) ([]types.Log, error) SendTransaction(context.Context, *ethtypes.Transaction) error BalanceAt(context.Context, ethcommon.Address, *big.Int) (*big.Int, error) NonceAt(context.Context, ethcommon.Address, *big.Int) (uint64, error) @@ -34,11 +36,15 @@ type Client interface { NetworkName(context.Context) (string, error) Peers(context.Context) ([]Peer, error) NodeVersion(context.Context) (string, error) + ContractInfo(contractAddress ethcommon.Address, isErc20 bool) (*ContractInfo, error) + CallContract(ctx context.Context, msg interfaces.CallMsg, blockNumber *big.Int) ([]byte, error) } type client struct { *EthClient *InfoClient + *EvmLogsClient + *ContractClient } // NewClient returns a new client for Avalanche APIs @@ -53,8 +59,20 @@ func NewClient(endpoint string) (Client, error) { return nil, err } + evmlogs, err := NewEvmLogsClient(endpoint) + if err != nil { + return nil, err + } + + contract, err := NewContractClient(endpoint) + if err != nil { + return nil, err + } + return client{ - EthClient: eth, - InfoClient: info, + EthClient: eth, + InfoClient: info, + EvmLogsClient: evmlogs, + ContractClient: contract, }, nil } diff --git a/client/contract.go b/client/contract.go new file mode 100644 index 00000000..c8b038ab --- /dev/null +++ b/client/contract.go @@ -0,0 +1,70 @@ +package client + +import ( + "fmt" + "strings" + + "github.com/ava-labs/avalanchego/cache" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +const ( + contractCacheSize = 1024 +) + +// ContractClient is a client for the calling contract information +type ContractClient struct { + ethClient *ethclient.Client + cache *cache.LRU +} + +// NewContractClient returns a new ContractInfo client +func NewContractClient(endpoint string) (*ContractClient, error) { + endpoint = strings.TrimSuffix(endpoint, "/") + + c, err := ethclient.Dial(fmt.Sprintf("%s%s", endpoint, prefixEth)) + if err != nil { + return nil, err + } + + cache := &cache.LRU{Size: contractCacheSize} + + return &ContractClient{ + ethClient: c, + cache: cache, + }, nil +} + +// ContractInfo returns the ContractInfo for a specific address +func (c *ContractClient) ContractInfo(contractAddress common.Address, isErc20 bool) (*ContractInfo, error) { + cachedInfo, isCached := c.cache.Get(contractAddress) + + if isCached { + castCachedInfo := cachedInfo.(*ContractInfo) + return castCachedInfo, nil + } + + token, err := NewContractInfoToken(contractAddress, c.ethClient) + if err != nil { + return nil, err + } + symbol, symbolErr := token.Symbol(nil) + decimals, decimalErr := token.Decimals(nil) + + // Any of these indicate a failure to get complete information from contract + if symbolErr != nil || decimalErr != nil || symbol == "" || decimals == 0 { + if isErc20 { + symbol = UnknownERC20Symbol + decimals = UnknownERC20Decimals + } else { + symbol = UnknownERC721Symbol + decimals = UnknownERC721Decimals + } + } + contractInfo := &ContractInfo{Symbol: symbol, Decimals: decimals} + + // Cache defaults for contract address to avoid unnecessary lookups + c.cache.Put(contractAddress, contractInfo) + return contractInfo, nil +} diff --git a/client/contractInfoToken.go b/client/contractInfoToken.go new file mode 100644 index 00000000..9bb34f91 --- /dev/null +++ b/client/contractInfoToken.go @@ -0,0 +1,242 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package client + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription +) + +// ContractInfoTokenMetaData contains all meta data concerning the ContractInfoToken contract. +var ContractInfoTokenMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"stateMutability\":\"view\",\"outputs\":[{\"type\":\"uint8\",\"name\":\"\",\"internalType\":\"uint8\"}],\"name\":\"decimals\",\"inputs\":[]},{\"type\":\"function\",\"stateMutability\":\"view\",\"outputs\":[{\"type\":\"string\",\"name\":\"\",\"internalType\":\"string\"}],\"name\":\"symbol\",\"inputs\":[]}]", +} + +// ContractInfoTokenABI is the input ABI used to generate the binding from. +// Deprecated: Use ContractInfoTokenMetaData.ABI instead. +var ContractInfoTokenABI = ContractInfoTokenMetaData.ABI + +// ContractInfoToken is an auto generated Go binding around an Ethereum contract. +type ContractInfoToken struct { + ContractInfoTokenCaller // Read-only binding to the contract + ContractInfoTokenTransactor // Write-only binding to the contract + ContractInfoTokenFilterer // Log filterer for contract events +} + +// ContractInfoTokenCaller is an auto generated read-only Go binding around an Ethereum contract. +type ContractInfoTokenCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ContractInfoTokenTransactor is an auto generated write-only Go binding around an Ethereum contract. +type ContractInfoTokenTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ContractInfoTokenFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type ContractInfoTokenFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// ContractInfoTokenSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type ContractInfoTokenSession struct { + Contract *ContractInfoToken // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ContractInfoTokenCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type ContractInfoTokenCallerSession struct { + Contract *ContractInfoTokenCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// ContractInfoTokenTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type ContractInfoTokenTransactorSession struct { + Contract *ContractInfoTokenTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// ContractInfoTokenRaw is an auto generated low-level Go binding around an Ethereum contract. +type ContractInfoTokenRaw struct { + Contract *ContractInfoToken // Generic contract binding to access the raw methods on +} + +// ContractInfoTokenCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type ContractInfoTokenCallerRaw struct { + Contract *ContractInfoTokenCaller // Generic read-only contract binding to access the raw methods on +} + +// ContractInfoTokenTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type ContractInfoTokenTransactorRaw struct { + Contract *ContractInfoTokenTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewContractInfoToken creates a new instance of ContractInfoToken, bound to a specific deployed contract. +func NewContractInfoToken(address common.Address, backend bind.ContractBackend) (*ContractInfoToken, error) { + contract, err := bindContractInfoToken(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &ContractInfoToken{ContractInfoTokenCaller: ContractInfoTokenCaller{contract: contract}, ContractInfoTokenTransactor: ContractInfoTokenTransactor{contract: contract}, ContractInfoTokenFilterer: ContractInfoTokenFilterer{contract: contract}}, nil +} + +// NewContractInfoTokenCaller creates a new read-only instance of ContractInfoToken, bound to a specific deployed contract. +func NewContractInfoTokenCaller(address common.Address, caller bind.ContractCaller) (*ContractInfoTokenCaller, error) { + contract, err := bindContractInfoToken(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &ContractInfoTokenCaller{contract: contract}, nil +} + +// NewContractInfoTokenTransactor creates a new write-only instance of ContractInfoToken, bound to a specific deployed contract. +func NewContractInfoTokenTransactor(address common.Address, transactor bind.ContractTransactor) (*ContractInfoTokenTransactor, error) { + contract, err := bindContractInfoToken(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &ContractInfoTokenTransactor{contract: contract}, nil +} + +// NewContractInfoTokenFilterer creates a new log filterer instance of ContractInfoToken, bound to a specific deployed contract. +func NewContractInfoTokenFilterer(address common.Address, filterer bind.ContractFilterer) (*ContractInfoTokenFilterer, error) { + contract, err := bindContractInfoToken(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &ContractInfoTokenFilterer{contract: contract}, nil +} + +// bindContractInfoToken binds a generic wrapper to an already deployed contract. +func bindContractInfoToken(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := abi.JSON(strings.NewReader(ContractInfoTokenABI)) + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ContractInfoToken *ContractInfoTokenRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ContractInfoToken.Contract.ContractInfoTokenCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ContractInfoToken *ContractInfoTokenRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ContractInfoToken.Contract.ContractInfoTokenTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ContractInfoToken *ContractInfoTokenRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ContractInfoToken.Contract.ContractInfoTokenTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_ContractInfoToken *ContractInfoTokenCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _ContractInfoToken.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_ContractInfoToken *ContractInfoTokenTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _ContractInfoToken.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_ContractInfoToken *ContractInfoTokenTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _ContractInfoToken.Contract.contract.Transact(opts, method, params...) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_ContractInfoToken *ContractInfoTokenCaller) Decimals(opts *bind.CallOpts) (uint8, error) { + var out []interface{} + err := _ContractInfoToken.contract.Call(opts, &out, "decimals") + + if err != nil { + return *new(uint8), err + } + + out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) + + return out0, err + +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_ContractInfoToken *ContractInfoTokenSession) Decimals() (uint8, error) { + return _ContractInfoToken.Contract.Decimals(&_ContractInfoToken.CallOpts) +} + +// Decimals is a free data retrieval call binding the contract method 0x313ce567. +// +// Solidity: function decimals() view returns(uint8) +func (_ContractInfoToken *ContractInfoTokenCallerSession) Decimals() (uint8, error) { + return _ContractInfoToken.Contract.Decimals(&_ContractInfoToken.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_ContractInfoToken *ContractInfoTokenCaller) Symbol(opts *bind.CallOpts) (string, error) { + var out []interface{} + err := _ContractInfoToken.contract.Call(opts, &out, "symbol") + + if err != nil { + return *new(string), err + } + + out0 := *abi.ConvertType(out[0], new(string)).(*string) + + return out0, err + +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_ContractInfoToken *ContractInfoTokenSession) Symbol() (string, error) { + return _ContractInfoToken.Contract.Symbol(&_ContractInfoToken.CallOpts) +} + +// Symbol is a free data retrieval call binding the contract method 0x95d89b41. +// +// Solidity: function symbol() view returns(string) +func (_ContractInfoToken *ContractInfoTokenCallerSession) Symbol() (string, error) { + return _ContractInfoToken.Contract.Symbol(&_ContractInfoToken.CallOpts) +} diff --git a/client/evm_logs.go b/client/evm_logs.go new file mode 100644 index 00000000..48332245 --- /dev/null +++ b/client/evm_logs.go @@ -0,0 +1,72 @@ +package client + +import ( + "context" + "fmt" + "strings" + + "github.com/ava-labs/avalanchego/cache" + "github.com/ava-labs/coreth/core/types" + "github.com/ava-labs/coreth/ethclient" + "github.com/ava-labs/coreth/interfaces" + "github.com/ethereum/go-ethereum/common" +) + +const ( + logCacheSize = 100 + transferMethodHash = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" +) + +// EvmLogsClient is a client for requesting evm logs +type EvmLogsClient struct { + ethClient *ethclient.Client + cache *cache.LRU +} + +// NewEvmLogsClient returns a new EVM Logs client +func NewEvmLogsClient(endpoint string) (*EvmLogsClient, error) { + endpoint = strings.TrimSuffix(endpoint, "/") + + c, err := ethclient.Dial(fmt.Sprintf("%s%s", endpoint, prefixEth)) + if err != nil { + return nil, err + } + + cache := &cache.LRU{Size: logCacheSize} + + return &EvmLogsClient{ + ethClient: c, + cache: cache, + }, nil +} + +// EvmTransferLogs returns a set of evm logs based on the requested block hash and transaction hash +func (c *EvmLogsClient) EvmTransferLogs( + ctx context.Context, + blockHash common.Hash, + transactionHash common.Hash, +) ([]types.Log, error) { + blockLogs, isCached := c.cache.Get(blockHash.String()) + if !isCached { + var err error + var topics [][]common.Hash = [][]common.Hash{{common.HexToHash(transferMethodHash)}} + + var filter interfaces.FilterQuery = interfaces.FilterQuery{BlockHash: &blockHash, Topics: topics} + blockLogs, err = c.ethClient.FilterLogs(ctx, filter) + + if err != nil { + return nil, err + } + c.cache.Put(blockHash.String(), blockLogs) + } + + var filteredLogs []types.Log + + for _, log := range blockLogs.([]types.Log) { + if log.TxHash == transactionHash { + filteredLogs = append(filteredLogs, log) + } + } + + return filteredLogs, nil +} diff --git a/client/types.go b/client/types.go index 288c2bb2..8fc956a1 100644 --- a/client/types.go +++ b/client/types.go @@ -7,10 +7,23 @@ import ( "github.com/ethereum/go-ethereum/common/hexutil" ) +const ( + UnknownERC20Symbol = "ERC20" + UnknownERC20Decimals = 0 + + UnknownERC721Symbol = "ERC721" + UnknownERC721Decimals = 0 +) + type infoPeersResponse struct { Peers []Peer `json:"peers"` } +type ContractInfo struct { + Symbol string `json:"symbol"` + Decimals uint8 `json:"decimals"` +} + type Peer struct { ID string `json:"nodeID"` IP string `json:"ip"` diff --git a/cmd/server/config.go b/cmd/server/config.go index c8712f62..f6abcf2b 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -6,12 +6,15 @@ import ( "os" "github.com/ava-labs/avalanche-rosetta/service" + ethcommon "github.com/ethereum/go-ethereum/common" ) var ( errMissingRPC = errors.New("avalanche rpc endpoint is not provided") errInvalidMode = errors.New("invalid rosetta mode") errGenesisBlockRequired = errors.New("genesis block hash is not provided") + errInvalidTokenAddress = errors.New("invalid token address provided") + errInvalidIngestionMode = errors.New("invalid rosetta ingestion mode") ) type config struct { @@ -22,6 +25,10 @@ type config struct { ChainID int64 `json:"chain_id"` LogRequests bool `json:"log_requests"` GenesisBlockHash string `json:"genesis_block_hash"` + + IngestionMode string `json:"ingestion_mode"` + TokenWhiteList []string `json:"token_whitelist"` + IndexUnknownTokens bool `json:"index_unknown_tokens"` } func readConfig(path string) (*config, error) { @@ -39,7 +46,11 @@ func readConfig(path string) (*config, error) { func (c *config) ApplyDefaults() { if c.Mode == "" { - c.Mode = "online" + c.Mode = service.ModeOnline + } + + if c.IngestionMode == "" { + c.IngestionMode = service.StandardIngestion } if c.RPCEndpoint == "" { @@ -66,5 +77,17 @@ func (c *config) Validate() error { return errGenesisBlockRequired } + if len(c.TokenWhiteList) != 0 { + for _, token := range c.TokenWhiteList { + if !ethcommon.IsHexAddress(token) { + return errInvalidTokenAddress + } + } + } + + if !(c.IngestionMode == service.AnalyticsIngestion || c.IngestionMode == service.StandardIngestion) { + return errInvalidIngestionMode + } + return nil } diff --git a/cmd/server/main.go b/cmd/server/main.go index 808071ca..3bcc0102 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -117,12 +117,15 @@ func main() { } serviceConfig := &service.Config{ - Mode: cfg.Mode, - ChainID: big.NewInt(cfg.ChainID), - NetworkID: network, - GenesisBlockHash: cfg.GenesisBlockHash, - AvaxAssetID: assetID, - AP5Activation: AP5Activation, + Mode: cfg.Mode, + ChainID: big.NewInt(cfg.ChainID), + NetworkID: network, + GenesisBlockHash: cfg.GenesisBlockHash, + AvaxAssetID: assetID, + AP5Activation: AP5Activation, + IndexUnknownTokens: cfg.IndexUnknownTokens, + IngestionMode: cfg.IngestionMode, + TokenWhiteList: cfg.TokenWhiteList, } handler := configureRouter(serviceConfig, asserter, apiClient) diff --git a/contractInfo.abi b/contractInfo.abi new file mode 100644 index 00000000..b15fc208 --- /dev/null +++ b/contractInfo.abi @@ -0,0 +1,28 @@ +[ + { + "type": "function", + "stateMutability": "view", + "outputs": [ + { + "type": "uint8", + "name": "", + "internalType": "uint8" + } + ], + "name": "decimals", + "inputs": [] + }, + { + "type": "function", + "stateMutability": "view", + "outputs": [ + { + "type": "string", + "name": "", + "internalType": "string" + } + ], + "name": "symbol", + "inputs": [] + } +] \ No newline at end of file diff --git a/go.mod b/go.mod index 92e38e76..899f800d 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/prometheus/client_golang v1.8.0 // indirect github.com/prometheus/common v0.15.0 // indirect github.com/stretchr/testify v1.7.0 - github.com/tyler-smith/go-bip39 v1.1.0 // indirect go.opencensus.io v0.22.5 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c ) diff --git a/go.sum b/go.sum index f7d6f810..5bb86767 100644 --- a/go.sum +++ b/go.sum @@ -630,6 +630,7 @@ github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/cors v0.0.0-20160617231935-a62a804a8a00/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/xhandler v0.0.0-20160618193221-ed27b6fd6521/go.mod h1:RvLn4FgxWubrpZHtQLnOf6EwhN2hEMusxZOhcW9H3UQ= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= @@ -705,9 +706,8 @@ github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZF github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= +github.com/tyler-smith/go-bip39 v1.0.2 h1:+t3w+KwLXO6154GNJY+qUtIxLTmFjfUmpguQT1OlOT8= github.com/tyler-smith/go-bip39 v1.0.2/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= -github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= -github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= diff --git a/mapper/address.go b/mapper/address.go new file mode 100644 index 00000000..214e69e0 --- /dev/null +++ b/mapper/address.go @@ -0,0 +1,14 @@ +package mapper + +import ( + ethcommon "github.com/ethereum/go-ethereum/common" +) + +// ConvertEVMTopicHashToAddress uses the last 20 bytes of a common.Hash to create a common.Address +func ConvertEVMTopicHashToAddress(hash *ethcommon.Hash) *ethcommon.Address { + if hash == nil { + return nil + } + address := ethcommon.BytesToAddress(hash[12:32]) + return &address +} diff --git a/mapper/address_test.go b/mapper/address_test.go new file mode 100644 index 00000000..9e251036 --- /dev/null +++ b/mapper/address_test.go @@ -0,0 +1,19 @@ +package mapper + +import ( + "strings" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" +) + +func TestAdress(t *testing.T) { + t.Run("ConvertEVMTopicHashToAddress", func(t *testing.T) { + addressString := "0x54761841b2005ee456ba5a5a46ee78dded90b16d" + hash := ethcommon.HexToHash(addressString) + convertedAddress := ConvertEVMTopicHashToAddress(&hash) + + assert.Equal(t, strings.ToLower(addressString), strings.ToLower(convertedAddress.String())) + }) +} diff --git a/mapper/amount.go b/mapper/amount.go index 92a5f253..34fc0ed8 100644 --- a/mapper/amount.go +++ b/mapper/amount.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/coinbase/rosetta-sdk-go/types" + "github.com/ethereum/go-ethereum/common" ) func Amount(value *big.Int, currency *types.Currency) *types.Amount { @@ -27,3 +28,29 @@ func FeeAmount(value int64) *types.Amount { func AvaxAmount(value *big.Int) *types.Amount { return Amount(value, AvaxCurrency) } + +func Erc20Amount( + data []byte, + contractAddress common.Address, + contractSymbol string, + contractDecimal uint8, + isSender bool) *types.Amount { + value := common.BytesToHash(data) + decimalValue := value.Big() + + if isSender { + decimalValue = new(big.Int).Neg(decimalValue) + } + metadata := make(map[string]interface{}) + metadata[TokenTypeMetadata] = "ERC20" + metadata[ContractAddressMetadata] = contractAddress.String() + + return &types.Amount{ + Value: decimalValue.String(), + Currency: &types.Currency{ + Symbol: contractSymbol, + Decimals: int32(contractDecimal), + Metadata: metadata, + }, + } +} diff --git a/mapper/helper.go b/mapper/helper.go new file mode 100644 index 00000000..ef8a2e7c --- /dev/null +++ b/mapper/helper.go @@ -0,0 +1,13 @@ +package mapper + +import "strings" + +// EqualFoldContains checks if the array contains the string regardless of casing +func EqualFoldContains(arr []string, str string) bool { + for _, a := range arr { + if strings.EqualFold(a, str) { + return true + } + } + return false +} diff --git a/mapper/transaction.go b/mapper/transaction.go index 110b3ddb..0c23ecc3 100644 --- a/mapper/transaction.go +++ b/mapper/transaction.go @@ -11,6 +11,13 @@ import ( "github.com/coinbase/rosetta-sdk-go/types" "github.com/ava-labs/avalanche-rosetta/client" + clientTypes "github.com/ava-labs/avalanche-rosetta/client" +) + +const ( + topicsInErc721Transfer = 4 + topicsInErc20Transfer = 3 + zeroAddress = "0x0000000000000000000000000000000000000000000000000000000000000000" ) var ( @@ -24,6 +31,11 @@ func Transaction( receipt *ethtypes.Receipt, trace *client.Call, flattenedTrace []*client.FlatCall, + transferLogs []ethtypes.Log, + client client.Client, + isAnalyticsMode bool, + standardModeWhiteList []string, + includeUnknownTokens bool, ) (*types.Transaction, error) { ops := []*types.Operation{} sender := msg.From() @@ -62,7 +74,43 @@ func Transaction( traceOps := traceOps(flattenedTrace, len(feeOps)) ops = append(ops, traceOps...) + // Logs will be empty if in standard mode and token whitelist is empty + for _, transferLog := range transferLogs { + // If in standard mode, token address must be whitelisted + if !isAnalyticsMode && !EqualFoldContains(standardModeWhiteList, transferLog.Address.String()) { + continue + } + + // ERC721 index the value in the transfer event. ERC20's do not + if len(transferLog.Topics) == topicsInErc721Transfer { + contractInfo, err := client.ContractInfo(transferLog.Address, false) + if err != nil { + return nil, err + } + + // Don't include default tokens if setting is not enabled + if !includeUnknownTokens && contractInfo.Symbol == clientTypes.UnknownERC721Symbol { + continue + } + erc721txs := parseErc721Txs(transferLog, int64(len(ops))) + ops = append(ops, erc721txs...) + } else { + contractInfo, err := client.ContractInfo(transferLog.Address, true) + if err != nil { + return nil, err + } + + // Don't include default tokens if setting is not enabled + if (!includeUnknownTokens && contractInfo.Symbol == clientTypes.UnknownERC20Symbol) || + (len(transferLog.Topics) != topicsInErc20Transfer) { + continue + } + + erc20txs := parseErc20Txs(transferLog, contractInfo, int64(len(ops))) + ops = append(ops, erc20txs...) + } + } return &types.Transaction{ TransactionIdentifier: &types.TransactionIdentifier{ Hash: tx.Hash().String(), @@ -171,7 +219,7 @@ func crossChainTransaction( idx++ } default: - return nil, fmt.Errorf("Unsupported transaction: %T", t) + return nil, fmt.Errorf("unsupported transaction: %T", t) } return ops, nil } @@ -390,3 +438,135 @@ func traceOps(trace []*client.FlatCall, startIndex int) []*types.Operation { return ops } + +func parseErc20Txs(transferLog ethtypes.Log, contractInfo *clientTypes.ContractInfo, opsLen int64) []*types.Operation { + ops := []*types.Operation{} + + contractAddress := transferLog.Address + addressFrom := transferLog.Topics[1] + addressTo := transferLog.Topics[2] + + if addressFrom.Hex() == zeroAddress { + mintOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen, + }, + Status: types.String(StatusSuccess), + Type: OpErc20Mint, + Amount: Erc20Amount(transferLog.Data, contractAddress, contractInfo.Symbol, contractInfo.Decimals, false), + Account: Account(ConvertEVMTopicHashToAddress(&addressTo)), + } + ops = append(ops, &mintOp) + return ops + } + + if addressTo.Hex() == zeroAddress { + burnOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen, + }, + Status: types.String(StatusSuccess), + Type: OpErc20Burn, + Amount: Erc20Amount(transferLog.Data, contractAddress, contractInfo.Symbol, contractInfo.Decimals, true), + Account: Account(ConvertEVMTopicHashToAddress(&addressFrom)), + } + ops = append(ops, &burnOp) + return ops + } + + sendingOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen, + }, + Status: types.String(StatusSuccess), + Type: OpErc20Transfer, + Amount: Erc20Amount(transferLog.Data, contractAddress, contractInfo.Symbol, contractInfo.Decimals, true), + Account: Account(ConvertEVMTopicHashToAddress(&addressFrom)), + } + receiptOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen + 1, + }, + Status: types.String(StatusSuccess), + Type: OpErc20Transfer, + Amount: Erc20Amount(transferLog.Data, contractAddress, contractInfo.Symbol, contractInfo.Decimals, false), + Account: Account(ConvertEVMTopicHashToAddress(&addressTo)), + RelatedOperations: []*types.OperationIdentifier{ + { + Index: opsLen, + }, + }, + } + ops = append(ops, &sendingOp) + ops = append(ops, &receiptOp) + + return ops +} + +func parseErc721Txs(transferLog ethtypes.Log, opsLen int64) []*types.Operation { + ops := []*types.Operation{} + + contractAddress := transferLog.Address + addressFrom := transferLog.Topics[1] + addressTo := transferLog.Topics[2] + erc721Index := transferLog.Topics[3] // Erc721 4th topic is the index. Data is empty + metadata := make(map[string]interface{}) + metadata[TokenTypeMetadata] = "ERC721" + metadata[ContractAddressMetadata] = contractAddress.String() + metadata[IndexTransferedMetadata] = erc721Index.String() + + if addressFrom.Hex() == zeroAddress { + mintOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen, + }, + Status: types.String(StatusSuccess), + Type: OpErc721Mint, + Account: Account(ConvertEVMTopicHashToAddress(&addressTo)), + Metadata: metadata, + } + ops = append(ops, &mintOp) + return ops + } + + if addressTo.Hex() == zeroAddress { + burnOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen, + }, + Status: types.String(StatusSuccess), + Type: OpErc721Burn, + Account: Account(ConvertEVMTopicHashToAddress(&addressFrom)), + Metadata: metadata, + } + ops = append(ops, &burnOp) + return ops + } + + sendingOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen, + }, + Status: types.String(StatusSuccess), + Type: OpErc721TransferSender, + Account: Account(ConvertEVMTopicHashToAddress(&addressFrom)), + Metadata: metadata, + } + receiptOp := types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: opsLen + 1, + }, + Status: types.String(StatusSuccess), + Type: OpErc721TransferReceive, + Account: Account(ConvertEVMTopicHashToAddress(&addressTo)), + Metadata: metadata, + RelatedOperations: []*types.OperationIdentifier{ + { + Index: opsLen, + }, + }, + } + ops = append(ops, &sendingOp) + ops = append(ops, &receiptOp) + return ops +} diff --git a/mapper/types.go b/mapper/types.go index 98859068..357f4544 100644 --- a/mapper/types.go +++ b/mapper/types.go @@ -13,17 +13,30 @@ const ( FujiChainID = 43113 FujiAssetID = "U8iRqJoiJm8xZHAacmvYyZVwqQx6uDNtQeP3CQ6fcgQk3JqnK" - OpCall = "CALL" - OpFee = "FEE" - OpCreate = "CREATE" - OpCreate2 = "CREATE2" - OpSelfDestruct = "SELFDESTRUCT" - OpCallCode = "CALLCODE" - OpDelegateCall = "DELEGATECALL" - OpStaticCall = "STATICCALL" - OpDestruct = "DESTRUCT" - OpImport = "IMPORT" - OpExport = "EXPORT" + TokenTypeMetadata = "tokenType" + ContractAddressMetadata = "contractAddress" + IndexTransferedMetadata = "indexTransfered" + TokenSymbol = "tokenSymbol" + + OpCall = "CALL" + OpFee = "FEE" + OpCreate = "CREATE" + OpCreate2 = "CREATE2" + OpSelfDestruct = "SELFDESTRUCT" + OpCallCode = "CALLCODE" + OpDelegateCall = "DELEGATECALL" + OpStaticCall = "STATICCALL" + OpDestruct = "DESTRUCT" + OpImport = "IMPORT" + OpExport = "EXPORT" + OpErc20Transfer = "ERC20_TRANSFER" + OpErc20Mint = "ERC20_MINT" + OpErc20Burn = "ERC20_BURN" + + OpErc721TransferSender = "ERC721_SENDER" + OpErc721TransferReceive = "ERC721_RECEIVE" + OpErc721Mint = "ERC721_MINT" + OpErc721Burn = "ERC721_BURN" StatusSuccess = "SUCCESS" StatusFailure = "FAILURE" @@ -71,6 +84,13 @@ var ( OpDestruct, OpImport, OpExport, + OpErc20Burn, + OpErc20Mint, + OpErc20Transfer, + OpErc721TransferReceive, + OpErc721TransferSender, + OpErc721Mint, + OpErc721Burn, } CallMethods = []string{ diff --git a/mocks/client/client.go b/mocks/client/client.go index 75388a39..81ac7ff9 100644 --- a/mocks/client/client.go +++ b/mocks/client/client.go @@ -455,3 +455,68 @@ func (_m *Client) TxPoolStatus(_a0 context.Context) (*client.TxPoolStatus, error return r0, r1 } +func (_m *Client) ContractInfo(_a0 common.Address, _a1 bool) (*client.ContractInfo, error) { + ret := _m.Called(_a0, _a1) + + var r0 *client.ContractInfo + if rf, ok := ret.Get(0).(func(common.Address, bool) *client.ContractInfo); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.ContractInfo) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(common.Address, bool) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +func (_m *Client) EvmTransferLogs(_a0 context.Context, _a1 common.Hash, _a2 common.Hash) ([]types.Log, error) { + ret := _m.Called(_a0, _a1, _a2) + + var r0 []types.Log + if rf, ok := ret.Get(0).(func(context.Context, common.Hash, common.Hash) []types.Log); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]types.Log) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, common.Hash, common.Hash) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +func (_m *Client) CallContract(_a0 context.Context, _a1 interfaces.CallMsg, _a2 *big.Int) ([]byte, error) { + ret := _m.Called(_a0, _a1, _a2) + + var r0 []byte + if rf, ok := ret.Get(0).(func(context.Context, interfaces.CallMsg, *big.Int) []byte); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, interfaces.CallMsg, *big.Int) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/rosetta-cli-conf/testnet/config.json b/rosetta-cli-conf/testnet/config.json index 4c11d2ec..e8d5ad07 100644 --- a/rosetta-cli-conf/testnet/config.json +++ b/rosetta-cli-conf/testnet/config.json @@ -40,7 +40,6 @@ "bootstrap_balances": "", "interesting_accounts": "", "reconciliation_disabled": false, - "inactive_discrepency_search_disabled": false, "balance_tracking_disabled": false, "historical_balance_enabled": true, "coin_tracking_disabled": false, diff --git a/service/config.go b/service/config.go index 95362cab..00ed0e0a 100644 --- a/service/config.go +++ b/service/config.go @@ -9,19 +9,24 @@ import ( // Config holds the service configuration type Config struct { - Mode string - ChainID *big.Int - NetworkID *types.NetworkIdentifier - GenesisBlockHash string - AvaxAssetID string + Mode string + ChainID *big.Int + NetworkID *types.NetworkIdentifier + GenesisBlockHash string + AvaxAssetID string + IngestionMode string + TokenWhiteList []string + IndexUnknownTokens bool // Upgrade Times AP5Activation uint64 } const ( - ModeOffline = "offline" - ModeOnline = "online" + ModeOffline = "offline" + ModeOnline = "online" + StandardIngestion = "standard" + AnalyticsIngestion = "analytics" ) // IsOfflineMode returns true if running in offline mode @@ -34,6 +39,21 @@ func (c Config) IsOnlineMode() bool { return c.Mode == ModeOnline } +// IsAnalyticsMode returns true if running in analytics ingestion mode +func (c Config) IsAnalyticsMode() bool { + return c.IngestionMode == AnalyticsIngestion +} + +// IsStandardMode returns true if running in standard ingestion mode +func (c Config) IsStandardMode() bool { + return c.IngestionMode == StandardIngestion +} + +// IsTokenListEmpty returns true if the token addresses list is empty +func (c Config) IsTokenListEmpty() bool { + return len(c.TokenWhiteList) == 0 +} + // Signer returns an eth signer object for a given chain func (c Config) Signer() ethtypes.Signer { return ethtypes.LatestSignerForChainID(c.ChainID) diff --git a/service/service_account.go b/service/service_account.go index 0be83c27..83dd3c91 100644 --- a/service/service_account.go +++ b/service/service_account.go @@ -2,14 +2,17 @@ package service import ( "context" + "fmt" "github.com/coinbase/rosetta-sdk-go/server" "github.com/coinbase/rosetta-sdk-go/types" ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ava-labs/avalanche-rosetta/client" "github.com/ava-labs/avalanche-rosetta/mapper" + "github.com/ava-labs/coreth/interfaces" ) // AccountService implements the /account/* endpoints @@ -45,10 +48,6 @@ func (s AccountService) AccountBalance( } address := ethcommon.HexToAddress(req.AccountIdentifier.Address) - balance, balanceErr := s.client.BalanceAt(context.Background(), address, header.Number) - if err != nil { - return nil, wrapError(errInternalError, balanceErr) - } nonce, nonceErr := s.client.NonceAt(ctx, address, header.Number) if nonceErr != nil { @@ -64,14 +63,59 @@ func (s AccountService) AccountBalance( return nil, wrapError(errInternalError, metadataErr) } + avaxBalance, balanceErr := s.client.BalanceAt(context.Background(), address, header.Number) + if balanceErr != nil { + return nil, wrapError(errInternalError, balanceErr) + } + + var balances []*types.Amount + if len(req.Currencies) == 0 { + balances = append(balances, mapper.AvaxAmount(avaxBalance)) + } + + for _, currency := range req.Currencies { + value, ok := currency.Metadata[mapper.ContractAddressMetadata] + if !ok { + if types.Hash(currency) == types.Hash(mapper.AvaxCurrency) { + balances = append(balances, mapper.AvaxAmount(avaxBalance)) + continue + } + return nil, wrapError(errCallInvalidParams, + fmt.Errorf("currencies outside of avax must have contractAddress in metadata field")) + } + + if s.config.IsStandardMode() && !mapper.EqualFoldContains(s.config.TokenWhiteList, value.(string)) { + return nil, wrapError(errCallInvalidParams, fmt.Errorf("only addresses contained in token whitelist are supported")) + } + + identifierAddress := req.AccountIdentifier.Address + if has0xPrefix(identifierAddress) { + identifierAddress = identifierAddress[2:42] + } + + data, err := hexutil.Decode(BalanceOfMethodPrefix + identifierAddress) + if err != nil { + return nil, wrapError(errCallInvalidParams, fmt.Errorf("failed to decode contractAddress in metadata field")) + } + + contractAddress := ethcommon.HexToAddress(value.(string)) + callMsg := interfaces.CallMsg{To: &contractAddress, Data: data} + response, err := s.client.CallContract(ctx, callMsg, header.Number) + if err != nil { + return nil, wrapError(errInternalError, err) + } + + amount := mapper.Erc20Amount(response, contractAddress, currency.Symbol, uint8(currency.Decimals), false) + + balances = append(balances, amount) + } + resp := &types.AccountBalanceResponse{ BlockIdentifier: &types.BlockIdentifier{ Index: header.Number.Int64(), Hash: header.Hash().String(), }, - Balances: []*types.Amount{ - mapper.AvaxAmount(balance), - }, + Balances: balances, Metadata: metadataMap, } diff --git a/service/service_block.go b/service/service_block.go index 3fe3bd14..ac1ba456 100644 --- a/service/service_block.go +++ b/service/service_block.go @@ -186,8 +186,18 @@ func (s *BlockService) fetchTransaction( if err != nil { return nil, wrapError(errClientError, err) } + var transactionEvmLogs []corethTypes.Log + // Only pull EVM logs if we're in analytics mode or we have tokens care about in standard mode + if s.config.IsAnalyticsMode() || !s.config.IsTokenListEmpty() { + transactionEvmLogs, err = s.client.EvmTransferLogs(ctx, header.Hash(), tx.Hash()) + if err != nil { + return nil, wrapError(errClientError, err) + } + } - transaction, err := mapper.Transaction(header, tx, &msg, receipt, trace, flattened) + transaction, err := mapper.Transaction(header, tx, &msg, receipt, trace, flattened, + transactionEvmLogs, s.client, s.config.IsAnalyticsMode(), s.config.TokenWhiteList, + s.config.IndexUnknownTokens) if err != nil { return nil, wrapError(errInternalError, err) } diff --git a/service/types.go b/service/types.go index a5a32b55..d9d699b6 100644 --- a/service/types.go +++ b/service/types.go @@ -9,7 +9,8 @@ import ( ) const ( - seconds2milliseconds = 1000 + seconds2milliseconds = 1000 + BalanceOfMethodPrefix = "0x70a08231000000000000000000000000" ) type options struct { @@ -286,3 +287,9 @@ func (m *accountMetadata) UnmarshalJSON(data []byte) error { m.Nonce = nonce return nil } + +// has0xPrefix validates str begins with '0x' or '0X'. +// Copied from the go-ethereum hextuil.go library +func has0xPrefix(str string) bool { + return len(str) >= 2 && str[0] == '0' && (str[1] == 'x' || str[1] == 'X') +}