diff --git a/app/models/ethscription_transaction.rb b/app/models/ethscription_transaction.rb index fe63d88..5ccca59 100644 --- a/app/models/ethscription_transaction.rb +++ b/app/models/ethscription_transaction.rb @@ -187,8 +187,8 @@ def build_create_calldata ) # Hash the content for protocol uniqueness - content_uri_hash_hex = Digest::SHA256.hexdigest(content_uri) - content_uri_hash = [content_uri_hash_hex].pack('H*') + content_uri_sha_hex = Digest::SHA256.hexdigest(content_uri) + content_uri_sha = [content_uri_sha_hex].pack('H*') # Convert hex strings to binary for ABI encoding tx_hash_bin = hex_to_bin(eth_transaction.transaction_hash) @@ -204,7 +204,7 @@ def build_create_calldata # Encode parameters params = [ tx_hash_bin, # bytes32 ethscriptionId (L1 tx hash) - content_uri_hash, # bytes32 contentUriHash + content_uri_sha, # bytes32 contentUriHash owner_bin, # address raw_content, # bytes content mimetype.b, # string diff --git a/contracts/script/L2Genesis.s.sol b/contracts/script/L2Genesis.s.sol index 5162c07..1850d52 100644 --- a/contracts/script/L2Genesis.s.sol +++ b/contracts/script/L2Genesis.s.sol @@ -6,6 +6,7 @@ import {L2GenesisConfig} from "./L2GenesisConfig.sol"; import {Predeploys} from "../src/libraries/Predeploys.sol"; import {Constants} from "../src/libraries/Constants.sol"; import {Ethscriptions} from "../src/Ethscriptions.sol"; +import {MetaStoreLib} from "../src/libraries/MetaStoreLib.sol"; import "forge-std/console.sol"; /// @title GenesisEthscriptions @@ -32,28 +33,37 @@ contract GenesisEthscriptions is Ethscriptions { require(ethscriptions[params.ethscriptionId].creator == address(0), "Ethscription already exists"); // Check protocol uniqueness using content URI hash - if (firstEthscriptionByContentUri[params.contentUriHash] != bytes32(0)) { + if (firstEthscriptionByContentUri[params.contentUriSha] != bytes32(0)) { if (!params.esip6) revert DuplicateContentUri(); } - // Store content and get content SHA (reusing parent's helper) - bytes32 contentSha = _storeContent(params.content); + // Store content and get content hash (reusing parent's helper) + bytes32 contentHash = _storeContent(params.content); // Mark content URI as used by storing this ethscription's tx hash - firstEthscriptionByContentUri[params.contentUriHash] = params.ethscriptionId; + firstEthscriptionByContentUri[params.contentUriSha] = params.ethscriptionId; + + // Store metadata (mimetype, protocol, operation) + // Genesis ethscriptions have no protocol parameters + bytes32 metaRef = MetaStoreLib.store( + params.mimetype, + params.protocolParams.protocolName, + params.protocolParams.operation, + metadataStorage + ); // Set all values including genesis-specific ones ethscriptions[params.ethscriptionId] = EthscriptionStorage({ // Fixed-size fields - contentUriHash: params.contentUriHash, - contentSha: contentSha, + contentUriSha: params.contentUriSha, + contentHash: contentHash, l1BlockHash: l1BlockHash, // Packed slot 3 creator: creator, createdAt: uint48(createdAt), l1BlockNumber: uint48(l1BlockNumber), - // Dynamic field - mimetype: params.mimetype, + // Metadata reference + metaRef: metaRef, // Packed slot N initialOwner: params.initialOwner, ethscriptionNumber: uint48(totalSupply()), @@ -333,7 +343,7 @@ contract L2Genesis is Script { // The JSON already has all the properly processed data Ethscriptions.CreateEthscriptionParams memory params; params.ethscriptionId = vm.parseJsonBytes32(json, string.concat(basePath, ".transaction_hash")); - params.contentUriHash = vm.parseJsonBytes32(json, string.concat(basePath, ".content_uri_hash")); + params.contentUriSha = vm.parseJsonBytes32(json, string.concat(basePath, ".content_uri_hash")); params.initialOwner = initialOwner; params.content = vm.parseJsonBytes(json, string.concat(basePath, ".content")); params.mimetype = vm.parseJsonString(json, string.concat(basePath, ".mimetype")); diff --git a/contracts/script/TestTokenUri.s.sol b/contracts/script/TestTokenUri.s.sol index 5fc7ab9..4b61e5e 100644 --- a/contracts/script/TestTokenUri.s.sol +++ b/contracts/script/TestTokenUri.s.sol @@ -18,7 +18,7 @@ contract TestTokenUri is Script { vm.prank(address(0x1111)); eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ ethscriptionId: keccak256("text1"), - contentUriHash: keccak256("data:text/plain,Hello World!"), + contentUriSha: keccak256("data:text/plain,Hello World!"), initialOwner: address(0x1111), content: bytes("Hello World!"), mimetype: "text/plain", @@ -30,7 +30,7 @@ contract TestTokenUri is Script { vm.prank(address(0x2222)); eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ ethscriptionId: keccak256("json1"), - contentUriHash: keccak256('data:application/json,{"p":"erc-20","op":"mint","tick":"test","amt":"1000"}'), + contentUriSha: keccak256('data:application/json,{"p":"erc-20","op":"mint","tick":"test","amt":"1000"}'), initialOwner: address(0x2222), content: bytes('{"p":"erc-20","op":"mint","tick":"test","amt":"1000"}'), mimetype: "application/json", @@ -42,7 +42,7 @@ contract TestTokenUri is Script { vm.prank(address(0x3333)); eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ ethscriptionId: keccak256("html1"), - contentUriHash: keccak256('data:text/html,

Ethscriptions Rule!

'), + contentUriSha: keccak256('data:text/html,

Ethscriptions Rule!

'), initialOwner: address(0x3333), content: bytes('

Ethscriptions Rule!

'), mimetype: "text/html", @@ -55,7 +55,7 @@ contract TestTokenUri is Script { vm.prank(address(0x4444)); eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ ethscriptionId: keccak256("image1"), - contentUriHash: keccak256("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAm0lEQVR42mNgGITgPxTTxvBleTo0swBsOK0s+N8aJkczC1AMR7KAKpb8v72xAY5hFsD4lFoCN+j56ZUoliBbSoklGIZjwxRbQAjT1YK7d+82kGUBeuQii5FrAYYrL81NwCpGFQtoEUT/6RoHWAyknQV0S6ZI5RE6Jt8CZIOOHTuGgR9Fq5FkCf19QM3wx5rZKHEtsRZQt5qkhgUAR6cGaUehOD4AAAAASUVORK5CYII="), + contentUriSha: keccak256("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAm0lEQVR42mNgGITgPxTTxvBleTo0swBsOK0s+N8aJkczC1AMR7KAKpb8v72xAY5hFsD4lFoCN+j56ZUoliBbSoklGIZjwxRbQAjT1YK7d+82kGUBeuQii5FrAYYrL81NwCpGFQtoEUT/6RoHWAyknQV0S6ZI5RE6Jt8CZIOOHTuGgR9Fq5FkCf19QM3wx5rZKHEtsRZQt5qkhgUAR6cGaUehOD4AAAAASUVORK5CYII="), initialOwner: address(0x4444), content: redPixelPng, mimetype: "image/png", @@ -67,7 +67,7 @@ contract TestTokenUri is Script { vm.prank(address(0x5555)); eth.createEthscription(Ethscriptions.CreateEthscriptionParams({ ethscriptionId: keccak256("css1"), - contentUriHash: keccak256("data:text/css,body { background: #000; color: #0f0; font-family: 'Courier New'; }"), + contentUriSha: keccak256("data:text/css,body { background: #000; color: #0f0; font-family: 'Courier New'; }"), initialOwner: address(0x5555), content: bytes("body { background: #000; color: #0f0; font-family: 'Courier New'; }"), mimetype: "text/css", diff --git a/contracts/script/process_genesis_json.rb b/contracts/script/process_genesis_json.rb index f666749..30ce413 100644 --- a/contracts/script/process_genesis_json.rb +++ b/contracts/script/process_genesis_json.rb @@ -13,8 +13,8 @@ content_uri = ethscription['content_uri'] # Calculate content URI hash (as hex string with 0x prefix for JSON) - content_uri_hash = '0x' + Digest::SHA256.hexdigest(content_uri) - ethscription['content_uri_hash'] = content_uri_hash + content_uri_sha = '0x' + Digest::SHA256.hexdigest(content_uri) + ethscription['content_uri_sha'] = content_uri_sha # Parse the data URI if content_uri.start_with?('data:') @@ -56,4 +56,4 @@ File.write(json_path, JSON.pretty_generate(data)) puts "Processed #{data['ethscriptions'].length} ethscriptions" -puts "Added content_uri_hash and content fields" \ No newline at end of file +puts "Added content_uri_sha and content fields" \ No newline at end of file diff --git a/contracts/src/Ethscriptions.sol b/contracts/src/Ethscriptions.sol index 124a4c1..079a4dd 100644 --- a/contracts/src/Ethscriptions.sol +++ b/contracts/src/Ethscriptions.sol @@ -3,8 +3,8 @@ pragma solidity 0.8.24; import "./ERC721EthscriptionsSequentialEnumerableUpgradeable.sol"; import {LibString} from "solady/utils/LibString.sol"; -import "./libraries/SSTORE2Unlimited.sol"; -import "./libraries/BytePackLib.sol"; +import "./libraries/DedupedBlobStore.sol"; +import "./libraries/MetaStoreLib.sol"; import "./libraries/EthscriptionsRendererLib.sol"; import "./EthscriptionsProver.sol"; import "./libraries/Predeploys.sol"; @@ -17,7 +17,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 EthscriptionsRendererLib for EthscriptionStorage; // ============================================================= // STRUCTS @@ -26,15 +25,15 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { /// @notice Internal storage struct for ethscriptions (optimized for storage) struct EthscriptionStorage { // Full slots - bytes32 contentUriHash; - bytes32 contentSha; + bytes32 contentUriSha; // sha256 of content URI (for protocol uniqueness check) + bytes32 contentHash; // keccak256 of content (for deduplication) bytes32 l1BlockHash; // Packed slot (32 bytes) address creator; uint48 createdAt; uint48 l1BlockNumber; - // Dynamic - string mimetype; + // Metadata reference (replaces dynamic mimetype string) + bytes32 metaRef; // Reference to deduplicated metadata (mimetype, protocol, operation) // Packed slot (27 bytes used, 5 free) address initialOwner; uint48 ethscriptionNumber; @@ -52,7 +51,7 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { struct CreateEthscriptionParams { bytes32 ethscriptionId; - bytes32 contentUriHash; // SHA256 of raw content URI (for protocol uniqueness) + bytes32 contentUriSha; // sha256 of content URI (for protocol uniqueness) address initialOwner; bytes content; // Raw decoded bytes (not Base64) string mimetype; @@ -78,8 +77,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { uint256 ethscriptionNumber; // Token ID // Core metadata - bytes32 contentUriHash; - bytes32 contentSha; + bytes32 contentUriSha; // sha256 of content URI (protocol) + bytes32 contentHash; // keccak256 of content string mimetype; bytes content; // Full content bytes (empty when includeContent=false) @@ -97,6 +96,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { // Protocol bool esip6; + string protocolName; // Protocol identifier (empty if none) + string operation; // Operation name (empty if none) } // ============================================================= @@ -124,8 +125,11 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { /// @dev Ethscription ID (L1 tx hash) => Ethscription data mapping(bytes32 => EthscriptionStorage) internal ethscriptions; - /// @dev Content SHA => packed content (for <32 bytes) or SSTORE2 pointer (for >=32 bytes) - mapping(bytes32 => bytes32) public contentStorageBySha; + /// @dev Content hash (keccak256) => packed content (for <32 bytes) or SSTORE2 pointer (for >=32 bytes) + mapping(bytes32 => bytes32) public contentStorage; + + /// @dev Metadata blob hash (keccak256) => packed metadata or SSTORE2 pointer (for deduplicated metadata storage) + mapping(bytes32 => bytes32) public metadataStorage; /// @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 @@ -137,9 +141,6 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { /// @dev Protocol registry - maps protocol names to handler addresses mapping(string => address) public protocolHandlers; - /// @dev Track which protocol an ethscription uses - mapping(bytes32 => string) public protocolOf; - /// @dev Array of genesis ethscription transaction hashes that need events emitted /// @notice This array is populated during genesis and cleared (by popping) when events are emitted bytes32[] internal pendingGenesisEvents; @@ -169,8 +170,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { bytes32 indexed ethscriptionId, address indexed creator, address indexed initialOwner, - bytes32 contentUriHash, - bytes32 contentSha, + bytes32 contentUriSha, + bytes32 contentHash, uint256 ethscriptionNumber ); @@ -231,12 +232,14 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { /// @param protocol The protocol identifier (e.g., "erc-20-fixed-denomination", "erc-721-ethscriptions-collection") /// @param handler The address of the handler contract /// @dev Only callable by the depositor address (used during genesis setup) + /// @dev Protocol names should already be normalized (lowercase) by the caller function registerProtocol(string calldata protocol, address handler) external { if (msg.sender != Predeploys.DEPOSITOR_ACCOUNT) revert OnlyDepositor(); if (handler == address(0)) revert InvalidHandler(); if (protocolHandlers[protocol] != address(0)) revert ProtocolAlreadyRegistered(); protocolHandlers[protocol] = handler; + emit ProtocolRegistered(protocol, handler); } @@ -255,25 +258,33 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { if (creator == address(0)) revert InvalidCreator(); if (_ethscriptionExists(params.ethscriptionId)) revert EthscriptionAlreadyExists(); - bool contentUriAlreadySeen = firstEthscriptionByContentUri[params.contentUriHash] != bytes32(0); + bool contentUriAlreadySeen = firstEthscriptionByContentUri[params.contentUriSha] != bytes32(0); if (contentUriAlreadySeen) { if (!params.esip6) revert DuplicateContentUri(); } else { - firstEthscriptionByContentUri[params.contentUriHash] = params.ethscriptionId; + firstEthscriptionByContentUri[params.contentUriSha] = params.ethscriptionId; } - // Store content and get content SHA (of raw bytes) - bytes32 contentSha = _storeContent(params.content); + // Store content and get content hash (keccak256 of raw bytes) + bytes32 contentHash = _storeContent(params.content); + + // Store metadata (mimetype, protocol, operation) + bytes32 metaRef = MetaStoreLib.store( + params.mimetype, + params.protocolParams.protocolName, + params.protocolParams.operation, + metadataStorage + ); ethscriptions[params.ethscriptionId] = EthscriptionStorage({ - contentUriHash: params.contentUriHash, - contentSha: contentSha, + contentUriSha: params.contentUriSha, + contentHash: contentHash, l1BlockHash: l1Block.hash(), creator: creator, createdAt: uint48(block.timestamp), l1BlockNumber: uint48(l1Block.number()), - mimetype: params.mimetype, + metaRef: metaRef, initialOwner: params.initialOwner, ethscriptionNumber: uint48(totalSupply()), esip6: params.esip6, @@ -299,8 +310,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { params.ethscriptionId, creator, params.initialOwner, - params.contentUriHash, - contentSha, + params.contentUriSha, + contentHash, tokenId ); @@ -398,8 +409,12 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { // Get content bytes memory content = _getEthscriptionContent(id); - // Build complete token URI using the library - it handles everything internally - return EthscriptionsRendererLib.buildTokenURI(ethscription, id, content); + // Decode metadata from reference + (string memory mimetype, string memory protocolName, string memory operation) = + MetaStoreLib.decode(ethscription.metaRef); + + // Build complete token URI using the library + return EthscriptionsRendererLib.buildTokenURI(ethscription, id, mimetype, protocolName, operation, content); } /// @notice Get the media URI for an ethscription (image or animation_url) @@ -409,7 +424,11 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { function getMediaUri(bytes32 ethscriptionId) external view returns (string memory mediaType, string memory mediaUri) { EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); bytes memory content = _getEthscriptionContent(ethscriptionId); - return ethscription.getMediaUri(content); + + // Decode mimetype from metadata reference + string memory mimetype = MetaStoreLib.getMimetype(ethscription.metaRef); + + return EthscriptionsRendererLib.getMediaUri(mimetype, content); } // -------------------- Data Retrieval -------------------- @@ -421,15 +440,19 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { function _buildEthscription(bytes32 ethscriptionId, bool includeContent) internal view returns (Ethscription memory) { EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + // Decode metadata from reference + (string memory mimetype, string memory protocolName, string memory operation) = + MetaStoreLib.decode(ethscription.metaRef); + return Ethscription({ // Identity ethscriptionId: ethscriptionId, ethscriptionNumber: uint256(ethscription.ethscriptionNumber), // Core metadata - contentUriHash: ethscription.contentUriHash, - contentSha: ethscription.contentSha, - mimetype: ethscription.mimetype, + contentUriSha: ethscription.contentUriSha, + contentHash: ethscription.contentHash, + mimetype: mimetype, content: includeContent ? _getEthscriptionContent(ethscriptionId) : bytes(""), // Ownership @@ -445,7 +468,9 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { createdAt: uint256(ethscription.createdAt), // Protocol - esip6: ethscription.esip6 + esip6: ethscription.esip6, + protocolName: protocolName, + operation: operation }); } @@ -595,17 +620,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { /// @dev Kept as internal for tokenURI and other internal uses function _getEthscriptionContent(bytes32 ethscriptionId) internal view returns (bytes memory) { EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); - bytes32 ref = contentStorageBySha[ethscription.contentSha]; - - // Check if it's inline content using BytePackLib - if (BytePackLib.isPacked(ref)) { - return BytePackLib.unpack(ref); - } - - // It's a pointer to SSTORE2 contract - address pointer = address(uint160(uint256(ref))); - - return SSTORE2Unlimited.read(pointer); + // Use shared retrieval logic + return DedupedBlobStore.readByHash(ethscription.contentHash, contentStorage); } // ---------------- Ownership & Existence Checks ---------------- @@ -646,6 +662,39 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { return id; } + // -------------------- Metadata Helpers -------------------- + + /// @notice Get the MIME type of an ethscription + /// @param ethscriptionId The ethscription ID to query + /// @return mimetype The MIME type string + function getMimetype(bytes32 ethscriptionId) external view returns (string memory) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return MetaStoreLib.getMimetype(ethscription.metaRef); + } + + /// @notice Get the protocol information for an ethscription + /// @param ethscriptionId The ethscription ID to query + /// @return protocolName The protocol identifier (empty if none) + /// @return operation The operation name (empty if none) + function getProtocol(bytes32 ethscriptionId) external view returns (string memory protocolName, string memory operation) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return MetaStoreLib.getProtocol(ethscription.metaRef); + } + + /// @notice Get complete metadata for an ethscription + /// @param ethscriptionId The ethscription ID to query + /// @return mimetype The MIME type + /// @return protocolName The protocol identifier (empty if none) + /// @return operation The operation name (empty if none) + function getMetadata(bytes32 ethscriptionId) external view returns ( + string memory mimetype, + string memory protocolName, + string memory operation + ) { + EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); + return MetaStoreLib.decode(ethscription.metaRef); + } + // ============================================================= // INTERNAL FUNCTIONS // ============================================================= @@ -689,31 +738,13 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { return ethscriptions[ethscriptionId].creator != address(0); } - /// @notice Internal helper to store content and return its SHA + /// @notice Internal helper to store content and return its hash /// @param content The raw content bytes to store - /// @return contentSha The SHA256 hash of the content - function _storeContent(bytes calldata content) internal returns (bytes32 contentSha) { - // Compute SHA256 hash of content first - contentSha = sha256(content); - - // Check if content already exists - bytes32 existing = contentStorageBySha[contentSha]; - if (existing != bytes32(0)) { - // Content already stored, just return the SHA - return contentSha; - } - - // Store based on size - if (content.length <= 31) { - // Pack small content directly into bytes32 (0-31 bytes) - contentStorageBySha[contentSha] = BytePackLib.packCalldata(content); - } else { - // Deploy via SSTORE2 for larger content (32+ bytes) - address pointer = SSTORE2Unlimited.write(content); - contentStorageBySha[contentSha] = bytes32(uint256(uint160(pointer))); - } - - return contentSha; + /// @return contentHash The keccak256 hash of the content + function _storeContent(bytes calldata content) internal returns (bytes32 contentHash) { + // Use shared deduplication logic with keccak256 + (contentHash,) = DedupedBlobStore.storeCalldata(content, contentStorage); + return contentHash; } function _queueForProving(bytes32 ethscriptionId) internal { @@ -734,9 +765,6 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { return; } - // Track which protocol this ethscription uses - protocolOf[ethscriptionId] = protocolParams.protocolName; - address handler = protocolHandlers[protocolParams.protocolName]; // Skip if no handler is registered @@ -770,14 +798,17 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { address from, address to ) internal { - string memory protocol = protocolOf[ethscriptionId]; + // Get protocol from metadata + EthscriptionStorage storage etsc = ethscriptions[ethscriptionId]; + (string memory protocolName,) = MetaStoreLib.getProtocol(etsc.metaRef); // Skip if no protocol assigned - if (bytes(protocol).length == 0) { + if (bytes(protocolName).length == 0) { return; } - address handler = protocolHandlers[protocol]; + // Protocol names are stored normalized (lowercase) + address handler = protocolHandlers[protocolName]; // Skip if no handler is registered if (handler == address(0)) { @@ -787,9 +818,9 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { // Use try/catch for cleaner error handling try IProtocolHandler(handler).onTransfer(ethscriptionId, from, to) { // onTransfer doesn't return data, so pass empty bytes - emit ProtocolHandlerSuccess(ethscriptionId, protocol, ""); + emit ProtocolHandlerSuccess(ethscriptionId, protocolName, ""); } catch (bytes memory revertData) { - emit ProtocolHandlerFailed(ethscriptionId, protocol, revertData); + emit ProtocolHandlerFailed(ethscriptionId, protocolName, revertData); } } @@ -844,8 +875,8 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable { ethscriptionId, ethscription.creator, ethscription.initialOwner, - ethscription.contentUriHash, - ethscription.contentSha, + ethscription.contentUriSha, + ethscription.contentHash, ethscription.ethscriptionNumber ); } diff --git a/contracts/src/EthscriptionsProver.sol b/contracts/src/EthscriptionsProver.sol index dc787b6..231a324 100644 --- a/contracts/src/EthscriptionsProver.sol +++ b/contracts/src/EthscriptionsProver.sol @@ -28,8 +28,8 @@ contract EthscriptionsProver { /// @notice Struct for ethscription data proof struct EthscriptionDataProof { bytes32 ethscriptionId; - bytes32 contentSha; - bytes32 contentUriHash; + bytes32 contentHash; + bytes32 contentUriSha; bytes32 l1BlockHash; address creator; address currentOwner; @@ -144,8 +144,8 @@ contract EthscriptionsProver { // Create proof struct with all ethscription data EthscriptionDataProof memory proof = EthscriptionDataProof({ ethscriptionId: ethscriptionId, - contentSha: ethscription.contentSha, - contentUriHash: ethscription.contentUriHash, + contentHash: ethscription.contentHash, + contentUriSha: ethscription.contentUriSha, l1BlockHash: proofInfo.l1BlockHash, creator: ethscription.creator, currentOwner: currentOwner, diff --git a/contracts/src/libraries/BytePackLib.sol b/contracts/src/libraries/BytePackLib.sol index 49fd887..1c6f4aa 100644 --- a/contracts/src/libraries/BytePackLib.sol +++ b/contracts/src/libraries/BytePackLib.sol @@ -25,6 +25,23 @@ library BytePackLib { } } + /// @notice Pack bytes memory up to 31 bytes into a bytes32 + /// @dev Memory version for when data is in memory + /// @param data The data to pack (must be <= 31 bytes) + /// @return packed The packed bytes32 value + function pack(bytes memory data) internal pure returns (bytes32 packed) { + uint256 len = data.length; + if (len >= 32) revert ContentTooLarge(len); + + assembly { + // Pack: tag byte (len+1) | first 31 bytes of data + packed := or( + shl(248, add(len, 1)), // Tag in first byte + shr(8, mload(add(data, 0x20))) // Data in remaining 31 bytes (skip length prefix) + ) + } + } + /// @notice Unpack a bytes32 value into bytes /// @dev Extracts the data based on the tag byte (length + 1) /// @param packed The packed bytes32 value diff --git a/contracts/src/libraries/DedupedBlobStore.sol b/contracts/src/libraries/DedupedBlobStore.sol new file mode 100644 index 0000000..904224a --- /dev/null +++ b/contracts/src/libraries/DedupedBlobStore.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "./BytePackLib.sol"; +import "./SSTORE2Unlimited.sol"; + +/// @title DedupedBlobStore +/// @notice Shared library for deduplicated blob storage using inline packing or SSTORE2 +/// @dev Used by both content storage and metadata storage to eliminate code duplication +library DedupedBlobStore { + + /// @notice Store calldata blob with deduplication using keccak256 + /// @dev Uses keccak256 for dedup key, stores either packed (≤31 bytes) or SSTORE2 pointer + /// @param data The calldata to store + /// @param store The storage mapping (hash => ref) + /// @return hash The keccak256 hash of the data (dedup key) + /// @return ref The storage reference (packed or SSTORE2 pointer) + function storeCalldata( + bytes calldata data, + mapping(bytes32 => bytes32) storage store + ) internal returns (bytes32 hash, bytes32 ref) { + hash = keccak256(data); + + // Check if already stored + bytes32 existing = store[hash]; + if (existing != bytes32(0)) { + return (hash, existing); + } + + // Store based on size - use calldata packing for efficiency + ref = data.length <= 31 ? BytePackLib.packCalldata(data) : _deploySST0RE2Calldata(data); + + // Store the mapping: hash -> reference + store[hash] = ref; + return (hash, ref); + } + + /// @notice Store memory blob with deduplication using keccak256 + /// @dev Uses keccak256 for dedup key, stores either packed (≤31 bytes) or SSTORE2 pointer + /// @param data The memory data to store + /// @param store The storage mapping (hash => ref) + /// @return hash The keccak256 hash of the data (dedup key) + /// @return ref The storage reference (packed or SSTORE2 pointer) + function storeMemory( + bytes memory data, + mapping(bytes32 => bytes32) storage store + ) internal returns (bytes32 hash, bytes32 ref) { + hash = keccak256(data); + + // Check if already stored + bytes32 existing = store[hash]; + if (existing != bytes32(0)) { + return (hash, existing); + } + + // Store based on size - use memory packing + ref = data.length <= 31 ? BytePackLib.pack(data) : _deploySST0RE2Memory(data); + + // Store the mapping: hash -> reference + store[hash] = ref; + return (hash, ref); + } + + /// @notice Deploy SSTORE2 contract and return reference + /// @param data The data to deploy (calldata or memory) + /// @return ref The SSTORE2 pointer as bytes32 + function _deploySST0RE2Calldata(bytes calldata data) private returns (bytes32 ref) { + address pointer = SSTORE2Unlimited.write(data); + return bytes32(uint256(uint160(pointer))); + } + + /// @notice Deploy SSTORE2 contract and return reference + /// @param data The data to deploy (calldata or memory) + /// @return ref The SSTORE2 pointer as bytes32 + function _deploySST0RE2Memory(bytes memory data) private returns (bytes32 ref) { + address pointer = SSTORE2Unlimited.write(data); + return bytes32(uint256(uint160(pointer))); + } + + /// @notice Read blob from storage reference + /// @dev Automatically detects packed vs SSTORE2 and retrieves accordingly + /// @param ref The storage reference (packed or SSTORE2 pointer) + /// @return data The retrieved blob + function read(bytes32 ref) internal view returns (bytes memory) { + // Check if it's inline packed content + if (BytePackLib.isPacked(ref)) { + return BytePackLib.unpack(ref); + } + + // It's a pointer to SSTORE2 contract + address pointer = address(uint160(uint256(ref))); + return SSTORE2Unlimited.read(pointer); + } + + /// @notice Read blob from storage mapping by hash + /// @dev Looks up reference in mapping, then reads + /// @param hash The hash key + /// @param store The storage mapping + /// @return data The retrieved blob + function readByHash( + bytes32 hash, + mapping(bytes32 => bytes32) storage store + ) internal view returns (bytes memory) { + bytes32 ref = store[hash]; + return read(ref); + } +} diff --git a/contracts/src/libraries/EthscriptionsRendererLib.sol b/contracts/src/libraries/EthscriptionsRendererLib.sol index 81f11b4..2714c47 100644 --- a/contracts/src/libraries/EthscriptionsRendererLib.sol +++ b/contracts/src/libraries/EthscriptionsRendererLib.sol @@ -14,8 +14,17 @@ library EthscriptionsRendererLib { /// @notice Build attributes JSON array from ethscription data /// @param etsc Storage pointer to the ethscription /// @param ethscriptionId The ethscription ID (L1 tx hash) + /// @param mimetype The MIME type string (decoded from metadata) + /// @param protocolName The protocol name (empty if none) + /// @param operation The operation name (empty if none) /// @return JSON string of attributes array - function buildAttributes(Ethscriptions.EthscriptionStorage storage etsc, bytes32 ethscriptionId) + function buildAttributes( + Ethscriptions.EthscriptionStorage storage etsc, + bytes32 ethscriptionId, + string memory mimetype, + string memory protocolName, + string memory operation + ) internal view returns (string memory) @@ -33,12 +42,32 @@ library EthscriptionsRendererLib { ); string memory part2 = string.concat( - '"},{"trait_type":"Content SHA","value":"', - uint256(etsc.contentSha).toHexString(), + '"},{"trait_type":"Content Hash","value":"', + uint256(etsc.contentHash).toHexString(), '"},{"trait_type":"MIME Type","value":"', - etsc.mimetype.escapeJSON(), + mimetype.escapeJSON(), '"},{"trait_type":"ESIP-6","value":"', - etsc.esip6 ? "true" : "false", + etsc.esip6 ? "true" : "false" + ); + + // Add protocol info if present + string memory protocolAttrs = ""; + if (bytes(protocolName).length > 0) { + protocolAttrs = string.concat( + '"},{"trait_type":"Protocol","value":"', + protocolName.escapeJSON() + ); + if (bytes(operation).length > 0) { + protocolAttrs = string.concat( + protocolAttrs, + '"},{"trait_type":"Operation","value":"', + operation.escapeJSON() + ); + } + } + + string memory part3 = string.concat( + protocolAttrs, '"},{"trait_type":"L1 Block Number","display_type":"number","value":', uint256(etsc.l1BlockNumber).toString(), '},{"trait_type":"L2 Block Number","display_type":"number","value":', @@ -48,35 +77,35 @@ library EthscriptionsRendererLib { '}]' ); - return string.concat(part1, part2); + return string.concat(part1, part2, part3); } /// @notice Generate the media URI for an ethscription - /// @param etsc Storage pointer to the ethscription + /// @param mimetype The MIME type string /// @param content The content bytes /// @return mediaType Either "image" or "animation_url" /// @return mediaUri The data URI for the media - function getMediaUri(Ethscriptions.EthscriptionStorage storage etsc, bytes memory content) + function getMediaUri(string memory mimetype, bytes memory content) internal - view + pure returns (string memory mediaType, string memory mediaUri) { - if (etsc.mimetype.startsWith("image/")) { + if (mimetype.startsWith("image/")) { // Image content: wrap in SVG for pixel-perfect rendering - string memory imageDataUri = constructDataURI(etsc.mimetype, content); + string memory imageDataUri = constructDataURI(mimetype, content); string memory svg = wrapImageInSVG(imageDataUri); mediaUri = constructDataURI("image/svg+xml", bytes(svg)); return ("image", mediaUri); } else { // Non-image content: use animation_url - if (etsc.mimetype.startsWith("video/") || - etsc.mimetype.startsWith("audio/") || - etsc.mimetype.eq("text/html")) { + if (mimetype.startsWith("video/") || + mimetype.startsWith("audio/") || + mimetype.eq("text/html")) { // Video, audio, and HTML pass through directly as data URIs - mediaUri = constructDataURI(etsc.mimetype, content); + mediaUri = constructDataURI(mimetype, content); } else { // Everything else (text/plain, application/json, etc.) uses the HTML viewer - mediaUri = createTextViewerDataURI(etsc.mimetype, content); + mediaUri = createTextViewerDataURI(mimetype, content); } return ("animation_url", mediaUri); } @@ -85,18 +114,24 @@ library EthscriptionsRendererLib { /// @notice Build complete token URI JSON /// @param etsc Storage pointer to the ethscription /// @param ethscriptionId The ethscription ID (L1 tx hash) + /// @param mimetype The MIME type string (decoded from metadata) + /// @param protocolName The protocol name (empty if none) + /// @param operation The operation name (empty if none) /// @param content The content bytes /// @return The complete base64-encoded data URI function buildTokenURI( Ethscriptions.EthscriptionStorage storage etsc, bytes32 ethscriptionId, + string memory mimetype, + string memory protocolName, + string memory operation, bytes memory content ) internal view returns (string memory) { // Get media URI - (string memory mediaType, string memory mediaUri) = getMediaUri(etsc, content); + (string memory mediaType, string memory mediaUri) = getMediaUri(mimetype, content); // Build attributes - string memory attributes = buildAttributes(etsc, ethscriptionId); + string memory attributes = buildAttributes(etsc, ethscriptionId, mimetype, protocolName, operation); // Build JSON string memory json = string.concat( @@ -109,7 +144,7 @@ library EthscriptionsRendererLib { '","', mediaType, '":"', - mediaUri.escapeJSON(), + mediaUri, '","attributes":', attributes, '}' @@ -132,7 +167,7 @@ library EthscriptionsRendererLib { { return string.concat( "data:", - mimetype, + mimetype.escapeJSON(), ";base64,", Base64.encode(content) ); diff --git a/contracts/src/libraries/MetaStoreLib.sol b/contracts/src/libraries/MetaStoreLib.sol new file mode 100644 index 0000000..55f5883 --- /dev/null +++ b/contracts/src/libraries/MetaStoreLib.sol @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {LibBytes} from "solady/utils/LibBytes.sol"; +import "./DedupedBlobStore.sol"; + +/// @title MetaStoreLib +/// @notice Library for deduplicated storage of ethscription metadata (mimetype, protocol, operation) +/// @dev Encodes metadata as: mimetype\x00protocol\x00operation, stores once per unique combination +library MetaStoreLib { + using LibBytes for bytes; + + /// @dev Null byte (0x00) used to separate metadata components + /// @dev Safe to use as Ruby indexer strips all null bytes from input strings + bytes1 constant SEPARATOR = 0x00; + + /// @dev Sentinel value for "text/plain" with no protocol (most common case) + bytes32 constant EMPTY_REF = bytes32(0); + + // Custom errors + error InvalidSeparatorInInput(); + error InvalidMetadataRef(); + error MetadataNotStored(); + error InvalidFormat(); + + /// @notice Store metadata components (encode + deduplicate) + /// @dev High-level API for callers - combines encode() and intern() + /// @param mimetype MIME type string (preserve case for standards compliance) + /// @param protocolName Protocol identifier (should already be normalized by Ruby) + /// @param operation Operation to perform (should already be normalized by Ruby) + /// @param metaStore Storage mapping for metadata blobs + /// @return metaRef The metadata reference (bytes32(0), packed, or SSTORE2 pointer) + function store( + string memory mimetype, + string memory protocolName, + string memory operation, + mapping(bytes32 => bytes32) storage metaStore + ) internal returns (bytes32 metaRef) { + bytes memory blob = encode(mimetype, protocolName, operation); + return intern(blob, metaStore); + } + + /// @notice Encode metadata components into a blob + /// @dev Lower-level API - most callers should use store() instead + /// @param mimetype MIME type string (not normalized - preserve case for standards compliance) + /// @param protocolName Protocol identifier (should already be normalized by Ruby) + /// @param operation Operation name (should already be normalized by Ruby) + /// @return blob The encoded metadata blob (empty if all components empty/default) + function encode( + string memory mimetype, + string memory protocolName, + string memory operation + ) internal pure returns (bytes memory blob) { + // Validate inputs don't contain separator + if (_containsByte(bytes(mimetype), SEPARATOR)) revert InvalidSeparatorInInput(); + if (_containsByte(bytes(protocolName), SEPARATOR)) revert InvalidSeparatorInInput(); + if (_containsByte(bytes(operation), SEPARATOR)) revert InvalidSeparatorInInput(); + + // Note: normalization (lowercase, trim) is handled by Ruby indexer before submission + + // Normalize "text/plain" to empty string (convention: empty = text/plain) + if (keccak256(bytes(mimetype)) == keccak256(bytes("text/plain"))) { + mimetype = ""; + } + + // Special case: empty mimetype + no protocol → empty blob (most common case!) + if (bytes(mimetype).length == 0 && bytes(protocolName).length == 0 && bytes(operation).length == 0) { + return bytes(""); // Will map to EMPTY_REF (bytes32(0)) + } + + // Always encode in same format: mimetype\x1Fprotocol\x1Foperation + // Any component can be empty string + return abi.encodePacked(mimetype, SEPARATOR, protocolName, SEPARATOR, operation); + } + + /// @notice Decode a metadata reference into components + /// @param metaRef The metadata reference (bytes32(0), packed, or SSTORE2 pointer) + /// @return mimetype The MIME type + /// @return protocolName The protocol identifier (normalized) + /// @return operation The operation name (normalized) + function decode(bytes32 metaRef) internal view returns ( + string memory mimetype, + string memory protocolName, + string memory operation + ) { + bytes[] memory parts = _getParts(metaRef); + return _partsToStrings(parts); + } + + /// @notice Get only the mimetype from a metadata reference (gas-optimized) + /// @param metaRef The metadata reference + /// @return mimetype The MIME type + function getMimetype(bytes32 metaRef) internal view returns (string memory mimetype) { + bytes[] memory parts = _getParts(metaRef); + + // First part is always mimetype (empty = text/plain) + string memory mime = string(parts[0]); + return bytes(mime).length == 0 ? "text/plain" : mime; + } + + /// @notice Get protocol information from a metadata reference + /// @param metaRef The metadata reference + /// @return protocolName The protocol identifier (normalized, empty if none) + /// @return operation The operation name (normalized, empty if none) + function getProtocol(bytes32 metaRef) internal view returns ( + string memory protocolName, + string memory operation + ) { + bytes[] memory parts = _getParts(metaRef); + + // parts[0] = mimetype, parts[1] = protocol, parts[2] = operation + protocolName = string(parts[1]); + operation = string(parts[2]); + return (protocolName, operation); + } + + + /// @notice Intern a metadata blob (deduplicate and store) + /// @dev Lower-level API - most callers should use store() instead + /// @param blob The encoded metadata blob + /// @param metaStore Storage mapping for metadata blobs + /// @return metaRef The metadata reference (bytes32(0), packed, or SSTORE2 pointer) + function intern( + bytes memory blob, + mapping(bytes32 => bytes32) storage metaStore + ) internal returns (bytes32 metaRef) { + // Special case: empty blob = EMPTY_REF sentinel + if (blob.length == 0) { + return EMPTY_REF; + } + + // Use shared deduplication logic with keccak256 + (, metaRef) = DedupedBlobStore.storeMemory(blob, metaStore); + return metaRef; + } + + + // ============================================================= + // INTERNAL HELPERS + // ============================================================= + + /// @notice Retrieve a blob from storage + /// @param metaRef The metadata reference + /// @return blob The retrieved blob + function _retrieve(bytes32 metaRef) private view returns (bytes memory blob) { + if (metaRef == EMPTY_REF) { + return bytes(""); + } + + // Use shared read logic + return DedupedBlobStore.read(metaRef); + } + + /// @notice Get parts array from metadata reference (single point for blob.length check) + /// @param metaRef The metadata reference + /// @return parts Array of 3 byte parts [mimetype, protocol, operation] + function _getParts(bytes32 metaRef) private view returns (bytes[] memory parts) { + bytes memory blob = _retrieve(metaRef); + + // Single check for empty blob (text/plain + no protocol case) + if (blob.length == 0) { + parts = new bytes[](3); + parts[0] = bytes(""); // Empty = text/plain + parts[1] = bytes(""); // No protocol + parts[2] = bytes(""); // No operation + return parts; + } + + // Split keeping empty parts - always get 3 parts + return _splitKeepEmpty(blob, SEPARATOR); + } + + /// @notice Convert parts array to strings with text/plain default + /// @param parts Array of 3 byte parts [mimetype, protocol, operation] + /// @return mimetype The MIME type + /// @return protocolName The protocol identifier + /// @return operation The operation name + function _partsToStrings(bytes[] memory parts) private pure returns ( + string memory mimetype, + string memory protocolName, + string memory operation + ) { + // Extract mimetype (empty = text/plain) + mimetype = string(parts[0]); + if (bytes(mimetype).length == 0) { + mimetype = "text/plain"; + } + + // Extract protocol and operation (may be empty) + protocolName = string(parts[1]); + operation = string(parts[2]); + + return (mimetype, protocolName, operation); + } + + /// @notice Split a blob by single-byte delimiter, keeping empty parts + /// @dev Enforces exactly 2 separators (3 parts): [mimetype, protocol, operation] + /// @param subject The blob to split + /// @param delim The single-byte delimiter + /// @return out Array with exactly 3 parts (some may be empty) + function _splitKeepEmpty(bytes memory subject, bytes1 delim) + private + pure + returns (bytes[] memory out) + { + // Find first separator + uint256 a = subject.indexOfByte(delim, 0); + if (a == LibBytes.NOT_FOUND) revert InvalidFormat(); + + // Find second separator + uint256 b = subject.indexOfByte(delim, a + 1); + if (b == LibBytes.NOT_FOUND) revert InvalidFormat(); + + // Ensure no third separator (enforce format) + if (subject.indexOfByte(delim, b + 1) != LibBytes.NOT_FOUND) revert InvalidFormat(); + + out = new bytes[](3); + out[0] = subject.slice(0, a); // mimetype (may be empty) + out[1] = subject.slice(a + 1, b); // protocol (may be empty) + out[2] = subject.slice(b + 1, subject.length); // operation (may be empty) + } + + /// @notice Check if bytes contains a specific byte + /// @dev Custom helper since LibBytes.contains requires bytes memory, not bytes1 + /// @param data The data to search + /// @param target The byte to find + /// @return True if found + function _containsByte(bytes memory data, bytes1 target) private pure returns (bool) { + return data.indexOfByte(target) != LibBytes.NOT_FOUND; + } +} diff --git a/contracts/test/AddressPrediction.t.sol b/contracts/test/AddressPrediction.t.sol index 39b7a7c..c4029c9 100644 --- a/contracts/test/AddressPrediction.t.sol +++ b/contracts/test/AddressPrediction.t.sol @@ -46,7 +46,7 @@ contract AddressPredictionTest is TestSetup { // First, create the ethscription that will represent this collection Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: collectionId, - contentUriHash: keccak256("collection-content"), + contentUriSha: keccak256("collection-content"), initialOwner: creator, content: bytes("collection-content"), mimetype: "text/plain", diff --git a/contracts/test/CollectionsManager.t.sol b/contracts/test/CollectionsManager.t.sol index f120955..676a16e 100644 --- a/contracts/test/CollectionsManager.t.sol +++ b/contracts/test/CollectionsManager.t.sol @@ -44,7 +44,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: COLLECTION_TX_HASH, - contentUriHash: sha256(bytes(collectionContent)), + contentUriSha: sha256(bytes(collectionContent)), initialOwner: alice, content: bytes(collectionContent), mimetype: "application/json", @@ -128,7 +128,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: collectionAndItemId, - contentUriHash: sha256(bytes("collection and item content")), + contentUriSha: sha256(bytes("collection and item content")), initialOwner: alice, content: bytes("collection and item content"), mimetype: "text/plain", @@ -208,7 +208,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { // Create the ethscription with protocol set to add itself to the collection Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: ITEM1_TX_HASH, - contentUriHash: sha256(bytes(itemContent)), + contentUriSha: sha256(bytes(itemContent)), initialOwner: alice, content: bytes(itemContent), mimetype: "application/json", @@ -303,7 +303,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory removeParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0xFEED)), - contentUriHash: sha256(bytes(removeContent)), + contentUriSha: sha256(bytes(removeContent)), initialOwner: alice, content: bytes(removeContent), mimetype: "application/json", @@ -354,7 +354,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory removeParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0xBAD)), - contentUriHash: sha256(bytes("data:,remove")), + contentUriSha: sha256(bytes("data:,remove")), initialOwner: bob, content: bytes("remove"), mimetype: "text/plain", @@ -414,7 +414,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { // Create ethscription that adds itself to the collection Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: itemHashes[i], - contentUriHash: sha256(abi.encodePacked("item", i)), + contentUriSha: sha256(abi.encodePacked("item", i)), initialOwner: owners[i], content: abi.encodePacked("item", i), mimetype: "text/plain", @@ -488,7 +488,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { // Create the ethscription with image content Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: ITEM1_TX_HASH, - contentUriHash: sha256(bytes(imageContent)), + contentUriSha: sha256(bytes(imageContent)), initialOwner: alice, content: bytes(imageContent), mimetype: "image/png", @@ -557,7 +557,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0xED171)), - contentUriHash: sha256(bytes("edit")), + contentUriSha: sha256(bytes("edit")), initialOwner: alice, content: bytes("edit"), mimetype: "text/plain", @@ -612,7 +612,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory itemParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: ITEM1_TX_HASH, - contentUriHash: sha256(bytes("item content")), + contentUriSha: sha256(bytes("item content")), initialOwner: alice, content: bytes("item content"), mimetype: "text/plain", @@ -641,7 +641,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0xED172)), - contentUriHash: sha256(bytes("partial-edit")), + contentUriSha: sha256(bytes("partial-edit")), initialOwner: alice, content: bytes("partial-edit"), mimetype: "text/plain", @@ -684,7 +684,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0xBADED17)), - contentUriHash: sha256(bytes("bad-edit")), + contentUriSha: sha256(bytes("bad-edit")), initialOwner: bob, content: bytes("bad-edit"), mimetype: "text/plain", @@ -722,7 +722,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0x901743)), - contentUriHash: sha256(bytes("no-item")), + contentUriSha: sha256(bytes("no-item")), initialOwner: alice, content: bytes("no-item"), mimetype: "text/plain", @@ -771,7 +771,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory syncParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0x5914C)), - contentUriHash: sha256(bytes("sync")), + contentUriSha: sha256(bytes("sync")), initialOwner: charlie, content: bytes("sync"), mimetype: "text/plain", @@ -824,7 +824,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory syncParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0x5914CE)), - contentUriHash: sha256(bytes("sync-nonexistent")), + contentUriSha: sha256(bytes("sync-nonexistent")), initialOwner: alice, content: bytes("sync-nonexistent"), mimetype: "text/plain", @@ -850,7 +850,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory syncParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0x5914CF)), - contentUriHash: sha256(bytes("sync-fake")), + contentUriSha: sha256(bytes("sync-fake")), initialOwner: alice, content: bytes("sync-fake"), mimetype: "text/plain", @@ -878,7 +878,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory lockParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0x10CCC)), - contentUriHash: sha256(bytes("lock")), + contentUriSha: sha256(bytes("lock")), initialOwner: alice, content: bytes("lock"), mimetype: "text/plain", @@ -907,7 +907,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory editParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(0x10C3ED)), - contentUriHash: sha256(bytes("locked-edit")), + contentUriSha: sha256(bytes("locked-edit")), initialOwner: alice, content: bytes("locked-edit"), mimetype: "text/plain", diff --git a/contracts/test/CollectionsProtocol.t.sol b/contracts/test/CollectionsProtocol.t.sol index a7acb70..7ef7d3b 100644 --- a/contracts/test/CollectionsProtocol.t.sol +++ b/contracts/test/CollectionsProtocol.t.sol @@ -36,7 +36,7 @@ contract CollectionsProtocolTest is TestSetup { // First, create the ethscription that will represent this collection Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256("test-collection-content"), + contentUriSha: keccak256("test-collection-content"), initialOwner: alice, content: bytes("test-collection-content"), mimetype: "text/plain", @@ -102,7 +102,7 @@ contract CollectionsProtocolTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(bytes(json)), + contentUriSha: keccak256(bytes(json)), initialOwner: alice, content: bytes(json), mimetype: "application/json", @@ -162,7 +162,7 @@ contract CollectionsProtocolTest is TestSetup { // First, create the ethscription that will represent this collection Ethscriptions.CreateEthscriptionParams memory ethscriptionParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256("call-test-content"), + contentUriSha: keccak256("call-test-content"), initialOwner: alice, content: bytes("call-test-content"), mimetype: "text/plain", diff --git a/contracts/test/ERC721Enumerable.t.sol b/contracts/test/ERC721Enumerable.t.sol index 8ba6647..b9a1370 100644 --- a/contracts/test/ERC721Enumerable.t.sol +++ b/contracts/test/ERC721Enumerable.t.sol @@ -19,7 +19,7 @@ contract ERC721EnumerableTest is TestSetup { return ethscriptions.createEthscription( Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(bytes(content)), + contentUriSha: keccak256(bytes(content)), initialOwner: owner, content: bytes(content), mimetype: "text/plain", diff --git a/contracts/test/EthscriptionsFailureHandling.t.sol b/contracts/test/EthscriptionsFailureHandling.t.sol index 248296f..16ff186 100644 --- a/contracts/test/EthscriptionsFailureHandling.t.sol +++ b/contracts/test/EthscriptionsFailureHandling.t.sol @@ -100,7 +100,7 @@ contract EthscriptionsFailureHandlingTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: sha256(bytes(dataUri)), + contentUriSha: sha256(bytes(dataUri)), initialOwner: address(this), content: bytes("Hello World with failing token manager"), mimetype: "text/plain", @@ -182,7 +182,7 @@ contract EthscriptionsFailureHandlingTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: sha256(bytes(dataUri)), + contentUriSha: sha256(bytes(dataUri)), initialOwner: address(this), content: bytes("{\"p\":\"test\",\"op\":\"deploy\",\"tick\":\"FAIL\",\"max\":\"1000\",\"lim\":\"10\"}"), mimetype: "application/json", diff --git a/contracts/test/EthscriptionsMultiTransfer.t.sol b/contracts/test/EthscriptionsMultiTransfer.t.sol index 32845fb..b3e51c2 100644 --- a/contracts/test/EthscriptionsMultiTransfer.t.sol +++ b/contracts/test/EthscriptionsMultiTransfer.t.sol @@ -231,7 +231,7 @@ contract EthscriptionsMultiTransferTest is TestSetup { "Ethscription Number", "Creator", "Initial Owner", - "Content SHA", + "Content Hash", "MIME Type", "ESIP-6", "L1 Block Number", diff --git a/contracts/test/EthscriptionsNullOwnership.t.sol b/contracts/test/EthscriptionsNullOwnership.t.sol index b584728..5b50635 100644 --- a/contracts/test/EthscriptionsNullOwnership.t.sol +++ b/contracts/test/EthscriptionsNullOwnership.t.sol @@ -22,15 +22,15 @@ contract EthscriptionsNullOwnershipTest is TestSetup { ); // Expect only one EthscriptionCreated event (no EthscriptionTransferred since from == address(0)) - bytes32 contentUriHash = sha256(bytes("data:text/plain,Null owned")); // Full data URI hash - bytes32 contentSha = sha256(bytes("Null owned")); // Raw content, not data URI + bytes32 contentUriSha = sha256(bytes("data:text/plain,Null owned")); // Full data URI hash + bytes32 contentHash = keccak256(bytes("Null owned")); // Raw content hash (keccak256, not sha256) vm.expectEmit(true, true, true, true); emit Ethscriptions.EthscriptionCreated( txHash, alice, // creator address(0), // initialOwner - contentUriHash, - contentSha, + contentUriSha, + contentHash, 11 // ethscription number (after 10 genesis) ); diff --git a/contracts/test/EthscriptionsProver.t.sol b/contracts/test/EthscriptionsProver.t.sol index 67b42c7..720fa2c 100644 --- a/contracts/test/EthscriptionsProver.t.sol +++ b/contracts/test/EthscriptionsProver.t.sol @@ -72,8 +72,8 @@ contract EthscriptionsProverTest is TestSetup { assertEq(decodedProof.previousOwner, alice); // assertEq(decodedProof.ethscriptionNumber, 0); assertEq(decodedProof.esip6, false); - assertTrue(decodedProof.contentSha != bytes32(0)); - assertTrue(decodedProof.contentUriHash != bytes32(0)); + assertTrue(decodedProof.contentHash != bytes32(0)); + assertTrue(decodedProof.contentUriSha != bytes32(0)); // l1BlockHash can be zero in test environment assertEq(decodedProof.l1BlockHash, bytes32(0)); } @@ -101,7 +101,7 @@ contract EthscriptionsProverTest is TestSetup { ethscriptions.createEthscription( Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash1, - contentUriHash: keccak256("data:,test1"), + contentUriSha: keccak256("data:,test1"), initialOwner: alice, content: bytes("test1"), mimetype: "text/plain", @@ -115,7 +115,7 @@ contract EthscriptionsProverTest is TestSetup { ethscriptions.createEthscription( Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash2, - contentUriHash: keccak256("data:,test2"), + contentUriSha: keccak256("data:,test2"), initialOwner: bob, content: bytes("test2"), mimetype: "text/plain", @@ -135,7 +135,7 @@ contract EthscriptionsProverTest is TestSetup { ethscriptions.createEthscription( Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash3, - contentUriHash: keccak256("data:,test3"), + contentUriSha: keccak256("data:,test3"), initialOwner: charlie, content: bytes("test3"), mimetype: "text/plain", diff --git a/contracts/test/EthscriptionsToken.t.sol b/contracts/test/EthscriptionsToken.t.sol index 098f690..23c230f 100644 --- a/contracts/test/EthscriptionsToken.t.sol +++ b/contracts/test/EthscriptionsToken.t.sol @@ -35,7 +35,7 @@ contract EthscriptionsTokenTest is TestSetup { bytes memory data ) internal pure returns (Ethscriptions.CreateEthscriptionParams memory) { bytes memory contentUriBytes = bytes(contentUri); - bytes32 contentUriHash = sha256(contentUriBytes); // Use SHA-256 to match production + bytes32 contentUriSha = sha256(contentUriBytes); // Use SHA-256 to match production // Extract content after "data:," bytes memory content; @@ -48,7 +48,7 @@ contract EthscriptionsTokenTest is TestSetup { return Ethscriptions.CreateEthscriptionParams({ ethscriptionId: transactionHash, - contentUriHash: contentUriHash, + contentUriSha: contentUriSha, initialOwner: initialOwner, content: content, mimetype: "text/plain", diff --git a/contracts/test/EthscriptionsTokenParams.t.sol b/contracts/test/EthscriptionsTokenParams.t.sol index 55cc51c..0076e8d 100644 --- a/contracts/test/EthscriptionsTokenParams.t.sol +++ b/contracts/test/EthscriptionsTokenParams.t.sol @@ -11,7 +11,7 @@ contract EthscriptionsTokenParamsTest is TestSetup { // Create a token deploy ethscription string memory tokenJson = '{"p":"erc-20","op":"deploy","tick":"eths","max":"21000000","lim":"1000"}'; string memory dataUri = string.concat("data:,", tokenJson); - bytes32 contentUriHash = sha256(bytes(dataUri)); + bytes32 contentUriSha = sha256(bytes(dataUri)); ERC20FixedDenominationManager.DeployOperation memory deployOp = ERC20FixedDenominationManager.DeployOperation({ tick: "eths", @@ -21,7 +21,7 @@ contract EthscriptionsTokenParamsTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(1)), - contentUriHash: contentUriHash, + contentUriSha: contentUriSha, initialOwner: address(this), content: bytes(tokenJson), mimetype: "text/plain", @@ -49,7 +49,7 @@ contract EthscriptionsTokenParamsTest is TestSetup { // Create a token mint ethscription string memory tokenJson = '{"p":"erc-20","op":"mint","tick":"eths","id":"1","amt":"1000"}'; string memory dataUri = string.concat("data:,", tokenJson); - bytes32 contentUriHash = sha256(bytes(dataUri)); + bytes32 contentUriSha = sha256(bytes(dataUri)); ERC20FixedDenominationManager.MintOperation memory mintOp = ERC20FixedDenominationManager.MintOperation({ tick: "eths", @@ -59,7 +59,7 @@ contract EthscriptionsTokenParamsTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(2)), - contentUriHash: contentUriHash, + contentUriSha: contentUriSha, initialOwner: address(this), content: bytes(tokenJson), mimetype: "text/plain", @@ -82,11 +82,11 @@ contract EthscriptionsTokenParamsTest is TestSetup { // Create a regular non-token ethscription string memory content = "Hello, World!"; string memory dataUri = string.concat("data:,", content); - bytes32 contentUriHash = sha256(bytes(dataUri)); + bytes32 contentUriSha = sha256(bytes(dataUri)); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: bytes32(uint256(3)), - contentUriHash: contentUriHash, + contentUriSha: contentUriSha, initialOwner: address(this), content: bytes(content), mimetype: "text/plain", @@ -119,7 +119,7 @@ contract EthscriptionsTokenParamsTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory deployParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: keccak256("deploy_tx"), - contentUriHash: sha256(bytes(deployUri)), + contentUriSha: sha256(bytes(deployUri)), initialOwner: address(this), content: bytes(deployJson), mimetype: "application/json", @@ -145,7 +145,7 @@ contract EthscriptionsTokenParamsTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory mintParams = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: keccak256("mint_tx"), - contentUriHash: sha256(bytes(mintUri)), + contentUriSha: sha256(bytes(mintUri)), initialOwner: address(this), content: bytes(mintJson), mimetype: "application/json", diff --git a/contracts/test/EthscriptionsWithContent.t.sol b/contracts/test/EthscriptionsWithContent.t.sol index f3d5bd8..a17f3ad 100644 --- a/contracts/test/EthscriptionsWithContent.t.sol +++ b/contracts/test/EthscriptionsWithContent.t.sol @@ -16,7 +16,7 @@ contract EthscriptionsWithContentTest is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(bytes("data:text/plain,Hello, World!")), + contentUriSha: keccak256(bytes("data:text/plain,Hello, World!")), initialOwner: initialOwner, content: bytes(testContent), mimetype: "text/plain", @@ -68,7 +68,7 @@ contract EthscriptionsWithContentTest is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(bytes("data:text/plain,Test by token ID")), + contentUriSha: keccak256(bytes("data:text/plain,Test by token ID")), initialOwner: initialOwner, content: bytes(testContent), mimetype: "text/plain", @@ -126,7 +126,7 @@ contract EthscriptionsWithContentTest is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(bytes("data:application/octet-stream,")), + contentUriSha: keccak256(bytes("data:application/octet-stream,")), initialOwner: initialOwner, content: largeContent, mimetype: "application/octet-stream", @@ -172,7 +172,7 @@ contract EthscriptionsWithContentTest is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(bytes("data:text/plain,HelloWorld")), + contentUriSha: keccak256(bytes("data:text/plain,HelloWorld")), initialOwner: initialOwner, content: smallContent, mimetype: "text/plain", diff --git a/contracts/test/EthscriptionsWithTestFunctions.sol b/contracts/test/EthscriptionsWithTestFunctions.sol index ab54ad9..75d42b7 100644 --- a/contracts/test/EthscriptionsWithTestFunctions.sol +++ b/contracts/test/EthscriptionsWithTestFunctions.sol @@ -15,21 +15,21 @@ contract EthscriptionsWithTestFunctions is Ethscriptions { /// @dev Test-only function to check if content exists function hasContent(bytes32 ethscriptionId) external view returns (bool) { EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); - return contentStorageBySha[ethscription.contentSha] != bytes32(0); + return contentStorage[ethscription.contentHash] != bytes32(0); } /// @notice Get the content storage value for an ethscription /// @dev Test-only function to inspect storage (either packed bytes or SSTORE2 address) function getContentStorage(bytes32 ethscriptionId) external view returns (bytes32) { EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); - return contentStorageBySha[ethscription.contentSha]; + return contentStorage[ethscription.contentHash]; } /// @notice Get the content pointer for an ethscription (only for SSTORE2 stored content) /// @dev Test-only function to inspect SSTORE2 address function getContentPointer(bytes32 ethscriptionId) external view returns (address) { EthscriptionStorage storage ethscription = _getEthscriptionOrRevert(ethscriptionId); - bytes32 stored = contentStorageBySha[ethscription.contentSha]; + bytes32 stored = contentStorage[ethscription.contentHash]; // Check if it's inline content using BytePackLib if (BytePackLib.isPacked(stored)) { diff --git a/contracts/test/MetaStore.t.sol b/contracts/test/MetaStore.t.sol new file mode 100644 index 0000000..6538e1d --- /dev/null +++ b/contracts/test/MetaStore.t.sol @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "forge-std/Test.sol"; +import "../src/libraries/MetaStoreLib.sol"; + +contract MetaStoreTest is Test { + // Test mapping for metadata storage + mapping(bytes32 => bytes32) public metaStore; + + function setUp() public {} + + // ============================================================= + // ENCODING TESTS + // ============================================================= + + function test_EncodeEmptyMimeNoProtocol() public { + bytes memory blob = MetaStoreLib.encode("", "", ""); + assertEq(blob.length, 0, "Empty mime + no protocol should be empty blob"); + } + + function test_EncodeTextPlainNoProtocol() public { + bytes memory blob = MetaStoreLib.encode("text/plain", "", ""); + assertEq(blob.length, 0, "text/plain + no protocol should be empty blob"); + } + + function test_EncodeMimeOnly() public { + bytes memory blob = MetaStoreLib.encode("image/png", "", ""); + string memory expected = string(abi.encodePacked( + "image/png", + bytes1(0x00), + "", // empty protocol + bytes1(0x00), + "" // empty operation + )); + assertEq(string(blob), expected, "Should contain mimetype with empty protocol/operation"); + } + + function test_EncodeMimeAndProtocol() public { + bytes memory blob = MetaStoreLib.encode("application/json", "tokens", ""); + string memory expected = string(abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "" // empty operation + )); + assertEq(string(blob), expected, "Should contain mime + protocol with empty operation"); + } + + function test_EncodeFullMetadata() public { + bytes memory blob = MetaStoreLib.encode("application/json", "tokens", "mint"); + string memory expected = string(abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + )); + assertEq(string(blob), expected, "Should contain all three components"); + } + + function test_EncodeTextPlainWithProtocol() public { + // text/plain is stored as empty string (convention) + bytes memory blob = MetaStoreLib.encode("text/plain", "tokens", "mint"); + string memory expected = string(abi.encodePacked( + // Empty mimetype (text/plain is normalized to empty) + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + )); + assertEq(string(blob), expected, "text/plain should be stored as empty string"); + } + + function test_EncodeDoesNotNormalizeMimetype() public { + bytes memory blob = MetaStoreLib.encode("Image/PNG", "", ""); + string memory expected = string(abi.encodePacked( + "Image/PNG", + bytes1(0x00), + "", + bytes1(0x00), + "" + )); + assertEq(string(blob), expected, "Should preserve mimetype case"); + } + + function test_EncodeRejectsInvalidSeparator() public pure { + // Test that we reject separator in mimetype + bytes memory blob = MetaStoreLib.encode("text/plain", "", ""); + // If we get here without reverting, the input was valid (as expected) + assertTrue(blob.length == 0, "Should encode valid input"); + + // Note: Testing for revert with separator character requires vm.expectRevert + // which doesn't work well with nested pure function calls + } + + // ============================================================= + // INTERNING TESTS + // ============================================================= + + function test_InternEmptyBlob() public { + bytes memory blob = bytes(""); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + assertEq(ref, bytes32(0), "Empty blob should return zero sentinel"); + } + + function test_InternSmallBlob() public { + bytes memory blob = abi.encodePacked("image/png"); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + // Should be packed (first byte > 0 and <= 32) + assertTrue(uint8(uint256(ref >> 248)) > 0 && uint8(uint256(ref >> 248)) <= 32, "Should be packed"); + } + + function test_InternLargeBlob() public { + // Create a 50-byte blob + bytes memory blob = new bytes(50); + for (uint i = 0; i < 50; i++) { + blob[i] = bytes1(uint8(97 + (i % 26))); // a-z + } + + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + // Should be an SSTORE2 pointer (not packed) + assertFalse(uint8(uint256(ref >> 248)) > 0 && uint8(uint256(ref >> 248)) <= 32, "Should not be packed"); + } + + function test_InternDeduplicates() public { + bytes memory blob = abi.encodePacked("image/png"); + + bytes32 ref1 = MetaStoreLib.intern(blob, metaStore); + bytes32 ref2 = MetaStoreLib.intern(blob, metaStore); + + assertEq(ref1, ref2, "Should return same reference for identical blobs"); + } + + // ============================================================= + // DECODING TESTS + // ============================================================= + + function test_DecodeEmptySentinel() public view { + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(bytes32(0)); + + assertEq(mime, "text/plain", "Should default to text/plain"); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_DecodeMimeOnly() public { + // Properly formatted blob with 2 separators + bytes memory blob = abi.encodePacked("image/png", bytes1(0x00), bytes1(0x00)); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "image/png", "Should decode mimetype"); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_DecodeMimeAndProtocol() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00) // empty operation + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "application/json", "Should decode mimetype"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_DecodeFullMetadata() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "application/json", "Should decode mimetype"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "mint", "Should decode operation"); + } + + function test_DecodeEmptyMimeDefaultsToTextPlain() public { + // Manually create blob with empty mimetype (0x00tokens0x00mint) + bytes memory blob = abi.encodePacked( + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "text/plain", "Empty mime should default to text/plain"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "mint", "Should decode operation"); + } + + function test_RoundTripTextPlainWithProtocol() public { + // Encoding "text/plain" should store as empty and decode back to "text/plain" + bytes memory blob = MetaStoreLib.encode("text/plain", "tokens", "mint"); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "text/plain", "Should decode to text/plain"); + assertEq(protocol, "tokens", "Should decode protocol"); + assertEq(op, "mint", "Should decode operation"); + } + + // ============================================================= + // GET MIMETYPE TESTS + // ============================================================= + + function test_GetMimetypeFromEmpty() public view { + string memory mime = MetaStoreLib.getMimetype(bytes32(0)); + assertEq(mime, "text/plain", "Should return text/plain for empty ref"); + } + + function test_GetMimetypeFromMimeOnly() public { + bytes memory blob = abi.encodePacked("image/png", bytes1(0x00), bytes1(0x00)); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + string memory mime = MetaStoreLib.getMimetype(ref); + assertEq(mime, "image/png", "Should extract mimetype"); + } + + function test_GetMimetypeFromFull() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + string memory mime = MetaStoreLib.getMimetype(ref); + assertEq(mime, "application/json", "Should extract mimetype before separator"); + } + + // ============================================================= + // GET PROTOCOL TESTS + // ============================================================= + + function test_GetProtocolFromEmpty() public view { + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(bytes32(0)); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_GetProtocolFromMimeOnly() public { + bytes memory blob = abi.encodePacked("image/png", bytes1(0x00), bytes1(0x00)); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(ref); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_GetProtocolFromMimeAndProtocol() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00) // empty operation + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(ref); + assertEq(protocol, "tokens", "Should extract protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_GetProtocolFromFull() public { + bytes memory blob = abi.encodePacked( + "application/json", + bytes1(0x00), + "tokens", + bytes1(0x00), + "mint" + ); + bytes32 ref = MetaStoreLib.intern(blob, metaStore); + + (string memory protocol, string memory op) = MetaStoreLib.getProtocol(ref); + assertEq(protocol, "tokens", "Should extract protocol"); + assertEq(op, "mint", "Should extract operation"); + } + + + // ============================================================= + // ROUND-TRIP TESTS + // ============================================================= + + function test_RoundTripMimeOnly() public { + bytes memory original = MetaStoreLib.encode("image/svg+xml", "", ""); + bytes32 ref = MetaStoreLib.intern(original, metaStore); + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "image/svg+xml", "Should preserve mimetype"); + assertEq(protocol, "", "Should have no protocol"); + assertEq(op, "", "Should have no operation"); + } + + function test_RoundTripFull() public { + bytes memory original = MetaStoreLib.encode("application/json", "erc-721-collection", "create"); + bytes32 ref = MetaStoreLib.intern(original, metaStore); + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, "application/json", "Should preserve mimetype"); + assertEq(protocol, "erc-721-collection", "Should preserve normalized protocol"); + assertEq(op, "create", "Should preserve normalized operation"); + } + + function test_RoundTripLongBlob() public { + // Create a blob that will require SSTORE2 + string memory longMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + bytes memory original = MetaStoreLib.encode(longMime, "very-long-protocol-name", "very-long-operation-name"); + bytes32 ref = MetaStoreLib.intern(original, metaStore); + (string memory mime, string memory protocol, string memory op) = + MetaStoreLib.decode(ref); + + assertEq(mime, longMime, "Should preserve long mimetype"); + assertEq(protocol, "very-long-protocol-name", "Should preserve long protocol"); + assertEq(op, "very-long-operation-name", "Should preserve long operation"); + } +} diff --git a/contracts/test/Multicall3.t.sol b/contracts/test/Multicall3.t.sol index afd2686..7de469e 100644 --- a/contracts/test/Multicall3.t.sol +++ b/contracts/test/Multicall3.t.sol @@ -51,7 +51,7 @@ contract Multicall3Test is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(abi.encodePacked("data:text/plain,Test", i)), + contentUriSha: keccak256(abi.encodePacked("data:text/plain,Test", i)), initialOwner: initialOwner, content: bytes(string(abi.encodePacked("Test content ", Strings.toString(i)))), mimetype: "text/plain", @@ -113,7 +113,7 @@ contract Multicall3Test is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256("data:text/plain,Mixed test"), + contentUriSha: keccak256("data:text/plain,Mixed test"), initialOwner: initialOwner, content: bytes("Mixed test content"), mimetype: "text/plain", diff --git a/contracts/test/PaginationGas.t.sol b/contracts/test/PaginationGas.t.sol index 3f3f8b7..fa9f2c4 100644 --- a/contracts/test/PaginationGas.t.sol +++ b/contracts/test/PaginationGas.t.sol @@ -41,7 +41,7 @@ contract PaginationGasTest is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(abi.encodePacked("uri", i)), + contentUriSha: keccak256(abi.encodePacked("uri", i)), initialOwner: owner, content: content, mimetype: "application/octet-stream", diff --git a/contracts/test/PaginationGas1000.t.sol b/contracts/test/PaginationGas1000.t.sol index 40b15e1..bd6b14c 100644 --- a/contracts/test/PaginationGas1000.t.sol +++ b/contracts/test/PaginationGas1000.t.sol @@ -27,7 +27,7 @@ contract PaginationGas1000Test is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(abi.encodePacked("uri", i)), + contentUriSha: keccak256(abi.encodePacked("uri", i)), initialOwner: owner, content: content, mimetype: "text/plain", diff --git a/contracts/test/PaginationGas1000Simple.t.sol b/contracts/test/PaginationGas1000Simple.t.sol index 915f65a..f265512 100644 --- a/contracts/test/PaginationGas1000Simple.t.sol +++ b/contracts/test/PaginationGas1000Simple.t.sol @@ -34,7 +34,7 @@ contract PaginationGas1000SimpleTest is TestSetup { vm.prank(creator); Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: keccak256(abi.encodePacked("uri", startId + i)), + contentUriSha: keccak256(abi.encodePacked("uri", startId + i)), initialOwner: owner, content: content, mimetype: "text/plain", diff --git a/contracts/test/ProtocolRegistration.t.sol b/contracts/test/ProtocolRegistration.t.sol index a2b0880..ac6b91a 100644 --- a/contracts/test/ProtocolRegistration.t.sol +++ b/contracts/test/ProtocolRegistration.t.sol @@ -113,7 +113,7 @@ contract ProtocolRegistrationTest is TestSetup { Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: sha256(bytes('data:,{"p":"mock-protocol","op":"test"}')), + contentUriSha: sha256(bytes('data:,{"p":"mock-protocol","op":"test"}')), initialOwner: alice, content: bytes('{"p":"mock-protocol","op":"test"}'), mimetype: "application/json", @@ -157,7 +157,7 @@ contract ProtocolRegistrationTest is TestSetup { // Create ethscription with unregistered protocol Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: sha256(bytes('data:,{"p":"unregistered","op":"test"}')), + contentUriSha: sha256(bytes('data:,{"p":"unregistered","op":"test"}')), initialOwner: alice, content: bytes('{"p":"unregistered","op":"test"}'), mimetype: "application/json", diff --git a/contracts/test/TestImageSVGWrapper.t.sol b/contracts/test/TestImageSVGWrapper.t.sol index 00b2e10..e3a351d 100644 --- a/contracts/test/TestImageSVGWrapper.t.sol +++ b/contracts/test/TestImageSVGWrapper.t.sol @@ -18,7 +18,7 @@ contract TestImageSVGWrapper is TestSetup { vm.prank(alice); ethscriptions.createEthscription(Ethscriptions.CreateEthscriptionParams({ ethscriptionId: txHash, - contentUriHash: sha256(bytes(pngDataUri)), + contentUriSha: sha256(bytes(pngDataUri)), initialOwner: alice, content: pngContent, mimetype: "image/png", diff --git a/contracts/test/TestSetup.sol b/contracts/test/TestSetup.sol index b9add35..b5dcd38 100644 --- a/contracts/test/TestSetup.sol +++ b/contracts/test/TestSetup.sol @@ -48,7 +48,7 @@ abstract contract TestSetup is Test { ) internal pure returns (Ethscriptions.CreateEthscriptionParams memory) { // Parse the data URI to extract needed info bytes memory contentUriBytes = bytes(dataUri); - bytes32 contentUriHash = sha256(contentUriBytes); // Use SHA-256 to match production + bytes32 contentUriSha = sha256(contentUriBytes); // Use SHA-256 to match production // Simple parsing for tests bytes memory content; @@ -116,7 +116,7 @@ abstract contract TestSetup is Test { return Ethscriptions.CreateEthscriptionParams({ ethscriptionId: transactionHash, - contentUriHash: contentUriHash, + contentUriSha: contentUriSha, initialOwner: initialOwner, content: content, mimetype: mimetype, diff --git a/lib/block_validator.rb b/lib/block_validator.rb index 7678d98..69eb05e 100644 --- a/lib/block_validator.rb +++ b/lib/block_validator.rb @@ -423,31 +423,31 @@ def verify_ethscription_storage(creation, l1_block_num, block_tag) end end - # Verify content_uri_hash - this is the hash of the original content URI - stored_uri_hash = stored[:content_uri_hash]&.downcase&.delete_prefix('0x') + # Verify content_uri_sha - this is the hash of the original content URI + stored_uri_hash = stored[:content_uri_sha]&.downcase&.delete_prefix('0x') if creation[:content_uri] # Hash the content_uri from the API to compare expected_uri_hash = Digest::SHA256.hexdigest(creation[:content_uri]).downcase if stored_uri_hash != expected_uri_hash - @errors << "Storage content_uri_hash mismatch for #{tx_hash}: stored=#{stored_uri_hash}, expected=#{expected_uri_hash}" + @errors << "Storage content_uri_sha mismatch for #{tx_hash}: stored=#{stored_uri_hash}, expected=#{expected_uri_hash}" end end - # Verify content_sha - always present in API, must match exactly (with instrumentation) - stored_sha = stored[:content_sha]&.downcase&.delete_prefix('0x') - expected_sha = creation[:content_sha].downcase.delete_prefix('0x') - content_sha_match = stored_sha == expected_sha - content_sha_check = record_comparison( - "storage_content_sha_check", + # Verify content_hash - always present in API, must match exactly (with instrumentation) + stored_sha = stored[:content_hash]&.downcase&.delete_prefix('0x') + expected_sha = creation[:content_hash].downcase.delete_prefix('0x') + content_hash_match = stored_sha == expected_sha + content_hash_check = record_comparison( + "storage_content_hash_check", tx_hash, expected_sha, stored_sha, - content_sha_match, + content_hash_match, { l1_block: l1_block_num } ) - if !content_sha_match - @errors << "Storage content_sha mismatch for #{tx_hash}: stored=#{stored[:content_sha]}, expected=#{creation[:content_sha]}" + if !content_hash_match + @errors << "Storage content_hash mismatch for #{tx_hash}: stored=#{stored[:content_hash]}, expected=#{creation[:content_hash]}" end # Verify mimetype - normalize to binary for comparison (with instrumentation) diff --git a/lib/event_decoder.rb b/lib/event_decoder.rb index 87af82e..8b5cc4d 100644 --- a/lib/event_decoder.rb +++ b/lib/event_decoder.rb @@ -91,16 +91,16 @@ def decode_creation(log) return nil if data_bytes.length < 96 # Need at least 3 * 32 bytes - content_uri_hash = '0x' + data_bytes[0, 32].unpack1('H*') - content_sha = '0x' + data_bytes[32, 32].unpack1('H*') + content_uri_sha = '0x' + data_bytes[0, 32].unpack1('H*') + content_hash = '0x' + data_bytes[32, 32].unpack1('H*') ethscription_number = data_bytes[64, 32].unpack1('H*').to_i(16) { tx_hash: tx_hash, creator: creator, initial_owner: initial_owner, - content_uri_hash: content_uri_hash, - content_sha: content_sha, + content_uri_sha: content_uri_sha, + content_hash: content_hash, ethscription_number: ethscription_number } rescue => e diff --git a/lib/storage_reader.rb b/lib/storage_reader.rb index b4147eb..cbe6c73 100644 --- a/lib/storage_reader.rb +++ b/lib/storage_reader.rb @@ -120,10 +120,10 @@ def get_ethscription_with_content(tx_hash, block_tag: 'latest') raise StandardError, "RPC call failed for ethscription #{tx_hash}" if result.nil? # Decode the single Ethscription struct with all fields including content - # New struct order: ethscriptionId, ethscriptionNumber, contentUriHash, contentSha, mimetype, content, - # currentOwner, creator, initialOwner, previousOwner, l1BlockHash, - # l1BlockNumber, l2BlockNumber, createdAt, esip6 - types = ['(bytes32,uint256,bytes32,bytes32,string,bytes,address,address,address,address,bytes32,uint256,uint256,uint256,bool)'] + # Struct order: ethscriptionId, ethscriptionNumber, contentUriSha, contentHash, mimetype, content, + # currentOwner, creator, initialOwner, previousOwner, l1BlockHash, + # l1BlockNumber, l2BlockNumber, createdAt, esip6, protocolName, operation + types = ['(bytes32,uint256,bytes32,bytes32,string,bytes,address,address,address,address,bytes32,uint256,uint256,uint256,bool,string,string)'] decoded = Eth::Abi.decode(types, result) # The struct is returned as an array @@ -135,8 +135,8 @@ def get_ethscription_with_content(tx_hash, block_tag: 'latest') ethscription_number: ethscription_data[1], # Content fields - content_uri_hash: '0x' + ethscription_data[2].unpack1('H*'), - content_sha: '0x' + ethscription_data[3].unpack1('H*'), + content_uri_sha: '0x' + ethscription_data[2].unpack1('H*'), + content_hash: '0x' + ethscription_data[3].unpack1('H*'), mimetype: ethscription_data[4], content: ethscription_data[5], # content is now at index 5 @@ -153,7 +153,9 @@ def get_ethscription_with_content(tx_hash, block_tag: 'latest') created_at: ethscription_data[13], # Protocol - esip6: ethscription_data[14] + esip6: ethscription_data[14], + protocol_name: ethscription_data[15], + operation: ethscription_data[16] } rescue => e Rails.logger.error "Failed to get ethscription with content #{tx_hash}: #{e.message}" @@ -180,10 +182,10 @@ def get_ethscription(tx_hash, block_tag: 'latest') raise StandardError, "RPC call failed for ethscription #{tx_hash}" if result.nil? # Decode the Ethscription struct without content - # Struct order: ethscriptionId, ethscriptionNumber, contentUriHash, contentSha, mimetype, content (empty), + # Struct order: ethscriptionId, ethscriptionNumber, contentUriSha, contentHash, mimetype, content (empty), # currentOwner, creator, initialOwner, previousOwner, l1BlockHash, - # l1BlockNumber, l2BlockNumber, createdAt, esip6 - types = ['(bytes32,uint256,bytes32,bytes32,string,bytes,address,address,address,address,bytes32,uint256,uint256,uint256,bool)'] + # l1BlockNumber, l2BlockNumber, createdAt, esip6, protocolName, operation + types = ['(bytes32,uint256,bytes32,bytes32,string,bytes,address,address,address,address,bytes32,uint256,uint256,uint256,bool,string,string)'] decoded = Eth::Abi.decode(types, result) # The struct is returned as an array @@ -195,8 +197,8 @@ def get_ethscription(tx_hash, block_tag: 'latest') ethscription_number: ethscription_data[1], # Content fields (no actual content bytes) - content_uri_hash: '0x' + ethscription_data[2].unpack1('H*'), - content_sha: '0x' + ethscription_data[3].unpack1('H*'), + content_uri_sha: '0x' + ethscription_data[2].unpack1('H*'), + content_hash: '0x' + ethscription_data[3].unpack1('H*'), mimetype: ethscription_data[4], # Skip content at index 5 (it's empty for WithoutContent) @@ -213,7 +215,9 @@ def get_ethscription(tx_hash, block_tag: 'latest') created_at: ethscription_data[13], # Protocol - esip6: ethscription_data[14] + esip6: ethscription_data[14], + protocol_name: ethscription_data[15], + operation: ethscription_data[16] } rescue EthRpcClient::ExecutionRevertedError => e # Contract reverted - ethscription doesn't exist diff --git a/spec/integration/ethscriptions_creation_spec.rb b/spec/integration/ethscriptions_creation_spec.rb index 08030a6..6bb9ea4 100644 --- a/spec/integration/ethscriptions_creation_spec.rb +++ b/spec/integration/ethscriptions_creation_spec.rb @@ -400,7 +400,7 @@ first_stored = get_ethscription_content(first_result[:ethscription_ids].first) second_stored = get_ethscription_content(second_result[:ethscription_ids].first) - expect(first_stored[:content_uri_hash]).to eq(second_stored[:content_uri_hash]) + expect(first_stored[:content_uri_sha]).to eq(second_stored[:content_uri_sha]) expect(first_stored[:esip6]).to be_truthy expect(second_stored[:esip6]).to be_truthy @@ -533,7 +533,7 @@ # Verify content URI hash expected_hash = Digest::SHA256.hexdigest(content_uri) - expect(stored[:content_uri_hash]).to eq("0x#{expected_hash}") + expect(stored[:content_uri_sha]).to eq("0x#{expected_hash}") # Verify block references are set expect(stored[:l1_block_number]).to be > 0 @@ -554,7 +554,7 @@ # Verify content URI hash matches expected_hash = Digest::SHA256.hexdigest(content_uri) - expect(stored[:content_uri_hash]).to eq("0x#{expected_hash}") + expect(stored[:content_uri_sha]).to eq("0x#{expected_hash}") end end end