Skip to content

Commit c75340f

Browse files
Merge pull request #138 from ethscriptions-protocol/fix_collections
Refactor ERC721 Ethscriptions Collection and Manager Contracts
2 parents 0ab3868 + f7f0997 commit c75340f

8 files changed

Lines changed: 133 additions & 41 deletions

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ GIT
1616

1717
GIT
1818
remote: https://github.com/0xfacet/facet_rails_common.git
19-
revision: e733e3877d835f68e8671d12d6ef9c0d24025af4
19+
revision: 05f6ce2b3b67de2bd8aef0151f985273f8074d05
2020
branch: lenient_base64
2121
specs:
2222
facet_rails_common (0.1.0)

contracts/src/ERC721EthscriptionsCollection.sol

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab
1717

1818
Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS);
1919

20-
/// @notice Factory (manager) that deployed this contract
21-
address public factory;
20+
/// @notice Manager contract that deployed and controls this collection
21+
ERC721EthscriptionsCollectionManager public manager;
2222

2323
// Events
2424
event MemberAdded(bytes32 indexed ethscriptionId, uint256 indexed tokenId);
@@ -30,7 +30,7 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab
3030
error TransferNotAllowed();
3131

3232
modifier onlyFactory() {
33-
if (msg.sender != factory) revert NotFactory();
33+
if (msg.sender != address(manager)) revert NotFactory();
3434
_;
3535
}
3636

@@ -41,12 +41,11 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab
4141
) external initializer {
4242
__ERC721_init(name_, symbol_);
4343
__Ownable_init(initialOwner_);
44-
factory = msg.sender;
44+
manager = ERC721EthscriptionsCollectionManager(msg.sender);
4545
}
4646

