Skip to content

Commit 51cc758

Browse files
Merge pull request #131 from ethscriptions-protocol/uncapped_sstore2
Remove SSTORE2 chunking now that we removed the contract size cap in geth
2 parents 557a7ff + 356f2c7 commit 51cc758

7 files changed

Lines changed: 438 additions & 177 deletions

contracts/foundry.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ libs = ["dependencies"]
55
solc = "0.8.24"
66
optimizer = true
77
optimizer_runs = 200
8+
gas_limit = "18446744073709551615"
89
# via_ir = true
910
remappings = [
1011
"@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-5.3.0/",

contracts/src/Ethscriptions.sol

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ pragma solidity 0.8.24;
33

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

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

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

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

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

500498
// Check if content already exists
501-
address[] storage existingPointers = contentPointersBySha[contentSha];
502-
503-
// Check if content was already stored (pointers array will be non-empty for stored content)
504-
if (existingPointers.length > 0) {
499+
address existingPointer = contentPointerBySha[contentSha];
500+
if (existingPointer != address(0)) {
505501
// Content already stored, just return the SHA
506502
return contentSha;
507503
}
508504

509-
// Content doesn't exist, store it using SSTORE2
510-
address[] memory pointers = SSTORE2ChunkedStorageLib.store(content);
511-
512-
// Only store non-empty pointer arrays (empty content doesn't need deduplication)
513-
if (pointers.length > 0) {
514-
contentPointersBySha[contentSha] = pointers;
515-
}
505+
// Content doesn't exist, store it using SSTORE2Unlimited (handles any size)
506+
contentPointerBySha[contentSha] = SSTORE2Unlimited.write(content);
516507

517508
return contentSha;
518509
}

contracts/src/libraries/SSTORE2ChunkedStorageLib.sol

Lines changed: 0 additions & 105 deletions
This file was deleted.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.4;
3+
4+
/// @notice Read and write to persistent storage at a fraction of the cost.
5+
/// @notice Modified to support unlimited content size (up to 4GB) using PUSH4
6+
/// @author Modified from Solady (https://github.com/vectorized/solady/blob/main/src/utils/SSTORE2.sol)
7+
library SSTORE2Unlimited {
8+
9+
10+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
11+
/* CUSTOM ERRORS */
12+
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
13+
14+
/// @dev Unable to deploy the storage contract.
15+
error DeploymentFailed();
16+
17+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
18+
/* WRITE LOGIC */
19+
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
20+
21+
/// @dev Writes `data` into the bytecode of a storage contract and returns its address.
22+
/// Uses a simpler approach with abi.encodePacked for clarity
23+
function write(bytes memory data) internal returns (address pointer) {
24+
// Prefix the bytecode with a STOP opcode to ensure it cannot be called.
25+
bytes memory runtimeCode = abi.encodePacked(hex"00", data);
26+
27+
bytes memory creationCode = abi.encodePacked(
28+
//---------------------------------------------------------------------------------------------------------------//
29+
// Opcode | Opcode + Arguments | Description | Stack View //
30+
//---------------------------------------------------------------------------------------------------------------//
31+
// 0x60 | 0x600B | PUSH1 11 | codeOffset //
32+
// 0x59 | 0x59 | MSIZE | 0 codeOffset //
33+
// 0x81 | 0x81 | DUP2 | codeOffset 0 codeOffset //
34+
// 0x38 | 0x38 | CODESIZE | codeSize codeOffset 0 codeOffset //
35+
// 0x03 | 0x03 | SUB | (codeSize - codeOffset) 0 codeOffset //
36+
// 0x80 | 0x80 | DUP | (codeSize - codeOffset) (codeSize - codeOffset) 0 codeOffset //
37+
// 0x92 | 0x92 | SWAP3 | codeOffset (codeSize - codeOffset) 0 (codeSize - codeOffset) //
38+
// 0x59 | 0x59 | MSIZE | 0 codeOffset (codeSize - codeOffset) 0 (codeSize - codeOffset) //
39+
// 0x39 | 0x39 | CODECOPY | 0 (codeSize - codeOffset) //
40+
// 0xf3 | 0xf3 | RETURN | //
41+
//---------------------------------------------------------------------------------------------------------------//
42+
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.
43+
runtimeCode // The bytecode we want the contract to have after deployment.
44+
);
45+
46+
/// @solidity memory-safe-assembly
47+
assembly {
48+
// Deploy a new contract with the generated creation code.
49+
// We start 32 bytes into the code to avoid copying the byte length.
50+
pointer := create(0, add(creationCode, 32), mload(creationCode))
51+
52+
if iszero(pointer) {
53+
mstore(0x00, 0x30116425) // `DeploymentFailed()`.
54+
revert(0x1c, 0x04)
55+
}
56+
}
57+
}
58+
59+
60+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
61+
/* READ LOGIC */
62+
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
63+
64+
/// @dev The offset of the data in the bytecode (skipping the STOP opcode).
65+
uint256 private constant DATA_OFFSET = 1;
66+
67+
/// @dev Reads all data from the storage contract, skipping the initial STOP opcode.
68+
function read(address pointer) internal view returns (bytes memory) {
69+
return readBytecode(pointer, DATA_OFFSET, pointer.code.length - DATA_OFFSET);
70+
}
71+
72+
/// @dev Reads bytecode from a contract at a specific offset and size.
73+
function readBytecode(
74+
address pointer,
75+
uint256 start,
76+
uint256 size
77+
) private view returns (bytes memory data) {
78+
/// @solidity memory-safe-assembly
79+
assembly {
80+
// Get a pointer to some free memory.
81+
data := mload(0x40)
82+
83+
// Update the free memory pointer to prevent overriding our data.
84+
// We use and(x, not(31)) as a cheaper equivalent to sub(x, mod(x, 32)).
85+
// Adding 31 to size and running the result through the logic above ensures
86+
// the memory pointer remains word-aligned, following the Solidity convention.
87+
mstore(0x40, add(data, and(add(add(size, 32), 31), not(31))))
88+
89+
// Store the size of the data in the first 32 byte chunk of free memory.
90+
mstore(data, size)
91+
92+
// Copy the code into memory right after the 32 bytes we used to store the size.
93+
extcodecopy(pointer, add(data, 32), start, size)
94+
}
95+
}
96+
}

contracts/test/EthscriptionsJson.t.sol

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -202,26 +202,19 @@ contract EthscriptionsJsonTest is TestSetup {
202202
vm.prank(creator);
203203
eth.createEthscription(params);
204204

205-
// Test readChunk - now it stores raw content only
206-
uint256 pointerCount = eth.getContentPointerCount(txHash);
207-
assertEq(pointerCount, 3, "Should have 3 chunks"); // 50000 bytes = 3 chunks
208-
209-
// Read first chunk
210-
bytes memory chunk0 = eth.readChunk(txHash, 0);
211-
assertEq(chunk0.length, 24575, "First chunk should be full size");
212-
213-
// Read last chunk
214-
bytes memory chunk2 = eth.readChunk(txHash, 2);
215-
uint256 totalLength = largeContent.length; // Use largeContent length, not contentUri
216-
uint256 expectedLastChunkSize = totalLength - (24575 * 2);
217-
assertEq(chunk2.length, expectedLastChunkSize, "Last chunk should be remainder");
218-
219-
// Verify content matches when reassembled from chunks
220-
bytes memory reconstructed;
221-
for (uint256 i = 0; i < pointerCount; i++) {
222-
reconstructed = abi.encodePacked(reconstructed, eth.readChunk(txHash, i));
223-
}
224-
assertEq(reconstructed, largeContent, "Reconstructed chunks should match original content");
205+
// Test content storage - now stores everything in a single contract
206+
assertTrue(eth.hasContent(txHash), "Should have content stored");
207+
208+
// Get the content pointer
209+
address pointer = eth.getContentPointer(txHash);
210+
assertTrue(pointer != address(0), "Should have a valid content pointer");
211+
212+
// Read the entire content (no chunking needed)
213+
bytes memory storedContent = eth.readContent(txHash);
214+
assertEq(storedContent.length, largeContent.length, "Content length should match");
215+
216+
// Verify content matches exactly
217+
assertEq(storedContent, largeContent, "Stored content should match original content");
225218

226219
// Also verify tokenURI returns valid JSON
227220
string memory tokenUri = eth.tokenURI(eth.getTokenId(txHash));
@@ -252,8 +245,8 @@ contract EthscriptionsJsonTest is TestSetup {
252245
false
253246
));
254247

255-
// Verify we have exactly 2 chunks
256-
assertEq(eth.getContentPointerCount(txHash), targetChunks, "Should have exactly 2 chunks");
248+
// Verify content is stored (no chunking anymore)
249+
assertTrue(eth.hasContent(txHash), "Should have content stored");
257250

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

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

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

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

329-
// Verify single chunk
330-
assertEq(eth.getContentPointerCount(txHash), 1, "Should have 1 chunk");
321+
// Verify content is stored
322+
assertTrue(eth.hasContent(txHash), "Should have content stored");
331323

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

433-
// Verify both have same pointer count
434-
assertEq(eth.getContentPointerCount(txHash1), eth.getContentPointerCount(txHash3), "Should have same pointer count");
425+
// Verify both have content stored and use the same pointer
426+
assertTrue(eth.hasContent(txHash1) && eth.hasContent(txHash3), "Both should have content");
427+
assertEq(eth.getContentPointer(txHash1), eth.getContentPointer(txHash3), "Should share same content pointer");
435428
}
436429

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

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

0 commit comments

Comments
 (0)