Skip to content

feat(xdc): reject blacklisted addresses on pool admission - #12879

Open
ak88 wants to merge 5 commits into
masterfrom
claude/blacklist-check-transaction-submit-fa3ada
Open

feat(xdc): reject blacklisted addresses on pool admission#12879
ak88 wants to merge 5 commits into
masterfrom
claude/blacklist-check-transaction-submit-fa3ada

Conversation

@ak88

@ak88 ak88 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Changes

  • Add BlackListedAddressFilter (IIncomingTxFilter), rejecting transactions whose sender or recipient is blacklisted, gated on IsBlackListingEnabled in the spec at the current head.
  • Add CompositeIncomingTxFilter, since TxPool accepts a single custom incoming filter and the new filter has to run alongside the existing SignTransactionFilter.
  • Wire both filters in InitializeBlockchainXdc and XdcTestBlockchain.
  • Note the pool-level check in the XDC README.

Why

The blacklist was only enforced at execution time, in XdcTransactionProcessor.ValidateSender. A transaction with a blacklisted sender or recipient was accepted into the pool and gossiped to peers, and was dropped only later — skipped by the block-production picker, or invalidating a received block that contained it.

The Go reference client (XinFinOrg/XDPoSChain) checks the denylist in both places:

  • core/txpool/validation.go — pool admission, for RPC submissions and p2p-received transactions alike.
  • core/state_processor.go — per transaction during block processing.

This PR adds the missing mempool-level check; the consensus-level one already matched.

Two intentional differences from the reference remain:

  • The reference also rejects when the block number is unknown (number == nil). Here a null head returns AcceptTxResult.Syncing, matching what SignTransactionFilter already does for a null head in the same pipeline — the address set is spec-derived, so there is no list to consult without a head.
  • The reference's list is hardcoded and shared across networks (common/constants.shared.go); ours comes from the chainspec (blackListedAddresses in xdc.json / xdc-testnet.json). The mainnet/testnet chainspecs already carry the same addresses.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

BlackListedAddressFilterTests covers sender and recipient rejection, both before and after activation, contract creation (null recipient), a null head, and that the spec is taken at the head number.

Note: the build and test run were not executed in the session that produced this branch — CI is the first verification.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

XDC nodes now reject transactions with a blacklisted sender or recipient at submission time (eth_sendRawTransaction and p2p), instead of accepting them into the mempool and dropping them at execution.

🤖 Generated with Claude Code

The XDC blacklist was only enforced during execution, in
XdcTransactionProcessor.ValidateSender. A transaction with a blacklisted
sender or recipient was therefore accepted into the pool and gossiped to
peers, only to be skipped by the block producer or to invalidate a block
that included it.

The Go reference client checks the denylist in both places:
core/txpool/validation.go on pool admission (for RPC submissions and
p2p-received transactions alike) and core/state_processor.go during block
processing. This adds the missing mempool-level check as a new incoming tx
filter, reading activation and the address set from the spec at the current
head. Since the pool takes a single custom filter, a small composite runs it
alongside the existing SignTransactionFilter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ak88's task in 8m 39s —— View job


Review: reject blacklisted addresses on pool admission

  • Gather context (diff, new files, surrounding XDC + TxPool code)
  • Review correctness / edge cases / regressions
  • Review security & performance
  • Check repo rules (coding-style, robustness, AGENTS.md)
  • Post review

The change is well-shaped and the reasoning in the PR body checks out. Two findings are worth resolving before merge; the rest are minor. 7 inline comments posted.

