블록체인 Block-Chain - ERC-721 메서드

dev_swan·2022년 7월 25일
0

블록체인

목록 보기
28/36
post-thumbnail
  • 지난 시간에는 open-zeppelin을 사용하여 간단하게 NFT를 발행해보았습니다.
  • 이번 시간에는 직접 ERC-721 규격을 간단하게 축소하여 NFT를 발행해보겠습니다.

Interface 정의

IERC-721

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

IERC-721 Metadata

// 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를 작성할 것입니다.

Contract 작성

ERC-721

// 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 {}
}

ERC-721 Enumerable

// 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]; // 
    }
}

핵심 내용 (1)

  • _afterTokenelse 부분이 헷갈려서 아래와 같이 풀어서 공부하였습니다.
  • _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 계정으로 TokenId4NFTtransfer 한다고 하면 위와 같이 _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
    }
  • 이처럼 tokenIdindex가 꼬이는것을 막고 최소한의 연산만으로 처리하기 위해
    먼저 0x1234의 계정에 있는 우리가 보내야 할 tokenId4의 위치한 index 22:4 (index : tokenId)로 표현할 수 있습니다.
  • _ownedTokens[_from][tokenIndex] = lastTokenId 이 부분을 통해 보내줄 NFTtokenId를 마지막 IndextokenId 값으로 변경합니다. ( 2 : 4 => 2 : 5 )
  • _ownedTokenIndex[lastTokenId] = tokenIndex 이 부분을 통해 마지막 tokenId값인 5index를 보내줄 NFTindex값으로 변경합니다. ( 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를 처리할 tokenId 4에 대한 값을 _ownedTokens[_to][length] = _tokenIdNFT를 받는 계정인 _to3 : 4를 추가 할 것입니다. 3_to 계정의 보유중인 NFTlength값을 가지고와 index로 추가하였고 4는 인자값으로 받은 _tokenId, 즉 _from_to에게 보낸 NFTtokenId 값을 넣어주었습니다.
  • 마지막으로 _ownedTokenIndex[_tokenId] = length을 통해 _ownedTokenIndex 매핑에 tokenId 4에 대한 NFT가 소유주가 변경되었으니 index값을 수정하여 새로 입력해주었습니다.

핵심 내용 (2)

  • ERC-721.sol파일의 _afterToken 함수를 살펴보면 내용은 작성되지 않은것을 확인할 수 있는데 이는 virtual 속성을 사용하여 부모 Contract에서는 함수에 대한 정의만 해준뒤 실제로 함수의 내용은 자식 ContractERC-721Enumerable.sol파일에서 함수의 내용을 추가하여 사용한것을 확인할 수 있습니다.
  • ERC-721
function _afterToken(address _from, address _to, uint _tokenId) internal virtual {}
  • ERC-721 Enumerable
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;
}

0개의 댓글