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
18 changes: 12 additions & 6 deletions src/cascade/commitment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export async function hashNode(left: Uint8Array, right: Uint8Array): Promise<Uin
/**
* Build a Merkle tree from leaf hashes.
* Returns all levels: tree[0] = leaves, tree[last] = [root].
* If a level has an odd number of nodes, the last node is duplicated and the
* stored level includes the duplicate, exactly like the on-chain
* lumera/x/action/v1/merkle.BuildTree the SuperNodes verify against.
*/
export async function buildTree(leafHashes: Uint8Array[]): Promise<Uint8Array[][]> {
if (leafHashes.length === 0) {
Expand All @@ -94,14 +97,17 @@ export async function buildTree(leafHashes: Uint8Array[]): Promise<Uint8Array[][
let current = leafHashes;

while (current.length > 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;
Expand Down
84 changes: 76 additions & 8 deletions tests/cascade/commitment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> = {
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);
Expand Down
Loading