What I verified as correct

  • Filter placement. The custom incomingTxFilter is appended to _postHashFilters (TxPool.cs:177-180), i.e. after MalformedTxFilter, so tx.SenderAddress is already recovered when Accept runs. No null-sender hole.
  • Semantics match consensus. XdcTransactionProcessor.ValidateSender checks both sender and To (XdcTransactionProcessor.cs:96), so rejecting on either at pool level does not over-reject relative to execution. It also holds for special transactions — ExecuteSpecialTransaction routes through ValidateSender (XdcTransactionProcessor.cs:191), so a sign/randomize tx from a blacklisted address is rejected at both layers. Good.
  • No NRE on an unpopulated spec. IsBlackListingEnabled is checked before BlackListedAddresses is touched, which matters because XdcReleaseSpec.BlackListedAddresses is a non-nullable-annotated field with no initializer (CreateEmptyReleaseSpec() leaves it null). HashSet<Address>.Contains(null) is also safe, so the to == null contract-creation path is doubly covered.
  • Cost per tx is small. XdcChainSpecBasedSpecProvider.GetXdcSpec is two binary searches plus a ConcurrentDictionary hit, allocation-free on the cached path. The composite's foreach over an array allocates no enumerator and the params array is built once at construction.
  • No build risk from the <see cref="XdcTransactionProcessor.ValidateSender"/> to a protected member: GenerateDocumentationFile isn't set for Nethermind.Xdc, so CS1574 isn't emitted despite TreatWarningsAsErrors.

Findings

# Severity Finding
1 Medium Spec read at head.Number instead of head.Number + 1 — off-by-one at activation (inline)
2 Medium AcceptTxResult.Invalid disconnects the peer immediately — undocumented p2p behaviour change (inline)
3 Low Head taken from IBlockTree rather than IChainHeadInfoProvider; null-head branch unreachable as wired (inline)
4 Low Test-blockchain wiring inert (IsBlackListingEnabled never set); no end-to-end pool test (inline)
5 Low CompositeIncomingTxFilter untested; no log/metric on a blacklist drop (inline)
6 Low HeadBlock helper duplicated verbatim from SignTransactionFilterTests (inline)
7 Low New filters missing from the README ### Transaction pool index (inline)

The two mediums interact, which is why #1 is more than cosmetic

SignTransactionFilter — the other half of the same composite — deliberately evaluates at header.Number + 1, because an admitted tx executes in the next block. BlackListedAddressFilter uses head.Number. On its own that only opens a one-block window at BlackListHFNumber - 1 where a blacklisted tx is admitted and gossiped.

But AcceptTxResult compares by Id, so Invalid.WithMessage(...) == AcceptTxResult.Invalid, and TxFloodController.Report disconnects on Invalid with no threshold:

if (accepted == AcceptTxResult.Invalid)
    disconnectRequest ??= new(DisconnectReason.InvalidTxReceived, "invalid tx", ...);

So in that window, node A at head = HF-1 admits and broadcasts the tx, and node B at head = HF disconnects A for it. Two honest Nethermind nodes one block apart across the boundary drop each other. Switching to head.Number + 1 closes the window and makes both filters agree on the spec the pipeline is judging against.

Independently of the boundary, #2 deserves an explicit note in the PR body: this makes "peer relayed a blacklisted tx" a disconnect condition, which is defensible (these txs are permanently invalid, and SignTransactionFilter already returns Invalid on the same path) but will drop any peer running an XDPoSChain build that lacks the pool-level check.

Not done

I could not build or run the tests — dotnet build was outside the permitted tool set in this session, so CI remains the first verification, as the PR body notes. My review of the new tests is static; the helpers they use (Build.A.XdcBlockHeader, TransactionBuilder.WithSenderAddress/WithTo, both accepting nullable Address?) do exist, and the TxFilteringState state = default + (bool)AcceptTxResult patterns already compile in SignTransactionFilterTests, so I have no specific compile concern.
· branch claude/blacklist-check-transaction-submit-fa3ada

if (blockTree.Head is null)
return AcceptTxResult.Syncing;

IXdcReleaseSpec spec = specProvider.GetXdcSpec(blockTree.Head.Number);

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.

Medium — spec is read at the head block, not the block the tx would land in.

