Skip to content
Merged
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
1 change: 1 addition & 0 deletions contracts/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ libs = ["dependencies"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200
gas_limit = "18446744073709551615"
# via_ir = true
remappings = [
"@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-5.3.0/",
Expand Down
27 changes: 9 additions & 18 deletions contracts/src/Ethscriptions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ pragma solidity 0.8.24;

import "./ERC721EthscriptionsSequentialEnumerableUpgradeable.sol";
import {LibString} from "solady/utils/LibString.sol";
import "./libraries/SSTORE2ChunkedStorageLib.sol";
import "./libraries/SSTORE2Unlimited.sol";
import "./libraries/EthscriptionsRendererLib.sol";
import "./EthscriptionsProver.sol";
import "./libraries/Predeploys.sol";
Expand All @@ -16,7 +16,6 @@ import "./libraries/Constants.sol";
/// @dev Uses ethscription number as token ID and name, while transaction hash remains the primary identifier for function calls
contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
using LibString for *;
using SSTORE2ChunkedStorageLib for address[];
using EthscriptionsRendererLib for Ethscription;

// =============================================================
Expand Down Expand Up @@ -76,8 +75,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
/// @dev Ethscription ID (L1 tx hash) => Ethscription data
mapping(bytes32 => Ethscription) internal ethscriptions;

/// @dev Content SHA => SSTORE2 pointers array
mapping(bytes32 => address[]) public contentPointersBySha;
/// @dev Content SHA => SSTORE2 pointer (single address, no array)
mapping(bytes32 => address) public contentPointerBySha;

/// @dev Content URI hash => first ethscription tx hash that used it (for protocol uniqueness check)
/// @dev bytes32(0) means unused, non-zero means the content URI has been used
Expand Down Expand Up @@ -374,9 +373,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
/// @notice Get content for an ethscription
function getEthscriptionContent(bytes32 ethscriptionId) public view returns (bytes memory) {
Ethscription storage ethscription = _getEthscriptionOrRevert(ethscriptionId);
address[] storage pointers = contentPointersBySha[ethscription.contentSha];
// Empty content is valid - returns "" for empty pointers array
return pointers.read();
address pointer = contentPointerBySha[ethscription.contentSha];

Copilot AI Nov 4, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential issue when reading content with a zero address pointer. If contentPointerBySha[ethscription.contentSha] is address(0) (which can happen for empty content or if content was never stored), calling SSTORE2Unlimited.read(pointer) will attempt to read from address(0). In the read function at line 69, this will compute pointer.code.length - DATA_OFFSET which for address(0) becomes 0 - 1, causing an underflow. Consider adding a check: if (pointer == address(0)) return new bytes(0); before the read call.

Suggested change
address pointer = contentPointerBySha[ethscription.contentSha];
address pointer = contentPointerBySha[ethscription.contentSha];
if (pointer == address(0)) {
return new bytes(0);
}

Copilot uses AI. Check for mistakes.
return SSTORE2Unlimited.read(pointer);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Overflow on zero address in SSTORE2Unlimited.read breaking empty content handling

The SSTORE2Unlimited.read function underflows when called with address(0), as pointer.code.length - DATA_OFFSET becomes 0 - 1. This causes getEthscriptionContent to revert for existing ethscriptions with empty content, because contentPointerBySha returns address(0) for these. This breaks backward compatibility, as the old system gracefully returned empty bytes for such cases.

Additional Locations (1)

Fix in Cursor Fix in Web

}

/// @notice Get ethscription details and content in a single call
Expand Down Expand Up @@ -498,21 +496,14 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
contentSha = sha256(content);

// Check if content already exists
address[] storage existingPointers = contentPointersBySha[contentSha];

// Check if content was already stored (pointers array will be non-empty for stored content)
if (existingPointers.length > 0) {
address existingPointer = contentPointerBySha[contentSha];
if (existingPointer != address(0)) {
// Content already stored, just return the SHA
return contentSha;
}

// Content doesn't exist, store it using SSTORE2
address[] memory pointers = SSTORE2ChunkedStorageLib.store(content);

// Only store non-empty pointer arrays (empty content doesn't need deduplication)
if (pointers.length > 0) {
contentPointersBySha[contentSha] = pointers;
}
// Content doesn't exist, store it using SSTORE2Unlimited (handles any size)
contentPointerBySha[contentSha] = SSTORE2Unlimited.write(content);

return contentSha;
}
Expand Down
105 changes: 0 additions & 105 deletions contracts/src/libraries/SSTORE2ChunkedStorageLib.sol

This file was deleted.

96 changes: 96 additions & 0 deletions contracts/src/libraries/SSTORE2Unlimited.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

/// @notice Read and write to persistent storage at a fraction of the cost.
/// @notice Modified to support unlimited content size (up to 4GB) using PUSH4
/// @author Modified from Solady (https://github.com/vectorized/solady/blob/main/src/utils/SSTORE2.sol)
library SSTORE2Unlimited {


/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* CUSTOM ERRORS */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

/// @dev Unable to deploy the storage contract.
error DeploymentFailed();

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* WRITE LOGIC */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

/// @dev Writes `data` into the bytecode of a storage contract and returns its address.
/// Uses a simpler approach with abi.encodePacked for clarity
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.
);

/// @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))

if iszero(pointer) {
mstore(0x00, 0x30116425) // `DeploymentFailed()`.
revert(0x1c, 0x04)
}
}
}


/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* READ LOGIC */
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

