Solidity Smart Contract Implementation and testing using Brownie
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
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 useraddress
and the respective userexpires
UNIX timestamp - _users :
Mapping of tokenId to its respectiveuserInfo
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 expires
timestamp.
Here, We intially check whether:
msg.sender
is 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 giventokenId
. - 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 theuserInfo
mapping . If the current timstamp is still less than the expires timestamp, we return the user as inuserInfo
, 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 theuserInfo
struct of thetokenId
and returnexpiry
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 themsg.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
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_ZEROdeployer = 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 expires
timestamp 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).
“ 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.