Skip to content

Commit b4dd853

Browse files
authored
feat: freeze NFT Metadata (#506)
* feat: freeze NFT metadata update NFTMetadata extension, add extension to prebuilt TokenERC721. update LoyaltyCard to fit updated extension. add test file for NFTMetadata (no test written yet) * Add testing for NFTMetadata - add testing coverage for NFTMetadata extension - Move URIFrozen to NFTMetadata extension - update INFTMetadata interface * rework metadata extension based on feedback: - replace freezeTokenURI with freezeMetadata - make metadata freeze apply to all tokens - add METADATA_ROLE to TokenERC721 - adjust TokenERC721 implementation such that METADATA_ROLE is required to set and freeze metadata. * Update URI structure of TokenERC721 * Fix review comments + lint + additional tests * add NFTMetadata extension to TokenERC1155 + tests * lint
1 parent 5ee15be commit b4dd853

File tree

9 files changed

+261
-14
lines changed

9 files changed

+261
-14
lines changed

contracts/extension/NFTMetadata.sol

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ pragma solidity ^0.8.0;
44
import "./interface/INFTMetadata.sol";
55

66
abstract contract NFTMetadata is INFTMetadata {
7-
mapping(uint256 => string) private _tokenURI;
7+
bool public uriFrozen;
8+
9+
mapping(uint256 => string) internal _tokenURI;
810

911
/// @notice Returns the metadata URI for a given NFT.
1012
function _getTokenURI(uint256 _tokenId) internal view virtual returns (string memory) {
1113
return _tokenURI[_tokenId];
1214
}
1315

14-
/// @notice SEts the metadata URI for a given NFT.
16+
/// @notice Sets the metadata URI for a given NFT.
1517
function _setTokenURI(uint256 _tokenId, string memory _uri) internal virtual {
1618
require(bytes(_uri).length > 0, "NFTMetadata: empty metadata.");
1719
_tokenURI[_tokenId] = _uri;
@@ -21,10 +23,19 @@ abstract contract NFTMetadata is INFTMetadata {
2123

2224
/// @notice Sets the metadata URI for a given NFT.
2325
function setTokenURI(uint256 _tokenId, string memory _uri) public virtual {
24-
require(_canSetMetadata(), "Not authorized to set metadata");
26+
require(_canSetMetadata(), "NFTMetadata: not authorized to set metadata.");
27+
require(!uriFrozen, "NFTMetadata: metadata is frozen.");
2528
_setTokenURI(_tokenId, _uri);
2629
}
2730

31+
function freezeMetadata() public virtual {
32+
require(_canFreezeMetadata(), "NFTMetadata: not authorized to freeze metdata");
33+
uriFrozen = true;
34+
emit MetadataFrozen();
35+
}
36+
2837
/// @dev Returns whether metadata can be set in the given execution context.
2938
function _canSetMetadata() internal view virtual returns (bool);
39+
40+
function _canFreezeMetadata() internal view virtual returns (bool);
3041
}

contracts/extension/interface/INFTMetadata.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ pragma solidity ^0.8.0;
44
import "../../eip/interface/IERC4906.sol";
55

66
interface INFTMetadata is IERC4906 {
7+
/// @dev This event emits when the metadata of all tokens are frozen.
8+
/// While not currently supported by marketplaces, this event allows
9+
/// future indexing if desired.
10+
event MetadataFrozen();
11+
712
/// @notice Sets the metadata URI for a given NFT.
813
function setTokenURI(uint256 _tokenId, string memory _uri) external;
14+
15+
/// @notice Freezes the metadata URI for a given NFT.
16+
function freezeMetadata() external;
917
}

contracts/prebuilts/loyalty/LoyaltyCard.sol

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,11 @@ contract LoyaltyCard is
350350
return hasRole(METADATA_ROLE, _msgSender());
351351
}
352352

353+
/// @dev Returns whether metadata can be frozen in the given execution context.
354+
function _canFreezeMetadata() internal view virtual override returns (bool) {
355+
return hasRole(METADATA_ROLE, _msgSender());
356+
}
357+
353358
/// @dev Returns whether operator restriction can be set in the given execution context.
354359
function _canSetOperatorRestriction() internal virtual override returns (bool) {
355360
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());

contracts/prebuilts/token/TokenERC1155.sol

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import "../../extension/interface/IPrimarySale.sol";
2121
import "../../extension/interface/IRoyalty.sol";
2222
import "../../extension/interface/IOwnable.sol";
2323

24+
import "../../extension/NFTMetadata.sol";
25+
2426
// Token
2527
import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol";
2628

@@ -59,7 +61,8 @@ contract TokenERC1155 is
5961
AccessControlEnumerableUpgradeable,
6062
DefaultOperatorFiltererUpgradeable,
6163
ERC1155Upgradeable,
62-
ITokenERC1155
64+
ITokenERC1155,
65+
NFTMetadata
6366
{
6467
using ECDSAUpgradeable for bytes32;
6568
using StringsUpgradeable for uint256;
@@ -82,6 +85,8 @@ contract TokenERC1155 is
8285
bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE");
8386
/// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s.
8487
bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE");
88+
/// @dev Only METADATA_ROLE holders can update NFT metadata.
89+
bytes32 private constant METADATA_ROLE = keccak256("METADATA_ROLE");
8590

8691
/// @dev Max bps in the thirdweb system
8792
uint256 private constant MAX_BPS = 10_000;
@@ -119,8 +124,6 @@ contract TokenERC1155 is
119124
/// @dev Mapping from mint request UID => whether the mint request is processed.
120125
mapping(bytes32 => bool) private minted;
121126

122-
mapping(uint256 => string) private _tokenURI;
123-
124127
/// @dev Token ID => total circulating supply of tokens with that ID.
125128
mapping(uint256 => uint256) public totalSupply;
126129

@@ -173,6 +176,9 @@ contract TokenERC1155 is
173176
_setupRole(MINTER_ROLE, _defaultAdmin);
174177
_setupRole(TRANSFER_ROLE, _defaultAdmin);
175178
_setupRole(TRANSFER_ROLE, address(0));
179+
180+
_setupRole(METADATA_ROLE, _defaultAdmin);
181+
_setRoleAdmin(METADATA_ROLE, METADATA_ROLE);
176182
}
177183

178184
/// ===== Public functions =====
@@ -388,8 +394,7 @@ contract TokenERC1155 is
388394
uint256 _amount
389395
) internal {
390396
if (bytes(_tokenURI[_tokenId]).length == 0) {
391-
require(bytes(_uri).length > 0, "empty uri.");
392-
_tokenURI[_tokenId] = _uri;
397+
_setTokenURI(_tokenId, _uri);
393398
}
394399

395400
_mint(_to, _tokenId, _amount, "");
@@ -584,6 +589,16 @@ contract TokenERC1155 is
584589
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
585590
}
586591

592+
/// @dev Returns whether metadata can be set in the given execution context.
593+
function _canSetMetadata() internal view virtual override returns (bool) {
594+
return hasRole(METADATA_ROLE, _msgSender());
595+
}
596+
597+
/// @dev Returns whether metadata can be frozen in the given execution context.
598+
function _canFreezeMetadata() internal view virtual override returns (bool) {
599+
return hasRole(METADATA_ROLE, _msgSender());
600+
}
601+
587602
function _msgSender()
588603
internal
589604
view

contracts/prebuilts/token/TokenERC721.sol

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import "../../extension/interface/IPrimarySale.sol";
2121
import "../../extension/interface/IRoyalty.sol";
2222
import "../../extension/interface/IOwnable.sol";
2323

24+
//Extensions
25+
import "../../extension/NFTMetadata.sol";
26+
2427
// Token
2528
import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
2629

@@ -61,7 +64,8 @@ contract TokenERC721 is
6164
AccessControlEnumerableUpgradeable,
6265
DefaultOperatorFiltererUpgradeable,
6366
ERC721EnumerableUpgradeable,
64-
ITokenERC721
67+
ITokenERC721,
68+
NFTMetadata
6569
{
6670
using ECDSAUpgradeable for bytes32;
6771
using StringsUpgradeable for uint256;
@@ -78,6 +82,8 @@ contract TokenERC721 is
7882
bytes32 private constant TRANSFER_ROLE = keccak256("TRANSFER_ROLE");
7983
/// @dev Only MINTER_ROLE holders can sign off on `MintRequest`s.
8084
bytes32 private constant MINTER_ROLE = keccak256("MINTER_ROLE");
85+
/// @dev Only METADATA_ROLE holders can update NFT metadata.
86+
bytes32 private constant METADATA_ROLE = keccak256("METADATA_ROLE");
8187

8288
/// @dev Max bps in the thirdweb system
8389
uint256 private constant MAX_BPS = 10_000;
@@ -109,9 +115,6 @@ contract TokenERC721 is
109115
/// @dev Mapping from mint request UID => whether the mint request is processed.
110116
mapping(bytes32 => bool) private minted;
111117

112-
/// @dev Mapping from tokenId => URI
113-
mapping(uint256 => string) private uri;
114-
115118
/// @dev Token ID => royalty recipient and bps for token
116119
mapping(uint256 => RoyaltyInfo) private royaltyInfoForToken;
117120

@@ -151,6 +154,10 @@ contract TokenERC721 is
151154
_owner = _defaultAdmin;
152155
_setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin);
153156
_setupRole(MINTER_ROLE, _defaultAdmin);
157+
158+
_setupRole(METADATA_ROLE, _defaultAdmin);
159+
_setRoleAdmin(METADATA_ROLE, METADATA_ROLE);
160+
154161
_setupRole(TRANSFER_ROLE, _defaultAdmin);
155162
_setupRole(TRANSFER_ROLE, address(0));
156163
}
@@ -182,7 +189,7 @@ contract TokenERC721 is
182189

