From f6696303550a0669ae2ebba03514de2f78e2678c Mon Sep 17 00:00:00 2001 From: Mikhail Mikheev <16622558+mmv08@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:20:31 +0200 Subject: [PATCH] Add Pages and Words for MIP-8 storage pages MIP-8 groups storage into 4 KiB pages of 128 slots, so contracts that lay their storage out by page need page and slot arithmetic. Pages gives them the PageIndex type, the PageHandle type that derives a page from its own storage slot, checked slot arithmetic that reverts past the end of storage, and one custom error for an offset outside a page. A PageHandle is a one-slot struct declared where the structure that uses the page lives, as a state variable or inside a struct, array, or mapping. Its page contains keccak256(abi.encode(slot)), where a dynamic array declared in its place would start its data, so distinct declarations get distinct pages. The slot itself is never written. Pages hands out slot numbers, and reading or writing one takes the same three lines of assembly in every consumer. Words.ref turns a slot number into a Word storage pointer once, in the library, so storage laid out by slot is plain Solidity at the call site. It takes uint256 to match Pages and adds no runtime dependency. It is named ref because solc 0.8.36 warns that at will become a keyword. Tests use forge-std, added as a test-only submodule, and check reverts with vm.expectRevert through a harness contract. CI now installs upstream Foundry, which ships Monad support since v1.8.0, checks out submodules, and runs the fuzz tests at 10,000 runs through the ci profile the workflow already selected but that was never defined. foundry.toml selects the Monad network family for local runs too. --- .github/workflows/test.yml | 5 +- .gitmodules | 3 + README.md | 18 +++++- foundry.lock | 8 +++ foundry.toml | 4 ++ lib/forge-std | 1 + src/utils/storage/Pages.sol | 109 ++++++++++++++++++++++++++++++++++++ src/utils/storage/Words.sol | 28 +++++++++ test/Pages.t.sol | 99 ++++++++++++++++++++++++++++++++ test/Words.t.sol | 33 +++++++++++ 10 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 .gitmodules create mode 100644 foundry.lock create mode 160000 lib/forge-std create mode 100644 src/utils/storage/Pages.sol create mode 100644 src/utils/storage/Words.sol create mode 100644 test/Pages.t.sol create mode 100644 test/Words.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d74040..03824c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,9 +20,10 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + submodules: recursive - - name: Install Monad Foundry - uses: category-labs/foundry-toolchain@v1 + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 - name: Show Forge version run: forge --version diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..888d42d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index 673ea3c..a8ccf0a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Monad Standard Library • [![CI status](https://github.com/category-labs/monad-std/actions/workflows/test.yml/badge.svg)](https://github.com/category-labs/monad-std/actions/workflows/test.yml) -Monad Standard Library (`monad-std`) is a collection of Monad-specific interfaces and testing helpers for [Foundry](https://github.com/foundry-rs/foundry). +Monad Standard Library (`monad-std`) is a collection of Monad-specific interfaces, storage utilities, and testing helpers for [Foundry](https://github.com/foundry-rs/foundry). -It provides Solidity interfaces that track Monad runtime behavior and lightweight base contracts for ergonomic test usage. +It provides Solidity interfaces that track Monad runtime behavior, helpers for Monad's page-based storage model, and lightweight base contracts for ergonomic test usage. ## Install @@ -18,7 +18,19 @@ forge install category-labs/monad-std ### `IReserveBalance` -[`src/interfaces/IReserveBalance.sol`](./src/interfaces/IReserveBalance.sol) defines the public interface for the reserve balance precompile at `0x1001` ([MIP-4](https://github.com/monad-crypto/MIPs/blob/main/MIPS/MIP-4.md)). +[`src/interfaces/IReserveBalance.sol`](./src/interfaces/IReserveBalance.sol) defines the public interface for the reserve balance precompile at `0x1001` ([MIP-4](https://github.com/monad-crypto/MIPs/blob/main/MIPs/MIP-4.md)). + +### `Pages` + +[`src/utils/storage/Pages.sol`](./src/utils/storage/Pages.sol) defines the `PageIndex` type, the `PageHandle` type that derives a page from its storage slot, and checked slot arithmetic for 128-slot [MIP-8](https://github.com/monad-crypto/MIPs/blob/6e78a6ac39547882f9905fba86d2c794eb1768ef/MIPs/MIP-8.md) pages. Import it as `monad-std/utils/storage/Pages.sol`. + +This utility has not had an independent audit. + +### `Words` + +[`src/utils/storage/Words.sol`](./src/utils/storage/Words.sol) defines the `Word` storage pointer and `Words.ref`, which turns a slot number into a pointer, so storage laid out by slot is read and written without assembly at the call site. Import it as `monad-std/utils/storage/Words.sol`. + +Like `Pages`, it has not had an independent audit. ### `MonadVm` diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..31b0fe2 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.2", + "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" + } + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 25b918f..c15d1cb 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,5 +2,9 @@ src = "src" out = "out" libs = ["lib"] +network = "monad" + +[profile.ci.fuzz] +runs = 10_000 # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..bf647bd --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit bf647bd6046f2f7da30d0c2bf435e5c76a780c1b diff --git a/src/utils/storage/Pages.sol b/src/utils/storage/Pages.sol new file mode 100644 index 0000000..d7ed7bb --- /dev/null +++ b/src/utils/storage/Pages.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13 <0.9.0; + +/// @notice Index of a 128-slot storage page, defined by MIP-8: `slot / 128` +type PageIndex is uint256; + +using {Pages.add, Pages.slot, Pages.slotUnbounded, Pages.words} for PageIndex global; + +/// @notice Handle for a page. When declared, it reserves one storage slot. +/// The slot number picks the page as `keccak256(abi.encode(slot)) / 128`, +/// similar to how solidity does it for dynamic arrays. +struct PageHandle { + uint256 _anchor; +} + +using {Pages.page} for PageHandle global; + +/** + * @title Pages + * @notice Implements page and slot arithmetic for MIP-8 storage pages. + * @dev MIP-8 groups 128 slots of 32 bytes into a 4 KiB page: + * + * page_index(slot) = slot / 128 + * offset(slot) = slot % 128 + * slot(page, offset) = page * 128 + offset + * + * MIP-8: https://github.com/monad-crypto/MIPs/blob/6e78a6ac39547882f9905fba86d2c794eb1768ef/MIPs/MIP-8.md + * + * Example usage: + * PageHandle internal ledger; + * + * uint256[128] storage words = ledger.page().words(); + * words[0] = total; + * words[1 + i] = amount; + */ +library Pages { + uint256 internal constant SLOTS_PER_PAGE = 128; + + /// @dev The offset passed to `slot` is 128 or more. + error OffsetOutOfPage(uint256 offset); + + /** + * @notice Returns the page of a page handle. + * @param handle Handle of the page. + * @return The page. + */ + function page(PageHandle storage handle) internal pure returns (PageIndex) { + uint256 anchor; + assembly ("memory-safe") { + anchor := handle.slot + } + return fromSlot(uint256(keccak256(abi.encode(anchor)))); + } + + /** + * @notice Returns the page that contains a storage slot. + * @param storageSlot Storage slot to locate. + * @return The page that contains the slot. + */ + function fromSlot(uint256 storageSlot) internal pure returns (PageIndex) { + return PageIndex.wrap(storageSlot / SLOTS_PER_PAGE); + } + + /** + * @notice Returns the page located `pages` pages after a base page. + * @param base Base page. + * @param pages Number of pages to add. + * @return The selected page. + */ + function add(PageIndex base, uint256 pages) internal pure returns (PageIndex) { + return PageIndex.wrap(PageIndex.unwrap(base) + pages); + } + + /** + * @notice Returns the slot at an offset inside a page. + * @dev Reverts with `OffsetOutOfPage` when `offset` is 128 or more + * @param base Page that holds the slot. + * @param offset Position of the slot in the page, from zero through 127. + * @return The storage slot. + */ + function slot(PageIndex base, uint256 offset) internal pure returns (uint256) { + if (offset >= SLOTS_PER_PAGE) revert OffsetOutOfPage(offset); + return slotUnbounded(base, offset); + } + + /** + * @notice Returns the slot at `index`, counting from the first slot of a page. + * @dev Crosses page bounds when `index` is 128 or more. + * @param base Page whose first slot is index zero. + * @param index Number of slots after the first slot of the page. + * @return The storage slot. + */ + function slotUnbounded(PageIndex base, uint256 index) internal pure returns (uint256) { + return PageIndex.unwrap(base) * SLOTS_PER_PAGE + index; + } + + /** + * @notice Returns the words of a page as a storage array. + * @param base Page to view. + * @return data Storage array whose element `offset` is the slot at `offset`. + */ + function words(PageIndex base) internal pure returns (uint256[SLOTS_PER_PAGE] storage data) { + // Checked multiplication also rejects a page past the end of storage. + uint256 start = PageIndex.unwrap(base) * SLOTS_PER_PAGE; + assembly ("memory-safe") { + data.slot := start + } + } +} diff --git a/src/utils/storage/Words.sol b/src/utils/storage/Words.sol new file mode 100644 index 0000000..04ec0d9 --- /dev/null +++ b/src/utils/storage/Words.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13 <0.9.0; + +/// @notice A storage slot reached through a pointer +struct Word { + uint256 value; +} + +/** + * @title Words + * @notice Turns a storage slot number into a storage pointer + * + * Example usage: + * Words.ref(slot).value = total; + * uint256 stored = Words.ref(slot).value; + */ +library Words { + /** + * @notice Returns a pointer to the word at a storage slot. + * @param slot Storage slot of the word. + * @return word Pointer to the word. + */ + function ref(uint256 slot) internal pure returns (Word storage word) { + assembly ("memory-safe") { + word.slot := slot + } + } +} diff --git a/test/Pages.t.sol b/test/Pages.t.sol new file mode 100644 index 0000000..37164c0 --- /dev/null +++ b/test/Pages.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13 <0.9.0; + +import {Test, stdError} from "forge-std/Test.sol"; + +import {PageIndex, PageHandle, Pages} from "../src/utils/storage/Pages.sol"; + +contract PagesHarness { + PageHandle internal handle; + mapping(uint256 => PageHandle) internal handles; + + function page() external view returns (PageIndex) { + return handle.page(); + } + + function page(uint256 key) external view returns (PageIndex) { + return handles[key].page(); + } + + function slot(PageIndex base, uint256 offset) external pure returns (uint256) { + return Pages.slot(base, offset); + } + + function word(PageIndex base, uint256 offset) external view returns (uint256) { + return base.words()[offset]; + } + + function setWord(PageIndex base, uint256 offset, uint256 value) external { + base.words()[offset] = value; + } +} + +contract PagesTest is Test { + PagesHarness internal harness; + + function setUp() public { + harness = new PagesHarness(); + } + + /// @dev The harness declares `handle` at slot 0, so slot zero of its page is + /// `keccak256(abi.encode(uint256(0)))` with its low seven bits cleared. + function testPageStartsAtHashOfHandleSlotRoundedDown() public view { + assertEq(harness.page().slot(0), 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e500); + } + + /// @dev The page follows the slot of the handle wherever it is declared. The harness declares + /// `handles` at slot 1, so `handles[key]` lives at `keccak256(abi.encode(key, uint256(1)))`. + function testFuzzPageFollowsHandleSlot(uint256 key) public view { + uint256 handleSlot = uint256(keccak256(abi.encode(key, uint256(1)))); + PageIndex expected = Pages.fromSlot(uint256(keccak256(abi.encode(handleSlot)))); + assertEq(PageIndex.unwrap(harness.page(key)), PageIndex.unwrap(expected)); + } + + function testFuzzFromSlotReturnsContainingPage(uint256 storageSlot) public pure { + assertEq(Pages.fromSlot(storageSlot).slot(storageSlot % Pages.SLOTS_PER_PAGE), storageSlot); + } + + function testSlotRejectsOffsetOutsidePage() public { + vm.expectRevert(abi.encodeWithSelector(Pages.OffsetOutOfPage.selector, Pages.SLOTS_PER_PAGE)); + // forge-lint: disable-next-line(unused-return) + harness.slot(PageIndex.wrap(0), Pages.SLOTS_PER_PAGE); + } + + /// @dev Counting `index` slots from the start of a page ends `index / 128` pages later, at + /// offset `index % 128`. `lastBase` is the last page the index fits in. + function testFuzzSlotUnboundedCrossesPages(uint256 rawBase, uint256 index) public pure { + uint256 lastBase = PageIndex.unwrap(Pages.fromSlot(type(uint256).max - index)); + PageIndex base = PageIndex.wrap(bound(rawBase, 0, lastBase)); + + assertEq(base.slotUnbounded(index), base.add(index / Pages.SLOTS_PER_PAGE).slot(index % Pages.SLOTS_PER_PAGE)); + } + + /// @dev Element `offset` of the array is the slot `slot(offset)` returns. + function testFuzzWordsIndexSlotsOfPage(uint256 offset, uint256 stored, uint256 written) public { + offset = bound(offset, 0, Pages.SLOTS_PER_PAGE - 1); + PageIndex page = harness.page(); + bytes32 slot = bytes32(page.slot(offset)); + + vm.store(address(harness), slot, bytes32(stored)); + assertEq(harness.word(page, offset), stored); + + harness.setWord(page, offset, written); + assertEq(vm.load(address(harness), slot), bytes32(written)); + } + + function testWordsRejectsOffsetOutsidePage() public { + vm.expectRevert(stdError.indexOOBError); + // forge-lint: disable-next-line(unused-return) + harness.word(PageIndex.wrap(0), Pages.SLOTS_PER_PAGE); + } + + /// @dev The page after the one holding the last slot starts past the end of storage. + function testWordsRejectsPagePastStorage() public { + PageIndex past = Pages.fromSlot(type(uint256).max).add(1); + vm.expectRevert(stdError.arithmeticError); + // forge-lint: disable-next-line(unused-return) + harness.word(past, 0); + } +} diff --git a/test/Words.t.sol b/test/Words.t.sol new file mode 100644 index 0000000..ef46f6e --- /dev/null +++ b/test/Words.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13 <0.9.0; + +import {Test} from "forge-std/Test.sol"; + +import {Words} from "../src/utils/storage/Words.sol"; + +contract WordsHarness { + function read(uint256 slot) external view returns (uint256) { + return Words.ref(slot).value; + } + + function write(uint256 slot, uint256 value) external { + Words.ref(slot).value = value; + } +} + +contract WordsTest is Test { + WordsHarness internal harness; + + function setUp() public { + harness = new WordsHarness(); + } + + /// @dev The pointer reads and writes exactly the slot it was given. + function testFuzzPointerTargetsSlot(uint256 slot, uint256 stored, uint256 written) public { + vm.store(address(harness), bytes32(slot), bytes32(stored)); + assertEq(harness.read(slot), stored); + + harness.write(slot, written); + assertEq(vm.load(address(harness), bytes32(slot)), bytes32(written)); + } +}