Sitelet https://github.com/category-labs/monad-std/commit/b716412ea3b96dccf32234e5d9ab3d5961735263
Skip to content

Commit b716412

Browse files
committed
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.
1 parent 2e79c40 commit b716412

10 files changed

Lines changed: 286 additions & 5 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ jobs:
2020
- uses: actions/checkout@v6
2121
with:
2222
persist-credentials: false
23+
submodules: recursive
2324

24-
- name: Install Monad Foundry
25-
uses: category-labs/foundry-toolchain@v1
25+
- name: Install Foundry
26+
uses: foundry-rs/foundry-toolchain@v1
2627

2728
- name: Show Forge version
2829
run: forge --version

‎.gitmodules‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "lib/forge-std"]
2+
path = lib/forge-std
3+
url = https://github.com/foundry-rs/forge-std

‎README.md‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# 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)
22

3-
Monad Standard Library (`monad-std`) is a collection of Monad-specific interfaces and testing helpers for [Foundry](https://github.com/foundry-rs/foundry).
3+
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).
44

5-
It provides Solidity interfaces that track Monad runtime behavior and lightweight base contracts for ergonomic test usage.
5+
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.
66

77
## Install
88

@@ -18,7 +18,19 @@ forge install category-labs/monad-std
1818

1919
### `IReserveBalance`
2020