183190
/// @dev Returns the URI for a tokenId
184191
function tokenURI(uint256 _tokenId) public view override returns (string memory) {
185-
return uri[_tokenId];
192+
return _tokenURI[_tokenId];
186193
}
187194

188195
/// @dev Lets an account with MINTER_ROLE mint an NFT.
@@ -320,7 +327,7 @@ contract TokenERC721 is
320327
nextTokenIdToMint += 1;
321328

322329
require(bytes(_uri).length > 0, "empty uri.");
323-
uri[tokenIdToMint] = _uri;
330+
_setTokenURI(tokenIdToMint, _uri);
324331

325332
_safeMint(_to, tokenIdToMint);
326333

@@ -464,6 +471,16 @@ contract TokenERC721 is
464471
return hasRole(DEFAULT_ADMIN_ROLE, _msgSender());
465472
}
466473

474+
/// @dev Returns whether metadata can be set in the given execution context.
475+
function _canSetMetadata() internal view virtual override returns (bool) {
476+
return hasRole(METADATA_ROLE, _msgSender());
477+
}
478+
479+
/// @dev Returns whether metadata can be frozen in the given execution context.
480+
function _canFreezeMetadata() internal view virtual override returns (bool) {
481+
return hasRole(METADATA_ROLE, _msgSender());
482+
}
483+
467484
function supportsInterface(bytes4 interfaceId)
468485
public
469486
view

