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