Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions test/lib/LibHashSlow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
12 changes: 12 additions & 0 deletions test/src/lib/AbiEncodeGas.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,33 @@ 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);
uint256 gasUsed = gasBefore - gasleft();
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);
assertGt(gasUsed, 100);
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);
Expand Down
5 changes: 5 additions & 0 deletions test/src/lib/HashPattern.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
}

Expand Down
6 changes: 6 additions & 0 deletions test/src/lib/HashPatternFold.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions test/src/lib/LibHashNoAlloc.crossType.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)))
Expand All @@ -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") {
Expand Down
5 changes: 5 additions & 0 deletions test/src/lib/LibHashNoAlloc.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions test/src/lib/MemoryLayout.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
Loading