diff --git a/bchain/coins/avalanche/contract_batch_integration_test.go b/bchain/coins/avalanche/contract_batch_integration_test.go index 4f8d167b..1de2e173 100644 --- a/bchain/coins/avalanche/contract_batch_integration_test.go +++ b/bchain/coins/avalanche/contract_batch_integration_test.go @@ -6,13 +6,14 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - "github.com/trezor/blockbook/bchain/coins" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" ) func TestAvalancheErc20ContractBalancesIntegration(t *testing.T) { - coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + bchain.RunERC20BatchBalanceTest(t, bchain.ERC20BatchCase{ Name: "avalanche", - RPCURL: coins.RPCURLFromConfig(t, "avalanche"), + RPCURL: bchain.RPCURLFromConfig(t, "avalanche"), // Token-rich address on Avalanche C-Chain (balanceOf works for any address). Addr: common.HexToAddress("0x60aE616a2155Ee3d9A68541Ba4544862310933d4"), Contracts: []common.Address{ @@ -25,5 +26,6 @@ func TestAvalancheErc20ContractBalancesIntegration(t *testing.T) { }, BatchSize: 200, SkipUnavailable: true, + NewClient: eth.NewERC20BatchIntegrationClient, }) } diff --git a/bchain/coins/base/contract_batch_integration_test.go b/bchain/coins/base/contract_batch_integration_test.go index 333897c2..36d8b752 100644 --- a/bchain/coins/base/contract_batch_integration_test.go +++ b/bchain/coins/base/contract_batch_integration_test.go @@ -6,13 +6,14 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - "github.com/trezor/blockbook/bchain/coins" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" ) func TestBaseErc20ContractBalancesIntegration(t *testing.T) { - coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + bchain.RunERC20BatchBalanceTest(t, bchain.ERC20BatchCase{ Name: "base", - RPCURL: coins.RPCURLFromConfig(t, "base"), + RPCURL: bchain.RPCURLFromConfig(t, "base"), Addr: common.HexToAddress("0x242E2d70d3AdC00a9eF23CeD6E88811fCefCA788"), Contracts: []common.Address{ common.HexToAddress("0x4200000000000000000000000000000000000006"), // WETH @@ -22,5 +23,6 @@ func TestBaseErc20ContractBalancesIntegration(t *testing.T) { }, BatchSize: 200, SkipUnavailable: true, + NewClient: eth.NewERC20BatchIntegrationClient, }) } diff --git a/bchain/coins/bsc/contract_batch_integration_test.go b/bchain/coins/bsc/contract_batch_integration_test.go index 9e3e6c9d..7d326a4f 100644 --- a/bchain/coins/bsc/contract_batch_integration_test.go +++ b/bchain/coins/bsc/contract_batch_integration_test.go @@ -6,13 +6,14 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - "github.com/trezor/blockbook/bchain/coins" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" ) func TestBNBSmartChainErc20ContractBalancesIntegration(t *testing.T) { - coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + bchain.RunERC20BatchBalanceTest(t, bchain.ERC20BatchCase{ Name: "bsc", - RPCURL: coins.RPCURLFromConfig(t, "bsc"), + RPCURL: bchain.RPCURLFromConfig(t, "bsc"), Addr: common.HexToAddress("0x21d45650db732cE5dF77685d6021d7D5d1da807f"), Contracts: []common.Address{ common.HexToAddress("0xBB4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"), // WBNB @@ -23,5 +24,6 @@ func TestBNBSmartChainErc20ContractBalancesIntegration(t *testing.T) { }, BatchSize: 200, SkipUnavailable: true, + NewClient: eth.NewERC20BatchIntegrationClient, }) } diff --git a/bchain/coins/eth/contract_batch_integration_test.go b/bchain/coins/eth/contract_batch_integration_test.go index 5c4dc3ec..f93f0de0 100644 --- a/bchain/coins/eth/contract_batch_integration_test.go +++ b/bchain/coins/eth/contract_batch_integration_test.go @@ -6,13 +6,14 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - "github.com/trezor/blockbook/bchain/coins" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" ) func TestEthereumTypeGetErc20ContractBalancesIntegration(t *testing.T) { - coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + bchain.RunERC20BatchBalanceTest(t, bchain.ERC20BatchCase{ Name: "ethereum", - RPCURL: coins.RPCURLFromConfig(t, "ethereum"), + RPCURL: bchain.RPCURLFromConfig(t, "ethereum"), // Token-rich EOA (CEX hot wallet) used as a stable address reference. Addr: common.HexToAddress("0x28C6c06298d514Db089934071355E5743bf21d60"), Contracts: []common.Address{ @@ -23,5 +24,6 @@ func TestEthereumTypeGetErc20ContractBalancesIntegration(t *testing.T) { }, BatchSize: 200, SkipUnavailable: false, + NewClient: eth.NewERC20BatchIntegrationClient, }) } diff --git a/bchain/coins/eth/erc20_batch_integration_client.go b/bchain/coins/eth/erc20_batch_integration_client.go new file mode 100644 index 00000000..3c5033ba --- /dev/null +++ b/bchain/coins/eth/erc20_batch_integration_client.go @@ -0,0 +1,24 @@ +//go:build integration + +package eth + +import ( + "time" + + "github.com/trezor/blockbook/bchain" +) + +// NewERC20BatchIntegrationClient builds an ERC20-capable RPC client for integration tests. +// EVM chains share ERC20 balanceOf semantics (eth_call) and coin wrappers embed EthereumRPC. +func NewERC20BatchIntegrationClient(rpcURL string, batchSize int) (bchain.ERC20BatchClient, func(), error) { + rc, _, err := OpenRPC(rpcURL) + if err != nil { + return nil, nil, err + } + client := &EthereumRPC{ + RPC: rc, + Timeout: 15 * time.Second, + ChainConfig: &Configuration{Erc20BatchSize: batchSize}, + } + return client, func() { rc.Close() }, nil +} diff --git a/bchain/coins/optimism/contract_batch_integration_test.go b/bchain/coins/optimism/contract_batch_integration_test.go index fe0b94ca..89f09cc5 100644 --- a/bchain/coins/optimism/contract_batch_integration_test.go +++ b/bchain/coins/optimism/contract_batch_integration_test.go @@ -6,13 +6,14 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" - "github.com/trezor/blockbook/bchain/coins" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" ) func TestOptimismErc20ContractBalancesIntegration(t *testing.T) { - coins.RunERC20BatchBalanceTest(t, coins.ERC20BatchCase{ + bchain.RunERC20BatchBalanceTest(t, bchain.ERC20BatchCase{ Name: "optimism", - RPCURL: coins.RPCURLFromConfig(t, "optimism"), + RPCURL: bchain.RPCURLFromConfig(t, "optimism"), Addr: common.HexToAddress("0xDF90C9B995a3b10A5b8570a47101e6c6a29eb945"), Contracts: []common.Address{ common.HexToAddress("0x4200000000000000000000000000000000000006"), // WETH @@ -23,5 +24,6 @@ func TestOptimismErc20ContractBalancesIntegration(t *testing.T) { }, BatchSize: 200, SkipUnavailable: true, + NewClient: eth.NewERC20BatchIntegrationClient, }) } diff --git a/bchain/coins/erc20_batch_integration.go b/bchain/erc20_batch_integration.go similarity index 60% rename from bchain/coins/erc20_batch_integration.go rename to bchain/erc20_batch_integration.go index b4d8240b..0630c6bb 100644 --- a/bchain/coins/erc20_batch_integration.go +++ b/bchain/erc20_batch_integration.go @@ -1,24 +1,17 @@ //go:build integration -package coins +package bchain import ( - "bytes" "context" "errors" "fmt" + "math/big" "net" - "os" - "path/filepath" - "runtime" "strings" "testing" - "time" "github.com/ethereum/go-ethereum/common" - "github.com/trezor/blockbook/bchain" - "github.com/trezor/blockbook/bchain/coins/eth" - buildcfg "github.com/trezor/blockbook/build/tools" ) const defaultBatchSize = 200 @@ -30,24 +23,25 @@ type ERC20BatchCase struct { Contracts []common.Address BatchSize int SkipUnavailable bool + NewClient ERC20BatchClientFactory } +// RunERC20BatchBalanceTest validates batch balanceOf results against single calls. func RunERC20BatchBalanceTest(t *testing.T, tc ERC20BatchCase) { t.Helper() if tc.BatchSize <= 0 { tc.BatchSize = defaultBatchSize } - rc, _, err := eth.OpenRPC(tc.RPCURL) + if tc.NewClient == nil { + t.Fatalf("NewClient is required for ERC20 batch integration test") + } + rpcClient, closeFn, err := tc.NewClient(tc.RPCURL, tc.BatchSize) 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 closeFn != nil { + t.Cleanup(closeFn) } if err := verifyBatchBalances(rpcClient, tc.Addr, tc.Contracts); err != nil { handleRPCError(t, tc, err) @@ -60,50 +54,6 @@ func RunERC20BatchBalanceTest(t *testing.T, tc ERC20BatchCase) { } } -func RPCURLFromConfig(t *testing.T, coinAlias string) string { - t.Helper() - configsDir, err := repoConfigsDir() - if err != nil { - t.Fatalf("integration config path error: %v", err) - } - cfg, err := buildcfg.LoadConfig(configsDir, coinAlias) - if err != nil { - t.Fatalf("load config for %s: %v", coinAlias, err) - } - templ := cfg.ParseTemplate() - var out bytes.Buffer - if err := templ.ExecuteTemplate(&out, "IPC.RPCURLTemplate", cfg); err != nil { - t.Fatalf("render rpc_url_template for %s: %v", coinAlias, err) - } - rpcURL := strings.TrimSpace(out.String()) - if rpcURL == "" { - t.Fatalf("empty rpc url from config for %s", coinAlias) - } - return rpcURL -} - -func repoConfigsDir() (string, error) { - _, file, _, ok := runtime.Caller(0) - if !ok { - return "", errors.New("unable to resolve caller path") - } - dir := filepath.Dir(file) - for i := 0; i < 6; i++ { - configsDir := filepath.Join(dir, "configs") - if _, err := os.Stat(filepath.Join(configsDir, "coins")); err == nil { - return configsDir, nil - } else if !os.IsNotExist(err) { - return "", err - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - return "", errors.New("configs/coins not found from caller path") -} - func handleRPCError(t *testing.T, tc ERC20BatchCase, err error) { t.Helper() if tc.SkipUnavailable && isRPCUnavailable(err) { @@ -127,15 +77,22 @@ func expandContracts(contracts []common.Address, minLen int) []common.Address { return out } -func verifyBatchBalances(rpcClient *eth.EthereumRPC, addr common.Address, contracts []common.Address) error { +type ERC20BatchClient interface { + EthereumTypeGetErc20ContractBalances(addrDesc AddressDescriptor, contractDescs []AddressDescriptor) ([]*big.Int, error) + EthereumTypeGetErc20ContractBalance(addrDesc, contractDesc AddressDescriptor) (*big.Int, error) +} + +type ERC20BatchClientFactory func(rpcURL string, batchSize int) (ERC20BatchClient, func(), error) + +func verifyBatchBalances(rpcClient ERC20BatchClient, addr common.Address, contracts []common.Address) error { if len(contracts) == 0 { return errors.New("no contracts to query") } - contractDescs := make([]bchain.AddressDescriptor, len(contracts)) + contractDescs := make([]AddressDescriptor, len(contracts)) for i, c := range contracts { - contractDescs[i] = bchain.AddressDescriptor(c.Bytes()) + contractDescs[i] = AddressDescriptor(c.Bytes()) } - addrDesc := bchain.AddressDescriptor(addr.Bytes()) + addrDesc := AddressDescriptor(addr.Bytes()) balances, err := rpcClient.EthereumTypeGetErc20ContractBalances(addrDesc, contractDescs) if err != nil { return fmt.Errorf("batch balances error: %w", err) diff --git a/bchain/integration_helpers.go b/bchain/integration_helpers.go new file mode 100644 index 00000000..9699843b --- /dev/null +++ b/bchain/integration_helpers.go @@ -0,0 +1,61 @@ +//go:build integration + +package bchain + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + buildcfg "github.com/trezor/blockbook/build/tools" +) + +// RPCURLFromConfig renders ipc.rpc_url_template from the coin config for integration tests. +func RPCURLFromConfig(t *testing.T, coinAlias string) string { + t.Helper() + configsDir, err := repoConfigsDir() + if err != nil { + t.Fatalf("integration config path error: %v", err) + } + cfg, err := buildcfg.LoadConfig(configsDir, coinAlias) + if err != nil { + t.Fatalf("load config for %s: %v", coinAlias, err) + } + templ := cfg.ParseTemplate() + var out bytes.Buffer + if err := templ.ExecuteTemplate(&out, "IPC.RPCURLTemplate", cfg); err != nil { + t.Fatalf("render rpc_url_template for %s: %v", coinAlias, err) + } + rpcURL := strings.TrimSpace(out.String()) + if rpcURL == "" { + t.Fatalf("empty rpc url from config for %s", coinAlias) + } + return rpcURL +} + +func repoConfigsDir() (string, error) { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("unable to resolve caller path") + } + dir := filepath.Dir(file) + // search the config directory in the parent folders so it is agnostic to the caller location + for i := 0; i < 3; i++ { + configsDir := filepath.Join(dir, "configs") + if _, err := os.Stat(filepath.Join(configsDir, "coins")); err == nil { + return configsDir, nil + } else if !os.IsNotExist(err) { + return "", err + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", errors.New("configs/coins not found from caller path") +}