Skip to content

Commit a6072fc

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 hash blindly. That path is being removed in snaps/2 and never verified the result against the state root. Refresh now sends a single-account GetAccountRange (limit = path + 1) and verifies the returned account against the pivot state root in an isolated, in-memory, content-addressed trie (throwaway MemDb + Hash-scheme NodeStorage + PatriciaTree.Get) — no writes to the client state DB, so it cannot race the partition account-range sync. Outcomes: verified (adopt storage root, re-queue storage), not found (deleted account, terminal), or invalid proof (retry and score the peer). Refreshes are rare, so one account per request is fine. 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 a6072fc

7 files changed

Lines changed: 141 additions & 47 deletions

File tree

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,42 @@ 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+
195231
[Test]
196232
public void TestGetTrieNode_Root()
197233
{

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: 85 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
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;
21+
using Nethermind.Trie.Pruning;
1922

2023
namespace Nethermind.Synchronization.SnapSync
2124
{
@@ -30,6 +33,8 @@ public class SnapProvider(ProgressTracker progressTracker, [KeyFilter(DbNames.Co
3033
// This is actually close to 97% effective.
3134
private readonly AssociativeKeyCache<ValueHash256> _codeExistKeyCache = new(1024 * 16);
3235

36+
private static readonly AccountDecoder _accountDecoder = new();
37+
3338
public bool CanSync() => _progressTracker.CanSync();
3439

3540
public bool IsFinished(out SnapSyncBatch? nextBatch) => _progressTracker.IsFinished(out nextBatch);
@@ -243,50 +248,96 @@ public AddRangeResult AddStorageRangeForAccount(StorageRange request, int accoun
243248
}
244249
}
245250

246-
public void RefreshAccounts(AccountsToRefreshRequest request, IByteArrayList response)
251+
public AddRangeResult RefreshAccounts(AccountsToRefreshRequest request, AccountsAndProofs response)
247252
{
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+
AccountWithStorageStartingHash requestedPath = request.Paths[0];
254+
ValueHash256 path = requestedPath.PathAndAccount.Path;
253255

254-
if (reqIndex < respLength)
256+
AddRangeResult result;
257+
if (response.PathAndAccounts.Count == 0 && response.Proofs.Count == 0)
258+
{
259+
// The peer does not have the state for this root (stale pivot).
260+
result = AddRangeResult.ExpiredRootHash;
261+
RetryAccountRefresh(requestedPath);
262+
}
263+
else
264+
{
265+
switch (VerifyRefreshedAccount(response.Proofs, request.RootHash, path, out Account? account))
255266
{
256-
ReadOnlySpan<byte> nodeData = response[reqIndex];
267+
case RefreshVerifyResult.Verified:
268+
result = AddRangeResult.OK;
269+
requestedPath.PathAndAccount.Account = requestedPath.PathAndAccount.Account.WithChangedStorageRoot(account!.StorageRoot);
257270

258-
if (nodeData.Length == 0)
259-
{
271+
if (requestedPath.StorageStartingHash > ValueKeccak.Zero)
272+
{
273+
_progressTracker.EnqueueNextSlot(new StorageRange
274+
{
275+
Accounts = new ArrayPoolList<PathWithAccount>(1) { requestedPath.PathAndAccount },
276+
StartingHash = requestedPath.StorageStartingHash,
277+
LimitHash = requestedPath.StorageHashLimit
278+
});
279+
}
280+
else
281+
{
282+
_progressTracker.EnqueueAccountStorage(requestedPath.PathAndAccount);
283+
}
284+
break;
285+
286+
case RefreshVerifyResult.NotFound:
287+
// The account no longer exists at the pivot, so there is no storage to retrieve.
288+
// It remains tracked for healing. Terminal success - must not retry or the refresh
289+
// would loop forever.
290+
result = AddRangeResult.OK;
291+
break;
292+
293+
default: // InvalidProof - the peer returned a missing or tampered proof.
294+
result = AddRangeResult.DifferentRootHash;
260295
RetryAccountRefresh(requestedPath);
261-
_logger.Trace($"SNAP - Empty Account Refresh: {requestedPath.PathAndAccount.Path}");
262-
continue;
263-
}
296+
break;
297+
}
298+
}
264299

265-
requestedPath.PathAndAccount.Account = requestedPath.PathAndAccount.Account.WithChangedStorageRoot(Keccak.Compute(nodeData));
300+
Metrics.SnapRangeResult.Increment(new SnapRangeResult(isStorage: false, result: result));
301+
// Must be the last statement, after any enqueue, so IsSnapGetRangesFinished cannot observe
302+
// an empty queue with a zeroed active count while work is still being scheduled.
303+
_progressTracker.ReportAccountRefreshFinished();
304+
return result;
305+
}
266306

267-
if (requestedPath.StorageStartingHash > ValueKeccak.Zero)
268-
{
269-
StorageRange range = new()
270-
{
271-
Accounts = new ArrayPoolList<PathWithAccount>(1) { requestedPath.PathAndAccount },
272-
StartingHash = requestedPath.StorageStartingHash,
273-
LimitHash = requestedPath.StorageHashLimit
274-
};
307+
private enum RefreshVerifyResult { Verified, NotFound, InvalidProof }
275308

276-
_progressTracker.EnqueueNextSlot(range);
277-
}
278-
else
279-
{
280-
_progressTracker.EnqueueAccountStorage(requestedPath.PathAndAccount);
281-
}
282-
}
283-
else
284-
{
285-
RetryAccountRefresh(requestedPath);
286-
}
309+
/// <summary>
310+
/// Verifies that <paramref name="path"/> resolves to an account under <paramref name="stateRoot"/>
311+
/// using only <paramref name="proofs"/>, reconstructed in an isolated in-memory content-addressed
312+
/// trie. Nothing is written to the client state DB, so this cannot race the partition account-range sync.
313+
/// </summary>
314+
private RefreshVerifyResult VerifyRefreshedAccount(IByteArrayList proofs, Hash256 stateRoot, in ValueHash256 path, out Account? account)
315+
{
316+
account = null;
317+
if (proofs.Count == 0) return RefreshVerifyResult.InvalidProof;
318+
319+
using MemDb memDb = new();
320+
NodeStorage nodeStorage = new(memDb, INodeStorage.KeyScheme.Hash);
321+
for (int i = 0; i < proofs.Count; i++)
322+
{
323+
ReadOnlySpan<byte> rlp = proofs[i];
324+
nodeStorage.Set(null, TreePath.Empty, ValueKeccak.Compute(rlp), rlp);
287325
}
288326

289-
_progressTracker.ReportAccountRefreshFinished();
327+
try
328+
{
329+
PatriciaTree tree = new(new RawScopedTrieStore(nodeStorage), logManager);
330+
ReadOnlySpan<byte> accountRlp = tree.Get(path.Bytes, stateRoot);
331+
if (accountRlp.IsEmpty) return RefreshVerifyResult.NotFound;
332+
333+
Rlp.ValueDecoderContext ctx = new(accountRlp);
334+
account = _accountDecoder.Decode(ref ctx);
335+
return account is null ? RefreshVerifyResult.NotFound : RefreshVerifyResult.Verified;
336+
}
337+
catch (Exception e) when (e is TrieException or TrieNodeException or RlpException)
338+
{
339+
return RefreshVerifyResult.InvalidProof;
340+
}
290341
}
291342

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

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
{

src/Nethermind/Nethermind.Synchronization/SnapSync/SnapSyncDownloader.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
using System.Threading;
66
using System.Threading.Tasks;
77
using Nethermind.Blockchain.Synchronization;
8+
using Nethermind.Core.Crypto;
9+
using Nethermind.Core.Extensions;
810
using Nethermind.Logging;
911
using Nethermind.Network.Contract.P2P;
12+
using Nethermind.State.Snap;
1013
using Nethermind.Synchronization.ParallelSync;
1114
using Nethermind.Synchronization.Peers;
1215

@@ -36,9 +39,15 @@ public async Task Dispatch(PeerInfo peerInfo, SnapSyncBatch batch, CancellationT
3639
{
3740
batch.CodesResponse = await handler.GetByteCodes(batch.CodesRequest, cancellationToken);
3841
}
39-
else if (batch.AccountsToRefreshRequest is not null)
42+
else if (batch.AccountsToRefreshRequest is { Paths.Count: > 0 })
4043
{
41-
batch.AccountsToRefreshResponse = await handler.GetTrieNodes(batch.AccountsToRefreshRequest, cancellationToken);
44+
// Refresh a single account via GetAccountRange so its storage root is verified
45+
// against the state root. Limit = path + 1 (the protocol returns at least one
46+
// account past the limit, so start == limit must be avoided).
47+
AccountWithStorageStartingHash account = batch.AccountsToRefreshRequest.Paths[0];
48+
ValueHash256 path = account.PathAndAccount.Path;
49+
AccountRange range = new(batch.AccountsToRefreshRequest.RootHash, path, path.IncrementPath());
50+
batch.AccountsToRefreshResponse = await handler.GetAccountRange(range, cancellationToken);
4251
}
4352
}
4453
catch (OperationCanceledException)

src/Nethermind/Nethermind.Synchronization/SnapSync/SnapSyncFeed.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public SyncResponseHandlingResult HandleResponse(SnapSyncBatch batch, PeerInfo?
8484
}
8585
else if (batch.AccountsToRefreshResponse is not null)
8686
{
87-
_snapProvider.RefreshAccounts(batch.AccountsToRefreshRequest, batch.AccountsToRefreshResponse);
87+
result = _snapProvider.RefreshAccounts(batch.AccountsToRefreshRequest, batch.AccountsToRefreshResponse);
8888
}
8989
else
9090
{

0 commit comments

Comments
 (0)