The sibling filter in the same composite deliberately looks one block ahead (SignTransactionFilter.cs:46):

ulong headerNumber = header.Number + 1;
IXdcReleaseSpec spec = specProvider.GetXdcSpec(headerNumber);

because an admitted tx executes in head + 1, not in head. Using head.Number here means that while head.Number == BlackListHFNumber - 1 a blacklisted tx is admitted and gossiped, and then rejected at execution — exactly the case this PR sets out to close.

This interacts badly with AcceptTxResult.Invalid (see the other comment): node A at head = HF-1 admits and broadcasts the tx, node B at head = HF returns Invalid and disconnects A. Two honest nodes one block apart across the boundary drop each other. Using head.Number + 1 in both filters removes the window and makes the two filters agree on which spec the pipeline is judging against.

Suggested change
IXdcReleaseSpec spec = specProvider.GetXdcSpec(blockTree.Head.Number);
IXdcReleaseSpec spec = specProvider.GetXdcSpec(blockTree.Head.Number + 1);

Note BlackListedAddressFilterTests.Accept_UsesSpecOfCurrentHead asserts the current behaviour, so it needs updating alongside.

Comment on lines +33 to +37
if (IsBlackListed(spec, tx.SenderAddress))
return AcceptTxResult.Invalid.WithMessage("Transaction sender is blacklisted");

if (IsBlackListed(spec, tx.To))
return AcceptTxResult.Invalid.WithMessage("Transaction recipient is blacklisted");

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.

Medium — AcceptTxResult.Invalid from an incoming filter disconnects the peer.

AcceptTxResult equality is Id-based (AcceptTxResult.cs:132), so Invalid.WithMessage(...) == AcceptTxResult.Invalid. For a tx received over p2p, Eth62ProtocolHandler.PrepareAndSubmitTransaction reports the result to the flood controller, which does:

if (accepted == AcceptTxResult.Invalid)
    disconnectRequest ??= new(DisconnectReason.InvalidTxReceived, "invalid tx", ...);

(TxFloodController.cs:85-91 — no threshold, immediate disconnect.)

So this filter turns "peer relayed a blacklisted tx" into "drop the peer". That is arguably correct — such txs are permanently invalid under consensus, and SignTransactionFilter already returns Invalid on the same path — but it is a p2p-visible behaviour change that the PR description doesn't mention, and it affects any peer running an XDPoSChain build without the pool-level check (which will keep relaying these forever). Please either acknowledge this explicitly as intended, or pick a non-Invalid result code.

Comment on lines +25 to +26
if (blockTree.Head is null)
return AcceptTxResult.Syncing;

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 — head source, and a dead branch in the wired configuration.