47-
/// @notice Lookup collection id via the factory registry
47+
/// @notice Lookup collection id via the manager registry
4848
function collectionId() public view returns (bytes32) {
49-
ERC721EthscriptionsCollectionManager manager = ERC721EthscriptionsCollectionManager(factory);
5049
bytes32 id = manager.collectionIdForAddress(address(this));
5150
if (id == bytes32(0)) revert UnknownCollection();
5251
return id;
@@ -89,7 +88,6 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab
8988
{
9089
if (!_tokenExists(tokenId)) revert("Token does not exist");
9190

92-
ERC721EthscriptionsCollectionManager manager = ERC721EthscriptionsCollectionManager(factory);
9391
ERC721EthscriptionsCollectionManager.CollectionItem memory item =
9492
manager.getCollectionItem(collectionId(), tokenId);
9593
if (item.ethscriptionId == bytes32(0)) revert("Token not in collection");
@@ -143,6 +141,25 @@ contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeab
143141
revert TransferNotAllowed();
144142
}
145143

144+
/// @notice OpenSea collection-level metadata
145+
/// @return JSON string with collection metadata
146+
function contractURI() external view returns (string memory) {
147+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory metadata =
148+
manager.getCollectionByAddress(address(this));
149+
150+
// Build JSON with OpenSea fields
151+
string memory json = string.concat(
152+
'{"name":"', metadata.name.escapeJSON(),
153+
'","description":"', metadata.description.escapeJSON(),
154+
'","image":"', metadata.logoImageUri.escapeJSON(),
155+
'","banner_image":"', metadata.bannerImageUri.escapeJSON(),
156+
'","external_link":"', metadata.websiteLink.escapeJSON(),
157+
'"}'
158+
);
159+
160+
return json;
161+
}
162+
146163
function safeTransferFrom(address, address, uint256, bytes memory)
147164
public
148165
pure

contracts/src/ERC721EthscriptionsCollectionManager.sol

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ pragma solidity 0.8.24;
33

44
import "@openzeppelin/contracts/utils/Create2.sol";
55
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
6+
import {LibString} from "solady/utils/LibString.sol";
67
import "./ERC721EthscriptionsCollection.sol";
78
import "./libraries/Proxy.sol";
9+
import "./libraries/DedupedBlobStore.sol";
810
import "./Ethscriptions.sol";
911
import "./libraries/Predeploys.sol";
1012
import "./interfaces/IProtocolHandler.sol";
1113

1214
contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
15+
using LibString for *;
16+
1317
struct Attribute {
1418
string traitType;
1519
string value;
@@ -30,6 +34,23 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
3034
}
3135

3236
struct CollectionRecord {
37+
address collectionContract;
38+
bool locked;
39+
bytes32 nameRef; // DedupedBlobStore reference
40+
bytes32 symbolRef; // DedupedBlobStore reference
41+
uint256 maxSupply;
42+
bytes32 descriptionRef; // DedupedBlobStore reference
43+
bytes32 logoImageRef; // DedupedBlobStore reference
44+
bytes32 bannerImageRef; // DedupedBlobStore reference
45+
bytes32 backgroundColorRef; // DedupedBlobStore reference
46+
bytes32 websiteLinkRef; // DedupedBlobStore reference
47+
bytes32 twitterLinkRef; // DedupedBlobStore reference
48+
bytes32 discordLinkRef; // DedupedBlobStore reference
49+
bytes32 merkleRoot;
50+
}
51+
52+
/// @notice View struct for external consumption with decoded strings
53+
struct CollectionMetadata {
3354
address collectionContract;
3455
bool locked;
3556
string name;
@@ -108,10 +129,13 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
108129
Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS);
109130
string public constant protocolName = "erc-721-ethscriptions-collection";
110131

111-
mapping(bytes32 => CollectionRecord) private collectionStore;
112-
mapping(bytes32 => mapping(uint256 => CollectionItem)) public collectionItems;
132+
mapping(bytes32 => CollectionRecord) internal collectionStore;
133+
mapping(bytes32 => mapping(uint256 => CollectionItem)) internal collectionItems;
113134
mapping(bytes32 => Membership) public membershipOfEthscription;
114-
mapping(address => bytes32) private collectionAddressToId;
135+
mapping(address => bytes32) internal collectionAddressToId;
136+
137+
/// @dev Deduplicated storage for collection string fields (name, description, URIs, etc.)
138+
mapping(bytes32 => bytes32) internal collectionBlobStorage;
115139

116140
bytes32[] public collectionIds;
117141

@@ -195,13 +219,14 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
195219
require(!collection.locked, "Collection is locked");
196220
_requireCollectionOwner(ethscriptionId, editOp.collectionId, "Only collection owner can edit");
197221

198-
if (bytes(editOp.description).length > 0) collection.description = editOp.description;
199-
if (bytes(editOp.logoImageUri).length > 0) collection.logoImageUri = editOp.logoImageUri;
200-
if (bytes(editOp.bannerImageUri).length > 0) collection.bannerImageUri = editOp.bannerImageUri;
201-
if (bytes(editOp.backgroundColor).length > 0) collection.backgroundColor = editOp.backgroundColor;
202-
if (bytes(editOp.websiteLink).length > 0) collection.websiteLink = editOp.websiteLink;
203-
if (bytes(editOp.twitterLink).length > 0) collection.twitterLink = editOp.twitterLink;
204-
if (bytes(editOp.discordLink).length > 0) collection.discordLink = editOp.discordLink;
222+
// Update fields (empty strings allowed to clear fields)
223+
(, collection.descriptionRef) = DedupedBlobStore.storeMemory(bytes(editOp.description), collectionBlobStorage);
224+
(, collection.logoImageRef) = DedupedBlobStore.storeMemory(bytes(editOp.logoImageUri), collectionBlobStorage);
225+
(, collection.bannerImageRef) = DedupedBlobStore.storeMemory(bytes(editOp.bannerImageUri), collectionBlobStorage);
226+
(, collection.backgroundColorRef) = DedupedBlobStore.storeMemory(bytes(editOp.backgroundColor), collectionBlobStorage);
227+
(, collection.websiteLinkRef) = DedupedBlobStore.storeMemory(bytes(editOp.websiteLink), collectionBlobStorage);
228+
(, collection.twitterLinkRef) = DedupedBlobStore.storeMemory(bytes(editOp.twitterLink), collectionBlobStorage);
229+
(, collection.discordLinkRef) = DedupedBlobStore.storeMemory(bytes(editOp.discordLink), collectionBlobStorage);
205230
collection.merkleRoot = editOp.merkleRoot;
206231

207232
emit CollectionEdited(editOp.collectionId);
@@ -263,8 +288,25 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
263288
return collectionStore[collectionId].collectionContract;
264289
}
265290

