diff --git a/README.md b/README.md index edf93ca..ca9aa51 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ go get github.com/status-im/go-wallet-sdk - Complete RPC method coverage (eth_*, net_*, web3_*) - Go-ethereum ethclient compatible interface for easy migration +### Token Lists Management +- **`pkg/tokenlists`**: Comprehensive token list management with privacy-aware fetching + - Multi-source support (Status, Uniswap, CoinGecko, custom sources) + - Privacy-aware automatic refresh with ETag support + - Cross-chain token management and validation + - Extensible parser system for custom token list formats + - Thread-safe concurrent access with proper synchronization + ### Common Utilities - **`pkg/common`**: Shared utilities and constants used across the SDK @@ -56,6 +64,7 @@ go-wallet-sdk/ ├── pkg/ # Core SDK packages │ ├── balance/ # Balance-related functionality │ ├── ethclient/ # Ethereum client with full RPC support +│ ├── tokenlists/ # Token list management and fetching │ └── common/ # Shared utilities ├── examples/ # Usage examples └── README.md # This file @@ -65,6 +74,7 @@ go-wallet-sdk/ - [Balance Fetcher](pkg/balance/fetcher/README.md) - Balance fetching functionality - [Ethereum Client](pkg/ethclient/README.md) - Complete Ethereum RPC client +- [Token Lists](pkg/tokenlists/README.md) - Token list management and fetching - [Web Example](examples/balance-fetcher-web/README.md) - Complete web application ## Contributing diff --git a/examples/tokenlists-usage/README.md b/examples/tokenlists-usage/README.md new file mode 100644 index 0000000..1a07073 --- /dev/null +++ b/examples/tokenlists-usage/README.md @@ -0,0 +1,85 @@ +# Token Lists Usage Example + +This example demonstrates how to use the `tokenlists` package from the go-wallet-sdk to manage and query cryptocurrency token lists. + +## Features Demonstrated + +1. **Basic Setup**: Creating and configuring a TokensList service with default settings +2. **Token Queries**: Various methods to query tokens from the lists +3. **Chain-specific Queries**: Getting tokens for specific blockchain networks +4. **Address Lookups**: Finding specific tokens by their contract addresses +5. **Token List Management**: Working with different token lists and their metadata +6. **Utility Functions**: Using token key generation and parsing utilities +7. **Refresh Operations**: Manual refresh of token lists from remote sources +8. **Event Notifications**: Handling update notifications when token lists change + +## What the TokensList Package Does + +The `tokenlists` package provides a comprehensive solution for managing cryptocurrency token metadata across multiple blockchain networks: + +The example works with a minimal configuration for demonstration purposes, a real configuration will provide more tokens/token lists. + +## Key Components + +### Token Structure +```go +type Token struct { + CrossChainID string // Unique identifier across chains + ChainID uint64 // Blockchain network ID + Address common.Address // Contract address + Decimals uint // Token decimal places + Name string // Full token name + Symbol string // Token symbol (e.g., "ETH", "USDT") + LogoURI string // URL to token logo + CustomToken bool // Whether it's a user-added token +} +``` + +### TokensList Interface +```go +type TokensList interface { + Start(ctx context.Context, notifyCh chan struct{}) error + Stop() error + + UniqueTokens() []*Token + GetTokenByChainAddress(chainID uint64, addr common.Address) (*Token, bool) + GetTokensByChain(chainID uint64) []*Token + + TokenLists() []*TokenList + TokenList(id string) (*TokenList, bool) + + RefreshNow(ctx context.Context) error + LastRefreshTime() (time.Time, error) +} +``` + +## Running the Example + +```bash +cd examples/tokenlists-usage +go run main.go +``` + +## Output Example + +The example will output information about: + +- Total number of tokens across all supported chains +- Sample tokens with their metadata (name, symbol, address, etc.) +- Chain-specific token counts (Ethereum, Optimism, Arbitrum, etc.) +- Token lookup by specific contract address +- Available token lists and their sources +- Native tokens for each supported network +- Token key generation and parsing utilities +- Last refresh timestamp and manual refresh operations + +## Configuration Options + +The TokensList can be configured with: + +- **Supported Chains**: Which blockchain networks to include +- **Token List Sources**: Remote URLs for fetching token lists +- **Refresh Intervals**: How often to check for updates +- **Privacy Settings**: Whether to fetch data from remote sources +- **Custom Storage**: Implement custom storage for caching token data +- **Logging**: Custom logger for debugging and monitoring diff --git a/examples/tokenlists-usage/go.mod b/examples/tokenlists-usage/go.mod new file mode 100644 index 0000000..0a26274 --- /dev/null +++ b/examples/tokenlists-usage/go.mod @@ -0,0 +1,21 @@ +module github.com/status-im/go-wallet-sdk/examples/tokenlists-usage + +go 1.23.0 + +require ( + github.com/ethereum/go-ethereum v1.16.3 + github.com/status-im/go-wallet-sdk v0.0.0 + go.uber.org/zap v1.27.0 +) + +require ( + github.com/holiman/uint256 v1.3.2 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/sys v0.31.0 // indirect +) + +replace github.com/status-im/go-wallet-sdk => ../../ diff --git a/examples/tokenlists-usage/go.sum b/examples/tokenlists-usage/go.sum new file mode 100644 index 0000000..8b8e4fa --- /dev/null +++ b/examples/tokenlists-usage/go.sum @@ -0,0 +1,31 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ethereum/go-ethereum v1.16.3 h1:nDoBSrmsrPbrDIVLTkDQCy1U9KdHN+F2PzvMbDoS42Q= +github.com/ethereum/go-ethereum v1.16.3/go.mod h1:Lrsc6bt9Gm9RyvhfFK53vboCia8kpF9nv+2Ukntnl+8= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/tokenlists-usage/main.go b/examples/tokenlists-usage/main.go new file mode 100644 index 0000000..322abce --- /dev/null +++ b/examples/tokenlists-usage/main.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "go.uber.org/zap" + + "github.com/ethereum/go-ethereum/common" + statuscommon "github.com/status-im/go-wallet-sdk/pkg/common" + "github.com/status-im/go-wallet-sdk/pkg/tokenlists" +) + +func printToken(token *tokenlists.Token) { + fmt.Printf("Token Key: %s:\n", token.Key()) + fmt.Printf(" Name: %s (%s)\n", token.Name, token.Symbol) + fmt.Printf(" Chain ID: %d\n", token.ChainID) + fmt.Printf(" Address: %s\n", token.Address.Hex()) + fmt.Printf(" Decimals: %d\n", token.Decimals) + fmt.Printf(" Native: %t\n", token.IsNative()) + fmt.Printf(" Custom: %t\n", token.CustomToken) + if token.LogoURI != "" { + fmt.Printf(" Logo: %s\n", token.LogoURI) + } + fmt.Println() +} + +func main() { + fmt.Println("🪙 Token Lists Management Example") + fmt.Println("=====================================") + + // Create logger + logger, err := zap.NewDevelopment() + if err != nil { + log.Fatalf("Failed to create logger: %v", err) + } + defer logger.Sync() + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Create configuration with defaults + config := tokenlists.DefaultConfig() + + // Configure with logger, main list, and specific chains + config.WithLogger(logger). + WithMainList(tokenlists.StatusListID, []byte(tokenlists.StatusTokenListJSON)). + WithChains([]uint64{ + statuscommon.EthereumMainnet, + statuscommon.OptimismMainnet, + statuscommon.ArbitrumMainnet, + statuscommon.BSCMainnet, + statuscommon.BaseMainnet, + }). + WithParsers(tokenlists.DefaultParsers). + WithAutoRefreshInterval(5*time.Minute, time.Minute) + + // Create token lists manager + tokensList, err := tokenlists.NewTokensList(config) + if err != nil { + log.Fatalf("Failed to create basic token list: %v", err) + } + + // Start the service + notifyCh := make(chan struct{}, 1) + if err := tokensList.Start(ctx, notifyCh); err != nil { + log.Fatalf("Failed to start token list service: %v", err) + } + defer tokensList.Stop() + + // Example 1: Query tokens by various methods + fmt.Println("\n🔍 Example 1: Querying Tokens") + fmt.Println("-----------------------------") + + // Get all unique tokens + allTokens := tokensList.UniqueTokens() + fmt.Printf("Total unique tokens: %d\n", len(allTokens)) + + // Show first few tokens + for _, token := range allTokens { + printToken(token) + } + + // Example 2: Get tokens by specific chain + fmt.Println("\n⛓️ Example 2: Tokens by Chain") + fmt.Println("-----------------------------") + + // Get Ethereum mainnet tokens + ethTokens := tokensList.GetTokensByChain(statuscommon.EthereumMainnet) + fmt.Printf("Ethereum mainnet tokens: %d\n", len(ethTokens)) + + // Show first few Ethereum tokens + for _, token := range ethTokens { + printToken(token) + } + + // Get Optimism tokens + opTokens := tokensList.GetTokensByChain(statuscommon.OptimismMainnet) + fmt.Printf("Optimism tokens: %d\n", len(opTokens)) + + // Example 3: Get specific token by address + fmt.Println("\n🎯 Example 3: Get Token by Address") + fmt.Println("----------------------------------") + + // Look for USDT on Ethereum (example address) + usdtAddress := common.HexToAddress("0x1234") + token, found := tokensList.GetTokenByChainAddress(statuscommon.EthereumMainnet, usdtAddress) + if found { + fmt.Printf("Found token at address: %s\n", usdtAddress.Hex()) + printToken(token) + } else { + fmt.Printf("Token not found at address: %s\n", usdtAddress.Hex()) + } + + sntOptimismAddress := common.HexToAddress("0x650af3c15af43dcb218406d30784416d64cfb6b2") + token, found = tokensList.GetTokenByChainAddress(statuscommon.OptimismMainnet, sntOptimismAddress) + if found { + fmt.Printf("Found token at address: %s\n", sntOptimismAddress.Hex()) + printToken(token) + } else { + fmt.Printf("Token not found at address: %s\n", sntOptimismAddress.Hex()) + } + + // Example 4: Working with Token Lists + fmt.Println("\n📝 Example 4: Token Lists Information") + fmt.Println("------------------------------------") + + allTokenLists := tokensList.TokenLists() + fmt.Printf("Total token lists: %d\n", len(allTokenLists)) + + for _, tokenList := range allTokenLists { + fmt.Printf("List: %s\n", tokenList.Name) + fmt.Printf(" Source: %s\n", tokenList.Source) + fmt.Printf(" Tokens: %d\n", len(tokenList.Tokens)) + fmt.Printf(" Version: %s\n", tokenList.Version.String()) + if tokenList.Timestamp != "" { + fmt.Printf(" Timestamp: %s\n", tokenList.Timestamp) + } + if tokenList.FetchedTimestamp != "" { + fmt.Printf(" Fetched: %s\n", tokenList.FetchedTimestamp) + } + + // Show sample tokens from this list + if len(tokenList.Tokens) > 0 && len(tokenList.Tokens) <= 10 { + fmt.Printf(" Tokens in this list:\n") + for _, token := range tokenList.Tokens { + fmt.Printf(" - %s (%s) on chain %d at %s\n", token.Name, token.Symbol, token.ChainID, token.Address.Hex()) + } + } else if len(tokenList.Tokens) > 10 { + fmt.Printf(" Sample tokens from this list:\n") + for i, token := range tokenList.Tokens { + if i >= 3 { // Show first 3 + break + } + fmt.Printf(" - %s (%s) on chain %d at %s\n", token.Name, token.Symbol, token.ChainID, token.Address.Hex()) + } + } + fmt.Println() + } + + // Example 5: Get specific token list + fmt.Println("\n📄 Example 5: Specific Token List") + fmt.Println("---------------------------------") + + if nativeList, found := tokensList.TokenList(tokenlists.NativeTokenListID); found { + fmt.Printf("Native token list: %s\n", nativeList.Name) + fmt.Printf("Native tokens count: %d\n", len(nativeList.Tokens)) + for _, token := range nativeList.Tokens { + fmt.Printf(" %s (%s) on chain %d\n", token.Name, token.Symbol, token.ChainID) + } + } + + // Try to get Status token list (may not be available in this basic example) + if statusList, found := tokensList.TokenList(tokenlists.StatusListID); found { + fmt.Printf("\nStatus token list: %s\n", statusList.Name) + fmt.Printf("Status tokens count: %d\n", len(statusList.Tokens)) + for _, token := range statusList.Tokens { + fmt.Printf(" %s (%s) on chain %d at %s\n", token.Name, token.Symbol, token.ChainID, token.Address.Hex()) + } + } else { + fmt.Printf("\nNote: Status token list not loaded in this basic example\n") + fmt.Printf("In production, token lists would be fetched from remote sources\n") + } + + // Example 6: Token key utilities + fmt.Println("\n🔑 Example 6: Token Key Utilities") + fmt.Println("---------------------------------") + + // Create token key + testAddress := common.HexToAddress("0x1234567890123456789012345678901234567890") + tokenKey := tokenlists.TokenKey(statuscommon.EthereumMainnet, testAddress) + fmt.Printf("Token key: %s\n", tokenKey) + + // Parse token key back + if chainID, address, valid := tokenlists.ChainAndAddressFromTokenKey(tokenKey); valid { + fmt.Printf("Parsed - Chain ID: %d, Address: %s\n", chainID, address.Hex()) + } else { + fmt.Printf("Failed to parse token key: %s\n", tokenKey) + } + + // Example 7: Check last refresh time + fmt.Println("\n🔄 Example 7: Refresh Information") + fmt.Println("---------------------------------") + + if lastRefresh, err := tokensList.LastRefreshTime(); err == nil { + if lastRefresh.IsZero() { + fmt.Println("Token lists have not been refreshed yet") + } else { + fmt.Printf("Last refresh: %s\n", lastRefresh.Format(time.RFC3339)) + } + } else { + fmt.Printf("Error getting last refresh time: %v\n", err) + } + + // Example 8: Manual refresh (if privacy is off) + fmt.Println("\n🔃 Example 8: Manual Refresh") + fmt.Println("----------------------------") + + fmt.Println("Triggering manual refresh...") + refreshCtx, refreshCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer refreshCancel() + + if err := tokensList.RefreshNow(refreshCtx); err != nil { + fmt.Printf("Refresh error: %v\n", err) + } else { + fmt.Println("✅ Refresh triggered successfully") + } + + // Wait a bit to see if we get a notification + select { + case <-notifyCh: + fmt.Println("📬 Received update notification!") + // Show updated counts + updatedTokens := tokensList.UniqueTokens() + fmt.Printf("Updated token count: %d\n", len(updatedTokens)) + case <-time.After(2 * time.Second): + fmt.Println("No notification received (might be using cached data)") + } + + fmt.Println("\n✅ Example completed successfully!") +} diff --git a/go.mod b/go.mod index fe0f32d..4f7af40 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,9 @@ require ( github.com/brianvoe/gofakeit/v7 v7.3.0 github.com/ethereum/go-ethereum v1.16.3 github.com/stretchr/testify v1.10.0 + github.com/xeipuuv/gojsonschema v1.2.0 go.uber.org/mock v0.6.0 + go.uber.org/zap v1.27.0 ) require ( @@ -86,7 +88,10 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/urfave/cli/v2 v2.27.5 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/go.sum b/go.sum index dd19716..a674fb2 100644 --- a/go.sum +++ b/go.sum @@ -234,13 +234,25 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/pkg/common/chainid.go b/pkg/common/chainid.go index 54c4968..bd16809 100644 --- a/pkg/common/chainid.go +++ b/pkg/common/chainid.go @@ -16,3 +16,17 @@ const ( BaseSepolia ChainID = 84532 StatusNetworkSepolia ChainID = 1660990954 ) + +var AllChains = []ChainID{ + EthereumMainnet, + EthereumSepolia, + OptimismMainnet, + OptimismSepolia, + ArbitrumMainnet, + ArbitrumSepolia, + BSCMainnet, + BSCTestnet, + BaseMainnet, + BaseSepolia, + StatusNetworkSepolia, +} diff --git a/pkg/tokenlists/README.md b/pkg/tokenlists/README.md new file mode 100644 index 0000000..d3a4222 --- /dev/null +++ b/pkg/tokenlists/README.md @@ -0,0 +1,267 @@ +# TokenLists Package + +The `tokenlists` package provides a comprehensive solution for managing and fetching token lists from various sources in a privacy-aware manner. It supports multiple token list formats, automatic refresh capabilities, and cross-chain token management. + +## Features + +- **Multi-source Support**: Fetch token lists from Status, Uniswap, CoinGecko, and custom sources +- **Privacy-aware**: Respects privacy settings to prevent unwanted network requests +- **Automatic Refresh**: Configurable automatic refresh intervals with ETag support +- **Cross-chain Support**: Manage tokens across multiple blockchain networks +- **Extensible**: Plugin-based parser system for custom token list formats +- **Thread-safe**: Concurrent access support with proper synchronization +- **Caching**: Built-in content caching with ETag support for efficient updates + +## Quick Start + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/status-im/go-wallet-sdk/pkg/tokenlists" + "go.uber.org/zap" +) + +func main() { + // Create configuration + config := tokenlists.DefaultConfig(). + WithChains([]uint64{1, 10, 137}). // Ethereum, Polygon, BSC + WithRemoteListOfTokenListsURL("https://example.com/token-lists.json"). + WithAutoRefreshInterval(30*time.Minute, 3*time.Minute). + WithLogger(zap.NewNop()) + + // Create token list manager + tl, err := tokenlists.NewTokensList(config) + if err != nil { + log.Fatal(err) + } + + // Start the service + ctx := context.Background() + notifyCh := make(chan struct{}, 1) + + if err := tl.Start(ctx, notifyCh); err != nil { + log.Fatal(err) + } + defer tl.Stop(ctx) + + // Wait for initial fetch + select { + case <-notifyCh: + log.Println("Token lists updated") + case <-time.After(10 * time.Second): + log.Println("Timeout waiting for token lists") + } + + // Get all unique tokens + tokens := tl.UniqueTokens() + log.Printf("Found %d unique tokens", len(tokens)) + + // Get tokens for a specific chain + ethereumTokens := tl.GetTokensByChain(1) + log.Printf("Found %d tokens on Ethereum", len(ethereumTokens)) +} +``` + +## Configuration + +The package uses a builder pattern for configuration: + +```go +config := &tokenlists.Config{ + // Required fields + MainList: []byte(`{"tokens": []}`), + MainListID: "status", + Chains: []uint64{1, 10, 137}, + + // Optional fields with defaults + RemoteListOfTokenListsURL: "https://example.com/lists.json", + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + + // Custom components + PrivacyGuard: tokenlists.NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: tokenlists.NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: tokenlists.NewDefaultContentStore(), + Parsers: make(map[string]tokenlists.Parser), +} +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `MainList` | `[]byte` | - | Initial token list data | +| `MainListID` | `string` | - | Identifier for the main token list | +| `Chains` | `[]uint64` | - | Supported blockchain chain IDs | +| `RemoteListOfTokenListsURL` | `string` | - | URL to fetch list of token lists | +| `AutoRefreshInterval` | `time.Duration` | 30 min | How often to refresh token lists | +| `AutoRefreshCheckInterval` | `time.Duration` | 3 min | How often to check if refresh is needed | +| `PrivacyGuard` | `PrivacyGuard` | `NewDefaultPrivacyGuard(false)` | Privacy mode controller | +| `ContentStore` | `ContentStore` | `NewDefaultContentStore()` | Content caching store | +| `Parsers` | `map[string]Parser` | Built-in parsers | Token list format parsers | + +## API Reference + +### TokensList Interface + +```go +type TokensList interface { + // Lifecycle management + Start(ctx context.Context, notifyCh chan struct{}) error + Stop(ctx context.Context) error + + LastRefreshTime() (time.Time, error) + RefreshNow(ctx context.Context) error + + // Privacy management + PrivacyModeUpdated(ctx context.Context) error + + // Token queries + UniqueTokens() []*Token + GetTokenByChainAddress(chainID uint64, addr common.Address) (*Token, bool) + GetTokensByChain(chainID uint64) []*Token + + // Token list queries + TokenLists() []*TokenList + TokenList(id string) (*TokenList, bool) +} +``` + +### Token Structure + +```go +type Token struct { + CrossChainID string `json:"crossChainId"` + ChainID uint64 `json:"chainId"` + Address common.Address `json:"address"` + Decimals uint `json:"decimals"` + Name string `json:"name"` + Symbol string `json:"symbol"` + LogoURI string `json:"logoUri"` + CustomToken bool `json:"custom"` +} +``` + +### TokenList Structure + +```go +type TokenList struct { + Name string `json:"name"` + Timestamp string `json:"timestamp"` + FetchedTimestamp string `json:"fetchedTimestamp"` + Source string `json:"source"` + Version Version `json:"version"` + Tags map[string]interface{} `json:"tags"` + LogoURI string `json:"logoURI"` + Keywords []string `json:"keywords"` + Tokens []*Token `json:"tokens"` +} +``` + +## Privacy Mode + +The package respects privacy settings to prevent unwanted network requests: + +```go +// Enable privacy mode +config := tokenlists.DefaultConfig(). + WithPrivacyGuard(tokenlists.NewDefaultPrivacyGuard(true)) + +// Privacy mode prevents: +// - Automatic token list fetching +// - RefreshNow() calls from making network requests +// - Background refresh worker from running +``` + +## Supported Token List Formats + +### Status Token List +- **Parser**: `StatusTokenListParser` +- **Format**: Status-specific JSON format + +### Standard Token List Formats (uniswap, platform specific coingecko list and others use this format) +- **Parser**: `StandardTokenListParser` +- **Format**: Standard Token List format + +### CoinGecko All Token List (doesn't contain decimals) +- **Parser**: `CoinGeckoAllTokensParser` +- **Format**: CoinGecko API format with chain mapping + +## Custom Parsers (if the list doesn't follow the standard token list format) + +Implement the `Parser` interface to support custom token list formats: + +```go +type CustomParser struct{} + +func (p *CustomParser) Parse(raw []byte, sourceURL string, fetchedAt time.Time) (*TokenList, error) { + // Parse your custom format + var customFormat struct { + Name string `json:"name"` + Tokens []*Token `json:"tokens"` + } + + if err := json.Unmarshal(raw, &customFormat); err != nil { + return nil, err + } + + return &TokenList{ + Name: customFormat.Name, + Timestamp: time.Now().Format(time.RFC3339), + FetchedTimestamp: fetchedAt.Format(time.RFC3339), + Source: sourceURL, + Tokens: customFormat.Tokens, + }, nil +} + +// Register custom parser +config := tokenlists.DefaultConfig(). + WithParsers(map[string]tokenlists.Parser{ + "custom": &CustomParser{}, + }) +``` + +## Error Handling + +The package provides comprehensive error handling: + +```go +tl, err := tokenlists.NewTokensList(config) +if err != nil { + // Handle configuration errors + log.Fatal(err) +} + +if err := tl.Start(ctx, notifyCh); err != nil { + // Handle startup errors + log.Fatal(err) +} + +// Handle refresh errors via notification channel +go func() { + for range notifyCh { + // Token lists updated successfully + log.Println("Token lists refreshed") + } +}() +``` + +## Testing + +The package includes comprehensive tests: + +```bash +# Run all tests +go test ./pkg/tokenlists/... + +# Run specific test +go test ./pkg/tokenlists -run TestTokensList_RefreshNow + +# Run with verbose output +go test ./pkg/tokenlists/... -v +``` diff --git a/pkg/tokenlists/config.go b/pkg/tokenlists/config.go new file mode 100644 index 0000000..9cca09b --- /dev/null +++ b/pkg/tokenlists/config.go @@ -0,0 +1,69 @@ +package tokenlists + +import ( + "time" + + "go.uber.org/zap" +) + +func (c *Config) WithMainList(id string, data []byte) *Config { + c.MainListID = id + c.MainList = data + return c +} + +func (c *Config) WithInitialLists(lists map[string][]byte) *Config { + c.InitialLists = lists + return c +} + +func (c *Config) WithParsers(parsers map[string]Parser) *Config { + c.Parsers = parsers + return c +} + +func (c *Config) WithChains(chains []uint64) *Config { + c.Chains = chains + return c +} + +func (c *Config) WithCoinGeckoChainsMapper(mapper map[string]uint64) *Config { + c.CoinGeckoChainsMapper = mapper + return c +} + +func (c *Config) WithRemoteListOfTokenListsURL(url string) *Config { + c.RemoteListOfTokenListsURL = url + return c +} + +func (c *Config) WithAutoRefreshInterval(interval, checkInterval time.Duration) *Config { + c.AutoRefreshInterval = interval + c.AutoRefreshCheckInterval = checkInterval + return c +} + +func (c *Config) WithLogger(logger *zap.Logger) *Config { + c.logger = logger + return c +} + +func (c *Config) WithPrivacyGuard(guard PrivacyGuard) *Config { + c.PrivacyGuard = guard + return c +} + +func (c *Config) WithLastTokenListsUpdateTimeStore(store LastTokenListsUpdateTimeStore) *Config { + c.LastTokenListsUpdateTimeStore = store + return c +} + +func (c *Config) WithContentStore(store ContentStore) *Config { + c.ContentStore = store + return c +} + +func (c *Config) WithCustomTokenStore(store CustomTokenStore) *Config { + c.CustomTokenStore = store + return c +} diff --git a/pkg/tokenlists/constants.go b/pkg/tokenlists/constants.go new file mode 100644 index 0000000..b0c5433 --- /dev/null +++ b/pkg/tokenlists/constants.go @@ -0,0 +1,90 @@ +package tokenlists + +const ( + EthereumNativeCrossChainID = "eth-native" + EthereumNativeSymbol = "ETH" + EthereumNativeName = "Ethereum" + + BinanceSmartChainNativeCrossChainID = "bsc-native" + BinanceSmartChainNativeSymbol = "BNB" + BinanceSmartChainNativeName = "BNB" + + StatusListOfTokenListsID = "status-list-of-token-lists" // #nosec G101 + + NativeTokenListID = "native" + StatusListID = "status" + UniswapListID = "uniswap" + CoingeckoAllTokensListID = "coingeckoAllTokens" + CoingeckoEthereumListID = "coingeckoEthereum" + CoingeckoOptimismListID = "coingeckoOptimism" + CoingeckoArbitrumListID = "coingeckoArbitrum" + CoingeckoBSCListID = "coingeckoBsc" + CoingeckoBaseListID = "coingeckoBase" + + LocalSourceURL = "local" + + CustomTokenListID = "custom" + + // #nosec G101 + listOfTokenListsSchema = `{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "description": "The timestamp of this list version", + "format": "date-time", + "additionalProperties": false + }, + "version": { + "type": "object", + "description": "The version of the list, used in change detection", + "properties": { + "major": { + "type": "integer" + }, + "minor": { + "type": "integer" + }, + "patch": { + "type": "integer" + } + }, + "required": [ + "major", + "minor", + "patch" + ], + "additionalProperties": false + }, + "tokenLists": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the token list source." + }, + "sourceUrl": { + "type": "string", + "format": "uri", + "description": "URL pointing to the token list source." + }, + "schema": { + "type": "string", + "format": "uri", + "description": "Optional URL pointing to the schema definition of the token list.", + "nullable": true + } + }, + "required": [ + "id", + "sourceUrl" + ], + "additionalProperties": false + } + } + } +}` +) diff --git a/pkg/tokenlists/defaults.go b/pkg/tokenlists/defaults.go new file mode 100644 index 0000000..50f98e4 --- /dev/null +++ b/pkg/tokenlists/defaults.go @@ -0,0 +1,138 @@ +package tokenlists + +import ( + "fmt" + "time" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "go.uber.org/zap" +) + +const ( + defaultAutoRefreshInterval = 30 * time.Minute // interval after which the token lists should be fetched from the remote source (or use the default one if remote source is not set) + defaultAutoRefreshCheckInterval = 3 * time.Minute // interval after which the auto-refresh should be checked if it should trigger the refresh +) + +var DefaultParsers = map[string]Parser{ + StatusListID: &StatusTokenListParser{}, + UniswapListID: &StandardTokenListParser{}, // Uniswap token list follows the StandardTokenList format + CoingeckoAllTokensListID: NewCoinGeckoAllTokensParser(DefaultCoinGeckoChainsMapper), + // Coingecko platform specific token lists follow the StandardTokenList format + CoingeckoEthereumListID: &StandardTokenListParser{}, + CoingeckoOptimismListID: &StandardTokenListParser{}, + CoingeckoArbitrumListID: &StandardTokenListParser{}, + CoingeckoBSCListID: &StandardTokenListParser{}, + CoingeckoBaseListID: &StandardTokenListParser{}, +} + +// DefaultCoinGeckoChainsMapper provides the default mapping from CoinGecko platform names to chain IDs. +var DefaultCoinGeckoChainsMapper = map[string]common.ChainID{ + "ethereum": common.EthereumMainnet, + "optimistic-ethereum": common.OptimismMainnet, + "arbitrum-one": common.ArbitrumMainnet, + "binance-smart-chain": common.BSCMainnet, + "base": common.BaseMainnet, +} + +// defaultPrivacyGuard provides a default privacy guard implementation. +type defaultPrivacyGuard struct { + privacyOn bool +} + +func (p *defaultPrivacyGuard) IsPrivacyOn() (bool, error) { + return p.privacyOn, nil +} + +// SetPrivacyMode is not the interface method, but it's here to be able to set the privacy on for testing +func (p *defaultPrivacyGuard) SetPrivacyMode(privacyOn bool) { + p.privacyOn = privacyOn +} + +func NewDefaultPrivacyGuard(initialPrivacy bool) PrivacyGuard { + return &defaultPrivacyGuard{privacyOn: initialPrivacy} +} + +// defaultLastTokenListsUpdateTimeStore provides a default last token lists update time store implementation. +type defaultLastTokenListsUpdateTimeStore struct { + lastUpdateTime time.Time +} + +func (s *defaultLastTokenListsUpdateTimeStore) Get() (time.Time, error) { + return s.lastUpdateTime, nil +} + +func (s *defaultLastTokenListsUpdateTimeStore) Set(time time.Time) error { + s.lastUpdateTime = time + return nil +} + +func NewDefaultLastTokenListsUpdateTimeStore() LastTokenListsUpdateTimeStore { + return &defaultLastTokenListsUpdateTimeStore{} +} + +// defaultContentStore provides a default content store implementation. +type defaultContentStore struct { + content map[string]Content +} + +func (s *defaultContentStore) GetEtag(id string) (string, error) { + content, ok := s.content[id] + if !ok { + return "", fmt.Errorf("etag not found") + } + return content.Etag, nil +} + +func (s *defaultContentStore) Get(id string) (Content, error) { + content, ok := s.content[id] + if !ok { + return Content{}, fmt.Errorf("content not found") + } + return content, nil +} + +func (s *defaultContentStore) Set(id string, content Content) error { + s.content[id] = content + return nil +} + +func (s *defaultContentStore) GetAll() (map[string]Content, error) { + return s.content, nil +} + +func NewDefaultContentStore() ContentStore { + return &defaultContentStore{ + content: make(map[string]Content), + } +} + +// defaultCustomTokenStore provides a default custom token store implementation. +type defaultCustomTokenStore struct { + customTokens []*Token +} + +func (s *defaultCustomTokenStore) GetAll() ([]*Token, error) { + return s.customTokens, nil +} + +func NewDefaultCustomTokenStore() CustomTokenStore { + return &defaultCustomTokenStore{} +} + +// DefaultConfig provides sensible defaults for TokensList configuration. +func DefaultConfig() *Config { + return &Config{ + Chains: common.AllChains, + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + MainListID: StatusListID, + AutoRefreshInterval: defaultAutoRefreshInterval, + AutoRefreshCheckInterval: defaultAutoRefreshCheckInterval, + logger: zap.NewNop(), + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + CustomTokenStore: NewDefaultCustomTokenStore(), + Parsers: make(map[string]Parser), + } +} diff --git a/pkg/tokenlists/defaults_test.go b/pkg/tokenlists/defaults_test.go new file mode 100644 index 0000000..ce43a9e --- /dev/null +++ b/pkg/tokenlists/defaults_test.go @@ -0,0 +1,304 @@ +package tokenlists + +import ( + "testing" + "time" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestDefaultParsers(t *testing.T) { + assert.NotNil(t, DefaultParsers) + + assert.Contains(t, DefaultParsers, StatusListID) + assert.NotNil(t, DefaultParsers[StatusListID]) + + assert.Contains(t, DefaultParsers, UniswapListID) + assert.NotNil(t, DefaultParsers[UniswapListID]) + + assert.Contains(t, DefaultParsers, CoingeckoAllTokensListID) + assert.NotNil(t, DefaultParsers[CoingeckoAllTokensListID]) + + assert.Contains(t, DefaultParsers, CoingeckoEthereumListID) + assert.NotNil(t, DefaultParsers[CoingeckoEthereumListID]) + + assert.Contains(t, DefaultParsers, CoingeckoOptimismListID) + assert.NotNil(t, DefaultParsers[CoingeckoOptimismListID]) + + assert.Contains(t, DefaultParsers, CoingeckoArbitrumListID) + assert.NotNil(t, DefaultParsers[CoingeckoArbitrumListID]) + + assert.Contains(t, DefaultParsers, CoingeckoBSCListID) + assert.NotNil(t, DefaultParsers[CoingeckoBSCListID]) + + assert.Contains(t, DefaultParsers, CoingeckoBaseListID) + assert.NotNil(t, DefaultParsers[CoingeckoBaseListID]) +} + +func TestDefaultCoinGeckoChainsMapper(t *testing.T) { + assert.NotEmpty(t, DefaultCoinGeckoChainsMapper) + + assert.Contains(t, DefaultCoinGeckoChainsMapper, "ethereum") + assert.Equal(t, common.EthereumMainnet, DefaultCoinGeckoChainsMapper["ethereum"]) + + assert.Contains(t, DefaultCoinGeckoChainsMapper, "optimistic-ethereum") + assert.Equal(t, common.OptimismMainnet, DefaultCoinGeckoChainsMapper["optimistic-ethereum"]) + + assert.Contains(t, DefaultCoinGeckoChainsMapper, "arbitrum-one") + assert.Equal(t, common.ArbitrumMainnet, DefaultCoinGeckoChainsMapper["arbitrum-one"]) + + assert.Contains(t, DefaultCoinGeckoChainsMapper, "binance-smart-chain") + assert.Equal(t, common.BSCMainnet, DefaultCoinGeckoChainsMapper["binance-smart-chain"]) + + assert.Contains(t, DefaultCoinGeckoChainsMapper, "base") + assert.Equal(t, common.BaseMainnet, DefaultCoinGeckoChainsMapper["base"]) +} + +func TestNewDefaultPrivacyGuard(t *testing.T) { + guard := NewDefaultPrivacyGuard(false) + assert.NotNil(t, guard) + privacyOn, err := guard.IsPrivacyOn() + assert.NoError(t, err) + assert.False(t, privacyOn) + + guard = NewDefaultPrivacyGuard(true) + assert.NotNil(t, guard) + privacyOn, err = guard.IsPrivacyOn() + assert.NoError(t, err) + assert.True(t, privacyOn) +} + +func TestNewDefaultLastTokenListsUpdateTimeStore(t *testing.T) { + store := NewDefaultLastTokenListsUpdateTimeStore() + assert.NotNil(t, store) +} + +func TestDefaultLastTokenListsUpdateTimeStore_GetSet(t *testing.T) { + store := NewDefaultLastTokenListsUpdateTimeStore() + + initialTime, err := store.Get() + assert.NoError(t, err) + assert.Equal(t, time.Time{}, initialTime) + + testTime := time.Date(2025, 9, 30, 12, 0, 0, 0, time.UTC) + err = store.Set(testTime) + assert.NoError(t, err) + + retrievedTime, err := store.Get() + assert.NoError(t, err) + assert.Equal(t, testTime, retrievedTime) +} + +func TestNewDefaultContentStore(t *testing.T) { + store := NewDefaultContentStore() + assert.NotNil(t, store) +} + +func TestDefaultContentStore_Operations(t *testing.T) { + store := NewDefaultContentStore() + + etag, err := store.GetEtag("test-id") + assert.Error(t, err) + assert.Empty(t, etag) + + content, err := store.Get("test-id") + assert.Error(t, err) + assert.Equal(t, Content{}, content) + + allContent, err := store.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 0) + + testContent := Content{ + SourceURL: "https://example.com/test.json", + Etag: "test-etag", + Data: []byte("test data"), + Fetched: time.Now(), + } + + err = store.Set("test-id", testContent) + assert.NoError(t, err) + + etag, err = store.GetEtag("test-id") + assert.NoError(t, err) + assert.Equal(t, "test-etag", etag) + + content, err = store.Get("test-id") + assert.NoError(t, err) + assert.Equal(t, testContent, content) + + allContent, err = store.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 1) + assert.Contains(t, allContent, "test-id") +} + +func TestNewDefaultCustomTokenStore(t *testing.T) { + store := NewDefaultCustomTokenStore() + assert.NotNil(t, store) +} + +func TestDefaultCustomTokenStore_GetAll(t *testing.T) { + store := &defaultCustomTokenStore{ + customTokens: []*Token{ + { + Symbol: "CUSTOM1", + Name: "Custom Token 1", + ChainID: 1, + }, + { + Symbol: "CUSTOM2", + Name: "Custom Token 2", + ChainID: 137, + }, + }, + } + + tokens, err := store.GetAll() + assert.NoError(t, err) + assert.Len(t, tokens, 2) + assert.Equal(t, "CUSTOM1", tokens[0].Symbol) + assert.Equal(t, "CUSTOM2", tokens[1].Symbol) +} + +func TestDefaultConfig(t *testing.T) { + config := DefaultConfig() + assert.NotNil(t, config) + + // Test default values + assert.Equal(t, common.AllChains, config.Chains) + assert.Equal(t, DefaultCoinGeckoChainsMapper, config.CoinGeckoChainsMapper) + assert.Equal(t, StatusListID, config.MainListID) + assert.Equal(t, defaultAutoRefreshInterval, config.AutoRefreshInterval) + assert.Equal(t, defaultAutoRefreshCheckInterval, config.AutoRefreshCheckInterval) + assert.NotNil(t, config.logger) + assert.NotNil(t, config.PrivacyGuard) + assert.NotNil(t, config.LastTokenListsUpdateTimeStore) + assert.NotNil(t, config.ContentStore) + assert.NotNil(t, config.CustomTokenStore) + assert.NotNil(t, config.Parsers) +} + +func TestConfig_WithMainList(t *testing.T) { + config := &Config{} + mainListData := []byte("test data") + mainListID := "test-main-list" + + result := config.WithMainList(mainListID, mainListData) + assert.Equal(t, config, result) + assert.Equal(t, mainListID, config.MainListID) + assert.Equal(t, mainListData, config.MainList) +} + +func TestConfig_WithInitialLists(t *testing.T) { + config := &Config{} + initialLists := map[string][]byte{ + "list1": []byte("data1"), + "list2": []byte("data2"), + } + + result := config.WithInitialLists(initialLists) + assert.Equal(t, config, result) + assert.Equal(t, initialLists, config.InitialLists) +} + +func TestConfig_WithParsers(t *testing.T) { + config := &Config{} + parsers := map[string]Parser{ + "parser1": &StatusTokenListParser{}, + "parser2": &StandardTokenListParser{}, + } + + result := config.WithParsers(parsers) + assert.Equal(t, config, result) + assert.Equal(t, parsers, config.Parsers) +} + +func TestConfig_WithChains(t *testing.T) { + config := &Config{} + chains := []uint64{1, 137, 56} + + result := config.WithChains(chains) + assert.Equal(t, config, result) + assert.Equal(t, chains, config.Chains) +} + +func TestConfig_WithCoinGeckoChainsMapper(t *testing.T) { + config := &Config{} + mapper := map[string]uint64{ + "ethereum": 1, + "polygon": 137, + } + + result := config.WithCoinGeckoChainsMapper(mapper) + assert.Equal(t, config, result) + assert.Equal(t, mapper, config.CoinGeckoChainsMapper) +} + +func TestConfig_WithRemoteListOfTokenListsURL(t *testing.T) { + config := &Config{} + url := "https://example.com/tokenlists.json" + + result := config.WithRemoteListOfTokenListsURL(url) + assert.Equal(t, config, result) + assert.Equal(t, url, config.RemoteListOfTokenListsURL) +} + +func TestConfig_WithAutoRefreshInterval(t *testing.T) { + config := &Config{} + interval := 1 * time.Hour + checkInterval := 10 * time.Minute + + result := config.WithAutoRefreshInterval(interval, checkInterval) + assert.Equal(t, config, result) + assert.Equal(t, interval, config.AutoRefreshInterval) + assert.Equal(t, checkInterval, config.AutoRefreshCheckInterval) +} + +func TestConfig_WithLogger(t *testing.T) { + config := &Config{} + logger := zap.NewNop() + + result := config.WithLogger(logger) + assert.Equal(t, config, result) + assert.Equal(t, logger, config.logger) +} + +func TestConfig_WithPrivacyGuard(t *testing.T) { + config := &Config{} + guard := &defaultPrivacyGuard{} + + result := config.WithPrivacyGuard(guard) + assert.Equal(t, config, result) + assert.Equal(t, guard, config.PrivacyGuard) +} + +func TestConfig_WithLastTokenListsUpdateTimeStore(t *testing.T) { + config := &Config{} + store := NewDefaultLastTokenListsUpdateTimeStore() + + result := config.WithLastTokenListsUpdateTimeStore(store) + assert.Equal(t, config, result) + assert.Equal(t, store, config.LastTokenListsUpdateTimeStore) +} + +func TestConfig_WithContentStore(t *testing.T) { + config := &Config{} + store := &defaultContentStore{} + + result := config.WithContentStore(store) + assert.Equal(t, config, result) + assert.Equal(t, store, config.ContentStore) +} + +func TestConfig_WithCustomTokenStore(t *testing.T) { + config := &Config{} + store := &defaultCustomTokenStore{} + + result := config.WithCustomTokenStore(store) + assert.Equal(t, config, result) + assert.Equal(t, store, config.CustomTokenStore) +} diff --git a/pkg/tokenlists/errors.go b/pkg/tokenlists/errors.go new file mode 100644 index 0000000..58cc5f9 --- /dev/null +++ b/pkg/tokenlists/errors.go @@ -0,0 +1,28 @@ +package tokenlists + +import ( + "errors" +) + +var ( + ErrConfigNotProvided = errors.New("config not provided") + ErrLoggerNotProvided = errors.New("logger not provided") + ErrMainListNotProvided = errors.New("main list not provided") + ErrMainListIDNotProvided = errors.New("main list ID not provided") + ErrMainListParserNotFound = errors.New("main list parser not found") + ErrMainListIDCannotBeUsedAsInitialListID = errors.New("main list ID cannot be used as an initial list ID") + ErrInitialListParserNotFound = errors.New("initial list parser not found") + ErrChainsNotProvided = errors.New("chains not provided") + ErrAutoRefreshCheckIntervalGreaterThanInterval = errors.New("check interval must be <= refresh interval") + ErrPrivacyGuardNotProvided = errors.New("privacy guard not provided") + ErrLastTokenListsUpdateTimeStoreNotProvided = errors.New("last token lists update time store not provided") + ErrContentStoreNotProvided = errors.New("content store not provided") + ErrChainNotAllowed = errors.New("chain not allowed") + ErrInvalidAddressLength = errors.New("invalid address length") + ErrSymbolCannotBeEmpty = errors.New("symbol cannot be empty") + ErrDecimalsExceedsMaximum = errors.New("decimals exceeds maximum") + ErrInvalidLogoURI = errors.New("invalid logo URI") + ErrTokenListDoesNotMatchSchema = errors.New("token list does not match schema") + ErrChannelClosed = errors.New("channel is closed") + ErrTokenNotProvided = errors.New("token not provided") +) diff --git a/pkg/tokenlists/fetcher.go b/pkg/tokenlists/fetcher.go new file mode 100644 index 0000000..f4ec6be --- /dev/null +++ b/pkg/tokenlists/fetcher.go @@ -0,0 +1,69 @@ +package tokenlists + +import ( + "context" + "sync" + + "go.uber.org/zap" +) + +type tokenListsFetcher struct { + config *Config + httpClient *HTTPClient +} + +func newTokenListsFetcher(config *Config) *tokenListsFetcher { + return &tokenListsFetcher{ + config: config, + httpClient: NewHTTPClient(), + } +} + +func (t *tokenListsFetcher) fetchAndStore(ctx context.Context) (int, error) { + remoteListOfTokenLists, err := t.resolveListOfTokenLists(ctx) + if err != nil || len(remoteListOfTokenLists.TokenLists) == 0 { + t.config.logger.Error("cannot resolve list of token lists", zap.Error(err)) + return 0, nil + } + + var wg sync.WaitGroup + tokenChannel := make(chan fetchedTokenList, len(remoteListOfTokenLists.TokenLists)) + + for _, tokenList := range remoteListOfTokenLists.TokenLists { + wg.Add(1) + go func(ctx context.Context) { + defer wg.Done() + + dbEtag, err := t.config.ContentStore.GetEtag(tokenList.ID) + if err != nil { + // don't return, but fetch using an empty etag + t.config.logger.Error("cannot get cached etag for token list", zap.String("list-id", tokenList.ID)) + } + err = t.fetchTokenList(ctx, tokenList, dbEtag, tokenChannel) + if err != nil { + t.config.logger.Error("failed to fetch token list", zap.Error(err), zap.String("list-id", tokenList.ID)) + } + }(ctx) + } + + wg.Wait() + close(tokenChannel) + + var successfullyFetchedListsCount int + for fetchedList := range tokenChannel { + if err := t.config.ContentStore.Set(fetchedList.ID, Content{ + SourceURL: fetchedList.SourceURL, + Etag: fetchedList.Etag, + Data: fetchedList.JsonData, + Fetched: fetchedList.Fetched, + }); err != nil { + t.config.logger.Error("failed to store token list", zap.Error(err)) + } else { + successfullyFetchedListsCount++ + } + } + + tokenChannel = nil + + return successfullyFetchedListsCount, nil +} diff --git a/pkg/tokenlists/fetcher_test.go b/pkg/tokenlists/fetcher_test.go new file mode 100644 index 0000000..a854706 --- /dev/null +++ b/pkg/tokenlists/fetcher_test.go @@ -0,0 +1,128 @@ +package tokenlists + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestNewTokenListsFetcher(t *testing.T) { + config := &Config{ + logger: zap.NewNop(), + } + + fetcher := newTokenListsFetcher(config) + assert.NotNil(t, fetcher) + assert.NotNil(t, fetcher.httpClient) +} + +func TestTokenListsFetcher_FetchAndStore(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + RemoteListOfTokenListsURL: server.URL + listOfTokenListsURL, + } + + fetcher := newTokenListsFetcher(config) + + count, err := fetcher.fetchAndStore(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 2, count, "Should fetch and store two token lists") + + content, err := config.ContentStore.Get("status") + assert.NoError(t, err) + assert.Equal(t, statusTokenListJsonResponse, string(content.Data)) + + content, err = config.ContentStore.Get("uniswap") + assert.NoError(t, err) + assert.Equal(t, uniswapTokenListJsonResponse, string(content.Data)) +} + +func TestTokenListsFetcher_FetchAndStore_NoRemoteURL(t *testing.T) { + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + } + + fetcher := newTokenListsFetcher(config) + + count, err := fetcher.fetchAndStore(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestTokenListsFetcher_FetchAndStore_EmptyTokenLists(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + RemoteListOfTokenListsURL: server.URL + emptyTokenListsURL, + } + + fetcher := newTokenListsFetcher(config) + + count, err := fetcher.fetchAndStore(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestTokenListsFetcher_FetchAndStore_ContextCancellation(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + RemoteListOfTokenListsURL: server.URL + delayedResponseURL, + } + + fetcher := newTokenListsFetcher(config) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + count, err := fetcher.fetchAndStore(ctx) + assert.NoError(t, err) + assert.Equal(t, 0, count) +} + +func TestTokenListsFetcher_FetchAndStore_PartialFailures(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + RemoteListOfTokenListsURL: server.URL + listOfTokenListsSomeWrongUrlsURL, + } + + fetcher := newTokenListsFetcher(config) + + count, err := fetcher.fetchAndStore(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 1, count) + + content, err := config.ContentStore.Get("status") + assert.NoError(t, err) + assert.NotNil(t, content.Data) + assert.Equal(t, statusTokenListJsonResponse, string(content.Data)) + + content, err = config.ContentStore.Get("invalid-list") + assert.Error(t, err) + assert.Nil(t, content.Data) +} diff --git a/pkg/tokenlists/httpclient.go b/pkg/tokenlists/httpclient.go new file mode 100644 index 0000000..3fd9503 --- /dev/null +++ b/pkg/tokenlists/httpclient.go @@ -0,0 +1,98 @@ +package tokenlists + +import ( + "compress/gzip" + "context" + "fmt" + "io" + "log" + "net/http" + "time" +) + +const ( + defaultRequestTimeout = 5 * time.Second + defaultIdleConnTimeout = 90 * time.Second + defaultMaxIdleConns = 10 +) + +// HTTPClient represents an HTTP client with configurable options +type HTTPClient struct { + client *http.Client +} + +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + client: &http.Client{ + Timeout: defaultRequestTimeout, + Transport: &http.Transport{ + MaxIdleConns: defaultMaxIdleConns, + IdleConnTimeout: defaultIdleConnTimeout, + DisableCompression: false, + }, + }, + } +} + +// DoGetRequestWithEtag performs a GET request with the given URL and parameters +// If etag is not empty, it will add an If-None-Match header to the request +// If the server responds with a 304 status code (`http.StatusNotModified`), it will return an empty body and the same etag +func (c *HTTPClient) DoGetRequestWithEtag(ctx context.Context, url string, etag string) ([]byte, string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to create request: %w", err) + } + + if etag != "" { + req.Header.Set("If-None-Match", etag) + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch %s: %w", url, err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Println(err.Error()) + } + }() + + if resp.StatusCode == http.StatusNotModified { + return nil, etag, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, url) + } + + newETag := resp.Header.Get("ETag") + + body, err := c.readResponse(resp) + if err != nil { + return nil, "", fmt.Errorf("failed to read response body: %w", err) + } + + return body, newETag, nil +} + +func (c *HTTPClient) readResponse(resp *http.Response) ([]byte, error) { + var reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + var gzipErr error + reader, gzipErr = gzip.NewReader(resp.Body) + if gzipErr != nil { + return nil, gzipErr + } + defer func() { + if err := reader.Close(); err != nil { + log.Println(err.Error()) + } + }() + } + body, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + return body, nil +} diff --git a/pkg/tokenlists/httpclient_test.go b/pkg/tokenlists/httpclient_test.go new file mode 100644 index 0000000..cac60ea --- /dev/null +++ b/pkg/tokenlists/httpclient_test.go @@ -0,0 +1,173 @@ +package tokenlists + +import ( + "compress/gzip" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewHTTPClient(t *testing.T) { + client := NewHTTPClient() + assert.NotNil(t, client) + assert.NotNil(t, client.client) + assert.Equal(t, defaultRequestTimeout, client.client.Timeout) +} + +func TestHTTPClient_DoGetRequestWithEtag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if etag := r.Header.Get("If-None-Match"); etag != "" { + if etag == "test-etag" { + w.WriteHeader(http.StatusNotModified) + return + } + } + + w.Header().Set("ETag", "new-etag") + _, err := w.Write([]byte("test response")) + assert.NoError(t, err) + })) + defer server.Close() + + client := NewHTTPClient() + ctx := context.Background() + + data, etag, err := client.DoGetRequestWithEtag(ctx, server.URL, "") + assert.NoError(t, err) + assert.Equal(t, "test response", string(data)) + assert.Equal(t, "new-etag", etag) + + data, etag, err = client.DoGetRequestWithEtag(ctx, server.URL, "test-etag") + assert.NoError(t, err) + assert.Empty(t, data) + assert.Equal(t, "test-etag", etag) + + data, etag, err = client.DoGetRequestWithEtag(ctx, server.URL, "non-matching-etag") + assert.NoError(t, err) + assert.Equal(t, "test response", string(data)) + assert.Equal(t, "new-etag", etag) +} + +func TestHTTPClient_DoGetRequestWithEtag_Error(t *testing.T) { + client := NewHTTPClient() + ctx := context.Background() + + data, etag, err := client.DoGetRequestWithEtag(ctx, "invalid-url", "") + assert.Error(t, err) + assert.Empty(t, data) + assert.Empty(t, etag) +} + +func TestHTTPClient_DoGetRequestWithEtag_NonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte("not found")) + assert.NoError(t, err) + })) + defer server.Close() + + client := NewHTTPClient() + ctx := context.Background() + + data, etag, err := client.DoGetRequestWithEtag(ctx, server.URL, "") + assert.Error(t, err) + assert.Empty(t, data) + assert.Empty(t, etag) + assert.Contains(t, err.Error(), "unexpected status code 404") +} + +func TestHTTPClient_DoGetRequestWithEtag_Timeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + _, err := w.Write([]byte("delayed response")) + assert.NoError(t, err) + })) + defer server.Close() + + client := &HTTPClient{ + client: &http.Client{ + Timeout: 10 * time.Millisecond, + }, + } + + ctx := context.Background() + + data, etag, err := client.DoGetRequestWithEtag(ctx, server.URL, "") + assert.Error(t, err) + assert.Empty(t, data) + assert.Empty(t, etag) + assert.True(t, err != nil) +} + +func TestHTTPClient_ReadResponse_Plain(t *testing.T) { + response := &http.Response{ + Body: io.NopCloser(strings.NewReader("plain text response")), + } + + client := NewHTTPClient() + data, err := client.readResponse(response) + assert.NoError(t, err) + assert.Equal(t, "plain text response", string(data)) +} + +func TestHTTPClient_ReadResponse_Gzip(t *testing.T) { + var buf strings.Builder + gw := gzip.NewWriter(&buf) + _, err := gw.Write([]byte("gzipped content")) + assert.NoError(t, err) + err = gw.Close() + assert.NoError(t, err) + + response := &http.Response{ + Body: io.NopCloser(strings.NewReader(buf.String())), + Header: http.Header{ + "Content-Encoding": []string{"gzip"}, + }, + } + + client := NewHTTPClient() + data, err := client.readResponse(response) + assert.NoError(t, err) + assert.Equal(t, "gzipped content", string(data)) +} + +func TestHTTPClient_ReadResponse_GzipError(t *testing.T) { + response := &http.Response{ + Body: io.NopCloser(strings.NewReader("invalid gzip content")), + Header: http.Header{ + "Content-Encoding": []string{"gzip"}, + }, + } + + client := NewHTTPClient() + data, err := client.readResponse(response) + assert.Error(t, err) + assert.Empty(t, data) +} + +func TestHTTPClient_ReadResponse_ReadError(t *testing.T) { + response := &http.Response{ + Body: &failingReader{}, + } + + client := NewHTTPClient() + data, err := client.readResponse(response) + assert.Error(t, err) + assert.Empty(t, data) +} + +type failingReader struct{} + +func (f *failingReader) Read(p []byte) (n int, err error) { + return 0, assert.AnError +} + +func (f *failingReader) Close() error { + return nil +} diff --git a/pkg/tokenlists/parser_coingecko_all_tokens.go b/pkg/tokenlists/parser_coingecko_all_tokens.go new file mode 100644 index 0000000..414082a --- /dev/null +++ b/pkg/tokenlists/parser_coingecko_all_tokens.go @@ -0,0 +1,75 @@ +package tokenlists + +import ( + "encoding/json" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// Note: +// Keeping this parser here, but it doesn't provide decimals (they all will be 0). +// This parser can be updated to use multicall3 (more in pkg/contracts/multicall3) and fetch the decimals from contracts. + +// CoinGeckoAllTokens represents a token in CoinGecko format. +type CoinGeckoAllTokens struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Platforms map[string]string `json:"platforms"` +} + +// CoinGeckoAllTokensParser parses tokens from CoinGecko format. +type CoinGeckoAllTokensParser struct { + chainsMapper map[string]uint64 +} + +// NewCoinGeckoAllTokensParser creates a new CoinGeckoAllTokensParser with the given chains mapper. +func NewCoinGeckoAllTokensParser(chainsMapper map[string]uint64) *CoinGeckoAllTokensParser { + return &CoinGeckoAllTokensParser{ + chainsMapper: chainsMapper, + } +} + +// Parse parses raw bytes as CoinGecko tokens and converts to Token objects. +func (p *CoinGeckoAllTokensParser) Parse(raw []byte, sourceURL string, fetchedAt time.Time, supportedChains []uint64) (*TokenList, error) { + var tokens []CoinGeckoAllTokens + if err := json.Unmarshal(raw, &tokens); err != nil { + return nil, err + } + + result := &TokenList{ + Source: sourceURL, + Tokens: make([]*Token, 0), + } + + if !fetchedAt.IsZero() { + result.FetchedTimestamp = fetchedAt.Format(time.RFC3339) + } + + for _, t := range tokens { + for platform, address := range t.Platforms { + chainID, exists := p.chainsMapper[platform] + if !exists { + continue + } + + if !common.IsHexAddress(address) || !slices.Contains(supportedChains, chainID) { + continue + } + + token := Token{ + ChainID: chainID, + Address: common.HexToAddress(address), + Name: t.Name, + Symbol: t.Symbol, + // CoinGecko doesn't provide decimals, logo URI, etc. + } + + result.Tokens = append(result.Tokens, &token) + } + } + + return result, nil +} diff --git a/pkg/tokenlists/parser_coingecko_all_tokens_test.go b/pkg/tokenlists/parser_coingecko_all_tokens_test.go new file mode 100644 index 0000000..078c8aa --- /dev/null +++ b/pkg/tokenlists/parser_coingecko_all_tokens_test.go @@ -0,0 +1,144 @@ +package tokenlists + +import ( + "strings" + "testing" + "time" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "github.com/stretchr/testify/assert" +) + +func TestNewCoinGeckoAllTokensParser(t *testing.T) { + chainsMapper := map[string]uint64{ + "ethereum": 1, + "polygon": 137, + } + + parser := NewCoinGeckoAllTokensParser(chainsMapper) + assert.NotNil(t, parser) + assert.Equal(t, chainsMapper, parser.chainsMapper) +} + +func TestCoinGeckoAllTokensParser_Parse(t *testing.T) { + parser := NewCoinGeckoAllTokensParser(DefaultCoinGeckoChainsMapper) + + tests := []struct { + name string + raw []byte + sourceURL string + useFetchedTimestamp bool + fetchedTokenList fetchedTokenList + expectedTokenList TokenList + }{ + { + name: "valid coingecko token list with fetched timestamp", + raw: []byte(coingeckoTokensJsonResponse), + sourceURL: "https://example.com/coingecko-token-list.json", + useFetchedTimestamp: true, + fetchedTokenList: fetchedCoingeckoTokenList, + expectedTokenList: coingeckoTokenList, + }, + { + name: "valid coingecko token list without fetched timestamp", + raw: []byte(coingeckoTokensJsonResponse), + sourceURL: "https://example.com/coingecko-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedCoingeckoTokenList, + expectedTokenList: coingeckoTokenList, + }, + { + name: "invalid JSON", + raw: []byte(coingeckoTokensJsonResponseInvalidTokens), + sourceURL: "https://example.com/coingecko-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedCoingeckoTokenListInvalidTokens, + expectedTokenList: coingeckoTokenListInvalidTokens, + }, + { + name: "empty tokens list", + raw: []byte("[]"), + sourceURL: "https://example.com/coingecko-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedTokenList{tokenList: tokenList{SourceURL: "https://example.com/coingecko-token-list.json"}}, + expectedTokenList: TokenList{Source: "https://example.com/coingecko-token-list.json"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timestamp := time.Time{} + if tt.useFetchedTimestamp { + timestamp = time.Now() + } + got, err := parser.Parse(tt.raw, tt.sourceURL, timestamp, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, tt.expectedTokenList.Name, got.Name) + assert.Equal(t, tt.expectedTokenList.Timestamp, got.Timestamp) + if tt.useFetchedTimestamp { + assert.Equal(t, timestamp.Format(time.RFC3339), got.FetchedTimestamp) + } else { + assert.Equal(t, tt.expectedTokenList.Timestamp, got.Timestamp) + } + assert.Equal(t, tt.expectedTokenList.Source, got.Source) + assert.Equal(t, tt.expectedTokenList.Version, got.Version) + assert.Equal(t, tt.expectedTokenList.Tags, got.Tags) + assert.Equal(t, tt.expectedTokenList.LogoURI, got.LogoURI) + assert.Equal(t, tt.expectedTokenList.Keywords, got.Keywords) + assert.Len(t, got.Tokens, len(tt.expectedTokenList.Tokens)) + + for _, expectedToken := range tt.expectedTokenList.Tokens { + found := false + for _, actualToken := range got.Tokens { + if actualToken.ChainID == expectedToken.ChainID && actualToken.Address == expectedToken.Address { + found = true + assert.Equal(t, expectedToken.CrossChainID, actualToken.CrossChainID) + assert.Equal(t, expectedToken.ChainID, actualToken.ChainID) + assert.Equal(t, strings.ToLower(expectedToken.Address.String()), strings.ToLower(actualToken.Address.String())) + assert.Equal(t, expectedToken.Name, actualToken.Name) + assert.Equal(t, expectedToken.Symbol, actualToken.Symbol) + assert.Equal(t, expectedToken.Decimals, actualToken.Decimals) + assert.Equal(t, strings.ToLower(expectedToken.LogoURI), strings.ToLower(actualToken.LogoURI)) + break + } + } + assert.True(t, found) + } + }) + } +} + +func TestCoinGeckoAllTokensParser_Parse_InvalidJSON(t *testing.T) { + parser := &CoinGeckoAllTokensParser{} + + raw := []byte(`{invalid json`) + _, err := parser.Parse(raw, "https://example.com/invalid.json", time.Time{}, common.AllChains) + assert.Error(t, err) +} + +func TestCoinGeckoAllTokensParser_Parse_MissingFields(t *testing.T) { + parser := &CoinGeckoAllTokensParser{} + + raw := []byte(``) + got, err := parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.Error(t, err) + assert.Nil(t, got) + + raw = []byte(`[]`) + got, err = parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got.Tokens) + + raw = []byte(`[{ + "id": "usd-coin", + "symbol-wrong": "usdc", + "name-wrong": "USDC", + "platforms": {}}]`) + got, err = parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got.Tokens) +} diff --git a/pkg/tokenlists/parser_standard.go b/pkg/tokenlists/parser_standard.go new file mode 100644 index 0000000..b2ec7d2 --- /dev/null +++ b/pkg/tokenlists/parser_standard.go @@ -0,0 +1,55 @@ +package tokenlists + +import ( + "encoding/json" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// StandardTokenListParser parses tokens in the StandardTokenList format. +type StandardTokenListParser struct{} + +// Parse parses raw bytes as a StandardTokenList and converts to Token objects. +func (p *StandardTokenListParser) Parse(raw []byte, sourceURL string, fetchedAt time.Time, supportedChains []uint64) (*TokenList, error) { + var tokenList StandardTokenList + if err := json.Unmarshal(raw, &tokenList); err != nil { + return nil, err + } + + result := &TokenList{ + Name: tokenList.Name, + Timestamp: tokenList.Timestamp, + FetchedTimestamp: tokenList.Timestamp, // by default (if fetchedAt is not provided) the list's `FetchedTimestamp` is the list's `Timestamp` (used for local lists) + Source: sourceURL, + Version: tokenList.Version, + Tags: tokenList.Tags, + LogoURI: tokenList.LogoURI, + Keywords: tokenList.Keywords, + Tokens: make([]*Token, 0), + } + + if !fetchedAt.IsZero() { + result.FetchedTimestamp = fetchedAt.Format(time.RFC3339) + } + + for _, t := range tokenList.Tokens { + if !common.IsHexAddress(t.Address) || !slices.Contains(supportedChains, t.ChainID) { + continue + } + + token := Token{ + ChainID: t.ChainID, + Address: common.HexToAddress(t.Address), + Name: t.Name, + Symbol: t.Symbol, + Decimals: t.Decimals, + LogoURI: t.LogoURI, + } + + result.Tokens = append(result.Tokens, &token) + } + + return result, nil +} diff --git a/pkg/tokenlists/parser_standard_test.go b/pkg/tokenlists/parser_standard_test.go new file mode 100644 index 0000000..d96a3fb --- /dev/null +++ b/pkg/tokenlists/parser_standard_test.go @@ -0,0 +1,133 @@ +package tokenlists + +import ( + "strings" + "testing" + "time" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "github.com/stretchr/testify/assert" +) + +// Uniswap token list follows the StandardTokenList format, so we can use the StandardTokenListParser to parse it. +func TestStandardTokenListParser_Parse(t *testing.T) { + parser := &StandardTokenListParser{} + + tests := []struct { + name string + raw []byte + sourceURL string + useFetchedTimestamp bool + fetchedTokenList fetchedTokenList + expectedTokenList TokenList + }{ + { + name: "valid uniswap token list with fetched timestamp", + raw: []byte(uniswapTokenListJsonResponse2), + sourceURL: "https://example.com/uniswap-token-list.json", + useFetchedTimestamp: true, + fetchedTokenList: fetchedUniswapTokenList2, + expectedTokenList: uniswapTokenList2, + }, + { + name: "valid status token list without fetched timestamp", + raw: []byte(uniswapTokenListJsonResponse2), + sourceURL: "https://example.com/uniswap-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedUniswapTokenList2, + expectedTokenList: uniswapTokenList2, + }, + { + name: "invalid JSON", + raw: []byte(uniswapTokenListInvalidTokensJsonResponse), + sourceURL: "https://example.com/uniswap-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedUniswapTokenListInvalidTokens, + expectedTokenList: uniswapTokenListInvalidTokens, + }, + { + name: "empty tokens list", + raw: []byte(uniswapTokenListEmptyTokensJsonResponse), + sourceURL: "https://example.com/uniswap-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedUniswapTokenListEmpty, + expectedTokenList: uniswapTokenListEmpty, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timestamp := time.Time{} + if tt.useFetchedTimestamp { + timestamp = tt.fetchedTokenList.Fetched + } + got, err := parser.Parse(tt.raw, tt.sourceURL, timestamp, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, tt.expectedTokenList.Name, got.Name) + assert.Equal(t, tt.expectedTokenList.Timestamp, got.Timestamp) + if tt.useFetchedTimestamp { + assert.Equal(t, tt.expectedTokenList.FetchedTimestamp, got.FetchedTimestamp) + } else { + assert.Equal(t, tt.expectedTokenList.Timestamp, got.Timestamp) + } + assert.Equal(t, tt.expectedTokenList.Source, got.Source) + assert.Equal(t, tt.expectedTokenList.Version, got.Version) + assert.Equal(t, tt.expectedTokenList.Tags, got.Tags) + assert.Equal(t, tt.expectedTokenList.LogoURI, got.LogoURI) + assert.Equal(t, tt.expectedTokenList.Keywords, got.Keywords) + assert.Len(t, got.Tokens, len(tt.expectedTokenList.Tokens)) + + for _, expectedToken := range tt.expectedTokenList.Tokens { + found := false + for _, actualToken := range got.Tokens { + if actualToken.ChainID == expectedToken.ChainID && actualToken.Address == expectedToken.Address { + found = true + assert.Equal(t, expectedToken.CrossChainID, actualToken.CrossChainID) + assert.Equal(t, expectedToken.ChainID, actualToken.ChainID) + assert.Equal(t, strings.ToLower(expectedToken.Address.String()), strings.ToLower(actualToken.Address.String())) + assert.Equal(t, expectedToken.Name, actualToken.Name) + assert.Equal(t, expectedToken.Symbol, actualToken.Symbol) + assert.Equal(t, expectedToken.Decimals, actualToken.Decimals) + assert.Equal(t, strings.ToLower(expectedToken.LogoURI), strings.ToLower(actualToken.LogoURI)) + break + } + } + assert.True(t, found) + } + }) + } +} + +func TestStandardTokenListParser_Parse_InvalidJSON(t *testing.T) { + parser := &StandardTokenListParser{} + + raw := []byte(`{invalid json`) + _, err := parser.Parse(raw, "https://example.com/invalid.json", time.Time{}, common.AllChains) + assert.Error(t, err) +} + +func TestStandardTokenListParser_Parse_MissingFields(t *testing.T) { + parser := &StandardTokenListParser{} + + raw := []byte(``) + got, err := parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.Error(t, err) + assert.Nil(t, got) + + raw = []byte(`{}`) + got, err = parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got.Tokens) + + raw = []byte(`{ + "name": "Uniswap Labs Default" + }`) + got, err = parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "Uniswap Labs Default", got.Name) + assert.Empty(t, got.Tokens) +} diff --git a/pkg/tokenlists/parser_status.go b/pkg/tokenlists/parser_status.go new file mode 100644 index 0000000..e2d610c --- /dev/null +++ b/pkg/tokenlists/parser_status.go @@ -0,0 +1,71 @@ +package tokenlists + +import ( + "encoding/json" + "slices" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// StatusTokenList represents a token list in Status format. +type StatusTokenList struct { + StandardTokenList + Tokens []struct { + CrossChainID string `json:"crossChainId"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Decimals uint `json:"decimals"` + LogoURI string `json:"logoURI"` + Contracts map[uint64]string `json:"contracts"` + } `json:"tokens"` +} + +// StatusTokenListParser parses tokens from Status format. +type StatusTokenListParser struct{} + +// Parse parses raw bytes as StatusTokenList and converts to TokenList objects. +func (p *StatusTokenListParser) Parse(raw []byte, sourceURL string, fetchedAt time.Time, supportedChains []uint64) (*TokenList, error) { + var tokenList StatusTokenList + if err := json.Unmarshal(raw, &tokenList); err != nil { + return nil, err + } + + result := &TokenList{ + Name: tokenList.Name, + Timestamp: tokenList.Timestamp, + FetchedTimestamp: tokenList.Timestamp, // by default (if fetchedAt is not provided) the list's `FetchedTimestamp` is the list's `Timestamp` (used for local lists) + Source: sourceURL, + Version: tokenList.Version, + Tags: tokenList.Tags, + LogoURI: tokenList.LogoURI, + Keywords: tokenList.Keywords, + Tokens: make([]*Token, 0), + } + + if !fetchedAt.IsZero() { + result.FetchedTimestamp = fetchedAt.Format(time.RFC3339) + } + + for _, t := range tokenList.Tokens { + for chainID, address := range t.Contracts { + if !common.IsHexAddress(address) || !slices.Contains(supportedChains, chainID) { + continue + } + + token := Token{ + CrossChainID: t.CrossChainID, + ChainID: chainID, + Address: common.HexToAddress(address), + Name: t.Name, + Symbol: t.Symbol, + Decimals: t.Decimals, + LogoURI: t.LogoURI, + } + + result.Tokens = append(result.Tokens, &token) + } + } + + return result, nil +} diff --git a/pkg/tokenlists/parser_status_test.go b/pkg/tokenlists/parser_status_test.go new file mode 100644 index 0000000..fe237d2 --- /dev/null +++ b/pkg/tokenlists/parser_status_test.go @@ -0,0 +1,132 @@ +package tokenlists + +import ( + "strings" + "testing" + "time" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "github.com/stretchr/testify/assert" +) + +func TestStatusTokenListParser_Parse(t *testing.T) { + parser := &StatusTokenListParser{} + + tests := []struct { + name string + raw []byte + sourceURL string + useFetchedTimestamp bool + fetchedTokenList fetchedTokenList + expectedTokenList TokenList + }{ + { + name: "valid status token list with fetched timestamp", + raw: []byte(statusTokenListJsonResponse), + sourceURL: "https://example.com/status-token-list.json", + useFetchedTimestamp: true, + fetchedTokenList: fetchedStatusTokenList, + expectedTokenList: statusTokenList, + }, + { + name: "valid status token list without fetched timestamp", + raw: []byte(statusTokenListJsonResponse), + sourceURL: "https://example.com/status-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedStatusTokenList, + expectedTokenList: statusTokenList, + }, + { + name: "invalid JSON", + raw: []byte(statusTokenListInvalidTokensJsonResponse), + sourceURL: "https://example.com/status-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedStatusTokenListInvalidTokens, + expectedTokenList: statusTokenListInvalidTokens, + }, + { + name: "empty tokens list", + raw: []byte(statusEmptyTokensJsonResponse), + sourceURL: "https://example.com/status-token-list.json", + useFetchedTimestamp: false, + fetchedTokenList: fetchedStatusTokenListEmpty, + expectedTokenList: statusTokenListEmpty, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timestamp := time.Time{} + if tt.useFetchedTimestamp { + timestamp = tt.fetchedTokenList.Fetched + } + got, err := parser.Parse(tt.raw, tt.sourceURL, timestamp, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, tt.expectedTokenList.Name, got.Name) + assert.Equal(t, tt.expectedTokenList.Timestamp, got.Timestamp) + if tt.useFetchedTimestamp { + assert.Equal(t, tt.expectedTokenList.FetchedTimestamp, got.FetchedTimestamp) + } else { + assert.Equal(t, tt.expectedTokenList.Timestamp, got.Timestamp) + } + assert.Equal(t, tt.expectedTokenList.Source, got.Source) + assert.Equal(t, tt.expectedTokenList.Version, got.Version) + assert.Equal(t, tt.expectedTokenList.Tags, got.Tags) + assert.Equal(t, tt.expectedTokenList.LogoURI, got.LogoURI) + assert.Equal(t, tt.expectedTokenList.Keywords, got.Keywords) + assert.Len(t, got.Tokens, len(tt.expectedTokenList.Tokens)) + + for _, expectedToken := range tt.expectedTokenList.Tokens { + found := false + for _, actualToken := range got.Tokens { + if actualToken.ChainID == expectedToken.ChainID && actualToken.Address == expectedToken.Address { + found = true + assert.Equal(t, expectedToken.CrossChainID, actualToken.CrossChainID) + assert.Equal(t, expectedToken.ChainID, actualToken.ChainID) + assert.Equal(t, strings.ToLower(expectedToken.Address.String()), strings.ToLower(actualToken.Address.String())) + assert.Equal(t, expectedToken.Name, actualToken.Name) + assert.Equal(t, expectedToken.Symbol, actualToken.Symbol) + assert.Equal(t, expectedToken.Decimals, actualToken.Decimals) + assert.Equal(t, strings.ToLower(expectedToken.LogoURI), strings.ToLower(actualToken.LogoURI)) + break + } + } + assert.True(t, found) + } + }) + } +} + +func TestStatusTokenListParser_Parse_InvalidJSON(t *testing.T) { + parser := &StatusTokenListParser{} + + raw := []byte(`{invalid json`) + _, err := parser.Parse(raw, "https://example.com/invalid.json", time.Time{}, common.AllChains) + assert.Error(t, err) +} + +func TestStatusTokenListParser_Parse_MissingFields(t *testing.T) { + parser := &StatusTokenListParser{} + + raw := []byte(``) + got, err := parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.Error(t, err) + assert.Nil(t, got) + + raw = []byte(`{}`) + got, err = parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Empty(t, got.Tokens) + + raw = []byte(`{ + "name": "Status Token List" + }`) + got, err = parser.Parse(raw, "https://example.com/missing-fields.json", time.Time{}, common.AllChains) + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "Status Token List", got.Name) + assert.Empty(t, got.Tokens) +} diff --git a/pkg/tokenlists/refresh.go b/pkg/tokenlists/refresh.go new file mode 100644 index 0000000..f8d81c5 --- /dev/null +++ b/pkg/tokenlists/refresh.go @@ -0,0 +1,116 @@ +package tokenlists + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "go.uber.org/zap" +) + +// refreshWorker handles the background refresh of token lists. +type refreshWorker struct { + config *Config + fetcher *tokenListsFetcher + cancel context.CancelFunc + wg sync.WaitGroup + running atomic.Bool +} + +// newRefreshWorker creates a new refresh worker. +func newRefreshWorker(config *Config) *refreshWorker { + fetcher := newTokenListsFetcher(config) + return &refreshWorker{ + config: config, + fetcher: fetcher, + } +} + +// start begins the background refresh worker. +func (w *refreshWorker) start(ctx context.Context) (refreshCh chan struct{}) { + if w.running.Load() { + return + } + + refreshCh = make(chan struct{}) + + childCtx, cancel := context.WithCancel(ctx) + w.cancel = cancel + + w.running.Store(true) + w.wg.Add(1) + go w.run(childCtx, refreshCh) + + return refreshCh +} + +// stop stops the background refresh worker. +func (w *refreshWorker) stop() { + if !w.running.Load() { + return + } + + if w.cancel != nil { + w.cancel() + } + + w.wg.Wait() + w.running.Store(false) +} + +func (w *refreshWorker) run(ctx context.Context, refreshCh chan struct{}) { + defer w.wg.Done() + + ticker := time.NewTicker(w.config.AutoRefreshCheckInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + close(refreshCh) + return + case <-ticker.C: + w.checkAndRefresh(ctx, refreshCh) + } + } +} + +func (w *refreshWorker) checkAndRefresh(ctx context.Context, refreshCh chan struct{}) { + privacyOn, err := w.config.PrivacyGuard.IsPrivacyOn() + if err != nil { + w.config.logger.Error("failed to get privacy mode", zap.Error(err)) + return + } + if privacyOn { + return + } + + lastRefresh, err := w.config.LastTokenListsUpdateTimeStore.Get() + if err != nil { + w.config.logger.Error("failed to get last token lists update time", zap.Error(err)) + return + } + + if time.Since(lastRefresh) < w.config.AutoRefreshInterval { + return + } + + storedListsCount, err := w.fetcher.fetchAndStore(ctx) + if err != nil { + w.config.logger.Error("failed to fetch and store token lists", zap.Error(err)) + // Just log the error and don't return, let program continue, cause we have to store last tokens update timestamp + } + + if storedListsCount > 0 { + w.config.logger.Info("updated token lists", zap.Int("count", storedListsCount)) + } + + currentTimestamp := time.Unix(time.Now().Unix(), 0) + err = w.config.LastTokenListsUpdateTimeStore.Set(currentTimestamp) + if err != nil { + w.config.logger.Error("failed to save last tokens update time", zap.Error(err)) + } + + refreshCh <- struct{}{} +} diff --git a/pkg/tokenlists/refresh_test.go b/pkg/tokenlists/refresh_test.go new file mode 100644 index 0000000..8e66b19 --- /dev/null +++ b/pkg/tokenlists/refresh_test.go @@ -0,0 +1,194 @@ +package tokenlists + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewRefreshWorker(t *testing.T) { + config := &Config{ + logger: zap.NewNop(), + } + + worker := newRefreshWorker(config) + assert.NotNil(t, worker) + assert.NotNil(t, worker.fetcher) +} + +func TestRefreshWorker_StartStop(t *testing.T) { + config := &Config{ + AutoRefreshCheckInterval: 100 * time.Millisecond, + AutoRefreshInterval: 200 * time.Millisecond, + logger: zap.NewNop(), + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + } + + worker := newRefreshWorker(config) + ctx := context.Background() + + // start + refreshCh := worker.start(ctx) + assert.NotNil(t, refreshCh) + assert.True(t, worker.running.Load()) + + // start again + refreshCh2 := worker.start(ctx) + assert.Nil(t, refreshCh2) + + // stop + worker.stop() + assert.False(t, worker.running.Load()) + + // stop again + worker.stop() +} + +func TestRefreshWorker_Run(t *testing.T) { + config := &Config{ + AutoRefreshCheckInterval: 50 * time.Millisecond, + AutoRefreshInterval: 100 * time.Millisecond, + logger: zap.NewNop(), + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + } + + worker := newRefreshWorker(config) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + refreshCh := worker.start(ctx) + require.NotNil(t, refreshCh) + + // Wait for refresh signal or context cancellation + select { + case <-refreshCh: + case <-ctx.Done(): + // Expected behavior, any of those two + } + + worker.stop() +} + +func TestRefreshWorker_PrivacyModeOn(t *testing.T) { + config := &Config{ + AutoRefreshCheckInterval: 50 * time.Millisecond, + AutoRefreshInterval: 100 * time.Millisecond, + logger: zap.NewNop(), + PrivacyGuard: NewDefaultPrivacyGuard(true), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + } + + worker := newRefreshWorker(config) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + refreshCh := worker.start(ctx) + require.NotNil(t, refreshCh) + + // With privacy mode on, no refresh should be triggered + select { + case <-refreshCh: + t.Fatal("Refresh should not be triggered when privacy mode is on") + case <-ctx.Done(): + // Expected behavior + } + + worker.stop() +} + +func TestRefreshWorker_CheckAndRefresh_PrivacyModeOff(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + RemoteListOfTokenListsURL: server.URL + listOfTokenListsURL, + AutoRefreshCheckInterval: 100 * time.Millisecond, + AutoRefreshInterval: 200 * time.Millisecond, + logger: zap.NewNop(), + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + } + + allContent, err := config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 0) + + worker := newRefreshWorker(config) + + ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond) + defer cancel() + + refreshCh := make(chan struct{}, 1) + + worker.checkAndRefresh(ctx, refreshCh) + + select { + case <-refreshCh: + // check if the content store has the data + allContent, err := config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 3) // list of token lists, status token list, uniswap token list + + assert.Contains(t, allContent, "status") + assert.Contains(t, allContent, "uniswap") + assert.Equal(t, statusTokenListJsonResponse, string(allContent["status"].Data)) + assert.Equal(t, uniswapTokenListJsonResponse, string(allContent["uniswap"].Data)) + return + + case <-ctx.Done(): + t.Fatal("context done") + } +} + +func TestRefreshWorker_CheckAndRefresh_PrivacyModeOn(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + RemoteListOfTokenListsURL: server.URL + listOfTokenListsURL, + AutoRefreshCheckInterval: 100 * time.Millisecond, + AutoRefreshInterval: 200 * time.Millisecond, + logger: zap.NewNop(), + PrivacyGuard: NewDefaultPrivacyGuard(true), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + } + + allContent, err := config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 0) + + worker := newRefreshWorker(config) + + ctx, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond) + defer cancel() + + refreshCh := make(chan struct{}, 1) + + worker.checkAndRefresh(ctx, refreshCh) + + select { + case <-refreshCh: + t.Fatal("refreshCh received") + case <-ctx.Done(): + // Expected behavior + } + + allContent, err = config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 0) +} diff --git a/pkg/tokenlists/remote_list_of_token_lists_fetcher.go b/pkg/tokenlists/remote_list_of_token_lists_fetcher.go new file mode 100644 index 0000000..59b1de5 --- /dev/null +++ b/pkg/tokenlists/remote_list_of_token_lists_fetcher.go @@ -0,0 +1,83 @@ +package tokenlists + +import ( + "context" + "encoding/json" + "time" + + "github.com/xeipuuv/gojsonschema" + "go.uber.org/zap" +) + +// fetchRemoteListOfTokenLists fetches the remote list of token lists from the URL specified in the config. +func (t *tokenListsFetcher) fetchRemoteListOfTokenLists(ctx context.Context, etag string) ([]byte, string, error) { + body, newEtag, err := t.httpClient.DoGetRequestWithEtag(ctx, t.config.RemoteListOfTokenListsURL, etag) + if err != nil { + return nil, "", err + } + + if etag != "" && newEtag == etag { + return nil, "", nil + } + + err = validateJsonAgainstSchema(string(body), gojsonschema.NewStringLoader(listOfTokenListsSchema)) + if err != nil { + return nil, "", err + } + + return body, newEtag, nil +} + +// resolveListOfTokenLists resolves the list of token lists from the remote list of token lists. +func (t *tokenListsFetcher) resolveListOfTokenLists(ctx context.Context) (remoteListOfTokenLists, error) { + var remoteListOfTokenLists remoteListOfTokenLists + var ok = true + storedContent, err := t.config.ContentStore.Get(StatusListOfTokenListsID) + if err != nil { + ok = false + t.config.logger.Error("failed to get stored content", zap.Error(err)) + } + + if t.config.RemoteListOfTokenListsURL != "" { + fetchedData, newEtag, err := t.fetchRemoteListOfTokenLists(ctx, storedContent.Etag) + if err != nil { + // don't return, but instead try to use the last cached list of token lists + t.config.logger.Error("failed to fetch remote list of token lists", zap.Error(err)) + goto useStoredList + } + + if fetchedData != nil { + if err = json.Unmarshal(fetchedData, &remoteListOfTokenLists); err != nil { + t.config.logger.Error("failed to unmarshal remote list of token lists", zap.Error(err)) + goto useStoredList + } + + t.config.logger.Info("new remote list of token lists fetched successfully", + zap.String("version", remoteListOfTokenLists.Version.String()), + zap.String("timestamp", remoteListOfTokenLists.Timestamp), + ) + + err := t.config.ContentStore.Set(StatusListOfTokenListsID, Content{ + SourceURL: t.config.RemoteListOfTokenListsURL, + Etag: newEtag, + Data: fetchedData, + Fetched: time.Now(), + }) + if err != nil { + t.config.logger.Error("failed to store remote list of token lists", zap.Error(err)) + } + + return remoteListOfTokenLists, nil + } + } + +useStoredList: + if ok && len(storedContent.Data) > 0 { + err := json.Unmarshal(storedContent.Data, &remoteListOfTokenLists) + if err != nil { + t.config.logger.Error("failed to unmarshal stored list of token lists", zap.Error(err)) + } + } + + return remoteListOfTokenLists, nil +} diff --git a/pkg/tokenlists/remote_list_of_token_lists_fetcher_test.go b/pkg/tokenlists/remote_list_of_token_lists_fetcher_test.go new file mode 100644 index 0000000..375eb64 --- /dev/null +++ b/pkg/tokenlists/remote_list_of_token_lists_fetcher_test.go @@ -0,0 +1,199 @@ +package tokenlists + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestTokenListsFetcher_ResolveListOfTokenLists(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + } + + fetcher := newTokenListsFetcher(config) + + expectedListOfTokenListsJsonResponse := strings.ReplaceAll(listOfTokenListsJsonResponse, serverURLPlaceholder, server.URL) + expectedListOfTokenListsCopy := copyRemoteListOfTokenLists(expectedListOfTokenLists) + for i := range expectedListOfTokenListsCopy.TokenLists { + tokenList := &expectedListOfTokenListsCopy.TokenLists[i] + tokenList.SourceURL = strings.ReplaceAll(tokenList.SourceURL, serverURLPlaceholder, server.URL) + tokenList.Schema = strings.ReplaceAll(tokenList.Schema, serverURLPlaceholder, server.URL) + } + + expectedListOfTokenListsJsonResponse1 := strings.ReplaceAll(listOfTokenListsJsonResponse1, serverURLPlaceholder, server.URL) + expectedListOfTokenListsCopy1 := copyRemoteListOfTokenLists(expectedListOfTokenLists1) + for i := range expectedListOfTokenListsCopy1.TokenLists { + tokenList := &expectedListOfTokenListsCopy1.TokenLists[i] + tokenList.SourceURL = strings.ReplaceAll(tokenList.SourceURL, serverURLPlaceholder, server.URL) + tokenList.Schema = strings.ReplaceAll(tokenList.Schema, serverURLPlaceholder, server.URL) + } + + expectedListOfTokenListsJsonResponse2 := strings.ReplaceAll(listOfTokenListsJsonResponse2, serverURLPlaceholder, server.URL) + expectedListOfTokenListsCopy2 := copyRemoteListOfTokenLists(expectedListOfTokenLists2) + for i := range expectedListOfTokenListsCopy2.TokenLists { + tokenList := &expectedListOfTokenListsCopy2.TokenLists[i] + tokenList.SourceURL = strings.ReplaceAll(tokenList.SourceURL, serverURLPlaceholder, server.URL) + tokenList.Schema = strings.ReplaceAll(tokenList.Schema, serverURLPlaceholder, server.URL) + } + + // no stored content/etag + content, err := fetcher.config.ContentStore.Get(StatusListOfTokenListsID) + require.Error(t, err) + require.Equal(t, "", content.Etag) + require.Equal(t, "", string(content.Data)) + + // fetch remote list of token lists with no tag + config.RemoteListOfTokenListsURL = server.URL + listOfTokenListsURL + tokenLists, err := fetcher.resolveListOfTokenLists(context.TODO()) + require.NoError(t, err) + require.Equal(t, expectedListOfTokenListsCopy, tokenLists) + + // check stored content/etag + content, err = fetcher.config.ContentStore.Get(StatusListOfTokenListsID) + require.NoError(t, err) + require.Equal(t, "", content.Etag) + require.Equal(t, expectedListOfTokenListsJsonResponse, string(content.Data)) + + // fetch remote list of token lists with etag + config.RemoteListOfTokenListsURL = server.URL + listOfTokenListsWithEtagURL + tokenLists, err = fetcher.resolveListOfTokenLists(context.TODO()) + require.NoError(t, err) + require.Equal(t, expectedListOfTokenListsCopy1, tokenLists) + + // check stored content/etag + content, err = fetcher.config.ContentStore.Get(StatusListOfTokenListsID) + require.NoError(t, err) + require.Equal(t, listOfTokenListsEtag, content.Etag) + require.Equal(t, expectedListOfTokenListsJsonResponse1, string(content.Data)) + + // fetch remote list of token lists with the same etag + config.RemoteListOfTokenListsURL = server.URL + listOfTokenListsWithSameEtagURL + tokenLists, err = fetcher.resolveListOfTokenLists(context.TODO()) + require.NoError(t, err) + require.Equal(t, expectedListOfTokenListsCopy1, tokenLists) + + // check stored content/etag + content, err = fetcher.config.ContentStore.Get(StatusListOfTokenListsID) + require.NoError(t, err) + require.Equal(t, listOfTokenListsEtag, content.Etag) + require.Equal(t, expectedListOfTokenListsJsonResponse1, string(content.Data)) + + // fetch remote list of token lists with a new etag + config.RemoteListOfTokenListsURL = server.URL + listOfTokenListsWithNewEtagURL + tokenLists, err = fetcher.resolveListOfTokenLists(context.TODO()) + require.NoError(t, err) + require.Equal(t, expectedListOfTokenListsCopy2, tokenLists) + + // check stored content/etag, should be the new one + content, err = fetcher.config.ContentStore.Get(StatusListOfTokenListsID) + require.NoError(t, err) + require.Equal(t, listOfTokenListsNewEtag, content.Etag) + require.Equal(t, expectedListOfTokenListsJsonResponse2, string(content.Data)) +} + +func TestTokenListsFetcher_ResolveListOfTokenLists_WithIssues(t *testing.T) { + + expectedListOfTokenListsCopy := copyRemoteListOfTokenLists(expectedListOfTokenLists) + for i := range expectedListOfTokenListsCopy.TokenLists { + tokenList := &expectedListOfTokenListsCopy.TokenLists[i] + tokenList.SourceURL = strings.ReplaceAll(tokenList.SourceURL, serverURLPlaceholder, serverURLPlaceholder) + tokenList.Schema = strings.ReplaceAll(tokenList.Schema, serverURLPlaceholder, serverURLPlaceholder) + } + + var tests = []struct { + name string + remoteURL string + storedContent *Content + expected remoteListOfTokenLists + }{ + { + name: "page not found no stored content", + remoteURL: "/not-found", + }, + { + name: "page not found has stored content", + remoteURL: "/not-found", + storedContent: &Content{ + SourceURL: "", + Etag: "some-etag", + Data: []byte(listOfTokenListsJsonResponse), + Fetched: time.Now(), + }, + expected: expectedListOfTokenListsCopy, + }, + { + name: "content of the response does not match the schema", + remoteURL: listOfTokenListsWrongSchemaURL, + expected: expectedListOfTokenListsCopy, + }, + { + name: "remote url not set", + expected: expectedListOfTokenListsCopy, + }, + } + + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + } + + fetcher := newTokenListsFetcher(config) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + config.RemoteListOfTokenListsURL = "" + if tt.remoteURL != "" { + config.RemoteListOfTokenListsURL = server.URL + tt.remoteURL + } + + if tt.storedContent != nil { + err := config.ContentStore.Set(StatusListOfTokenListsID, *tt.storedContent) + require.NoError(t, err) + } + + tokenLists, err := fetcher.resolveListOfTokenLists(context.TODO()) + + require.NoError(t, err) + require.Equal(t, tt.expected, tokenLists) + }) + } +} + +func TestTokenListsFetcher_ResolveListOfTokenLists_ContextCancellation(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + RemoteListOfTokenListsURL: server.URL + delayedResponseURL, + } + + fetcher := newTokenListsFetcher(config) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := fetcher.resolveListOfTokenLists(ctx) + assert.NoError(t, err) // Should not return an error - the function is designed to be resilient +} diff --git a/pkg/tokenlists/remote_token_lists_fetcher.go b/pkg/tokenlists/remote_token_lists_fetcher.go new file mode 100644 index 0000000..a0d7e97 --- /dev/null +++ b/pkg/tokenlists/remote_token_lists_fetcher.go @@ -0,0 +1,45 @@ +package tokenlists + +import ( + "context" + "time" + + "github.com/xeipuuv/gojsonschema" +) + +// fetchTokenList fetches a token list from the URL specified in the list. +func (t *tokenListsFetcher) fetchTokenList(ctx context.Context, list tokenList, etag string, ch chan<- fetchedTokenList) error { + body, newEtag, err := t.httpClient.DoGetRequestWithEtag(ctx, list.SourceURL, etag) + if err != nil { + return err + } + + if etag != "" && newEtag == etag { + return nil + } + + if list.Schema != "" { + err = validateJsonAgainstSchema(string(body), gojsonschema.NewReferenceLoader(list.Schema)) + if err != nil { + return err + } + } + + // check if the channel is closed + if ch == nil { + return ErrChannelClosed + } + + ch <- fetchedTokenList{ + tokenList: tokenList{ + ID: list.ID, + SourceURL: list.SourceURL, + Schema: list.Schema, + }, + Etag: newEtag, + Fetched: time.Now(), + JsonData: body, + } + + return nil +} diff --git a/pkg/tokenlists/remote_token_lists_fetcher_test.go b/pkg/tokenlists/remote_token_lists_fetcher_test.go new file mode 100644 index 0000000..e27b66b --- /dev/null +++ b/pkg/tokenlists/remote_token_lists_fetcher_test.go @@ -0,0 +1,199 @@ +package tokenlists + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestTokenListsFetcher_FetchTokenList(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + } + + fetcher := newTokenListsFetcher(config) + + tests := []struct { + name string + list tokenList + etag string + expectedError bool + expectedEtag string + expectedData []byte + }{ + { + name: "successful fetch without schema without etag", + list: tokenList{ + ID: "test-list", + SourceURL: server.URL + uniswapURL, + Schema: "", + }, + etag: "", + expectedError: false, + expectedEtag: "", + expectedData: []byte(uniswapTokenListJsonResponse), + }, + { + name: "successful fetch with wrong schema without etag", + list: tokenList{ + ID: "test-list", + SourceURL: server.URL + uniswapURL, + Schema: "wrong-schema.json", + }, + etag: "", + expectedError: true, + expectedEtag: "", + expectedData: nil, + }, + { + name: "successful fetch with right schema without etag", + list: tokenList{ + ID: "test-list", + SourceURL: server.URL + uniswapURL, + Schema: server.URL + uniswapSchemaURL, + }, + etag: "", + expectedError: false, + expectedEtag: "", + expectedData: []byte(uniswapTokenListJsonResponse), + }, + { + name: "successful fetch with etag", + list: tokenList{ + ID: "test-list", + SourceURL: server.URL + uniswapWithEtagURL, + Schema: "", + }, + etag: "", + expectedError: false, + expectedEtag: uniswapEtag, + expectedData: []byte(uniswapTokenListJsonResponse1), + }, + { + name: "successful fetch with the same etag", + list: tokenList{ + ID: "test-list", + SourceURL: server.URL + uniswapSameEtagURL, + Schema: "", + }, + etag: uniswapEtag, + expectedError: false, + expectedEtag: uniswapEtag, + expectedData: nil, + }, + { + name: "successful fetch with the new etag", + list: tokenList{ + ID: "test-list", + SourceURL: server.URL + uniswapNewEtagURL, + Schema: "", + }, + etag: uniswapEtag, + expectedError: false, + expectedEtag: uniswapNewEtag, + expectedData: []byte(uniswapTokenListJsonResponse2), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := make(chan fetchedTokenList, 1) + defer close(ch) + + err := fetcher.fetchTokenList(context.Background(), tt.list, tt.etag, ch) + if tt.expectedError { + assert.Error(t, err) + return + } + require.NoError(t, err) + + // Check if we got data (for non-304 responses) + if tt.expectedData != nil { + select { + case fetched := <-ch: + assert.Equal(t, tt.list.ID, fetched.ID) + assert.Equal(t, tt.list.SourceURL, fetched.SourceURL) + assert.Equal(t, tt.list.Schema, fetched.Schema) + assert.Equal(t, tt.expectedEtag, fetched.Etag) + assert.Equal(t, tt.expectedData, fetched.JsonData) + assert.WithinDuration(t, time.Now(), fetched.Fetched, 2*time.Second) + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for fetched token list") + } + } else { + select { + case <-ch: + t.Fatal("unexpected data received for 304 response") + case <-time.After(100 * time.Millisecond): + // This is expected - no data should be sent + } + } + }) + } +} + +func TestTokenListsFetcher_FetchTokenList_ContextCancellation(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + } + + fetcher := newTokenListsFetcher(config) + + list := tokenList{ + ID: "slow-response", + SourceURL: server.URL + delayedResponseURL, + Schema: "", + } + + ch := make(chan fetchedTokenList, 1) + defer close(ch) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := fetcher.fetchTokenList(ctx, list, "", ch) + assert.Error(t, err) +} + +func TestTokenListsFetcher_FetchTokenList_ChannelClosed(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + logger: zap.NewNop(), + ContentStore: NewDefaultContentStore(), + } + + fetcher := newTokenListsFetcher(config) + + list := tokenList{ + ID: "status", + SourceURL: server.URL + uniswapURL, + Schema: "", + } + + // Create a closed channel + ch := make(chan fetchedTokenList) + close(ch) + ch = nil + + err := fetcher.fetchTokenList(context.Background(), list, "", ch) + assert.Error(t, err) +} diff --git a/pkg/tokenlists/test_data_coingecko_token_list.go b/pkg/tokenlists/test_data_coingecko_token_list.go new file mode 100644 index 0000000..1d5f8cb --- /dev/null +++ b/pkg/tokenlists/test_data_coingecko_token_list.go @@ -0,0 +1,143 @@ +package tokenlists + +import ( + "github.com/ethereum/go-ethereum/common" +) + +// #nosec G101 +const coingeckoTokensJsonResponse = `[ + { + "id": "usd-coin", + "symbol": "usdc", + "name": "USDC", + "platforms": { + "ethereum": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "arbitrum-one": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "optimistic-ethereum": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "base": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "avalanche": "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e", + "algorand": "31566704", + "stellar": "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + "celo": "0xceba9300f2b948710d2653dd7b07f33a8b32118c", + "sui": "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", + "polygon-pos": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "solana": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + } + }, + { + "id": "wrapped-bitcoin", + "symbol": "wbtc", + "name": "Wrapped Bitcoin", + "platforms": { + "ethereum": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "osmosis": "factory/osmo1z0qrq605sjgcqpylfl4aa6s90x738j7m58wyatt0tdzflg2ha26q67k743/wbtc", + "solana": "5XZw2LKTyrfvfiskJ78AMpackRjPcyCif1WhUsPDuVqQ" + } + } +]` + +// #nosec G101 +const coingeckoTokensJsonResponseInvalidTokens = `[ + { + "id": "usd-coin", + "symbol": "usdc", + "name": "USDC", + "platforms": { + "ethereum": "invalid-address", + "arbitrum-one": "invalid-address", + "optimistic-ethereum": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "base": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "avalanche": "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e", + "algorand": "invalid-address", + "stellar": "USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + "celo": "0xceba9300f2b948710d2653dd7b07f33a8b32118c", + "sui": "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC", + "polygon-pos": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "solana": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + } + }, + { + "id": "wrapped-bitcoin", + "symbol": "wbtc", + "name": "Wrapped Bitcoin", + "platforms": { + "ethereum": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "osmosis": "factory/osmo1z0qrq605sjgcqpylfl4aa6s90x738j7m58wyatt0tdzflg2ha26q67k743/wbtc", + "solana": "5XZw2LKTyrfvfiskJ78AMpackRjPcyCif1WhUsPDuVqQ" + } + } +]` + +var fetchedCoingeckoTokenList = fetchedTokenList{ + tokenList: tokenList{ + SourceURL: "https://example.com/coingecko-token-list.json", + }, + JsonData: []byte(coingeckoTokensJsonResponse), +} + +var fetchedCoingeckoTokenListInvalidTokens = fetchedTokenList{ + tokenList: tokenList{ + SourceURL: "https://example.com/coingecko-token-list.json", + }, + JsonData: []byte(coingeckoTokensJsonResponseInvalidTokens), +} + +var coingeckoTokenList = TokenList{ + Source: "https://example.com/coingecko-token-list.json", + Tokens: []*Token{ + { + ChainID: 1, + Address: common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), + Name: "USDC", + Symbol: "usdc", + }, + { + ChainID: 42161, + Address: common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831"), + Name: "USDC", + Symbol: "usdc", + }, + { + ChainID: 10, + Address: common.HexToAddress("0x0b2c639c533813f4aa9d7837caf62653d097ff85"), + Name: "USDC", + Symbol: "usdc", + }, + { + ChainID: 8453, + Address: common.HexToAddress("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"), + Name: "USDC", + Symbol: "usdc", + }, + { + ChainID: 1, + Address: common.HexToAddress("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599"), + Name: "Wrapped Bitcoin", + Symbol: "wbtc", + }, + }, +} + +var coingeckoTokenListInvalidTokens = TokenList{ + Source: "https://example.com/coingecko-token-list.json", + Tokens: []*Token{ + { + ChainID: 10, + Address: common.HexToAddress("0x0b2c639c533813f4aa9d7837caf62653d097ff85"), + Name: "USDC", + Symbol: "usdc", + }, + { + ChainID: 8453, + Address: common.HexToAddress("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"), + Name: "USDC", + Symbol: "usdc", + }, + { + ChainID: 1, + Address: common.HexToAddress("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599"), + Name: "Wrapped Bitcoin", + Symbol: "wbtc", + }, + }, +} diff --git a/pkg/tokenlists/test_data_lists_of_token_lists.go b/pkg/tokenlists/test_data_lists_of_token_lists.go new file mode 100644 index 0000000..6c5d15d --- /dev/null +++ b/pkg/tokenlists/test_data_lists_of_token_lists.go @@ -0,0 +1,138 @@ +package tokenlists + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + serverURLPlaceholder = "SERVER-URL" + + listOfTokenListsWrongSchemaURL = "/list-of-token-lists-with-wrong-schema.json" // #nosec G101 + + emptyTokenListsURL = "/empty.json" + + delayedResponseURL = "/delayed-response.json" + + listOfTokenListsEtag = "lotlEtag" + listOfTokenListsNewEtag = "lotlNewEtag" + listOfTokenListsURL = "/list-of-token-lists.json" // #nosec G101 + listOfTokenListsSomeWrongUrlsURL = "/list-of-token-lists-some-wrong-urls.json" // #nosec G101 + listOfTokenListsWithEtagURL = "/list-of-token-lists-with-etag.json" // #nosec G101 + listOfTokenListsWithSameEtagURL = "/list-of-token-lists-with-same-etag.json" // #nosec G101 + listOfTokenListsWithNewEtagURL = "/list-of-token-lists-with-new-etag.json" // #nosec G101 +) + +// #nosec G101 +const emptyTokenListsResponse = `{ + "timestamp": "2025-09-01T00:00:00.000Z", + "version": { + "major": 0, + "minor": 1, + "patch": 0 + } +}` + +// #nosec G101 +const listOfTokenListsWrongSchemaResponse = `{ + "timestamp": "2025-09-01T00:00:00.000Z", + { + "id": "uniswap" + } +}` + +// #nosec G101 +const listOfTokenListsJsonResponseTemplate = `{ + "timestamp": "TIMESTAMP", + "version": { + "major": 0, + "minor": MINOR, + "patch": 0 + }, + "tokenLists": TOKEN_LISTS +}` + +// #nosec G101 +const tokenListsJsonResponse = `[ + { + "id": "status", + "sourceUrl": "SERVER-URL/status-token-list.json" + }, + { + "id": "uniswap", + "sourceUrl": "SERVER-URL/uniswap.json" + } +]` + +// #nosec G101 +const tokenListsJsonResponse1 = `[ + { + "id": "status", + "sourceUrl": "SERVER-URL/status-token-list.json" + }, + { + "id": "uniswap", + "sourceUrl": "SERVER-URL/uniswap.json" + }, + { + "id": "coingecko", + "sourceUrl": "SERVER-URL/coingecko.json" + } +]` + +// #nosec G101 +const tokenListsJsonResponse2 = `[ + { + "id": "status", + "sourceUrl": "SERVER-URL/status-token-list.json" + }, + { + "id": "uniswap", + "sourceUrl": "SERVER-URL/uniswap.json" + }, + { + "id": "coingecko", + "sourceUrl": "SERVER-URL/coingecko.json" + }, + { + "id": "aave", + "sourceUrl": "SERVER-URL/aave.json" + } +]` + +// #nosec G101 +const listOfTokenListsSomeWrongUrlsResponse = `[ + { + "id": "status", + "sourceUrl": "SERVER-URL/status-token-list.json" + }, + { + "id": "invalid-list", + "sourceUrl": "SERVER-URL/invalid-url-tokens.json" + } +]` + +func createListOfTokenListsJsonResponse(timestamp string, minor int, tokenLists string) string { + list := strings.ReplaceAll(listOfTokenListsJsonResponseTemplate, "TIMESTAMP", timestamp) + list = strings.ReplaceAll(list, "MINOR", fmt.Sprintf("%d", minor)) + return strings.ReplaceAll(list, "TOKEN_LISTS", tokenLists) +} + +func createListOfTokenListsFromResponse(response string) remoteListOfTokenLists { + var list remoteListOfTokenLists + err := json.Unmarshal([]byte(response), &list) + if err != nil { + panic(err) + } + return list +} + +var listOfTokenListsJsonResponse = createListOfTokenListsJsonResponse("2025-09-01T00:00:00.000Z", 1, tokenListsJsonResponse) +var listOfTokenListsJsonResponse1 = createListOfTokenListsJsonResponse("2025-09-02T00:00:00.000Z", 2, tokenListsJsonResponse1) +var listOfTokenListsJsonResponse2 = createListOfTokenListsJsonResponse("2025-09-03T00:00:00.000Z", 3, tokenListsJsonResponse2) +var listOfTokenListsWrongUrlsJsonResponse = createListOfTokenListsJsonResponse("2025-09-01T00:00:00.000Z", 4, listOfTokenListsSomeWrongUrlsResponse) + +var expectedListOfTokenLists = createListOfTokenListsFromResponse(listOfTokenListsJsonResponse) +var expectedListOfTokenLists1 = createListOfTokenListsFromResponse(listOfTokenListsJsonResponse1) +var expectedListOfTokenLists2 = createListOfTokenListsFromResponse(listOfTokenListsJsonResponse2) diff --git a/pkg/tokenlists/test_data_status_token_list.go b/pkg/tokenlists/test_data_status_token_list.go new file mode 100644 index 0000000..715a2f6 --- /dev/null +++ b/pkg/tokenlists/test_data_status_token_list.go @@ -0,0 +1,755 @@ +package tokenlists + +import ( + "fmt" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +const ( + statusURL = "/status-token-list.json" +) + +// #nosec G101 +const statusTokenListJsonResponseTemplate = `{ + "name": "NAME", + "timestamp": "TIMESTAMP", + "version": { + "major": MAJOR, + "minor": MINOR, + "patch": 0 + }, + "tags": {}, + "logoURI": "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + "keywords": [ + "uniswap", + "default" + ], + "tokens": TOKENS +}` + +// #nosec G101 +const statusTokensJsonResponse = `[ + { + "crossChainId": "status", + "symbol": "SNT", + "name": "Status", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "contracts": { + "1": "0x744d70fdbe2ba4cf95131626614a1763df805b9e", + "10": "0x650af3c15af43dcb218406d30784416d64cfb6b2", + "8453": "0x662015ec830df08c0fc45896fab726542e8ac09e", + "42161": "0x707f635951193ddafbb40971a0fcaab8a6415160" + } + }, + { + "crossChainId": "status-test-token", + "symbol": "STT", + "name": "Status Test Token", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "contracts": { + "84532": "0xfdb3b57944943a7724fcc0520ee2b10659969a06", + "11155111": "0xe452027cdef746c7cd3db31cb700428b16cd8e51", + "1660990954": "0x1c3ac2a186c6149ae7cb4d716ebbd0766e4f898a" + } + }, + { + "crossChainId": "usd-coin", + "symbol": "USDC", + "name": "USDC (EVM)", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "contracts": { + "1": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "10": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "8453": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "42161": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "84532": "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + "421614": "0x75faf114eafb1bdbe2f0316df893fd58ce46aa4d", + "11155111": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + "11155420": "0x5fd84259d66cd46123540766be93dfe6d43130d7", + "1660990954": "0xc445a18ca49190578dad62fba3048c07efc07ffe" + } + }, + { + "crossChainId": "usd-coin-bsc", + "symbol": "USDC", + "name": "USDC (BSC)", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "contracts": { + "56": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" + } + }, + { + "crossChainId": "tether", + "symbol": "USDT", + "name": "USDT (EVM)", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + "contracts": { + "1": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "10": "0x94b008aa00579c1307b0ef2c499ad98a8ce58e58", + "8453": "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2", + "42161": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9" + } + }, + { + "crossChainId": "tether-bsc", + "symbol": "USDT", + "name": "USDT (BSC)", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + "contracts": { + "56": "0x55d398326f99059ff775485246999027b3197955" + } + }, + { + "crossChainId": "dai", + "symbol": "DAI", + "name": "DAI", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + "contracts": { + "1": "0x6b175474e89094c44da98b954eedeac495271d0f", + "56": "0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3", + "8453": "0x50c5725949a6f0c72e6c4a641f24049a917db0cb", + "42161": "0xda10009cbd5d07dd0cecc66161fc93d7c9000da1", + "11155111": "0x3e622317f8c93f7328350cf0b56d9ed4c620c5d6" + } + }, + { + "crossChainId": "binancecoin", + "symbol": "WBNB", + "name": "Wrapped BNB", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/smartchain/assets/0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c/logo.png", + "contracts": { + "56": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c" + } + }, + { + "crossChainId": "chainlink", + "symbol": "LINK", + "name": "Chainlink", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png", + "contracts": { + "1": "0x514910771af9ca656af840dff83e8264ecf986ca", + "10": "0x350a791bfc2c21f9ed5d10980dad2e2638ffa7f6", + "56": "0xf8a0bf9cf54bb92f17374d9e9a321e6a111a51bd", + "8453": "0x88fb150bdc53a65fe94dea0c9ba0a6daf8c6e196", + "42161": "0xf97f4df75117a78c1a5a0dbb814af92458539fb4" + } + }, + { + "crossChainId": "wrapped-bitcoin", + "symbol": "WBTC", + "name": "Wrapped BTC", + "decimals": 8, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png", + "contracts": { + "1": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", + "10": "0x68f180fcce6836688e9084f035309e29bf0a2095", + "42161": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f" + } + }, + { + "crossChainId": "ethena-usde", + "symbol": "USDE", + "name": "Ethena USDe", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x4c9edd5852cd905f086c759e8383e09bff1e68b3/logo.png", + "contracts": { + "1": "0x4c9edd5852cd905f086c759e8383e09bff1e68b3", + "42161": "0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34" + } + }, + { + "crossChainId": "uniswap", + "symbol": "UNI", + "name": "Uniswap", + "decimals": 18, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840A85d5aF5bf1D1762F925BDADdC4201F984/logo.png", + "contracts": { + "1": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", + "10": "0x6fd9d7ad17242c41f7131d257212c54a0e816691", + "56": "0xbf5140a22578168fd562dccf235e5d43a02ce9b1", + "42161": "0xfa7f8980b0f1e64a2062791cc3b0871572f1f7f0" + } + }, + { + "crossChainId": "eurc", + "symbol": "EURC", + "name": "EURC", + "decimals": 6, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1aBAEA1f7C830bD89Acc67eC4af516284b1bC33c/logo.png", + "contracts": { + "1": "0x1abaea1f7c830bd89acc67ec4af516284b1bc33c", + "8453": "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42", + "42161": "0x0863708032b5c328e11abcb0df9d79c71fc52a48", + "84532": "0x808456652fdb597867f38412077a9182bf77359f", + "11155111": "0x08210f9170f89ab7658f0b5e3ff39b0e03c594d4", + "1660990954": "0xfe8be27656b1508194d9302d12a940b4d7c35b99" + } + } +]` + +// #nosec G101 +const statusInvalidTokensJsonResponse = `[ + { + "crossChainId": "status", + "symbol": "SNT", + "name": "Status", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "contracts": { + "1": "0x744d70fdbe2ba4cf95131626614a1763df805b9e", + "10": "invalid-address" + } + } +]` + +func createStatusTokenListJsonResponse(name string, timestamp string, major int, minor int, tokens string) string { + list := strings.ReplaceAll(statusTokenListJsonResponseTemplate, "NAME", name) + list = strings.ReplaceAll(list, "TIMESTAMP", timestamp) + list = strings.ReplaceAll(list, "MAJOR", fmt.Sprintf("%d", major)) + list = strings.ReplaceAll(list, "MINOR", fmt.Sprintf("%d", minor)) + return strings.ReplaceAll(list, "TOKENS", tokens) +} + +var statusTokenListJsonResponse = createStatusTokenListJsonResponse("Status Token List", "2025-09-01T13:00:00.000Z", 0, 1, statusTokensJsonResponse) + +var statusTokenListInvalidTokensJsonResponse = createStatusTokenListJsonResponse("Status Token List", "2025-09-01T13:00:00.000Z", 0, 1, statusInvalidTokensJsonResponse) + +var statusEmptyTokensJsonResponse = createStatusTokenListJsonResponse("Status Token List", "2025-09-01T13:00:00.000Z", 0, 1, "[]") + +var fetchedStatusTokenList = createFetchedTokenListFromResponse(statusTokenListJsonResponse) + +var fetchedStatusTokenListInvalidTokens = createFetchedTokenListFromResponse(statusTokenListInvalidTokensJsonResponse) + +var fetchedStatusTokenListEmpty = createFetchedTokenListFromResponse(statusEmptyTokensJsonResponse) + +// #nosec G101 +var StatusTokenListJSON = `{ + "name": "Status Token List", + "timestamp": "2025-09-01T10:00:00.000Z", + "version": { + "major": 1, + "minor": 0, + "patch": 0 + }, + "tags": {}, + "logoURI": "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + "keywords": ["status", "default"], + "tokens": [ + { + "crossChainId": "status", + "symbol": "SNT", + "name": "Status", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "contracts": { + "1": "0x744d70fdbe2ba4cf95131626614a1763df805b9e", + "10": "0x650af3c15af43dcb218406d30784416d64cfb6b2", + "8453": "0x662015ec830df08c0fc45896fab726542e8ac09e", + "42161": "0x707f635951193ddafbb40971a0fcaab8a6415160" + } + } + ] +}` + +var statusTokenListEmpty = TokenList{ + Name: "Status Token List", + Timestamp: "2025-09-01T13:00:00.000Z", + FetchedTimestamp: fetchedStatusTokenListEmpty.Fetched.Format(time.RFC3339), + Source: "https://example.com/status-token-list.json", + Version: Version{ + Major: 0, + Minor: 1, + Patch: 0, + }, + Tags: map[string]interface{}{}, + LogoURI: "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + Keywords: []string{"uniswap", "default"}, + Tokens: []*Token{}, +} + +var statusTokenListInvalidTokens = TokenList{ + Name: "Status Token List", + Timestamp: "2025-09-01T13:00:00.000Z", + FetchedTimestamp: fetchedStatusTokenList.Fetched.Format(time.RFC3339), + Source: "https://example.com/status-token-list.json", + Version: Version{ + Major: 0, + Minor: 1, + Patch: 0, + }, + Tags: map[string]interface{}{}, + LogoURI: "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + Keywords: []string{"uniswap", "default"}, + Tokens: []*Token{ + { + CrossChainID: "status", + ChainID: 1, + Address: common.HexToAddress("0x744d70fdbe2ba4cf95131626614a1763df805b9e"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + }, +} + +var statusTokenList = TokenList{ + Name: "Status Token List", + Timestamp: "2025-09-01T13:00:00.000Z", + FetchedTimestamp: fetchedStatusTokenList.Fetched.Format(time.RFC3339), + Source: "https://example.com/status-token-list.json", + Version: Version{ + Major: 0, + Minor: 1, + Patch: 0, + }, + Tags: map[string]interface{}{}, + LogoURI: "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + Keywords: []string{"uniswap", "default"}, + Tokens: []*Token{ + { + CrossChainID: "status", + ChainID: 1, + Address: common.HexToAddress("0x744d70fdbe2ba4cf95131626614a1763df805b9e"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + CrossChainID: "status", + ChainID: 10, + Address: common.HexToAddress("0x650af3c15af43dcb218406d30784416d64cfb6b2"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + CrossChainID: "status", + ChainID: 8453, + Address: common.HexToAddress("0x662015ec830df08c0fc45896fab726542e8ac09e"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + CrossChainID: "status", + ChainID: 42161, + Address: common.HexToAddress("0x707f635951193ddafbb40971a0fcaab8a6415160"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + CrossChainID: "status-test-token", + ChainID: 84532, + Address: common.HexToAddress("0xfdb3b57944943a7724fcc0520ee2b10659969a06"), + Name: "Status Test Token", + Symbol: "STT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + CrossChainID: "status-test-token", + ChainID: 11155111, + Address: common.HexToAddress("0xe452027cdef746c7cd3db31cb700428b16cd8e51"), + Name: "Status Test Token", + Symbol: "STT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + CrossChainID: "status-test-token", + ChainID: 1660990954, + Address: common.HexToAddress("0x1c3ac2a186c6149ae7cb4d716ebbd0766e4f898a"), + Name: "Status Test Token", + Symbol: "STT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + CrossChainID: "usd-coin", + ChainID: 1, + Address: common.HexToAddress("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 10, + Address: common.HexToAddress("0x0b2c639c533813f4aa9d7837caf62653d097ff85"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 8453, + Address: common.HexToAddress("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 42161, + Address: common.HexToAddress("0xaf88d065e77c8cc2239327c5edb3a432268e5831"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 84532, + Address: common.HexToAddress("0x036cbd53842c5426634e7929541ec2318f3dcf7e"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 421614, + Address: common.HexToAddress("0x75faf114eafb1bdbe2f0316df893fd58ce46aa4d"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 11155111, + Address: common.HexToAddress("0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 11155420, + Address: common.HexToAddress("0x5fd84259d66cd46123540766be93dfe6d43130d7"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin", + ChainID: 1660990954, + Address: common.HexToAddress("0xc445a18ca49190578dad62fba3048c07efc07ffe"), + Name: "USDC (EVM)", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "usd-coin-bsc", + ChainID: 56, + Address: common.HexToAddress("0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d"), + Name: "USDC (BSC)", + Symbol: "USDC", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + CrossChainID: "tether", + ChainID: 1, + Address: common.HexToAddress("0xdac17f958d2ee523a2206206994597c13d831ec7"), + Name: "USDT (EVM)", + Symbol: "USDT", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + }, + { + CrossChainID: "tether", + ChainID: 10, + Address: common.HexToAddress("0x94b008aa00579c1307b0ef2c499ad98a8ce58e58"), + Name: "USDT (EVM)", + Symbol: "USDT", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + }, + { + CrossChainID: "tether", + ChainID: 8453, + Address: common.HexToAddress("0xfde4c96c8593536e31f229ea8f37b2ada2699bb2"), + Name: "USDT (EVM)", + Symbol: "USDT", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + }, + { + CrossChainID: "tether", + ChainID: 42161, + Address: common.HexToAddress("0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"), + Name: "USDT (EVM)", + Symbol: "USDT", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + }, + { + CrossChainID: "tether-bsc", + ChainID: 56, + Address: common.HexToAddress("0x55d398326f99059ff775485246999027b3197955"), + Name: "USDT (BSC)", + Symbol: "USDT", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xdAC17F958D2ee523a2206206994597C13D831ec7/logo.png", + }, + { + CrossChainID: "dai", + ChainID: 1, + Address: common.HexToAddress("0x6b175474e89094c44da98b954eedeac495271d0f"), + Name: "DAI", + Symbol: "DAI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + }, + { + CrossChainID: "dai", + ChainID: 56, + Address: common.HexToAddress("0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3"), + Name: "DAI", + Symbol: "DAI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + }, + { + CrossChainID: "dai", + ChainID: 8453, + Address: common.HexToAddress("0x50c5725949a6f0c72e6c4a641f24049a917db0cb"), + Name: "DAI", + Symbol: "DAI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + }, + { + CrossChainID: "dai", + ChainID: 42161, + Address: common.HexToAddress("0xda10009cbd5d07dd0cecc66161fc93d7c9000da1"), + Name: "DAI", + Symbol: "DAI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + }, + { + CrossChainID: "dai", + ChainID: 11155111, + Address: common.HexToAddress("0x3e622317f8c93f7328350cf0b56d9ed4c620c5d6"), + Name: "DAI", + Symbol: "DAI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png", + }, + { + CrossChainID: "binancecoin", + ChainID: 56, + Address: common.HexToAddress("0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c"), + Name: "Wrapped BNB", + Symbol: "WBNB", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/smartchain/assets/0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c/logo.png", + }, + { + CrossChainID: "chainlink", + ChainID: 1, + Address: common.HexToAddress("0x514910771af9ca656af840dff83e8264ecf986ca"), + Name: "Chainlink", + Symbol: "LINK", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png", + }, + { + CrossChainID: "chainlink", + ChainID: 10, + Address: common.HexToAddress("0x350a791bfc2c21f9ed5d10980dad2e2638ffa7f6"), + Name: "Chainlink", + Symbol: "LINK", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png", + }, + { + CrossChainID: "chainlink", + ChainID: 56, + Address: common.HexToAddress("0xf8a0bf9cf54bb92f17374d9e9a321e6a111a51bd"), + Name: "Chainlink", + Symbol: "LINK", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png", + }, + { + CrossChainID: "chainlink", + ChainID: 8453, + Address: common.HexToAddress("0x88fb150bdc53a65fe94dea0c9ba0a6daf8c6e196"), + Name: "Chainlink", + Symbol: "LINK", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png", + }, + { + CrossChainID: "chainlink", + ChainID: 42161, + Address: common.HexToAddress("0xf97f4df75117a78c1a5a0dbb814af92458539fb4"), + Name: "Chainlink", + Symbol: "LINK", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png", + }, + { + CrossChainID: "wrapped-bitcoin", + ChainID: 1, + Address: common.HexToAddress("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599"), + Name: "Wrapped BTC", + Symbol: "WBTC", + Decimals: 8, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png", + }, + { + CrossChainID: "wrapped-bitcoin", + ChainID: 10, + Address: common.HexToAddress("0x68f180fcce6836688e9084f035309e29bf0a2095"), + Name: "Wrapped BTC", + Symbol: "WBTC", + Decimals: 8, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png", + }, + { + CrossChainID: "wrapped-bitcoin", + ChainID: 42161, + Address: common.HexToAddress("0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f"), + Name: "Wrapped BTC", + Symbol: "WBTC", + Decimals: 8, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/logo.png", + }, + { + CrossChainID: "ethena-usde", + ChainID: 1, + Address: common.HexToAddress("0x4c9edd5852cd905f086c759e8383e09bff1e68b3"), + Name: "Ethena USDe", + Symbol: "USDE", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x4c9edd5852cd905f086c759e8383e09bff1e68b3/logo.png", + }, + { + CrossChainID: "ethena-usde", + ChainID: 42161, + Address: common.HexToAddress("0x5d3a1ff2b6bab83b63cd9ad0787074081a52ef34"), + Name: "Ethena USDe", + Symbol: "USDE", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x4c9edd5852cd905f086c759e8383e09bff1e68b3/logo.png", + }, + { + CrossChainID: "uniswap", + ChainID: 1, + Address: common.HexToAddress("0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"), + Name: "Uniswap", + Symbol: "UNI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984/logo.png", + }, + { + CrossChainID: "uniswap", + ChainID: 10, + Address: common.HexToAddress("0x6fd9d7ad17242c41f7131d257212c54a0e816691"), + Name: "Uniswap", + Symbol: "UNI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984/logo.png", + }, + { + CrossChainID: "uniswap", + ChainID: 56, + Address: common.HexToAddress("0xbf5140a22578168fd562dccf235e5d43a02ce9b1"), + Name: "Uniswap", + Symbol: "UNI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984/logo.png", + }, + { + CrossChainID: "uniswap", + ChainID: 42161, + Address: common.HexToAddress("0xfa7f8980b0f1e64a2062791cc3b0871572f1f7f0"), + Name: "Uniswap", + Symbol: "UNI", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984/logo.png", + }, + { + CrossChainID: "eurc", + ChainID: 1, + Address: common.HexToAddress("0x1abaea1f7c830bd89acc67ec4af516284b1bc33c"), + Name: "EURC", + Symbol: "EURC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1abaea1f7c830bd89acc67ec4af516284b1bc33c/logo.png", + }, + { + CrossChainID: "eurc", + ChainID: 8453, + Address: common.HexToAddress("0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42"), + Name: "EURC", + Symbol: "EURC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1abaea1f7c830bd89acc67ec4af516284b1bc33c/logo.png", + }, + { + CrossChainID: "eurc", + ChainID: 42161, + Address: common.HexToAddress("0x0863708032b5c328e11abcb0df9d79c71fc52a48"), + Name: "EURC", + Symbol: "EURC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1abaea1f7c830bd89acc67ec4af516284b1bc33c/logo.png", + }, + { + CrossChainID: "eurc", + ChainID: 84532, + Address: common.HexToAddress("0x808456652fdb597867f38412077a9182bf77359f"), + Name: "EURC", + Symbol: "EURC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1abaea1f7c830bd89acc67ec4af516284b1bc33c/logo.png", + }, + { + CrossChainID: "eurc", + ChainID: 11155111, + Address: common.HexToAddress("0x08210f9170f89ab7658f0b5e3ff39b0e03c594d4"), + Name: "EURC", + Symbol: "EURC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1abaea1f7c830bd89acc67ec4af516284b1bc33c/logo.png", + }, + { + CrossChainID: "eurc", + ChainID: 1660990954, + Address: common.HexToAddress("0xfe8be27656b1508194d9302d12a940b4d7c35b99"), + Name: "EURC", + Symbol: "EURC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1abaea1f7c830bd89acc67ec4af516284b1bc33c/logo.png", + }, + }, +} diff --git a/pkg/tokenlists/test_data_uniswap_token_list.go b/pkg/tokenlists/test_data_uniswap_token_list.go new file mode 100644 index 0000000..98e87f6 --- /dev/null +++ b/pkg/tokenlists/test_data_uniswap_token_list.go @@ -0,0 +1,953 @@ +package tokenlists + +import ( + "fmt" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +const ( + uniswapSchemaURL = "/uniswap.schema.json" + + uniswapEtag = "uniswapEtag" + uniswapNewEtag = "uniswapNewEtag" + uniswapURL = "/uniswap.json" + uniswapWithEtagURL = "/uniswap-with-etag.json" // #nosec G101 + uniswapSameEtagURL = "/uniswap-with-same-etag.json" // #nosec G101 + uniswapNewEtagURL = "/uniswap-with-new-etag.json" // #nosec G101 +) + +// #nosec G101 +const uniswapTokenListJsonResponseTemplate = `{ + "name": "NAME", + "timestamp": "TIMESTAMP", + "version": { + "major": MAJOR, + "minor": MINOR, + "patch": 0 + }, + "tags": {}, + "logoURI": "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + "keywords": [ + "uniswap", + "default" + ], + "tokens": TOKENS +}` + +// #nosec G101 +const uniswapTokensJsonResponse = `[ + { + "chainId": 1, + "address": "0x744d70FDBE2Ba4CF95131626614a1763DF805B9E", + "name": "Status", + "symbol": "SNT", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "extensions": { + "bridgeInfo": { + "130": { + "tokenAddress": "0x914f7CE2B080B2186159C2213B1e193E265aBF5F" + }, + "8453": { + "tokenAddress": "0x662015EC830DF08C0FC45896FaB726542e8AC09E" + }, + "42161": { + "tokenAddress": "0x707F635951193dDaFBB40971a0fCAAb8A6415160" + } + } + } + } +]` + +// #nosec G101 +const uniswapTokensJsonResponse1 = `[ + { + "chainId": 1, + "address": "0x111111111117dC0aa78b770fA6A738034120C302", + "name": "1inch", + "symbol": "1INCH", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/13469/thumb/1inch-token.png?1608803028", + "extensions": { + "bridgeInfo": { + "10": { + "tokenAddress": "0xAd42D013ac31486B73b6b059e748172994736426" + }, + "56": { + "tokenAddress": "0x111111111117dC0aa78b770fA6A738034120C302" + }, + "130": { + "tokenAddress": "0xbe41cde1C5e75a7b6c2c70466629878aa9ACd06E" + }, + "137": { + "tokenAddress": "0x9c2C5fd7b07E95EE044DDeba0E97a665F142394f" + }, + "8453": { + "tokenAddress": "0xc5fecC3a29Fb57B5024eEc8a2239d4621e111CBE" + }, + "42161": { + "tokenAddress": "0x6314C31A7a1652cE482cffe247E9CB7c3f4BB9aF" + }, + "43114": { + "tokenAddress": "0xd501281565bf7789224523144Fe5D98e8B28f267" + } + } + } + }, + { + "chainId": 1, + "address": "0x744d70FDBE2Ba4CF95131626614a1763DF805B9E", + "name": "Status", + "symbol": "SNT", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "extensions": { + "bridgeInfo": { + "10": { + "tokenAddress": "0x650AF3C15AF43dcB218406d30784416D64Cfb6B2" + }, + "130": { + "tokenAddress": "0x914f7CE2B080B2186159C2213B1e193E265aBF5F" + }, + "8453": { + "tokenAddress": "0x662015EC830DF08C0FC45896FaB726542e8AC09E" + }, + "42161": { + "tokenAddress": "0x707F635951193dDaFBB40971a0fCAAb8A6415160" + } + } + } + } +]` + +// #nosec G101 +const uniswapTokensJsonResponse2 = `[ + { + "chainId": 1, + "address": "0x111111111117dC0aa78b770fA6A738034120C302", + "name": "1inch", + "symbol": "1INCH", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/13469/thumb/1inch-token.png?1608803028", + "extensions": { + "bridgeInfo": { + "10": { + "tokenAddress": "0xAd42D013ac31486B73b6b059e748172994736426" + }, + "56": { + "tokenAddress": "0x111111111117dC0aa78b770fA6A738034120C302" + }, + "130": { + "tokenAddress": "0xbe41cde1C5e75a7b6c2c70466629878aa9ACd06E" + }, + "137": { + "tokenAddress": "0x9c2C5fd7b07E95EE044DDeba0E97a665F142394f" + }, + "8453": { + "tokenAddress": "0xc5fecC3a29Fb57B5024eEc8a2239d4621e111CBE" + }, + "42161": { + "tokenAddress": "0x6314C31A7a1652cE482cffe247E9CB7c3f4BB9aF" + }, + "43114": { + "tokenAddress": "0xd501281565bf7789224523144Fe5D98e8B28f267" + } + } + } + }, + { + "chainId": 1, + "address": "0x3E5A19c91266aD8cE2477B91585d1856B84062dF", + "name": "Ancient8", + "symbol": "A8", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/39170/standard/A8_Token-04_200x200.png?1720798300", + "extensions": { + "bridgeInfo": { + "130": { + "tokenAddress": "0x44D618C366D7bC85945Bfc922ACad5B1feF7759A" + } + } + } + }, + { + "chainId": 1, + "address": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", + "name": "Aave", + "symbol": "AAVE", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/12645/thumb/AAVE.png?1601374110", + "extensions": { + "bridgeInfo": { + "10": { + "tokenAddress": "0x76FB31fb4af56892A25e32cFC43De717950c9278" + }, + "56": { + "tokenAddress": "0xfb6115445Bff7b52FeB98650C87f44907E58f802" + }, + "130": { + "tokenAddress": "0x02a24C380dA560E4032Dc6671d8164cfbEEAAE1e" + }, + "137": { + "tokenAddress": "0xD6DF932A45C0f255f85145f286eA0b292B21C90B" + }, + "8453": { + "tokenAddress": "0x63706e401c06ac8513145b7687A14804d17f814b" + }, + "42161": { + "tokenAddress": "0xba5DdD1f9d7F570dc94a51479a000E3BCE967196" + }, + "43114": { + "tokenAddress": "0x63a72806098Bd3D9520cC43356dD78afe5D386D9" + } + } + } + }, + { + "chainId": 1, + "address": "0x744d70FDBE2Ba4CF95131626614a1763DF805B9E", + "name": "Status", + "symbol": "SNT", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "extensions": { + "bridgeInfo": { + "10": { + "tokenAddress": "0x650AF3C15AF43dcB218406d30784416D64Cfb6B2" + }, + "130": { + "tokenAddress": "0x914f7CE2B080B2186159C2213B1e193E265aBF5F" + }, + "8453": { + "tokenAddress": "0x662015EC830DF08C0FC45896FaB726542e8AC09E" + }, + "42161": { + "tokenAddress": "0x707F635951193dDaFBB40971a0fCAAb8A6415160" + } + } + } + }, + { + "chainId": 10, + "address": "0x650AF3C15AF43dcB218406d30784416D64Cfb6B2", + "name": "Status", + "symbol": "SNT", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "extensions": { + "bridgeInfo": { + "1": { + "tokenAddress": "0x744d70FDBE2Ba4CF95131626614a1763DF805B9E" + } + } + } + }, + { + "chainId": 8453, + "address": "0x662015EC830DF08C0FC45896FaB726542e8AC09E", + "name": "Status", + "symbol": "SNT", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + "extensions": { + "bridgeInfo": { + "1": { + "tokenAddress": "0x744d70FDBE2Ba4CF95131626614a1763DF805B9E" + } + } + } + }, + { + "name": "USDCoin", + "address": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "symbol": "USDC", + "decimals": 6, + "chainId": 10, + "logoURI": "https://ethereum-optimism.github.io/data/USDC/logo.png", + "extensions": { + "bridgeInfo": { + "1": { + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + } + }, + { + "name": "USDCoin", + "address": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "symbol": "USDC", + "decimals": 6, + "chainId": 42161, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + "extensions": { + "bridgeInfo": { + "1": { + "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + } + } + }, + { + "name": "Wrapped Ether", + "address": "0xA6FA4fB5f76172d178d61B04b0ecd319C5d1C0aa", + "symbol": "WETH", + "decimals": 18, + "chainId": 80001, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + }, + { + "name": "Wrapped Matic", + "address": "0x9c3C9283D3e44854697Cd22D3Faa240Cfb032889", + "symbol": "WMATIC", + "decimals": 18, + "chainId": 80001, + "logoURI": "https://assets.coingecko.com/coins/images/4713/thumb/matic-token-icon.png?1624446912" + }, + { + "chainId": 81457, + "address": "0xb1a5700fA2358173Fe465e6eA4Ff52E36e88E2ad", + "name": "Blast", + "symbol": "BLAST", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/35494/standard/Blast.jpg?1719385662" + }, + { + "chainId": 7777777, + "address": "0xCccCCccc7021b32EBb4e8C08314bD62F7c653EC4", + "name": "USD Coin (Bridged from Ethereum)", + "symbol": "USDzC", + "decimals": 6, + "logoURI": "https://assets.coingecko.com/coins/images/35218/large/USDC_Icon.png?1707908537" + }, + { + "name": "Uniswap", + "address": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "symbol": "UNI", + "decimals": 18, + "chainId": 11155111, + "logoURI": "ipfs://QmXttGpZrECX5qCyXbBQiqgQNytVGeZW5Anewvh2jc4psg" + }, + { + "name": "Wrapped Ether", + "address": "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", + "symbol": "WETH", + "decimals": 18, + "chainId": 11155111, + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + } +]` + +// #nosec G101 +const uniswapInvalidTokensJsonResponse = `[ + { + "chainId": 1, + "address": "invalid-address", + "name": "1inch", + "symbol": "1INCH", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/13469/thumb/1inch-token.png?1608803028" + }, + { + "chainId": 1, + "address": "0x744d70FDBE2Ba4CF95131626614a1763DF805B9E", + "name": "Status", + "symbol": "SNT", + "decimals": 18, + "logoURI": "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778" + } +]` + +func createUniswapTokenListJsonResponse(name string, timestamp string, major int, minor int, tokens string) string { + list := strings.ReplaceAll(uniswapTokenListJsonResponseTemplate, "NAME", name) + list = strings.ReplaceAll(list, "TIMESTAMP", timestamp) + list = strings.ReplaceAll(list, "MAJOR", fmt.Sprintf("%d", major)) + list = strings.ReplaceAll(list, "MINOR", fmt.Sprintf("%d", minor)) + return strings.ReplaceAll(list, "TOKENS", tokens) +} + +var uniswapTokenListJsonResponse = createUniswapTokenListJsonResponse("Uniswap Labs Default", "2025-08-26T21:30:26.717Z", 13, 45, uniswapTokensJsonResponse) +var uniswapTokenListJsonResponse1 = createUniswapTokenListJsonResponse("Uniswap Labs Default", "2025-08-27T21:30:26.717Z", 13, 46, uniswapTokensJsonResponse1) +var uniswapTokenListJsonResponse2 = createUniswapTokenListJsonResponse("Uniswap Labs Default", "2025-08-28T21:30:26.717Z", 13, 47, uniswapTokensJsonResponse2) + +var uniswapTokenListInvalidTokensJsonResponse = createStatusTokenListJsonResponse("Uniswap Labs Default", "2025-08-26T21:30:26.717Z", 13, 45, uniswapInvalidTokensJsonResponse) + +var uniswapTokenListEmptyTokensJsonResponse = createStatusTokenListJsonResponse("Uniswap Labs Default", "2025-08-26T21:30:26.717Z", 13, 45, "[]") + +var fetchedUniswapTokenList = createFetchedTokenListFromResponse(uniswapTokenListJsonResponse) +var fetchedUniswapTokenList1 = createFetchedTokenListFromResponse(uniswapTokenListJsonResponse1) +var fetchedUniswapTokenList2 = createFetchedTokenListFromResponse(uniswapTokenListJsonResponse2) + +var fetchedUniswapTokenListInvalidTokens = createFetchedTokenListFromResponse(uniswapTokenListInvalidTokensJsonResponse) + +var fetchedUniswapTokenListEmpty = createFetchedTokenListFromResponse(uniswapTokenListEmptyTokensJsonResponse) + +var uniswapTokenListEmpty = TokenList{ + Name: "Uniswap Labs Default", + Timestamp: "2025-08-26T21:30:26.717Z", + FetchedTimestamp: fetchedUniswapTokenListEmpty.Fetched.Format(time.RFC3339), + Source: "https://example.com/uniswap-token-list.json", + Version: Version{ + Major: 13, + Minor: 45, + Patch: 0, + }, + Tags: map[string]interface{}{}, + LogoURI: "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + Keywords: []string{"uniswap", "default"}, + Tokens: []*Token{}, +} + +var uniswapTokenListInvalidTokens = TokenList{ + Name: "Uniswap Labs Default", + Timestamp: "2025-08-26T21:30:26.717Z", + FetchedTimestamp: fetchedUniswapTokenListInvalidTokens.Fetched.Format(time.RFC3339), + Source: "https://example.com/uniswap-token-list.json", + Version: Version{ + Major: 13, + Minor: 45, + Patch: 0, + }, + Tags: map[string]interface{}{}, + LogoURI: "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + Keywords: []string{"uniswap", "default"}, + Tokens: []*Token{ + { + ChainID: 1, + Address: common.HexToAddress("0x744d70FDBE2Ba4CF95131626614a1763DF805B9E"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + }, +} + +var uniswapTokenList = TokenList{ + Name: "Uniswap Labs Default", + Timestamp: "2025-08-28T21:30:26.717Z", + FetchedTimestamp: fetchedUniswapTokenList2.Fetched.Format(time.RFC3339), + Source: "https://example.com/uniswap-token-list.json", + Version: Version{ + Major: 13, + Minor: 47, + Patch: 0, + }, + Tags: map[string]interface{}{}, + LogoURI: "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + Keywords: []string{"uniswap", "default"}, + Tokens: []*Token{ + { + ChainID: 1, + Address: common.HexToAddress("0x744d70FDBE2Ba4CF95131626614a1763DF805B9E"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + }, +} + +var uniswapTokenList2 = TokenList{ + Name: "Uniswap Labs Default", + Timestamp: "2025-08-28T21:30:26.717Z", + FetchedTimestamp: fetchedUniswapTokenList2.Fetched.Format(time.RFC3339), + Source: "https://example.com/uniswap-token-list.json", + Version: Version{ + Major: 13, + Minor: 47, + Patch: 0, + }, + Tags: map[string]interface{}{}, + LogoURI: "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir", + Keywords: []string{"uniswap", "default"}, + Tokens: []*Token{ + { + ChainID: 1, + Address: common.HexToAddress("0x111111111117dC0aa78b770fA6A738034120C302"), + Name: "1inch", + Symbol: "1INCH", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/13469/thumb/1inch-token.png?1608803028", + }, + { + ChainID: 1, + Address: common.HexToAddress("0x3E5A19c91266aD8cE2477B91585d1856B84062dF"), + Name: "Ancient8", + Symbol: "A8", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/39170/standard/A8_Token-04_200x200.png?1720798300", + }, + { + ChainID: 1, + Address: common.HexToAddress("0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9"), + Name: "Aave", + Symbol: "AAVE", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/12645/thumb/AAVE.png?1601374110", + }, + { + ChainID: 1, + Address: common.HexToAddress("0x744d70FDBE2Ba4CF95131626614a1763DF805B9E"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + ChainID: 10, + Address: common.HexToAddress("0x650AF3C15AF43dcB218406d30784416D64Cfb6B2"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + ChainID: 8453, + Address: common.HexToAddress("0x662015EC830DF08C0FC45896FaB726542e8AC09E"), + Name: "Status", + Symbol: "SNT", + Decimals: 18, + LogoURI: "https://assets.coingecko.com/coins/images/779/thumb/status.png?1548610778", + }, + { + ChainID: 10, + Address: common.HexToAddress("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), + Name: "USDCoin", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://ethereum-optimism.github.io/data/USDC/logo.png", + }, + { + ChainID: 42161, + Address: common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"), + Name: "USDCoin", + Symbol: "USDC", + Decimals: 6, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png", + }, + { + ChainID: 11155111, + Address: common.HexToAddress("0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984"), + Name: "Uniswap", + Symbol: "UNI", + Decimals: 18, + LogoURI: "ipfs://QmXttGpZrECX5qCyXbBQiqgQNytVGeZW5Anewvh2jc4psg", + }, + { + ChainID: 11155111, + Address: common.HexToAddress("0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"), + Name: "Wrapped Ether", + Symbol: "WETH", + Decimals: 18, + LogoURI: "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + }, + }, +} + +// #nosec G101 +const uniswapTokenListSchemaResponse = `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://uniswap.org/tokenlist.schema.json", + "title": "Uniswap Token List", + "description": "Schema for lists of tokens compatible with the Uniswap Interface", + "definitions": { + "Version": { + "type": "object", + "description": "The version of the list, used in change detection", + "examples": [ + { + "major": 1, + "minor": 0, + "patch": 0 + } + ], + "additionalProperties": false, + "properties": { + "major": { + "type": "integer", + "description": "The major version of the list. Must be incremented when tokens are removed from the list or token addresses are changed.", + "minimum": 0, + "examples": [ + 1, + 2 + ] + }, + "minor": { + "type": "integer", + "description": "The minor version of the list. Must be incremented when tokens are added to the list.", + "minimum": 0, + "examples": [ + 0, + 1 + ] + }, + "patch": { + "type": "integer", + "description": "The patch version of the list. Must be incremented for any changes to the list.", + "minimum": 0, + "examples": [ + 0, + 1 + ] + } + }, + "required": [ + "major", + "minor", + "patch" + ] + }, + "TagIdentifier": { + "type": "string", + "description": "The unique identifier of a tag", + "minLength": 1, + "maxLength": 10, + "pattern": "^[\\w]+$", + "examples": [ + "compound", + "stablecoin" + ] + }, + "ExtensionIdentifier": { + "type": "string", + "description": "The name of a token extension property", + "minLength": 1, + "maxLength": 40, + "pattern": "^[\\w]+$", + "examples": [ + "color", + "is_fee_on_transfer", + "aliases" + ] + }, + "ExtensionMap": { + "type": "object", + "description": "An object containing any arbitrary or vendor-specific token metadata", + "maxProperties": 10, + "propertyNames": { + "$ref": "#/definitions/ExtensionIdentifier" + }, + "additionalProperties": { + "$ref": "#/definitions/ExtensionValue" + }, + "examples": [ + { + "color": "#000000", + "is_verified_by_me": true + }, + { + "x-bridged-addresses-by-chain": { + "1": { + "bridgeAddress": "0x4200000000000000000000000000000000000010", + "tokenAddress": "0x4200000000000000000000000000000000000010" + } + } + } + ] + }, + "ExtensionPrimitiveValue": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 42, + "examples": [ + "#00000" + ] + }, + { + "type": "boolean", + "examples": [ + true + ] + }, + { + "type": "number", + "examples": [ + 15 + ] + }, + { + "type": "null" + } + ] + }, + "ExtensionValue": { + "anyOf": [ + { + "$ref": "#/definitions/ExtensionPrimitiveValue" + }, + { + "type": "object", + "maxProperties": 10, + "propertyNames": { + "$ref": "#/definitions/ExtensionIdentifier" + }, + "additionalProperties": { + "$ref": "#/definitions/ExtensionValueInner0" + } + } + ] + }, + "ExtensionValueInner0": { + "anyOf": [ + { + "$ref": "#/definitions/ExtensionPrimitiveValue" + }, + { + "type": "object", + "maxProperties": 10, + "propertyNames": { + "$ref": "#/definitions/ExtensionIdentifier" + }, + "additionalProperties": { + "$ref": "#/definitions/ExtensionValueInner1" + } + } + ] + }, + "ExtensionValueInner1": { + "anyOf": [ + { + "$ref": "#/definitions/ExtensionPrimitiveValue" + } + ] + }, + "TagDefinition": { + "type": "object", + "description": "Definition of a tag that can be associated with a token via its identifier", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the tag", + "pattern": "^[ \\w]+$", + "minLength": 1, + "maxLength": 20 + }, + "description": { + "type": "string", + "description": "A user-friendly description of the tag", + "pattern": "^[ \\w\\.,:]+$", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "name", + "description" + ], + "examples": [ + { + "name": "Stablecoin", + "description": "A token with value pegged to another asset" + } + ] + }, + "TokenInfo": { + "type": "object", + "description": "Metadata for a single token in a token list", + "additionalProperties": false, + "properties": { + "chainId": { + "type": "integer", + "description": "The chain ID of the Ethereum network where this token is deployed", + "minimum": 1, + "examples": [ + 1, + 42 + ] + }, + "address": { + "type": "string", + "description": "The checksummed address of the token on the specified chain ID", + "pattern": "^0x[a-fA-F0-9]{40}$", + "examples": [ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + ] + }, + "decimals": { + "type": "integer", + "description": "The number of decimals for the token balance", + "minimum": 0, + "maximum": 255, + "examples": [ + 18 + ] + }, + "name": { + "type": "string", + "description": "The name of the token", + "minLength": 0, + "maxLength": 60, + "anyOf": [ + { + "const": "" + }, + { + "pattern": "^[ \\S+]+$" + } + ], + "examples": [ + "USD Coin" + ] + }, + "symbol": { + "type": "string", + "description": "The symbol for the token", + "minLength": 0, + "maxLength": 20, + "anyOf": [ + { + "const": "" + }, + { + "pattern": "^\\S+$" + } + ], + "examples": [ + "USDC" + ] + }, + "logoURI": { + "type": "string", + "description": "A URI to the token logo asset; if not set, interface will attempt to find a logo based on the token address; suggest SVG or PNG of size 64x64", + "format": "uri", + "examples": [ + "ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM" + ] + }, + "tags": { + "type": "array", + "description": "An array of tag identifiers associated with the token; tags are defined at the list level", + "items": { + "$ref": "#/definitions/TagIdentifier" + }, + "maxItems": 10, + "examples": [ + "stablecoin", + "compound" + ] + }, + "extensions": { + "$ref": "#/definitions/ExtensionMap" + } + }, + "required": [ + "chainId", + "address", + "decimals", + "name", + "symbol" + ] + } + }, + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the token list", + "minLength": 1, + "maxLength": 30, + "pattern": "^[\\w ]+$", + "examples": [ + "My Token List" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "The timestamp of this list version; i.e. when this immutable version of the list was created" + }, + "version": { + "$ref": "#/definitions/Version" + }, + "tokens": { + "type": "array", + "description": "The list of tokens included in the list", + "items": { + "$ref": "#/definitions/TokenInfo" + }, + "minItems": 1, + "maxItems": 10000 + }, + "tokenMap": { + "type": "object", + "description": "A mapping of key 'chainId_tokenAddress' to its corresponding token object", + "minProperties": 1, + "maxProperties": 10000, + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/definitions/TokenInfo" + }, + "examples": [ + { + "4_0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984": { + "name": "Uniswap", + "address": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "symbol": "UNI", + "decimals": 18, + "chainId": 4, + "logoURI": "ipfs://QmXttGpZrECX5qCyXbBQiqgQNytVGeZW5Anewvh2jc4psg" + } + } + ] + }, + "keywords": { + "type": "array", + "description": "Keywords associated with the contents of the list; may be used in list discoverability", + "items": { + "type": "string", + "description": "A keyword to describe the contents of the list", + "minLength": 1, + "maxLength": 20, + "pattern": "^[\\w ]+$", + "examples": [ + "compound", + "lending", + "personal tokens" + ] + }, + "maxItems": 20, + "uniqueItems": true + }, + "tags": { + "type": "object", + "description": "A mapping of tag identifiers to their name and description", + "propertyNames": { + "$ref": "#/definitions/TagIdentifier" + }, + "additionalProperties": { + "$ref": "#/definitions/TagDefinition" + }, + "maxProperties": 20, + "examples": [ + { + "stablecoin": { + "name": "Stablecoin", + "description": "A token with value pegged to another asset" + } + } + ] + }, + "logoURI": { + "type": "string", + "description": "A URI for the logo of the token list; prefer SVG or PNG of size 256x256", + "format": "uri", + "examples": [ + "ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM" + ] + } + }, + "required": [ + "name", + "timestamp", + "version", + "tokens" + ] +}` diff --git a/pkg/tokenlists/test_helper.go b/pkg/tokenlists/test_helper.go new file mode 100644 index 0000000..73482c5 --- /dev/null +++ b/pkg/tokenlists/test_helper.go @@ -0,0 +1,151 @@ +package tokenlists + +import ( + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "strings" + "time" +) + +func copyRemoteListOfTokenLists(original remoteListOfTokenLists) remoteListOfTokenLists { + copy := remoteListOfTokenLists{ + Timestamp: original.Timestamp, + Version: original.Version, + TokenLists: make([]tokenList, len(original.TokenLists)), + } + + for i, tokenList := range original.TokenLists { + copy.TokenLists[i] = tokenList + } + + return copy +} + +func createFetchedTokenListFromResponse(response string) fetchedTokenList { + var list fetchedTokenList + err := json.Unmarshal([]byte(response), &list) + if err != nil { + panic(err) + } + list.Fetched = time.Now() + return list +} + +func GetTestServer() (server *httptest.Server, close func()) { + mux := http.NewServeMux() + server = httptest.NewServer(mux) + + mux.HandleFunc(emptyTokenListsURL, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(emptyTokenListsResponse)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(delayedResponseURL, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(10 * time.Second) + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("delayed-response")); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(listOfTokenListsWrongSchemaURL, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := strings.ReplaceAll(listOfTokenListsWrongSchemaResponse, serverURLPlaceholder, server.URL) + if _, err := w.Write([]byte(resp)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(listOfTokenListsURL, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := strings.ReplaceAll(listOfTokenListsJsonResponse, serverURLPlaceholder, server.URL) + if _, err := w.Write([]byte(resp)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(listOfTokenListsSomeWrongUrlsURL, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + resp := strings.ReplaceAll(listOfTokenListsWrongUrlsJsonResponse, serverURLPlaceholder, server.URL) + if _, err := w.Write([]byte(resp)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(listOfTokenListsWithEtagURL, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", listOfTokenListsEtag) + w.WriteHeader(http.StatusOK) + resp := strings.ReplaceAll(listOfTokenListsJsonResponse1, serverURLPlaceholder, server.URL) + if _, err := w.Write([]byte(resp)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(listOfTokenListsWithSameEtagURL, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", listOfTokenListsEtag) + w.WriteHeader(http.StatusNotModified) + if _, err := w.Write([]byte(listOfTokenListsJsonResponse1)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(listOfTokenListsWithNewEtagURL, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", listOfTokenListsNewEtag) + w.WriteHeader(http.StatusOK) + resp := strings.ReplaceAll(listOfTokenListsJsonResponse2, serverURLPlaceholder, server.URL) + if _, err := w.Write([]byte(resp)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(statusURL, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(statusTokenListJsonResponse)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(uniswapSchemaURL, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(uniswapTokenListSchemaResponse)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(uniswapURL, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(uniswapTokenListJsonResponse)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(uniswapWithEtagURL, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", uniswapEtag) + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(uniswapTokenListJsonResponse1)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(uniswapSameEtagURL, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", uniswapEtag) + w.WriteHeader(http.StatusNotModified) + if _, err := w.Write([]byte(uniswapTokenListJsonResponse1)); err != nil { + log.Println(err.Error()) + } + }) + + mux.HandleFunc(uniswapNewEtagURL, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", uniswapNewEtag) + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(uniswapTokenListJsonResponse2)); err != nil { + log.Println(err.Error()) + } + }) + + return server, server.Close +} diff --git a/pkg/tokenlists/tokenslist.go b/pkg/tokenlists/tokenslist.go new file mode 100644 index 0000000..b82cf51 --- /dev/null +++ b/pkg/tokenlists/tokenslist.go @@ -0,0 +1,454 @@ +package tokenlists + +import ( + "context" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" + + gethcommon "github.com/ethereum/go-ethereum/common" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "go.uber.org/zap" +) + +// tokensList implements the TokensList interface with thread-safe state management. +type tokensList struct { + mu sync.RWMutex + + config *Config + refreshWorker *refreshWorker + + state atomic.Pointer[state] + + notifyCh chan struct{} + + started atomic.Bool +} + +// NewTokensList creates a new TokensList instance. +func NewTokensList(config *Config) (TokensList, error) { + if err := validateConfig(config); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + return &tokensList{ + config: config, + refreshWorker: newRefreshWorker(config), + }, nil +} + +// Start begins the TokensList service. +func (tl *tokensList) Start(ctx context.Context, notifyCh chan struct{}) error { + if tl.started.Load() { + return fmt.Errorf("starting tokens list which has already been started") + } + + tl.mu.Lock() + defer tl.mu.Unlock() + + if err := tl.buildState(); err != nil { + return fmt.Errorf("failed to build initial state: %w", err) + } + + tl.notifyCh = notifyCh + + if err := tl.manageRefreshWorker(ctx); err != nil { + return fmt.Errorf("failed to manage refresh worker: %w", err) + } + + tl.started.Store(true) + + return nil +} + +// Stop stops the TokensList service. +func (tl *tokensList) Stop() error { + if !tl.started.Load() { + return fmt.Errorf("stopping tokens list which has not been started") + } + + tl.mu.Lock() + defer tl.mu.Unlock() + + tl.refreshWorker.stop() + + return nil +} + +func (tl *tokensList) manageRefreshWorker(ctx context.Context) error { + privacyOn, err := tl.config.PrivacyGuard.IsPrivacyOn() + if err != nil { + return err + } + if privacyOn { + tl.refreshWorker.stop() + return nil + } + + refreshCh := tl.refreshWorker.start(ctx) + go func() { + for { + select { + case _, ok := <-refreshCh: + if !ok { + return + } + tl.mu.Lock() + err := tl.buildState() + if err != nil { + tl.config.logger.Error("failed to build state", zap.Error(err)) + } else { + tl.notifyCh <- struct{}{} + } + tl.mu.Unlock() + case <-ctx.Done(): + return + } + } + }() + + return nil +} + +// PrivacyModeUpdated is called when privacy mode is updated. +func (tl *tokensList) PrivacyModeUpdated(ctx context.Context) error { + tl.mu.Lock() + defer tl.mu.Unlock() + + if err := tl.manageRefreshWorker(ctx); err != nil { + return fmt.Errorf("failed to manage refresh worker: %w", err) + } + + return nil +} + +// LastRefreshTime returns the last refresh time. +func (tl *tokensList) LastRefreshTime() (time.Time, error) { + tl.mu.RLock() + defer tl.mu.RUnlock() + + return tl.config.LastTokenListsUpdateTimeStore.Get() +} + +// RefreshNow refreshes the tokens list. +func (tl *tokensList) RefreshNow(ctx context.Context) error { + tl.mu.Lock() + defer tl.mu.Unlock() + + privacyOn, err := tl.config.PrivacyGuard.IsPrivacyOn() + if err != nil { + return err + } + if !privacyOn { + // if privacy mode is off, we need to fetch the remote lists, build the state and and notify the client, all that is done by the manageRefreshWorker. + if err := tl.manageRefreshWorker(ctx); err != nil { + return fmt.Errorf("failed to manage refresh worker: %w", err) + } + return nil + } + + // if privacy mode is on, we need to build the state and notify the client. + if err := tl.buildState(); err != nil { + return fmt.Errorf("failed to build state: %w", err) + } + tl.notifyCh <- struct{}{} + + return nil +} + +// UniqueTokens returns all unique tokens. +func (tl *tokensList) UniqueTokens() []*Token { + state := tl.state.Load() + if state == nil { + return nil + } + tokens := make([]*Token, 0, len(state.tokens)) + for _, token := range state.tokens { + tokens = append(tokens, token) + } + return tokens +} + +// GetTokenByChainAddress retrieves a token by chain ID and address. +func (tl *tokensList) GetTokenByChainAddress(chainID uint64, addr gethcommon.Address) (*Token, bool) { + state := tl.state.Load() + if state == nil { + return nil, false + } + key := TokenKey(chainID, addr) + token, exists := state.tokens[key] + return token, exists +} + +// GetTokensByChain returns all tokens for a specific chain. +func (tl *tokensList) GetTokensByChain(chainID uint64) []*Token { + state := tl.state.Load() + if state == nil { + return nil + } + var tokens []*Token + for _, token := range state.tokens { + if token.ChainID != chainID { + continue + } + tokens = append(tokens, token) + } + return tokens +} + +// TokenList returns a token list by ID. +func (tl *tokensList) TokenList(id string) (*TokenList, bool) { + state := tl.state.Load() + if state == nil { + return nil, false + } + tokenList, exists := state.tokenLists[id] + return tokenList, exists +} + +// TokenLists returns all token lists. +func (tl *tokensList) TokenLists() []*TokenList { + state := tl.state.Load() + if state == nil { + return nil + } + tokenLists := make([]*TokenList, 0, len(state.tokenLists)) + for _, tokenList := range state.tokenLists { + tokenLists = append(tokenLists, tokenList) + } + return tokenLists +} + +func (tl *tokensList) buildState() error { + newState := &state{ + tokens: make(map[string]*Token), + tokenLists: make(map[string]*TokenList), + } + + // 1. native token list + if err := tl.mergeNativeTokenList(newState); err != nil { + tl.config.logger.Error("failed to merge native token list", zap.Error(err)) + } + + // merge tokens from all sources in the specified order. + // 2. main list (remote if available, otherwise initial) + if err := tl.mergeMainList(newState); err != nil { + tl.config.logger.Error("failed to merge main list", zap.Error(err)) + } + + // 3. other initial lists (in deterministic order), remote if available, otherwise initial list + if err := tl.mergeInitialLists(newState); err != nil { + tl.config.logger.Error("failed to merge initial lists", zap.Error(err)) + } + + // 4. remote lists that are not main or initial lists (in deterministic order) + if err := tl.mergeRemoteLists(newState); err != nil { + tl.config.logger.Error("failed to merge remote lists", zap.Error(err)) + } + + // 5. custom tokens + if err := tl.mergeCustomTokens(newState); err != nil { + tl.config.logger.Error("failed to merge custom tokens", zap.Error(err)) + } + + tl.state.Store(newState) + return nil +} + +func getNativeToken(chainID uint64) *Token { + crossChainID := EthereumNativeCrossChainID + symbol := EthereumNativeSymbol + name := EthereumNativeName + logoURI := "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png" + if chainID == common.BSCMainnet || chainID == common.BSCTestnet { + crossChainID = BinanceSmartChainNativeCrossChainID + symbol = BinanceSmartChainNativeSymbol + name = BinanceSmartChainNativeName + logoURI = "https://assets.coingecko.com/coins/images/825/thumb/bnb-icon2_2x.png?1696501970" + } + return &Token{ + CrossChainID: crossChainID, + ChainID: chainID, + Symbol: symbol, + Name: name, + Decimals: 18, + LogoURI: logoURI, + } +} + +func (tl *tokensList) mergeNativeTokenList(state *state) error { + nativeTokenList := &TokenList{ + Name: "Native tokens", + Tokens: make([]*Token, 0), + } + + for _, chainID := range tl.config.Chains { + nativeToken := getNativeToken(chainID) + nativeTokenList.Tokens = append(nativeTokenList.Tokens, nativeToken) + } + + tl.addTokenListToState(state, NativeTokenListID, nativeTokenList) + return nil +} + +func (tl *tokensList) mergeMainList(state *state) error { + parser, exists := tl.config.Parsers[tl.config.MainListID] + if !exists { + // because we validate config in NewTokensList, this should never happen + return fmt.Errorf("main list parser not found for list ID %s", tl.config.MainListID) + } + + // process last fetched main list + storedContent, err := tl.config.ContentStore.Get(tl.config.MainListID) + if err != nil { + tl.config.logger.Error("failed to get stored content for main list", zap.Error(err)) + goto processProvidedMainList + } + + if len(storedContent.Data) > 0 { + tokenList, err := parser.Parse(storedContent.Data, storedContent.SourceURL, storedContent.Fetched, tl.config.Chains) + if err != nil { + tl.config.logger.Error("failed to parse main list", zap.Error(err)) + goto processProvidedMainList + } + tl.addTokenListToState(state, tl.config.MainListID, tokenList) + return nil + } + + tl.config.logger.Info("main list not found in content store") + +processProvidedMainList: // process provided main list + if tl.config.MainList != nil { + if tokenList, err := parser.Parse(tl.config.MainList, LocalSourceURL, time.Time{}, tl.config.Chains); err == nil { + tl.addTokenListToState(state, tl.config.MainListID, tokenList) + } + } + + return nil +} + +func (tl *tokensList) mergeInitialLists(state *state) error { + // sort keys for deterministic order + keys := make([]string, 0, len(tl.config.InitialLists)) + for key := range tl.config.InitialLists { + if key != tl.config.MainListID { + keys = append(keys, key) + } + } + sort.Strings(keys) + + for _, key := range keys { + data := tl.config.InitialLists[key] + parser, exists := tl.config.Parsers[key] + if !exists { + // because we validate config in NewTokensList, this should never happen + tl.config.logger.Error("initial list parser not found for list ID", zap.String("listID", key)) + continue + } + + // process last fetched list + storedContent, err := tl.config.ContentStore.Get(key) + if err != nil { + tl.config.logger.Error("failed to get stored content for initial list", zap.Error(err)) + goto processProvidedList + } + if len(storedContent.Data) > 0 { + tokenList, err := parser.Parse(storedContent.Data, storedContent.SourceURL, storedContent.Fetched, tl.config.Chains) + if err != nil { + tl.config.logger.Error("failed to parse initial list", zap.Error(err)) + goto processProvidedList + } + tl.addTokenListToState(state, key, tokenList) + continue + } + + tl.config.logger.Info("initial list not found in content store", zap.String("listID", key)) + + processProvidedList: // process provided list + if tokens, err := parser.Parse(data, LocalSourceURL, time.Time{}, tl.config.Chains); err == nil { + tl.addTokenListToState(state, key, tokens) + } + } + + return nil +} + +func (tl *tokensList) mergeRemoteLists(state *state) error { + allStoredContent, err := tl.config.ContentStore.GetAll() + if err != nil { + return err + } + + // sort keys for deterministic order + keys := make([]string, 0, len(allStoredContent)) + for key := range allStoredContent { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, contentID := range keys { + storedContent, ok := allStoredContent[contentID] + if !ok { + continue + } + + if contentID == tl.config.MainListID { + continue + } + + if _, ok := tl.config.InitialLists[contentID]; ok { + continue + } + + parser, exists := tl.config.Parsers[contentID] + if !exists { + tl.config.logger.Error("remote list parser not found for list ID", zap.String("listID", contentID)) + continue + } + + if tokenList, err := parser.Parse(storedContent.Data, storedContent.SourceURL, storedContent.Fetched, tl.config.Chains); err == nil { + tl.addTokenListToState(state, contentID, tokenList) + } + } + + return nil +} + +func (tl *tokensList) mergeCustomTokens(state *state) error { + if tl.config.CustomTokenStore == nil { + return nil + } + customTokens, err := tl.config.CustomTokenStore.GetAll() + if err != nil { + return err + } + + customTokenList := &TokenList{ + Name: "Custom tokens", + Tokens: make([]*Token, 0, len(customTokens)), + } + for _, token := range customTokens { + if err := validateToken(token, tl.config.Chains); err != nil { + tl.config.logger.Error("invalid token", zap.String("symbol", token.Symbol), zap.Error(err)) + continue + } + customTokenList.Tokens = append(customTokenList.Tokens, token) + } + + tl.addTokenListToState(state, CustomTokenListID, customTokenList) + + return nil +} + +func (tl *tokensList) addTokenListToState(currentState *state, tokenListID string, tokenList *TokenList) { + currentState.tokenLists[tokenListID] = tokenList + for _, token := range tokenList.Tokens { + if _, exists := currentState.tokens[token.Key()]; !exists { + currentState.tokens[token.Key()] = token + } + } +} diff --git a/pkg/tokenlists/tokenslist_test.go b/pkg/tokenlists/tokenslist_test.go new file mode 100644 index 0000000..d3aee10 --- /dev/null +++ b/pkg/tokenlists/tokenslist_test.go @@ -0,0 +1,538 @@ +package tokenlists + +import ( + "context" + "math/rand" + "testing" + "time" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewTokensList(t *testing.T) { + tests := []struct { + name string + config *Config + uniqueTokens int + wantErr bool + }{ + { + name: "nil config", + config: nil, + wantErr: true, + }, + { + name: "missing main list", + config: &Config{ + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + }, + wantErr: true, + }, + { + name: "missing main list ID", + config: &Config{ + MainList: []byte("{}"), + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + }, + wantErr: true, + }, + { + name: "missing chains", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + }, + wantErr: true, + }, + { + name: "missing privacy guard", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + }, + wantErr: true, + }, + { + name: "missing last update time store", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(false), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + }, + wantErr: true, + }, + { + name: "missing content store", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + Parsers: make(map[string]Parser), + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + }, + wantErr: true, + }, + { + name: "invalid refresh intervals", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + AutoRefreshInterval: 1 * time.Minute, + AutoRefreshCheckInterval: 2 * time.Minute, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + }, + wantErr: true, + }, + { + name: "not provided logger", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + }, + wantErr: true, + }, + { + name: "valid config with initial lists", + config: &Config{ + MainList: []byte(StatusTokenListJSON), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: DefaultParsers, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + logger: zap.NewNop(), + }, + uniqueTokens: 2, // eth and status for chain 1 + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.config != nil { + t.Logf("Config: AutoRefreshInterval=%v, AutoRefreshCheckInterval=%v", + tt.config.AutoRefreshInterval, tt.config.AutoRefreshCheckInterval) + } + tl, err := NewTokensList(tt.config) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + notifyCh := make(chan struct{}, 1) + err = tl.Start(context.Background(), notifyCh) + assert.NoError(t, err) + + tokens := tl.UniqueTokens() + assert.Equal(t, tt.uniqueTokens, len(tokens)) + + err = tl.Stop() + assert.NoError(t, err) + } + }) + } +} + +func TestTokensList_Start(t *testing.T) { + config := &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + CoinGeckoChainsMapper: DefaultCoinGeckoChainsMapper, + AutoRefreshInterval: 30 * time.Minute, + AutoRefreshCheckInterval: 3 * time.Minute, + logger: zap.NewNop(), + } + + tl, err := NewTokensList(config) + require.NoError(t, err) + + ctx := context.Background() + notifyCh := make(chan struct{}, 1) + + err = tl.Start(ctx, notifyCh) + assert.NoError(t, err) + + // Give some time for the start operation to complete + time.Sleep(100 * time.Millisecond) + + tokens := tl.UniqueTokens() + assert.NotNil(t, tokens) +} + +func TestTokensList_PrivacyModeUpdated(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + RemoteListOfTokenListsURL: server.URL + listOfTokenListsURL, + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(true), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + AutoRefreshInterval: 200 * time.Millisecond, + AutoRefreshCheckInterval: 100 * time.Millisecond, + logger: zap.NewNop(), + } + + tl, err := NewTokensList(config) + require.NoError(t, err) + + ctx := context.Background() + + ctxTimeout, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + notifyCh := make(chan struct{}, 1) + + err = tl.Start(ctx, notifyCh) + require.NoError(t, err) + + // check that nothing will be fetched in privacy mode + select { + case <-notifyCh: + t.Fatal("notifyCh received") + case <-ctxTimeout.Done(): + // Expected behavior + } + + ctxTimeout, cancel = context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + config.PrivacyGuard.(*defaultPrivacyGuard).SetPrivacyMode(false) + + err = tl.PrivacyModeUpdated(ctx) + assert.NoError(t, err) + + select { + case <-notifyCh: + // check if the content store has the data + allContent, err := config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 3) + + assert.Contains(t, allContent, "status") + assert.Contains(t, allContent, "uniswap") + assert.Equal(t, statusTokenListJsonResponse, string(allContent["status"].Data)) + assert.Equal(t, uniswapTokenListJsonResponse, string(allContent["uniswap"].Data)) + case <-ctxTimeout.Done(): + t.Fatal("context done") + } + + // reset the content store to check if the new data will be stored when switching back to privacy mode + config.ContentStore = NewDefaultContentStore() + + ctxTimeout, cancel = context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + config.PrivacyGuard.(*defaultPrivacyGuard).SetPrivacyMode(true) + + err = tl.PrivacyModeUpdated(ctx) + assert.NoError(t, err) + + select { + case <-notifyCh: + t.Fatal("notifyCh received") + case <-ctxTimeout.Done(): + // Expected behavior + } + + allContent, err := config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 0) + + err = tl.Stop() + assert.NoError(t, err) +} + +func TestTokensList_RefreshNow(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + RemoteListOfTokenListsURL: server.URL + listOfTokenListsURL, + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: NewDefaultPrivacyGuard(true), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + Parsers: make(map[string]Parser), + AutoRefreshInterval: 200 * time.Millisecond, + AutoRefreshCheckInterval: 100 * time.Millisecond, + logger: zap.NewNop(), + } + + tl, err := NewTokensList(config) + require.NoError(t, err) + + lastRefreshTime, err := tl.LastRefreshTime() + require.NoError(t, err) + assert.True(t, lastRefreshTime.IsZero()) + + ctx := context.Background() + + initialContent := Content{ + SourceURL: "https://example.com/status-token-list.json", + Etag: "123", + Data: []byte("some data"), + Fetched: time.Now(), + } + err = config.ContentStore.Set("initial-list", initialContent) + require.NoError(t, err) + + ctxTimeout, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + notifyCh := make(chan struct{}, 1) + + err = tl.Start(ctx, notifyCh) + require.NoError(t, err) + + // check that nothing will be fetched in privacy mode + select { + case <-notifyCh: + t.Fatal("notifyCh received") + case <-ctxTimeout.Done(): + // Expected behavior + } + + ctxTimeout, cancel = context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + err = tl.RefreshNow(ctx) + assert.NoError(t, err) + + // check that only initial content is stored in the content store in privacy mode after RefreshNow + select { + case <-notifyCh: + // check if the content store has the data + allContent, err := config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 1) + assert.Contains(t, allContent, "initial-list") + assert.Equal(t, initialContent.Data, allContent["initial-list"].Data) + case <-ctxTimeout.Done(): + t.Fatal("context done") + } + + lastRefreshTime, err = tl.LastRefreshTime() + require.NoError(t, err) + assert.True(t, lastRefreshTime.IsZero()) + + ctxTimeout, cancel = context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + config.PrivacyGuard.(*defaultPrivacyGuard).SetPrivacyMode(false) + + err = tl.PrivacyModeUpdated(ctx) + assert.NoError(t, err) + + // check that the content store has the initial content plus fetched content for non privacy mode after RefreshNow + select { + case <-notifyCh: + // check if the content store has the data + allContent, err := config.ContentStore.GetAll() + assert.NoError(t, err) + assert.Len(t, allContent, 4) + + assert.Contains(t, allContent, "initial-list") + assert.Equal(t, initialContent.Data, allContent["initial-list"].Data) + assert.Contains(t, allContent, "status") + assert.Contains(t, allContent, "uniswap") + assert.Equal(t, statusTokenListJsonResponse, string(allContent["status"].Data)) + assert.Equal(t, uniswapTokenListJsonResponse, string(allContent["uniswap"].Data)) + case <-ctxTimeout.Done(): + t.Fatal("context done") + } + + lastRefreshTime, err = tl.LastRefreshTime() + require.NoError(t, err) + assert.False(t, lastRefreshTime.IsZero()) + + err = tl.Stop() + assert.NoError(t, err) +} + +func TestTokensList_TokenMethods(t *testing.T) { + server, closeServer := GetTestServer() + t.Cleanup(func() { + closeServer() + }) + + config := &Config{ + RemoteListOfTokenListsURL: server.URL + listOfTokenListsURL, + MainList: []byte("{}"), + MainListID: StatusListID, + Parsers: DefaultParsers, + Chains: common.AllChains, + PrivacyGuard: NewDefaultPrivacyGuard(false), + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: NewDefaultContentStore(), + AutoRefreshInterval: 200 * time.Millisecond, + AutoRefreshCheckInterval: 100 * time.Millisecond, + logger: zap.NewNop(), + } + + tl, err := NewTokensList(config) + require.NoError(t, err) + + tokens := tl.UniqueTokens() + assert.Empty(t, tokens) + + ctx := context.Background() + ctxTimeout, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + notifyCh := make(chan struct{}, 1) + + err = tl.Start(ctx, notifyCh) + require.NoError(t, err) + + select { + case <-notifyCh: + // Expected behavior + case <-ctxTimeout.Done(): + t.Fatal("context done") + } + + // Test after fetching the token list + tokens = tl.UniqueTokens() + assert.NotNil(t, tokens) + + // check if no duplicate tokens + tokensByKey := make(map[string]struct{}) + tokensByChainID := make(map[uint64][]*Token) + for _, token := range tokens { + if _, ok := tokensByKey[token.Key()]; ok { + t.Fatal("duplicate token", token.Key()) + } + tokensByKey[token.Key()] = struct{}{} + tokensByChainID[token.ChainID] = append(tokensByChainID[token.ChainID], token) + } + + randomIndex := rand.Intn(len(tokens)) //nolint:gosec + randomToken := tokens[randomIndex] + + token, ok := tl.GetTokenByChainAddress(randomToken.ChainID, randomToken.Address) + assert.True(t, ok) + assert.Equal(t, randomToken, token) + + tokensByChain := tl.GetTokensByChain(randomToken.ChainID) + assert.Equal(t, len(tokensByChainID[randomToken.ChainID]), len(tokensByChain)) + + for _, tokne := range tokensByChain { + assert.Equal(t, tokne.ChainID, randomToken.ChainID) + } + + tokenLists := tl.TokenLists() + assert.Equal(t, len(tokenLists), 3) + for _, tokenList := range tokenLists { + var expectedTokensLength int + switch tokenList.Name { + case statusTokenList.Name: + expectedTokensLength = len(statusTokenList.Tokens) + case uniswapTokenList.Name: + expectedTokensLength = len(uniswapTokenList.Tokens) + default: + expectedTokensLength = len(config.Chains) + } + + assert.Equal(t, expectedTokensLength, len(tokenList.Tokens)) + } + + tokenList, ok := tl.TokenList(StatusListID) + assert.True(t, ok) + assert.Equal(t, statusTokenList.Name, tokenList.Name) + assert.Equal(t, statusTokenList.Timestamp, tokenList.Timestamp) + assert.Equal(t, statusTokenList.Version, tokenList.Version) + assert.Equal(t, statusTokenList.Tags, tokenList.Tags) + assert.Equal(t, statusTokenList.LogoURI, tokenList.LogoURI) + assert.Equal(t, statusTokenList.Keywords, tokenList.Keywords) + assert.Equal(t, len(statusTokenList.Tokens), len(tokenList.Tokens)) + + // check if all native tokens have correct cross chain ID + realNumOfNativeTokens := 0 + for _, token := range tokens { + if !token.IsNative() { + continue + } + + realNumOfNativeTokens++ + if token.ChainID == common.BSCMainnet || token.ChainID == common.BSCTestnet { + assert.Equal(t, BinanceSmartChainNativeCrossChainID, token.CrossChainID) + } else { + assert.Equal(t, EthereumNativeCrossChainID, token.CrossChainID) + } + } + assert.Equal(t, len(config.Chains), realNumOfNativeTokens) + + err = tl.Stop() + assert.NoError(t, err) +} diff --git a/pkg/tokenlists/types.go b/pkg/tokenlists/types.go new file mode 100644 index 0000000..9cacd87 --- /dev/null +++ b/pkg/tokenlists/types.go @@ -0,0 +1,215 @@ +package tokenlists + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + gethcommon "github.com/ethereum/go-ethereum/common" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "go.uber.org/zap" +) + +const ( + tokenKeySeparator = "-" +) + +// Token represents a token with cross-chain identification. +type Token struct { + CrossChainID string `json:"crossChainId"` + ChainID uint64 `json:"chainId"` + Address gethcommon.Address `json:"address"` + Decimals uint `json:"decimals"` + Name string `json:"name"` + Symbol string `json:"symbol"` + LogoURI string `json:"logoUri"` + + CustomToken bool `json:"custom"` +} + +// TokenKey creates a key from provided chainID and address. +func TokenKey(chainID uint64, addr gethcommon.Address) string { + return fmt.Sprintf("%d%s%s", chainID, tokenKeySeparator, strings.ToLower(addr.Hex())) +} + +// ChainAndAddressFromTokenKey extracts chainID and address from a token key. +func ChainAndAddressFromTokenKey(tokenKey string) (uint64, gethcommon.Address, bool) { + split := strings.Split(tokenKey, tokenKeySeparator) + if len(split) != 2 { + return 0, gethcommon.Address{}, false + } + chainID, err := strconv.ParseUint(split[0], 10, 64) + if err != nil { + return 0, gethcommon.Address{}, false + } + address := gethcommon.HexToAddress(split[1]) + return chainID, address, true +} + +func (t *Token) Key() string { + return TokenKey(t.ChainID, t.Address) +} + +func (t *Token) IsNative() bool { + if (t.Address != gethcommon.Address{}) { + return false + } + + if t.ChainID == common.BSCMainnet || + t.ChainID == common.BSCTestnet { + return strings.EqualFold(t.Symbol, BinanceSmartChainNativeSymbol) + } + return strings.EqualFold(t.Symbol, EthereumNativeSymbol) +} + +type Version struct { + Major int `json:"major"` + Minor int `json:"minor"` + Patch int `json:"patch"` +} + +func (r *Version) String() string { + return fmt.Sprintf("%d.%d.%d", r.Major, r.Minor, r.Patch) +} + +// StandardTokenList represents the TokenLists standard format. +type StandardTokenList struct { + Name string `json:"name"` + Timestamp string `json:"timestamp"` + Version struct { + Major int `json:"major"` + Minor int `json:"minor"` + Patch int `json:"patch"` + } `json:"version"` + Tags map[string]interface{} `json:"tags"` + LogoURI string `json:"logoURI"` + Keywords []string `json:"keywords"` + Tokens []struct { + ChainID uint64 `json:"chainId"` + Address string `json:"address"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Decimals uint `json:"decimals"` + LogoURI string `json:"logoURI"` + } `json:"tokens"` +} + +// TokenList represents a token list. +type TokenList struct { + Name string `json:"name"` + Timestamp string `json:"timestamp"` // time when the list was last updated + FetchedTimestamp string `json:"fetchedTimestamp"` // time when the list was fetched + Source string `json:"source"` + Version Version `json:"version"` + Tags map[string]interface{} `json:"tags"` + LogoURI string `json:"logoURI"` + Keywords []string `json:"keywords"` + Tokens []*Token `json:"tokens"` +} + +// tokenList represents a token list in the remote list of token lists. +type tokenList struct { + ID string `json:"id"` + SourceURL string `json:"sourceUrl"` + Schema string `json:"schema"` +} + +// fetchedTokenList represents a fetched token list. +type fetchedTokenList struct { + tokenList + Etag string + Fetched time.Time + JsonData []byte +} + +// remoteListOfTokenLists represents the remote list of token lists. +type remoteListOfTokenLists struct { + Timestamp string `json:"timestamp"` + Version Version `json:"version"` + TokenLists []tokenList `json:"tokenLists"` +} + +// TokensList is the public interface for managing token lists. +type TokensList interface { + Start(ctx context.Context, notifyCh chan struct{}) error + Stop() error + + LastRefreshTime() (time.Time, error) + RefreshNow(ctx context.Context) error + + PrivacyModeUpdated(ctx context.Context) error + + UniqueTokens() []*Token + GetTokenByChainAddress(chainID uint64, addr gethcommon.Address) (*Token, bool) + GetTokensByChain(chainID uint64) []*Token + + TokenLists() []*TokenList + TokenList(id string) (*TokenList, bool) +} + +// Parser interface for parsing different token list formats. +type Parser interface { + Parse(raw []byte, sourceURL string, fetchedAt time.Time, supportedChains []uint64) (*TokenList, error) +} + +// PrivacyGuard interface for checking privacy mode. +type PrivacyGuard interface { + IsPrivacyOn() (bool, error) +} + +// LastTokenListsUpdateTimeStore interface for storing and retrieving the last token lists update time. +type LastTokenListsUpdateTimeStore interface { + Get() (time.Time, error) + Set(time.Time) error +} + +type Content struct { + SourceURL string + Etag string + Data []byte + Fetched time.Time +} + +// ContentStore interface for storing and retrieving fetched content. +type ContentStore interface { + GetEtag(id string) (string, error) + Get(id string) (Content, error) + Set(id string, content Content) error + GetAll() (map[string]Content, error) +} + +// CustomTokenStore interface for storing and retrieving custom tokens. +type CustomTokenStore interface { + GetAll() ([]*Token, error) +} + +// Config holds the configuration for TokensList. +type Config struct { + MainList []byte + MainListID string + InitialLists map[string][]byte + Parsers map[string]Parser + + Chains []uint64 + CoinGeckoChainsMapper map[string]uint64 + + RemoteListOfTokenListsURL string + AutoRefreshInterval time.Duration + AutoRefreshCheckInterval time.Duration // must be <= AutoRefreshInterval + + logger *zap.Logger + PrivacyGuard PrivacyGuard + LastTokenListsUpdateTimeStore LastTokenListsUpdateTimeStore + ContentStore ContentStore + CustomTokenStore CustomTokenStore +} + +// state represents the internal state of TokensList. +type state struct { + tokens map[string]*Token // key: "chainID-address" + tokenLists map[string]*TokenList // key: "tokenListID" +} diff --git a/pkg/tokenlists/types_test.go b/pkg/tokenlists/types_test.go new file mode 100644 index 0000000..0366372 --- /dev/null +++ b/pkg/tokenlists/types_test.go @@ -0,0 +1,98 @@ +package tokenlists + +import ( + "testing" + + gethcommon "github.com/ethereum/go-ethereum/common" + + "github.com/status-im/go-wallet-sdk/pkg/common" + + "github.com/stretchr/testify/assert" +) + +func TestTokenKey(t *testing.T) { + token := &Token{ + ChainID: 1, + Address: gethcommon.HexToAddress("0x123"), + } + assert.Equal(t, "1-0x0000000000000000000000000000000000000123", token.Key()) +} + +func TestTokenIsNative(t *testing.T) { + token := &Token{} + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: 1, + } + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: 1, + Address: gethcommon.HexToAddress("0x123"), + } + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: 1, + Address: gethcommon.Address{}, + Symbol: "ETH", + } + assert.True(t, token.IsNative()) + + token = &Token{ + ChainID: 1, + Address: gethcommon.HexToAddress("0x123"), + Symbol: "ETH", + } + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: common.BSCMainnet, + Address: gethcommon.HexToAddress("0x123"), + Symbol: "ETH", + } + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: common.BSCTestnet, + Address: gethcommon.HexToAddress("0x123"), + Symbol: "ETH", + } + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: common.BSCMainnet, + Address: gethcommon.Address{}, + Symbol: "BNB", + } + assert.True(t, token.IsNative()) + + token = &Token{ + ChainID: common.BSCTestnet, + Address: gethcommon.Address{}, + Symbol: "BNB", + } + assert.True(t, token.IsNative()) + + token = &Token{ + ChainID: common.EthereumMainnet, + Address: gethcommon.HexToAddress("0x123"), + Symbol: "BNB", + } + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: common.EthereumMainnet, + Address: gethcommon.Address{}, + Symbol: "BNB", + } + assert.False(t, token.IsNative()) + + token = &Token{ + ChainID: common.BSCTestnet, + Address: gethcommon.Address{}, + Symbol: "ETH", + } + assert.False(t, token.IsNative()) +} diff --git a/pkg/tokenlists/validate.go b/pkg/tokenlists/validate.go new file mode 100644 index 0000000..2f25366 --- /dev/null +++ b/pkg/tokenlists/validate.go @@ -0,0 +1,121 @@ +package tokenlists + +import ( + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/xeipuuv/gojsonschema" +) + +func validateConfig(config *Config) error { + if config == nil { + return ErrConfigNotProvided + } + if config.logger == nil { + return ErrLoggerNotProvided + } + if config.MainList == nil { + return ErrMainListNotProvided + } + if config.MainListID == "" { + return ErrMainListIDNotProvided + } + _, existsInProvidedParsers := config.Parsers[StatusListID] + _, existsInDefaultParsers := DefaultParsers[config.MainListID] + if !existsInProvidedParsers && !existsInDefaultParsers { + return ErrMainListParserNotFound + } + for listID := range config.InitialLists { + if listID == config.MainListID { + return ErrMainListIDCannotBeUsedAsInitialListID + } + _, existsInProvidedParsers := config.Parsers[listID] + _, existsInDefaultParsers := DefaultParsers[listID] + if !existsInProvidedParsers && !existsInDefaultParsers { + return fmt.Errorf("%w listID: %s", ErrInitialListParserNotFound, listID) + } + } + + if len(config.Chains) == 0 { + return ErrChainsNotProvided + } + if config.AutoRefreshCheckInterval > config.AutoRefreshInterval { + return ErrAutoRefreshCheckIntervalGreaterThanInterval + } + if config.PrivacyGuard == nil { + return ErrPrivacyGuardNotProvided + } + if config.LastTokenListsUpdateTimeStore == nil { + return ErrLastTokenListsUpdateTimeStoreNotProvided + } + if config.ContentStore == nil { + return ErrContentStoreNotProvided + } + return nil +} + +func isChainAllowed(chainID uint64, allowedChains []uint64) bool { + for _, allowed := range allowedChains { + if allowed == chainID { + return true + } + } + return false +} + +func isValidLogoURI(logoURI string) bool { + if logoURI == "" || + strings.HasPrefix(logoURI, "data:") || + strings.HasPrefix(logoURI, "ipfs://") || + strings.HasPrefix(logoURI, "http://") || + strings.HasPrefix(logoURI, "https://") { + return true + } + + return false +} + +func validateToken(token *Token, allowedChains []uint64) error { + if token == nil { + return ErrTokenNotProvided + } + + if !isChainAllowed(token.ChainID, allowedChains) { + return fmt.Errorf("%w chainID: %d", ErrChainNotAllowed, token.ChainID) + } + + if len(token.Address) != common.AddressLength { + return fmt.Errorf("%w address length: %d", ErrInvalidAddressLength, len(token.Address)) + } + + if token.Symbol == "" { + return ErrSymbolCannotBeEmpty + } + + // even theoretically the limit is 256, in practice we should not let users use more than 18 + if token.Decimals > 18 { + return fmt.Errorf("%w decimals: %d", ErrDecimalsExceedsMaximum, token.Decimals) + } + + if !isValidLogoURI(token.LogoURI) { + return fmt.Errorf("%w logoURI: %s", ErrInvalidLogoURI, token.LogoURI) + } + + return nil +} + +func validateJsonAgainstSchema(jsonData string, schemaLoader gojsonschema.JSONLoader) error { + docLoader := gojsonschema.NewStringLoader(jsonData) + + result, err := gojsonschema.Validate(schemaLoader, docLoader) + if err != nil { + return err + } + + if !result.Valid() { + return ErrTokenListDoesNotMatchSchema + } + + return nil +} diff --git a/pkg/tokenlists/validate_test.go b/pkg/tokenlists/validate_test.go new file mode 100644 index 0000000..c1d9075 --- /dev/null +++ b/pkg/tokenlists/validate_test.go @@ -0,0 +1,330 @@ +package tokenlists + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/xeipuuv/gojsonschema" + "go.uber.org/zap" +) + +func TestValidateConfig(t *testing.T) { + tests := []struct { + name string + config *Config + wantErr error + }{ + { + name: "nil config", + config: nil, + wantErr: ErrConfigNotProvided, + }, + { + name: "missing logger", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + }, + wantErr: ErrLoggerNotProvided, + }, + { + name: "missing main list", + config: &Config{ + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + logger: zap.NewNop(), + }, + wantErr: ErrMainListNotProvided, + }, + { + name: "missing main list ID", + config: &Config{ + MainList: []byte("{}"), + Chains: []uint64{1}, + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + logger: zap.NewNop(), + }, + wantErr: ErrMainListIDNotProvided, + }, + { + name: "missing chains", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + logger: zap.NewNop(), + }, + wantErr: ErrChainsNotProvided, + }, + { + name: "missing privacy guard", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + logger: zap.NewNop(), + }, + wantErr: ErrPrivacyGuardNotProvided, + }, + { + name: "missing last update time store", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: &defaultPrivacyGuard{}, + ContentStore: &defaultContentStore{}, + logger: zap.NewNop(), + }, + wantErr: ErrLastTokenListsUpdateTimeStoreNotProvided, + }, + { + name: "missing content store", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + logger: zap.NewNop(), + }, + wantErr: ErrContentStoreNotProvided, + }, + { + name: "invalid refresh intervals", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + AutoRefreshInterval: 1, + AutoRefreshCheckInterval: 2, + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + logger: zap.NewNop(), + }, + wantErr: ErrAutoRefreshCheckIntervalGreaterThanInterval, + }, + { + name: "valid config", + config: &Config{ + MainList: []byte("{}"), + MainListID: StatusListID, + Chains: []uint64{1}, + PrivacyGuard: &defaultPrivacyGuard{}, + LastTokenListsUpdateTimeStore: NewDefaultLastTokenListsUpdateTimeStore(), + ContentStore: &defaultContentStore{}, + logger: zap.NewNop(), + }, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConfig(tt.config) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestIsChainAllowed(t *testing.T) { + allowedChains := []uint64{1, 2, 3} + + tests := []struct { + name string + chainID uint64 + allowed []uint64 + expected bool + }{ + { + name: "chain allowed", + chainID: 1, + allowed: allowedChains, + expected: true, + }, + { + name: "chain not allowed", + chainID: 4, + allowed: allowedChains, + expected: false, + }, + { + name: "empty allowed chains", + chainID: 1, + allowed: []uint64{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isChainAllowed(tt.chainID, tt.allowed) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsValidLogoURI(t *testing.T) { + tests := []struct { + name string + logoURI string + expected bool + }{ + { + name: "empty logo URI", + logoURI: "", + expected: true, + }, + { + name: "data URI", + logoURI: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + expected: true, + }, + { + name: "IPFS URI", + logoURI: "ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYjPjoiQX5cL1T3bqgm1", + expected: true, + }, + { + name: "HTTP URI", + logoURI: "http://example.com/logo.png", + expected: true, + }, + { + name: "HTTPS URI", + logoURI: "https://example.com/logo.png", + expected: true, + }, + { + name: "invalid URI", + logoURI: "ftp://example.com/logo.png", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidLogoURI(tt.logoURI) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateToken(t *testing.T) { + allowedChains := []uint64{1, 2, 3} + + tests := []struct { + name string + token Token + allowed []uint64 + wantErr error + }{ + { + name: "valid token", + token: Token{ + ChainID: 1, + Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), + Symbol: "TEST", + Decimals: 18, + LogoURI: "https://example.com/logo.png", + }, + allowed: allowedChains, + wantErr: nil, + }, + { + name: "chain not allowed", + token: Token{ + ChainID: 4, + Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), + Symbol: "TEST", + Decimals: 18, + }, + allowed: allowedChains, + wantErr: ErrChainNotAllowed, + }, + { + name: "empty symbol", + token: Token{ + ChainID: 1, + Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), + Symbol: "", + Decimals: 18, + }, + allowed: allowedChains, + wantErr: ErrSymbolCannotBeEmpty, + }, + { + name: "decimals too high", + token: Token{ + ChainID: 1, + Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), + Symbol: "TEST", + Decimals: 19, + }, + allowed: allowedChains, + wantErr: ErrDecimalsExceedsMaximum, + }, + { + name: "invalid logo URI", + token: Token{ + ChainID: 1, + Address: common.HexToAddress("0x1234567890123456789012345678901234567890"), + Symbol: "TEST", + Decimals: 18, + LogoURI: "ftp://example.com/logo.png", + }, + allowed: allowedChains, + wantErr: ErrInvalidLogoURI, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateToken(&tt.token, tt.allowed) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateJsonAgainstSchema(t *testing.T) { + validJSON := `{"name": "Test Token List", "tokens": []}` + invalidJSON := `{"name": "Test Token List"}` + + schema := `{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "tokens": {"type": "array"} + }, + "required": ["name", "tokens"] + }` + + schemaLoader := gojsonschema.NewStringLoader(schema) + + err := validateJsonAgainstSchema(validJSON, schemaLoader) + assert.NoError(t, err) + + err = validateJsonAgainstSchema(invalidJSON, schemaLoader) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrTokenListDoesNotMatchSchema) +}