컨트랙트 개발 시작 - ERC721Enumerable

수호 첫 TF팀의 카페 NFT 프로젝트 여정기 ep.2
수호 첫 TF팀의 카페 NFT 프로젝트 여정기 ep.2

수호 카페 NFT 프로젝트 두 번째 이야기입니다! 지난번 포스트에서 재료와 제품을 저장하는 컨트랙트를 만들었으니, 그 다음은 토큰 발급과 관련된 컨트랙트를 개발해 보겠습니다.


MaterialFactory

먼저, 재료 토큰을 발급하는 MaterialFactory 컨트랙트입니다.

1. Constructor

지난 포스트에서 만들어 두었던 CafeMenu 컨트랙트를 사용하기 위해 cafeMenu 변수를 선언합니다. 이후 constructor에서 CafeMenu 컨트랙트를 초기화합니다. 토큰은 관리자만 발급해줄 수 있기 때문에, CafeMenu 컨트랙트에서처럼 OpenZeppelin의 Ownable 컨트랙트를 상속합니다.

contract MaterialFactory is Ownable {
		CafeMenu public cafeMenu;
		constructor (address _cafeMenuAddress) {
		    cafeMenu = CafeMenu(_cafeMenuAddress);
		}
		...
}

2. 고객들이 가진 토큰을 저장하는 MaterialToken

고객들에게 실제로 발급할 토큰에 대한 정보입니다. 토큰의 Id와 현재 고객이 갖고 있는 재료의 양을 저장합니다. 이 토큰들은 address => MaterialToken[] 매핑의 각 사용자(주소)에 해당하는 토큰 배열에 저장됩니다.

struct MaterialToken {
    uint materialId;
    uint amount;
}

mapping(address => MaterialToken[]) ownerToTokens;

3. 고객에게 재료 토큰 발급하기

재료 토큰을 발급하려면 <어떤 재료인지>, <누구에게 발급해 주어야 하는지> 두 가지 정보가 필요합니다.

그리고 <어떤 재료인지>를 알았으면, 그 재료의 1회 제공량은 얼마인지(defaultAmount)를 알아야 합니다.

이를 위해, 처음에는 아래와 같이 CafeMenu 컨트랙트 내 materials 변수의 getter 함수를 사용하여 정보를 얻으려 했습니다.

CafeMenu.Material memory material = cafeMenu.materials(_materialId);
uint defaultAmount = material.defaultAmount;

하지만 첫 줄에서 오류가 발생했습니다.

Different number of components on the left hand side (1) than on the right hand side (3). Type string memory is not implicitly convertible to expected type struct CafeMenu.Material memory.

cafeMenu.materials(_materialId)Material struct인데 왜 stringMaterial로 형변환이 불가능하다는 오류가 나는 건지, 이해가 안 되어서 한참을 헤맸습니다.

알고보니, 외부 컨트랙트인 MaterialFactory에서 CafeMenu 컨트랙트를 읽을 때 struct로 읽는 것이 아닌 일반 배열로 읽어온다는 것이었습니다. 참고

의도치 않게 Javascript의 Destructing Assignment와 비슷한 방식으로 아래와 같이 더 쉽게 defaultAmount의 값을 구할 수 있었습니다.

(, , uint defaultAmount) = cafeMenu.materials(_materialId);

깔끔😊

함수 전체를 보면 다음과 같습니다.

function issueMaterialToken(uint _materialId, address _customer) public onlyOwner {
    (, , uint defaultAmount) = cafeMenu.materials(_materialId);

    // 이미 해당 토큰을 가지고 있었으면 양(amount)만 추가한다.
    uint tokenCount = ownerToTokens[_customer].length;
    for(uint i = 0; i < tokenCount; i++){
        """ 요청된 재료와 동일한 재료일 때 """
        if(ownerToTokens[_customer][i].materialId == _materialId){
            ownerToTokens[_customer][i].amount += defaultAmount;
            console.log("Material Token(id: %s) is issued to %s, total: %s", _materialId, _customer, ownerToTokens[_customer][i].amount);
            return;
        }
    }

    """ 토큰이 없다면 토큰을 추가하고 값은 defaultAmount로 한다. """
    ownerToTokens[_customer].push(MaterialToken(_materialId, defaultAmount));
    console.log("Material Token(id: %s) is issued to %s, total: %s", _materialId, _customer, defaultAmount);
}

