-
Notifications
You must be signed in to change notification settings - Fork 25
Expand file tree
/
Copy pathERC721EthscriptionsCollection.sol
More file actions
268 lines (219 loc) · 10.1 KB
/
Copy pathERC721EthscriptionsCollection.sol
File metadata and controls
268 lines (219 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import "./ERC721EthscriptionsEnumerableUpgradeable.sol";
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";
/// @title ERC721EthscriptionsCollection
/// @notice Thin ERC-721 wrapper for Ethscription collections where the manager controls mint/burn
contract ERC721EthscriptionsCollection is ERC721EthscriptionsEnumerableUpgradeable, OwnableUpgradeable {
using LibString for *;
Ethscriptions public constant ethscriptions = Ethscriptions(Predeploys.ETHSCRIPTIONS);
/// @notice Manager contract that deployed and controls this collection
ERC721EthscriptionsCollectionManager public manager;
/// @notice Collection ID stored locally to avoid callback to manager
bytes32 public collectionId;
// Events
event MemberAdded(bytes32 indexed ethscriptionId, uint256 indexed tokenId);
event MemberRemoved(bytes32 indexed ethscriptionId, uint256 indexed tokenId);
// Errors
error NotFactory();
error UnknownCollection();
error TransferNotAllowed();
modifier onlyFactory() {
if (msg.sender != address(manager)) revert NotFactory();
_;
}
function initialize(
string memory name_,
string memory symbol_,
address initialOwner_,
bytes32 collectionId_
) external initializer {
__ERC721_init(name_, symbol_);
if (initialOwner_ == address(0)) {
__Ownable_init(address(1));
_transferOwnership(address(0));
} else {
__Ownable_init(initialOwner_);
}
manager = ERC721EthscriptionsCollectionManager(msg.sender);
collectionId = collectionId_;
}
function addMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory {
address owner = ethscriptions.ownerOf(ethscriptionId);
// Handle minting to address(0) - mint to creator first then transfer
if (owner == address(0)) {
Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(ethscriptionId, false);
address creator = ethscription.creator;
_mint(creator, tokenId);
_transfer(creator, address(0), tokenId);
} else {
_mint(owner, tokenId);
}
emit MemberAdded(ethscriptionId, tokenId);
}
function removeMember(bytes32 ethscriptionId, uint256 tokenId) external onlyFactory {
require(_tokenExists(tokenId), "Token does not exist");
address owner = ownerOf(tokenId);
// Mark token as non-existent (handles enumeration cleanup)
_setTokenExists(tokenId, false);
// Emit burn-style transfer for indexers
emit Transfer(owner, address(0), tokenId);
emit MemberRemoved(ethscriptionId, tokenId);
}
/// @notice Called by the manager to mirror Ethscription transfers
function forceTransfer(address from, address to, uint256 tokenId) external onlyFactory {
require(_ownerOf(tokenId) == from, "Unexpected owner");
_transfer(from, to, tokenId);
}
/// @notice Let the manager update Ownable owner to match inscription holder
function factoryTransferOwnership(address newOwner) external onlyFactory {
_transferOwnership(newOwner);
}
function tokenURI(uint256 tokenId)
public
view
override(ERC721EthscriptionsUpgradeable)
returns (string memory)
{
if (!_tokenExists(tokenId)) revert("Token does not exist");
ERC721EthscriptionsCollectionManager.CollectionItem memory item =
manager.getCollectionItem(collectionId, tokenId);
if (item.ethscriptionId == bytes32(0)) revert("Token not in collection");
// Get the ethscription data to extract the ethscription number
Ethscriptions.Ethscription memory ethscription = ethscriptions.getEthscription(item.ethscriptionId, false);
(string memory mediaType, string memory mediaUri) = ethscriptions.getMediaUri(item.ethscriptionId);
// Convert ethscriptionId to hex string (0x prefixed)
string memory ethscriptionIdHex = uint256(item.ethscriptionId).toHexString(32);
string memory jsonStart = string.concat('{"name":"', item.name.escapeJSON(), '"');
if (bytes(item.description).length > 0) {
jsonStart = string.concat(jsonStart, ',"description":"', item.description.escapeJSON(), '"');
}
// Add ethscription ID and number
string memory ethscriptionFields = string.concat(
',"ethscription_id":"', ethscriptionIdHex, '"',
',"ethscription_number":', ethscription.ethscriptionNumber.toString()
);
string memory mediaField = string.concat(
',"',
mediaType,
'":"',
mediaUri,
'"'
);
string memory bgColor = "";
if (bytes(item.backgroundColor).length > 0) {
bgColor = string.concat(',"background_color":"', item.backgroundColor.escapeJSON(), '"');
}
string memory attributesJson = ',"attributes":[';
for (uint256 i = 0; i < item.attributes.length; i++) {
if (i > 0) attributesJson = string.concat(attributesJson, ',');
attributesJson = string.concat(
attributesJson,
'{"trait_type":"',
item.attributes[i].traitType.escapeJSON(),
'","value":"',
item.attributes[i].value.escapeJSON(),
'"}'
);
}
attributesJson = string.concat(attributesJson, ']');
string memory json = string.concat(jsonStart, ethscriptionFields, mediaField, bgColor, attributesJson, '}');
return string.concat("data:application/json;base64,", Base64.encode(bytes(json)));
}
// --- Transfer/approvals blocked externally ---------------------------------
function transferFrom(address, address, uint256)
public
pure
override(ERC721EthscriptionsUpgradeable, IERC721)
{
revert TransferNotAllowed();
}
/// @notice OpenSea collection-level metadata
/// @return JSON string with collection metadata
function contractURI() external view returns (string memory) {
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":"', image.escapeJSON(),
'","banner_image":"', bannerImage.escapeJSON(),
'","external_link":"', metadata.websiteLink.escapeJSON(),
'"}'
);
return string.concat("data:application/json;base64,", Base64.encode(bytes(json)));
}
function safeTransferFrom(address, address, uint256, bytes memory)
public
pure
override(ERC721EthscriptionsUpgradeable, IERC721)
{
revert TransferNotAllowed();
}
function approve(address, uint256)
public
pure
override(ERC721EthscriptionsUpgradeable, IERC721)
{
revert TransferNotAllowed();
}
function setApprovalForAll(address, bool)
public
pure
override(ERC721EthscriptionsUpgradeable, IERC721)
{
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));
}
}