Blockchain

#29 ERC-721 토큰 만들기 Ch2

Sila 2022. 7. 28. 21:14

 

지난 글에서는 erc-721 토큰의 가장 기본적인 기능을 구현해보았는데, 오늘은 여기에 더해

 

openzeppelin에서 제공하는 erc721 관련 컨트랙트들을 사용해 사용자들간의 nft 소유권 이전, 위임 등에 대한

 

기능들을 구현해보자.

 

truffle을 셋업하고 npm으로 openzeppelin-solidity를 설치한다.

 

그리고 contract 폴더 안에 ERC721 폴더를 만들고 ERC721.sol, IERC721Metadata.sol, IERC721.sol 파일을 총 3개 생성해준다.

 

이 파일들에 interface, contract들을 작성한 후, 서로 상속하고 받음으로써 컨트랙트를 완성해나갈 것이다.

 

실제로 nft를 발행할때 이런 식으로 일일히 함수를 작성하진 않지만 여기서는 각 코드들, 함수들이 어떤 식으로 작동하는지,

 

그에 따라 nft라는 시스템이 어떻게 돌아가는지를 이해하는데 중점을 둔다.

 

1. interface 작성

우선 ERC721 컨트랙트에 상속시켜줄 메타데이터 인터페이스와 함수들에 대한 정보를 담은 인터페이스를 작성해주자.

 

/*  truffle/contracts/ERC721/IERC721Metadata.sol. */

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

 

IERC721Metadata 에는 토큰에 대한 간단한 메타 데이터를 포함하는 인터페이스를 작성하는데,

 

여기엔 토큰의 이름심볼, 토큰의 정보를 응답으로 주는 uri가 담겨 있다.

 

 

다음으로 ERC721 컨트랙트에서 실행할 함수들에게 미리 문법적 틀을 지정해줄 인터페이스 IERC721을 작성하고,

 

안을 이벤트와 함수들로 채워준다. 이 때 함수의 내용은 작성하지 않고 함수의 이름과 속성들만 적어준다.

 

나중에 ERC721 '컨트랙트'에 이 '인터페이스'와 동일한 함수들을 작성할 때, 반드시 '인터페이스'에서 지정한 속성을

 

그대로 가져가야 한다.

 

/*  truffle/contracts/ERC721/IERC721.sol. */

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

interface IERC721 {
    
    // 이벤트
    // indexed는 나중에 방출된 이벤트를 블록체인 상에서 조회할 때 원하는 이벤트만을 필터링해
    // 가져오기 위해 사용하는 속성이라고 알아두면 된다
    event Transfer(address indexed _from, address indexed _to, uint indexed _tokenId);
    event Approval(address indexed _from, address indexed _approved, uint indexed _tokenId);
    event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
    
    // _owner가 가진 nft의 총 갯수를 보여주는 함수
    function balanceOf(address _owner) external view returns(uint)
    
    //_tokenIdfh 그 토큰을 소유한 address를 보여주는 함수
    function ownerOf(uint _tokenId) external view returns(address);
    
    // token의 소유권 이전에 관련된 함수
    // _from에서 _to로 _tokenId값을 가진 토큰을 준다
    function transferFrom( address _from, address _to, uint _tokenId) external;
    
    // 특정 토큰에 대한 위임 관련 함수 (_to 에서 _tokenId)를 위임
    function approve(address _to, uint _tokenId) external;
    
    // _tokenId를 위임받은 address를 알려주는 함수
    function getApproved(uint _tokenId) external view returns(address);
    
    // 모든 토큰을 operator에게 위임
    function setApprovalForAll (address _operator, bool _approved) external;
    
    // 현재 (모든) 토큰들이 _owner에게서 _operator로 위임받은 상태인지를 bool 값으로 알려주는 함수
    function isApprovedForAll(address _owner, address _operator) external view returns(bool);
}

 

2. 컨트랙트 작성

이제 이 두 인터페이스를 ERC721에 상속시켜 본격적인 함수를 작성해보자.

 

/*  truffle/contracts/ERC721/ERC721.sol */

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

import "./IERC721.sol";
import "./IERC721Metadata.sol";

