diff --git a/src/Net.Cache.DynamoDb.ERC20/Api/ApiERC20Service.cs b/src/Net.Cache.DynamoDb.ERC20/Api/ApiERC20Service.cs
deleted file mode 100644
index 3febd4d..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Api/ApiERC20Service.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-using System;
-using Flurl.Http;
-using System.Numerics;
-using Newtonsoft.Json;
-using HandlebarsDotNet;
-using Net.Web3.EthereumWallet;
-using Net.Cache.DynamoDb.ERC20.RPC;
-using Net.Cache.DynamoDb.ERC20.Models.Api;
-
-namespace Net.Cache.DynamoDb.ERC20.Api
-{
- ///
- /// The ApiERC20Service class provides functionality to interact with ERC20 tokens via an API.
- /// This service caches the token data to avoid redundant API calls.
- ///
- public class ApiERC20Service : IERC20Service
- {
- private readonly ApiERC20ServiceConfig _config;
- private readonly Lazy _tokenDataCache;
-
- ///
- /// Gets the contract address of the ERC20 token.
- ///
- public EthereumAddress ContractAddress => _config.ContractAddress;
-
- ///
- /// Initializes a new instance of the class using the provided configuration.
- ///
- /// The configuration containing API key, chain ID, contract address, and API URL.
- public ApiERC20Service(ApiERC20ServiceConfig config)
- {
- _config = config;
- _tokenDataCache = new Lazy(() => LoadTokenData(_config.ApiUrl));
- }
-
- ///
- /// Loads the token data from the API and caches it.
- ///
- /// The API URL template to fetch the token data.
- /// The token data as an .
- private ApiResponse LoadTokenData(string apiUrl)
- {
- var template = Handlebars.Compile(apiUrl);
-
- var url = template(new
- {
- chainId = _config.ChainId,
- contractAddress = _config.ContractAddress,
- apiKey = _config.ApiKey
- });
-
- var responseString = url.GetStringAsync().GetAwaiter().GetResult();
- return JsonConvert.DeserializeObject(responseString);
- }
-
- ///
- /// Gets the cached token data.
- ///
- /// The cached token data as an .
- public ApiResponse GetTokenData() => _tokenDataCache.Value;
-
- ///
- /// Retrieves the number of decimals the ERC20 token uses.
- ///
- /// The number of decimals.
- public byte Decimals() => GetTokenData().Data.Items[1].ContractDecimals;
-
- ///
- /// Retrieves the name of the ERC20 token.
- ///
- /// The name of the token.
- public string Name() => GetTokenData().Data.Items[1].ContractName;
-
- ///
- /// Retrieves the symbol of the ERC20 token.
- ///
- /// The symbol of the token.
- public string Symbol() => GetTokenData().Data.Items[1].ContractTickerSymbol;
-
- ///
- /// Retrieves the total supply of the ERC20 token.
- ///
- /// The total supply as a .
- public BigInteger TotalSupply() => BigInteger.Parse(GetTokenData().Data.Items[1].TotalSupply);
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/DynamoDb/DynamoDbClient.cs b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/DynamoDbClient.cs
new file mode 100644
index 0000000..c0e435f
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/DynamoDbClient.cs
@@ -0,0 +1,70 @@
+using System;
+using Amazon.DynamoDBv2;
+using System.Threading.Tasks;
+using Amazon.DynamoDBv2.DataModel;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.DynamoDb
+{
+ ///
+ /// Default implementation of using the AWS SDK.
+ ///
+ public class DynamoDbClient : IDynamoDbClient
+ {
+ private readonly IDynamoDBContext _dynamoDbContext;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The DynamoDB context to operate on.
+ public DynamoDbClient(IDynamoDBContext dynamoDbContext)
+ {
+ _dynamoDbContext = dynamoDbContext ?? throw new ArgumentNullException(nameof(dynamoDbContext));
+ }
+
+ ///
+ /// Initializes a new instance using a context builder.
+ ///
+ /// Builder that produces a DynamoDB context.
+ public DynamoDbClient(IDynamoDBContextBuilder contextBuilder)
+ : this((contextBuilder ?? throw new ArgumentNullException(nameof(contextBuilder))).Build())
+ { }
+
+ ///
+ /// Initializes a new instance from a raw DynamoDB client.
+ ///
+ /// The AWS DynamoDB client.
+ public DynamoDbClient(IAmazonDynamoDB dynamoDb)
+ : this(
+ new DynamoDBContextBuilder()
+ .WithDynamoDBClient(() => dynamoDb ?? throw new ArgumentNullException(nameof(dynamoDb)))
+ )
+ { }
+
+ ///
+ /// Initializes a new instance using default AWS configuration.
+ ///
+ public DynamoDbClient()
+ : this(
+ new DynamoDBContextBuilder()
+ .WithDynamoDBClient(() => new AmazonDynamoDBClient())
+ )
+ { }
+
+ ///
+ public async Task GetErc20TokenAsync(HashKey hashKey, LoadConfig? config = null)
+ {
+ return await _dynamoDbContext
+ .LoadAsync(hashKey.Value, config)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ public async Task SaveErc20TokenAsync(Erc20TokenDynamoDbEntry entry, SaveConfig? config = null)
+ {
+ await _dynamoDbContext
+ .SaveAsync(entry, config)
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/DynamoDb/IDynamoDbClient.cs b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/IDynamoDbClient.cs
new file mode 100644
index 0000000..3a9f8d3
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/IDynamoDbClient.cs
@@ -0,0 +1,27 @@
+using System.Threading.Tasks;
+using Amazon.DynamoDBv2.DataModel;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.DynamoDb
+{
+ ///
+ /// Provides access to ERC20 token metadata stored in DynamoDB.
+ ///
+ public interface IDynamoDbClient
+ {
+ ///
+ /// Retrieves a token entry by its hash key.
+ ///
+ /// The composite hash key of the token.
+ /// Optional DynamoDB load configuration.
+ /// The token entry if it exists; otherwise, null.
+ public Task GetErc20TokenAsync(HashKey hashKey, LoadConfig? config = null);
+
+ ///
+ /// Persists a token entry into DynamoDB.
+ ///
+ /// The token entry to store.
+ /// Optional DynamoDB save configuration.
+ public Task SaveErc20TokenAsync(Erc20TokenDynamoDbEntry entry, SaveConfig? config = null);
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/DynamoDb/Models/Erc20TokenDynamoDbEntry.cs b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/Models/Erc20TokenDynamoDbEntry.cs
new file mode 100644
index 0000000..823925e
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/Models/Erc20TokenDynamoDbEntry.cs
@@ -0,0 +1,76 @@
+using Amazon.DynamoDBv2.DataModel;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.DynamoDb.Models
+{
+ ///
+ /// Represents a persisted ERC20 token entry in the DynamoDB cache table.
+ ///
+ [DynamoDBTable("TokensInfoCache")]
+ public class Erc20TokenDynamoDbEntry
+ {
+ ///
+ /// Gets or sets the composite hash key value.
+ ///
+ [DynamoDBHashKey]
+ public string HashKey { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the blockchain network identifier.
+ ///
+ [DynamoDBProperty]
+ public long ChainId { get; set; }
+
+ ///
+ /// Gets or sets the ERC20 token contract address.
+ ///
+ [DynamoDBProperty]
+ public string Address { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the token name.
+ ///
+ [DynamoDBProperty]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the token symbol.
+ ///
+ [DynamoDBProperty]
+ public string Symbol { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the number of decimal places used by the token.
+ ///
+ [DynamoDBProperty]
+ public byte Decimals { get; set; }
+
+ ///
+ /// Gets or sets the total supply of the token.
+ ///
+ [DynamoDBProperty]
+ public decimal TotalSupply { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ /// Constructor without parameters for working "AWSSDK.DynamoDBv2" library.
+ ///
+ public Erc20TokenDynamoDbEntry() { }
+
+ ///
+ /// Initializes a new instance of the class with specified values.
+ ///
+ /// The hash key identifying the token.
+ /// The token data retrieved from RPC.
+ public Erc20TokenDynamoDbEntry(HashKey hashKey, Erc20TokenData erc20Token)
+ {
+ HashKey = hashKey.Value;
+ ChainId = hashKey.ChainId;
+ Address = hashKey.Address;
+ Name = erc20Token.Name;
+ Symbol = erc20Token.Symbol;
+ Decimals = erc20Token.Decimals;
+ TotalSupply = Nethereum.Web3.Web3.Convert.FromWei(erc20Token.TotalSupply, erc20Token.Decimals);
+ }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/DynamoDb/Models/HashKey.cs b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/Models/HashKey.cs
new file mode 100644
index 0000000..13a5db7
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/DynamoDb/Models/HashKey.cs
@@ -0,0 +1,65 @@
+using System;
+using Net.Cryptography.SHA256;
+using Net.Web3.EthereumWallet;
+
+namespace Net.Cache.DynamoDb.ERC20.DynamoDb.Models
+{
+ ///
+ /// Represents a unique key that combines a blockchain chain identifier and an ERC20 token address.
+ /// The key value is a SHA256 hash of the combined chain identifier and address.
+ ///
+ public class HashKey
+ {
+ ///
+ /// Gets the blockchain network identifier.
+ ///
+ public long ChainId { get; }
+
+ ///
+ /// Gets the ERC20 token contract address.
+ ///
+ public EthereumAddress Address { get; }
+
+ ///
+ /// Gets the hashed representation of the and combination.
+ ///
+ public string Value { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The blockchain network identifier.
+ /// The ERC20 token contract address.
+ /// Thrown when is less than or equal to zero.
+ /// Thrown when is null.
+ public HashKey(long chainId, EthereumAddress address)
+ {
+ if (chainId <= 0) throw new ArgumentOutOfRangeException(nameof(chainId));
+ if (address == null) throw new ArgumentNullException(nameof(address));
+
+ ChainId = chainId;
+ Address = address;
+ Value = Generate(chainId, address);
+ }
+
+ ///
+ /// Generates a hashed key for the specified chain identifier and address.
+ ///
+ /// The blockchain network identifier.
+ /// The ERC20 token contract address.
+ /// A SHA256 hash representing the combined chain identifier and address.
+ public static string Generate(long chainId, EthereumAddress address)
+ {
+ if (chainId <= 0) throw new ArgumentOutOfRangeException(nameof(chainId));
+ if (address == null) throw new ArgumentNullException(nameof(address));
+
+ return $"{chainId}-{address}".ToSha256();
+ }
+
+ ///
+ /// Returns the hash key value.
+ ///
+ /// The hashed key string.
+ public override string ToString() => Value;
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/ERC20CacheProvider.cs b/src/Net.Cache.DynamoDb.ERC20/ERC20CacheProvider.cs
deleted file mode 100644
index 18927da..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/ERC20CacheProvider.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using Net.Cryptography.SHA256;
-using Net.Cache.DynamoDb.ERC20.Models;
-using System.Threading.Tasks;
-
-namespace Net.Cache.DynamoDb.ERC20
-{
- ///
- /// Provides functionality to manage the caching of ERC20 token information.
- ///
- ///
- /// This provider encapsulates logic for retrieving and storing ERC20 token details
- /// within a DynamoDB cache. It supports operations to get existing cache entries or add new entries when required.
- ///
- public class ERC20CacheProvider
- {
- private readonly ERC20StorageProvider storageProvider;
-
- ///
- /// Initializes a new instance of the class with a default storage provider.
- ///
- public ERC20CacheProvider()
- : this(new ERC20StorageProvider())
- { }
-
- ///
- /// Initializes a new instance of the class with a specified storage provider.
- ///
- /// The storage provider to be used for caching.
- public ERC20CacheProvider(ERC20StorageProvider storageProvider)
- {
- this.storageProvider = storageProvider;
- }
-
- ///
- /// Retrieves an ERC20 token information from the cache or adds it to the cache if it does not exist.
- ///
- /// The request containing details required to retrieve or add the ERC20 token information.
- /// The ERC20 token information from the cache.
- ///
- /// This method checks the cache for an existing entry for the specified ERC20 token.
- /// If the entry exists, it is returned. Otherwise, a new entry is created using the provided
- /// details in the request and added to the cache.
- ///
- public virtual ERC20DynamoDbTable GetOrAdd(GetCacheRequest request)
- {
- if (storageProvider.TryGetValue($"{request.ChainId}-{request.ERC20Service.ContractAddress}".ToSha256(), request, out var storedValue))
- {
- return storedValue;
- }
-
- storedValue = new ERC20DynamoDbTable(request.ChainId, request.ERC20Service);
- storageProvider.Store(storedValue.HashKey, storedValue);
- return storedValue;
- }
-
- public virtual async Task GetOrAddAsync(GetCacheRequest request)
- {
- var (isExist, storedValue) = await storageProvider.TryGetValueAsync($"{request.ChainId}-{request.ERC20Service.ContractAddress}".ToSha256(), request);
- if (isExist && storedValue != null)
- {
- return storedValue;
- }
-
- storedValue = new ERC20DynamoDbTable(request.ChainId, request.ERC20Service);
- await storageProvider.StoreAsync(storedValue.HashKey, storedValue);
- return storedValue;
- }
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/ERC20StorageProvider.cs b/src/Net.Cache.DynamoDb.ERC20/ERC20StorageProvider.cs
deleted file mode 100644
index 2406fb6..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/ERC20StorageProvider.cs
+++ /dev/null
@@ -1,127 +0,0 @@
-using System.Threading.Tasks;
-using Amazon.DynamoDBv2.DataModel;
-using Net.Cache.DynamoDb.ERC20.RPC;
-using Net.Cache.DynamoDb.ERC20.Models;
-using System.Diagnostics.CodeAnalysis;
-
-namespace Net.Cache.DynamoDb.ERC20
-{
- ///
- /// Manages the storage and retrieval of ERC20 token information in a DynamoDB table.
- ///
- ///
- /// This provider extends the to specifically handle the operations
- /// related to storing and accessing ERC20 token data. It includes functionalities to update
- /// the total supply of a token and to retrieve or store token information based on a given key.
- ///
- public class ERC20StorageProvider : DynamoDbStorageProvider
- {
- ///
- /// Initializes a new instance of the class with an optional .
- ///
- /// The name of the DynamoDB table to be used. If not provided, a default table name is used.
- public ERC20StorageProvider(string tableName = EmptyString)
- : base(tableName)
- { }
-
- ///
- /// Initializes a new instance of the class with a specific DynamoDB and an optional .
- ///
- /// The DynamoDB context to be used for operations.
- /// The name of the DynamoDB table to be used. If not provided, a default table name is used.
- public ERC20StorageProvider(IDynamoDBContext context, string tableName = EmptyString)
- : base(context, tableName)
- { }
-
- ///
- /// Updates the total supply of an existing ERC20 token information entry.
- ///
- /// The existing entry of ERC20 token information.
- /// The ERC20 service to use for updating the total supply.
- /// The updated ERC20 token information entry.
- protected virtual ERC20DynamoDbTable UpdateTotalSupply(ERC20DynamoDbTable existValue, IERC20Service erc20Service)
- {
- var updatedValue = new ERC20DynamoDbTable(
- existValue.ChainId,
- existValue.Address,
- existValue.Name,
- existValue.Symbol,
- existValue.Decimals,
- erc20Service.TotalSupply()
- );
- Context.SaveAsync(updatedValue, OperationConfig())
- .GetAwaiter()
- .GetResult();
- return updatedValue;
- }
-
- protected virtual async Task UpdateTotalSupplyAsync(ERC20DynamoDbTable existValue, IERC20Service erc20Service)
- {
- var updatedValue = new ERC20DynamoDbTable(
- existValue.ChainId,
- existValue.Address,
- existValue.Name,
- existValue.Symbol,
- existValue.Decimals,
- erc20Service.TotalSupply()
- );
- await Context.SaveAsync(updatedValue, OperationConfig());
- return updatedValue;
- }
-
- ///
- /// Tries to retrieve an ERC20 token information entry from the cache based on a given key.
- ///
- /// The key associated with the ERC20 token information.
- /// The request containing details for the retrieval operation.
- /// When this method returns, contains the ERC20 token information associated with the specified key, if the key is found; otherwise, .
- /// if the token information is found; otherwise, .
- ///
- /// This method also updates the total supply of the token information if the flag is set in the .
- ///
- public virtual bool TryGetValue(string key, GetCacheRequest request, [MaybeNullWhen(false)] out ERC20DynamoDbTable value)
- {
- if (!base.TryGetValue(key, out value))
- {
- return false;
- }
-
- if (request.UpdateTotalSupply)
- {
- value = UpdateTotalSupply(value, request.ERC20Service);
- }
-
- return true;
- }
-
- public virtual async Task<(bool isExist, ERC20DynamoDbTable? Value)> TryGetValueAsync(string key, GetCacheRequest request)
- {
- try
- {
- var storedValue = await Context.LoadAsync(key, OperationConfig());
-
- if (storedValue == null)
- {
- return (false, null);
- }
-
- if (request.UpdateTotalSupply)
- {
- storedValue = await UpdateTotalSupplyAsync(storedValue, request.ERC20Service);
- }
-
- return (true, storedValue);
- }
- catch (Amazon.DynamoDBv2.AmazonDynamoDBException)
- {
- throw;
- }
- catch
- {
- return (false, null);
- }
- }
-
- public virtual Task StoreAsync(string key, ERC20DynamoDbTable value) => Context.SaveAsync(value);
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Erc20CacheService.cs b/src/Net.Cache.DynamoDb.ERC20/Erc20CacheService.cs
new file mode 100644
index 0000000..bf38efe
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/Erc20CacheService.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Threading.Tasks;
+using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.Rpc;
+using System.Collections.Concurrent;
+using Net.Cache.DynamoDb.ERC20.DynamoDb;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20
+{
+ ///
+ /// Provides caching of ERC20 token information with a backing store in DynamoDB and an in-memory layer.
+ ///
+ public class Erc20CacheService : IErc20CacheService
+ {
+ private readonly IDynamoDbClient _dynamoDbClient;
+ private readonly IErc20ServiceFactory _erc20ServiceFactory;
+ private readonly ConcurrentDictionary _inMemoryCache;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The DynamoDB client used for persistent storage.
+ /// Factory for creating RPC services to query token metadata.
+ public Erc20CacheService(IDynamoDbClient dynamoDbClient, IErc20ServiceFactory erc20ServiceFactory)
+ {
+ _dynamoDbClient = dynamoDbClient ?? throw new ArgumentNullException(nameof(dynamoDbClient));
+ _erc20ServiceFactory = erc20ServiceFactory ?? throw new ArgumentNullException(nameof(erc20ServiceFactory));
+ _inMemoryCache = new ConcurrentDictionary();
+ }
+
+ ///
+ /// Initializes a new instance of the class using default implementations.
+ ///
+ public Erc20CacheService()
+ : this(
+ new DynamoDbClient(),
+ new Erc20ServiceFactory()
+ )
+ { }
+
+ ///
+ public async Task GetOrAddAsync(HashKey hashKey, Func> rpcUrlFactory, Func> multiCallFactory)
+ {
+ if (hashKey == null) throw new ArgumentNullException(nameof(hashKey));
+ if (rpcUrlFactory == null) throw new ArgumentNullException(nameof(rpcUrlFactory));
+ if (multiCallFactory == null) throw new ArgumentNullException(nameof(multiCallFactory));
+
+ if (_inMemoryCache.TryGetValue(hashKey.Value, out var cachedEntry)) return cachedEntry;
+
+ var entry = await _dynamoDbClient
+ .GetErc20TokenAsync(hashKey)
+ .ConfigureAwait(false);
+ if (entry != null)
+ {
+ _inMemoryCache.TryAdd(hashKey.Value, entry);
+ return entry;
+ }
+
+ var rpcUrlTask = rpcUrlFactory();
+ var multiCallTask = multiCallFactory();
+
+ await Task.WhenAll(rpcUrlTask, multiCallTask);
+
+ var rpcUrl = rpcUrlTask.Result;
+ var multiCall = multiCallTask.Result;
+
+ var erc20Service = _erc20ServiceFactory.Create(new Nethereum.Web3.Web3(rpcUrl), multiCall);
+ var erc20Token = await erc20Service.GetErc20TokenAsync(hashKey.Address).ConfigureAwait(false);
+
+ entry = new Erc20TokenDynamoDbEntry(hashKey, erc20Token);
+ await _dynamoDbClient.SaveErc20TokenAsync(entry).ConfigureAwait(false);
+ _inMemoryCache.TryAdd(hashKey.Value, entry);
+
+ return entry;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/IErc20CacheService.cs b/src/Net.Cache.DynamoDb.ERC20/IErc20CacheService.cs
new file mode 100644
index 0000000..10d0566
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/IErc20CacheService.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Threading.Tasks;
+using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20
+{
+ ///
+ /// Provides caching operations for ERC20 token metadata backed by DynamoDB.
+ ///
+ public interface IErc20CacheService
+ {
+ ///
+ /// Retrieves a cached ERC20 token entry or adds a new one when it is missing.
+ ///
+ /// The composite key of chain identifier and token address.
+ /// Factory used to resolve the RPC endpoint URL.
+ /// Factory used to resolve the address of the multicall contract.
+ /// The cached or newly created instance.
+ public Task GetOrAddAsync(HashKey hashKey, Func> rpcUrlFactory, Func> multiCallFactory);
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/Models/Api/ApiERC20ServiceConfig.cs b/src/Net.Cache.DynamoDb.ERC20/Models/Api/ApiERC20ServiceConfig.cs
deleted file mode 100644
index b729b1e..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Models/Api/ApiERC20ServiceConfig.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using Net.Web3.EthereumWallet;
-
-namespace Net.Cache.DynamoDb.ERC20.Models.Api
-{
- ///
- /// Represents the configuration settings required for the API-based ERC20 service.
- ///
- public class ApiERC20ServiceConfig
- {
- ///
- /// Initializes a new instance of the class with the specified settings.
- ///
- /// The API key used to authenticate requests to the ERC20 token service.
- /// The blockchain chain ID where the ERC20 token resides.
- /// The Ethereum address of the ERC20 token contract.
- /// The URL template for making API requests to retrieve ERC20 token information.
- public ApiERC20ServiceConfig(string apiKey, long chainId, EthereumAddress contractAddress, string apiUrl)
- {
- ApiKey = apiKey;
- ChainId = chainId;
- ContractAddress = contractAddress;
- ApiUrl = apiUrl;
- }
-
- ///
- /// Gets or sets the API key used to authenticate requests to the ERC20 token service.
- ///
- public string ApiKey { get; set; }
-
- ///
- /// Gets or sets the blockchain chain ID where the ERC20 token resides.
- ///
- public long ChainId { get; set; }
-
- ///
- /// Gets or sets the Ethereum address of the ERC20 token contract.
- ///
- public EthereumAddress ContractAddress { get; set; }
-
- ///
- /// Gets or sets the URL template for making API requests to retrieve ERC20 token information.
- ///
- public string ApiUrl { get; set; }
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Models/Api/ApiResponse.cs b/src/Net.Cache.DynamoDb.ERC20/Models/Api/ApiResponse.cs
deleted file mode 100644
index 71ac17b..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Models/Api/ApiResponse.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using Newtonsoft.Json;
-
-namespace Net.Cache.DynamoDb.ERC20.Models.Api
-{
- ///
- /// Represents the data container for ERC20 token information retrieved from an API response.
- ///
- public class ApiResponse
- {
- ///
- /// Gets or sets the list of items containing ERC20 token information.
- ///
- /// A list of objects that hold the details of ERC20 tokens.
- [JsonProperty("data")]
- public Data Data { get; set; } = null!;
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Models/Api/Data.cs b/src/Net.Cache.DynamoDb.ERC20/Models/Api/Data.cs
deleted file mode 100644
index e3204a4..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Models/Api/Data.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using Newtonsoft.Json;
-using System.Collections.Generic;
-
-namespace Net.Cache.DynamoDb.ERC20.Models.Api
-{
- ///
- /// Represents the data container for ERC20 token information retrieved from an API response.
- ///
- public class Data
- {
- ///
- /// Gets or sets the list of items containing ERC20 token information.
- ///
- /// A list of objects that hold the details of ERC20 tokens.
- [JsonProperty("items")]
- public List- Items { get; set; } = null!;
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Models/Api/Item.cs b/src/Net.Cache.DynamoDb.ERC20/Models/Api/Item.cs
deleted file mode 100644
index 0cf1283..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Models/Api/Item.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using Newtonsoft.Json;
-
-namespace Net.Cache.DynamoDb.ERC20.Models.Api
-{
- ///
- /// Represents an item containing ERC20 token information retrieved from an API response.
- ///
- public class Item
- {
- ///
- /// Gets or sets the number of decimals the ERC20 token uses.
- ///
- /// The number of decimals for the ERC20 token.
- [JsonProperty("contract_decimals")]
- public byte ContractDecimals { get; set; }
-
- ///
- /// Gets or sets the name of the ERC20 token.
- ///
- /// The name of the ERC20 token.
- [JsonProperty("contract_name")]
- public string ContractName { get; set; } = null!;
-
- ///
- /// Gets or sets the ticker symbol of the ERC20 token.
- ///
- /// The ticker symbol of the ERC20 token.
- [JsonProperty("contract_ticker_symbol")]
- public string ContractTickerSymbol { get; set; } = null!;
-
- ///
- /// Gets or sets the total supply of the ERC20 token as a string.
- ///
- /// The total supply of the ERC20 token.
- [JsonProperty("total_supply")]
- public string TotalSupply { get; set; } = null!;
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Models/ApiRequestFactory.cs b/src/Net.Cache.DynamoDb.ERC20/Models/ApiRequestFactory.cs
deleted file mode 100644
index ff38adf..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Models/ApiRequestFactory.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using Net.Web3.EthereumWallet;
-using Net.Cache.DynamoDb.ERC20.Api;
-using Net.Cache.DynamoDb.ERC20.Models.Api;
-
-namespace Net.Cache.DynamoDb.ERC20.Models
-{
- ///
- /// A factory class for creating instances of API-related services and requests.
- ///
- ///
- /// This factory class simplifies the creation of configurations, services, and requests
- /// for interacting with ERC20 tokens via API services.
- ///
- public class ApiRequestFactory
- {
- ///
- /// Creates an instance of using the provided API key, chain ID, contract address, and API URL.
- ///
- /// The API key for accessing the ERC20 token data.
- /// The blockchain chain ID where the ERC20 token resides.
- /// The Ethereum address of the ERC20 token contract.
- /// The API URL template for fetching the token data.
- /// An instance of configured with the provided parameters.
- public virtual ApiERC20ServiceConfig CreateApiServiceConfig(string apiKey, long chainId, EthereumAddress contractAddress, string apiUrl) => new ApiERC20ServiceConfig(apiKey, chainId, contractAddress, apiUrl);
-
- ///
- /// Creates an instance of using the provided configuration.
- ///
- /// The configuration object containing API key, chain ID, contract address, and API URL.
- /// An instance of configured with the provided configuration.
- public virtual ApiERC20Service CreateApiService(ApiERC20ServiceConfig config) => new ApiERC20Service(config);
-
- ///
- /// Creates an instance of using the provided API service and chain ID.
- ///
- /// The API service for interacting with the ERC20 token.
- /// The blockchain chain ID where the ERC20 token resides.
- /// An instance of configured with the provided service and chain ID.
- public virtual GetCacheRequest CreateWithApiService(ApiERC20Service apiService, long chainId) => new GetCacheRequest(chainId, apiService);
-
- ///
- /// Creates an instance of by configuring and initializing the necessary API service.
- ///
- /// The API key used to authenticate requests to the ERC20 token service.
- /// The blockchain chain ID where the ERC20 token resides.
- /// The Ethereum address of the ERC20 token contract.
- /// The API URL template for fetching the token data.
- /// An instance of configured with the appropriate service and chain ID.
- ///
- /// This method simplifies the process of creating a by internally handling the
- /// creation of and objects.
- ///
- public virtual GetCacheRequest CreateCacheRequest(string apiKey, long chainId, EthereumAddress contractAddress, string apiUrl)
- {
- var config = new ApiERC20ServiceConfig(apiKey, chainId, contractAddress, apiUrl);
- var apiService = new ApiERC20Service(config);
- return new GetCacheRequest(chainId, apiService);
- }
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Models/ERC20DynamoDbTable.cs b/src/Net.Cache.DynamoDb.ERC20/Models/ERC20DynamoDbTable.cs
deleted file mode 100644
index 5b2f98b..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Models/ERC20DynamoDbTable.cs
+++ /dev/null
@@ -1,132 +0,0 @@
-using System.Numerics;
-using Net.Web3.EthereumWallet;
-using Net.Cryptography.SHA256;
-using Amazon.DynamoDBv2.DataModel;
-using Net.Cache.DynamoDb.ERC20.RPC;
-
-namespace Net.Cache.DynamoDb.ERC20.Models
-{
- ///
- /// Represents an ERC20 token information cache entry in DynamoDB.
- ///
- ///
- /// This class is designed to store and manage the basic details of an ERC20 token,
- /// including its chain ID, address, name, symbol, decimals, and total supply. It also
- /// generates a unique hash key for each token based on its chain ID and address.
- ///
- [DynamoDBTable("TokensInfoCache")]
- public class ERC20DynamoDbTable
- {
- ///
- /// Gets the hash key for the ERC20 token entry, uniquely generated based on the chain ID and token address.
- ///
- [DynamoDBHashKey]
- public string HashKey { get; set; } = string.Empty;
-
- ///
- /// Gets the block-chain chain ID where the ERC20 token is located.
- ///
- [DynamoDBProperty]
- public long ChainId { get; set; }
-
- ///
- /// Gets the ERC20 token contract address.
- ///
- [DynamoDBProperty]
- public string Address { get; set; } = string.Empty;
-
- ///
- /// Gets the name of the ERC20 token.
- ///
- [DynamoDBProperty]
- public string Name { get; set; } = string.Empty;
-
- ///
- /// Gets the symbol of the ERC20 token.
- ///
- [DynamoDBProperty]
- public string Symbol { get; set; } = string.Empty;
-
- ///
- /// Gets the decimals of the ERC20 token, indicating how divisible it is.
- ///
- [DynamoDBProperty]
- public byte Decimals { get; set; }
-
- ///
- /// Gets the total supply of the ERC20 token.
- ///
- [DynamoDBProperty]
- public decimal TotalSupply { get; set; }
-
- ///
- /// Initializes a new instance of the class with specified token details.
- ///
- /// The block-chain chain ID.
- /// The ERC20 token contract address.
- /// The name of the ERC20 token.
- /// The symbol of the ERC20 token.
- /// The decimals of the ERC20 token.
- /// The total supply of the ERC20 token.
- ///
- /// This constructor expects an already calculated based on the total supply value and .
- ///
- public ERC20DynamoDbTable(long chainId, EthereumAddress address, string name, string symbol, byte decimals, decimal totalSupply)
- {
- ChainId = chainId;
- Address = address;
- Name = name;
- Symbol = symbol;
- Decimals = decimals;
- TotalSupply = totalSupply;
- HashKey = $"{ChainId}-{Address}".ToSha256();
- }
-
- ///
- /// Initializes a new instance of the class with specified token details and total supply in .
- ///
- /// The block-chain chain ID.
- /// The ERC20 token contract address.
- /// The name of the ERC20 token.
- /// The symbol of the ERC20 token.
- /// The decimals of the ERC20 token.
- /// The total supply of the ERC20 token in format.
- ///
- /// This constructor converts the from to , considering the token's .
- ///
- public ERC20DynamoDbTable(long chainId, EthereumAddress address, string name, string symbol, byte decimals, BigInteger totalSupply)
- {
- ChainId = chainId;
- Address = address;
- Name = name;
- Symbol = symbol;
- Decimals = decimals;
- TotalSupply = Nethereum.Web3.Web3.Convert.FromWei(totalSupply, decimals);
- HashKey = $"{ChainId}-{Address}".ToSha256();
- }
-
- ///
- /// Initializes a new instance of the class using an to populate token details.
- ///
- /// The block-chain chain ID.
- /// The ERC20 service providing access to token details.
- ///
- /// This constructor retrieves token details such as name, symbol, decimals, and total supply from the provided .
- ///
- public ERC20DynamoDbTable(long chainId, IERC20Service erc20Service)
- {
- ChainId = chainId;
- Address = erc20Service.ContractAddress;
- Name = erc20Service.Name();
- Symbol = erc20Service.Symbol();
- Decimals = erc20Service.Decimals();
- TotalSupply = Nethereum.Web3.Web3.Convert.FromWei(erc20Service.TotalSupply(), Decimals);
- HashKey = $"{ChainId}-{Address}".ToSha256();
- }
-
- ///
- /// Constructor without parameters for working "Amazon.DynamoDBv2"
- ///
- public ERC20DynamoDbTable() { }
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Models/GetCacheRequest.cs b/src/Net.Cache.DynamoDb.ERC20/Models/GetCacheRequest.cs
deleted file mode 100644
index 77fa7b2..0000000
--- a/src/Net.Cache.DynamoDb.ERC20/Models/GetCacheRequest.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using System;
-using Net.Web3.EthereumWallet;
-using Net.Cache.DynamoDb.ERC20.RPC;
-using System.Threading.Tasks;
-
-namespace Net.Cache.DynamoDb.ERC20.Models
-{
- ///
- /// Represents a request to retrieve or update ERC20 token information in the cache.
- ///
- ///
- /// This class encapsulates the details necessary for fetching or updating the cached data of an ERC20 token,
- /// including the block-chain chain ID, the , and a flag indicating whether to update the total supply.
- ///
- public class GetCacheRequest
- {
- ///
- /// Gets the block-chain chain ID for the request.
- ///
- public long ChainId { get; }
-
- ///
- /// Gets the used to interact with the ERC20 token contract.
- ///
- public IERC20Service ERC20Service { get; }
-
- ///
- /// Gets a value indicating whether the total supply of the token should be updated in the cache.
- ///
- public bool UpdateTotalSupply { get; }
-
- ///
- /// Initializes a new instance of the class with specified chain ID and contract address.
- ///
- /// The block-chain chain ID.
- /// The ERC20 token contract address.
- /// The URL of the RPC endpoint to interact with the block-chain.
- /// Optional. Indicates whether to update the total supply of the token in the cache. Defaults to .
- ///
- /// This constructor creates an instance of the class using the provided RPC URL and contract address.
- ///
- public GetCacheRequest(long chainId, EthereumAddress contractAddress, string rpcUrl, bool updateTotalSupply = true)
- : this(chainId, new ERC20Service(() => rpcUrl, contractAddress), updateTotalSupply)
- { }
-
- ///
- /// Initializes a new instance of the class with specified chain ID, contract address and RPC URL factory.
- ///
- /// The block-chain chain ID.
- /// The ERC20 token contract address.
- /// A function that returns the RPC URL to interact with the block-chain.
- /// Optional. Indicates whether to update the total supply of the token in the cache. Defaults to .
- public GetCacheRequest(long chainId, EthereumAddress contractAddress, Func rpcUrlFactory, bool updateTotalSupply = true)
- : this(chainId, new ERC20Service(rpcUrlFactory, contractAddress), updateTotalSupply)
- { }
-
- ///
- /// Initializes a new instance of the class with specified chain ID, contract address and asynchronous RPC URL factory.
- ///
- /// The block-chain chain ID.
- /// The ERC20 token contract address.
- /// A function that asynchronously returns the RPC URL to interact with the block-chain.
- /// Optional. Indicates whether to update the total supply of the token in the cache. Defaults to .
- public GetCacheRequest(long chainId, EthereumAddress contractAddress, Func> rpcUrlFactoryAsync, bool updateTotalSupply = true)
- : this(chainId, new ERC20Service(rpcUrlFactoryAsync, contractAddress), updateTotalSupply)
- { }
-
- ///
- /// Initializes a new instance of the class with a specified ERC20 service.
- ///
- /// The block-chain chain ID.
- /// The providing access to the token's details.
- /// Optional. Indicates whether to update the total supply of the token in the cache. Defaults to .
- ///
- /// This constructor allows for more flexibility by accepting an instance of an ,
- /// enabling the use of customized or mock services for testing purposes.
- ///
- public GetCacheRequest(long chainId, IERC20Service erc20Service, bool updateTotalSupply = true)
- {
- ChainId = chainId;
- ERC20Service = erc20Service;
- UpdateTotalSupply = updateTotalSupply;
- }
- }
-}
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/Net.Cache.DynamoDb.ERC20.csproj b/src/Net.Cache.DynamoDb.ERC20/Net.Cache.DynamoDb.ERC20.csproj
index 063b406..9130c80 100644
--- a/src/Net.Cache.DynamoDb.ERC20/Net.Cache.DynamoDb.ERC20.csproj
+++ b/src/Net.Cache.DynamoDb.ERC20/Net.Cache.DynamoDb.ERC20.csproj
@@ -10,8 +10,7 @@
-
-
+
diff --git a/src/Net.Cache.DynamoDb.ERC20/Properties/AssemblyInfo.cs b/src/Net.Cache.DynamoDb.ERC20/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..86ae4f3
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Net.Cache.DynamoDb.ERC20.Tests")]
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/README.md b/src/Net.Cache.DynamoDb.ERC20/README.md
index d1579af..2f50ea0 100644
--- a/src/Net.Cache.DynamoDb.ERC20/README.md
+++ b/src/Net.Cache.DynamoDb.ERC20/README.md
@@ -9,8 +9,8 @@ It integrates seamlessly with Ethereum blockchain networks to provide efficient
- `ERC20 Token Information Caching`: Efficiently caches ERC20 token information, reducing the need for repeated blockchain queries.
- `Automatic Key Generation`: Generates unique hash keys for each token based on its chain ID and address, ensuring efficient data retrieval.
-- `Flexible Initialization Options`: Supports initialization with custom or default DynamoDB client settings.
-- `Update Total Supply`: Offers functionality to update the total supply of tokens in the cache dynamically.
+- `Flexible Initialization Options`: Supports initialization with custom or default DynamoDB client and RPC service settings.
+- `In-Memory Layer`: Uses an in-memory cache alongside DynamoDB for faster repeated access.
- `Integration with Net.Cache.DynamoDb`: Builds upon the robust caching capabilities of Net.Cache.DynamoDb, providing a specific solution for ERC20 tokens.
@@ -23,35 +23,39 @@ Then, include Net.Cache.DynamoDb.ERC20 in your project
### Usage
-Initialize ERC20CacheProvider
+#### Initialize `Erc20CacheService`
-You can initialize the `ERC20CacheProvider` using the default constructor or by passing an instance of `ERC20StorageProvider` for more customized settings.
+You can initialize the `Erc20CacheService` using the default constructor or by passing custom implementations of `IDynamoDbClient` and `IErc20ServiceFactory`:
```csharp
-var erc20CacheProvider = new ERC20CacheProvider();
+var cacheService = new Erc20CacheService();
```
-Or with a custom storage provider:
+Or with custom dependencies:
```csharp
-var customContext = new DynamoDBContext(customClient);
-var erc20StorageProvider = new ERC20StorageProvider(customContext);
-var erc20CacheProvider = new ERC20CacheProvider(erc20StorageProvider);
+var context = new DynamoDBContext(customClient);
+var dynamoDbClient = new DynamoDbClient(context);
+var erc20ServiceFactory = new Erc20ServiceFactory();
+var cacheService = new Erc20CacheService(dynamoDbClient, erc20ServiceFactory);
```
-Caching ERC20 Token Information
+#### Caching ERC20 Token Information
-To cache ERC20 token information, create a `GetCacheRequest` and use the `GetOrAdd` method of `ERC20CacheProvider`:
+To cache ERC20 token information, create a `HashKey` and use the `GetOrAddAsync` method of `Erc20CacheService`:
```csharp
-var chainId = BigInteger.Parse("1"); // Ethereum mainnet
+var chainId = 1L; // Ethereum mainnet
var contractAddress = new EthereumAddress("0x..."); // ERC20 token contract address
-var rpcUrl = "https://mainnet.infura.io/v3/YOUR_INFURA_PROJECT_ID";
+var hashKey = new HashKey(chainId, contractAddress);
-var request = new GetCacheRequest(chainId, contractAddress, rpcUrl);
-var tokenInfo = erc20CacheProvider.GetOrAdd(contractAddress, request);
+var tokenInfo = await cacheService.GetOrAddAsync(
+ hashKey,
+ rpcUrlFactory: () => Task.FromResult("https://mainnet.infura.io/v3/YOUR_INFURA_PROJECT_ID"),
+ multiCallFactory: () => Task.FromResult(new EthereumAddress("0x...multicall"))
+);
Console.WriteLine($"Token Name: {tokenInfo.Name}, Symbol: {tokenInfo.Symbol}");
```
-This method retrieves the token information from the cache if it exists or fetches it from the blockchain and adds it to the cache otherwise.
+This method retrieves the token information from the cache if it exists, or fetches it from the blockchain and stores it in both the DynamoDB table and the in-memory cache otherwise.
\ No newline at end of file
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/ERC20Service.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/ERC20Service.cs
index 910a107..10dd35d 100644
--- a/src/Net.Cache.DynamoDb.ERC20/RPC/ERC20Service.cs
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/ERC20Service.cs
@@ -1,93 +1,83 @@
using System;
+using System.Linq;
using Nethereum.Web3;
-using System.Numerics;
-using System.Threading;
+using Nethereum.Contracts;
using System.Threading.Tasks;
using Net.Web3.EthereumWallet;
-using Nethereum.Contracts.Standards.ERC20;
+using System.Collections.Generic;
+using Net.Cache.DynamoDb.ERC20.Rpc.Exceptions;
+using Net.Cache.DynamoDb.ERC20.Rpc.Extensions;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+using Net.Cache.DynamoDb.ERC20.Rpc.Validators;
+using Nethereum.Contracts.Standards.ERC20.ContractDefinition;
-namespace Net.Cache.DynamoDb.ERC20.RPC
+namespace Net.Cache.DynamoDb.ERC20.Rpc
{
///
- /// Provides functionalities to interact with ERC20 tokens on the block-chain via RPC calls.
+ /// Retrieves ERC20 token information via blockchain RPC calls.
///
- ///
- /// This class encapsulates the interactions with an ERC20 token contract,
- /// allowing for queries such as retrieving the token's name, symbol, decimals, and total supply.
- ///
- public class ERC20Service : IERC20Service
+ public class Erc20Service : IErc20Service
{
- private readonly Lazy contractService;
-
- ///
- public EthereumAddress ContractAddress { get; }
-
- ///
- /// Initializes a new instance of the class using an RPC URL and contract address.
- ///
- /// The URL of the RPC endpoint to interact with the block-chain.
- /// The ERC20 token contract address.
- ///
- /// This constructor initializes the contract service for interacting with the ERC20 token.
- ///
- public ERC20Service(string rpcUrl, EthereumAddress contractAddress)
- : this(() => rpcUrl, contractAddress)
- { }
+ private readonly IWeb3 _web3;
+ private readonly EthereumAddress _multiCall;
///
- /// Initializes a new instance of the class using a function that provides an RPC URL and contract address.
+ /// Initializes a new instance of the class.
///
- /// A function that returns the RPC URL to interact with the block-chain.
- /// The ERC20 token contract address.
- ///
- /// The RPC URL is retrieved only when the service methods are invoked, allowing for lazy initialization.
- ///
- public ERC20Service(Func rpcUrlFactory, EthereumAddress contractAddress)
+ /// The web3 client used for RPC communication.
+ /// The address of the multicall contract.
+ public Erc20Service(IWeb3 web3, EthereumAddress multiCall)
{
- ContractAddress = contractAddress;
- contractService = new Lazy(() =>
- {
- var web3 = new Nethereum.Web3.Web3(rpcUrlFactory());
- return web3.Eth.ERC20.GetContractService(contractAddress);
- });
+ _web3 = web3 ?? throw new ArgumentNullException(nameof(web3));
+ _multiCall = multiCall ?? throw new ArgumentNullException(nameof(multiCall));
}
- ///
- /// Initializes a new instance of the class using an asynchronous function that provides an RPC URL and contract address.
- ///
- /// A function that asynchronously returns the RPC URL to interact with the block-chain.
- /// The ERC20 token contract address.
- ///
- /// The RPC URL is retrieved only when the service methods are invoked, allowing for lazy initialization.
- ///
- public ERC20Service(Func> rpcUrlFactoryAsync, EthereumAddress contractAddress)
- : this(() => rpcUrlFactoryAsync().GetAwaiter().GetResult(), contractAddress)
- { }
-
- ///
- /// Initializes a new instance of the class using a instance and contract address.
- ///
- /// The Web3 instance for interacting with the Ethereum network.
- /// The ERC20 token contract address.
- ///
- /// This constructor provides more flexibility by allowing the use of an existing Web3 instance.
- ///
- public ERC20Service(IWeb3 web3, EthereumAddress contractAddress)
+ ///
+ public async Task GetErc20TokenAsync(EthereumAddress token)
{
- ContractAddress = contractAddress;
- contractService = new Lazy(() => web3.Eth.ERC20.GetContractService(contractAddress), LazyThreadSafetyMode.ExecutionAndPublication);
- }
+ if (token == null) throw new ArgumentNullException(nameof(token));
- ///
- public virtual byte Decimals() => contractService.Value.DecimalsQueryAsync().GetAwaiter().GetResult();
+ var multiCallFunction = new MultiCallFunction(
+ calls: new[]
+ {
+ new MultiCall(to: token, data: new NameFunction().GetCallData()),
+ new MultiCall(to: token, data: new SymbolFunction().GetCallData()),
+ new MultiCall(to: token, data: new DecimalsFunction().GetCallData()),
+ new MultiCall(to: token, data: new TotalSupplyFunction().GetCallData())
+ }
+ );
- ///
- public virtual string Name() => contractService.Value.NameQueryAsync().GetAwaiter().GetResult();
+ var handler = _web3.Eth.GetContractQueryHandler();
+ var response = await handler
+ .QueryAsync
>(_multiCall, multiCallFunction)
+ .ConfigureAwait(false);
+ var responseValidator = new MultiCallResponseValidator(multiCallFunction.Calls.Length);
+ var validation = await responseValidator
+ .ValidateAsync(response)
+ .ConfigureAwait(false);
+ if (!validation.IsValid)
+ {
+ var error = string.Join(" ", validation.Errors.Select(e => e.ErrorMessage));
+ throw new Erc20QueryException(token, error);
+ }
- ///
- public virtual string Symbol() => contractService.Value.SymbolQueryAsync().GetAwaiter().GetResult();
+ var name = response[0].Decode().Name;
+ var symbol = response[1].Decode().Symbol;
+ var decimals = response[2].Decode().Decimals;
+ var supply = response[3].Decode().TotalSupply;
- ///
- public virtual BigInteger TotalSupply() => contractService.Value.TotalSupplyQueryAsync().GetAwaiter().GetResult();
+ var tokenResult = new Erc20TokenData(token, name, symbol, decimals, supply);
+ var tokenValidator = new Erc20TokenValidator();
+ var tokenValidation = await tokenValidator
+ .ValidateAsync(tokenResult)
+ .ConfigureAwait(false);
+ if (!tokenValidation.IsValid)
+ {
+ var error = string.Join(" ", tokenValidation.Errors.Select(e => e.ErrorMessage));
+ throw new Erc20QueryException(token, error);
+ }
+
+ return tokenResult;
+ }
}
}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/Erc20ServiceFactory.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/Erc20ServiceFactory.cs
new file mode 100644
index 0000000..c1cfa3a
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/Erc20ServiceFactory.cs
@@ -0,0 +1,17 @@
+using Nethereum.Web3;
+using Net.Web3.EthereumWallet;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc
+{
+ ///
+ /// Default implementation of .
+ ///
+ public class Erc20ServiceFactory : IErc20ServiceFactory
+ {
+ ///
+ public IErc20Service Create(IWeb3 web3, EthereumAddress multiCall)
+ {
+ return new Erc20Service(web3, multiCall);
+ }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/Exceptions/Erc20QueryException.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/Exceptions/Erc20QueryException.cs
new file mode 100644
index 0000000..9d38ffa
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/Exceptions/Erc20QueryException.cs
@@ -0,0 +1,28 @@
+using System;
+using Net.Web3.EthereumWallet;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc.Exceptions
+{
+ ///
+ /// Represents errors that occur during ERC20 token queries.
+ ///
+ public sealed class Erc20QueryException : Exception
+ {
+ ///
+ /// Gets the address of the token that caused the error.
+ ///
+ public EthereumAddress Token { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token contract address.
+ /// The error message.
+ /// An optional inner exception.
+ public Erc20QueryException(EthereumAddress token, string message, Exception? inner = null)
+ : base($"[ERC20 {token}] {message}", inner)
+ {
+ Token = token;
+ }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/IERC20Service.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/IERC20Service.cs
index 6ddc223..da771a3 100644
--- a/src/Net.Cache.DynamoDb.ERC20/RPC/IERC20Service.cs
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/IERC20Service.cs
@@ -1,46 +1,19 @@
-using System.Numerics;
+using System.Threading.Tasks;
using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
-namespace Net.Cache.DynamoDb.ERC20.RPC
+namespace Net.Cache.DynamoDb.ERC20.Rpc
{
///
- /// Defines the basic functionalities for interacting with an ERC20 token contract.
+ /// Defines operations for retrieving ERC20 token information via RPC calls.
///
- ///
- /// This interface provides methods for accessing key properties of an ERC20 token,
- /// such as its contract address, decimals, name, symbol, and total supply. Implementations
- /// of this interface should encapsulate the logic necessary to query these properties from the block-chain.
- ///
- public interface IERC20Service
+ public interface IErc20Service
{
///
- /// Gets the contract address of the ERC20 token.
+ /// Retrieves token information asynchronously.
///
- /// The Ethereum address of the ERC20 token contract.
- public EthereumAddress ContractAddress { get; }
-
- ///
- /// Retrieves the number of decimals the ERC20 token uses.
- ///
- /// The number of decimals for the ERC20 token.
- public byte Decimals();
-
- ///
- /// Retrieves the name of the ERC20 token.
- ///
- /// The name of the ERC20 token.
- public string Name();
-
- ///
- /// Retrieves the symbol of the ERC20 token.
- ///
- /// The symbol of the ERC20 token.
- public string Symbol();
-
- ///
- /// Retrieves the total supply of the ERC20 token.
- ///
- /// The total supply of the ERC20 token as a .
- public BigInteger TotalSupply();
+ /// The token contract address.
+ /// A task that resolves to the token data.
+ public Task GetErc20TokenAsync(EthereumAddress token);
}
-}
\ No newline at end of file
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/IErc20ServiceFactory.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/IErc20ServiceFactory.cs
new file mode 100644
index 0000000..15bf0cf
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/IErc20ServiceFactory.cs
@@ -0,0 +1,19 @@
+using Nethereum.Web3;
+using Net.Web3.EthereumWallet;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc
+{
+ ///
+ /// Factory abstraction for creating instances of .
+ ///
+ public interface IErc20ServiceFactory
+ {
+ ///
+ /// Creates an ERC20 service for the specified web3 client and multicall address.
+ ///
+ /// The web3 client used to perform RPC calls.
+ /// The address of the multicall contract.
+ /// A new instance.
+ public IErc20Service Create(IWeb3 web3, EthereumAddress multiCall);
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/Models/Erc20TokenData.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/Models/Erc20TokenData.cs
new file mode 100644
index 0000000..df0ce8b
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/Models/Erc20TokenData.cs
@@ -0,0 +1,53 @@
+using System.Numerics;
+using Net.Web3.EthereumWallet;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc.Models
+{
+ ///
+ /// Represents ERC20 token metadata retrieved from the blockchain.
+ ///
+ public class Erc20TokenData
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token contract address.
+ /// The token name.
+ /// The token symbol.
+ /// The number of decimal places used by the token.
+ /// The total token supply.
+ public Erc20TokenData(EthereumAddress address, string name, string symbol, byte decimals, BigInteger totalSupply)
+ {
+ Address = address;
+ Name = name;
+ Symbol = symbol;
+ Decimals = decimals;
+ TotalSupply = totalSupply;
+ }
+
+ ///
+ /// Gets the token contract address.
+ ///
+ public EthereumAddress Address { get; }
+
+ ///
+ /// Gets the token name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the token symbol.
+ ///
+ public string Symbol { get; }
+
+ ///
+ /// Gets the number of decimal places used by the token.
+ ///
+ public byte Decimals { get; }
+
+ ///
+ /// Gets the total token supply.
+ ///
+ public BigInteger TotalSupply { get; }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/Models/MultiCall.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/Models/MultiCall.cs
new file mode 100644
index 0000000..308fdb8
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/Models/MultiCall.cs
@@ -0,0 +1,34 @@
+using Net.Web3.EthereumWallet;
+using Nethereum.ABI.FunctionEncoding.Attributes;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc.Models
+{
+ ///
+ /// Represents a single call item for the Multicall contract.
+ ///
+ public class MultiCall
+ {
+ ///
+ /// Gets the address of the contract to call.
+ ///
+ [Parameter("address", "to", order: 1)]
+ public string To { get; }
+
+ ///
+ /// Gets the encoded call data.
+ ///
+ [Parameter("bytes", "data", order: 2)]
+ public byte[] Data { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The contract address to call.
+ /// The encoded function data.
+ public MultiCall(EthereumAddress to, byte[] data)
+ {
+ To = to;
+ Data = data;
+ }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/Models/MultiCallFunction.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/Models/MultiCallFunction.cs
new file mode 100644
index 0000000..c3bee62
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/Models/MultiCallFunction.cs
@@ -0,0 +1,34 @@
+using System.Linq;
+using Nethereum.Contracts;
+using System.Collections.Generic;
+using Nethereum.ABI.FunctionEncoding.Attributes;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc.Models
+{
+ ///
+ /// Represents a multicall function message that aggregates multiple calls.
+ ///
+ [Function("multicall", "bytes[]")]
+ public class MultiCallFunction : FunctionMessage
+ {
+ ///
+ /// Gets the collection of calls to execute.
+ ///
+ [Parameter("tuple[]", "calls", order: 1)]
+ public MultiCall[] Calls { get; }
+
+ ///
+ /// Initializes a new instance of the class with the specified calls.
+ ///
+ /// The calls to execute within the multicall.
+ public MultiCallFunction(IEnumerable calls)
+ {
+ Calls = calls.ToArray();
+ }
+
+ ///
+ /// Initializes a new instance of the class with no calls.
+ ///
+ public MultiCallFunction() : this(Enumerable.Empty()) { }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/Validators/Erc20TokenValidator.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/Validators/Erc20TokenValidator.cs
new file mode 100644
index 0000000..2019144
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/Validators/Erc20TokenValidator.cs
@@ -0,0 +1,28 @@
+using System.Numerics;
+using FluentValidation;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc.Validators
+{
+ internal class Erc20TokenValidator : AbstractValidator
+ {
+ public Erc20TokenValidator()
+ {
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .WithMessage("Name is missing.");
+
+ RuleFor(x => x.Symbol)
+ .NotEmpty()
+ .WithMessage("Symbol is missing.");
+
+ RuleFor(x => x.Decimals)
+ .GreaterThanOrEqualTo((byte)0)
+ .WithMessage("Decimals is invalid.");
+
+ RuleFor(x => x.TotalSupply)
+ .GreaterThanOrEqualTo(BigInteger.Zero)
+ .WithMessage("TotalSupply is negative.");
+ }
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/RPC/Validators/MultiCallResponseValidator.cs b/src/Net.Cache.DynamoDb.ERC20/RPC/Validators/MultiCallResponseValidator.cs
new file mode 100644
index 0000000..1dffe85
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/RPC/Validators/MultiCallResponseValidator.cs
@@ -0,0 +1,39 @@
+using FluentValidation;
+using System.Collections.Generic;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc.Validators
+{
+ internal class MultiCallResponseValidator : AbstractValidator>
+ {
+ public MultiCallResponseValidator(int expectedCount)
+ {
+ RuleFor(x => x).Custom((list, context) =>
+ {
+ if (list.Count != expectedCount)
+ {
+ context.AddFailure("MultiCall", "MultiCall returned unexpected number of results.");
+ return;
+ }
+
+ for (var i = 0; i < list.Count; i++)
+ {
+ var data = list[i];
+ if (data == null || data.Length == 0)
+ {
+ var field = GetFieldName(i);
+ context.AddFailure(field, $"{field} call returned no data.");
+ }
+ }
+ });
+ }
+
+ private static string GetFieldName(int index) => index switch
+ {
+ 0 => "Name",
+ 1 => "Symbol",
+ 2 => "Decimals",
+ 3 => "TotalSupply",
+ _ => $"Call[{index}]"
+ };
+ }
+}
diff --git a/src/Net.Cache.DynamoDb.ERC20/Rpc/Extensions/DecoderExtensions.cs b/src/Net.Cache.DynamoDb.ERC20/Rpc/Extensions/DecoderExtensions.cs
new file mode 100644
index 0000000..0be3fb6
--- /dev/null
+++ b/src/Net.Cache.DynamoDb.ERC20/Rpc/Extensions/DecoderExtensions.cs
@@ -0,0 +1,24 @@
+using Nethereum.Contracts;
+using Nethereum.Hex.HexConvertors.Extensions;
+using Nethereum.ABI.FunctionEncoding.Attributes;
+
+namespace Net.Cache.DynamoDb.ERC20.Rpc.Extensions
+{
+ ///
+ /// Provides helper methods for decoding multicall responses.
+ ///
+ public static class DecoderExtensions
+ {
+ ///
+ /// Decodes raw call data into the specified DTO type.
+ ///
+ /// The DTO type to decode into.
+ /// The raw byte data returned by the contract call.
+ /// The decoded DTO instance.
+ public static TFunctionOutputDTO Decode(this byte[] data) where TFunctionOutputDTO : IFunctionOutputDTO, new()
+ {
+ var dto = new TFunctionOutputDTO();
+ return dto.DecodeOutput(data.ToHex());
+ }
+ }
+}
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Api/ApiERC20ServiceTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Api/ApiERC20ServiceTests.cs
deleted file mode 100644
index a4065bd..0000000
--- a/tests/Net.Cache.DynamoDb.ERC20.Tests/Api/ApiERC20ServiceTests.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using Xunit;
-using System.Numerics;
-using FluentAssertions;
-using Flurl.Http.Testing;
-using Newtonsoft.Json.Linq;
-using Net.Web3.EthereumWallet;
-using Net.Cache.DynamoDb.ERC20.Api;
-using Net.Cache.DynamoDb.ERC20.Models;
-using Net.Cache.DynamoDb.ERC20.Models.Api;
-
-namespace Net.Cache.DynamoDb.ERC20.Tests.Api;
-
-public class ApiERC20ServiceTests
-{
- private readonly long _chainId = 1;
- private readonly string _apiKey = "test-api-key";
- private readonly ApiERC20Service _apiErc20Service;
- private readonly string _apiUrl = "https://api.covalenthq.com/v1/{{chainId}}/tokens/{{contractAddress}}/token_holders_v2/?page-size=100&page-number=0&key={{apiKey}}";
- private readonly string _contractAddress;
-
- public ApiERC20ServiceTests()
- {
- _contractAddress = EthereumAddress.ZeroAddress;
- _apiErc20Service = new ApiERC20Service(new ApiERC20ServiceConfig(_apiKey, _chainId, _contractAddress, _apiUrl));
- }
-
- private static JObject CreateMockResponse(byte decimals, string name, string symbol, string totalSupply)
- {
- return new JObject
- {
- ["data"] = new JObject
- {
- ["items"] = new JArray
- {
- new JObject
- {
- ["contract_decimals"] = decimals + 1,
- ["contract_name"] = name + " 2",
- ["contract_ticker_symbol"] = symbol + "2",
- ["total_supply"] = totalSupply
- },
- new JObject
- {
- ["contract_decimals"] = decimals,
- ["contract_name"] = name,
- ["contract_ticker_symbol"] = symbol,
- ["total_supply"] = totalSupply
- }
- }
- }
- };
- }
-
- [Fact]
- public void ApiERC20Service_Methods_ShouldReturnExpectedValues()
- {
- using var httpTest = new HttpTest();
- const byte expectedDecimals = 6;
- const string expectedName = "Test Token";
- const string expectedSymbol = "TTT";
- const string expectedTotalSupply = "1000000";
- var jsonResponse = CreateMockResponse(expectedDecimals, expectedName, expectedSymbol, expectedTotalSupply);
-
- httpTest.RespondWith(jsonResponse.ToString());
-
- var decimals = _apiErc20Service.Decimals();
- var name = _apiErc20Service.Name();
- var symbol = _apiErc20Service.Symbol();
- var totalSupply = _apiErc20Service.TotalSupply();
-
- decimals.Should().Be(expectedDecimals);
- name.Should().Be(expectedName);
- symbol.Should().Be(expectedSymbol);
- totalSupply.Should().Be(BigInteger.Parse(expectedTotalSupply));
- }
-
- [Fact]
- public void ApiRequestFactory_ShouldCreateCorrectlyConfiguredServices()
- {
- var ethereumAddress = (EthereumAddress)_contractAddress;
-
- var apiRequestFactory = new ApiRequestFactory();
- var config = apiRequestFactory.CreateApiServiceConfig(_apiKey, _chainId, ethereumAddress, _apiUrl);
- var apiService = apiRequestFactory.CreateApiService(config);
- var cacheRequest = apiRequestFactory.CreateWithApiService(apiService, _chainId);
-
- cacheRequest.ChainId.Should().Be(_chainId);
- cacheRequest.ERC20Service.Should().NotBeNull();
- cacheRequest.ERC20Service.ContractAddress.Should().Be(ethereumAddress);
- }
-
- [Fact]
- public void GetTokenData_ShouldReturnCorrectData()
- {
- using var httpTest = new HttpTest();
- var expectedJson = CreateMockResponse(18, "Test Token", "TTT", "1000000");
-
- httpTest.RespondWith(expectedJson.ToString());
-
- var tokenData = _apiErc20Service.GetTokenData();
-
- var result = JObject.FromObject(tokenData);
-
- result.Should().BeEquivalentTo(expectedJson);
-
- var expectedUrl = $"https://api.covalenthq.com/v1/1/tokens/0x0000000000000000000000000000000000000000/token_holders_v2/?page-size=100&page-number=0&key=test-api-key";
-
- httpTest.ShouldHaveCalled(expectedUrl)
- .WithVerb(HttpMethod.Get);
- }
-
-
- [Fact]
- public void Constructor_ShouldInitializeFieldsCorrectly()
- {
- var service = new ApiERC20Service(new ApiERC20ServiceConfig(_apiKey, _chainId, _contractAddress, _apiUrl));
-
- service.ContractAddress.Should().Be((EthereumAddress)_contractAddress);
- service.Should().NotBeNull();
- }
-}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/DynamoDbClientTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/DynamoDbClientTests.cs
new file mode 100644
index 0000000..f37b7de
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/DynamoDbClientTests.cs
@@ -0,0 +1,109 @@
+using Moq;
+using Xunit;
+using System.Numerics;
+using FluentAssertions;
+using Amazon.DynamoDBv2;
+using Net.Web3.EthereumWallet;
+using Amazon.DynamoDBv2.DataModel;
+using Net.Cache.DynamoDb.ERC20.DynamoDb;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.DynamoDb;
+
+public class DynamoDbClientTests
+{
+ public class Constructor
+ {
+ [Fact]
+ public void WhenContextIsNull_ShouldThrow()
+ {
+ var act = () => new DynamoDbClient((IDynamoDBContext)null!);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void WhenContextBuilderIsNull_ShouldThrow()
+ {
+ var act = () => new DynamoDbClient((IDynamoDBContextBuilder)null!);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void WhenDynamoDbClientIsNull_ShouldThrow()
+ {
+ var act = () => new DynamoDbClient((IAmazonDynamoDB)null!);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ShouldBuildContextFromBuilder()
+ {
+ Environment.SetEnvironmentVariable("AWS_REGION", "us-east-1");
+
+ var mockBuilder = new Mock();
+ var mockContext = new DynamoDBContext(new AmazonDynamoDBClient());
+ mockBuilder.Setup(b => b.Build()).Returns(mockContext);
+
+ _ = new DynamoDbClient(mockBuilder.Object);
+
+ mockBuilder.Verify(b => b.Build(), Times.Once);
+ }
+
+ [Fact]
+ public void Default()
+ {
+ Environment.SetEnvironmentVariable("AWS_REGION", "us-east-1");
+
+ var dynamoDbClient = new DynamoDbClient();
+
+ dynamoDbClient.Should().NotBeNull();
+ }
+ }
+
+ public class GetErc20TokenAsync
+ {
+ [Fact]
+ public async Task ShouldCallLoadAsyncWithCorrectParameters()
+ {
+ var mockContext = new Mock();
+ var client = new DynamoDbClient(mockContext.Object);
+ var hashKey = new HashKey(1, EthereumAddress.ZeroAddress);
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "TKN", 2, new BigInteger(1000));
+ var expectedEntry = new Erc20TokenDynamoDbEntry(hashKey, token);
+ var config = new LoadConfig();
+ mockContext
+ .Setup(c => c.LoadAsync(hashKey.Value, config, CancellationToken.None))
+ .ReturnsAsync(expectedEntry);
+
+ var result = await client.GetErc20TokenAsync(hashKey, config);
+
+ result.Should().Be(expectedEntry);
+ mockContext.Verify(c => c.LoadAsync(hashKey.Value, config, CancellationToken.None), Times.Once);
+ }
+ }
+
+ public class SaveErc20TokenAsync
+ {
+ [Fact]
+ public async Task ShouldCallSaveAsyncWithCorrectParameters()
+ {
+ var mockContext = new Mock();
+ var client = new DynamoDbClient(mockContext.Object);
+ var hashKey = new HashKey(1, EthereumAddress.ZeroAddress);
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "TKN", 2, new BigInteger(1000));
+ var entry = new Erc20TokenDynamoDbEntry(hashKey, token);
+ var config = new SaveConfig();
+ mockContext
+ .Setup(c => c.SaveAsync(entry, config, CancellationToken.None))
+ .Returns(Task.CompletedTask);
+
+ await client.SaveErc20TokenAsync(entry, config);
+
+ mockContext.Verify(c => c.SaveAsync(entry, config, CancellationToken.None), Times.Once);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/Models/Erc20TokenDynamoDbEntryTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/Models/Erc20TokenDynamoDbEntryTests.cs
new file mode 100644
index 0000000..99fbcad
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/Models/Erc20TokenDynamoDbEntryTests.cs
@@ -0,0 +1,31 @@
+using Xunit;
+using System.Numerics;
+using FluentAssertions;
+using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.DynamoDb.Models
+{
+ public class Erc20TokenDynamoDbEntryTests
+ {
+ [Fact]
+ public void Constructor_ShouldMapFields()
+ {
+ var address = new EthereumAddress("0x0000000000000000000000000000000000000001");
+ var chainId = 1;
+ var hashKey = new HashKey(chainId, address);
+ var token = new Erc20TokenData(address, "Token", "TKN", 2, new BigInteger(1000));
+
+ var entry = new Erc20TokenDynamoDbEntry(hashKey, token);
+
+ entry.HashKey.Should().Be(hashKey.Value);
+ entry.ChainId.Should().Be(chainId);
+ entry.Address.Should().Be(address);
+ entry.Name.Should().Be("Token");
+ entry.Symbol.Should().Be("TKN");
+ entry.Decimals.Should().Be(2);
+ entry.TotalSupply.Should().Be(10m);
+ }
+ }
+}
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/Models/HashKeyTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/Models/HashKeyTests.cs
new file mode 100644
index 0000000..3bf1183
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/DynamoDb/Models/HashKeyTests.cs
@@ -0,0 +1,60 @@
+using Xunit;
+using FluentAssertions;
+using Net.Cryptography.SHA256;
+using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.DynamoDb.Models;
+
+public class HashKeyTests
+{
+ public const long chainId = 1;
+ public const string address = EthereumAddress.ZeroAddress;
+
+ public class Constructor
+ {
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void WhenChainIdIsInvalid_ShouldThrow(long chainId)
+ {
+ var act = () => new HashKey(chainId, address);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void WhenAddressIsNull_ShouldThrow()
+ {
+ var act = () => new HashKey(1, null!);
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void ShouldInitializeProperties()
+ {
+ var expected = $"{chainId}-{address}".ToSha256();
+
+ var key = new HashKey(chainId, address);
+
+ key.ChainId.Should().Be(chainId);
+ key.Address.Should().Be(new EthereumAddress(address));
+ key.Value.Should().Be(expected);
+ key.ToString().Should().Be(expected);
+ }
+ }
+
+ public class Generate
+ {
+ [Fact]
+ public void ShouldReturnSha256Value()
+ {
+ var expected = $"{chainId}-{address}".ToSha256();
+
+ var value = HashKey.Generate(chainId, address);
+
+ value.Should().Be(expected);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/ERC20CacheProviderTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/ERC20CacheProviderTests.cs
deleted file mode 100644
index 31a4c9d..0000000
--- a/tests/Net.Cache.DynamoDb.ERC20.Tests/ERC20CacheProviderTests.cs
+++ /dev/null
@@ -1,146 +0,0 @@
-using Moq;
-using Xunit;
-using System.Numerics;
-using FluentAssertions;
-using Net.Cryptography.SHA256;
-using Amazon.DynamoDBv2.DataModel;
-using Net.Cache.DynamoDb.ERC20.RPC;
-using Net.Cache.DynamoDb.ERC20.Models;
-
-namespace Net.Cache.DynamoDb.ERC20.Tests;
-
-public class ERC20CacheProviderTests
-{
- private const long chainId = 56;
- private const string contractAddress = "0x0000000000000000000000000000000000000000";
- private const byte decimals = 18;
- private const string name = "Test";
- private const string symbol = "TST";
- private const long totalSupply = 5500000;
- private const long updatedTotalSupply = 5000000;
- private readonly string key = $"{chainId}-{contractAddress}".ToSha256();
- private readonly IERC20Service mockErc20Service;
-
- public ERC20CacheProviderTests()
- {
- Environment.SetEnvironmentVariable("AWS_REGION", "us-east-1");
- mockErc20Service = MockERC20Service();
- }
-
- [Fact]
- internal void GetOrAdd_ItemReceivedFromCache_TotalSupplyHasBeenUpdated()
- {
- var erc20StorageProvider = new ERC20StorageProvider(MockContext(true));
- var erc20CacheProvider = new ERC20CacheProvider(erc20StorageProvider);
-
- var addedItem = erc20CacheProvider.GetOrAdd(new GetCacheRequest(chainId, mockErc20Service));
- var updatedItem = erc20CacheProvider.GetOrAdd(new GetCacheRequest(chainId, mockErc20Service));
-
- addedItem.Should().BeEquivalentTo(new ERC20DynamoDbTable(
- chainId, contractAddress, name, symbol, decimals, 0.0000000000055m
- ));
- updatedItem.Should().BeEquivalentTo(new ERC20DynamoDbTable(
- chainId, contractAddress, name, symbol, decimals, 0.0000000000050m
- ));
- }
-
- [Fact]
- internal void GetOrAdd_ItemSavedToCache()
- {
- var erc20StorageProvider = new ERC20StorageProvider(MockContext(false));
- var erc20CacheProvider = new ERC20CacheProvider(erc20StorageProvider);
-
- var addedItem = erc20CacheProvider.GetOrAdd(new GetCacheRequest(chainId, mockErc20Service));
-
- addedItem.Should().BeEquivalentTo(new ERC20DynamoDbTable(
- chainId, contractAddress, name, symbol, decimals, 0.0000000000055m
- ));
- }
-
- [Fact]
- internal async Task GetOrAddAsync_ItemReceivedFromCache_TotalSupplyHasBeenUpdated()
- {
- var erc20StorageProvider = new ERC20StorageProvider(MockContext(true));
- var erc20CacheProvider = new ERC20CacheProvider(erc20StorageProvider);
-
- var addedItem = await erc20CacheProvider.GetOrAddAsync(new GetCacheRequest(chainId, mockErc20Service));
- var updatedItem = await erc20CacheProvider.GetOrAddAsync(new GetCacheRequest(chainId, mockErc20Service));
-
- addedItem.Should().BeEquivalentTo(new ERC20DynamoDbTable(
- chainId, contractAddress, name, symbol, decimals, 0.0000000000055m
- ));
- updatedItem.Should().BeEquivalentTo(new ERC20DynamoDbTable(
- chainId, contractAddress, name, symbol, decimals, 0.0000000000050m
- ));
- }
-
- [Fact]
- internal async Task GetOrAddAsync_ItemSavedToCache()
- {
- var erc20StorageProvider = new ERC20StorageProvider(MockContext(false));
- var erc20CacheProvider = new ERC20CacheProvider(erc20StorageProvider);
-
- var addedItem = await erc20CacheProvider.GetOrAddAsync(new GetCacheRequest(chainId, mockErc20Service));
-
- addedItem.Should().BeEquivalentTo(new ERC20DynamoDbTable(
- chainId, contractAddress, name, symbol, decimals, 0.0000000000055m
- ));
- }
-
- [Fact]
- internal void GetOrAdd_ApiERC20ServiceIntegration_WorksCorrectly()
- {
- const byte expectedDecimals = 6;
- const string expectedName = "USD Coin";
- const string expectedSymbol = "USDC";
- var expectedTotalSupply = new BigInteger(1000000);
-
- var mockApiERC20Service = new Mock();
- mockApiERC20Service.Setup(x => x.ContractAddress).Returns(contractAddress);
- mockApiERC20Service.Setup(x => x.Decimals()).Returns(expectedDecimals);
- mockApiERC20Service.Setup(x => x.Name()).Returns(expectedName);
- mockApiERC20Service.Setup(x => x.Symbol()).Returns(expectedSymbol);
- mockApiERC20Service.Setup(x => x.TotalSupply()).Returns(expectedTotalSupply);
-
- var erc20StorageProvider = new ERC20StorageProvider(MockContext(false));
- var erc20CacheProvider = new ERC20CacheProvider(erc20StorageProvider);
-
- var addedItem = erc20CacheProvider.GetOrAdd(new GetCacheRequest(chainId, mockApiERC20Service.Object));
-
- addedItem.Should().BeEquivalentTo(new ERC20DynamoDbTable(
- chainId, contractAddress, expectedName, expectedSymbol, expectedDecimals,
- Nethereum.Web3.Web3.Convert.FromWei(expectedTotalSupply, expectedDecimals)
- ));
- }
-
- private static IERC20Service MockERC20Service()
- {
- var mock = new Mock();
- mock.Setup(x => x.ContractAddress)
- .Returns(contractAddress);
- mock.Setup(x => x.Decimals())
- .Returns(decimals);
- mock.Setup(x => x.Name())
- .Returns(name);
- mock.Setup(x => x.Symbol())
- .Returns(symbol);
- mock.SetupSequence(x => x.TotalSupply())
- .Returns(totalSupply)
- .Returns(updatedTotalSupply);
-
- return mock.Object;
- }
-
- private IDynamoDBContext MockContext(bool setupLoad)
- {
- var mock = new Mock();
- if (setupLoad)
- {
- var value = new ERC20DynamoDbTable(chainId, contractAddress, name, symbol, decimals, 0.0000000000055m);
- mock.SetupSequence(x => x.LoadAsync(key, It.IsAny(), CancellationToken.None))
- .ReturnsAsync(value)
- .ReturnsAsync(value);
- }
- return mock.Object;
- }
-}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Erc20CacheServiceTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Erc20CacheServiceTests.cs
new file mode 100644
index 0000000..cdba996
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/Erc20CacheServiceTests.cs
@@ -0,0 +1,129 @@
+using Moq;
+using Xunit;
+using System.Numerics;
+using FluentAssertions;
+using System.Reflection;
+using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.Rpc;
+using System.Collections.Concurrent;
+using Net.Cache.DynamoDb.ERC20.DynamoDb;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+using Net.Cache.DynamoDb.ERC20.DynamoDb.Models;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests;
+
+public class Erc20CacheServiceTests
+{
+ public class Constructor
+ {
+ [Fact]
+ public void Default()
+ {
+ Environment.SetEnvironmentVariable("AWS_REGION", "us-east-1");
+
+ var cacheService = new Erc20CacheService();
+
+ cacheService.Should().NotBeNull();
+ }
+ }
+
+ public class GetOrAddAsync
+ {
+ [Fact]
+ public async Task ReturnsEntryFromInMemoryCache_WhenPresent()
+ {
+ var dynamoDbClientMock = new Mock(MockBehavior.Strict);
+ var erc20FactoryMock = new Mock(MockBehavior.Strict);
+ var service = new Erc20CacheService(dynamoDbClientMock.Object, erc20FactoryMock.Object);
+
+ var hashKey = new HashKey(1, EthereumAddress.ZeroAddress);
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "TKN", 18, new BigInteger(1000));
+ var entry = new Erc20TokenDynamoDbEntry(hashKey, token);
+
+ var cacheField = typeof(Erc20CacheService).GetField("_inMemoryCache", BindingFlags.NonPublic | BindingFlags.Instance);
+ var cache = (ConcurrentDictionary)cacheField!.GetValue(service)!;
+ cache.TryAdd(hashKey.Value, entry);
+
+ var rpcUrlFactoryMock = new Mock>>(MockBehavior.Strict);
+ var multiCallFactoryMock = new Mock>>(MockBehavior.Strict);
+
+ var result = await service.GetOrAddAsync(hashKey, rpcUrlFactoryMock.Object, multiCallFactoryMock.Object);
+
+ result.Should().BeSameAs(entry);
+ }
+
+ [Fact]
+ public async Task ReturnsEntryFromDynamoDb_WhenCacheMisses()
+ {
+ var hashKey = new HashKey(1, EthereumAddress.ZeroAddress);
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "TKN", 18, new BigInteger(1000));
+ var entry = new Erc20TokenDynamoDbEntry(hashKey, token);
+
+ var dynamoDbClientMock = new Mock(MockBehavior.Strict);
+ dynamoDbClientMock
+ .Setup(x => x.GetErc20TokenAsync(hashKey, null))
+ .ReturnsAsync(entry);
+
+ var erc20FactoryMock = new Mock(MockBehavior.Strict);
+ var service = new Erc20CacheService(dynamoDbClientMock.Object, erc20FactoryMock.Object);
+
+ var rpcUrlFactoryMock = new Mock>>(MockBehavior.Strict);
+ var multiCallFactoryMock = new Mock>>(MockBehavior.Strict);
+
+ var result = await service.GetOrAddAsync(hashKey, rpcUrlFactoryMock.Object, multiCallFactoryMock.Object);
+
+ result.Should().BeEquivalentTo(entry);
+
+ var cacheField = typeof(Erc20CacheService).GetField("_inMemoryCache", BindingFlags.NonPublic | BindingFlags.Instance);
+ var cache = (ConcurrentDictionary)cacheField!.GetValue(service)!;
+ cache[hashKey.Value].Should().BeEquivalentTo(entry);
+
+ dynamoDbClientMock.Verify(x => x.GetErc20TokenAsync(hashKey, null), Times.Once);
+ }
+
+ [Fact]
+ public async Task FetchesFromRpcAndSaves_WhenNotInCacheOrDb()
+ {
+ var hashKey = new HashKey(1, EthereumAddress.ZeroAddress);
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "TKN", 18, new BigInteger(1000));
+
+ var dynamoDbClientMock = new Mock(MockBehavior.Strict);
+ dynamoDbClientMock
+ .Setup(x => x.GetErc20TokenAsync(hashKey, null))
+ .ReturnsAsync((Erc20TokenDynamoDbEntry?)null);
+ dynamoDbClientMock
+ .Setup(x => x.SaveErc20TokenAsync(It.Is(e => e.HashKey == hashKey.Value), null))
+ .Returns(Task.CompletedTask);
+
+ var erc20ServiceMock = new Mock(MockBehavior.Strict);
+ erc20ServiceMock
+ .Setup(x => x.GetErc20TokenAsync(EthereumAddress.ZeroAddress))
+ .ReturnsAsync(token);
+
+ var erc20FactoryMock = new Mock(MockBehavior.Strict);
+ var multiCall = new EthereumAddress("0x0000000000000000000000000000000000000001");
+ erc20FactoryMock
+ .Setup(x => x.Create(It.IsAny(), multiCall))
+ .Returns(erc20ServiceMock.Object);
+
+ var service = new Erc20CacheService(dynamoDbClientMock.Object, erc20FactoryMock.Object);
+
+ var rpcUrlFactoryMock = new Mock>>();
+ rpcUrlFactoryMock.Setup(f => f()).ReturnsAsync("https://rpc");
+ var multiCallFactoryMock = new Mock>>();
+ multiCallFactoryMock.Setup(f => f()).ReturnsAsync(multiCall);
+
+ var result = await service.GetOrAddAsync(hashKey, rpcUrlFactoryMock.Object, multiCallFactoryMock.Object);
+
+ result.HashKey.Should().Be(hashKey.Value);
+ result.Name.Should().Be("Token");
+
+ dynamoDbClientMock.Verify(x => x.GetErc20TokenAsync(hashKey, null), Times.Once);
+ dynamoDbClientMock.Verify(x => x.SaveErc20TokenAsync(It.IsAny(), null), Times.Once);
+ erc20FactoryMock.Verify(x => x.Create(It.IsAny(), multiCall), Times.Once);
+ erc20ServiceMock.Verify(x => x.GetErc20TokenAsync(EthereumAddress.ZeroAddress), Times.Once);
+ rpcUrlFactoryMock.Verify(f => f(), Times.Once);
+ multiCallFactoryMock.Verify(f => f(), Times.Once);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Models/GetCacheRequestTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Models/GetCacheRequestTests.cs
deleted file mode 100644
index 93574ff..0000000
--- a/tests/Net.Cache.DynamoDb.ERC20.Tests/Models/GetCacheRequestTests.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using Xunit;
-using FluentAssertions;
-using Net.Web3.EthereumWallet;
-using Net.Cache.DynamoDb.ERC20.Models;
-
-namespace Net.Cache.DynamoDb.ERC20.Tests.Models;
-
-public class GetCacheRequestTests
-{
- [Fact]
- public void Constructor_WithRpcUrlFactory_ShouldNotInvokeFactory()
- {
- var called = false;
-
- var request = new GetCacheRequest(1, EthereumAddress.ZeroAddress, () =>
- {
- called = true;
- return "http://localhost";
- }, false);
-
- called.Should().BeFalse();
- request.ChainId.Should().Be(1);
- request.ERC20Service.Should().NotBeNull();
- }
-
- [Fact]
- public void Constructor_WithAsyncRpcUrlFactory_ShouldNotInvokeFactory()
- {
- var called = false;
-
- var request = new GetCacheRequest(1, EthereumAddress.ZeroAddress, () =>
- {
- called = true;
- return Task.FromResult("http://localhost");
- }, false);
-
- called.Should().BeFalse();
- request.ChainId.Should().Be(1);
- request.ERC20Service.Should().NotBeNull();
- }
-}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/RPC/ERC20ServiceTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/RPC/ERC20ServiceTests.cs
deleted file mode 100644
index 7b2f335..0000000
--- a/tests/Net.Cache.DynamoDb.ERC20.Tests/RPC/ERC20ServiceTests.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Moq;
-using Xunit;
-using Nethereum.Web3;
-using FluentAssertions;
-using Net.Web3.EthereumWallet;
-using Net.Cache.DynamoDb.ERC20.RPC;
-using Nethereum.Contracts.Services;
-
-namespace Net.Cache.DynamoDb.ERC20.Tests.RPC;
-
-public class ERC20ServiceTests
-{
- [Fact]
- internal void Ctor()
- {
- var eth = new Mock();
- var erc20 = new Mock(eth.Object).Object;
-
- eth.Setup(x => x.ERC20).Returns(erc20);
-
- var web3 = new Mock();
- web3.Setup(x => x.Eth).Returns(eth.Object);
-
- var contractAddress = EthereumAddress.ZeroAddress;
-
- var erc20Service = new ERC20Service(web3.Object, contractAddress);
-
- erc20Service.Should().NotBeNull();
- }
-}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Erc20ServiceFactoryTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Erc20ServiceFactoryTests.cs
new file mode 100644
index 0000000..f7fd42b
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Erc20ServiceFactoryTests.cs
@@ -0,0 +1,37 @@
+using Moq;
+using Xunit;
+using Nethereum.Web3;
+using FluentAssertions;
+using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.Rpc;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.Rpc;
+
+public class Erc20ServiceFactoryTests
+{
+ public class Create
+ {
+ [Fact]
+ public void ShouldReturnErc20Service()
+ {
+ var web3 = new Mock();
+ var factory = new Erc20ServiceFactory();
+
+ var service = factory.Create(web3.Object, EthereumAddress.ZeroAddress);
+
+ service.Should().BeOfType();
+ }
+
+ [Fact]
+ public void WhenDependencyNull_ShouldThrow()
+ {
+ var factory = new Erc20ServiceFactory();
+
+ var act1 = () => factory.Create(null!, EthereumAddress.ZeroAddress);
+ var act2 = () => factory.Create(new Mock().Object, null!);
+
+ act1.Should().Throw();
+ act2.Should().Throw();
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Erc20ServiceTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Erc20ServiceTests.cs
new file mode 100644
index 0000000..f4f8dff
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Erc20ServiceTests.cs
@@ -0,0 +1,104 @@
+using Moq;
+using Xunit;
+using Nethereum.ABI;
+using Nethereum.Web3;
+using System.Numerics;
+using FluentAssertions;
+using Nethereum.RPC.Eth.DTOs;
+using Net.Web3.EthereumWallet;
+using Nethereum.Contracts.Services;
+using Net.Cache.DynamoDb.ERC20.Rpc;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+using Nethereum.Contracts.ContractHandlers;
+using Net.Cache.DynamoDb.ERC20.Rpc.Exceptions;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.Rpc;
+
+public class Erc20ServiceTests
+{
+ public class Constructor
+ {
+ [Fact]
+ public void WhenWeb3Null_ShouldThrow()
+ {
+ var act = () => new Erc20Service(null!, EthereumAddress.ZeroAddress);
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void WhenMultiCallNull_ShouldThrow()
+ {
+ var web3 = Mock.Of();
+ var act = () => new Erc20Service(web3, null!);
+ act.Should().Throw();
+ }
+ }
+
+ public class GetErc20TokenAsync
+ {
+ [Fact]
+ public async Task WhenTokenNull_ShouldThrow()
+ {
+ var handler = new Mock>();
+ var service = CreateService(handler.Object);
+ var act = async () => await service.GetErc20TokenAsync(null!);
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task WhenResponseValid_ShouldReturnToken()
+ {
+ var response = BuildResponse("Token", "TKN", 18, new BigInteger(1000));
+ var handlerMock = new Mock>();
+ handlerMock
+ .Setup(h => h.QueryAsync>(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(response);
+ var service = CreateService(handlerMock.Object);
+
+ var result = await service.GetErc20TokenAsync(EthereumAddress.ZeroAddress);
+
+ result.Address.Should().Be(new EthereumAddress(EthereumAddress.ZeroAddress));
+ result.Name.Should().Be("Token");
+ result.Symbol.Should().Be("TKN");
+ result.Decimals.Should().Be(18);
+ result.TotalSupply.Should().Be(new BigInteger(1000));
+ }
+
+ [Fact]
+ public async Task WhenTokenInvalid_ShouldThrow()
+ {
+ var response = BuildResponse(string.Empty, "TKN", 18, new BigInteger(1000));
+ var handlerMock = new Mock>();
+ handlerMock
+ .Setup(h => h.QueryAsync>(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(response);
+ var service = CreateService(handlerMock.Object);
+
+ var act = async () => await service.GetErc20TokenAsync(EthereumAddress.ZeroAddress);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*[ERC20*Name is missing.*");
+ }
+ }
+
+ private static Erc20Service CreateService(IContractQueryHandler handler)
+ {
+ var web3Mock = new Mock();
+ var ethMock = new Mock();
+ ethMock.Setup(e => e.GetContractQueryHandler()).Returns(handler);
+ web3Mock.SetupGet(w => w.Eth).Returns(ethMock.Object);
+ return new Erc20Service(web3Mock.Object, EthereumAddress.ZeroAddress);
+ }
+
+ private static List BuildResponse(string name, string symbol, byte decimals, BigInteger supply)
+ {
+ var abiEncode = new ABIEncode();
+ return
+ [
+ abiEncode.GetABIEncoded(new ABIValue("string", name)),
+ abiEncode.GetABIEncoded(new ABIValue("string", symbol)),
+ abiEncode.GetABIEncoded(new ABIValue("uint8", decimals)),
+ abiEncode.GetABIEncoded(new ABIValue("uint256", supply))
+ ];
+ }
+}
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Extensions/DecoderExtensionsTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Extensions/DecoderExtensionsTests.cs
new file mode 100644
index 0000000..6ad7bc6
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Extensions/DecoderExtensionsTests.cs
@@ -0,0 +1,43 @@
+using Xunit;
+using System.Text;
+using System.Numerics;
+using FluentAssertions;
+using Nethereum.Hex.HexConvertors.Extensions;
+using Net.Cache.DynamoDb.ERC20.Rpc.Extensions;
+using Nethereum.Contracts.Standards.ERC20.ContractDefinition;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.Rpc.Extensions
+{
+ public class DecoderExtensionsTests
+ {
+ [Fact]
+ public void ShouldDecodeErc20Outputs()
+ {
+ var nameData = EncodeString("TokenName");
+ var symbolData = EncodeString("TN");
+ var decimalsData = EncodeNumber(18);
+ var supplyData = EncodeNumber(new BigInteger(1000));
+
+ var responses = new[] { nameData, symbolData, decimalsData, supplyData };
+
+ responses[0].Decode().Name.Should().Be("TokenName");
+ responses[1].Decode().Symbol.Should().Be("TN");
+ responses[2].Decode().Decimals.Should().Be(18);
+ responses[3].Decode().TotalSupply.Should().Be(new BigInteger(1000));
+ }
+
+ private static byte[] EncodeString(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ var lengthHex = bytes.Length.ToString("x").PadLeft(64, '0');
+ var dataHex = bytes.ToHex().PadRight(((bytes.Length + 31) / 32) * 64, '0');
+ var hex = "0000000000000000000000000000000000000000000000000000000000000020" + lengthHex + dataHex;
+ return hex.HexToByteArray();
+ }
+
+ private static byte[] EncodeNumber(BigInteger value)
+ {
+ return value.ToString("x").PadLeft(64, '0').HexToByteArray();
+ }
+ }
+}
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Validators/Erc20TokenValidatorTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Validators/Erc20TokenValidatorTests.cs
new file mode 100644
index 0000000..d8ab2d5
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Validators/Erc20TokenValidatorTests.cs
@@ -0,0 +1,59 @@
+using Xunit;
+using System.Numerics;
+using FluentAssertions;
+using Net.Web3.EthereumWallet;
+using Net.Cache.DynamoDb.ERC20.Rpc.Models;
+using Net.Cache.DynamoDb.ERC20.Rpc.Validators;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.Rpc.Validators;
+
+public class Erc20TokenValidatorTests
+{
+ public class Validate
+ {
+ private readonly Erc20TokenValidator validator = new();
+
+ [Fact]
+ public void WhenTokenIsValid_ShouldReturnValid()
+ {
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "TKN", 18, BigInteger.Zero);
+
+ var result = validator.Validate(token);
+
+ result.IsValid.Should().BeTrue();
+ }
+
+ [Fact]
+ public void WhenNameMissing_ShouldReturnError()
+ {
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "", "TKN", 18, BigInteger.Zero);
+
+ var result = validator.Validate(token);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().ContainSingle(e => e.PropertyName == "Name" && e.ErrorMessage == "Name is missing.");
+ }
+
+ [Fact]
+ public void WhenSymbolMissing_ShouldReturnError()
+ {
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "", 18, BigInteger.Zero);
+
+ var result = validator.Validate(token);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().ContainSingle(e => e.PropertyName == "Symbol" && e.ErrorMessage == "Symbol is missing.");
+ }
+
+ [Fact]
+ public void WhenTotalSupplyIsNegative_ShouldReturnError()
+ {
+ var token = new Erc20TokenData(EthereumAddress.ZeroAddress, "Token", "TKN", 18, BigInteger.MinusOne);
+
+ var result = validator.Validate(token);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().ContainSingle(e => e.PropertyName == "TotalSupply" && e.ErrorMessage == "TotalSupply is negative.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Validators/MultiCallResponseValidatorTests.cs b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Validators/MultiCallResponseValidatorTests.cs
new file mode 100644
index 0000000..ae66f25
--- /dev/null
+++ b/tests/Net.Cache.DynamoDb.ERC20.Tests/Rpc/Validators/MultiCallResponseValidatorTests.cs
@@ -0,0 +1,46 @@
+using Xunit;
+using FluentAssertions;
+using Net.Cache.DynamoDb.ERC20.Rpc.Validators;
+
+namespace Net.Cache.DynamoDb.ERC20.Tests.Rpc.Validators;
+
+public class MultiCallResponseValidatorTests
+{
+ public class Validate
+ {
+ [Fact]
+ public void WhenCountIsUnexpected_ShouldReturnError()
+ {
+ var validator = new MultiCallResponseValidator(2);
+ var response = new List { new byte[] { 1 } };
+
+ var result = validator.Validate(response);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().ContainSingle(e => e.PropertyName == "MultiCall" && e.ErrorMessage == "MultiCall returned unexpected number of results.");
+ }
+
+ [Fact]
+ public void WhenCallReturnsNoData_ShouldReturnError()
+ {
+ var validator = new MultiCallResponseValidator(2);
+ var response = new List { new byte[] { 1 }, Array.Empty() };
+
+ var result = validator.Validate(response);
+
+ result.IsValid.Should().BeFalse();
+ result.Errors.Should().ContainSingle(e => e.PropertyName == "Symbol" && e.ErrorMessage == "Symbol call returned no data.");
+ }
+
+ [Fact]
+ public void WhenAllCallsReturnData_ShouldBeValid()
+ {
+ var validator = new MultiCallResponseValidator(2);
+ var response = new List { new byte[] { 1 }, new byte[] { 2 } };
+
+ var result = validator.Validate(response);
+
+ result.IsValid.Should().BeTrue();
+ }
+ }
+}
\ No newline at end of file