src/test/LoyaltyCard.t.sol

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,39 @@ contract LoyaltyCardTest is BaseTest {
302302
loyaltyCard.mintWithSignature(_mintrequest, _signature);
303303
}
304304

305+
/*///////////////////////////////////////////////////////////////
306+
Unit tests: `setTokenURI`
307+
//////////////////////////////////////////////////////////////*/
308+
309+
function test_setTokenURI_state() public {
310+
string memory uri = "uri_string";
311+
312+
vm.prank(signer);
313+
loyaltyCard.setTokenURI(0, uri);
314+
315+
string memory _tokenURI = loyaltyCard.tokenURI(0);
316+
317+
assertEq(_tokenURI, uri);
318+
}
319+
320+
function test_setTokenURI_revert_NotAuthorized() public {
321+
string memory uri = "uri_string";
322+
323+
vm.expectRevert("NFTMetadata: not authorized to set metadata.");
324+
vm.prank(address(0x1));
325+
loyaltyCard.setTokenURI(0, uri);
326+
}
327+
328+
function test_setTokenURI_revert_Frozen() public {
329+
string memory uri = "uri_string";
330+
331+
vm.startPrank(signer);
332+
loyaltyCard.freezeMetadata();
333+
334+
vm.expectRevert("NFTMetadata: metadata is frozen.");
335+
loyaltyCard.setTokenURI(0, uri);
336+
}
337+
305338
/*///////////////////////////////////////////////////////////////
306339
Audit fixes tests
307340
//////////////////////////////////////////////////////////////*/
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity ^0.8.11;
3+
4+
import "@std/Test.sol";
5+
import "@ds-test/test.sol";
6+
7+
import { NFTMetadata } from "contracts/extension/NFTMetadata.sol";
8+
9+
contract NFTMetadataHarness is NFTMetadata {
10+
address private authorized;
11+
12+
constructor() {
13+
authorized = msg.sender;
14+
}
15+
16+
function _canSetMetadata() internal view override returns (bool) {
17+
if (msg.sender == authorized) return true;
18+
return false;
19+
}
20+
21+
function _canFreezeMetadata() internal view override returns (bool) {
22+
if (msg.sender == authorized) return true;
23+
return false;
24+
}
25+
26+
function getTokenURI(uint256 _tokenId) external view returns (string memory) {
27+
return _getTokenURI(_tokenId);
28+
}
29+
30+
function URIStatus() external view returns (bool) {
31+
return uriFrozen;
32+
}
33+
34+
function supportsInterface(bytes4 interfaceId) external view override returns (bool) {}
35+
}
36+
37+
contract ExtensionNFTMetadata is DSTest, Test {
38+
NFTMetadataHarness internal ext;
39+
40+
function setUp() public {
41+
ext = new NFTMetadataHarness();
42+
}
43+
44+
/*///////////////////////////////////////////////////////////////
45+
Unit tests: `setTokenURI`
46+
//////////////////////////////////////////////////////////////*/
47+
48+
function test_setTokenURI_state() public {
49+
string memory uri = "test";
50+
ext.setTokenURI(0, uri);
51+
assertEq(ext.getTokenURI(0), uri);
52+
53+
string memory uri2 = "test2";
54+
ext.setTokenURI(0, uri2);
55+
assertEq(ext.getTokenURI(0), uri2);
56+
}
57+
58+
function test_setTokenURI_revert_notAuthorized() public {
59+
vm.startPrank(address(0x1));
60+
string memory uri = "test";
61+
vm.expectRevert("NFTMetadata: not authorized to set metadata.");
62+
ext.setTokenURI(1, uri);
63+
}
64+
65+
function test_setTokenURI_revert_emptyMetadata() public {
66+
string memory uri = "";
67+
vm.expectRevert("NFTMetadata: empty metadata.");
68+
ext.setTokenURI(1, uri);
69+
}
70+
71+
function test_setTokenURI_revert_frozen() public {
72+
ext.freezeMetadata();
73+
string memory uri = "test";
74+
vm.expectRevert("NFTMetadata: metadata is frozen.");
75+
ext.setTokenURI(2, uri);
76+
}
77+
78+
/*///////////////////////////////////////////////////////////////
79+
Unit tests: `freezeMetadata`
80+
//////////////////////////////////////////////////////////////*/
81+
82+
function test_freezeMetadata_state() public {
83+
ext.freezeMetadata();
84+
assertEq(ext.URIStatus(), true);
85+
}
86+
87+
function test_freezeMetadata_revert_notAuthorized() public {
88+
vm.startPrank(address(0x1));
89+
vm.expectRevert("NFTMetadata: not authorized to freeze metdata");
90+
ext.freezeMetadata();
91+
}
92+
}

