diff --git a/contracts/libs/0.8.x/BytecodeStorageV1.sol b/contracts/libs/0.8.x/BytecodeStorageV1.sol index c4f2d25f6..17a8a8241 100644 --- a/contracts/libs/0.8.x/BytecodeStorageV1.sol +++ b/contracts/libs/0.8.x/BytecodeStorageV1.sol @@ -8,8 +8,9 @@ pragma solidity ^0.8.0; * @notice Utilize contract bytecode as persistant storage for large chunks of script string data. * This library is intended to have an external deployed copy that is released in the future, * and, as such, has been designed to support both updated V1 (versioned, with purging removed) - * reads as well as backwards-compatible reads for the unversioned "V0" storage contracts - * which were deployed by the original version of this libary. + * reads as well as backwards-compatible reads for both a) the unversioned "V0" storage contracts + * which were deployed by the original version of this libary and b) contracts that were deployed + * using one of the SSTORE2 implementations referenced below. * For these pre-V1 storage contracts (which themselves did not have any explict versioning semantics) * backwards-compatible reads are optimistic, and only expected to work for contracts actually * deployed by the original version of this library – and may fail ungracefully if attempted to be @@ -49,6 +50,10 @@ library BytecodeStorage { uint256 internal constant DATA_OFFSET = 65; // Define the set of known *historic* offset values for where the "meta bytes" end, and the "data bytes" begin. + // SSTORE2 deployed storage contracts take the general format of: + // concat(0x00, data) + // note: this is true for both variants of the SSTORE2 library + uint256 internal constant SSTORE2_DATA_OFFSET = 1; // V0 deployed storage contracts take the general format of: // concat(gated-cleanup-logic, deployer-address, data) uint256 internal constant V0_ADDRESS_OFFSET = 72; @@ -148,19 +153,21 @@ library BytecodeStorage { //////////////////////////////////////////////////////////////*/ /** - * @notice Read a string from contract bytecode + * @notice Read the bytes from contract bytecode, with an explicitly provided starting offset * @param _address address of deployed contract with bytecode stored in the V0 or V1 format - * @return data string read from contract bytecode + * @param _offset offset to read from in contract bytecode, explicitly provided (not calculated) + * @return data bytes read from contract bytecode + * @dev This function performs no input validation on the provided contract, + * other than that there is content to read (but not that its a "storage contract") */ - function readFromBytecode( - address _address - ) internal view returns (string memory data) { + function readBytesFromBytecode( + address _address, + uint256 _offset + ) internal view returns (bytes memory data) { // get the size of the bytecode uint256 bytecodeSize = _bytecodeSizeAt(_address); - // the dataOffset for the bytecode - uint256 dataOffset = _bytecodeDataOffsetAt(_address); - // handle case where address contains code < dataOffset - if (bytecodeSize < dataOffset) { + // handle case where address contains code < _offset + if (bytecodeSize < _offset) { revert("ContractAsStorage: Read Error"); } @@ -168,7 +175,7 @@ library BytecodeStorage { // decrement by dataOffset to account for header info uint256 size; unchecked { - size = bytecodeSize - dataOffset; + size = bytecodeSize - _offset; } assembly { @@ -182,10 +189,36 @@ library BytecodeStorage { // store length of data in first 32 bytes mstore(data, size) // copy code to memory, excluding the deployer-address - extcodecopy(_address, add(data, 0x20), dataOffset, size) + extcodecopy(_address, add(data, 0x20), _offset, size) } } + /** + * @notice Read the bytes from contract bytecode that was written to the EVM using SSTORE2 + * @param _address address of deployed contract with bytecode stored in the SSTORE2 format + * @return data bytes read from contract bytecode + * @dev This function performs no input validation on the provided contract, + * other than that there is content to read (but not that its a "storage contract") + */ + function readBytesFromSSTORE2Bytecode( + address _address + ) internal view returns (bytes memory data) { + return readBytesFromBytecode(_address, SSTORE2_DATA_OFFSET); + } + + /** + * @notice Read a string from contract bytecode + * @param _address address of deployed contract with bytecode stored in the V0 or V1 format + * @return data string read from contract bytecode + * @dev This function performs input validation that the contract to read is in an expected format + */ + function readFromBytecode( + address _address + ) internal view returns (string memory data) { + uint256 dataOffset = _bytecodeDataOffsetAt(_address); + return string(readBytesFromBytecode(_address, dataOffset)); + } + /** * @notice Get address for deployer for given contract bytecode * @param _address address of deployed contract with bytecode stored in the V0 or V1 format @@ -271,7 +304,8 @@ library BytecodeStorage { } else if (version == V0_VERSION_STRING) { dataOffset = V0_DATA_OFFSET; } else { - dataOffset = 0; + // unknown version, revert + revert("ContractAsStorage: Unsupported Version"); } } @@ -289,7 +323,8 @@ library BytecodeStorage { } else if (version == V0_VERSION_STRING) { addressOffset = V0_ADDRESS_OFFSET; } else { - addressOffset = 0; + // unknown version, revert + revert("ContractAsStorage: Unsupported Version"); } } @@ -303,9 +338,10 @@ library BytecodeStorage { ) private view returns (bytes32 version) { // get the size of the data uint256 bytecodeSize = _bytecodeSizeAt(_address); - // handle case where address contains code < minimum expected version string size + // handle case where address contains code < minimum expected version string size, + // by returning early with the unknown version string if (bytecodeSize < (VERSION_OFFSET + 32)) { - revert("ContractAsStorage: Read Error"); + return UNKNOWN_VERSION_STRING; } assembly { diff --git a/contracts/libs/0.8.x/SSTORE2.sol b/contracts/libs/0.8.x/SSTORE2.sol new file mode 100644 index 000000000..e51dc7169 --- /dev/null +++ b/contracts/libs/0.8.x/SSTORE2.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.0; + +/// @notice Read and write to persistent storage at a fraction of the cost. +/// @author Solmate (https://github.com/transmissions11/solmate/blob/main/src/utils/SSTORE2.sol) +/// @author Modified from 0xSequence (https://github.com/0xSequence/sstore2/blob/master/contracts/SSTORE2.sol) +library SSTORE2 { + uint256 internal constant DATA_OFFSET = 1; // We skip the first byte as it's a STOP opcode to ensure the contract can't be called. + + /*////////////////////////////////////////////////////////////// + WRITE LOGIC + //////////////////////////////////////////////////////////////*/ + + function write(bytes memory data) internal returns (address pointer) { + // Prefix the bytecode with a STOP opcode to ensure it cannot be called. + bytes memory runtimeCode = abi.encodePacked(hex"00", data); + + bytes memory creationCode = abi.encodePacked( + //---------------------------------------------------------------------------------------------------------------// + // Opcode | Opcode + Arguments | Description | Stack View // + //---------------------------------------------------------------------------------------------------------------// + // 0x60 | 0x600B | PUSH1 11 | codeOffset // + // 0x59 | 0x59 | MSIZE | 0 codeOffset // + // 0x81 | 0x81 | DUP2 | codeOffset 0 codeOffset // + // 0x38 | 0x38 | CODESIZE | codeSize codeOffset 0 codeOffset // + // 0x03 | 0x03 | SUB | (codeSize - codeOffset) 0 codeOffset // + // 0x80 | 0x80 | DUP | (codeSize - codeOffset) (codeSize - codeOffset) 0 codeOffset // + // 0x92 | 0x92 | SWAP3 | codeOffset (codeSize - codeOffset) 0 (codeSize - codeOffset) // + // 0x59 | 0x59 | MSIZE | 0 codeOffset (codeSize - codeOffset) 0 (codeSize - codeOffset) // + // 0x39 | 0x39 | CODECOPY | 0 (codeSize - codeOffset) // + // 0xf3 | 0xf3 | RETURN | // + //---------------------------------------------------------------------------------------------------------------// + hex"60_0B_59_81_38_03_80_92_59_39_F3", // Returns all code in the contract except for the first 11 (0B in hex) bytes. + runtimeCode // The bytecode we want the contract to have after deployment. Capped at 1 byte less than the code size limit. + ); + + /// @solidity memory-safe-assembly + assembly { + // Deploy a new contract with the generated creation code. + // We start 32 bytes into the code to avoid copying the byte length. + pointer := create(0, add(creationCode, 32), mload(creationCode)) + } + + require(pointer != address(0), "DEPLOYMENT_FAILED"); + } + + /*////////////////////////////////////////////////////////////// + READ LOGIC + //////////////////////////////////////////////////////////////*/ + + function read(address pointer) internal view returns (bytes memory) { + return + readBytecode( + pointer, + DATA_OFFSET, + pointer.code.length - DATA_OFFSET + ); + } + + function read( + address pointer, + uint256 start + ) internal view returns (bytes memory) { + start += DATA_OFFSET; + + return readBytecode(pointer, start, pointer.code.length - start); + } + + function read( + address pointer, + uint256 start, + uint256 end + ) internal view returns (bytes memory) { + start += DATA_OFFSET; + end += DATA_OFFSET; + + require(pointer.code.length >= end, "OUT_OF_BOUNDS"); + + return readBytecode(pointer, start, end - start); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HELPER LOGIC + //////////////////////////////////////////////////////////////*/ + + function readBytecode( + address pointer, + uint256 start, + uint256 size + ) private view returns (bytes memory data) { + /// @solidity memory-safe-assembly + assembly { + // Get a pointer to some free memory. + data := mload(0x40) + + // Update the free memory pointer to prevent overriding our data. + // We use and(x, not(31)) as a cheaper equivalent to sub(x, mod(x, 32)). + // Adding 31 to size and running the result through the logic above ensures + // the memory pointer remains word-aligned, following the Solidity convention. + mstore(0x40, add(data, and(add(add(size, 32), 31), not(31)))) + + // Store the size of the data in the first 32 byte chunk of free memory. + mstore(data, size) + + // Copy the code into memory right after the 32 bytes we used to store the size. + extcodecopy(pointer, add(data, 32), start, size) + } + } +} diff --git a/contracts/mock/BytecodeV1TextCR_DMock.sol b/contracts/mock/BytecodeV1TextCR_DMock.sol index 6a917cc52..be9f4150a 100644 --- a/contracts/mock/BytecodeV1TextCR_DMock.sol +++ b/contracts/mock/BytecodeV1TextCR_DMock.sol @@ -111,6 +111,39 @@ contract BytecodeV1TextCR_DMock { return _bytecodeAddress.readFromBytecode(); } + /** + * @notice Allows additional read introspection, to read a chunk of text, + * from chain-state that lives at a given deployed address with an + * explicitly provided `_offset`. + * @param _bytecodeAddress address from which to read text content. + * @param _offset Offset to read from in contract bytecode, + * explicitly provided (not calculated) + * @return string Content read from contract bytecode at the given address. + * @dev Intentionally do not perform input validation, instead allowing + * the underlying BytecodeStorage lib to throw errors where applicable. + */ + function forceReadTextAtAddress( + address _bytecodeAddress, + uint256 _offset + ) public view returns (string memory) { + return string(_bytecodeAddress.readBytesFromBytecode(_offset)); + } + + /** + * @notice Allows additional read introspection, to read a chunk of text, + * from chain-state that lives at a given deployed address that + * was written with SSTORE2. + * @param _bytecodeAddress address from which to read text content. + * @return string Content read from contract bytecode at the given address. + * @dev Intentionally do not perform input validation, instead allowing + * the underlying BytecodeStorage lib to throw errors where applicable. + */ + function readSSTORE2TextAtAddress( + address _bytecodeAddress + ) public view returns (string memory) { + return string(_bytecodeAddress.readBytesFromSSTORE2Bytecode()); + } + /** * @notice Allows introspection of who deployed a given contracts-as-storage * contract, based on a provided `_bytecodeAddress`. diff --git a/contracts/mock/SSTORE2Mock.sol b/contracts/mock/SSTORE2Mock.sol new file mode 100644 index 000000000..3192912d8 --- /dev/null +++ b/contracts/mock/SSTORE2Mock.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity 0.8.17; + +// Created By: Art Blocks Inc. + +import "../libs/0.8.x/SSTORE2.sol"; + +/** + * @title Art Blocks SSTORE2Mock. + * @author Art Blocks Inc. + * @notice This contract serves as a mock client of the SSTORE2 library + * to allow for more testing of this library in the context of + * backwards-compatible reads with the Art Blocks BytecodeStorage library + * @dev For the purposes of our backwards-compatibility testing, the two different + * variations of the SSTORE2 library are functionally equivalent, so we just test + * against the one that is more widely tracked on Github for simplicity, but the + * same tests could be run against the other variation of the library as well + * and would be expected to "just work" given both use the same data offset of + * a single 0x00 "stop byte". + */ +contract SSTORE2Mock { + using SSTORE2 for bytes; + using SSTORE2 for address; + + // monotonically increasing slot counter and associated slot-storage mapping + uint256 public nextTextSlotId = 0; + mapping(uint256 => address) public storedTextBytecodeAddresses; + + // save deployer address to support basic ACL checks for non-read operations + address public deployerAddress; + + modifier onlyDeployer() { + require(msg.sender == deployerAddress, "Only deployer"); + _; + } + + /** + * @notice Initializes contract. + */ + constructor() { + deployerAddress = msg.sender; + } + + /*////////////////////////////////////////////////////////////// + Create + Read + //////////////////////////////////////////////////////////////*/ + + /** + * @notice "Create": Adds a chunk of text to be stored to chain-state. + * @param _text Text to be created in chain-state. + * @return uint256 Slot that the written bytecode contract address was + * stored in. + * @dev Intentionally do not perform input validation, instead allowing + * the underlying BytecodeStorage lib to throw errors where applicable. + */ + function createText( + string memory _text + ) external onlyDeployer returns (uint256) { + // store text in contract bytecode + storedTextBytecodeAddresses[nextTextSlotId] = bytes(_text).write(); + // record written slot before incrementing + uint256 textSlotId = nextTextSlotId; + nextTextSlotId++; + return textSlotId; + } + + /** + * @notice "Read": Reads chunk of text currently in the provided slot, from + * chain-state. + * @param _textSlotId Slot (associated with this contract) for which to + * read text content. + * @return string Content read from contract bytecode in the given slot. + * @dev Intentionally do not perform input validation, instead allowing + * the underlying BytecodeStorage lib to throw errors where applicable. + */ + function readText(uint256 _textSlotId) public view returns (string memory) { + return string(storedTextBytecodeAddresses[_textSlotId].read()); + } + + /** + * @notice Allows additional read introspection, to read a chunk of text, + * from chain-state that lives at a given deployed address. + * @param _bytecodeAddress address from which to read text content. + * @return string Content read from contract bytecode at the given address. + * @dev Intentionally do not perform input validation, instead allowing + * the underlying BytecodeStorage lib to throw errors where applicable. + */ + function readTextAtAddress( + address _bytecodeAddress + ) public view returns (string memory) { + return string(_bytecodeAddress.read()); + } +} diff --git a/test/libs/BytecodeStorageV1_BackwardsCompatibleReadsMock.test.ts b/test/libs/BytecodeStorageV1_BackwardsCompatibleReadsMock.test.ts index 9b0faca5a..f2342fd7e 100644 --- a/test/libs/BytecodeStorageV1_BackwardsCompatibleReadsMock.test.ts +++ b/test/libs/BytecodeStorageV1_BackwardsCompatibleReadsMock.test.ts @@ -33,6 +33,40 @@ import { * library under test here, BytecodeStorage. */ describe("BytecodeStorageV1 Backwards Compatible Reads Tests", async function () { + // Helper that validates a write from the SSTORE2 library is readable + // from the V1 library, for a given string. + async function validateReadInterop_SSTORE2_V1( + config: T_Config, + targetText: string, + sstore2Mock: Contract, + bytecodeV1TextCR_DMock: Contract, + deployer: SignerWithAddress + ) { + // Upload the target text via the SSTORE2 library. + const createTextTX = await sstore2Mock + .connect(deployer) + .createText(targetText); + + // Retrieve the address of the written target text from the SSTORE2 library. + const textBytecodeAddress = getLatestTextDeploymentAddressSSTORE2( + config, + config.sstore2Mock + ); + + // Validate that V1 read of SSTORE2 written text is same as original text. + const text = await bytecodeV1TextCR_DMock.readSSTORE2TextAtAddress( + textBytecodeAddress + ); + expect(text).to.equal(targetText); + // Validate that read is the same when using manually provided read-offsets. + const textManualOffset = + await bytecodeV1TextCR_DMock.forceReadTextAtAddress( + textBytecodeAddress, + 1 // for SSTORE2, expected data offset is `1` + ); + expect(textManualOffset).to.equal(targetText); + } + // Helper that validates a write from the V0 library is readable // from the V1 library, for a given string. async function validateReadInterop_V0_V1( @@ -58,6 +92,28 @@ describe("BytecodeStorageV1 Backwards Compatible Reads Tests", async function () textBytecodeAddress ); expect(text).to.equal(targetText); + // Validate that read is the same when using manually provided read-offsets. + const textManualOffset = + await bytecodeV1TextCR_DMock.forceReadTextAtAddress( + textBytecodeAddress, + 104 // for V0, expected data offset is `104` + ); + expect(textManualOffset).to.equal(targetText); + } + + // Helper that retrieves the address of the most recently deployed contract + // containing bytecode for storage, from the SSTORE2 library. + async function getLatestTextDeploymentAddressSSTORE2( + config: T_Config, + sstore2Mock: Contract + ) { + const nextTextSlotId = await sstore2Mock.nextTextSlotId(); + // decrement from `nextTextSlotId` to get last updated slot + const textSlotId = nextTextSlotId - 1; + const textBytecodeAddress = await sstore2Mock.storedTextBytecodeAddresses( + textSlotId + ); + return textBytecodeAddress; } // Helper that retrieves the address of the most recently deployed contract @@ -93,41 +149,63 @@ describe("BytecodeStorageV1 Backwards Compatible Reads Tests", async function () accounts: await getAccounts(), }; config = await assignDefaultConstants(config); - // deploy the library mock + // deploy the V1 library mock config.bytecodeV1TextCR_DMock = await deployAndGet( config, "BytecodeV1TextCR_DMock", [] // no deployment args ); - // deploy the library mock + // deploy the V0 library mock config.bytecodeV0TextCR_DMock = await deployAndGet( config, "BytecodeV0TextCR_DMock", [] // no deployment args ); + // deploy the SSTORE2 library mock + config.sstore2Mock = await deployAndGet( + config, + "SSTORE2Mock", + [] // no deployment args + ); return config; } describe("validate readFromBytecode backwards-compatible interoperability", function () { it("validates interop for a single-byte script", async function () { const config = await loadFixture(_beforeEach); + let testString = "0"; await validateReadInterop_V0_V1( config, - "0", + testString, config.bytecodeV0TextCR_DMock, config.bytecodeV1TextCR_DMock, config.accounts.deployer ); + await validateReadInterop_SSTORE2_V1( + config, + testString, + config.sstore2Mock, + config.bytecodeV1TextCR_DMock, + config.accounts.deployer + ); }); it("validates interop for an short script < 32 bytes", async function () { const config = await loadFixture(_beforeEach); + let testString = "console.log(hello world)"; await validateReadInterop_V0_V1( config, - "console.log(hello world)", + testString, config.bytecodeV0TextCR_DMock, config.bytecodeV1TextCR_DMock, config.accounts.deployer ); + await validateReadInterop_SSTORE2_V1( + config, + testString, + config.sstore2Mock, + config.bytecodeV1TextCR_DMock, + config.accounts.deployer + ); }); it("validates interop for chromie squiggle script", async function () { const config = await loadFixture(_beforeEach); @@ -138,6 +216,13 @@ describe("BytecodeStorageV1 Backwards Compatible Reads Tests", async function () config.bytecodeV1TextCR_DMock, config.accounts.deployer ); + await validateReadInterop_SSTORE2_V1( + config, + SQUIGGLE_SCRIPT, + config.sstore2Mock, + config.bytecodeV1TextCR_DMock, + config.accounts.deployer + ); }); it("validates interop for different script", async function () { const config = await loadFixture(_beforeEach); @@ -148,6 +233,13 @@ describe("BytecodeStorageV1 Backwards Compatible Reads Tests", async function () config.bytecodeV1TextCR_DMock, config.accounts.deployer ); + await validateReadInterop_SSTORE2_V1( + config, + SKULPTUUR_SCRIPT_APPROX, + config.sstore2Mock, + config.bytecodeV1TextCR_DMock, + config.accounts.deployer + ); }); it("validates interop for misc. UTF-8 script", async function () { const config = await loadFixture(_beforeEach); @@ -158,6 +250,13 @@ describe("BytecodeStorageV1 Backwards Compatible Reads Tests", async function () config.bytecodeV1TextCR_DMock, config.accounts.deployer ); + await validateReadInterop_SSTORE2_V1( + config, + MULTI_BYTE_UTF_EIGHT_SCRIPT, + config.sstore2Mock, + config.bytecodeV1TextCR_DMock, + config.accounts.deployer + ); }); }); @@ -186,18 +285,44 @@ describe("BytecodeStorageV1 Backwards Compatible Reads Tests", async function () expect(textAuthorAddressV1).to.equal(resolvedMockAddress); expect(textAuthorAddressV1).to.equal(textAuthorAddressV0); }); + + it("getWriterAddressForBytecode is not supported for SSTORE2", async function () { + const config = await loadFixture(_beforeEach); + await config.sstore2Mock + .connect(config.accounts.deployer) + .createText("zip zipppity zoooop zop"); + const textBytecodeAddress = getLatestTextDeploymentAddressSSTORE2( + config, + config.sstore2Mock + ); + + // validate read with V1 library + await expectRevert( + config.bytecodeV1TextCR_DMock.readAuthorForTextAtAddress( + textBytecodeAddress + ), + "ContractAsStorage: Unsupported Version" + ); + }); }); describe("validate getLibraryVersionForBytecode works across versions", function () { it("read unknown contract from V1 library", async function () { const config = await loadFixture(_beforeEach); + await config.sstore2Mock + .connect(config.accounts.deployer) + .createText("zip zipppity zoooop zop"); + const textBytecodeAddress = getLatestTextDeploymentAddressSSTORE2( + config, + config.sstore2Mock + ); - // read the mock contract itself (it _is_ an unknown storage contract) - // from the V1 library, to validate unknown-reads + // read SSTORE2 version from V1 library const textLibraryVersionV1 = await config.bytecodeV1TextCR_DMock.readLibraryVersionForTextAtAddress( - config.bytecodeV1TextCR_DMock.address + textBytecodeAddress ); + // hard-coded expected value let textLibraryVersionV1UTF8 = ethers.utils.toUtf8String(textLibraryVersionV1);