Skip to content

fix(LibConvert): unsafeTo16BitBytes reverts on a length it cannot double - #25

Closed
thedavidmeister wants to merge 2 commits into
mainfrom
fix/libconvert-16bit-length-overflow-reverts
Closed

thedavidmeister wants to merge 2 commits into
mainfrom
fix/libconvert-16bit-length-overflow-reverts

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #21.

The NatSpec of unsafeTo16BitBytes hands the caller exactly one obligation, and
it is about the values:

The caller MUST ensure that all values fit in type(uint16).max or that silent
overflow is safe.

Nothing is said about us.length. The body was wrapped in unchecked, which put
the allocation size under that same silence, so the code was relying on a length
obligation the docs never handed over. us.length * 2 wrapped, and a us whose
length prefix cannot be doubled was packed as the wrapped count: the issue's
repro, three real elements behind a prefix claiming 2 ** 255 + 3, returned
0xaaaabbbbcccc — six bytes, no error, a well formed result of the wrong length.

This is option (a) from the issue. The unchecked block guarded exactly one
expression, the allocation size, so the block goes and the multiply is checked. A
length prefix that cannot be doubled is now Panic(0x11).

Chosen over option (b), documenting a length precondition, because the caller
cannot discharge that obligation more cheaply than the library can check it, and
because widening the documented unsafety of a function whose name promises one
specific unsafety trades a silent wrong answer for a silent wrong answer the
caller has been told to expect. Rejecting a length prefix it cannot honour is the
library's job, not the caller's.

Panic(0x11) rather than a named error because a memory array whose length
prefix does not match its own allocation is an invariant violation, not caller
input to validate, and because it costs no new API surface in a library that
currently declares no errors at all.

Reachability, measured on this tree rather than argued

new bytes(n) already panics 0x41 for n > type(uint64).max, so on main
every forged length from 2 ** 63 up already reverted, and below that memory
expansion runs out of gas first. The silent window was exactly
us.length >= 2 ** 255, where the doubling wraps back down into a size that
allocates — which is exactly the window the checked multiply closes. Nothing
Solidity itself can build is affected, and no honest caller can reach the check.

The memory-corruption reading recorded as REFUTED in the issue stays refuted;
this PR does not re-raise it. The loop bound mul(mload(us), 0x20) still wraps
in principle, at 2 ** 251, and is still unreachable — now for a stronger
reason than "it wraps by the same factor as the allocation": the allocation above
it reverts first, from 2 ** 63. That is now a comment on the line rather than
only a paragraph in a closed issue.

Gas

gasleft() either side of the call, solc 0.8.25, optimizer 100000 runs. "main"
here is the pre-fix unsafeTo16BitBytes; #24 has landed since and changes only
NatSpec, so the bytecode being compared against is unchanged:

us.length main this PR
0 269 355
8 1058 1144

+86 gas, flat in length. The cheaper shape —
if (us.length > type(uint256).max / 2) revert SomeError(); with the multiply
left unchecked — measured +31 instead. I did not take it: 55 gas is not worth
a new error type in this library's surface, and Panic(0x11) is the right revert
for a corrupt length prefix. Happy to switch if the 55 gas is wanted.

