Rentable NFTs ( ERC-4907) : Part II

Sidarth S
Coinmonks
Published in
7 min readJul 21, 2022

--

Solidity Smart Contract Implementation and testing using Brownie

Rentable NFTs

Introduction

Welcome Back to the Part II of the series !!!
In the Part I of the Rentable NFTs, we have already discussed about :

  • NFT Rentals and Traditional Rental Systems
  • Dual-Role NFT Rental Standard
  • IERC 4907

Now that we have understood the core concepts of NFT renting, Lets dive into implementing Rentable NFT smart contract using IERC 4907.
This might end up being a long read, as we will have both the contracts and the test cases explained thoroughly.

Note: 
The logic for renting used here are only for mock implementation purposes.
For better understanding of this blog, Please have a quick look into the Part1 of the series.

Smart Contract

ERC4907.sol : raw_source_code

Follow my Github repo for complete code : Rentable-Nfts

Contract Explained :

Code walkthrough and explained implementation of the smart contract

Inheritance :

  • ERC721.sol
    Inherits all functionalities of ERC 721 token standards.
  • IERC4907.sol
    Inherits the interface IERC4907 renting functionalities, which needs to be implemented in our contract.

Setting Up Global Variables :

struct UserInfo{
address user; // address of user role
uint64 expires; // unix timestamp, user expires
}
mapping (uint256 => UserInfo) private _users;
  • struct UserInfo :
    Stores user address and the respective user expires UNIX timestamp
  • _users :
    Mapping of tokenId to its respective userInfo struct.

Functions :

setUser (uint256 tokenId, address user, uint64 expires)

function setUser(uint256 tokenId, address user, uint64 expires)
public override virtual
{
require(_isApprovedOrOwner(msg.sender, tokenId),
"ERC721: caller is not owner nor approved");
require(userOf(tokenId)==address(0),"User already assigned");
require(expires > block.timestamp, "expires should be in future");
UserInfo storage info = _users[tokenId];
info.user = user;
info.expires = expires;
emit UpdateUser(tokenId,user,expires);
}

This function is used to set a user to the given tokenId until the given UNIX expirestimestamp.

Here, We intially check whether:

  • msg.senderis authorized to set user.
  • user has been already setup to this tokenId.
  • expires UNIX timestamp is valid
  • Once validation is complete , the details are updated in userInfo and is mapped to the given tokenId .
  • Finally once the user is set, event UpdateUser is emitted.

userOf (uint256 tokenId)

function userOf(uint256 tokenId)
public view override virtual returns(address)
{
if( uint256(_users[tokenId].expires) >= block.timestamp){
return _users[tokenId].user;
}
return address(0);
}
  • This view function returns the current active user of the tokenId .
  • Here we retreive the expires of the tokenId from the userInfo mapping . If the current timstamp is still less than the expires timestamp, we return the user as in userInfo, as he is still the active user of the NFT.
  • Zero address is returned , whenever there is no current active user for the given tokenId

userExpires (uint256 tokenId)

function userExpires(uint256 tokenId) 
public view override virtual returns(uint256)
{
return _users[tokenId].expires;
}
  • This view function returns the user expiry timestamp of the tokenId .
  • Here we access the _users mapping to retrieve the userInfo struct of the tokenId and return expiry timestamp.

nftMint( )

function nftMint() public returns (uint256)
{
_tokenIdCounter.increment();
uint256 tokenId = _tokenIdCounter.current();
_safeMint(msg.sender, tokenId);
return tokenId;
}
  • As simple as said, This function is used to mint the 721-NFTs to the msg.sender

_beforeTokenTransfer (address from, address to, uint256 tokenId)

function _beforeTokenTransfer(
address from, address to, uint256 tokenId)
internal virtual override
{
super._beforeTokenTransfer(from, to, tokenId);

if (
from != to &&
_users[tokenId].user != address(0) && //user still present
block.timestamp >= _users[tokenId].expires // user expired
){
delete _users[tokenId];
emit UpdateUser(tokenId, address(0), 0);
}
}
  • This function is just to have some basic housekeeping and remove unwanted data, if present .
  • If a expired user is still maintained in the internal book-keeping of the users mapping ( _users ), it is redundant unwanted data.
  • These obsolete mapping are removed and deleted, before transfer of NFT and respective update events are emitted.

Now that we have the contract ready, lets test the contract with some basic functionality tests using Brownie Framework .

“ Basic walkthrough on IPO / ICO using Dutch Auction mechanism and on why its been adopted widely in the blockchain space.!! “
Check out :
Dutch Auction — IPO/ICO

Brownie Testing:

Smart Contract Testing using Brownie

NOTE: Refer Brownie docs for setting up brownie environment
Brownie Test

Refer here for complete code of Brownie setup and Test cases here : test_rentable.py

Initial Setup

from brownie import ERC4907, accounts, chain
import brownie
import pytest
from web3.constants import ADDRESS_ZERO
deployer = owner1 = owner2 = user1 = user2 = None
DAY = 1 * 24 * 60 * 60
@pytest.fixture(scope="module")
def testNft():

global deployer, owner1, owner2, user1, user2
deployer, owner1, owner2, user1, user2 = accounts[0:5]
testNft = ERC4907.deploy({"from":deployer})
return testNft

After importing the required libraries, we initialize constants and global variables with default values.

Function testNft() , is given the scope of module and thus will be called only once at start of the test module (same as before() in hardhat). The same contract will be used for all the test preserving its state.
We then deploy ERC907 contract from the deployer’s account.

