Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NethermindNode.Core/Helpers/DockerCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ public static string GetExecutionDataPath(Logger logger)
return GetDockerDetails(ConfigurationHelper.Instance["execution-container-name"], "{{ range .Mounts }}{{ if eq .Destination \\\"/nethermind/data\\\" }}{{ .Source }}{{ end }}{{ end }}", logger).Trim();
}

public static string GetEraDataPath(Logger logger)
{
return GetDockerDetails(ConfigurationHelper.Instance["execution-container-name"], "{{ range .Mounts }}{{ if eq .Destination \\\"/era\\\" }}{{ .Source }}{{ end }}{{ end }}", logger).Trim();
}

public static IEnumerable<string> GetDockerLogs(string containerIdOrName, string logFilter = null, bool followLogs = false, CancellationToken? cancellationToken = null, string additionaloptions = "")
{
string followFlag = followLogs ? "-f" : "";
Expand Down
17 changes: 16 additions & 1 deletion NethermindNode.Core/Helpers/NodeInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public enum NetworkType
Volta = 73799,
Sepolia = 11155111,
Holesky = 17000,
Hoodi = 560048,
}

public static bool IsFullySynced(Logger logger)
Expand Down Expand Up @@ -143,7 +144,8 @@ public static async Task<NetworkType> GetNetworkType(Logger logger)
else
{
logger.Info($"Network type: {result}");
return (NetworkType)int.Parse(result, System.Globalization.NumberStyles.HexNumber);
string hex = result.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? result[2..] : result;
return (NetworkType)int.Parse(hex, System.Globalization.NumberStyles.HexNumber);
}
}

Expand Down Expand Up @@ -176,6 +178,19 @@ public static async Task<long> GetAncientReceiptsBarrier(Logger logger)
return long.Parse(result.Result);
}

public static async Task<long> GetMergeBlockNumber()
{
NetworkType networkType = await GetNetworkType(TestLoggerContext.Logger);
return networkType switch
{
NetworkType.Mainnet => 15_537_394,
NetworkType.Sepolia => 1_000_000,
NetworkType.Holesky => 100_000,
NetworkType.Hoodi => 100_000,
_ => throw new NotSupportedException($"Merge block number not known for network {networkType}.")
};
}

public static bool VerifyLogsForUndesiredEntries(ref List<string> errors)
{
var exceptions = DockerCommands.GetDockerLogs(ConfigurationHelper.Instance["execution-container-name"], "Exception");
Expand Down
1 change: 1 addition & 0 deletions NethermindNode.Tests/NethermindNode.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
</PackageReference>
<PackageReference Include="NunitXml.TestLogger" Version="5.0.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<None Include="..\config.json" Link="config.json" CopyToOutputDirectory="Always" />
</ItemGroup>

Expand Down
231 changes: 231 additions & 0 deletions NethermindNode.Tests/Tests/HistoryExpiry/EraTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using NethermindNode.Core;
using NethermindNode.Core.Helpers;
using NethermindNode.Tests.CustomAttributes;
using NLog;

namespace NethermindNode.Tests.HistoryExpiry;

[TestFixture]
[NonParallelizable]
[Category("EraExportImport")]
public class EraTests : BaseTest
{
private const string EraDirectory = "/era";
private const string ImportDirectory = EraDirectory + "/import";
private const string ExportDirectory = EraDirectory + "/export";
private const string VolumeMapping = "/mnt/era-stavros:" + EraDirectory;
private const string ComposeFile = "/root/docker-compose.yml";
private const string RemoteBaseUrl = "https://data.ethpandaops.io/erae/mainnet/";

// Pre-merge blocks: snap sync does not download bodies for these.
// After EraE import they must be accessible with correct hashes.
private static readonly (long Number, string Hash)[] PreMergeBlocks =
[
(1L, "0x88e96d4537bea4d9c05d12549907b32561d3bf31f45aae734cdc119f13406cb6"),
(1_920_000L, "0x4985f5ca3d2afbec36529aa96f74de3cc10a2a4a6c44f2157a57d2c6059a11bb"), // DAO fork
(15_537_393L, "0x55b11b918355b1ef9c5db810302ebad0bf2544255b530cdce90674d5887bb286"), // last PoW block
];

// Post-merge blocks: accessible from snap sync regardless of EraE import.
// Verified here to confirm import/export did not corrupt existing DB state.
private static readonly (long Number, string Hash)[] PostMergeBlocks =
[
(15_537_394L, "0x56a9bb0302da44b8c0b3df540781424684c3af04d0b7a38d72842b762076a664"), // first PoS block
(16_000_000L, "0x3dc4ef568ae2635db1419c5fec55c4a9322c05302ae527cd40bff380c1d465dd"),
];

[NethermindTest]
[Category("EraImport")]
public async Task ShouldImportFromRemote()
{
Logger logger = TestLoggerContext.Logger;
string containerName = ConfigurationHelper.Instance["execution-container-name"];

NodeInfo.WaitForNodeToBeReady(logger);
NodeInfo.WaitForNodeToBeSynced(logger);
logger.Info("Node snap sync complete.");

// Verify pre-merge blocks are NOT accessible before import
foreach ((long number, string _) in PreMergeBlocks)
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain("\"result\":null"),
$"Block {number} should not be accessible before EraE import.");
}
logger.Info("Confirmed pre-merge blocks absent before import.");

long lastImportedBlock = await ImportFromRemote(containerName, logger);

// Verify pre-merge blocks ARE accessible with correct hashes after import
foreach ((long number, string expectedHash) in PreMergeBlocks)
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain(expectedHash),
$"Block {number} hash mismatch after import.");
}
logger.Info("All pre-merge blocks verified after import.");

