Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ protected override TxPool.TxPool CreateTxPool(IChainHeadInfoProvider chainHeadIn
api.LogManager,
CreateTxPoolTxComparer(txPriorityContract, localDataSource),
_txGossipPolicy,
new TxFilterAdapter(api.BlockTree, txPoolFilter, api.LogManager, api.SpecProvider),
[new TxFilterAdapter(api.BlockTree, txPoolFilter, api.LogManager, api.SpecProvider)],
api.HeadTxValidator,
txPriorityContract is not null || localDataSource is not null);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Nethermind/Nethermind.TxPool.Test/TxPoolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2517,7 +2517,7 @@ private TxPool CreatePool(
_logManager,
transactionComparerProvider.GetDefaultComparer(),
ShouldGossip.Instance,
incomingTxFilter,
incomingTxFilter is null ? null : [incomingTxFilter],
new HeadTxValidator(),
thereIsPriorityContract);
}
Expand Down
8 changes: 4 additions & 4 deletions src/Nethermind/Nethermind.TxPool/TxPool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public class TxPool : ITxPool, IAsyncDisposable
/// <param name="logManager"></param>
/// <param name="comparer"></param>
/// <param name="transactionsGossipPolicy"></param>
/// <param name="incomingTxFilter"></param>
/// <param name="incomingTxFilters"></param>
/// <param name="thereIsPriorityContract"></param>
/// <param name="headTxValidator"></param>
public TxPool(IEthereumEcdsa ecdsa,
Expand All @@ -105,7 +105,7 @@ public TxPool(IEthereumEcdsa ecdsa,
ILogManager? logManager,
IComparer<Transaction> comparer,
ITxGossipPolicy? transactionsGossipPolicy = null,
IIncomingTxFilter? incomingTxFilter = null,
IIncomingTxFilter[]? incomingTxFilters = null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — this is a source-breaking change to a public constructor; the PR is checkboxed as non-breaking.

TxPool and this constructor are public, and IIncomingTxFilter? incomingTxFilterIIncomingTxFilter[]? incomingTxFilters breaks any out-of-tree plugin that constructs a pool with a custom filter (the in-tree call sites — AuRa, XDC, InitializeBlockchain, the benchmark, TxPoolTests — are all updated correctly, and null/omitted still compiles, so only the "I pass one filter" shape breaks). The parameter rename also breaks named-argument callers.

Worth ticking Breaking change and giving it a line in the release notes; the migration is a one-character […]. No objection to the change itself — it's the right shape, and it's what lets XDC drop CompositeIncomingTxFilter.

Two smaller notes while here:

  • The XML doc for the param is still empty. Since the array's semantics aren't obvious from the name, one line — that these are appended to the built-in post-hash filters, i.e. they run after sender recovery and can rely on tx.SenderAddress — would earn its place. That guarantee is exactly what makes BlackListedAddressFilter's sender check safe.
  • Because Autofac resolves T[] implicitly, this parameter is now a container extension point: anything registering IIncomingTxFilter gets picked up by every DI-resolved TxPool. Nothing in the repo does today, and the two XDC/AuRa call sites construct the pool by hand, so no behaviour change — just be aware the coupling now exists.

[KeyFilter(ITxValidator.HeadTxValidatorKey)] ITxValidator? headTxValidator = null,
bool thereIsPriorityContract = false)
{
Expand Down Expand Up @@ -174,9 +174,9 @@ public TxPool(IEthereumEcdsa ecdsa,
new DelegatedAccountFilter(_specProvider, _transactions, _blobTransactions, chainHeadInfoProvider.ReadOnlyStateProvider, _pendingDelegations),
];

if (incomingTxFilter is not null)
if (incomingTxFilters is not null)
{
postHashFilters.Add(incomingTxFilter);
postHashFilters.AddRange(incomingTxFilters);
}

postHashFilters.Add(new DeployedCodeFilter(chainHeadInfoProvider.ReadOnlyStateProvider, _specProvider));
Expand Down
128 changes: 128 additions & 0 deletions src/Nethermind/Nethermind.Xdc.Test/BlackListedAddressFilterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using System.Collections.Generic;
using System.Threading.Tasks;
using Nethermind.Consensus;
using Nethermind.Core;
using Nethermind.Core.Specs;
using Nethermind.Core.Test.Builders;
using Nethermind.Crypto;
using Nethermind.Logging;
using Nethermind.TxPool;
using Nethermind.Xdc.Spec;
using Nethermind.Xdc.Test.Helpers;
using Nethermind.Xdc.TxPool;
using NSubstitute;
using NUnit.Framework;

namespace Nethermind.Xdc.Test;

[Parallelizable(ParallelScope.All)]
internal class BlackListedAddressFilterTests
{
private static readonly Address BlackListed = TestItem.AddressA;

private static BlackListedAddressFilter CreateFilter(ulong headNumber, bool blackListingEnabled, ISpecProvider? specProvider = null)
{
IChainHeadInfoProvider chainHeadInfoProvider = Substitute.For<IChainHeadInfoProvider>();
chainHeadInfoProvider.HeadNumber.Returns(headNumber);

IXdcReleaseSpec xdcSpec = Substitute.For<IXdcReleaseSpec>();
xdcSpec.IsBlackListingEnabled.Returns(blackListingEnabled);
HashSet<Address> blackList = [BlackListed];
xdcSpec.BlackListedAddresses.Returns(blackList);

specProvider ??= Substitute.For<ISpecProvider>();
specProvider.GetSpec(Arg.Any<ForkActivation>()).Returns(xdcSpec);

return new BlackListedAddressFilter(chainHeadInfoProvider, specProvider, LimboLogs.Instance);
}

private static AcceptTxResult Accept(BlackListedAddressFilter filter, Transaction tx)
{
TxFilteringState state = default;
return filter.Accept(tx, ref state, TxHandlingOptions.None);
}

private static Transaction BuildTx(Address? sender, Address? to) =>
Build.A.Transaction.WithSenderAddress(sender).WithTo(to).TestObject;

[TestCase(true, false, true, false, TestName = "Blacklisted sender rejected once activated")]
[TestCase(true, false, false, true, TestName = "Blacklisted sender allowed before activation")]
[TestCase(false, true, true, false, TestName = "Blacklisted recipient rejected once activated")]
[TestCase(false, true, false, true, TestName = "Blacklisted recipient allowed before activation")]
[TestCase(false, false, true, true, TestName = "Unlisted addresses accepted once activated")]
[TestCase(false, false, false, true, TestName = "Unlisted addresses accepted before activation")]
public void Accept_ChecksSenderAndRecipient(bool blackListSender, bool blackListRecipient, bool blackListingEnabled, bool expectedAccepted)
{
BlackListedAddressFilter filter = CreateFilter(headNumber: 100, blackListingEnabled);
Transaction tx = BuildTx(blackListSender ? BlackListed : TestItem.AddressB, blackListRecipient ? BlackListed : TestItem.AddressC);

Assert.That((bool)Accept(filter, tx), Is.EqualTo(expectedAccepted));
}

[TestCase(true, "sender")]
[TestCase(false, "recipient")]
public void Accept_BlackListedAddress_ReportsRoleWithoutDisconnectingPeer(bool blackListSender, string expectedRole)
{
BlackListedAddressFilter filter = CreateFilter(headNumber: 100, blackListingEnabled: true);
Transaction tx = blackListSender ? BuildTx(BlackListed, TestItem.AddressC) : BuildTx(TestItem.AddressB, BlackListed);

AcceptTxResult result = Accept(filter, tx);

Assert.That(result, Is.EqualTo(XdcAcceptTxResult.BlackListedSender));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — this assertion can't fail for the reason the test name implies.

AcceptTxResult.Equals compares Id only (AcceptTxResult.cs:132), and BlackListedSender / BlackListedRecipient deliberately share BlackListedAddressId = 1000. So BlackListedSender == BlackListedRecipient is true, and line 74 passes identically in both [TestCase]s — the only assertion with teeth on the role is the ToString() check on line 75.

Same at line 125: Is.EqualTo(blackListSender ? BlackListedSender : BlackListedRecipient) is exactly equivalent to Is.EqualTo(BlackListedSender), so the end-to-end test would not notice the filter returning the wrong one of the two.

Sharing the id is the right call (it's what keeps TxFloodController on the non-Invalid branch for both, and WithMessage exists precisely to make same-id/different-message results). The fix is on the test side — assert on ToString() in both places, since the message is the only thing that actually distinguishes them:

Suggested change
Assert.That(result, Is.EqualTo(XdcAcceptTxResult.BlackListedSender));
Assert.That(result.ToString(), Is.EqualTo(XdcAcceptTxResult.BlackListedSender.ToString()).Or.EqualTo(XdcAcceptTxResult.BlackListedRecipient.ToString()));

(or simply drop line 74 and keep the Does.Contain(expectedRole) check, which already covers it.)

Assert.That(result.ToString(), Does.Contain(expectedRole));
Assert.That(result, Is.Not.EqualTo(AcceptTxResult.Invalid), "Invalid makes TxFloodController disconnect the relaying peer");
}

[Test]
public void Accept_ContractCreation_IsAccepted()
{
BlackListedAddressFilter filter = CreateFilter(headNumber: 100, blackListingEnabled: true);

Assert.That(Accept(filter, BuildTx(TestItem.AddressB, to: null)), Is.EqualTo(AcceptTxResult.Accepted));
}

[Test]
public void Accept_UsesSpecOfBlockAfterHead()
{
const ulong headNumber = 1234;
ISpecProvider specProvider = Substitute.For<ISpecProvider>();
BlackListedAddressFilter filter = CreateFilter(headNumber, blackListingEnabled: true, specProvider);

Accept(filter, BuildTx(TestItem.AddressB, TestItem.AddressC));

specProvider.Received().GetSpec(Arg.Is<ForkActivation>(f => f.BlockNumber == headNumber + 1));
}

[TestCase(true, true, false, TestName = "Pool rejects blacklisted sender")]
[TestCase(false, true, false, TestName = "Pool rejects blacklisted recipient")]
[TestCase(true, false, true, TestName = "Pool accepts blacklisted sender before activation")]
public async Task SubmitTx_BlackListedAddress_IsRejectedOnPoolAdmission(bool blackListSender, bool blackListingEnabled, bool expectedAccepted)
{
using XdcTestBlockchain chain = await XdcTestBlockchain.Create(5, false);
chain.ChangeReleaseSpec(spec =>
{
spec.BlackListedAddresses = [blackListSender ? TestItem.AddressB : TestItem.AddressC];
spec.IsBlackListingEnabled = blackListingEnabled;
});

Transaction tx = Build.A.Transaction
.WithSenderAddress(TestItem.AddressB)
.WithTo(TestItem.AddressC)
.WithValue(1)
.WithType(TxType.Legacy)
.WithNonce(chain.TxPool.GetLatestPendingNonce(TestItem.AddressB))
.TestObject;
new Signer(chain.SpecProvider.ChainId, TestItem.PrivateKeyB, NullLogManager.Instance).TrySign(tx);
tx.Hash = tx.CalculateHash();

AcceptTxResult result = chain.TxPool.SubmitTx(tx, TxHandlingOptions.None);

Assert.That((bool)result, Is.EqualTo(expectedAccepted), result.ToString());
if (!expectedAccepted)
Assert.That(result, Is.EqualTo(blackListSender ? XdcAcceptTxResult.BlackListedSender : XdcAcceptTxResult.BlackListedRecipient));
Assert.That(chain.TxPool.GetPendingTransactions(), Has.Length.EqualTo(expectedAccepted ? 1 : 0));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@ protected override ContainerBuilder ConfigureContainer(ContainerBuilder builder,
ctx.Resolve<ILogManager>(),
new XdcTransactionComparerProvider(ctx.Resolve<ISpecProvider>(), ctx.Resolve<IBlockTree>()).GetDefaultComparer(),
ctx.Resolve<ITxGossipPolicy>(),
new SignTransactionFilter(ctx.Resolve<ISnapshotManager>(), ctx.Resolve<IBlockTree>(), ctx.Resolve<ISpecProvider>()),
[
new SignTransactionFilter(ctx.Resolve<ISnapshotManager>(), ctx.Resolve<IBlockTree>(), ctx.Resolve<ISpecProvider>()),
new BlackListedAddressFilter(ctx.Resolve<IChainHeadInfoProvider>(), ctx.Resolve<ISpecProvider>(), ctx.Resolve<ILogManager>())
],
ctx.Resolve<ITxValidator>()
);

Expand Down
5 changes: 4 additions & 1 deletion src/Nethermind/Nethermind.Xdc/InitializeBlockchainXdc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ protected override ITxPool CreateTxPool(IChainHeadInfoProvider chainHeadInfoProv
_api.LogManager,
CreateTxPoolTxComparer(),
_txGossipPolicy,
new SignTransactionFilter(snapshotManager, _api.BlockTree, XdcSpecProvider),
[
new SignTransactionFilter(snapshotManager, _api.BlockTree, XdcSpecProvider),
new BlackListedAddressFilter(chainHeadInfoProvider, XdcSpecProvider, _api.LogManager)
],
_api.HeadTxValidator,
true
);
Expand Down
6 changes: 5 additions & 1 deletion src/Nethermind/Nethermind.Xdc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ closed on mainnet.

- Post-`TipTrc21Fee`, gas fees are paid to the **candidate owner** of the block beneficiary rather than the
beneficiary itself.
- Post-`BlackListHFNumber`, transactions with a blacklisted sender or recipient are rejected.
- Post-`BlackListHFNumber`, transactions with a blacklisted sender or recipient are rejected during execution,
and on pool admission, so they are never gossiped.

### Block execution context

Expand All @@ -434,6 +435,9 @@ value, and forces blob base fee to zero, since XDC enables the `BLOBBASEFEE` opc

- [`SignTransactionFilter`](TxPool/SignTransactionFilter.cs) accepts fee-exempt transactions only from current
epoch candidates, and only when the signed block is recent.
- [`BlackListedAddressFilter`](TxPool/BlackListedAddressFilter.cs) rejects transactions with a blacklisted
sender or recipient once `BlackListHFNumber` activates, so they never reach a block or a peer. The rejection
code is deliberately not `AcceptTxResult.Invalid`, which would disconnect the relaying peer.
- [`XdcTxGossipPolicy`](TxPool/XdcTxGossipPolicy.cs) withholds the DEX/lending family; sign and randomize
transactions are gossiped normally.
- [`XdcTxFilterPipeline`](TxPool/XdcTxFilterPipeline.cs) lets the fee-exempt transactions bypass the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using Nethermind.Core;
using Nethermind.Core.Specs;
using Nethermind.Logging;
using Nethermind.TxPool;
using Nethermind.TxPool.Filters;
using Nethermind.Xdc.Spec;

namespace Nethermind.Xdc.TxPool;

/// <summary>
/// Rejects transactions whose sender or recipient is blacklisted, keeping them out of the pool and out of gossip.
/// </summary>
/// <remarks>
/// The blacklist is a consensus rule enforced during execution by <see cref="XdcTransactionProcessor.ValidateSender"/>;
/// this filter is the mempool-admission counterpart, so that such transactions are dropped on submission rather than
/// when a block containing them is processed. Activation is read from the spec of the block the transaction would land
/// in, one past the current head, matching <see cref="SignTransactionFilter"/>.
/// </remarks>
internal sealed class BlackListedAddressFilter(
IChainHeadInfoProvider chainHeadInfoProvider,
ISpecProvider specProvider,
ILogManager logManager) : IIncomingTxFilter
{
private readonly ILogger _logger = logManager.GetClassLogger<BlackListedAddressFilter>();

public AcceptTxResult Accept(Transaction tx, ref TxFilteringState state, TxHandlingOptions txHandlingOptions)
{
IXdcReleaseSpec spec = specProvider.GetXdcSpec(chainHeadInfoProvider.HeadNumber + 1);

if (!spec.IsBlackListingEnabled)
return AcceptTxResult.Accepted;

if (IsBlackListed(spec, tx.SenderAddress))
return Reject(tx, XdcAcceptTxResult.BlackListedSender);

if (IsBlackListed(spec, tx.To))
return Reject(tx, XdcAcceptTxResult.BlackListedRecipient);

return AcceptTxResult.Accepted;
}

private AcceptTxResult Reject(Transaction tx, AcceptTxResult result)
{
if (_logger.IsDebug) _logger.Debug($"Skipped adding transaction {tx.ToString(" ")}, {result}.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Debug diverges from the Trace convention for pool discards, on a peer-reachable path.

Every built-in incoming filter logs its discard at Trace, with this exact message shape — BalanceTooLowFilter, GapNonceFilter, GasLimitTxFilter, LowNonceFilter, MalformedTxFilter, NotSupportedTxFilter, PriorityFeeTooLowFilter. This one logs at Debug.

That matters because the trigger is remote: a blacklisted tx does not produce AcceptTxResult.Invalid, so the peer isn't disconnected for it and can keep relaying up to the flood-controller threshold (100/s). Each one writes a Debug line containing a full tx.ToString(" "). On a node running at Debug — which operators do, unlike Trace — that's a remotely-driven log amplification for no added signal, since the p2p path already traces the rejection in Eth62ProtocolHandler.

If the goal is operator visibility on blacklist hits (a reasonable goal — it's rarer and more interesting than a nonce gap), a counter is the better instrument than a per-tx line: it survives an adversary and shows up in Grafana. Trace + a metric, or Trace alone, both match the surrounding code better than Debug.

Suggested change
if (_logger.IsDebug) _logger.Debug($"Skipped adding transaction {tx.ToString(" ")}, {result}.");
if (_logger.IsTrace) _logger.Trace($"Skipped adding transaction {tx.ToString(" ")}, {result}.");

return result;
}

private static bool IsBlackListed(IXdcReleaseSpec spec, Address? address) =>
address is not null && spec.BlackListedAddresses.Contains(address);
}
15 changes: 15 additions & 0 deletions src/Nethermind/Nethermind.Xdc/TxPool/XdcAcceptTxResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using Nethermind.TxPool;

namespace Nethermind.Xdc.TxPool;

internal static class XdcAcceptTxResult
{
private const int BlackListedAddressId = 1000;
private const string BlackListedAddressCode = "BlackListedAddress";

public static AcceptTxResult BlackListedSender { get; } = new(BlackListedAddressId, BlackListedAddressCode, "Transaction sender is blacklisted");
public static AcceptTxResult BlackListedRecipient { get; } = new(BlackListedAddressId, BlackListedAddressCode, "Transaction recipient is blacklisted");
}
Loading