diff --git a/README.md b/README.md index 3c21558..bc8ea3e 100644 --- a/README.md +++ b/README.md @@ -295,15 +295,54 @@ var stateInfo = await txStatusService.GetTransactionStateAsync( // 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.Replaced – another tx with the same nonce was mined first // TransactionState.StalePending – node no longer knows the tx (likely dropped) -if (stateInfo.State == TransactionState.ConfirmedSuccess) +switch (stateInfo.State) { - Console.WriteLine($"Mined in block {stateInfo.BlockNumber}"); + case TransactionState.ConfirmedSuccess: + case TransactionState.ConfirmedReverted: + Console.WriteLine($"Mined in block {stateInfo.BlockNumber}"); + Console.WriteLine($"Receipt status: {stateInfo.ReceiptStatus}"); // 1 or 0 + Console.WriteLine($"Gas used: {stateInfo.Receipt!.GasUsed}"); // full receipt available + Console.WriteLine($"Nonce: {stateInfo.Nonce}, Value: {stateInfo.Value}, Gas limit: {stateInfo.Gas}"); + break; + + case TransactionState.Replaced: + // The sender's confirmed nonce has already passed this tx's nonce + Console.WriteLine($"Tx nonce {stateInfo.Nonce} was replaced; sender's confirmed nonce is now {stateInfo.ConfirmedNonce}"); + Console.WriteLine($"Original value: {stateInfo.Value}, gas limit: {stateInfo.Gas}"); + break; + + case TransactionState.Pending: + Console.WriteLine($"Still pending – nonce {stateInfo.Nonce}, value {stateInfo.Value}, gas limit {stateInfo.Gas}"); + break; + + case TransactionState.StalePending: + Console.WriteLine("Transaction dropped from the mempool"); + break; } ``` +##### `TransactionStateInfo` property population by state + +| Property | ConfirmedSuccess / ConfirmedReverted | Pending | Replaced | StalePending | +|---|---|---|---|---| +| `State` | ✅ | ✅ | ✅ | ✅ | +| `TransactionHash` | ✅ | ✅ | ✅ | ✅ | +| `Receipt` | ✅ | — | — | — | +| `ReceiptStatus` | ✅ | — | — | — | +| `BlockNumber` | ✅ | — | — | — | +| `Nonce` | ✅ | ✅ | ✅ | — | +| `Value` | ✅ | ✅ | ✅ | — | +| `Gas` | ✅ | ✅ | ✅ | — | +| `ConfirmedNonce` | — | — | ✅ | — | + +`Receipt`, `ReceiptStatus`, and `BlockNumber` are only available when a receipt exists (confirmed states). +`Nonce`, `Value`, and `Gas` are populated whenever the node still holds the transaction object. +`ConfirmedNonce` is exclusively set for the `Replaced` state — it holds the sender's current on-chain nonce at the point replacement was detected. +All properties are `null` for states where the data source is unavailable. + #### Network Health Service Check blockchain network health before sending transactions: diff --git a/src/BlockChainTools/DataTransferObjects/TransactionStateInfo.cs b/src/BlockChainTools/DataTransferObjects/TransactionStateInfo.cs index 2fb8a65..444bb85 100644 --- a/src/BlockChainTools/DataTransferObjects/TransactionStateInfo.cs +++ b/src/BlockChainTools/DataTransferObjects/TransactionStateInfo.cs @@ -1,4 +1,5 @@ using System.Numerics; +using Nethereum.RPC.Eth.DTOs; namespace BlockChainTools.DataTransferObjects; @@ -17,6 +18,12 @@ public class TransactionStateInfo /// public required string TransactionHash { get; init; } + /// + /// The full transaction receipt when a receipt exists (ConfirmedSuccess or ConfirmedReverted states); otherwise null. + /// Contains canonical on-chain data such as gas used, logs, contract address, and effective gas price. + /// + public TransactionReceipt? Receipt { get; init; } + /// /// The receipt status value (1 = success, 0 = revert) when a receipt exists; otherwise null. /// @@ -29,11 +36,34 @@ public class TransactionStateInfo /// /// The nonce of the transaction, if the transaction data was available from the node. + /// The nonce is not included in the transaction receipt and is sourced from the transaction object. /// public BigInteger? Nonce { get; init; } /// - /// The confirmed on-chain nonce for the sender address, if checked. + /// The confirmed on-chain nonce for the sender address at the time the replacement was detected. + /// Only populated when is ; + /// null for all other states. /// public BigInteger? ConfirmedNonce { get; init; } + + /// + /// The amount of native currency (in wei) sent with the transaction. + /// Populated whenever the transaction data is available from the node: + /// , , + /// , and . + /// Null for (node no longer holds the transaction). + /// This property is not included in the transaction receipt and is sourced from the transaction object. + /// + public BigInteger? Value { get; init; } + + /// + /// The gas limit provided by the sender. + /// Populated whenever the transaction data is available from the node: + /// , , + /// , and . + /// Null for (node no longer holds the transaction). + /// This property is not included in the transaction receipt and is sourced from the transaction object. + /// + public BigInteger? Gas { get; init; } } diff --git a/src/BlockChainTools/Services/TransactionStatusService.cs b/src/BlockChainTools/Services/TransactionStatusService.cs index 61596f7..00207e3 100644 --- a/src/BlockChainTools/Services/TransactionStatusService.cs +++ b/src/BlockChainTools/Services/TransactionStatusService.cs @@ -10,23 +10,32 @@ 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 + cancellationToken.ThrowIfCancellationRequested(); TransactionReceipt? receipt = await web3.Eth.Transactions.GetTransactionReceipt.SendRequestAsync(transactionHash); if (receipt is not null) { var succeeded = receipt.Status?.Value == 1; + + // Fetch the transaction to populate fields not included in the receipt (Nonce, Value, Gas) + cancellationToken.ThrowIfCancellationRequested(); + var confirmedTx = await web3.Eth.Transactions.GetTransactionByHash.SendRequestAsync(transactionHash); + return new TransactionStateInfo { State = succeeded ? TransactionState.ConfirmedSuccess : TransactionState.ConfirmedReverted, TransactionHash = transactionHash, + Receipt = receipt, ReceiptStatus = receipt.Status?.Value, BlockNumber = receipt.BlockNumber?.Value, - Nonce = null, - ConfirmedNonce = null + Nonce = confirmedTx?.Nonce?.Value, + Value = confirmedTx?.Value?.Value, + Gas = confirmedTx?.Gas?.Value }; } // 2. No receipt — check if the node still knows about the transaction + cancellationToken.ThrowIfCancellationRequested(); var transaction = await web3.Eth.Transactions.GetTransactionByHash.SendRequestAsync(transactionHash); if (transaction is not null) @@ -38,6 +47,7 @@ public async Task GetTransactionStateAsync(Web3 web3, stri if (senderAddress is not null && txNonce is not null) { // Get the confirmed (mined) nonce for the sender + cancellationToken.ThrowIfCancellationRequested(); var confirmedNonce = await web3.Eth.Transactions.GetTransactionCount.SendRequestAsync( senderAddress, BlockParameter.CreateLatest()); @@ -48,9 +58,9 @@ public async Task GetTransactionStateAsync(Web3 web3, stri { State = TransactionState.Replaced, TransactionHash = transactionHash, - ReceiptStatus = null, - BlockNumber = null, Nonce = txNonce, + Value = transaction.Value?.Value, + Gas = transaction.Gas?.Value, ConfirmedNonce = confirmedNonce.Value }; } @@ -60,10 +70,9 @@ public async Task GetTransactionStateAsync(Web3 web3, stri { State = TransactionState.Pending, TransactionHash = transactionHash, - ReceiptStatus = null, - BlockNumber = null, Nonce = txNonce, - ConfirmedNonce = null + Value = transaction.Value?.Value, + Gas = transaction.Gas?.Value }; } @@ -71,11 +80,7 @@ public async Task GetTransactionStateAsync(Web3 web3, stri return new TransactionStateInfo { State = TransactionState.StalePending, - TransactionHash = transactionHash, - ReceiptStatus = null, - BlockNumber = null, - Nonce = null, - ConfirmedNonce = null + TransactionHash = transactionHash }; } } diff --git a/tests/BlockChainTools.Tests/TransactionStatusServiceTests.cs b/tests/BlockChainTools.Tests/TransactionStatusServiceTests.cs index d0f33f2..c106fee 100644 --- a/tests/BlockChainTools.Tests/TransactionStatusServiceTests.cs +++ b/tests/BlockChainTools.Tests/TransactionStatusServiceTests.cs @@ -1,110 +1,209 @@ using System.Numerics; using BlockChainTools.DataTransferObjects; -using BlockChainTools.Interfaces; -using Nethereum.Web3; +using BlockChainTools.Services; +using Nethereum.Hex.HexTypes; using Nethereum.JsonRpc.Client; -using NUnit.Framework; +using Nethereum.RPC.Eth.DTOs; +using Nethereum.Web3; using NSubstitute; +using NUnit.Framework; namespace BlockChainTools.Tests; [TestFixture] public class TransactionStatusServiceTests { - private ITransactionStatusService? _service; - private Web3? _web3; + private TransactionStatusService _service = null!; [SetUp] - public void SetUp() + public void SetUp() => _service = new TransactionStatusService(); + + /// + /// Builds a whose underlying is stubbed so that + /// each Ethereum JSON-RPC method returns the supplied value (or null when omitted). + /// + private static Web3 BuildWeb3( + TransactionReceipt? receipt = null, + Transaction? tx = null, + HexBigInteger? transactionCount = null) { - _service = Substitute.For(); - _web3 = new Web3(Substitute.For()); + var client = Substitute.For(); + + client.SendRequestAsync( + Arg.Is(r => r.Method == "eth_getTransactionReceipt"), + Arg.Any()) + .Returns(receipt!); + + client.SendRequestAsync( + Arg.Is(r => r.Method == "eth_getTransactionByHash"), + Arg.Any()) + .Returns(tx!); + + client.SendRequestAsync( + Arg.Is(r => r.Method == "eth_getTransactionCount"), + Arg.Any()) + .Returns(transactionCount!); + + return new Web3(client); } [Test] public async Task GetTransactionStateAsync_ConfirmedSuccess_ReturnsCorrectState() { - var expected = new TransactionStateInfo + var receipt = new TransactionReceipt { - State = TransactionState.ConfirmedSuccess, - TransactionHash = "0xabc", - ReceiptStatus = BigInteger.One, - BlockNumber = new BigInteger(100) + Status = new HexBigInteger(1), + BlockNumber = new HexBigInteger(100) }; - _service!.GetTransactionStateAsync(_web3!, "0xabc", Arg.Any()).Returns(expected); - var result = await _service.GetTransactionStateAsync(_web3!, "0xabc"); + var tx = new Transaction + { + Nonce = new HexBigInteger(7), + Value = new HexBigInteger(1_000_000_000_000_000_000L), + Gas = new HexBigInteger(21000) + }; + var web3 = BuildWeb3(receipt: receipt, tx: tx); + + var result = await _service.GetTransactionStateAsync(web3, "0xabc"); + Assert.That(result.State, Is.EqualTo(TransactionState.ConfirmedSuccess)); + Assert.That(result.TransactionHash, Is.EqualTo("0xabc")); + Assert.That(result.Receipt, Is.Not.Null); + Assert.That(result.Receipt!.Status?.Value, Is.EqualTo(BigInteger.One)); Assert.That(result.ReceiptStatus, Is.EqualTo(BigInteger.One)); Assert.That(result.BlockNumber, Is.EqualTo(new BigInteger(100))); - Assert.That(result.TransactionHash, Is.EqualTo("0xabc")); + Assert.That(result.Nonce, Is.EqualTo(new BigInteger(7))); + Assert.That(result.Value, Is.EqualTo(new BigInteger(1_000_000_000_000_000_000L))); + Assert.That(result.Gas, Is.EqualTo(new BigInteger(21000))); + Assert.That(result.ConfirmedNonce, Is.Null); } [Test] public async Task GetTransactionStateAsync_ConfirmedReverted_ReturnsCorrectState() { - var expected = new TransactionStateInfo + var receipt = new TransactionReceipt + { + Status = new HexBigInteger(0), + BlockNumber = new HexBigInteger(200) + }; + var tx = new Transaction { - State = TransactionState.ConfirmedReverted, - TransactionHash = "0xdef", - ReceiptStatus = BigInteger.Zero, - BlockNumber = new BigInteger(200) + Nonce = new HexBigInteger(3), + Value = new HexBigInteger(0), + Gas = new HexBigInteger(21000) }; - _service!.GetTransactionStateAsync(_web3!, "0xdef", Arg.Any()).Returns(expected); - var result = await _service.GetTransactionStateAsync(_web3!, "0xdef"); + var web3 = BuildWeb3(receipt: receipt, tx: tx); + + var result = await _service.GetTransactionStateAsync(web3, "0xdef"); + Assert.That(result.State, Is.EqualTo(TransactionState.ConfirmedReverted)); + Assert.That(result.Receipt, Is.Not.Null); + Assert.That(result.Receipt!.Status?.Value, Is.EqualTo(BigInteger.Zero)); Assert.That(result.ReceiptStatus, Is.EqualTo(BigInteger.Zero)); + Assert.That(result.BlockNumber, Is.EqualTo(new BigInteger(200))); + Assert.That(result.Nonce, Is.EqualTo(new BigInteger(3))); + Assert.That(result.Value, Is.EqualTo(BigInteger.Zero)); + Assert.That(result.Gas, Is.EqualTo(new BigInteger(21000))); + Assert.That(result.ConfirmedNonce, Is.Null); } [Test] public async Task GetTransactionStateAsync_Pending_ReturnsCorrectState() { - var expected = new TransactionStateInfo + var tx = new Transaction { - State = TransactionState.Pending, - TransactionHash = "0xpending", - Nonce = new BigInteger(5) + From = "0xsender", + Nonce = new HexBigInteger(5), + Value = new HexBigInteger(500_000_000_000_000_000L), + Gas = new HexBigInteger(21000) }; - _service!.GetTransactionStateAsync(_web3!, "0xpending", Arg.Any()).Returns(expected); - var result = await _service.GetTransactionStateAsync(_web3!, "0xpending"); + // Confirmed nonce equals tx nonce — not yet replaced, still pending + var web3 = BuildWeb3(tx: tx, transactionCount: new HexBigInteger(5)); + + var result = await _service.GetTransactionStateAsync(web3, "0xpending"); + Assert.That(result.State, Is.EqualTo(TransactionState.Pending)); + Assert.That(result.Receipt, Is.Null); Assert.That(result.ReceiptStatus, Is.Null); Assert.That(result.BlockNumber, Is.Null); Assert.That(result.Nonce, Is.EqualTo(new BigInteger(5))); + Assert.That(result.Value, Is.EqualTo(new BigInteger(500_000_000_000_000_000L))); + Assert.That(result.Gas, Is.EqualTo(new BigInteger(21000))); + Assert.That(result.ConfirmedNonce, Is.Null); } [Test] public async Task GetTransactionStateAsync_Replaced_ReturnsCorrectState() { - var expected = new TransactionStateInfo + var tx = new Transaction { - State = TransactionState.Replaced, - TransactionHash = "0xreplaced", - Nonce = new BigInteger(3), - ConfirmedNonce = new BigInteger(5) + From = "0xsender", + Nonce = new HexBigInteger(3), + Value = new HexBigInteger(250_000_000_000_000_000L), + Gas = new HexBigInteger(21000) }; - _service!.GetTransactionStateAsync(_web3!, "0xreplaced", Arg.Any()).Returns(expected); - var result = await _service.GetTransactionStateAsync(_web3!, "0xreplaced"); + // Confirmed nonce (5) > tx nonce (3) — the tx was replaced by another + var web3 = BuildWeb3(tx: tx, transactionCount: new HexBigInteger(5)); + + 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.Nonce, Is.EqualTo(new BigInteger(3))); + Assert.That(result.ConfirmedNonce, Is.EqualTo(new BigInteger(5))); Assert.That(result.ConfirmedNonce!.Value, Is.GreaterThan(result.Nonce!.Value)); + Assert.That(result.Value, Is.EqualTo(new BigInteger(250_000_000_000_000_000L))); + Assert.That(result.Gas, Is.EqualTo(new BigInteger(21000))); } [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"); + // Node returns null for both receipt and transaction — likely dropped from mempool + var web3 = BuildWeb3(); + + var result = await _service.GetTransactionStateAsync(web3, "0xstale"); + Assert.That(result.State, Is.EqualTo(TransactionState.StalePending)); + Assert.That(result.TransactionHash, Is.EqualTo("0xstale")); + Assert.That(result.Receipt, Is.Null); Assert.That(result.ReceiptStatus, Is.Null); Assert.That(result.BlockNumber, Is.Null); Assert.That(result.Nonce, Is.Null); + Assert.That(result.Value, Is.Null); + Assert.That(result.Gas, Is.Null); Assert.That(result.ConfirmedNonce, Is.Null); } + [Test] + public async Task GetTransactionStateAsync_ConfirmedSuccess_TransactionDataUnavailable_NullableFieldsAreNull() + { + // Receipt found but the node has pruned the transaction object. + // Nonce, Value, and Gas fall back to null gracefully; Receipt and ReceiptStatus are still populated. + var receipt = new TransactionReceipt + { + Status = new HexBigInteger(1), + BlockNumber = new HexBigInteger(50) + }; + var web3 = BuildWeb3(receipt: receipt); // tx = null (pruned) + + var result = await _service.GetTransactionStateAsync(web3, "0xpruned"); + + Assert.That(result.State, Is.EqualTo(TransactionState.ConfirmedSuccess)); + Assert.That(result.Receipt, Is.Not.Null); + Assert.That(result.ReceiptStatus, Is.EqualTo(BigInteger.One)); + Assert.That(result.BlockNumber, Is.EqualTo(new BigInteger(50))); + Assert.That(result.Nonce, Is.Null); + Assert.That(result.Value, Is.Null); + Assert.That(result.Gas, Is.Null); + } + + [Test] + public void GetTransactionStateAsync_CancelledToken_ThrowsOperationCanceledException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var web3 = BuildWeb3(); + + Assert.ThrowsAsync( + () => _service.GetTransactionStateAsync(web3, "0xany", cts.Token)); + } }