diff --git a/bchain/coins/avalanche/contract_batch_integration_test.go b/bchain/coins/avalanche/contract_batch_integration_test.go new file mode 100644 index 00000000..5fa3dfc4 --- /dev/null +++ b/bchain/coins/avalanche/contract_batch_integration_test.go @@ -0,0 +1,31 @@ +//go:build integration + +package avalanche + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain/coins" +) + +const defaultAvaxRpcURL = "http://localhost:8098/ext/bc/C/rpc" + +func TestAvalancheErc20ContractBalancesIntegration(t *testing.T) { + coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + Name: "avalanche", + RPCURL: defaultAvaxRpcURL, + // Token-rich address on Avalanche C-Chain (balanceOf works for any address). + Addr: common.HexToAddress("0x60aE616a2155Ee3d9A68541Ba4544862310933d4"), + Contracts: []common.Address{ + common.HexToAddress("0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7"), // WAVAX + common.HexToAddress("0xA7D7079b0FEAD91F3e65f86E8915Cb59c1a4C664"), // USDC.e + common.HexToAddress("0xc7198437980c041c805A1EDcbA50c1Ce5db95118"), // USDT.e + common.HexToAddress("0xd586e7f844cea2f87f50152665bcbc2c279d8d70"), // DAI.e + common.HexToAddress("0x49D5c2BdFfac6Ce2BFdB6640F4F80f226bc10bAB"), // WETH.e + common.HexToAddress("0x60781C2586D68229fde47564546784ab3fACA982"), // PNG + }, + BatchSize: 200, + SkipUnavailable: true, + }) +} diff --git a/bchain/coins/base/contract_batch_integration_test.go b/bchain/coins/base/contract_batch_integration_test.go new file mode 100644 index 00000000..e3a90c81 --- /dev/null +++ b/bchain/coins/base/contract_batch_integration_test.go @@ -0,0 +1,28 @@ +//go:build integration + +package base_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain/coins" +) + +const defaultBaseRpcURL = "ws://localhost:8309" + +func TestBaseErc20ContractBalancesIntegration(t *testing.T) { + coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + Name: "base", + RPCURL: defaultBaseRpcURL, + Addr: common.HexToAddress("0x242E2d70d3AdC00a9eF23CeD6E88811fCefCA788"), + Contracts: []common.Address{ + common.HexToAddress("0x4200000000000000000000000000000000000006"), // WETH + common.HexToAddress("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), // USDC + common.HexToAddress("0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb"), // DAI + common.HexToAddress("0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22"), // cbETH + }, + BatchSize: 200, + SkipUnavailable: true, + }) +} diff --git a/bchain/coins/bsc/contract_batch_integration_test.go b/bchain/coins/bsc/contract_batch_integration_test.go new file mode 100644 index 00000000..5c628bd1 --- /dev/null +++ b/bchain/coins/bsc/contract_batch_integration_test.go @@ -0,0 +1,29 @@ +//go:build integration + +package bsc_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain/coins" +) + +const defaultBscRpcURL = "ws://localhost:8064" + +func TestBNBSmartChainErc20ContractBalancesIntegration(t *testing.T) { + coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + Name: "bsc", + RPCURL: defaultBscRpcURL, + Addr: common.HexToAddress("0x21d45650db732cE5dF77685d6021d7D5d1da807f"), + Contracts: []common.Address{ + common.HexToAddress("0xBB4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"), // WBNB + common.HexToAddress("0x55d398326f99059fF775485246999027B3197955"), // USDT + common.HexToAddress("0xe9e7CEA3Dedca5984780Bafc599bd69ADd087d56"), // BUSD + common.HexToAddress("0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d"), // USDC + common.HexToAddress("0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3"), // DAI + }, + BatchSize: 200, + SkipUnavailable: true, + }) +} diff --git a/bchain/coins/erc20_batch_integration.go b/bchain/coins/erc20_batch_integration.go new file mode 100644 index 00000000..f08b15a0 --- /dev/null +++ b/bchain/coins/erc20_batch_integration.go @@ -0,0 +1,133 @@ +//go:build integration + +package coins + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" +) + +const defaultBatchSize = 200 + +type ERC20BatchCase struct { + Name string + RPCURL string + Addr common.Address + Contracts []common.Address + BatchSize int + SkipUnavailable bool +} + +func RunERC20BatchBalanceTest(t *testing.T, tc ERC20BatchCase) { + t.Helper() + if tc.BatchSize <= 0 { + tc.BatchSize = defaultBatchSize + } + rc, _, err := eth.OpenRPC(tc.RPCURL) + if err != nil { + handleRPCError(t, tc, fmt.Errorf("rpc dial error: %w", err)) + return + } + t.Cleanup(func() { rc.Close() }) + + rpcClient := ð.EthereumRPC{ + RPC: rc, + Timeout: 15 * time.Second, + ChainConfig: ð.Configuration{Erc20BatchSize: tc.BatchSize}, + } + if err := verifyBatchBalances(rpcClient, tc.Addr, tc.Contracts); err != nil { + handleRPCError(t, tc, err) + return + } + chunkedContracts := expandContracts(tc.Contracts, tc.BatchSize+1) + if err := verifyBatchBalances(rpcClient, tc.Addr, chunkedContracts); err != nil { + handleRPCError(t, tc, err) + return + } +} + +func handleRPCError(t *testing.T, tc ERC20BatchCase, err error) { + t.Helper() + if tc.SkipUnavailable && isRPCUnavailable(err) { + t.Skipf("WARN: %s RPC not available: %v", tc.Name, err) + return + } + t.Fatalf("%v", err) +} + +func expandContracts(contracts []common.Address, minLen int) []common.Address { + if len(contracts) >= minLen { + return contracts + } + out := make([]common.Address, 0, minLen) + for len(out) < minLen { + out = append(out, contracts...) + } + if len(out) > minLen { + out = out[:minLen] + } + return out +} + +func verifyBatchBalances(rpcClient *eth.EthereumRPC, addr common.Address, contracts []common.Address) error { + if len(contracts) == 0 { + return errors.New("no contracts to query") + } + contractDescs := make([]bchain.AddressDescriptor, len(contracts)) + for i, c := range contracts { + contractDescs[i] = bchain.AddressDescriptor(c.Bytes()) + } + addrDesc := bchain.AddressDescriptor(addr.Bytes()) + balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(addrDesc, contractDescs) + if err != nil { + return fmt.Errorf("batch balances error: %w", err) + } + if len(balances) != len(contractDescs) { + return fmt.Errorf("expected %d balances, got %d", len(contractDescs), len(balances)) + } + for i, contractDesc := range contractDescs { + single, err := rpcClient.EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc) + if err != nil { + return fmt.Errorf("single balance error for %s: %w", contracts[i].Hex(), err) + } + if balances[i] == nil { + return fmt.Errorf("batch balance missing for %s", contracts[i].Hex()) + } + if balances[i].Cmp(single) != 0 { + return fmt.Errorf("balance mismatch for %s: batch=%s single=%s", contracts[i].Hex(), balances[i].String(), single.String()) + } + } + return nil +} + +func isRPCUnavailable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) { + return true + } + msg := strings.ToLower(err.Error()) + switch { + case strings.Contains(msg, "context deadline exceeded"), + strings.Contains(msg, "connection refused"), + strings.Contains(msg, "no such host"), + strings.Contains(msg, "i/o timeout"), + strings.Contains(msg, "timeout"): + return true + } + return false +} diff --git a/bchain/coins/eth/contract_batch_integration_test.go b/bchain/coins/eth/contract_batch_integration_test.go index 23f71b3c..f41809f5 100644 --- a/bchain/coins/eth/contract_batch_integration_test.go +++ b/bchain/coins/eth/contract_batch_integration_test.go @@ -1,78 +1,29 @@ //go:build integration -package eth +package eth_test import ( "testing" - "time" "github.com/ethereum/go-ethereum/common" - "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins" ) -const defaultEthRpcURL = "http://naked:8545" - -func verifyBatchBalances(t *testing.T, rpcClient *EthereumRPC, addr common.Address, contracts []common.Address) { - t.Helper() - contractDescs := make([]bchain.AddressDescriptor, len(contracts)) - for i, c := range contracts { - contractDescs[i] = bchain.AddressDescriptor(c.Bytes()) - } - addrDesc := bchain.AddressDescriptor(addr.Bytes()) - balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(addrDesc, contractDescs) - if err != nil { - t.Fatalf("batch balances error: %v", err) - } - if len(balances) != len(contractDescs) { - t.Fatalf("expected %d balances, got %d", len(contractDescs), len(balances)) - } - for i, contractDesc := range contractDescs { - single, err := rpcClient.EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc) - if err != nil { - t.Fatalf("single balance error for %s: %v", contracts[i].Hex(), err) - } - if balances[i] == nil { - t.Fatalf("batch balance missing for %s", contracts[i].Hex()) - } - if balances[i].Cmp(single) != 0 { - t.Fatalf("balance mismatch for %s: batch=%s single=%s", contracts[i].Hex(), balances[i].String(), single.String()) - } - } -} +const defaultEthRpcURL = "http://localhost:8545" func TestEthereumTypeGetErc20ContractBalancesIntegration(t *testing.T) { - rpcURL := defaultEthRpcURL - rc, _, err := OpenRPC(rpcURL) - if err != nil { - t.Skipf("skipping: cannot connect to RPC at %s: %v", rpcURL, err) - return - } - defer rc.Close() - - // Use stable mainnet ERC20 contracts and a well-known EOA. - addr := common.HexToAddress("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045") - baseContracts := []common.Address{ - common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), // USDC - common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7"), // USDT - common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), // WETH - } - - rpcClient := &EthereumRPC{ - RPC: rc, - Timeout: 15 * time.Second, - } - verifyBatchBalances(t, rpcClient, addr, baseContracts) - - chunkedContracts := []common.Address{ - common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), // USDC - common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7"), // USDT - common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), // WETH - common.HexToAddress("0x6B175474E89094C44Da98b954EedeAC495271d0F"), // DAI - } - rpcClientChunked := &EthereumRPC{ - RPC: rc, - Timeout: 15 * time.Second, - ChainConfig: &Configuration{Erc20BatchSize: 2}, - } - verifyBatchBalances(t, rpcClientChunked, addr, chunkedContracts) + coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + Name: "ethereum", + RPCURL: defaultEthRpcURL, + // Token-rich EOA (CEX hot wallet) used as a stable address reference. + Addr: common.HexToAddress("0x28C6c06298d514Db089934071355E5743bf21d60"), + Contracts: []common.Address{ + common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), // USDC + common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7"), // USDT + common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), // WETH + common.HexToAddress("0x6B175474E89094C44Da98b954EedeAC495271d0F"), // DAI + }, + BatchSize: 200, + SkipUnavailable: false, + }) } diff --git a/bchain/coins/optimism/contract_batch_integration_test.go b/bchain/coins/optimism/contract_batch_integration_test.go new file mode 100644 index 00000000..5b9c64cb --- /dev/null +++ b/bchain/coins/optimism/contract_batch_integration_test.go @@ -0,0 +1,29 @@ +//go:build integration + +package optimism_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/trezor/blockbook/bchain/coins" +) + +const defaultOptimismRpcURL = "ws://localhost:8200" + +func TestOptimismErc20ContractBalancesIntegration(t *testing.T) { + coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + Name: "optimism", + RPCURL: defaultOptimismRpcURL, + Addr: common.HexToAddress("0xDF90C9B995a3b10A5b8570a47101e6c6a29eb945"), + Contracts: []common.Address{ + common.HexToAddress("0x4200000000000000000000000000000000000006"), // WETH + common.HexToAddress("0x7F5c764cBc14f9669B88837ca1490cCa17c31607"), // USDC + common.HexToAddress("0x94b008aa00579c1307b0ef2c499ad98a8ce58e58"), // USDT + common.HexToAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"), // DAI + common.HexToAddress("0x4200000000000000000000000000000000000042"), // OP + }, + BatchSize: 200, + SkipUnavailable: true, + }) +}