diff --git a/src/libs/blockchain/genesis/normalizeGenesisForHash.ts b/src/libs/blockchain/genesis/normalizeGenesisForHash.ts new file mode 100644 index 00000000..316ebfcf --- /dev/null +++ b/src/libs/blockchain/genesis/normalizeGenesisForHash.ts @@ -0,0 +1,154 @@ +/** + * Canonical genesis-data normalizer for consensus-relevant hashing. + * + * Problem + * ------- + * Two nodes that share the same consensus rules but list their own + * connection URL as `localhost` (so the entry self-resolves at boot) + * end up with different `genesisData.validators[*].connection_url` + * values. A naive `Hashing.sha256(JSON.stringify(genesisData))` then + * produces a different hash on each node, breaking `peerBootstrap` + * pairing for what is otherwise an identical consensus configuration. + * + * `connection_url` is network topology — peer routing metadata — not + * consensus state. The set of validators, their stakes, status, and + * active-block windows are consensus-significant; how each node is + * reachable on the wire is not. + * + * Solution + * -------- + * This module produces a stable, canonical byte representation of + * `genesisData` suitable for cross-node hashing: + * + * 1. `connection_url` is stripped from every `validators[]` entry. + * 2. Validators are sorted by `address` to remove insertion-order + * sensitivity (operators editing the file by hand may reorder). + * 3. The result is `JSON.stringify`-ed with **sorted object keys at + * every depth** so two semantically-equal payloads produce + * byte-identical output regardless of authoring tool. + * 4. The original `genesisData` object is left untouched (deep + * cloning happens internally). + * + * Callers + * ------- + * Every site that computes a genesis-data hash for inter-peer + * comparison MUST go through `hashGenesisData(...)`. Today that is: + * - `peerBootstrap.ts` (local baseline + post-fetch re-hash) + * - `blockHandlers.ts:getGenesisDataHash` (RPC response to peers) + * + * If a new call site is added, it must call this helper too — adding a + * stringify+sha256 inline will silently re-introduce the divergence. + */ + +import Hashing from "src/libs/crypto/hashing" + +/** + * Produce a deep clone of `genesisData` where every validator entry + * has its `connection_url` field removed and the array is sorted by + * `address`. Returns a new object; input is not mutated. + * + * If `genesisData.validators` is missing or not an array, the field + * is omitted from the canonical form (treated as empty). + */ +export function canonicalGenesisForHashing( + genesisData: unknown, +): Record { + if ( + genesisData === null || + typeof genesisData !== "object" || + Array.isArray(genesisData) + ) { + // Defensive: a non-object input cannot represent valid genesis + // data. Coerce to an empty object so the downstream hash is + // deterministic rather than throwing here (callers detect the + // mismatch by hash, not by exception). + return {} + } + + const src = genesisData as Record + const out: Record = {} + + for (const key of Object.keys(src)) { + if (key === "validators") continue + // Shallow copy; nested objects/arrays inside genesis are + // already content-addressed (balances, forks, properties, + // mutables) — their authoring order is fixed by the genesis + // file and stable across all peers using the same file. We + // do NOT need to recursively sort their keys; we only sort + // the OUTER stringify pass below. + out[key] = src[key] + } + + const rawValidators = src.validators + if (Array.isArray(rawValidators)) { + const stripped = rawValidators.map(v => { + if (v === null || typeof v !== "object") return v + const entry = v as Record + const copy: Record = {} + for (const k of Object.keys(entry)) { + if (k === "connection_url") continue + copy[k] = entry[k] + } + return copy + }) + + stripped.sort((a, b) => { + const aAddr = + a && typeof a === "object" && "address" in a + ? String((a as Record).address ?? "") + : "" + const bAddr = + b && typeof b === "object" && "address" in b + ? String((b as Record).address ?? "") + : "" + if (aAddr < bAddr) return -1 + if (aAddr > bAddr) return 1 + return 0 + }) + + out.validators = stripped + } + + return out +} + +/** + * Deterministic JSON serialiser with lexicographically-sorted keys at + * every depth. Arrays are walked element-wise; primitives pass through + * `JSON.stringify` as-is. + * + * Note: this is a small in-file helper. The repo has no canonical JSON + * library and the input shape is bounded (genesis is human-edited and + * small), so a 30-line stable stringifier is cheaper than pulling in + * `fast-json-stable-stringify` for one consumer. + */ +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) + } + + if (Array.isArray(value)) { + const parts = value.map(v => stableStringify(v)) + return "[" + parts.join(",") + "]" + } + + const obj = value as Record + const keys = Object.keys(obj).sort() + const parts = keys.map(k => JSON.stringify(k) + ":" + stableStringify(obj[k])) + return "{" + parts.join(",") + "}" +} + +/** + * Hash a `genesisData` object for cross-node comparison. + * + * Canonicalises (strip `connection_url`, sort validators by address), + * stably stringifies (lex-sorted keys at every depth), and SHA-256s. + * + * Any two nodes whose genesis files differ ONLY in + * `validators[*].connection_url` (or in object-key authoring order) + * will produce the same hash here. + */ +export function hashGenesisData(genesisData: unknown): string { + const canonical = canonicalGenesisForHashing(genesisData) + return Hashing.sha256(stableStringify(canonical)) +} diff --git a/src/libs/network/handlers/blockHandlers.ts b/src/libs/network/handlers/blockHandlers.ts index 95e1323c..9c7e8c04 100644 --- a/src/libs/network/handlers/blockHandlers.ts +++ b/src/libs/network/handlers/blockHandlers.ts @@ -1,5 +1,5 @@ import Chain from "../../blockchain/chain" -import Hashing from "../../crypto/hashing" +import { hashGenesisData } from "../../blockchain/genesis/normalizeGenesisForHash" import getPreviousHashFromBlockNumber from "../routines/nodecalls/getPreviousHashFromBlockNumber" import getPreviousHashFromBlockHash from "../routines/nodecalls/getPreviousHashFromBlockHash" import getBlockHeaderByNumber from "../routines/nodecalls/getBlockHeaderByNumber" @@ -29,7 +29,7 @@ export const blockHandlers: Record = { genesisData = JSON.parse(genesisData) } - response.response = Hashing.sha256(JSON.stringify(genesisData)) + response.response = hashGenesisData(genesisData) } catch (error) { log.error( "[manageNodeCall] Failed to get genesis data hash: " + diff --git a/src/libs/peer/routines/peerBootstrap.ts b/src/libs/peer/routines/peerBootstrap.ts index 9f88560d..771a829e 100644 --- a/src/libs/peer/routines/peerBootstrap.ts +++ b/src/libs/peer/routines/peerBootstrap.ts @@ -15,11 +15,11 @@ import axios from "axios" import Peer from "../Peer" import log from "src/utilities/logger" import PeerManager from "../PeerManager" -import Hashing from "@/libs/crypto/hashing" import getPeerIdentity from "./getPeerIdentity" import { sleep } from "@kynesyslabs/demosdk/utils" import { RPCRequest } from "@kynesyslabs/demosdk/types" import { getSharedState } from "@/utilities/sharedState" +import { hashGenesisData } from "@/libs/blockchain/genesis/normalizeGenesisForHash" let ourGenesisDataHash = "" const genesisFile = "data/genesis.json" @@ -79,10 +79,11 @@ async function ensureGenesisDataMatch(verifiedPeer: Peer) { if (res.status === 200) { // INFO: Save the new genesis data to the file fs.writeFileSync(genesisFile, JSON.stringify(res.data, null, 2)) - const ourNewGenesisDataHash = Hashing.sha256( - JSON.stringify( - JSON.parse(fs.readFileSync(genesisFile, "utf8")), - ), + // Re-hash through the canonical normalizer so connection_url + // differences between this node and the source peer do not + // produce a spurious mismatch (see normalizeGenesisForHash.ts). + const ourNewGenesisDataHash = hashGenesisData( + JSON.parse(fs.readFileSync(genesisFile, "utf8")), ) // INFO: Update discovered genesis hashes and current genesis hash @@ -215,7 +216,7 @@ export default async function peerBootstrap( // INFO: Get our genesis data hash const genesisFile = "data/genesis.json" const genesisData = JSON.parse(fs.readFileSync(genesisFile, "utf8")) - ourGenesisDataHash = Hashing.sha256(JSON.stringify(genesisData)) + ourGenesisDataHash = hashGenesisData(genesisData) // Validity check for (const peer of localList) { diff --git a/testing/genesis/normalizeGenesisForHash.test.ts b/testing/genesis/normalizeGenesisForHash.test.ts new file mode 100644 index 00000000..33b7140e --- /dev/null +++ b/testing/genesis/normalizeGenesisForHash.test.ts @@ -0,0 +1,243 @@ +/** + * Unit tests for src/libs/blockchain/genesis/normalizeGenesisForHash.ts + * + * Coverage: + * - connection_url stripped from each validator entry + * - validators sorted by address regardless of input order + * - stableStringify emits lex-sorted keys at every depth + * - two genesis-data objects differing only in connection_url and + * insertion order produce the same hash + * - empty / missing / non-array validators is tolerated + * - non-object input (null, primitive, array) is coerced safely + * - mutation safety: input object is not modified + */ + +import { describe, it, expect } from "bun:test" +import { + canonicalGenesisForHashing, + stableStringify, + hashGenesisData, +} from "src/libs/blockchain/genesis/normalizeGenesisForHash" + +const VALIDATORS_A = [ + { + address: "0xb0000000000000000000000000000000000000000000000000000000000000bb", + status: "2", + connection_url: "http://localhost:53552", + staked_amount: "1000000000000000000", + first_seen: 0, + valid_at: 0, + }, + { + address: "0xa0000000000000000000000000000000000000000000000000000000000000aa", + status: "2", + connection_url: "http://dev.node3.demos.sh:53550", + staked_amount: "1000000000000000000", + first_seen: 0, + valid_at: 0, + }, +] + +// Same validators as VALIDATORS_A but: +// - reversed order +// - different connection_url values (mimic the "other node's view") +const VALIDATORS_B = [ + { + address: "0xa0000000000000000000000000000000000000000000000000000000000000aa", + status: "2", + connection_url: "http://localhost:53550", // <- differs from A + staked_amount: "1000000000000000000", + first_seen: 0, + valid_at: 0, + }, + { + address: "0xb0000000000000000000000000000000000000000000000000000000000000bb", + status: "2", + connection_url: "http://dev.node2.demos.sh:53552", // <- differs from A + staked_amount: "1000000000000000000", + first_seen: 0, + valid_at: 0, + }, +] + +const GENESIS_BASE = { + properties: { id: 1, name: "DEMOS", currency: "DEM" }, + mutables: { minBlocksForValidationOnlineStatus: 4 }, + forks: { + osDenomination: { activationHeight: null }, + gasFeeSeparation: { + activationHeight: null, + treasuryAddress: "0x" + "f7".repeat(32), + }, + }, + balances: [], + timestamp: "1692734616", + status: "confirmed", +} + +describe("canonicalGenesisForHashing", () => { + it("strips connection_url from every validator entry", () => { + const canonical = canonicalGenesisForHashing({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + + const vs = canonical.validators as Array> + expect(vs).toHaveLength(2) + for (const v of vs) { + expect("connection_url" in v).toBe(false) + } + }) + + it("sorts validators by address ascending", () => { + const canonical = canonicalGenesisForHashing({ + ...GENESIS_BASE, + validators: VALIDATORS_A, // input has bb first, then aa + }) + + const vs = canonical.validators as Array> + expect(vs[0].address).toBe( + "0xa0000000000000000000000000000000000000000000000000000000000000aa", + ) + expect(vs[1].address).toBe( + "0xb0000000000000000000000000000000000000000000000000000000000000bb", + ) + }) + + it("does not mutate the input object", () => { + const input = { + ...GENESIS_BASE, + validators: VALIDATORS_A.map(v => ({ ...v })), + } + const snapshotBefore = JSON.parse(JSON.stringify(input)) + canonicalGenesisForHashing(input) + expect(input).toEqual(snapshotBefore) + // connection_url still on the originals + expect(input.validators[0].connection_url).toBe( + "http://localhost:53552", + ) + }) + + it("tolerates missing validators field (treated as empty)", () => { + const canonical = canonicalGenesisForHashing(GENESIS_BASE) + expect(canonical.validators).toBeUndefined() + }) + + it("tolerates non-array validators (skipped)", () => { + const canonical = canonicalGenesisForHashing({ + ...GENESIS_BASE, + validators: "not an array" as unknown, + }) + expect(canonical.validators).toBeUndefined() + }) + + it("coerces null/primitive/array input to empty object", () => { + expect(canonicalGenesisForHashing(null)).toEqual({}) + expect(canonicalGenesisForHashing(42)).toEqual({}) + expect(canonicalGenesisForHashing("foo")).toEqual({}) + expect(canonicalGenesisForHashing([])).toEqual({}) + }) + + it("preserves non-validators top-level fields verbatim", () => { + const canonical = canonicalGenesisForHashing({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + expect(canonical.properties).toEqual(GENESIS_BASE.properties) + expect(canonical.mutables).toEqual(GENESIS_BASE.mutables) + expect(canonical.forks).toEqual(GENESIS_BASE.forks) + expect(canonical.balances).toEqual(GENESIS_BASE.balances) + expect(canonical.timestamp).toEqual(GENESIS_BASE.timestamp) + expect(canonical.status).toEqual(GENESIS_BASE.status) + }) +}) + +describe("stableStringify", () => { + it("emits object keys in lexicographic order", () => { + const out = stableStringify({ b: 2, a: 1, c: { y: 2, x: 1 } }) + expect(out).toBe('{"a":1,"b":2,"c":{"x":1,"y":2}}') + }) + + it("walks arrays element-wise", () => { + const out = stableStringify([{ b: 2, a: 1 }, { d: 4, c: 3 }]) + expect(out).toBe('[{"a":1,"b":2},{"c":3,"d":4}]') + }) + + it("handles primitives and null", () => { + expect(stableStringify(null)).toBe("null") + expect(stableStringify(42)).toBe("42") + expect(stableStringify("x")).toBe('"x"') + expect(stableStringify(true)).toBe("true") + expect(stableStringify(false)).toBe("false") + }) + + it("produces same output for differently-authored equivalent objects", () => { + const a = stableStringify({ x: 1, y: { b: 2, a: 1 } }) + const b = stableStringify({ y: { a: 1, b: 2 }, x: 1 }) + expect(a).toBe(b) + }) +}) + +describe("hashGenesisData", () => { + it("produces the same hash for two genesis objects differing only in connection_url + validator order", () => { + const hashA = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + const hashB = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_B, + }) + expect(hashA).toBe(hashB) + }) + + it("produces a different hash when a consensus-significant field changes", () => { + const baseline = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + + // bumping staked_amount on a validator + const tampered = hashGenesisData({ + ...GENESIS_BASE, + validators: [ + { ...VALIDATORS_A[0], staked_amount: "999" }, + VALIDATORS_A[1], + ], + }) + expect(tampered).not.toBe(baseline) + }) + + it("produces a different hash when an unrelated top-level field changes", () => { + const baseline = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + const tampered = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + properties: { ...GENESIS_BASE.properties, currency: "OS" }, + }) + expect(tampered).not.toBe(baseline) + }) + + it("is deterministic across repeated calls", () => { + const h1 = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + const h2 = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + expect(h1).toBe(h2) + }) + + it("emits a hex-encoded sha256 (64 lowercase hex chars)", () => { + const h = hashGenesisData({ + ...GENESIS_BASE, + validators: VALIDATORS_A, + }) + expect(h).toMatch(/^[0-9a-f]{64}$/) + }) +})