먼저 사용자가 해당 토큰을 이미 가지고 있는지 확인하고, 가지고 있다면 그 값을 defaultAmount만큼 더해주고, 가지고 있지 않다면 새로 토큰을 발급해 줍니다.

4. 고객이 가진 재료 토큰 목록 읽기

Solidity가 제공하는 public 변수의 getter 함수는 배열 전체를 리턴하지 않습니다. 참고

If you have a public state variable of array type, then you can only retrieve single elements of the array via the generated getter function. This mechanism exists to avoid high gas costs when returning an entire array.

이는 전체 배열을 리턴했을 때 높은 가스비가 들 수 있는 위험을 피하기 위함이라고 하는데요, 하지만 재료의 종류가 많지 않을 것이라 예상되기 때문에 저는 별도로 전체 배열을 리턴하는 함수를 작성했습니다.

function materialTokensOf(address _owner) public view returns (MaterialToken[] memory){
    return ownerToTokens[_owner];
}

이렇게 MaterialFactory 컨트랙트에서 재료 토큰을 발급하는 등의 기본적인 함수를 작성해 보았습니다. 다음 단계로, 이 컨트랙트를 확장하여 재료 토큰을 커피 토큰으로 교환해 주는 ProductFactory 컨트랙트를 작성해 보겠습니다😙


ProductFactory

위에서 구현했던 재료 토큰은 대체 가능한, 즉 Fungible한 토큰이었습니다. 제가 가진 우유 100ml와 다른 사람이 가진 우유 100ml는 동일한 가치를 가지고 있는 것이죠.

하지만 재료들을 조합해서 만들어 낸 커피 토큰에는 모든 토큰 하나 하나에 unique함이 있도록 만들고 싶었고, 그래서 ProductFactory 컨트랙트는 대체 불가능 토큰(Non-Fungible Token, NFT)의 표준인 ERC-721을 활용했습니다.

ERC-721을 활용한 대표적인 예시로는 크립토키티 게임이 있습니다. 모든 크립토키티 토큰이 각자 유일한 유전 코드를 갖고, 이 유전 코드를 통해 눈 모양, 털 색깔, 입 모양 등의 속성을 갖게 되는데요. 저도 여기에서 아이디어를 얻어서 모든 커피들에 저마다 다른 코드를 부여해서 컵 모양, 컵홀더 디자인, 음료의 색깔, 배경 색깔 등을 다양하게 부여해 보려 합니다.

ERC721

OpenZeppelin 라이브러리에서 제공하는 ERC721 컨트랙트를 활용하면 ERC-721 표준에 맞는 NFT를 빠르게 구현할 수 있습니다.

예를 들면 사용자가 가지고 있는 토큰의 양을 조회하는 balanceOf 함수, 토큰의 주인을 조회하는 ownerOf 함수, 토큰의 소유권을 옮기기 위해 필요한 approve, transferFrom과 같은 함수들이 기본적으로 구현이 되어있기 때문에 빠르고 간편하게 NFT를 개발할 수 있습니다.

ERC721Enumerable

ERC721Enumerable 컨트랙트는 기존의 ERC721 에서 totalSupply(), tokenByIndex(), tokenOfOwnerByIndex() 등의 유용한 함수를 추가적으로 사용할 수 있도록 확장한 컨트랙트입니다.

1. Constructor

contract ProductFactory is ERC721Enumerable, MaterialFactory {
    constructor(
        address _cafeMenuAddress
    ) ERC721("Sooho Café", "SHC") MaterialFactory(_cafeMenuAddress) {}
		...
}

대체 불가능한 커피 토큰을 구현하기 위해 OpenZeppelin 라이브러리의 ERC721Enumerable 컨트랙트를 상속했고, 이전 포스트에서 작성했던 MaterialFactory 컨트랙트를 상속함으로써  재료 토큰을 커피 토큰으로 교환해주는 등의 역할을 수행할 수 있도록 확장했습니다.

ERC721 컨트랙트를 초기화하기 위해서는 namesymbol이 필요합니다. nameSooho Café,  symbolSHC로 설정했습니다.

이전 포스트에서 MaterialFactory를 초기화하기 위해서는, 카페의 메뉴 정보를 가져오기 위한 CafeMenu 컨트랙트의 주소가 필요했었죠? ProductFactory 또한 MaterialFactory를 상속받은 것이니 동일하게 CafeMenu 컨트랙트 주소를 매개변수로 넘겨줍니다.

