Skip to content

refactor(storage): structural slot-packing scaffolding + tighter layout pins#75

Merged
amiecorso merged 2 commits into
mainfrom
amie/layout-pin-tests
May 23, 2026
Merged

refactor(storage): structural slot-packing scaffolding + tighter layout pins#75
amiecorso merged 2 commits into
mainfrom
amie/layout-pin-tests

Conversation

@amiecorso
Copy link
Copy Markdown
Collaborator

Summary

Addresses Conner's "comments-as-spec vs structural enforcement"
concern from Slack by both:

  1. Migrating the three per-op packed policy slots to Solidity
    packed structs
    so the field declaration order IS the binary
    layout spec (not a comment), and consumer code uses named field
    access instead of inline shifts/masks.
  2. Tightening the existing full-layout tests so they catch more
    classes of Rust-impl divergence (lane swaps, packing into
    reserved bits, etc.) under fork mode.

Storage layout is frozen — the struct migration is bit-identical
to the prior hand-rolled uint256 packing, so the Rust side needs
zero changes. This is purely a Solidity-side clarity win plus
stronger CI cross-validation.

Commit 1: test(storage): tighten packed-slot layout pins for Rust cross-validation

The full-layout tests under test/unit/storage/ are the primary
cross-validation mechanism for catching Rust storage divergence in
fork mode (LIVE_PRECOMPILES=true FOUNDRY_PROFILE=fork forge test ...).
Three gaps closed:

redeemPolicyIds had zero layout coverage

New test/unit/storage/B20SecurityFullLayout.t.sol mirrors
B20StablecoinFullLayout's pattern for the security variant's
two added namespaces (base.b20.security + base.b20.redeem),
covering every field of both — including the REDEEM_SENDER_POLICY
factory-seed default and the reserved upper 192 bits of
redeemPolicyIds.

transferPolicyIds lanes weren't lane-swap-detectable

Previous version wrote the same ALWAYS_BLOCK_ID into all three
lanes. A Rust impl that swapped any two lanes would pass for the
wrong reason. Fixed by creating four real custom policies in the
registry per run and using each as a distinct lane marker
(updatePolicy validates policyExists, so synthetic markers
aren't usable). Also: pause every defined PausableFeature (not
just TRANSFER + MINT) so each bit position is pinned, and assertion
math is now raw bit ranges, not codec calls — codec helpers can
mask a buggy layout because they encode the same bit math.

PolicyRegistryFullLayout didn't pin reserved bits or post-renounce state

Added explicit assertions that bits 160..254 of policies[id]
remain zero — both after createPolicy AND after renounceAdmin
— so a Rust impl can't sneak fields into the reserved range. The
post-renounce assertion also pins the exact bit pattern
policyExists relies on to distinguish "renounced" from "never
created" (admin cleared, exists flag survives, reserved still zero).

Commit 2: refactor(storage): migrate packed policy slots to Solidity packed structs

Replaces three uint256 packed slots with Solidity packed structs.
Solidity packs struct fields LSB-first into a single 256-bit slot,
which is exactly the convention the Rust impl must reproduce —
so the struct field declaration order becomes the binary layout
spec with no comment-vs-code drift surface.

Slot Before After
MockB20Storage.transferPolicyIds uint256 + 4-lane comment struct TransferPolicyIds { uint64 sender; uint64 receiver; uint64 executor; }
MockB20Storage.mintPolicyIds uint256 + 4-lane comment struct MintPolicyIds { uint64 receiver; }
MockB20RedeemStorage.redeemPolicyIds uint256 + 4-lane comment struct RedeemPolicyIds { uint64 sender; }

Reserved lanes are now implicit (no field declared) so consumer
code can't accidentally write to them, and adding a new lane is a
natural Solidity edit that handles the bit math automatically.

Layout is bit-identical — verified by the layout-pin tests in
commit 1 still passing against the migrated storage (they read raw
slot bytes via vm.load, so they exercise the Solidity-side
packing rules without caring how the slot is declared).

Consumer code in MockB20 and MockB20Security goes from

\$.transferPolicyIds = (\$.transferPolicyIds & ~(mask << 64)) | (uint256(receiverId) << 64);

to

\$.transferPolicyIds.receiver = receiverId;

The five inline shifts in MockB20._readPolicyId / _writePolicyId
plus the inline shifts in _transfer, _mint, transferFrom,
transferFromWithMemo, burnBlocked, and the redeem path in
MockB20Security._redeemBurn all become named struct field
accesses.

Out of scope (intentional)

  • pausedVectors stays as a uint256 bitmap — Solidity has no
    native type for "256-bit bitmap of enum positions" and
    struct { bool transfer; bool mint; ... } would break the
    layout (bools get 8 bits each in Solidity).
  • MockPolicyRegistryStorage.policies[id] packed slot (exists
    at bit 255 with 95-bit gap) stays as-is. Conner flagged this
    earlier as a low-bits-first convention violation; the cleanest
    fix changes the binary layout (e.g. moving exists to bit 160)
    which requires coordinated Rust storage migration — separate PR
    conversation.

Tests

459 passed / 0 failed. Tests stay valid because the binary layout
under the struct migration is bit-identical to the hand-rolled
version. Both the per-mutator tests (which exercise the consumer
code) and the layout-pin tests (which read raw slot bytes) confirm
this.

@ilikesymmetry
Copy link
Copy Markdown
Collaborator

I like this improvement a lot. Mind doing it for policy storage as well? I think that one may be more hairy given we do a single bit and it's the highest bit in base/base. Maybe we should change it to be next bit right after admin address? I'm also expecting the policy struct to be somewhat polymorphic in that we expect a future UNION/INTERSECT type to be immutable (no admin) and needs to point to two (maybe more) uint64 policyA; uint64 policyB to apply to.

@amiecorso amiecorso merged commit 53bb4bf into main May 23, 2026
3 checks passed
@amiecorso amiecorso deleted the amie/layout-pin-tests branch May 23, 2026 02:01
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