From 1281a9096bff5d1dd04e569c5f34befd6b9a96e3 Mon Sep 17 00:00:00 2001 From: baku-ccron Date: Wed, 16 Sep 2026 01:07:26 +0000 Subject: [PATCH] docs: put back the swept content the merges kept Five comment-sweep commits reached main and cut prose that was not a restatement. 845e591 would have put two of them back but #102 merged without it. Restored: - `test/src/lib/AbiEncodeGas.t.sol`: the suite docstring naming the title-block cost bullet this file exists to measure and why the gasleft() window provably contains the encoding, the two docstrings giving the ABI length arithmetic term by term, and the one naming the helper's unnamed tuple returns. 6e24ffd cut these alongside the `src` title block claims; only the `src` half came back. - `LibHashSlow`'s title block, which is the only place "Slow" is defined as allocation rather than speed and the only statement that `hashBytesSlow` is a value oracle with no saving to measure, plus the `hashBytesSlow` docstring that says why. - `LibHashNoAlloc.t.sol`'s suite docstring. Rewritten to the suite as it now stands: the empty-input identities it used to list moved to the cross-type suite with ccc3720. - `testBytesTrueLength`: the `hex"01"` / `hex"0100"` collision the test is about. It was the only test in its file left undocumented. - The `keccak256(0, 0x40)` step comment in the README Yul, the last of five steps and the only one uncommented. - `testDeeplyNestedStructIsOnePointerWordPerLevel`, in the corrected "two outer levels" form 7566d57 cut rather than the "each level" form it replaced. - `testNewFooArrayAllocatesListThenElements`, `testFoldPrefixIsStepwiseCombine` (the README's N-A-B-C-D letters over the locals), `testFoldSingletonIsNotItem` (the "Nil hash prefix" citation its sibling kept), and the four pointer-only struct comments in the cross-type suite. Left cut as genuine restatements: the sign-extension comment under the lint directive the docstring above it already states, `take`'s one-line paraphrase of its body, `testLeafNodeCollision`'s docstring, which `hashBytes`'s NatSpec already carries, the per-function `abi.encodePacked` notes in `LibHashSlow` that the restored title block covers, and the per-test one-liners 9e711a8 cut from `LibHashNoAlloc.t.sol`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- test/lib/LibHashSlow.sol | 10 ++++++++++ test/src/lib/AbiEncodeGas.t.sol | 12 ++++++++++++ test/src/lib/HashPattern.t.sol | 5 +++++ test/src/lib/HashPatternFold.t.sol | 6 ++++++ test/src/lib/LibHashNoAlloc.crossType.t.sol | 11 +++++++++++ test/src/lib/LibHashNoAlloc.t.sol | 5 +++++ test/src/lib/MemoryLayout.t.sol | 6 ++++++ 7 files changed, 55 insertions(+) diff --git a/test/lib/LibHashSlow.sol b/test/lib/LibHashSlow.sol index 621db1d..b8b6fe0 100644 --- a/test/lib/LibHashSlow.sol +++ b/test/lib/LibHashSlow.sol @@ -8,7 +8,17 @@ bytes32 constant HASH_ABC = 0x4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec /// @dev `keccak256` of the 64 bytes that are the word `1` then the word `2`. bytes32 constant HASH_WORDS_ONE_TWO = 0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0; +/// Builtin-only reference for every `LibHashNoAlloc` function, so equality +/// against it pins the value each one computes without reusing its assembly. +/// "Slow" is about allocation: the `hashWordsSlow` and `combineHashesSlow` +/// bodies copy their data into a fresh `abi.encodePacked` buffer before +/// hashing it, which is the cost the `gasleft()` deltas in +/// `LibHashNoAlloc.t.sol` measure against. `hashBytesSlow` does not, so it is a +/// value oracle only. library LibHashSlow { + /// The builtin `keccak256` over the bytes, which allocates nothing. + /// `LibHashNoAlloc.hashBytes` compiles to the same hash of the same range, + /// so this pins its value and there is no saving to measure against it. function hashBytesSlow(bytes memory data) internal pure returns (bytes32) { return keccak256(data); } diff --git a/test/src/lib/AbiEncodeGas.t.sol b/test/src/lib/AbiEncodeGas.t.sol index ad1efe4..d500f51 100644 --- a/test/src/lib/AbiEncodeGas.t.sol +++ b/test/src/lib/AbiEncodeGas.t.sol @@ -5,7 +5,15 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; import {Foo} from "../../lib/LibFooOracle.sol"; +/// The cost bullet in LibHashNoAlloc's title block says `abi.encode` of a +/// struct with one or two dynamic typed fields costs several hundred gas when +/// those fields are empty and over 1k once they hold a handful of words. Both +/// ends are measured here on the README's `Foo`, by gasleft() delta around the +/// encode alone. Each encoded length is asserted against the length the ABI +/// spec gives for that value, so the measured window provably contains the +/// encoding. contract AbiEncodeGasTest is Test { + /// Gas spent on `abi.encode(foo)` alone, and the length it produced. function encodeGas(Foo memory foo) internal view returns (uint256, uint256) { uint256 gasBefore = gasleft(); bytes memory encoded = abi.encode(foo); @@ -13,6 +21,8 @@ contract AbiEncodeGasTest is Test { return (gasUsed, encoded.length); } + /// `Foo` with both dynamic fields empty: one word of outer offset, 4 head + /// words, then a length word each for `c` and `d` with no tail. function testEncodeFooCostsHundredsOfGasWhenEmpty() public view { (uint256 gasUsed, uint256 length) = encodeGas(Foo(1, address(2), new uint256[](0), "")); assertEq(length, 0x20 + 4 * 0x20 + 0x20 + 0x20); @@ -20,6 +30,8 @@ contract AbiEncodeGasTest is Test { assertLt(gasUsed, 1000); } + /// `Foo` with 8 words in `c` and 64 bytes in `d`: the empty encoding plus + /// 8 words of `c` and 2 words of `d`. function testEncodeFooCostsOverOneThousandGasAtEightWords() public view { (uint256 gasUsed, uint256 length) = encodeGas(Foo(1, address(2), new uint256[](8), new bytes(64))); assertEq(length, 0x20 + 4 * 0x20 + 0x20 + 8 * 0x20 + 0x20 + 2 * 0x20); diff --git a/test/src/lib/HashPattern.t.sol b/test/src/lib/HashPattern.t.sol index 7635caa..e52e91a 100644 --- a/test/src/lib/HashPattern.t.sol +++ b/test/src/lib/HashPattern.t.sol @@ -124,6 +124,10 @@ contract HashPatternTest is Test { assertEq(hashBytesExample(bytes(baz)), keccak256(bytes(baz))); } + /// "We MUST respect the true length": `hex"01"` and `hex"0100"` occupy the + /// same single data word, `0x01` followed by 31 zero bytes (1 and 2 bytes, + /// both zero-padded to 0x20), so a hash over the allocated word collides; + /// the example hashes only the `length` bytes and does tell them apart. function testBytesTrueLength() public pure { bytes memory one = hex"01"; bytes memory two = hex"0100"; @@ -174,6 +178,7 @@ contract HashPatternTest is Test { // Store D in scratch mstore(0x20, keccak256(add(deref, 0x20), mload(deref))) + // Hash C and D, already in scratch, to produce the final hash E hash := keccak256(0, 0x40) } diff --git a/test/src/lib/HashPatternFold.t.sol b/test/src/lib/HashPatternFold.t.sol index a311c95..c328a9e 100644 --- a/test/src/lib/HashPatternFold.t.sol +++ b/test/src/lib/HashPatternFold.t.sol @@ -41,6 +41,10 @@ contract HashPatternFoldTest is Test { return foos; } + /// The README's step-by-step letters over `foos[0]` and `foos[1]`: + /// `nilHash` is N, `hashFoo0` is A, `foldOne` is B (N then A), `hashFoo1` + /// is C and `foldTwo` is D (B then C). `foldOne` is the fold of the first + /// item alone and `foldTwo` the fold of both. function testFoldPrefixIsStepwiseCombine(Foo memory foo0, Foo memory foo1) public pure { Foo[] memory foos = new Foo[](2); foos[0] = foo0; @@ -77,6 +81,8 @@ contract HashPatternFoldTest is Test { assertEq(foldPattern(foos), HASH_NIL); } + /// README "Nil hash prefix": a one-item list folds to `hash(nil + hash(item))`, + /// which is not `hash(item)`. function testFoldSingletonIsNotItem(Foo memory item) public pure { Foo[] memory foos = new Foo[](1); foos[0] = item; diff --git a/test/src/lib/LibHashNoAlloc.crossType.t.sol b/test/src/lib/LibHashNoAlloc.crossType.t.sol index b05fd80..8ecf600 100644 --- a/test/src/lib/LibHashNoAlloc.crossType.t.sol +++ b/test/src/lib/LibHashNoAlloc.crossType.t.sol @@ -6,6 +6,8 @@ import {Test} from "forge-std-1.16.2/src/Test.sol"; import {LibHashNoAlloc, HASH_NIL} from "../../../src/lib/LibHashNoAlloc.sol"; import {LibHashSlow, HASH_WORDS_ONE_TWO} from "../../lib/LibHashSlow.sol"; +/// A struct whose every field is a pointer, so there is no data before the +/// first one. struct TwoBytes { bytes d1; bytes d2; @@ -83,10 +85,16 @@ contract LibHashNoAllocCrossTypeTest is Test { assertEq(LibHashNoAlloc.hashWords(new uint256[](0)), HASH_NIL); } + /// README "Handling pointers" walks a struct whose fields are all pointers + /// from the hash of the zero bytes preceding the first pointer, which is + /// the same nil seed the fold of a list starts from, so the walk and the + /// fold build the same tree. `struct { bytes d1; bytes d2; }` hashes as the + /// `bytes[]` `[d1, d2]` for every value. function testPointerOnlyStructEqualsFoldOfList(bytes memory d1, bytes memory d2) public pure { TwoBytes memory s = TwoBytes(d1, d2); bytes32 walked; assembly ("memory-safe") { + // Hash of all data up to the first pointer, of which there is none. mstore(0, keccak256(s, 0)) let deref := mload(s) mstore(0x20, keccak256(add(deref, 0x20), mload(deref))) @@ -111,6 +119,9 @@ contract LibHashNoAllocCrossTypeTest is Test { assertEq(folded, expected); } + /// The items region of a `T[]` is laid out exactly as an `n`-field struct of + /// `T` pointer fields, so walking that region the way README "Handling + /// pointers" walks a struct is the fold of the list, at every length. function testPointerOnlyStructEqualsFoldOfListAnyLength(bytes[] memory fields) public pure { bytes32 walked; assembly ("memory-safe") { diff --git a/test/src/lib/LibHashNoAlloc.t.sol b/test/src/lib/LibHashNoAlloc.t.sol index c400668..79800e3 100644 --- a/test/src/lib/LibHashNoAlloc.t.sol +++ b/test/src/lib/LibHashNoAlloc.t.sol @@ -13,6 +13,11 @@ uint256 constant KECCAK256_BASE_GAS = 30; /// @dev Gas KECCAK256 charges per 32 byte word of input. uint256 constant KECCAK256_WORD_GAS = 6; +/// Each library function against the `LibHashSlow` builtin oracle, the gas +/// floor the KECCAK256 schedule gives for the bytes each one hashes, that no +/// function moves the free memory pointer at `0x40` or the zero slot at `0x60`, +/// and that hashing words where they sit costs less than packing them into a +/// fresh allocation first. contract LibHashNoAllocTest is Test { /// Gas a KECCAK256 over `byteLength` bytes costs, excluding any memory /// expansion, which no function under test pays. diff --git a/test/src/lib/MemoryLayout.t.sol b/test/src/lib/MemoryLayout.t.sol index 2588cfc..8463e9d 100644 --- a/test/src/lib/MemoryLayout.t.sol +++ b/test/src/lib/MemoryLayout.t.sol @@ -224,6 +224,9 @@ contract MemoryLayoutTest is Test { assertEq(LibMemorySnapshot.wordAt(ptr, 0x20), innerPtr); } + /// Nesting depth does not change the rule: an `Outermost` holding an + /// `Outer` holding a `Foo` is 2 words at each of the two outer levels, and + /// the pointer word at each level is the pointer to the next struct down. function testDeeplyNestedStructIsOnePointerWordPerLevel(uint256 x, uint256 y) public pure { Foo memory inner = Foo(1, ADDR, new uint256[](0), ""); uint256 fmp0 = LibMemorySnapshot.freeMemoryPointer(); @@ -258,6 +261,9 @@ contract MemoryLayoutTest is Test { assertEq(midW1, x); } + /// `new Foo[](n)` allocates the 0x20 + n * 0x20 word list first and then + /// one 0x80 `Foo` per element in element order, each default-initialised + /// (value members 0, dynamic members pointing at the zero slot). function testNewFooArrayAllocatesListThenElements(uint8 length) public pure { uint256 n = length; uint256 fmpBefore;