Skip to content

feat_: introduce erc20 balance fetcher #5

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ignore:
- "examples"
- "pkg/contracts"
- "mock"

comment:
behavior: default
layout: diff,flags,tree

github_checks:
annotations: false
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ go get github.com/status-im/go-wallet-sdk

### Balance Management
- **`pkg/balance/fetcher`**: High-performance balance fetching with automatic fallback strategies
- Batch processing for multiple addresses
- Native token (ETH) balance fetching for multiple addresses
- ERC20 token balance fetching for multiple addresses and tokens
- Smart fallback between different fetching methods
- Chain-agnostic design

Expand Down
219 changes: 201 additions & 18 deletions examples/balance-fetcher-web/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"math/big"
"net/http"
"strconv"
"strings"
Expand All @@ -16,12 +17,73 @@ import (
"github.com/status-im/go-wallet-sdk/pkg/balance/fetcher"
)

// Global token list service
var tokenListService = NewTokenListService()

// handleGetChains handles the GET /api/chains endpoint
func handleGetChains(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Implement the logic to return the list of available chains
}

// handleGetTokens handles the GET /api/tokens/{chainID} endpoint
func handleGetTokens(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

// Extract chain ID from URL path
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}

chainIDStr := pathParts[3]
chainID, err := strconv.Atoi(chainIDStr)
if err != nil {
http.Error(w, "Invalid chain ID", http.StatusBadRequest)
return
}

// Get tokens for the chain
tokens, err := tokenListService.GetTokensForChain(chainID)
if err != nil {
log.Printf("Failed to get tokens for chain %d: %v", chainID, err)
// Return common tokens as fallback
tokens = tokenListService.GetCommonTokens(chainID)
}

json.NewEncoder(w).Encode(map[string]interface{}{
"tokens": tokens,
"chainId": chainID,
})
}

// handleGetTokenListInfo handles the GET /api/tokenlist/info endpoint
func handleGetTokenListInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

info := tokenListService.GetTokenListInfo()
json.NewEncoder(w).Encode(info)
}

// handleSearchTokens handles the GET /api/tokens/search?symbol={symbol} endpoint
func handleSearchTokens(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

symbol := r.URL.Query().Get("symbol")
if symbol == "" {
http.Error(w, "Symbol parameter is required", http.StatusBadRequest)
return
}

tokens := tokenListService.SearchTokensBySymbol(symbol)
json.NewEncoder(w).Encode(map[string]interface{}{
"symbol": symbol,
"tokens": tokens,
"count": len(tokens),
})
}

