Skip to main content

Nethereum.TokenServices

NuGet: Nethereum.TokenServices | Source: src/Nethereum.TokenServices/

Nethereum.TokenServices

A standalone .NET library for ERC20 token discovery, balance retrieval, pricing, and multi-account scanning. Designed for multi-chain support with pluggable providers and discovery strategies.

Installation​

dotnet add package Nethereum.TokenServices

Problems This Library Solves​

ProblemSolution
"How do I find all tokens a wallet holds?"Token discovery via token lists or event scanning
"How do I get balances for many tokens efficiently?"Multicall batching (100+ tokens per call)
"How do I get USD prices for tokens?"CoinGecko integration with caching
"How do I track new tokens received?"Event-based refresh from Transfer logs
"How do I scan multiple wallets across chains?"Multi-account service with parallel scanning
"How do I use my own token list/price source?"Pluggable providers and strategies

Quick Start​

using Nethereum.TokenServices.ERC20;
using Nethereum.TokenServices.ERC20.Balances;
using Nethereum.Web3;

var web3 = new Web3("https://eth.llamarpc.com");
var tokenService = new Erc20TokenService();

// Get all token balances with prices
var balances = await tokenService.GetBalancesWithPricesAsync(
web3,
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", // Vitalik
chainId: 1,
vsCurrency: "usd",
includeNative: true,
nativeToken: NativeTokenConfig.ForChain(1, "ETH", "Ether"));

foreach (var b in balances.Where(x => x.Value > 0).OrderByDescending(x => x.Value))
{
Console.WriteLine($"{b.Token?.Symbol ?? "ETH"}: {b.BalanceDecimal:N4} @ ${b.Price:N2} = ${b.Value:N2}");
}

Use Cases & Scenarios​

Scenario 1: Wallet Portfolio Display​

Goal: Show all tokens a user holds with current USD values.

var tokenService = new Erc20TokenService();
var web3 = new Web3(rpcUrl);

var balances = await tokenService.GetBalancesWithPricesAsync(
web3, userAddress, chainId, "usd", includeNative: true,
NativeTokenConfig.ForChain(chainId, "ETH", "Ether"));

var portfolio = balances
.Where(b => b.Balance > 0)
.OrderByDescending(b => b.Value)
.ToList();

decimal totalValue = portfolio.Sum(b => b.Value ?? 0);

Scenario 2: Incremental Balance Updates (Polling)​

Goal: Efficiently update balances by only checking tokens that changed.

var tokenService = new Erc20TokenService();
ulong lastScannedBlock = GetLastScannedBlock(); // From your storage

// Scan for Transfer events and get updated balances
// Returns List<TokenBalance> — includes both existing and newly discovered tokens
var updatedBalances = await tokenService.RefreshBalancesFromEventsAsync(
web3, userAddress, chainId,
fromBlock: lastScannedBlock,
existingTokens: currentTokenList,
vsCurrency: "usd");

// Update your local state with refreshed balances
foreach (var balance in updatedBalances)
{
UpdateLocalBalance(balance);
}

Console.WriteLine($"Refreshed {updatedBalances.Count} token balances");

Scenario 3: Multi-Wallet, Multi-Chain Scanning​

Goal: Scan multiple wallets across multiple chains (e.g., for a portfolio app).

using Nethereum.TokenServices.MultiAccount;
using Nethereum.TokenServices.ERC20.Discovery;

// Get services via DI
var multiAccountService = serviceProvider.GetRequiredService<IMultiAccountTokenService>();
var discoveryStrategy = serviceProvider.GetRequiredService<IDiscoveryStrategy>();

var accounts = new[] { "0xWallet1...", "0xWallet2...", "0xWallet3..." };
var chains = new[] { 1L, 137L, 42161L, 8453L }; // Ethereum, Polygon, Arbitrum, Base

var result = await multiAccountService.ScanAsync(
accounts,
chains,
web3Factory: chainId => new Web3(GetRpcUrl(chainId)),
strategy: discoveryStrategy,
options: new MultiAccountScanOptions
{
MaxParallelChains = 3,
PageSize = 100,
IncludeNativeToken = true
},
progress: new Progress<MultiAccountProgress>(p =>
{
Console.WriteLine($"Progress: {p.OverallPercentComplete:F1}%");
Console.WriteLine($"Chains: {p.CompletedChains}/{p.TotalChains}");
Console.WriteLine($"Tokens found: {p.TokensFound}");
}));

// Access results by chain
foreach (var chainResult in result.ChainResults)
{
Console.WriteLine($"Chain {chainResult.Key}: {chainResult.Value.TokensFound} tokens");
}

