OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / test / test_verify_vectors.cpp

709 lines · cpp ref:
/**
 * test_verify_vectors.cpp — C++ verification against JS reference vectors
 *
 * Reads vectors.json (generated by generate_vectors.js) and verifies
 * that every C++ function produces identical output to the JS reference.
 *
 * This is the core of the verification-first methodology:
 *   - JS IS the spec
 *   - This test compares C++ output to JS output, byte by byte
 *   - When a test fails, the diff tells you exactly what's wrong
 *
 * Build:
 *   cd /storage/projects/code/godot/hyperswarm-peer/test
 *   bash build_tests.sh   (includes test_verify_vectors)
 *
 * Run:
 *   node generate_vectors.js > vectors.json
 *   ./test_verify_vectors vectors.json
 *
 * Philosophy: See memory/verification-first.md
 */

#include "compact_encoding.h"
#include "dht_messages.h"
#include "hyperdht.h"
#include "noise_ik.h"

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sodium.h>
#include <sstream>
#include <string>
#include <vector>

// ============================================================================
// Minimal JSON parser (just enough for vectors.json)
// ============================================================================
// We parse flat key-value pairs from known paths. No dependency needed.

static std::string read_file(const char *path) {
	std::ifstream f(path);
	if (!f.is_open()) {
		fprintf(stderr, "ERROR: Cannot open %s\n", path);
		exit(1);
	}
	std::stringstream ss;
	ss << f.rdbuf();
	return ss.str();
}

