Blockchain - ERC-721 함수의 기능

김도영·2022년 7월 19일
1
post-custom-banner

ERC-721 함수의 기능

ERC-721에는 9개의 표준 함수가 있다.

  • balanceOf - owner가 소유한 NFT의 갯수를 반환
  • ownerOf - 특정 tokenId를 가진 NFT의 소유주 주소를 반환
  • approve - 특정 계정에게 자신이 소유한 NFT 하나를 사용하도록 허용
  • getApproved - 특정 NFT가 다른 계정에게 사용 승인되었는지의 여부 반환
  • setApprovalForAll - 특정 계정에게 자신이 소유한 모든 NFT에 대한 사용을 허용
  • isApprovedForAll - owner가 특정 계정에게 자신의 모든 NFT에 대한 사용을 허용했는지의 여부 반환
  • transferFrom - NFT 소유권 전송
  • safeTransferFrom - 받는 주소가 NFT를 받을 수 있는지 확인 후 NFT 소유권 전송

이 표준 함수를 포함한 컨트랙트를 통해 민팅된 토큰이 바로 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(address owner) -> uint256

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 변수에 매핑된 값을 가져온다.
}

ownerOf(uint256 tokenId) -> address

모든 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(address to, uint256 tokenId)

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(uint256 tokenId) -> address

getApproved()함수는 token ID가 누군가에게 승인된 상태이면, 그 승인된 poerator의 주소를 반환한다.

function getApproved(uint256 tokenId) public view virtual override returns (address) {
	return _tokenApprovals[tokenId];
}

setApprovalForAll(address to, bool approved)

setApprovalForAll()함수는 이 함수를 호출한 msg.sender가 이 컨트랙트에서 가지고 있는 모든 NFT를 특정 operator에게 승인하는 함수이다.

첫 번째 인자인 to는 operator의 주소이며, 두 번째 인자인 approved는 승인 여부를 나타낸다.

  • approvedtrue인 경우: to주소에게 모든 NFT의 사용을 승인한다.
  • approvedfalse인 경우: 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(address owner, address operator) -> bool

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(address form, address to, uint256 tokenId)

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컨트랙트에서 엘리스의 주소로 NFT를 보내는 경우

A 컨트랙트에서 엘리스의 주소 0x1133로 NFT를 보낸다면 어떻게 될까? A 컨트랙트 내부에 해당 NFT의 token ID의 owner가 0x1133로 설정될 것이다. 이후, 엘리스는 NFT를 다른 곳에 다시 전송하고 싶을 때는 A 컨트랙트의 transferFrom()을 호출하는 트랜잭션을 생성하면 된다.

  • A 컨트랙트에서 B컨트랙트의 주소로 NFT를 보내는 경우

사용자 계정(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(address from, address to, uint256 tokenId)

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는 쉽게 말해서 함수의 아이디고 구하는 방식은 두 가지가 있다.

  • 함수의 시그니처(함수명과 파라미터의 타입)를 통해 함수의 시그니처를 keccak256으로 암호화한 후, bytes4로 형변환한다.
// balanceOf(address) 함수의 Selector를 구하는 경우
bytes4(keccak256("balanceOf(address)"))
  • 컨트랙트 내부 메서드의 Selector 속성을 통해
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");
}
profile
Blockchain Developer
post-custom-banner

0개의 댓글