Jumping into Solidity — The ERC721 Standard (Part 4)
In my last article, we started writing our ERC721 contract. There was plenty of preamble and explanation in the first three articles (Part 1, Part 2, Part 3), so I’m going to assume you’re playing along at home and jump straight back into the code.
To the code!
Towards the end of the last article, in my burnToken
function, you may have noticed me referencing two mappings that we declared at the start of our contract, allowance
and authorised
. Both of these are used for managing approvals, which is basically a system for giving control of your tokens to someone else, while retaining ownership of it.
There are two forms of approvals, one which grants someone permission to control one specific token (allowance
), and the other which grants them control over of all of your tokens (authorised
). Both types of approval have their own functions, and they work quite differently from each other, so we’ll cover them separately.
Authorised
Authorised is described in the ERC721 standard by two functions,
function setApprovalForAll(address _operator, bool _approved) external;
which sets who is authorised to control all your tokens (lets call these people operators), and
function isApprovedForAll(address _owner, address _operator) external view returns (bool) {
return authorised[_owner][_operator];
}
which reads that data. This is where the authorised
mapping which we declared at the start of our contract comes in handy. In case you’ve forgotten, that mapping was declared as:
mapping (address => mapping (address => bool)) internal authorised;
If you’ve never seen a nested mapping before, it’s actually pretty simple. In this case, each owner address maps to its own mapping, which maps operator addresses to a bool stating whether or not they’re actually an operator.
So reading from this nested mapping just takes the following structure,
authorised[owner][operator]
which will evaluate to true if operator
is an operator for owner
, and false otherwise.
In fact, this is precisely how our isApprovedForAll
function works, it simply returns a value from this mapping:
function isApprovedForAll(address _owner, address _operator) external view returns (bool) {
return authorised[_owner][_operator];
}
Similarly, our setApprovalForAll
function just sets a value in this mapping. The only other requirement is that our function “emits the ApprovalForAll event”, which is defined in the Standard interface as
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
So our setApprovalForAll
function is simply,
function setApprovalForAll(address _operator, bool _approved) external {
emit ApprovalForAll(msg.sender,_operator, _approved);
authorised[msg.sender][_operator] = _approved;
}
Allowance
Allowance is also described by two functions,
function approve(address _approved, uint256 _tokenId) external payable;
which sets who is approved for a given token, and
function getApproved(uint256 _tokenId) external view returns (address);
which reads this data. The most important thing to note is that for any given token, there can only ever be one authorised address at any given time. If you approve someone for a token, and then approve someone else, then the first person will no longer be authorised for that token.
Similar to our authorised
mapping for dealing with operators, we have allowance
for dealing with single-token approvals. To refresh your memory:
mapping (uint256 => address) internal allowance;
There are no fancy tricks here, it’s just a mapping of each tokenId
to its approved address. If it hasn’t got one, it evaluates to 0x0
.
The two requirements of our getApproved
function are that it “throws if _tokenId
is not a valid NFT” and that it returns “the approved address for this NFT, or the zero address if there is none”, which gives us:
function getApproved(uint256 _tokenId) external view returns (address) {
require(isValidToken(_tokenId));
return allowance[_tokenId];
}
And to round off approvals, we have our approve
function. Note that I’ve changed the mutability from payable to implicit non-payable — I made the same change in my copy of the interface too. We don’t need to take payment from this function.
function approve(address _approved, uint256 _tokenId) external{
The standard has the following condition for our approve
function,
“Throws unless `msg.sender` is the current NFT owner, or an authorised operator of the current owner.”
We begin by getting the result of ownerOf
and storing it in a temporary variable. If you remember from earlier, ownerOf
is a little more complex than just reading a variable, so this way it’s cheaper than re-calling ownerOf
each time we need the owner’s address.
address owner = ownerOf(_tokenId);
then we check if the msg.sender
is the owner or operator as per the requirements. Note that I’m directly reading the authorised
array rather than calling isApprovedForAll
, this is just another gas-saving measure.
require( owner == msg.sender
|| authorised[owner][msg.sender]
);
Lastly we have an event to emit, and we have to actually update the allowance
mapping.
emit Approval(owner, _approved, _tokenId);
allowance[_tokenId] = _approved;
The transfer functions
The last and arguably most important part of our contract is the transfer functions. There are technically three, but they all basically do the same thing, except two of them have some extra features. Let’s start with the most simple one,
transferFrom
As the name suggests, the transferFrom
function is used for transferring a token from one address to another. Before we go any further, note that I’ve changed the mutability from payable to implicit non-payable (because we don’t need to take payments with this one), and I’ve also changed the visibility from external to public (because transferFrom
will be re-used by our other transfer functions, so making it public will save gas).
function transferFrom(address _from, address _to, uint256 _tokenId) public {
Next there are a bunch of requirements for this function that will cause it to throw if they’re not met. Let’s start by getting the token’s owner
because we’ll be using that a lot in our checks, and our ownerOf
function actually contains a check for if a tokenId
is valid which is one of transferFrom
’s requirements:
address owner = ownerOf(_tokenId);
Next we have “Throws unless `msg.sender` is the current owner, an authorised operator, or the approved address for this NFT.”, which is:
require ( owner == msg.sender
|| allowance[_tokenId] == msg.sender
|| authorised[owner][msg.sender]
);
Again I’m directly accessing the mappings rather than using the approval functions we wrote before because it‘s cheaper.
Next, “Throws if `_from` is not the current owner.” is just:
require(owner == _from);
And our last check, “Throws if `_to` is the zero address.”:
require(_to != 0x0);
Personally, I like to emit any events immediately after doing checks, but it doesn’t really matter whether you do them now or at the end.
emit Transfer(_from, _to, _tokenId);
Next we update our owners
mapping to reflect the new owner,
owners[_tokenId] = _to;
and adjust the balances of the _to
and _from
addresses,
balances[_from]--;
balances[_to]++;
Note there’s no need for SafeMath in a case like this, our contract logic ensures that nobody with a balance of 0 can get to this point, meaning they can’t overflow their balance.
The only thing left to do is reset the allowance
for this token. Now that the token has changed hands, the new owner needs to decide who has control of it.
if(allowance[_tokenId] != 0x0){
delete allowance[_tokenId];
}
The Transfer
event implies that we reset the allowance, so there ‘s no need to emit the Approval
event too.
safeTransferFrom
The safeTransferFrom
functions are almost identical to transferFrom
except that they check whether the recipient is a valid ERC721 receiver contract, and if it is, let you pass some data to that contract. There’s a 3-parameter version and a 4-parameter version of this function, but the 3-param one just calls the 4-param one with the last parameter blank, so we’ll start with the 4-param.
Once again, notice I’ve modified the mutability and visibility from external payable to public and implicit non-payable for gas reasons and because we don’t need to take payment.
function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) public{
The first thing we’ll do is just call our transferFrom
function, this will do most of our checks for us, and transfer the token.
transferFrom(_from, _to, _tokenId);
Next we have to meet the added conditions of safeTransferFrom
,
When transfer is complete, this function checks if `_to` is a smart contract (code size > 0). If so, it calls `onERC721Received` on `_to` and throws if the return value is not `bytes4(keccak256(“onERC721Received(address,address,uint256,bytes)”))`.
We’re going to have to use a little bit of assembly here, luckily there’s an opcode which returns the size of the code at a given address. I’ve you’ve never used assembly before literally all this is doing is checking the code size of _to
and storing it in size
. Then we return safely to the bounds of Solidity.
uint32 size;
assembly {
size := extcodesize(_to)
}
If the size is 0, then this address doesn’t belong to a contract. But if it does, we need to call onERC721Received
on this contract and require that it give us the correct response:
if(size > 0){
ERC721TokenReceiver receiver = ERC721TokenReceiver(_to);
require(receiver.onERC721Received(msg.sender,_from,_tokenId,data) == bytes4(keccak256("onERC721Received(address,address,uint256,bytes)")));
}
That’s the only difference between transferFrom
and safeTransferFrom
. The only thing left is the 3-parameter version, which simply calls the 4-parameter version with the final argument as an empty string. Notice once again that I’ve changed the mutability from payable to implicit non-payable:
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external {
safeTransferFrom(_from,_to,_tokenId,"");
}
Wrapping up
Voila! You‘ve just finished written your own ERC721 implementation contract, nice one! A copy of this contract is available on my GitHub for you to use if you haven’t been playing along at home. I hope you’ve found this series informative so far, and don’t despair because there’s more to come!
Next time we’ll be testing our contract, because code is no good if it hasn’t been tested, and I’m also going to run through the Metadata and Enumerable extensions in a future article as well.