이번 포스팅에서는 openzeppelin-solidity 와 같은 라이브러리의 도움 없이 직접 ERC721 컨트랙트를 만들어보고자 한다. 전체적인 흐름과 각각의 컨트랙트 안에서 정의된 함수들의 기능에 대한 설명을 초점으로 해서 작성하려 한다.
< 목차 >
- interface IERC721Metadata
- interface IERC721
- contract ERC721
- contract ERC721Enumerable
1. interface IERC721Metadata
우선 ERC-721 컨트랙트 작성에 필요한 인터페이스를 만들어주고자 한다. IERC721Metadata 인터페이스와 IERC721 인터페이스를 만들어줄 것인데 interface IERC721Metadata 는 다음과 같다.
/* interface IERC721Metadata */
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
interface IERC721Metadata {
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function tokenURI(uint256 _tokenId) external view returns (string memory);
}
name( ) 함수와 symbol( ) 함수는 토큰의 이름과 단위에 대한 view 함수이며 tokenURI( ) 는 각각의 토큰에 매칭되는 URI를 return 하는 함수이다.
2. interface IERC721
다음으로 IERC721 인터페이스를 살펴보자.
/* interface IERC721 */
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
interface IERC721 {
/* Event */
event Transfer(address indexed _from, address indexed _to, uint indexed _tokenId);
// 토큰 전송시 발동되는 이벤트
// ERC20과 다르게 tokenId를 인자값으로 전달
event Approval(address indexed _from, address indexed _approved, uint indexed _tokenId);
// 계정 내 토큰 하나에 대한 위임시 발동
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
// 계정이 가지고 있는 모든 토큰에 대한 위임시 발동
// _approved true : _owner가 가지고 있는 모든 토큰을 _operator에게 위임
// _approved false : 대리인 취소
/* Function */
function balanceOf(address _owner) external view returns (uint);
// owner가 가지고 있는 총 NFT 개수 반환
function ownerOf(uint _tokenId) external view returns (address);
// _tokenId <- 소유하고 있는 address 반환
function transferFrom(address _from, address _to, uint _tokenId) external;
// transfer 함수
// _from 이 _to 에게 _tokenId 값을 갖는 NFT 전송
function approve(address _to, uint _tokenId) external;
// 토큰 하나에 대해 대리인을 선정하는 함수
// msg.sender가 _to를 대리인으로 지정
function getApproved(uint _tokenId) external view returns (address);
// approve 한 대리인의 계정을 반환하는 함수
// approve가 되었는지 안되어 있는지 확인 가능
// approve()의 _to 값을 반환하는 함수
function setApprovalForAll(address _operator, bool _approved) external;
// msg.sender가 가진 모든 NFT를 대리인에게 위임
// _operator : 모든 NFT를 관리하는 대리인
// bool 타입의 _approved로 대리인을 만들수도 취소시킬 수도 있다.
function isApprovedForAll(address _owner, address _operator) external view returns (bool);
// setApprovalForAll()의 _approved 를 return 해주는 함수
}
👉 event
- Transfer( ) : 토큰 전송시 발동되는 이벤트 , transferFrom( ) , _mint( ) 함수 안에서 emit. 인자값으로 _from , _to , _tokenId 전달. ERC20과는 다르게 인자값으로 tokenId 값을 전달한다.
- Approval( ) : NFT를 위임하는 approve( ) 함수 실행시 발동되는 이벤트 , approve( ) 함수 안에서 emit. 인자값으로 _from , _approved , _tokenId 전달. 계정 내에 존재하는 NFT 중 하나의 토큰에 대한 위임이 이루어졌을 때 발동한다.
- ApprovalForAll( ) : 계정 내 존재하는 모든 NFT에 대한 위임이 일어났을 때 발동되는 이벤트 , setApprovalForAll( ) 함수 안에서 emit. 인자값으로 _owner , _operator , _approved 전달. 인자값으로 전달하는 _approved 는 boolean 타입.
- _approved 가 true 일 경우 : _owner가 가지고 있는 모든 NFT를 _operator에게 위임.
- _approved 가 false 일 경우 : _operator에 대한 위임 취소.
👉 function
- balanceOf( ) : 인자값으로 _owner 전달. _owner가 보유하고 있는 총 NFT 개수 반환.
- ownerOf( ) : 인자값으로 _tokenId 전달. 해당 _tokenId 값을 갖는 NFT 소유자 계정 반환.
- transferFrom( ) : 인자값으로 _from , _to , _tokenId 전달. _from 계정에서 _to 계정으로 _tokenId 값에 해당하는 NFT 전송.
- approve( ) : 인자값으로 _to , _tokenId 전달. msg.sender가 _to 계정을 대리인으로 지정. 단, approve( ) 함수를 사용해 대리인을 선정할 경우 하나의 NFT에 대한 대리인 지정만 가능.
- getApproved( ) : 인자값으로 _tokenId 전달. _tokenId 값에 해당하는 NFT의 대리인 계정을 반환하는 함수. approve( ) 함수의 _to 값을 반환해주는 것이며 getApproved( ) 함수를 통해 approve 되었는지 안 되어 있는지 확인 용도로 사용 가능.
- setApprovalForAll( ) : 인자값으로 _operator , _approved 전달. msg.sender 가 _operator에게 자신이 소유한 모든 NFT에 대한 권한을 위임하는 함수. 여기서 _operator는 msg.sender의 모든 NFT를 관리하는 대리인. 인자값으로 전달하는 _approved는 bool 타입.
- _approved 가 true 일 경우 : _operator에게 모든 토큰에 대한 권한 위임.
- _approved 가 false 일 경우 : _operator에 대한 위임 취소.
- isApprovalForAll( ) : 인자값으로 _owner , _operator 전달. setApprovalForAll( ) 함수의 _approved 를 반환해주는 함수. msg.sender 계정이 소유하고 있는 모든 NFT의 대리인이 _operator인지 확인하는 용도로 사용.
3. contract ERC721
다음은 위에 작성된 interface IERCMetadata 와 interface IERC721 을 상속 받아서 작성한 ERC721 컨트랙트이다. Solidity 에서는 이와같은 다중 상속이 가능하며 interface 에서 선언된 함수들의 기능을 ERC721 컨트랙트 안에서 구현해주었다.
/* contract ERC721 */
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import './IERC721.sol';
import './IERC721Metadata.sol';
contract ERC721 is IERC721, IERC721Metadata {
string public override name;
string public override symbol;
mapping(address => uint) private _balances;
// 특정 계정이 몇개의 NFT를 가지고 있는지 조회
mapping(uint => address) private _owners;
// tokenId 값을 기준으로 누가 소유하고 있는지 조회
mapping(uint => address) private _tokenApprovals;
// tokenId 값를 받아서 대리인이 있는지 조회 가능한 상태변수 (토큰 한 개에 대해)
mapping(address => mapping(address => bool)) private _operatorApprovals;
// 모든 NFT에 대한 대리인 권한 조회
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
/* Function */
// 부모 컨트랙트의 함수를 자식 컨트랙트에서 덮어씌울 때 override 사용
function balanceOf(address _owner) public override view returns (uint) {
require(_owner != address(0), "ERC721 : balance query for the zero address");
return _balances[_owner];
}
function ownerOf(uint _tokenId) public override view returns (address) {
address owner = _owners[_tokenId];
require(owner != address(0), "ERC721 : owner query for the nonexistent token");
return _owners[_tokenId];
}
function approve(address _to, uint _tokenId) external override {
// msg.sender 가 _to 에게 msg.sender가 가지고 있는 tokenId 값의 NFT를 사용할 수 있게끔 대리인 설정
address owner = _owners[_tokenId]; // _owners 상태변수에서 가져오기
// 1. msg.sender 와 _to가 다른지 체크
require(_to != owner, "ERC721 : approval to current owner");
// 2. msg.sender가 _tokenId 값에 해당하는 NFT 소유자인지 체크
require(msg.sender == owner || isApprovedForAll(owner, msg.sender));
// setApprovalforAll A -> B
// isApprovalForAll B -> C // 여기서 msg.sender는 대리인(B) // 복대리는 토큰 한개까지만 가능
_tokenApprovals[_tokenId] = _to; // 대리인 지정
emit Approval(owner, _to, _tokenId); // approve 성공시 이벤트 발생
}
function getApproved(uint _tokenId) public override view returns (address) {
// 1. tokenId 값에 해당하는 NFT의 실제 소유자가 있는지 확인
require(_owners[_tokenId] != address(0));
return _tokenApprovals[_tokenId]; // address를 return 한다면 승인 완료된 토큰
}
function setApprovalForAll(address _operator, bool _approved) external override {
// msg.sender가 _operator에게 자신이 소유한 모든 NFT에 대한 권한 위임
require(msg.sender != _operator);
_operatorApprovals[msg.sender][_operator] = _approved;
emit ApprovalForAll(msg.sender, _operator, _approved);
}
function isApprovedForAll(address _owner, address _operator) public override view returns (bool){
// _owner 의 대리인이 _operator인지 확인 (모든 NFT에 대한 대리인)
return _operatorApprovals[_owner][_operator];
}
// transferFrom에서 사용되는 함수
function _isApprovedOrOwner (address _spender, uint _tokenId) private view returns(bool) {
address owner = _owners[_tokenId];
require(owner != address(0));
// _spender == owner : from이 본인일 경우
// isApprovedForAll(owner, _spender) // from이 모든 NFT에 대한 대리인일 경우
// getApproved(_tokenId) == _spender // from이 하나의 NFT에 대한 대리인일 경우
return (_spender == owner || isApprovedForAll(owner, _spender) || getApproved(_tokenId) == _spender);
}
function transferFrom(address _from, address _to, uint _tokenId) external override {
// 본인이 from일 경우
// 대리인이 from
// approve일 경우 -> getApproved() 체크
// setApprovalForAll일 경우 -> isApprovedForAll() 체크
require(_isApprovedOrOwner(_from, _tokenId));
// _from != _to
require(_from != _to);
_beforeTokenTransfer(_from, _to, _tokenId);
_balances[_from] -= 1;
_balances[_to] += 1;
_owners[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
function tokenURI(uint256 _tokenId) external override virtual view returns (string memory) {}
function _mint(address _to, uint _tokenId) public {
// NFT 발행 함수
require(_to != address(0));
// 중복된 tokenId 값인지 체크
address owner = _owners[_tokenId];
require(owner == address(0));
_beforeTokenTransfer(address(0), _to, _tokenId);
_balances[_to] += 1;
_owners[_tokenId] = _to;
emit Transfer(address(0), _to, _tokenId);
// Transfer 이벤트의 from 값이 address(0) 이면 Minting이라고 해석
}
function _beforeTokenTransfer(address _from, address _to, uint _token) internal virtual {}
// _mint() 함수와 transferFrom() 함수 안에서 실행시키지만 함수의 기능은 ERC721Enumerable 컨트랙트 안에서 구현
}
👉 상태변수
- name : 토큰의 이름을 나타내는 상태변수.
- symbol : 토큰의 단위를 나타내는 상태변수.
- _balances : 특정 계정이 소유하고 있는 NFT 전체 개수를 mapping(address => uint) 타입으로 나타낸 상태변수.
- _owners : 특정 tokenId 값을 가지는 NFT의 소유자를 mapping(uint => address) 타입으로 나타낸 상태변수. tokenId 값을 기준으로 해당 NFT 소유자 계정 조회 가능.
- _tokenApprovals : 특정 tokenId 값을 갖는 NFT에 대해 대리인 계정이 있는지 mapping(uint => address) 타입으로 나타낸 상태변수. _tokenApprovals는 NFT 한 개에 대한 대리인 계정 조회 가능. ( tokenId 값을 받아서 해당 NFT에 대한 대리인이 있는지 조회 )
- _operatorApprovals : 특정 계정이 소유하고 있는 모든 NFT에 대한 대리인 계정의 권한을 mapping(address => mapping(address => bool)) 타입으로 나타낸 상태변수. msg.sender => (operator => true/false) 형식으로 사용. _operatorApprovals는 모든 NFT에 대한 대리인 계정 권한 조회 가능.
👉 function
부모 컨트랙트의 함수를 자식 컨트랙트에서 덮어씌울 때 override 키워드를 사용한다. ERC721 컨트랙트는 IERCMetadata 와 IERC721 을 상속받고 있으며 interface IERCMetadata 와 interface IERC721 에서 선언된 함수들은 기본적으로 virtual 속성을 갖는다. 인터페이스에서 선언된 함수들의 기능을 구현하면서 덮어씌울 때는 override 속성을 명시해줘야만 한다.
function balanceOf(address _owner) public override view returns (uint) {
require(_owner != address(0), "ERC721 : balance query for the zero address");
return _balances[_owner];
}
address 타입으로 지정된 변수에 아무런 값이 없을 때 address(0) 으로 표현한다. address(0) 은 address 타입의 null 값이라고 생각하면 된다. balanceOf( ) 함수에서는 인자값으로 전달받은 _owner 가 소유하고 있는 전체 NFT의 개수를 _balances 상태변수를 통해 조회하여 return 한다.
function ownerOf(uint _tokenId) public override view returns (address) {
address owner = _owners[_tokenId];
require(owner != address(0), "ERC721 : owner query for the nonexistent token");
return _owners[_tokenId];
}
ownerOf( ) 함수는 인자값으로 전달받은 _tokenId 값을 갖는 NFT 소유자의 계정을 _owners 상태변수를 통해 조회하여 return 해준다.
function approve(address _to, uint _tokenId) external override {
// msg.sender 가 _to 에게 msg.sender가 가지고 있는 tokenId 값의 NFT를 사용할 수 있게끔 대리인 설정
address owner = _owners[_tokenId]; // _owners 상태변수에서 가져오기
// 1. msg.sender 와 _to가 다른지 체크
require(_to != owner, "ERC721 : approval to current owner");
// 2. msg.sender가 _tokenId 값에 해당하는 NFT 소유자인지 체크
require(msg.sender == owner || isApprovedForAll(owner, msg.sender));
// setApprovalforAll A -> B
// isApprovalForAll B -> C // 여기서 msg.sender는 대리인(B) // 복대리는 토큰 한개까지만 가능
_tokenApprovals[_tokenId] = _to; // 대리인 지정
emit Approval(owner, _to, _tokenId); // approve 성공시 이벤트 발생
}
approve( ) 함수에서는 인자값으로 _to 와 _tokenId 값을 받으며 msg.sender 가 _to 계정에게 msg.sender 가 보유하고 있는 NFT에 대한 권한을 위임하는 기능이 구현된다. 이 때 인자값으로 전달받은 tokenId 값을 갖는 NFT 한 개에 대한 대리인 지정이 이루어진다. address 타입의 owner 변수를 만들고 인자값으로 전달받은 _tokenId 값과 _owners 상태변수를 사용해 조회한 NFT 소유자의 계정을 owner 에 할당해주었다. 이 때 앞서 만들어놓은 ownerOf( ) 함수를 사용하지 않고 _owners 상태변수를 사용해 조회하는 데에는 다음과 같은 이유가 있다.
- 다른 컨트랙트 안에서 정의된 view 함수를 호출하는 것은 가스비가 들지 않지만, 같은 컨트랙트 안에서 작성된 view 함수에 대한 호출은 가스비를 지불해야만 한다. 따라서 ownerOf( ) 함수를 호출하는 방식이 아닌 _owners 상태변수를 통해 조회하는 방식을 택하였다.
- 인자값으로 전달받은 tokenId 값에 해당하는 NFT의 owner 가 없을 경우 _owners 상태변수를 통한 조회는 값이 없어도 approve( ) 함수의 실행 자체에 영향을 미치지 않지만 ownerOf( ) 함수 호출을 통한 조회는 값이 없을 경우에 에러를 발생시킨다.
그리고 require( ) 를 사용해 다음의 조건들을 체크해준다.
- msg.sender 가 _to 계정과 다른 계정인지 체크
- msg.sender 가 _tokenId 값에 해당하는 NFT 소유자인지 체크
- isApproveForAll( ) 함수를 이용한 체크는 복대리가 발생했을 때를 위한 체크
isApproveForAll( ) 함수를 통한 체크는 복대리와 관련된 내용을 의미한다. 가령 A 계정이 B 계정에게 자신이 소유하고 있는 모든 NFT에 대한 권한을 위임했을 때 B 계정은 위임 받은 A의 NFT 를 제 3자인 C에게 다시 위임할 수 있다. 단, 이렇게 A의 대리인 B가 C에게 다시 A의 NFT를 위임하는 복대리의 경우에는 한 개의 NFT에 대한 위임만 허용된다. B 가 제 3자에게 다시 A의 NFT를 위임할 때는 하나의 NFT만 위임이 가능하다는 의미이다. 따라서 isApproveForAll( ) 함수의 인자값으로 전달되는 owner는 A 계정 , msg.sender는 B 계정이 된다.
function getApproved(uint _tokenId) public override view returns (address) {
// 1. tokenId 값에 해당하는 NFT의 실제 소유자가 있는지 확인
require(_owners[_tokenId] != address(0), "ERC721 : ");
return _tokenApprovals[_tokenId]; // address를 return 한다면 승인 완료된 토큰
}
getApproved( ) 함수는 _tokenId 값을 인자값으로 받으며 _tokenId 값에 해당하는 NFT 소유자의 계정을 return 한다. approve 가 완료된 토큰인지를 확인하는 함수라고 볼 수 있으며 getApproved( ) 함수는 하나의 NFT에 대한 대리인 계정을 return 한다.
function setApprovalForAll(address _operator, bool _approved) external override {
// msg.sender가 _operator에게 자신이 소유한 모든 NFT에 대한 권한 위임
require(msg.sender != _operator);
_operatorApprovals[msg.sender][_operator] = _approved;
emit ApprovalForAll(msg.sender, _operator, _approved);
}
setApprovalForAll( ) 함수에서는 msg.sender 가 _operator 에게 자신이 소유하고 있는 모든 NFT에 대한 위임을 진행한다. require( ) 를 사용해 msg.sender 와 _operator 가 다른 계정인지 체크한 다음 _operatorApprovals 상태변수에 값을 채워넣어 준다.
function isApprovedForAll(address _owner, address _operator) public override view returns (bool){
// _owner 의 대리인이 _operator인지 확인 (모든 NFT에 대한 대리인)
return _operatorApprovals[_owner][_operator];
}
isApprovedForAll( ) 함수는 인자값으로 전달받은 _owner 가 소유하고 있는 모든 NFT에 대한 대리인이 _operator 인지 _operatorApprovals 상태변수를 통해 확인하며 true / false 값을 return 한다.
// transferFrom에서 사용되는 함수
function _isApprovedOrOwner (address _spender, uint _tokenId) private view returns(bool) {
address owner = _owners[_tokenId];
require(owner != address(0));
// _spender == owner : from이 본인일 경우
// isApprovedForAll(owner, _spender) // from이 모든 NFT에 대한 대리인일 경우
// getApproved(_tokenId) == _spender // from이 하나의 NFT에 대한 대리인일 경우
return (_spender == owner || isApprovedForAll(owner, _spender) || getApproved(_tokenId) == _spender);
}
_isApprovedOrOwner( ) 함수는 private 함수로 transferFrom( ) 함수 안에서 사용되는 함수이다. 인자값으로 _spender 와 _tokenId 값을 받으며 transferFrom( ) 함수를 실행하는 주체에 대한 검증을 진행해주게 된다. transferFrom( ) 함수는 NFT를 전송하는 함수인데 NFT 전송이 가능한 from 계정에 대한 검증 과정이 _isApprovedOrOwner( ) 함수 안에서 진행된다. NFT 전송이 가능한 계정으로는 다음과 같은 세가지 케이스가 존재한다.
- from 계정이 owner 계정일 경우
- from 계정이 특정 계정의 모든 NFT에 대한 대리인 계정일 경우
- from 계정이 특정 계정이 소유한 한 개의 NFT에 대한 대리인 계정일 경우
_isApprovedOrOwner( ) 함수의 _spender 인자값에는 transferFrom( ) 함수의 _from 인자값이 들어가게 되고 위에서 언급한 세가지 케이스에 대한 검증을 진행하여 true / false 의 bool 값을 return 한다.
- _spender == owner
- isApprovedForAll( owner , _spender )
- getApproved( _tokenId ) == _spender
function transferFrom(address _from, address _to, uint _tokenId) external override {
// 본인이 from일 경우
// 대리인이 from
// approve일 경우 -> getApproved() 체크
// setApprovalForAll일 경우 -> isApprovedForAll() 체크
require(_isApprovedOrOwner(_from, _tokenId));
// _from != _to
require(_from != _to);
_beforeTokenTransfer(_from, _to, _tokenId);
_balances[_from] -= 1;
_balances[_to] += 1;
_owners[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
transferFrom( ) 함수는 NFT 전송을 담당하는 함수로 _from , _to , _tokenId 를 인자값으로 받는다. 그리고 앞서 정의한 _isApprovedOrOwner( ) 함수를 통해 _from 계정에게 NFT 전송에 대한 권한이 있는지를 검증한다. 추가로 자기 자신에게 NFT를 전송하는 것을 방지하기 위해 _from 과 _to 가 일치하지 않는지에 대한 검증 역시 진행해준다. 마지막으로 NFT 전송이 완료되었다면 _balances 상태변수와 _owners 상태변수를 업데이트 해주게 된다.
한 가지 transferFrom( ) 함수 안에서 주의 깊에 살펴봐야할 것이 있는데 이는 바로 _beforeTokenTransfer( ) 함수이다. _balances 상태변수와 _owners 상태변수를 업데이트 해주기 전에 _beforeTokenTransfer( ) 함수를 통해 NFT 소유권 이전과 관련된 추가적인 기능들을 실행시키기 때문이다. 그리고 contract ERC721 의 전체 코드를 살펴보면 _beforeTokenTransfer( ) 함수는 contract ERC721 안에서 다음과 같이 선언만 되어 있는 것을 확인할 수 있다.
function _beforeTokenTransfer(address _from, address _to, uint _token) internal virtual {}
// _mint() 함수와 transferFrom() 함수 안에서 실행시키지만 함수의 기능은 ERC721Enumerable 컨트랙트 안에서 구현
이는 마치 인터페이스에서 함수를 선언만 해주는 것과 유사한데 virtual 속성이 들어가 있는 것을 통해 _beforeTokenTransfer( ) 함수의 기능이 다른 컨트랙트 안에서 구현될 것이라는 것을 유추할 수 있다. 이러한 방식으로 코드를 작성하는 이유는 ERC721 컨트랙트 안에서 함수의 실행 시점을 잡아줘야 할 필요성이 있기 때문이다. _beforeTokenTransfer( ) 함수가 맡게 될 기능을 구현하기 위해서는 다른 컨트랙트 내의 상태변수가 필요하지만 실행 시점은 해당 컨트랙트 안에서 잡아줘야만 할 경우 이와 같은 방식으로 함수를 선언해 놓는다. virtual 키워드가 작성되어 있다라는 것은 ERC721 컨트랙트를 상속받는 다른 컨트랙트에서 _beforeTokenTransfer( ) 함수를 override 할 수 있다는 의미이고 여기서는 앞으로 작성할 ERC721Enumerable 컨트랙트 안에서 _beforeTokenTransfer( ) 함수의 기능을 구현하고자 이와 같은 방식을 채택한 것이다.
다시말해, ERC721 컨트랙트 안에서는 _beforeTokenTransfer( ) 함수의 실행시점만을 잡아주고 ERC721Enumerable 컨트랙트 안에서 _beforeTokenTransfer( ) 함수의 기능을 구현해주겠다는 의미이다. 이는 _beforeTokenTransfer( ) 함수의 기능 구현에 있어 ERC721Enumerable 컨트랙트 안에서 정의될 상태변수가 사용되기 때문이다. virtual 속성과 override 속성을 사용하여 이와같이 실행 시점은 ERC721 컨트랙트를 기준으로, 기능 구현은 ERC721Enumerable 컨트랙트를 기준으로 잡아주는 것이 가능하다.
function tokenURI(uint256 _tokenId) external override virtual view returns (string memory) {}
tokenURI( ) 함수의 경우 IERC721Metadata로부터 상속받은 함수이기 때문에 override 키워드를 사용했으며 _beforeTokenTransfer( ) 함수와 마찬가지로 ERC721 컨트랙트가 아닌 다른 컨트랙트 안에서 기능 구현을 해주기 위해 virtual 속성을 사용하였다.
function _mint(address _to, uint _tokenId) public {
// NFT 발행 함수
require(_to != address(0));
// 중복된 tokenId 값인지 체크
address owner = _owners[_tokenId];
require(owner == address(0));
_beforeTokenTransfer(address(0), _to, _tokenId);
_balances[_to] += 1;
_owners[_tokenId] = _to;
emit Transfer(address(0), _to, _tokenId);
// Transfer 이벤트의 from 값이 address(0) 이면 Minting 이라고 해석
}
_mint( ) 함수는 NFT를 발행하는 함수이며 require( ) 를 사용해 인자값으로 전달받은 _to 값이 null 값이 아닌지와 _owners 상태변수 안에 _tokenId 값에 해당하는 NFT를 소유하는 계정이 없는지를 체크해주었다. 이후 _beforeTokenTransfer( ) 함수를 실행시켜 NFT 소유권 이전과 관련된 추가적인 기능들을 실행해준다. 앞서 언급했던 것처럼 _beforeTokenTransfer( ) 함수의 기능에 대한 구현은 ERC721Enumerable 컨트랙트 안에서 작업해주고자 한다. _beforeTokenTransfer( ) 함수가 성공적으로 실행되었다면 _balances 상태변수와 _owners 상태변수를 업데이트 해주고 Transfer( ) 이벤트를 발생시켜주었다. 참고로 Transfer( ) 이벤트를 발생시킬 때 첫번째 인자값으로 전달하는 _from 값이 address(0)일 경우 NFT가 발행된 것이라고 생각하면 된다.
4. contract ERC721Enumerable
contract ERC721Enumerable 은 ERC721 컨트랙트를 상속받고 있으며 ERC721Enumerable 컨트랙트 안에서 구현되는 기능은 크게 두가지이다.
- Minting을 했을 때 tokenId 값을 자동으로 생성해주는 기능.
- NFT 마켓 플레이스에서는 특정 계정이 소유하고 있는 NFT 목록들을 보여주게 된다. 따라서 ERC721Enumerable 컨트랙트 안에서 특정 계정이 소유하고 있는 tokenId 값들을 찾아내는 기능을 구현.
추가로 ERC721 컨트랙트 안에서 선언해주었던 _beforeTokenTransfer( ) 함수의 기능을 ERC721Enumerable 컨트랙트 안에서 구현해주고자 한다. 다음은 contract ERC721Enumerable 의 전체 코드이다.
/* contract ERC721Enumerable */
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import "./ERC721.sol";
contract ERC721Enumerable is ERC721 {
uint[] private _allTokens;
// 발행된 NFT의 tokenId 값을 배열 안에 담아놓은 상태변수
mapping(address => mapping(uint => uint)) private _ownedTokens;
// address => (index => tokenId)
mapping(uint => uint) private _ownedTokensIndex;
// tokenId => index
// ERC721 constructor() 함수도 같이 실행
constructor(string memory _name, string memory _symbol) ERC721(_name, _symbol) {}
// constructor() 함수가 실행되면 인스턴스 생성까지 완료되는 것이기 때문에 ERC721에 있는 함수들까지 상속된다.
function mint(address _to) public {
// mint() 함수는 tokenId 값의 자동 생성이 목적이기 때문에 인자값으로 _to 만 받는다
// uint[] _allTokens : NFT 전체 리스트 상태변수
_mint(_to, _allTokens.length);
}
function _beforeTokenTransfer(address _from, address _to, uint _tokenId) internal override {
if (_from == address(0)) {
// _mint() 함수에서 실행됐을 경우
_allTokens.push(_allTokens.length);
} else {
// transferFrom() 함수에서 실행됐을 경우
/*
< 전송 로직 >
마지막 index에 있는 tokenId의 index와
내가 전송할 토큰의 index를 바꿔준다
이렇게 함으로써 인덱스가 꼬이는 현상을 방지.
*/
uint latestTokenIndex = ERC721.balanceOf(_from) - 1; // 가지고 있는 토큰들의 마지막 index 값
uint tokenIndex = _ownedTokensIndex[_tokenId];
if (tokenIndex != latestTokenIndex) {
uint latestTokenId = _ownedTokens[_from][latestTokenIndex];
_ownedTokens[_from][tokenIndex] = latestTokenId;
_ownedTokensIndex[latestTokenId] = tokenIndex;
}
delete _ownedTokens[_from][latestTokenIndex];
delete _ownedTokensIndex[_tokenId];
}
uint length = ERC721.balanceOf(_to); // ERC721의 balanceOf()를 실행한 것이기에 가스비 소모 X
// 그냥 balanceOf() 실행시 ERC721Enumerable가 상속 받은 balanceOf()를 사용한 것이기에 가스비 발생
_ownedTokens[_to][length] = _tokenId;
_ownedTokensIndex[_tokenId] = length;
}
function totalSupply() public view returns (uint) {
return _allTokens.length;
}
function tokenByIndex(uint _index) public view returns (uint) {
require(_index < _allTokens.length);
return _allTokens[_index]; // 전체 토큰 리스트에서 index에 해당하는 tokenId 반환
}
function tokenOfOwnerByIndex(address _owner, uint _index) public view returns (uint) {
require(_index < balanceOf(_owner)); // index는 0부터 시작하기 때문에 index 범위 검사
return _ownedTokens[_owner][_index];
}
}
👉 상태변수
- _allTokens : Minting 된 NFT의 tokenId 값을 배열 안에 담아 놓은 상태변수.
- _ownedTokens : 특정 계정이 소유하고 있는 NFT를 인덱스 값과 tokenId 값을 사용해 mapping(address => mapping(uint => uint)) 타입으로 나타낸 상태변수.
- mapping(uint => uint) 에서 첫번째 uint : address 가 가지고 있는 NFT의 index 값.
- mapping(uint => uint) 에서 두번째 uint : address 가 가지고 있는 NFT의 tokenId 값.
- _ownedTokensIndex : 특정 tokenId 값에 해당하는 index 값을 mapping(uint => uint) 타입으로 나타낸 상태변수.
- 첫번째 uint : tokenId 값.
- 두번째 uint : tokenId 값에 해당하는 index 값.
👉 function
function mint(address _to) public {
// mint() 함수는 tokenId 값의 자동 생성이 목적이기 때문에 인자값으로 _to 만 받는다
// uint[] _allTokens : NFT 전체 리스트 상태변수
_mint(_to, _allTokens.length);
}
mint( ) 함수는 상속 받은 ERC721 컨트랙트 안에 존재하는 _mint( ) 함수를 실행시키기 위한 함수이다. _allTokens 배열의 전체 길이인 _allTokens.length 를 _mint( ) 함수의 두번째 인자값으로 전달하여 발행되는 NFT의 tokenId 값이 자동으로 생성되게끔 한다. ERC721 컨트랙트 안의 _mint( ) 함수에서는 _beforeTokenTransfer(address(0), _to, _tokenId) 함수가 실행되며 _mint( ) 함수의 인자값으로 전달한 _allTokens.length 가 _tokenId 값이 된다.
function _beforeTokenTransfer(address _from, address _to, uint _tokenId) internal override {
if (_from == address(0)) {
// _mint() 함수에서 실행됐을 경우
_allTokens.push(_allTokens.length);
} else {
// transferFrom() 함수에서 실행됐을 경우
/*
< 전송 로직 >
마지막 index에 있는 tokenId의 index와
내가 전송할 토큰의 index를 바꿔준다
이렇게 함으로써 인덱스가 꼬이는 현상을 방지.
*/
uint latestTokenIndex = ERC721.balanceOf(_from) - 1; // 가지고 있는 토큰들의 마지막 index 값
uint tokenIndex = _ownedTokensIndex[_tokenId];
if (tokenIndex != latestTokenIndex) {
uint latestTokenId = _ownedTokens[_from][latestTokenIndex];
_ownedTokens[_from][tokenIndex] = latestTokenId;
_ownedTokensIndex[latestTokenId] = tokenIndex;
}
delete _ownedTokens[_from][latestTokenIndex];
delete _ownedTokensIndex[_tokenId];
}
uint length = ERC721.balanceOf(_to); // ERC721의 balanceOf()를 실행한 것이기에 가스비 소모 X
// 그냥 balanceOf() 실행시 ERC721Enumerable가 상속 받은 balanceOf()를 사용한 것이기에 가스비 발생
_ownedTokens[_to][length] = _tokenId;
_ownedTokensIndex[_tokenId] = length;
}
ERC721Enumerable 컨트랙트 안에서 구현되는 _beforeTokenTransfer( ) 함수는 tokenId 값 자동 생성 및 NFT 소유권 이전과 관련된 기능을 수행한다. 인자값으로 전달받은 _from 값이 address(0)일 경우 _allTokens.push( _allTokens.length ) 를 통해 _allTokens 배열의 전체길이를 tokenId 값으로 만들어 _allTokens 상태변수를 업데이트 한다.
_beforeTokenTransfer( ) 함수는 ERC721 컨트랙트의 _mint( ) 함수 뿐만 아니라 transferFrom( ) 함수 안에서도 실행되는데 transferFrom( ) 함수 안에서는 NFT 소유권 이전에 관한 기능을 수행한다. _beforeTokenTransfer( ) 함수 안의 else 코드블록 부분에서 소유권 이전에 관한 처리가 진행되며 프로세스는 다음과 같다.
/* else 코드블록 실행 전 */
// _ownedTokens
{
0x1111: {
// index : tokenId
0: 0,
1: 1,
2: 4, // 전송할 tokenId
3: 5,
4: 6 // latest
},
0x2222: {
0: 2,
1: 3,
2: 7
}
}
// _ownedTokensIndex
{
// tokenId : index
0: 0,
1: 1,
2: 0,
3: 1,
4: 2,
5: 3,
6: 4,
7: 2
}
"0x1111" 계정이 소유하고 있는 NFT 중 tokenId 값이 4인 NFT를 "0x2222" 계정에게 전송한다고 했을 때 _ownedTokens 상태변수와 _ownedTokensIndex 상태변수를 이용해 "0x2222"에게 전송할 tokenId(4)의 index 값(2)에 해당하는 tokenId 값을 "0x1111" 계정이 소유하고 있는 모든 NFT 중 마지막 index(4)에 해당하는 tokenId 값(6)으로 변경한다. 그리고 _ownedTokensIndex 상태변수에서 변경한 tokenId 값(6)의 index를 전송할 tokenId(4)의 index 값(2)으로 바꿔준다. 그 다음 delete 명령어를 사용해 "0x1111" 계정이 소유하고 있는 모든 NFT 중 마지막 index(4)에 해당하는 tokenId 값(6)을 삭제하고 _ownedTokensIndex에서 전송하는 tokenId(4)에 해당하는 index 값(2)을 삭제해준다.
/* else 코드블록 실행 후 */
// _ownedTokens
{
0x1111: {
// index : tokenId
0: 0,
1: 1,
2: 6, // 변경된 부분
3: 5,
4: 6 // delete
},
0x2222: {
0: 2,
1: 3,
2: 7
}
}
// _ownedTokensIndex
{
// tokenId : index
0: 0,
1: 1,
2: 0,
3: 1,
4: 2, // delete
5: 3,
6: 2, // 변경된 부분
7: 2
}
else 코드블록이 실행된 이후에는 다시 _ownedTokens 상태변수와 _ownedTokensIndex 상태변수를 이용해 "0x2222" 계정이 소유하고 있는 NFT 리스트에 "0x1111" 계정으로부터 전달받은 tokenId 값(4)을 추가해준다. 그리고 _ownedTokensIndex 상태변수에서도 전달받은 tokenId 값(4)에 해당하는 index 값을 "0x2222" 계정의 NFT 리스트의 길이(3)로 하여 추가해준다.
/* else 코드블록 이후의 코드 실행 결과 */
// _ownedTokens
{
0x1111: {
// index : tokenId
0: 0,
1: 1,
2: 6,
3: 5
},
0x2222: {
0: 2,
1: 3,
2: 7,
3: 4 // 추가된 부분
}
}
// _ownedTokensIndex
{
// tokenId : index
0: 0,
1: 1,
2: 0,
3: 1,
5: 3,
6: 2,
7: 2,
4: 3, // 추가된 부분
}
참고로 이러한 로직으로 NFT 소유권 이전 기능을 구현하는 이유는 index가 꼬이는 현상을 방지하기 위함이라고 생각하면 된다.
function totalSupply() public view returns (uint) {
return _allTokens.length;
}
totalSupply( ) 함수는 _allTokens 상태변수의 전체 배열 길이를 return 하며 발행된 전체 NFT의 개수를 조회하기 위한 view 함수이다.
function tokenByIndex(uint _index) public view returns (uint) {
require(_index < _allTokens.length);
return _allTokens[_index]; // 전체 토큰 리스트에서 index에 해당하는 tokenId 반환
}
tokenByIndex( ) 함수는 _index를 인자값으로 전달 받아 발행된 NFT들의 tokenId 값이 들어있는 _allTokens 상태변수에서 _index 값에 해당하는 tokenId 값을 return 하는 view 함수이다.
function tokenOfOwnerByIndex(address _owner, uint _index) public view returns (uint) {
require(_index < balanceOf(_owner)); // index는 0부터 시작하기 때문에 index 범위 검사
return _ownedTokens[_owner][_index];
}
tokenOfOwnerByIndex( ) 함수는 _owner 와 _index 를 인자값으로 받고 있으며 _ownedTokens 상태변수를 사용해 _owner 계정이 소유하고 있는 NFT들 중 _index 값에 해당하는 tokenId 값을 return 하는 view 함수이다.
'Ethereum' 카테고리의 다른 글
Ethereum/이더리움 - Fundraising 컨트랙트 (0) | 2022.10.23 |
---|---|
Ethereum/이더리움 - NFT / SaleToken 컨트랙트 (0) | 2022.08.02 |
Ethereum/이더리움 - NFT / Remix로 컨트랙트 배포하기 / OpenSea에 NFT 올리기 (2) | 2022.07.26 |
Ethereum/이더리움 - OpenZeppelin / 토큰 컨트랙트 / 스왑 컨트랙트 (2) | 2022.07.23 |
Ethereum/이더리움 - 인터페이스 & ERC-20 / 토큰 발행하기 (0) | 2022.07.22 |