Test Case 1

Mint NFTs and check their users and owners

def test_mint(testNft):

# Mint Nfts

tx = testNft.nftMint({"from":owner1})
id1 = tx.return_value
print(f'Minted NFT ( TokenId : {id1} )')
tx = testNft.nftMint({"from":owner2})
id2 = tx.return_value
print(f'Minted NFT ( TokenId : {id2} )')
# check Nft Balance
assert testNft.balanceOf(owner1.address) == 1
assert testNft.balanceOf(owner2.address) == 1
# check Owners
assert testNft.ownerOf(1) == owner1.address
assert testNft.ownerOf(2) == owner2.address
# check Users
assert testNft.userOf(1) == ADDRESS_ZERO
assert testNft.userOf(2) == ADDRESS_ZERO

With our already deployed testNft contract. Owner1 and Owner2 mint their NFTS. We assert the correctness of owner and user details of these minted tokenIds

Test Case 2

Assign user roles and rent the NFTs

def test_renting(testNft):   rent_expire_time = chain.time() + 2*DAY   # set user to the NFTs   testNft.setUser(1, user1.address, rent_expire_time,
{"from" : owner1.address})
testNft.setUser(2, user2.address, rent_expire_time,
{"from" : owner2.address})
# check Owners
assert testNft.ownerOf(1) == owner1.address
assert testNft.ownerOf(2) == owner2.address
# check Users
assert testNft.userOf(1) == user1.address
assert testNft.userOf(2) == user2.address
# Check expires
assert testNft.userExpires(1)==rent_expire_time
assert testNft.userExpires(2)==rent_expire_time

With our NFTs already minted , we now set users to the tokens. user1 is given role of user to tokenId: 1 which is owned by owner1 , and the same goes for tokenId:2 as well. The expirestimestamp is set for 2 days from the current timestamp

We check whether the details of the users are updated correctly and also
ensure this doesn’t affect the owners of these tokenIds. The expires timestamp should equal to the rent_expire_time (2 days from current time).

Brownie Break

“ Ever wondered what does the huge number in the Opensea Token ID actually mean !! “
Check out : Opensea TokenId: Explained

Test Case 3

Multiple user roles cannot be assigned to single NFT.

def test_double_renting(testNft):   # Owner cannot rent a NFT to not more than 1 users

with brownie.reverts("User already assigned"):
testNft.setUser(
1, user2.address, chain.time() + 1*DAY,
{"from":owner1.address}
)
testNft.setUser(
2, user1.address, chain.time() + 1*DAY,
{"from":owner2.address}
)

With our NFTs already minted and rented , we now try again set users to the tokens (tokenId 1 &2). Thus, Double renting the same tokens. Here Owners of tokenId: 1 and tokenId: 2 are trying to rent their nfts again to user2 , user1 respectivelty. While doing so, We expect a revert message stating "User Already Assigned". Therefore, making it clear that Multiple user roles cannot be assigned to single NFT.

Test Case 4

User of a Nft has no permission / privilege as of owner.

def test_user_nft_transfer(testNft) :   # User should not be able to transfer NFTs   with brownie.reverts(
"ERC721: transfer caller is not owner nor approved"):
testNft.safeTransferFrom(
owner1.address, user1.address, 1,
{"from":user1.address}
)

Here we perform a very simple sanity check, just to prove the point that user has no permissions and privilege as of a owner. Here a user tries to transfer the NFT from owner’s address to his own wallet address. But, we expect the transaction reverts stating that the "caller is not owner nor approved".

Test Case 5

Assigned User Role for a Nft expires after the given expires timestamp.

def test_renting_expired(testNft):   # 2 days After Renting
chain.sleep(2*DAY + 1) #Fast forwarding 2 days
chain.mine(1)
# Check expires
assert testNft.userExpires(1) < chain.time()
assert testNft.userExpires(2) < chain.time()
# check Users
assert testNft.userOf(1) == ADDRESS_ZERO
assert testNft.userOf(2) == ADDRESS_ZERO

In the Test Case 2, we have set the renting expires timestamp to 2 days from current time. Thus to check the expires funtionality we fast forward the timestamp to 2 days in future and try querying for the user.
We First confirm that the current timestamp is greater than the user’s expire timestamp , making sure that renting of the user is expired.
Now that rented user is expired, We expect a Zero Address, on requesting for the current active user for tokenIds 1 and 2.

Compile and Run Test Cases

Now that the test cases ready, Lets test the contract with brownie.
Given that you have brownie env set up in your system. make sure you have the test_cases(test_rentable.py) in your Tests folder.
Now open up cmd and run :

>>> brownie test -v

Yay! All our test cases have PASSED successfully.

Conclusion:

Renting NFTs can now be achieved in a efficient manner with this implementation of Dual-roles.
The logic for renting used here are only for mock implementation purposes.
You can always update the implemented logic as per the requirements. Other functionalities such as userTransfership() , etc. . . can be added additional to this existing implementation to make it even more interesting and useful.

Creators Note

Thank you so much for reading. If any queries, feel free to reach out.
Follow for more contents on Blockchain, NFTs, Defi, Smart Contracts, etc
connect with me on twitter, LinkedIn , Email.

--

--

Sidarth S
Coinmonks

Blockchain Developer | NFTs | Smart Contract | Data Scientist | Artificial Intelligence | Deep Learning