// handleFetchBalances handles the POST /fetch endpoint
func handleFetchBalances(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Expand All @@ -36,7 +98,7 @@ func handleFetchBalances(w http.ResponseWriter, r *http.Request) {
}

response := FetchResponse{
Results: make(map[string]map[string]BalanceResult),
Results: make(map[string]map[string]AccountBalances),
Errors: []string{},
}

Expand Down Expand Up @@ -64,8 +126,12 @@ func handleFetchBalances(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Chain ID %d: Failed to create RPC client: %v", chainConfig.ChainID, err)
// Chain-level error
response.Results[fmt.Sprintf("%d", chainConfig.ChainID)] = map[string]BalanceResult{
"__chain_error__": {Error: fmt.Sprintf("Failed to connect: %v", err)},
response.Results[fmt.Sprintf("%d", chainConfig.ChainID)] = map[string]AccountBalances{
"__chain_error__": {
Address: "__chain_error__",
NativeBalance: BalanceResult{Error: fmt.Sprintf("Failed to connect: %v", err)},
ERC20Balances: make(map[string]ERC20BalanceResult),
},
}
continue
}
Expand All @@ -87,48 +153,111 @@ func handleFetchBalances(w http.ResponseWriter, r *http.Request) {

if len(addresses) == 0 {
log.Printf("Chain ID %d: No valid addresses provided", chainConfig.ChainID)
response.Results[fmt.Sprintf("%d", chainConfig.ChainID)] = map[string]BalanceResult{
"__chain_error__": {Error: "No valid addresses provided"},
response.Results[fmt.Sprintf("%d", chainConfig.ChainID)] = map[string]AccountBalances{
"__chain_error__": {
Address: "__chain_error__",
NativeBalance: BalanceResult{Error: "No valid addresses provided"},
ERC20Balances: make(map[string]ERC20BalanceResult),
},
}
continue
}

log.Printf("Chain ID %d: Fetching balances for %d addresses", chainConfig.ChainID, len(addresses))

// Fetch balances
// Fetch native balances
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

balances, err := fetcher.FetchNativeBalances(ctx, addresses, atBlock, rpcClient, 10)
nativeBalances, err := fetcher.FetchNativeBalances(ctx, addresses, atBlock, rpcClient, 10)
if err != nil {
log.Printf("Chain ID %d: Failed to fetch balances: %v", chainConfig.ChainID, err)
response.Results[fmt.Sprintf("%d", chainConfig.ChainID)] = map[string]BalanceResult{
"__chain_error__": {Error: fmt.Sprintf("Failed to fetch balances: %v", err)},
log.Printf("Chain ID %d: Failed to fetch native balances: %v", chainConfig.ChainID, err)
response.Results[fmt.Sprintf("%d", chainConfig.ChainID)] = map[string]AccountBalances{
"__chain_error__": {
Address: "__chain_error__",
NativeBalance: BalanceResult{Error: fmt.Sprintf("Failed to fetch native balances: %v", err)},
ERC20Balances: make(map[string]ERC20BalanceResult),
},
}
continue
}

log.Printf("Chain ID %d: Successfully fetched balances for %d addresses", chainConfig.ChainID, len(balances))
// Fetch ERC20 balances if token addresses are provided
var erc20Balances fetcher.BalancePerAccountAndTokenAddress
if len(chainConfig.TokenAddresses) > 0 {
tokenAddresses := make([]common.Address, 0, len(chainConfig.TokenAddresses))
for _, tokenAddrStr := range chainConfig.TokenAddresses {
tokenAddrStr = strings.TrimSpace(tokenAddrStr)
if tokenAddrStr == "" {
continue
}
if !common.IsHexAddress(tokenAddrStr) {
response.Errors = append(response.Errors, fmt.Sprintf("Invalid token address: %s", tokenAddrStr))
continue
}
tokenAddresses = append(tokenAddresses, common.HexToAddress(tokenAddrStr))
}

if len(tokenAddresses) > 0 {
log.Printf("Chain ID %d: Fetching ERC20 balances for %d tokens", chainConfig.ChainID, len(tokenAddresses))
erc20Balances, err = fetcher.FetchErc20Balances(ctx, addresses, tokenAddresses, atBlock, rpcClient, 10)
if err != nil {
log.Printf("Chain ID %d: Failed to fetch ERC20 balances: %v", chainConfig.ChainID, err)
// Continue with native balances only
}
}
}

log.Printf("Chain ID %d: Successfully fetched balances for %d addresses", chainConfig.ChainID, len(nativeBalances))

// Store results
chainResults := make(map[string]BalanceResult)
chainResults := make(map[string]AccountBalances)
for _, addr := range addresses {
addrStr := addr.Hex()
balance := balances[addr]
nativeBalance := nativeBalances[addr]

accountBalances := AccountBalances{
Address: addrStr,
ERC20Balances: make(map[string]ERC20BalanceResult),
}

if balance == nil {
chainResults[addrStr] = BalanceResult{
// Set native balance
if nativeBalance == nil {
accountBalances.NativeBalance = BalanceResult{
Address: addrStr,
Balance: "0",
Wei: "0",
}
} else {
chainResults[addrStr] = BalanceResult{
accountBalances.NativeBalance = BalanceResult{
Address: addrStr,
Balance: weiToEther(balance),
Wei: balance.String(),
Balance: weiToEther(nativeBalance),
Wei: nativeBalance.String(),
}
}

// Set ERC20 balances
if erc20Balances != nil {
if accountTokenBalances, exists := erc20Balances[addr]; exists {
for tokenAddr, tokenBalance := range accountTokenBalances {
tokenAddrStr := tokenAddr.Hex()

// Get token info for display
tokenInfo := getTokenInfo(int(chainConfig.ChainID), tokenAddrStr)

accountBalances.ERC20Balances[tokenAddrStr] = ERC20BalanceResult{
TokenAddress: tokenAddrStr,
TokenSymbol: tokenInfo.Symbol,
TokenName: tokenInfo.Name,
Balance: formatTokenBalance(tokenBalance, tokenInfo.Decimals),
Wei: tokenBalance.String(),
Decimals: tokenInfo.Decimals,
}
}
}
}

chainResults[addrStr] = accountBalances
}

response.Results[fmt.Sprintf("%d", chainConfig.ChainID)] = chainResults
Expand All @@ -137,3 +266,57 @@ func handleFetchBalances(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

// getTokenInfo retrieves token information from the token list service
func getTokenInfo(chainID int, tokenAddress string) TokenInfo {
tokens, err := tokenListService.GetTokensForChain(chainID)
if err != nil {
// Return fallback token info
return TokenInfo{
Address: tokenAddress,
Symbol: "UNKNOWN",
Name: "Unknown Token",
Decimals: 18,
ChainID: chainID,
}
}

// Find the token in the list
for _, token := range tokens {
if strings.EqualFold(token.Address, tokenAddress) {
return token
}
}

// Return fallback token info if not found
return TokenInfo{
Address: tokenAddress,
Symbol: "UNKNOWN",
Name: "Unknown Token",
Decimals: 18,
ChainID: chainID,
}
}

// formatTokenBalance formats a token balance based on its decimals
func formatTokenBalance(balance *big.Int, decimals int) string {
if balance == nil {
return "0"
}

// Convert to string with proper decimal places
balanceStr := balance.String()

if decimals == 0 {
return balanceStr
}

if len(balanceStr) <= decimals {
// Pad with leading zeros
padded := strings.Repeat("0", decimals-len(balanceStr)+1) + balanceStr
return "0." + padded[1:]
}

// Insert decimal point
return balanceStr[:len(balanceStr)-decimals] + "." + balanceStr[len(balanceStr)-decimals:]
}
3 changes: 3 additions & 0 deletions examples/balance-fetcher-web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ func main() {
r.HandleFunc("/", handleHome)
r.HandleFunc("/fetch", handleFetchBalances)
r.HandleFunc("/api/chains", handleGetChains)
r.HandleFunc("/api/tokensearch", handleSearchTokens)
r.HandleFunc("/api/tokens/{chainID}", handleGetTokens)
r.HandleFunc("/api/tokenlist/info", handleGetTokenListInfo)

// Start server
port := ":8080"
Expand Down
Loading
Loading