contract ERC721 is IERC721, IERC721Metadata {

    // IERC721Matadata 에서 name, symbol을 가져온다.
    string public override name;
    string public override symbol;
    
    // 지갑 주소 > 해당 지갑이 가진 토큰 수
    mapping(address => uint) private _balances;

    // tokenId > 해당 토큰의 소유자
    mapping(uint => address) private _owners;

    // 토큰이 위임된 address
    mapping(uint => address) private _tokenApprovals;

    // address 가 다른 address에게 위임을 맡겼는지 여부를 확인
    // a가 b에게 위임을 맡겼다면 a => mapping( b => true )
    mapping (address => mapping(address => bool)) private _operatorApprovals;

    constructor(string memory _name, string memory _symbol) {
        name = _name;
        symbol = _symbol;
    }

    function balanceOf(address _owner) public override view returns(uint) {
        // 지갑 주소가 존재하는 주소여야 한다.
        require(_owner != address(0), "ERC721: no address exist" );
        // 매핑을 이용해 소유 토큰 수 확인
        return _balances[_owner];
    }

    function ownerOf(uint _tokenId) public override view returns(address) {
        address owner = _owners[_tokenId];
        // ownerOf 함수보다는 매핑을 이용하는 것이 좋다.
        require(owner != address(0), "ERC721 : no address exist");
        return owner;
    }

    function approve(address _to, uint _tokenId) external override {
        address owner = _owners(_tokenId);
        require(_to != owner, "ERC721 : cannot approve to yourself");

        _tokenApprovals[_tokenId] = _to;
        // 새로운 매핑 관계 형성
        emit Approval(owner, _to, _tokenId);
    }

    function getApproved(uint _tokenId) public override view returns(address) {
        require(ownerOf(_tokenId) != address(0), "ERC721 no master for nft to approve");
        return _tokenApprovals[_tokenId];
        // 해당 토큰이 누구에게 approve되었는지를 매핑을 이용해 보여줌
    }

    // _operator에게 모든 token의 위임을 주겠다
    function setApprovalForAll(address _operator, bool _approved) external override {
        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) {
        return _operatorApprovals[_owner][_operator];
    }

    function _isApprovedOrOwner(address _spender, uint _tokenId) private view returns(bool) {
        address owner = ownerOf(_tokenId);
        require(owner != address(0));
        return (_spender == owner || isApprovedForAll(owner, _spender) || getApproved(_tokenId) == _spender);
        // owner 본인이거나, 전체 위임을 받은 사람이거나, 그 토큰을 위임 받은 사람이라면 true, 아니면 false
    }

    // 소유권 이전 함수
    function transferFrom(address _from, address _to, uint _tokenId) external override {
        // 소유권을 이전하려면 위임받은 사람이거나 주인이거나
        require(_isApprovedOrOwner(_from, _tokenId));
        require(_from != _to);

        _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 {
        require(_to != address(0));
        address owner = ownerOf(_tokenId);
        require(owner == address(0));

        _balances[_to] += 1;
        _owners[_tokenId] = _to;

        emit Transfer(address(0), _to, _tokenId);
    }
    
    function _afterToken(address _from, address _to, uint _token) internal virtual {}
}

 

몇 가지 용어를 확인하고 넘어가자.

 

- 함수는 여러 가지 속성을 가지고 있고, 그 분류 기준중 하나는 '해당 함수를 누가 (혹은 어떤 컨트랙트가) 호출할 수 있는지'이다.

 

이 분류 기준에 따라 함수를 4가지로 나눌 수 있다.

 

1. public : 어디에서나 호출 가능

 

2. private : 함수를 가진 그 컨트랙트 내부에서만 호출 가능

 

3. internal : 함수를 가진 컨트랙트, 그리고 그 컨트랙트를 상속하는 컨트랙트에서 호출 가능

 

4. external : internal과 반대로 컨트랙트의 외부에서만 호출 가능 ( 해당 컨트랙트와 상속 컨트랙트에서 호출 불가)

 

 

- 인터페이스를 상속받은 컨트랙트는 인터페이스의 함수를 동일하게 가짐과 동시에 이를 일부 수정해 덮어쓰기 할 수 있다.

 

이런 경우 함수에 override 속성을 붙인다.

 

- virtual 속성은ERC721Enumerable 컨트랙트를 작성후 알아보는 것이 좋겠다.

 

 

이제 마지막으로 ERC721를 상속받는 컨트랙트 ERC721Enumerable을 작성해보자.

 

/*  truffle/contracts/ERC721/ERC721Enumerable.sol  */

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

import "./ERC721.sol";

contract ERC721Enumerable is ERC721 {
    // 모든 토큰을 담고 있는 배열
    uint[] private _allTokens;
    
    // 지갑 주소 > 그 지갑이 가진 토큰을 원소로 가지는 배열의 인덱스 > tokenId까지 연결해주는 매핑
    // 예를 들어 0x1234가 토큰을 3개 가지고 있고, 그 중 1번 인덱스의 tokenId가 5라면
    // 0x1234 => { "1" : "5" }
    // _ownedTOkens[address][index] = tokenId처럼 나타낼 수 있다.
    mapping(address => mapping(uint => uint)) private _ownedTokens;
    
    // tokenId => index를 연결해주는 매핑 (소유자의 소유 토큰을 담은 배열의 인덱스를 의미)
    mapping(uint => uint) private _ownedTokenIndex;
    
    constructor(string memory _name, string memory _symbol) ERC721 (_name, _symbol){}
    
    // 토큰의 총 갯수를 알려주는 함수
    function totalSupply() public view returns (uint) {
        return _allTokens.length;
    }
    
    // 전체 토큰 배열에서의 인덱스로 원하는 토큰을 찾는 함수
    function tokenByIndex(uint _index) public view returns (uint) {
        require(_index < _allToken.length);
        return _allTokens[_index];
    }
    
    // _owner의 n번째 토큰을 찾아주는 함수
    function tokenOfOwnerByIndex (address _owner, uint _index) public view returns(uint) {
        require(_index < ERC721.balanceOf(_owner));
        return _ownedTOkens[_owner][_index];
    }
    
    function mint(address _to) public {
        _mint(_to, _allTokens.length);
    }
    
    function _afterToken(address _from, address _to, uint _tokenId) internal override {
        require( _from == address(0));
        // 배열에 새로운 토큰을 추가
        _allTokens.push(_allTokens.length);
        
        // _to가 가진 토큰의 수 + 1 (새로 만들어진 토큰의 id로 삼을 수 있다.)
        uint length = ERC721.balanceOf(_to);
        
        // 매핑에 새로운 토큰의 주인과 인덱스 추가
        _ownedTokens[_to][length] = _tokenId;
        
        _ownedTokenIndex[_tokenId] = length;
        
    }
}

 

3. 민팅

민팅은 그 뜻을 보면 주조한다는 뜻이다. 

 

토큰을 새로 만든다고 생각해보자. 이전 소유자는 존재하지 않고,

 

누구에게 줄지, 새로 만들어진 token의 tokenId는 무엇인지를 정해줘야한다.

 

우선 ERC721Enumerable 컨트랙트에서 mint함수가 실행된다. 이 함수는 ERC721의 _mint 함수를 실행시킨다.

 

_mint 함수에서는 받을 지갑 주소가 존재하는지, 혹시라도 새로 만들어진 토큰이 주인이 있지 않은지를 require문으로 확인한 후,

 

_afterToken 함수를 실행해야한다.

 

_afterToken 함수에서는 _allToken 배열에 새로운 토큰을 추가하고, 소유자의 토큰 소유 정보를 업데이트 해준다.

 

(토큰 소유 목록 배열에 하나를 추가해주고, 새로운 매핑 관계 생성)

 

_afterToken 함수의 실행이 완료된 후 _mint 함수가 이어서 실행되는데,

 

이 때 소유자의 잔고를 업데이트 해주고 토큰의 소유자 매핑 정보를 추가해준 후, 이벤트를 방출한다.

 

 

여기서 각 함수가 어떤 컨트랙트 안에 있는지에 주의해야한다.

 

mint 함수, _afterToken는 erc721enumerable 컨트랙트 안에 있고 _mint 함수는 erc721 컨트랙트 안에 있다.

 

그런데 ERC721 컨트랙트에 있는 mint 함수에서 erc721enumerable 컨트랙트에 있는 _afterToken 함수를 호출하고 있다.

 

이럴 때 사용되는 함수 속성이 바로 virtual 속성이다.

 

한 컨트랙트가 자신을 상속받은 컨트랙트에 있는 함수를 불러와 실행해야 할 때 이처럼 함수의 내용만 제외하고 가져다 쓴 후,

 

virtual 속성을 붙여주면 문제 없이 자신을 상속받은 컨트랙트에서도 함수를 가져와 (심지어 internal일 때도) 사용할 수 있다.

 

 

ERC721 컨트랙트의 _mint 함수에 다음과 같이 _afterToken 함수를  추가해준다.

 

/*  truffle/contracts/ERC721/ERC721.sol */

function _mint (address _to, uint _tokenId) public {
    require(_to != address(0));
    address owner = ownerOf(_tokenId);
    require(owner == address(0));
    
    _afterToken(address(0), _to, _tokenId);
    _balances[_to] += 1;
    _owners[_tokenId] = _to;
    
    emit Transfer(address(0), _to, _tokenId);
}

 

4. 소유권 이전

다음으로 소유권 이전을 하는 기능을 구현해보자.

 

민팅과는 다르게 누가 누군가에게 어떤식으로든 소유권을 넘기는 것이므로 그 전 소유자 지갑 주소 값이 있어야 하고,

 

그 지갑 주인 (전송인)의 토큰 소유에 관한 매핑 (_ownedTokens, _ownedTokenIndex) 을 지운 다음,

 

받는 사람에게 토큰 소유에 관한 매핑을 추가해야 한다.

 

우선 매핑 정보를 업데이트 하기 위해 ERC721Enumerable 컨트랙트에 _afterTransfer 함수를 추가해줄 것이다.

 

/*  truffle/contracts/ERC721/ERC721Enumerable.sol  */

function _afterTransfer(address _from, address _to, uint _tokenId) internal override {
    // 이전 소유자 _from이 존재해야 한다
    require(_from != address(0));
    
    // 사용자의 소유 토큰 배열의 마지막 인덱스 값을 다음과 같이 가져올 수 있다.
    uint latestTokenIndex = ERC721.balanceOf(_from) - 1;
    
    // 보낼 토큰의 인덱스 (in 발신인 소유 토큰 배열)
    uint tokenIndex = _ownedTokenIndex[_tokenId];
    
    // 전송자 입장에서 보내는 토큰이 마지막 토큰이 아닌 경우 인덱스에 대응하는 token 인덱스를
    // 오름차순으로 정리하기 위해 보내는 토큰의 인덱스를 마지막 인덱스와 교체한다.
    if(tokenIndex != latestTokenIndex) {
        uint latestTokenId = _ownedTokens[_from][latestTokenIndex];
        
        _ownedTokens[_from][tokenIndex] = latestTokenId;
        _ownedTokenIndex[latestTokenId] = tokenIndex;
    }
    
    // 소유권 이전이 끝났다면 발신인의 매핑에서 해당 토큰을 지운다.
    delete _ownedTokens[_from][latestTokenIndex];
    delete _ownedTokenIndex[_tokenId];
    
    // 받은 사람의 토큰에 대한 매핑을 새로 업데이트 해준다
    uint length = ERC721.balanceOf(_to);
    _ownedToken[_to][length] = _tokenId;
    _ownedTokenIndex[_tokenId] = length;
    
}

 

전송자 입장에서 소유한 토큰들의 id를 배열로 가지고 있는데, 이는 인덱스가 커질수록 tokenId 값도 커지게끔 구성되어 있다.

 

이런 오름차순 관계를 토큰을 주고 받아도 유지할 수 있도록 토큰을 주기 전에 배열의 순서를 고치게 되는데,

 

이는 배열에서 보낼 토큰을 가장 마지막 인덱스의 토큰과 위치를 바꾼 후, 마지막 인덱스를 제거하는 식으로 이루어진다.

 

그에 관련된 코드가 if 조건문 안에 들어있는데, 만약 마지막 인덱스에 해당하는 토큰을 줄 경우 이 과정을 거치지 않고

 

그냥 그 토큰 인덱스만을 배열에서 제거하면 된다. (if문을 거치지 않고 바로 다음 코드 실행)

 

 

이 _afterTokenTransfer 함수를 ERC721의 transferFrom 함수에서 호출해주면 된다.

 

/*  truffle/contracts/ERC721/ERC721.sol  */

function transferFrom(address _from, address _to, uint _tokenId) external override {
    require(_isApprovedOrOwner(_from, _tokenId));
    require(_from != _to);

    _afterTransfer(_from, _to, _tokenId);

    _balances[_from] -= 1;
    _balances[_to] += 1;
    _owners[_tokenId] = _to;
        
    emit Transfer(_from, _to, _tokenId);
    // 이벤트 방출
}

 

_afterToken 함수처럼 _afterTransfer함수도 ERC721에서 virtual 속성을 가지고 선언되어야 한다.

 

/*  truffel/contracts/ERC721/ERC721.sol  */

// ...중략

contract ERC721 is IERC721, IERC721Metadata {
    // ...중략
    
    function _afterTransfer(address _from, address _to, uint _token) internal virtual {}
}

 

여기까지 하면 ERC721 토큰의 기본적인 기능은 어느 정도 구현된 것이다.

 

이렇게 일일히 코드를 쳐서 구현하는 것은 ERC721, enumerable 등 컨트랙트 (openzeppelin에서 제공하는)의 함수들이

 

어떤식으로 동작하는지 감을 잡기 위해 해본 것이고, 다음 글부터는 openzeppelin에서 제공하는 솔리디티 파일의 컨트랙트들을

 

바로 가져와서 nft에 대한 이런 저런 기능들을 사용해보도록 한다.