Two smaller points:

  1. Every other pool filter that needs the head takes IChainHeadInfoProvider and reads HeadNumber (see GasLimitTxFilter, PriorityFeeTooLowFilter, and XDC's own XdcTxGossipPolicy.cs:15). TxPool.FilterTransactions runs under _newHeadLock specifically so filters see a head consistent with the pool's own state; IBlockTree.Head sidesteps that. HeadNumber also removes this null branch entirely.

  2. As wired, SignTransactionFilter runs first in the composite and already returns Syncing for a null head, so this branch is unreachable in production. Harmless defensively, just noting it isn't the safety net it looks like.

Comment on lines +37 to +38
private static Block HeadBlock(ulong number = 100) =>
Build.A.Block.WithHeader(Build.A.XdcBlockHeader().WithNumber(number).TestObject).TestObject;

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 — duplicated test helper.

HeadBlock is byte-identical to SignTransactionFilterTests.cs:46-47, and the CreateFilter / Accept(filter, tx) / TxFilteringState state = default shape repeats it too. AGENTS.md asks for shared helpers when parts of tests are similar:

When only parts of tests are similar (shared setup, common assertions, recurring scenarios), factor those parts into helper methods or helper types.

A small static helper under Nethermind.Xdc.Test/Helpers covering HeadBlock (and the Accept wrapper, which both files need because ref args can't be used in an expression-bodied assert) would serve both filter test classes.

Comment on lines +17 to +28
{
public AcceptTxResult Accept(Transaction tx, ref TxFilteringState state, TxHandlingOptions txHandlingOptions)
{
foreach (IIncomingTxFilter filter in filters)
{
AcceptTxResult result = filter.Accept(tx, ref state, txHandlingOptions);
if (!result)
return result;
}

return AcceptTxResult.Accepted;
}

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 — no test, and no observability on rejection.

The logic is simple and correct (first non-accepting result wins, foreach over an array so no enumerator allocation, params array allocated once at construction), but:

  • There is no test for CompositeIncomingTxFilter itself — ordering, short-circuiting so a later filter isn't consulted after a rejection, and the rejecting filter's AcceptTxResult (including its WithMessage text) being propagated verbatim rather than collapsed to a bare Invalid. That last one matters for RPC error text.
  • Neither the composite nor BlackListedAddressFilter logs or bumps a metric on rejection. Most Nethermind.TxPool filters take an ILogger and Trace-log the discard (e.g. BalanceZeroFilter, LowNonceFilter). SignTransactionFilter doesn't either, so this is consistent — but a blacklist drop is the kind of event an operator will want to see in a log, and the only trace today comes from Eth62ProtocolHandler on the p2p path.

Comment thread src/Nethermind/Nethermind.Xdc/README.md Outdated
Comment on lines +419 to +421
- Post-`BlackListHFNumber`, transactions with a blacklisted sender or recipient are rejected during execution,
and also on pool admission by [`BlackListedAddressFilter`](TxPool/BlackListedAddressFilter.cs) so they are
never gossiped.

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 — also list the new filter under ### Transaction pool.

That section (README.md:435-445) is the index of pool components — SignTransactionFilter, XdcTxGossipPolicy, XdcTxFilterPipeline, XdcTransactionComparerProvider. BlackListedAddressFilter and CompositeIncomingTxFilter belong there too; a reader looking for "what filters does the XDC pool run" won't find them under "Fees and blacklist".

Comment on lines +188 to +190
new CompositeIncomingTxFilter(
new SignTransactionFilter(ctx.Resolve<ISnapshotManager>(), ctx.Resolve<IBlockTree>(), ctx.Resolve<ISpecProvider>()),
new BlackListedAddressFilter(ctx.Resolve<IBlockTree>(), ctx.Resolve<ISpecProvider>())),

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 wiring is inert in every existing test, and is a latent trap for future ones.

WrapReleaseSpec sets BlackListedAddresses (the 0x…b1Ac70x list, XdcTestBlockchain.cs:235) but never sets IsBlackListingEnabled, so it defaults to false and the new filter short-circuits to Accepted for every tx here. The wiring is therefore never actually exercised.

The flip side: any test that calls ChangeReleaseSpec to turn blacklisting on now silently changes pool admission as well as execution. SpecialTransactionsTests already does exactly that with TestItem.AddressA — which is XdcTestBlockchain's own signer (XdcTestBlockchain.cs:170) — and with blockChain.Signer.Address. Those two tests happen to be safe because they drive XdcTransactionProcessor directly and Create(5, false) builds its blocks before ChangeReleaseSpec runs, but the next test that enables blacklisting and then submits a tx will fail in a way that points at the pool rather than at its own spec tweak.

Worth an end-to-end test that flips IsBlackListingEnabled on and asserts TxPool.SubmitTx rejects a blacklisted sender — that is the actual claim of the PR, and it would also cover the composite and this registration in one go.

Note the PR body says the branch was never built or tested locally, so CI is the first check that no existing XDC test regressed on this.

ak88 and others added 2 commits August 18, 2026 17:31
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses review feedback on the XDC blacklist filter:

- TxPool now takes IIncomingTxFilter[] rather than a single filter, so XDC
  can register both of its filters without a composite wrapper.
- BlackListedAddressFilter reads the head from IChainHeadInfoProvider, like
  every other pool filter that needs it, and gates on the spec of head + 1 —
  the block the transaction would land in — matching SignTransactionFilter.
  This also removes the null-head branch.
- The rejection no longer uses AcceptTxResult.Invalid, which makes
  TxFloodController disconnect the relaying peer: pool admission is judged
  against the local head, so two honest nodes one block apart across
  BlackListHFNumber would drop each other. The transaction is dropped and
  the peer is kept.
- Log the drop at debug level, and list the filter in the README's
  transaction pool section.
- Cover pool admission end to end through TxPool.SubmitTx with blacklisting
  activated, which also exercises the registration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ak88

ak88 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

@claude

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ak88's task in 6m 40s —— View job


Re-review after the follow-up commits

  • Gather the current diff (CompositeIncomingTxFilter dropped, XdcAcceptTxResult added, filters now an array)
  • Check the 7 earlier findings
  • Review the new TxPool array-of-filters refactor, the custom result code, and the end-to-end test
  • Post review

All seven points from my previous pass are resolved, and the two mediums are resolved in a way that's better than what I suggested: the spec is now read at HeadNumber + 1, and instead of picking a different existing result code you introduced a dedicated one with the rationale recorded in the source. Nothing blocking left — four low-severity notes below.

Verified

  • Off-by-one closed. BlackListedAddressFilter.cs:31 reads specProvider.GetXdcSpec(chainHeadInfoProvider.HeadNumber + 1), matching SignTransactionFilter and matching the block the tx would actually execute in. Accept_UsesSpecOfBlockAfterHead pins it; ForkActivation.BlockNumber is ulong, so the f.BlockNumber == headNumber + 1 predicate type-checks.
  • No peer disconnect. XdcAcceptTxResult.BlackListedAddress uses id 1000; AcceptTxResult only uses 0–19 and 503, so no collision, and TxFloodController.Report takes the non-Invalid branch. Note what that branch does: blacklisted txs still count toward _notAcceptedSinceLastCheck, so a peer relaying them at >10/s is downgraded and >100/s is disconnected as flooding. That's the right behaviour — a peer running an XDPoSChain build without the pool check relays a handful and is left alone, a spammer is still bounded.
  • Head source. IChainHeadInfoProvider.HeadNumber matches every other head-reading pool filter and removes the dead null-head branch. InitializeBlockchainXdc.CreateTxPool passes the method parameter (not the shadowed ctor field), which is the one InitializeBlockchain actually wires.
  • End-to-end test is real. I traced it through the pipeline: FeeTooLowFilter is pre-hash but CalculateGasPrice returns GasPrice (1) for a legacy tx, non-zero, and the pool isn't full → passes; MalformedTxFilter recovers AddressB from the signature, which agrees with the explicit WithSenderAddress; ValidateChainId is false in XdcTestBlockchain; AddressB is genesis-funded. So the filter is genuinely reached, and finding Hive #4 (inert wiring) is properly closed.
  • SpecialTransactionsTests not regressed. The two cases that flip IsBlackListingEnabled (lines 226, 280) drive XdcTransactionProcessor.Execute directly and never touch TxPool, so the new pool wiring can't affect them.
  • Array refactor is behaviour-preserving. null → nothing appended, same as before. Autofac resolves IIncomingTxFilter[] implicitly (empty array when nothing is registered), which also reaches postHashFilters.AddRange as a no-op. Nothing in the repo registers IIncomingTxFilter, so no filter is silently picked up today.

Notes

1. Low — the array parameter is a DI extension point you're not using yet. Because Autofac treats T[] as an implicit collection relationship, a plugin can now contribute a pool filter by registering IIncomingTxFilter in its module, with no change to TxPool or to the init step. That is exactly the "alter behavior through module registration" shape AGENTS.md prefers, and it would let XDC drop the hand-constructed TxPool in InitializeBlockchainXdc/XdcTestBlockchain entirely. Not for this PR — but if that's where this is heading, the two call sites here are the ones to revisit, and it's worth a line in the ctor's XML doc that the array is additive to the built-in post-hash filters. Also note the flip side: once anything registers an IIncomingTxFilter for another purpose, every TxPool resolved from the container gets it.

2. Low — one string allocation per rejected transaction. Reject (BlackListedAddressFilter.cs:45-49) interpolates $"Transaction {role} is blacklisted" on every rejection, and this path is peer-reachable — a peer can spam blacklisted txs up to the flood-controller threshold. Every other filter returns a preallocated AcceptTxResult. Two statics on XdcAcceptTxResult (BlackListedSender / BlackListedRecipient, each with its message baked in) remove both the allocation and the role parameter. Fix this →

3. Low — the Code string reaches RPC callers. eth_sendRawTransaction failures return acceptTxResult.ToString() (EthRpcModule.cs:594), which for this result is "BlackListedAddress, Transaction sender is blacklisted". The other two nameof()-based codes (Accepted, SignFailed) each carry a comment explaining that their Code never reaches a caller — here it does, so the doubled/CamelCase prefix is user-visible. Compare AlreadyKnown"already known". Folding it into a single lowercase message ("transaction sender is blacklisted", geth says "address in black list") reads better and pairs naturally with note 2.

4. Low — test brittleness. Assert.That(chain.TxPool.GetPendingTransactions(), Has.Length.EqualTo(...)) asserts on the whole pool, so any tx left over from the five setup blocks (added via AddBlockMayHaveExtraTx) turns into an unrelated failure. Asserting on the submitted tx specifically — chain.TxPool.TryGetPendingTransaction(tx.Hash!, out _) — is equivalent for what the test claims and immune to that.

Also, minor and pre-existing: with a non-XdcChainSpecBasedSpecProvider the GetXdcSpec fallback mutates the shared spec via ApplyV2Config(round: 0), so every pool submission in tests resets the spec to the round-0 V2 config. Harmless here (all five V2Configs in XdcTestBlockchain are identical, and neither filter reads round-derived fields), and SignTransactionFilter already does the same — just worth knowing it exists.

Not verified

Same limitation as last time: dotnet is outside the permitted tool set in this session, so I could not build or run anything. Everything above is static. The two constructs I'd watch in CI are incomingTxFilter is null ? null : [incomingTxFilter] in TxPoolTests.cs:2520 (target-typed conditional with a collection expression — should be fine under C# 14, but it's the least common shape in the diff) and the end-to-end test's exact pending-count assertion.
· branch claude/blacklist-check-transaction-submit-fa3ada

Comment thread src/Nethermind/Nethermind.Xdc/TxPool/XdcAcceptTxResult.cs Outdated
ak88 and others added 2 commits August 18, 2026 22:50
The rejection message was interpolated per transaction on a peer-reachable
path. Bake it into two statics instead, one per role, like every other pool
filter's preallocated AcceptTxResult.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ak88
ak88 marked this pull request as ready for review August 18, 2026 22:02
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ak88's task in 5m 14s —— View job


Review: reject blacklisted addresses on pool admission (3rd pass)

  • Gather the current diff (post perf(xdc): preallocate blacklist rejection results)
  • Check the notes from the previous pass
  • Review correctness / security / performance / repo rules
  • Post review

Nothing blocking. All prior findings are resolved; both notes from the last pass that were actionable in code (the per-rejection string allocation, and the dedicated non-Invalid result) are done. 3 inline comments, all Low.

What I re-verified on the current head

  • Types line up on the new head-number path. IChainHeadInfoProvider.HeadNumber is ulong, XdcChainSpecBasedSpecProvider.GetXdcSpec(ulong, ulong) takes ulong, and ForkActivation.BlockNumber is ulong — so HeadNumber + 1 and the f.BlockNumber == headNumber + 1 predicate in Accept_UsesSpecOfBlockAfterHead both type-check without a cast.
  • No NRE on BlackListedAddresses. XdcChainSpecBasedSpecProvider.CreateReleaseSpec assigns [.. chainSpecEngineParameters.BlackListedAddresses ?? []], so it's non-null for the real provider even when a chainspec sets BlackListHFNumber with no address list. The filter's own address is not null guard makes contract creation safe, matching HashSet<Address>.Contains(null) semantics that XdcTransactionProcessor.IsBlackListed already relies on.
  • Still exactly as strict as consensus. ValidateSender gates on xdcSpec.IsBlackListingEnabled and rejects on sender || target (XdcTransactionProcessor.cs:94-101) — same two addresses, same gate. No over-rejection at the pool relative to execution.
  • All TxPool construction sites compile against the new signature. InitializeBlockchain.cs:64 passes null positionally, Eth62ProtocolHandlerBenchmarks stops before the parameter, AuRa and XDC pass collection expressions, TxPoolTests uses the target-typed conditional. Nothing else in the repo constructs a TxPool.
  • No DI surprise from IIncomingTxFilter[]. Only three types implement the interface (TxFilterAdapter, SignTransactionFilter, BlackListedAddressFilter) and none is registered in a container, so the implicit Autofac array resolution yields an empty array — identical to the old null.
  • InitializeBlockchainXdc.CreateTxPool uses the method parameter, not the shadowed ctor field — the one InitializeBlockchain actually wires.
  • Production never hits the mutating GetXdcSpec fallback. XdcSpecProvider is a real XdcChainSpecBasedSpecProvider, so the extension takes the ConcurrentDictionary-cached branch (XdcExtensions.cs:50-51); the ApplyV2Config-mutating path at line 55 is the documented test-only fallback.
  • Rejection is allocation-free now. Both results are preallocated statics; the interpolated log string is behind IsDebug, and {result} goes through the generic AppendFormatted<T> so the struct isn't boxed.

Findings — all Low

# Finding
1 Debug-level discard log diverges from the Trace used by every built-in pool filter, on a peer-reachable path (inline)
2 BlackListedSender/BlackListedRecipient share id 1000, so the role assertions in both tests are vacuous (inline)
3 Public TxPool ctor signature change is source-breaking for out-of-tree plugins; PR is checkboxed non-breaking (inline)
4 Two carry-overs from the last pass, unchanged and still minor — see below

#4, briefly. The end-to-end test still asserts on the whole pool (GetPendingTransactions() Has.Length.EqualTo(...), line 126), so a stray tx from the five setup blocks becomes an unrelated failure; TryGetPendingTransaction(tx.Hash!, out _) asserts the same claim without that coupling. And the RPC error text is still "BlackListedAddress, Transaction sender is blacklisted" — the doubled CamelCase prefix is user-visible via EthRpcModule.cs:594, unlike the other two nameof()-based codes whose comments note they never reach a caller. Both are fine to leave.

One judgement call worth recording rather than re-litigating: the comment explaining why the id is 1000 was dropped in b3a6d8c. The "not Invalid, because that disconnects the relaying peer" half now lives in the README, which is a good home for it. The other half — that the id must stay clear of AcceptTxResult's own range because equality is id-based — is now nowhere near the constant it constrains. AcceptTxResult ids run 0–19 and 503, so a collision would take someone jumping to four digits; low risk, and the test asserting Is.Not.EqualTo(AcceptTxResult.Invalid) would catch the one collision that actually matters.

Not verified

Same as the previous two passes: dotnet isn't in this session's permitted tool set, so I could not build or run anything — everything above is static reading. CI remains the first execution of these tests.
· branch claude/blacklist-check-transaction-submit-fa3ada


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}.");


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.)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants