Skip to content

fix(StdStorage): handle signed fields narrower than 256 bits - #904

Open
pucedoteth wants to merge 1 commit into
foundry-rs:masterfrom
pucedoteth:fix-stdstorage-narrow-signed-ints
Open

fix(StdStorage): handle signed fields narrower than 256 bits#904
pucedoteth wants to merge 1 commit into
foundry-rs:masterfrom
pucedoteth:fix-stdstorage-narrow-signed-ints

Conversation

@pucedoteth

Copy link
Copy Markdown

A getter whose return type is a signed integer narrower than 256 bits ABI-encodes its value sign-extended, while storage holds only the field's own bits. int64(-5) comes back from the call as

0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffb

but sits in the slot as 0x…fffffffffffffffb. find compares the two directly:

uint256 curVal = (uint256(prev) & getMaskByOffsets(offsetLeft, offsetRight)) >> offsetRight;
if (!shortBytesFound && uint256(callData.result) != curVal) continue;

so they never match and the loop walks past the slot that actually holds the value:

stdStorage find(StdStorage): Slot(s) not found.

Scope

Any negative value in an int8int248 field, whether it shares a slot or sits in one alone, and with or without enable_packed_slots.

  • Full-width int256 is unaffected — there is nothing to extend. The existing test_StorageReadInt (type(int256).min) keeps passing.
  • Unsigned fields are unaffected — their high bits are already zero.

Changes

Three changes, one cause:

  1. find compares the call result truncated to the field's width. For a full-width field the mask is all ones, so this is a no-op.
  2. read_int sign-extends the extracted field back to int256 from the field's width. Without this, find succeeds but the value reads as its zero-extension — 18446744073709551611 instead of -5.
  3. checked_write narrows a sign-extended negative back to the field before the fit check. Previously checked_write_int(-42) on a packed field tripped the packed-slot bound with a misleading message about not fitting a value "greater than 18446744073709551616". The getter still returns the sign-extended form, so the write verification below is unchanged.

read_int no longer routes through _read, since it needs the field width; _read is still used by read_bytes32 / read_address / read_uint, which must not sign-extend. read_bool delegates to read_int and is unaffected (0 and 1 extend to themselves).

Test plan

Four tests added next to the existing test_StorageReadInt, plus narrow signed fields on StorageTest (int64 tJ packed with uint64 tK, and int8 tSolo pushed into its own slot by a following uint256). All state vars are appended, and nothing in the suite hardcodes a slot index — every test discovers slots via find().

Verified by reverting only src/StdStorage.sol and keeping the tests:

test without the fix
test_StorageReadIntPackedNegative Slot(s) not found.
test_StorageReadIntSoloNegative Slot(s) not found.
test_StorageWriteIntPackedNegative Slot(s) not found.
test_StorageReadIntPackedPositiveSibling passes — regression guard, green either way by design

forge test is green: 210 passed, 0 failed (206 before, plus these 4), including the whole existing StdStorage suite — packed-slot fuzzing, struct depth, short bytes/string, and the enable_packed_slots paths. forge fmt --check is clean.


🤖 Written with Claude Code. All output above is from a local forge test run.

A getter whose return type is a signed integer narrower than 256 bits
ABI-encodes its value sign-extended, while storage holds only the
field's own bits. `int64(-5)` comes back from the call as
0xff..fffb but sits in the slot as 0x..fffffffffffffffb, so the two
never compare equal and `find` walks past the slot that holds it:

    stdStorage find(StdStorage): Slot(s) not found.

This affects every negative value in an `int8`..`int248` field,
whether it shares a slot or sits in one alone, with or without
`enable_packed_slots`. Full-width `int256` is unaffected, because
there is nothing to extend. Unsigned fields are unaffected, because
their high bits are already zero.

Three changes, one cause:

- `find` compares the call result truncated to the field's width. For
  a full-width field the mask is all ones, so this is a no-op.
- `read_int` sign-extends the extracted field back to `int256` from
  the field's width, so a narrow negative value reads as itself rather
  than as its zero-extension (-5 rather than 18446744073709551611).
- `checked_write` narrows a sign-extended negative back to the field
  before the fit check. Previously `checked_write_int(-42)` on a packed
  field failed the packed-slot bound with a misleading message about
  not fitting a value "greater than 18446744073709551616". The getter
  still returns the sign-extended form, so the write verification is
  unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants