Skip to content

feat: add built-in portfolio viewer UI at /portfolio - #12360

Merged
Marchhill merged 123 commits into
masterfrom
feature/balance-viewer
Aug 10, 2026
Merged

feat: add built-in portfolio viewer UI at /portfolio#12360
Marchhill merged 123 commits into
masterfrom
feature/balance-viewer

Conversation

@Marchhill

@Marchhill Marchhill commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Changes

  • Adds a minimal, self-contained portfolio viewer UI served by the node at the /portfolio path of the JSON-RPC HTTP endpoint (no external assets — all data comes from the node's own JSON-RPC for privacy). Packaged as a new embedded plugin, Nethermind.PortfolioViewer.Plugin, disabled by default (opt in with --PortfolioViewer.Enabled true).
  • Unified multi-chain viewing (Ethereum, Gnosis, Sepolia, Hoodi, Chiado): the plugin probes localhost for sibling Nethermind nodes (/portfolio-nodes, proxied JSON-RPC via /portfolio-rpc/{port}), continuously re-discovering them.
  • NFT tracking and display.
  • Automatic token/NFT detection. Recommended to run with the log index enabled (--LogIndex.Enabled true) for usable detection performance, and with the LogIndex JSON-RPC module exposed so the UI can show index-build progress. Without the index it still works but falls back to a slower per-block bloom scan.
  • ENS name resolution (name.eth)
  • Subscribe to notifications about account activity.
  • Fiat conversion and token values via on-chain Chainlink price feeds, as well as Uniswap pools.

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

  • Unit tests in StartupTests cover serving the embedded page on plain HTTP ports and 404 on authenticated/unknown ports.
  • The in-page keccak-256/namehash was verified against EIP-137 vectors and cross-checked against Python's SHA3-256 for the multi-block absorb path.
  • UI exercised end-to-end in a browser against a mocked JSON-RPC node (ENS pinning, token tracking with metadata prefill, balance rendering, persistence across reloads).

Documentation

Requires documentation update

  • Yes
  • No

New PortfolioViewer.Enabled config option (disabled by default) and the /portfolio page should be mentioned in the docs. The docs should also recommend running with the log index enabled (--LogIndex.Enabled true) and the LogIndex JSON-RPC module exposed for good detection performance.

Requires explanation in Release Notes

  • Yes
  • No

Nethermind now ships a built-in portfolio viewer at the /portfolio path of the JSON-RPC HTTP endpoint (native + ERC-20 balances, NFTs, ENS support), using only local node data. Disabled by default; enable with --PortfolioViewer.Enabled true.

For good auto-detection performance it is recommended to also enable the log index (--LogIndex.Enabled true) and expose the LogIndex JSON-RPC module.

Adds a minimal, self-contained balance viewer page served by the node
at the /balances path of the JSON-RPC HTTP endpoint, gated by the new
Init.BalanceViewerEnabled config option (enabled by default, never
served on authenticated Engine API ports).

The page tracks pinned addresses (native + manually added ERC-20
tokens) using only same-origin JSON-RPC data, supports simple ENS
forward resolution (in-page keccak-256/namehash), and covers Ethereum
and Gnosis networks including testnets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marchhill and others added 2 commits July 10, 2026 01:46
Prices are read on-chain from the node itself: the Chainlink Feed
Registry on mainnet (WETH/WBTC aliased to ETH/BTC) and verified
aggregator addresses on Gnosis. A currency selector converts USD
values via Chainlink FX feeds (EUR/GBP/JPY/CHF on mainnet; EUR/JPY/CHF
on Gnosis). Each balance row shows its fiat value and the header shows
the grand total; stale (>48h) or missing feeds degrade to no fiat.
Popular tokens are seeded on first visit but remain removable.
Testnets have no fiat layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Use the Nethermind logo in the header (text fallback when static
files are off), currency symbols outside the dropdown, and +/− icon
buttons for pinning addresses and tracking tokens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@asdacap

asdacap commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Wrap in a plugin?

Marchhill and others added 8 commits July 10, 2026 10:57
Cache the last fetched balance and fiat value per address/asset so
unpinning or removing a token re-renders from cache instead of
flashing placeholders until the next poll, and recompute totals
locally without a refetch. Also add a per-address fiat total row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bright accents on the dashboard's black background: pink names,
purple addresses, green amounts, orange totals, grey per-row fiat.
Tokens and accounts get stable per-entity colors hashed from the
ticker/address (cyan reserved for the native asset, WBTC pinned red).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves the balance viewer into a new Nethermind.BalanceViewer.Plugin
embedded plugin. The page is served by a middleware injected through
IJsonRpcServiceConfigurer/IStartupFilter (same pattern as the SSZ REST
API), and the Init.BalanceViewerEnabled option becomes
BalanceViewer.Enabled (still on by default), so disabling the plugin
removes the endpoint entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Track ERC-721 collections per pinned address: owned counts via
balanceOf, and for ERC721Enumerable collections thumbnails of fully
on-chain artwork (data: tokenURIs, e.g. Nouns/Loot) decoded straight
from eth_call — external metadata URIs are never fetched. Thumbnails
expand in pages, collapse back, and open full-size in a new tab via a
blob URL. Fiat currencies now cover every Chainlink feed available
(12 on mainnet, 4 on Gnosis, ordered by popularity), with FX feed
decimals fetched rather than assumed since PHP/USD uses 18. Also:
clipboard fallback for insecure (plain-HTTP) contexts, click-to-copy
token addresses, and pinned viewport scale on mobile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bell on each pinned address watches it for activity: new blocks are
scanned via the node (block transactions plus ERC-20 Transfer logs)
and events — sent/received, swaps when both legs are visible in one
transaction, contract interactions — surface as system notifications
through a service worker, so installed (home-screen) web apps get
native notifications. No push service is involved: subscribing to one
would route activity through third-party servers, so scanning happens
in-page and data never leaves the node. The middleware now also
serves the embedded service worker at /balances-sw.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rows with a zero balance (including NFT collections with no owned
tokens and their thumbnail rows) can be hidden with a persisted
toggle in the watch panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the labeled checkbox with a single ∅ icon button in the top
bar that lights up in the accent color when active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plugin probes localhost ports (BalanceViewer.SiblingProbePorts)
for sibling Nethermind nodes on other chains, lists them at
/balances-nodes, and proxies their JSON-RPC through /balances-rpc/
{port}, so the browser only ever talks to the serving node. When
siblings are detected the header swaps the chain/head text for
per-chain logo toggles with a syncing indicator, cards merge rows
across enabled chains with superscript chain tags on everything but
mainnet, and totals/currencies span all enabled chains. With a single
node the UI is unchanged. Dockerfile.multichain plus the workflow's
multi_chain input run a second node (gnosis by default) inside the
same container for testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marchhill and others added 16 commits July 11, 2026 02:56
Chain buttons show the logo only (tooltip carries name/head/sync), the
syncing indicator is a centered ring spinner instead of a glyph, and
chain visibility persistence stores disabled ids so newly discovered
chains default to visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slower rotation, muted color, and absolutely positioned above the
logo so the button keeps its size when syncing finishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Border-box sizing with exact offsets centers the ring on both axes
over the logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CCY/USD rate is chain-agnostic, so it is resolved once from the
first enabled chain with a fresh feed for the selected currency and
applied to all chains' USD values. Synced chains thereby cover each
other's currencies and the full union of the dropdown works on every
row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
IJsonRpcUrlCollection is created by the StartRpc step and registered
only in the JSON-RPC web host's MS DI container, so constructing
SiblingNodeRegistry from Autofac threw during runner startup and
killed the whole JSON-RPC server. Build the registry in MS DI instead,
bridging the plugin's Autofac dependencies through the configurer.
Verified with two live nodes: discovery lists the sibling and the
proxy forwards its RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gnosis secondary in Dockerfile.multichain is now supervised (a
crash no longer removes the chain permanently) and given a small
memory hint so it does not compete with the syncing primary. The
sibling registry keeps recently seen nodes through transient probe
failures, and the page re-discovers siblings periodically so a node
that starts or restarts after page load joins the multi-chain view
without a reload (with an unreachable marker on dead chains).
Notifications: serve a web app manifest and icon so the page installs
as a proper home-screen app, await service worker readiness, fire a
test notification when the bell is enabled, and explain clearly when
system notifications are unavailable (plain-HTTP origins are not a
secure context, so browsers refuse them outside HTTPS/localhost).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The web-app icon now uses the complete Nethermind emblem (the
previous crop cut the left ring). Balances of a syncing or
genesis-stuck chain display as '—' and stay out of totals instead of
reading misleading zeros from stale state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MXN, TRY, IDR and ARS have standalone mainnet Chainlink aggregators
that the feed registry does not list; they are added via the same
direct-feed fallback Gnosis uses, completing every fiat Chainlink
serves on mainnet (16 + USD).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nethermind keeps returning an eth_syncing object during background
stages (old bodies/receipts backfill, state-heal tail) long after it
is processing blocks at head, which left balances showing as syncing
for hours. Judge by distance from head instead: only a node at
genesis or more than 16 blocks behind its target displays dashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Activity notifications resolve token symbols/decimals on demand so
transfers read "sent 250 USDC" even for untracked tokens, describe
ERC-721 transfers by token id, and detect approvals and contract
deployments. Notifications land in a persisted in-page feed with
relative timestamps and per-item dismissal (system notifications
unchanged), tagged with the chain when several are shown. Accounts
can be named via a pencil icon next to the bell. Chain tags and a
matching dot on the toggle buttons get stable per-chain colors so
same-ticker chains (e.g. Hoodi and Sepolia) stay distinguishable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A node can report eth_syncing=false yet stop following the chain (e.g.
its consensus client died), leaving balances silently frozen. Detect a
head that has not advanced for 90s and show the sync spinner with a
"stalled (head not advancing)" tooltip (and a "· stalled" suffix in the
single-node header); it clears automatically once the head moves again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
eth_syncing returns false during full-sync catch-up of a backlog (not
just at head), so a node replaying blocks after downtime showed no
sync indicator despite being far behind. Judge sync state by how old
the latest block is: a caught-up node's head is always seconds fresh,
so a head older than 180s means the node is behind the tip. This one
signal subsumes catch-up, at-head background stages, snap sync, and
stalled heads. Both poll paths now read the latest block header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ENS was assumed per chain via a static flag, but many testnets (e.g.
Hoodi) do not have the registry deployed at the canonical address, so
name input was offered and then failed confusingly. Probe the registry
for contract code on connect and enable ENS only where it exists;
resolution routes through any enabled chain that has it (mainnet first).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marchhill and others added 4 commits July 20, 2026 15:41
…trim comments

Renames the project, namespace, types, config category (`PortfolioViewer.Enabled`),
plugin name, and on-disk data files (portfolio-viewer-*.json) so the naming is
consistent with the `/portfolio` UI it serves. Also condenses/removes verbose
comments across the plugin. No behavioural change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cuts the remaining verbose comments across the plugin (configurer, detection cache
and scanner, pin store, service worker) down to concise one-line "why" notes, and
removes the unrelated doc comment this PR had added to AuRaBlockStoreDecoderTests
(reverting that file to master).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Narrows the plugin's exception handling to the types that can actually occur:
- IPFS gateway/RPC calls: HttpRequestException / OperationCanceledException (+ IOException on the streamed gateway GET)
- cache and pin-store file I/O: IOException / UnauthorizedAccessException (+ JsonException on load)
- sibling probe: HttpRequestException / JsonException / IOException
- detection history-floor: ResourceNotFoundException (what LogFinder throws below the retained receipts)

The scanner's top-level per-chunk catch stays broad by design: it's the background-task
boundary that must clean up _active and log any unexpected failure. Updates the pruned-history
test to throw the real ResourceNotFoundException.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…in.Test

The portfolio tests lived in Nethermind.Runner.Test, which pulled the ClearScript
V8 packages (needed only by the page-script tests) into the runner test project and
broke the repo convention of a dedicated <Plugin>.Plugin.Test project. Move the five
test files into a new Nethermind.PortfolioViewer.Plugin.Test project (added to the
solution and the CI test matrix), drop the ClearScript references from Runner.Test,
and trim the now-redundant usings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Marchhill and others added 2 commits July 22, 2026 12:11
Removes the OP Mainnet mark this PR added under wwwroot/logos and renders the
Optimism network button with its "OP" text tag instead, so the repo doesn't
redistribute a third-party trademark under its own license. renderChainToggles now
falls back to a text label for any chain without a bundled logo. Also renames the
scanner's MaxContracts -> MaxContractsPerScan to distinguish it from the cache's
per-list cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Optimism "OP" text label sized to its content, making that toggle a different
width from the 20x20 logo buttons. Box the chain-btn text label to the same 20x20
so all chain toggles are uniform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Marchhill
Marchhill marked this pull request as ready for review July 22, 2026 14:08
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 2m 19s —— View job


Re-review: Portfolio Viewer plugin (#12360)

I re-checked the code against the five substantive findings from the previous review (2 High, 3 Medium) plus the Low/naming items. All are resolved, and the fixes are covered by tests. No new blocking issues.

  • Verify prior High findings
  • Verify prior Medium findings
  • Fresh pass over backend correctness/security/perf

Prior findings — status

# Prior finding Status
1 (High) UnpinAllAsync wiped the entire Kubo pinset ✅ Fixed — PinnedCidStore now tracks only CIDs this plugin pinned; UnpinAllAsync iterates pins.Snapshot() and pin/rms exactly those (PortfolioViewerConfigurer.cs:247-260, PinnedCidStore.cs).
2 (High) Documented disable flag didn't exist ✅ Fixed — interface renamed to IPortfolioViewerConfig → category PortfolioViewer, so keys are PortfolioViewer.Enabled / PortfolioViewer.SiblingProbePorts, matching the PR body, release notes, and README. Plugin now consistently named PortfolioViewer throughout.
3 (Medium) No CSRF/origin protection on side-effecting endpoints ✅ Fixed — IsCrossOrigin guard (:110-158) rejects state-changing requests (proxy, detect POST/DELETE, pin, unpin-all) carrying a foreign Origin with 403; covered by SideEffecting_CrossOriginRequest_Rejected / _SameOriginRequest_Allowed.
4 (Medium) Unvalidated rel in IPFS gateway proxy ✅ Fixed — IsSafeIpfsRef (:161-164) enforces a charset and rejects ./.. segments; applied to both the gateway rel and the pin CID. Covered by Ipfs_InvalidRef_Returns400.
5 (Medium) Full-file rewrite on every Put ✅ Fixed — DetectionCache now coalesces writes with a 2s leading/trailing throttle (:135-165).
6–8 (Low) README path/key, naming ✅ Fixed — README documents /portfolio and PortfolioViewer.*; naming unified.

Fresh pass — observations (non-blocking)

  • DetectionCache.RequestSave runs the leading-edge Save() (file I/O) while holding _saveGate (DetectionCache.cs:143-144), briefly blocking other Put callers. Bounded to once per 2s window, so acceptable — noting only.
  • IsCrossOrigin treats an absent Origin as same-origin (:156). Correct for browser CSRF (browsers attach Origin on cross-origin state-changing requests); non-browser clients like curl are intentionally out of scope. Good.
  • Proxy targets are constrained to discovered siblingsProxyAsync/ProxyDetectAsync only fire for IsKnownSibling(port) on 127.0.0.1, and sibling ports come from the configured probe list, so there's no arbitrary-port SSRF. Good.
  • Fire-and-forget tasks (PinAsync, UnpinAllAsync, ScheduleTrailingFlushAsync) are documented as intentional and each handles its own exceptions — no async void, no swallowed faults.
  • Detection scanner runs via IBackgroundTaskScheduler at BelowNormal priority with adaptive chunking, cancellation, and a MaxContracts cap — no consensus/hot-path impact, unchanged from the prior review's assessment.

Recommendation

Mergeable. All previously-raised High and Medium items have been fixed and are backed by tests. Remaining notes are minor and non-blocking.
· branch feature/balance-viewer

@Marchhill
Marchhill requested a review from benaadams July 22, 2026 14:16
Comment thread src/Nethermind/Nethermind.PortfolioViewer.Plugin/DetectionScanner.cs Outdated
…-empted

The forward-phase cancellation handler persisted the previous contract sets and
dropped fErc20/fNfts, discarding contracts discovered before pre-emption. Persist
the accumulated sets instead (matching the downward phase), so a pre-empted forward
chunk doesn't throw away already-completed work; the upper bound is still not
advanced, so the range is retried. Adds a regression test.

Reported-by: AnkushinDaniil

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Marchhill
Marchhill requested a review from AnkushinDaniil July 29, 2026 12:43

@benaadams benaadams left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the current head while accounting for prior feedback. The earlier plugin-structure, configuration naming, IPFS path validation, cache-write throttling, and cancellation-persistence concerns appear resolved; the remaining issues are called out inline.

using HttpResponseMessage resp = await IpfsClient.GetAsync(
$"{IpfsGateway}/ipfs/{rel}", HttpCompletionOption.ResponseHeadersRead, cts.Token);
context.Response.StatusCode = (int)resp.StatusCode;
if (resp.Content.Headers.ContentType is not null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This serves arbitrary IPFS bytes with their upstream Content-Type under the portfolio/JSON-RPC origin. A malicious HTML or SVG CID can therefore execute with same-origin access to portfolio storage and RPC; IPFS path gateways explicitly lack origin isolation. Please use a separate/subdomain origin, or sandbox responses and allowlist safe media types with nosniff; GETs should also reject cross-site fetches so foreign pages cannot drive the local Kubo gateway. See IPFS gateway guidance.

{
using HttpResponseMessage resp = await IpfsClient.PostAsync($"{IpfsApi}/api/v0/pin/add?arg={Uri.EscapeDataString(cid)}", content: null);
// track only pins we added, so unpin-all reclaims exactly these
if (resp.IsSuccessStatusCode) pins.Add(cid);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A successful pin/add does not prove that this plugin created the pin: Kubo also succeeds when a CID is already pinned. Recording every success means “unpin all” can remove a pin the operator created independently. Check the exact pin state before adding and record only newly created pins, or avoid automatically removing ownership-ambiguous pins.

try { using HttpResponseMessage _ = await IpfsClient.PostAsync($"{IpfsApi}/api/v0/pin/rm?arg={Uri.EscapeDataString(cid)}", content: null); }
catch (Exception e) when (e is HttpRequestException or OperationCanceledException) { }
}
pins.Clear();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

pins.Clear() runs even when pin/rm fails or returns an error, and it can erase a pin added concurrently after Snapshot(). That permanently loses the ability to retry cleanup. Remove each CID from the store only after a successful unpin, retain failures, and serialize pin/unpin operations or use an atomic drain protocol.

return;
}

scanner.RequestScan(post.ChainId, account);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This unauthenticated endpoint lets the caller schedule a full-history scan for any chain ID and address. The supplied chain ID is only used in _active/cache keys while the scanner always queries the local chain, so arbitrary IDs bypass deduplication and can keep the background queue full. Derive or validate the local chain ID and bound/rate-limit unique account scans.

long head = (long)(blockFinder.Head?.Number ?? 0);
DetectionEntry? entry = cache.Get(chainId, account.ToString());
// skip only if history is fully covered AND no new blocks arrived; else re-scan the forward gap
if (entry is { Complete: true } && entry.Head >= head) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A numeric head alone cannot establish that this cache is current. After a same-height or deeper reorg, replacement blocks at or below entry.Head are never scanned, so newly introduced token contracts can be missed indefinitely. Persist and revalidate the canonical block hash, or rescan an overlap/finality window on every update.

{
if (entry.Contracts.Count > _maxContractsPerEntry)
{
entry = entry with { Contracts = entry.Contracts.Take(_maxContractsPerEntry).ToArray() };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Repository coding rules avoid LINQ where straightforward loops work. Please replace these Take/allocation pipelines with an explicit bounded copy; the same rule applies to the new All/Any/Select usage in PortfolioViewerConfigurer and the LINQ pipelines in SiblingNodeRegistry.


namespace Nethermind.PortfolioViewer.Plugin;

public interface IPortfolioViewerConfig : IConfig

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The new public config/plugin contracts need XML documentation under the repository rules. Please document the public interfaces and members and use <inheritdoc/> on their implementations; this also applies to the undocumented IPinnedCidStore/IDetectionCache methods and public plugin/module types.

public void LooksLikeSpam_FlagsPromoUrlAndAbsurdTokens()
{
using V8ScriptEngine engine = CreateEngine();
Assert.Multiple(() =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The test rules require NUnit 4’s using (Assert.EnterMultipleScope()) rather than legacy Assert.Multiple; please update all six occurrences in this file. Independent result assertions in DetectionScannerTests and PortfolioViewerMiddlewareTests should likewise use a multiple scope.

[TearDown]
public void TearDown()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort cleanup */ }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This blanket catch hides failed cleanup and can leave temporary state that contaminates later test runs. Let teardown fail when deletion fails, or log a narrowly caught expected exception.

// Owned token ids come from tokenOfOwnerByIndex for ERC721Enumerable, or from
// owner-filtered Transfer logs for plain ERC-721 and ERC-1155 (then confirmed via
// ownerOf / balanceOf). Only fully on-chain metadata (data: URIs, e.g. Nouns) is
// rendered — external URIs are never fetched, so no request leaves the node.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This comment says external URIs are never fetched, but the current implementation performs opt-in off-chain metadata and artwork requests. Please update it to describe the actual privacy behavior.

@benaadams

Copy link
Copy Markdown
Member

@claude confirm or refute the feedback from me

@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 3m 58s —— View job


Adjudicating @benaadams' review feedback

  • Read backend files (PortfolioViewerConfigurer, DetectionScanner, DetectionCache, PinnedCidStore, SiblingNodeRegistry)
  • Read frontend regions (portfolio.html metadata cache / refresh gating / privacy comment)
  • Read tests + repo rules (test-infrastructure.md, coding-style.md, robustness.md)
  • Adjudicate each of the 13 inline findings

Verdict: 11 of 13 confirmed as written; 2 confirmed with a qualification. I found nothing to refute outright. Details below, with the extra evidence I turned up.


Confirmed

1. IPFS proxy serves untrusted bytes with upstream Content-Type under the JSON-RPC originPortfolioViewerConfigurer.cs:194
Confirmed, and this is the most serious item in the set. context.Response.ContentType = resp.Content.Headers.ContentType.ToString() forwards whatever Kubo reports; there is no X-Content-Type-Options: nosniff, no CSP, and no Content-Disposition. An NFT whose tokenURI resolves to an HTML or SVG CID renders as script in the same origin as /portfolio, which holds the watched-address list in localStorage, the IndexedDB metadata store, and same-origin POST / access to unauthenticated JSON-RPC (including whatever modules the operator enabled). An <iframe src="http://localhost:8545/portfolio-ipfs/<cid>"> from any page reaches it — the attacker can't read the frame, but the script inside it runs with that origin's privileges and can exfiltrate. Delivery doesn't even need social engineering: an unsolicited NFT transfer is enough to get the CID into pinIpfs/resolveArtUri.

Your second half also holds: isIpfs is deliberately excluded from isSideEffecting at :112, so the GET path has no origin check. A foreign page can drive the local Kubo node into fetching arbitrary network content (<img>/no-cors fetch), and response timing leaks whether a CID is already local.

Fix this →

2. A successful pin/add does not prove this plugin created the pin:232
Confirmed. Kubo's pin/add is idempotent: pinning an already-pinned CID returns 200 with the CID in Pins, so if (resp.IsSuccessStatusCode) pins.Add(cid) records operator-created pins that happen to coincide with viewed art. Unpin-all then removes them.

Related, and not covered by your comment: UnpinAllAsync finishes with repo/gc (:258), which is repo-wide — it evicts all unpinned blocks on the user's node, not just the ones this plugin dropped. That's a narrower survivor of the original "wipes the pinset" finding, and it should probably be dropped or made opt-in regardless of how #2 is resolved.

3. pins.Clear() runs unconditionally:257
Confirmed, and slightly worse than described: pin/rm's response status is never inspected at all (using HttpResponseMessage _ = await ...), so a 500 from Kubo is indistinguishable from success. Combined with the swallowed HttpRequestException, the store is cleared even when nothing was unpinned, and the CIDs become permanently unreclaimable. The Snapshot()Clear() window also drops any concurrent PinAsync addition, leaking an untracked pin.

4. Unauthenticated /portfolio-detect accepts an arbitrary chain ID:306
Confirmed. post.ChainId is used only for _active/cache keys; DetectionScanner always queries the local logFinder/blockFinder, so distinct IDs are pure key-space inflation that bypasses the _active dedup and pushes work into the background queue. Concrete impact you didn't spell out: DetectionCache is capped at DefaultMaxEntries = 10_000 with LRU-by-UpdatedMs eviction, so a flood evicts real users' scan cursors and forces full re-scans.

Two qualifications, neither of which changes the conclusion: the IsCrossOrigin guard at :113 does cover isDetectPost, so browser drive-by is blocked and the exposure is any local process (or anything remote if the RPC port is bound non-loopback); and since Address.TryParse accepts 2^160 addresses, flooding works with the correct chain ID too — so the bound/rate-limit half of your recommendation is the load-bearing part, chain-ID validation is the cheap correctness fix.

5. A numeric head can't establish cache currency across reorgsDetectionScanner.cs:61
Confirmed. entry.Head >= head skips, and the forward phase resumes at existing.Head + 1, so an equal-height or deeper reorg permanently skips the replacement blocks. Impact is bounded — this is an additive discovery cache, so the failure is a missed token rather than wrong balances — but it is a real permanent gap. The inverse also holds: contracts discovered in a reorged-out block are never removed.

6. Forward phase doesn't handle ResourceNotFoundException:95
Confirmed. The forward try catches only OperationCanceledException; LogFinder.cs:64/:70 throw ResourceNotFoundException("Receipt not available for From block …"), which falls through to the outer catch at :148. It doesn't hard-loop within a single run, but the effect is what you describe across polls: _active is cleared, the entry is never marked Complete, the client re-triggers, and the same unavailable range is retried indefinitely — at Warn each time.

One point in your favour that the comment understates: because the forward block at :87 precedes the existing?.Complete check and the downward walk, a permanently-failing forward range blocks the downward history scan entirely, not just the forward gap. The downward path already handles this correctly at :134, which makes the asymmetry look unintentional.

7. NFT metadata cache returns before revalidating tokenURIportfolio.html:1947
Confirmed. idbGetManyMeta is keyed chainId|collection|id and short-circuits at :1948 before the SEL_TOKEN_URI batch at :1959. The persistence gate at :1996 (mk === 'on-chain' || mk === 'IPFS') conflates CID immutability with mapping immutability — a reveal or dynamic NFT returns a different ipfs:// CID for the same id, and the stale entry is then never displaced. The comment at :1663 ("immutable, so nothing can go stale") states the incorrect invariant explicitly and should be corrected along with the behaviour.

8. Count-based refresh signature misses equal-count ownership changesportfolio.html:2682
Confirmed on both halves. For ERC-721, sig = erc721Count, so a transfer-out plus transfer-in between refreshes leaves the count unchanged and continue skips re-discovery — held ids stay wrong for the session. For ERC-1155, sig = 'x' is constant, so every refresh after the first is a no-op; the comment at :2679-2680 acknowledges that as a deliberate trade-off, but the ERC-721 case reads as an oversight rather than a trade-off.

9. LINQ where a loop would doDetectionCache.cs:66
Confirmed against coding-style.md ("No LINQ when a simple for/foreach works — use LINQ only for complex queries"). 13 sites: DetectionCache.cs:66,70; PortfolioViewerConfigurer.cs:163,164,172; SiblingNodeRegistry.cs:58,59,60,73,80,85. Worth noting IsSafeIpfsRef (:163-164) is on the request path and allocates a string[] per call via Split('/') plus a closure per char — a single foreach over the span with a running segment check does the same job allocation-free, so this one is more than style.

11. Assert.EnterMultipleScope() over Assert.MultiplePortfolioViewerScriptTests.cs:156
Confirmed verbatim against .agents/rules/test-infrastructure.md:69: "wrap them in using (Assert.EnterMultipleScope()) { ... } (the NUnit 4 form; prefer this over the older Assert.Multiple(() => { ... }) lambda)." Six occurrences: :156, 170, 213, 229, 564, 600. The second half of your comment (adding scopes in DetectionScannerTests, 27 bare Assert.That, and PortfolioViewerMiddlewareTests, 18) is a per-group judgement call rather than a rule violation, but the same rule notes the dedupe-into-a-helper preference, which those files would benefit from.

13. Stale privacy commentportfolio.html:1647
Confirmed. "Only fully on-chain metadata (data: URIs, e.g. Nouns) is rendered — external URIs are never fetched, so no request leaves the node" is contradicted ~320 lines later by fetchOffchain(murl, 3) at :1968 and the pinIpfs calls at :1984. Per coding-style.md, a comment that contradicts the code is worse than none.


Confirmed with a qualification

10. XML documentation on public contractsIPortfolioViewerConfig.cs:8
Confirmed for IPinnedCidStore (PinnedCidStore.cs:14-16), IDetectionCache (DetectionCache.cs:19-22), and the public PortfolioViewerPlugin/PortfolioViewerModule/PortfolioViewerConfig types — all undocumented, and coding-style.md asks for doc comments on all public APIs. Also worth flagging while there: those three are the only non-sealed public types in the plugin.

The qualification is the anchor you chose. IConfig interfaces in this repo document themselves through [ConfigItem(Description=…, DefaultValue=…)] and carry no XML docs — ITxPoolConfig has zero <summary> tags, IJsonRpcConfig likewise. IPortfolioViewerConfig already has Description + DefaultValue on both members, so it matches the established convention; requiring <summary> there specifically would be a new bar rather than an existing rule.

12. Blanket catch in test teardownPortfolioViewerPinStoreTests.cs:25
Confirmed on the rule, refuted on the stated rationale. robustness.md prohibits swallowing exceptions in a bare catch, and this branch already has commit 8594dad "catch specific exceptions instead of bare catch" — so consistency clearly argues for catch (Exception e) when (e is IOException or UnauthorizedAccessException) plus a TestContext.Out line here too.

But the contamination mechanism doesn't apply: _dir is Path.Combine(Path.GetTempPath(), "bv-pins-" + TestContext.CurrentContext.Test.ID), unique per test, so a leaked directory can't be observed by any other test. And "let teardown fail when deletion fails" would trade a harmless temp-dir leak for CI flakiness on Windows, where a transient handle on the just-written portfolio-viewer-pins.json fails the delete. Narrow the catch and log — don't make it throw.


Summary

# Finding Verdict
1 IPFS proxy content-type / origin isolation ✅ Confirmed — most serious of the set
2 pin/add success ≠ pin ownership ✅ Confirmed (+ repo-wide repo/gc)
3 Unconditional pins.Clear() ✅ Confirmed (+ status never checked)
4 Unauthenticated scan, arbitrary chain ID ✅ Confirmed (+ LRU cache eviction)
5 Numeric head vs reorg ✅ Confirmed
6 Forward phase misses ResourceNotFoundException ✅ Confirmed (+ blocks downward walk)
7 Metadata cache not revalidated vs tokenURI ✅ Confirmed (+ wrong invariant in comment)
8 Count-based NFT refresh signature ✅ Confirmed
9 LINQ vs loops ✅ Confirmed (13 sites)
10 XML docs on public contracts ⚠️ Confirmed except for IConfig, where ConfigItem is the repo convention
11 Assert.EnterMultipleScope() ✅ Confirmed (6 occurrences)
12 Blanket catch in teardown ⚠️ Rule confirmed; contamination rationale doesn't apply (per-test unique dir)
13 Stale privacy comment ✅ Confirmed

I've only adjudicated here — no code changed. Say the word and I'll implement any subset; #1, #3 and #6 look like the ones worth doing first.
· branch feature/balance-viewer

Security / correctness:
- IPFS gateway GET now serves inert content: X-Content-Type-Options: nosniff, a
  `default-src 'none'; sandbox` CSP, a safe media-type allowlist (others become an
  opaque download), and rejects cross-site fetches (Sec-Fetch-Site) so a foreign
  page can't drive the local Kubo gateway or run an HTML/SVG CID same-origin.
- Auto-pin only records CIDs it actually created (checks pin/ls first, since pin/add
  succeeds on an already-pinned CID), so unpin-all never removes an operator's pins.
- Unpin-all drops each CID from the store only after its unpin succeeds (new
  IPinnedCidStore.Remove), retaining failures and never erasing a concurrent add.
- The detect endpoint's chain id is validated against the local chain, so an
  unauthenticated caller can't fill the scan queue with junk-id (un-deduplicated) scans.
- Detection forward pass re-scans a reorg-overlap window below the last head, and
  handles the retained-history floor, so shallow reorgs surface and an offline gap
  below the receipts floor no longer retries forever.
- NFT metadata cache gets a bounded TTL (tokenURI mapping can change on reveal), and
  held-id ownership is periodically re-discovered (equal ERC-721 counts hide swaps;
  ERC-1155 had no refresh signal at all).

Style / docs (repo rules):
- Replaced LINQ pipelines with loops in DetectionCache, PortfolioViewerConfigurer and
  SiblingNodeRegistry.
- Added XML docs to the public config/plugin/module and cache/pin-store contracts.
- Tests use `using (Assert.EnterMultipleScope())`; pin-store teardown no longer
  swallows cleanup failures.
- Corrected the NFT privacy comment to describe the opt-in off-chain fetch behaviour.

Adds regression tests for the pin-store Remove, chain-id gating, reorg overlap and
forward-phase history floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Marchhill
Marchhill force-pushed the feature/balance-viewer branch from 1e9f0aa to 25bf9a1 Compare August 10, 2026 13:58
@Marchhill
Marchhill merged commit 76c7cdb into master Aug 10, 2026
1097 of 1099 checks passed
@Marchhill
Marchhill deleted the feature/balance-viewer branch August 10, 2026 14:51
AnkushinDaniil added a commit that referenced this pull request Aug 13, 2026
* fix(rpc-bench): run benchmarked nodes the way production runs them (#12625)

* diag(rpc-bench): run Nethermind with production-default runtime (no TC=0 pin)

* reword comment for master

* review: document expb divergence, add NODE_ENV_VARS escape hatch

- README: the 'Alignment with expb' section no longer claims the removed
  env pins; documents the deliberate code-gen divergence and that JIT
  warm-up now lands inside the measured window; dotTrace reports are not
  comparable across this change
- start-node.sh: reword comment (no warm-up phase exists yet), add
  NODE_ENV_VARS passthrough for deliberate one-off code-gen experiments

* trim comments to one-liners; rationale stays in the PR

* drop the Merge GC flags: inert here and misleading

GCKeeper only runs on Engine API calls; this harness parks the node at a
snapshot head and never sends newPayload, so the three flags changed
nothing while implying the node ran a non-production GC configuration.

* keep the image entrypoint for Nethermind

The override skipped entrypoint.sh, which applies host tuning and enables a
shipped PGO profile. Its comment claimed parity with expb, but expb only
overrides the entrypoint for dotTrace, so normal expb runs (and production)
do run entrypoint.sh - this harness was the outlier.

* Rename EIP-8037 regular gas dimension to execution gas (#12600)

* Auto-update fast sync settings (#12665)

Co-authored-by: rubo <rubo@users.noreply.github.com>

* refactor(net): namespace snap by version (#12606)

* refactor(net): namespace snap messages by version

Prepare the snap subprotocol for a second version by moving the concrete
snap/1 messages, message codes and protocol handler into a versioned
namespace, so a snap/2 handler can be added alongside without touching
snap/1 code.

  Snap/Messages/*            -> Snap/V1/Messages/*
  Snap/SnapMessageCode       -> Snap/V1/Snap1MessageCode
  Snap/SnapProtocolHandler   -> Snap/V1/Snap1ProtocolHandler
  P2P/P2PMessageKey.cs       -> P2P/VersionedProtocol.cs  (file renamed to
                                match the type it declares)

SnapMessageBase and SnapSerializerBase deliberately stay in
Snap.Messages: they are shared by all protocol versions, not specific to
snap/1.

Introduce SnapVersions constants and ISnapSyncPeer.SnapProtocolVersion so
version checks can replace the magic numbers currently spelled as 1.

PeerInfoExtensions.CanGetSnapData is renamed to CanGetTrieNodes to say
what it actually tests. The rename is nominal - the peer probe is
unchanged, so behaviour is identical.

No functional change.

* refactor(net): remove Snap2 version constant from SnapVersions

* address review comments

* rename

* feat(sync): serve block access lists from the snap server (#12607)

* Refactor SnapServer and SnapStateServer integration

- Renamed SnapServer to SnapStateServer for clarity and consistency.
- Updated WorldStateManager to use SnapStateServer instead of SnapServer.
- Modified StateSyncFeedTestsBase to accommodate changes in SnapServer instantiation.
- Adjusted SnapProviderTests to reflect the new SnapStateServer type.
- Introduced SnapServerTests to validate SnapServer functionality.
- Added SnapStateServerTests to ensure robust testing of state management.
- Implemented new methods in SnapServer for handling bytecode and block access lists.
- Enhanced test coverage for account range retrieval and storage management.

* refactor: change SnapServer field type to interface ISnapServer

* test: enhance SnapServerTests with additional block access list scenarios

* chore: Update Dockerfiles (#12663)

Update Dockerfiles

Co-authored-by: rubo <rubo@users.noreply.github.com>

* test: make prewarmer env-return assertion pool-hit independent (#12616)

PreWarmCaches_ReturnsAddressWarmEnvWhenScopeBuildThrows asserts
Returned == Created, but with maxPoolSize 1 an env returned to the pool
can be rented again without a Create, so a pool hit legitimately
increments Returned twice against a single Create. Whether a hit occurs
depends on worker interleaving: flaky on CI, fails deterministically on
high-core machines (0/30 locally).

ThrowingBuildPolicy.Return now refuses retention, so every rental is a
fresh Create and the invariant holds under any interleaving (30/30
locally). Stack-trace instrumentation confirmed every rental is returned
exactly once - the prewarmer itself is correct.

* Update OP Superchain chains (#12664)

Co-authored-by: emlautarom1 <emlautarom1@users.noreply.github.com>
Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>

* fix(receipts): restore the post-merge flag before regeneration (#12641)

* fix(receipts): restore the post-merge flag before regeneration

Stored headers do not carry IsPostMerge and regeneration bypasses the
recovery step that restores it, so post-merge blocks re-executed with
PREVRANDAO evaluating to the zeroed difficulty - any transaction reading
it produced receipts failing the root check (~7% of post-merge blocks
refused on mainnet archives deriving receipts from state).

* test(receipts): dispose buffer, pin logged value

* fix(receipts): classify post-merge via the switcher

A difficulty heuristic misreads chains that repurpose the field -
Taiko stores per-block ZK gas in Difficulty while AlwaysPoS - so ask
IPoSSwitcher instead, mirroring MergeProcessingRecoveryStep. On
mainnet the switcher's TD-null branch is the same difficulty check,
so behavior there is unchanged.

* test(receipts): pin the real switcher's TD-null derivation

The production failure arrived as a mainnet-shaped header with
TotalDifficulty unset; cover PoSSwitcher's TD-null branch end to end,
not only the honour-the-switcher contract.

* test(receipts): cover the switcher registration path

A hand-injected switcher cannot catch a composition regression that
leaves the container-resolved regenerator on the NoPoS default, so
resolve it from a graph whose IPoSSwitcher registration is overridden
the way a merge-enabled node overrides it.

* fix(tests): mark RecoverReceiptsBlockchain.Create as hiding

* Expose the node's ENR in admin_nodeInfo (#12631)

feat(rpc): expose the node's ENR in admin_nodeInfo

Nethermind is the only discv5-capable execution client whose
admin_nodeInfo omits the node record. Tooling that bootstraps
discv5-only networks reads the ENR from this endpoint and has to
special-case Nethermind to the enode instead, which is useless once
discv4 is disabled.

NodeRecordProvider already maintains a signed, sequence-numbered self
record, so surface it as an 'enr' field. Move INodeRecordProvider to
Nethermind.Network so Nethermind.JsonRpc can reference it without a new
dependency on Nethermind.Network.Discovery; the implementation stays in
Discovery. The provider is only registered when discovery is enabled, so
it is resolved optionally and the field is omitted otherwise.

* Validate ABI decode allocation bounds (#12588)

* ci: disable stateless glamsterdam-devnet-7 scheduled run (#12680)

* Fix EIP-7708 tracing with logs (#12577)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Naming

* fix(flat): guard the trie-warmer against a TransientResource recycle race (storage reads as 0x00) (#12429)

* fix(flatdb): warm the trie from persistence only

The trie warmer read the recyclable `_snapshots` and `_transientResource`
while the warm job held only a `ReadOnlySnapshotBundle` lease, which does not
cover them. A concurrent scope reset could recycle those under the running
warmer, so a warm read could return a torn or foreign node.

The warmer only needs to warm from persistence, so restrict its reads to the
trie node cache and the `ReadOnlySnapshotBundle` - exactly what the lease
covers. In-memory nodes are already hot and do not need warming.

Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX

* fix(flat): warm the transient resource via a per-job lease

The trie warmer now warms nodes into the per-job-pinned TransientResource
(not just the trie node cache and ReadOnlySnapshotBundle), covered by a single
transient lease held for the whole warm traversal. It still never reads the
recyclable _snapshots. A warmer read on a torn-down bundle bails to a
persistence-only read instead of spinning for a lease that will never land.

Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX

* refactor(flat): drop the warmer transient ThreadStatic capture

Measured TS-on vs always-per-node-lease on x10 (3 runs, heavy-block warmer
load): newPayload 178 vs 178 ms, getProof p99 20.1 vs 20.2 ms, RSS identical.
The ambient capture bought nothing, so keep only the per-node lease + ABA
re-check (the actual recycle-race fix) and drop the ThreadStatic + the
EnterWarmerTransientScope pins in the two warm-job callers.

* fix(flat): register the transient return owner at pool checkout

- ResourcePool.GetCachedResource now calls OnRented, so every checkout
  carries a registered return owner; a final ReleaseLease without one
  throws instead of silently dropping the resource (which leaked the
  BloomFilter allocation on the public AddSnapshot path)
- re-check _isDisposed after the warmer's lease acquire: Dispose releases
  the owner lease but leaves _transientResource pointing at the recycled
  instance, so the identity re-check alone could latch a resource already
  re-rented by another bundle
- document why ReleaseLease is distinct from Dispose: the pool contract
  reserves Dispose for destroying an over-capacity resource
- run Nethermind.State.Flat.Test in the nethermind-tests.yml matrix; it
  was compiled but not run by any CI job
- FlatDbManagerTests duplicate-snapshot test asserts the resource lands
  back in the checkout pool; new ResourcePoolTests cover the final-release
  return and the unregistered-release throw; refresh stale warmer test
  comments

* fix(flat): pin the transient resource for prewarm dedupe reads

ShouldQueuePrewarm read _transientResource without pinning it. The dedupe
bloom lives on that recyclable resource and the call runs on prewarmer and
BAL threads, so the owner could retire the resource mid-read: the pool
Resets it and, on overflow, Disposes the BloomFilter, whose backing store is
native memory. Route both overloads through the same lease + ABA re-check
the warmer node reads already use, and decline the prewarm on a torn-down
bundle. Rename the helper accordingly, since it is no longer warmer-only.

The FlatWorldStateScopeProvider and FlatOverridableWorldScope test doubles
returned the committed resource to the pool directly instead of releasing
its lease, mirroring neither AddSnapshot implementation. That recycles the
resource while a warmer lease is outstanding and returns it a second time
when that lease is released; the scope provider double also returned the
wrong instance and then returned it again on teardown. Both now release the
lease, which is the single return-to-pool path.

Test changes:
- the persistence-only test now commits the written nodes into the bundle's
  recyclable _snapshots before reading, so the warmer's Unknown result is a
  genuine miss. Previously the node was still in the transient (SetStateNode
  writes both) and was itself Unknown, so the assertion held either way.
- the churn test gives every epoch its own persisted node instance, so a
  read served from another epoch's recycled transient is caught by identity
  rather than by value, drives both recycle paths (CollectAndApplySnapshot
  swap and Dispose), exercises ShouldQueuePrewarm alongside the node reads,
  and joins the readers with a bounded wait instead of blocking forever.
- new test: a warmer read and a prewarm check on a disposed bundle fall back
  to the leased persistence reader within a bounded wait, covering the
  Dispose bail-out deterministically.

* fix(jsonrpc): synchronise SubscriptionManager per-client subscription bag (#12672)

* fix(jsonrpc): synchronise SubscriptionManager per-client bag

The per-client subscription bag is a HashSet mutated and enumerated from
multiple threads: concurrent subscribe requests (socket worker tasks),
unsubscribe, and the Closed handler that fires on connection teardown.
Concurrent HashSet access could corrupt it, dropping a subscription so
its event handlers stayed attached and leaked. Lock on the bag for every
add, remove, and snapshot-before-dispose.

Fixes #12668

* refactor(jsonrpc): dispose client subscriptions under bag lock without snapshot

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

* test(jsonrpc): race unsubscribe path too; drop bag field comment

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

---------

Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test: eth_createAccessList affordability with omitted fee fields (#12629)

* test(rpc): eth_createAccessList affordability with omitted fee fields (execution-apis #854)

execution-apis PR #854 clarifies that eth_createAccessList must not fail
solely because an unfunded sender cannot afford client-selected default fees
when all gas-fee fields are omitted. Nethermind already conforms (verified in
hive rpc-compat); this adds a regression test mirroring the conformance
fixture: unfunded sender, codeless recipient, zero value, no gas/fee fields
-> {"accessList":[],"gasUsed":"0x5208"}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(rpc): run affordability test on London chain, assert no in-body error, reuse helper

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>

* Fix stale transaction pool snapshots (#12685)

* Encode engine_newPayloadWithWitness witness as an RLP data string (#12635)

* fix

Signed-off-by: jsign <jsign.uy@gmail.com>

* Tighten witness RLP JSON encoding

---------

Signed-off-by: jsign <jsign.uy@gmail.com>
Co-authored-by: jsign <jsign.uy@gmail.com>

* perf(state): skip trie warmup for read-only BAL accounts in flat layout (#12681)

* perf(state): skip trie warmup for read-only BAL accounts in flat layout

With a suggested BAL the block's write set is known upfront, and trie
nodes are only needed at commit for written accounts. Gate address
trie-warm hints (HintBal, HintGet, HintWarmAccount) on the BAL write
set so read-only accounts no longer trigger state-trie path walks.

On BAL blocks dominated by cold account reads this removes up to ~8-10
wasted trie-node DB reads per unique read-only account from the
measured processing window.

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

* Apply suggestions from code review

Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>

* refactor(state): extract QueueStateTrieWarmup and address review findings

- Extract the NeedsStateTrieWarmup + PushAddressJob + increment pattern
  into QueueStateTrieWarmup, used by all three address warmup call sites
- Hoist CancelHintBal above the empty-BAL early return so a stale write
  set never survives into the next block
- Drop the inaccurate bloom false-positive-rate comment
- Tests: parameterize warm-per-write-kind over balance/nonce/code/storage,
  add empty-BAL reset regression test, split the HintWarmAccount test,
  wrap scopes in using, use order-insensitive assertions

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

* Update src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatWorldStateScope.cs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* refactor(test): reuse TestContext for recording-warmer scope construction

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

* fix(state): bind BAL warmup gate to HintBal lifecycle; address review findings

- Keep _warmupWriteSet across StartWriteBatch: on the parallel BAL path the
  BAL apply commits mid-block, concurrently with tx workers, so clearing the
  gate in CancelHintBal collapsed it at a nondeterministic point. The gate is
  now replaced only by the next HintBal.
- Drop the token from Task.Run in both HintBal implementations: a task
  cancelled before being dequeued never ran the finally that returns the
  pooled accountChanges array. The body already observes the token.
- Align TrieStoreScopeProvider.HintBal with the flat scope: a new hint
  supersedes the previous one even when it carries no work itself.
- Remove the stale prestate-load mutation mention from ReadOnlyBlockAccessList
  docs, state the immutability invariant, and seal the type.
- Tests: pin the gate surviving StartWriteBatch and a second BAL replacing
  the previous write set.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* test: make can-never-fail tests assert what their names claim (#12690)

* test(xdc): drop redundant trailing Assert.Pass in SpecialTransactionsTests

* test(core): bound McsLock re-acquire test instead of passing unconditionally

SingleThreadAcquireRelease asserted nothing and is subsumed by
ReacquireAfterReleaseSucceeds, which now runs on a worker with a timeout
so a broken release fails in seconds instead of hanging the test host.

* test(flat): assert real postconditions instead of Assert.Pass

Add_ConcurrentWithMightContain_ShouldWork now verifies no write is lost
under concurrent readers (a bloom filter never false-negatives).
DisposeAsync test renamed: FlatDbManager does not dispose the repository
(the container owns it); it now asserts bounded drain completion and
idempotent double-dispose. ConstructorAcceptsPersistedRepository removed -
Not.Null on a fresh object, subsumed by every other test in the file.

* test(merge): assert pending-validation cleanup instead of catch-only assertions

The memory-leak test asserted only inside catch blocks and ended with
Assert.Pass, so it passed whenever nothing threw; it now drives the
handler directly and asserts the pending-validation count stays zero
across repeated timed-out payloads. The TrySet double-completion test
is removed: it was a weaker duplicate of the concurrent-calls test
above it (same scenario, but swallowing OperationCanceledException and
keying Assert.Fail on exception message text).

* test(merge): await header-sync test helpers

The helpers were async void and invoked without awaiting - including one
un-awaited call inside the other helper - so their assertions raced the
test body and failures could surface as host crashes or not at all.

* test(network): restore DisconnectsAnalyzer assertions with deterministic flush capture

All four tests had their assertions commented out as CI-flaky, leaving
them assertion-free. The flakiness came from racing the 10ms flush
timer: reports are now recorded while the default 10s interval is in
effect and only then is the interval shortened, so a flush cannot fire
mid-arrangement. Assertions scan captured flush reports, including that
counters aggregate, reset after each flush, and cleared categories do
not resurface (the analyzer double-buffers, so a lost clear shows up as
a stale count in every other flush, not as a doubled count).

* test: address review findings on strengthened tests

Will_clear_after_report no longer issues a second report at all - a
report can race the flush's enumerate-then-clear window (Timer.Stop does
not drain a queued Elapsed callback, so no test-side quiesce is airtight).
A lost clear is observable without it: the analyzer double-buffers, so a
stale count resurfaces in later flushes, and the test asserts the
category appears in exactly one flush across several more (mutation-
verified). Bloom capacity raised to 100k - at 10k the saturated filter's
~34% false-positive rate masked a single lost write - and misses are
collected into one assertion. The repeated-timeouts payload test is
dropped rather than parameterized: the pending dictionary is keyed by
block hash and the test resubmits one block, so the count can never
exceed one and iterations add no coverage. The FlatDbManager dispose
test states its intent with Assert.DoesNotThrowAsync and a corrected
comment (WaitAsync bounds the wait, not the drain). Wait timeouts are
named constants and polling uses Thread.Sleep(1).

* test: simplify comments per ASD-STE100 and drop dead times parameter

Comments now use short, active, single-topic sentences. The times
parameter of ShouldEventuallyReport had no remaining non-default call
site after the second-report removal.

* test: use SpinWait.SpinUntil instead of a custom poll helper

Keep one condition re-check after a timeout: a flush can land in
SpinUntil's final sleep tick.

* Stop parallel transaction execution once BAL validation rejects the block (#12697)

* fix(consensus): stop parallel tx execution once BAL validation rejects

The parallel BAL block validator ran the incremental validator alongside the
transaction workers, but a validator failure faulted neither the worker loop nor
its cancellation token. `ParallelUnbalancedWork` only stops fetching new indices
on caller cancellation or a transaction-worker fault, so a block rejected at an
early transaction index still executed every remaining transaction before the
foreground observed the failure at `GetResult()`. For a block whose invalidity is
decided by a cheap prefix, that turns a sub-second rejection into slot-scale CPU
work.

`IncrementalValidationWorkItem` now owns a cancellation source that is signalled
together with the stored exception, and the worker loop runs under that token, so
workers stop pulling transactions as soon as validation becomes terminal. The
resulting `OperationCanceledException` is translated back into the original
validation failure, keeping the returned error identical.

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

* refactor(consensus): signal BAL validation failure with a flag, not cancellation

Routing the stop signal through ParallelUnbalancedWork's cancellation token meant
`For` ended by throwing an OperationCanceledException that existed only to be
caught and translated back into the original validation failure. The token also
needed a per-block CancellationTokenSource, whose recycling in Schedule was only
safe because the previous block's validator had already been joined.

Replace all of it with a volatile read of the exception the work item already
stores: workers check `HasFailed` before doing any work, the loop drains its
remaining indices without executing anything, and `GetResult()` reports the
rejection on the normal return path. Same behaviour, no exception used as control
flow, no extra state to keep in sync, and no ordering constraint between Schedule
and the work that follows it.

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

* refactor(test): trim comments and simplify the tail-cancellation test

Comments cut back to the non-obvious reasoning only. The regression test now
counts executions instead of collecting indices, so a single Is.InRange assertion
covers both requirements — the decisive prefix ran, the tail did not — and the
prefix array, the index bag and one assertion all go away. Transaction count is a
plain constant rather than derived from the canonical lead, which the test never
depended on: with uniform gas limits the tail sort is stable, so the schedule is
natural order regardless. CreateParallelValidationTransactions takes an optional
gas limit so the block no longer needs patching after construction.

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

* fix(consensus): address review — exempt iteration 0, loosen test bound

Iteration 0 (WaitForBalWarmup + ApplyStateChanges) is now exempt from the
HasFailed guard, so pre-execution keeps its previous semantics instead of being
droppable when validation fails before any worker starts. Skipping it was traced
as benign, but the fix only needs to stop transaction execution, so leaving the
pre-execution step alone keeps the behavioural change narrower at no cost.

The regression test's upper bound leaned on SpinWait outlasting exception
unwinding, because the fake validator releases its gate before the work item
stores the exception. Bound is now txCount / 8, which asserts the tail stopped
without depending on cancellation-propagation timing — a revert still executes
all 2048 and fails.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* test: pin eth/62-66 serializer wire encodings with hand-derived goldens (#12696)

* test(network): remove duplicate eth serializer tests

ZeroNewBlockMessageSerializerTests.Roundtrip2 and V63
NodeDataMessageSerializerTests.Zero_roundtrip were verbatim copies of
their Roundtrip siblings. Can_deserialize_own_eth_64 shared its body
and one payload with Can_deserialize_eth_64; its unique payload moves
there as another TestCase.

* test(network): pin eth/62-66 wire encodings with hand-derived goldens

Every serializer test file in Eth V62-V66 now asserts an exact wire
encoding somewhere. The goldens are derived from the RLP rules and
verified with an independent encoder (pyrlp + pycryptodome keccak);
values shared across files live in EthSerializerGoldens. ToString
smoke tests now assert the log line names the message type, and the
32-byte-selector test asserts the exact decoded hash.

* test(network): address review feedback on serializer goldens

- BlockBodies: pin the null-body framing (c1c0) and the empty-vs-absent
  withdrawals distinction (e9e8...c0c0)
- Status To_string: assert against the independent Protocol.Eth constant
- V63 NodeData: rename Roundtrip_with_nulls to Roundtrip_with_empty_entry
  (the data holds an empty array, not null)

* feat: EIP-8037 two-dimensional gas tracing (stateGasTracer + callTracer) (#12628)

* feat(tracing): add EIP-8037 stateGasTracer (execution-apis #852)

Implements the `stateGasTracer` named tracer specified in execution-apis
PR #852, returning the per-transaction two-dimensional gas summary
`{gasUsed, regularGasUsed, stateGasUsed, gasRefund}` (EIP-8037/EIP-7778).

The values are already computed for block-level gas accounting and carried
on `GasConsumed`; the tracer only reads and formats them. Adds the missing
applied EIP-3529 refund (capped) to `GasConsumed.GasRefund`, populated in
the success and top-level-halt refund paths. The native tracer factory is
threaded with `IReleaseSpec` so the fork is determined explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tracing): add EIP-8037 two-dimensional gas to callTracer (execution-apis #852)

Adds `regularGasUsed`, `stateGasUsed` and `gasRefund` to the callTracer
top-level frame for Amsterdam+ blocks, per execution-apis PR #852. The
fields are gated on `IReleaseSpec.IsEip8037Enabled` (MUST NOT appear before
the fork) and set only on the top frame (omitted on sub-frames). Values are
read from the transaction's `GasConsumed` result, matching the stateGasTracer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): address #12628 review feedback

- Restore native-tracer factory API back-compat: keep the public 4-arg
  GethLikeNativeTracerFactoryDelegate/RegisterTracer/CreateTracer so external
  plugin registrations stay source- and binary-compatible; built-ins receive
  the (nullable) IReleaseSpec via an internal spec-aware factory.
- stateGasTracer: disable IsTracingOpLevelStorage/IsTracingStack so the
  terminal-only tracer stops invoking per-opcode storage/stack callbacks.
- Consolidate the callTracer top-frame regularGasUsed/stateGasUsed/gasRefund
  into a single TwoDimensionalGas? value, removing the coupled nullables and
  the unsafe null-forgiving dereferences in the converter.
- Document that regularGasUsed is floor-clamped (block-accounting value) so
  the two-dimensional invariant is exempted under the calldata floor.
- Add an end-to-end stateGasTracer test executing a real Amsterdam tx through
  the TransactionProcessor (fresh SSTORE + in-tx reset) that exercises field
  selection and the GasConsumed.GasRefund plumbing; assert
  regularGasUsed + stateGasUsed == gasUsed + gasRefund.
- Test cleanups: multiple-assert scope and a shared helper for the two
  callTracer Amsterdam cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): address #12628 re-review nits

- Document the spec-less CreateTracer overload's pre-fork fallback and cover it
  with a factory test (back-compat public contract was untested).
- stateGasTracer hex test now forces the ambient NumberConversion.Raw so it
  actually exercises StateGasTraceConverter's hex-quantity override.
- E2E test: use a `using` tracer and correct the refund assertion message
  (the slot is reset to its original zero value within the tx, not pre-nonzero).
- Convert the remaining `//` member comments to XML doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: remove unused using in NativeStateGasTracerE2ETests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): drop excess comments; non-nullable spec in native tracer factory

Address flcl42 review on #12628:
- Remove the newly added explanatory comments that restated the code (kept only a
  few essential EIP-referenced DTO docs and the hex-conversion note).
- Make the release spec non-nullable through GethLikeNativeTracerFactory and the
  native tracers: drop the unused spec-less 4-arg CreateTracer overload (the only
  null source) — RegisterTracer, the actual plugin API, is unchanged. Also drops
  the brittle regularGasUsed occurrence-count assertion flcl42 flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: collapse double blank line before DeepNesting test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): skip EIP-3607 on the EIP-7928 BAL path in eth_simulateV1 (#12691)

* fix(simulate): skip EIP-3607 on the EIP-7928 BAL path in eth_simulateV1

eth_simulateV1 relaxes EIP-3607 so a state-overridden contract can be the tx
`from`. That relaxation covered only the main execution (via
SimulateTransactionProcessorAdapter). Under glamsterdam (EIP-7928),
BlockProcessor runs transactions through the BlockAccessListManager's own tx
processors, which bypass the adapter and re-enforce EIP-3607 — so a contract
sender is rejected with `-38024 sender has deployed code` instead of reaching
the normal balance/fee checks (hive rpc-compat divergence on glamsterdam-devnet-8:
ethSimulate-simple-send-from-contract*, ethSimulate-override-address-twice).

Relax EIP-3607 on the block execution context in
SimulateBlockValidationTransactionsExecutor instead:
ParallelBlockValidationTransactionsExecutor sets that context on both the main tx
processor and the BAL manager, so both paths skip the check — while BlockProcessor
still receives the unwrapped spec, preserving chain-specific release-spec
interfaces (Taiko / XDC / Optimism).

Verified end-to-end on a glamsterdam-at-genesis chain (the three hive fixtures
flip -38024 -> -38014/-38012, matching besu/erigon/reth/geth) and by an
integration test through the real EIP-7928 BAL path that fails -38024 without the
fix and passes with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): preserve PrevRandao in context rebuild; harden 3607 BAL test

Address re-review on #12691:
- SimulateBlockValidationTransactionsExecutor is now the single context-rebuild
  funnel for the simulate scope, so preserve the incoming PrevRandao (via
  BlockExecutionContext.WithPrevRandao*) instead of re-deriving the default —
  a BlockProcessor subclass (e.g. XdcBlockProcessor) may have supplied a
  non-default value.
- Make eth_simulateV1_contract_sender_skips_eip3607_on_bal_path validation-enabled
  so the -38014 expectation is fork-independent and stable across the #12692 fix
  (with validation:false the -38014 relied on the BAL path ignoring NoValidation).
  Pin the EIP-7928 premise with an explicit BlockLevelAccessListsEnabled assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): forward BlobBaseFee too in the context rebuild

Re-review follow-up on #12691: WithPrevRandao recomputes BlobBaseFee from the
header, so the no-override branch was newly lossy for a processor that forced a
non-derivable value (XdcBlockProcessor sets BlobBaseFee = 0 on a header cloned
with ExcessBlobGas = 0; recomputation yields MinBlobGasPrice = 1). Collapse to a
single WithPrevRandaoAndBlobBaseFee that forwards the incoming BlobBaseFee (or the
block override when present), leaving Spec as the only field the rebuild changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): relax EIP-3607 via execution flag, not spec wrapping

The eth_simulateV1 contract-sender fix relaxed EIP-3607 by wrapping the block
execution context spec with WithoutEip3607(), which returns a NoEip3607Spec
decorator implementing only IReleaseSpec. Because the tx processors read their
spec from that context (TransactionProcessor.GetSpec => BlockExecutionContext.Spec),
the decorator reached chain-specific processors that hard-cast it — e.g.
TaikoTransactionProcessor.PayFees does (ITaikoReleaseSpec)spec and
XdcTransactionProcessor casts on every tx — so eth_simulateV1 on Taiko/XDC threw
InvalidCastException/InvalidOperationException instead of returning a result
(Eip3607Transition defaults to 0, so the wrap is always active there).

Replace the spec wrapping with a dedicated ExecutionOptions.SkipSenderCodeCheck
policy flag gated in ValidateSender. The main simulate adapter ORs it into its
Process call; the EIP-7928 BAL path receives it via a small
BlockAccessListTxExecutionOptions injected into BlockAccessListManager and threaded
to its ExecuteTransactionProcessorAdapter. The release spec now keeps its concrete
runtime type on every path, so chain-specific interfaces survive.

Regression test: Nethermind.Taiko.Test exercises a code-bearing sender through
TaikoTransactionProcessor.PayFees — it passes with the flag and throws
InvalidCastException under the old spec-wrapping. eth_simulateV1 BAL-path and full
simulate suite remain green.

Follow-up to #12691; addresses the residual type-erasure raised in its review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(simulate): trim explanatory comments to essentials

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): carry EIP-3607 relaxation on BlockExecutionContext

Replace the ExecutionOptions.SkipSenderCodeCheck flag (and the machinery to thread
it into the EIP-7928 BAL path) with a SkipSenderCodeCheck field on
BlockExecutionContext. Both the main tx processor and the BAL manager's own
processors already share the context via SetBlockExecutionContext, so the field
reaches every path for free — no threading through BlockAccessListManager /
TxProcessorPool / ExecuteTransactionProcessorAdapter, and no injected policy type.

The context is the same shared channel #12691 originally relaxed on; this just uses
a flag instead of a spec decorator, keeping the spec's concrete runtime type (so
ITaikoReleaseSpec/IXdcReleaseSpec casts survive). Net simpler diff and consistent
with the existing IsGenesis flag on the same struct.

ValidateSender reads VirtualMachine.BlockExecutionContext.SkipSenderCodeCheck; the
simulate executor sets it in its context rebuild. Taiko regression test updated to
set the flag on the context (still throws InvalidCastException under spec-wrapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): carry EIP-3607 relaxation on TransactionProcessor

Per review preference (@LukaszRozmej), move the SkipSenderCodeCheck flag from
BlockExecutionContext onto TransactionProcessor. ValidateSender reads the
processor's SkipSenderCodeCheck property. The simulate scope sets it type-
preservingly on both creation paths: Intercept<ITransactionProcessor> for the
main processor, and a factory decorator for the EIP-7928 BAL processors — so each
chain keeps its concrete processor type (no spec wrapping, no processor-type swap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: trim comments to the essential why

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): remove dead WithoutEip3607; address review polish

Follow-up to the SkipSenderCodeCheck switch (review by @claude on #12691):
- Remove the now-dead EIP-3607 spec wrapper — this PR dropped its last production
  caller. Deletes WithoutEip3607, NoEip3607Spec, GetNoEip3607Spec (std + zkevm) and
  the _noEip3607Specs cache; keeps WithoutEip158 (still live). Fixes two comments
  that cited the removed decorator.
- Enforce the relaxation invariant: a shared Apply() throws if the resolved
  ITransactionProcessor isn't a TransactionProcessorBase, instead of silently
  no-op'ing back to -38024. Used by both the Intercept hook and the factory decorator.
- XML-doc SkipSenderCodeCheck with its set-before-use / unsynchronised-read invariant.
- Make SkipSenderCodeCheckTransactionProcessorFactory internal.
- Revert SimulateBlockValidationTransactionsExecutor to master (the fix no longer
  touches it; its PrevRandao tweak was unrelated).
- Assert fee payment in the Taiko regression test (matches its name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dns): verify EIP-1459 subtree hashes (#12707)

* fix(dns): verify EIP-1459 subtree hashes

EIP-1459 serves every subtree entry from the subdomain
base32(keccak256(entry)[..16]). The root signature covers only the
enrtree-root entry, so this hash chain is the only binding between the
signed root and the branch and ENR records a client consumes. The
crawler never checked it, so any resolver or poisoned cache could
substitute arbitrary node records and steer the crawl to
attacker-chosen labels.

Verification follows go-ethereum: unpadded standard base32, prefix
comparison against keccak256 of the record, abbreviated labels accepted
down to 12 decoded bytes (minHashLength). The tree root is exempt, as
EIP-1459 serves it from the bare domain with no hash label. A mismatch
logs at Warn and skips the record.

No base32 decoder existed in src/Nethermind, so EnrTreeHash carries a
small one, tested against labels produced by the reference
implementation.

Validated against live trees: all.mainnet.ethdisco.net crawls 3000
records with zero rejections, so the check does not misfire on
anything the reference publisher emits.

* refactor(dns): simplify and harden EnrTreeHash

- Reuse Keccak.Size instead of a local MaxHashLength constant.
- Decode base32 arithmetically instead of scanning the alphabet string.
- Guard the decoded-length check against int overflow on hostile input.
- Hash UTF-8 bytes from a stackalloc/pooled buffer instead of allocating.

* refactor(dns): log hash-mismatch rejections at Debug, clarify base32 mapping

---------

Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>

* Selectable dotTrace profiling mode + dotnet-trace EventPipe sidecar for benchmark workflows (#12708)

* benchmarks: selectable dotTrace profiling mode (sampling | tracing | timeline)

The dottrace input on run-rpc-benchmarks and run-expb-reproducible-benchmarks
becomes a choice: false | sampling | tracing | timeline ('true' stays accepted
as a legacy alias for sampling on API dispatches). The mode maps to the
dotTrace CLI's --profiling-type: rpc-bench sets it on the wrapped entrypoint in
start-node.sh; the EXPB workflow passes --dottrace-mode to expb (only when
non-default, so pinned expb versions keep working for sampling runs; requires
execution-payloads-benchmarks feature/dottrace-profiling-modes for the new
modes).

Timeline snapshots cannot be converted to XML by Reporter.exe, so the
generate-dottrace-reports and summary jobs skip that mode - the raw .dtp
artifact is still collected and uploaded for the dotTrace UI. Line-by-line is
deliberately not offered: it needs PDBs the client docker images do not carry.
EXPB additionally rejects trace_blocks with timeline, since per-block
snapshots ride the MeasureProfiler API, which needs a performance session.

* expb workflow: collect a dotnet-trace EventPipe sidecar with every dotTrace run

Whenever dottrace is enabled the run also passes --dotnet-trace to expb: a
host-side EventPipe session records gc/contention/threading/exception events
(no CPU sampler - dotTrace owns the stacks) and the .nettrace ships in the same
dottrace-* artifact. Requires expb feature/dottrace-profiling-modes.

* rpcbench/expb: document the profiling modes and fix two review nits

Docs still described the pre-mode behaviour: the rpc-bench README documented a
dottrace command line with no --profiling-type and stated capture deliberately
runs in default sampling mode, and its input table listed dottrace as a boolean.
AGENTS.md's expb section had the same gap. Both now cover the four choices, when
to reach for each, that timeline yields no XML, and the dotnet-trace sidecar -
that section is what agents read to interpret run artifacts.

Timeline snapshots now save as .dtt rather than .dtp. The report job is gated off
for timeline either way, but the extension is what stops Reporter.exe's .dtp glob
from picking up a snapshot it cannot convert if that gate is ever relaxed.

The expb dottrace flag no longer special-cases sampling: the run already requires
an expb that understands --dotnet-trace, so there is no older-expb compatibility
left to preserve, and the flag string was spelled out twice in each of two
copy-pasted job bodies. The trace_blocks default guard becomes an explicit if -
as the last statement of its if body the AND-list left the block with status 1,
surviving only through errexit's AND-OR exemption.

* docs: scope the EventPipe sidecar to EXPB

The rpc-bench README claimed every profiled run drops a .nettrace into the
dottrace-rpcbench artifact, but --dotnet-trace was only wired into the EXPB
workflow - nothing in run-rpc-benchmarks.yml or scripts/rpc-bench collects one, so
the paragraph sent readers hunting for a file that is never produced, and promised
it precisely for timeline runs, which have no XML either. Say what a timeline
rpc-bench run actually yields: the .dtt snapshot alone.

* fix(jsonrpc): serialize receipt root as full-width DATA (#12706)

* fix(jsonrpc): serialize receipt root as full-width DATA

* test(jsonrpc): parameterize the receipt-root width cases

* test(jsonrpc): pin the whole-byte leading-zero root case

* test: add hand-derived golden tests for eth/71 and snap serializers (#12699)

* test(network): pin eth/71 and snap serializer wire encodings

Every serializer test file in Eth V71 and Snap/V1 now asserts an exact
wire encoding. The goldens are hand-derived from the RLP rules and
verified with an independent encoder (pyrlp + pycryptodome keccak).
Random request ids are pinned only in the goldened tests; ByteCodes
gains a sibling roundtrip that keeps the constructor-assigned id, and
GetAccountRange pins long.MaxValue to keep the eight-byte id path.

* test(network): share repeated snap golden fragments

The request-id, keccak-of-empty-string, and range-hash fragments moved
into SnapSerializerGoldens. Each golden fragment and its test input
come from one hex constant, so the expectation cannot drift from the
input.

* test(network): address review feedback on snap golden tests

- ByteCodes: one parameterized Roundtrip; long.MaxValue pins the
  eight-byte request id deterministically instead of a random id
- GetAccountRange: independent field asserts in EnterMultipleScope
- SnapSerializerGoldens: expose RequestId1111 next to its RLP item;
  the remarks state which fragments share hex with inputs and that
  the keccak("") fragment is an independent literal on purpose

* `debug_trace*`: Fix phantom logs on frame revert (#12621)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Phantom log test

* Remove logs on a reverted frame

* Naming

* Fix leaking `ArrayPooList`

* Code cleanup

* Additional test

* Simplify tests

* Build fix

* Snap sync: reject storage range responses with unmatched slot lists (#12729)

* fix(snap): reject storage range responses with unmatched slot lists

A `StorageRanges` reply carries one slot list per requested account, but
nothing verified that the responder returned no more lists than were asked
for. `AddStorageRange` looped over the response count and indexed the request
account list with it, so a longer-than-requested reply read past the end of
the request.

Reject such a response up front and retry the range, which also settles the
active storage request accounting so that the range download phase can still
reach completion.

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

* test(snap): pin the slot list count boundary

Cover equal and fewer slot lists than requested accounts so that widening the
guard to `>=` cannot pass, which would reject every well-formed response.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(metrics): durable sync-time and full-pruning duration metrics (#12590)

* feat(metrics): durable sync-time + per-stage breakdown and full-pruning duration

Sync:
- Fix UpdateAndGetSyncTime() so the total is retained after sync completes
  instead of resetting to zero (EthSyncingInfo and Taiko override).
- Replace nethermind_sync_time with nethermind_sync_time_seconds (durable).
- Add nethermind_sync_time_in_mode_seconds{sync_mode} per-stage breakdown,
  driven by a new SyncTimeInModeTracker on ISyncModeSelector.

Pruning:
- Add nethermind_full_pruning_last_duration_seconds and
  nethermind_full_pruning_count, recorded on successful FullPruningDb.PruningFinished.
- Suffix in-memory trie pruning gauges with *Ms and document the unit.

* refactor(metrics): address PR review on sync/pruning time metrics

- Sync time no longer drops to 0 for one scrape when a stopped node
  re-syncs: extract shared SyncTimeStopwatch that always returns the
  retained total (used by EthSyncingInfo and Taiko override). Add
  stop->resume regression tests.
- SyncTimeInModeTracker is now IDisposable (unsubscribes from
  ISyncModeSelector.Changed), seeds its labels with TryAdd instead of
  clobbering the shared static dictionary, and is owned by the container.
  Timestamp seam moved to an internal ctor.
- SyncTimeInModeSeconds is get-only to match the other [KeyIsLabel] metrics.
- FullPruningCount uses the Interlocked backing-field pattern like the
  other counters in Db/Metrics.
- Clarify that FullPruningLastDurationSeconds covers the trie copy+commit,
  not the wait for a suitable state root.
- Make the full-pruning duration test assert the write actually happened.

* fix(metrics): avoid DI cycle wiring the sync-mode tracker; drop unused using

- Attach SyncTimeInModeTracker via Intercept<ISyncModeSelector> instead of
  resolving it during IMonitoringService construction. Resolving it there
  created a container cycle (IMonitoringService -> tracker -> ISyncModeSelector
  -> ... -> DbTracker -> IMonitoringService) that crashed the node at startup.
  This keeps the monitoring module free of outward dependencies, mirroring the
  existing IEthSyncingInfo wiring.
- Remove unused `using System;` in SyncTimeInModeTrackerTests (IDE0005).

* Only accept the requested header in FetchHeaderFromPeer (#12730)

* fix(sync): only accept the requested header in FetchHeaderFromPeer

FetchHeaderFromPeer queries every initialized peer in parallel and takes
the first non-null response, but never checked that the returned header
is actually the one that was asked for. A peer that answers with some
other block had its header accepted, so the result depended on which
peer replied first rather than on what was requested.

Compare the returned header's hash against the requested hash on both
response paths. A non-matching response is treated like a missing one,
so the remaining in-flight peers can still supply the header instead of
the whole lookup failing.

The sibling lookups in StartingSyncPivotUpdater and PeerRefresher
already do this; FetchHeaderFromPeer was the one that did not.

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

* test(sync): cover the allocated-peer fallback and tighten assertions

Add a case where no peer answers the head-header request, so the
GetBlockHeaders fallback is the one that resolves the header. Nothing
pinned its success branch before, so it could have been broken without
a test noticing.

Assert on the header instance rather than its hash, so the negative
case cannot pass for a header that merely has no hash set.

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

* fix(sync): report a peer that answers with a different block

A mismatched header was discarded silently, leaving it indistinguishable
from a peer that simply does not have the block. Requests are serialised
per peer by MessageQueue and a late response to a timed-out request is
disposed rather than handed to the next caller, so a mismatch is always
a protocol violation rather than a benign response race.

Report it as UnexpectedHeaderHash, matching HeadersSyncFeed and the
other header lookups.

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

* test(sync): cover that an honest peer keeps its connection

Reporting a mismatch disconnects the peer, so the costly failure mode is
now an honest peer being dropped rather than a wrong header accepted.
Only the reported direction was asserted.

Cover all three answer shapes, including a peer that does not have the
block, which is the normal answer while a head is unknown and must not
cost a connection.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Fix doubled revert handling in some tracers (#12715)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Phantom log test

* Remove logs on a reverted frame

* Naming

* Fix leaking `ArrayPooList`

* Code cleanup

* Additional test

* Simplify tests

* Showcase test

* Direct fix

* More failing tracing tests

* Remove problematic `ReportActionRevert` > `ReportActionError` delegation from base

* Get rid of virtual-to-virtual calls in report revert/error

* Formatting

* Build fix

* Remove other `ReportActionRevert` -> `ReportActionError` calls

* Move common test codes to base class

* Fix `IsTracingActions` summary

* Small test fix

* Code cleanup

* test(era): anchor accumulator tests to EIP-7643 vectors, remove unused GetProof (#12718)

* test(era): anchor AccumulatorCalculator roots to derived spec vectors

Replaces the two-instances self-oracle, the inequality-only comparisons,
and the DoesNotThrow-only Add tests with parameterized roots derived by
an independent Python SSZ merkleization of the portal-network history
spec. Adds proof fold-up verification against the derived roots.
Deletes the byte-identical duplicate golden test in Era1.Test.

* test(era): assert the accumulator root the readers return

ReadAccumulator and ReadAccumulatorRoot tests discarded the root and
asserted only Throws.Nothing. They now assert the root equals the
accumulator of the written contents. The shared calculator loop moves
into a ComputeAccumulatorRoot helper in each file.

* test(era): apply review round on the accumulator vector tests

Parameterizes the proof verification and adds a three-entry index 2
case, so the upper tree levels exercise the right-hand sibling order.
Moves the proof length assert into the fold-up helper. Rewrites the
provenance comments in active voice and adds the spec link.

* test(era): cite EIP-7643 as the accumulator spec reference

The portal-network history spec was restructured and no longer defines
the accumulator. EIP-7643 defines HeaderRecord and the 8192-entry epoch
record. Also states the exact value of the index 2 proof case: it is
the only case that catches a wrong index shift.

* refactor(era): remove unused AccumulatorCalculator.GetProof

GetProof shipped with the EraE feature but no production code ever
called it; its only callers were its own tests. The removal also drops
the _totalDifficulties list, which only GetProof read, and the proof
tests in Nethermind.EraE.Test.

* docs(era): cite EIP-7643 on AccumulatorCalculator

The linked portal-network document was restructured and no longer
defines the accumulator. EIP-7643 defines HeaderRecord and the
8192-entry epoch record this class computes.

* test(era): apply removal-round polish

Converts the class comment to an XML summary per the documentation
rule. Inlines the two-entry root, which lost its second consumer with
the proof tests.

* test(era): consolidate the accumulator fixtures into Era1.Test

Both fixtures tested Nethermind.Era1.AccumulatorCalculator with
overlapping vectors. The Era1.Test table now carries every
discriminating single-entry case, a pinned empty-accumulator root, and
a Clear reset test against the writer-reuse contract. The EraE.Test
copy is deleted.

* test(era): state only true contracts in the fixture comments

The Clear comment claimed a multi-file writer-reuse path that does not
exist: Add throws after Finalize and each era file gets a fresh writer.
The vector comment claimed a pairwise single-input difference that two
case pairs violate. Both now state only what holds.

* test: strengthen mock-echo and vacuous tests in Abi, Optimism and Xdc test projects (#12720)

* test(abi): pin forwarding and return propagation in encoder extensions

The extension tests asserted only Received on the inner call and
ignored the extension return value. A stub on the exact unpacked
arguments plus an identity assert on the result covers both.

* test(xdc): assert the RocksDb config factory routing

The single test asserted Is.Not.Null on a result that cannot be null.
The factory contract is routing: Xdc databases get a PerTableDbConfig
without consulting the base factory, and every other database delegates
to it. Both branches are now pinned; the delegation branch was
untested.

* test(xdc): pin the routed timeout instance

The vote routing test pins the exact vote, but the timeout test used
Arg.Any, so a handler that routes the wrong timeout passed.

* test: apply the C11 review round

Removes an unused using that fails lint CI. Pins the factory options
against the provided IDbConfig, so a wrong database name in the special
branch turns the test red. Moves the extension rationale to class level
and renames the Abi tests to the project snake_case convention.

* test: state only true mechanisms in the C11 comments

NSubstitute returns an empty array, not null, for an unmatched call on
an array-returning member. The prefixed-options claim holds for only
half of the database prefixes, so the sentence is dropped.

* test(xdc): use a neutral database name in the delegation test

Review feedback on #12720: the delegation test used the literal
"State"/"Code", and "State" is the one name PerTableDbConfig
special-cases (StartsWith("State")). Use nameof(DbNames.Blocks) with a
null column so the test isolates the delegate-to-base contract without
that special-case, matching the sibling FlatRocksDbConfigAdjusterTests
idiom.

* test: anchor crypto and RLP tests to independent expectations (#12712)

* test(core): anchor the keccak span test to an independent vector

* test(core): anchor RLP ulong lengths to the spec

* test(core): compare decoded blocks to the original and drop the ignored file writer

* test(core): compare decoded header fields to the original block

* test(core): anchor the regression block decode to pyrlp-derived fields

* test(core): apply review round - drop dead using, widen anchors, guard indexed asserts

* test(core): apply round-2 review polish

* test(core): cover the header tail fields and sharpen the roundtrip comments

* test(core): apply confirm-round nits

* test(core): compare decoded uncle hashes in the block roundtrip

The body compared uncles by count only. The scenarios build two uncles
with distinct headers, so the hash comparison catches an order or
content error the count cannot see. The count guard moves outside the
multiple-assert scope like the transaction guard.

* Update OP Superchain chains (#12752)

Co-authored-by: emlautarom1 <emlautarom1@users.noreply.github.com>

* Auto-update fast sync settings (#12751)

Co-authored-by: rubo <rubo@users.noreply.github.com>

* test: Db tests assert stored state instead of smoke-calling empty methods (#12693)

* test(db): assert stored state instead of smoke-calling empty methods

MemDb.Flush and Dispose (and the Snapshotable variants) are empty method
bodies, so the six *_does_not_cause_trouble tests could never fail;
removed. The no-assert MemDb creation tests now verify the value round-
trips. ReadOnlyDbProviderTests.Can_clear exercised an empty registry (no
db was ever registered), making ClearTempChanges a no-op; it now
registers a real MemDb-backed read-only db and asserts the overlay is
dropped while the wrapped db stays intact, plus the no-writes contract
when localChanges is off. Can_get_all_on_empty now asserts emptiness
instead of discarding the enumeration.

* test(db): assert overlay drop only where a write could land in the overlay

* test(db): group independent post-condition asserts in Assert.EnterMultipleScope

* Reject invalid fixed-size header RLP (#12579)

* Treat a null header answer as the block being absent (#12741)

* fix(sync): treat a null header answer as the block being absent

An empty list item decodes to a null header and is kept in the response
list, so the allocated-peer fallback could pass one into the hash
comparison and throw. FetchHeaderFromPeer only catches cancellation and
timeouts, so it would surface into engine_forkchoiceUpdated.

Handle it in Validate, which lets the head-header path drop its own null
check too.

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

* test(sync): name the mock switch after the answer it produces

The peer answers with a null header, not an empty one. Move it next to
the other answer switches so HeaderToReturn keeps its own doc.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Return only the requested header from GetHeadBlockHeader (#12740)

* fix(network): return only the requested header from GetHeadBlockHeader

GetHeadBlockHeader asks for one specific block but returned whatever
single header the peer sent back. Every caller wants the block it asked
for, and two of them re-checked the hash themselves afterwards; the peer
refresh in SyncPeerPool did not, so it would take a substituted header
and record the wrong head number and total difficulty for that peer.

Compare the response against the requested hash where the request is
made, and disconnect a peer that answers with a different block. A peer
that does not have the block answers with an empty list, which is the
normal response while a head is unknown and still yields null.

Drop the now-redundant check in StartingSyncPivotUpdater. The one in
PeerRefresher stays: that method validates its head/parent response
locally anyway, since GetBlockHeaders carries no such guarantee.

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

* fix(network): treat a null header answer as the block being absent

An empty list item decodes to a null header and is kept in the response
list, so a peer can answer a single-header request with one null entry.
Dereferencing it to compare hashes threw, and two callers do not catch
that: the pivot updater would leave its update loop for good, and
FetchHeaderFromPeer would surface it into engine_forkchoiceUpdated.

Treat it the same as an empty list — the peer does not have the block —
rather than as a breach.

Also type the requested hash as nullable, matching _remoteHeadBlockHash
before the status handshake, and assert the absent cases on the result
itself rather than on its hash.

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

* fix(network): make the requested-header guarantee unconditional

The hash check was skipped when neither the argument nor the announced
head was known, so the documented contract had a hole. Return early
instead: with no hash to ask for there is no meaningful request to send.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: add built-in portfolio viewer UI at /portfolio (#12360)

* Handle failed sender recovery (#12757)

* fix(simulate): route the EIP-7928 BAL path through the simulate tx adapter (#12692) (#12721)

* fix(simulate): route EIP-7928 BAL path through the simulate tx adapter

Under EIP-7928, eth_simulateV1 runs transactions through the
BlockAccessListManager's own tx processors, bypassing
SimulateTransactionProcessorAdapter. That lost its GasCap budget clamp, its
TotalGasLeft/BlockGasLeft accounting (block gasUsed reported as 0), and its
validation:false handling (the BAL path always called Execute, never Trace).

Inject the adapter via a new ITransactionProcessorAdapterFactory so the
sequential BAL manager — the only one simulate drives — wraps each tx processor
in the simulate adapter. The parallel manager always uses the default
ExecuteTransactionProcessorAdapter: the stateful simulate adapter is
sequential-only and simulate never triggers the parallel path (it attaches no
BlockAccessList).

The factory is an interface, not a delegate, so Autofac does not
auto-synthesise one on the real block-production scope, where the optional
parameter must stay null and fall back to the default adapter.

The no-gas EIP-8037 execution-dimension default is a separate ordering issue
(the per-tx inclusion check runs before the adapter) and is left as a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): make the tx-processor-adapter factory a required delegate; add GasCap test

Replace the ITransactionProcessorAdapterFactory interface with a required
TransactionProcessorAdapterFactory delegate, mirroring CodeInfoRepositoryFactory:
the block-processing module registers the default (ExecuteTransactionProcessorAdapter)
and the simulate scope overrides it with the simulate adapter. This drops the
optional/null-fallback and the auto-synthesis hazard an optional delegate would
carry (ITransactionProcessorAdapter is registered on the block-processing scope,
so Autofac would otherwise fill an optional delegate on the real path).

Also add a regression test for the JsonRpc.GasCap budget (#12692 item 2): a
two-call request whose cumulative gas exceeds the cap has its second call clamped
below intrinsic gas and rejected; without the adapter the cap is not enforced and
both calls run unclamped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: address #12721 feedback (enforce sequential BAL, DI style, docs, test dedup)

- Set ProcessingOptions.ForceSequentialBlockAccessList in the simulate options so the
  stateful SimulateTransactionProcessorAdapter can never reach the parallel BAL pool
  (previously guaranteed only by simulate not attaching a BlockAccessList); documented
  the single-threaded contract on the adapter itself.
- Register the default TransactionProcessorAdapterFactory with AddScoped + a method group
  (drops the lambda cast) and the simulate override with the typed-dependency AddScoped
  overload (no manual Resolve / captive singleton).
- Reworded the factory <remarks> to describe what is actually wired (default Execute;
  other scopes still get the default on the BAL path) and dropped the overstated
  "gas defaulting" from the registration comment.
- Extracted BuildAmsterdamBalChain test helper; pinned the EIP-2780 intrinsic in the
  GasCap test comment.

Follow-up filed as #12723 (scopes overriding ITransactionProcessorAdapter still get
Execute on the BAL path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: single-axis tx-processor-adapter registration (step 1)

Addresses LukaszRozmej's review: the TransactionProcessorAdapterFactory was a
second registration axis alongside ITransactionProcessorAdapter, so scopes that
only overrode the latter (block production, trace, proof) drifted to the default
Execute adapter on the EIP-7928 BAL path — the same bug class this PR fixes for
simulate (#12723), left live elsewhere. Notably block production silently
downgraded its intended BuildUp semantics to Execute under Amsterdam.

Make the factory the single source of truth: the root registers the default
(Execute) plus a derivation ITransactionProcessorAdapter = factory(processor), and
each scope overrides only the factory — production BuildUp, trace (…
Marchhill added a commit that referenced this pull request Aug 19, 2026
* fix(rpc-bench): run benchmarked nodes the way production runs them (#12625)

* diag(rpc-bench): run Nethermind with production-default runtime (no TC=0 pin)

* reword comment for master

* review: document expb divergence, add NODE_ENV_VARS escape hatch

- README: the 'Alignment with expb' section no longer claims the removed
  env pins; documents the deliberate code-gen divergence and that JIT
  warm-up now lands inside the measured window; dotTrace reports are not
  comparable across this change
- start-node.sh: reword comment (no warm-up phase exists yet), add
  NODE_ENV_VARS passthrough for deliberate one-off code-gen experiments

* trim comments to one-liners; rationale stays in the PR

* drop the Merge GC flags: inert here and misleading

GCKeeper only runs on Engine API calls; this harness parks the node at a
snapshot head and never sends newPayload, so the three flags changed
nothing while implying the node ran a non-production GC configuration.

* keep the image entrypoint for Nethermind

The override skipped entrypoint.sh, which applies host tuning and enables a
shipped PGO profile. Its comment claimed parity with expb, but expb only
overrides the entrypoint for dotTrace, so normal expb runs (and production)
do run entrypoint.sh - this harness was the outlier.

* Rename EIP-8037 regular gas dimension to execution gas (#12600)

* Auto-update fast sync settings (#12665)

Co-authored-by: rubo <rubo@users.noreply.github.com>

* refactor(net): namespace snap by version (#12606)

* refactor(net): namespace snap messages by version

Prepare the snap subprotocol for a second version by moving the concrete
snap/1 messages, message codes and protocol handler into a versioned
namespace, so a snap/2 handler can be added alongside without touching
snap/1 code.

  Snap/Messages/*            -> Snap/V1/Messages/*
  Snap/SnapMessageCode       -> Snap/V1/Snap1MessageCode
  Snap/SnapProtocolHandler   -> Snap/V1/Snap1ProtocolHandler
  P2P/P2PMessageKey.cs       -> P2P/VersionedProtocol.cs  (file renamed to
                                match the type it declares)

SnapMessageBase and SnapSerializerBase deliberately stay in
Snap.Messages: they are shared by all protocol versions, not specific to
snap/1.

Introduce SnapVersions constants and ISnapSyncPeer.SnapProtocolVersion so
version checks can replace the magic numbers currently spelled as 1.

PeerInfoExtensions.CanGetSnapData is renamed to CanGetTrieNodes to say
what it actually tests. The rename is nominal - the peer probe is
unchanged, so behaviour is identical.

No functional change.

* refactor(net): remove Snap2 version constant from SnapVersions

* address review comments

* rename

* feat(sync): serve block access lists from the snap server (#12607)

* Refactor SnapServer and SnapStateServer integration

- Renamed SnapServer to SnapStateServer for clarity and consistency.
- Updated WorldStateManager to use SnapStateServer instead of SnapServer.
- Modified StateSyncFeedTestsBase to accommodate changes in SnapServer instantiation.
- Adjusted SnapProviderTests to reflect the new SnapStateServer type.
- Introduced SnapServerTests to validate SnapServer functionality.
- Added SnapStateServerTests to ensure robust testing of state management.
- Implemented new methods in SnapServer for handling bytecode and block access lists.
- Enhanced test coverage for account range retrieval and storage management.

* refactor: change SnapServer field type to interface ISnapServer

* test: enhance SnapServerTests with additional block access list scenarios

* chore: Update Dockerfiles (#12663)

Update Dockerfiles

Co-authored-by: rubo <rubo@users.noreply.github.com>

* test: make prewarmer env-return assertion pool-hit independent (#12616)

PreWarmCaches_ReturnsAddressWarmEnvWhenScopeBuildThrows asserts
Returned == Created, but with maxPoolSize 1 an env returned to the pool
can be rented again without a Create, so a pool hit legitimately
increments Returned twice against a single Create. Whether a hit occurs
depends on worker interleaving: flaky on CI, fails deterministically on
high-core machines (0/30 locally).

ThrowingBuildPolicy.Return now refuses retention, so every rental is a
fresh Create and the invariant holds under any interleaving (30/30
locally). Stack-trace instrumentation confirmed every rental is returned
exactly once - the prewarmer itself is correct.

* Update OP Superchain chains (#12664)

Co-authored-by: emlautarom1 <emlautarom1@users.noreply.github.com>
Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>

* fix(receipts): restore the post-merge flag before regeneration (#12641)

* fix(receipts): restore the post-merge flag before regeneration

Stored headers do not carry IsPostMerge and regeneration bypasses the
recovery step that restores it, so post-merge blocks re-executed with
PREVRANDAO evaluating to the zeroed difficulty - any transaction reading
it produced receipts failing the root check (~7% of post-merge blocks
refused on mainnet archives deriving receipts from state).

* test(receipts): dispose buffer, pin logged value

* fix(receipts): classify post-merge via the switcher

A difficulty heuristic misreads chains that repurpose the field -
Taiko stores per-block ZK gas in Difficulty while AlwaysPoS - so ask
IPoSSwitcher instead, mirroring MergeProcessingRecoveryStep. On
mainnet the switcher's TD-null branch is the same difficulty check,
so behavior there is unchanged.

* test(receipts): pin the real switcher's TD-null derivation

The production failure arrived as a mainnet-shaped header with
TotalDifficulty unset; cover PoSSwitcher's TD-null branch end to end,
not only the honour-the-switcher contract.

* test(receipts): cover the switcher registration path

A hand-injected switcher cannot catch a composition regression that
leaves the container-resolved regenerator on the NoPoS default, so
resolve it from a graph whose IPoSSwitcher registration is overridden
the way a merge-enabled node overrides it.

* fix(tests): mark RecoverReceiptsBlockchain.Create as hiding

* Expose the node's ENR in admin_nodeInfo (#12631)

feat(rpc): expose the node's ENR in admin_nodeInfo

Nethermind is the only discv5-capable execution client whose
admin_nodeInfo omits the node record. Tooling that bootstraps
discv5-only networks reads the ENR from this endpoint and has to
special-case Nethermind to the enode instead, which is useless once
discv4 is disabled.

NodeRecordProvider already maintains a signed, sequence-numbered self
record, so surface it as an 'enr' field. Move INodeRecordProvider to
Nethermind.Network so Nethermind.JsonRpc can reference it without a new
dependency on Nethermind.Network.Discovery; the implementation stays in
Discovery. The provider is only registered when discovery is enabled, so
it is resolved optionally and the field is omitted otherwise.

* Validate ABI decode allocation bounds (#12588)

* ci: disable stateless glamsterdam-devnet-7 scheduled run (#12680)

* Fix EIP-7708 tracing with logs (#12577)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Naming

* fix(flat): guard the trie-warmer against a TransientResource recycle race (storage reads as 0x00) (#12429)

* fix(flatdb): warm the trie from persistence only

The trie warmer read the recyclable `_snapshots` and `_transientResource`
while the warm job held only a `ReadOnlySnapshotBundle` lease, which does not
cover them. A concurrent scope reset could recycle those under the running
warmer, so a warm read could return a torn or foreign node.

The warmer only needs to warm from persistence, so restrict its reads to the
trie node cache and the `ReadOnlySnapshotBundle` - exactly what the lease
covers. In-memory nodes are already hot and do not need warming.

Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX

* fix(flat): warm the transient resource via a per-job lease

The trie warmer now warms nodes into the per-job-pinned TransientResource
(not just the trie node cache and ReadOnlySnapshotBundle), covered by a single
transient lease held for the whole warm traversal. It still never reads the
recyclable _snapshots. A warmer read on a torn-down bundle bails to a
persistence-only read instead of spinning for a lease that will never land.

Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX

* refactor(flat): drop the warmer transient ThreadStatic capture

Measured TS-on vs always-per-node-lease on x10 (3 runs, heavy-block warmer
load): newPayload 178 vs 178 ms, getProof p99 20.1 vs 20.2 ms, RSS identical.
The ambient capture bought nothing, so keep only the per-node lease + ABA
re-check (the actual recycle-race fix) and drop the ThreadStatic + the
EnterWarmerTransientScope pins in the two warm-job callers.

* fix(flat): register the transient return owner at pool checkout

- ResourcePool.GetCachedResource now calls OnRented, so every checkout
  carries a registered return owner; a final ReleaseLease without one
  throws instead of silently dropping the resource (which leaked the
  BloomFilter allocation on the public AddSnapshot path)
- re-check _isDisposed after the warmer's lease acquire: Dispose releases
  the owner lease but leaves _transientResource pointing at the recycled
  instance, so the identity re-check alone could latch a resource already
  re-rented by another bundle
- document why ReleaseLease is distinct from Dispose: the pool contract
  reserves Dispose for destroying an over-capacity resource
- run Nethermind.State.Flat.Test in the nethermind-tests.yml matrix; it
  was compiled but not run by any CI job
- FlatDbManagerTests duplicate-snapshot test asserts the resource lands
  back in the checkout pool; new ResourcePoolTests cover the final-release
  return and the unregistered-release throw; refresh stale warmer test
  comments

* fix(flat): pin the transient resource for prewarm dedupe reads

ShouldQueuePrewarm read _transientResource without pinning it. The dedupe
bloom lives on that recyclable resource and the call runs on prewarmer and
BAL threads, so the owner could retire the resource mid-read: the pool
Resets it and, on overflow, Disposes the BloomFilter, whose backing store is
native memory. Route both overloads through the same lease + ABA re-check
the warmer node reads already use, and decline the prewarm on a torn-down
bundle. Rename the helper accordingly, since it is no longer warmer-only.

The FlatWorldStateScopeProvider and FlatOverridableWorldScope test doubles
returned the committed resource to the pool directly instead of releasing
its lease, mirroring neither AddSnapshot implementation. That recycles the
resource while a warmer lease is outstanding and returns it a second time
when that lease is released; the scope provider double also returned the
wrong instance and then returned it again on teardown. Both now release the
lease, which is the single return-to-pool path.

Test changes:
- the persistence-only test now commits the written nodes into the bundle's
  recyclable _snapshots before reading, so the warmer's Unknown result is a
  genuine miss. Previously the node was still in the transient (SetStateNode
  writes both) and was itself Unknown, so the assertion held either way.
- the churn test gives every epoch its own persisted node instance, so a
  read served from another epoch's recycled transient is caught by identity
  rather than by value, drives both recycle paths (CollectAndApplySnapshot
  swap and Dispose), exercises ShouldQueuePrewarm alongside the node reads,
  and joins the readers with a bounded wait instead of blocking forever.
- new test: a warmer read and a prewarm check on a disposed bundle fall back
  to the leased persistence reader within a bounded wait, covering the
  Dispose bail-out deterministically.

* fix(jsonrpc): synchronise SubscriptionManager per-client subscription bag (#12672)

* fix(jsonrpc): synchronise SubscriptionManager per-client bag

The per-client subscription bag is a HashSet mutated and enumerated from
multiple threads: concurrent subscribe requests (socket worker tasks),
unsubscribe, and the Closed handler that fires on connection teardown.
Concurrent HashSet access could corrupt it, dropping a subscription so
its event handlers stayed attached and leaked. Lock on the bag for every
add, remove, and snapshot-before-dispose.

Fixes #12668

* refactor(jsonrpc): dispose client subscriptions under bag lock without snapshot

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

* test(jsonrpc): race unsubscribe path too; drop bag field comment

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

---------

Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test: eth_createAccessList affordability with omitted fee fields (#12629)

* test(rpc): eth_createAccessList affordability with omitted fee fields (execution-apis #854)

execution-apis PR #854 clarifies that eth_createAccessList must not fail
solely because an unfunded sender cannot afford client-selected default fees
when all gas-fee fields are omitted. Nethermind already conforms (verified in
hive rpc-compat); this adds a regression test mirroring the conformance
fixture: unfunded sender, codeless recipient, zero value, no gas/fee fields
-> {"accessList":[],"gasUsed":"0x5208"}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(rpc): run affordability test on London chain, assert no in-body error, reuse helper

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>

* Fix stale transaction pool snapshots (#12685)

* Encode engine_newPayloadWithWitness witness as an RLP data string (#12635)

* fix

Signed-off-by: jsign <jsign.uy@gmail.com>

* Tighten witness RLP JSON encoding

---------

Signed-off-by: jsign <jsign.uy@gmail.com>
Co-authored-by: jsign <jsign.uy@gmail.com>

* perf(state): skip trie warmup for read-only BAL accounts in flat layout (#12681)

* perf(state): skip trie warmup for read-only BAL accounts in flat layout

With a suggested BAL the block's write set is known upfront, and trie
nodes are only needed at commit for written accounts. Gate address
trie-warm hints (HintBal, HintGet, HintWarmAccount) on the BAL write
set so read-only accounts no longer trigger state-trie path walks.

On BAL blocks dominated by cold account reads this removes up to ~8-10
wasted trie-node DB reads per unique read-only account from the
measured processing window.

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

* Apply suggestions from code review

Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>

* refactor(state): extract QueueStateTrieWarmup and address review findings

- Extract the NeedsStateTrieWarmup + PushAddressJob + increment pattern
  into QueueStateTrieWarmup, used by all three address warmup call sites
- Hoist CancelHintBal above the empty-BAL early return so a stale write
  set never survives into the next block
- Drop the inaccurate bloom false-positive-rate comment
- Tests: parameterize warm-per-write-kind over balance/nonce/code/storage,
  add empty-BAL reset regression test, split the HintWarmAccount test,
  wrap scopes in using, use order-insensitive assertions

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

* Update src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatWorldStateScope.cs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* refactor(test): reuse TestContext for recording-warmer scope construction

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

* fix(state): bind BAL warmup gate to HintBal lifecycle; address review findings

- Keep _warmupWriteSet across StartWriteBatch: on the parallel BAL path the
  BAL apply commits mid-block, concurrently with tx workers, so clearing the
  gate in CancelHintBal collapsed it at a nondeterministic point. The gate is
  now replaced only by the next HintBal.
- Drop the token from Task.Run in both HintBal implementations: a task
  cancelled before being dequeued never ran the finally that returns the
  pooled accountChanges array. The body already observes the token.
- Align TrieStoreScopeProvider.HintBal with the flat scope: a new hint
  supersedes the previous one even when it carries no work itself.
- Remove the stale prestate-load mutation mention from ReadOnlyBlockAccessList
  docs, state the immutability invariant, and seal the type.
- Tests: pin the gate surviving StartWriteBatch and a second BAL replacing
  the previous write set.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* test: make can-never-fail tests assert what their names claim (#12690)

* test(xdc): drop redundant trailing Assert.Pass in SpecialTransactionsTests

* test(core): bound McsLock re-acquire test instead of passing unconditionally

SingleThreadAcquireRelease asserted nothing and is subsumed by
ReacquireAfterReleaseSucceeds, which now runs on a worker with a timeout
so a broken release fails in seconds instead of hanging the test host.

* test(flat): assert real postconditions instead of Assert.Pass

Add_ConcurrentWithMightContain_ShouldWork now verifies no write is lost
under concurrent readers (a bloom filter never false-negatives).
DisposeAsync test renamed: FlatDbManager does not dispose the repository
(the container owns it); it now asserts bounded drain completion and
idempotent double-dispose. ConstructorAcceptsPersistedRepository removed -
Not.Null on a fresh object, subsumed by every other test in the file.

* test(merge): assert pending-validation cleanup instead of catch-only assertions

The memory-leak test asserted only inside catch blocks and ended with
Assert.Pass, so it passed whenever nothing threw; it now drives the
handler directly and asserts the pending-validation count stays zero
across repeated timed-out payloads. The TrySet double-completion test
is removed: it was a weaker duplicate of the concurrent-calls test
above it (same scenario, but swallowing OperationCanceledException and
keying Assert.Fail on exception message text).

* test(merge): await header-sync test helpers

The helpers were async void and invoked without awaiting - including one
un-awaited call inside the other helper - so their assertions raced the
test body and failures could surface as host crashes or not at all.

* test(network): restore DisconnectsAnalyzer assertions with deterministic flush capture

All four tests had their assertions commented out as CI-flaky, leaving
them assertion-free. The flakiness came from racing the 10ms flush
timer: reports are now recorded while the default 10s interval is in
effect and only then is the interval shortened, so a flush cannot fire
mid-arrangement. Assertions scan captured flush reports, including that
counters aggregate, reset after each flush, and cleared categories do
not resurface (the analyzer double-buffers, so a lost clear shows up as
a stale count in every other flush, not as a doubled count).

* test: address review findings on strengthened tests

Will_clear_after_report no longer issues a second report at all - a
report can race the flush's enumerate-then-clear window (Timer.Stop does
not drain a queued Elapsed callback, so no test-side quiesce is airtight).
A lost clear is observable without it: the analyzer double-buffers, so a
stale count resurfaces in later flushes, and the test asserts the
category appears in exactly one flush across several more (mutation-
verified). Bloom capacity raised to 100k - at 10k the saturated filter's
~34% false-positive rate masked a single lost write - and misses are
collected into one assertion. The repeated-timeouts payload test is
dropped rather than parameterized: the pending dictionary is keyed by
block hash and the test resubmits one block, so the count can never
exceed one and iterations add no coverage. The FlatDbManager dispose
test states its intent with Assert.DoesNotThrowAsync and a corrected
comment (WaitAsync bounds the wait, not the drain). Wait timeouts are
named constants and polling uses Thread.Sleep(1).

* test: simplify comments per ASD-STE100 and drop dead times parameter

Comments now use short, active, single-topic sentences. The times
parameter of ShouldEventuallyReport had no remaining non-default call
site after the second-report removal.

* test: use SpinWait.SpinUntil instead of a custom poll helper

Keep one condition re-check after a timeout: a flush can land in
SpinUntil's final sleep tick.

* Stop parallel transaction execution once BAL validation rejects the block (#12697)

* fix(consensus): stop parallel tx execution once BAL validation rejects

The parallel BAL block validator ran the incremental validator alongside the
transaction workers, but a validator failure faulted neither the worker loop nor
its cancellation token. `ParallelUnbalancedWork` only stops fetching new indices
on caller cancellation or a transaction-worker fault, so a block rejected at an
early transaction index still executed every remaining transaction before the
foreground observed the failure at `GetResult()`. For a block whose invalidity is
decided by a cheap prefix, that turns a sub-second rejection into slot-scale CPU
work.

`IncrementalValidationWorkItem` now owns a cancellation source that is signalled
together with the stored exception, and the worker loop runs under that token, so
workers stop pulling transactions as soon as validation becomes terminal. The
resulting `OperationCanceledException` is translated back into the original
validation failure, keeping the returned error identical.

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

* refactor(consensus): signal BAL validation failure with a flag, not cancellation

Routing the stop signal through ParallelUnbalancedWork's cancellation token meant
`For` ended by throwing an OperationCanceledException that existed only to be
caught and translated back into the original validation failure. The token also
needed a per-block CancellationTokenSource, whose recycling in Schedule was only
safe because the previous block's validator had already been joined.

Replace all of it with a volatile read of the exception the work item already
stores: workers check `HasFailed` before doing any work, the loop drains its
remaining indices without executing anything, and `GetResult()` reports the
rejection on the normal return path. Same behaviour, no exception used as control
flow, no extra state to keep in sync, and no ordering constraint between Schedule
and the work that follows it.

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

* refactor(test): trim comments and simplify the tail-cancellation test

Comments cut back to the non-obvious reasoning only. The regression test now
counts executions instead of collecting indices, so a single Is.InRange assertion
covers both requirements — the decisive prefix ran, the tail did not — and the
prefix array, the index bag and one assertion all go away. Transaction count is a
plain constant rather than derived from the canonical lead, which the test never
depended on: with uniform gas limits the tail sort is stable, so the schedule is
natural order regardless. CreateParallelValidationTransactions takes an optional
gas limit so the block no longer needs patching after construction.

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

* fix(consensus): address review — exempt iteration 0, loosen test bound

Iteration 0 (WaitForBalWarmup + ApplyStateChanges) is now exempt from the
HasFailed guard, so pre-execution keeps its previous semantics instead of being
droppable when validation fails before any worker starts. Skipping it was traced
as benign, but the fix only needs to stop transaction execution, so leaving the
pre-execution step alone keeps the behavioural change narrower at no cost.

The regression test's upper bound leaned on SpinWait outlasting exception
unwinding, because the fake validator releases its gate before the work item
stores the exception. Bound is now txCount / 8, which asserts the tail stopped
without depending on cancellation-propagation timing — a revert still executes
all 2048 and fails.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* test: pin eth/62-66 serializer wire encodings with hand-derived goldens (#12696)

* test(network): remove duplicate eth serializer tests

ZeroNewBlockMessageSerializerTests.Roundtrip2 and V63
NodeDataMessageSerializerTests.Zero_roundtrip were verbatim copies of
their Roundtrip siblings. Can_deserialize_own_eth_64 shared its body
and one payload with Can_deserialize_eth_64; its unique payload moves
there as another TestCase.

* test(network): pin eth/62-66 wire encodings with hand-derived goldens

Every serializer test file in Eth V62-V66 now asserts an exact wire
encoding somewhere. The goldens are derived from the RLP rules and
verified with an independent encoder (pyrlp + pycryptodome keccak);
values shared across files live in EthSerializerGoldens. ToString
smoke tests now assert the log line names the message type, and the
32-byte-selector test asserts the exact decoded hash.

* test(network): address review feedback on serializer goldens

- BlockBodies: pin the null-body framing (c1c0) and the empty-vs-absent
  withdrawals distinction (e9e8...c0c0)
- Status To_string: assert against the independent Protocol.Eth constant
- V63 NodeData: rename Roundtrip_with_nulls to Roundtrip_with_empty_entry
  (the data holds an empty array, not null)

* feat: EIP-8037 two-dimensional gas tracing (stateGasTracer + callTracer) (#12628)

* feat(tracing): add EIP-8037 stateGasTracer (execution-apis #852)

Implements the `stateGasTracer` named tracer specified in execution-apis
PR #852, returning the per-transaction two-dimensional gas summary
`{gasUsed, regularGasUsed, stateGasUsed, gasRefund}` (EIP-8037/EIP-7778).

The values are already computed for block-level gas accounting and carried
on `GasConsumed`; the tracer only reads and formats them. Adds the missing
applied EIP-3529 refund (capped) to `GasConsumed.GasRefund`, populated in
the success and top-level-halt refund paths. The native tracer factory is
threaded with `IReleaseSpec` so the fork is determined explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tracing): add EIP-8037 two-dimensional gas to callTracer (execution-apis #852)

Adds `regularGasUsed`, `stateGasUsed` and `gasRefund` to the callTracer
top-level frame for Amsterdam+ blocks, per execution-apis PR #852. The
fields are gated on `IReleaseSpec.IsEip8037Enabled` (MUST NOT appear before
the fork) and set only on the top frame (omitted on sub-frames). Values are
read from the transaction's `GasConsumed` result, matching the stateGasTracer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): address #12628 review feedback

- Restore native-tracer factory API back-compat: keep the public 4-arg
  GethLikeNativeTracerFactoryDelegate/RegisterTracer/CreateTracer so external
  plugin registrations stay source- and binary-compatible; built-ins receive
  the (nullable) IReleaseSpec via an internal spec-aware factory.
- stateGasTracer: disable IsTracingOpLevelStorage/IsTracingStack so the
  terminal-only tracer stops invoking per-opcode storage/stack callbacks.
- Consolidate the callTracer top-frame regularGasUsed/stateGasUsed/gasRefund
  into a single TwoDimensionalGas? value, removing the coupled nullables and
  the unsafe null-forgiving dereferences in the converter.
- Document that regularGasUsed is floor-clamped (block-accounting value) so
  the two-dimensional invariant is exempted under the calldata floor.
- Add an end-to-end stateGasTracer test executing a real Amsterdam tx through
  the TransactionProcessor (fresh SSTORE + in-tx reset) that exercises field
  selection and the GasConsumed.GasRefund plumbing; assert
  regularGasUsed + stateGasUsed == gasUsed + gasRefund.
- Test cleanups: multiple-assert scope and a shared helper for the two
  callTracer Amsterdam cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): address #12628 re-review nits

- Document the spec-less CreateTracer overload's pre-fork fallback and cover it
  with a factory test (back-compat public contract was untested).
- stateGasTracer hex test now forces the ambient NumberConversion.Raw so it
  actually exercises StateGasTraceConverter's hex-quantity override.
- E2E test: use a `using` tracer and correct the refund assertion message
  (the slot is reset to its original zero value within the tx, not pre-nonzero).
- Convert the remaining `//` member comments to XML doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: remove unused using in NativeStateGasTracerE2ETests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): drop excess comments; non-nullable spec in native tracer factory

Address flcl42 review on #12628:
- Remove the newly added explanatory comments that restated the code (kept only a
  few essential EIP-referenced DTO docs and the hex-conversion note).
- Make the release spec non-nullable through GethLikeNativeTracerFactory and the
  native tracers: drop the unused spec-less 4-arg CreateTracer overload (the only
  null source) — RegisterTracer, the actual plugin API, is unchanged. Also drops
  the brittle regularGasUsed occurrence-count assertion flcl42 flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: collapse double blank line before DeepNesting test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): skip EIP-3607 on the EIP-7928 BAL path in eth_simulateV1 (#12691)

* fix(simulate): skip EIP-3607 on the EIP-7928 BAL path in eth_simulateV1

eth_simulateV1 relaxes EIP-3607 so a state-overridden contract can be the tx
`from`. That relaxation covered only the main execution (via
SimulateTransactionProcessorAdapter). Under glamsterdam (EIP-7928),
BlockProcessor runs transactions through the BlockAccessListManager's own tx
processors, which bypass the adapter and re-enforce EIP-3607 — so a contract
sender is rejected with `-38024 sender has deployed code` instead of reaching
the normal balance/fee checks (hive rpc-compat divergence on glamsterdam-devnet-8:
ethSimulate-simple-send-from-contract*, ethSimulate-override-address-twice).

Relax EIP-3607 on the block execution context in
SimulateBlockValidationTransactionsExecutor instead:
ParallelBlockValidationTransactionsExecutor sets that context on both the main tx
processor and the BAL manager, so both paths skip the check — while BlockProcessor
still receives the unwrapped spec, preserving chain-specific release-spec
interfaces (Taiko / XDC / Optimism).

Verified end-to-end on a glamsterdam-at-genesis chain (the three hive fixtures
flip -38024 -> -38014/-38012, matching besu/erigon/reth/geth) and by an
integration test through the real EIP-7928 BAL path that fails -38024 without the
fix and passes with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): preserve PrevRandao in context rebuild; harden 3607 BAL test

Address re-review on #12691:
- SimulateBlockValidationTransactionsExecutor is now the single context-rebuild
  funnel for the simulate scope, so preserve the incoming PrevRandao (via
  BlockExecutionContext.WithPrevRandao*) instead of re-deriving the default —
  a BlockProcessor subclass (e.g. XdcBlockProcessor) may have supplied a
  non-default value.
- Make eth_simulateV1_contract_sender_skips_eip3607_on_bal_path validation-enabled
  so the -38014 expectation is fork-independent and stable across the #12692 fix
  (with validation:false the -38014 relied on the BAL path ignoring NoValidation).
  Pin the EIP-7928 premise with an explicit BlockLevelAccessListsEnabled assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): forward BlobBaseFee too in the context rebuild

Re-review follow-up on #12691: WithPrevRandao recomputes BlobBaseFee from the
header, so the no-override branch was newly lossy for a processor that forced a
non-derivable value (XdcBlockProcessor sets BlobBaseFee = 0 on a header cloned
with ExcessBlobGas = 0; recomputation yields MinBlobGasPrice = 1). Collapse to a
single WithPrevRandaoAndBlobBaseFee that forwards the incoming BlobBaseFee (or the
block override when present), leaving Spec as the only field the rebuild changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): relax EIP-3607 via execution flag, not spec wrapping

The eth_simulateV1 contract-sender fix relaxed EIP-3607 by wrapping the block
execution context spec with WithoutEip3607(), which returns a NoEip3607Spec
decorator implementing only IReleaseSpec. Because the tx processors read their
spec from that context (TransactionProcessor.GetSpec => BlockExecutionContext.Spec),
the decorator reached chain-specific processors that hard-cast it — e.g.
TaikoTransactionProcessor.PayFees does (ITaikoReleaseSpec)spec and
XdcTransactionProcessor casts on every tx — so eth_simulateV1 on Taiko/XDC threw
InvalidCastException/InvalidOperationException instead of returning a result
(Eip3607Transition defaults to 0, so the wrap is always active there).

Replace the spec wrapping with a dedicated ExecutionOptions.SkipSenderCodeCheck
policy flag gated in ValidateSender. The main simulate adapter ORs it into its
Process call; the EIP-7928 BAL path receives it via a small
BlockAccessListTxExecutionOptions injected into BlockAccessListManager and threaded
to its ExecuteTransactionProcessorAdapter. The release spec now keeps its concrete
runtime type on every path, so chain-specific interfaces survive.

Regression test: Nethermind.Taiko.Test exercises a code-bearing sender through
TaikoTransactionProcessor.PayFees — it passes with the flag and throws
InvalidCastException under the old spec-wrapping. eth_simulateV1 BAL-path and full
simulate suite remain green.

Follow-up to #12691; addresses the residual type-erasure raised in its review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(simulate): trim explanatory comments to essentials

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): carry EIP-3607 relaxation on BlockExecutionContext

Replace the ExecutionOptions.SkipSenderCodeCheck flag (and the machinery to thread
it into the EIP-7928 BAL path) with a SkipSenderCodeCheck field on
BlockExecutionContext. Both the main tx processor and the BAL manager's own
processors already share the context via SetBlockExecutionContext, so the field
reaches every path for free — no threading through BlockAccessListManager /
TxProcessorPool / ExecuteTransactionProcessorAdapter, and no injected policy type.

The context is the same shared channel #12691 originally relaxed on; this just uses
a flag instead of a spec decorator, keeping the spec's concrete runtime type (so
ITaikoReleaseSpec/IXdcReleaseSpec casts survive). Net simpler diff and consistent
with the existing IsGenesis flag on the same struct.

ValidateSender reads VirtualMachine.BlockExecutionContext.SkipSenderCodeCheck; the
simulate executor sets it in its context rebuild. Taiko regression test updated to
set the flag on the context (still throws InvalidCastException under spec-wrapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): carry EIP-3607 relaxation on TransactionProcessor

Per review preference (@LukaszRozmej), move the SkipSenderCodeCheck flag from
BlockExecutionContext onto TransactionProcessor. ValidateSender reads the
processor's SkipSenderCodeCheck property. The simulate scope sets it type-
preservingly on both creation paths: Intercept<ITransactionProcessor> for the
main processor, and a factory decorator for the EIP-7928 BAL processors — so each
chain keeps its concrete processor type (no spec wrapping, no processor-type swap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: trim comments to the essential why

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): remove dead WithoutEip3607; address review polish

Follow-up to the SkipSenderCodeCheck switch (review by @claude on #12691):
- Remove the now-dead EIP-3607 spec wrapper — this PR dropped its last production
  caller. Deletes WithoutEip3607, NoEip3607Spec, GetNoEip3607Spec (std + zkevm) and
  the _noEip3607Specs cache; keeps WithoutEip158 (still live). Fixes two comments
  that cited the removed decorator.
- Enforce the relaxation invariant: a shared Apply() throws if the resolved
  ITransactionProcessor isn't a TransactionProcessorBase, instead of silently
  no-op'ing back to -38024. Used by both the Intercept hook and the factory decorator.
- XML-doc SkipSenderCodeCheck with its set-before-use / unsynchronised-read invariant.
- Make SkipSenderCodeCheckTransactionProcessorFactory internal.
- Revert SimulateBlockValidationTransactionsExecutor to master (the fix no longer
  touches it; its PrevRandao tweak was unrelated).
- Assert fee payment in the Taiko regression test (matches its name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dns): verify EIP-1459 subtree hashes (#12707)

* fix(dns): verify EIP-1459 subtree hashes

EIP-1459 serves every subtree entry from the subdomain
base32(keccak256(entry)[..16]). The root signature covers only the
enrtree-root entry, so this hash chain is the only binding between the
signed root and the branch and ENR records a client consumes. The
crawler never checked it, so any resolver or poisoned cache could
substitute arbitrary node records and steer the crawl to
attacker-chosen labels.

Verification follows go-ethereum: unpadded standard base32, prefix
comparison against keccak256 of the record, abbreviated labels accepted
down to 12 decoded bytes (minHashLength). The tree root is exempt, as
EIP-1459 serves it from the bare domain with no hash label. A mismatch
logs at Warn and skips the record.

No base32 decoder existed in src/Nethermind, so EnrTreeHash carries a
small one, tested against labels produced by the reference
implementation.

Validated against live trees: all.mainnet.ethdisco.net crawls 3000
records with zero rejections, so the check does not misfire on
anything the reference publisher emits.

* refactor(dns): simplify and harden EnrTreeHash

- Reuse Keccak.Size instead of a local MaxHashLength constant.
- Decode base32 arithmetically instead of scanning the alphabet string.
- Guard the decoded-length check against int overflow on hostile input.
- Hash UTF-8 bytes from a stackalloc/pooled buffer instead of allocating.

* refactor(dns): log hash-mismatch rejections at Debug, clarify base32 mapping

---------

Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>

* Selectable dotTrace profiling mode + dotnet-trace EventPipe sidecar for benchmark workflows (#12708)

* benchmarks: selectable dotTrace profiling mode (sampling | tracing | timeline)

The dottrace input on run-rpc-benchmarks and run-expb-reproducible-benchmarks
becomes a choice: false | sampling | tracing | timeline ('true' stays accepted
as a legacy alias for sampling on API dispatches). The mode maps to the
dotTrace CLI's --profiling-type: rpc-bench sets it on the wrapped entrypoint in
start-node.sh; the EXPB workflow passes --dottrace-mode to expb (only when
non-default, so pinned expb versions keep working for sampling runs; requires
execution-payloads-benchmarks feature/dottrace-profiling-modes for the new
modes).

Timeline snapshots cannot be converted to XML by Reporter.exe, so the
generate-dottrace-reports and summary jobs skip that mode - the raw .dtp
artifact is still collected and uploaded for the dotTrace UI. Line-by-line is
deliberately not offered: it needs PDBs the client docker images do not carry.
EXPB additionally rejects trace_blocks with timeline, since per-block
snapshots ride the MeasureProfiler API, which needs a performance session.

* expb workflow: collect a dotnet-trace EventPipe sidecar with every dotTrace run

Whenever dottrace is enabled the run also passes --dotnet-trace to expb: a
host-side EventPipe session records gc/contention/threading/exception events
(no CPU sampler - dotTrace owns the stacks) and the .nettrace ships in the same
dottrace-* artifact. Requires expb feature/dottrace-profiling-modes.

* rpcbench/expb: document the profiling modes and fix two review nits

Docs still described the pre-mode behaviour: the rpc-bench README documented a
dottrace command line with no --profiling-type and stated capture deliberately
runs in default sampling mode, and its input table listed dottrace as a boolean.
AGENTS.md's expb section had the same gap. Both now cover the four choices, when
to reach for each, that timeline yields no XML, and the dotnet-trace sidecar -
that section is what agents read to interpret run artifacts.

Timeline snapshots now save as .dtt rather than .dtp. The report job is gated off
for timeline either way, but the extension is what stops Reporter.exe's .dtp glob
from picking up a snapshot it cannot convert if that gate is ever relaxed.

The expb dottrace flag no longer special-cases sampling: the run already requires
an expb that understands --dotnet-trace, so there is no older-expb compatibility
left to preserve, and the flag string was spelled out twice in each of two
copy-pasted job bodies. The trace_blocks default guard becomes an explicit if -
as the last statement of its if body the AND-list left the block with status 1,
surviving only through errexit's AND-OR exemption.

* docs: scope the EventPipe sidecar to EXPB

The rpc-bench README claimed every profiled run drops a .nettrace into the
dottrace-rpcbench artifact, but --dotnet-trace was only wired into the EXPB
workflow - nothing in run-rpc-benchmarks.yml or scripts/rpc-bench collects one, so
the paragraph sent readers hunting for a file that is never produced, and promised
it precisely for timeline runs, which have no XML either. Say what a timeline
rpc-bench run actually yields: the .dtt snapshot alone.

* fix(jsonrpc): serialize receipt root as full-width DATA (#12706)

* fix(jsonrpc): serialize receipt root as full-width DATA

* test(jsonrpc): parameterize the receipt-root width cases

* test(jsonrpc): pin the whole-byte leading-zero root case

* test: add hand-derived golden tests for eth/71 and snap serializers (#12699)

* test(network): pin eth/71 and snap serializer wire encodings

Every serializer test file in Eth V71 and Snap/V1 now asserts an exact
wire encoding. The goldens are hand-derived from the RLP rules and
verified with an independent encoder (pyrlp + pycryptodome keccak).
Random request ids are pinned only in the goldened tests; ByteCodes
gains a sibling roundtrip that keeps the constructor-assigned id, and
GetAccountRange pins long.MaxValue to keep the eight-byte id path.

* test(network): share repeated snap golden fragments

The request-id, keccak-of-empty-string, and range-hash fragments moved
into SnapSerializerGoldens. Each golden fragment and its test input
come from one hex constant, so the expectation cannot drift from the
input.

* test(network): address review feedback on snap golden tests

- ByteCodes: one parameterized Roundtrip; long.MaxValue pins the
  eight-byte request id deterministically instead of a random id
- GetAccountRange: independent field asserts in EnterMultipleScope
- SnapSerializerGoldens: expose RequestId1111 next to its RLP item;
  the remarks state which fragments share hex with inputs and that
  the keccak("") fragment is an independent literal on purpose

* `debug_trace*`: Fix phantom logs on frame revert (#12621)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Phantom log test

* Remove logs on a reverted frame

* Naming

* Fix leaking `ArrayPooList`

* Code cleanup

* Additional test

* Simplify tests

* Build fix

* Snap sync: reject storage range responses with unmatched slot lists (#12729)

* fix(snap): reject storage range responses with unmatched slot lists

A `StorageRanges` reply carries one slot list per requested account, but
nothing verified that the responder returned no more lists than were asked
for. `AddStorageRange` looped over the response count and indexed the request
account list with it, so a longer-than-requested reply read past the end of
the request.

Reject such a response up front and retry the range, which also settles the
active storage request accounting so that the range download phase can still
reach completion.

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

* test(snap): pin the slot list count boundary

Cover equal and fewer slot lists than requested accounts so that widening the
guard to `>=` cannot pass, which would reject every well-formed response.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(metrics): durable sync-time and full-pruning duration metrics (#12590)

* feat(metrics): durable sync-time + per-stage breakdown and full-pruning duration

Sync:
- Fix UpdateAndGetSyncTime() so the total is retained after sync completes
  instead of resetting to zero (EthSyncingInfo and Taiko override).
- Replace nethermind_sync_time with nethermind_sync_time_seconds (durable).
- Add nethermind_sync_time_in_mode_seconds{sync_mode} per-stage breakdown,
  driven by a new SyncTimeInModeTracker on ISyncModeSelector.

Pruning:
- Add nethermind_full_pruning_last_duration_seconds and
  nethermind_full_pruning_count, recorded on successful FullPruningDb.PruningFinished.
- Suffix in-memory trie pruning gauges with *Ms and document the unit.

* refactor(metrics): address PR review on sync/pruning time metrics

- Sync time no longer drops to 0 for one scrape when a stopped node
  re-syncs: extract shared SyncTimeStopwatch that always returns the
  retained total (used by EthSyncingInfo and Taiko override). Add
  stop->resume regression tests.
- SyncTimeInModeTracker is now IDisposable (unsubscribes from
  ISyncModeSelector.Changed), seeds its labels with TryAdd instead of
  clobbering the shared static dictionary, and is owned by the container.
  Timestamp seam moved to an internal ctor.
- SyncTimeInModeSeconds is get-only to match the other [KeyIsLabel] metrics.
- FullPruningCount uses the Interlocked backing-field pattern like the
  other counters in Db/Metrics.
- Clarify that FullPruningLastDurationSeconds covers the trie copy+commit,
  not the wait for a suitable state root.
- Make the full-pruning duration test assert the write actually happened.

* fix(metrics): avoid DI cycle wiring the sync-mode tracker; drop unused using

- Attach SyncTimeInModeTracker via Intercept<ISyncModeSelector> instead of
  resolving it during IMonitoringService construction. Resolving it there
  created a container cycle (IMonitoringService -> tracker -> ISyncModeSelector
  -> ... -> DbTracker -> IMonitoringService) that crashed the node at startup.
  This keeps the monitoring module free of outward dependencies, mirroring the
  existing IEthSyncingInfo wiring.
- Remove unused `using System;` in SyncTimeInModeTrackerTests (IDE0005).

* Only accept the requested header in FetchHeaderFromPeer (#12730)

* fix(sync): only accept the requested header in FetchHeaderFromPeer

FetchHeaderFromPeer queries every initialized peer in parallel and takes
the first non-null response, but never checked that the returned header
is actually the one that was asked for. A peer that answers with some
other block had its header accepted, so the result depended on which
peer replied first rather than on what was requested.

Compare the returned header's hash against the requested hash on both
response paths. A non-matching response is treated like a missing one,
so the remaining in-flight peers can still supply the header instead of
the whole lookup failing.

The sibling lookups in StartingSyncPivotUpdater and PeerRefresher
already do this; FetchHeaderFromPeer was the one that did not.

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

* test(sync): cover the allocated-peer fallback and tighten assertions

Add a case where no peer answers the head-header request, so the
GetBlockHeaders fallback is the one that resolves the header. Nothing
pinned its success branch before, so it could have been broken without
a test noticing.

Assert on the header instance rather than its hash, so the negative
case cannot pass for a header that merely has no hash set.

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

* fix(sync): report a peer that answers with a different block

A mismatched header was discarded silently, leaving it indistinguishable
from a peer that simply does not have the block. Requests are serialised
per peer by MessageQueue and a late response to a timed-out request is
disposed rather than handed to the next caller, so a mismatch is always
a protocol violation rather than a benign response race.

Report it as UnexpectedHeaderHash, matching HeadersSyncFeed and the
other header lookups.

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

* test(sync): cover that an honest peer keeps its connection

Reporting a mismatch disconnects the peer, so the costly failure mode is
now an honest peer being dropped rather than a wrong header accepted.
Only the reported direction was asserted.

Cover all three answer shapes, including a peer that does not have the
block, which is the normal answer while a head is unknown and must not
cost a connection.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Fix doubled revert handling in some tracers (#12715)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Phantom log test

* Remove logs on a reverted frame

* Naming

* Fix leaking `ArrayPooList`

* Code cleanup

* Additional test

* Simplify tests

* Showcase test

* Direct fix

* More failing tracing tests

* Remove problematic `ReportActionRevert` > `ReportActionError` delegation from base

* Get rid of virtual-to-virtual calls in report revert/error

* Formatting

* Build fix

* Remove other `ReportActionRevert` -> `ReportActionError` calls

* Move common test codes to base class

* Fix `IsTracingActions` summary

* Small test fix

* Code cleanup

* test(era): anchor accumulator tests to EIP-7643 vectors, remove unused GetProof (#12718)

* test(era): anchor AccumulatorCalculator roots to derived spec vectors

Replaces the two-instances self-oracle, the inequality-only comparisons,
and the DoesNotThrow-only Add tests with parameterized roots derived by
an independent Python SSZ merkleization of the portal-network history
spec. Adds proof fold-up verification against the derived roots.
Deletes the byte-identical duplicate golden test in Era1.Test.

* test(era): assert the accumulator root the readers return

ReadAccumulator and ReadAccumulatorRoot tests discarded the root and
asserted only Throws.Nothing. They now assert the root equals the
accumulator of the written contents. The shared calculator loop moves
into a ComputeAccumulatorRoot helper in each file.

* test(era): apply review round on the accumulator vector tests

Parameterizes the proof verification and adds a three-entry index 2
case, so the upper tree levels exercise the right-hand sibling order.
Moves the proof length assert into the fold-up helper. Rewrites the
provenance comments in active voice and adds the spec link.

* test(era): cite EIP-7643 as the accumulator spec reference

The portal-network history spec was restructured and no longer defines
the accumulator. EIP-7643 defines HeaderRecord and the 8192-entry epoch
record. Also states the exact value of the index 2 proof case: it is
the only case that catches a wrong index shift.

* refactor(era): remove unused AccumulatorCalculator.GetProof

GetProof shipped with the EraE feature but no production code ever
called it; its only callers were its own tests. The removal also drops
the _totalDifficulties list, which only GetProof read, and the proof
tests in Nethermind.EraE.Test.

* docs(era): cite EIP-7643 on AccumulatorCalculator

The linked portal-network document was restructured and no longer
defines the accumulator. EIP-7643 defines HeaderRecord and the
8192-entry epoch record this class computes.

* test(era): apply removal-round polish

Converts the class comment to an XML summary per the documentation
rule. Inlines the two-entry root, which lost its second consumer with
the proof tests.

* test(era): consolidate the accumulator fixtures into Era1.Test

Both fixtures tested Nethermind.Era1.AccumulatorCalculator with
overlapping vectors. The Era1.Test table now carries every
discriminating single-entry case, a pinned empty-accumulator root, and
a Clear reset test against the writer-reuse contract. The EraE.Test
copy is deleted.

* test(era): state only true contracts in the fixture comments

The Clear comment claimed a multi-file writer-reuse path that does not
exist: Add throws after Finalize and each era file gets a fresh writer.
The vector comment claimed a pairwise single-input difference that two
case pairs violate. Both now state only what holds.

* test: strengthen mock-echo and vacuous tests in Abi, Optimism and Xdc test projects (#12720)

* test(abi): pin forwarding and return propagation in encoder extensions

The extension tests asserted only Received on the inner call and
ignored the extension return value. A stub on the exact unpacked
arguments plus an identity assert on the result covers both.

* test(xdc): assert the RocksDb config factory routing

The single test asserted Is.Not.Null on a result that cannot be null.
The factory contract is routing: Xdc databases get a PerTableDbConfig
without consulting the base factory, and every other database delegates
to it. Both branches are now pinned; the delegation branch was
untested.

* test(xdc): pin the routed timeout instance

The vote routing test pins the exact vote, but the timeout test used
Arg.Any, so a handler that routes the wrong timeout passed.

* test: apply the C11 review round

Removes an unused using that fails lint CI. Pins the factory options
against the provided IDbConfig, so a wrong database name in the special
branch turns the test red. Moves the extension rationale to class level
and renames the Abi tests to the project snake_case convention.

* test: state only true mechanisms in the C11 comments

NSubstitute returns an empty array, not null, for an unmatched call on
an array-returning member. The prefixed-options claim holds for only
half of the database prefixes, so the sentence is dropped.

* test(xdc): use a neutral database name in the delegation test

Review feedback on #12720: the delegation test used the literal
"State"/"Code", and "State" is the one name PerTableDbConfig
special-cases (StartsWith("State")). Use nameof(DbNames.Blocks) with a
null column so the test isolates the delegate-to-base contract without
that special-case, matching the sibling FlatRocksDbConfigAdjusterTests
idiom.

* test: anchor crypto and RLP tests to independent expectations (#12712)

* test(core): anchor the keccak span test to an independent vector

* test(core): anchor RLP ulong lengths to the spec

* test(core): compare decoded blocks to the original and drop the ignored file writer

* test(core): compare decoded header fields to the original block

* test(core): anchor the regression block decode to pyrlp-derived fields

* test(core): apply review round - drop dead using, widen anchors, guard indexed asserts

* test(core): apply round-2 review polish

* test(core): cover the header tail fields and sharpen the roundtrip comments

* test(core): apply confirm-round nits

* test(core): compare decoded uncle hashes in the block roundtrip

The body compared uncles by count only. The scenarios build two uncles
with distinct headers, so the hash comparison catches an order or
content error the count cannot see. The count guard moves outside the
multiple-assert scope like the transaction guard.

* Update OP Superchain chains (#12752)

Co-authored-by: emlautarom1 <emlautarom1@users.noreply.github.com>

* Auto-update fast sync settings (#12751)

Co-authored-by: rubo <rubo@users.noreply.github.com>

* test: Db tests assert stored state instead of smoke-calling empty methods (#12693)

* test(db): assert stored state instead of smoke-calling empty methods

MemDb.Flush and Dispose (and the Snapshotable variants) are empty method
bodies, so the six *_does_not_cause_trouble tests could never fail;
removed. The no-assert MemDb creation tests now verify the value round-
trips. ReadOnlyDbProviderTests.Can_clear exercised an empty registry (no
db was ever registered), making ClearTempChanges a no-op; it now
registers a real MemDb-backed read-only db and asserts the overlay is
dropped while the wrapped db stays intact, plus the no-writes contract
when localChanges is off. Can_get_all_on_empty now asserts emptiness
instead of discarding the enumeration.

* test(db): assert overlay drop only where a write could land in the overlay

* test(db): group independent post-condition asserts in Assert.EnterMultipleScope

* Reject invalid fixed-size header RLP (#12579)

* Treat a null header answer as the block being absent (#12741)

* fix(sync): treat a null header answer as the block being absent

An empty list item decodes to a null header and is kept in the response
list, so the allocated-peer fallback could pass one into the hash
comparison and throw. FetchHeaderFromPeer only catches cancellation and
timeouts, so it would surface into engine_forkchoiceUpdated.

Handle it in Validate, which lets the head-header path drop its own null
check too.

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

* test(sync): name the mock switch after the answer it produces

The peer answers with a null header, not an empty one. Move it next to
the other answer switches so HeaderToReturn keeps its own doc.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Return only the requested header from GetHeadBlockHeader (#12740)

* fix(network): return only the requested header from GetHeadBlockHeader

GetHeadBlockHeader asks for one specific block but returned whatever
single header the peer sent back. Every caller wants the block it asked
for, and two of them re-checked the hash themselves afterwards; the peer
refresh in SyncPeerPool did not, so it would take a substituted header
and record the wrong head number and total difficulty for that peer.

Compare the response against the requested hash where the request is
made, and disconnect a peer that answers with a different block. A peer
that does not have the block answers with an empty list, which is the
normal response while a head is unknown and still yields null.

Drop the now-redundant check in StartingSyncPivotUpdater. The one in
PeerRefresher stays: that method validates its head/parent response
locally anyway, since GetBlockHeaders carries no such guarantee.

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

* fix(network): treat a null header answer as the block being absent

An empty list item decodes to a null header and is kept in the response
list, so a peer can answer a single-header request with one null entry.
Dereferencing it to compare hashes threw, and two callers do not catch
that: the pivot updater would leave its update loop for good, and
FetchHeaderFromPeer would surface it into engine_forkchoiceUpdated.

Treat it the same as an empty list — the peer does not have the block —
rather than as a breach.

Also type the requested hash as nullable, matching _remoteHeadBlockHash
before the status handshake, and assert the absent cases on the result
itself rather than on its hash.

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

* fix(network): make the requested-header guarantee unconditional

The hash check was skipped when neither the argument nor the announced
head was known, so the documented contract had a hole. Return early
instead: with no hash to ask for there is no meaningful request to send.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: add built-in portfolio viewer UI at /portfolio (#12360)

* Handle failed sender recovery (#12757)

* fix(simulate): route the EIP-7928 BAL path through the simulate tx adapter (#12692) (#12721)

* fix(simulate): route EIP-7928 BAL path through the simulate tx adapter

Under EIP-7928, eth_simulateV1 runs transactions through the
BlockAccessListManager's own tx processors, bypassing
SimulateTransactionProcessorAdapter. That lost its GasCap budget clamp, its
TotalGasLeft/BlockGasLeft accounting (block gasUsed reported as 0), and its
validation:false handling (the BAL path always called Execute, never Trace).

Inject the adapter via a new ITransactionProcessorAdapterFactory so the
sequential BAL manager — the only one simulate drives — wraps each tx processor
in the simulate adapter. The parallel manager always uses the default
ExecuteTransactionProcessorAdapter: the stateful simulate adapter is
sequential-only and simulate never triggers the parallel path (it attaches no
BlockAccessList).

The factory is an interface, not a delegate, so Autofac does not
auto-synthesise one on the real block-production scope, where the optional
parameter must stay null and fall back to the default adapter.

The no-gas EIP-8037 execution-dimension default is a separate ordering issue
(the per-tx inclusion check runs before the adapter) and is left as a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): make the tx-processor-adapter factory a required delegate; add GasCap test

Replace the ITransactionProcessorAdapterFactory interface with a required
TransactionProcessorAdapterFactory delegate, mirroring CodeInfoRepositoryFactory:
the block-processing module registers the default (ExecuteTransactionProcessorAdapter)
and the simulate scope overrides it with the simulate adapter. This drops the
optional/null-fallback and the auto-synthesis hazard an optional delegate would
carry (ITransactionProcessorAdapter is registered on the block-processing scope,
so Autofac would otherwise fill an optional delegate on the real path).

Also add a regression test for the JsonRpc.GasCap budget (#12692 item 2): a
two-call request whose cumulative gas exceeds the cap has its second call clamped
below intrinsic gas and rejected; without the adapter the cap is not enforced and
both calls run unclamped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: address #12721 feedback (enforce sequential BAL, DI style, docs, test dedup)

- Set ProcessingOptions.ForceSequentialBlockAccessList in the simulate options so the
  stateful SimulateTransactionProcessorAdapter can never reach the parallel BAL pool
  (previously guaranteed only by simulate not attaching a BlockAccessList); documented
  the single-threaded contract on the adapter itself.
- Register the default TransactionProcessorAdapterFactory with AddScoped + a method group
  (drops the lambda cast) and the simulate override with the typed-dependency AddScoped
  overload (no manual Resolve / captive singleton).
- Reworded the factory <remarks> to describe what is actually wired (default Execute;
  other scopes still get the default on the BAL path) and dropped the overstated
  "gas defaulting" from the registration comment.
- Extracted BuildAmsterdamBalChain test helper; pinned the EIP-2780 intrinsic in the
  GasCap test comment.

Follow-up filed as #12723 (scopes overriding ITransactionProcessorAdapter still get
Execute on the BAL path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: single-axis tx-processor-adapter registration (step 1)

Addresses LukaszRozmej's review: the TransactionProcessorAdapterFactory was a
second registration axis alongside ITransactionProcessorAdapter, so scopes that
only overrode the latter (block production, trace, proof) drifted to the default
Execute adapter on the EIP-7928 BAL path — the same bug class this PR fixes for
simulate (#12723), left live elsewhere. Notably block production silently
downgraded its intended BuildUp semantics to Execute under Amsterdam.

Make the factory the single source of truth: the root registers the default
(Execute) plus a derivation ITransactionProcessorAdapter = factory(processor), and
each scope overrides only the factory — production BuildUp, trace (…
Marchhill added a commit that referenced this pull request Aug 19, 2026
* fix(rpc-bench): run benchmarked nodes the way production runs them (#12625)

* diag(rpc-bench): run Nethermind with production-default runtime (no TC=0 pin)

* reword comment for master

* review: document expb divergence, add NODE_ENV_VARS escape hatch

- README: the 'Alignment with expb' section no longer claims the removed
  env pins; documents the deliberate code-gen divergence and that JIT
  warm-up now lands inside the measured window; dotTrace reports are not
  comparable across this change
- start-node.sh: reword comment (no warm-up phase exists yet), add
  NODE_ENV_VARS passthrough for deliberate one-off code-gen experiments

* trim comments to one-liners; rationale stays in the PR

* drop the Merge GC flags: inert here and misleading

GCKeeper only runs on Engine API calls; this harness parks the node at a
snapshot head and never sends newPayload, so the three flags changed
nothing while implying the node ran a non-production GC configuration.

* keep the image entrypoint for Nethermind

The override skipped entrypoint.sh, which applies host tuning and enables a
shipped PGO profile. Its comment claimed parity with expb, but expb only
overrides the entrypoint for dotTrace, so normal expb runs (and production)
do run entrypoint.sh - this harness was the outlier.

* Rename EIP-8037 regular gas dimension to execution gas (#12600)

* Auto-update fast sync settings (#12665)

Co-authored-by: rubo <rubo@users.noreply.github.com>

* refactor(net): namespace snap by version (#12606)

* refactor(net): namespace snap messages by version

Prepare the snap subprotocol for a second version by moving the concrete
snap/1 messages, message codes and protocol handler into a versioned
namespace, so a snap/2 handler can be added alongside without touching
snap/1 code.

  Snap/Messages/*            -> Snap/V1/Messages/*
  Snap/SnapMessageCode       -> Snap/V1/Snap1MessageCode
  Snap/SnapProtocolHandler   -> Snap/V1/Snap1ProtocolHandler
  P2P/P2PMessageKey.cs       -> P2P/VersionedProtocol.cs  (file renamed to
                                match the type it declares)

SnapMessageBase and SnapSerializerBase deliberately stay in
Snap.Messages: they are shared by all protocol versions, not specific to
snap/1.

Introduce SnapVersions constants and ISnapSyncPeer.SnapProtocolVersion so
version checks can replace the magic numbers currently spelled as 1.

PeerInfoExtensions.CanGetSnapData is renamed to CanGetTrieNodes to say
what it actually tests. The rename is nominal - the peer probe is
unchanged, so behaviour is identical.

No functional change.

* refactor(net): remove Snap2 version constant from SnapVersions

* address review comments

* rename

* feat(sync): serve block access lists from the snap server (#12607)

* Refactor SnapServer and SnapStateServer integration

- Renamed SnapServer to SnapStateServer for clarity and consistency.
- Updated WorldStateManager to use SnapStateServer instead of SnapServer.
- Modified StateSyncFeedTestsBase to accommodate changes in SnapServer instantiation.
- Adjusted SnapProviderTests to reflect the new SnapStateServer type.
- Introduced SnapServerTests to validate SnapServer functionality.
- Added SnapStateServerTests to ensure robust testing of state management.
- Implemented new methods in SnapServer for handling bytecode and block access lists.
- Enhanced test coverage for account range retrieval and storage management.

* refactor: change SnapServer field type to interface ISnapServer

* test: enhance SnapServerTests with additional block access list scenarios

* chore: Update Dockerfiles (#12663)

Update Dockerfiles

Co-authored-by: rubo <rubo@users.noreply.github.com>

* test: make prewarmer env-return assertion pool-hit independent (#12616)

PreWarmCaches_ReturnsAddressWarmEnvWhenScopeBuildThrows asserts
Returned == Created, but with maxPoolSize 1 an env returned to the pool
can be rented again without a Create, so a pool hit legitimately
increments Returned twice against a single Create. Whether a hit occurs
depends on worker interleaving: flaky on CI, fails deterministically on
high-core machines (0/30 locally).

ThrowingBuildPolicy.Return now refuses retention, so every rental is a
fresh Create and the invariant holds under any interleaving (30/30
locally). Stack-trace instrumentation confirmed every rental is returned
exactly once - the prewarmer itself is correct.

* Update OP Superchain chains (#12664)

Co-authored-by: emlautarom1 <emlautarom1@users.noreply.github.com>
Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>

* fix(receipts): restore the post-merge flag before regeneration (#12641)

* fix(receipts): restore the post-merge flag before regeneration

Stored headers do not carry IsPostMerge and regeneration bypasses the
recovery step that restores it, so post-merge blocks re-executed with
PREVRANDAO evaluating to the zeroed difficulty - any transaction reading
it produced receipts failing the root check (~7% of post-merge blocks
refused on mainnet archives deriving receipts from state).

* test(receipts): dispose buffer, pin logged value

* fix(receipts): classify post-merge via the switcher

A difficulty heuristic misreads chains that repurpose the field -
Taiko stores per-block ZK gas in Difficulty while AlwaysPoS - so ask
IPoSSwitcher instead, mirroring MergeProcessingRecoveryStep. On
mainnet the switcher's TD-null branch is the same difficulty check,
so behavior there is unchanged.

* test(receipts): pin the real switcher's TD-null derivation

The production failure arrived as a mainnet-shaped header with
TotalDifficulty unset; cover PoSSwitcher's TD-null branch end to end,
not only the honour-the-switcher contract.

* test(receipts): cover the switcher registration path

A hand-injected switcher cannot catch a composition regression that
leaves the container-resolved regenerator on the NoPoS default, so
resolve it from a graph whose IPoSSwitcher registration is overridden
the way a merge-enabled node overrides it.

* fix(tests): mark RecoverReceiptsBlockchain.Create as hiding

* Expose the node's ENR in admin_nodeInfo (#12631)

feat(rpc): expose the node's ENR in admin_nodeInfo

Nethermind is the only discv5-capable execution client whose
admin_nodeInfo omits the node record. Tooling that bootstraps
discv5-only networks reads the ENR from this endpoint and has to
special-case Nethermind to the enode instead, which is useless once
discv4 is disabled.

NodeRecordProvider already maintains a signed, sequence-numbered self
record, so surface it as an 'enr' field. Move INodeRecordProvider to
Nethermind.Network so Nethermind.JsonRpc can reference it without a new
dependency on Nethermind.Network.Discovery; the implementation stays in
Discovery. The provider is only registered when discovery is enabled, so
it is resolved optionally and the field is omitted otherwise.

* Validate ABI decode allocation bounds (#12588)

* ci: disable stateless glamsterdam-devnet-7 scheduled run (#12680)

* Fix EIP-7708 tracing with logs (#12577)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Naming

* fix(flat): guard the trie-warmer against a TransientResource recycle race (storage reads as 0x00) (#12429)

* fix(flatdb): warm the trie from persistence only

The trie warmer read the recyclable `_snapshots` and `_transientResource`
while the warm job held only a `ReadOnlySnapshotBundle` lease, which does not
cover them. A concurrent scope reset could recycle those under the running
warmer, so a warm read could return a torn or foreign node.

The warmer only needs to warm from persistence, so restrict its reads to the
trie node cache and the `ReadOnlySnapshotBundle` - exactly what the lease
covers. In-memory nodes are already hot and do not need warming.

Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX

* fix(flat): warm the transient resource via a per-job lease

The trie warmer now warms nodes into the per-job-pinned TransientResource
(not just the trie node cache and ReadOnlySnapshotBundle), covered by a single
transient lease held for the whole warm traversal. It still never reads the
recyclable _snapshots. A warmer read on a torn-down bundle bails to a
persistence-only read instead of spinning for a lease that will never land.

Claude-Session: https://claude.ai/code/session_01S3BG321zDG9BNjAgWhZhCX

* refactor(flat): drop the warmer transient ThreadStatic capture

Measured TS-on vs always-per-node-lease on x10 (3 runs, heavy-block warmer
load): newPayload 178 vs 178 ms, getProof p99 20.1 vs 20.2 ms, RSS identical.
The ambient capture bought nothing, so keep only the per-node lease + ABA
re-check (the actual recycle-race fix) and drop the ThreadStatic + the
EnterWarmerTransientScope pins in the two warm-job callers.

* fix(flat): register the transient return owner at pool checkout

- ResourcePool.GetCachedResource now calls OnRented, so every checkout
  carries a registered return owner; a final ReleaseLease without one
  throws instead of silently dropping the resource (which leaked the
  BloomFilter allocation on the public AddSnapshot path)
- re-check _isDisposed after the warmer's lease acquire: Dispose releases
  the owner lease but leaves _transientResource pointing at the recycled
  instance, so the identity re-check alone could latch a resource already
  re-rented by another bundle
- document why ReleaseLease is distinct from Dispose: the pool contract
  reserves Dispose for destroying an over-capacity resource
- run Nethermind.State.Flat.Test in the nethermind-tests.yml matrix; it
  was compiled but not run by any CI job
- FlatDbManagerTests duplicate-snapshot test asserts the resource lands
  back in the checkout pool; new ResourcePoolTests cover the final-release
  return and the unregistered-release throw; refresh stale warmer test
  comments

* fix(flat): pin the transient resource for prewarm dedupe reads

ShouldQueuePrewarm read _transientResource without pinning it. The dedupe
bloom lives on that recyclable resource and the call runs on prewarmer and
BAL threads, so the owner could retire the resource mid-read: the pool
Resets it and, on overflow, Disposes the BloomFilter, whose backing store is
native memory. Route both overloads through the same lease + ABA re-check
the warmer node reads already use, and decline the prewarm on a torn-down
bundle. Rename the helper accordingly, since it is no longer warmer-only.

The FlatWorldStateScopeProvider and FlatOverridableWorldScope test doubles
returned the committed resource to the pool directly instead of releasing
its lease, mirroring neither AddSnapshot implementation. That recycles the
resource while a warmer lease is outstanding and returns it a second time
when that lease is released; the scope provider double also returned the
wrong instance and then returned it again on teardown. Both now release the
lease, which is the single return-to-pool path.

Test changes:
- the persistence-only test now commits the written nodes into the bundle's
  recyclable _snapshots before reading, so the warmer's Unknown result is a
  genuine miss. Previously the node was still in the transient (SetStateNode
  writes both) and was itself Unknown, so the assertion held either way.
- the churn test gives every epoch its own persisted node instance, so a
  read served from another epoch's recycled transient is caught by identity
  rather than by value, drives both recycle paths (CollectAndApplySnapshot
  swap and Dispose), exercises ShouldQueuePrewarm alongside the node reads,
  and joins the readers with a bounded wait instead of blocking forever.
- new test: a warmer read and a prewarm check on a disposed bundle fall back
  to the leased persistence reader within a bounded wait, covering the
  Dispose bail-out deterministically.

* fix(jsonrpc): synchronise SubscriptionManager per-client subscription bag (#12672)

* fix(jsonrpc): synchronise SubscriptionManager per-client bag

The per-client subscription bag is a HashSet mutated and enumerated from
multiple threads: concurrent subscribe requests (socket worker tasks),
unsubscribe, and the Closed handler that fires on connection teardown.
Concurrent HashSet access could corrupt it, dropping a subscription so
its event handlers stayed attached and leaked. Lock on the bag for every
add, remove, and snapshot-before-dispose.

Fixes #12668

* refactor(jsonrpc): dispose client subscriptions under bag lock without snapshot

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

* test(jsonrpc): race unsubscribe path too; drop bag field comment

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

---------

Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test: eth_createAccessList affordability with omitted fee fields (#12629)

* test(rpc): eth_createAccessList affordability with omitted fee fields (execution-apis #854)

execution-apis PR #854 clarifies that eth_createAccessList must not fail
solely because an unfunded sender cannot afford client-selected default fees
when all gas-fee fields are omitted. Nethermind already conforms (verified in
hive rpc-compat); this adds a regression test mirroring the conformance
fixture: unfunded sender, codeless recipient, zero value, no gas/fee fields
-> {"accessList":[],"gasUsed":"0x5208"}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(rpc): run affordability test on London chain, assert no in-body error, reuse helper

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>

* Fix stale transaction pool snapshots (#12685)

* Encode engine_newPayloadWithWitness witness as an RLP data string (#12635)

* fix

Signed-off-by: jsign <jsign.uy@gmail.com>

* Tighten witness RLP JSON encoding

---------

Signed-off-by: jsign <jsign.uy@gmail.com>
Co-authored-by: jsign <jsign.uy@gmail.com>

* perf(state): skip trie warmup for read-only BAL accounts in flat layout (#12681)

* perf(state): skip trie warmup for read-only BAL accounts in flat layout

With a suggested BAL the block's write set is known upfront, and trie
nodes are only needed at commit for written accounts. Gate address
trie-warm hints (HintBal, HintGet, HintWarmAccount) on the BAL write
set so read-only accounts no longer trigger state-trie path walks.

On BAL blocks dominated by cold account reads this removes up to ~8-10
wasted trie-node DB reads per unique read-only account from the
measured processing window.

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

* Apply suggestions from code review

Co-authored-by: Lukasz Rozmej <lukasz.rozmej@gmail.com>

* refactor(state): extract QueueStateTrieWarmup and address review findings

- Extract the NeedsStateTrieWarmup + PushAddressJob + increment pattern
  into QueueStateTrieWarmup, used by all three address warmup call sites
- Hoist CancelHintBal above the empty-BAL early return so a stale write
  set never survives into the next block
- Drop the inaccurate bloom false-positive-rate comment
- Tests: parameterize warm-per-write-kind over balance/nonce/code/storage,
  add empty-BAL reset regression test, split the HintWarmAccount test,
  wrap scopes in using, use order-insensitive assertions

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

* Update src/Nethermind/Nethermind.State.Flat/ScopeProvider/FlatWorldStateScope.cs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* refactor(test): reuse TestContext for recording-warmer scope construction

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

* fix(state): bind BAL warmup gate to HintBal lifecycle; address review findings

- Keep _warmupWriteSet across StartWriteBatch: on the parallel BAL path the
  BAL apply commits mid-block, concurrently with tx workers, so clearing the
  gate in CancelHintBal collapsed it at a nondeterministic point. The gate is
  now replaced only by the next HintBal.
- Drop the token from Task.Run in both HintBal implementations: a task
  cancelled before being dequeued never ran the finally that returns the
  pooled accountChanges array. The body already observes the token.
- Align TrieStoreScopeProvider.HintBal with the flat scope: a new hint
  supersedes the previous one even when it carries no work itself.
- Remove the stale prestate-load mutation mention from ReadOnlyBlockAccessList
  docs, state the immutability invariant, and seal the type.
- Tests: pin the gate surviving StartWriteBatch and a second BAL replacing
  the previous write set.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* test: make can-never-fail tests assert what their names claim (#12690)

* test(xdc): drop redundant trailing Assert.Pass in SpecialTransactionsTests

* test(core): bound McsLock re-acquire test instead of passing unconditionally

SingleThreadAcquireRelease asserted nothing and is subsumed by
ReacquireAfterReleaseSucceeds, which now runs on a worker with a timeout
so a broken release fails in seconds instead of hanging the test host.

* test(flat): assert real postconditions instead of Assert.Pass

Add_ConcurrentWithMightContain_ShouldWork now verifies no write is lost
under concurrent readers (a bloom filter never false-negatives).
DisposeAsync test renamed: FlatDbManager does not dispose the repository
(the container owns it); it now asserts bounded drain completion and
idempotent double-dispose. ConstructorAcceptsPersistedRepository removed -
Not.Null on a fresh object, subsumed by every other test in the file.

* test(merge): assert pending-validation cleanup instead of catch-only assertions

The memory-leak test asserted only inside catch blocks and ended with
Assert.Pass, so it passed whenever nothing threw; it now drives the
handler directly and asserts the pending-validation count stays zero
across repeated timed-out payloads. The TrySet double-completion test
is removed: it was a weaker duplicate of the concurrent-calls test
above it (same scenario, but swallowing OperationCanceledException and
keying Assert.Fail on exception message text).

* test(merge): await header-sync test helpers

The helpers were async void and invoked without awaiting - including one
un-awaited call inside the other helper - so their assertions raced the
test body and failures could surface as host crashes or not at all.

* test(network): restore DisconnectsAnalyzer assertions with deterministic flush capture

All four tests had their assertions commented out as CI-flaky, leaving
them assertion-free. The flakiness came from racing the 10ms flush
timer: reports are now recorded while the default 10s interval is in
effect and only then is the interval shortened, so a flush cannot fire
mid-arrangement. Assertions scan captured flush reports, including that
counters aggregate, reset after each flush, and cleared categories do
not resurface (the analyzer double-buffers, so a lost clear shows up as
a stale count in every other flush, not as a doubled count).

* test: address review findings on strengthened tests

Will_clear_after_report no longer issues a second report at all - a
report can race the flush's enumerate-then-clear window (Timer.Stop does
not drain a queued Elapsed callback, so no test-side quiesce is airtight).
A lost clear is observable without it: the analyzer double-buffers, so a
stale count resurfaces in later flushes, and the test asserts the
category appears in exactly one flush across several more (mutation-
verified). Bloom capacity raised to 100k - at 10k the saturated filter's
~34% false-positive rate masked a single lost write - and misses are
collected into one assertion. The repeated-timeouts payload test is
dropped rather than parameterized: the pending dictionary is keyed by
block hash and the test resubmits one block, so the count can never
exceed one and iterations add no coverage. The FlatDbManager dispose
test states its intent with Assert.DoesNotThrowAsync and a corrected
comment (WaitAsync bounds the wait, not the drain). Wait timeouts are
named constants and polling uses Thread.Sleep(1).

* test: simplify comments per ASD-STE100 and drop dead times parameter

Comments now use short, active, single-topic sentences. The times
parameter of ShouldEventuallyReport had no remaining non-default call
site after the second-report removal.

* test: use SpinWait.SpinUntil instead of a custom poll helper

Keep one condition re-check after a timeout: a flush can land in
SpinUntil's final sleep tick.

* Stop parallel transaction execution once BAL validation rejects the block (#12697)

* fix(consensus): stop parallel tx execution once BAL validation rejects

The parallel BAL block validator ran the incremental validator alongside the
transaction workers, but a validator failure faulted neither the worker loop nor
its cancellation token. `ParallelUnbalancedWork` only stops fetching new indices
on caller cancellation or a transaction-worker fault, so a block rejected at an
early transaction index still executed every remaining transaction before the
foreground observed the failure at `GetResult()`. For a block whose invalidity is
decided by a cheap prefix, that turns a sub-second rejection into slot-scale CPU
work.

`IncrementalValidationWorkItem` now owns a cancellation source that is signalled
together with the stored exception, and the worker loop runs under that token, so
workers stop pulling transactions as soon as validation becomes terminal. The
resulting `OperationCanceledException` is translated back into the original
validation failure, keeping the returned error identical.

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

* refactor(consensus): signal BAL validation failure with a flag, not cancellation

Routing the stop signal through ParallelUnbalancedWork's cancellation token meant
`For` ended by throwing an OperationCanceledException that existed only to be
caught and translated back into the original validation failure. The token also
needed a per-block CancellationTokenSource, whose recycling in Schedule was only
safe because the previous block's validator had already been joined.

Replace all of it with a volatile read of the exception the work item already
stores: workers check `HasFailed` before doing any work, the loop drains its
remaining indices without executing anything, and `GetResult()` reports the
rejection on the normal return path. Same behaviour, no exception used as control
flow, no extra state to keep in sync, and no ordering constraint between Schedule
and the work that follows it.

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

* refactor(test): trim comments and simplify the tail-cancellation test

Comments cut back to the non-obvious reasoning only. The regression test now
counts executions instead of collecting indices, so a single Is.InRange assertion
covers both requirements — the decisive prefix ran, the tail did not — and the
prefix array, the index bag and one assertion all go away. Transaction count is a
plain constant rather than derived from the canonical lead, which the test never
depended on: with uniform gas limits the tail sort is stable, so the schedule is
natural order regardless. CreateParallelValidationTransactions takes an optional
gas limit so the block no longer needs patching after construction.

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

* fix(consensus): address review — exempt iteration 0, loosen test bound

Iteration 0 (WaitForBalWarmup + ApplyStateChanges) is now exempt from the
HasFailed guard, so pre-execution keeps its previous semantics instead of being
droppable when validation fails before any worker starts. Skipping it was traced
as benign, but the fix only needs to stop transaction execution, so leaving the
pre-execution step alone keeps the behavioural change narrower at no cost.

The regression test's upper bound leaned on SpinWait outlasting exception
unwinding, because the fake validator releases its gate before the work item
stores the exception. Bound is now txCount / 8, which asserts the tail stopped
without depending on cancellation-propagation timing — a revert still executes
all 2048 and fails.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* test: pin eth/62-66 serializer wire encodings with hand-derived goldens (#12696)

* test(network): remove duplicate eth serializer tests

ZeroNewBlockMessageSerializerTests.Roundtrip2 and V63
NodeDataMessageSerializerTests.Zero_roundtrip were verbatim copies of
their Roundtrip siblings. Can_deserialize_own_eth_64 shared its body
and one payload with Can_deserialize_eth_64; its unique payload moves
there as another TestCase.

* test(network): pin eth/62-66 wire encodings with hand-derived goldens

Every serializer test file in Eth V62-V66 now asserts an exact wire
encoding somewhere. The goldens are derived from the RLP rules and
verified with an independent encoder (pyrlp + pycryptodome keccak);
values shared across files live in EthSerializerGoldens. ToString
smoke tests now assert the log line names the message type, and the
32-byte-selector test asserts the exact decoded hash.

* test(network): address review feedback on serializer goldens

- BlockBodies: pin the null-body framing (c1c0) and the empty-vs-absent
  withdrawals distinction (e9e8...c0c0)
- Status To_string: assert against the independent Protocol.Eth constant
- V63 NodeData: rename Roundtrip_with_nulls to Roundtrip_with_empty_entry
  (the data holds an empty array, not null)

* feat: EIP-8037 two-dimensional gas tracing (stateGasTracer + callTracer) (#12628)

* feat(tracing): add EIP-8037 stateGasTracer (execution-apis #852)

Implements the `stateGasTracer` named tracer specified in execution-apis
PR #852, returning the per-transaction two-dimensional gas summary
`{gasUsed, regularGasUsed, stateGasUsed, gasRefund}` (EIP-8037/EIP-7778).

The values are already computed for block-level gas accounting and carried
on `GasConsumed`; the tracer only reads and formats them. Adds the missing
applied EIP-3529 refund (capped) to `GasConsumed.GasRefund`, populated in
the success and top-level-halt refund paths. The native tracer factory is
threaded with `IReleaseSpec` so the fork is determined explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tracing): add EIP-8037 two-dimensional gas to callTracer (execution-apis #852)

Adds `regularGasUsed`, `stateGasUsed` and `gasRefund` to the callTracer
top-level frame for Amsterdam+ blocks, per execution-apis PR #852. The
fields are gated on `IReleaseSpec.IsEip8037Enabled` (MUST NOT appear before
the fork) and set only on the top frame (omitted on sub-frames). Values are
read from the transaction's `GasConsumed` result, matching the stateGasTracer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): address #12628 review feedback

- Restore native-tracer factory API back-compat: keep the public 4-arg
  GethLikeNativeTracerFactoryDelegate/RegisterTracer/CreateTracer so external
  plugin registrations stay source- and binary-compatible; built-ins receive
  the (nullable) IReleaseSpec via an internal spec-aware factory.
- stateGasTracer: disable IsTracingOpLevelStorage/IsTracingStack so the
  terminal-only tracer stops invoking per-opcode storage/stack callbacks.
- Consolidate the callTracer top-frame regularGasUsed/stateGasUsed/gasRefund
  into a single TwoDimensionalGas? value, removing the coupled nullables and
  the unsafe null-forgiving dereferences in the converter.
- Document that regularGasUsed is floor-clamped (block-accounting value) so
  the two-dimensional invariant is exempted under the calldata floor.
- Add an end-to-end stateGasTracer test executing a real Amsterdam tx through
  the TransactionProcessor (fresh SSTORE + in-tx reset) that exercises field
  selection and the GasConsumed.GasRefund plumbing; assert
  regularGasUsed + stateGasUsed == gasUsed + gasRefund.
- Test cleanups: multiple-assert scope and a shared helper for the two
  callTracer Amsterdam cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): address #12628 re-review nits

- Document the spec-less CreateTracer overload's pre-fork fallback and cover it
  with a factory test (back-compat public contract was untested).
- stateGasTracer hex test now forces the ambient NumberConversion.Raw so it
  actually exercises StateGasTraceConverter's hex-quantity override.
- E2E test: use a `using` tracer and correct the refund assertion message
  (the slot is reset to its original zero value within the tx, not pre-nonzero).
- Convert the remaining `//` member comments to XML doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: remove unused using in NativeStateGasTracerE2ETests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(tracing): drop excess comments; non-nullable spec in native tracer factory

Address flcl42 review on #12628:
- Remove the newly added explanatory comments that restated the code (kept only a
  few essential EIP-referenced DTO docs and the hex-conversion note).
- Make the release spec non-nullable through GethLikeNativeTracerFactory and the
  native tracers: drop the unused spec-less 4-arg CreateTracer overload (the only
  null source) — RegisterTracer, the actual plugin API, is unchanged. Also drops
  the brittle regularGasUsed occurrence-count assertion flcl42 flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: collapse double blank line before DeepNesting test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): skip EIP-3607 on the EIP-7928 BAL path in eth_simulateV1 (#12691)

* fix(simulate): skip EIP-3607 on the EIP-7928 BAL path in eth_simulateV1

eth_simulateV1 relaxes EIP-3607 so a state-overridden contract can be the tx
`from`. That relaxation covered only the main execution (via
SimulateTransactionProcessorAdapter). Under glamsterdam (EIP-7928),
BlockProcessor runs transactions through the BlockAccessListManager's own tx
processors, which bypass the adapter and re-enforce EIP-3607 — so a contract
sender is rejected with `-38024 sender has deployed code` instead of reaching
the normal balance/fee checks (hive rpc-compat divergence on glamsterdam-devnet-8:
ethSimulate-simple-send-from-contract*, ethSimulate-override-address-twice).

Relax EIP-3607 on the block execution context in
SimulateBlockValidationTransactionsExecutor instead:
ParallelBlockValidationTransactionsExecutor sets that context on both the main tx
processor and the BAL manager, so both paths skip the check — while BlockProcessor
still receives the unwrapped spec, preserving chain-specific release-spec
interfaces (Taiko / XDC / Optimism).

Verified end-to-end on a glamsterdam-at-genesis chain (the three hive fixtures
flip -38024 -> -38014/-38012, matching besu/erigon/reth/geth) and by an
integration test through the real EIP-7928 BAL path that fails -38024 without the
fix and passes with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): preserve PrevRandao in context rebuild; harden 3607 BAL test

Address re-review on #12691:
- SimulateBlockValidationTransactionsExecutor is now the single context-rebuild
  funnel for the simulate scope, so preserve the incoming PrevRandao (via
  BlockExecutionContext.WithPrevRandao*) instead of re-deriving the default —
  a BlockProcessor subclass (e.g. XdcBlockProcessor) may have supplied a
  non-default value.
- Make eth_simulateV1_contract_sender_skips_eip3607_on_bal_path validation-enabled
  so the -38014 expectation is fork-independent and stable across the #12692 fix
  (with validation:false the -38014 relied on the BAL path ignoring NoValidation).
  Pin the EIP-7928 premise with an explicit BlockLevelAccessListsEnabled assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): forward BlobBaseFee too in the context rebuild

Re-review follow-up on #12691: WithPrevRandao recomputes BlobBaseFee from the
header, so the no-override branch was newly lossy for a processor that forced a
non-derivable value (XdcBlockProcessor sets BlobBaseFee = 0 on a header cloned
with ExcessBlobGas = 0; recomputation yields MinBlobGasPrice = 1). Collapse to a
single WithPrevRandaoAndBlobBaseFee that forwards the incoming BlobBaseFee (or the
block override when present), leaving Spec as the only field the rebuild changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(simulate): relax EIP-3607 via execution flag, not spec wrapping

The eth_simulateV1 contract-sender fix relaxed EIP-3607 by wrapping the block
execution context spec with WithoutEip3607(), which returns a NoEip3607Spec
decorator implementing only IReleaseSpec. Because the tx processors read their
spec from that context (TransactionProcessor.GetSpec => BlockExecutionContext.Spec),
the decorator reached chain-specific processors that hard-cast it — e.g.
TaikoTransactionProcessor.PayFees does (ITaikoReleaseSpec)spec and
XdcTransactionProcessor casts on every tx — so eth_simulateV1 on Taiko/XDC threw
InvalidCastException/InvalidOperationException instead of returning a result
(Eip3607Transition defaults to 0, so the wrap is always active there).

Replace the spec wrapping with a dedicated ExecutionOptions.SkipSenderCodeCheck
policy flag gated in ValidateSender. The main simulate adapter ORs it into its
Process call; the EIP-7928 BAL path receives it via a small
BlockAccessListTxExecutionOptions injected into BlockAccessListManager and threaded
to its ExecuteTransactionProcessorAdapter. The release spec now keeps its concrete
runtime type on every path, so chain-specific interfaces survive.

Regression test: Nethermind.Taiko.Test exercises a code-bearing sender through
TaikoTransactionProcessor.PayFees — it passes with the flag and throws
InvalidCastException under the old spec-wrapping. eth_simulateV1 BAL-path and full
simulate suite remain green.

Follow-up to #12691; addresses the residual type-erasure raised in its review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(simulate): trim explanatory comments to essentials

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): carry EIP-3607 relaxation on BlockExecutionContext

Replace the ExecutionOptions.SkipSenderCodeCheck flag (and the machinery to thread
it into the EIP-7928 BAL path) with a SkipSenderCodeCheck field on
BlockExecutionContext. Both the main tx processor and the BAL manager's own
processors already share the context via SetBlockExecutionContext, so the field
reaches every path for free — no threading through BlockAccessListManager /
TxProcessorPool / ExecuteTransactionProcessorAdapter, and no injected policy type.

The context is the same shared channel #12691 originally relaxed on; this just uses
a flag instead of a spec decorator, keeping the spec's concrete runtime type (so
ITaikoReleaseSpec/IXdcReleaseSpec casts survive). Net simpler diff and consistent
with the existing IsGenesis flag on the same struct.

ValidateSender reads VirtualMachine.BlockExecutionContext.SkipSenderCodeCheck; the
simulate executor sets it in its context rebuild. Taiko regression test updated to
set the flag on the context (still throws InvalidCastException under spec-wrapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): carry EIP-3607 relaxation on TransactionProcessor

Per review preference (@LukaszRozmej), move the SkipSenderCodeCheck flag from
BlockExecutionContext onto TransactionProcessor. ValidateSender reads the
processor's SkipSenderCodeCheck property. The simulate scope sets it type-
preservingly on both creation paths: Intercept<ITransactionProcessor> for the
main processor, and a factory decorator for the EIP-7928 BAL processors — so each
chain keeps its concrete processor type (no spec wrapping, no processor-type swap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: trim comments to the essential why

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): remove dead WithoutEip3607; address review polish

Follow-up to the SkipSenderCodeCheck switch (review by @claude on #12691):
- Remove the now-dead EIP-3607 spec wrapper — this PR dropped its last production
  caller. Deletes WithoutEip3607, NoEip3607Spec, GetNoEip3607Spec (std + zkevm) and
  the _noEip3607Specs cache; keeps WithoutEip158 (still live). Fixes two comments
  that cited the removed decorator.
- Enforce the relaxation invariant: a shared Apply() throws if the resolved
  ITransactionProcessor isn't a TransactionProcessorBase, instead of silently
  no-op'ing back to -38024. Used by both the Intercept hook and the factory decorator.
- XML-doc SkipSenderCodeCheck with its set-before-use / unsynchronised-read invariant.
- Make SkipSenderCodeCheckTransactionProcessorFactory internal.
- Revert SimulateBlockValidationTransactionsExecutor to master (the fix no longer
  touches it; its PrevRandao tweak was unrelated).
- Assert fee payment in the Taiko regression test (matches its name).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dns): verify EIP-1459 subtree hashes (#12707)

* fix(dns): verify EIP-1459 subtree hashes

EIP-1459 serves every subtree entry from the subdomain
base32(keccak256(entry)[..16]). The root signature covers only the
enrtree-root entry, so this hash chain is the only binding between the
signed root and the branch and ENR records a client consumes. The
crawler never checked it, so any resolver or poisoned cache could
substitute arbitrary node records and steer the crawl to
attacker-chosen labels.

Verification follows go-ethereum: unpadded standard base32, prefix
comparison against keccak256 of the record, abbreviated labels accepted
down to 12 decoded bytes (minHashLength). The tree root is exempt, as
EIP-1459 serves it from the bare domain with no hash label. A mismatch
logs at Warn and skips the record.

No base32 decoder existed in src/Nethermind, so EnrTreeHash carries a
small one, tested against labels produced by the reference
implementation.

Validated against live trees: all.mainnet.ethdisco.net crawls 3000
records with zero rejections, so the check does not misfire on
anything the reference publisher emits.

* refactor(dns): simplify and harden EnrTreeHash

- Reuse Keccak.Size instead of a local MaxHashLength constant.
- Decode base32 arithmetically instead of scanning the alphabet string.
- Guard the decoded-length check against int overflow on hostile input.
- Hash UTF-8 bytes from a stackalloc/pooled buffer instead of allocating.

* refactor(dns): log hash-mismatch rejections at Debug, clarify base32 mapping

---------

Co-authored-by: lukasz.rozmej <lukasz.rozmej@gmail.com>

* Selectable dotTrace profiling mode + dotnet-trace EventPipe sidecar for benchmark workflows (#12708)

* benchmarks: selectable dotTrace profiling mode (sampling | tracing | timeline)

The dottrace input on run-rpc-benchmarks and run-expb-reproducible-benchmarks
becomes a choice: false | sampling | tracing | timeline ('true' stays accepted
as a legacy alias for sampling on API dispatches). The mode maps to the
dotTrace CLI's --profiling-type: rpc-bench sets it on the wrapped entrypoint in
start-node.sh; the EXPB workflow passes --dottrace-mode to expb (only when
non-default, so pinned expb versions keep working for sampling runs; requires
execution-payloads-benchmarks feature/dottrace-profiling-modes for the new
modes).

Timeline snapshots cannot be converted to XML by Reporter.exe, so the
generate-dottrace-reports and summary jobs skip that mode - the raw .dtp
artifact is still collected and uploaded for the dotTrace UI. Line-by-line is
deliberately not offered: it needs PDBs the client docker images do not carry.
EXPB additionally rejects trace_blocks with timeline, since per-block
snapshots ride the MeasureProfiler API, which needs a performance session.

* expb workflow: collect a dotnet-trace EventPipe sidecar with every dotTrace run

Whenever dottrace is enabled the run also passes --dotnet-trace to expb: a
host-side EventPipe session records gc/contention/threading/exception events
(no CPU sampler - dotTrace owns the stacks) and the .nettrace ships in the same
dottrace-* artifact. Requires expb feature/dottrace-profiling-modes.

* rpcbench/expb: document the profiling modes and fix two review nits

Docs still described the pre-mode behaviour: the rpc-bench README documented a
dottrace command line with no --profiling-type and stated capture deliberately
runs in default sampling mode, and its input table listed dottrace as a boolean.
AGENTS.md's expb section had the same gap. Both now cover the four choices, when
to reach for each, that timeline yields no XML, and the dotnet-trace sidecar -
that section is what agents read to interpret run artifacts.

Timeline snapshots now save as .dtt rather than .dtp. The report job is gated off
for timeline either way, but the extension is what stops Reporter.exe's .dtp glob
from picking up a snapshot it cannot convert if that gate is ever relaxed.

The expb dottrace flag no longer special-cases sampling: the run already requires
an expb that understands --dotnet-trace, so there is no older-expb compatibility
left to preserve, and the flag string was spelled out twice in each of two
copy-pasted job bodies. The trace_blocks default guard becomes an explicit if -
as the last statement of its if body the AND-list left the block with status 1,
surviving only through errexit's AND-OR exemption.

* docs: scope the EventPipe sidecar to EXPB

The rpc-bench README claimed every profiled run drops a .nettrace into the
dottrace-rpcbench artifact, but --dotnet-trace was only wired into the EXPB
workflow - nothing in run-rpc-benchmarks.yml or scripts/rpc-bench collects one, so
the paragraph sent readers hunting for a file that is never produced, and promised
it precisely for timeline runs, which have no XML either. Say what a timeline
rpc-bench run actually yields: the .dtt snapshot alone.

* fix(jsonrpc): serialize receipt root as full-width DATA (#12706)

* fix(jsonrpc): serialize receipt root as full-width DATA

* test(jsonrpc): parameterize the receipt-root width cases

* test(jsonrpc): pin the whole-byte leading-zero root case

* test: add hand-derived golden tests for eth/71 and snap serializers (#12699)

* test(network): pin eth/71 and snap serializer wire encodings

Every serializer test file in Eth V71 and Snap/V1 now asserts an exact
wire encoding. The goldens are hand-derived from the RLP rules and
verified with an independent encoder (pyrlp + pycryptodome keccak).
Random request ids are pinned only in the goldened tests; ByteCodes
gains a sibling roundtrip that keeps the constructor-assigned id, and
GetAccountRange pins long.MaxValue to keep the eight-byte id path.

* test(network): share repeated snap golden fragments

The request-id, keccak-of-empty-string, and range-hash fragments moved
into SnapSerializerGoldens. Each golden fragment and its test input
come from one hex constant, so the expectation cannot drift from the
input.

* test(network): address review feedback on snap golden tests

- ByteCodes: one parameterized Roundtrip; long.MaxValue pins the
  eight-byte request id deterministically instead of a random id
- GetAccountRange: independent field asserts in EnterMultipleScope
- SnapSerializerGoldens: expose RequestId1111 next to its RLP item;
  the remarks state which fragments share hex with inputs and that
  the keccak("") fragment is an independent literal on purpose

* `debug_trace*`: Fix phantom logs on frame revert (#12621)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Phantom log test

* Remove logs on a reverted frame

* Naming

* Fix leaking `ArrayPooList`

* Code cleanup

* Additional test

* Simplify tests

* Build fix

* Snap sync: reject storage range responses with unmatched slot lists (#12729)

* fix(snap): reject storage range responses with unmatched slot lists

A `StorageRanges` reply carries one slot list per requested account, but
nothing verified that the responder returned no more lists than were asked
for. `AddStorageRange` looped over the response count and indexed the request
account list with it, so a longer-than-requested reply read past the end of
the request.

Reject such a response up front and retry the range, which also settles the
active storage request accounting so that the range download phase can still
reach completion.

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

* test(snap): pin the slot list count boundary

Cover equal and fewer slot lists than requested accounts so that widening the
guard to `>=` cannot pass, which would reject every well-formed response.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(metrics): durable sync-time and full-pruning duration metrics (#12590)

* feat(metrics): durable sync-time + per-stage breakdown and full-pruning duration

Sync:
- Fix UpdateAndGetSyncTime() so the total is retained after sync completes
  instead of resetting to zero (EthSyncingInfo and Taiko override).
- Replace nethermind_sync_time with nethermind_sync_time_seconds (durable).
- Add nethermind_sync_time_in_mode_seconds{sync_mode} per-stage breakdown,
  driven by a new SyncTimeInModeTracker on ISyncModeSelector.

Pruning:
- Add nethermind_full_pruning_last_duration_seconds and
  nethermind_full_pruning_count, recorded on successful FullPruningDb.PruningFinished.
- Suffix in-memory trie pruning gauges with *Ms and document the unit.

* refactor(metrics): address PR review on sync/pruning time metrics

- Sync time no longer drops to 0 for one scrape when a stopped node
  re-syncs: extract shared SyncTimeStopwatch that always returns the
  retained total (used by EthSyncingInfo and Taiko override). Add
  stop->resume regression tests.
- SyncTimeInModeTracker is now IDisposable (unsubscribes from
  ISyncModeSelector.Changed), seeds its labels with TryAdd instead of
  clobbering the shared static dictionary, and is owned by the container.
  Timestamp seam moved to an internal ctor.
- SyncTimeInModeSeconds is get-only to match the other [KeyIsLabel] metrics.
- FullPruningCount uses the Interlocked backing-field pattern like the
  other counters in Db/Metrics.
- Clarify that FullPruningLastDurationSeconds covers the trie copy+commit,
  not the wait for a suitable state root.
- Make the full-pruning duration test assert the write actually happened.

* fix(metrics): avoid DI cycle wiring the sync-mode tracker; drop unused using

- Attach SyncTimeInModeTracker via Intercept<ISyncModeSelector> instead of
  resolving it during IMonitoringService construction. Resolving it there
  created a container cycle (IMonitoringService -> tracker -> ISyncModeSelector
  -> ... -> DbTracker -> IMonitoringService) that crashed the node at startup.
  This keeps the monitoring module free of outward dependencies, mirroring the
  existing IEthSyncingInfo wiring.
- Remove unused `using System;` in SyncTimeInModeTrackerTests (IDE0005).

* Only accept the requested header in FetchHeaderFromPeer (#12730)

* fix(sync): only accept the requested header in FetchHeaderFromPeer

FetchHeaderFromPeer queries every initialized peer in parallel and takes
the first non-null response, but never checked that the returned header
is actually the one that was asked for. A peer that answers with some
other block had its header accepted, so the result depended on which
peer replied first rather than on what was requested.

Compare the returned header's hash against the requested hash on both
response paths. A non-matching response is treated like a missing one,
so the remaining in-flight peers can still supply the header instead of
the whole lookup failing.

The sibling lookups in StartingSyncPivotUpdater and PeerRefresher
already do this; FetchHeaderFromPeer was the one that did not.

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

* test(sync): cover the allocated-peer fallback and tighten assertions

Add a case where no peer answers the head-header request, so the
GetBlockHeaders fallback is the one that resolves the header. Nothing
pinned its success branch before, so it could have been broken without
a test noticing.

Assert on the header instance rather than its hash, so the negative
case cannot pass for a header that merely has no hash set.

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

* fix(sync): report a peer that answers with a different block

A mismatched header was discarded silently, leaving it indistinguishable
from a peer that simply does not have the block. Requests are serialised
per peer by MessageQueue and a late response to a timed-out request is
disposed rather than handed to the next caller, so a mismatch is always
a protocol violation rather than a benign response race.

Report it as UnexpectedHeaderHash, matching HeadersSyncFeed and the
other header lookups.

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

* test(sync): cover that an honest peer keeps its connection

Reporting a mismatch disconnects the peer, so the costly failure mode is
now an honest peer being dropped rather than a wrong header accepted.
Only the reported direction was asserted.

Cover all three answer shapes, including a peer that does not have the
block, which is the normal answer while a head is unknown and must not
cost a connection.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Fix doubled revert handling in some tracers (#12715)

* Test for EIP-7708 top frame log

* [WIP] attach log to parent frame

* Attach log to correct frame

* Reuse common code in tests

* More tests

* Phantom log test

* Remove logs on a reverted frame

* Naming

* Fix leaking `ArrayPooList`

* Code cleanup

* Additional test

* Simplify tests

* Showcase test

* Direct fix

* More failing tracing tests

* Remove problematic `ReportActionRevert` > `ReportActionError` delegation from base

* Get rid of virtual-to-virtual calls in report revert/error

* Formatting

* Build fix

* Remove other `ReportActionRevert` -> `ReportActionError` calls

* Move common test codes to base class

* Fix `IsTracingActions` summary

* Small test fix

* Code cleanup

* test(era): anchor accumulator tests to EIP-7643 vectors, remove unused GetProof (#12718)

* test(era): anchor AccumulatorCalculator roots to derived spec vectors

Replaces the two-instances self-oracle, the inequality-only comparisons,
and the DoesNotThrow-only Add tests with parameterized roots derived by
an independent Python SSZ merkleization of the portal-network history
spec. Adds proof fold-up verification against the derived roots.
Deletes the byte-identical duplicate golden test in Era1.Test.

* test(era): assert the accumulator root the readers return

ReadAccumulator and ReadAccumulatorRoot tests discarded the root and
asserted only Throws.Nothing. They now assert the root equals the
accumulator of the written contents. The shared calculator loop moves
into a ComputeAccumulatorRoot helper in each file.

* test(era): apply review round on the accumulator vector tests

Parameterizes the proof verification and adds a three-entry index 2
case, so the upper tree levels exercise the right-hand sibling order.
Moves the proof length assert into the fold-up helper. Rewrites the
provenance comments in active voice and adds the spec link.

* test(era): cite EIP-7643 as the accumulator spec reference

The portal-network history spec was restructured and no longer defines
the accumulator. EIP-7643 defines HeaderRecord and the 8192-entry epoch
record. Also states the exact value of the index 2 proof case: it is
the only case that catches a wrong index shift.

* refactor(era): remove unused AccumulatorCalculator.GetProof

GetProof shipped with the EraE feature but no production code ever
called it; its only callers were its own tests. The removal also drops
the _totalDifficulties list, which only GetProof read, and the proof
tests in Nethermind.EraE.Test.

* docs(era): cite EIP-7643 on AccumulatorCalculator

The linked portal-network document was restructured and no longer
defines the accumulator. EIP-7643 defines HeaderRecord and the
8192-entry epoch record this class computes.

* test(era): apply removal-round polish

Converts the class comment to an XML summary per the documentation
rule. Inlines the two-entry root, which lost its second consumer with
the proof tests.

* test(era): consolidate the accumulator fixtures into Era1.Test

Both fixtures tested Nethermind.Era1.AccumulatorCalculator with
overlapping vectors. The Era1.Test table now carries every
discriminating single-entry case, a pinned empty-accumulator root, and
a Clear reset test against the writer-reuse contract. The EraE.Test
copy is deleted.

* test(era): state only true contracts in the fixture comments

The Clear comment claimed a multi-file writer-reuse path that does not
exist: Add throws after Finalize and each era file gets a fresh writer.
The vector comment claimed a pairwise single-input difference that two
case pairs violate. Both now state only what holds.

* test: strengthen mock-echo and vacuous tests in Abi, Optimism and Xdc test projects (#12720)

* test(abi): pin forwarding and return propagation in encoder extensions

The extension tests asserted only Received on the inner call and
ignored the extension return value. A stub on the exact unpacked
arguments plus an identity assert on the result covers both.

* test(xdc): assert the RocksDb config factory routing

The single test asserted Is.Not.Null on a result that cannot be null.
The factory contract is routing: Xdc databases get a PerTableDbConfig
without consulting the base factory, and every other database delegates
to it. Both branches are now pinned; the delegation branch was
untested.

* test(xdc): pin the routed timeout instance

The vote routing test pins the exact vote, but the timeout test used
Arg.Any, so a handler that routes the wrong timeout passed.

* test: apply the C11 review round

Removes an unused using that fails lint CI. Pins the factory options
against the provided IDbConfig, so a wrong database name in the special
branch turns the test red. Moves the extension rationale to class level
and renames the Abi tests to the project snake_case convention.

* test: state only true mechanisms in the C11 comments

NSubstitute returns an empty array, not null, for an unmatched call on
an array-returning member. The prefixed-options claim holds for only
half of the database prefixes, so the sentence is dropped.

* test(xdc): use a neutral database name in the delegation test

Review feedback on #12720: the delegation test used the literal
"State"/"Code", and "State" is the one name PerTableDbConfig
special-cases (StartsWith("State")). Use nameof(DbNames.Blocks) with a
null column so the test isolates the delegate-to-base contract without
that special-case, matching the sibling FlatRocksDbConfigAdjusterTests
idiom.

* test: anchor crypto and RLP tests to independent expectations (#12712)

* test(core): anchor the keccak span test to an independent vector

* test(core): anchor RLP ulong lengths to the spec

* test(core): compare decoded blocks to the original and drop the ignored file writer

* test(core): compare decoded header fields to the original block

* test(core): anchor the regression block decode to pyrlp-derived fields

* test(core): apply review round - drop dead using, widen anchors, guard indexed asserts

* test(core): apply round-2 review polish

* test(core): cover the header tail fields and sharpen the roundtrip comments

* test(core): apply confirm-round nits

* test(core): compare decoded uncle hashes in the block roundtrip

The body compared uncles by count only. The scenarios build two uncles
with distinct headers, so the hash comparison catches an order or
content error the count cannot see. The count guard moves outside the
multiple-assert scope like the transaction guard.

* Update OP Superchain chains (#12752)

Co-authored-by: emlautarom1 <emlautarom1@users.noreply.github.com>

* Auto-update fast sync settings (#12751)

Co-authored-by: rubo <rubo@users.noreply.github.com>

* test: Db tests assert stored state instead of smoke-calling empty methods (#12693)

* test(db): assert stored state instead of smoke-calling empty methods

MemDb.Flush and Dispose (and the Snapshotable variants) are empty method
bodies, so the six *_does_not_cause_trouble tests could never fail;
removed. The no-assert MemDb creation tests now verify the value round-
trips. ReadOnlyDbProviderTests.Can_clear exercised an empty registry (no
db was ever registered), making ClearTempChanges a no-op; it now
registers a real MemDb-backed read-only db and asserts the overlay is
dropped while the wrapped db stays intact, plus the no-writes contract
when localChanges is off. Can_get_all_on_empty now asserts emptiness
instead of discarding the enumeration.

* test(db): assert overlay drop only where a write could land in the overlay

* test(db): group independent post-condition asserts in Assert.EnterMultipleScope

* Reject invalid fixed-size header RLP (#12579)

* Treat a null header answer as the block being absent (#12741)

* fix(sync): treat a null header answer as the block being absent

An empty list item decodes to a null header and is kept in the response
list, so the allocated-peer fallback could pass one into the hash
comparison and throw. FetchHeaderFromPeer only catches cancellation and
timeouts, so it would surface into engine_forkchoiceUpdated.

Handle it in Validate, which lets the head-header path drop its own null
check too.

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

* test(sync): name the mock switch after the answer it produces

The peer answers with a null header, not an empty one. Move it next to
the other answer switches so HeaderToReturn keeps its own doc.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Return only the requested header from GetHeadBlockHeader (#12740)

* fix(network): return only the requested header from GetHeadBlockHeader

GetHeadBlockHeader asks for one specific block but returned whatever
single header the peer sent back. Every caller wants the block it asked
for, and two of them re-checked the hash themselves afterwards; the peer
refresh in SyncPeerPool did not, so it would take a substituted header
and record the wrong head number and total difficulty for that peer.

Compare the response against the requested hash where the request is
made, and disconnect a peer that answers with a different block. A peer
that does not have the block answers with an empty list, which is the
normal response while a head is unknown and still yields null.

Drop the now-redundant check in StartingSyncPivotUpdater. The one in
PeerRefresher stays: that method validates its head/parent response
locally anyway, since GetBlockHeaders carries no such guarantee.

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

* fix(network): treat a null header answer as the block being absent

An empty list item decodes to a null header and is kept in the response
list, so a peer can answer a single-header request with one null entry.
Dereferencing it to compare hashes threw, and two callers do not catch
that: the pivot updater would leave its update loop for good, and
FetchHeaderFromPeer would surface it into engine_forkchoiceUpdated.

Treat it the same as an empty list — the peer does not have the block —
rather than as a breach.

Also type the requested hash as nullable, matching _remoteHeadBlockHash
before the status handshake, and assert the absent cases on the result
itself rather than on its hash.

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

* fix(network): make the requested-header guarantee unconditional

The hash check was skipped when neither the argument nor the announced
head was known, so the documented contract had a hole. Return early
instead: with no hash to ask for there is no meaningful request to send.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: add built-in portfolio viewer UI at /portfolio (#12360)

* Handle failed sender recovery (#12757)

* fix(simulate): route the EIP-7928 BAL path through the simulate tx adapter (#12692) (#12721)

* fix(simulate): route EIP-7928 BAL path through the simulate tx adapter

Under EIP-7928, eth_simulateV1 runs transactions through the
BlockAccessListManager's own tx processors, bypassing
SimulateTransactionProcessorAdapter. That lost its GasCap budget clamp, its
TotalGasLeft/BlockGasLeft accounting (block gasUsed reported as 0), and its
validation:false handling (the BAL path always called Execute, never Trace).

Inject the adapter via a new ITransactionProcessorAdapterFactory so the
sequential BAL manager — the only one simulate drives — wraps each tx processor
in the simulate adapter. The parallel manager always uses the default
ExecuteTransactionProcessorAdapter: the stateful simulate adapter is
sequential-only and simulate never triggers the parallel path (it attaches no
BlockAccessList).

The factory is an interface, not a delegate, so Autofac does not
auto-synthesise one on the real block-production scope, where the optional
parameter must stay null and fall back to the default adapter.

The no-gas EIP-8037 execution-dimension default is a separate ordering issue
(the per-tx inclusion check runs before the adapter) and is left as a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(simulate): make the tx-processor-adapter factory a required delegate; add GasCap test

Replace the ITransactionProcessorAdapterFactory interface with a required
TransactionProcessorAdapterFactory delegate, mirroring CodeInfoRepositoryFactory:
the block-processing module registers the default (ExecuteTransactionProcessorAdapter)
and the simulate scope overrides it with the simulate adapter. This drops the
optional/null-fallback and the auto-synthesis hazard an optional delegate would
carry (ITransactionProcessorAdapter is registered on the block-processing scope,
so Autofac would otherwise fill an optional delegate on the real path).

Also add a regression test for the JsonRpc.GasCap budget (#12692 item 2): a
two-call request whose cumulative gas exceeds the cap has its second call clamped
below intrinsic gas and rejected; without the adapter the cap is not enforced and
both calls run unclamped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: address #12721 feedback (enforce sequential BAL, DI style, docs, test dedup)

- Set ProcessingOptions.ForceSequentialBlockAccessList in the simulate options so the
  stateful SimulateTransactionProcessorAdapter can never reach the parallel BAL pool
  (previously guaranteed only by simulate not attaching a BlockAccessList); documented
  the single-threaded contract on the adapter itself.
- Register the default TransactionProcessorAdapterFactory with AddScoped + a method group
  (drops the lambda cast) and the simulate override with the typed-dependency AddScoped
  overload (no manual Resolve / captive singleton).
- Reworded the factory <remarks> to describe what is actually wired (default Execute;
  other scopes still get the default on the BAL path) and dropped the overstated
  "gas defaulting" from the registration comment.
- Extracted BuildAmsterdamBalChain test helper; pinned the EIP-2780 intrinsic in the
  GasCap test comment.

Follow-up filed as #12723 (scopes overriding ITransactionProcessorAdapter still get
Execute on the BAL path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: single-axis tx-processor-adapter registration (step 1)

Addresses LukaszRozmej's review: the TransactionProcessorAdapterFactory was a
second registration axis alongside ITransactionProcessorAdapter, so scopes that
only overrode the latter (block production, trace, proof) drifted to the default
Execute adapter on the EIP-7928 BAL path — the same bug class this PR fixes for
simulate (#12723), left live elsewhere. Notably block production silently
downgraded its intended BuildUp semantics to Execute under Amsterdam.

Make the factory the single source of truth: the root registers the default
(Execute) plus a derivation ITransactionProcessorAdapter = factory(processor), and
each scope overrides only the factory — production BuildUp, trace (…
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.

4 participants