From 4e97c9d5a7740762d6bec0bbee8224576054665d Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 13:26:12 -0400 Subject: [PATCH] Initial import of verae-tree-node from zapier monorepo --- NATS.md | 13 ++++++ README.md | 7 ++++ SUMMARY.md | 11 ++++++ package.json | 8 ++++ src/merkle.js | 96 +++++++++++++++++++++++++++++++++++++++++++++ test/merkle.test.js | 34 ++++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 NATS.md create mode 100644 README.md create mode 100644 SUMMARY.md create mode 100644 package.json create mode 100644 src/merkle.js create mode 100644 test/merkle.test.js diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..b531cef --- /dev/null +++ b/NATS.md @@ -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.` | 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cca1ec6 --- /dev/null +++ b/README.md @@ -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` diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..e31f461 --- /dev/null +++ b/SUMMARY.md @@ -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.` 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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b9654d --- /dev/null +++ b/package.json @@ -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" } +} diff --git a/src/merkle.js b/src/merkle.js new file mode 100644 index 0000000..84a96ea --- /dev/null +++ b/src/merkle.js @@ -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; +} diff --git a/test/merkle.test.js b/test/merkle.test.js new file mode 100644 index 0000000..42f793f --- /dev/null +++ b/test/merkle.test.js @@ -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); + }); +});