21-
[`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)).
21+
[`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)).
22+
23+
### `Pages`
24+
25+
[`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`.
26+
27+
This utility has not had an independent audit.
28+
29+
### `Words`
30+
31+
[`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`.
32+
33+
Like `Pages`, it has not had an independent audit.
2234

2335
### `MonadVm`
2436

‎foundry.lock‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"lib/forge-std": {
3+
"tag": {
4+
"name": "v1.16.2",
5+
"rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b"
6+
}
7+
}
8+
}

‎foundry.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,9 @@
22
src = "src"
33
out = "out"
44
libs = ["lib"]
5+
network = "monad"
6+
7+
[profile.ci.fuzz]
8+
runs = 10_000
59

610
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

‎lib/forge-std‎

Submodule forge-std added at bf647bd

‎src/utils/storage/Pages.sol‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13 <0.9.0;
3+
4+
/// @notice Index of a 128-slot storage page, as defined in MIP-8: `slot / 128`. See `Pages`.
5+
type PageIndex is uint256;
6+
7+
using {Pages.add, Pages.slot, Pages.slotUnbounded} for PageIndex global;
8+
9+
/// @notice Handle for a page. Declare it where the structure that uses the page lives. See `Pages.page`.
10+
struct PageHandle {
11+
uint256 _anchor;
12+
}
13+
14+
using {Pages.page} for PageHandle global;
15+
16+
/**
17+
* @title Pages
18+
* @notice Implements page and slot arithmetic for MIP-8 storage pages.
19+
* @dev MIP-8 groups 128 slots of 32 bytes into a 4 KiB page:
20+
*
21+
* page_index(slot) = slot / 128
22+
* offset(slot) = slot % 128
23+
* slot(page, offset) = page * 128 + offset
24+
*
25+
* MIP-8: https://github.com/monad-crypto/MIPs/blob/6e78a6ac39547882f9905fba86d2c794eb1768ef/MIPs/MIP-8.md
26+
*
27+
* Example usage:
28+
* ```
29+
* PageHandle internal ledger;
30+
*
31+
* PageIndex page = ledger.page();
32+
* Words.ref(page.slot(0)).value = total;
33+
* Words.ref(page.slotUnbounded(1 + i)).value = amount;
34+
* ```
35+
*/
36+
library Pages {
37+
uint256 internal constant SLOTS_PER_PAGE = 128;
38+
39+
/// @dev The offset passed to `slot` is 128 or more.
40+
error OffsetOutOfPage(uint256 offset);
41+
42+
/**
43+
* @notice Returns the page of a handle.
44+
* @dev The page that contains `keccak256(abi.encode(slot))`, where `slot` is the storage slot of
45+
* `handle`, whether `handle` is a state variable or a member of a struct, array, or mapping. A
46+
* dynamic array declared in place of `handle` would start its data there, so distinct declarations
47+
* get distinct pages. The page is derived from the slot of `handle`, not the page around it, and
48+
* the slot itself is never written.
49+
* @param handle Handle of the page.
50+
* @return The page.
51+
*/
52+
function page(PageHandle storage handle) internal pure returns (PageIndex) {
53+
uint256 anchor;
54+
assembly ("memory-safe") {
55+
anchor := handle.slot
56+
}
57+
return fromSlot(uint256(keccak256(abi.encode(anchor))));
58+
}
59+
60+
/**
61+
* @notice Returns the page that contains a storage slot.
62+
* @param storageSlot Storage slot to locate.
63+
* @return The page that contains the slot.
64+
*/
65+
function fromSlot(uint256 storageSlot) internal pure returns (PageIndex) {
66+
return PageIndex.wrap(storageSlot / SLOTS_PER_PAGE);
67+
}
68+
69+
/**
70+
* @notice Returns the page located `pages` pages after a base page.
71+
* @param base Base page.
72+
* @param pages Number of pages to add.
73+
* @return The selected page.
74+
*/
75+
function add(PageIndex base, uint256 pages) internal pure returns (PageIndex) {
76+
return PageIndex.wrap(PageIndex.unwrap(base) + pages);
77+
}
78+
79+
/**
80+
* @notice Returns the slot at an offset inside a page.
81+
* @dev Reverts with `OffsetOutOfPage` when `offset` is 128 or more. Use `slotUnbounded` to reach later pages.
82+
* @param base Page that holds the slot.
83+
* @param offset Position of the slot in the page, from zero through 127.
84+
* @return The storage slot.
85+
*/
86+
function slot(PageIndex base, uint256 offset) internal pure returns (uint256) {
87+
if (offset >= SLOTS_PER_PAGE) revert OffsetOutOfPage(offset);
88+
return slotUnbounded(base, offset);
89+
}
90+
91+
/**
92+
* @notice Returns the slot at `index`, counting from the first slot of a page, with no bound on `index`.
93+
* @dev Crosses into later pages when `index` is 128 or more.
94+
* @param base Page whose first slot is index zero.
95+
* @param index Number of slots after the first slot of the page.
96+
* @return The storage slot.
97+
*/
98+
function slotUnbounded(PageIndex base, uint256 index) internal pure returns (uint256) {
99+
return PageIndex.unwrap(base) * SLOTS_PER_PAGE + index;
100+
}
101+
}

‎src/utils/storage/Words.sol‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13 <0.9.0;
3+
4+
/// @notice One storage slot, reached through a pointer. See `Words.ref`.
5+
struct Word {
6+
uint256 value;
7+
}
8+
9+
/**
10+
* @title Words
11+
* @notice Turns a storage slot number into a storage pointer, so storage laid out by slot, such as
12+
* MIP-8 pages, is read and written without assembly at the call site.
13+
*
14+
* Example usage:
15+
* ```
16+
* PageHandle internal ledger;
17+
*
18+
* Words.ref(ledger.page().slot(0)).value = total;
19+
* ```
20+
*/
21+
library Words {
22+
/**
23+
* @notice Returns a pointer to the word at a storage slot.
24+
* @param slot Storage slot of the word.
25+
* @return word Pointer to the word.
26+
*/
27+
function ref(uint256 slot) internal pure returns (Word storage word) {
28+
assembly ("memory-safe") {
29+
word.slot := slot
30+
}
31+
}
32+
}

‎test/Pages.t.sol‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13 <0.9.0;
3+
4+
import {Test, stdError} from "forge-std/Test.sol";
5+
6+
import {PageIndex, PageHandle, Pages} from "../src/utils/storage/Pages.sol";
7+
8+
contract PagesHarness {
9+
PageHandle internal handle;
10+
mapping(uint256 => PageHandle) internal handles;
11+
12+
function page() external view returns (PageIndex) {
13+
return handle.page();
14+
}
15+
16+
function page(uint256 key) external view returns (PageIndex) {
17+
return handles[key].page();
18+
}
19+
20+
function add(PageIndex base, uint256 pages) external pure returns (PageIndex) {
21+
return Pages.add(base, pages);
22+
}
23+
24+
function slot(PageIndex base, uint256 offset) external pure returns (uint256) {
25+
return Pages.slot(base, offset);
26+
}
27+
28+
function slotUnbounded(PageIndex base, uint256 index) external pure returns (uint256) {
29+
return Pages.slotUnbounded(base, index);
30+
}
31+
}
32+
33+
contract PagesTest is Test {
34+
PagesHarness internal harness;
35+
36+
function setUp() public {
37+
harness = new PagesHarness();
38+
}
39+
40+
/// @dev Pins the layout to where a dynamic array declared in place of the handle would start its
41+
/// data, rounded down to the page. The harness declares `handle` at slot 0, so that is
42+
/// `keccak256(abi.encode(uint256(0)))` with its page offset cleared.
43+
function testPageStartsAtArrayDataLocation() public view {
44+
assertEq(harness.page().slot(0), 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e500);
45+
}
46+
47+
/// @dev The page follows the slot of the handle wherever it is declared. The harness declares
48+
/// `handles` at slot 1, so `handles[key]` lives at `keccak256(abi.encode(key, uint256(1)))`.
49+
function testFuzzPageFollowsHandleSlot(uint256 key) public view {
50+
uint256 handleSlot = uint256(keccak256(abi.encode(key, uint256(1))));
51+
PageIndex expected = Pages.fromSlot(uint256(keccak256(abi.encode(handleSlot))));
52+
assertEq(PageIndex.unwrap(harness.page(key)), PageIndex.unwrap(expected));
53+
}
54+
55+
function testFuzzFromSlotReturnsContainingPage(uint256 storageSlot) public pure {
56+
assertEq(Pages.fromSlot(storageSlot).slot(storageSlot % Pages.SLOTS_PER_PAGE), storageSlot);
57+
}
58+
59+
/// @dev Offsets outside a page. The table test below runs once per entry.
60+
uint256[] public fixtureOffset = [Pages.SLOTS_PER_PAGE, type(uint256).max];
61+
62+
function tableSlotRejectsOffsetOutsidePage(uint256 offset) public {
63+
vm.expectRevert(abi.encodeWithSelector(Pages.OffsetOutOfPage.selector, offset));
64+
// forge-lint: disable-next-line(unused-return)
65+
harness.slot(PageIndex.wrap(0), offset);
66+
}
67+
68+
/// @dev Counting `index` slots from the start of a page ends `index / 128` pages later, at
69+
/// offset `index % 128`. `lastBase` is the last page the index fits in, so counting from the
70+
/// page after it must revert.
71+
function testFuzzSlotUnboundedCrossesPages(uint256 rawBase, uint256 index) public {
72+
uint256 lastBase = PageIndex.unwrap(Pages.fromSlot(type(uint256).max - index));
73+
PageIndex base = PageIndex.wrap(bound(rawBase, 0, lastBase));
74+
75+
assertEq(base.slotUnbounded(index), base.add(index / Pages.SLOTS_PER_PAGE).slot(index % Pages.SLOTS_PER_PAGE));
76+
vm.expectRevert(stdError.arithmeticError);
77+
// forge-lint: disable-next-line(unused-return)
78+
harness.slotUnbounded(PageIndex.wrap(lastBase + 1), index);
79+
}
80+
81+
function testAddRejectsOverflow() public {
82+
vm.expectRevert(stdError.arithmeticError);
83+
// forge-lint: disable-next-line(unused-return)
84+
harness.add(PageIndex.wrap(type(uint256).max), 1);
85+
}
86+
}

‎test/Words.t.sol‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity >=0.8.13 <0.9.0;
3+
4+
import {Test} from "forge-std/Test.sol";
5+
6+
import {Words} from "../src/utils/storage/Words.sol";
7+
8+
contract WordsHarness {
9+
function read(uint256 slot) external view returns (uint256) {
10+
return Words.ref(slot).value;
11+
}
12+
13+
function write(uint256 slot, uint256 value) external {
14+
Words.ref(slot).value = value;
15+
}
16+
}
17+
18+
contract WordsTest is Test {
19+
WordsHarness internal harness;
20+
21+
function setUp() public {
22+
harness = new WordsHarness();
23+
}
24+
25+
/// @dev The pointer reads and writes exactly the slot it was given.
26+
function testFuzzPointerTargetsSlot(uint256 slot, uint256 stored, uint256 written) public {
27+
vm.store(address(harness), bytes32(slot), bytes32(stored));
28+
assertEq(harness.read(slot), stored);
29+
30+
harness.write(slot, written);
31+
assertEq(vm.load(address(harness), bytes32(slot)), bytes32(written));
32+
}
33+
}

0 commit comments

Comments
 (0)