2. 고객이 가진 제품 토큰의 정보를 저장하는 ProductToken

struct ProductToken {
    uint productId;
    uint data;  """ token이 가진 유일한 값 """
}

ProductToken[] public productTokens;

앞서 개요에서 크립토키티처럼 저희가 발급하는 토큰에도 저마다의 고유한 값을 부여하겠다고 언급했었는데요. 여기서는 ProductTokendata 변수가 그 역할을 담당하게 됩니다. productIdCafeMenu 컨트랙트에서 관리하는 재료들(products)의 Id 값입니다.

3. 고객이 소유한 재료 토큰을 제품 토큰과 교환해주기

재료 토큰을 제품 토큰과 교환하는 것은 아래 3가지의 과정이 필요합니다.

  1. CafeMenu 컨트랙트에서 제품 토큰을 만들기 위한 레시피 정보 가져오기
  2. 고객이 필요한 재료들을 충분히 갖고 있는지 체크하고, 재료들을 모두 갖추었다면 필요한 재료를 소진
  3. 제품 토큰 발급

함수로 표시하면 아래처럼 되겠네요.

""" @notice 재료를 제품으로 교환합니다. """
""" @param _productId 제품의 ID """
function exchangeMaterialsToProduct(uint _productId) external {
    """ 필요한 재료 목록 """
    CafeMenu.Recipe[] memory recipes = cafeMenu.getRecipes(_productId);

    """ 필요한 재료 소진(각 재료들이 충분한지 체크) """
    _payMaterials(recipes);

    """ 제품(product) token 발급 """
    _createProductToken(msg.sender, _productId);
}

이렇게 세 가지 과정을 각각의 함수로 분리하여 작성해 보겠습니다.

제품 토큰을 만들기 위한 레시피 가져오기
여기서 필요할 줄 알고 CafeMenu 컨트랙트에서 레시피 목록을 가져오는 함수를 만들어 두었습니다!

그러니 CafeMenu 컨트랙트의 getRecipes함수를 사용하여 레시피를 간단하게 구해봅시다.

CafeMenu.Recipe[] memory recipes = cafeMenu.getRecipes(_productId);

필요한 재료들 소진하기
여기는 조금 복잡한 과정이 필요합니다. 먼저 전체 함수 코드를 보시죠.


function _payMaterials(CafeMenu.Recipe[] memory _recipes) internal {
    MaterialToken[] memory tokens = ownerToTokens[msg.sender];

    """ 가지고 있는 material 개수가 recipe의 개수보다 적으면 선제적으로 error를 throw한다. """
    require(tokens.length >= _recipes.length, "You have insufficient materials");

    """ 유저가 가지고 있는 토큰 중, 필요한 재료 토큰이 몇 번째에 저장되어 있는지를 저장한다. """
    uint[] memory ownerTargetMaterialTokenIds = new uint[](_recipes.length);

    """ 각 material들이 payable한지 체크 """
    for(uint p = 0; p < _recipes.length; p++){
        bool hasTargetMaterial = false;

        for(uint i = 0; i < tokens.length; i++){
            if( tokens[i].materialId != _recipes[p].materialId )
                continue;

            hasTargetMaterial = true;
            uint balance = tokens[i].amount;
            require(
                balance >= _recipes[p].amount, 
                string(abi.encodePacked("Your balance of material ", _recipes[p].materialId, "is insufficient"))
            );

            ownerTargetMaterialTokenIds[p] = i;
            break;
        }

        """ 타겟 material을 가지고 있고, 위의 require문들을 통과해야(가진 재료 양이 기준보다 많아야)한다. """
        require(
            hasTargetMaterial, 
            string(abi.encodePacked("You don't have a material (ID:", _recipes[p].materialId, ")"))
        );
    }

		""" 재료 소진 """
		for(uint i = 0; i < _recipes.length; i++){
        ownerToTokens[msg.sender][ownerTargetMaterialTokenIds[i]].amount -= _recipes[i].amount;
    }
}

먼저 고객이 제품 생성에 필요한 재료들을 모두 갖추었는지 확인해 주어야 합니다.

지난 포스트에서 MaterialTokens 컨트랙트 내에 있었던 ownerToTokens 매핑을 통해 사용자가 가지고 있는 토큰들의 목록을 가져옵니다.

만약 갖고 있는 토큰의 종류 수가 레시피에 필요한 재료들의 종류 수보다 적다면, 더 이상 볼 것도 없이 고객이 가진 재료가 부족하겠죠? 그러니 괜히 가스비 낭비하지 말고 에러를 던져줍시다.

이제부터는 쉬운 설명을 위해 예시를 들어보겠습니다.

아메리카노 토큰을 만들기 위해 물 200ml샷 2개가 필요하다고 해봅시다.

먼저, 고객이 가지고 있는 재료 토큰 배열에서 물 토큰을 몇 ml나 가지고 있는지, 샷은 몇 개가 있는지를 알아야 합니다. 또한 나중에 재료를 소진할 때도 물 토큰이랑 샷 토큰이 어디에 있었는지 알면 더 빠르게 접근할 수 있으니, 고객이 가진 토큰의 양을 체크하는 김에 배열 내에 어디에 있는지까지 저장해 둡시다.

""" 각 material들이 payable한지 체크 """
for(uint p = 0; p < _recipes.length; p++){
    bool hasTargetMaterial = false;

    for(uint i = 0; i < tokens.length; i++){
        if( tokens[i].materialId != _recipes[p].materialId )
            continue;

        hasTargetMaterial = true;
        uint balance = tokens[i].amount;
        require(
            balance >= _recipes[p].amount, 
            string(abi.encodePacked("Your balance of material ", _recipes[p].materialId, "is insufficient"))
        );

        ownerTargetMaterialTokenIds[p] = i;
        break;
    }

    """ 타겟 material을 가지고 있고, 위의 require문들을 통과해야(가진 재료 양이 기준보다 많아야)한다. """
    require(
        hasTargetMaterial, 
        string(abi.encodePacked("You don't have a material (ID:", _recipes[p].materialId, ")"))
    );
}

이중 for문을 통해 각 재료들이 고객이 소유한 재료 토큰 배열 중 몇 번째에 위치해 있는지 파악하고, 배열을 끝까지 탐색했음에도 토큰을 찾지 못한 경우 에러를 발생시키기 위해 hasTargetMaterial 플래그 변수를 사용하였습니다.

타겟 토큰이 고객이 소유한 토큰 배열의 몇 번째에 위치해 있는지 알아냈으면 레시피에 필요한 양보다 많이 갖고 있는지 확인하고, 문제가 없으면 ownerTargetMaterialTokenIds 배열에 해당 토큰의 인덱스를 추가합니다.

이렇게 수많은 require문을 통과해야 비로소 고객이 제품을 만들기 위해 필요한 재료들을 충분히 갖고 있다는 것을 보장할 수 있습니다.

이제 재료를 소진하는 단계입니다.

다행히 고객이 소유한 재료 토큰의 양을 확인하는 과정에서 id를 확보해 두었기 때문에, 재료 소진은 아래와 같이 아주 쉽게 할 수 있었습니다🥲

""" 재료 소진 """
for(uint i = 0; i < _recipes.length; i++){
    ownerToTokens[msg.sender][ownerTargetMaterialTokenIds[i]].amount -= _recipes[i].amount;
}

드디어, 제품 토큰 발급하기
고객이 가진 재료 양을 확인하고 소진까지 하는 단계가 생각보다 생각할 게 많네요...

제품 토큰 발급하는 건 그럼 얼마나 더 복잡하려나 했는데... ERC721 컨트랙트가 제공하는 internal 함수 _mint가 절 살렸습니다😙😙😙

function _createProductToken(address _owner, uint _productId) private {
    uint productTokenId = totalSupply();
    uint data = uint(keccak256(abi.encodePacked(block.timestamp, _owner, productTokenId)));

    productTokens.push(ProductToken(_productId, data));

    _mint(_owner, productTokenId);
}

저는 해시함수 keccak256 를 사용하여 제품 토큰의 고유한 값만 만들어 주기만 하고, ERC721의 _mint 함수로 간단하게 제품 토큰을 발급할 수 있었습니다!

ERC721 컨트랙트 내부에서 _mint(address to, uint256 tokenId)함수가 private mapping인 _balances, _owners를 업데이트 해주기 때문에, 저는 _mint함수를 호출하고 tokenId에 해당하는 토큰을 productTokens과 같은 배열에 따로 저장만 해주기만 하면 되는 것이었습니다.

ERC721 컨트랙트는 아래처럼 _balances, _owners 말고도 _tokenApprovals, _operatorApprovals mapping 또한 유지하고 있는데요. 토큰과 소유자들을 어떻게 관리해야 할지, 그런 자료구조에 대한 고민을 제가 따로 하지않아도 되었던 점이 가장 좋았습니다😙😙