src/test/token/TokenERC1155.t.sol

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,4 +903,37 @@ contract TokenERC1155Test is BaseTest {
903903
vm.prank(address(0x1));
904904
tokenContract.setContractURI("");
905905
}
906+
907+
/*///////////////////////////////////////////////////////////////
908+
Unit tests: setTokenURI
909+
//////////////////////////////////////////////////////////////*/
910+
911+
function test_setTokenURI_state() public {
912+
string memory uri = "uri_string";
913+
914+
vm.prank(deployerSigner);
915+
tokenContract.setTokenURI(0, uri);
916+
917+
string memory _tokenURI = tokenContract.uri(0);
918+
919+
assertEq(_tokenURI, uri);
920+
}
921+
922+
function test_setTokenURI_revert_NotAuthorized() public {
923+
string memory uri = "uri_string";
924+
925+
vm.expectRevert("NFTMetadata: not authorized to set metadata.");
926+
vm.prank(address(0x1));
927+
tokenContract.setTokenURI(0, uri);
928+
}
929+
930+
function test_setTokenURI_revert_Frozen() public {
931+
string memory uri = "uri_string";
932+
933+
vm.startPrank(deployerSigner);
934+
tokenContract.freezeMetadata();
935+
936+
vm.expectRevert("NFTMetadata: metadata is frozen.");
937+
tokenContract.setTokenURI(0, uri);
938+
}
906939
}

0 commit comments

Comments
 (0)