ERC-721 함수의 기능
ERC-721에는 9개의 표준 함수가 있다.
이 표준 함수를 포함한 컨트랙트를 통해 민팅된 토큰이 바로 NFT
이다.
그렇다면, 각각의 함수가 어떤 역할을 하는지 알아보고자 한다. 여기서는 OpenZeppelin에서 제공하는 ERC-721 API 에 구현된 코드를 사용한다.
string private _name;
string private _symbol;
mapping(uint256 => address) private _owners;
mapping(address => uint256) private _balances;
mapping(uint256 => address) private _tokenApprovals;
mapping(address => mapping(address => bool)) private _operatorApprovals;
name
: 토큰의 이름을 저장한다._symbol
: 토큰의 심볼을 저장한다._owners
: 각 토큰의 ID와 토큰 소유자의 주소를 매핑한다._balances
: 토큰 소유자의 주소와 소유자가 가지고 있는 토큰의 갯수를 매핑한다._tokenApprovals
: 토큰 ID와 approved 된 주소를 매핑한다._operatorApprovals
: 토큰 소유자와 operator 주소의 approval 여부를 저장한다.balanceOf()
함수는 owner주소가 가지고 있는 NFT의 갯수를 리턴한다.
function balanceOf(address owner) public view virtual override returns (uint256) {
require(owner != address(0), "ERC721: address zero is not a valid owner"); // owner의 주소가 0인 경우
return _balances[owner]; // _balances 변수에 매핑된 값을 가져온다.
}
모든 NFT는 발행된 컨트랙트 내에서 고유한 token ID를 가지고 있다. 따라서 컨트랙트 주소와 token ID만 있으면 해당 NFT의 정보에 접근할 수 있다.
onwerOf()
함수는 token ID를 통해 토큰 owner의 주소를 반환한다.
function ownerOf(uint256 tokenId) public view virtual override returns (address) {
address owner = _owners[tokenId]; // _owners 변수에는 token ID와 owner의 주소가 매핑되어 있다.
require(owner != address(0), "ERC721: invalid token ID");
return owner;
}
approve()
함수는 특정 tokenId를 제3자가 사용할 수 있도록 승인할 수 있다. approve()
를 통해 tokenId 사용을 승인 받은 제3자는 operator
라고 부르며, operator
는 이 tokenId를 다른 스마트 컨트랙트에 사용하거나 다시 approve 할 수도 있다.
approve()
함수는 소유권을 승인하는 행위이기 떄문에, tokenId의 owner나 operator만 호출할 수 있다.
function approve(address to, utin256 tokenId) public virtual override {
// 유효성 검사 1. operator(to)의 주소와 토큰의 owenr가 동일인인지 확인
address owner = ERC721.ownerOf(tokenId);
require(to != owner, "ERC721: approval to current owner");
// 유효성 검사 2. approve 함수를 호출한 사람과 토큰의 owner가 동일한지 확인
require(
_msgSender() == owner || isApprovedForAll(owner, _msgSender()),
"ERC721: approve caller is not token owner nor approved for all"
);
_approve(to, tokenId);
}
function _approve(address to, uint256 tokenId) internal virtual {
_tokenApprovals[tokenId] = to; // _tokenApprovals 변수에 tokenId와 approve된 operator의 주소를 매핑한다.
emit Approval(ERC721.ownerOf(tokenId), to, tokenId);
}
getApproved()
함수는 token ID가 누군가에게 승인된 상태이면, 그 승인된 poerator의 주소를 반환한다.
function getApproved(uint256 tokenId) public view virtual override returns (address) {
return _tokenApprovals[tokenId];
}
setApprovalForAll()
함수는 이 함수를 호출한 msg.sender
가 이 컨트랙트에서 가지고 있는 모든 NFT를 특정 operator에게 승인하는 함수이다.
첫 번째 인자인 to
는 operator의 주소이며, 두 번째 인자인 approved
는 승인 여부를 나타낸다.
approved
가 true
인 경우: to
주소에게 모든 NFT의 사용을 승인한다.approved
가 false
인 경우: operator인 to
주소로부터 NFT 사용 승인을 철회한다.function setApprovalForAll(address operator, bool approved) public virtual override {
_setApprovalForAll(-msgSender(), operator, approved);
}
fucntion _setApprovalForAll(address owner, address operator, bool approved) internal virtual {
require(owner != operator, "ERC721: approve to caller"); // 컨트랙트를 호출한 msg.sender와 approve를 주려는 operator가 동일한지 확인
_operatorApprovals[owner][operator] = approved; // _operatorApprovals에 owner와 operator, approve 여부가 매핑
emit ApprovalForAll(owner, operator, approved);
}
isApprovedForAll()
함수는 첫 번째 인자 owner
가 두 번째 인자 operator
주소에게 setApprovalForAll()
함수를 통해 모든 NFT를 승인했는지의 여부를 전달한다.
true
인 경우: setApprovalForAll()
함수를 호출하여 owner
의 모든 NFT에 대해 operator
에게 승인한 상태이다.false
인 경우: setApprovalForAll()
함수를 호출한 적이 없거나, 승인을 철회한 상태이다.function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) {
return _operatorApprovals[owner][operator];
}
transferFrom()
은 from
주소에서 to
주소로 tokenId
를 옮긴다. 따라서, from
주소는 옮기려는 토큰의 owner이거나, 승인받은 operator여야 한다.
function transferFrom(address from, address to, uint256 tokenId) public virtual override {
require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner nor approved");
_transfer(from, to, tokenId);
}
// tokenId가 spender의 소유이거나, spender에게 승인 되었는지 확인
function _isApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool) {
address owner = ERC721.ownerOf(tokenId);
return (spender == owner || isApprovedForAll(owner, spender) || getApproved(tokenId) == spender);
}
function _transfer(address from, address to, uint256 tokenId) internal virtual {
// 유효성 검사
require(ERC721.ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");
require(to != address(0), "ERC721: transfer to the zero address");
// 이전 owner(=from)가 승인했던 approvals를 모두 삭제
_approve(address(0), tokenId);
_balances[from] -= 1; // 이전 owner의 NFT 보유 갯수 갱신
_balances[to] += 1; // 현재 owner(=to)의 NFT 보유 갯수 갱신
_owners[tokenId] = to; // tokenId의 owner를 to로 변경
emit Transfer(from, to, tokenId);
}
그러나, 실제로 NFT를 다른 주소로 옮길 때 transferFrom()
을 사용하는 것은 권장하지 않고 있다. transferFrom()
함수가 가진 문제는 받는 주소가 NFT를 사용할 수 있느지 확인하지 않고 보낸다는 것이다. 예를 들어,
A 컨트랙트에서 엘리스의 주소 0x1133
로 NFT를 보낸다면 어떻게 될까? A 컨트랙트 내부에 해당 NFT의 token ID의 owner가 0x1133
로 설정될 것이다. 이후, 엘리스는 NFT를 다른 곳에 다시 전송하고 싶을 때는 A 컨트랙트의 transferFrom()
을 호출하는 트랜잭션을 생성하면 된다.
사용자 계정(EOA)뿐만 아니라, 스마트 컨트랙트 역시 계정을 가지고 있다. 이를 CA(Contract Account)라고 하는데, CA 역시 계정이기 때문에 계정 주소로 ERC-20 또는 ERC-721 토큰을 보낼수 있다. A 컨트랙트에 B 컨트랙트 주소 0x3344
로 NFT를 보낸다고 가정해보자. A 컨트랙트 내부에 해당 NFT의 token ID의 owner가 0x3344
로 설정될 것이다. 그런데 그 이후가 문제이다.
컨트랙트 계정은 코드
에 의해 동작한다. 엘리스의 예시에서는 엘리스가 A 컨트랙트의 함수를 호출할 수 있었지만, 컨트랙트 계정에는 "A 컨트랙트의 함수 transferFrom()
호출"을 수행하는 코드가 없다면 수신받은 NFT를 사용할 수 없다. 즉 B 컨트랙트에 이 NFT를 다룰수 있는 코드가 없다면, NFT를 사용할 수 없고 잠기게 되는 것이다. 이러한 문제를 방지하고자 safeTransferFrom()
을 권장하고 있다.
safeTransferFrom()
의 특징은 NFT를 받는 주소가 NFT를 받을수 있는 주소인지 확인한다는 점이다. 확인하는 방법은 onERC721Received
함수를 사용하는 것이다. 먼저, A 컨트랙트에서 safeTransferFrom()
함수를 실행한다.
function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual {
_transfer(from, to, tokenId);
require(_checkOnERC721Received(from, to, tokenId, data), "ERC721: transfer to non ERC721Receiver implementer");
}
먼저 _transfer()
함수를 실행하여 token ID의 소유권을 변경한다. 그리고 require문 안에 있는 _checkOnERC721Received()
함수를 실행한다. _checkOnERC721Received()
함수는 B 컨트랙트에 onERC721Received()
함수가 제대로 구현되었는지 확인하는 함수이다.
// _checkOnERC721Received 함수
function _checkOnERC721Received(address from, address to, uint256 tokenId, bytes memory data) private returns (bool) {
if (to.isContract()) {
try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
} catch (bytes memory reason) {
if (reason.length == 0) {
revert("ERC721: transfer to non ERC721Receiver implementer");
} else {
assembly {
revert(add(32, reason), mload(reason))
}
}
}
} else {
return true;
}
}
try 구문을 확인해보면, to
즉, NFT를 수신하는 B 컨트랙트의 IERC721Receiver
인터페이스를 통해 구현된 onERC721Received()
를 실행한다. 그렇다면, B 컨트랙트의 onERC721Received()
함수는 어떻게 구현되어 있을까?
// B 컨트랙트
function onERC721Received(address msgSender, address nftContractAddress, uint256 _tokenId, bytes calldata _data) public virtual override returns (bytes4) {
// 전달받은 NFT를 다루는 함수...
return this.onERC721Received.selector;
}
onERC721Received()
함수 안에는 전달받은 NFT를 다루는 함수를 작성해야 한다.
그리고 NFT를 송신한 A 컨트랙트에게 자신이 onERC721Received()
함수를 가지고 있고, 실행했음을 알려주도록 onERC721Received()
함수의 Selector
를 반환한다.
ERC-165(Standard Interface Detection)를 통해, 모든 컨트랙트의 함수는 고유한 Selector
를 가진다. 함수의 Selector는 쉽게 말해서 함수의 아이디고 구하는 방식은 두 가지가 있다.
// balanceOf(address) 함수의 Selector를 구하는 경우
bytes4(keccak256("balanceOf(address)"))
function onERC721REceived(address msgSender, address nftContractAddress, uint256, _tokenId, bytes calldata _data) public virtual overrdie returns (bytes4) {
// 전달받은 NFT를 다루는 함수...
return this.onERC721Received.selector;
}
B 컨트랙트가 onERC721Received()
함수를 실행하고 나면 A 컨트랙트는 B 컨트랙트의 onERC721Received()
가 반환한 Selector가 IERC721REceiver
인터페이스 표준에 맞게 구현된 함수인지 확인한다.
// _checkOnERC721REceived 함수
try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
return retval == IERC721Receiver.onERC721Received.selector;
}
그리고 _checkOnERC721Received()
함수는 인터페이스 표준에 맞게 구현되었는지의 여부에 따라 true 또는 false 값을 반환한다. _checkOnERC721Received()
를 호출한 _safeTransferFrom()
은 _checkOnERC721Received()
의 반환값에 따라 정상적으로 transfer가 완료되거나, 혹은 실패하여 트랜잭션이 취소된다.
function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual {
_transfer(from, to, tokenId);
require(_checkOnERC721Received(from, to, tokenId, data), "ERC721: transfer to non ERC721REceiver implementer");
}