266-
function getCollection(bytes32 collectionId) external view returns (CollectionRecord memory) {
267-
return collectionStore[collectionId];
291+
function getCollection(bytes32 collectionId) public view returns (CollectionMetadata memory) {
292+
CollectionRecord storage record = collectionStore[collectionId];
293+
require(record.collectionContract != address(0), "Collection does not exist");
294+
295+
return CollectionMetadata({
296+
collectionContract: record.collectionContract,
297+
locked: record.locked,
298+
name: DedupedBlobStore.readString(record.nameRef),
299+
symbol: DedupedBlobStore.readString(record.symbolRef),
300+
maxSupply: record.maxSupply,
301+
description: DedupedBlobStore.readString(record.descriptionRef),
302+
logoImageUri: DedupedBlobStore.readString(record.logoImageRef),
303+
bannerImageUri: DedupedBlobStore.readString(record.bannerImageRef),
304+
backgroundColor: DedupedBlobStore.readString(record.backgroundColorRef),
305+
websiteLink: DedupedBlobStore.readString(record.websiteLinkRef),
306+
twitterLink: DedupedBlobStore.readString(record.twitterLinkRef),
307+
discordLink: DedupedBlobStore.readString(record.discordLinkRef),
308+
merkleRoot: record.merkleRoot
309+
});
268310
}
269311

270312
function getCollectionItem(bytes32 collectionId, uint256 itemIndex) external view returns (CollectionItem memory) {
@@ -300,29 +342,40 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
300342
require(!collectionExists(collectionId), "Collection already exists");
301343

302344
Proxy collectionProxy = new Proxy{salt: collectionId}(address(this));
303-
bytes memory initCalldata = abi.encodeWithSelector(
345+
346+
collectionProxy.upgradeToAndCall(collectionsImplementation, abi.encodeWithSelector(
304347
ERC721EthscriptionsCollection.initialize.selector,
305348
metadata.name,
306349
metadata.symbol,
307350
ethscriptions.ownerOf(collectionId)
308-
);
351+
));
309352

310-
collectionProxy.upgradeToAndCall(collectionsImplementation, initCalldata);
311353
collectionProxy.changeAdmin(Predeploys.PROXY_ADMIN);
312354

355+
// Store string fields using DedupedBlobStore
356+
(, bytes32 nameRef) = DedupedBlobStore.storeMemory(bytes(metadata.name), collectionBlobStorage);
357+
(, bytes32 symbolRef) = DedupedBlobStore.storeMemory(bytes(metadata.symbol), collectionBlobStorage);
358+
(, bytes32 descriptionRef) = DedupedBlobStore.storeMemory(bytes(metadata.description), collectionBlobStorage);
359+
(, bytes32 logoImageRef) = DedupedBlobStore.storeMemory(bytes(metadata.logoImageUri), collectionBlobStorage);
360+
(, bytes32 bannerImageRef) = DedupedBlobStore.storeMemory(bytes(metadata.bannerImageUri), collectionBlobStorage);
361+
(, bytes32 backgroundColorRef) = DedupedBlobStore.storeMemory(bytes(metadata.backgroundColor), collectionBlobStorage);
362+
(, bytes32 websiteLinkRef) = DedupedBlobStore.storeMemory(bytes(metadata.websiteLink), collectionBlobStorage);
363+
(, bytes32 twitterLinkRef) = DedupedBlobStore.storeMemory(bytes(metadata.twitterLink), collectionBlobStorage);
364+
(, bytes32 discordLinkRef) = DedupedBlobStore.storeMemory(bytes(metadata.discordLink), collectionBlobStorage);
365+
313366
collectionStore[collectionId] = CollectionRecord({
314367
collectionContract: address(collectionProxy),
315368
locked: false,
316-
name: metadata.name,
317-
symbol: metadata.symbol,
369+
nameRef: nameRef,
370+
symbolRef: symbolRef,
318371
maxSupply: metadata.maxSupply,
319-
description: metadata.description,
320-
logoImageUri: metadata.logoImageUri,
321-
bannerImageUri: metadata.bannerImageUri,
322-
backgroundColor: metadata.backgroundColor,
323-
websiteLink: metadata.websiteLink,
324-
twitterLink: metadata.twitterLink,
325-
discordLink: metadata.discordLink,
372+
descriptionRef: descriptionRef,
373+
logoImageRef: logoImageRef,
374+
bannerImageRef: bannerImageRef,
375+
backgroundColorRef: backgroundColorRef,
376+
websiteLinkRef: websiteLinkRef,
377+
twitterLinkRef: twitterLinkRef,
378+
discordLinkRef: discordLinkRef,
326379
merkleRoot: metadata.merkleRoot
327380
});
328381

@@ -406,4 +459,18 @@ contract ERC721EthscriptionsCollectionManager is IProtocolHandler {
406459
function _inImportMode() private view returns (bool) {
407460
return block.timestamp < Constants.historicalBackfillApproxDoneAt;
408461
}
462+
463+
function getMembershipOfEthscription(bytes32 ethscriptionId) external view returns (Membership memory) {
464+
return membershipOfEthscription[ethscriptionId];
465+
}
466+
467+
/// @notice Get collection metadata by address
468+
/// @param collectionAddress The collection contract address
469+
/// @return metadata The collection metadata with decoded strings
470+
function getCollectionByAddress(address collectionAddress) external view returns (CollectionMetadata memory) {
471+
bytes32 collectionId = collectionAddressToId[collectionAddress];
472+
require(collectionId != bytes32(0), "Collection not found");
473+
return getCollection(collectionId);
474+
}
409475
}
476+

contracts/src/Ethscriptions.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,10 @@ contract Ethscriptions is ERC721EthscriptionsSequentialEnumerableUpgradeable {
126126
mapping(bytes32 => EthscriptionStorage) internal ethscriptions;
127127

128128
/// @dev Content hash (keccak256) => packed content (for <32 bytes) or SSTORE2 pointer (for >=32 bytes)
129-
mapping(bytes32 => bytes32) public contentStorage;
129+
mapping(bytes32 => bytes32) internal contentStorage;
130130

131131
/// @dev Metadata blob hash (keccak256) => packed metadata or SSTORE2 pointer (for deduplicated metadata storage)
132-
mapping(bytes32 => bytes32) public metadataStorage;
132+
mapping(bytes32 => bytes32) internal metadataStorage;
133133

134134
/// @dev Content URI hash => first ethscription tx hash that used it (for protocol uniqueness check)
135135
/// @dev bytes32(0) means unused, non-zero means the content URI has been used

contracts/src/libraries/DedupedBlobStore.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ library DedupedBlobStore {
9292
return SSTORE2Unlimited.read(pointer);
9393
}
9494

95+
/// @notice Read blob from storage reference and convert to string
96+
/// @dev Convenience wrapper to avoid repetitive string() casting
97+
/// @param ref The storage reference (packed or SSTORE2 pointer)
98+
/// @return str The retrieved data as string
99+
function readString(bytes32 ref) internal view returns (string memory) {
100+
return string(read(ref));
101+
}
102+
95103
/// @notice Read blob from storage mapping by hash
96104
/// @dev Looks up reference in mapping, then reads
97105
/// @param hash The hash key

contracts/test/AddressPrediction.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ contract AddressPredictionTest is TestSetup {
8585
collectionsHandler.op_create_collection(collectionId, abi.encode(metadata));
8686

8787
// Assert deployed matches predicted
88-
ERC721EthscriptionsCollectionManager.CollectionRecord memory collection =
88+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection =
8989
collectionsHandler.getCollection(collectionId);
9090
address actual = collection.collectionContract;
9191
assertEq(actual, predicted, "Predicted collection address should match actual deployed proxy");

contracts/test/CollectionsManager.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ contract ERC721EthscriptionsCollectionManagerTest is TestSetup {
6868
// Collection owner is tracked through the original ethscription ownership
6969

7070
// Verify metadata was stored
71-
ERC721EthscriptionsCollectionManager.CollectionRecord memory stored = collectionsHandler.getCollection(COLLECTION_TX_HASH);
71+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory stored = collectionsHandler.getCollection(COLLECTION_TX_HASH);
7272
assertEq(stored.name, "Test Collection");
7373
assertEq(stored.symbol, "TEST");
7474
assertEq(stored.maxSupply, 100);

contracts/test/CollectionsProtocol.t.sol

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ contract CollectionsProtocolTest is TestSetup {
5858
bytes32 collectionId = txHash;
5959

6060
// Use the getter functions instead of direct mapping access
61-
ERC721EthscriptionsCollectionManager.CollectionRecord memory collection = collectionsHandler.getCollection(collectionId);
61+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection = collectionsHandler.getCollection(collectionId);
6262
assertNotEq(collection.collectionContract, address(0), "Collection contract should be deployed");
6363
assertEq(collection.locked, false, "Should not be locked");
6464

6565
ERC721EthscriptionsCollection collectionContract = ERC721EthscriptionsCollection(collection.collectionContract);
6666
assertEq(collectionContract.totalSupply(), 0, "Initial size should be 0");
6767

6868
// Verify metadata
69-
ERC721EthscriptionsCollectionManager.CollectionRecord memory stored = collectionsHandler.getCollection(collectionId);
69+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory stored = collectionsHandler.getCollection(collectionId);
7070
assertEq(stored.name, "Test Collection", "Name should match");
7171
assertEq(stored.symbol, "TEST", "Symbol should match");
7272
assertEq(stored.maxSupply, 100, "Max supply should match");
@@ -121,7 +121,7 @@ contract CollectionsProtocolTest is TestSetup {
121121
bytes32 collectionId = txHash;
122122

123123
// Read back the state
124-
ERC721EthscriptionsCollectionManager.CollectionRecord memory collection = collectionsHandler.getCollection(collectionId);
124+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection = collectionsHandler.getCollection(collectionId);
125125

126126
console.log("Collection exists:", collection.collectionContract != address(0));
127127
console.log("Collection contract:", collection.collectionContract);
@@ -134,7 +134,7 @@ contract CollectionsProtocolTest is TestSetup {
134134
assertEq(collectionContract.totalSupply(), 0);
135135

136136
// Read metadata
137-
ERC721EthscriptionsCollectionManager.CollectionRecord memory stored = collectionsHandler.getCollection(collectionId);
137+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory stored = collectionsHandler.getCollection(collectionId);
138138
assertEq(stored.name, "Test NFTs");
139139
assertEq(stored.symbol, "TEST");
140140
assertEq(stored.maxSupply, 100);
@@ -200,7 +200,7 @@ contract CollectionsProtocolTest is TestSetup {
200200
console.logBytes(result);
201201

202202
// Decode the result
203-
ERC721EthscriptionsCollectionManager.CollectionRecord memory collection = abi.decode(result, (ERC721EthscriptionsCollectionManager.CollectionRecord));
203+
ERC721EthscriptionsCollectionManager.CollectionMetadata memory collection = abi.decode(result, (ERC721EthscriptionsCollectionManager.CollectionMetadata));
204204

205205
assertTrue(collection.collectionContract != address(0), "Should have collection contract");
206206
assertEq(collection.locked, false);

0 commit comments

Comments
 (0)