// Verify post-merge blocks are unaffected
foreach ((long number, string expectedHash) in PostMergeBlocks)
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain(expectedHash),
$"Post-merge block {number} hash mismatch — DB may be corrupt.");
}
logger.Info("All post-merge blocks verified.");
}

[NethermindTest]
[Category("EraExport")]
public async Task ShouldExportFromDb()
{
Logger logger = TestLoggerContext.Logger;
string containerName = ConfigurationHelper.Instance["execution-container-name"];

NodeInfo.WaitForNodeToBeReady(logger);

// Verify pre-merge blocks are in DB (import must have run first)
foreach ((long number, string expectedHash) in PreMergeBlocks)
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain(expectedHash),
$"Block {number} not found — run EraImport test first.");
}

DeleteImportedEraFiles(logger);

await ExportFromDb(containerName, 24_518_655L, logger);

// Verify era files were written to disk
string eraHostPath = DockerCommands.GetEraDataPath(logger);
string[] exportedFiles = Directory.GetFiles(Path.Combine(eraHostPath, "export"), "*.era", SearchOption.AllDirectories);
Assert.That(exportedFiles.Length, Is.GreaterThan(0), "No era files found in export directory.");
logger.Info($"Export produced {exportedFiles.Length} era file(s).");

// Verify all blocks still correct after export
foreach ((long number, string expectedHash) in PreMergeBlocks)
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain(expectedHash),
$"Pre-merge block {number} hash mismatch after export.");
}
foreach ((long number, string expectedHash) in PostMergeBlocks)
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain(expectedHash),
$"Post-merge block {number} hash mismatch after export.");
}
logger.Info("All blocks verified after export.");
}

[NethermindTest]
[Category("EraE2E")]
public async Task ShouldImportFromRemoteAndExportFromDbWithMatchingBlockData()
{
Logger logger = TestLoggerContext.Logger;
string containerName = ConfigurationHelper.Instance["execution-container-name"];

NodeInfo.WaitForNodeToBeReady(logger);
NodeInfo.WaitForNodeToBeSynced(logger);
logger.Info("Node snap sync complete.");

long lastImportedBlock = await ImportFromRemote(containerName, logger);

foreach ((long number, string expectedHash) in PreMergeBlocks)
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain(expectedHash),
$"Block {number} hash mismatch after import.");
}

DeleteImportedEraFiles(logger);

await ExportFromDb(containerName, lastImportedBlock, logger);

string eraHostPath = DockerCommands.GetEraDataPath(logger);
string[] exportedFiles = Directory.GetFiles(Path.Combine(eraHostPath, "export"), "*.era", SearchOption.AllDirectories);
Assert.That(exportedFiles.Length, Is.GreaterThan(0), "No era files found in export directory.");

foreach ((long number, string expectedHash) in PreMergeBlocks.Concat(PostMergeBlocks))
{
string response = await QueryBlock(number, logger);
Assert.That(response, Does.Contain(expectedHash),
$"Block {number} hash mismatch after export.");
}
logger.Info("Full E2E import/export verified.");
}

private static async Task<long> ImportFromRemote(string containerName, Logger logger)
{
logger.Info("Configuring remote era import.");

NodeConfig.AddVolume(VolumeMapping);
NodeConfig.AddElFlag("EraE", "ImportDirectory", ImportDirectory);
NodeConfig.AddElFlag("EraE", "RemoteBaseUrl", RemoteBaseUrl);

DockerCommands.StopDockerContainer(containerName, logger);
DockerCommands.ComposeUp("execution", ComposeFile, logger);
NodeInfo.WaitForNodeToBeReady(logger);

string logLine = await WaitForLog(containerName, "Finished EraE import", logger);
logger.Info("Remote era import finished.");

// Parse last imported block from: "Finished EraE import from {from} to {to}"
const string toMarker = " to ";
int toIndex = logLine.LastIndexOf(toMarker, StringComparison.Ordinal);
long lastImportedBlock = long.Parse(logLine[(toIndex + toMarker.Length)..].Trim());
logger.Info($"Last imported block: {lastImportedBlock}");
return lastImportedBlock;
}

