From 9956bbd211e43884104ee77da62469ba7ede3106 Mon Sep 17 00:00:00 2001 From: kaleababayneh Date: Wed, 19 Aug 2026 11:52:17 +0300 Subject: [PATCH] fix(cascade): duplicate last merkle node on odd levels to match LEP-5 --- src/cascade/commitment.ts | 18 ++++--- tests/cascade/commitment.test.ts | 84 +++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/cascade/commitment.ts b/src/cascade/commitment.ts index 9cbcbf4..8ab4b2f 100644 --- a/src/cascade/commitment.ts +++ b/src/cascade/commitment.ts @@ -84,6 +84,9 @@ export async function hashNode(left: Uint8Array, right: Uint8Array): Promise { if (leafHashes.length === 0) { @@ -94,14 +97,17 @@ export async function buildTree(leafHashes: Uint8Array[]): Promise 1) { + // Odd number of nodes: duplicate the last node so it pairs with itself. + // Promoting it unchanged produces a different root than the chain and + // makes SuperNodes reject the upload with a merkle root mismatch. + if (current.length % 2 !== 0) { + current = [...current, current[current.length - 1]]; + levels[levels.length - 1] = current; + } + const next: Uint8Array[] = []; for (let i = 0; i < current.length; i += 2) { - if (i + 1 < current.length) { - next.push(await hashNode(current[i], current[i + 1])); - } else { - // Odd node: promote to next level - next.push(current[i]); - } + next.push(await hashNode(current[i], current[i + 1])); } levels.push(next); current = next; diff --git a/tests/cascade/commitment.test.ts b/tests/cascade/commitment.test.ts index 28daba4..169a4bc 100644 --- a/tests/cascade/commitment.test.ts +++ b/tests/cascade/commitment.test.ts @@ -103,19 +103,87 @@ describe('buildTree', () => { expect(tree[1]).toHaveLength(1); // root }); - it('builds tree with odd number of leaves', async () => { - const leaves = [ - new Uint8Array(32).fill(1), - new Uint8Array(32).fill(2), - new Uint8Array(32).fill(3), - ]; - const tree = await buildTree(leaves); - // Level 0: 3 leaves, Level 1: 2 nodes, Level 2: 1 root + it('builds tree with odd number of leaves by duplicating the last node', async () => { + const l0 = new Uint8Array(32).fill(1); + const l1 = new Uint8Array(32).fill(2); + const l2 = new Uint8Array(32).fill(3); + const tree = await buildTree([l0, l1, l2]); + // Level 0 is padded with a copy of the last leaf (matches the Go + // merkle.BuildTree), then 4 → 2 → 1. expect(tree).toHaveLength(3); + expect(tree[0]).toHaveLength(4); + expect(tree[0][3]).toEqual(l2); expect(tree[tree.length - 1]).toHaveLength(1); // root + + // The odd node pairs with itself; promoting it unchanged would yield + // hashNode(hashNode(l0, l1), l2) instead and diverge from the chain. + const expectedRoot = await hashNode( + await hashNode(l0, l1), + await hashNode(l2, l2), + ); + expect(tree[tree.length - 1][0]).toEqual(expectedRoot); }); }); +describe('parity with the Go chain implementation', () => { + // Gold vectors generated with the production Go code the network verifies + // against: lumera@v1.20.0-rc2 x/action/v1/merkle.BuildTree and + // supernode/v2@v2.5.2 pkg/cascadekit.BuildCommitmentFromFile. + // Inputs are deterministic pattern bytes: data[i] = i % 251. + const pattern = (n: number): Uint8Array => { + const b = new Uint8Array(n); + for (let i = 0; i < n; i++) b[i] = i % 251; + return b; + }; + const toHex = (u: Uint8Array): string => + Array.from(u).map((b) => b.toString(16).padStart(2, '0')).join(''); + + // merkle.BuildTree over n chunks of 100 pattern bytes each. + const TREE_ROOTS: Record = { + 1: '78ca6f707098d9aeaabb9d5c9627e57112d4558f8671c4c85abf84fae6728537', + 2: 'e40e95a6d278b6bfcbfc871cb3e373d66683a969845505cac34d419e6945b85f', + 3: '200a5841eddc94942787639e62dbbc4fd78a0c1547cf3212df641b2d47522291', + 4: 'be7e0ee2db07ed863c7501a2e0f0cb517fd3841826cf9582f7abf3d8f5b0d859', + 5: '657308f3aa846892cc76966185dd30ebc2fb5adc4059f406dc6328c75b49d2b3', + 6: '05ead30eeb3d114896f4dd08a56e0dd1eff3de9ff92c4c5dce75b66b037be88c', + 7: '8dbc55950c0f6b03a2f1977c9827b7017d7fd0601803cd6a23acb07407f7a635', + 8: 'b23ca96cd16dba2d18a492035a08b8e950a0327185471aac3d8e162ffbe4876b', + }; + + for (const [count, expectedRoot] of Object.entries(TREE_ROOTS)) { + it(`buildTree root matches Go for ${count} chunks`, async () => { + const n = Number(count); + const data = pattern(n * 100); + const leaves: Uint8Array[] = []; + for (let i = 0; i < n; i++) { + leaves.push(await hashLeaf(i, data.subarray(i * 100, (i + 1) * 100))); + } + const tree = await buildTree(leaves); + expect(toHex(tree[tree.length - 1][0])).toBe(expectedRoot); + }); + } + + // cascadekit.BuildCommitmentFromFile with challengeCount=8, minChunks=4. + const COMMITMENTS = [ + { size: 4, chunkSize: 1, numChunks: 4, root: 'db63e60869903c406e89ee1987ff65a1d6e05f0354140576e49c8d03ea83482b', indices: [2, 0, 1, 3] }, + { size: 300, chunkSize: 64, numChunks: 5, root: '90345c45aa032d1bbb6b8c6835ae0005734ec27d5cf596157eb4819eaa213da2', indices: [3, 0, 2, 4, 1] }, + { size: 1064, chunkSize: 256, numChunks: 5, root: '74d4c43726bb8c67e094af2a4d31789453169b179d5c4bf782e209f6bf8fe887', indices: [4, 0, 3, 1, 2] }, + { size: 2048, chunkSize: 512, numChunks: 4, root: 'aba5eb3fddcf81923b1cd341f510b08a659e23f31e215c14597b8a3a0dd5f8fe', indices: [1, 0, 2, 3] }, + ]; + + for (const gold of COMMITMENTS) { + it(`buildCommitment matches Go for a ${gold.size}-byte file`, async () => { + const result = await buildCommitment(pattern(gold.size), 8, 4); + expect(result).toBeDefined(); + const { commitment } = result!; + expect(commitment.chunkSize).toBe(gold.chunkSize); + expect(commitment.numChunks).toBe(gold.numChunks); + expect(toHex(commitment.root)).toBe(gold.root); + expect(commitment.challengeIndices).toEqual(gold.indices); + }); + } +}); + describe('deriveIndices', () => { it('produces correct number of indices', async () => { const root = new Uint8Array(32).fill(0xAB);