// Extract a JSON string value by key path like "sections.keypair.tests[0].output.public_key"
// This is intentionally simple — vectors.json has a known structure.
static std::string json_get(const std::string &json, const std::string &path) {
	// Find the key in the JSON
	std::string search = "\"" + path + "\"";

	// For nested paths, we search key by key
	size_t pos = 0;
	std::string remaining = path;

	while (!remaining.empty()) {
		size_t dot = remaining.find('.');
		std::string key;
		if (dot != std::string::npos) {
			key = remaining.substr(0, dot);
			remaining = remaining.substr(dot + 1);
		} else {
			key = remaining;
			remaining = "";
		}

		// Handle array index: key[N]
		int index = -1;
		size_t bracket = key.find('[');
		if (bracket != std::string::npos) {
			index = std::stoi(key.substr(bracket + 1));
			key = key.substr(0, bracket);
		}

		search = "\"" + key + "\"";
		size_t found = json.find(search, pos);
		if (found == std::string::npos) return "";
		pos = found + search.length();

		// Skip to value (past colon and whitespace)
		while (pos < json.size() && (json[pos] == ' ' || json[pos] == ':' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t'))
			pos++;

		// If we need an array index, skip into the array
		if (index >= 0) {
			if (json[pos] == '[') pos++;
			for (int i = 0; i < index; i++) {
				// Skip one element (brace/bracket matching)
				int depth = 0;
				bool in_string = false;
				while (pos < json.size()) {
					char ch = json[pos++];
					if (in_string) {
						if (ch == '\\') pos++;
						else if (ch == '"') in_string = false;
					} else {
						if (ch == '"') in_string = true;
						else if (ch == '{' || ch == '[') depth++;
						else if (ch == '}' || ch == ']') { if (--depth < 0) break; }
						else if (ch == ',' && depth == 0) break;
					}
				}
				while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t'))
					pos++;
			}
		}
	}

	// Now pos points to the value
	if (pos >= json.size()) return "";

	if (json[pos] == '"') {
		// String value
		size_t end = json.find('"', pos + 1);
		if (end == std::string::npos) return "";
		return json.substr(pos + 1, end - pos - 1);
	} else if (json[pos] == 't') return "true";
	else if (json[pos] == 'f') return "false";
	else if (json[pos] == 'n') return "null";
	else {
		// Number
		size_t end = pos;
		while (end < json.size() && json[end] != ',' && json[end] != '}' && json[end] != ']' && json[end] != '\n')
			end++;
		return json.substr(pos, end - pos);
	}
}

// ============================================================================
// Hex utilities
// ============================================================================

static std::vector<uint8_t> from_hex(const std::string &hex) {
	std::vector<uint8_t> bytes;
	for (size_t i = 0; i + 1 < hex.size(); i += 2) {
		uint8_t b = 0;
		char c1 = hex[i], c2 = hex[i + 1];
		b |= ((c1 >= 'a' ? c1 - 'a' + 10 : c1 - '0') << 4);
		b |= (c2 >= 'a' ? c2 - 'a' + 10 : c2 - '0');
		bytes.push_back(b);
	}
	return bytes;
}

static std::string to_hex(const uint8_t *data, size_t len) {
	static const char hex_chars[] = "0123456789abcdef";
	std::string result;
	result.reserve(len * 2);
	for (size_t i = 0; i < len; i++) {
		result += hex_chars[(data[i] >> 4) & 0x0F];
		result += hex_chars[data[i] & 0x0F];
	}
	return result;
}

// ============================================================================
// Test harness
// ============================================================================

static int tests_run = 0;
static int tests_passed = 0;
static int tests_failed = 0;

static void check_hex(const std::string &expected_hex, const uint8_t *actual, size_t actual_len, const char *name) {
	tests_run++;
	std::string actual_hex = to_hex(actual, actual_len);

	if (actual_hex == expected_hex) {
		tests_passed++;
		printf("  PASS: %s\n", name);
	} else {
		tests_failed++;
		printf("  FAIL: %s\n", name);
		printf("    expected: %s (%zu bytes)\n", expected_hex.c_str(), expected_hex.size() / 2);
		printf("    got:      %s (%zu bytes)\n", actual_hex.c_str(), actual_len);

		// Show first diff position
		size_t min_len = std::min(expected_hex.size(), actual_hex.size());
		for (size_t i = 0; i < min_len; i += 2) {
			if (expected_hex.substr(i, 2) != actual_hex.substr(i, 2)) {
				printf("    first diff at byte %zu: expected %s, got %s\n",
					   i / 2, expected_hex.substr(i, 2).c_str(), actual_hex.substr(i, 2).c_str());
				break;
			}
		}
		if (expected_hex.size() != actual_hex.size()) {
			printf("    length mismatch: expected %zu bytes, got %zu bytes\n",
				   expected_hex.size() / 2, actual_len);
		}
	}
}

static void check_equal(int expected, int actual, const char *name) {
	tests_run++;
	if (expected == actual) {
		tests_passed++;
		printf("  PASS: %s\n", name);
	} else {
		tests_failed++;
		printf("  FAIL: %s (expected %d, got %d)\n", name, expected, actual);
	}
}

static void check_bool(bool expected, bool actual, const char *name) {
	tests_run++;
	if (expected == actual) {
		tests_passed++;
		printf("  PASS: %s\n", name);
	} else {
		tests_failed++;
		printf("  FAIL: %s (expected %s, got %s)\n", name,
			   expected ? "true" : "false", actual ? "true" : "false");
	}
}

// ============================================================================
// Section tests
// ============================================================================

static void test_keypair(const std::string &json) {
	printf("\n=== Section: keypair ===\n");

	// Test keypair_from_seed_A
	std::string seed_hex = json_get(json, "sections.keypair.tests[0].input.seed");
	std::string expected_pk = json_get(json, "sections.keypair.tests[0].output.public_key");
	std::string expected_sk = json_get(json, "sections.keypair.tests[0].output.secret_key");

	auto seed = from_hex(seed_hex);
	uint8_t pk[32], sk[64];
	crypto_sign_seed_keypair(pk, sk, seed.data());

	check_hex(expected_pk, pk, 32, "keypair_from_seed_A: public_key");
	check_hex(expected_sk, sk, 64, "keypair_from_seed_A: secret_key");

	// Test keypair_from_seed_B
	seed_hex = json_get(json, "sections.keypair.tests[1].input.seed");
	expected_pk = json_get(json, "sections.keypair.tests[1].output.public_key");
	expected_sk = json_get(json, "sections.keypair.tests[1].output.secret_key");

	seed = from_hex(seed_hex);
	crypto_sign_seed_keypair(pk, sk, seed.data());

	check_hex(expected_pk, pk, 32, "keypair_from_seed_B: public_key");
	check_hex(expected_sk, sk, 64, "keypair_from_seed_B: secret_key");
}

static void test_namespace(const std::string &json) {
	printf("\n=== Section: namespace ===\n");

	// Test hyperdht_namespaces: namespace("hyperswarm/dht", [4,5,6,0,1])
	uint8_t ns_out[5 * 32];
	uint8_t ids[] = {4, 5, 6, 0, 1};
	NoiseIK::compute_namespace("hyperswarm/dht", ids, 5, ns_out);

	std::string expected;

	expected = json_get(json, "sections.namespace.tests[0].output.ns_announce");
	check_hex(expected, ns_out + 0 * 32, 32, "ns_announce (id=4)");

	expected = json_get(json, "sections.namespace.tests[0].output.ns_unannounce");
	check_hex(expected, ns_out + 1 * 32, 32, "ns_unannounce (id=5)");

	expected = json_get(json, "sections.namespace.tests[0].output.ns_mutable_put");
	check_hex(expected, ns_out + 2 * 32, 32, "ns_mutable_put (id=6)");

	expected = json_get(json, "sections.namespace.tests[0].output.ns_peer_handshake");
	check_hex(expected, ns_out + 3 * 32, 32, "ns_peer_handshake (id=0)");

	expected = json_get(json, "sections.namespace.tests[0].output.ns_peer_holepunch");
	check_hex(expected, ns_out + 4 * 32, 32, "ns_peer_holepunch (id=1)");

	// Also verify they match the NS.* constants from constants.js
	expected = json_get(json, "sections.namespace.tests[1].output.NS_ANNOUNCE");
	check_hex(expected, ns_out + 0 * 32, 32, "matches NS.ANNOUNCE constant");
}

static void test_token(const std::string &json) {
	printf("\n=== Section: token ===\n");

	std::string secret_hex = json_get(json, "sections.token.tests[0].input.secret");
	auto secret = from_hex(secret_hex);

	// Token = BLAKE2b(key=secret, data=host_string)
	const char *hosts[] = {"127.0.0.1", "192.168.1.100", "10.0.0.1"};
	for (int i = 0; i < 3; i++) {
		char path[256];
		snprintf(path, sizeof(path), "sections.token.tests[0].output[%d].token", i);
		std::string expected = json_get(json, path);

		uint8_t token[32];
		crypto_generichash(token, 32,
						   reinterpret_cast<const uint8_t *>(hosts[i]), strlen(hosts[i]),
						   secret.data(), 32);

		char name[64];
		snprintf(name, sizeof(name), "token(%s)", hosts[i]);
		check_hex(expected, token, 32, name);
	}
}

static void test_varint(const std::string &json) {
	printf("\n=== Section: varint ===\n");

	int values[] = {0, 1, 127, 252, 253, 254, 255, 256, 1000, 65535, 65536, 100000, (int)0xFFFFFFFF};
	int n_values = sizeof(values) / sizeof(values[0]);

	for (int i = 0; i < n_values; i++) {
		char path_enc[256], path_len[256];
		snprintf(path_enc, sizeof(path_enc), "sections.varint.tests[%d].output.encoded", i);
		snprintf(path_len, sizeof(path_len), "sections.varint.tests[%d].output.byte_length", i);

		std::string expected_enc = json_get(json, path_enc);
		int expected_len = std::stoi(json_get(json, path_len));

		// Use EncodeState pattern (matching cenc::uint_encode)
		EncodeState vs;
		vs.start = 0; vs.end = 0; vs.buffer = nullptr;
		uint64_t v = static_cast<uint64_t>(static_cast<uint32_t>(values[i]));
		cenc::uint_preencode(vs, v);
		uint8_t buf[16];
		vs.buffer = buf;
		vs.start = 0;
		cenc::uint_encode(vs, v);

		char name[64];
		snprintf(name, sizeof(name), "varint(%u)", static_cast<uint32_t>(v));
		check_hex(expected_enc, buf, vs.start, name);
		check_equal(expected_len, (int)vs.start, (std::string(name) + " length").c_str());
	}
}

static void test_peer_record(const std::string &json) {
	printf("\n=== Section: peer_record ===\n");

	// Test peer with no relays
	std::string pk_hex = json_get(json, "sections.peer_record.tests[0].input.public_key");
	std::string expected = json_get(json, "sections.peer_record.tests[0].output.encoded");
	int expected_len = std::stoi(json_get(json, "sections.peer_record.tests[0].output.byte_length"));

	auto pk = from_hex(pk_hex);

	dht_msg::PeerRecord peer;
	memcpy(peer.public_key, pk.data(), 32);
	peer.relay_count = 0;

	// Encode using EncodeState pattern
	EncodeState s1;
	s1.start = 0; s1.end = 0;
	dht_msg::peer_record_preencode(s1, peer);
	std::vector<uint8_t> enc1(s1.end);
	s1.buffer = enc1.data();
	s1.start = 0;
	dht_msg::peer_record_encode(s1, peer);

	check_hex(expected, enc1.data(), enc1.size(), "peer_no_relays");
	check_equal(expected_len, (int)enc1.size(), "peer_no_relays length");

	// Test peer with one relay
	expected = json_get(json, "sections.peer_record.tests[1].output.encoded");
	expected_len = std::stoi(json_get(json, "sections.peer_record.tests[1].output.byte_length"));

	dht_msg::PeerRecord peer2;
	memcpy(peer2.public_key, pk.data(), 32);
	peer2.relay_addresses[0].ip[0] = 192; peer2.relay_addresses[0].ip[1] = 168;
	peer2.relay_addresses[0].ip[2] = 1; peer2.relay_addresses[0].ip[3] = 100;
	peer2.relay_addresses[0].port = 49737;
	peer2.relay_count = 1;

	EncodeState s2;
	s2.start = 0; s2.end = 0;
	dht_msg::peer_record_preencode(s2, peer2);
	std::vector<uint8_t> enc2(s2.end);
	s2.buffer = enc2.data();
	s2.start = 0;
	dht_msg::peer_record_encode(s2, peer2);

	check_hex(expected, enc2.data(), enc2.size(), "peer_one_relay");
	check_equal(expected_len, (int)enc2.size(), "peer_one_relay length");
}

static void test_announce_signable(const std::string &json) {
	printf("\n=== Section: announce_signable (CRITICAL) ===\n");

	// This is the section that caught the 8-hour bug.
	// The JS annSignable function computes:
	//   signable = NS(32) || BLAKE2b_batch(target, node_id, token, encoded_peer, refresh)(32)
	//
	// We replicate this in C++ and verify byte-by-byte.

	std::string target_hex = json_get(json, "sections.announce_signable.tests[0].input.target");
	std::string token_hex = json_get(json, "sections.announce_signable.tests[0].input.token");
	std::string node_id_hex = json_get(json, "sections.announce_signable.tests[0].input.node_id");
	std::string pk_hex = json_get(json, "sections.announce_signable.tests[0].input.public_key");

	auto target = from_hex(target_hex);
	auto token = from_hex(token_hex);
	auto node_id = from_hex(node_id_hex);
	auto pk = from_hex(pk_hex);

	// Get the namespace constant for ANNOUNCE
	std::string ns_announce_hex = json_get(json, "sections.namespace.tests[1].output.NS_ANNOUNCE");
	auto ns_announce = from_hex(ns_announce_hex);

	// Encode the peer record (no relay addresses)
	dht_msg::PeerRecord peer;
	memcpy(peer.public_key, pk.data(), 32);
	peer.relay_count = 0;

	EncodeState sp;
	sp.start = 0; sp.end = 0;
	dht_msg::peer_record_preencode(sp, peer);
	std::vector<uint8_t> encoded_peer(sp.end);
	sp.buffer = encoded_peer.data();
	sp.start = 0;
	dht_msg::peer_record_encode(sp, peer);

	// Verify encoded peer matches JS
	std::string expected_peer_hex = json_get(json, "sections.announce_signable.tests[0].output.encoded_peer");
	check_hex(expected_peer_hex, encoded_peer.data(), encoded_peer.size(), "encoded_peer matches JS");

	// Compute signable: NS(32) || BLAKE2b_batch(target, node_id, token, encoded_peer, empty)(32)
	uint8_t signable[64];
	memcpy(signable, ns_announce.data(), 32);

	// BLAKE2b batch hash: init, update with each input, finalize
	crypto_generichash_state state;
	crypto_generichash_init(&state, nullptr, 0, 32);
	crypto_generichash_update(&state, target.data(), 32);
	crypto_generichash_update(&state, node_id.data(), 32);
	crypto_generichash_update(&state, token.data(), 32);
	crypto_generichash_update(&state, encoded_peer.data(), encoded_peer.size());
	// refresh = empty (0 bytes) — verified that empty doesn't change the hash
	crypto_generichash_final(&state, signable + 32, 32);

	std::string expected_signable = json_get(json, "sections.announce_signable.tests[0].output.signable");
	check_hex(expected_signable, signable, 64, "signable_no_refresh");

	// Test with refresh
	std::string refresh_hex = json_get(json, "sections.announce_signable.tests[1].input.refresh");
	auto refresh = from_hex(refresh_hex);

	uint8_t signable2[64];
	memcpy(signable2, ns_announce.data(), 32);

	crypto_generichash_init(&state, nullptr, 0, 32);
	crypto_generichash_update(&state, target.data(), 32);
	crypto_generichash_update(&state, node_id.data(), 32);
	crypto_generichash_update(&state, token.data(), 32);
	crypto_generichash_update(&state, encoded_peer.data(), encoded_peer.size());
	crypto_generichash_update(&state, refresh.data(), refresh.size());
	crypto_generichash_final(&state, signable2 + 32, 32);

	std::string expected_signable2 = json_get(json, "sections.announce_signable.tests[1].output.signable");
	check_hex(expected_signable2, signable2, 64, "signable_with_refresh");

	// Test unannounce (different namespace)
	std::string ns_unannounce_hex = json_get(json, "sections.namespace.tests[1].output.NS_UNANNOUNCE");
	auto ns_unannounce = from_hex(ns_unannounce_hex);

	uint8_t signable3[64];
	memcpy(signable3, ns_unannounce.data(), 32);

	crypto_generichash_init(&state, nullptr, 0, 32);
	crypto_generichash_update(&state, target.data(), 32);
	crypto_generichash_update(&state, node_id.data(), 32);
	crypto_generichash_update(&state, token.data(), 32);
	crypto_generichash_update(&state, encoded_peer.data(), encoded_peer.size());
	crypto_generichash_final(&state, signable3 + 32, 32);

	std::string expected_signable3 = json_get(json, "sections.announce_signable.tests[2].output.signable");
	check_hex(expected_signable3, signable3, 64, "signable_unannounce");

	// Test different node_id (MUST produce different signable — per-node proof)
	std::string node_id2_hex = json_get(json, "sections.announce_signable.tests[3].input.node_id");
	auto node_id2 = from_hex(node_id2_hex);

	uint8_t signable4[64];
	memcpy(signable4, ns_announce.data(), 32);

	crypto_generichash_init(&state, nullptr, 0, 32);
	crypto_generichash_update(&state, target.data(), 32);
	crypto_generichash_update(&state, node_id2.data(), 32);
	crypto_generichash_update(&state, token.data(), 32);
	crypto_generichash_update(&state, encoded_peer.data(), encoded_peer.size());
	crypto_generichash_final(&state, signable4 + 32, 32);

	std::string expected_signable4 = json_get(json, "sections.announce_signable.tests[3].output.signable");
	check_hex(expected_signable4, signable4, 64, "signable_different_node_id");

	// Verify it actually differs from test 1
	check_bool(true, memcmp(signable, signable4, 64) != 0, "different_node_id produces different signable");

	// Test sign + verify round-trip
	std::string sk_hex = json_get(json, "sections.announce_signable.tests[5].input.secret_key");
	auto sk = from_hex(sk_hex);

	uint8_t signature[64];
	std::string signable_hex = json_get(json, "sections.announce_signable.tests[5].input.signable");
	auto signable_buf = from_hex(signable_hex);

	crypto_sign_detached(signature, nullptr, signable_buf.data(), signable_buf.size(), sk.data());

	std::string expected_sig = json_get(json, "sections.announce_signable.tests[5].output.signature");
	check_hex(expected_sig, signature, 64, "sign_deterministic");

	// Verify
	auto pk_verify = from_hex(json_get(json, "sections.announce_signable.tests[5].input.public_key"));
	int verify_ok = crypto_sign_verify_detached(signature, signable_buf.data(), signable_buf.size(), pk_verify.data());
	check_bool(true, verify_ok == 0, "verify_signature");
}

static void test_noise_ik(const std::string &json) {
	printf("\n=== Section: noise_ik ===\n");

	// Test Ed25519 -> Curve25519 conversion
	std::string ed_sk_hex = json_get(json, "sections.noise_ik.tests[0].input.ed25519_secret_key");
	std::string expected_curve_sk = json_get(json, "sections.noise_ik.tests[0].output.curve25519_secret_key");

	auto ed_sk = from_hex(ed_sk_hex);
	uint8_t curve_sk[32];
	crypto_sign_ed25519_sk_to_curve25519(curve_sk, ed_sk.data());
	check_hex(expected_curve_sk, curve_sk, 32, "ed25519_to_curve25519_sk");

	// Test Ed25519 pk -> Curve25519 pk
	std::string ed_pk_hex = json_get(json, "sections.noise_ik.tests[1].input.ed25519_public_key");
	std::string expected_curve_pk = json_get(json, "sections.noise_ik.tests[1].output.curve25519_public_key");

	auto ed_pk = from_hex(ed_pk_hex);
	uint8_t curve_pk[32];
	crypto_sign_ed25519_pk_to_curve25519(curve_pk, ed_pk.data());
	check_hex(expected_curve_pk, curve_pk, 32, "ed25519_to_curve25519_pk");

	// Test DH
	std::string expected_shared = json_get(json, "sections.noise_ik.tests[2].output.shared_secret");
	uint8_t shared[32];
	crypto_scalarmult(shared, curve_sk, curve_pk);
	check_hex(expected_shared, shared, 32, "dh_shared_secret");

	// Test prologue
	std::string expected_prologue = json_get(json, "sections.noise_ik.tests[3].output.prologue");
	uint8_t ns_out2[5 * 32];
	uint8_t ids2[] = {4, 5, 6, 0, 1};
	NoiseIK::compute_namespace("hyperswarm/dht", ids2, 5, ns_out2);
	// ns_peer_handshake is at index 3 (id=0)
	check_hex(expected_prologue, ns_out2 + 3 * 32, 32, "noise_prologue = NS.PEER_HANDSHAKE");
}

static void test_find_peer_target(const std::string &json) {
	printf("\n=== Section: find_peer_target ===\n");

	std::string pk_hex = json_get(json, "sections.find_peer_target.tests[0].input.public_key");
	std::string expected_target = json_get(json, "sections.find_peer_target.tests[0].output.target");

	auto pk = from_hex(pk_hex);
	uint8_t target[32];
	crypto_generichash(target, 32, pk.data(), 32, nullptr, 0);

	check_hex(expected_target, target, 32, "find_peer_target = BLAKE2b(publicKey)");
}

static void test_immutable(const std::string &json) {
	printf("\n=== Section: immutable ===\n");

	std::string value_hex = json_get(json, "sections.immutable.tests[0].input.value");
	std::string expected_target = json_get(json, "sections.immutable.tests[0].output.target");

	auto value = from_hex(value_hex);
	uint8_t target[32];
	crypto_generichash(target, 32, value.data(), value.size(), nullptr, 0);

	check_hex(expected_target, target, 32, "immutable_target = BLAKE2b(value)");
}

static void test_blake2b_batch(const std::string &json) {
	printf("\n=== Section: blake2b_batch ===\n");

	// Single input
	std::string input1_hex = json_get(json, "sections.blake2b_batch.tests[0].input.inputs[0]");
	auto input1 = from_hex(input1_hex);

	uint8_t hash1[32];
	crypto_generichash_state state;
	crypto_generichash_init(&state, nullptr, 0, 32);
	crypto_generichash_update(&state, input1.data(), input1.size());
	crypto_generichash_final(&state, hash1, 32);

	std::string expected1 = json_get(json, "sections.blake2b_batch.tests[0].output.hash");
	check_hex(expected1, hash1, 32, "blake2b_batch_single");

	// Three inputs
	std::string input2_hex = json_get(json, "sections.blake2b_batch.tests[1].input.inputs[1]");
	std::string input3_hex = json_get(json, "sections.blake2b_batch.tests[1].input.inputs[2]");
	auto input2 = from_hex(input2_hex);
	auto input3 = from_hex(input3_hex);

	uint8_t hash2[32];
	crypto_generichash_init(&state, nullptr, 0, 32);
	crypto_generichash_update(&state, input1.data(), input1.size());
	crypto_generichash_update(&state, input2.data(), input2.size());
	crypto_generichash_update(&state, input3.data(), input3.size());
	crypto_generichash_final(&state, hash2, 32);

	std::string expected2 = json_get(json, "sections.blake2b_batch.tests[1].output.hash");
	check_hex(expected2, hash2, 32, "blake2b_batch_three");

	// Three inputs + empty (should equal three inputs without empty)
	uint8_t hash3[32];
	crypto_generichash_init(&state, nullptr, 0, 32);
	crypto_generichash_update(&state, input1.data(), input1.size());
	crypto_generichash_update(&state, input2.data(), input2.size());
	crypto_generichash_update(&state, input3.data(), input3.size());
	// Empty update
	crypto_generichash_update(&state, nullptr, 0);
	crypto_generichash_final(&state, hash3, 32);

	std::string expected3 = json_get(json, "sections.blake2b_batch.tests[2].output.hash");
	check_hex(expected3, hash3, 32, "blake2b_batch_three_plus_empty");

	// Verify empty doesn't change hash
	check_bool(true, memcmp(hash2, hash3, 32) == 0, "empty_trailing_input_does_not_change_hash");
}

static void test_ipv4(const std::string &json) {
	printf("\n=== Section: ipv4 ===\n");

	struct TestAddr {
		uint8_t ip[4];
		uint16_t port;
		const char *name;
	};

	TestAddr addrs[] = {
		{{127, 0, 0, 1}, 49737, "127.0.0.1:49737"},
		{{192, 168, 1, 100}, 12345, "192.168.1.100:12345"},
		{{10, 0, 0, 1}, 443, "10.0.0.1:443"},
		{{255, 255, 255, 255}, 65535, "255.255.255.255:65535"}};

	for (int i = 0; i < 4; i++) {
		char path[256];
		snprintf(path, sizeof(path), "sections.ipv4.tests[%d].output.encoded", i);
		std::string expected = json_get(json, path);

		uint8_t buf[6];
		buf[0] = addrs[i].ip[0];
		buf[1] = addrs[i].ip[1];
		buf[2] = addrs[i].ip[2];
		buf[3] = addrs[i].ip[3];
		buf[4] = addrs[i].port & 0xFF;
		buf[5] = (addrs[i].port >> 8) & 0xFF;

		check_hex(expected, buf, 6, addrs[i].name);
	}
}

// ============================================================================
// Main
// ============================================================================

int main(int argc, char **argv) {
	if (argc < 2) {
		fprintf(stderr, "Usage: %s vectors.json\n", argv[0]);
		fprintf(stderr, "\nGenerate vectors first:\n");
		fprintf(stderr, "  node generate_vectors.js > vectors.json\n");
		return 1;
	}

	if (sodium_init() < 0) {
		fprintf(stderr, "ERROR: sodium_init() failed\n");
		return 1;
	}

	std::string json = read_file(argv[1]);

	printf("Verification-First Test Suite\n");
	printf("Vectors: %s\n", argv[1]);
	printf("Reference: hyperdht %s, hypercore-crypto %s\n",
		   json_get(json, "reference_packages.hyperdht").c_str(),
		   json_get(json, "reference_packages.hypercore-crypto").c_str());
	printf("Generated: %s\n", json_get(json, "generated_at").c_str());

	test_keypair(json);
	test_namespace(json);
	test_token(json);
	test_varint(json);
	test_peer_record(json);
	test_announce_signable(json);
	test_noise_ik(json);
	test_find_peer_target(json);
	test_immutable(json);
	test_blake2b_batch(json);
	test_ipv4(json);

	printf("\n===========================\n");
	printf("Results: %d/%d passed", tests_passed, tests_run);
	if (tests_failed > 0) {
		printf(" (%d FAILED)", tests_failed);
	}
	printf("\n===========================\n");

	return tests_failed > 0 ? 1 : 0;
}