Skip to content

Commit bea732f

Browse files
asdacapclaude
andcommitted
Refresh snap-sync account storage root via verified GetAccountRange
When a storage root mismatches during snap sync, the account was refreshed by fetching its storage-trie root node via GetTrieNodes and trusting the returned node's hash blindly. That refresh path is being removed in snaps/2 and never verified the new storage root against the pivot state root. Refresh now sends a single-account GetAccountRange (limit = path + 1) and verifies the returned account against the pivot state root by reconstructing the account range the same way account-range sync does: fill the boundary proof nodes, set the returned account(s), then check the recomputed root. The reconstruction runs in an isolated, empty-backed trie (no writes to the client state DB, so it cannot race the partition sync), and because the leaf is set from the response it does not rely on the proof containing it. Outcomes: verified (adopt storage root, re-queue storage), not found (deleted account, terminal), expired (stale pivot), or invalid proof (retry and score the peer). Refreshes are rare, so one account per request is fine. The shared verification is factored into SnapProviderHelper.BuildAndVerifyRoot, used by both CommitRange and the new no-commit VerifyAccountRange. The now-unused GetTrieNodes(AccountsToRefreshRequest) overload is left for the snaps/2 cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ecad55f commit bea732f

8 files changed

Lines changed: 240 additions & 61 deletions

File tree

src/Nethermind/Nethermind.Synchronization.Test/SnapSync/SnapServerTest.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,87 @@ public void TestGetAccountRange_InvalidRange()
192192
proofs.Dispose();
193193
}
194194

