基于EIP-4907实现NFT租赁

一:

这篇文章讲到什么是EIP-4907EIP-721ERC-20

二:Brownie

一:介绍

Brownie 是一个基于 Python 的智能合约开发和测试框架。Brownie项目严重依赖web3.py

二:用法

安装

  1. pip install eth-brownie

  2. npm install ganache-cli@latest --global

    ganache-cli 会启动本地的以太坊测试网络便于我们测试,终端输入ganache-cli会看到一些测试账号私钥等信息)

post image

安装成功后创建一个空文件夹(例如名为:brownie_test)

进入文件夹中执行brownie init

post image

成功之后生成以下文件夹

  • contracts/: 用于存放要部署的智能合约

  • interfaces/: 用于存放接口,被智能合约调用

  • scripts/: 用于部署和交互的脚本

  • tests/: 用于测试项目的脚本

  • build/:存放项目数据,例如编译合约后的abi和单元测试结果

  • reports/:用于 GUI 的 JSON 报告文件

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

首先将EIP-4907接口代码放到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配置文件)
post image
在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 进行编译

post image

该命令会将 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成功
这里代表我们上面测试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简单的实现就是这样。