Contents

How to Create an NFT Contract with Oppenzeppelin

ERC721

  • // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import { ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    
    contract Wave is ERC721 {
    	constructor() ERC721("Wave", "WV") {}
    
      function mint(address to, uint256 tokenId) public {
        _safeMint(to, tokenId);
      }
    }
    

Access

Owner

  •   // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.20;
    
      import { ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
    
      contract Wave is ERC721, Ownable {
          constructor() ERC721("Wave", "WV") Ownable(msg.sender) {}
    
          function mint(address to, uint256 tokenId) public onlyOwner  {
              _safeMint(to, tokenId);
          }
      }
    

Minter

  • Add /interfaces/IMinterManager.sol

    // SPDX-License-Identifier: MIT
    
    pragma solidity ^0.8.20;
    
    interface IMinterManager {
        function isMinter(address account) external view returns (bool);
        function addMinter(address account) external;
        function removeMinter(address account) external;
    }
    
  • // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import { ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
    import {IMinterManager} from "./interfaces/IMinterManager.sol";
    
    contract Wave is ERC721, Ownable, IMinterManager{
        mapping (address => bool) private  _minters;
    
        constructor() ERC721("Wave", "WV") Ownable(msg.sender) {}
    
        modifier onlyMinter(){
            require(_minters[msg.sender], "Not minter");
            _;
        }
    
        function mint(address to, uint256 tokenId) public onlyMinter {
            _safeMint(to, tokenId);
        }
    
        function isMinter(address account) external view  override  returns (bool){
            return _minters[account];
        }
    
        function addMinter(address account) external onlyOwner  override {
            _minters[account] = true;
        }
    
        function removeMinter(address account) external onlyOwner  override {
            delete _minters[account];
        }
    }
    

Pausable

  • // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import { ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
    import {IMinterManager} from "./interfaces/IMinterManager.sol";
    import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
    
    contract Wave is ERC721, Ownable, IMinterManager, Pausable{
        mapping (address => bool) private  _minters;
    
        constructor() ERC721("Wave", "WV") Ownable(msg.sender) {}
    
        modifier onlyMinter(){
            require(_minters[msg.sender], "Not minter");
            _;
        }
    
        function mint(address to, uint256 tokenId) public whenNotPaused onlyMinter {
            _safeMint(to, tokenId);
        }
    
        function isMinter(address account) external view   returns (bool){
            return _minters[account];
        }
    
        function addMinter(address account) external whenNotPaused onlyOwner  override {
            _minters[account] = true;
        }
    
        function removeMinter(address account) external whenNotPaused onlyOwner  override {
            delete _minters[account];
        }
    
        function pause() public onlyOwner {
            _pause();
        }
    
        function unpause() public onlyOwner {
            _unpause();
        }
    }
    

Lockable

  • Add /interfaces/ILockable.sol

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    interface ILockable  {
        function lockTokens(uint256[] calldata tokenIds,uint256 lockTime) external;
        function unlockTokens(uint256[] calldata tokenIds) external;
        function isTokenLocked(uint256 tokenId) external view returns (bool);
    }
    
  • // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
    import {IMinterManager} from "./interfaces/IMinterManager.sol";
    import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
    import {ILockable} from "./interfaces/ILockable.sol";
    
    contract Wave is ERC721, Ownable, IMinterManager, Pausable, ILockable {
        mapping(address => bool) private _minters;
        mapping(uint256 => bool) private _lockedTokens;
        mapping(uint256 => uint256) private _lockTime;
    
        constructor() ERC721("Wave", "WV") Ownable(msg.sender) {}
    
        modifier onlyMinter() {
            require(_minters[msg.sender], "Not minter");
            _;
        }
    
        function mint(address to, uint256 tokenId) public whenNotPaused onlyMinter {
            _safeMint(to, tokenId);
        }
    
        function isMinter(address account) public view returns (bool) {
            return _minters[account];
        }
    
        function addMinter(address account) external override whenNotPaused onlyOwner {
            _minters[account] = true;
        }
    
        function removeMinter(address account) external override whenNotPaused onlyOwner{
            delete _minters[account];
        }
    
        function pause() public onlyOwner {
            _pause();
        }
    
        function unpause() public onlyOwner {
            _unpause();
        }
    
        function lockTokens(uint256[] calldata tokenIds, uint256 lockTime)external override whenNotPaused{
            require(lockTime > 0, "Lock time must be greater than 0");
    
            // change isMinter to public
            if (isMinter(msg.sender)) {
                for (uint256 i = 0; i < tokenIds.length; i++) {
                    _requireOwned(tokenIds[i]);
                    _lockedTokens[tokenIds[i]] = true;
                    _lockTime[tokenIds[i]] = block.timestamp + lockTime;
                }
                return;
            }
    
            for (uint256 i = 0; i < tokenIds.length; i++) {
                uint256 tokenId = tokenIds[i];
                require(
                    ownerOf(tokenId) == msg.sender,
                    "Only the owner or the minter can modify the lock status"
                );
    
                _lockedTokens[tokenId] = true;
                _lockTime[tokenId] = block.timestamp + lockTime;
            }
        }
    
        function unlockTokens(uint256[] calldata tokenIds)external override whenNotPaused{
            if (isMinter(msg.sender)) {
                for (uint256 i = 0; i < tokenIds.length; i++) {
                    _requireOwned(tokenIds[i]);
                    delete _lockedTokens[tokenIds[i]];
                    delete _lockTime[tokenIds[i]];
                }
                return;
            }
    
            for (uint256 i = 0; i < tokenIds.length; i++) {
                uint256 tokenId = tokenIds[i];
                require(
                    ownerOf(tokenId) == msg.sender,
                    "Only the owner or the minter can modify the lock status"
                );
    
                delete _lockedTokens[tokenIds[i]];
                delete _lockTime[tokenIds[i]];
            }
        }
    
        function isTokenLocked(uint256 tokenId) public view  override returns (bool) {
            _requireOwned(tokenId);
            return _lockedTokens[tokenId];
        }
    }
    

Burnable

  • // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
    import {IMinterManager} from "./interfaces/IMinterManager.sol";
    import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
    import {ILockable} from "./interfaces/ILockable.sol";
    import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
    
    contract Wave is ERC721, Ownable, IMinterManager, Pausable, ILockable, ERC721Burnable {
        mapping(address => bool) private _minters;
        mapping(uint256 => bool) private _lockedTokens;
        mapping(uint256 => uint256) private _lockTime;
    
        constructor() ERC721("Wave", "WV") Ownable(msg.sender) {}
    
        modifier onlyMinter() {
            require(_minters[msg.sender], "Not minter");
            _;
        }
    
        function mint(address to, uint256 tokenId) public whenNotPaused onlyMinter {
            _safeMint(to, tokenId);
        }
    
        function isMinter(address account) public view returns (bool) {
            return _minters[account];
        }
    
        function addMinter(address account) external override whenNotPaused onlyOwner {
            _minters[account] = true;
        }
    
        function removeMinter(address account) external override whenNotPaused onlyOwner{
            delete _minters[account];
        }
    
        function pause() public onlyOwner {
            _pause();
        }
    
        function unpause() public onlyOwner {
            _unpause();
        }
    
        function lockTokens(uint256[] calldata tokenIds, uint256 lockTime)external override whenNotPaused{
            require(lockTime > 0, "Lock time must be greater than 0");
    
            // change isMinter to public
            if (isMinter(msg.sender)) {
                for (uint256 i = 0; i < tokenIds.length; i++) {
                    _requireOwned(tokenIds[i]);
                    _lockedTokens[tokenIds[i]] = true;
                    _lockTime[tokenIds[i]] = block.timestamp + lockTime;
                }
                return;
            }
    
            for (uint256 i = 0; i < tokenIds.length; i++) {
                uint256 tokenId = tokenIds[i];
                require(ownerOf(tokenId) == msg.sender, "Not owner or minter");
    
                _lockedTokens[tokenId] = true;
                _lockTime[tokenId] = block.timestamp + lockTime;
            }
        }
    
        function unlockTokens(uint256[] calldata tokenIds)external override whenNotPaused{
            if (isMinter(msg.sender)) {
                for (uint256 i = 0; i < tokenIds.length; i++) {
                    _requireOwned(tokenIds[i]);
                    delete _lockedTokens[tokenIds[i]];
                    delete _lockTime[tokenIds[i]];
                }
                return;
            }
    
            for (uint256 i = 0; i < tokenIds.length; i++) {
                uint256 tokenId = tokenIds[i];
                require(ownerOf(tokenId) == msg.sender, "Not owner or minter");
    
                delete _lockedTokens[tokenIds[i]];
                delete _lockTime[tokenIds[i]];
            }
        }
    
        function isTokenLocked(uint256 tokenId) public view  override returns (bool) {
            _requireOwned(tokenId);
            return _lockedTokens[tokenId];
        }
    
        function burn(uint256 tokenId) public  override whenNotPaused {
            require(ownerOf(tokenId) == msg.sender,"Not owner");
            super.burn(tokenId);
        }
    }
    

Meta Transactions

Forwarder Contract

  • See the forwarder contract on sepolia
  • See the target contract on sepolia
  • Let’s say a user wants to call logSenders() of the target contract
  • The user generates the signature
  • The platform helps forward the call to the target contract by the forwarder contract
  • The platform calls execute() of the forwarder contract and pays the gas
  • import {
      createPublicClient,
      encodeFunctionData,
      hashTypedData,
      http,
      parseAbiItem,
      parseAbiParameters,
      parseSignature,
      verifyTypedData,
    } from 'viem';
    import { privateKeyToAccount } from 'viem/accounts';
    import { sepolia } from 'viem/chains';
    
    async function main() {
      // generate a random private key as the user
      const user = privateKeyToAccount('0xfc96a8fd9299be969720c9c26ed7e52df8d2eea51e7c642f10ebac31ad194935');
    
      const targetContract: `0x${string}` = '0x44016C1c7636E3406fa2C70Cf0AF93617b74F99d';
      const forwarderContract: `0x${string}` = '0xf43a501ec56D6228eF5008Fbce263A9197CEDC64';
      const publicClient = createPublicClient({
        chain: sepolia,
        transport: http('https://sepolia.infura.io/v3/{API_KEY}'),
      });
    
      const { domain } = await publicClient.getEip712Domain({ address: forwarderContract });
    
      // don't include salt otherwise the ERC2771Forwarder contract will fail to veriry
      delete domain.salt;
    
      const types = {
        ForwardRequest: parseAbiParameters(
          'address from,address to,uint256 value,uint256 gas,uint256 nonce,uint48 deadline,bytes data'
        ),
      };
    
      const data = encodeFunctionData({
        abi: [parseAbiItem('function logSenders()')],
        args: [],
      });
    
      const nonce = await publicClient.getTransactionCount({ address: user.address });
      const request = {
        from: user.address,
        to: targetContract,
        value: BigInt(0),
        gas: BigInt(300000),
        nonce: BigInt(nonce),
        deadline: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 days
        data: data,
      };
    
      const signature = await user.signTypedData({ domain, types, primaryType: 'ForwardRequest', message: request });
    
      console.log({ signature, request });
    }
    
    main();
    

The Real Msg Sender

  • See the transaction log
    • msg.sender returns 0xf43a501ec56D6228eF5008Fbce263A9197CEDC64 which is the forwarder contract
    • _msgSender() returns 0xaECbc534702E2eE2c6fD225c715091ecA00aceCA which is the user
  • How does _msgSender() return the user’s address?
    • the forwarder contract pads the user’s address to the end of the msg.data
      // _execute() of ERC2771Forwarder.sol
      bytes memory data = abi.encodePacked(request.data, request.from);
      
    • the target contract parses the user’s address from the msg.data
      // _msgSender() of ERC2771Context.sol
      function _msgSender() internal view virtual override returns (address) {
          uint256 calldataLength = msg.data.length;
          uint256 contextSuffixLength = _contextSuffixLength();
          if (isTrustedForwarder(msg.sender) && calldataLength >= contextSuffixLength) {
              return address(bytes20(msg.data[calldataLength - contextSuffixLength:]));
          } else {
              return super._msgSender();
          }
      }
      

Enable Forwarder

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {IMinterManager} from "./interfaces/IMinterManager.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {ILockable} from "./interfaces/ILockable.sol";
import {ERC721Burnable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
import {Context} from "@openzeppelin/contracts/utils/Context.sol";

contract Wave is ERC721, Ownable, IMinterManager, Pausable, ILockable, ERC721Burnable, ERC2771Context{
    mapping(address => bool) private _minters;
    mapping(uint256 => bool) private _lockedTokens;
    mapping(uint256 => uint256) private _lockTime;

    modifier onlyMinter() {
        require(_minters[_msgSender()], "Not minter");
        _;
    }

    constructor(address trustedForwarder) ERC721("Wave", "WV") Ownable(_msgSender()) ERC2771Context(trustedForwarder){}


    function mint(address to, uint256 tokenId) public whenNotPaused onlyMinter {
        _safeMint(to, tokenId);
    }

    function isMinter(address account) public view returns (bool) {
        return _minters[account];
    }

    function addMinter(address account) external override whenNotPaused onlyOwner {
        _minters[account] = true;
    }

    function removeMinter(address account) external override whenNotPaused onlyOwner{
        delete _minters[account];
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function lockTokens(uint256[] calldata tokenIds, uint256 lockTime)external override whenNotPaused{
        require(lockTime > 0, "Lock time must be greater than 0");

        // change isMinter to public
        if (isMinter(_msgSender())) {
            for (uint256 i = 0; i < tokenIds.length; i++) {
                _requireOwned(tokenIds[i]);
                _lockedTokens[tokenIds[i]] = true;
                _lockTime[tokenIds[i]] = block.timestamp + lockTime;
            }
            return;
        }

        for (uint256 i = 0; i < tokenIds.length; i++) {
            uint256 tokenId = tokenIds[i];
            require(ownerOf(tokenId) == _msgSender(), "Not owner or minter");

            _lockedTokens[tokenId] = true;
            _lockTime[tokenId] = block.timestamp + lockTime;
        }
    }

    function unlockTokens(uint256[] calldata tokenIds)external override whenNotPaused{
        if (isMinter(_msgSender())) {
            for (uint256 i = 0; i < tokenIds.length; i++) {
                _requireOwned(tokenIds[i]);
                delete _lockedTokens[tokenIds[i]];
                delete _lockTime[tokenIds[i]];
            }
            return;
        }

        for (uint256 i = 0; i < tokenIds.length; i++) {
            uint256 tokenId = tokenIds[i];
            require(ownerOf(tokenId) == _msgSender(), "Not owner or minter");

            delete _lockedTokens[tokenIds[i]];
            delete _lockTime[tokenIds[i]];
        }
    }

    function isTokenLocked(uint256 tokenId) public view  override returns (bool) {
        _requireOwned(tokenId);
        return _lockedTokens[tokenId];
    }

    function burn(uint256 tokenId) public  override whenNotPaused {
        require(ownerOf(tokenId) == _msgSender(),"Not owner");
        super.burn(tokenId);
    }

    function _msgSender()
        internal
        view
        virtual
        override(ERC2771Context, Context)
        returns (address sender)
    {
        return super._msgSender();
    }

    function _msgData()
        internal
        view
        virtual
        override(ERC2771Context, Context)
        returns (bytes calldata)
    {
        return super._msgData();
    }


    function _contextSuffixLength()
        internal
        view
        virtual
         override(ERC2771Context, Context)
        returns (uint256)
    {
        return super._contextSuffixLength();
    }
}

Upgradable

Initializable

  • No constructors can be used in upgradeable contracts
    • the codes in the constructor of logic contract are executed only once when deployed
    • so the codes won’t be executed again when the proxy contract links to this logic contract
  • The initialize() function is used to replace the constructor function and can be called only once
  • // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.20;
    
      import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
      import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
      import {IMinterManager} from "./interfaces/IMinterManager.sol";
      import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
      import {ILockable} from "./interfaces/ILockable.sol";
      import {ERC721BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol";
      import {ERC2771ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/metatx/ERC2771ContextUpgradeable.sol";
      import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol";
    
      contract Wave is
          ERC721Upgradeable,
          OwnableUpgradeable,
          IMinterManager,
          PausableUpgradeable,
          ILockable,
          ERC721BurnableUpgradeable,
          ERC2771ContextUpgradeable
      {
          mapping(address => bool) private _minters;
          mapping(uint256 => bool) private _lockedTokens;
          mapping(uint256 => uint256) private _lockTime;
    
          modifier onlyMinter() {
              require(_minters[_msgSender()], "Not minter");
              _;
          }
    
          // {trustedForwarder} is initialized as a immutable variable of Wave, which locates at the code segment
          // so the proxy can access the {trustedForwarder} without accessing its storage
          /// @custom:oz-upgrades-unsafe-allow constructor
          constructor(address trustedForwarder) ERC2771ContextUpgradeable(trustedForwarder){
              _disableInitializers();
          }
    
          function initialize() public initializer {
              __ERC721_init("Wave", "WV");
              __Ownable_init( _msgSender());
              __Pausable_init();
              __ERC721Burnable_init();
          }
    
          function mint(address to, uint256 tokenId) public whenNotPaused onlyMinter {
              _safeMint(to, tokenId);
          }
    
          function isMinter(address account) public view returns (bool) {
              return _minters[account];
          }
    
          function addMinter(address account) external override whenNotPaused onlyOwner {
              _minters[account] = true;
          }
    
          function removeMinter(address account) external override whenNotPaused onlyOwner{
              delete _minters[account];
          }
    
          function pause() public onlyOwner {
              _pause();
          }
    
          function unpause() public onlyOwner {
              _unpause();
          }
    
          function lockTokens(uint256[] calldata tokenIds, uint256 lockTime)
              external
              override
              whenNotPaused
          {
              require(lockTime > 0, "Lock time must be greater than 0");
    
              // change isMinter to public
              if (isMinter(_msgSender())) {
                  for (uint256 i = 0; i < tokenIds.length; i++) {
                      _requireOwned(tokenIds[i]);
                      _lockedTokens[tokenIds[i]] = true;
                      _lockTime[tokenIds[i]] = block.timestamp + lockTime;
                  }
                  return;
              }
    
              for (uint256 i = 0; i < tokenIds.length; i++) {
                  uint256 tokenId = tokenIds[i];
                  require(ownerOf(tokenId) == _msgSender(), "Not owner or minter");
    
                  _lockedTokens[tokenId] = true;
                  _lockTime[tokenId] = block.timestamp + lockTime;
              }
          }
    
          function unlockTokens(uint256[] calldata tokenIds) external override whenNotPaused {
              if (isMinter(_msgSender())) {
                  for (uint256 i = 0; i < tokenIds.length; i++) {
                      _requireOwned(tokenIds[i]);
                      delete _lockedTokens[tokenIds[i]];
                      delete _lockTime[tokenIds[i]];
                  }
                  return;
              }
    
              for (uint256 i = 0; i < tokenIds.length; i++) {
                  uint256 tokenId = tokenIds[i];
                  require(ownerOf(tokenId) == _msgSender(), "Not owner or minter");
    
                  delete _lockedTokens[tokenIds[i]];
                  delete _lockTime[tokenIds[i]];
              }
          }
    
          function isTokenLocked(uint256 tokenId)
              public
              view
              override
              returns (bool)
          {
              _requireOwned(tokenId);
              return _lockedTokens[tokenId];
          }
    
          function burn(uint256 tokenId) public override {
              require(ownerOf(tokenId) == msg.sender, "Not owner");
              super.burn(tokenId);
          }
    
          function _msgSender()
              internal
              view
              virtual
              override(ERC2771ContextUpgradeable, ContextUpgradeable)
              returns (address sender)
          {
              return super._msgSender();
          }
    
          function _msgData()
              internal
              view
              virtual
              override(ERC2771ContextUpgradeable, ContextUpgradeable)
              returns (bytes calldata)
          {
              return super._msgData();
          }
    
          function _contextSuffixLength()
              internal
              view
              virtual
              override(ERC2771ContextUpgradeable, ContextUpgradeable)
              returns (uint256)
          {
              return super._contextSuffixLength();
          }
      }
    

Proxy

  • Transparent proxy

  • UUPS proxy

    • // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.20;
      
      import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
      import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
      import {IMinterManager} from "./interfaces/IMinterManager.sol";
      import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
      import {ILockable} from "./interfaces/ILockable.sol";
      import {ERC721BurnableUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721BurnableUpgradeable.sol";
      import {ERC2771ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/metatx/ERC2771ContextUpgradeable.sol";
      import {ContextUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol";
      import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
      
      contract Wave is
          ERC721Upgradeable,
          OwnableUpgradeable,
          IMinterManager,
          PausableUpgradeable,
          ILockable,
          ERC721BurnableUpgradeable,
          ERC2771ContextUpgradeable,
          UUPSUpgradeable
      {
          mapping(address => bool) private _minters;
          mapping(uint256 => bool) private _lockedTokens;
          mapping(uint256 => uint256) private _lockTime;
      
          modifier onlyMinter() {
              require(_minters[_msgSender()], "Not minter");
              _;
          }
      
          // {trustedForwarder} is initialized as a immutable variable of Wave, which locates at the code segment
          // so the proxy can access the {trustedForwarder} without accessing its storage
          /// @custom:oz-upgrades-unsafe-allow constructor
          constructor(address trustedForwarder)
              ERC2771ContextUpgradeable(trustedForwarder)
          {
              _disableInitializers();
          }
      
          function initialize() public initializer {
              __ERC721_init("Wave", "WV");
              __Ownable_init(_msgSender());
              __Pausable_init();
              __ERC721Burnable_init();
          }
      
          function mint(address to, uint256 tokenId) public whenNotPaused onlyMinter {
              _safeMint(to, tokenId);
          }
      
          function isMinter(address account) public view returns (bool) {
              return _minters[account];
          }
      
          function addMinter(address account)
              external
              override
              whenNotPaused
              onlyOwner
          {
              _minters[account] = true;
          }
      
          function removeMinter(address account)
              external
              override
              whenNotPaused
              onlyOwner
          {
              delete _minters[account];
          }
      
          function pause() public onlyOwner {
              _pause();
          }
      
          function unpause() public onlyOwner {
              _unpause();
          }
      
          function lockTokens(uint256[] calldata tokenIds, uint256 lockTime)
              external
              override
              whenNotPaused
          {
              require(lockTime > 0, "Lock time must be greater than 0");
      
              // change isMinter to public
              if (isMinter(_msgSender())) {
                  for (uint256 i = 0; i < tokenIds.length; i++) {
                      _requireOwned(tokenIds[i]);
                      _lockedTokens[tokenIds[i]] = true;
                      _lockTime[tokenIds[i]] = block.timestamp + lockTime;
                  }
                  return;
              }
      
              for (uint256 i = 0; i < tokenIds.length; i++) {
                  uint256 tokenId = tokenIds[i];
                  require(ownerOf(tokenId) == _msgSender(), "Not owner or minter");
      
                  _lockedTokens[tokenId] = true;
                  _lockTime[tokenId] = block.timestamp + lockTime;
              }
          }
      
          function unlockTokens(uint256[] calldata tokenIds)
              external
              override
              whenNotPaused
          {
              if (isMinter(_msgSender())) {
                  for (uint256 i = 0; i < tokenIds.length; i++) {
                      _requireOwned(tokenIds[i]);
                      delete _lockedTokens[tokenIds[i]];
                      delete _lockTime[tokenIds[i]];
                  }
                  return;
              }
      
              for (uint256 i = 0; i < tokenIds.length; i++) {
                  uint256 tokenId = tokenIds[i];
                  require(ownerOf(tokenId) == _msgSender(), "Not owner or minter");
      
                  delete _lockedTokens[tokenIds[i]];
                  delete _lockTime[tokenIds[i]];
              }
          }
      
          function isTokenLocked(uint256 tokenId)
              public
              view
              override
              returns (bool)
          {
              _requireOwned(tokenId);
              return _lockedTokens[tokenId];
          }
      
          function burn(uint256 tokenId) public override {
              require(ownerOf(tokenId) == msg.sender, "Not owner");
              super.burn(tokenId);
          }
      
          function _msgSender()
              internal
              view
              virtual
              override(ERC2771ContextUpgradeable, ContextUpgradeable)
              returns (address sender)
          {
              return super._msgSender();
          }
      
          function _msgData()
              internal
              view
              virtual
              override(ERC2771ContextUpgradeable, ContextUpgradeable)
              returns (bytes calldata)
          {
              return super._msgData();
          }
      
          function _contextSuffixLength()
              internal
              view
              virtual
              override(ERC2771ContextUpgradeable, ContextUpgradeable)
              returns (uint256)
          {
              return super._contextSuffixLength();
          }
      
          function _authorizeUpgrade(address) internal override onlyOwner {}
      }
      
    • see the deployed Wave on the etherscan

    • see the deployed Proxy on the etherscan

    • see the deployed upgraded Wave on the etherscan