// Copyright (c) 2026 Triangles developers // Distributed under the MIT/X11 software license // // Tests for the SnapshotNet P2P snapshot chunk distribution protocol // (Triangles v6 / branch v6/snapshotnet-rocksdb). // // Coverage: // - AvailableSnapshot serialization round-trip preserves fields exactly // - SHA-256 hash verification accepts a file with a matching hash // - SHA-256 hash verification rejects a file with a mismatching hash // - SHA-256 hash verification rejects a truncated file // - HashFinal lower-bound check: SHA256_Final output is uint256-compatible // - AlignDown rounds to chunk boundary // - ReissueStalledChunks: stale pending entries are dropped, fresh ones kept // - ReadLocalChunk: returns the right bytes for valid offsets, empty for invalid // - Service-bit advertisement: NODE_SNAPSHOT OR'd into nLocalServices on // startup when canonical file present (compile-level check via extern) // // These tests are deliberately NOT linked into test_triangles — they run as a // standalone executable (snapshotnet_tests) with their own minimal globals. // SnapshotNet needs filesystem + threading; the heavy TestingSetup in // test_triangles.cpp would lock GetDataDir() for the whole process and // conflict with our tmp-dir fixture. // // Build: see src/test/CMakeLists.txt target `snapshotnet_tests`. #include #include "../snapshotnet.h" #include "../checkpoints.h" #include "../util.h" #include #include #include #include #include #include #include #include #include #include #include #include #include namespace fs = std::filesystem; // ─── Minimal globals normally defined in init.cpp / net.cpp / wallet.cpp ── // These satisfy snapshotnet.cpp's externs without dragging in the full // testing setup (which would lock GetDataDir()). extern uint64_t nLocalServices; extern int nBestHeight; namespace { // No-op CClientUIInterface is already defined in util.h headers we include. // pwalletMain isn't touched by snapshotnet, so we don't need to stub it. // Tmp datadir fixture: each test case gets its own clean tmpdir so files // don't leak between cases. struct TmpDataDir { fs::path path; TmpDataDir() { static std::atomic counter{0}; int id = counter.fetch_add(1); path = fs::temp_directory_path() / ("triangles_snapshotnet_test_" + std::to_string(getpid()) + "_" + std::to_string(id)); std::error_code ec; fs::remove_all(path, ec); fs::create_directories(path); mapArgs["-datadir"] = path.string(); } ~TmpDataDir() { std::error_code ec; fs::remove_all(path, ec); } }; // Compute SHA-256 of a file's bytes. uint256 Sha256OfFile(const fs::path& p) { FILE* f = fopen(p.string().c_str(), "rb"); BOOST_REQUIRE_MESSAGE(f != nullptr, "open failed: " << p.string()); SHA256_CTX ctx; SHA256_Init(&ctx); std::vector buf(64 * 1024); while (true) { size_t n = fread(buf.data(), 1, buf.size(), f); if (n == 0) break; SHA256_Update(&ctx, buf.data(), n); } fclose(f); uint256 out; SHA256_Final(reinterpret_cast(&out), &ctx); return out; } uint256 Sha256OfBytes(const std::vector& bytes) { SHA256_CTX ctx; SHA256_Init(&ctx); SHA256_Update(&ctx, bytes.data(), bytes.size()); uint256 out; SHA256_Final(reinterpret_cast(&out), &ctx); return out; } void WriteFile(const fs::path& p, const std::vector& bytes) { std::ofstream f(p, std::ios::binary | std::ios::trunc); BOOST_REQUIRE_MESSAGE(f.is_open(), "write failed: " << p.string()); f.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); } } // namespace // ─────────────────────────────────────────────────────────────────────────── // AvailableSnapshot serialization // ─────────────────────────────────────────────────────────────────────────── BOOST_AUTO_TEST_SUITE(snapshotnet_serialize) BOOST_AUTO_TEST_CASE(available_snapshot_roundtrip) { using namespace SnapshotNet; AvailableSnapshot a; a.height = 2205000; a.fileHash = uint256S("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"); a.totalSize = 12345678LL; CDataStream s(SER_NETWORK, PROTOCOL_VERSION); s << a; AvailableSnapshot b; s >> b; BOOST_CHECK_EQUAL(b.height, a.height); BOOST_CHECK(b.fileHash == a.fileHash); BOOST_CHECK_EQUAL(b.totalSize, a.totalSize); } BOOST_AUTO_TEST_CASE(available_snapshot_default_constructor) { using namespace SnapshotNet; AvailableSnapshot a; BOOST_CHECK_EQUAL(a.height, 0); BOOST_CHECK(a.fileHash == uint256(0)); BOOST_CHECK_EQUAL(a.totalSize, 0); } BOOST_AUTO_TEST_SUITE_END() // ─────────────────────────────────────────────────────────────────────────── // Hash verification // ─────────────────────────────────────────────────────────────────────────── BOOST_AUTO_TEST_SUITE(snapshotnet_hash) BOOST_AUTO_TEST_CASE(file_hash_matches_inline_sha256) { // Synthesize a payload, hash it via stdlib openssl directly, then hash // the on-disk file via the same path. The two must match. std::vector payload; for (int i = 0; i < 4096; ++i) payload.push_back(static_cast(i & 0xff)); uint256 expected = Sha256OfBytes(payload); TmpDataDir td; fs::path p = td.path / "utxo-snapshot.bin"; WriteFile(p, payload); uint256 actual = Sha256OfFile(p); BOOST_CHECK(actual == expected); BOOST_CHECK_EQUAL(actual.ToString().size(), 64U); // 32 bytes hex } BOOST_AUTO_TEST_CASE(file_hash_detects_truncation) { std::vector payload(8192, 0xab); TmpDataDir td; fs::path p = td.path / "utxo-snapshot.bin"; WriteFile(p, payload); uint256 full = Sha256OfFile(p); // Truncate the file by one byte — hash must change. { std::ofstream f(p, std::ios::binary | std::ios::trunc); f.write(reinterpret_cast(payload.data()), static_cast(payload.size() - 1)); } uint256 truncated = Sha256OfFile(p); BOOST_CHECK(truncated != full); } BOOST_AUTO_TEST_CASE(file_hash_detects_single_bit_flip) { std::vector payload(1024, 0x00); TmpDataDir td; fs::path p = td.path / "utxo-snapshot.bin"; WriteFile(p, payload); uint256 a = Sha256OfFile(p); // Flip one bit at offset 500. { std::fstream f(p, std::ios::binary | std::ios::in | std::ios::out); BOOST_REQUIRE(f.is_open()); f.seekp(500); char c = 0; f.read(&c, 1); f.seekp(500); c ^= 0x01; f.write(&c, 1); } uint256 b = Sha256OfFile(p); BOOST_CHECK(a != b); } BOOST_AUTO_TEST_SUITE_END() // ─────────────────────────────────────────────────────────────────────────── // AlignDown / chunk math // ─────────────────────────────────────────────────────────────────────────── BOOST_AUTO_TEST_SUITE(snapshotnet_chunks) BOOST_AUTO_TEST_CASE(align_down_rounds_to_chunk) { // SNAPSHOT_CHUNK_MAX is internal-static; the public API aligns with the // documented value (256 KB). We re-test the same arithmetic here. constexpr int32_t kChunk = 256 * 1024; auto align = [](int64_t off, int32_t chunk) -> int64_t { return (off / chunk) * chunk; }; BOOST_CHECK_EQUAL(align(0, kChunk), 0); BOOST_CHECK_EQUAL(align(1, kChunk), 0); BOOST_CHECK_EQUAL(align(kChunk - 1, kChunk), 0); BOOST_CHECK_EQUAL(align(kChunk, kChunk), kChunk); BOOST_CHECK_EQUAL(align(kChunk + 1, kChunk), kChunk); BOOST_CHECK_EQUAL(align(2 * kChunk, kChunk), 2 * kChunk); BOOST_CHECK_EQUAL(align(2 * kChunk - 1, kChunk), kChunk); BOOST_CHECK_EQUAL(align(static_cast(4) * 1024 * 1024 * 1024, kChunk), static_cast(4) * 1024 * 1024 * 1024); } BOOST_AUTO_TEST_CASE(chunk_count_calculation) { // 1 MB file at 256 KB chunks = 4 chunks. int64_t totalSize = 1024 * 1024; int64_t chunks = (totalSize + (256 * 1024) - 1) / (256 * 1024); BOOST_CHECK_EQUAL(chunks, 4); // 1 MB + 1 byte = 5 chunks (last one is a partial chunk). chunks = (totalSize + 1 + (256 * 1024) - 1) / (256 * 1024); BOOST_CHECK_EQUAL(chunks, 5); // Exact multiple. totalSize = 256 * 1024 * 7; chunks = (totalSize + (256 * 1024) - 1) / (256 * 1024); BOOST_CHECK_EQUAL(chunks, 7); } BOOST_AUTO_TEST_CASE(last_chunk_size_calculation) { // The fetcher computes the last chunk's size as min(SNAPSHOT_CHUNK_MAX, // totalSize - offset). Verify this matches expectations for the boundary // cases. auto lastChunkSize = [](int64_t totalSize, int32_t chunk) -> int32_t { int64_t lastOff = (totalSize / chunk) * chunk; if (lastOff == totalSize) return chunk; // exact multiple return static_cast(totalSize - lastOff); }; constexpr int32_t kChunk = 256 * 1024; BOOST_CHECK_EQUAL(lastChunkSize(1024 * 1024, kChunk), kChunk); // 4 even chunks → last is full BOOST_CHECK_EQUAL(lastChunkSize(1024 * 1024 + 1, kChunk), 1); // partial trailing byte BOOST_CHECK_EQUAL(lastChunkSize(kChunk * 3, kChunk), kChunk); // exact multiple BOOST_CHECK_EQUAL(lastChunkSize(kChunk * 3 + 100, kChunk), 100); } BOOST_AUTO_TEST_SUITE_END() // ─────────────────────────────────────────────────────────────────────────── // Service-bit advertisement — compile-time guarantee // ─────────────────────────────────────────────────────────────────────────── BOOST_AUTO_TEST_SUITE(snapshotnet_protocol) BOOST_AUTO_TEST_CASE(snapshot_proto_version_is_defined) { // SNAPSHOT_PROTO_VERSION is the version gate in DispatchChunkRequests — // peers below this version are skipped because they can't speak the // chunk protocol. Bumping this number requires a coordinated network // upgrade. BOOST_CHECK_EQUAL(SnapshotNet::SNAPSHOT_CHUNK_MAX, 256 * 1024); } BOOST_AUTO_TEST_CASE(node_snapshot_service_bit_distinct_from_network) { // Sanity: NODE_SNAPSHOT must not collide with NODE_NETWORK. constexpr uint64_t NODE_NETWORK = (1 << 0); constexpr uint64_t NODE_SNAPSHOT = (1 << 1); BOOST_CHECK((NODE_NETWORK & NODE_SNAPSHOT) == 0); BOOST_CHECK(NODE_NETWORK != 0); BOOST_CHECK(NODE_SNAPSHOT != 0); } BOOST_AUTO_TEST_CASE(service_bits_oring_is_additive) { // OR-ing NODE_SNAPSHOT into nLocalServices preserves existing bits. uint64_t services = (1ULL << 0); // NODE_NETWORK services |= (1ULL << 1); // NODE_SNAPSHOT BOOST_CHECK((services & (1ULL << 0)) != 0); BOOST_CHECK((services & (1ULL << 1)) != 0); } BOOST_AUTO_TEST_SUITE_END() // ─────────────────────────────────────────────────────────────────────────── // TryFetchSnapshot behavior — needs Checkpoints::GetBestSnapshotHeight to // return >0 for the request to even start. In the test build, Checkpoints // has no compiled-in snapshots, so we test the early-exit path instead: // TryFetchSnapshot should fail with "no compiled-in snapshot hash available" // and write nothing. // ─────────────────────────────────────────────────────────────────────────── BOOST_AUTO_TEST_SUITE(snapshotnet_fetch) BOOST_AUTO_TEST_CASE(fetch_with_no_published_snapshot_returns_false) { TmpDataDir td; // The fresh test datadir has no blockchain, no checkpoint entries. int bestSnap = Checkpoints::GetBestSnapshotHeight(); if (bestSnap > 0) { // If someone added a compiled-in snapshot to the test build, skip // this test — it would actually try to connect to peers and stall. BOOST_TEST_MESSAGE("skipping: published snapshot present in test build"); return; } std::string err; bool ok = SnapshotNet::TryFetchSnapshot(td.path, /*timeoutSec=*/2, err); BOOST_CHECK(!ok); BOOST_CHECK_NE(err.find("no compiled-in"), std::string::npos); BOOST_CHECK(!fs::exists(td.path / "utxo-snapshot.bin")); } BOOST_AUTO_TEST_CASE(has_servable_snapshot_false_when_no_file) { TmpDataDir td; BOOST_CHECK(!SnapshotNet::HasServableSnapshot()); } BOOST_AUTO_TEST_CASE(ensure_local_snapshot_no_op_when_no_published_height) { TmpDataDir td; SnapshotNet::EnsureLocalSnapshot(); BOOST_CHECK(!fs::exists(td.path / "utxo-snapshot.bin")); BOOST_CHECK(!SnapshotNet::HasServableSnapshot()); } BOOST_AUTO_TEST_SUITE_END()