diff --git a/src/escrow/increasing/Lock.sol b/src/escrow/increasing/Lock.sol index b37dd86..5f23dcb 100644 --- a/src/escrow/increasing/Lock.sol +++ b/src/escrow/increasing/Lock.sol @@ -2,14 +2,23 @@ pragma solidity ^0.8.17; import {ILock} from "@escrow-interfaces/ILock.sol"; +import {ERC721Upgradeable as ERC721} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; import {ERC721EnumerableUpgradeable as ERC721Enumerable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; +import {ERC721URIStorageUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; import {ReentrancyGuardUpgradeable as ReentrancyGuard} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {DaoAuthorizableUpgradeable as DaoAuthorizable} from "@aragon/osx/core/plugin/dao-authorizable/DaoAuthorizableUpgradeable.sol"; import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; /// @title NFT representation of an escrow locking mechanism -contract Lock is ILock, ERC721Enumerable, UUPSUpgradeable, DaoAuthorizable, ReentrancyGuard { +contract Lock is + ILock, + ERC721Enumerable, + ERC721URIStorageUpgradeable, + UUPSUpgradeable, + DaoAuthorizable, + ReentrancyGuard +{ /// @dev enables transfers without whitelisting address public constant WHITELIST_ANY_ADDRESS = address(uint160(uint256(keccak256("WHITELIST_ANY_ADDRESS")))); @@ -23,6 +32,8 @@ contract Lock is ILock, ERC721Enumerable, UUPSUpgradeable, DaoAuthorizable, Reen /// @notice Whitelisted contracts that are allowed to transfer mapping(address => bool) public whitelisted; + string public baseTokenURI; + /*////////////////////////////////////////////////////////////// Modifiers //////////////////////////////////////////////////////////////*/ @@ -38,7 +49,7 @@ contract Lock is ILock, ERC721Enumerable, UUPSUpgradeable, DaoAuthorizable, Reen function supportsInterface( bytes4 _interfaceId - ) public view override(ERC721Enumerable) returns (bool) { + ) public view override(ERC721Enumerable, ERC721URIStorageUpgradeable) returns (bool) { return super.supportsInterface(_interfaceId) || _interfaceId == type(ILock).interfaceId; } @@ -83,6 +94,16 @@ contract Lock is ILock, ERC721Enumerable, UUPSUpgradeable, DaoAuthorizable, Reen emit WhitelistSet(WHITELIST_ANY_ADDRESS, true); } + /// @notice Override the beforeTokenTransfer as required by ERC721Enumerable + function _beforeTokenTransfer( + address _from, + address _to, + uint256 _tokenId, + uint256 batchSize + ) internal override(ERC721, ERC721Enumerable) { + super._beforeTokenTransfer(_from, _to, _tokenId, batchSize); + } + /// @dev Override the transfer to check if the recipient is whitelisted /// This avoids needing to check for mint/burn but is less idomatic than beforeTokenTransfer function _transfer(address _from, address _to, uint256 _tokenId) internal override { @@ -110,6 +131,30 @@ contract Lock is ILock, ERC721Enumerable, UUPSUpgradeable, DaoAuthorizable, Reen _burn(_tokenId); } + function _burn(uint256 _tokenId) internal override(ERC721, ERC721URIStorageUpgradeable) { + super._burn(_tokenId); + } + + function tokenURI( + uint256 _tokenId + ) public view override(ERC721, ERC721URIStorageUpgradeable) returns (string memory) { + return super.tokenURI(_tokenId); + } + + function _baseURI() internal view override returns (string memory) { + return baseTokenURI; + } + + function setBaseURI(string memory _baseTokenURI) external auth(LOCK_ADMIN_ROLE) { + baseTokenURI = _baseTokenURI; + + emit BaseURISet(baseTokenURI); + } + + function setTokenURI(uint256 _tokenId, string memory _tokenURI) external auth(LOCK_ADMIN_ROLE) { + _setTokenURI(_tokenId, _tokenURI); + } + /*////////////////////////////////////////////////////////////// UUPS Upgrade //////////////////////////////////////////////////////////////*/ diff --git a/src/escrow/increasing/interfaces/ILock.sol b/src/escrow/increasing/interfaces/ILock.sol index 82a22a8..90f9914 100644 --- a/src/escrow/increasing/interfaces/ILock.sol +++ b/src/escrow/increasing/interfaces/ILock.sol @@ -1,6 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; +/*/////////////////////////////////////////////////////////////// + METADATA +//////////////////////////////////////////////////////////////*/ + +interface IMetadataEvents { + /// @notice Event emmited when the base metadata URI is updated + /// @param uri New base URI + event BaseURISet(string uri); +} + /*/////////////////////////////////////////////////////////////// WHITELIST //////////////////////////////////////////////////////////////*/ @@ -21,7 +31,9 @@ interface IWhitelist is IWhitelistEvents, IWhitelistErrors { function whitelisted(address addr) external view returns (bool); } -interface ILock is IWhitelist { +interface IMetadata is IMetadataEvents {} + +interface ILock is IWhitelist, IMetadata { error OnlyEscrow(); /// @notice Address of the escrow contract that holds underyling assets diff --git a/test/escrow/escrow/Lock.t.sol b/test/escrow/escrow/Lock.t.sol index 4c5ae05..338d498 100644 --- a/test/escrow/escrow/Lock.t.sol +++ b/test/escrow/escrow/Lock.t.sol @@ -5,8 +5,12 @@ import {EscrowBase} from "./EscrowBase.sol"; import {console2 as console} from "forge-std/console2.sol"; import {IDAO} from "@aragon/osx/core/dao/IDAO.sol"; import {DAO} from "@aragon/osx/core/dao/DAO.sol"; +import {DaoUnauthorized} from "@aragon/osx/core/utils/auth.sol"; import {Multisig, MultisigSetup} from "@aragon/multisig/MultisigSetup.sol"; +import {IERC721EnumerableUpgradeable as IERC721Enumerable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/IERC721EnumerableUpgradeable.sol"; +import {IERC721MetadataUpgradeable as IERC721Metadata} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/IERC721MetadataUpgradeable.sol"; + import {ProxyLib} from "@libs/ProxyLib.sol"; import {IEscrowCurveTokenStorage} from "@escrow-interfaces/IEscrowCurveIncreasing.sol"; @@ -31,12 +35,15 @@ contract TestLockMintBurn is EscrowBase, IEscrowCurveTokenStorage, IGaugeVote { assertEq(_nftLock.name(), _name); assertEq(_nftLock.symbol(), _symbol); assertEq(_nftLock.escrow(), _escrow); + assertEq(_nftLock.baseTokenURI(), ""); assertEq(address(_nftLock.dao()), _dao); } function testSupportsInterface() public view { assertTrue(nftLock.supportsInterface(type(ILock).interfaceId)); assertFalse(nftLock.supportsInterface(0xffffffff)); + assertTrue(nftLock.supportsInterface(type(IERC721Metadata).interfaceId)); + assertTrue(nftLock.supportsInterface(type(IERC721Enumerable).interfaceId)); } function testFuzz_OnlyEscrowCanMint(address _notEscrow) public { @@ -95,6 +102,54 @@ contract TestLockMintBurn is EscrowBase, IEscrowCurveTokenStorage, IGaugeVote { vm.expectRevert("revert"); newLock.mint(address(reentrant), 1); } + + function testSetNFTMetadata() public { + vm.prank(address(escrow)); + nftLock.mint(address(123), 1); + + assertEq(nftLock.tokenURI(1), ""); + + vm.prank(address(this)); + nftLock.setBaseURI("https://example.com/"); + assertEq(nftLock.baseTokenURI(), "https://example.com/"); + assertEq(nftLock.tokenURI(1), "https://example.com/1"); + + vm.prank(address(this)); + nftLock.setTokenURI(1, "?tokenId=1"); + assertEq(nftLock.tokenURI(1), "https://example.com/?tokenId=1"); + + vm.prank(address(escrow)); + nftLock.mint(address(123), 2); + + assertEq(nftLock.tokenURI(2), "https://example.com/2"); + } + + function testOnlyOwnerCanSetNFTMetadata(address _notEscrow) public { + vm.assume(_notEscrow != address(this)); + + bytes memory data = abi.encodeWithSelector( + DaoUnauthorized.selector, + address(dao), + address(nftLock), + address(_notEscrow), + nftLock.LOCK_ADMIN_ROLE() + ); + + vm.prank(address(escrow)); + nftLock.mint(address(123), 1); + + vm.prank(_notEscrow); + vm.expectRevert(data); + nftLock.setBaseURI("https://example.com/"); + + assertEq(nftLock.baseTokenURI(), ""); + assertEq(nftLock.tokenURI(1), ""); + + vm.prank(_notEscrow); + vm.expectRevert(data); + nftLock.setTokenURI(1, "?tokenId=1"); + assertEq(nftLock.tokenURI(1), ""); + } } contract NFTReentrant {