diff --git a/README.md b/README.md index 803d685..7edc78d 100644 --- a/README.md +++ b/README.md @@ -435,10 +435,11 @@ assembly ("memory-safe") { ``` If we had a list of pointers, such as a `Foo[]` then this would be modelled as -a simple fold/reduce style accumulator where each item is hashed as above -individually then hashed into the accumulator. I.e. Hash `foos_[0]` to hash A, -then hash `foos_[1]` to hash B, then write both to scratch and hash to produce C, -then hash `foos_[2]` to hash D, and hash C and D to produce E, etc. +a simple fold/reduce-style accumulator, seeded with the nil hash (see below), +where each item is hashed as above individually then hashed into the +accumulator. I.e. Start from the nil hash N, hash `foos_[0]` to hash A, then +write N and A to scratch and hash to produce B, then hash `foos_[1]` to hash C, +and hash B and C to produce D, etc. This process of iterating and accumulating a hash incrementally seems to be about 40% cheaper in gas terms than ABI encoding then hashing, based on some simple @@ -452,6 +453,9 @@ array of pointers, we start with the hash of nil bytes, i.e. `keccak256(0, 0)`. If the array is 0 length then the hash will be the nil hash, regardless of the type behind the pointers. +The seed is also what separates a one-item array from its item: `[x]` hashes +to `hash(nil + hash(x))` rather than `hash(x)`. + #### Security of composition Assume that we're comfortable with concepts like blockchains and merkle trees, diff --git a/test/HashPatternFold.t.sol b/test/HashPatternFold.t.sol new file mode 100644 index 0000000..23c43c8 --- /dev/null +++ b/test/HashPatternFold.t.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibHashNoAlloc, HASH_NIL} from "../src/LibHashNoAlloc.sol"; + +/// The struct README.md "Handling pointers" hashes, and folds a list of. +struct Foo { + uint256 a; + address b; + uint256[] c; + bytes d; +} + +/// The `Foo[]` fold README.md "Handling pointers" and "Nil hash prefix" +/// describe: an accumulator seeded with the nil hash, into which each item's +/// hash is combined by writing the pair to scratch space and hashing it. The +/// oracle for every assertion is built from `keccak256`, `abi.encode` and +/// `abi.encodePacked` only; `LibHashNoAlloc.combineHashes` stands in for +/// "write both to scratch and hash" on the side under test. +contract HashPatternFoldTest is Test { + /// The README's hash of one `Foo`, steps A to E: A is the first two + /// words, B the word list `c`, C combines A and B, D the bytes `d`, E + /// combines C and D. + function hashFoo(Foo memory foo) internal pure returns (bytes32) { + bytes32 hashA = keccak256(abi.encode(foo.a, foo.b)); + bytes32 hashB = keccak256(abi.encodePacked(foo.c)); + bytes32 hashC = keccak256(abi.encodePacked(hashA, hashB)); + bytes32 hashD = keccak256(foo.d); + return keccak256(abi.encodePacked(hashC, hashD)); + } + + /// The README fold: start from the nil hash, then for each item write + /// the accumulator and the item's hash to scratch and hash the pair. + function foldPattern(Foo[] memory foos) internal pure returns (bytes32 acc) { + acc = HASH_NIL; + for (uint256 i = 0; i < foos.length; i++) { + acc = LibHashNoAlloc.combineHashes(acc, hashFoo(foos[i])); + } + } + + /// The same fold with builtins only: the pair in scratch is the packed + /// concatenation of the accumulator then the item's hash. + function foldOracle(Foo[] memory foos) internal pure returns (bytes32 expected) { + expected = HASH_NIL; + for (uint256 i = 0; i < foos.length; i++) { + expected = keccak256(abi.encodePacked(expected, hashFoo(foos[i]))); + } + } + + /// The first `n` items of `pool` as a `Foo[]`. + function take(Foo[4] memory pool, uint256 n) internal pure returns (Foo[] memory foos) { + foos = new Foo[](n); + for (uint256 i = 0; i < n; i++) { + foos[i] = pool[i]; + } + } + + /// The README's step-by-step letters over `foos_[0]` and `foos_[1]`: N is + /// the nil hash, A the hash of `foos_[0]`, B the hash of N then A, C the + /// hash of `foos_[1]`, D the hash of B then C. B is the fold of the first + /// item alone and D the fold of both. + function testFoldLetters(Foo memory foo0, Foo memory foo1) public pure { + Foo[] memory foos = new Foo[](2); + foos[0] = foo0; + foos[1] = foo1; + + bytes32 n = HASH_NIL; + bytes32 a = hashFoo(foos[0]); + bytes32 b = LibHashNoAlloc.combineHashes(n, a); + bytes32 c = hashFoo(foos[1]); + bytes32 d = LibHashNoAlloc.combineHashes(b, c); + + Foo[] memory first = new Foo[](1); + first[0] = foo0; + assertEq(b, foldOracle(first)); + assertEq(b, foldPattern(first)); + assertEq(d, foldOracle(foos)); + assertEq(d, foldPattern(foos)); + } + + /// Every length from 0 to 4: the scratch-space fold equals the builtin + /// fold. + function testFoldMatchesBuiltins(Foo[4] memory pool) public pure { + for (uint256 n = 0; n <= 4; n++) { + Foo[] memory foos = take(pool, n); + assertEq(foldPattern(foos), foldOracle(foos)); + } + } + + /// README "Nil hash prefix": an empty `Foo[]` folds to the nil hash, the + /// hash of no bytes. + function testFoldEmptyIsNilHash() public pure { + Foo[] memory foos = new Foo[](0); + assertEq(foldPattern(foos), keccak256("")); + assertEq(foldPattern(foos), HASH_NIL); + } + + /// README "Nil hash prefix": `[x]` folds to `hash(nil + hash(x))`, which + /// is not `hash(x)`. + function testFoldSingletonIsNotItem(Foo memory x) public pure { + Foo[] memory foos = new Foo[](1); + foos[0] = x; + bytes32 hashX = hashFoo(x); + bytes32 folded = foldPattern(foos); + assertEq(folded, keccak256(abi.encodePacked(HASH_NIL, hashX))); + assertTrue(folded != hashX); + } +}