195+
// Refresh re-fetches a single account via GetAccountRange and verifies its storage root against the
196+
// state root from the proof. A correct root must verify and propagate the real storage root; a wrong
197+
// root must be rejected.
198+
[TestCase(true, AddRangeResult.OK)]
199+
[TestCase(false, AddRangeResult.DifferentRootHash)]
200+
public void TestRefreshAccount(bool useCorrectRoot, AddRangeResult expectedResult)
201+
{
202+
using ISnapServerContext context = CreateContext();
203+
FillWithTestAccounts(context);
204+
Hash256 storageRoot = FillAccountWithDefaultStorage(context);
205+
206+
ValueHash256 path = TestItem.Tree.AccountsWithPaths[0].Path;
207+
(IOwnedReadOnlyList<PathWithAccount> accounts, IByteArrayList proofs) =
208+
context.Server.GetAccountRanges(context.RootHash, path, path.IncrementPath(), 4000, CancellationToken.None);
209+
using AccountsAndProofs response = new() { PathAndAccounts = accounts, Proofs = proofs };
210+
211+
PathWithAccount stale = new(path, Build.An.Account.WithBalance(2).TestObject);
212+
using AccountsToRefreshRequest request = new()
213+
{
214+
RootHash = useCorrectRoot ? context.RootHash : TestItem.KeccakA,
215+
Paths = new ArrayPoolList<AccountWithStorageStartingHash>(1)
216+
{
217+
new() { PathAndAccount = stale, StorageStartingHash = ValueKeccak.Zero, StorageHashLimit = Keccak.MaxValue }
218+
}
219+
};
220+
221+
AddRangeResult result = context.SnapProvider.RefreshAccounts(request, response);
222+
223+
using (Assert.EnterMultipleScope())
224+
{
225+
Assert.That(result, Is.EqualTo(expectedResult));
226+
// On success the stale empty storage root must be replaced by the verified one.
227+
Assert.That(stale.Account.StorageRoot, Is.EqualTo(useCorrectRoot ? storageRoot : Keccak.EmptyTreeHash));
228+
}
229+
}
230+
231+
// The range proof can omit the leaf node. Verification must still succeed by setting the account from the
232+
// returned accounts (not by hashing a proof node), then checking the reconstructed root.
233+
[Test]
234+
public void TestRefreshAccount_LeafMissingFromProof()
235+
{
236+
using ISnapServerContext context = CreateContext();
237+
FillWithTestAccounts(context);
238+
Hash256 storageRoot = FillAccountWithDefaultStorage(context);
239+
240+
ValueHash256 path = TestItem.Tree.AccountsWithPaths[0].Path;
241+
(IOwnedReadOnlyList<PathWithAccount> accounts, IByteArrayList proofs) =
242+
context.Server.GetAccountRanges(context.RootHash, path, path.IncrementPath(), 4000, CancellationToken.None);
243+
244+
// Drop the leaf nodes from the proof; the leaves are still present in the returned accounts.
245+
ArrayPoolList<byte[]> trimmedProofs = new(proofs.Count);
246+
for (int i = 0; i < proofs.Count; i++)
247+
{
248+
byte[] proof = proofs[i].ToArray();
249+
TrieNode node = new(NodeType.Unknown, proof);
250+
node.ResolveNode(null!, TreePath.Empty);
251+
if (!node.IsLeaf) trimmedProofs.Add(proof);
252+
}
253+
Assert.That(trimmedProofs.Count, Is.LessThan(proofs.Count), "test setup: expected at least one leaf in the proof");
254+
proofs.Dispose();
255+
256+
using AccountsAndProofs response = new() { PathAndAccounts = accounts, Proofs = new ByteArrayListAdapter(trimmedProofs) };
257+
PathWithAccount stale = new(path, Build.An.Account.WithBalance(2).TestObject);
258+
using AccountsToRefreshRequest request = new()
259+
{
260+
RootHash = context.RootHash,
261+
Paths = new ArrayPoolList<AccountWithStorageStartingHash>(1)
262+
{
263+
new() { PathAndAccount = stale, StorageStartingHash = ValueKeccak.Zero, StorageHashLimit = Keccak.MaxValue }
264+
}
265+
};
266+
267+
AddRangeResult result = context.SnapProvider.RefreshAccounts(request, response);
268+
269+
using (Assert.EnterMultipleScope())
270+
{
271+
Assert.That(result, Is.EqualTo(AddRangeResult.OK));
272+
Assert.That(stale.Account.StorageRoot, Is.EqualTo(storageRoot));
273+
}
274+
}
275+
195276
[Test]
196277
public void TestGetTrieNode_Root()
197278
{

src/Nethermind/Nethermind.Synchronization/SnapSync/ISnapProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public interface ISnapProvider
2020

2121
void AddCodes(IReadOnlyList<ValueHash256> requestedHashes, IByteArrayList codes);
2222

23-
void RefreshAccounts(AccountsToRefreshRequest request, IByteArrayList response);
23+
AddRangeResult RefreshAccounts(AccountsToRefreshRequest request, AccountsAndProofs response);
2424

2525
void RetryRequest(SnapSyncBatch batch);
2626

src/Nethermind/Nethermind.Synchronization/SnapSync/ProgressTracker.cs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -269,19 +269,17 @@ private SnapSyncBatch CreateAccountRangeRequest(Hash256 rootHash, AccountRangePa
269269
return new SnapSyncBatch { AccountRangeRequest = range };
270270
}
271271

272-
private SnapSyncBatch DequeAccountToRefresh(Hash256 rootHash)
272+
private SnapSyncBatch? DequeAccountToRefresh(Hash256 rootHash)
273273
{
274+
// One account per request: each refresh is served by a single GetAccountRange.
275+
if (!AccountsToRefresh.TryDequeue(out AccountWithStorageStartingHash acc))
276+
return null;
277+
274278
Interlocked.Increment(ref _activeAccRefreshRequests);
275279

276280
LogRequest($"AccountsToRefresh: {AccountsToRefresh.Count}");
277281

278-
int queueLength = AccountsToRefresh.Count;
279-
ArrayPoolList<AccountWithStorageStartingHash> paths = new(queueLength);
280-
281-
for (int i = 0; i < queueLength && AccountsToRefresh.TryDequeue(out AccountWithStorageStartingHash acc); i++)
282-
{
283-
paths.Add(acc);
284-
}
282+
ArrayPoolList<AccountWithStorageStartingHash> paths = new(1) { acc };
285283

286284
return new SnapSyncBatch { AccountsToRefreshRequest = new AccountsToRefreshRequest { RootHash = rootHash, Paths = paths } };
287285
}

src/Nethermind/Nethermind.Synchronization/SnapSync/SnapProvider.cs

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
using Nethermind.Core.Extensions;
1616
using Nethermind.Db;
1717
using Nethermind.Logging;
18+
using Nethermind.Serialization.Rlp;
1819
using Nethermind.State.Snap;
20+
using Nethermind.Trie;
1921

2022
namespace Nethermind.Synchronization.SnapSync
2123
{
@@ -243,50 +245,98 @@ public AddRangeResult AddStorageRangeForAccount(StorageRange request, int accoun
243245
}
244246
}
245247

246-
public void RefreshAccounts(AccountsToRefreshRequest request, IByteArrayList response)
248+
public AddRangeResult RefreshAccounts(AccountsToRefreshRequest request, AccountsAndProofs response)
247249
{
248-
int respLength = response.Count;
249-
ReadOnlySpan<AccountWithStorageStartingHash> paths = request.Paths.AsSpan();
250-
for (int reqIndex = 0; reqIndex < paths.Length; reqIndex++)
251-
{
252-
AccountWithStorageStartingHash requestedPath = paths[reqIndex];
253-
254-
if (reqIndex < respLength)
255-
{
256-
ReadOnlySpan<byte> nodeData = response[reqIndex];
257-
258-
if (nodeData.Length == 0)
259-
{
260-
RetryAccountRefresh(requestedPath);
261-
_logger.Trace($"SNAP - Empty Account Refresh: {requestedPath.PathAndAccount.Path}");
262-
continue;
263-
}
250+
AccountWithStorageStartingHash requestedPath = request.Paths[0];
251+
ValueHash256 path = requestedPath.PathAndAccount.Path;
264252

265-
requestedPath.PathAndAccount.Account = requestedPath.PathAndAccount.Account.WithChangedStorageRoot(Keccak.Compute(nodeData));
253+
AddRangeResult result;
254+
switch (VerifyRefreshedAccount(response, request.RootHash, path, out Account? account))
255+
{
256+
case RefreshVerifyResult.Verified:
257+
result = AddRangeResult.OK;
258+
requestedPath.PathAndAccount.Account = requestedPath.PathAndAccount.Account.WithChangedStorageRoot(account!.StorageRoot);
266259

267260
if (requestedPath.StorageStartingHash > ValueKeccak.Zero)
268261
{
269-
StorageRange range = new()
262+
_progressTracker.EnqueueNextSlot(new StorageRange
270263
{
271264
Accounts = new ArrayPoolList<PathWithAccount>(1) { requestedPath.PathAndAccount },
272265
StartingHash = requestedPath.StorageStartingHash,
273266
LimitHash = requestedPath.StorageHashLimit
274-
};
275-
276-
_progressTracker.EnqueueNextSlot(range);
267+
});
277268
}
278269
else
279270
{
280271
_progressTracker.EnqueueAccountStorage(requestedPath.PathAndAccount);
281272
}
282-
}
283-
else
284-
{
273+
break;
274+
275+
case RefreshVerifyResult.NotFound:
276+
// The account no longer exists at the pivot, so there is no storage to retrieve. It remains
277+
// tracked for healing. Terminal success - must not retry or the refresh would loop forever.
278+
result = AddRangeResult.OK;
279+
break;
280+
281+
case RefreshVerifyResult.Expired:
282+
// The peer does not have the state for this root (stale pivot).
283+
result = AddRangeResult.ExpiredRootHash;
285284
RetryAccountRefresh(requestedPath);
286-
}
285+
break;
286+
287+
default: // InvalidProof - the proof does not reconstruct the state root.
288+
result = AddRangeResult.DifferentRootHash;
289+
RetryAccountRefresh(requestedPath);
290+
break;
287291
}
288292

293+
Metrics.SnapRangeResult.Increment(new SnapRangeResult(isStorage: false, result: result));
294+
// Must be the last statement, after any enqueue, so IsSnapGetRangesFinished cannot observe
295+
// an empty queue with a zeroed active count while work is still being scheduled.
289296
_progressTracker.ReportAccountRefreshFinished();
297+
return result;
298+
}
299+
300+
private enum RefreshVerifyResult { Verified, NotFound, Expired, InvalidProof }
301+
302+
/// <summary>
303+
/// Reconstructs the returned account range and verifies it against <paramref name="stateRoot"/> in an
304+
/// isolated, empty-backed trie - the account leaf is set from the response, not assumed to be in the proof -
305+
/// then extracts the verified account at <paramref name="path"/>. Nothing is written to the client state DB.
306+
/// </summary>
307+
private RefreshVerifyResult VerifyRefreshedAccount(AccountsAndProofs response, Hash256 stateRoot, in ValueHash256 path, out Account? account)
308+
{
309+
account = null;
310+
IReadOnlyList<PathWithAccount> accounts = response.PathAndAccounts;
311+
if (accounts.Count == 0)
312+
return response.Proofs.Count == 0 ? RefreshVerifyResult.Expired : RefreshVerifyResult.NotFound;
313+
314+
AddRangeResult result;
315+
try
316+
{
317+
// Empty-backed isolated factory: a proof node that cannot be resolved from the proof itself fails
318+
// verification instead of being completed from (or racing) the live client state DB.
319+
ISnapTrieFactory factory = new PatriciaSnapTrieFactory(new NodeStorage(new MemDb()), logManager);
320+
result = SnapProviderHelper.VerifyAccountRange(factory, stateRoot, path, path.IncrementPath(), accounts, response.Proofs);
321+
}
322+
catch (Exception e) when (e is TrieException or TrieNodeException or RlpException)
323+
{
324+
return RefreshVerifyResult.InvalidProof;
325+
}
326+
327+
if (result != AddRangeResult.OK)
328+
return RefreshVerifyResult.InvalidProof;
329+
330+
// Filter to the requested account; the response also carries at least one account past the limit.
331+
for (int i = 0; i < accounts.Count; i++)
332+
{
333+
if (accounts[i].Path == path)
334+
{
335+
account = accounts[i].Account;
336+
return RefreshVerifyResult.Verified;
337+
}
338+
}
339+
return RefreshVerifyResult.NotFound;
290340
}
291341

292342
private void RetryAccountRefresh(AccountWithStorageStartingHash requestedPath) => _progressTracker.EnqueueAccountRefresh(requestedPath.PathAndAccount, requestedPath.StorageStartingHash, requestedPath.StorageHashLimit);

src/Nethermind/Nethermind.Synchronization/SnapSync/SnapProviderHelper.cs

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ public static (AddRangeResult result, bool moreChildrenToRight, Hash256 actualRo
7070
return (AddRangeResult.OK, moreChildrenToRight, tree.RootHash, isRootPersisted);
7171
}
7272

73+
/// <summary>
74+
/// Verifies that <paramref name="accounts"/> and <paramref name="proofs"/> reconstruct
75+
/// <paramref name="expectedRootHash"/> for the range starting at <paramref name="startingHash"/>, without
76+
/// committing anything. Intended for single-account refresh against an isolated, empty-backed factory:
77+
/// the boundary nodes and the returned accounts are assembled in memory only to recompute the root, so an
78+
/// incomplete proof fails verification rather than being completed from (or racing) the live state DB.
79+
/// </summary>
80+
public static AddRangeResult VerifyAccountRange(
81+
ISnapTrieFactory factory,
82+
in ValueHash256 expectedRootHash,
83+
in ValueHash256 startingHash,
84+
in ValueHash256 limitHash,
85+
IReadOnlyList<PathWithAccount> accounts,
86+
IByteArrayList? proofs)
87+
{
88+
using ISnapTree<PathWithAccount> tree = factory.CreateStateTree();
89+
return BuildAndVerifyRoot(tree, accounts, startingHash, limitHash, expectedRootHash, proofs).result;
90+
}
91+
7392
private static (AddRangeResult result, bool moreChildrenToRight, bool isRootPersisted) CommitRange<TEntry>(
7493
ISnapTree<TEntry> tree,
7594
IReadOnlyList<TEntry> entries,
@@ -78,30 +97,16 @@ private static (AddRangeResult result, bool moreChildrenToRight, bool isRootPers
7897
in ValueHash256 expectedRootHash,
7998
IByteArrayList? proofs) where TEntry : ISnapEntry
8099
{
81-
if (entries.Count == 0)
82-
return (AddRangeResult.EmptyRange, true, false);
83-
84-
// Validate sorting order
85-
for (int i = 1; i < entries.Count; i++)
86-
{
87-
if (entries[i - 1].Path.CompareTo(entries[i].Path) >= 0)
88-
return (AddRangeResult.InvalidOrder, true, false);
89-
}
90-
91-
if (entries[0].Path < startingHash)
92-
return (AddRangeResult.InvalidOrder, true, false);
93-
94-
ValueHash256 lastPath = entries[entries.Count - 1].Path;
95-
96-
(AddRangeResult result, List<(TrieNode, TreePath)> sortedBoundaryList, bool moreChildrenToRight) =
97-
FillBoundaryTree(tree, startingHash, lastPath, limitHash, expectedRootHash, proofs);
100+
(AddRangeResult result, List<(TrieNode, TreePath)>? sortedBoundaryList, bool moreChildrenToRight) =
101+
BuildAndVerifyRoot(tree, entries, startingHash, limitHash, expectedRootHash, proofs);
98102

99103
if (result != AddRangeResult.OK)
100104
return (result, true, false);
101105

102106
// The upper bound is used to prevent proof nodes that covers next range from being persisted, except if
103107
// this is the last range. This prevent double node writes per path which break flat. It also prevent leaf o
104108
// that is after the range from being persisted, which prevent double write again.
109+
ValueHash256 lastPath = entries[entries.Count - 1].Path;
105110
ValueHash256 upperBound = lastPath;
106111
if (upperBound > limitHash)
107112
{
@@ -112,11 +117,6 @@ private static (AddRangeResult result, bool moreChildrenToRight, bool isRootPers
112117
if (!moreChildrenToRight) upperBound = ValueKeccak.MaxValue;
113118
}
114119

115-
tree.BulkSetAndUpdateRootHash(entries);
116-
117-
if (tree.RootHash.ValueHash256 != expectedRootHash)
118-
return (AddRangeResult.DifferentRootHash, true, false);
119-
120120
StitchBoundaries(sortedBoundaryList, tree, startingHash);
121121

122122
tree.Commit(upperBound);
@@ -125,6 +125,47 @@ private static (AddRangeResult result, bool moreChildrenToRight, bool isRootPers
125125
return (AddRangeResult.OK, moreChildrenToRight, isRootPersisted);
126126
}
127127

128+
/// <summary>
129+
/// Fills the boundary proof nodes and bulk-sets the range entries into <paramref name="tree"/> in memory,
130+
/// then checks that the recomputed root equals <paramref name="expectedRootHash"/>. Nothing is committed.
131+
/// </summary>
132+
private static (AddRangeResult result, List<(TrieNode, TreePath)>? sortedBoundaryList, bool moreChildrenToRight) BuildAndVerifyRoot<TEntry>(
133+
ISnapTree<TEntry> tree,
134+
IReadOnlyList<TEntry> entries,
135+
in ValueHash256 startingHash,
136+
in ValueHash256 limitHash,
137+
in ValueHash256 expectedRootHash,
138+
IByteArrayList? proofs) where TEntry : ISnapEntry
139+
{
140+
if (entries.Count == 0)
141+
return (AddRangeResult.EmptyRange, null, false);
142+
143+
// Validate sorting order
144+
for (int i = 1; i < entries.Count; i++)
145+
{
146+
if (entries[i - 1].Path.CompareTo(entries[i].Path) >= 0)
147+
return (AddRangeResult.InvalidOrder, null, false);
148+
}
149+
150+
if (entries[0].Path < startingHash)
151+
return (AddRangeResult.InvalidOrder, null, false);
152+
153+
ValueHash256 lastPath = entries[entries.Count - 1].Path;
154+
155+
(AddRangeResult result, List<(TrieNode, TreePath)> sortedBoundaryList, bool moreChildrenToRight) =
156+
FillBoundaryTree(tree, startingHash, lastPath, limitHash, expectedRootHash, proofs);
157+
158+
if (result != AddRangeResult.OK)
159+
return (result, null, false);
160+
161+
tree.BulkSetAndUpdateRootHash(entries);
162+
163+
if (tree.RootHash.ValueHash256 != expectedRootHash)
164+
return (AddRangeResult.DifferentRootHash, null, false);
165+
166+
return (AddRangeResult.OK, sortedBoundaryList, moreChildrenToRight);
167+
}
168+
128169
[SkipLocalsInit]
129170
private static (AddRangeResult result, List<(TrieNode, TreePath)> sortedBoundaryList, bool moreChildrenToRight) FillBoundaryTree<TEntry>(
130171
ISnapTree<TEntry> tree,

src/Nethermind/Nethermind.Synchronization/SnapSync/SnapSyncBatch.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class SnapSyncBatch : IDisposable
2020
public IByteArrayList? CodesResponse { get; set; }
2121

2222
public AccountsToRefreshRequest? AccountsToRefreshRequest { get; set; }
23-
public IByteArrayList? AccountsToRefreshResponse { get; set; }
23+
public AccountsAndProofs? AccountsToRefreshResponse { get; set; }
2424

2525
public override string ToString()
2626
{

0 commit comments

Comments
 (0)