Initial import of verae-tree-node from zapier monorepo
This commit is contained in:
commit
4e97c9d5a7
6 changed files with 169 additions and 0 deletions
13
NATS.md
Normal file
13
NATS.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# NATS — verae-tree-node
|
||||
|
||||
Tree nodes are WORM archives that store **Merkle leaf proofs**. They listen on the same archive subjects so a leaf that was never itemized on the main chain can still be found.
|
||||
|
||||
Zapier never connects here. Middleware aggregator broadcasts; a tree node that does not hold the leaf stays silent.
|
||||
|
||||
| Direction | Address | From / to | Body |
|
||||
|-----------|---------|-----------|------|
|
||||
| IN | `verae.archive.put` | batch splitter / merkle builder | `{ sha256, tenantId, kind: "tree", record: { merkleRoot, proof, leafIndex, chainSealJobId }, traceId }` |
|
||||
| IN | `verae.archive.query` | aggregator (broadcast, **no queue group**) | `{ correlationId, sha256, tenantId, kinds: ["tree", …], traceId }` |
|
||||
| OUT | `verae.archive.reply.<correlationId>` | aggregator | `{ archiveId, sha256, records[], traceId }` **only if bloom.mightHave(sha256)** |
|
||||
|
||||
`record.merkleRoot` is the hash sealed on the main Verae chain. `proof` lets a verifier recompute the root from this leaf.
|
||||
7
README.md
Normal file
7
README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# verae-tree-node
|
||||
|
||||
External tree-node role: Bloom-filtered WORM store for Merkle **leaf** receipts.
|
||||
|
||||
When Zapier submits a **batch**, middleware builds a Merkle tree of item SHA-256s, seals **only the root** on `api.veraetime.net`, and puts each leaf proof on a sharded tree node. A later lookup of a leaf that is not on the main chain fans out `verae.archive.query` with `kinds: ["tree"]`. Nodes that do not hold the leaf send nothing.
|
||||
|
||||
Clone: `ssh://git@git.georgelambert.org:2223/marchon/verae-tree-node.git`
|
||||
11
SUMMARY.md
Normal file
11
SUMMARY.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# verae-tree-node
|
||||
|
||||
**Job:** Hold inclusion proofs for SHA-256 leaves that were registered only as part of a bulk Merkle summary. The main Verae chain itemizes the **root**, not each leaf.
|
||||
|
||||
**Expects messages from:** middleware batch path (`verae.archive.put` kind `tree`); aggregator (`verae.archive.query`).
|
||||
|
||||
**Sends messages to:** `verae.archive.reply.<correlationId>` on bloom hit.
|
||||
|
||||
**Does not talk to Zapier.** Same bloom-silence rule as `verae-archive-worm`.
|
||||
|
||||
**Test:** `npm test` — three leaves, root sealed, each leaf verifies; missing leaf has no proof.
|
||||
8
package.json
Normal file
8
package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "verae-tree-node",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Merkle leaf proofs for hashes only sealed as a bulk summary root on the main Verae chain",
|
||||
"scripts": { "test": "node --test test/*.test.js" },
|
||||
"engines": { "node": ">=20" }
|
||||
}
|
||||
96
src/merkle.js
Normal file
96
src/merkle.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Merkle tree over SHA-256 leaf hex digests.
|
||||
* Only the root is sealed on the main Verae chain; leaves live on tree nodes.
|
||||
* @module merkle
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* @param {string|Buffer|Uint8Array} data
|
||||
* @returns {string} lowercase hex
|
||||
*/
|
||||
export function sha256Hex(data) {
|
||||
const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent of two 32-byte hex children (left || right).
|
||||
* Odd last child is duplicated (Bitcoin-style).
|
||||
* @param {string} left
|
||||
* @param {string} right
|
||||
*/
|
||||
export function parentHash(left, right) {
|
||||
return createHash('sha256')
|
||||
.update(Buffer.from(left, 'hex'))
|
||||
.update(Buffer.from(right, 'hex'))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} leaves hex SHA-256 digests
|
||||
* @returns {{ root: string, layers: string[][], leaves: string[] }}
|
||||
*/
|
||||
export function buildMerkle(leaves) {
|
||||
if (!leaves.length) {
|
||||
throw new Error('at least one leaf is required');
|
||||
}
|
||||
const layer0 = leaves.map((h) => String(h).toLowerCase());
|
||||
const layers = [layer0];
|
||||
let layer = layer0;
|
||||
while (layer.length > 1) {
|
||||
const next = [];
|
||||
for (let i = 0; i < layer.length; i += 2) {
|
||||
const left = layer[i];
|
||||
const right = layer[i + 1] ?? layer[i];
|
||||
next.push(parentHash(left, right));
|
||||
}
|
||||
layer = next;
|
||||
layers.push(layer);
|
||||
}
|
||||
return { root: layer[0], layers, leaves: layer0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusion proof for leaf index.
|
||||
* @param {string[][]} layers
|
||||
* @param {number} index
|
||||
* @returns {Array<{ sibling: string, position: 'left'|'right' }>}
|
||||
*/
|
||||
export function merkleProof(layers, index) {
|
||||
const proof = [];
|
||||
let i = index;
|
||||
for (let level = 0; level < layers.length - 1; level += 1) {
|
||||
const layer = layers[level];
|
||||
const isRight = i % 2 === 1;
|
||||
const sib = isRight ? i - 1 : i + 1;
|
||||
const sibling = layer[sib] ?? layer[i];
|
||||
proof.push({ sibling, position: isRight ? 'left' : 'right' });
|
||||
i = Math.floor(i / 2);
|
||||
}
|
||||
return proof;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} leaf
|
||||
* @param {Array<{ sibling: string, position: 'left'|'right' }>} proof
|
||||
* @param {string} root
|
||||
*/
|
||||
export function verifyProof(leaf, proof, root) {
|
||||
let h = String(leaf).toLowerCase();
|
||||
for (const step of proof) {
|
||||
h = step.position === 'left' ? parentHash(step.sibling, h) : parentHash(h, step.sibling);
|
||||
}
|
||||
return h === String(root).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shard a leaf onto one of N tree nodes (others stay silent on bloom miss).
|
||||
* @param {string} sha256
|
||||
* @param {number} n
|
||||
*/
|
||||
export function shardIndex(sha256, n) {
|
||||
const b = Buffer.from(String(sha256).slice(0, 8), 'hex');
|
||||
return n <= 0 ? 0 : b.readUInt32BE(0) % n;
|
||||
}
|
||||
34
test/merkle.test.js
Normal file
34
test/merkle.test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
sha256Hex,
|
||||
buildMerkle,
|
||||
merkleProof,
|
||||
verifyProof,
|
||||
shardIndex,
|
||||
} from '../src/merkle.js';
|
||||
|
||||
describe('merkle', () => {
|
||||
it('one leaf is its own root', () => {
|
||||
const leaf = sha256Hex('only');
|
||||
const t = buildMerkle([leaf]);
|
||||
assert.equal(t.root, leaf);
|
||||
assert.equal(verifyProof(leaf, merkleProof(t.layers, 0), t.root), true);
|
||||
});
|
||||
|
||||
it('three leaves: each proof verifies, unknown leaf does not', () => {
|
||||
const leaves = ['a', 'b', 'c'].map((s) => sha256Hex(s));
|
||||
const t = buildMerkle(leaves);
|
||||
for (let i = 0; i < leaves.length; i += 1) {
|
||||
const proof = merkleProof(t.layers, i);
|
||||
assert.equal(verifyProof(leaves[i], proof, t.root), true);
|
||||
}
|
||||
assert.equal(verifyProof(sha256Hex('nope'), merkleProof(t.layers, 0), t.root), false);
|
||||
});
|
||||
|
||||
it('shards stably into three nodes', () => {
|
||||
const h = sha256Hex('x');
|
||||
assert.equal(shardIndex(h, 3), shardIndex(h, 3));
|
||||
assert.ok(shardIndex(h, 3) >= 0 && shardIndex(h, 3) < 3);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue