ERC721 & NFT


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 소유권 전송
safeTransferFrom 받는 주소가 NFT를 받을 수 있는지 확인 후 NFT 소유권 전송

ERC-721에는 9개의 표준 함수가 정의되어 있는데, 이 표준 함수를 포함한 컨트랙트를 통해 민팅된 토큰이 바로 NFT 이다.

OpenZeppeling에서 제공하는 ERC-721 API에 구현된 코드를 사용하여 각각의 함수가 하는 역할에 대해 확인.


변수

1
2
3
4
5
6
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
    • 토큰의 심볼을 저장
      • 이름이 Bored Ape Yacht Club인 토큰의 심볼은 BAYC이다.
  • _owners
    • 토큰의 ID토큰 소유자의 주소매핑
  • _balances
    • 토큰 소유자의 주소소유자가 가지고 있는 토큰의 갯수매핑
  • _tokenApprovals
    • 토큰 IDapproved 된 주소매핑
  • _operatorApprovals
    • 토큰 소유자operator 주소approval 여부를 저장
      • e.g. 0x1234가 0x5678에게 자신의 모든 토큰을 관리할 수 있는 operator 권한을 준 경우

        1
        _operatorApprovals[0x1234][0x5678] // true
      • e.g. 0x1234가 0x5678의 operator 권한을 취소한 경우

        1
        _operatorApprovals[0x1234][0x5678] // false

balanceOf(address owner) → uint256

balanceOf() 함수는 owner 주소가 가지고 있는 NFT의 갯수를 리턴한다.

1
2
3
4
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의 정보에 접근할 수 있다.

ownerOf() 함수는 token ID를 통해 토큰 owner의 주소를 반환한다.

1
2
3
4
5
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제삼자가 사용할 수 있도록 승인(approve) 할 수 있다.

approve() 를 통해 tokenId 사용을 승인받은 제삼자operator라고 부르고, operator는 이 tokenId를 다른 스마트 컨트랙트에 사용하거나 다시 approve 할 수 있다.

approve() 함수는 소유권을 승인하는 행위라서 tokenId의 owner나 operator만 호출 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function approve(address to, uint256 tokenId) public virtual override {
// 유효성 검사 1. operator(to)의 주소와 토큰의 owner가 동일인인지 확인
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가 누구에게 승인(approve)된 상태면, 해당 승인된 operator 주소를 반환한다.

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

setApprovalForAll(address to, bool approved)

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

ApprovalForAll 이벤트 실행 함수.

  • 첫번째 인자 to : operator의 주소
  • 두번째 인자 approved : 승인 여부
    • approved → true : to 주소에게 모든 NFT의 사용을 승인
    • approved → false : operator인 to 주소로부터 NFT 사용 승인 철회
1
2
3
4
5
6
7
8
9
function setApprovalForAll(address operator, bool approved) public virtual override {
_setApprovalForAll(_msgSender(), operator, approved);
}

function _setApprovalForAll(address owner, address operator, bool approved) internal virtual {
require(owner != operator, "ERC721: approve to caller"); // 컨트랙트를 호출한 msg.sender와 approve를 주려는 operator가 동일한지 확인
_operatorApprovals[owner][operator] = approved; // _opeatorApprovals에 owner와 operator, 그리고 approve 여부가 매핑
emit ApprovalForAll(owner, operator, approved);
}

  • more

    ERC-20에는 setApprovalForAll() 함수가 표준으로 지정되어 있지 않으나, ERC-721에는 표준 함수로 등장함. ERC-20에서는 왜 setApprovalForAll() 함수를 사용하지 않을까?

    ERC-721에서 setApprovalForAll 함수는 함수를 호출한 sender가 해당 컨트랙트에서 가진 모든 NFT 토큰 전송 권한을 특정 operator에게 승인하거나 해제하는 용도로 사용 된다. 또한, 대체불가능을 위해 소유자와 operator가 동일하지는 않은지 확인하고, 전송 단계에서 송신주소에서 수신주소로 권한과 tokenId를 변경하는 과정이 있다. ERC-20에서는 FT, 즉, 대체 가능한 토큰을 발행한다. 전송시 송신주소에서 수신주소로 원하는 토큰 양을 설정하여 토큰을 보내기 때문에 위임할 토큰 수량을 정해둘 수 있다.


isApprovedForAll(address owner, address operator) → bool

isApprovedForAll() 함수는 첫번째 인자 owner두번째 인자 operator 주소에게 setApprovalForAll() 함수를 통하여 모든 NFT를 승인했는가 여부를 전달.

  • return값 → true : setApprovalForAll() 함수 호출해서 owner의 모든 NFT에 대해 operator에게 승인한 상태임.
  • return값 → false : setApprovalForAll() 함수를 호출한 적 없거나, setApprovalForAll() 을 호출해서 승인 철회한 상태임.
1
2
3
function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) {
return _operatorApprovals[owner][operator];
}

transferFrom(address from, address to, uint256 tokenId)

transferFrom()from 주소에서 to 주소로 tokenId를 옮긴다. from 주소는 옮기려는 토큰의 owner 혹은 승인받은 operator여야 함.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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);

}