// Access results by account
foreach (var accountResult in result.AccountResults)
{
Console.WriteLine($"Account {accountResult.Key}: {accountResult.Value.TokensFound} tokens");
}

Scenario 4: Price Refresh Only​

Goal: Update prices without re-fetching balances.

var multiAccountService = serviceProvider.GetRequiredService<IMultiAccountTokenService>();

// Prepare token data (account, chain, existing balances)
var accountTokens = new List<(string account, long chainId, IEnumerable<TokenBalance> tokens)>
{
("0xWallet1...", 1, ethereumTokens),
("0xWallet1...", 137, polygonTokens),
("0xWallet2...", 1, wallet2Tokens)
};

var priceResult = await multiAccountService.RefreshPricesAsync(
accountTokens,
currency: "usd");

if (priceResult.Success)
{
Console.WriteLine($"Updated prices for {priceResult.TokensUpdated} tokens");
}

Scenario 5: Specific Token Monitoring​

Goal: Track only specific tokens (e.g., USDC, WETH, DAI).

var tokenService = new Erc20TokenService();

var watchlist = new[]
{
new TokenInfo { Address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Symbol = "USDC", Decimals = 6 },
new TokenInfo { Address = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", Symbol = "WETH", Decimals = 18 },
new TokenInfo { Address = "0x6B175474E89094C44Da98b954EescdeCB5BAD0", Symbol = "DAI", Decimals = 18 }
};

var balances = await tokenService.GetBalancesForTokensWithPricesAsync(
web3, userAddress, watchlist, "usd");

Discovery Strategies​

Discovery strategies determine how tokens are found for an account. Two built-in strategies are provided:

TokenListDiscoveryStrategy (Default, FREE)​

Scans a known list of tokens (from CoinGecko/embedded) and checks balances via multicall.

  • Pros: Fast, low RPC usage, known tokens only
  • Cons: Won't find obscure/new tokens not in the list
  • Best for: Standard portfolios, quick scans
var strategy = new TokenListDiscoveryStrategy(tokenListProvider, discoveryEngine);

EventLogDiscoveryStrategy (FREE, Thorough)​

Scans ALL Transfer events for the account from genesis and checks current balances.

  • Pros: Finds every token ever held
  • Cons: Slow, high RPC usage, requires archive node for full history
  • Best for: Complete portfolio discovery, one-time scans
var strategy = new EventLogDiscoveryStrategy(eventScanner, balanceProvider, tokenListProvider);

Custom Discovery Strategies (Pluggable)​

Implement IDiscoveryStrategy for custom sources (e.g., Etherscan API):

public class EtherscanDiscoveryStrategy : IDiscoveryStrategy
{
private readonly string _apiKey;

public string StrategyName => "Etherscan";

public async Task<TokenDiscoveryResult> DiscoverAsync(
IWeb3 web3, string accountAddress, long chainId,
DiscoveryOptions options = null,
IProgress<DiscoveryProgress> progress = null,
CancellationToken ct = default)
{
// Call Etherscan API: /api?module=account&action=tokentx&address={account}
var transfers = await _etherscanClient.GetTokenTransfersAsync(accountAddress);

// Extract unique token addresses
var tokenAddresses = transfers.Select(t => t.ContractAddress).Distinct();

// Get current balances
var tokens = tokenAddresses.Select(a => new TokenInfo { Address = a, Decimals = 18 });
var balances = await _balanceProvider.GetBalancesAsync(web3, accountAddress, tokens);

return TokenDiscoveryResult.Successful(
balances.Where(b => b.Balance > 0).ToList(),
progress, StrategyName);
}

public Task<bool> SupportsChainAsync(long chainId) => Task.FromResult(chainId == 1);
public Task<int> GetExpectedTokenCountAsync(long chainId) => Task.FromResult(0);
}

// Register in DI
services.AddErc20TokenServices()
.UseDiscoveryStrategy<EtherscanDiscoveryStrategy>();

Pluggable Providers​

Token List Provider​

public class MyTokenListProvider : ITokenListProvider
{
public async Task<List<TokenInfo>> GetTokensAsync(long chainId)
{
// Fetch from your database, API, or static list
return await _myDatabase.GetTokensForChain(chainId);
}

public Task<TokenInfo> GetTokenAsync(long chainId, string address) { ... }
public Task<bool> SupportsChainAsync(long chainId) { ... }
}

// Use it
var tokenService = new Erc20TokenService(tokenListProvider: new MyTokenListProvider());

// Or via DI
services.AddErc20TokenServices().UseTokenListProvider<MyTokenListProvider>();

Price Provider​

public class MyPriceProvider : ITokenPriceProvider
{
public async Task<Dictionary<string, TokenPrice>> GetPricesByContractAsync(
long chainId, IEnumerable<string> addresses, string currency)
{
// Fetch from your price source
return await _myPriceApi.GetPrices(chainId, addresses, currency);
}

// ... other interface methods
}

services.AddErc20TokenServices().UsePriceProvider<MyPriceProvider>();

Cache Provider​

// Use file-based cache
services.AddErc20TokenServices(options =>
{
options.UseFileCache = true;
options.CacheDirectory = "/data/token-cache";
});

// Or provide custom cache (Redis, etc.)
public class RedisCacheProvider : ICacheProvider { ... }
services.AddErc20TokenServices().UseCacheProvider<RedisCacheProvider>();

Storage Providers (Database-Ready)​

The library uses pluggable storage interfaces for persisting token list diffs and coin mappings. Default implementations are file-based, but you can easily swap to PostgreSQL, SQLite, or any database:

// Default: file-based storage
services.AddErc20TokenServices(options =>
{
options.UseFileDiffStorage = true;
options.TokenListDiffStorageDirectory = "/data/tokenlists";
options.CoinMappingDiffStorageDirectory = "/data/coinmappings";
});

// PostgreSQL implementation
public class PostgresTokenListDiffStorage : ITokenListDiffStorage
{
private readonly NpgsqlConnection _connection;

public async Task<List<TokenInfo>> GetAdditionalTokensAsync(long chainId)
{
return await _connection.QueryAsync<TokenInfo>(
"SELECT * FROM additional_tokens WHERE chain_id = @chainId",
new { chainId });
}

public async Task SaveAdditionalTokensAsync(long chainId, List<TokenInfo> tokens)
{
await _connection.ExecuteAsync(
"INSERT INTO additional_tokens (chain_id, address, symbol, decimals) VALUES ...",
tokens.Select(t => new { chainId, t.Address, t.Symbol, t.Decimals }));
}

public async Task<DateTime?> GetLastUpdateAsync(long chainId)
{
return await _connection.QuerySingleOrDefaultAsync<DateTime?>(
"SELECT last_update FROM token_list_metadata WHERE chain_id = @chainId",
new { chainId });
}

public async Task SetLastUpdateAsync(long chainId, DateTime updateTime)
{
await _connection.ExecuteAsync(
"INSERT INTO token_list_metadata (chain_id, last_update) VALUES (@chainId, @updateTime) " +
"ON CONFLICT (chain_id) DO UPDATE SET last_update = @updateTime",
new { chainId, updateTime });
}

public async Task ClearAsync(long chainId)
{
await _connection.ExecuteAsync(
"DELETE FROM additional_tokens WHERE chain_id = @chainId;" +
"DELETE FROM token_list_metadata WHERE chain_id = @chainId",
new { chainId });
}
}

// Register your custom storage
services.AddErc20TokenServices()
.UseTokenListDiffStorage<PostgresTokenListDiffStorage>()
.UseCoinMappingDiffStorage<PostgresCoinMappingDiffStorage>();

Storage Interfaces:

InterfacePurposeDefault
ITokenListDiffStorageStores tokens discovered beyond embedded listsFileTokenListDiffStorage
ICoinMappingDiffStorageStores contract→CoinGecko ID mappingsFileCoinMappingDiffStorage
ICacheProviderGeneral key-value cache with expiryMemoryCacheProvider

Dependency Injection Setup​

Basic Setup​

services.AddErc20TokenServices();

With Options​

services.AddErc20TokenServices(options =>
{
options.UseFileCache = true;
options.CacheDirectory = "/data/cache";
options.DefaultCurrency = "usd";
options.PriceCacheExpiry = TimeSpan.FromMinutes(5);
options.TokenListCacheExpiry = TimeSpan.FromDays(7);
options.MultiCallBatchSize = 100;
options.MaxParallelChains = 3;
});

With Custom Providers​

services.AddErc20TokenServices()
.UseTokenListProvider<MyTokenListProvider>()
.UsePriceProvider<MyPriceProvider>()
.UseDiscoveryStrategy<MyDiscoveryStrategy>()
.UseCacheProvider<RedisCacheProvider>();

Registered Services​

After calling AddErc20TokenServices(), these services are available:

InterfaceDefault ImplementationLifetime
IErc20TokenServiceErc20TokenServiceSingleton
ITokenListProviderResilientTokenListProviderSingleton
ITokenBalanceProviderMultiCallBalanceProviderSingleton
ITokenPriceProviderCoinGeckoPriceProviderSingleton
ITokenEventScannerErc20EventScannerSingleton
ITokenDiscoveryEngineTokenDiscoveryEngineSingleton
IDiscoveryStrategyTokenListDiscoveryStrategySingleton
IMultiAccountTokenServiceMultiAccountTokenServiceSingleton
ICacheProviderMemoryCacheProviderSingleton
ITokenListDiffStorageNullTokenListDiffStorage*Singleton
ICoinMappingDiffStorageNullCoinMappingDiffStorage*Singleton

*Use UseFileDiffStorage = true option or UseTokenListDiffStorage<T>() builder method for persistence.

Token Catalog Subsystem​

The Token Catalog provides a persistent, refreshable token registry that replaces the legacy diff-based storage. It acts as a local database of known tokens per chain, with automatic seeding from embedded lists and refresh from external sources like CoinGecko.

Setup​

using Nethereum.TokenServices.Catalog;

// Basic setup with default file-based repository
services.AddTokenCatalog(options =>
{
options.CatalogDirectory = "/data/token-catalog";
options.AutoSeedFromEmbedded = true;
options.DefaultRefreshInterval = TimeSpan.FromHours(6);
options.RegisterCoinGeckoSource = true;
});

// With custom repository (e.g., database-backed)
services.AddTokenCatalogWithCustomRepository<MyDatabaseCatalogRepository>();

Querying the Catalog​

var repository = serviceProvider.GetRequiredService<ITokenCatalogRepository>();

// Get all tokens for a chain
var tokens = await repository.GetAllTokensAsync(chainId: 1);
Console.WriteLine($"Known tokens on Ethereum: {tokens.Count}");

// Look up a specific token
var usdc = await repository.GetTokenByAddressAsync(chainId: 1, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
Console.WriteLine($"{usdc.Symbol}: {usdc.Name} ({usdc.Decimals} decimals)");

// Get tokens added since a date
var recentTokens = await repository.GetTokensAddedSinceAsync(chainId: 1, DateTime.UtcNow.AddDays(-7));

Refreshing from External Sources​

var refreshService = serviceProvider.GetRequiredService<ITokenCatalogRefreshService>();

// Check if refresh is needed (based on min interval)
if (await refreshService.ShouldRefreshAsync(chainId: 1))
{
var result = await refreshService.RefreshAsync(chainId: 1, new CatalogRefreshOptions
{
ForceRefresh = false,
IncrementalOnly = true,
UpdateExistingTokens = false
});

Console.WriteLine($"Source: {result.SourceUsed}");
Console.WriteLine($"Added: {result.TotalTokensAdded}, Updated: {result.TotalTokensUpdated}");
Console.WriteLine($"Total in catalog: {result.TotalTokensInCatalog}");
}

Using Catalog as Token List Provider​

Bridge the catalog into the ITokenListProvider interface used by Erc20TokenService:

var catalogProvider = new CatalogTokenListProviderAdapter(repository, autoSeed: true);
var tokenService = new Erc20TokenService(tokenListProvider: catalogProvider);

Custom Refresh Sources​

Implement ITokenCatalogRefreshSource to add your own token data sources:

public class MyCustomTokenSource : ITokenCatalogRefreshSource
{
public string SourceName => "MyCustomSource";
public int Priority => 10; // Lower = tried first

public Task<bool> SupportsChainAsync(long chainId, CancellationToken ct) =>
Task.FromResult(chainId == 1);

public async Task<TokenCatalogRefreshResult> FetchTokensAsync(
long chainId, DateTime? sinceUtc, CancellationToken ct)
{
var tokens = await FetchFromMyApi(chainId, sinceUtc);
return new TokenCatalogRefreshResult
{
Success = true,
Tokens = tokens.Select(t => new CatalogTokenInfo
{
Address = t.Address,
Symbol = t.Symbol,
Name = t.Name,
Decimals = t.Decimals,
ChainId = chainId,
Source = SourceName
}).ToList()
};
}

public Task<RateLimitInfo> GetRateLimitInfoAsync(CancellationToken ct) =>
Task.FromResult(new RateLimitInfo { IsRateLimited = false });
}

// Register
services.AddTokenCatalog().AddRefreshSource<MyCustomTokenSource>();

Migrating from Legacy Diff Storage​

var migrationService = new TokenCatalogMigrationService(catalogRepository, legacyDiffStorage);

// Migrate a single chain
var result = await migrationService.MigrateAsync(chainId: 1, new MigrationOptions
{
ForceMigration = false,
ClearLegacyAfterMigration = true
});
Console.WriteLine($"Migrated {result.MigratedTokenCount} tokens ({result.EmbeddedTokenCount} embedded + {result.DiffTokenCount} custom)");

// Migrate all chains
var allResults = await migrationService.MigrateAllChainsAsync();

CatalogTokenInfo Model​

public class CatalogTokenInfo
{
public string Address { get; set; }
public string Symbol { get; set; }
public string Name { get; set; }
public int Decimals { get; set; }
public string LogoUri { get; set; }
public long ChainId { get; set; }
public string CoinGeckoId { get; set; }
public DateTime AddedAtUtc { get; set; }
public DateTime? UpdatedAtUtc { get; set; }
public string Source { get; set; }

// Convert to/from TokenInfo
public static CatalogTokenInfo FromTokenInfo(TokenInfo tokenInfo, string source = "embedded");
public TokenInfo ToTokenInfo();
}

Architecture​

Nethereum.TokenServices/
├── ERC20/
│ ├── IErc20TokenService.cs # Main facade
│ ├── Erc20TokenService.cs
│ ├── Discovery/
│ │ ├── IDiscoveryStrategy.cs # Plugin interface
│ │ ├── TokenListDiscoveryStrategy.cs
│ │ ├── EventLogDiscoveryStrategy.cs
│ │ ├── ITokenListProvider.cs
│ │ ├── ITokenListDiffStorage.cs # Pluggable storage
│ │ ├── ResilientTokenListProvider.cs
│ │ ├── EmbeddedTokenListProvider.cs
│ │ └── CoinGeckoTokenListProvider.cs
│ ├── Balances/
│ │ ├── ITokenBalanceProvider.cs
│ │ └── MultiCallBalanceProvider.cs
│ ├── Pricing/
│ │ ├── ITokenPriceProvider.cs
│ │ ├── ICoinMappingDiffStorage.cs # Pluggable storage
│ │ ├── CoinGeckoPriceProvider.cs
│ │ └── BatchPriceService.cs
│ ├── Events/
│ │ ├── ITokenEventScanner.cs
│ │ └── Erc20EventScanner.cs
│ └── Models/
│ ├── TokenInfo.cs
│ ├── TokenBalance.cs
│ └── TokenPrice.cs
├── MultiAccount/
│ ├── IMultiAccountTokenService.cs # Multi-wallet scanning
│ ├── MultiAccountTokenService.cs
│ └── Models/
├── Catalog/
│ ├── ITokenCatalogRepository.cs # Persistent catalog storage
│ ├── ITokenCatalogRefreshService.cs # Refresh orchestration
│ ├── ITokenCatalogRefreshSource.cs # Plugin interface for sources
│ ├── TokenCatalogRefreshService.cs
│ ├── CatalogTokenInfo.cs # Catalog token model
│ ├── CatalogTokenListProviderAdapter.cs # Bridge to ITokenListProvider
│ ├── TokenCatalogMigrationService.cs # Legacy diff → catalog migration
│ └── TokenCatalogServiceCollectionExtensions.cs # DI setup
├── Caching/
│ ├── ICacheProvider.cs
│ ├── MemoryCacheProvider.cs
│ ├── FileCacheProvider.cs
│ ├── FileStorageBase.cs # Base class for file storage
│ ├── FileTokenListDiffStorage.cs # File-based impl
│ ├── FileCoinMappingDiffStorage.cs # File-based impl
│ ├── NullTokenListDiffStorage.cs # No-op impl
│ └── NullCoinMappingDiffStorage.cs # No-op impl
└── Resources/ # Embedded token lists

Supported Chains (Out of Box)​

ChainChain IDNative Token
Ethereum1ETH
Optimism10ETH
BNB Chain56BNB
Gnosis100xDAI
Polygon137MATIC
zkSync Era324ETH
Base8453ETH
Arbitrum One42161ETH
Celo42220CELO
Avalanche43114AVAX
Linea59144ETH

Cache Expiry Defaults​

DataDefault Expiry
Token Lists7 days
Prices5 minutes
Coin Mappings7 days
Platforms30 days

Performance Tips​

  1. Use InitializeCacheAsync at startup to preload token lists
  2. Use event-based refresh instead of full rescans for updates
  3. Batch price requests using BatchPriceService for many tokens
  4. Use file cache for persistence across restarts
  5. Limit parallel chains to 3-5 to avoid RPC rate limits

Dependencies​

  • Nethereum.Web3
  • Nethereum.DataServices (CoinGecko API)
  • Nethereum.BlockchainProcessing (Event scanning)
  • Microsoft.Extensions.DependencyInjection.Abstractions

License​

MIT License - see the main Nethereum repository for details.