""" Mapping from token ID to owner address """
mapping(uint256 => address) private _owners;

""" Mapping owner address to token count """
mapping(address => uint256) private _balances;

""" Mapping from token ID to approved address """
mapping(uint256 => address) private _tokenApprovals;

""" Mapping from owner to operator approvals """
mapping(address => mapping(address => bool)) private _operatorApprovals;

이제 제품 토큰을 교환해주는 메인 함수에 위에서 구현한 세 가지 함수를 모아보겠습니다. 이렇게 보니 깔끔하군요🙃

function exchangeMaterialsToProduct(uint _productId) external {
    """ 필요한 재료 목록 """
    CafeMenu.Recipe[] memory recipes = cafeMenu.getRecipes(_productId);

    """ 필요한 재료 소진(각 재료들이 충분한지 체크) """
    _payMaterials(recipes);

    """ 제품(product) token 발급 """
    _createProductToken(msg.sender, _productId);
}

4. 고객이 소유한 토큰 목록 조회하기

function productTokensOf(address _productTokenOwner) public view returns (ProductToken[] memory) {
    uint256 balanceLength = balanceOf(_productTokenOwner);
    require(balanceLength != 0, "Owner does not have token.");

    ProductToken[] memory tokens = new ProductToken[](balanceLength);

    for(uint256 i = 0; i < balanceLength; i++) {
        uint256 productTokenId = tokenOfOwnerByIndex(_productTokenOwner, i);
        tokens[i] = productTokens[productTokenId];
    }

    return tokens;
}

가장 먼저 고객이 가진 토큰들을 조회하는 함수를 작성할텐데요, 여기서도 마찬가지로 ERC721 컨트랙트와ERC721Enumerable 컨트랙트를 사용하니 기본적인 함수들이 많이 구현되어 있어서 개발이 정말 편했습니다😚

  1. 먼저 ERC721 컨트랙트의 함수 balanceOf 를 사용하여 고객이 소유한 토큰의 개수를 가져옵니다. 보유한 토큰이 없다면 에러를 발생시킵니다.
  2. 소유하고 있는 토큰이 있다면, for문을 통해 ERC721Enumerable 컨트랙트의 tokenOfOwnerByIndex 함수를 통해 유저가 갖고 있는 토큰의 ID를 가져옵니다.
  3. 2번에서 얻은 토큰의 ID를 통해 토큰들의 정보가 저장되어있는 productTokens 배열에서 유저의 토큰 정보를 가져와 tokens 배열에 담습니다.

ERC721Enumerable 컨트랙트는 유저가 여러 가지 토큰을 소유하는 경우 더욱 쉽게 토큰에 접근할 수 있게 해주기 위해 ERC721을 확장한 컨트랙트입니다. ERC721 컨트랙트의 _balances, _owners 처럼 ERC721Enumerable 컨트랙트 또한 여러 토큰들을 관리하기 쉽게 _ownedTokens 과 같은 private 매핑이나 배열을 사용하고 있습니다.

""" Mapping from owner to list of owned token IDs """
mapping(address => mapping(uint256 => uint256)) private _ownedTokens;

""" Mapping from token ID to index of the owner tokens list """
mapping(uint256 => uint256) private _ownedTokensIndex;

""" Array with all token ids, used for enumeration """
uint256[] private _allTokens;

""" Mapping from token id to position in the allTokens array """
mapping(uint256 => uint256) private _allTokensIndex;

컨트랙트 작성이 드디어 마무리되었습니다! 솔리디티를 배운 지 2주 만에 처음 구현해 본 프로젝트였는데요. 막히는 게 있을 때마다 주변에서 많은 개발자분들이 도움을 주셔서 빠르게 배울 수 있었습니다😙😙😙

다음 포스트부터는 지금까지 작성한 컨트랙트를 회사 내 다른 개발자 분들과 코드 리뷰를 하고, 제대로 작동하는지 테스트를 진행한 후 테스트넷에 배포하여 실제로 이 커피 토큰을 발급해주고 사용할 수 있는 모바일용 웹페이지를 제작해 보겠습니다!


Cafe NFT Project 시리즈 모아보기
수호 카페에 NFT가 생긴다면?
컨트랙트 개발 시작 - ERC721Enumerable
컨트랙트 코드 리뷰 - 가스비 절약하기
스마트 컨트랙트와 상호작용하기