From e779dde9912e9d02d3c48ffabe1aa00693d4981d Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 3 Sep 2026 09:45:26 +0000 Subject: [PATCH 1/4] README: describe the Foo[] fold as nil-hash seeded in every passage L434-438 walked through the fold unseeded (A = h(foos_[0]), B = h(foos_[1]), C = h(A + B), ...) while "Nil hash prefix" seeds it with keccak256(0, 0). The unseeded form hashes [x] to h(x), which the composition argument cannot tolerate. The step-by-step now starts from the nil hash, and the prefix section states that the seed is what separates [x] from x. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FvfmeRQKubdbFW1GL3kmib --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d44ea48..2e63f18 100644 --- a/README.md +++ b/README.md @@ -432,10 +432,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 @@ -449,6 +450,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, From 8617e00b171c79afd272036ae81a5d47a3b32fd1 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 3 Sep 2026 12:51:16 +0000 Subject: [PATCH 2/4] test: pin the README Foo[] fold as nil-hash seeded test/ReadmeFold.t.sol checks the fold README.md "Handling pointers" and "Nil hash prefix" describe against a builtin-only oracle: the step-by-step letters N, A, B, C, D; every length 0 to 4 against the plain-Solidity fold; the empty array folding to the nil hash; and [x] folding to hash(nil + hash(x)), which is not hash(x). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FvfmeRQKubdbFW1GL3kmib --- test/ReadmeFold.t.sol | 110 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 test/ReadmeFold.t.sol diff --git a/test/ReadmeFold.t.sol b/test/ReadmeFold.t.sol new file mode 100644 index 0000000..6b9c1f1 --- /dev/null +++ b/test/ReadmeFold.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 ReadmeFoldTest 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 foldReadme(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 testReadmeFoldLetters(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, foldReadme(first)); + assertEq(d, foldOracle(foos)); + assertEq(d, foldReadme(foos)); + } + + /// Every length from 0 to 4: the scratch-space fold equals the builtin + /// fold. + function testReadmeFoldMatchesBuiltins(Foo[4] memory pool) public pure { + for (uint256 n = 0; n <= 4; n++) { + Foo[] memory foos = take(pool, n); + assertEq(foldReadme(foos), foldOracle(foos)); + } + } + + /// README "Nil hash prefix": an empty `Foo[]` folds to the nil hash, the + /// hash of no bytes. + function testReadmeFoldEmptyIsNilHash() public pure { + Foo[] memory foos = new Foo[](0); + assertEq(foldReadme(foos), keccak256("")); + assertEq(foldReadme(foos), HASH_NIL); + } + + /// README "Nil hash prefix": `[x]` folds to `hash(nil + hash(x))`, which + /// is not `hash(x)`. + function testReadmeFoldSingletonIsNotItem(Foo memory x) public pure { + Foo[] memory foos = new Foo[](1); + foos[0] = x; + bytes32 hashX = hashFoo(x); + bytes32 folded = foldReadme(foos); + assertEq(folded, keccak256(abi.encodePacked(HASH_NIL, hashX))); + assertTrue(folded != hashX); + } +} From fc1a9073416bda57b6ef4527812bea55b373dd21 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 3 Sep 2026 13:11:07 +0000 Subject: [PATCH 3/4] test: name the fold tests by what they pin HashPatternFold.t.sol pins the nil-seeded fold over a list of pointers. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FvfmeRQKubdbFW1GL3kmib --- ...ReadmeFold.t.sol => HashPatternFold.t.sol} | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) rename test/{ReadmeFold.t.sol => HashPatternFold.t.sol} (85%) diff --git a/test/ReadmeFold.t.sol b/test/HashPatternFold.t.sol similarity index 85% rename from test/ReadmeFold.t.sol rename to test/HashPatternFold.t.sol index 6b9c1f1..23c43c8 100644 --- a/test/ReadmeFold.t.sol +++ b/test/HashPatternFold.t.sol @@ -19,7 +19,7 @@ struct Foo { /// 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 ReadmeFoldTest is 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. @@ -33,7 +33,7 @@ contract ReadmeFoldTest is Test { /// 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 foldReadme(Foo[] memory foos) internal pure returns (bytes32 acc) { + 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])); @@ -61,7 +61,7 @@ contract ReadmeFoldTest is Test { /// 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 testReadmeFoldLetters(Foo memory foo0, Foo memory foo1) public pure { + function testFoldLetters(Foo memory foo0, Foo memory foo1) public pure { Foo[] memory foos = new Foo[](2); foos[0] = foo0; foos[1] = foo1; @@ -75,35 +75,35 @@ contract ReadmeFoldTest is Test { Foo[] memory first = new Foo[](1); first[0] = foo0; assertEq(b, foldOracle(first)); - assertEq(b, foldReadme(first)); + assertEq(b, foldPattern(first)); assertEq(d, foldOracle(foos)); - assertEq(d, foldReadme(foos)); + assertEq(d, foldPattern(foos)); } /// Every length from 0 to 4: the scratch-space fold equals the builtin /// fold. - function testReadmeFoldMatchesBuiltins(Foo[4] memory pool) public pure { + function testFoldMatchesBuiltins(Foo[4] memory pool) public pure { for (uint256 n = 0; n <= 4; n++) { Foo[] memory foos = take(pool, n); - assertEq(foldReadme(foos), foldOracle(foos)); + assertEq(foldPattern(foos), foldOracle(foos)); } } /// README "Nil hash prefix": an empty `Foo[]` folds to the nil hash, the /// hash of no bytes. - function testReadmeFoldEmptyIsNilHash() public pure { + function testFoldEmptyIsNilHash() public pure { Foo[] memory foos = new Foo[](0); - assertEq(foldReadme(foos), keccak256("")); - assertEq(foldReadme(foos), HASH_NIL); + 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 testReadmeFoldSingletonIsNotItem(Foo memory x) public pure { + function testFoldSingletonIsNotItem(Foo memory x) public pure { Foo[] memory foos = new Foo[](1); foos[0] = x; bytes32 hashX = hashFoo(x); - bytes32 folded = foldReadme(foos); + bytes32 folded = foldPattern(foos); assertEq(folded, keccak256(abi.encodePacked(HASH_NIL, hashX))); assertTrue(folded != hashX); } From e7011009922dc1a9060570db8a8dedd8deebd28a Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 3 Sep 2026 15:30:15 +0000 Subject: [PATCH 4/4] README: hyphenate compound modifiers in the fold passage Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FvfmeRQKubdbFW1GL3kmib --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90033f2..7edc78d 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,7 @@ 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, seeded with the nil hash (see below), +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, @@ -453,7 +453,7 @@ 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 +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