diff --git a/Directory.Packages.props b/Directory.Packages.props index 3594807..c3068dc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,17 +5,17 @@ - - - - - - - - - + + + + + + + + + - + diff --git a/README.md b/README.md index c5e857d..3c21558 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ A .NET 9 library that provides comprehensive utilities and services for working - **HD Wallet Management**: Generate and restore hierarchical deterministic wallets - **Balance Services**: Query native token and ERC20/ERC721 token balances - **Transaction Services**: Send and track blockchain transactions +- **Transaction Status**: Detect the correct lifecycle state of a transaction (pending, confirmed, reverted, replaced, dropped) - **Gas Estimation**: Estimate gas costs for various transaction types - **Transfer Services**: Simplified token transfer operations (native, ERC20, ERC721) - **Block Services**: Query blockchain block information +- **Network Health**: Check blockchain network and RPC endpoint health before sending transactions - **Web3 Provider**: Easy Web3 instance creation with various configurations ## Prerequisites @@ -272,6 +274,64 @@ var blockService = serviceProvider.GetRequiredService(); var latestBlock = await blockService.GetLastBlockAsync(web3, cancellationToken); ``` +#### Transaction Status Service + +Detect the lifecycle state of a transaction: + +```csharp +using BlockChainTools.Interfaces; +using BlockChainTools.DataTransferObjects; + +var txStatusService = serviceProvider.GetRequiredService(); + +// Check the state of a transaction by hash +var stateInfo = await txStatusService.GetTransactionStateAsync( + web3, + "0xTransactionHash...", + cancellationToken +); + +// stateInfo.State can be: +// TransactionState.Pending – known to node but not mined +// TransactionState.ConfirmedSuccess – receipt exists, status == 1 +// TransactionState.ConfirmedReverted– receipt exists, status == 0 (on-chain revert) +// TransactionState.Replaced – another tx with the same nonce was mined +// TransactionState.StalePending – node no longer knows the tx (likely dropped) + +if (stateInfo.State == TransactionState.ConfirmedSuccess) +{ + Console.WriteLine($"Mined in block {stateInfo.BlockNumber}"); +} +``` + +#### Network Health Service + +Check blockchain network health before sending transactions: + +```csharp +using BlockChainTools.Interfaces; +using Nethereum.Signer; + +var healthService = serviceProvider.GetRequiredService(); + +// Full health check with expected chain and freshness threshold +var health = await healthService.CheckNetworkHealthAsync( + web3, + expectedChain: Chain.Polygon, + maxBlockAgeSeconds: 120, + cancellationToken +); + +if (!health.IsHealthy) +{ + foreach (var issue in health.Issues) + Console.WriteLine($"Issue: {issue}"); +} + +// Quick check without chain ID validation +var quickHealth = await healthService.CheckNetworkHealthAsync(web3); +``` + ## Available Services ### IWeb3ProviderService @@ -308,9 +368,15 @@ var latestBlock = await blockService.GetLastBlockAsync(web3, cancellationToken); ### ITransactionService - `GetTransactionByHashAsync(Web3, string hash)` - Get transaction details +### ITransactionStatusService +- `GetTransactionStateAsync(Web3, string transactionHash)` - Determine the lifecycle state of a transaction (Pending, ConfirmedSuccess, ConfirmedReverted, Replaced, StalePending) + ### IBlockService - `GetLastBlockAsync(Web3)` - Get latest block number +### INetworkHealthService +- `CheckNetworkHealthAsync(Web3, Chain? expectedChain, int maxBlockAgeSeconds)` - Check network health (block freshness, gas price availability, chain ID match) + ## Project Structure ``` @@ -323,7 +389,9 @@ BlockChainTools/ │ │ ├── BlockService.cs │ │ ├── GasService.cs │ │ ├── HdWalletService.cs +│ │ ├── NetworkHealthService.cs │ │ ├── TransactionService.cs +│ │ ├── TransactionStatusService.cs │ │ ├── TransferService.cs │ │ └── Web3ProviderService.cs │ ├── Interfaces/ # Service contracts @@ -334,12 +402,12 @@ BlockChainTools/ ## Dependencies -- **Nethereum.Web3** (5.0.0) - Ethereum Web3 implementation -- **Nethereum.Accounts** (5.0.0) - Account management -- **Nethereum.Signer** (5.0.0) - Transaction signing -- **Nethereum.HdWallet** (5.0.0) - HD wallet support -- **Microsoft.Extensions.DependencyInjection** (9.0.10) - DI support -- **Tricksfor.DistributedNonce** (9.0.0) - Distributed nonce management +- **Nethereum.Web3** (6.0.4) - Ethereum Web3 implementation +- **Nethereum.Accounts** (6.0.4) - Account management +- **Nethereum.Signer** (6.0.4) - Transaction signing +- **Nethereum.HdWallet** (6.0.4) - HD wallet support +- **Microsoft.Extensions.DependencyInjection** (10.0.5) - DI support +- **Tricksfor.DistributedNonce** (10.0.2) - Distributed nonce management ## Testing diff --git a/src/BlockChainTools/Configuration.cs b/src/BlockChainTools/Configuration.cs index f7678cc..0478ecd 100644 --- a/src/BlockChainTools/Configuration.cs +++ b/src/BlockChainTools/Configuration.cs @@ -16,6 +16,8 @@ public static void AddBlockChainTools(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddDistributedNonce(); } diff --git a/src/BlockChainTools/DataTransferObjects/NetworkHealthInfo.cs b/src/BlockChainTools/DataTransferObjects/NetworkHealthInfo.cs new file mode 100644 index 0000000..612f221 --- /dev/null +++ b/src/BlockChainTools/DataTransferObjects/NetworkHealthInfo.cs @@ -0,0 +1,56 @@ +using System.Numerics; + +namespace BlockChainTools.DataTransferObjects; + +/// +/// Contains the results of a blockchain network health check. +/// +public class NetworkHealthInfo +{ + /// + /// Whether the network is considered healthy overall. + /// True when blocks are advancing, timestamps are fresh, gas estimates succeed, and chain ID matches. + /// + public required bool IsHealthy { get; init; } + + /// + /// The latest block number reported by the RPC endpoint. + /// + public BigInteger? LatestBlockNumber { get; init; } + + /// + /// The timestamp of the latest block as a UTC DateTime, if available. + /// + public DateTime? LatestBlockTimestamp { get; init; } + + /// + /// Whether the latest block timestamp is within the acceptable freshness threshold. + /// + public bool IsBlockTimestampFresh { get; init; } + + /// + /// The current gas price returned by the node, if available. + /// + public BigInteger? GasPrice { get; init; } + + /// + /// Whether a gas price was successfully retrieved and is non-zero. + /// + public bool IsGasPriceAvailable { get; init; } + + /// + /// The chain ID returned by the RPC endpoint, if available. + /// + public BigInteger? ChainId { get; init; } + + /// + /// Whether the returned chain ID matches the expected chain ID (when an expected value was provided). + /// + public bool ChainIdMatches { get; init; } + + /// + /// Human-readable summary of any issues detected during the health check. + /// Empty when fully healthy. + /// + public required IReadOnlyList Issues { get; init; } +} diff --git a/src/BlockChainTools/DataTransferObjects/TransactionState.cs b/src/BlockChainTools/DataTransferObjects/TransactionState.cs new file mode 100644 index 0000000..47948a2 --- /dev/null +++ b/src/BlockChainTools/DataTransferObjects/TransactionState.cs @@ -0,0 +1,35 @@ +namespace BlockChainTools.DataTransferObjects; + +/// +/// Represents the lifecycle state of an EVM transaction. +/// +public enum TransactionState +{ + /// + /// The transaction is known to the node (returned by eth_getTransactionByHash) + /// but has not yet been mined (no receipt). + /// + Pending, + + /// + /// A receipt exists and receipt.Status == 1 — the transaction executed successfully on-chain. + /// + ConfirmedSuccess, + + /// + /// A receipt exists and receipt.Status == 0 — the transaction was mined but reverted on-chain. + /// + ConfirmedReverted, + + /// + /// Another transaction with the same nonce from the same sender has been mined, + /// meaning this transaction was replaced or cancelled. + /// + Replaced, + + /// + /// No receipt exists and the node no longer returns the transaction, + /// indicating it was likely dropped from the mempool. + /// + StalePending +} diff --git a/src/BlockChainTools/DataTransferObjects/TransactionStateInfo.cs b/src/BlockChainTools/DataTransferObjects/TransactionStateInfo.cs new file mode 100644 index 0000000..2fb8a65 --- /dev/null +++ b/src/BlockChainTools/DataTransferObjects/TransactionStateInfo.cs @@ -0,0 +1,39 @@ +using System.Numerics; + +namespace BlockChainTools.DataTransferObjects; + +/// +/// Contains the resolved state of a transaction along with supporting metadata. +/// +public class TransactionStateInfo +{ + /// + /// The resolved lifecycle state of the transaction. + /// + public required TransactionState State { get; init; } + + /// + /// The transaction hash that was queried. + /// + public required string TransactionHash { get; init; } + + /// + /// The receipt status value (1 = success, 0 = revert) when a receipt exists; otherwise null. + /// + public BigInteger? ReceiptStatus { get; init; } + + /// + /// The block number in which the transaction was mined, if available. + /// + public BigInteger? BlockNumber { get; init; } + + /// + /// The nonce of the transaction, if the transaction data was available from the node. + /// + public BigInteger? Nonce { get; init; } + + /// + /// The confirmed on-chain nonce for the sender address, if checked. + /// + public BigInteger? ConfirmedNonce { get; init; } +} diff --git a/src/BlockChainTools/Interfaces/INetworkHealthService.cs b/src/BlockChainTools/Interfaces/INetworkHealthService.cs new file mode 100644 index 0000000..2cb2cee --- /dev/null +++ b/src/BlockChainTools/Interfaces/INetworkHealthService.cs @@ -0,0 +1,25 @@ +using BlockChainTools.DataTransferObjects; +using Nethereum.Signer; +using Nethereum.Web3; + +namespace BlockChainTools.Interfaces; + +public interface INetworkHealthService +{ + /// + /// Performs a health check on the blockchain network via the given Web3 endpoint. + /// Verifies that blocks are being produced, timestamps are fresh, gas price is available, + /// and the chain ID matches expectations. + /// + /// The Web3 instance to query against. + /// + /// Optional expected chain. When provided, the health check verifies the endpoint returns the correct chain ID. + /// + /// + /// Maximum acceptable age (in seconds) of the latest block timestamp. + /// Defaults to 120 seconds. If the latest block is older than this, the network is considered unhealthy. + /// + /// Cancellation token. + /// A describing the health status of the network. + Task CheckNetworkHealthAsync(Web3 web3, Chain? expectedChain = null, int maxBlockAgeSeconds = 120, CancellationToken cancellationToken = default); +} diff --git a/src/BlockChainTools/Interfaces/ITransactionStatusService.cs b/src/BlockChainTools/Interfaces/ITransactionStatusService.cs new file mode 100644 index 0000000..917ba37 --- /dev/null +++ b/src/BlockChainTools/Interfaces/ITransactionStatusService.cs @@ -0,0 +1,19 @@ +using BlockChainTools.DataTransferObjects; +using Nethereum.Web3; + +namespace BlockChainTools.Interfaces; + +public interface ITransactionStatusService +{ + /// + /// Determines the current lifecycle state of a transaction by checking its receipt and mempool presence. + /// Uses receipt status as the canonical success/failure signal when a receipt exists. + /// When no receipt is found, checks whether the transaction is still known to the node + /// and whether the sender's on-chain nonce has advanced past the transaction nonce (indicating replacement). + /// + /// The Web3 instance to query against. + /// The hash of the transaction to check. + /// Cancellation token. + /// A describing the current state and metadata of the transaction. + Task GetTransactionStateAsync(Web3 web3, string transactionHash, CancellationToken cancellationToken = default); +} diff --git a/src/BlockChainTools/README.md b/src/BlockChainTools/README.md index d69d37f..b6aa5fd 100644 --- a/src/BlockChainTools/README.md +++ b/src/BlockChainTools/README.md @@ -9,6 +9,8 @@ A .NET 9 library built on Nethereum that provides ready-to-use services for comm - **Gas Estimation** - Estimate gas costs for various transaction types - **Transfer Services** - Simplified transfers for native tokens, ERC20, and ERC721 (NFTs) - **Transaction & Block Services** - Query blockchain data +- **Transaction Status** - Detect lifecycle state of transactions (pending, confirmed, reverted, replaced, dropped) +- **Network Health** - Check blockchain network and RPC endpoint health - **Web3 Provider** - Easy Web3 instance creation with distributed nonce support ## Installation @@ -87,7 +89,9 @@ var txHash = await transferService.TransferAsync(web3Tx, txInput); - **ITransferService** - Transfer native tokens, ERC20, and ERC721 (NFTs) - **IHdWalletService** - Generate/restore HD wallets and derive accounts - **ITransactionService** - Query transaction details +- **ITransactionStatusService** - Detect transaction lifecycle state (pending, confirmed, reverted, replaced, dropped) - **IBlockService** - Query blockchain block information +- **INetworkHealthService** - Check network health (block freshness, gas price, chain ID) ## Distributed Nonce Management diff --git a/src/BlockChainTools/Services/NetworkHealthService.cs b/src/BlockChainTools/Services/NetworkHealthService.cs new file mode 100644 index 0000000..f132060 --- /dev/null +++ b/src/BlockChainTools/Services/NetworkHealthService.cs @@ -0,0 +1,112 @@ +using System.Numerics; +using BlockChainTools.DataTransferObjects; +using BlockChainTools.Interfaces; +using Nethereum.Hex.HexTypes; +using Nethereum.Signer; +using Nethereum.Web3; + +namespace BlockChainTools.Services; + +public class NetworkHealthService : INetworkHealthService +{ + public async Task CheckNetworkHealthAsync(Web3 web3, Chain? expectedChain = null, int maxBlockAgeSeconds = 120, CancellationToken cancellationToken = default) + { + var issues = new List(); + BigInteger? latestBlockNumber = null; + DateTime? latestBlockTimestamp = null; + bool isBlockTimestampFresh = false; + BigInteger? gasPrice = null; + bool isGasPriceAvailable = false; + BigInteger? chainId = null; + bool chainIdMatches = true; + + // 1. Check latest block number and timestamp + try + { + var block = await web3.Eth.Blocks.GetBlockWithTransactionsHashesByNumber.SendRequestAsync( + Nethereum.RPC.Eth.DTOs.BlockParameter.CreateLatest()); + + if (block is not null) + { + latestBlockNumber = block.Number?.Value; + (latestBlockTimestamp, isBlockTimestampFresh) = EvaluateBlockFreshness(block.Timestamp, maxBlockAgeSeconds); + + if (latestBlockTimestamp is null) + issues.Add("Latest block has no timestamp."); + else if (!isBlockTimestampFresh) + issues.Add($"Latest block timestamp is {(DateTime.UtcNow - latestBlockTimestamp.Value).TotalSeconds:F0}s old, exceeding the {maxBlockAgeSeconds}s threshold."); + } + else + { + issues.Add("Unable to retrieve the latest block."); + } + } + catch (Exception ex) + { + issues.Add($"Failed to retrieve latest block: {ex.Message}"); + } + + // 2. Check gas price + try + { + var gasPriceResult = await web3.Eth.GasPrice.SendRequestAsync(); + gasPrice = gasPriceResult?.Value; + isGasPriceAvailable = gasPrice is not null && gasPrice > 0; + + if (!isGasPriceAvailable) + { + issues.Add("Gas price is unavailable or zero."); + } + } + catch (Exception ex) + { + issues.Add($"Failed to retrieve gas price: {ex.Message}"); + } + + // 3. Check chain ID + try + { + var chainIdResult = await web3.Eth.ChainId.SendRequestAsync(); + chainId = chainIdResult?.Value; + + if (expectedChain is not null) + { + chainIdMatches = chainId == (int)expectedChain; + if (!chainIdMatches) + { + issues.Add($"Chain ID mismatch: expected {(int)expectedChain}, got {chainId}."); + } + } + } + catch (Exception ex) + { + chainIdMatches = false; + issues.Add($"Failed to retrieve chain ID: {ex.Message}"); + } + + var isHealthy = issues.Count == 0; + + return new NetworkHealthInfo + { + IsHealthy = isHealthy, + LatestBlockNumber = latestBlockNumber, + LatestBlockTimestamp = latestBlockTimestamp, + IsBlockTimestampFresh = isBlockTimestampFresh, + GasPrice = gasPrice, + IsGasPriceAvailable = isGasPriceAvailable, + ChainId = chainId, + ChainIdMatches = chainIdMatches, + Issues = issues.AsReadOnly() + }; + } + + private static (DateTime? Timestamp, bool IsFresh) EvaluateBlockFreshness(HexBigInteger? blockTimestamp, int maxBlockAgeSeconds) + { + if (blockTimestamp?.Value is null) + return (null, false); + + var timestamp = DateTimeOffset.FromUnixTimeSeconds((long)blockTimestamp.Value).UtcDateTime; + var isFresh = (DateTime.UtcNow - timestamp).TotalSeconds <= maxBlockAgeSeconds; + return (timestamp, isFresh); + } +} diff --git a/src/BlockChainTools/Services/TransactionStatusService.cs b/src/BlockChainTools/Services/TransactionStatusService.cs new file mode 100644 index 0000000..61596f7 --- /dev/null +++ b/src/BlockChainTools/Services/TransactionStatusService.cs @@ -0,0 +1,81 @@ +using BlockChainTools.DataTransferObjects; +using BlockChainTools.Interfaces; +using Nethereum.RPC.Eth.DTOs; +using Nethereum.Web3; + +namespace BlockChainTools.Services; + +public class TransactionStatusService : ITransactionStatusService +{ + public async Task GetTransactionStateAsync(Web3 web3, string transactionHash, CancellationToken cancellationToken = default) + { + // 1. Try to get the receipt — this is the canonical success/failure signal + TransactionReceipt? receipt = await web3.Eth.Transactions.GetTransactionReceipt.SendRequestAsync(transactionHash); + + if (receipt is not null) + { + var succeeded = receipt.Status?.Value == 1; + return new TransactionStateInfo + { + State = succeeded ? TransactionState.ConfirmedSuccess : TransactionState.ConfirmedReverted, + TransactionHash = transactionHash, + ReceiptStatus = receipt.Status?.Value, + BlockNumber = receipt.BlockNumber?.Value, + Nonce = null, + ConfirmedNonce = null + }; + } + + // 2. No receipt — check if the node still knows about the transaction + var transaction = await web3.Eth.Transactions.GetTransactionByHash.SendRequestAsync(transactionHash); + + if (transaction is not null) + { + // Transaction exists in the node but is not yet mined — check for replacement + var senderAddress = transaction.From; + var txNonce = transaction.Nonce?.Value; + + if (senderAddress is not null && txNonce is not null) + { + // Get the confirmed (mined) nonce for the sender + var confirmedNonce = await web3.Eth.Transactions.GetTransactionCount.SendRequestAsync( + senderAddress, BlockParameter.CreateLatest()); + + if (confirmedNonce?.Value > txNonce) + { + // On-chain nonce has advanced past this tx's nonce — it was replaced + return new TransactionStateInfo + { + State = TransactionState.Replaced, + TransactionHash = transactionHash, + ReceiptStatus = null, + BlockNumber = null, + Nonce = txNonce, + ConfirmedNonce = confirmedNonce.Value + }; + } + } + + return new TransactionStateInfo + { + State = TransactionState.Pending, + TransactionHash = transactionHash, + ReceiptStatus = null, + BlockNumber = null, + Nonce = txNonce, + ConfirmedNonce = null + }; + } + + // 3. Node does not know the transaction and no receipt — likely dropped + return new TransactionStateInfo + { + State = TransactionState.StalePending, + TransactionHash = transactionHash, + ReceiptStatus = null, + BlockNumber = null, + Nonce = null, + ConfirmedNonce = null + }; + } +} diff --git a/tests/BlockChainTools.Tests/NetworkHealthServiceTests.cs b/tests/BlockChainTools.Tests/NetworkHealthServiceTests.cs new file mode 100644 index 0000000..dace814 --- /dev/null +++ b/tests/BlockChainTools.Tests/NetworkHealthServiceTests.cs @@ -0,0 +1,156 @@ +using System.Numerics; +using BlockChainTools.DataTransferObjects; +using BlockChainTools.Interfaces; +using Nethereum.Signer; +using Nethereum.Web3; +using Nethereum.JsonRpc.Client; +using NUnit.Framework; +using NSubstitute; + +namespace BlockChainTools.Tests; + +[TestFixture] +public class NetworkHealthServiceTests +{ + private INetworkHealthService? _service; + private Web3? _web3; + + [SetUp] + public void SetUp() + { + _service = Substitute.For(); + _web3 = new Web3(Substitute.For()); + } + + [Test] + public async Task CheckNetworkHealthAsync_Healthy_ReturnsHealthy() + { + var expected = new NetworkHealthInfo + { + IsHealthy = true, + LatestBlockNumber = new BigInteger(50000), + LatestBlockTimestamp = DateTime.UtcNow, + IsBlockTimestampFresh = true, + GasPrice = new BigInteger(30000000000), + IsGasPriceAvailable = true, + ChainId = new BigInteger(137), + ChainIdMatches = true, + Issues = [] + }; + _service!.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120, Arg.Any()).Returns(expected); + var result = await _service.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120); + Assert.That(result.IsHealthy, Is.True); + Assert.That(result.IsBlockTimestampFresh, Is.True); + Assert.That(result.IsGasPriceAvailable, Is.True); + Assert.That(result.ChainIdMatches, Is.True); + Assert.That(result.Issues, Is.Empty); + } + + [Test] + public async Task CheckNetworkHealthAsync_StaleBlock_ReturnsUnhealthy() + { + var expected = new NetworkHealthInfo + { + IsHealthy = false, + LatestBlockNumber = new BigInteger(50000), + LatestBlockTimestamp = DateTime.UtcNow.AddMinutes(-10), + IsBlockTimestampFresh = false, + GasPrice = new BigInteger(30000000000), + IsGasPriceAvailable = true, + ChainId = new BigInteger(137), + ChainIdMatches = true, + Issues = ["Latest block timestamp is 600s old, exceeding the 120s threshold."] + }; + _service!.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120, Arg.Any()).Returns(expected); + var result = await _service.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120); + Assert.That(result.IsHealthy, Is.False); + Assert.That(result.IsBlockTimestampFresh, Is.False); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + } + + [Test] + public async Task CheckNetworkHealthAsync_ChainIdMismatch_ReturnsUnhealthy() + { + var expected = new NetworkHealthInfo + { + IsHealthy = false, + LatestBlockNumber = new BigInteger(50000), + LatestBlockTimestamp = DateTime.UtcNow, + IsBlockTimestampFresh = true, + GasPrice = new BigInteger(30000000000), + IsGasPriceAvailable = true, + ChainId = new BigInteger(1), + ChainIdMatches = false, + Issues = ["Chain ID mismatch: expected 137, got 1."] + }; + _service!.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120, Arg.Any()).Returns(expected); + var result = await _service.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120); + Assert.That(result.IsHealthy, Is.False); + Assert.That(result.ChainIdMatches, Is.False); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + } + + [Test] + public async Task CheckNetworkHealthAsync_NoGasPrice_ReturnsUnhealthy() + { + var expected = new NetworkHealthInfo + { + IsHealthy = false, + LatestBlockNumber = new BigInteger(50000), + LatestBlockTimestamp = DateTime.UtcNow, + IsBlockTimestampFresh = true, + GasPrice = null, + IsGasPriceAvailable = false, + ChainId = new BigInteger(137), + ChainIdMatches = true, + Issues = ["Gas price is unavailable or zero."] + }; + _service!.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120, Arg.Any()).Returns(expected); + var result = await _service.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120); + Assert.That(result.IsHealthy, Is.False); + Assert.That(result.IsGasPriceAvailable, Is.False); + Assert.That(result.Issues, Has.Count.EqualTo(1)); + } + + [Test] + public async Task CheckNetworkHealthAsync_WithoutExpectedChainId_SkipsChainIdCheck() + { + var expected = new NetworkHealthInfo + { + IsHealthy = true, + LatestBlockNumber = new BigInteger(50000), + LatestBlockTimestamp = DateTime.UtcNow, + IsBlockTimestampFresh = true, + GasPrice = new BigInteger(30000000000), + IsGasPriceAvailable = true, + ChainId = new BigInteger(137), + ChainIdMatches = true, + Issues = [] + }; + _service!.CheckNetworkHealthAsync(_web3!, null, 120, Arg.Any()).Returns(expected); + var result = await _service.CheckNetworkHealthAsync(_web3!, null, 120); + Assert.That(result.IsHealthy, Is.True); + Assert.That(result.ChainIdMatches, Is.True); + } + + [Test] + public async Task CheckNetworkHealthAsync_MultipleIssues_ReportsAll() + { + var expected = new NetworkHealthInfo + { + IsHealthy = false, + LatestBlockNumber = null, + LatestBlockTimestamp = null, + IsBlockTimestampFresh = false, + GasPrice = null, + IsGasPriceAvailable = false, + ChainId = null, + ChainIdMatches = false, + Issues = ["Unable to retrieve the latest block.", "Gas price is unavailable or zero.", "Failed to retrieve chain ID: timeout"] + }; + _service!.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120, Arg.Any()).Returns(expected); + var result = await _service.CheckNetworkHealthAsync(_web3!, Chain.Polygon, 120); + Assert.That(result.IsHealthy, Is.False); + Assert.That(result.Issues, Has.Count.EqualTo(3)); + } +} diff --git a/tests/BlockChainTools.Tests/TransactionStatusServiceTests.cs b/tests/BlockChainTools.Tests/TransactionStatusServiceTests.cs new file mode 100644 index 0000000..d0f33f2 --- /dev/null +++ b/tests/BlockChainTools.Tests/TransactionStatusServiceTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using BlockChainTools.DataTransferObjects; +using BlockChainTools.Interfaces; +using Nethereum.Web3; +using Nethereum.JsonRpc.Client; +using NUnit.Framework; +using NSubstitute; + +namespace BlockChainTools.Tests; + +[TestFixture] +public class TransactionStatusServiceTests +{ + private ITransactionStatusService? _service; + private Web3? _web3; + + [SetUp] + public void SetUp() + { + _service = Substitute.For(); + _web3 = new Web3(Substitute.For()); + } + + [Test] + public async Task GetTransactionStateAsync_ConfirmedSuccess_ReturnsCorrectState() + { + var expected = new TransactionStateInfo + { + State = TransactionState.ConfirmedSuccess, + TransactionHash = "0xabc", + ReceiptStatus = BigInteger.One, + BlockNumber = new BigInteger(100) + }; + _service!.GetTransactionStateAsync(_web3!, "0xabc", Arg.Any()).Returns(expected); + var result = await _service.GetTransactionStateAsync(_web3!, "0xabc"); + Assert.That(result.State, Is.EqualTo(TransactionState.ConfirmedSuccess)); + Assert.That(result.ReceiptStatus, Is.EqualTo(BigInteger.One)); + Assert.That(result.BlockNumber, Is.EqualTo(new BigInteger(100))); + Assert.That(result.TransactionHash, Is.EqualTo("0xabc")); + } + + [Test] + public async Task GetTransactionStateAsync_ConfirmedReverted_ReturnsCorrectState() + { + var expected = new TransactionStateInfo + { + State = TransactionState.ConfirmedReverted, + TransactionHash = "0xdef", + ReceiptStatus = BigInteger.Zero, + BlockNumber = new BigInteger(200) + }; + _service!.GetTransactionStateAsync(_web3!, "0xdef", Arg.Any()).Returns(expected); + var result = await _service.GetTransactionStateAsync(_web3!, "0xdef"); + Assert.That(result.State, Is.EqualTo(TransactionState.ConfirmedReverted)); + Assert.That(result.ReceiptStatus, Is.EqualTo(BigInteger.Zero)); + } + + [Test] + public async Task GetTransactionStateAsync_Pending_ReturnsCorrectState() + { + var expected = new TransactionStateInfo + { + State = TransactionState.Pending, + TransactionHash = "0xpending", + Nonce = new BigInteger(5) + }; + _service!.GetTransactionStateAsync(_web3!, "0xpending", Arg.Any()).Returns(expected); + var result = await _service.GetTransactionStateAsync(_web3!, "0xpending"); + Assert.That(result.State, Is.EqualTo(TransactionState.Pending)); + Assert.That(result.ReceiptStatus, Is.Null); + Assert.That(result.BlockNumber, Is.Null); + Assert.That(result.Nonce, Is.EqualTo(new BigInteger(5))); + } + + [Test] + public async Task GetTransactionStateAsync_Replaced_ReturnsCorrectState() + { + var expected = new TransactionStateInfo + { + State = TransactionState.Replaced, + TransactionHash = "0xreplaced", + Nonce = new BigInteger(3), + ConfirmedNonce = new BigInteger(5) + }; + _service!.GetTransactionStateAsync(_web3!, "0xreplaced", Arg.Any()).Returns(expected); + var result = await _service.GetTransactionStateAsync(_web3!, "0xreplaced"); + Assert.That(result.State, Is.EqualTo(TransactionState.Replaced)); + Assert.That(result.Nonce, Is.Not.Null); + Assert.That(result.ConfirmedNonce, Is.Not.Null); + Assert.That(result.ConfirmedNonce!.Value, Is.GreaterThan(result.Nonce!.Value)); + } + + [Test] + public async Task GetTransactionStateAsync_StalePending_ReturnsCorrectState() + { + var expected = new TransactionStateInfo + { + State = TransactionState.StalePending, + TransactionHash = "0xstale" + }; + _service!.GetTransactionStateAsync(_web3!, "0xstale", Arg.Any()).Returns(expected); + var result = await _service.GetTransactionStateAsync(_web3!, "0xstale"); + Assert.That(result.State, Is.EqualTo(TransactionState.StalePending)); + Assert.That(result.ReceiptStatus, Is.Null); + Assert.That(result.BlockNumber, Is.Null); + Assert.That(result.Nonce, Is.Null); + Assert.That(result.ConfirmedNonce, Is.Null); + } + +}