# 基于EIP-4907实现NFT租赁

By [Yooma](https://paragraph.com/@yooma) · 2022-12-26

---

一：
--

[这篇文章](https://mirror.xyz/dashboard/edit/ELXDC4gxLxwYYn6m2FB0v8SQW10_y8F2sPDpzdgoaw0)讲到什么是[EIP-4907](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-4907.md)，[EIP-721](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md)，[ERC-20](https://ethereum.org/zh/developers/docs/standards/tokens/erc-20/)

二：Brownie
---------

**一：介绍**

[Brownie](https://eth-brownie.readthedocs.io/en/stable/index.html) 是一个基于 Python 的**智能合约**开发和测试框架。Brownie项目严重依赖[web3.py](https://web3py.readthedocs.io/en/stable/quickstart.html)

**二：用法**

**安装**

1.  `pip install eth-brownie`
    
2.  `npm install ganache-cli@latest --global`
    
    （`ganache-cli` 会启动本地的以太坊测试网络便于我们测试，终端输入ganache-cli会看到一些测试账号私钥等信息）
    

![](https://storage.googleapis.com/papyrus_images/b0b62a0e0fd4639424c80dc2c7427dbd2b7ba9f5261cd1ca30a100f76ccf839d.png)

安装成功后创建一个空文件夹（例如名为：brownie\_test）

**进入文件夹中执行brownie init**

![](https://storage.googleapis.com/papyrus_images/a0d99cf0aa8cd0a29228f841cf8dd3094a1a73f438a49fd4f82aacf66fc37129.png)

成功之后生成以下文件夹

*   `contracts/`: 用于存放要部署的智能合约
    
*   `interfaces/`: 用于存放接口，被智能合约调用
    
*   `scripts/`: 用于部署和交互的脚本
    
*   `tests/`: 用于测试项目的脚本
    
*   `build/`：存放项目数据，例如编译合约后的abi和单元测试结果
    
*   `reports/`：用于 GUI 的 JSON 报告文件
    

**接下来编写继承自EIP-721和EIP-4907合约代码**

首先将[EIP-4907](https://github.com/ethereum/EIPs/blob/master/assets/eip-4907/contracts/IERC4907.sol)接口代码放到`interfaces/`

然后我们在`contracts/` 创建一个名为rent\_test.sol文件写入以下代码

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    
    import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
    import "@openzeppelin/contracts/utils/Counters.sol";
    import "../interfaces/IERC4907.sol";
    
    contract ERC4907 is ERC721, IERC4907 {
        struct UserInfo
        {
            address user;   // address of user role
            uint64 expires; // unix timestamp, user expires
        }
    
        mapping (uint256  => UserInfo) private _users;
    
        constructor()
         ERC721("RentNFTTest","RNT"){
    
         }
    
        /// @notice set the user and expires of a NFT
        /// @dev The zero address indicates there is no user
        /// Throws if `tokenId` is not valid NFT
        /// @param user  The new user of the NFT
        /// @param expires  UNIX timestamp, The new user could use the NFT before expires
        function setUser(uint256 tokenId, address user, uint64 expires) public override virtual{
            // 判断msg.sender是否是该token的owner
            require(_isApprovedOrOwner(msg.sender, tokenId), "ERC4907: transfer caller is not owner nor approved");
    
            // 设置 租赁者以及到期时间
            UserInfo storage info = _users[tokenId];
            info.user = user;
            info.expires = expires;
            // 更新该token的User
            emit UpdateUser(tokenId,user,expires);
        }
    
        /// @notice Get the user address of an NFT
        /// @dev The zero address indicates that there is no user or the user is expired
        /// @param tokenId The NFT to get the user address for
        /// @return The user address for this NFT
        function userOf(uint256 tokenId) public view override virtual returns(address){
            if( uint256(_users[tokenId].expires) >=  block.timestamp){
                return _users[tokenId].user;
            }
            return address(0);
        }
    
        /// @notice Get the user expires of an NFT
        /// @dev The zero value indicates that there is no user
        /// @param tokenId The NFT to get the user expires for
        /// @return The user expires for this NFT
        function userExpires(uint256 tokenId) public view override virtual returns(uint256){
            return _users[tokenId].expires;
        }
    
        /// @dev See {IERC165-supportsInterface}.
        function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721) returns (bool) {
            return interfaceId == type(IERC4907).interfaceId || super.supportsInterface(interfaceId);
        }
    
        function mint(uint256 tokenId) public returns (uint256){
            _safeMint(msg.sender, tokenId);
            return tokenId;
        }
    }
    

**配置brownie-config.yaml**

    终端运行：
    brownie pm install OpenZeppelin/openzeppelin-contracts@v4.6.0
    创建 brownie-config.yaml(brownie配置文件)
    

![](https://storage.googleapis.com/papyrus_images/58f17a1eda8b08460658f998b415adef317c793d2243107437a351e90d75692f.png)

    在brownie-config.yaml中写入以下代码
    
    dependencies:
      - OpenZeppelin/openzeppelin-contracts@4.6.0
    compiler:
      solc:
        version: null
        optimizer:
          enabled: true
          runs: 200
        remappings:
          - '@openzeppelin=OpenZeppelin/openzeppelin-contracts@4.6.0'
    

终端执行 brownie compile 进行编译

![](https://storage.googleapis.com/papyrus_images/acd01d5eba9daa7e8d12a75773163975fbe84591bb8accbdf477b98c3356826b.png)

该命令会将 contracts 目录下所有的智能合约都进行编译，编译完成后，在 `build/contracts和interfaces` 中会出现同名的 json 文件，包含bytecode和abi等信息

**编写测试合约代码**

接下来在 `tests/` 目录下创建一个名为 test\_rentable.py 的文件（**文件名必须符合test\_\*_.py /_ \*\_test.py的格式**）

**在文件中要测试到的方法函数需要以test为前缀，例如下面的test\_nft**

编写代码，brownie使用pytest来测试

    
    # brownie中并没有ERC4907，这个是我们合约中定义的名字（contract ERC4907 is ERC721, IERC4907...）所以不论运行和测试都要在终端以brownie的命令来做
    from brownie import ERC4907, accounts, chain 
    import brownie
    import pytest
    from web3.constants import ADDRESS_ZERO
    DAY = 24 * 60 * 60
    
    
    # scope="module"设置该方法在测试的时候只执行一次test_nft()
    @pytest.fixture(scope="module") 
    def test_nft():
        global deployer, owner, user
        deployer, owner, user = accounts[0:3]
      
        # 可以说是这里对合约中的ERC4907进行实例化，然后调用其中定义的function
        testNft = ERC4907.deploy({'from': deployer}) 
    
        return testNft
    
    
    def test_mint(test_nft):
        # Mint Nft
        # 1 为 合约中 mint 接受的tokenId参数， {"from": owner} 中owner 则是msg.sender
        test_nft.mint(1, {"from": owner})  
        print(f'Minted NFT ( TokenId : {1} )')
    
        # 验证owner账户上的nft数量，如果是1个的话证明mint成功
        assert test_nft.balanceOf(owner.address) == 1
    
        # 验证 tokenId是1的nft 的owner是否与我们这里定义的owner相同
        assert test_nft.ownerOf(1) == owner.address
    

终端执行 brownie test ，以下代码测试同理

![这里代表我们上面测试mint成功](https://storage.googleapis.com/papyrus_images/b89bfc189adf152d2a3847930540949e948a9fbc91c0c19c7e6d33fed2200923.png)

这里代表我们上面测试mint成功

接下来mint成功之后接着上面的代码来测试租赁

    def test_renting(test_nft):
        # 设置租赁两天
        rent_expire_time = chain.time() + 2 * DAY
    
        # 设置 租赁的nft的tokenId, user, 以及租赁时间
        # 合约代码中会判断tokenId为1的owner（应该是我们上方mint此nft的地址）地址是否等于msg.sender, 而msg.sender是由{"from": owner.address}传过去，因为只有该nft的owner才有权力租
        test_nft.setUser(1, user1.address, rent_expire_time, {"from": owner.address})
    
        # 租出去之后再次查看下nft的owner变没变
        assert test_nft.ownerOf(1) == owner1.address
    
        # 验证租该nft用户的地址
        assert test_nft.userOf(1) == user1.address
    
        # 验证租nft的到期时间
        assert test_nft.userExpires(1) == rent_expire_time
    

测试下不是owner进行租赁（设置uesr），接着上面代码继续

    def test_user_nft_transfer(test_nft):
        # 对于user不可以有transfer的权限
        # 使用brownie.reverts来捕捉代码中报出的错误。由于{"from": user.address}（合约以msg.sender来接收，判断tokenId=1的owner是否与msg.sender相同），这里safeTransferFrom返回我们捕捉到的错误证明判断成功，而代码会继续向下执行，如果返回的错误和我们捕捉的不一样该代码会测试失败
        with brownie.reverts("ERC721: transfer caller is not owner nor approved"):
            testNft.safeTransferFrom(owner.address, user.address, 1, {"from": user.address})
    

测试租赁到期后是否还有user

    def test_renting_expired(testNft):
        # 假定两天后的时间点
        chain.sleep(2 * DAY + 1)
        chain.mine(1)
    
        # 来看一下当前的时间是否过了租赁到期时间
        assert testNft.userExpires(1) < chain.time()
    
        # 到期后此nft的user为0x0000000000000000000000000000000000000000
        assert testNft.userOf(1) == ADDRESS_ZERO
    

测试成功后结果都为passed

如果需要其他的判断逻辑使合约更严谨可以在代码中加入判断，基于EIP4907简单的实现就是这样。

---

*Originally published on [Yooma](https://paragraph.com/@yooma/eip-4907-nft)*
