Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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`

Expand Down
8 changes: 8 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"lib/forge-std": {
"tag": {
"name": "v1.16.2",
"rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b"
}
}
}
4 changes: 4 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions lib/forge-std
Submodule forge-std added at bf647b
109 changes: 109 additions & 0 deletions src/utils/storage/Pages.sol
Original file line number Diff line number Diff line change
@@ -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
}
}
}
28 changes: 28 additions & 0 deletions src/utils/storage/Words.sol
Original file line number Diff line number Diff line change
@@ -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
}
}
}
99 changes: 99 additions & 0 deletions test/Pages.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
33 changes: 33 additions & 0 deletions test/Words.t.sol
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading