From 772146b0a9a6f1beeed99ad59e3985bee722de6b Mon Sep 17 00:00:00 2001 From: Tom Lehman Date: Fri, 7 Nov 2025 13:59:55 -0500 Subject: [PATCH] Add URI resolution for Ethscriptions in ERC721 Collection - Introduced `_resolveEthscriptionURI` function to handle `esc://ethscriptions/{id}/data` references, resolving them to their corresponding media URIs. - Updated `contractURI` method to utilize resolved URIs for logo and banner images, enhancing metadata representation. - Added comprehensive tests for URI resolution, including validation for regular HTTP URIs, data URIs, and handling of malformed or non-existent ethscription references. --- .../src/ERC721EthscriptionsCollection.sol | 59 +++++- contracts/test/CollectionURIResolution.t.sol | 188 ++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 contracts/test/CollectionURIResolution.t.sol diff --git a/contracts/src/ERC721EthscriptionsCollection.sol b/contracts/src/ERC721EthscriptionsCollection.sol index 9bf1b10..00c1c86 100644 --- a/contracts/src/ERC721EthscriptionsCollection.sol +++ b/contracts/src/ERC721EthscriptionsCollection.sol @@ -6,6 +6,7 @@ import "./Ethscriptions.sol"; import "./libraries/Predeploys.sol"; import {LibString} from "solady/utils/LibString.sol"; import {Base64} from "solady/utils/Base64.sol"; +import {JSONParserLib} from "solady/utils/JSONParserLib.sol"; import "./ERC721EthscriptionsCollectionManager.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; @@ -147,12 +148,16 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = manager.getCollectionByAddress(address(this)); + // Resolve URIs (handles esc://ethscriptions/{id}/data references) + string memory image = _resolveEthscriptionURI(metadata.logoImageUri); + string memory bannerImage = _resolveEthscriptionURI(metadata.bannerImageUri); + // Build JSON with OpenSea fields string memory json = string.concat( '{"name":"', metadata.name.escapeJSON(), '","description":"', metadata.description.escapeJSON(), - '","image":"', metadata.logoImageUri.escapeJSON(), - '","banner_image":"', metadata.bannerImageUri.escapeJSON(), + '","image":"', image.escapeJSON(), + '","banner_image":"', bannerImage.escapeJSON(), '","external_link":"', metadata.websiteLink.escapeJSON(), '"}' ); @@ -183,4 +188,54 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab { revert TransferNotAllowed(); } + + // -------------------- URI Resolution Helpers -------------------- + + /// @notice Resolve URI, handling esc://ethscriptions/{id}/data format + /// @dev Returns empty string if esc:// reference not found (doesn't revert) + /// @param uri The URI to resolve (can be regular URI, data URI, or esc:// reference) + /// @return The resolved URI (or empty string if esc:// reference not found) + function _resolveEthscriptionURI(string memory uri) private view returns (string memory) { + // Check if it's an ethscription reference + if (!uri.startsWith("esc://ethscriptions/")) { + return uri; // Regular URI or data URI, pass through + } + + // Format: esc://ethscriptions/0x{64 hex chars}/data + // Split by "/" to extract parts: ["esc:", "", "ethscriptions", "0x{id}", "data"] + string[] memory parts = uri.split("/"); + + if (parts.length != 5 || !parts[4].eq("data")) { + return ""; // Invalid format + } + + // The ID should be at index 3 (after esc: / / ethscriptions /) + string memory hexId = parts[3]; + + // Validate hex ID format before parsing + if (bytes(hexId).length != 66) { + return ""; // Must be 0x + 64 hex chars + } + + // Parse hex string to bytes32 using JSONParserLib (reverts on invalid) + bytes32 ethscriptionId; + try this._parseHexToBytes32(hexId) returns (bytes32 parsed) { + ethscriptionId = parsed; + } catch { + return ""; // Invalid hex format + } + + // Try to get the ethscription's media URI + try ethscriptions.getMediaUri(ethscriptionId) returns (string memory, string memory mediaUri) { + return mediaUri; // Return the data URI from the referenced ethscription + } catch { + return ""; // Ethscription doesn't exist, return empty (don't revert) + } + } + + /// @notice Parse hex string to bytes32 (external for try/catch) + /// @dev Must be external to allow try/catch usage + function _parseHexToBytes32(string calldata hexStr) external pure returns (bytes32) { + return bytes32(JSONParserLib.parseUintFromHex(hexStr)); + } } diff --git a/contracts/test/CollectionURIResolution.t.sol b/contracts/test/CollectionURIResolution.t.sol new file mode 100644 index 0000000..4d65da7 --- /dev/null +++ b/contracts/test/CollectionURIResolution.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "./TestSetup.sol"; +import {LibString} from "solady/utils/LibString.sol"; + +contract CollectionURIResolutionTest is TestSetup { + using LibString for *; + bytes32 constant COLLECTION_TX_HASH = keccak256("collection_uri_test"); + bytes32 constant IMAGE_ETSC_TX_HASH = keccak256("image_ethscription"); + + address alice = makeAddr("alice"); + + function setUp() public override { + super.setUp(); + } + + function test_RegularHTTPURIPassesThrough() public { + // Create collection with regular HTTP URI + string memory regularUri = "https://example.com/logo.png"; + + bytes32 collectionId = _createCollectionWithLogo(regularUri); + + // Get collection metadata + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + collectionsHandler.getCollection(collectionId); + + assertEq(metadata.logoImageUri, regularUri, "Should preserve regular URI"); + + // contractURI should also pass it through + address collectionAddr = metadata.collectionContract; + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + string memory contractUri = collection.contractURI(); + + assertTrue(bytes(contractUri).length > 0, "Should have contractURI"); + assertTrue(contractUri.contains(regularUri), "Should contain original URI"); + } + + function test_DataURIPassesThrough() public { + // Create collection with data URI + string memory dataUri = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iMTAiPjwvc3ZnPg=="; + + bytes32 collectionId = _createCollectionWithLogo(dataUri); + + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + collectionsHandler.getCollection(collectionId); + + assertEq(metadata.logoImageUri, dataUri, "Should preserve data URI"); + } + + function test_EthscriptionReferenceResolvesToMediaURI() public { + // First create an ethscription with image content + string memory imageContent = "data:image/png;base64,iVBORw0KGgo="; + + Ethscriptions.CreateEthscriptionParams memory imageParams = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: IMAGE_ETSC_TX_HASH, + contentUriSha: sha256(bytes(imageContent)), + initialOwner: alice, + content: bytes(imageContent), + mimetype: "image/png", + esip6: false, + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "", + operation: "", + data: "" + }) + }); + + vm.prank(alice); + ethscriptions.createEthscription(imageParams); + + // Create collection with esc:// reference to the image + string memory escUri = string.concat( + "esc://ethscriptions/", + uint256(IMAGE_ETSC_TX_HASH).toHexString(), + "/data" + ); + + bytes32 collectionId = _createCollectionWithLogo(escUri); + + // Get collection and check contractURI resolves the reference + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + collectionsHandler.getCollection(collectionId); + + // Stored value should be the esc:// URI + assertEq(metadata.logoImageUri, escUri, "Should store esc:// URI"); + + // contractURI should resolve it to the media URI + address collectionAddr = metadata.collectionContract; + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + string memory contractUri = collection.contractURI(); + + // Should contain a data URI (resolved from the referenced ethscription) + assertTrue(contractUri.contains("data:"), "Should contain resolved data URI"); + } + + function test_InvalidEthscriptionReferenceReturnsEmpty() public { + // Reference to non-existent ethscription + bytes32 fakeId = keccak256("nonexistent"); + string memory escUri = string.concat( + "esc://ethscriptions/", + uint256(fakeId).toHexString(), + "/data" + ); + + bytes32 collectionId = _createCollectionWithLogo(escUri); + + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + collectionsHandler.getCollection(collectionId); + address collectionAddr = metadata.collectionContract; + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Should not revert, just return empty/placeholder + string memory contractUri = collection.contractURI(); + assertTrue(bytes(contractUri).length > 0, "Should return contractURI without reverting"); + } + + function test_MalformedEscURIReturnsEmpty() public { + // Various malformed esc:// URIs + string[] memory badUris = new string[](4); + badUris[0] = "esc://ethscriptions/notahexid/data"; + badUris[1] = "esc://ethscriptions/0x123/data"; // Too short + badUris[2] = "esc://ethscriptions/"; // Incomplete + badUris[3] = "esc://wrong/0x1234567890123456789012345678901234567890123456789012345678901234/data"; + + for (uint i = 0; i < badUris.length; i++) { + // Use unique collection ID for each iteration + bytes32 uniqueCollectionId = keccak256(abi.encodePacked("malformed_test", i)); + bytes32 collectionId = _createCollectionWithLogoAndId(badUris[i], uniqueCollectionId); + + ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata = + collectionsHandler.getCollection(collectionId); + address collectionAddr = metadata.collectionContract; + ERC721EthscriptionsCollection collection = ERC721EthscriptionsCollection(collectionAddr); + + // Should not revert + string memory contractUri = collection.contractURI(); + assertTrue(bytes(contractUri).length > 0, "Should return contractURI without reverting"); + } + } + + // -------------------- Helpers -------------------- + + function _createCollectionWithLogo(string memory logoUri) private returns (bytes32) { + return _createCollectionWithLogoAndId(logoUri, COLLECTION_TX_HASH); + } + + function _createCollectionWithLogoAndId(string memory logoUri, bytes32 collectionId) private returns (bytes32) { + ERC721EthscriptionsCollectionManager.CollectionParams memory metadata = + ERC721EthscriptionsCollectionManager.CollectionParams({ + name: "Test Collection", + symbol: "TEST", + maxSupply: 100, + description: "Test collection", + logoImageUri: logoUri, + bannerImageUri: "", + backgroundColor: "", + websiteLink: "", + twitterLink: "", + discordLink: "", + merkleRoot: bytes32(0) + }); + + string memory collectionContent = string.concat( + 'data:application/json,', + '{"p":"erc-721-ethscriptions-collection","op":"create_collection"}' + ); + + Ethscriptions.CreateEthscriptionParams memory params = Ethscriptions.CreateEthscriptionParams({ + ethscriptionId: collectionId, + contentUriSha: sha256(bytes(collectionContent)), + initialOwner: alice, + content: bytes(collectionContent), + mimetype: "application/json", + esip6: true, // Allow duplicate content URI + protocolParams: Ethscriptions.ProtocolParams({ + protocolName: "erc-721-ethscriptions-collection", + operation: "create_collection", + data: abi.encode(metadata) + }) + }); + + vm.prank(alice); + ethscriptions.createEthscription(params); + + return collectionId; + } +}