private static void DeleteImportedEraFiles(Logger logger)
{
string eraHostPath = DockerCommands.GetEraDataPath(logger);
CommandExecutor.RemoveDirectory(Path.Combine(eraHostPath, "import"), logger);
logger.Info("Deleted downloaded era files.");
}

private static async Task ExportFromDb(string containerName, long to, Logger logger)
{
logger.Info("Configuring era export from DB.");

NodeConfig.RemoveElFlag("EraE", "ImportDirectory");
NodeConfig.RemoveElFlag("EraE", "RemoteBaseUrl");
NodeConfig.RemoveElFlag("EraE", "From");
NodeConfig.RemoveElFlag("EraE", "To");
NodeConfig.AddElFlag("EraE", "ExportDirectory", ExportDirectory);
NodeConfig.AddElFlag("EraE", "From", "0");
NodeConfig.AddElFlag("EraE", "To", to.ToString());

DockerCommands.StopDockerContainer(containerName, logger);
DockerCommands.ComposeUp("execution", ComposeFile, logger);
NodeInfo.WaitForNodeToBeReady(logger);

await WaitForLog(containerName, "Finished EraE export", logger);
logger.Info("Era export finished.");
}

private static async Task<string> QueryBlock(long blockNumber, Logger logger)
{
string rpcParams = $"\"0x{blockNumber:X}\", true";
Tuple<string, TimeSpan, bool> rpcResult = await HttpExecutor.ExecuteNethermindJsonRpcCommand(
"eth_getBlockByNumber", rpcParams, NodeInfo.apiBaseUrl, logger);
logger.Info($"Block {blockNumber} response: {rpcResult.Item1}");
return rpcResult.Item1;
}

private static async Task<string> WaitForLog(string containerName, string expectedLog, Logger logger)
{
logger.Info($"Waiting for log: '{expectedLog}'");
CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromHours(6));
await foreach (string line in DockerCommands.GetDockerLogsAsync(containerName, expectedLog, true, cts.Token))
{
logger.Info($"Found log: {line}");
return line;
}
throw new TimeoutException($"Log '{expectedLog}' not found within 6 hours.");
}
}
94 changes: 94 additions & 0 deletions NethermindNode.Tests/Tests/HistoryExpiry/NodeConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using NethermindNode.Core;
using YamlDotNet.RepresentationModel;

namespace NethermindNode.Tests.HistoryExpiry;

internal static class NodeConfig
{
private const string ComposeFileName = "docker-compose.yml";
private static YamlStream _yaml = new();

public static void AddElFlag(string nameSpace, string key, string value)
{
Load();
string flag = $"--{nameSpace}.{key}={value}";
string prefix = $"--{nameSpace}.{key}=";
YamlSequenceNode commandNode = GetSequenceNode("execution", "command");
if (commandNode.Children.Any(c => c.ToString().StartsWith(prefix, StringComparison.Ordinal)))
{
TestLoggerContext.Logger.Info($"Flag already set, skipping: {flag}");
return;
}
commandNode.Add(new YamlScalarNode(flag));
TestLoggerContext.Logger.Info($"Added flag: {flag}");
Save();
}

public static void RemoveElFlag(string nameSpace, string key)
{
Load();
string prefix = $"--{nameSpace}.{key}";
YamlSequenceNode commandNode = GetSequenceNode("execution", "command");
for (int i = 0; i < commandNode.Children.Count; i++)
{
if (commandNode.Children[i].ToString().StartsWith(prefix, StringComparison.Ordinal))
{
commandNode.Children.RemoveAt(i);
TestLoggerContext.Logger.Info($"Removed flag: {prefix}");
break;
}
}
Save();
}

public static void AddVolume(string volume)
{
Load();
YamlSequenceNode volumesNode = GetSequenceNode("execution", "volumes");
if (volumesNode.Children.Any(c => c.ToString() == volume))
{
TestLoggerContext.Logger.Info($"Volume already set, skipping: {volume}");
return;
}
volumesNode.Add(new YamlScalarNode(volume));
Save();
}

private static void Load()
{
_yaml = new YamlStream();
string path = GetComposeFilePath();
using StreamReader reader = new StreamReader(path);
_yaml.Load(reader);
}

private static void Save()
{
string path = GetComposeFilePath();
using StreamWriter writer = new StreamWriter(path);
_yaml.Save(writer, assignAnchors: false);
}

private static YamlSequenceNode GetSequenceNode(string service, string key)
{
YamlMappingNode serviceNode = GetServiceNode(service);
return (YamlSequenceNode)serviceNode.Children[new YamlScalarNode(key)];
}

private static YamlMappingNode GetServiceNode(string service)
{
YamlMappingNode root = (YamlMappingNode)_yaml.Documents[0].RootNode;
YamlMappingNode services = (YamlMappingNode)root.Children[new YamlScalarNode("services")];
return (YamlMappingNode)services.Children[new YamlScalarNode(service)];
}

private static string GetComposeFilePath()
{
string baseDirectory = AppContext.BaseDirectory;
string repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "../../../../../"));
return Path.Combine(repoRoot, ComposeFileName);
}
}