├── .gas-snapshot ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .gitmodules ├── LICENSE.md ├── Makefile ├── README.md ├── assets └── sparse_arr.png ├── foundry.toml ├── src └── SparseArrLib.sol └── test ├── SparseArrLib.t.sol └── invariants └── SparseArrLib.t.sol /.gas-snapshot: -------------------------------------------------------------------------------- 1 | SparseArrLib_UnitTest:test_deleteAt_doubleDelete_works() (gas: 210865) 2 | SparseArrLib_UnitTest:test_deleteAt_manyDeletions_works() (gas: 357747) 3 | SparseArrLib_UnitTest:test_deleteAt_outOfBounds_reverts() (gas: 125285) 4 | SparseArrLib_UnitTest:test_deleteAt_singleDelete_works() (gas: 189383) 5 | SparseArrLib_UnitTest:test_get_outOfBounds_reverts() (gas: 5720) 6 | SparseArrLib_UnitTest:test_pop_works() (gas: 149408) 7 | SparseArrLib_UnitTest:test_push_afterDelete_works() (gas: 213859) 8 | SparseArrLib_UnitTest:test_push_works() (gas: 142042) 9 | SparseArrLib_UnitTest:test_safeDeleteAt_deletionUnderflow_reverts() (gas: 170039) 10 | SparseArrLib_UnitTest:test_store_appendAfterDeletion_works() (gas: 166414) 11 | SparseArrLib_UnitTest:test_store_append_works() (gas: 47499) 12 | SparseArrLib_UnitTest:test_store_outOfBounds_reverts() (gas: 8039) 13 | SparseArrLib_UnitTest:test_store_overwriteAfterDeletion_works() (gas: 142871) 14 | SparseArrLib_UnitTest:test_store_overwrite_works() (gas: 48682) 15 | SparseArrLib_InvariantTest:invariant_length() (runs: 256, calls: 3840, reverts: 1239) 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Forge Tests 2 | 3 | on: push 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | check: 10 | strategy: 11 | fail-fast: true 12 | 13 | name: Foundry project 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Install Foundry 21 | uses: foundry-rs/foundry-toolchain@v1 22 | with: 23 | version: nightly 24 | 25 | - name: Run Forge build 26 | run: | 27 | forge --version 28 | forge build --sizes 29 | id: build 30 | 31 | - name: Run Forge tests 32 | run: | 33 | forge test -vvv 34 | id: test 35 | 36 | - name: Lint 37 | run: | 38 | forge fmt 39 | git diff --exit-code . 40 | 41 | - name: Gas snapshot 42 | run: | 43 | forge snapshot 44 | git diff --exit-code .gas-snapshot 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores development broadcast logs 6 | !/broadcast 7 | /broadcast/*/31337/ 8 | /broadcast/**/dry-run/ 9 | 10 | # Dotenv file 11 | .env 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | "THE BEER-WARE LICENSE" (Revision 42): 4 | wrote this file. As long as you retain this notice you 5 | can do whatever you want with this stuff. If we meet some day, and you think 6 | this stuff is worth it, you can buy me a beer in return. 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | tests: 2 | forge test -vvv 3 | 4 | snapshot: tests 5 | forge snapshot 6 | 7 | precommit: snapshot 8 | forge fmt 9 | 10 | docs: 11 | forge doc && forge doc --serve 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `sparse-arr-lib` [![License](https://img.shields.io/badge/License-Beerware-green)](./LICENSE.md) 2 | 3 | > **Warning** 4 | > This library is unfinished & is a WIP. Use discretion, don't test in prod. 5 | 6 | 7 | A library to assist with utilizing sparse storage arrays in Solidity. 8 | 9 | ## Rationale 10 | 11 | In Solidity, it is impossible to delete an element from the middle of a storage array without shifting all elements 12 | following the deleted element, disrupting order, or leaving a gap. This library is an experiment to enable the use of a 13 | **sparse array**-esq data structure to combat this shortcoming as efficiently as possible. 14 | 15 | ### Intended Behavior 16 | ![Sparse Demo](./assets/sparse_arr.png) 17 | 18 | ### How does it work? 19 | Before any elements are deleted, the library will treat the array as if it is normal- Elements will both be retrieved and 20 | stored at their canonical indicies (the **canonical index** is the true index of the element within the array, sans any offsets.) 21 | 22 | When the first element is deleted with `deleteAt`, a sub array of deleted elements is created at slot `keccak256(abi.encode(arrSlot, 0x535041525345))`. 23 | This array contains the canonical indicies of the deleted elements. 24 | 25 | If the subarray of deleted elements contains any values when a call to `store`, `push`, or `get` is made, a binary search will be performed over the array 26 | in order to find the nearest offset that is less than or equal to the supplied relative index. This offset will be added to the relative index in order 27 | to retrieve the canonical index. 28 | 29 | ### Current limitations: 30 | - Elements must be deleted linearly and in continuously ascending order. (i.e., one cannot delete index `5` and then `3`). This is due to the fact that the deleted elements subarray must be sorted for the binary search to work properly. 31 | - BUG: An element may not be deleted at the same relative index more than once. 32 | 33 | ## Usage 34 | 35 | To view docs, run `forge doc --serve` and navigate to `http://localhost:3000/`. 36 | 37 | ## Contributions 38 | 39 | Contributions are welcome! 40 | -------------------------------------------------------------------------------- /assets/sparse_arr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clabby/sparse-arr-lib/de65260d2c0e8c7ddcd9dc868e0e42faf00f5570/assets/sparse_arr.png -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'src' 3 | out = 'out' 4 | libs = ['lib'] 5 | 6 | [fmt] 7 | bracket_spacing = true 8 | -------------------------------------------------------------------------------- /src/SparseArrLib.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Beerware 2 | pragma solidity ^0.8.17; 3 | 4 | /// @title SparseArrLib 5 | /// @author clabby 6 | /// @author N0xMare 7 | /// @notice A library for handling sparse storage arrays. 8 | /// ───────────────────────────────────────────────────── 9 | /// TODO: 10 | /// - [ ] Finalize core logic. 11 | /// - [ ] Optimize 12 | /// - [x] Add tests for core `store` / `get` / `deleteAt` logic. 13 | /// - [ ] Fix known bugs with edges / deleting the same sparse (i.e. non-canonical) index twice. 14 | /// - [x] Fix certain cases where the binary search can recurse infinitely. 15 | /// - [ ] Invariant tests 16 | /// - [x] After `n` `store` operations and `m` `deleteAt` operations, the array length should be `n - m`. 17 | /// - [ ] ... 18 | /// - [ ] Gas profiling over a wide range of array sizes / deletions. 19 | /// - [x] Add utility functions such as `pop`, `push`, etc. 20 | library SparseArrLib { 21 | //////////////////////////////////////////////////////////////// 22 | // Sparse Array Wranglin' // 23 | //////////////////////////////////////////////////////////////// 24 | 25 | error DeletionUnderflow(); 26 | 27 | /// @notice Stores a value within a sparse array 28 | /// @param slot The storage slot of the array to write to. 29 | /// @param index The index within the sparse array to write `contents` to. 30 | /// @param contents The value to write to the array at `index`. 31 | function store(bytes32 slot, uint256 index, bytes32 contents) internal { 32 | // Compute the slot for the given index in the array stored at `slot` 33 | bytes32 rawTargetSlot = computeIndexSlot(slot, index); 34 | // Get the sparse offset at the given index 35 | uint256 offset = getSparseOffset(slot, index); 36 | 37 | assembly { 38 | // Grab the sparse length of the array from storage. 39 | let length := sload(slot) 40 | 41 | // Do not allow out of bounds writes. 42 | if gt(index, length) { 43 | // Store the `Panic(uint256)` selector in scratch space 44 | mstore(0x00, 0x4e487b71) 45 | // Store the out of bounds panic code in scratch space. 46 | mstore(0x20, 0x20) 47 | // Revert with `Panic(32)` 48 | revert(0x1c, 0x24) 49 | } 50 | 51 | // If the index is equal to the length, then we are appending to the array. 52 | // Otherwise, we are overwriting an existing value, so we don't need to update 53 | // the sparse length. 54 | if eq(index, length) { sstore(slot, add(length, 0x01)) } 55 | 56 | // Store the contents at the computed slot. 57 | sstore(add(rawTargetSlot, offset), contents) 58 | } 59 | } 60 | 61 | /// @notice Retrieves a value from a sparse array at a given index. 62 | /// TODO: Explain what's going on here. 63 | /// @param slot The storage slot of the array to read from. 64 | /// @param index The index within the array to read from. 65 | /// @return _value The value at the given index in the array. 66 | function get(bytes32 slot, uint256 index) internal view returns (bytes32 _value) { 67 | assembly { 68 | // If the requested index is greater than or equal to the length of the array, revert. 69 | if iszero(lt(index, sload(slot))) { 70 | // Store the `Panic(uint256)` selector in scratch space 71 | mstore(0x00, 0x4e487b71) 72 | // Store the out of bounds panic code in scratch space. 73 | mstore(0x20, 0x20) 74 | // Revert with `Panic(32)` 75 | revert(0x1c, 0x24) 76 | } 77 | } 78 | 79 | // Compute the slot for the given index in the array stored at `slot` 80 | bytes32 rawTargetSlot = computeIndexSlot(slot, index); 81 | // Get the sparse offset at the given index 82 | uint256 offset = getSparseOffset(slot, index); 83 | 84 | assembly { 85 | // Fetch the value at `index` within the sparse array. 86 | _value := sload(add(rawTargetSlot, offset)) 87 | } 88 | } 89 | 90 | /// @notice Removes an element from the array at the given index and adds a new 91 | /// sparse offset to the deleted elements subarray. 92 | /// @dev WARNING! This function will not revert when deleting an element with a 93 | /// canonical index less than the largest deleted canonical index. If this 94 | /// is done, the data structure will break! Only use this function if you 95 | /// ensure that this will never happen elsewhere in your code. 96 | /// @param slot The storage slot of the array to delete the element from. 97 | /// @param index The index of the element to delete. 98 | function deleteAt(bytes32 slot, uint256 index) internal { 99 | // Compute the storage slot of the deleted elements subarray. 100 | bytes32 sparseSlot = computeSparseSlot(slot); 101 | 102 | // TODO: Handle deletions at the same relative index twice. 103 | // TODO: Do not require linear progression of deletions (? - this would kinda suck to do) 104 | // TODO: Ensure edge deletions are handled correctly. 105 | assembly { 106 | let length := sload(slot) 107 | 108 | // If the requested index is greater than or equal to the array length, revert. 109 | // Out of bounds deletions are not allowed 110 | if iszero(lt(index, length)) { 111 | // Store the `Panic(uint256)` selector in scratch space 112 | mstore(0x00, 0x4e487b71) 113 | // Store the out of bounds panic code in scratch space. 114 | mstore(0x20, 0x20) 115 | // Revert with `Panic(32)` 116 | revert(0x1c, 0x24) 117 | } 118 | 119 | // Decrement the sparse length of the target array by 1. 120 | sstore(slot, sub(length, 0x01)) 121 | 122 | // Fetch the total offset from the deleted elements subarray 123 | // (the total offset is just the length) 124 | let totalOffset := sload(sparseSlot) 125 | 126 | // Increment the total offset of the deleted elements subarray by 1. 127 | let newTotalOffset := add(totalOffset, 0x01) 128 | sstore(sparseSlot, newTotalOffset) 129 | 130 | // Store the sparse slot in scratch space for hashing. 131 | mstore(0x00, sparseSlot) 132 | 133 | // Store the canonical index of the deleted element as well as the sparse 134 | // offset of elements proceeding it. 135 | // Canonical index = index + sparseOffset 136 | sstore(add(totalOffset, keccak256(0x00, 0x20)), add(index, newTotalOffset)) 137 | } 138 | } 139 | 140 | /// @notice Removes an element from the array at the given index and adds a new 141 | /// sparse offset to the deleted elements subarray. 142 | /// @dev This function *will* revert if the canonical index of `index` is less than 143 | /// the largest deleted canonical index. 144 | /// @param slot The storage slot of the array to delete the element from. 145 | /// @param index The index of the element to delete. 146 | function safeDeleteAt(bytes32 slot, uint256 index) internal { 147 | // Compute the storage slot of the deleted elements subarray. 148 | bytes32 sparseSlot = computeSparseSlot(slot); 149 | 150 | // TODO: Handle deletions at the same relative index twice. 151 | // TODO: Do not require linear progression of deletions (? - this would kinda suck to do) 152 | // TODO: Ensure edge deletions are handled correctly. 153 | assembly { 154 | let length := sload(slot) 155 | 156 | // If the requested index is greater than or equal to the array length, revert. 157 | // Out of bounds deletions are not allowed 158 | if iszero(lt(index, length)) { 159 | // Store the `Panic(uint256)` selector in scratch space 160 | mstore(0x00, 0x4e487b71) 161 | // Store the out of bounds panic code in scratch space. 162 | mstore(0x20, 0x20) 163 | // Revert with `Panic(32)` 164 | revert(0x1c, 0x24) 165 | } 166 | 167 | // Store the sparse slot in scratch space for hashing. 168 | mstore(0x00, sparseSlot) 169 | 170 | // Get the slot of the first element in the deleted elements subarray 171 | let sparseStartSlot := keccak256(0x00, 0x20) 172 | 173 | // Fetch the total offset from the deleted elements subarray 174 | // (the total offset is just the length) 175 | let totalOffset := sload(sparseSlot) 176 | 177 | // Do not allow deletion of a canonical index that is less than the largest deleted canonical index. 178 | if lt(add(index, totalOffset), sload(add(sparseStartSlot, sub(totalOffset, 0x01)))) { 179 | // Store the `DeletionUnderflow()` selector in scratch space 180 | mstore(0x00, 0xdb199ace) 181 | // Revert with `DeletionUnderflow()` 182 | revert(0x1c, 0x04) 183 | } 184 | 185 | // Decrement the sparse length of the target array by 1. 186 | sstore(slot, sub(length, 0x01)) 187 | 188 | // Increment the total offset of the deleted elements subarray by 1. 189 | let newTotalOffset := add(totalOffset, 0x01) 190 | sstore(sparseSlot, newTotalOffset) 191 | 192 | // Store the canonical index of the deleted element as well as the sparse 193 | // offset of elements proceeding it. 194 | // Canonical index = index + sparseOffset 195 | sstore(add(totalOffset, sparseStartSlot), add(index, newTotalOffset)) 196 | } 197 | } 198 | 199 | /// @notice Push a value onto the end of the array. 200 | /// @param slot The storage slot of the array to push to. 201 | /// @param contents The value to push onto the array. 202 | function push(bytes32 slot, bytes32 contents) internal { 203 | uint256 length; 204 | assembly { 205 | length := sload(slot) 206 | } 207 | 208 | // Compute the slot for the given index in the array stored at `slot` 209 | bytes32 rawTargetSlot = computeIndexSlot(slot, length); 210 | // Get the sparse offset at the given index 211 | uint256 offset = getSparseOffset(slot, length); 212 | 213 | assembly { 214 | // We are appending to the array- increment the length by 1. 215 | sstore(slot, add(length, 0x01)) 216 | 217 | // Store the contents at the computed slot. 218 | sstore(add(rawTargetSlot, offset), contents) 219 | } 220 | } 221 | 222 | /// @notice Pop, removes the last item of the sparse array if array length is greater than 0 223 | /// @param slot The storage slot of the array to delete the element from. 224 | function pop(bytes32 slot) internal { 225 | assembly { 226 | let length := sload(slot) 227 | if iszero(length) { 228 | // Store the `Panic(uint256)` selector in scratch space 229 | mstore(0x00, 0x4e487b71) 230 | // Store the out of bounds panic code in scratch space. 231 | mstore(0x20, 0x20) 232 | // Revert with `Panic(32)` 233 | revert(0x1c, 0x24) 234 | } 235 | sstore(slot, sub(length, 0x01)) 236 | } 237 | } 238 | 239 | //////////////////////////////////////////////////////////////// 240 | // Helpers // 241 | //////////////////////////////////////////////////////////////// 242 | 243 | /// @notice Performs a binary search on all the deleted elements in the array to find 244 | /// the sparse offset of the given index. 245 | /// @param slot The storage slot of the array to read from. 246 | /// @param index The index within the array to read from. 247 | /// @return _offset The sparse offset of the given index. 248 | function getSparseOffset(bytes32 slot, uint256 index) internal view returns (uint256 _offset) { 249 | // Compute the storage slot for the array of deleted elements. 250 | bytes32 sparseSlot = computeSparseSlot(slot); 251 | 252 | assembly { 253 | // Search for sparse offset of the given index by performing a binary 254 | // search on the deleted elements in the array. 255 | let low := 0x00 256 | let high := sload(sparseSlot) 257 | 258 | // If low and high are not equal, elements within the sparse array have been 259 | // deleted. We need to perform a binary search to find the sparse offset at 260 | // the given index. 261 | if xor(low, high) { 262 | // Store the sparse slot in scratch space for hashing 263 | mstore(0x00, sparseSlot) 264 | // Get the slot of the first element within the deleted elements array. 265 | sparseSlot := keccak256(0x00, 0x20) 266 | 267 | // Only perform a search for the offset if the index >= (firstDeletionIndex - 1) 268 | // Otherwise, the offset is always zero. 269 | if iszero(lt(index, sub(sload(sparseSlot), 0x01))) { 270 | // Subtract one from the high bound to set it to the final *index* rather than 271 | // the length of the deleted elements subarray. 272 | high := sub(high, 0x01) 273 | 274 | // TODO: Optimize inner loop 275 | for { 276 | // Calculate the midpoint of [low, high] with a floor div 277 | let mid := shr(0x01, add(low, high)) 278 | // Get the canonical index of the midpoint in the deleted elements subarray. 279 | let midIndex := sload(add(sparseSlot, mid)) 280 | // Get the sparse offset of the midpoint in the deleted elements subarray. 281 | _offset := add(mid, 0x01) 282 | } iszero(gt(low, high)) { 283 | // Calculate the midpoint of [low, high] with a floor div 284 | mid := shr(0x01, add(low, high)) 285 | // Get the canonical index of the midpoint in the deleted elements subarray. 286 | midIndex := sload(add(sparseSlot, mid)) 287 | // Get the sparse offset of the midpoint in the deleted elements subarray. 288 | _offset := add(mid, 0x01) 289 | } { 290 | // Calculate the canonical index 291 | let canonicalIndex := add(index, _offset) 292 | 293 | // If the canonical index is less than the index at the midpoint, set the high bound to mid - 1 294 | if lt(canonicalIndex, midIndex) { 295 | high := sub(mid, 0x01) 296 | continue 297 | } 298 | // If the canonical index is greater than the index at the midpoint, set the low bound to mid + 1 299 | if gt(canonicalIndex, midIndex) { 300 | low := add(mid, 0x01) 301 | continue 302 | } 303 | // If the indexes are equal, we've found our offset! 304 | break 305 | } 306 | } 307 | } 308 | } 309 | } 310 | 311 | /// @notice Computes the canonical storage slot for an `index` within an array at `slot`. 312 | /// @dev Will not revert if the index is out of bounds of the current array size. 313 | /// @param slot The storage slot of the array. 314 | /// @param index The desired index within the array. 315 | /// @return _slot The canonical storage slot for the given `index`. 316 | function computeIndexSlot(bytes32 slot, uint256 index) internal pure returns (bytes32 _slot) { 317 | assembly { 318 | // Store the array's length slot in scratch space 319 | mstore(0x00, slot) 320 | // Compute the slot for the index within the array 321 | _slot := add(keccak256(0x00, 0x20), index) 322 | } 323 | } 324 | 325 | /// @notice Computes the storage slot for the sparse offset of an array at `slot`. 326 | /// @param slot The storage slot of the array. 327 | /// @return _slot The storage slot for the sparse offset of the array. 328 | function computeSparseSlot(bytes32 slot) internal pure returns (bytes32 _slot) { 329 | assembly { 330 | // Store the array's length slot in scratch space @ 0x00 331 | mstore(0x00, slot) 332 | // Store the sparse magic bytes in scratch space @ 0x20 333 | mstore(0x20, 0x535041525345) 334 | // Compute the slot for the sparse offset of the array 335 | _slot := keccak256(0x00, 0x40) 336 | } 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /test/SparseArrLib.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Beerware 2 | pragma solidity ^0.8.17; 3 | 4 | import { Test } from "forge-std/Test.sol"; 5 | import { SparseArrLib } from "../src/SparseArrLib.sol"; 6 | 7 | contract SparseArrLib_UnitTest is Test { 8 | /// @notice 4byte error selector for `Panic(uint256)` 9 | bytes4 internal constant PANIC_SELECTOR = 0x4e487b71; 10 | 11 | /// @notice Test array. 12 | uint256[] public arr; 13 | 14 | error DeletionUnderflow(); 15 | 16 | //////////////////////////////////////////////////////////////// 17 | // `store` tests // 18 | //////////////////////////////////////////////////////////////// 19 | 20 | /// @notice Tests that appending to an array at the next expected slot works as expected. 21 | function test_store_append_works() public { 22 | bytes32 slot = _getArrSlot(); 23 | 24 | SparseArrLib.store(slot, 0, b(1)); 25 | assertEq(SparseArrLib.get(slot, 0), b(1)); 26 | } 27 | 28 | /// @notice Tests that overwriting a value at an existing, non-zero slot works as expected. 29 | function test_store_overwrite_works() public { 30 | bytes32 slot = _getArrSlot(); 31 | 32 | SparseArrLib.store(slot, 0, b(1)); 33 | assertEq(SparseArrLib.get(slot, 0), b(1)); 34 | SparseArrLib.store(slot, 0, b(2)); 35 | assertEq(SparseArrLib.get(slot, 0), b(2)); 36 | } 37 | 38 | /// @notice Tests that appending a value to the array after a deletion works as expected. 39 | function test_store_appendAfterDeletion_works() public { 40 | bytes32 slot = _getArrSlot(); 41 | 42 | // Store 3 elements in the sparse array. 43 | for (uint256 i; i < 3; ++i) { 44 | bytes32 ins = b(i + 1); 45 | SparseArrLib.store(slot, i, ins); 46 | assertEq(SparseArrLib.get(slot, i), ins); 47 | } 48 | 49 | // Assert that the length is correct. 50 | assertEq(arr.length, 3); 51 | 52 | // Delete index 1 53 | SparseArrLib.deleteAt(slot, 1); 54 | 55 | // Assert that the length is correct. 56 | assertEq(arr.length, 2); 57 | 58 | assertEq(SparseArrLib.get(slot, 0), b(1)); 59 | assertEq(SparseArrLib.get(slot, 1), b(3)); 60 | 61 | // Append a new element after deletion of index 1. 62 | SparseArrLib.store(slot, 2, b(4)); 63 | 64 | // Assert that the length is correct. 65 | assertEq(arr.length, 3); 66 | 67 | assertEq(SparseArrLib.get(slot, 0), b(1)); 68 | assertEq(SparseArrLib.get(slot, 1), b(3)); 69 | assertEq(SparseArrLib.get(slot, 2), b(4)); 70 | } 71 | 72 | /// @notice Tests that overwriting a value in the array after a deletion works as expected. 73 | function test_store_overwriteAfterDeletion_works() public { 74 | bytes32 slot = _getArrSlot(); 75 | 76 | // Store 3 elements in the sparse array. 77 | for (uint256 i; i < 3; ++i) { 78 | bytes32 ins = b(i + 1); 79 | SparseArrLib.store(slot, i, ins); 80 | assertEq(SparseArrLib.get(slot, i), ins); 81 | } 82 | 83 | // Assert that the length is correct. 84 | assertEq(arr.length, 3); 85 | 86 | // Delete index 1 87 | SparseArrLib.deleteAt(slot, 1); 88 | 89 | assertEq(SparseArrLib.get(slot, 0), b(1)); 90 | assertEq(SparseArrLib.get(slot, 1), b(3)); 91 | 92 | // Assert that the length is correct. 93 | assertEq(arr.length, 2); 94 | 95 | // Append a new element after deletion of index 1. 96 | SparseArrLib.store(slot, 1, b(4)); 97 | 98 | assertEq(SparseArrLib.get(slot, 0), b(1)); 99 | assertEq(SparseArrLib.get(slot, 1), b(4)); 100 | 101 | // Assert that the length did not change after overwriting index 1 post-deletion. 102 | assertEq(arr.length, 2); 103 | } 104 | 105 | /// @notice Tests that attempting to write to an out of bounds index causes `store` to revert 106 | /// with the expected data. 107 | function test_store_outOfBounds_reverts() public { 108 | vm.expectRevert(abi.encodeWithSelector(PANIC_SELECTOR, 0x20)); 109 | SparseArrLib.store(_getArrSlot(), 1, b(1)); 110 | } 111 | 112 | //////////////////////////////////////////////////////////////// 113 | // `get` tests // 114 | //////////////////////////////////////////////////////////////// 115 | 116 | /// @notice Tests that attempting to retrieve a value at an out of bounds index causes `get` to 117 | /// revert with the expected data. 118 | function test_get_outOfBounds_reverts() public { 119 | vm.expectRevert(abi.encodeWithSelector(PANIC_SELECTOR, 0x20)); 120 | SparseArrLib.get(_getArrSlot(), 0); 121 | } 122 | 123 | //////////////////////////////////////////////////////////////// 124 | // `deleteAt` tests // 125 | //////////////////////////////////////////////////////////////// 126 | 127 | /// @notice Tests that after deleting a single value from a sparse array, the indexes of the 128 | /// values are shifted appropriately. 129 | function test_deleteAt_singleDelete_works() public { 130 | bytes32 slot = _getArrSlot(); 131 | 132 | // Store 5 elements in the sparse array 133 | for (uint256 i; i < 5; ++i) { 134 | bytes32 ins = b(i + 1); 135 | SparseArrLib.store(slot, i, ins); 136 | assertEq(SparseArrLib.get(slot, i), ins); 137 | } 138 | 139 | // Assert that the length is correct. 140 | assertEq(arr.length, 5); 141 | 142 | // Delete element at index 1 143 | SparseArrLib.deleteAt(slot, 1); 144 | 145 | // Assert that the length is correct. 146 | assertEq(arr.length, 4); 147 | 148 | // Assert that index 0 retained its original value. 149 | assertEq(SparseArrLib.get(slot, 0), b(1)); 150 | // Assert that index 1 now contains the value that used to be at index 2 151 | assertEq(SparseArrLib.get(slot, 1), b(3)); 152 | // Assert that index 2 now contains the value that used to be at index 3 153 | assertEq(SparseArrLib.get(slot, 2), b(4)); 154 | // Assert that index 3 now contains the value that used to be at index 4 155 | assertEq(SparseArrLib.get(slot, 3), b(5)); 156 | } 157 | 158 | /// @notice Tests that after deleting two values from a sparse array, the indexes of the 159 | /// values are shifted appropriately. 160 | function test_deleteAt_doubleDelete_works() public { 161 | bytes32 slot = _getArrSlot(); 162 | 163 | // Store 5 elements in the sparse array 164 | for (uint256 i; i < 5; ++i) { 165 | bytes32 ins = bytes32(uint256(i + 1)); 166 | SparseArrLib.store(slot, i, ins); 167 | assertEq(SparseArrLib.get(slot, i), ins); 168 | } 169 | 170 | // Assert that the length is correct 171 | assertEq(arr.length, 5); 172 | 173 | // Delete elements at canonical index 1 & 3 (adj: 1 & 2) 174 | // . og: [1, 2, 3, 4, 5] 175 | SparseArrLib.deleteAt(slot, 1); // new: [1, 3, 4, 5] 176 | SparseArrLib.deleteAt(slot, 2); // new: [1, 3, 5] 177 | 178 | // Assert that index 0 retained its original value. 179 | assertEq(SparseArrLib.get(slot, 0), b(1)); 180 | // Assert that index 1 now contains the value that used to be at index 2 181 | assertEq(SparseArrLib.get(slot, 1), b(3)); 182 | // Assert that index 2 now contains the value that used to be at index 4 183 | assertEq(SparseArrLib.get(slot, 2), b(5)); 184 | 185 | // Assert that the length is correct 186 | assertEq(arr.length, 3); 187 | } 188 | 189 | /// @notice Tests that after deleting multiple values from a sparse array, the indexes of the 190 | /// values are shifted appropriately. 191 | function test_deleteAt_manyDeletions_works() public { 192 | bytes32 slot = _getArrSlot(); 193 | 194 | // Insert 10 elements into the sparse array 195 | for (uint256 i; i < 10; ++i) { 196 | bytes32 ins = bytes32(i); 197 | SparseArrLib.store(slot, i, ins); 198 | assertEq(SparseArrLib.get(slot, i), ins); 199 | } 200 | 201 | // Assert that the length is correct 202 | assertEq(arr.length, 10); 203 | 204 | // Delete elements at index 1, 3, 5, & 6 205 | // . og: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 206 | SparseArrLib.deleteAt(slot, 1); // new: [0, 2, 3, 4, 5, 6, 7, 8, 9] 207 | SparseArrLib.deleteAt(slot, 3); // new: [0, 2, 3, 5, 6, 7, 8, 9] 208 | SparseArrLib.deleteAt(slot, 5); // new: [0, 2, 3, 5, 6, 8, 9] 209 | SparseArrLib.deleteAt(slot, 6); // new: [0, 2, 3, 5, 6, 8] 210 | 211 | // Assert that the values at each index were properly shifted (if any shifting was necessary). 212 | assertEq(SparseArrLib.get(slot, 0), b(0)); 213 | assertEq(SparseArrLib.get(slot, 1), b(2)); 214 | assertEq(SparseArrLib.get(slot, 2), b(3)); 215 | assertEq(SparseArrLib.get(slot, 3), b(5)); 216 | assertEq(SparseArrLib.get(slot, 4), b(6)); 217 | assertEq(SparseArrLib.get(slot, 5), b(8)); 218 | 219 | // Assert that the length is correct 220 | assertEq(arr.length, 6); 221 | } 222 | 223 | /// @notice Tests that attempting to delete an out of bounds index causes `deleteAt` to revert 224 | function test_deleteAt_outOfBounds_reverts() public { 225 | bytes32 slot = _getArrSlot(); 226 | 227 | // Store 5 elements in the sparse array 228 | for (uint256 i; i < 5; ++i) { 229 | bytes32 ins = bytes32(i); 230 | SparseArrLib.store(slot, i, ins); 231 | assertEq(SparseArrLib.get(slot, i), ins); 232 | } 233 | 234 | // Assert that the length is correct. 235 | assertEq(arr.length, 5); 236 | 237 | vm.expectRevert(abi.encodeWithSelector(PANIC_SELECTOR, 0x20)); 238 | 239 | // Delete element at index 5 (Out of Bounds), revert 240 | SparseArrLib.deleteAt(slot, 5); 241 | } 242 | 243 | //////////////////////////////////////////////////////////////// 244 | // `safeDeleteAt` tests // 245 | //////////////////////////////////////////////////////////////// 246 | 247 | /// @notice Tests that `safeDeleteAt` reverts as expected when attempting to delete a canonical 248 | /// index that is less than the largest deleted canonical index. 249 | function test_safeDeleteAt_deletionUnderflow_reverts() public { 250 | bytes32 slot = _getArrSlot(); 251 | 252 | // Store 5 elements in the sparse array 253 | for (uint256 i; i < 5; ++i) { 254 | bytes32 ins = bytes32(i); 255 | SparseArrLib.store(slot, i, ins); 256 | assertEq(SparseArrLib.get(slot, i), ins); 257 | } 258 | 259 | // Assert that the length is correct. 260 | assertEq(arr.length, 5); 261 | 262 | // Delete element at index 3 263 | SparseArrLib.safeDeleteAt(slot, 3); 264 | 265 | // Assert that the length is correct. 266 | assertEq(arr.length, 4); 267 | 268 | // Attempt to delete an element at a prior index. 269 | vm.expectRevert(DeletionUnderflow.selector); 270 | SparseArrLib.safeDeleteAt(slot, 2); 271 | } 272 | 273 | //////////////////////////////////////////////////////////////// 274 | // `push` tests // 275 | //////////////////////////////////////////////////////////////// 276 | 277 | /// @notice Tests that the `push` function works as expected before any deletions. 278 | function test_push_works() public { 279 | bytes32 slot = _getArrSlot(); 280 | 281 | // Assert that the length is correct. 282 | assertEq(arr.length, 0); 283 | 284 | // Push 5 elements into the sparse array 285 | for (uint256 i; i < 5; ++i) { 286 | bytes32 ins = b(i + 1); 287 | SparseArrLib.push(slot, ins); 288 | assertEq(SparseArrLib.get(slot, i), ins); 289 | } 290 | 291 | // Assert that the length is correct. 292 | assertEq(arr.length, 5); 293 | } 294 | 295 | /// @notice Tests that the `push` function works as expected after deletions. 296 | function test_push_afterDelete_works() public { 297 | bytes32 slot = _getArrSlot(); 298 | 299 | // Assert that the length is correct. 300 | assertEq(arr.length, 0); 301 | 302 | // Push 5 elements into the sparse array 303 | for (uint256 i; i < 5; ++i) { 304 | bytes32 ins = b(i + 1); 305 | SparseArrLib.push(slot, ins); 306 | assertEq(SparseArrLib.get(slot, i), ins); 307 | } 308 | 309 | // Delete an element at index 1. 310 | SparseArrLib.deleteAt(slot, 1); 311 | 312 | // Assert that the length is correct. 313 | assertEq(arr.length, 4); 314 | 315 | // Push a new element into the sparse array. 316 | SparseArrLib.push(slot, b(6)); 317 | 318 | // Assert that the length is correct. 319 | assertEq(arr.length, 5); 320 | 321 | assertEq(SparseArrLib.get(slot, 0), b(1)); 322 | assertEq(SparseArrLib.get(slot, 1), b(3)); 323 | assertEq(SparseArrLib.get(slot, 2), b(4)); 324 | assertEq(SparseArrLib.get(slot, 3), b(5)); 325 | assertEq(SparseArrLib.get(slot, 4), b(6)); 326 | } 327 | 328 | //////////////////////////////////////////////////////////////// 329 | // `pop` tests // 330 | //////////////////////////////////////////////////////////////// 331 | 332 | /// @notice Tests that the `pop` function works as expected. 333 | function test_pop_works() public { 334 | bytes32 slot = _getArrSlot(); 335 | 336 | // Store 5 elements in the sparse array 337 | for (uint256 i; i < 5; ++i) { 338 | bytes32 ins = b(i + 1); 339 | SparseArrLib.store(slot, i, ins); 340 | assertEq(SparseArrLib.get(slot, i), ins); 341 | } 342 | 343 | // Assert that the length is correct. 344 | assertEq(arr.length, 5); 345 | 346 | // Pop the final element from the sparse array 347 | SparseArrLib.pop(slot); 348 | 349 | // Assert that the length is correct. 350 | assertEq(arr.length, 4); 351 | 352 | // Assert that the values at each index are correct 353 | for (uint256 i; i < 4; ++i) { 354 | assertEq(SparseArrLib.get(slot, i), b(i + 1)); 355 | } 356 | 357 | // Assert that get() on the index that was previously popped now reverts 358 | vm.expectRevert(abi.encodeWithSelector(PANIC_SELECTOR, 0x20)); 359 | SparseArrLib.get(slot, 4); 360 | } 361 | 362 | //////////////////////////////////////////////////////////////// 363 | // Gas Profiling // 364 | //////////////////////////////////////////////////////////////// 365 | 366 | // TODO 367 | 368 | //////////////////////////////////////////////////////////////// 369 | // Helpers // 370 | //////////////////////////////////////////////////////////////// 371 | 372 | /// @notice Helper function to get the storage slot of `arr` 373 | function _getArrSlot() internal pure returns (bytes32 _slot) { 374 | assembly { 375 | _slot := arr.slot 376 | } 377 | } 378 | 379 | /// @notice Helper to quickly cast a `uint256` to a `bytes32` 380 | function b(uint256 _val) internal pure returns (bytes32 _ret) { 381 | _ret = bytes32(_val); 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /test/invariants/SparseArrLib.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.17; 2 | 3 | import { InvariantTest } from "forge-std/InvariantTest.sol"; 4 | import { Test } from "forge-std/Test.sol"; 5 | import { SparseArrLib } from "../../src/SparseArrLib.sol"; 6 | import { StdUtils } from "forge-std/StdUtils.sol"; 7 | 8 | /// @notice An actor that can 9 | contract SparseArrActor is StdUtils { 10 | /// @notice The test sparse array 11 | uint256[] public arr; 12 | 13 | /// @notice The number of items stored in the invariant test run 14 | uint256 public numStores; 15 | 16 | /// @notice The number of items deleted in the invariant test run 17 | uint256 public numDeletes; 18 | 19 | /// @dev Store a value in the sparse array at `index` 20 | function store(uint256 index, bytes32 contents) public { 21 | // Bound `index` to [0, len(arr)] 22 | index = bound(index, 0, arr.length); 23 | 24 | // Only increment `numStores` if the store operation is appending to the array. 25 | // Otherwise, the store operation is overwriting an existing value. 26 | if (index == arr.length) { 27 | numStores++; 28 | } 29 | 30 | // Store the contents at `index` 31 | SparseArrLib.store(_getArrSlot(), index, contents); 32 | } 33 | 34 | /// @dev Push a value to the end of the sparse array 35 | function push(bytes32 contents) public { 36 | SparseArrLib.push(_getArrSlot(), contents); 37 | numStores++; 38 | } 39 | 40 | /// @dev Delete a value from the sparse array at `index` 41 | function deleteAt(uint256 index) public { 42 | index = bound(index, 0, arr.length - 1); 43 | 44 | SparseArrLib.deleteAt(_getArrSlot(), index); 45 | numDeletes++; 46 | } 47 | 48 | /// @dev Delete a value from the sparse array at `index`, but only if it can 49 | /// be safely deleted. 50 | function safeDeleteAt(uint256 index) public { 51 | index = bound(index, 0, arr.length - 1); 52 | 53 | SparseArrLib.safeDeleteAt(_getArrSlot(), index); 54 | numDeletes++; 55 | } 56 | 57 | /// @dev Pop a value off of the end of the sparse array. 58 | function pop() public { 59 | SparseArrLib.pop(_getArrSlot()); 60 | numDeletes++; 61 | } 62 | 63 | /// @dev Helper to get `arr`'s length in the test contract 64 | function getArrLength() public view returns (uint256) { 65 | return arr.length; 66 | } 67 | 68 | /// @dev Helper to get the storage slot of `arr` 69 | function _getArrSlot() internal pure returns (bytes32 _slot) { 70 | assembly { 71 | _slot := arr.slot 72 | } 73 | } 74 | } 75 | 76 | contract SparseArrLib_InvariantTest is Test, InvariantTest { 77 | SparseArrActor public actor; 78 | 79 | function setUp() public { 80 | // Create a new actor 81 | actor = new SparseArrActor(); 82 | 83 | // Target the actor 84 | targetContract(address(actor)); 85 | } 86 | 87 | /// @notice Test that the sparse array's length is always equal to the number of items stored 88 | /// minus the number of items deleted. 89 | function invariant_length() public { 90 | assertEq(actor.getArrLength(), actor.numStores() - actor.numDeletes()); 91 | } 92 | } 93 | --------------------------------------------------------------------------------