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));
+ }
}