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;