Pass 2 residue on a ledgered repo (all 37 library mutants killed; code unchanged since). These are claim-witness tests: each pins a sentence the docs make, passes at HEAD, and adds no mutant-discriminating coverage beyond the existing fuzz-vs-builtin oracles (verified for the first: given hashBytes == keccak256(data) and combineHashes == keccak256(a||b), inequality follows from collision resistance). File them only if the repo's "every README claim has a test" bar is wanted for these too; otherwise close.
- NatSpec L14-21/L33-36: "abc"+"def" vs "ab"+"cdef" pack identically and the composition separates every pair — no test builds two splits of one byte string. Proposed
testCompositionSeparatesPackedCollision(bytes s, uint256 i, uint256 j) + testCompositionSeparatesAbcDef() (verified 2/2; carry over the slice helper the fuzz test needs).
- README L277-279 "a
Foo is ALWAYS 4 words": testFooIsFourWords uses empty members; add the fuzzed testFooIsFourWordsWhateverItsMembersHold(uint256, address, uint256[], bytes).
- NatSpec word-boundary equalities are pinned deterministically only at 0 and 2 words / 3 bytes; the singleton word list (
hashWords([x]) == hashBytes(32 bytes of x), unlike the seeded fold) is stated nowhere: testHashBytesBoundaryLengths() over {0,1,31,32,33,64} and testSingletonWordListIsItsWord(bytes32).
- README L382-384 "a pointer ... is not even deterministic", the reason for the section: no test shows two equal-valued
Foos at different addresses hashing apart as a raw region and together under the composition (testEqualFoosHashApartByRegionAndTogetherByComposition, verified passing).
- README L317-318 names
bytes1[] as a word list for hashing; only its layout is tested: testHashBytes1ArrayAsWordList(bytes1[]) with the README Yul against keccak256(abi.encodePacked(bar_)).
Findings from the audit run
F73 [LOW] dim 2 cat — src/LibHashNoAlloc.sol:14-21, 33-36
The library's motivating claim — composition separates what abi.encodePacked collides — has no test
NatSpec opens with "abc"+"def" vs "ab"+"cdef" packing identically and the hash-of-hashes composition separating them for all pairs; no test exercises two splits of one byte string through combineHashes(hashBytes, hashBytes) and asserts inequality.
EVIDENCE: src L15-16, L33-36; grep -rn 'cdef\|"def"' test/ -> none.
FIX: Add testCompositionSeparatesPackedCollision(bytes s, uint256 i, uint256 j) (two distinct split points: packed equal, abi.encode differs, combined hashes differ) and testCompositionSeparatesAbcDef() to test/LibHashNoAllocCrossType.t.sol (text in d2:coverage:claims#4, verified passing).
F75 [INFO] dim 2 cat — test/MemoryLayout.t.sol:148-168
'a Foo is ALWAYS 4 words' is pinned only for empty and zero-initialised members
testFooIsFourWords uses an empty array and empty bytes; testFooListIsWordList measures zero-initialised Foos; no test measures a Foo with populated c and d, the case ALWAYS is about.
EVIDENCE: README L277-279; MemoryLayout L151-152 new uint256[](0); bytes memory d = "";.
FIX: Add testFooIsFourWordsWhateverItsMembersHold(uint256, address, uint256[] memory, bytes memory) asserting ptr == fmpBefore and fmpAfter - ptr == 0x80 (text in d2:coverage:claims#7).
F77 [INFO] dim 2 cat — test/LibHashNoAlloc.t.sol:19-21, 47-49
Byte-length and word-count boundaries (1, 31, 32, 33, 64 bytes; 1 word) are reached only if the fuzzer samples them
The NatSpec word-boundary equalities (L89-90, L104-107) are pinned deterministically only at length 0 and 2 words / 3 bytes; the singleton word list — where hashWords([x]) IS keccak256 of the bare word x, unlike the seeded fold — is stated nowhere.
EVIDENCE: L19-21, L47-49 fuzz only; fixed cases L24, L56, L82 (empty) and L28, L60, L86; CrossType L27 fuzz length, L41 n=2, L75-80 n=0. README L463-464, L513-515.
FIX: Add testHashBytesBoundaryLengths() over {0,1,31,32,33,64} with hashWords equality at whole-word lengths (d2:coverage:src#4) and testSingletonWordListIsItsWord(bytes32 x) through hashWords x2, hashBytes and the static word (d5:correctness:LibHashNoAllocCrossType.t.sol#1); optionally a README 'Across types' bullet for the singleton.
F78 [INFO] dim 2 cat — README.md:382-384
'A pointer ... is not even deterministic' — the reason for the pointer-handling section — has no test
No test constructs two equal-valued Foos at different addresses and shows they hash apart as a raw region and together under the composition.
EVIDENCE: README L382-384; test/HashPattern.t.sol L40 is the only pointer-word hash assertion.
FIX: Add testEqualFoosHashApartByRegionAndTogetherByComposition(uint256, address, uint256[], bytes) with a hashFooSteps oracle (text in sweep1:d2#2, verified passing).
F80 [INFO] dim 2 cat — README.md:317-318
'single byte values bytes1[]' is named as a word list for hashing but only its layout, never its hash, is tested
testHashWordList applies the README word-list Yul to uint256[] only; the claim that left-aligned bytes1 words hash as abi.encodePacked(bytes1[]) is unpinned.
EVIDENCE: README L317-318; HashPattern.t.sol L110 testHashWordList(uint256[] memory bar_); MemoryLayout L172-201 layout only.
FIX: Add testHashBytes1ArrayAsWordList(bytes1[] memory bar_) with the README Yul asserting keccak256(abi.encodePacked(bar_)) (sweep1:d2#5).
Fix texts
fix text from sweep1:d2#2
FIX:
Add to test/HashPattern.t.sol:
/// README "Handling pointers": two Foos with equal members at different
/// addresses hash apart as a raw region, because the region holds their
/// pointer words, and hash together under the pointer-following steps.
function testEqualFoosHashApartByRegionAndTogetherByComposition(
uint256 a,
address b,
uint256[] memory c,
bytes memory d
) public pure {
Foo memory first = Foo(a, b, c, d);
uint256[] memory c2 = new uint256[](c.length);
for (uint256 i = 0; i < c.length; i++) {
c2[i] = c[i];
}
bytes memory d2 = bytes.concat(d);
Foo memory second = Foo(a, b, c2, d2);
bytes32 regionFirst;
bytes32 regionSecond;
assembly ("memory-safe") {
regionFirst := keccak256(first, 0x80)
regionSecond := keccak256(second, 0x80)
}
assertTrue(regionFirst != regionSecond);
assertEq(hashFooSteps(first), hashFooSteps(second));
}
/// Steps A to E of README "Handling pointers" over one Foo.
function hashFooSteps(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));
}
fix text from sweep1:d2#5
FIX:
Add to test/HashPattern.t.sol:
/// "single byte values `bytes1[]`" are a word list: the word-list example
/// hashes the `length` left-aligned words after the prefix, which is what
/// `abi.encodePacked` of a `bytes1[]` lays out.
function testHashBytes1ArrayAsWordList(bytes1[] memory bar_) public pure {
bytes32 hash_;
assembly ("memory-safe") {
// Assume bar_ is some dynamic length list of words
hash_ := keccak256(
// Skip the length prefix
add(bar_, 0x20),
// Read the length prefix and multiply by 0x20 to know how many _words_
// to hash
mul(mload(bar_), 0x20)
)
}
assertEq(hash_, keccak256(abi.encodePacked(bar_)));
}
Whole-repo audit of rainlanguage/rain.lib.hash at 1e6e59f3d1fc4fd30c86a5836aa156eed382e729 (main, 2026-09-06; audit skill 0.35.0 at claude-audit-skills 9fefd09902d777aabc967ef6625d038e6c2101b7). Pass-2 gate: ledgered (adversarial-mutation-test record 2026-09-03 at 5055221444d547aba034b88fbd2525973b17d9a9; library code unchanged since). Fixes marked verified were applied to a scratch copy and run with forge in the rainix sol-shell (dbcd9d3c) during the audit; everything else is proposed only. Full run record: /home/gildlab/artifacts/rain.lib.hash-audit-2026-09-06/.
Pass 2 residue on a
ledgeredrepo (all 37 library mutants killed; code unchanged since). These are claim-witness tests: each pins a sentence the docs make, passes at HEAD, and adds no mutant-discriminating coverage beyond the existing fuzz-vs-builtin oracles (verified for the first: givenhashBytes == keccak256(data)andcombineHashes == keccak256(a||b), inequality follows from collision resistance). File them only if the repo's "every README claim has a test" bar is wanted for these too; otherwise close.testCompositionSeparatesPackedCollision(bytes s, uint256 i, uint256 j)+testCompositionSeparatesAbcDef()(verified 2/2; carry over theslicehelper the fuzz test needs).Foois ALWAYS 4 words":testFooIsFourWordsuses empty members; add the fuzzedtestFooIsFourWordsWhateverItsMembersHold(uint256, address, uint256[], bytes).hashWords([x]) == hashBytes(32 bytes of x), unlike the seeded fold) is stated nowhere:testHashBytesBoundaryLengths()over {0,1,31,32,33,64} andtestSingletonWordListIsItsWord(bytes32).Foos at different addresses hashing apart as a raw region and together under the composition (testEqualFoosHashApartByRegionAndTogetherByComposition, verified passing).bytes1[]as a word list for hashing; only its layout is tested:testHashBytes1ArrayAsWordList(bytes1[])with the README Yul againstkeccak256(abi.encodePacked(bar_)).Findings from the audit run
F73 [LOW] dim 2 cat — src/LibHashNoAlloc.sol:14-21, 33-36
The library's motivating claim — composition separates what abi.encodePacked collides — has no test
NatSpec opens with "abc"+"def" vs "ab"+"cdef" packing identically and the hash-of-hashes composition separating them for all pairs; no test exercises two splits of one byte string through combineHashes(hashBytes, hashBytes) and asserts inequality.
EVIDENCE: src L15-16, L33-36;
grep -rn 'cdef\|"def"' test/-> none.FIX: Add
testCompositionSeparatesPackedCollision(bytes s, uint256 i, uint256 j)(two distinct split points: packed equal, abi.encode differs, combined hashes differ) andtestCompositionSeparatesAbcDef()to test/LibHashNoAllocCrossType.t.sol (text in d2:coverage:claims#4, verified passing).F75 [INFO] dim 2 cat — test/MemoryLayout.t.sol:148-168
'a Foo is ALWAYS 4 words' is pinned only for empty and zero-initialised members
testFooIsFourWords uses an empty array and empty bytes; testFooListIsWordList measures zero-initialised Foos; no test measures a Foo with populated c and d, the case ALWAYS is about.
EVIDENCE: README L277-279; MemoryLayout L151-152
new uint256[](0); bytes memory d = "";.FIX: Add
testFooIsFourWordsWhateverItsMembersHold(uint256, address, uint256[] memory, bytes memory)asserting ptr == fmpBefore and fmpAfter - ptr == 0x80 (text in d2:coverage:claims#7).F77 [INFO] dim 2 cat — test/LibHashNoAlloc.t.sol:19-21, 47-49
Byte-length and word-count boundaries (1, 31, 32, 33, 64 bytes; 1 word) are reached only if the fuzzer samples them
The NatSpec word-boundary equalities (L89-90, L104-107) are pinned deterministically only at length 0 and 2 words / 3 bytes; the singleton word list — where hashWords([x]) IS keccak256 of the bare word x, unlike the seeded fold — is stated nowhere.
EVIDENCE: L19-21, L47-49 fuzz only; fixed cases L24, L56, L82 (empty) and L28, L60, L86; CrossType L27 fuzz length, L41 n=2, L75-80 n=0. README L463-464, L513-515.
FIX: Add
testHashBytesBoundaryLengths()over {0,1,31,32,33,64} with hashWords equality at whole-word lengths (d2:coverage:src#4) andtestSingletonWordListIsItsWord(bytes32 x)through hashWords x2, hashBytes and the static word (d5:correctness:LibHashNoAllocCrossType.t.sol#1); optionally a README 'Across types' bullet for the singleton.F78 [INFO] dim 2 cat — README.md:382-384
'A pointer ... is not even deterministic' — the reason for the pointer-handling section — has no test
No test constructs two equal-valued Foos at different addresses and shows they hash apart as a raw region and together under the composition.
EVIDENCE: README L382-384; test/HashPattern.t.sol L40 is the only pointer-word hash assertion.
FIX: Add
testEqualFoosHashApartByRegionAndTogetherByComposition(uint256, address, uint256[], bytes)with ahashFooStepsoracle (text in sweep1:d2#2, verified passing).F80 [INFO] dim 2 cat — README.md:317-318
'single byte values bytes1[]' is named as a word list for hashing but only its layout, never its hash, is tested
testHashWordList applies the README word-list Yul to uint256[] only; the claim that left-aligned bytes1 words hash as abi.encodePacked(bytes1[]) is unpinned.
EVIDENCE: README L317-318; HashPattern.t.sol L110
testHashWordList(uint256[] memory bar_); MemoryLayout L172-201 layout only.FIX: Add
testHashBytes1ArrayAsWordList(bytes1[] memory bar_)with the README Yul assertingkeccak256(abi.encodePacked(bar_))(sweep1:d2#5).Fix texts
fix text from sweep1:d2#2
FIX:
Add to test/HashPattern.t.sol:
fix text from sweep1:d2#5
FIX:
Add to test/HashPattern.t.sol:
Whole-repo audit of rainlanguage/rain.lib.hash at
1e6e59f3d1fc4fd30c86a5836aa156eed382e729(main, 2026-09-06; audit skill 0.35.0 at claude-audit-skills9fefd09902d777aabc967ef6625d038e6c2101b7). Pass-2 gate:ledgered(adversarial-mutation-test record 2026-09-03 at5055221444d547aba034b88fbd2525973b17d9a9; library code unchanged since). Fixes marked verified were applied to a scratch copy and run withforgein the rainix sol-shell (dbcd9d3c) during the audit; everything else is proposed only. Full run record:/home/gildlab/artifacts/rain.lib.hash-audit-2026-09-06/.