/// @dev The offset of the data in the bytecode (skipping the STOP opcode).
uint256 private constant DATA_OFFSET = 1;

/// @dev Reads all data from the storage contract, skipping the initial STOP opcode.
function read(address pointer) internal view returns (bytes memory) {
return readBytecode(pointer, DATA_OFFSET, pointer.code.length - DATA_OFFSET);
}

/// @dev Reads bytecode from a contract at a specific offset and size.
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)
}
}
}
65 changes: 29 additions & 36 deletions contracts/test/EthscriptionsJson.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -202,26 +202,19 @@ contract EthscriptionsJsonTest is TestSetup {
vm.prank(creator);
eth.createEthscription(params);

// Test readChunk - now it stores raw content only
uint256 pointerCount = eth.getContentPointerCount(txHash);
assertEq(pointerCount, 3, "Should have 3 chunks"); // 50000 bytes = 3 chunks

// Read first chunk
bytes memory chunk0 = eth.readChunk(txHash, 0);
assertEq(chunk0.length, 24575, "First chunk should be full size");

// Read last chunk
bytes memory chunk2 = eth.readChunk(txHash, 2);
uint256 totalLength = largeContent.length; // Use largeContent length, not contentUri
uint256 expectedLastChunkSize = totalLength - (24575 * 2);
assertEq(chunk2.length, expectedLastChunkSize, "Last chunk should be remainder");

// Verify content matches when reassembled from chunks
bytes memory reconstructed;
for (uint256 i = 0; i < pointerCount; i++) {
reconstructed = abi.encodePacked(reconstructed, eth.readChunk(txHash, i));
}
assertEq(reconstructed, largeContent, "Reconstructed chunks should match original content");
// Test content storage - now stores everything in a single contract
assertTrue(eth.hasContent(txHash), "Should have content stored");

// Get the content pointer
address pointer = eth.getContentPointer(txHash);
assertTrue(pointer != address(0), "Should have a valid content pointer");

// Read the entire content (no chunking needed)
bytes memory storedContent = eth.readContent(txHash);
assertEq(storedContent.length, largeContent.length, "Content length should match");

// Verify content matches exactly
assertEq(storedContent, largeContent, "Stored content should match original content");

// Also verify tokenURI returns valid JSON
string memory tokenUri = eth.tokenURI(eth.getTokenId(txHash));
Expand Down Expand Up @@ -252,8 +245,8 @@ contract EthscriptionsJsonTest is TestSetup {
false
));

// Verify we have exactly 2 chunks
assertEq(eth.getContentPointerCount(txHash), targetChunks, "Should have exactly 2 chunks");
// Verify content is stored (no chunking anymore)
assertTrue(eth.hasContent(txHash), "Should have content stored");

// Read back and verify in JSON metadata
string memory retrieved = eth.tokenURI(tokenId);
Expand Down Expand Up @@ -291,13 +284,12 @@ contract EthscriptionsJsonTest is TestSetup {
false
));

// Verify we have 2 chunks (24575 + 5425 bytes for raw content)
assertEq(eth.getContentPointerCount(txHash), 2, "Should have 2 chunks");
// Verify content is stored (no chunking anymore)
assertTrue(eth.hasContent(txHash), "Should have content stored");

// Verify second chunk has correct size
bytes memory secondChunk = eth.readChunk(txHash, 1);
uint256 expectedSecondChunkSize = content.length - 24575; // Use content length, not URI
assertEq(secondChunk.length, expectedSecondChunkSize, "Second chunk size mismatch");
// Verify the full content is stored correctly
bytes memory storedContent = eth.readContent(txHash);
assertEq(storedContent.length, content.length, "Content length should match");

// Read back and verify in JSON metadata
string memory retrieved = eth.tokenURI(tokenId);
Expand Down Expand Up @@ -326,8 +318,8 @@ contract EthscriptionsJsonTest is TestSetup {
false
));

// Verify single chunk
assertEq(eth.getContentPointerCount(txHash), 1, "Should have 1 chunk");
// Verify content is stored
assertTrue(eth.hasContent(txHash), "Should have content stored");

// Verify content in JSON metadata
string memory retrieved = eth.tokenURI(tokenId);
Expand Down Expand Up @@ -430,20 +422,21 @@ contract EthscriptionsJsonTest is TestSetup {
console.log("ESIP6 creation gas (reusing content):", esip6Gas);
assertTrue(esip6Gas < 1000000, "ESIP6 should save gas by reusing content");

// Verify both have same pointer count
assertEq(eth.getContentPointerCount(txHash1), eth.getContentPointerCount(txHash3), "Should have same pointer count");
// Verify both have content stored and use the same pointer
assertTrue(eth.hasContent(txHash1) && eth.hasContent(txHash3), "Both should have content");
assertEq(eth.getContentPointer(txHash1), eth.getContentPointer(txHash3), "Should share same content pointer");
}

function test_WorstCaseGas_1MB() public {
// Skip this test - HTML viewer generation runs out of memory with 1MB content
// This is a known limitation for extremely large content
vm.skip(true);
vm.pauseGasMetering();

// Create 1MB content URI (1,048,576 bytes)
bytes memory largeContent = new bytes(1048576);
for (uint i = 0; i < largeContent.length; i++) {
for (uint i = 0; i < largeContent.length;) {
largeContent[i] = bytes1(uint8(65 + (i % 26))); // Fill with A-Z pattern
unchecked {
++i;
}
}
bytes memory contentUri = abi.encodePacked("data:text/plain;base64,", largeContent);

Expand Down
Loading