QA

  • Discriminating tests:
    testUnsafeTo16BitBytesForgedLengthOverflowReverts(uint256[],uint256) fuzzes
    the forged prefix over the whole silent window, [2 ** 255, type(uint256).max];
    testUnsafeTo16BitBytesForgedLengthOverflowRevertsForThreeRealElements() pins
    the issue's exact repro. Both assert stdError.arithmeticError, i.e.
    Panic(0x11) specifically, not a bare revert — a mutant that reverts with
    Panic(0x41) instead still fails them. Both forge the prefix behind an
    external call boundary, because expectRevert needs a call to watch and an
    array claiming more elements than it has cannot be ABI encoded across one.

  • Mutations applied: 2 against src/LibConvert.sol via
    nix run github:rainlanguage/adversarial-mutation-test#mutation-probe -- mutants.toml,
    baseline green at 17 passed, 2/2 killed; survived: 0; no-run: 0; harness errors: 0. M01 restores the pre-fix code exactly (bytes memory bs; +
    unchecked { bs = new bytes(us.length * 2); }); M02 reaches the same wrap by a
    route that needs no unchecked at all (new bytes(us.length << 1)), so a test
    that pinned the keyword rather than the behaviour would survive it. Both are
    killed by both new tests. Re-run on the merged tree after docs(LibConvert): the toX convention marks cost, not consumption #24 landed, same
    result.

  • Oracle: the function's own NatSpec, which names one unsafety and puts it on the
    values. A length obligation that appears in the code and nowhere in the docs is
    the defect; the fix is to make the code stop needing it, and the tests assert
    the revert that follows from that, not the implementation that produces it.

  • Category check: arithmetic/off-by-one (the allocation size wrapping) is the
    category the issue reports and the one fixed here. Re-checked for the same
    shape elsewhere in the repo: unsafeToBytes's mul(0x20, mload(bs)) is Yul
    and wraps at 2 ** 251, but it rewrites a length prefix in place and allocates
    nothing, so a wrapped result is a corrupt bytes the caller was already told
    is unsafe to use — out of scope for LibConvert.unsafeTo16BitBytes sizes its allocation with an unchecked multiply, so a forged length truncates silently instead of reverting #21 and not touched. LibCast does no
    arithmetic at all. No other unchecked block exists in src.

  • Static analysis: forge fmt --check, slither . (2 contracts, 99 detectors,
    0 results) and the full suite green locally in the rainix shell, on the merged
    tree.

#24 (the fix for #22) landed on main while this was open, touching the same
NatSpec block and the same test file. main is merged in here and both sides are
kept: its "the truncation is the only unsafety / does NOT consume us" paragraph
and this PR's length-obligation paragraph both stand, in that order after the
values paragraph, and its reworded comment in
testUnsafeTo16BitBytesReferenceImplementation is untouched. Suite green at 17
tests on the merged tree; every number above is measured there.

🤖 Generated with Claude Code

The NatSpec gives the caller exactly one obligation, and it is about the
values: they must fit in `type(uint16).max` or silent overflow must be
safe. It says nothing about `us.length`. The whole body was `unchecked`
though, which put the allocation size under that too, so a `us` whose
length prefix cannot be doubled without wrapping was packed as the
wrapped count instead: a forged prefix claiming `2 ** 255 + 3` elements
returned a well formed six byte result and no error.

The only arithmetic the `unchecked` block guarded was the allocation
size, so the block goes and the multiply becomes checked. A length that
cannot be doubled is now Panic(0x11) rather than a shorter array.
Nothing Solidity can build is affected: the check first fires at
`2 ** 255`, and `new bytes` already panics 0x41 from `2 ** 63` up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 74829dc3-eb8f-4585-b712-6233c98d3e47

📥 Commits

Reviewing files that changed from the base of the PR and between 0d7f498 and 08e8030.

📒 Files selected for processing (2)
  • src/LibConvert.sol
  • test/LibConvert.t.sol

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…length-overflow-reverts

# Conflicts:
#	src/LibConvert.sol
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

Closing unmerged — #21 closed as by-design.

The premise this PR rests on does not hold: a length prefix that exceeds its allocation cannot come from memory-safe assembly, and every assembly block in the org is annotated memory-safe. See the reasoning on #21.

The measurement work stands and is worth keeping in the record: the silent window was exactly us.length >= 2**255 (new bytes(n) already panics from 2**63 up, and memory expansion prices out everything below), the fix cost +86 gas flat, and the two mutants were killed. None of that changes the conclusion that the state being guarded is unreachable from any correct caller.

Branch left in place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LibConvert.unsafeTo16BitBytes sizes its allocation with an unchecked multiply, so a forged length truncates silently instead of reverting

1 participant