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)); + } +}