// SPDX-License-Identifier: MIT import {SSTORE2} from "solady/src/utils/SSTORE2.sol"; /// @title ColorgasmStorage — chunked onchain blob storage /// @notice Holds arbitrary bytes as SSTORE2 chunks under a caller-chosen key, /// written one chunk per transaction. The key is the pointer: once a /// blob is sealed, `chunksOf(key)` is all a renderer needs. /// Deliberately generic and permissionless. The first address to write /// a key owns it, so nobody can overwrite someone else's blob, and /// sealing is irreversible — a sealed key's bytes can never change, /// which is the property a collector actually wants to verify. /// @dev SSTORE2 code deposit is the cheapest persistent storage the EVM /// offers: 200 gas per byte, against 625 gas per byte for regular /// storage slots. Measured all-in with calldata and transaction /// overhead it comes to ~221 gas per byte. /// Chunk size: EIP-7825 caps a transaction at 16,777,216 gas and a /// max-size chunk costs ~5.53M, so one chunk per transaction is the /// comfortable shape (and the reason EthFS and ScriptyStorageV2 work /// the same way). MAX_CHUNK is the SSTORE2 limit rounded down to a /// multiple of 3 so that base64 encoding can be done chunk by chunk at /// render time — see ColorgasmRendererV3. contract ColorgasmStorage { event ChunkAppended(bytes32 indexed key, uint256 index, uint256 size, address pointer); event KeyFinalised(bytes32 indexed key, uint256 chunks, uint256 size); /// @notice Largest chunk accepted: SSTORE2's 24575-byte limit rounded down /// to a multiple of 3 (3 x 8191), so base64 encodes chunk-locally. uint256 public constant MAX_CHUNK = 24573; /// @notice Whoever wrote a key's first chunk. Only they can extend or seal it. mapping(bytes32 => address) public ownerOf; /// @notice True once a key can never be written again. mapping(bytes32 => bool) public isSealed; mapping(bytes32 => address[]) private _chunks; mapping(bytes32 => uint256) private _size; /// @notice Append one chunk to a key. One transaction per chunk. /// @return index position of this chunk in the blob. function append(bytes32 key, bytes calldata chunk) external returns (uint256 index) { if (chunk.length == 0) revert EmptyChunk(); if (chunk.length > MAX_CHUNK) revert ChunkTooLarge(); if (isSealed[key]) revert KeySealed(); address owner = ownerOf[key]; if (owner == address(0)) ownerOf[key] = msg.sender; else if (owner != msg.sender) revert NotOwner(); address pointer = SSTORE2.write(chunk); index = _chunks[key].length; _chunks[key].push(pointer); _size[key] += chunk.length; emit ChunkAppended(key, index, chunk.length, pointer); /// @notice Freeze a key forever. Irreversible, and the point of the whole /// exercise — after this the bytes behind the pointer cannot change. function seal(bytes32 key) external { if (ownerOf[key] != msg.sender) revert NotOwner(); if (isSealed[key]) revert KeySealed(); if (_chunks[key].length == 0) revert NothingStored(); emit KeyFinalised(key, _chunks[key].length, _size[key]); /// @notice The SSTORE2 pointers holding a blob, in order. This is what a /// renderer reads — it never needs the bytes through this contract. function chunksOf(bytes32 key) external view returns (address[] memory) { /// @notice Total bytes stored under a key. function sizeOf(bytes32 key) external view returns (uint256) { /// @notice Number of chunks under a key. function chunkCount(bytes32 key) external view returns (uint256) { return _chunks[key].length; /// @notice The whole blob, assembled. /// @dev One allocation, one `extcodecopy` per pointer straight into /// place — linear, where repeated `bytes.concat` is quadratic. /// Convenience only: a renderer assembling a document should read /// `chunksOf` and copy into its own output buffer instead, to avoid /// paying for this intermediate copy at all. function read(bytes32 key) external view returns (bytes memory out) { address[] memory pointers = _chunks[key]; uint256 n = pointers.length; uint256 total = _size[key]; mstore(0x40, add(out, and(add(add(total, 0x20), 0x1f), not(0x1f)))) let dst := add(out, 0x20) for { let i := 0 } lt(i, n) { i := add(i, 1) } { let ptr := mload(add(pointers, add(0x20, mul(i, 0x20)))) let size := sub(extcodesize(ptr), 1) extcodecopy(ptr, dst, 1, size)