Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions contracts/src/ERC721EthscriptionsCollection.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
'"}'
);
Expand Down Expand Up @@ -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)

Copilot AI Nov 7, 2025

Copy link

Choose a reason for hiding this comment

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

The comment states "using JSONParserLib (reverts on invalid)" but the code explicitly catches the revert and returns an empty string. The comment should be updated to reflect the actual behavior, e.g., "Parse hex string to bytes32 (returns empty on invalid)".

Suggested change
// Parse hex string to bytes32 using JSONParserLib (reverts on invalid)
// Parse hex string to bytes32 (returns empty on invalid)

Copilot uses AI. Check for mistakes.
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));
}
}
188 changes: 188 additions & 0 deletions contracts/test/CollectionURIResolution.t.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}