- 지난 시간에는
open-zeppelin
을 사용하여 간단하게NFT
를 발행해보았습니다.- 이번 시간에는 직접
ERC-721
규격을 간단하게 축소하여NFT
를 발행해보겠습니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
interface IERC721 {
// Event
// 누가 어떤 계정에게 어떤 Token을 줄것인가
event Transfer(address indexed _from, address indexed _to, uint indexed _tokenId);
// 누가 어떤 대리인에게 어떤 Token을 위임할 것인가.
event Approval(address indexed _from, address indexed _approved, uint indexed _tokenId);
// owner가 가지고 있는 Token을 모두 Operator에게 위임할 것인가.
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
// Function
// onwer가 가지고 있는 NFT의 갯수를 반환합니다.
function balanceOf(address _owner) external view returns(uint);
// TokenId를 소유하고 있는 address를 반환합니다.
function ownerOf(uint _tokenId) external view returns(address);
// A => B 에게 NFT를 줍니다.
function transferFrom(address _from, address _to, uint _tokenId) external;
// 대리인에게 토큰 하나를 위임
function approve(address _to, uint _tokenId) external;
// approve가 되어 있을때 해당 NFT를 위임받은 대리인의 주소를 반환합니다.
function getApproved(uint _tokenId) external view returns(address);
// owner가 가진 모든 토큰을 대리인에게 위임합니다. bool이 true라면 위임이고 false이면 위임 취소입니다.
function setApprobalForAll(address _operator, bool _approved) external;
// 대리인이 있는지 없는지 반환합니다.
function isApprovalForAll(address _owner, address _operator) external view returns(bool);
}
// 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);
}
위와 같이 간단한 함수 , 이벤트들만 정의한
Interface
를 작성해줍니다. 이Interface
들을 상속받아Contract
를 작성할 것입니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import './IERC721.sol';
import './IERC721Metadata.sol';
// public : 외부를 포함해 어디에서든 사용가능
// private : Contract 내에서만 사용가능
// external : 본인 컨트랙트 내에 서로 공유가 안됨.
// internal : 본인 컨트랙트 내에서만 공유됨.
// virtual : 부모는 함수를 정의만 해두고 실제로 내용은 자식이 채워줍니다.
contract ERC721 is IERC721 ,IERC721Metadata{
constructor (string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
// 상태 변수
string public override name;
string public override symbol;
mapping (address => uint) private _balances; // "0x1234" => 1 // 가지고있는 NFT 총 갯수
mapping (uint => address) private _owners; // 1 => "0x1234" // tokenId를 가진 NFT를 가지고 있는 계정
mapping (uint => address) private _tokenApprovals; // Token의 대리인을 확인하는 상태변수
mapping (address => mapping(address => bool))private _operatorApproval; // Token의 owner가 모든 토큰을 대리인에게 위임을 확인하는 상태변수
// onwer가 가지고 있는 NFT의 갯수를 반환합니다.
function balanceOf(address _owner) public override view returns(uint){
require(_owner != address(0)); // _owner의 값이 없는지 확인합니다. address(0) = "0x0" address 타입의 null값이라 생각하면 됩니다.
return _balances[_owner];
}
// TokenId를 소유하고 있는 address를 반환합니다.
// mapping (uint => address) private _owners과 다르게 ownerOf 함수는 값이 없으면 error를 return합니다.
// 또한 이 함수는 Contract 내에서 사용할 때에도 가스비를 지불합니다.
function ownerOf(uint _tokenId) public override view returns(address){
address owner = _owners[_tokenId];
require(owner != address(0)); // _tokenId의 값이 없는지 확인
return owner;
}
// 대리인에게 토큰 하나를 위임. 대리인이 다른 대리인에게 토큰을 위임하는 복대리는 토큰 하나에 대해서만 가능하다.
function approve(address _to, uint _tokenId) external override {
address owner = _owners[_tokenId];
// msg.sender와 _to가 다른 계정인가 검증 ( 같을 경우 가스비 지불되는것을 방지 )
require(_to != owner);
// msg.sender가 tokenId를 소유하고 있는지 검증 // 복대리의 경우 검증
require(msg.sender == owner || isApprovedForAll(owner,msg.sender));
// 대리인에게 tokenId를 위임해줌
_tokenApprovals[_tokenId] = _to;
emit Approval(owner, _to, _tokenId);
}
// approve가 되어 있을때 해당 NFT를 위임받은 대리인의 주소를 반환합니다.
function getApproved(uint _tokenId) public override view returns(address) {
// tokenId가 실제 소유자가 있는지 검증. 없다면 해당 tokenId는 없는거임
require(_owners[_tokenId] != address(0));
return _tokenApprovals[_tokenId];
}
// owner가 가진 모든 토큰을 대리인에게 위임합니다. bool이 true라면 위임이고 false이면 위임 취소입니다. 또한 여려명의 대리인에게 모든 토큰을 위임할 수 있습니다.
function setApprovalForAll(address _operator, bool _approved) external override{
require(msg.sender != _operator);
_operatorApproval[msg.sender][_operator] = _approved;
emit ApprovalForAll(msg.sender, _operator, _approved);
}
// _owner의 Token이 대리인이 있는지 확인
// 대리인이 있는지 없는지 반환합니다.
function isApprovedForAll(address _owner, address _operator) public override view returns(bool){
return _operatorApproval[_owner][_operator];
}
// transferFrom을 할 때 Token을 본인이 보내거나 대리인이 보낼때 검증하는 코드
function _isApprovedOrOwner(address _spender, uint _tokenId) private view returns(bool) {
address owner = _owners[_tokenId];
require(owner != address(0));
// 본인이 from일 경우 || 대리인이 from일 경우 setApprovalForAll 일때 || 대리인이 from일 경우 approve 일때를 검증한 후 true or false를 return
return(_spender == owner || isApprovedForAll(owner,_spender) || getApproved(_tokenId) == _spender);
}
// A => B 에게 NFT를 줍니다.
// from은 A , A의 대리인일 2가지의 경우
function transferFrom(address _from, address _to, uint _tokenId) external override {
require(_isApprovedOrOwner(_from,_tokenId));
require(_from != _to);
// address(0)이 아니기 때문에 else문으로 처리됩니다.
_afterToken(_from,_to,_tokenId);
// 실질적으로 NFT의 소유주를 구매자로 변경함
_owners[_tokenId] = _to;
// 판매자와 구매자의 총 NFT 보유수를 수정
_balances[_from] -= 1;
_balances[_to] += 1;
emit Transfer(_from, _to, _tokenId);
}
// virtual을 사용하여 다른 Contract에서 내용을 작성하도록 해줍니다.
function tokenURI(uint256 _tokenId) external override virtual view returns(string memory){}
// NFT를 발행할 함수
function _mint(address _to, uint _tokenId) public {
require(_to != address(0));
// 아래에서 balanceOf 함수를 호출해서 사용할 경우에는 address가 없으면 error로 빠지기 때문에
// 그 아래의 코드가 더 이상 실행되지 않는 오류가 있기에 상태변수에서 값을 가져와야합니다.
address owner = _owners[_tokenId];
require(owner == address(0));
// address(0)이기 때문에 if문의 true로 minting을 처리할 것입니다.
_afterToken(address(0),_to,_tokenId);
_balances[_to] += 1;
_owners[_tokenId] = _to;
emit Transfer(address(0), _to, _tokenId);
}
// NFT를 발행하고 난 후 ERC721Enumerable Contract에 _allTokens 배열안에 tokenId값을 추가할 _afterToken함수를 생성만 해주고 virtual 속성을 사용하여
// 실제로 코드는 자식 Class인 ERC721Enumerable Contract에서 작성할 것 입니다.
function _afterToken(address _from, address _to, uint _tokenId) internal virtual {}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import "./ERC721.sol";
contract ERC721Enumerable is ERC721 {
uint[] private _allTokens;
mapping (address => mapping(uint => uint)) private _ownedTokens; // address가 보유하고 있는 Index의 TokenId를 반환하는 mapping입니다.
mapping (uint => uint) private _ownedTokenIndex; // TokenId에서 Index를 반환하는 mapping입니다.
// ERC721(_name,_symbol)는 super() 메서드처럼 상속받은 Class를 생성해주는 역할을 합니다.
constructor (string memory _name, string memory _symbol) ERC721(_name,_symbol){}
// _mint 함수를 실행하여 인자값으로 _allTokens.length를 넣어 자동으로 Auto Increment가 되도록 처리합니다.
function mint(address _to) public {
_mint(_to,_allTokens.length);
}
// 부모 Class인 ERC721 Contract에 있는 _afterToken함수의 내용을 작성해줍니다.
// 이때 override 속성을 사용하여 덮어씌우기를 해줘야합니다.
// 이 함수는 ERC721 Contract에 있는 _mint 함수에서 NFT를 발행하고 난 후 실행되는 함수로 _allTokens 배열안에 tokenId 값을 추가하여 줄 것입니다.
function _afterToken(address _from, address _to, uint _tokenId) internal override {
// mint 함수를 실행했을 경우
if ( _from == address(0)) {
_allTokens.push(_allTokens.length);
} else {
// transferFrom
// A가 가지고 있는 tokenId의 NFT를 B에게 줄 것입니다.
uint lastTokenIndex = ERC721.balanceOf(_from)-1; // A가 가지고 있는 NFT의 마지막 Index값을 가져옵니다.
uint tokenIndex = _ownedTokenIndex[_tokenId]; // 보내줄 _tokenId에 대한 NFT의 index값
if(tokenIndex != lastTokenIndex) {
uint lastTokenId = _ownedTokens[_from][lastTokenIndex]; // _from 계정의 마지막 index에 있는 tokenId값을 가져옵니다.
_ownedTokens[_from][tokenIndex] = lastTokenId; // 보내줄 NFT의 보내줄 _tokenId에 대한 NFT의 index값을 마지막 index에 있는 tokenId값으로 변경합니다.
_ownedTokenIndex[lastTokenId] = tokenIndex; // 마지막 tokenId의 index 값을 보내줄 NFT의 index값으로 변경합니다.
}
delete _ownedTokens[_from][lastTokenIndex]; // _ownedTokens[_from][lastTokenIndex] lastTokenIndex 값의 일치하는 tokenId를 가진 값을 제거합니다..
delete _ownedTokenIndex[_tokenId]; // 인자값으로 받은 tokenId에 해당하는 NFT를 지워줍니다.
}
uint length = ERC721.balanceOf(_to); // 배포된 ERC721 CA를 통해 가져온 함수를 사용하는 식으로 처리되서 가스비가 들지 않습니다.
_ownedTokens[_to][length] = _tokenId; // _to ( NFT를 할당받은 계정 )에게 _to가 가지고 있는 NFT를 담아둔 배열의 length를 index로하고 value값으로는 tokenId를 할당해줍니다.
// 예시 { "0x1234" : { length : _tokenId} } { "0x1234" : { 0x1234가 가지고 있는 NFT를 담아놓은 배열의 length : 새로 할당받은 NFT} }
_ownedTokenIndex[_tokenId] = length; // tokenId가 length의 index에 위치하고 있다고 할당합니다. 예시 { tokenId : index }
}
// _allTokens 배열의 총 길이를 return합니다.
function totalSupply() public view returns(uint) {
return _allTokens.length;
}
// _allTokens 배열안에 index에 위치하는 tokenId를 return합니다.
function tokenByIndex(uint _index) public view returns(uint) {
require(_index < _allTokens.length);
return _allTokens[_index];
}
// 계정과 index값을 넣으면 _ownedTokens에 해당 계정의 index에 있는 tokenId값을 return 합니다.
function tokenOfOwnerByIndex(address _owner, uint _index) public view returns(uint) {
require(_index < ERC721.balanceOf(_owner)); // 내가 가진 총 NFT개수가 3개일때는 index로 구분하면 0,1,2여야 하기 때문에 검증하는 코드를 추가합니다.
return _ownedTokens[_owner][_index]; //
}
}
_afterToken
의else
부분이 헷갈려서 아래와 같이 풀어서 공부하였습니다._ownedTokens
매핑의 내용은address : { index => tokenId }
와 같은 형식으로address
가 보유하고 있는 index에 해당하는tokenId
를 반환하는 매핑입니다._ownedTokenIndex
매핑의 내용은{ tokenId => index }
와 같은 형식으로tokenId
에 해당하는index
를 반환하는 매핑입니다.
_ownedTokens 초기값
{
0x1234 : {
0 : 0,
1 : 1,
2 : 4,
3 : 5,
},
0x1111 : {
0 : 2,
1 : 3,
2 : 6,
}
}
_ownedTokenIndex 초기값
{
0 : 0,
1 : 1,
0 : 2,
1 : 3,
2 : 4,
3 : 5,
2 : 6,
}
_ownedTokens
와_ownedTokenIndex
의 내용은 위와 같은 형태로 구현되어 있습니다.
_ownedTokens
{
0x1234 : {
0 : 0,
1 : 1,
3 : 5,
},
0x1111 : {
0 : 2,
1 : 3,
2 : 6,
3 : 4,
}
}
- 이때
0x1234
=>0x1111
계정으로TokenId
가4
인NFT
를transfer
한다고 하면 위와 같이_ownedTokens
의 형태가 변경될 것입니다.- 이렇게 처리 할 경우
0x1234
의 계정에는index 2
가 없어서index
가 꼬이게 될 것입니다.
_ownedTokens
{
0x1234 : {
0 : 0,
1 : 1,
2 : 5,
3 : 5
},
0x1111 : {
0 : 2,
1 : 3,
2 : 6
}
}
_ownedTokenIndex
{
0 : 0,
1 : 1,
0 : 2,
1 : 3,
2 : 4,
2 : 5,
2 : 6
}
- 이처럼
tokenId
의index
가 꼬이는것을 막고 최소한의 연산만으로 처리하기 위해
먼저0x1234
의 계정에 있는 우리가 보내야 할tokenId
가4
의 위치한index 2
는2:4
(index : tokenId)
로 표현할 수 있습니다._ownedTokens[_from][tokenIndex] = lastTokenId
이 부분을 통해 보내줄NFT
의tokenId
를 마지막Index
의tokenId
값으로 변경합니다.( 2 : 4 => 2 : 5 )
_ownedTokenIndex[lastTokenId] = tokenIndex
이 부분을 통해 마지막tokenId
값인5
의index
를 보내줄NFT
의index
값으로 변경합니다.( 3 : 5 => 2 : 5 )
_ownedTokens
{
0x1234 : {
0 : 0,
1 : 1,
2 : 5,
},
0x1111 : {
0 : 2,
1 : 3,
2 : 6
}
}
_ownedTokenIndex
{
0 : 0,
1 : 1,
0 : 2,
1 : 3,
2 : 5,
2 : 6
}
delete _ownedTokens[_from][lastTokenIndex] lastTokenIndex
값의 일치하는tokenId
를 가진 값을 제거합니다.( 3 : 5 제거 )
delete _ownedTokenIndex[_tokenId]
로 인자값으로 받은tokenId
에 해당하는NFT
를 지워줍니다.( 2 : 4 제거 )
_ownedTokens
{
0x1234 : {
0 : 0,
1 : 1,
2 : 5,
},
0x1111 : {
0 : 2,
1 : 3,
2 : 6,
3 : 4, // 0x1234 계정의 tokenId 4번에 해당하는 NFT의 소유주를 0x1111 계정으로 입력
}
}
_ownedTokenIndex
{
0 : 0,
1 : 1,
0 : 2,
1 : 3,
2 : 5,
2 : 6,
3 : 4 // tokenId 4에 해당하는 NFT의 소유주가 0x1111 계정으로 변경되었으니 index값과 tokenId값을 새로 입력
}
Transfer
를 처리할 tokenId4
에 대한 값을_ownedTokens[_to][length] = _tokenId
로NFT
를 받는 계정인_to
에3 : 4
를 추가 할 것입니다.3
은_to
계정의 보유중인NFT
의length
값을 가지고와index
로 추가하였고4
는 인자값으로 받은_tokenId
, 즉_from
이_to
에게 보낸NFT
의tokenId
값을 넣어주었습니다.- 마지막으로
_ownedTokenIndex[_tokenId] = length
을 통해_ownedTokenIndex
매핑에tokenId
4에 대한NFT
가 소유주가 변경되었으니index
값을 수정하여 새로 입력해주었습니다.
ERC-721.sol
파일의_afterToken
함수를 살펴보면 내용은 작성되지 않은것을 확인할 수 있는데 이는virtual
속성을 사용하여 부모Contract
에서는 함수에 대한 정의만 해준뒤 실제로 함수의 내용은 자식Contract
인ERC-721Enumerable.sol
파일에서 함수의 내용을 추가하여 사용한것을 확인할 수 있습니다.
function _afterToken(address _from, address _to, uint _tokenId) internal virtual {}
function _afterToken(address _from, address _to, uint _tokenId) internal override {
if ( _from == address(0)) {
_allTokens.push(_allTokens.length);
} else {
uint lastTokenIndex = ERC721.balanceOf(_from)-1;
uint tokenIndex = _ownedTokenIndex[_tokenId];
if(tokenIndex != lastTokenIndex) {
uint lastTokenId = _ownedTokens[_from][lastTokenIndex];
_ownedTokens[_from][tokenIndex] = lastTokenId;
_ownedTokenIndex[lastTokenId] = tokenIndex;
}
delete _ownedTokens[_from][lastTokenIndex];
delete _ownedTokenIndex[_tokenId];
}
uint length = ERC721.balanceOf(_to);
_ownedTokens[_to][length] = _tokenId;
_ownedTokenIndex[_tokenId] = length;
}