Block Access Lists (Snap-v2 BAL Heal)
Snap Sync follows a rolling pivot as the chain advances, and Phase 3 heal re-fetches missing trie nodes when the root doesn't match. But there's a gap those mechanisms don't close by themselves: while Phase 2/3 is still streaming or healing, the live chain keeps producing blocks — and the account/storage ranges you already fetched at the old pivot are now stale relative to the new one. Re-streaming those ranges from scratch would waste everything already downloaded. Snap-v2's Block Access List (BAL) — a per-block account/storage/code/nonce/balance change set — lets you patch just the deltas instead. This is an advanced, opt-in path: everything here sits behind SnapSyncOrchestratorOptions.BalHealEnabled, which defaults to false.
Prerequisites
dotnet add package Nethereum.DevP2P.Sync
This guide assumes you've already read Snap Sync — BAL heal is a supplement to Phase 2/3, not a replacement. You also need peers advertising snap/2 — Connect to a Peer covers DevP2PConfig.AdvertiseSnap2, the flag that puts snap/2 in your own Hello.
Mental model: patch the frontier, don't re-stream it
Everything Phase 2 has already fetched has a frontier — the boundary of account/storage ranges already covered. When the pivot moves mid-flight, Snap/CatchUp's job is narrow and specific:
- Fetch each intervening block's BAL from a peer.
- Verify it against the block header's BAL-hash commitment (never trust an unverified change set).
- Apply only the changes that land inside the already-fetched frontier — anything outside it will be picked up naturally when Phase 2/3 reaches that range anyway, so applying it early would be wasted (or worse, inconsistent) work.
This is the same trustless-fetch discipline as the rest of the sync engine (Full Sync, Snap Sync): nothing gets applied to state without being checked against a cryptographic commitment first.
Source: src/Nethereum.DevP2P.Sync/README.md — "Snap-v2 BAL heal (Snap/CatchUp)".
Fetch: pulling raw BAL entries
IBlockAccessListFetcher (concrete: BlockAccessListFetcher) fetches raw BAL entries for a set of block references from serviceable peers, retrying and redistributing across peers on refusal, hash mismatch, or a stateless response:
// IBlockAccessListFetcher.FetchAsync — Snap/CatchUp/IBlockAccessListFetcher.cs:10
Task<IReadOnlyList<IReadOnlyList<AccountChanges>>> FetchAsync(
IReadOnlyList<BalBlockRef> blocks, CancellationToken ct);
Each BalBlockRef is (byte[] BlockHash, byte[] BlockAccessListHash) — the block's identity plus the commitment the fetched entry must hash-match. The fetcher batches at most MaxHashesPerRequest (28) blocks per request within a ResponseByteBudget of 2 MiB, and throws InvalidOperationException if a block becomes unobtainable from every serviceable peer — there's no silent partial result to accidentally apply.
Source: src/Nethereum.DevP2P.Sync/README.md — "Snap-v2 BAL heal" table (IBlockAccessListFetcher / BlockAccessListFetcher, BalBlockRef).
Verify: checking the commitment before trusting anything
BlockAccessListVerifier.Verify(expectedBalHashes, rawEntries) Keccak-hashes each raw entry against its expected commitment and decodes it via BlockAccessListRLPEncoder, returning one VerifiedBlockAccessList per entry:
VerifiedBlockAccessList.Status(enumBlockAccessListStatus) is one ofVerified/Unavailable/HashMismatch.- Only on
Verifieddoes the result carry a decodedIReadOnlyList<AccountChanges>— anUnavailableorHashMismatchresult carries nothing to apply.
This is the checkpoint that keeps a malicious or buggy peer from injecting fabricated balance/storage changes: a mismatched hash is discarded, not applied.
Source: src/Nethereum.DevP2P.Sync/README.md — "Snap-v2 BAL heal" table (BlockAccessListVerifier, VerifiedBlockAccessList / BlockAccessListStatus).
Apply: patching flat state and the trie
IBlockAccessListApplier (concrete: BlockAccessListApplier) is where verified changes actually land in storage:
// IBlockAccessListApplier.ApplyAsync — Snap/CatchUp/IBlockAccessListApplier.cs:10
Task ApplyAsync(IReadOnlyList<AccountChanges> blockAccessList, ISnapTaskFrontier frontier, CancellationToken ct = default);
ApplyAsync applies a verified block's AccountChanges — balance, nonce, code, and storage — into flat state (ISnapFlatStateWriter, plus IStateStore for code), and, when supplied, into the in-progress trie via IHealedStateWriter. Empty accounts are deleted, matching Ethereum's account-clearing rule. Within one account's changes, conflicting writes at different points in the block resolve last-writer-wins by BlockAccessIndex — not by list order:
Given balance changes at indices
3(→ 500) and0(→ 100) submitted in that order, the applied balance is 500 — the change with the higherBlockAccessIndexwins, regardless of which one appears first in the list.
Source: src/Nethereum.DevP2P.Sync/README.md — "Snap-v2 BAL heal" table (IBlockAccessListApplier / BlockAccessListApplier); last-writer-wins-by-index behavior confirmed in tests/Nethereum.DevP2P.Sync.UnitTests/CatchUp/BlockAccessListApplierTests.cs:24 (Given_BalanceAndNonceChanges_When_Applied_Then_PostValuesAreLastWriteByIndexNotByListOrder, untagged for doc traceability — see follow-up below).
The frontier: only patch what you already fetched
ISnapTaskFrontier (concrete: SnapTaskFrontier) is what makes "only patch state already fetched" possible — it answers two questions the applier asks before writing anything:
IsAccountFetched(accountHash)IsStorageFetched(accountHash, slotHash)
If an account or slot falls outside the Phase-2 range-task frontier, the applier skips it — that range hasn't been streamed yet, so applying a delta to it now would create state that's inconsistent with everything around it. It'll be picked up correctly once Phase 2 actually reaches that range.
Source: src/Nethereum.DevP2P.Sync/README.md — "Snap-v2 BAL heal" table (ISnapTaskFrontier / SnapTaskFrontier).
Where BAL peers come from
IBlockAccessListPeerSource (concrete: PeerPoolBlockAccessListPeerSource) filters your existing peer pool down to sessions where SyncPeerSession.SupportsSnap2 is true — you don't maintain a separate BAL-capable peer list; it's derived from the same pool Connect to a Peer and Peer Discovery built up.
Source: src/Nethereum.DevP2P.Sync/README.md — "Snap-v2 BAL heal" table (IBlockAccessListPeer / IBlockAccessListPeerSource / PeerPoolBlockAccessListPeerSource).
Turning it on
BAL heal is opt-in for a reason — it's additional machinery most syncs don't need. Enable it via the orchestrator option covered in Snap Sync:
var options = new SnapSyncOrchestratorOptions { BalHealEnabled = true };
Gotcha — the flag alone isn't enough. Setting BalHealEnabled = true has no effect unless the block's active hardfork is Amsterdam or later — SnapBootstrapper.ShouldBalHeal gates on both the option and the fork schedule. Enabling the flag against a pre-Amsterdam chain silently does nothing; there's no error, because BAL simply isn't part of the wire protocol yet at that fork.
Source: src/Nethereum.DevP2P.Sync/README.md — "Snap-v2 BAL heal" (SnapSyncOrchestratorOptions.cs:39, default false); the Amsterdam-fork gate is verified in tests/Nethereum.DevP2P.Sync.UnitTests/SnapBalHealGapCloserTests.cs:36-53 (ShouldBalHeal_DisabledByConfig_ReturnsFalse, ShouldBalHeal_EnabledButPreAmsterdam_ReturnsFalse, ShouldBalHeal_EnabledAndAmsterdamActive_ReturnsTrue), untagged for doc traceability — see follow-up below.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
BalHealEnabled = true but nothing happens | Pre-Amsterdam fork active at the pivot | Confirm IChainActivations resolves Amsterdam or later at the relevant block |
BlockAccessListFetcher.FetchAsync throws InvalidOperationException | Every serviceable (snap/2-supporting) peer refused, mismatched, or went stateless for at least one requested block | Widen the peer pool, or check whether any peers actually advertise snap/2 (DevP2PConfig.AdvertiseSnap2) |
| A verified BAL entry doesn't change state | The affected account/slot fell outside ISnapTaskFrontier's already-fetched range | Expected — that range will be covered correctly once Phase 2 reaches it |
| Conflicting values for the same account after apply | Assumed list order determines the winner | It doesn't — highest BlockAccessIndex wins, regardless of list position |
Decision guidance
| Situation | Action |
|---|---|
| Pivot is stable, Phase 2/3 keeps up on its own | Leave BalHealEnabled at its default (false) — no need for this machinery |
| Pivot moves faster than heal converges, chain is on/after Amsterdam | Enable BalHealEnabled; peers must advertise snap/2 |
| Pivot moves but chain is pre-Amsterdam | BAL heal is unavailable at this fork — rely on TrieHealer's own pivot-follow instead (see Snap Sync) |
Full API reference (all Snap/CatchUp types): see the Nethereum.DevP2P.Sync reference.
Next steps
- Snap Sync — revisit Phase 2/3 and the rolling-pivot mechanics this guide extends.
- Connect to a Peer — capability negotiation and
DevP2PConfig.AdvertiseSnap2, the flag that putssnap/2on your ownHello. - Run a Node — the
SyncNodefacade andSnapSyncOrchestratorOptionsthis guide'sBalHealEnabledflag is set on.