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.