diff --git a/off-chain/lib/index.js b/off-chain/lib/index.js index acfd16b..6fe42f9 100644 --- a/off-chain/lib/index.js +++ b/off-chain/lib/index.js @@ -133,6 +133,47 @@ export class Tree { return loop('', values.map(value => ({ key: intoKey(value), value }))); } + + /** Set the prefix on a tree, and computes its corresponding root hash. Both steps + * are done in lock-step because the node's hash crucially includes its prefix. + * + * @param {string} prefix A sequence of nibbles. + * @param {Buffer} hash A hash digest of the value. + * @return {Tree} A reference to the underlying tree with its prefix modified. + * @private + */ + setPrefix(prefix, hash) { + // NOTE: + // We append the remaining prefix to the value. However, to make this + // step more efficient on-chain, we append it as a raw bytestring instead of + // an array of nibbles. + // + // If the prefix's length is odd however, we do still add one nibble, and + // then the rest. + const isOdd = prefix.length % 2 > 0; + + const head = isOdd + ? nibbles(prefix.slice(0, 1)) + : Buffer.from([]); + + const tail = Buffer.from(isOdd + ? prefix.slice(1) + : prefix, + 'hex' + ); + + assert( + hash.length === DIGEST_LENGTH, + `hash must be a ${DIGEST_LENGTH}-byte digest in 'setPrefix' but it is` + ); + + this.hash = prefix.length === 0 ? hash : digest(Buffer.concat([head, tail, hash])); + this.prefix = prefix; + + return this; + } + + /** Conveniently access a child in the tree at the given path. A path is * sequence of nibbles, as an hex-encoded string. * @@ -234,7 +275,7 @@ export class Leaf extends Tree { this.size = 1; this.value = value; - this.setPrefix(prefix); + this.setPrefix(prefix, digest(value)); } @@ -246,25 +287,16 @@ export class Leaf extends Tree { * @private */ [inspect.custom](depth, options, _inspect) { + const hash = options.stylize( + `#${this.hash.toString('hex').slice(0, DIGEST_SUMMARY_LENGTH)}`, + 'special' + ); + const prefix = withEllipsis(this.prefix, PREFIX_CUTOFF, options); const value = options.stylize(this.value, 'string'); - return `${prefix} → ${value}`; - } - - - /** Set the prefix on a tree, and computes its corresponding root hash. Both steps - * are done in lock-step because the node's hash crucially includes its prefix. - * - * @param {string} prefix A sequence of nibbles. - * @return {Tree} A reference to the underlying tree with its prefix modified. - * @private - */ - setPrefix(prefix) { - this.hash = digest(this.value); - this.prefix = prefix; - return this; + return `${prefix} ${hash} → ${value}`; } @@ -346,24 +378,12 @@ export class BranchNode extends Tree { this.size = children.reduce((size, child) => size + (child?.size || 0), 0); this.children = children; - this.setPrefix(prefix); - } - - /** Sets the prefix and hash of the node, ensuring that the former is used to - * compute the latter. - * - * @param {string} prefix A sequence of nibbles. - * @return {BranchNode} - * @private - */ - setPrefix(prefix) { - const children = this.children.flatMap(node => node === undefined ? [] : [node.hash || node]); - - this.hash = digest(Buffer.concat([nibbles(prefix), ...children])); - - this.prefix = prefix; - - return this; + this.setPrefix( + prefix, + digest(Buffer.concat(this.children.flatMap( + node => node === undefined ? [] : [node.hash] + ))), + ); } /** @@ -413,7 +433,7 @@ export class BranchNode extends Tree { '│' ); if (depth === 2 && this.prefix.length > 0) { - first = `\n ${this.prefix.toString('hex')}${first}` + first = `\n ${this.prefix}${first}` } // ----- In-between @@ -493,10 +513,33 @@ export class Proof { * @private */ rewind(target, skip, children) { - this.steps.unshift({ - skip, - neighbors: children.flatMap(x => x?.hash.equals(target.hash) ? [] : [x?.hash]), - }); + children = children.filter(x => !x?.hash.equals(target.hash)); + + if (children.filter(x => x !== undefined).length === 1) { + this.steps.unshift({ + skip, + neighbors: children.map(x => { + if (x === undefined) { + return x; + } + + return { + prefix: x.prefix, + value: digest(x instanceof Leaf + ? x.value + : Buffer.concat(x.children.flatMap(node => { + return node === undefined ? [] : [node.hash] + })) + ), + }; + }), + }); + } else { + this.steps.unshift({ + skip, + neighbors: children.map(x => x?.hash), + }); + } return this; } @@ -531,7 +574,7 @@ export class Proof { const suffix = path.slice(cursor); const zero = withElement - ? Leaf.prototype.setPrefix.call({ value: this.value }, suffix).hash + ? Tree.prototype.setPrefix.call({}, suffix, digest(this.value)).hash : undefined; return this.steps.reduceRight((currentHash, step, ix) => { @@ -548,15 +591,43 @@ export class Proof { // // Now, when the last branch node had only two elements (i.e. neighbors.length === 1), // we must treat this node as a leaf instead and take its hash directly. - if (currentHash === undefined && neighbors.length === 1) { - return neighbors[0]; + if (neighbors.length === 1) { + const neighbor = neighbors[0]; + + assert(neighbor.prefix !== undefined); + + const neighborPos = step.neighbors.indexOf(neighbor); + + const neighborNibble = neighborPos + (nibble <= neighborPos ? 1 : 0); + + if (currentHash === undefined) { + return Tree.prototype.setPrefix.call({}, + `${neighborNibble.toString('16')}${neighbor.prefix}`, + neighbor.value + ).hash; + } + + const neighborHash = Tree.prototype.setPrefix + .call({}, neighbor.prefix, neighbor.value) + .hash; + + return Tree.prototype.setPrefix.call({}, + prefix, + nibble <= neighborPos + ? digest(Buffer.concat([currentHash, neighborHash])) + : digest(Buffer.concat([neighborHash, currentHash])) + ).hash; } const children = step.neighbors.slice(0, nibble) .concat(currentHash === undefined ? [] : currentHash) - .concat(step.neighbors.slice(nibble)); + .concat(step.neighbors.slice(nibble)) + .filter(x => x !== undefined); - return BranchNode.prototype.setPrefix.call({ children }, prefix).hash; + return Tree.prototype.setPrefix.call({}, + prefix, + digest(Buffer.concat(children)) + ).hash; }, zero); } @@ -568,6 +639,7 @@ export class Proof { */ serialise() { // TODO: rewrite to CBOR. + throw new Error('TODO: rework proof format with new structure.'); return JSON.stringify(this.steps.map(step => { let nextDefined = 0; const lookup = step.neighbors.map((x, ix) => { diff --git a/off-chain/tests/index.test.js b/off-chain/tests/index.test.js index 954c901..efda259 100644 --- a/off-chain/tests/index.test.js +++ b/off-chain/tests/index.test.js @@ -34,66 +34,6 @@ const FRUITS_LIST = [ 'watermelon', ]; -// ------------------------------------------------------------------------ Leaf - -test('Leaf: a new Leaf is a Leaf', t => { - const tree = new Leaf('00000000', 'value'); - t.true(tree instanceof Leaf); -}); - -test('Leaf: inspect with human-readable key', t => { - const tree = new Leaf('00000000', 'value'); - t.is(inspect(tree), '00000000 → value'); -}); - - -// ------------------------------------------------------------------------ BranchNode - -test('BranchNode: is not empty', t => { - const tree = new BranchNode('', { - 1: new Leaf('01', '14'), - 2: new Leaf('02', '42'), - }); - t.false(tree.isEmpty()); -}); - -test('BranchNode: inspect a simple one-level tree', t => { - const tree = new BranchNode('', { - 1: new Leaf('01', '14'), - 2: new Leaf('02', '42'), - }); - t.is(inspect(tree), unindent` - ╔═══════════════════════════════════════════════════════════════════╗ - ║ #2781af6c16c2da267d64825c0348ade4ab73927d602fd1c9459f317e63841fb8 ║ - ╚═══════════════════════════════════════════════════════════════════╝ - ┌─ 101 → 14 - └─ 202 → 42 - `); -}); - -test('BranchNode: inspect complex trees', t => { - const tree = new BranchNode('0000', { - 1: new BranchNode('01', { - 0: new Leaf('00', '14'), - 'f': new Leaf('ff', '1337'), - }), - 2: new Leaf('02', '42'), - 9: new Leaf('09', '999'), - }); - t.is(inspect(tree), unindent` - ╔═══════════════════════════════════════════════════════════════════╗ - ║ #0e5435b5bde2452c7357a6fb2f1bce9b3c43a648c04a2f0498c07c1146bc1f6b ║ - ╚═══════════════════════════════════════════════════════════════════╝ - 0000 - ├─ 101 #a8048fe39838 - │ ├─ 000 → 14 - │ └─ fff → 1337 - ├─ 202 → 42 - └─ 909 → 999 - `); -}); - - // ------------------------------------------------------------------------- Tree test('Tree: a new Tree is always empty', t => { @@ -158,16 +98,15 @@ test('Tree: cannot create proof for leaf-tree for non-existing elements', t => { test('Tree: can create proof for simple trees', t => { const values = [ 'foo', 'bar' ].map(Buffer.from); - /* - ╔═══════════════════════════════════════════════════════════════════╗ - ║ #6d9a495a9352061d331fd6039e97297aec9591b291b61f4d46d28c6a9b302577 ║ - ╚═══════════════════════════════════════════════════════════════════╝ - ┌─ 84418..[55 digits]..e71d → bar - └─ b8fe9..[55 digits]..49fd → foo - */ const tree = Tree.fromList(values); - t.is(tree.size, 2); + t.is(inspect(tree), unindent` + ╔═══════════════════════════════════════════════════════════════════╗ + ║ #5e9870f1f1d3464cc775d9e81ba36ab815e72642a1b9150bfd476c9d1e508f01 ║ + ╚═══════════════════════════════════════════════════════════════════╝ + ┌─ 84418..[55 digits]..e71d #80980aea9d27 → bar + └─ b8fe9..[55 digits]..49fd #9cadc73321de → foo + `); const proofs = { foo: tree.prove('foo'), @@ -189,50 +128,50 @@ test('Tree: can create proof for complex trees', t => { const values = FRUITS_LIST.map(Buffer.from); /* - ╔═══════════════════════════════════════════════════════════════════╗ - ║ #18c56d4fb717703237c19903ca4c58e94e66dd49ccaad1b20665d8a0fec7e081 ║ - ╚═══════════════════════════════════════════════════════════════════╝ - ┌─ 09ad7..[55 digits]..19d9 → apple - ├─ 12b59..[55 digits]..9386 → durian - ├─ 2 #98cd7528f92a - │ ├─ 36e5e..[54 digits]..97f6 → cranberry - │ └─ 9d848..[54 digits]..e47b → grapefruit - ├─ 3 #6f923a110c88 - │ ├─ 70a5c..[54 digits]..bd36 → orange - │ └─ 9cd47..[54 digits]..9e65 → blueberry - ├─ 4c4ce..[55 digits]..8230 → watermelon - ├─ 5 #4b5ee2d337f1 - │ ├─ 4d444..[54 digits]..9f76 → banana - │ └─ acb40..[54 digits]..7a07 → grape - ├─ 7 #dbfd99280a60 - │ ├─ 0abba..[54 digits]..81c3 → lime - │ └─ 548bb..[54 digits]..f3a9 → lemon - ├─ 8 #6fcc4950bc99 - │ ├─ c0dfd..[54 digits]..ae0e → pineapple - │ ├─ dafdb..[54 digits]..00ca → blackberry - │ └─ f #e1231b489fe6 - │ ├─ 04b7b..[53 digits]..adc6 → cherry - │ └─ 7033a..[53 digits]..6a64 → fig - ├─ a #d9447ec94c48 - │ ├─ 3d7d4..[54 digits]..f988 → kumquat - │ └─ a2fb0..[54 digits]..4587 → coconut - ├─ b8 #e9f8df4e5ca0 - │ ├─ 884aa..[53 digits]..ad80 → raspberry - │ └─ aad88..[53 digits]..fcf0 → pear - ├─ c49a4..[55 digits]..3565 → peach - ├─ d #4a8837353ceb - │ ├─ 4a3ea..[54 digits]..7bd9 → passionfruit - │ └─ 8bf23..[54 digits]..eca7 → strawberry - ├─ e #3b6f7a6c29e1 - │ ├─ 5a02a..[54 digits]..37b6 → papaya - │ └─ 6 #ba86887bf06b - │ ├─ 4d91f..[53 digits]..0cfe → guava - │ └─ 7e298..[53 digits]..d9d1 → mango - └─ f #177cab65ae5d - ├─ 3 #9a4b2591979d - │ ├─ 2e49b..[53 digits]..9728 → pomegranate - │ └─ 7d2a6..[53 digits]..d578 → kiwi - └─ a3c8d..[54 digits]..52ff → plum + ╔═══════════════════════════════════════════════════════════════════╗ + ║ #1010037b1fe7c96b833527ec7968f3b00dd48fc3d8f1d778b54c356e21174e03 ║ + ╚═══════════════════════════════════════════════════════════════════╝ + ┌─ 09ad7..[55 digits]..19d9 #9261850f9a56 → apple + ├─ 12b59..[55 digits]..9386 #dfea1e596dff → durian + ├─ 2 #8a4f388e0661 + │ ├─ 36e5e..[54 digits]..97f6 #b7c699f0347f → cranberry + │ └─ 9d848..[54 digits]..e47b #2498f0adb130 → grapefruit + ├─ 3 #6ea585310006 + │ ├─ 70a5c..[54 digits]..bd36 #40f9ef4577ae → orange + │ └─ 9cd47..[54 digits]..9e65 #654672508f82 → blueberry + ├─ 4c4ce..[55 digits]..8230 #2711855c9d13 → watermelon + ├─ 5 #c880dc733511 + │ ├─ 4d444..[54 digits]..9f76 #574d2d71ad4b → banana + │ └─ acb40..[54 digits]..7a07 #dcaae7b42e37 → grape + ├─ 7 #46f0eec92a88 + │ ├─ 0abba..[54 digits]..81c3 #d31a6a9db0aa → lime + │ └─ 548bb..[54 digits]..f3a9 #6741d5499cc2 → lemon + ├─ 8 #0260cafbd265 + │ ├─ c0dfd..[54 digits]..ae0e #d589f73c5992 → pineapple + │ ├─ dafdb..[54 digits]..00ca #98e27cac47e2 → blackberry + │ └─ f #52883d12a023 + │ ├─ 04b7b..[53 digits]..adc6 #4d4c4a45c0a0 → cherry + │ └─ 7033a..[53 digits]..6a64 #32ac5559d8f6 → fig + ├─ a #6813cef05801 + │ ├─ 3d7d4..[54 digits]..f988 #51ce43f8c8c2 → kumquat + │ └─ a2fb0..[54 digits]..4587 #1c793dfde026 → coconut + ├─ b8 #ff065ee6497e + │ ├─ 884aa..[53 digits]..ad80 #59f0a53949c3 → raspberry + │ └─ aad88..[53 digits]..fcf0 #0a83da727401 → pear + ├─ c49a4..[55 digits]..3565 #8d935cd32d15 → peach + ├─ d #39de29c529fe + │ ├─ 4a3ea..[54 digits]..7bd9 #74d9e59c7370 → passionfruit + │ └─ 8bf23..[54 digits]..eca7 #228660a26f9c → strawberry + ├─ e #f0aa8cc42fee + │ ├─ 5a02a..[54 digits]..37b6 #93144299de64 → papaya + │ └─ 6 #e170d1dfc702 + │ ├─ 4d91f..[53 digits]..0cfe #f5e9e5122ccc → guava + │ └─ 7e298..[53 digits]..d9d1 #e35735c3689b → mango + └─ f #8feafd139ff2 + ├─ 3 #7aee7f871e07 + │ ├─ 2e49b..[53 digits]..9728 #1d20453bad70 → pomegranate + │ └─ 7d2a6..[53 digits]..d578 #73d471559ed4 → kiwi + └─ a3c8d..[54 digits]..52ff #4e7480807124 → plum */ const tree = Tree.fromList(values); @@ -249,7 +188,7 @@ test('Tree: can create proof for complex trees', t => { [ { skip: 0, - neighbors: Array.from('12345678abcdef').map(x => tree.childAt(x)), + neighbors: Array.from('123456789abcdef').map(x => tree.childAt(x)?.hash), } ] ); @@ -262,13 +201,18 @@ test('Tree: can create proof for complex trees', t => { [ { skip: 0, - neighbors: Array.from('01234568abcdef').map(x => tree.childAt(x)), + neighbors: Array.from('012345689abcdef').map(x => tree.childAt(x)?.hash), }, { skip: 0, - neighbors: [ - tree.childAt('75'), - ] + neighbors: helpers.intoVector({ + // NOTE: It's at position '5', but because it's only the neighbors + // and 'lime' is at position '0', it shifts towards 0. + 4: { + prefix: tree.childAt('75').prefix, + value: digest(tree.childAt('75').value) + }, + }, 15) }, ] ); @@ -281,13 +225,18 @@ test('Tree: can create proof for complex trees', t => { [ { skip: 0, - neighbors: Array.from('012345678acdef').map(x => tree.childAt(x)), + neighbors: Array.from('0123456789acdef').map(x => tree.childAt(x)?.hash), }, { skip: 1, - neighbors: [ - tree.childAt('ba'), - ] + neighbors: helpers.intoVector({ + // NOTE: Same reasoning as above, should be 'a', but it is shifted by one + // because we only store *neighbors* and the actual element is before ('8'). + 9: { + prefix: tree.childAt('ba').prefix, + value: digest(tree.childAt('ba').value), + } + }, 15), } ] ); @@ -300,19 +249,25 @@ test('Tree: can create proof for complex trees', t => { [ { skip: 0, - neighbors: Array.from('012345678abcde').map(x => tree.childAt(x)), + neighbors: Array.from('0123456789abcde').map(x => tree.childAt(x)?.hash), }, { skip: 0, - neighbors: [ - tree.childAt('fa'), - ] + neighbors: helpers.intoVector({ + 9: { + prefix: tree.childAt('fa').prefix, + value: digest(tree.childAt('fa').value), + }, + }, 15), }, { skip: 0, - neighbors: [ - tree.childAt('f32'), - ] + neighbors: helpers.intoVector({ + 2: { + prefix: tree.childAt('f32').prefix, + value: digest(tree.childAt('f32').value), + }, + }, 15) } ] ); @@ -322,38 +277,38 @@ test('Tree: can create proof for complex trees', t => { }); test('Tree: checking for insertion', t => { - /* - ╔═══════════════════════════════════════════════════════════════════╗ - ║ #28264c9e300e2ec8433c07d839042b7681fe85df6ab6a83b39d5f4dbf51b3ae7 ║ - ╚═══════════════════════════════════════════════════════════════════╝ - ┌─ 09ad7..[55 digits]..19d9 → apple - └─ fa3c8..[55 digits]..52ff → plum - */ const st0 = Tree.fromList([ 'apple', 'plum' ]); + t.is(inspect(st0), unindent` + ╔═══════════════════════════════════════════════════════════════════╗ + ║ #d15c97eac41f304f939e820c821a7bdc72562d5613179b66e9b51950487ec790 ║ + ╚═══════════════════════════════════════════════════════════════════╝ + ┌─ 09ad7..[55 digits]..19d9 #9261850f9a56 → apple + └─ fa3c8..[55 digits]..52ff #603120a89780 → plum + `); - /* - ╔═══════════════════════════════════════════════════════════════════╗ - ║ #a694e4b261e7650bdc9f99279ea092b3be9e6e7eb5d1bb4836ca64c9784c8259 ║ - ╚═══════════════════════════════════════════════════════════════════╝ - ┌─ 09ad7..[55 digits]..19d9 → apple - └─ f #9c0875c21f33 - ├─ 37d2a..[54 digits]..d578 → kiwi - └─ a3c8d..[54 digits]..52ff → plum - */ const st1 = Tree.fromList([ 'apple', 'kiwi', 'plum' ]); + t.is(inspect(st1), unindent` + ╔═══════════════════════════════════════════════════════════════════╗ + ║ #e16e4c64efd32469d535590fdd702ee73e1f73d585e5cb370134bc8059f72b27 ║ + ╚═══════════════════════════════════════════════════════════════════╝ + ┌─ 09ad7..[55 digits]..19d9 #9261850f9a56 → apple + └─ f #095584676d31 + ├─ 37d2a..[54 digits]..d578 #5fcb97ee97a9 → kiwi + └─ a3c8d..[54 digits]..52ff #4e7480807124 → plum + `); - /* - ╔═══════════════════════════════════════════════════════════════════╗ - ║ #b3ea3161299926db3326f74edaea9f5bc4879f2ff46eea251eeb11e60010b4c5 ║ - ╚═══════════════════════════════════════════════════════════════════╝ - ┌─ 09ad7..[55 digits]..19d9 → apple - └─ f #eb2355dcead1 - ├─ 3 #843cadeff6b7 - │ ├─ 2e49b..[53 digits]..9728 → pomegranate - │ └─ 7d2a6..[53 digits]..d578 → kiwi - └─ a3c8d..[54 digits]..52ff → plum - */ const st2 = Tree.fromList([ 'apple', 'pomegranate', 'kiwi', 'plum' ]); + t.is(inspect(st2), unindent` + ╔═══════════════════════════════════════════════════════════════════╗ + ║ #94ca4d707f679d96b8fe71fa383e712f7960f63c3366047c1f60fb84ab4663db ║ + ╚═══════════════════════════════════════════════════════════════════╝ + ┌─ 09ad7..[55 digits]..19d9 #9261850f9a56 → apple + └─ f #8feafd139ff2 + ├─ 3 #7aee7f871e07 + │ ├─ 2e49b..[53 digits]..9728 #1d20453bad70 → pomegranate + │ └─ 7d2a6..[53 digits]..d578 #73d471559ed4 → kiwi + └─ a3c8d..[54 digits]..52ff #4e7480807124 → plum + `); // Insert 'kiwi' into st0 t.true(st1.prove('kiwi').verify(false).equals(st0.hash)); @@ -366,7 +321,45 @@ test('Tree: checking for insertion', t => { // Insert 'pomegranate' into st1 t.true(st2.prove('pomegranate').verify(false).equals(st1.hash)); -}) +}); + +test('tree extension from top', t => { + const st0 = Tree.fromList([ 'apple' ]); + + /* + ╔═══════════════════════════════════════════════════════════════════╗ + ║ #94ca4d707f679d96b8fe71fa383e712f7960f63c3366047c1f60fb84ab4663db ║ + ╚═══════════════════════════════════════════════════════════════════╝ + ┌─ 09ad7..[55 digits]..19d9 #9261850f9a56 → apple + └─ f #8feafd139ff2 + ├─ 3 #7aee7f871e07 + │ ├─ 2e49b..[53 digits]..9728 #1d20453bad70 → pomegranate + │ └─ 7d2a6..[53 digits]..d578 #73d471559ed4 → kiwi + └─ a3c8d..[54 digits]..52ff #4e7480807124 → plum + */ + const st1 = Tree.fromList([ 'apple', 'pomegranate', 'kiwi', 'plum' ]); + + const value = Buffer.concat(st1.children[15].children + .flatMap(x => x === undefined ? [] : [x.hash]) + ); + + const st2 = Tree.fromList([ 'apple', value ]); + + const proof = new Proof(value); + proof.steps = [{ + skip: 0, + neighbors: helpers.intoVector({ + 0: { + prefix: st0.prefix, + value: digest(st0.value), + } + }) + }]; + + t.true(proof.verify().toString('hex') === st2.hash.toString('hex')); + t.true(proof.verify().toString('hex') !== st1.hash.toString('hex')); + t.true(st1.hash.toString('hex') !== st2.hash.toString('hex')); +}); // ---------------------------------------------------------------------- Helpers @@ -438,21 +431,25 @@ function unindent(str) { return lines.map(s => s.slice(n)).join('\n').trimEnd(); } +function stringifyNeighbor(x) { + if (x == undefined || x instanceof Buffer) { + return x?.toString('hex'); + } + + return { ...x, value: x.value.toString('hex') }; +} + + function assertProof(t, root, proof, expected) { - t.is(proof.verify().toString('hex'), root.toString('hex')); proof.steps.forEach((step, k) => { t.is(step.skip, expected[k].skip); t.deepEqual( - step.neighbors.flatMap(x => { - return x === undefined - ? [] - : [x.toString('hex')] - }), - expected[k].neighbors.flatMap(x => { - return x === undefined - ? [] - : [x.hash.toString('hex')]; - }), + step.neighbors.map(stringifyNeighbor), + expected[k].neighbors.map(stringifyNeighbor), ); }); + + t.is(proof.steps.length, expected.length); + + t.is(proof.verify().toString('hex'), root.toString('hex')); }