safeTransferFrom(address from, address to, uint256 tokenId)

safeTransferFrom() 은 NFT를 받는 주소가 NFT를 받을 수 있는 주소인지 확인하는 특징이 있음.

  • 🔪**transferFrom() 함수의 문제**

    transferFrom()은 받는 주소가 NFT를 사용할 수 있는지 확인을 거치지 않고 보낸다.

    • A 컨트랙트에서 I의 주소 0x1234 로 NFT를 전송

      → A 컨트랙트 내부에 해당 NFT의 token ID의 owner가 0x1234로 설정됨

      → I가 NFT를 다른 곳에 재전송을 원할시 A 컨트랙트의 transferFrom() 호출 트랜잭션을 생성하면 됨

    • A 컨트랙트에서 B 컨트랙트의 주소록 NFT를 전송

      ⇒ 사용자 계정(EOA) 뿐만 아닌, 스마트 컨트랙트도 계정을 가짐. 이를 Contract Account(;CA)라고 하는데, CA 또한 계정이라 계정 주소로 ERC-20 or ERC-721 토큰을 전송할 수 있음.

      → A 컨트랙트에서 B 컨트랙트 주소 0x5678에 NFT를 전송하면, A 컨트랙트 내부에 해당 NFT의 token ID의 owner가 0x5678로 설정됨.

      → 컨트랙트 계정은 ‘코드’에 의해 동작하기 때문에, 앞선 I 예시로는 I가 A 컨트랙트의 함수를 호출했지만, 컨트랙트 계정에는 ‘A 컨트랙트의 함수 transferFrom() 호출’ 수행 코드가 없다면 수신받은 NFT를 사용할 수 없다.

      → B 컨트랙트에 수신받은 NFT를 다루는 코드가 없을시,

      → NFT 소유권은 B 컨트랙트에 넘어왔으나, B 컨트랙트는 이 NFT를 다룰 수 있는 코드가 없어서 NFT를 사용할 수 없다. 해당 NFT는 존재해도 어디에서도 사용할 수 없이 잠기는 것이다.

      ⇒ 해당 문제 발생 방지를 위하여 A 컨트랙트는 B 컨트랙트가 NFT를 다룰 준비가 됐는지 여부를 검증함.

# onERC721Received 함수

→ A 컨트랙트에서 safeTransferFrom() 함수 실행

1
2
3
4
5
6
// 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() 함수가 제대로 구현됐는지 확인하는 함수


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// _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) {
return retval == IERC721Receiver.onERC721Received.selector;
} 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 구문 확인하기
    1
    2
    3
    4
    5
    // _checkOnERC721Received 함수

    try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
    // ...
    }
    toNFT를 수신하는 B 컨트랙트의 IERC721Receiver 인터페이스를 통해 구현된 **onERC721Received()** 실행

B 컨트랙트onERC721Received() 함수 구현 확인


1
2
3
4
5
6
// 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를 반환한다.

  • 🔪 모든 컨트랙트 함수는 고유한 Selector 보유 ERC-165(Standard Interface Detection)를 통해서 모든 컨트랙트의 함수는 고유한 Selector를 가진다. 함수의 Selector함수의 아이디로 생각하며, Selector를 구하는 두 가지 방식이 있다.

    **1. 함수의 시그니처(함수명과 파라미터의 타입)**를 통해 함수의 시그니처를 keccak256으로 암호화하고, bytes4로 형변환.

    1
    2
    // balanceOf(address) 함수의 Selector를 구하는 경우
    bytes4(keccak256(”balanceOf(address)”))
    1. 컨트랙트 내부 메서드의 Selector 속성을 통함.
    1
    2
    3
    4
    function onERC721Received(address msgSender, address nftContractAddress, uint256 _tokenId, bytes calldata _data) public virtual override returns (bytes4) {
    // 전달받은 NFT를 다루는 함수..
    return this.onERC721Received.selector;
    }

→ B 컨트랙트가 onERC721Received() 함수를 실행한 후, A 컨트랙트는 B 컨트랙트의 onERC721Received() 가 반환된 SelectorIERC721Receiver 인터페이스 표준에 맞춰 구현된 onERC721Received() 함수인지 확인.


1
2
3
4
// _checkOnERC721Received 함수
try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
return retval == IERC721Receiver.onERC721Received.selector;
}

_checkOnERC721Received() 함수는 인터페이스 표준에 맞게 구현됐는지 여부에 따라서 true or false 값을 리턴.


1
2
3
4
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");
}

_checkOnERC721Received() 를 호출한 _safeTransferFrom()**_checkOnERC721Received() 의 반환값**에 따라 정상적으로 transfer가 완료 or 실패로 판단되어 트랜잭션이 취소.

흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function safeTransferFrom(address from, address to, uint256 tokenId) public virtual override {
safeTransferFrom(from, to, tokenId, "");
}

function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual override {
require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner nor approved");
_safeTransfer(from, to, tokenId, data);
}

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");
}

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) {
return retval == IERC721Receiver.onERC721Received.selector;
} 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;
}
}