diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 2311e57..14c78e5 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -159,7 +159,7 @@ contract MockB20 is IB20 { // Read the executor policy ID out of the transfer-side packed // slot. Cold here; warm by the time _transfer reads the same // slot for sender + receiver. - uint64 executorPolicyId = uint64(MockB20Storage.layout().transferPolicyIds >> 128); + uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor; if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) { revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId); } @@ -189,7 +189,7 @@ contract MockB20 is IB20 { function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool) { if (!_isPrivileged() && msg.sender != from) { _consumeAllowance(from, msg.sender, amount); - uint64 executorPolicyId = uint64(MockB20Storage.layout().transferPolicyIds >> 128); + uint64 executorPolicyId = MockB20Storage.layout().transferPolicyIds.executor; if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(executorPolicyId, msg.sender)) { revert PolicyForbids(TRANSFER_EXECUTOR_POLICY, executorPolicyId); } @@ -243,7 +243,7 @@ contract MockB20 is IB20 { // accounts. Read the transfer-sender policy ID out of the // transfer-side packed slot and reject if the target is // currently authorized. - uint64 senderPolicyId = uint64(MockB20Storage.layout().transferPolicyIds); + uint64 senderPolicyId = MockB20Storage.layout().transferPolicyIds.sender; if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(senderPolicyId, from)) { revert AccountNotBlocked(from); } @@ -390,31 +390,31 @@ contract MockB20 is IB20 { /// falling through to `super`. function _readPolicyId(bytes32 policyScope) internal view virtual returns (uint64) { MockB20Storage.Layout storage $ = MockB20Storage.layout(); - if (policyScope == TRANSFER_SENDER_POLICY) return uint64($.transferPolicyIds); - if (policyScope == TRANSFER_RECEIVER_POLICY) return uint64($.transferPolicyIds >> 64); - if (policyScope == TRANSFER_EXECUTOR_POLICY) return uint64($.transferPolicyIds >> 128); - if (policyScope == MINT_RECEIVER_POLICY) return uint64($.mintPolicyIds); + if (policyScope == TRANSFER_SENDER_POLICY) return $.transferPolicyIds.sender; + if (policyScope == TRANSFER_RECEIVER_POLICY) return $.transferPolicyIds.receiver; + if (policyScope == TRANSFER_EXECUTOR_POLICY) return $.transferPolicyIds.executor; + if (policyScope == MINT_RECEIVER_POLICY) return $.mintPolicyIds.receiver; revert UnsupportedPolicyType(policyScope); } /// @dev Writes a policy ID to storage. Hot-path types update their - /// per-operation packed slot in-place (preserving the other - /// three packed lanes); anything else reverts + /// named field on the per-operation packed-struct slot + /// (`TransferPolicyIds.sender` etc.); Solidity emits the + /// appropriate mask/shift sequence to preserve the other + /// lanes in the slot. Anything else reverts /// `UnsupportedPolicyType` — the token has no slot for it. /// Variants override to handle their own policy types before - /// falling through to `super`. Mask + shift are explicit so - /// the Rust impl can replicate the exact bit layout. + /// falling through to `super`. function _writePolicyId(bytes32 policyScope, uint64 newPolicyId) internal virtual { MockB20Storage.Layout storage $ = MockB20Storage.layout(); - uint256 mask = uint256(type(uint64).max); if (policyScope == TRANSFER_SENDER_POLICY) { - $.transferPolicyIds = ($.transferPolicyIds & ~mask) | uint256(newPolicyId); + $.transferPolicyIds.sender = newPolicyId; } else if (policyScope == TRANSFER_RECEIVER_POLICY) { - $.transferPolicyIds = ($.transferPolicyIds & ~(mask << 64)) | (uint256(newPolicyId) << 64); + $.transferPolicyIds.receiver = newPolicyId; } else if (policyScope == TRANSFER_EXECUTOR_POLICY) { - $.transferPolicyIds = ($.transferPolicyIds & ~(mask << 128)) | (uint256(newPolicyId) << 128); + $.transferPolicyIds.executor = newPolicyId; } else if (policyScope == MINT_RECEIVER_POLICY) { - $.mintPolicyIds = ($.mintPolicyIds & ~mask) | uint256(newPolicyId); + $.mintPolicyIds.receiver = newPolicyId; } else { revert UnsupportedPolicyType(policyScope); } @@ -611,14 +611,14 @@ contract MockB20 is IB20 { // One SLOAD pulls both policy IDs we need for the transfer // check (and was already warmed if we came in via transferFrom, // which reads the executor lane of the same slot first). - uint256 packed = MockB20Storage.layout().transferPolicyIds; - uint64 senderPolicyId = uint64(packed); - uint64 receiverPolicyId = uint64(packed >> 64); - if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(senderPolicyId, from)) { - revert PolicyForbids(TRANSFER_SENDER_POLICY, senderPolicyId); + // Solidity emits a single SLOAD for the struct read + masked + // extracts for the named fields. + MockB20Storage.TransferPolicyIds memory packed = MockB20Storage.layout().transferPolicyIds; + if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(packed.sender, from)) { + revert PolicyForbids(TRANSFER_SENDER_POLICY, packed.sender); } - if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(receiverPolicyId, to)) { - revert PolicyForbids(TRANSFER_RECEIVER_POLICY, receiverPolicyId); + if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(packed.receiver, to)) { + revert PolicyForbids(TRANSFER_RECEIVER_POLICY, packed.receiver); } } @@ -637,7 +637,7 @@ contract MockB20 is IB20 { if (!_isPrivileged()) { if (_isPaused(PausableFeature.MINT)) revert ContractPaused(PausableFeature.MINT); - uint64 mintReceiverPolicyId = uint64(MockB20Storage.layout().mintPolicyIds); + uint64 mintReceiverPolicyId = MockB20Storage.layout().mintPolicyIds.receiver; if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(mintReceiverPolicyId, to)) { revert PolicyForbids(MINT_RECEIVER_POLICY, mintReceiverPolicyId); } diff --git a/test/lib/mocks/MockB20Security.sol b/test/lib/mocks/MockB20Security.sol index 518c07c..3713012 100644 --- a/test/lib/mocks/MockB20Security.sol +++ b/test/lib/mocks/MockB20Security.sol @@ -255,16 +255,14 @@ contract MockB20Security is MockB20, IB20Security { /// revert is the terminal case. function _readPolicyId(bytes32 policyScope) internal view virtual override returns (uint64) { if (policyScope == REDEEM_SENDER_POLICY) { - return uint64(MockB20RedeemStorage.layout().redeemPolicyIds); + return MockB20RedeemStorage.layout().redeemPolicyIds.sender; } return super._readPolicyId(policyScope); } function _writePolicyId(bytes32 policyScope, uint64 newPolicyId) internal virtual override { if (policyScope == REDEEM_SENDER_POLICY) { - MockB20RedeemStorage.Layout storage $ = MockB20RedeemStorage.layout(); - uint256 mask = uint256(type(uint64).max); - $.redeemPolicyIds = ($.redeemPolicyIds & ~mask) | uint256(newPolicyId); + MockB20RedeemStorage.layout().redeemPolicyIds.sender = newPolicyId; return; } super._writePolicyId(policyScope, newPolicyId); @@ -290,7 +288,7 @@ contract MockB20Security is MockB20, IB20Security { function _redeemBurn(uint256 amount) internal returns (uint256 ratio) { if (_isPaused(PausableFeature.REDEEM)) revert ContractPaused(PausableFeature.REDEEM); MockB20RedeemStorage.Layout storage $ = MockB20RedeemStorage.layout(); - uint64 REDEEMSenderPolicyId = uint64($.redeemPolicyIds); + uint64 REDEEMSenderPolicyId = $.redeemPolicyIds.sender; if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(REDEEMSenderPolicyId, msg.sender)) { revert PolicyForbids(REDEEM_SENDER_POLICY, REDEEMSenderPolicyId); } diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index 44038f4..a3e91c0 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -38,6 +38,41 @@ pragma solidity ^0.8.20; /// variant-fixed (`18` for default, `6` for stablecoin/security) /// and read from code, not storage. library MockB20Storage { + // ============================================================ + // PACKED POLICY STRUCTS + // ============================================================ + // Solidity packs struct fields LSB-first into a single 256-bit slot + // (all fields fit because each is `uint64` and there are at most + // four of them per struct). The struct definition IS the binary + // layout spec — the Rust impl mirrors the field order with `u64`s + // of the same names. Reserved bits are implicit: any uint64 lane + // not declared as a field is simply uninitialized (zero) and the + // struct cannot accidentally write to it. + + /// @notice Transfer-side policy IDs (read by `_transfer`, + /// `transferFrom*`, and the seize check in `burnBlocked`). + /// @dev Bit layout (Solidity LSB-first): + /// bits 0.. 63 : sender + /// bits 64..127 : receiver + /// bits 128..191 : executor + /// bits 192..255 : reserved (implicit, no field declared) + struct TransferPolicyIds { + uint64 sender; + uint64 receiver; + uint64 executor; + } + + /// @notice Mint-side policy IDs (read by `_mint`). Only the + /// receiver policy is defined today; future granular + /// mint-side policy types get added as additional `uint64` + /// fields here so adding one doesn't force a second SLOAD. + /// @dev Bit layout: + /// bits 0.. 63 : receiver + /// bits 64..255 : reserved (implicit) + struct MintPolicyIds { + uint64 receiver; + } + /// @custom:storage-location erc7201:base.b20 struct Layout { // ---------- Identity (mutable via admin) ---------- @@ -65,25 +100,24 @@ library MockB20Storage { // matches `uint64 policyId` everywhere else in the system, and // four IDs fit exactly into the 256-bit slot. // + // **Layout via Solidity packed structs.** Each per-op packed slot + // is declared as a struct of `uint64` fields. Solidity packs + // struct fields LSB-first into the slot, which is the exact + // convention the Rust precompile impl must reproduce — so the + // struct field DECLARATION ORDER is the binary layout spec, with + // no comment-vs-code drift surface. Bit-identical to the prior + // hand-rolled `uint256` layout; consumer code uses named field + // access (`$.transferPolicyIds.sender = id;`) instead of inline + // shifts and mask operations. + // // Transfer-side policies (read by `_transfer`, `transferFrom*`, // and the seize check in `burnBlocked`). - // Packed as four uint64 lanes from least-significant to most-significant: - // word0 (bytes 0.. 7, bits 0.. 63): transferSenderPolicyId - // word1 (bytes 8..15, bits 64..127): transferReceiverPolicyId - // word2 (bytes 16..23, bits 128..191): transferExecutorPolicyId - // word3 (bytes 24..31, bits 192..255): reserved - // Note: bit ranges are shown low..high to avoid [end:start] ambiguity. - uint256 transferPolicyIds; - // Mint-side policies (read by `_mint`). Only `MINT_RECEIVER_POLICY` is - // defined today; the remaining three uint64 slots are reserved - // for future granular mint-side policy types (e.g. - // MINT_AUTHORIZER) so adding one doesn't force a second SLOAD. - // Layout uses the same uint64 lane convention: - // word0 (bytes 0.. 7, bits 0.. 63): mintReceiverPolicyId - // word1 (bytes 8..15, bits 64..127): reserved - // word2 (bytes 16..23, bits 128..191): reserved - // word3 (bytes 24..31, bits 192..255): reserved - uint256 mintPolicyIds; + TransferPolicyIds transferPolicyIds; + // Mint-side policies (read by `_mint`). Only `MINT_RECEIVER_POLICY` + // is defined today; future granular mint-side policy types (e.g. + // `MINT_AUTHORIZER`) get added as additional `uint64` fields on + // `MintPolicyIds` so adding one doesn't force a second SLOAD. + MintPolicyIds mintPolicyIds; // There is no generic fallback mapping for "other" policy // types. Each supported `policyType` lives in a fixed slot on // this struct (or, for variant-specific operations, in the @@ -239,12 +273,17 @@ library MockB20Storage { // ============================================================ // PACKED-SLOT CODECS // ============================================================ - // The transfer-side and mint-side policy slots each pack four - // uint64 lanes into a single 256-bit slot, low-to-high (lane 0 in - // bits 0..63, lane 1 in bits 64..127, lane 2 in bits 128..191, - // lane 3 in bits 192..255). These pure codecs let test callers - // extract / compose lane values without re-deriving the shifts at - // every callsite. + // Production code accesses the packed policy slots via the + // `TransferPolicyIds` / `MintPolicyIds` structs (named fields on + // `Layout`) — Solidity handles the bit math automatically. These + // pure codecs operate on a raw `uint256` (what `vm.load` returns + // for the slot) and exist for test-side use only: layout-pin tests + // that read the raw slot bytes can use them to extract lanes + // without re-deriving the shifts at every callsite. + // + // The roundtrip tests in `MockB20SlotHelpers.t.sol` verify that + // these codecs' bit math matches Solidity's struct packing — so a + // codec drifting away from the canonical struct layout fails CI. /// @notice Extracts the TRANSFER_SENDER policy id (lane 0) from the packed slot. function transferSenderPolicyId(uint256 packed) internal pure returns (uint64) { @@ -413,6 +452,21 @@ library MockB20RedeemStorage { } } + /// @notice Redeem-side policy IDs (read by `_redeemBurn` on the + /// security variant). Mirrors the per-op packed-slot + /// convention of `MockB20Storage.TransferPolicyIds` / + /// `MintPolicyIds`. Only the sender policy is defined + /// today; future granular redeem-side types + /// (e.g. `REDEEMER_RECEIVER`) get added as additional + /// `uint64` fields here so adding one doesn't force a + /// second SLOAD. + /// @dev Bit layout (Solidity LSB-first packing): + /// bits 0.. 63 : sender + /// bits 64..255 : reserved (implicit) + struct RedeemPolicyIds { + uint64 sender; + } + /// @custom:storage-location erc7201:base.b20.redeem struct Layout { // ---------- Redemption ---------- @@ -421,12 +475,11 @@ library MockB20RedeemStorage { uint256 minimumRedeemable; // ---------- Redeem-side policies (PACKED) ---------- - // Layout: - // [63:0] redeemerSenderPolicyId - // [127:64] reserved - // [191:128] reserved - // [255:192] reserved - uint256 redeemPolicyIds; + // Solidity-packed struct: field declarations are the binary + // layout spec. Bit-identical to the prior hand-rolled + // `uint256` layout; consumer code uses named field access + // (`$.redeemPolicyIds.sender = id;`) instead of inline shifts. + RedeemPolicyIds redeemPolicyIds; } /// @notice Returns the storage location derived per the ERC-7201 formula diff --git a/test/unit/storage/B20FullLayout.t.sol b/test/unit/storage/B20FullLayout.t.sol index 6b26544..a379911 100644 --- a/test/unit/storage/B20FullLayout.t.sol +++ b/test/unit/storage/B20FullLayout.t.sol @@ -2,6 +2,8 @@ pragma solidity ^0.8.20; import {IB20} from "src/interfaces/IB20.sol"; +import {IPolicyRegistry} from "src/interfaces/IPolicyRegistry.sol"; +import {StdPrecompiles} from "src/StdPrecompiles.sol"; import {B20Test} from "test/lib/B20Test.sol"; import {MockB20, B20Constants} from "test/lib/mocks/MockB20.sol"; @@ -23,7 +25,38 @@ import {MockPolicyRegistry, PolicyRegistryConstants} from "test/lib/mocks/MockPo /// field drift AND as a self-contained spec that a Rust /// implementer can compare against without running the rest /// of the suite. +/// +/// **Lane / bit assertions use explicit bit math, not codec +/// helpers.** Asserting through `MockB20Storage.transferSenderPolicyId(packed)` +/// lets a buggy codec hide a buggy layout (the codec would +/// translate the wrong slot bits into the value the caller +/// wrote, and the assertion would pass). Reading the raw slot +/// and asserting bit ranges grounds the test at the bytes; the +/// codec helpers are separately verified by roundtrip tests in +/// `MockB20SlotHelpers.t.sol`. Both signals together prove +/// "the layout is what we think AND the codec matches that +/// layout". +/// +/// **Lane markers are deliberately distinct per lane** so a +/// lane-swap regression (e.g. Rust putting sender at lane 1 +/// and receiver at lane 0) produces an assertion failure with +/// a recognizable counterexample. Reusing the same value across +/// lanes would mask exactly the bug this test exists to catch. contract B20FullLayoutTest is B20Test { + // ---------- Distinct policy ID markers per lane ---------- + // Set in `_populate` by `createPolicy` calls into the registry. + // Each lane gets a freshly-created, distinct policy ID so a + // lane-swap regression produces a recognizable diff in the + // assertion failure. Built-in sentinel IDs (ALWAYS_ALLOW_ID, + // ALWAYS_BLOCK_ID) would only give us TWO distinct values for + // the three transfer lanes; real custom policies give us as many + // distinct IDs as we need AND satisfy `updatePolicy`'s + // `policyExists` precondition. + + uint64 internal transferSenderMarker; + uint64 internal transferReceiverMarker; + uint64 internal transferExecutorMarker; + uint64 internal mintReceiverMarker; /// @notice Cross-cuts every field of MockB20Storage.Layout in a single /// populated snapshot. /// @dev Setup writes non-default values to every reachable storage @@ -122,41 +155,50 @@ contract B20FullLayoutTest is B20Test { assertEq(uint256(vm.load(tokenAddr, MockB20Storage.adminCountSlot())), 1, "slot 8: adminCount"); // ---------- Policy lanes (slots 9..10) ---------- - // All three transfer-side lanes set to ALWAYS_BLOCK_ID; mint-side - // receiver lane likewise. Reserved lanes pinned to zero. + // Lanes set to DISTINCT markers (not all the same value) so a + // lane-swap regression in the Rust impl produces a recognizable + // diff. Assertions go directly against raw bit ranges rather + // than through codec helpers (see contract-level NatSpec for + // the rationale). uint256 packedTransfer = uint256(vm.load(tokenAddr, MockB20Storage.transferPolicyIdsSlot())); assertEq( - MockB20Storage.transferSenderPolicyId(packedTransfer), - PolicyRegistryConstants.ALWAYS_BLOCK_ID, - "slot 9 lane 0: transfer SENDER" + packedTransfer & 0xFFFFFFFFFFFFFFFF, + uint256(transferSenderMarker), + "slot 9 bits 0..63: transfer SENDER lane" ); assertEq( - MockB20Storage.transferReceiverPolicyId(packedTransfer), - PolicyRegistryConstants.ALWAYS_BLOCK_ID, - "slot 9 lane 1: transfer RECEIVER" + (packedTransfer >> 64) & 0xFFFFFFFFFFFFFFFF, + uint256(transferReceiverMarker), + "slot 9 bits 64..127: transfer RECEIVER lane" ); assertEq( - MockB20Storage.transferExecutorPolicyId(packedTransfer), - PolicyRegistryConstants.ALWAYS_BLOCK_ID, - "slot 9 lane 2: transfer EXECUTOR" + (packedTransfer >> 128) & 0xFFFFFFFFFFFFFFFF, + uint256(transferExecutorMarker), + "slot 9 bits 128..191: transfer EXECUTOR lane" ); - // Lane 3 (bits 192..255) is reserved and must be zero. - assertEq(packedTransfer >> 192, 0, "slot 9 lane 3: reserved must be zero"); + assertEq(packedTransfer >> 192, 0, "slot 9 bits 192..255: reserved lane must be zero"); uint256 packedMint = uint256(vm.load(tokenAddr, MockB20Storage.mintPolicyIdsSlot())); assertEq( - MockB20Storage.mintReceiverPolicyId(packedMint), - PolicyRegistryConstants.ALWAYS_BLOCK_ID, - "slot 10 lane 0: mint RECEIVER" + packedMint & 0xFFFFFFFFFFFFFFFF, + uint256(mintReceiverMarker), + "slot 10 bits 0..63: mint RECEIVER lane" ); - // Lanes 1..3 reserved. - assertEq(packedMint >> 64, 0, "slot 10 lanes 1..3: reserved must be zero"); + assertEq(packedMint >> 64, 0, "slot 10 bits 64..255: three reserved lanes must be zero"); // ---------- pausedVectors (slot 11) ---------- - uint256 expectedPaused = (1 << uint8(IB20.PausableFeature.TRANSFER)) | (1 << uint8(IB20.PausableFeature.MINT)); - assertEq( - uint256(vm.load(tokenAddr, MockB20Storage.pausedVectorsSlot())), expectedPaused, "slot 11: pausedVectors" - ); + // Every PausableFeature is paused so every defined bit position + // is independently asserted. Pinning all four (vs only two) is + // what makes "Rust uses different bit positions for these + // features" detectable. + uint256 pausedRaw = uint256(vm.load(tokenAddr, MockB20Storage.pausedVectorsSlot())); + uint256 expectedPaused = (uint256(1) << uint8(IB20.PausableFeature.TRANSFER)) + | (uint256(1) << uint8(IB20.PausableFeature.MINT)) | (uint256(1) << uint8(IB20.PausableFeature.BURN)) + | (uint256(1) << uint8(IB20.PausableFeature.REDEEM)); + assertEq(pausedRaw, expectedPaused, "slot 11: pausedVectors must hold exactly the four defined bits"); + // No bits set outside the defined PausableFeature range. Computed + // as the complement of the union of all defined bits. + assertEq(pausedRaw & ~expectedPaused, 0, "slot 11: no bits may be set outside the defined PausableFeature range"); // ---------- supplyCap (slot 12) ---------- assertEq(uint256(vm.load(tokenAddr, MockB20Storage.supplyCapSlot())), token.supplyCap(), "slot 12: supplyCap"); @@ -218,17 +260,33 @@ contract B20FullLayoutTest is B20Test { token.setRoleAdmin(B20Constants.MINT_ROLE, B20Constants.PAUSE_ROLE); // ---------- Policy lanes ---------- - _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); - _setPolicy(B20Constants.MINT_RECEIVER_POLICY, PolicyRegistryConstants.ALWAYS_BLOCK_ID); + // Create four real custom policies in the registry so each lane + // gets a DISTINCT, well-formed ID. `updatePolicy`'s `policyExists` + // precondition rejects arbitrary uint64s, so we can't use synthetic + // hex markers like `0x1111...`. Mixing ALLOWLIST + BLOCKLIST types + // makes the top byte vary between lanes too, not just the counter. + transferSenderMarker = + StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + transferReceiverMarker = + StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); + transferExecutorMarker = + StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + mintReceiverMarker = + StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.BLOCKLIST); + _setPolicy(B20Constants.TRANSFER_SENDER_POLICY, transferSenderMarker); + _setPolicy(B20Constants.TRANSFER_RECEIVER_POLICY, transferReceiverMarker); + _setPolicy(B20Constants.TRANSFER_EXECUTOR_POLICY, transferExecutorMarker); + _setPolicy(B20Constants.MINT_RECEIVER_POLICY, mintReceiverMarker); // ---------- Pause vectors ---------- - // Pause TRANSFER and MINT (note: we just blocked MINT via policy - // above so this is consistent — the pause bit and the policy ID - // are independent storage fields). + // Pause every defined PausableFeature so the layout pin covers + // each enum position. The policy lanes above blocked MINT + // already via policy ID; pause and policy are independent + // storage fields so both signals are simultaneously valid. _pause(IB20.PausableFeature.TRANSFER); _pause(IB20.PausableFeature.MINT); + _pause(IB20.PausableFeature.BURN); + _pause(IB20.PausableFeature.REDEEM); // ---------- Nonce ---------- // Permit increments alice's nonce. To sign a valid permit we diff --git a/test/unit/storage/B20SecurityFullLayout.t.sol b/test/unit/storage/B20SecurityFullLayout.t.sol new file mode 100644 index 0000000..7cbc1f0 --- /dev/null +++ b/test/unit/storage/B20SecurityFullLayout.t.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20Security} from "src/interfaces/IB20Security.sol"; +import {IPolicyRegistry} from "src/interfaces/IPolicyRegistry.sol"; +import {StdPrecompiles} from "src/StdPrecompiles.sol"; + +import {B20SecurityTest} from "test/lib/B20SecurityTest.sol"; +import {MockB20Security} from "test/lib/mocks/MockB20Security.sol"; +import {MockB20SecurityStorage, MockB20RedeemStorage} from "test/lib/mocks/MockB20Storage.sol"; +import {PolicyRegistryConstants} from "test/lib/mocks/MockPolicyRegistry.sol"; + +/// @notice Exhaustive layout spec for the `base.b20.security` and +/// `base.b20.redeem` namespaces. +/// +/// @dev Mirrors `B20FullLayout.t.sol`'s pattern for the security +/// variant's two added namespaces. Populates non-default values +/// in every field via the public `IB20Security` surface, then +/// asserts the raw slot bytes at each absolute slot via +/// `vm.load` and explicit bit-position math. +/// +/// The base-namespace layout itself (`base.b20`, slots 0..14) +/// is covered by `B20FullLayout.t.sol`. Per-mutator base +/// behavior on the security variant is exercised through +/// `B20SecurityTest`-derived test contracts. +/// +/// **Why explicit bit-position math instead of codec helpers?** +/// The codec helpers in `MockB20RedeemStorage` (when they +/// exist) and inline shifts elsewhere encode the SAME bit +/// positions the Rust impl must reproduce. Asserting via the +/// codec would let codec bugs hide each other ("the codec says +/// the slot says what we wrote, even if both are wrong"). The +/// layout pin reads the raw slot and asserts exact bit ranges, +/// so the test grounds out at the bytes — the codec is then +/// separately verified by roundtrip tests under +/// `MockB20SlotHelpers.t.sol` / `MockPolicyRegistrySlotHelpers.t.sol`. +/// Both signals together prove "the layout is what we think AND +/// the codec matches that layout". +contract B20SecurityFullLayoutTest is B20SecurityTest { + // ---------- Distinct marker values per lane / field ---------- + + /// @dev Marker for the REDEEM_SENDER_POLICY lane. Set in `_populate` + /// by creating a real custom policy in the registry, so the ID + /// satisfies `updatePolicy`'s `policyExists` precondition. + /// Synthetic uint64 markers (e.g. `0x5555...`) would be rejected. + uint64 internal redeemSenderMarker; + + /// @dev Non-WAD ratio so the slot value is observably different from + /// both zero (the "unwritten = WAD" default) and WAD itself. + uint256 internal constant SHARE_RATIO_MARKER = 2.5e18; + + /// @dev Per-mutation `minimumRedeemable`. Distinct from the + /// bootstrap-seeded value below so the post-mutation slot is + /// observably the post-mutation value, not the seeded one. + uint256 internal constant MINIMUM_REDEEMABLE_MARKER = 7e18; + + string internal constant FIGI_VALUE = "BBG000B9XRY4"; + string internal constant ANNOUNCEMENT_ID = "layout-pin-announcement"; + + /// @notice Cross-cuts every field of the two security-variant + /// namespaces in one populated snapshot. + /// @dev Field coverage: + /// + /// `base.b20.security` (slots 0..2): + /// - 0: sharesToTokensRatio + /// - 1: usedAnnouncementIds[id] + /// - 2: identifiers[identifierType] (ISIN seeded + FIGI mutated) + /// + /// `base.b20.redeem` (slots 0..1): + /// - 0: minimumRedeemable + /// - 1: redeemPolicyIds (lane 0 = REDEEM_SENDER_POLICY, + /// lanes 1..3 reserved) + function test_b20SecurityLayout_success_populatedSnapshotMatchesAllSlots() public { + // ---------- Populate ---------- + _populate(); + + address tokenAddr = address(token); + + // ============================================================ + // base.b20.security namespace + // ============================================================ + + // ---------- sharesToTokensRatio (slot 0) ---------- + assertEq( + uint256(vm.load(tokenAddr, MockB20SecurityStorage.sharesToTokensRatioSlot())), + SHARE_RATIO_MARKER, + "security slot 0: sharesToTokensRatio must hold the written ratio" + ); + + // ---------- usedAnnouncementIds[id] (slot 1, hashed by id) ---------- + // Slot resolves to keccak256(abi.encodePacked(id, baseSlot)). Solidity + // stores a `bool true` as a one-word `uint256(1)`. + assertEq( + uint256(vm.load(tokenAddr, MockB20SecurityStorage.usedAnnouncementIdSlot(ANNOUNCEMENT_ID))), + uint256(1), + "security slot 1: usedAnnouncementIds[id] must be true after announce" + ); + + // ---------- identifiers[identifierType] (slot 2, hashed by type) ---------- + // Both the seeded ISIN (DEFAULT_ISIN from _securityParams() bootstrap) + // and the post-creation FIGI write are independently pinned to the + // canonical string-field encoding at their derived slots. + assertEq( + vm.load(tokenAddr, MockB20SecurityStorage.identifierSlot(IDENTIFIER_ISIN)), + _expectedStringFieldSlot(DEFAULT_ISIN), + "security slot 2: identifiers[ISIN] must hold the bootstrap-seeded short-string encoding" + ); + assertEq( + vm.load(tokenAddr, MockB20SecurityStorage.identifierSlot(IDENTIFIER_FIGI)), + _expectedStringFieldSlot(FIGI_VALUE), + "security slot 2: identifiers[FIGI] must hold the post-creation short-string encoding" + ); + + // ============================================================ + // base.b20.redeem namespace + // ============================================================ + + // ---------- minimumRedeemable (slot 0) ---------- + assertEq( + uint256(vm.load(tokenAddr, MockB20RedeemStorage.minimumRedeemableSlot())), + MINIMUM_REDEEMABLE_MARKER, + "redeem slot 0: minimumRedeemable must hold the post-mutation value" + ); + + // ---------- redeemPolicyIds (slot 1, packed) ---------- + // Layout: + // bits 0.. 63 : REDEEM_SENDER_POLICY lane + // bits 64..127 : reserved + // bits 128..191 : reserved + // bits 192..255 : reserved + // The factory seeds lane 0 with ALWAYS_BLOCK_ID at creation; the + // populate step overrides it with REDEEM_SENDER_MARKER via the public + // `updatePolicy` surface. The reserved lanes MUST remain zero so the + // Rust impl can't sneak fields into reserved space without us + // noticing. + uint256 packedRedeem = uint256(vm.load(tokenAddr, MockB20RedeemStorage.redeemPolicyIdsSlot())); + assertEq( + packedRedeem & 0xFFFFFFFFFFFFFFFF, + uint256(redeemSenderMarker), + "redeem slot 1 bits 0..63: REDEEM_SENDER_POLICY lane must hold the marker" + ); + assertEq( + packedRedeem >> 64, + uint256(0), + "redeem slot 1 bits 64..255: three reserved lanes must be zero" + ); + } + + /// @notice Verifies the factory-seeded default in `redeemPolicyIds` + /// lane 0 is `ALWAYS_BLOCK_ID` BEFORE any post-creation write. + /// @dev Companion to the populated-snapshot test. Catches a Rust + /// impl that skips the seed write at creation time — without + /// this, the populated-snapshot test would mask that bug + /// (the populate's `_setRedeemPolicy` would overwrite whatever + /// the seed left behind). + function test_b20SecurityLayout_success_freshTokenSeedsRedeemPolicyToBlock() public view { + uint256 packedRedeem = + uint256(vm.load(address(token), MockB20RedeemStorage.redeemPolicyIdsSlot())); + assertEq( + packedRedeem & 0xFFFFFFFFFFFFFFFF, + uint256(PolicyRegistryConstants.ALWAYS_BLOCK_ID), + "fresh token: redeem slot 1 bits 0..63 must be ALWAYS_BLOCK_ID from factory seed" + ); + assertEq( + packedRedeem >> 64, + uint256(0), + "fresh token: redeem slot 1 bits 64..255 reserved lanes must be zero" + ); + } + + /// @notice Verifies the `base.b20.security` and `base.b20.redeem` + /// namespaces derive from disjoint ERC-7201 roots. + /// @dev Two adjacent variant namespaces must not alias each other + /// or the base `base.b20` namespace. The disjoint-roots + /// property is tested for `base.b20` vs `base.b20.security` + /// in `MockPolicyRegistryStorage.t.sol`-style tests already; + /// this pins the redeem-vs-security pair specifically. + function test_b20SecurityLayout_success_namespaceRootsDisjoint() public pure { + assertTrue( + MockB20SecurityStorage.STORAGE_LOCATION != MockB20RedeemStorage.STORAGE_LOCATION, + "base.b20.security and base.b20.redeem must derive to disjoint roots" + ); + } + + /// @notice Populates the security variant with non-default values + /// across every field of both added namespaces. Centralized so + /// the layout test reads as a single assertion sweep with no + /// interleaved mutations. + function _populate() internal { + // ---------- base.b20.security ---------- + // sharesToTokensRatio: write the non-WAD marker via the public surface. + _updateShareRatio(SHARE_RATIO_MARKER); + // identifiers[FIGI]: post-creation operator write. ISIN was seeded + // at creation (DEFAULT_ISIN via _securityParams() bootstrap). + _grantOperator(); + vm.prank(operator); + security().updateSecurityIdentifier(IDENTIFIER_FIGI, FIGI_VALUE); + // usedAnnouncementIds[ANNOUNCEMENT_ID]: flip via announce. + _announce(ANNOUNCEMENT_ID); + + // ---------- base.b20.redeem ---------- + // minimumRedeemable: post-creation admin write. + _updateMinimumRedeemable(MINIMUM_REDEEMABLE_MARKER); + // redeemPolicyIds lane 0: create a real custom policy in the + // registry, then overwrite the factory-seeded ALWAYS_BLOCK_ID + // default with that ID. The `updatePolicy` write path validates + // the new ID via `policyExists`, so we can't use a synthetic + // uint64 marker. Using a fresh real policy (distinct from the + // ALWAYS_BLOCK_ID default) gives us a recognizable post-write + // observable. + redeemSenderMarker = + StdPrecompiles.POLICY_REGISTRY.createPolicy(admin, IPolicyRegistry.PolicyType.ALLOWLIST); + _setRedeemPolicy(redeemSenderMarker); + } +} diff --git a/test/unit/storage/PolicyRegistryFullLayout.t.sol b/test/unit/storage/PolicyRegistryFullLayout.t.sol index b4c2644..04c5ca8 100644 --- a/test/unit/storage/PolicyRegistryFullLayout.t.sol +++ b/test/unit/storage/PolicyRegistryFullLayout.t.sol @@ -75,14 +75,26 @@ contract PolicyRegistryFullLayoutTest is PolicyRegistryTest { ); } // Allowlist policy: admin = alice; type carried by the ID's top byte. + // Layout pin on the packed word: bits 0..159 hold the admin, bits + // 160..254 are reserved (must be zero), bit 255 is the exists flag. + // Assertions go directly against raw bit ranges rather than through + // codec helpers so a buggy codec can't hide a buggy layout (the + // codec's bit math is separately verified by roundtrip tests in + // `MockPolicyRegistrySlotHelpers.t.sol`). { uint256 packedA = uint256(vm.load(registry, MockPolicyRegistryStorage.policySlot(allowlistId))); assertEq( - MockPolicyRegistryStorage.policyAdminFromPacked(packedA), alice, "policies[allowlistId] admin lane" + packedA & ((uint256(1) << 160) - 1), + uint256(uint160(alice)), + "policies[allowlistId] bits 0..159: admin lane" ); - assertTrue( - MockPolicyRegistryStorage.policyExistsFromPacked(packedA), - "policies[allowlistId] exists bit must be set" + assertEq( + (packedA >> 160) & ((uint256(1) << 95) - 1), + uint256(0), + "policies[allowlistId] bits 160..254: reserved must be zero" + ); + assertEq( + packedA >> 255, uint256(1), "policies[allowlistId] bit 255: exists flag must be set" ); assertEq( MockPolicyRegistryStorage.policyTypeFromId(allowlistId), @@ -94,11 +106,17 @@ contract PolicyRegistryFullLayoutTest is PolicyRegistryTest { { uint256 packedB = uint256(vm.load(registry, MockPolicyRegistryStorage.policySlot(blocklistId))); assertEq( - MockPolicyRegistryStorage.policyAdminFromPacked(packedB), attacker, "policies[blocklistId] admin lane" + packedB & ((uint256(1) << 160) - 1), + uint256(uint160(attacker)), + "policies[blocklistId] bits 0..159: admin lane" + ); + assertEq( + (packedB >> 160) & ((uint256(1) << 95) - 1), + uint256(0), + "policies[blocklistId] bits 160..254: reserved must be zero" ); - assertTrue( - MockPolicyRegistryStorage.policyExistsFromPacked(packedB), - "policies[blocklistId] exists bit must be set" + assertEq( + packedB >> 255, uint256(1), "policies[blocklistId] bit 255: exists flag must be set" ); assertEq( MockPolicyRegistryStorage.policyTypeFromId(blocklistId), @@ -132,6 +150,32 @@ contract PolicyRegistryFullLayoutTest is PolicyRegistryTest { "pendingAdmins[blocklistId] must be cleared" ); + // ---------- Post-renounce slot layout ---------- + // Renouncing admin must clear bits 0..159 (admin lane) AND leave + // bit 255 (exists) set AND keep bits 160..254 (reserved) zero. + // This is the invariant that lets `policyExists` distinguish + // "renounced" (exists=1, admin=0) from "never created" (slot==0). + vm.prank(attacker); + policyRegistry.renounceAdmin(blocklistId); + { + uint256 packedBafter = uint256(vm.load(registry, MockPolicyRegistryStorage.policySlot(blocklistId))); + assertEq( + packedBafter & ((uint256(1) << 160) - 1), + uint256(0), + "renounced policies[blocklistId] bits 0..159: admin lane must be cleared" + ); + assertEq( + (packedBafter >> 160) & ((uint256(1) << 95) - 1), + uint256(0), + "renounced policies[blocklistId] bits 160..254: reserved must remain zero" + ); + assertEq( + packedBafter >> 255, + uint256(1), + "renounced policies[blocklistId] bit 255: exists flag must survive renunciation" + ); + } + // ---------- nextCounter (slot 3) ---------- // Lazy init advances the counter from 0 to BUILTIN_POLICY_COUNT (=2) // on the first create; allowlistId then consumes counter 2 and