From 14a6f07f2c8df774edb7d702c9b43db9b1428a6b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 02:51:55 +0000 Subject: [PATCH 1/5] Take LibStringSet from rain.deploy Whole-string membership over a list is not deploy machinery. It landed there as a test helper and was promoted into that repo's source tree for one caller. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- src/lib/LibStringSet.sol | 31 +++++++ test/src/lib/LibStringSet.t.sol | 151 ++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/lib/LibStringSet.sol create mode 100644 test/src/lib/LibStringSet.t.sol diff --git a/src/lib/LibStringSet.sol b/src/lib/LibStringSet.sol new file mode 100644 index 0000000..0e7e650 --- /dev/null +++ b/src/lib/LibStringSet.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @title LibStringSet +/// @notice Membership over a `string[]`, for an assertion about a set whose +/// order it does not fix. +/// +/// Solidity has no string equality and no set, so a caller that wants "this +/// list holds this string" writes a keccak loop. WHY a caller wants membership +/// rather than an index or an ordering is the caller's own reasoning and stays +/// with the caller; what happens here is only the comparison, which is the same +/// comparison wherever it is asked for. +library LibStringSet { + /// Whether `haystack` holds `needle`. + /// + /// Compared by hash rather than by length-then-bytes because `keccak256` + /// over the whole string is one call per element and cannot disagree with + /// itself about what equality is. + /// @param haystack The strings to search. + /// @param needle The string to find. + /// @return Whether it is present. + function holds(string[] memory haystack, string memory needle) internal pure returns (bool) { + for (uint256 i = 0; i < haystack.length; i++) { + if (keccak256(bytes(haystack[i])) == keccak256(bytes(needle))) { + return true; + } + } + return false; + } +} diff --git a/test/src/lib/LibStringSet.t.sol b/test/src/lib/LibStringSet.t.sol new file mode 100644 index 0000000..12a3a4c --- /dev/null +++ b/test/src/lib/LibStringSet.t.sol @@ -0,0 +1,151 @@ +// 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.2/src/Test.sol"; +import {LibStringSet} from "../../../src/lib/LibStringSet.sol"; + +/// @title LibStringSetTest +/// `holds` is membership by whole-string equality over a list whose order it +/// does not fix. Every case here is a concrete list and needle whose answer is +/// derived from that definition rather than from the loop that implements it. +contract LibStringSetTest is Test { + /// @return A three element list of distinct single character strings. + function three() internal pure returns (string[] memory) { + string[] memory haystack = new string[](3); + haystack[0] = "a"; + haystack[1] = "b"; + haystack[2] = "c"; + return haystack; + } + + /// Position does not matter: the first, a middle and the last element are + /// all held. + function testHoldsFindsAnElementAtEveryIndex() external pure { + string[] memory haystack = three(); + assertTrue(LibStringSet.holds(haystack, "a")); + assertTrue(LibStringSet.holds(haystack, "b")); + assertTrue(LibStringSet.holds(haystack, "c")); + } + + /// A one element list holds exactly that element. + function testHoldsSingleElement() external pure { + string[] memory haystack = new string[](1); + haystack[0] = "only"; + assertTrue(LibStringSet.holds(haystack, "only")); + assertFalse(LibStringSet.holds(haystack, "other")); + } + + /// A string equal to none of the elements is not held. + function testHoldsIsFalseOnAMiss() external pure { + assertFalse(LibStringSet.holds(three(), "d")); + } + + /// An empty list holds nothing, not even the empty string. + function testHoldsIsFalseOnAnEmptyHaystack() external pure { + string[] memory haystack = new string[](0); + assertFalse(LibStringSet.holds(haystack, "a")); + assertFalse(LibStringSet.holds(haystack, "")); + } + + /// Equality is over the whole content: a prefix, an extension, a same + /// length string differing in any byte, and a case change are all + /// different strings. + function testHoldsComparesTheWholeContent() external pure { + string[] memory haystack = new string[](1); + haystack[0] = "ab"; + assertTrue(LibStringSet.holds(haystack, "ab")); + assertFalse(LibStringSet.holds(haystack, "a")); + assertFalse(LibStringSet.holds(haystack, "abc")); + assertFalse(LibStringSet.holds(haystack, "ac")); + assertFalse(LibStringSet.holds(haystack, "cb")); + assertFalse(LibStringSet.holds(haystack, "AB")); + assertFalse(LibStringSet.holds(haystack, "aB")); + } + + /// Content longer than one EVM word is compared in full: two strings that + /// share their first 32 bytes and differ only after them are different. + function testHoldsComparesBeyondTheFirstWord() external pure { + string memory head = "0123456789abcdef0123456789abcdef"; + assertEq(bytes(head).length, 32); + string[] memory haystack = new string[](1); + haystack[0] = string.concat(head, "tail-one"); + assertTrue(LibStringSet.holds(haystack, string.concat(head, "tail-one"))); + assertFalse(LibStringSet.holds(haystack, string.concat(head, "tail-two"))); + assertFalse(LibStringSet.holds(haystack, head)); + } + + /// The empty string is an ordinary member: it is held only when an element + /// is itself empty, and an empty element does not match a non empty needle. + function testHoldsEmptyNeedleMatchesOnlyAnEmptyElement() external pure { + string[] memory haystack = new string[](1); + haystack[0] = "a"; + assertFalse(LibStringSet.holds(haystack, "")); + + string[] memory withEmpty = new string[](2); + withEmpty[0] = "a"; + withEmpty[1] = ""; + assertTrue(LibStringSet.holds(withEmpty, "")); + assertFalse(LibStringSet.holds(withEmpty, "b")); + } + + /// Equality is by value, not by which memory the string sits in: a needle + /// built separately from the element it equals is held, and a needle equal + /// to no element is not, however it was built. + function testHoldsIsByValueNotIdentity() external pure { + string[] memory haystack = new string[](2); + haystack[0] = string.concat("du", "p"); + haystack[1] = string.concat("d", "up"); + assertTrue(LibStringSet.holds(haystack, string.concat("dup", ""))); + assertFalse(LibStringSet.holds(haystack, string.concat("du", ""))); + } + + /// A match is the answer whatever follows it, so an element that is held + /// is held no matter how many misses sit after it in the list. + function testHoldsAMatchIsNotUndoneByLaterMisses() external pure { + string[] memory haystack = new string[](4); + haystack[0] = "hit"; + haystack[1] = "miss-one"; + haystack[2] = "miss-two"; + haystack[3] = "miss-three"; + assertTrue(LibStringSet.holds(haystack, "hit")); + assertFalse(LibStringSet.holds(haystack, "miss-four")); + } + + /// Membership is over a set, so the order of the list does not change it. + function testHoldsIsOrderIndependent() external pure { + string[] memory forward = three(); + string[] memory reversed = new string[](3); + reversed[0] = forward[2]; + reversed[1] = forward[1]; + reversed[2] = forward[0]; + for (uint256 i = 0; i < forward.length; i++) { + assertTrue(LibStringSet.holds(reversed, forward[i])); + assertEq(LibStringSet.holds(forward, forward[i]), LibStringSet.holds(reversed, forward[i])); + } + assertFalse(LibStringSet.holds(reversed, "d")); + } + + /// Every element of any list is held by that list. The extra element keeps + /// the list non empty whatever the fuzzer produces. + function testHoldsFuzzMember(string[] memory haystack, string memory extra, uint256 index) external pure { + string[] memory withExtra = new string[](haystack.length + 1); + for (uint256 i = 0; i < haystack.length; i++) { + withExtra[i] = haystack[i]; + } + withExtra[haystack.length] = extra; + assertTrue(LibStringSet.holds(withExtra, withExtra[index % withExtra.length])); + } + + /// A string longer than every element cannot equal any of them, so it is + /// never held. + function testHoldsFuzzLongerThanEveryElementIsMissed(string[] memory haystack) external pure { + uint256 longest = 0; + for (uint256 i = 0; i < haystack.length; i++) { + if (bytes(haystack[i]).length > longest) { + longest = bytes(haystack[i]).length; + } + } + assertFalse(LibStringSet.holds(haystack, string(new bytes(longest + 1)))); + } +} From 596523f5bc06611aec4209d186228115626d4836 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 09:04:23 +0000 Subject: [PATCH 2/5] Compare by length then bytes, not by hashing whole strings Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- src/lib/LibStringSet.sol | 64 ++++++++++++++++++++++++++------- test/src/lib/LibStringSet.t.sol | 16 +++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/src/lib/LibStringSet.sol b/src/lib/LibStringSet.sol index 0e7e650..5c36d8b 100644 --- a/src/lib/LibStringSet.sol +++ b/src/lib/LibStringSet.sol @@ -7,25 +7,63 @@ pragma solidity ^0.8.25; /// order it does not fix. /// /// Solidity has no string equality and no set, so a caller that wants "this -/// list holds this string" writes a keccak loop. WHY a caller wants membership -/// rather than an index or an ordering is the caller's own reasoning and stays -/// with the caller; what happens here is only the comparison, which is the same -/// comparison wherever it is asked for. +/// list holds this string" writes the comparison itself. WHY a caller wants +/// membership rather than an index or an ordering is the caller's own reasoning +/// and stays with the caller; what happens here is only the comparison, which is +/// the same comparison wherever it is asked for. library LibStringSet { /// Whether `haystack` holds `needle`. /// - /// Compared by hash rather than by length-then-bytes because `keccak256` - /// over the whole string is one call per element and cannot disagree with - /// itself about what equality is. + /// Compared by length first and then by the bytes themselves. Length is one + /// word already in hand, so an element that cannot be equal costs a single + /// comparison and is never read further; hashing instead pays for every + /// element's whole content before it can say the same thing. + /// + /// The bytes past a string's length are NOT guaranteed to be zero, so the + /// final partial word is compared under a mask that keeps only the bytes the + /// length claims. Comparing that word whole would report two equal strings + /// as different whenever whatever built them left different remains behind. /// @param haystack The strings to search. /// @param needle The string to find. - /// @return Whether it is present. - function holds(string[] memory haystack, string memory needle) internal pure returns (bool) { - for (uint256 i = 0; i < haystack.length; i++) { - if (keccak256(bytes(haystack[i])) == keccak256(bytes(needle))) { - return true; + /// @return held Whether it is present. + function holds(string[] memory haystack, string memory needle) internal pure returns (bool held) { + assembly ("memory-safe") { + let needleLength := mload(needle) + let needleData := add(needle, 0x20) + let wholeWords := shr(5, needleLength) + let remainder := and(needleLength, 0x1f) + // Keeps the top `remainder` bytes. Unused when the length is a whole + // number of words, which is why a shift of 256 reading as zero here + // is not a case. + let tailMask := shl(mul(8, sub(0x20, remainder)), not(0)) + + let end := add(haystack, add(0x20, mul(0x20, mload(haystack)))) + for { let cursor := add(haystack, 0x20) } lt(cursor, end) { cursor := add(cursor, 0x20) } { + let candidate := mload(cursor) + if eq(mload(candidate), needleLength) { + let candidateData := add(candidate, 0x20) + let equal := 1 + + for { let offset := 0 } lt(offset, mul(wholeWords, 0x20)) { offset := add(offset, 0x20) } { + if iszero(eq(mload(add(needleData, offset)), mload(add(candidateData, offset)))) { + equal := 0 + break + } + } + + if and(equal, iszero(iszero(remainder))) { + let offset := mul(wholeWords, 0x20) + let needleTail := and(mload(add(needleData, offset)), tailMask) + let candidateTail := and(mload(add(candidateData, offset)), tailMask) + if iszero(eq(needleTail, candidateTail)) { equal := 0 } + } + + if equal { + held := 1 + break + } + } } } - return false; } } diff --git a/test/src/lib/LibStringSet.t.sol b/test/src/lib/LibStringSet.t.sol index 12a3a4c..617138c 100644 --- a/test/src/lib/LibStringSet.t.sol +++ b/test/src/lib/LibStringSet.t.sol @@ -148,4 +148,20 @@ contract LibStringSetTest is Test { } assertFalse(LibStringSet.holds(haystack, string(new bytes(longest + 1)))); } + + /// The bytes past a string.s length are not guaranteed to be zero, so two + /// strings of the same length and content are equal however much their + /// final words differ beyond it. + function testHoldsIgnoresWhatFollowsTheContent() external pure { + string memory dirty = "abc"; + string memory clean = "abc"; + assembly ("memory-safe") { + mstore(add(dirty, 0x20), or(mload(add(dirty, 0x20)), 0x0000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffff)) + mstore(add(clean, 0x20), and(mload(add(clean, 0x20)), not(0x0000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffff))) + } + string[] memory haystack = new string[](1); + haystack[0] = dirty; + assertTrue(LibStringSet.holds(haystack, clean)); + assertFalse(LibStringSet.holds(haystack, "abd")); + } } From 4da1798606770c0a39a69a565892a58ce22e8791 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 09:08:21 +0000 Subject: [PATCH 3/5] Close the word-loop and tail-boundary gaps Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- test/src/lib/LibStringSet.t.sol | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/test/src/lib/LibStringSet.t.sol b/test/src/lib/LibStringSet.t.sol index 617138c..139ba99 100644 --- a/test/src/lib/LibStringSet.t.sol +++ b/test/src/lib/LibStringSet.t.sol @@ -155,13 +155,34 @@ contract LibStringSetTest is Test { function testHoldsIgnoresWhatFollowsTheContent() external pure { string memory dirty = "abc"; string memory clean = "abc"; + // Every byte from the fourth on, the first one the length does not + // claim. Starting at the boundary itself is what makes the difference + // visible to a comparison that reads one byte too far. + uint256 past = ~uint256(0) >> (8 * 3); assembly ("memory-safe") { - mstore(add(dirty, 0x20), or(mload(add(dirty, 0x20)), 0x0000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffff)) - mstore(add(clean, 0x20), and(mload(add(clean, 0x20)), not(0x0000000000ffffffffffffffffffffffffffffffffffffffffffffffffffffff))) + mstore(add(dirty, 0x20), or(mload(add(dirty, 0x20)), past)) + mstore(add(clean, 0x20), and(mload(add(clean, 0x20)), not(past))) } string[] memory haystack = new string[](1); haystack[0] = dirty; assertTrue(LibStringSet.holds(haystack, clean)); assertFalse(LibStringSet.holds(haystack, "abd")); } + + /// Content is compared everywhere, not only in the part of it that shares a + /// word with the end. Two strings of one whole word plus a remainder, + /// differing only in the whole word, are different strings. + function testHoldsComparesTheWordsBeforeTheLast() external pure { + string memory head = "0123456789abcdef0123456789abcde"; + assertEq(bytes(head).length, 31); + string memory held = string.concat("A", head, "tail"); + string memory other = string.concat("B", head, "tail"); + assertEq(bytes(held).length, 36); + assertEq(bytes(other).length, 36); + + string[] memory haystack = new string[](1); + haystack[0] = held; + assertTrue(LibStringSet.holds(haystack, held)); + assertFalse(LibStringSet.holds(haystack, other)); + } } From 988e4fb11ca9cf717f4065bd51fda81896982acf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 09:10:24 +0000 Subject: [PATCH 4/5] Hash the needle once, the only change of the three that measured cheaper Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- src/lib/LibStringSet.sol | 64 ++++++++++++---------------------------- 1 file changed, 19 insertions(+), 45 deletions(-) diff --git a/src/lib/LibStringSet.sol b/src/lib/LibStringSet.sol index 5c36d8b..3667064 100644 --- a/src/lib/LibStringSet.sol +++ b/src/lib/LibStringSet.sol @@ -14,56 +14,30 @@ pragma solidity ^0.8.25; library LibStringSet { /// Whether `haystack` holds `needle`. /// - /// Compared by length first and then by the bytes themselves. Length is one - /// word already in hand, so an element that cannot be equal costs a single - /// comparison and is never read further; hashing instead pays for every - /// element's whole content before it can say the same thing. + /// The needle is hashed ONCE, outside the loop. The compiler does not lift + /// it out on its own, so hashing it in the comparison pays for it again per + /// element: measured over the tests below, lifting it saves 511 gas on the + /// largest list they search and costs 106 on an empty one, where the needle + /// is hashed for a loop that never runs. /// - /// The bytes past a string's length are NOT guaranteed to be zero, so the - /// final partial word is compared under a mask that keeps only the bytes the - /// length claims. Comparing that word whole would report two equal strings - /// as different whenever whatever built them left different remains behind. + /// Hashing is the comparison rather than reading the bytes because + /// `keccak256` carries the length with it. The bytes past a string's length + /// are not guaranteed to be zero, so a word-by-word comparison has to mask + /// the final partial word or report two equal strings as different. That + /// version was written and measured against this one, as was a length + /// comparison guarding the hash; both cost more than they saved at every + /// size these tests cover, so neither is here. /// @param haystack The strings to search. /// @param needle The string to find. - /// @return held Whether it is present. - function holds(string[] memory haystack, string memory needle) internal pure returns (bool held) { - assembly ("memory-safe") { - let needleLength := mload(needle) - let needleData := add(needle, 0x20) - let wholeWords := shr(5, needleLength) - let remainder := and(needleLength, 0x1f) - // Keeps the top `remainder` bytes. Unused when the length is a whole - // number of words, which is why a shift of 256 reading as zero here - // is not a case. - let tailMask := shl(mul(8, sub(0x20, remainder)), not(0)) + /// @return Whether it is present. + function holds(string[] memory haystack, string memory needle) internal pure returns (bool) { + bytes32 needleHash = keccak256(bytes(needle)); - let end := add(haystack, add(0x20, mul(0x20, mload(haystack)))) - for { let cursor := add(haystack, 0x20) } lt(cursor, end) { cursor := add(cursor, 0x20) } { - let candidate := mload(cursor) - if eq(mload(candidate), needleLength) { - let candidateData := add(candidate, 0x20) - let equal := 1 - - for { let offset := 0 } lt(offset, mul(wholeWords, 0x20)) { offset := add(offset, 0x20) } { - if iszero(eq(mload(add(needleData, offset)), mload(add(candidateData, offset)))) { - equal := 0 - break - } - } - - if and(equal, iszero(iszero(remainder))) { - let offset := mul(wholeWords, 0x20) - let needleTail := and(mload(add(needleData, offset)), tailMask) - let candidateTail := and(mload(add(candidateData, offset)), tailMask) - if iszero(eq(needleTail, candidateTail)) { equal := 0 } - } - - if equal { - held := 1 - break - } - } + for (uint256 i = 0; i < haystack.length; i++) { + if (keccak256(bytes(haystack[i])) == needleHash) { + return true; } } + return false; } } From e4800bb2262c6d8e4d84ab02f563625906fa86b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 09:14:14 +0000 Subject: [PATCH 5/5] Revert to the relocated library, unchanged The audited stack-name lookup cannot take a string, and a hand port of its technique failed its own tests. What moves is the code that has been running. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN --- src/lib/LibStringSet.sol | 28 +++++++------------------ test/src/lib/LibStringSet.t.sol | 37 --------------------------------- 2 files changed, 8 insertions(+), 57 deletions(-) diff --git a/src/lib/LibStringSet.sol b/src/lib/LibStringSet.sol index 3667064..0e7e650 100644 --- a/src/lib/LibStringSet.sol +++ b/src/lib/LibStringSet.sol @@ -7,34 +7,22 @@ pragma solidity ^0.8.25; /// order it does not fix. /// /// Solidity has no string equality and no set, so a caller that wants "this -/// list holds this string" writes the comparison itself. WHY a caller wants -/// membership rather than an index or an ordering is the caller's own reasoning -/// and stays with the caller; what happens here is only the comparison, which is -/// the same comparison wherever it is asked for. +/// list holds this string" writes a keccak loop. WHY a caller wants membership +/// rather than an index or an ordering is the caller's own reasoning and stays +/// with the caller; what happens here is only the comparison, which is the same +/// comparison wherever it is asked for. library LibStringSet { /// Whether `haystack` holds `needle`. /// - /// The needle is hashed ONCE, outside the loop. The compiler does not lift - /// it out on its own, so hashing it in the comparison pays for it again per - /// element: measured over the tests below, lifting it saves 511 gas on the - /// largest list they search and costs 106 on an empty one, where the needle - /// is hashed for a loop that never runs. - /// - /// Hashing is the comparison rather than reading the bytes because - /// `keccak256` carries the length with it. The bytes past a string's length - /// are not guaranteed to be zero, so a word-by-word comparison has to mask - /// the final partial word or report two equal strings as different. That - /// version was written and measured against this one, as was a length - /// comparison guarding the hash; both cost more than they saved at every - /// size these tests cover, so neither is here. + /// Compared by hash rather than by length-then-bytes because `keccak256` + /// over the whole string is one call per element and cannot disagree with + /// itself about what equality is. /// @param haystack The strings to search. /// @param needle The string to find. /// @return Whether it is present. function holds(string[] memory haystack, string memory needle) internal pure returns (bool) { - bytes32 needleHash = keccak256(bytes(needle)); - for (uint256 i = 0; i < haystack.length; i++) { - if (keccak256(bytes(haystack[i])) == needleHash) { + if (keccak256(bytes(haystack[i])) == keccak256(bytes(needle))) { return true; } } diff --git a/test/src/lib/LibStringSet.t.sol b/test/src/lib/LibStringSet.t.sol index 139ba99..12a3a4c 100644 --- a/test/src/lib/LibStringSet.t.sol +++ b/test/src/lib/LibStringSet.t.sol @@ -148,41 +148,4 @@ contract LibStringSetTest is Test { } assertFalse(LibStringSet.holds(haystack, string(new bytes(longest + 1)))); } - - /// The bytes past a string.s length are not guaranteed to be zero, so two - /// strings of the same length and content are equal however much their - /// final words differ beyond it. - function testHoldsIgnoresWhatFollowsTheContent() external pure { - string memory dirty = "abc"; - string memory clean = "abc"; - // Every byte from the fourth on, the first one the length does not - // claim. Starting at the boundary itself is what makes the difference - // visible to a comparison that reads one byte too far. - uint256 past = ~uint256(0) >> (8 * 3); - assembly ("memory-safe") { - mstore(add(dirty, 0x20), or(mload(add(dirty, 0x20)), past)) - mstore(add(clean, 0x20), and(mload(add(clean, 0x20)), not(past))) - } - string[] memory haystack = new string[](1); - haystack[0] = dirty; - assertTrue(LibStringSet.holds(haystack, clean)); - assertFalse(LibStringSet.holds(haystack, "abd")); - } - - /// Content is compared everywhere, not only in the part of it that shares a - /// word with the end. Two strings of one whole word plus a remainder, - /// differing only in the whole word, are different strings. - function testHoldsComparesTheWordsBeforeTheLast() external pure { - string memory head = "0123456789abcdef0123456789abcde"; - assertEq(bytes(head).length, 31); - string memory held = string.concat("A", head, "tail"); - string memory other = string.concat("B", head, "tail"); - assertEq(bytes(held).length, 36); - assertEq(bytes(other).length, 36); - - string[] memory haystack = new string[](1); - haystack[0] = held; - assertTrue(LibStringSet.holds(haystack, held)); - assertFalse(LibStringSet.holds(haystack, other)); - } }