11. Solidity(NFT)

정예찬·2022년 8월 15일
1

solidity

목록 보기
12/13

본 글은 freeCodeCamp.Org의 Youtube 영상 'Solidity, Blockchain, and Smart Contract Course – Beginner to Expert Python Tutorial'와 관련 코드인 SmartContract의 Github 코드를 기초로 작성되었다.

Youtube 영상 링크: https://www.youtube.com/watch?v=M576WGiDBdQ&t=10336s
Github 코드 링크: https://github.com/smartcontractkit/full-blockchain-solidity-course-py
오늘의 코드: https://github.com/PatrickAlphaC/nft-demo

이번 포스팅은 유튜브 영상 09:50:20~11:49:15에 해당하는 내용이다.

NFT에 대해 다루는 시간이다.
NFT란 Non-Fungible Token의 약자로서 '대체 불가능 토큰'이라는 뜻이다. 대체 불가능 토큰은 말 그대로 고유한 디지털 정보가 담겨 있어 다른 토큰과 대체가 불가능한, 유일무이한 토큰이다.
대체 불가능 토큰의 표준 형식은 ERC-721이다. ERC-1155는 반(semi) 대체 불가능 토큰 형식이다. 이번 시간에는 ERC-721 형식의 NFT를 다룰 예정이다.

NFT는 예술 작품 등으로 많이 활용된다. 그러나 이미지 파일과 같이 거대 용량의 파일을 네트워크 상에 올리려면 막대한 gas fee가 필요하다. 따라서 NFT는 다른 공간에 저장하면서도 그 NFT와 관련한 transaction을 chain에 올릴 수 있다. 그 방법은 다음과 같다.
1. IPFS에 NFT 이미지 저장
2. IPFS에 토큰 URI json file 추가
3. IPFS에 추가한 URI를 NFT URI에 추가

토큰 URI(Uniform Resource Identifier) json file은 NFT 이미지를 가리키기 위한 데이터이다. 이처럼 다른 데이터를 설명하기 위한 데이터를 metadata(메타데이터)라고 한다.
URI는 통합 자원 식별자로, 인터넷에 있는 자원을 나타내는 유일한 주소이다.

NFT는 고유한 token ID와 해당 NFT를 가리키기 위한 URI를 갖는다.

brownie는 nft를 활용하기 위한 mix를 제공한다. 명칭은 nft-mix고, 해당 mix를 사용하기 위해서는 터미널에 다음 코드를 입력해주면 된다. nft-mix를 본 포스팅에서는 활용하지 않는다.

brownie bake nft-mix

본격적으로 코드 설계를 진행해보자!

cd demos
mkdir nft-demo
cd nft-demo
brownie init

nft-demo 폴더를 만들어 brownie initialize하자.

ERC721 토큰을 만드는 절차도 ERC20 만들 때와 크게 다르지 않다. EIPS(Ethereum Improvement Proposals)에서 제공하는 문법을 긁어올 수도, openzeppelin에서 제공하는 표준을 사용할 수도 있다.
EIPS link: https://eips.ethereum.org/EIPS/eip-721
Openzeppelin link: https://docs.openzeppelin.com/contracts/4.x/erc721

ERC20 토큰을 만들었을 때처럼 openzepplin의 ERC721 표준을 github에서 따오자. 이를 위해 먼저 brownie-config.yaml을 채워주자.

dependencies:
  - OpenZeppelin/openzeppelin-contracts@3.4.0
  - smartcontractkit/chainlink-brownie-contracts@1.1.1
reports:
  exclude_contracts:
    - LinkToken
    - VRFCoordinatorMock
    - ERC721
    - EnumerableMap
    - Address
    - EnumerableSet
compiler:
  solc:
    remappings:
      - '@openzeppelin=OpenZeppelin/openzeppelin-contracts@3.4.0'
      - '@chainlink=smartcontractkit/chainlink-brownie-contracts@1.1.1'
dotenv: .env
wallets:
  from_key: ${PRIVATE_KEY}
networks:
  development:
    keyhash: '0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311'
    fee: 100000000000000000
  rinkeby:
    vrf_coordinator: '0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B'
    link_token: '0x01BE23585060835E02B77ef475b0Cc51aA1e0709'
    keyhash: '0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311'
    fee: 100000000000000000 # 0.1

이후 코드 작성할 때 알겠지만 Smart Lottery 때처럼 Randomnumber를 가져온다. 이를 위해 smartcontractkit의 chainlink-brownie-contracts를 dependencies에 추가해주고, vrf_coordinator, link_token, keyhash, fee 등을 작성해주었다.
reports는 처음 등장한 키워드이다. 이 키워드와 내용은 영상에서 나오지는 않는다. 넣어도, 넣지 않아도 되는 키워드이기 때문이다. reports에 exclude_contracts를 해주면 이에 해당하는 contracts가 data report에서 배제된다. 불필요한 내용까지 report되지 않게 reports 키워드를 추가해주고 있다.

다음으로 .env파일을 열고 다음 내용을 추가해주자.

.env 내용~~

다음으로 minting을 위한 이미지 3장을 저장해주자.

Minting: NFT 발급

img 폴더를 만들고 아래 링크의 png 파일 3장을 추가해주자.(사진이 너무 귀여워 넣지 않을 수 없었다...)
https://github.com/PatrickAlphaC/nft-mix/tree/main/img



이제 본격적으로 nft minting을 위한 contract를 작성해주자!

Contracts 폴더에 SimpleCollectible.sol을 만들고 아래 코드를 추가해주자.

// SPDX-License-Identifier: MIT
pragma solidity 0.6.6;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract SimpleCollectible is ERC721 {
    uint256 public tokenCounter;
    
    constructor () public ERC721 ("Dogie", "DOG"){
        tokenCounter = 0;
    }

    function createCollectible(string memory tokenURI) public returns (uint256){
        uint256 newTokenId = tokenCounter;
        _safeMint(msg.sender, newTokenId);
        _setTokenURI(newTokenId, tokenURI);
        tokenCounter = tokenCounter + 1;
        return newTokenId;
    }
}

openzeppelin에서 ERC721을 가져와 SimpleCollectible contract에 상속해주고 있다.
ERC721.sol의 내용은 아래 링크에서 확인 가능하다.
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/ERC721.sol

ERC721의 constructor에는 토큰의 내용과 Symbol을 추가해주어야 한다. 이를 각각 "Dogie"와 "DOG"로 설정하고 있다.
토큰의 개수를 세는 변수인 tokenCounter를 0으로 설정해주고 있다.

다음으로 토큰을 만드는 함수인, 즉 NFT 생성 함수인 createCollectible를 보자.
NFT를 만들기 위해서는 토큰의 주인을 설정해주고, 고유한 tokenId를 설정해주어야 한다. 이때 tokenId의 고유성은 우리의 작업 공간 내, 즉 우리가 현재 NFT를 찍어내고 있는 공장 내에서 보장된다는 뜻이지, 전세계에 있는 모든 NFT에 대해 갖는 고유성이 아니다. 따라서 처음 만들어내는 NFT의 tokenId를 0으로 하고, 새로운 NFT를 생성할 때마다 tokenId를 1씩 늘려 배정해준다.
다음으로 _safeMint 함수를 이용하여 minting을 하고 있다. _safeMint는 입력값으로 owner와 tokenId를 받는다. 이때 owner로는 이 함수를 호출한 사람인 msg.sender를 지정하고 있다.
tokenURI 또한 설정해주고 있다. 이전에 설명했듯 토큰의 정보를 담기 위해 tokenURI라는 metadata가 사용된다. 이는 tokenId와 tokenURI를 입력값으로 하는 _setTokenURI 함수에 의해 설정된다. ERC721에 대해 사용할 수 있는 함수는 아래 링크에서 확인할 수 있다.
https://docs.openzeppelin.com/contracts/3.x/api/token/erc721#ERC721-_setTokenURI-uint256-string-
token counter를 1 늘려주어 다음 함수 실행에 tokenId가 1이 늘어난 값으로 설정되게 한다.
이 함수에서 minting된 TokenId를 반환하며 함수가 마무리된다.

SimpleCollectible은 단일한 대상을 minting한다. 이번에는 3가지 대상(멍멍이) 중 하나를 무작위로 minting하는 AdvancedCollectible.sol을 작성해보자. 코드가 기니 끊어서 작성해보자.

// SPDX-License-Identifier: MIT
pragma solidity 0.6.6;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@chainlink/contracts/src/v0.6/VRFConsumerBase.sol";

contract AdvancedCollectible is ERC721, VRFConsumerBase {
    uint256 public tokenCounter;
    bytes32 public keyhash;
    uint256 public fee;
    enum Breed{PUG, SHIBA_INU, ST_BERNARD}
    mapping(uint256 => Breed) public tokenIdToBreed;
    mapping(bytes32 => address) public requestIdToSender;
    event requestedCollectible(bytes32 indexed requestId, address requester);
    event breedAssigned(uint256 indexed tokenId, Breed breed);

AdvancedCollectible 계약은 ERC721에 더하여 Randomnumber 추출을 위한 VRFConsumerBase를 상속 받고 있다.
Minting을 위해 tokenCounter를, randomnumber 추출을 위해 keyhash와 fee를 선언하고 있다.
PUG, SHIBA_INU, ST_BERNARD를 내용으로 Breed 열거형을 정의하고 있다.
다음으로 tokenId를 멍멍이의 종류인 Breed와 연결해주는 mapping을 정의하고 있다.
또한 requestId를 msg sender로 연결해주는 mapping을 정의하고 있다. 이 mapping은 requestId(Randomness 요청 Id)를 발생시킨 자를 msg sender로 별도로 지정하기 위한 용도이다. randomnumber를 호출하는 본 계약에서는 randomness 검증자인 vrfcoordinator가 최종적으로 msg sender가 된다. msg sender를 NFT owner로 설정하기에 vrfcoordator의 randomness 검증 이후 msg sender를 별도로 지정해주지 않으면 vfrcoordinator가 nft의 주인이 된다. 따라서 이를 방지하고 본 주인에게 nft를 발급하기 위해 requestId와 msg sender를 잇는 mapping을 정의해주고 있다.
Collectible(NFT) 생성까지의 로그, tokenId와 Breed 연결까지의 로그 확인을 위한 event를 각각 정의해주고 있다.

constructor(address _vrfCoordinator, address _linkToken, bytes32 _keyhash, uint256 _fee) public 
    VRFConsumerBase(_vrfCoordinator, _linkToken)
    ERC721("Dogie", "DOG")
    {
        tokenCounter = 0;
        keyhash = _keyhash;
        fee = _fee;
    }

    function createCollectible() public returns (bytes32){
        bytes32 requestId = requestRandomness(keyhash, fee);
        requestIdToSender[requestId] = msg.sender;
        emit requestedCollectible(requestId, msg.sender);
    }

constructor로는 randomness 요청을 위한 네 요소를 받고, VRFConsumerBase는 _vrfCoordinator와 _linkToken, ERC721는 token name과 symbol을 constructor의 입력값으로 받고 있다. 또한 tokenCounter를 0으로, keyhash와 fee를 선언하고 각각에 입력 받은 _keyhash와 _fee를 저장해주고 있다.

createCollectible 함수를 보자.
먼저 Randomness를 요청하고 있다.
뒤이어 randomness의 requestId를 키로, msg sender를 값으로 하는 mapping을 설정해주고 있다.
requestedCollectible event를 emit하며 함수가 종료된다.

function fulfillRandomness(bytes32 requestId, uint256 randomNumber) internal override {
        Breed breed = Breed(randomNumber % 3);
        uint256 newTokenId = tokenCounter;
        tokenIdToBreed[newTokenId] = breed;
        emit breedAssigned(newTokenId, breed);
        address owner = requestIdToSender[requestId];
        _safeMint(owner, newTokenId);
        tokenCounter = tokenCounter + 1;
    }

    function setTokenURI(uint256 tokenId, string memory _tokenURI) public {
        // pug, shiba inu, st bernard
        require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not owner or not approved");
        _setTokenURI(tokenId, _tokenURI);
    }
}

다음으로 fulfillRandomness 함수에서 3가지 멍멍이 중 하나를 랜덤으로 선정하여 NFT를 발급하는 과정을 보자.
Breed(0), Breed(1), Breed(2)에 각각 해당하는 멍멍이가 지정되어 있으므로 randomNumber를 3으로 나눈 값을 Breed의 index로 지정하여 이를 Breed형 변수 breed에 저장하고 있다.
tokenId를 tokenCounter로 설정해주고 있다.
tokenId를 breed에 mapping하고 있다.
breed 지정 및 연결이 종료되었으므로 breedAssigned event를 emit해준다.
owner를 vrfcoordinator가 아니라 randomness 요청자로 설정해주고,
minting을 진행하고 있다.
다음 minting을 대비하여 tokenCounter에 1을 더하며 함수가 종료된다.

setTokenURI 함수에서는 이 함수 호출자가 owner인 경우에만 tokenURI를 설정할 수 있도록 하고 있다.

다음으로 mocking을 위한 contract 추가를 해주자.
contracts 폴더에 test 폴더를 만들고, LinkToken.sol과 VRFCoordinatorMock.sol를 추가하여 아래 링크의 코드를 넣어주자. 기존에 이미 다룬 내용이므로 추가적인 설명은 하지 않겠다.
https://github.com/PatrickAlphaC/nft-demo/tree/main/contracts/test

이번에는 uri 설정을 위해 metadata 형식을 지정해주자. nft-demo에 metadata 폴더를 만들고 sample_metadata.py를 추가해준 후 아래 코드를 붙여넣자.

metadata_template = {
    "name": "",
    "description": "",
    "image": "",
    "attributes": [{"trait_type": "cuteness", "value": 100}],
}

위 코드의 내용을 채움으로써 uri가 완성된다.

.env 파일 추가 및 내용 입력도 진행하자. 항상 그랬듯 아래 코드를 넣어주자.

export PRIVATE_KEY = 0xb5e857091a491a306f8a13ac49ed53655f51c2d780db0e9faf0305878b3f8fe5
export WEB3_INFURA_PROJECT_ID=a9c5bc0ec75c4a83a3e48086df81acfe
export ETHERSCAN_TOKEN=BEF1HMKFS34JWYP6RQACG8S3MX6G4FN4N4

그러나 이번 시간에는 이 3줄로 .env가 끝나지 않는다.
이번 시간 NFT 이미지 저장을 위해 ipfs를 사용한다. 그러나 pinata를 사용하는 방법에 대해서도 영상에서 설명을 한다. pinata를 사용하기 위한 코드를 .env에 몇 줄 넣어줄 예정이다. 아래 사이트에 들어가 회원가입부터 진행하자.
https://www.pinata.cloud/
다음으로 아래 링크에 들어가 '+New Key'를 눌러 API key를 생성하자.(Key를 본인만 쓰는 경우면 굳이 Max Uses를 제한할 필요 없다.)
https://app.pinata.cloud/keys

.env에 추출한 API key와 API secret key를 다음 형식으로 저장해주자. 이는 추후 활용 예정이다.

export PINATA_API_KEY=9a29f46470df2ecf5272
export PINATA_API_SECRET=
91e56b7906e90214546dab334f4c4011584af5fe5257910c590a3f6625b8c715

이제 scripts 폴더를 채울 준비가 모두 끝났다!

scripts 폴더에 __init__.py를 추가해주자.

다음으로 helpful_scripts.py를 만들고 채워주자.

from brownie import accounts, network, config, LinkToken, VRFCoordinatorMock, Contract
from web3 import Web3

LOCAL_BLOCKCHAIN_ENVIRONMENTS = ["hardhat", "development", "ganache", "mainnet-fork"]
OPENSEA_URL = "https://testnets.opensea.io/assets/{}/{}"
BREED_MAPPING = {0: "PUG", 1: "SHIBA_INU", 2: "ST_BERNARD"}


def get_breed(breed_number):
    return BREED_MAPPING[breed_number]


def get_account(index=None, id=None):
    if index:
        return accounts[index]
    if network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        return accounts[0]
    if id:
        return accounts.load(id)
    return accounts.add(config["wallets"]["from_key"])


contract_to_mock = {"link_token": LinkToken, "vrf_coordinator": VRFCoordinatorMock}


def get_contract(contract_name):
    contract_type = contract_to_mock[contract_name]
    if network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        if len(contract_type) <= 0:
            deploy_mocks()
        contract = contract_type[-1]
    else:
        contract_address = config["networks"][network.show_active()][contract_name]
        contract = Contract.from_abi(
            contract_type._name, contract_address, contract_type.abi
        )
    return contract


def deploy_mocks():
    print(f"The active network is {network.show_active()}")
    print("Deploying mocks...")
    account = get_account()
    print("Deploying Mock LinkToken...")
    link_token = LinkToken.deploy({"from": account})
    print(f"Link Token deployed to {link_token.address}")
    print("Deploying Mock VRF Coordinator...")
    vrf_coordinator = VRFCoordinatorMock.deploy(link_token.address, {"from": account})
    print(f"VRFCoordinator deployed to {vrf_coordinator.address}")
    print("All done!")


def fund_with_link(
    contract_address, account=None, link_token=None, amount=Web3.toWei(0.3, "ether")
):
    account = account if account else get_account()
    link_token = link_token if link_token else get_contract("link_token")
    funding_tx = link_token.transfer(contract_address, amount, {"from": account})
    funding_tx.wait(1)
    print(f"Funded {contract_address}")
    return funding_tx

기존에 대부분 다루었던 내용이기에 새로 추가된 내용만 살펴보겠다.
OPENSEA_URL을 추가해주고 있는데, 우리가 minting을 하면 opensea에서 mint된 nft를 볼 수 있다. 이때 opensea의 nft url을 출력해주기 위해 OPENSEA_URL을 정의하였다.
다음으로 0, 1, 2에 해당하는 각 멍멍이를 각 숫자에 대응시키는 BREED_MAPPING이 정의되었다.
breed number를 받아 해당 숫자의 멍멍이를 반환하는 get_breed 함수가 정의되었다.(이하 설명 생략)

deploy_mocks.py를 만들고 아래 코드를 넣어주자.

from scripts.helpful_scripts import deploy_mocks


def main():
    deploy_mocks()

upload_to_pinata.py를 작성하여 NFT image를 pinata에 업로드하는 법을 알아보자.

import os
from pathlib import Path
import requests

PINATA_BASE_URL = "https://api.pinata.cloud/"
endpoint = "pinning/pinFileToIPFS"
# Change this filepath
filepath = "./img/pug.png"
filename = filepath.split("/")[-1:][0]
headers = {
    "pinata_api_key": os.getenv("PINATA_API_KEY"),
    "pinata_secret_api_key": os.getenv("PINATA_API_SECRET"),
}


def main():
    with Path(filepath).open("rb") as fp:
        image_binary = fp.read()
        response = requests.post(
            PINATA_BASE_URL + endpoint,
            files={"file": (filename, image_binary)},
            headers=headers,
        )
        print(response.json())

pathlib에서 Path를 import해주고 있다. Path는 파일 경로를 '/'로 접근하게 해주어 직관적이고 간편하게 파일에 접근하게 해준다.
request도 import하고 있다. requests는 HTTP 사용을 위한 라이브러리로, 본 스크립트에서는 requests.post를 사용하고 있다.
pinata에 파일 올리는 주소는 아래 링크에서 확인 가능하다.
https://docs.pinata.cloud/pinata-api/pinning/pin-file-or-directory
pinata에 파일 올리는 주소를 Base url과 endpoint로 양분하여 저장하고 있다.
filepath로 본 디렉터리 안 img폴더 안의 pug.png를 가리키고 있다.
filename에 "pug.png"를 저장하기 위해 split 함수를 이용하고 있다.
문자열.split("문자")를 하게 되면 문자를 기준으로 문자열이 나누어진 배열이 생성된다. [-1:]은 뒤에서부터 문자열 끝까지 읽고 그 내용을 저장한 배열이고(문자열이"/"에 의해 나누어졌으므로 pug.png 앞의 문자열은 읽지 않게 된다.) [0]은 그 배열의 첫 번째 요소를 지칭한 문자이다. 즉, filepath.split("/")[-1:]은 "pug.png"를 배열 요소로 하는 길이 1짜리 배열이고, filepath.split("/")[-1:][0]은 "pug.png"이다. filepath.split("/")[-1:][0] 대신 filepath.split("/")[-1]을 써도 된다. 둘은 동일한 문자열을 나타낸다.

main함수를 보자. Path(파일 경로)를 입력하면 경로에 있는 파일이 반환된다.
이 파일을 바이너리 모드로 읽어서("rb"는 바이너리 모드로 읽는다는 뜻이다.) image_binary에 저장하고 있다.
파일 올리는 주소에 파일(파일 이름과 바이너리 형식 이미지)과 headers(api_key, secret_api_key)를 담아 post 함수로 데이터를 전송하고 있다.
post 함수는 첫 번째 입력값으로 url을 받고, 추가적으로 data, files, headers, cookies 등을 입력값에 추가해줄 수 있다.
파일을 전달해주는 방식은 코드에서 볼 수 있듯이 files={"file": (파일명, 파일)}이다.
request함수는 response를 반환하는데, 이를 json 형식으로 출력해주고 있다.

이제 본격적으로 NFT를 minting해보자.
먼저 scripts 내에 simple_collectible이라는 폴더를 만들고 그 안에 deploy_and_create.py를 추가한 후 아래 코드로 채워주자.


from scripts.helpful_scripts import get_account, OPENSEA_URL
from brownie import SimpleCollectible

sample_token_uri = "https://ipfs.io/ipfs/Qmd9MCGtdVz2miNumBHDbvj8bigSgTwnr4SbyH6DNnpWdt?filename=0-PUG.json"


def deploy_and_create():
    account = get_account()
    simple_collectible = SimpleCollectible.deploy({"from": account})
    tx = simple_collectible.createCollectible(sample_token_uri, {"from": account})
    tx.wait(1)
    print(
        f"Awesome, you can view your NFT at {OPENSEA_URL.format(simple_collectible.address, simple_collectible.tokenCounter() - 1)}"
    )
    print("Please wait up to 20 minutes, and hit the refresh metadata button. ")
    return simple_collectible


def main():
    deploy_and_create()

이 스크립트는 정해진 한 장의 이미지를 NFT로 minting한다.
sample_token_uri에 들어가보면 아래 코드가 발견된다.

{
    "name": "PUG",
    "description": "An adorable PUG pup!",
    "image": "https://ipfs.io/ipfs/QmSsYRx3LpDAb1GZQm7zZ1AuHZjfbPkD6J7s9r41xu1mf8?filename=pug.png",
    "attributes": [
        {
            "trait_type": "cuteness",
            "value": 100
        }
    ]
}

즉, 이 uri는 PUG 이미지에 대한 json 형식임을 알 수 있다.
get_account로 주소를 하나 가져와서
SimpleCollectible을 deploy하여 simple_collectible에 저장한다.
simple_collectible의 createCollectible에 sample_token_uri를 전달하여 transaction을 발생시킨다.
발급된 NFT를 볼 수 있는 Opensea주소를 전달하고 있다. OPENSEA_URL을 정의할 때 중괄호({}) 두개를 비워놨다. 이를 simple_collectible.address, simple_collectible.tokenCounter()-1로 각각 채워주고 있다. NFT가 발급된 후 다음 NFT 발급을 위해 tokenCounter에 1이 더해진다. 따라서 최근에 발급된 NFT의 tokenId를 전달해주기 위해 tokenCounter에서 1을 빼고 있다.
앞에 중괄호를 두고 '내용.format(값)'을 입력하게 되면 내용에 있는 중괄호 대신 값이 들어간다. 중괄호와 값은 2개 이상일 수 있으며 값은 쉼표로 구분한다.
simple_collectible을 반환하며 함수가 종료된다.

main 함수에서는 deploy_and_create()을 실행하고 있다.

작성이 끝났다! 모든 contracts를 컴파일해주고 rinkeby network로 방금 작성한 script를 deploy해보자.

Opensea 링크에 들어가 조금 기다렸다가 새로고침을 해보자. 발급된 NFT를 확인할 수 있다.

사진

이제 3가지 사진 중 랜덤으로 골라진 하나의 이미지로 NFT를 minting해보자. simple_collectible에서는 json 형식의 sample uri를 사용했지만 이번에는 ipfs에 올린 이미지 uri를 사용하겠다. 아래 링크에서 ipfs command line을 살펴보자.
https://docs.ipfs.tech/install/command-line/#system-requirements
자신의 os에 맞게 절차대로 터미널을 이용하여 설치해주면 된다.

minting에는 총 4가지 스크립트가 활용된다. 이 4개의 스크립트를 담을 폴더인 advanced_collectible을 scripts 내에 생성해주자.

먼저 AdvancedCollectible을 deploy하고 NFT 토큰을 생성하는 deploy_and_create.py 스크립트부터 작성해주자.

from scripts.helpful_scripts import (
    get_account,
    OPENSEA_URL,
    get_contract,
    fund_with_link,
)
from brownie import AdvancedCollectible, network, config


def deploy_and_create():
    account = get_account()
    advanced_collectible = AdvancedCollectible.deploy(
        get_contract("vrf_coordinator"),
        get_contract("link_token"),
        config["networks"][network.show_active()]["keyhash"],
        config["networks"][network.show_active()]["fee"],
        {"from": account},
    )
    fund_with_link(advanced_collectible.address)
    creating_tx = advanced_collectible.createCollectible({"from": account})
    creating_tx.wait(1)
    print("New token has been created!")
    return advanced_collectible, creating_tx


def main():
    deploy_and_create()

deploy_and_create 함수를 보자.
advanced_collectible에 AdvancedCollectible을 deploy하여 저장해주고 있다.
이때 필요한 인자 4가지를 모두 전달해주고 있는데, local network의 keyhash와 fee가 전달된다면 mock이, rinkeby network와 같은 testnet의 keyhash와 fee가 전달된다면 testnet 상에서의 함수 실행이 진행된다.
advanced_collectible에 randomnumber 추출을 위한 link를 전달하고 advanced_collectible의 createCollectible을 실행하면 새로운 NFT 토큰이 생성된다.

다음으로 deploy된 AdvancedCollectible을 활용하여 token을 생성하는 스크립트인 create_collectible.py를 작성하자.

from brownie import AdvancedCollectible
from scripts.helpful_scripts import fund_with_link, get_account
from web3 import Web3


def main():
    account = get_account()
    advanced_collectible = AdvancedCollectible[-1]
    fund_with_link(advanced_collectible.address, amount=Web3.toWei(0.1, "ether"))
    creation_transaction = advanced_collectible.createCollectible({"from": account})
    creation_transaction.wait(1)
    print("Collectible created!")

최근에 deploy된 AdvancedCollectible에 link token을 전달하고 있다.
deploy된 contract에 대해 transaction을 생성하면 NFT 토큰이 발급된다.

metadata를 생성하는 create_metadata.py를 작성하자.
rinkeby network 상에서 이용될 metadata를 생성할 것이기에 metadata 폴더 안에 rinkeby 폴더를 추가해주자.
script가 조금 긴 편이라 끊어서 보겠다.

from brownie import AdvancedCollectible, network
from scripts.helpful_scripts import get_breed
from metadata.sample_metadata import metadata_template
from pathlib import Path
import requests
import json
import os

breed_to_image_uri = {
    "PUG": "https://ipfs.io/ipfs/QmSsYRx3LpDAb1GZQm7zZ1AuHZjfbPkD6J7s9r41xu1mf8?filename=pug.png",
    "SHIBA_INU": "https://ipfs.io/ipfs/QmYx6GsYAKnNzZ9A6NvEKV9nf1VaDzJrqDR23Y8YSkebLU?filename=shiba-inu.png",
    "ST_BERNARD": "https://ipfs.io/ipfs/QmUPjADFGEKmfohdTaNcWhp7VGk26h5jXDA7v3VtTnTLcW?filename=st-bernard.png",
}

metadata 생성을 위해 우리가 사전에 작성한 metadata_template을 import하고 있다.
다음으로 각각의 breed를 image에 연결하는 breed_to_image_uri를 선언하고 있다. 사실 이 uri는 이후에 진행될 코드에 의해 생성된다. 그러나 한 번 생성을 한 후에는 이렇게 지정해놓고 쓰는 게 편하다. 또한 우리는 모두 동일한 이미지에 대해 ipfs 주소를 만들고, 이는 같은 ipfs 주소 생성으로 귀결된다. 이미지를 담는 ipfs주소를 생성하는 방법은 곧 설명할 예정이니 코드만 이렇게 작성해두자.

def main():
    advanced_collectible = AdvancedCollectible[-1]
    number_of_advanced_collectibles = advanced_collectible.tokenCounter()
    print(f"You have created {number_of_advanced_collectibles} collectibles!")
    for token_id in range(number_of_advanced_collectibles):
        breed = get_breed(advanced_collectible.tokenIdToBreed(token_id))
        metadata_file_name = (
            f"./metadata/{network.show_active()}/{token_id}-{breed}.json"
        )
        collectible_metadata = metadata_template
        if Path(metadata_file_name).exists():
            print(f"{metadata_file_name} already exists! Delete it to overwrite")
        else:
            print(f"Creating Metadata file: {metadata_file_name}")
            collectible_metadata["name"] = breed
            collectible_metadata["description"] = f"An adorable {breed} pup!"
            image_path = "./img/" + breed.lower().replace("_", "-") + ".png"

            image_uri = None
            if os.getenv("UPLOAD_IPFS") == "true":
                image_uri = upload_to_ipfs(image_path)
            image_uri = image_uri if image_uri else breed_to_image_uri[breed]

            collectible_metadata["image"] = image_uri
            with open(metadata_file_name, "w") as file:
                json.dump(collectible_metadata, file)
            if os.getenv("UPLOAD_IPFS") == "true":
                upload_to_ipfs(metadata_file_name)

최근 deploy된 AdvancedCollectible을 advanced_collectible에 저장하고 있다.
advanced_collectible의 tokenCounter로 생성된 토큰의 개수를 불러와 이를 number_of_advanced_collectibles에 저장하고 있다.
for in 구문이 등장하였다. 'for A in B'에서 B에 해당하는 A에 대해 뒤이은 문장이 실행된다. range(숫자)는 0부터 그 숫자까지의 모든 정수를 반환한다. 따라서 위 코드에서는 0부터 number_of_advanced_collectibles에 해당하는 모든 token_id에 대해 for문 이하의 문장들이 실행된다.
for문 이하의 문장들을 보자.
'breed = get_breed(advanced_collectible.tokenIdToBreed(token_id))'을 보자. 복잡하게 생겼다. advanced_collectible의 tokenIdToBreed를 token_id를 입력값으로 호출하면 randomnumber에 의해 배정된 숫자 형태의 breed가 반환된다. getbreed에 숫자 형태의 breed를 입력하면 문자 형태의 breed("PUG" 등)를 반환한다. 따라서 최종적으로 breed에는 문자 형태의 breed가 저장된다.
metadata_file_name에는 metadata 파일의 경로와 이름을 저장해주고 있다.
collectible_metadata에 내용을 추가하기 위해 metadata_template을 collectible_metadata에 저장해준다.
metadata_file_name이 이미 존재하면 metadata 파일을 만들 필요가 없기에 만들지 않는다.
metadata_file_name이 존재하지 않는 경우 metadata파일을 만들어준다. metadata 파일을 채워보자.
먼저 collectible_metadata의 "name"에 breed를 저장해준다.
collectible_metadata의 "description"에 'An adorable {breed} pup!'이 저장된다. 이때 {breed}는 PUG, SHIBA_INU, ST_BERNARD 중 하나이다.
image_path에는 img폴더 안에 있는 해당 멍멍이의 png파일이 지정된다.
이를 위해 lower함수와 replace함수를 사용하고 있다. lower함수는 대문자를 소문자로 바꾸어주는 함수이고, replace("문자A", "문자B")는 문자A를 문자B로 바꾸어준다.

이제 ipfs에 image를 업로드하여 image uri를 만드는 법과 ipfs에 metadata를 업로드하여 metadata uri를 만드는 법을 설명하겠다. 그러나 앞서 이야기했듯 실행하는 방법만 알면 되지 굳이 실행할 필요는 없는 과정이다. 따라서 오늘 다루는 사진에 대해서는 실행이 되지 않게 장치를 해두자.
.env 파일을 열고 다음 코드를 추가해주자. 이를 통해 정상적으로 작성된 코드가 실행만 되지 않을 것이다.

export UPLOAD_IPFS=false

'image_uri = None'으로 돌아와서 코드를 살펴보자. image_uri는 일단 비웠다.
.env의 UPLOAD_IPFS가 true이면 뒤이은 코드를 실행한다.
뒤이은 코드는 이미지를 ipfs에 올리고 이를 image_uri에 저장한다.
ipfs에 올리는 과정이 진행되었으면 그때의 image_uri를 image_uri로 하고, 그 과정이 진행되지 않았으면 breed를 이용한 mapping으로 해당 breed에 대응하는 image_uri를 image_uri에 저장한다.
collectible_metadata의 "image"에 image_uri를 저장한다.
metadata_file_name을 열고 우리가 지금까지 내용을 채운 collectible_metadata로 dump한다.
.env의 UPLOAD_IPFS가 true이면, 즉 이미지 주소를 ipfs에 새롭게 생성하였으면 metadata_file_name도 ipfs에 업로드한다.

def upload_to_ipfs(filepath):
    with Path(filepath).open("rb") as fp:
        image_binary = fp.read()
        ipfs_url = "http://127.0.0.1:5001"
        endpoint = "/api/v0/add"
        response = requests.post(ipfs_url + endpoint, files={"file": image_binary})
        ipfs_hash = response.json()["Hash"]
        # "./img/0-PUG.png" -> "0-PUG.png"
        filename = filepath.split("/")[-1:][0]
        image_uri = f"https://ipfs.io/ipfs/{ipfs_hash}?filename={filename}"
        print(image_uri)
        return image_uri

upload_to_ipfs 함수를 구성하고 스크립트를 마무리하자.
파일 경로를 받아 파일을 바이너리모드로 연다.
ipfs_url은 추후 설명하기로 하자.
endpoint는 ipfs documentation에서 찾을 수 있다. 아래 링크를 확인해보자.
https://docs.ipfs.tech/reference/kubo/rpc/#api-v0-add
ipfs_url에 endpoint를 합친 대상에 읽은 바이너리 이미지 파일을 post하고 있다.
post의 반환값인 response는 아래 사진의 내용을 가진다.(이는 ipfs documentation에서 확인할 수 있다.)

response를 사진처럼 json형식으로 변환한 후 "Hash"에 mapping되어 있는 문자열을 ipfs_hash에 저장하고 있다.
filename에 필요한 파일명을 저장하고 있다.
image_uri에 앞서 저장한 ipfs_hash와 filename을 포함한 uri를 저장한다.
image_uri을 출력하고 반환하며 함수가 종료된다.

ipfs와 연결을 하기 위해서는 다음과 같은 절차가 필요하다. 터미널을 하나 더 열고 다음 코드를 입력해주자.

ipfs init
ipfs daemon

ipfs daemon을 실행했을 때 설명으로 WebUI: http://127.0.0.1:5001/webui가 나온다.
앞서 살펴본 ipfs_url은 이로부터 알 수 있다.

create_metadata.py를 실행할 때 ipfs로의 연결이 필요하므로 ipfs daemon을 켜놓고 실행하도록 하자.

네 번째 script인 set_tokenuri.py를 작성해보자.

from brownie import network, AdvancedCollectible
from scripts.helpful_scripts import OPENSEA_URL, get_breed, get_account

dog_metadata_dic = {
    "PUG": "https://ipfs.io/ipfs/Qmd9MCGtdVz2miNumBHDbvj8bigSgTwnr4SbyH6DNnpWdt?filename=0-PUG.json",
    "SHIBA_INU": "https://ipfs.io/ipfs/QmdryoExpgEQQQgJPoruwGJyZmz6SqV4FRTX1i73CT3iXn?filename=1-SHIBA_INU.json",
    "ST_BERNARD": "https://ipfs.io/ipfs/QmbBnUjyHHN7Ytq9xDsYF9sucZdDJLRkWz7vnZfrjMXMxs?filename=2-ST_BERNARD.json",
}

def main():
    print(f"Working on {network.show_active()}")
    advanced_collectible = AdvancedCollectible[-1]
    number_of_collectibles = advanced_collectible.tokenCounter()
    print(f"You have {number_of_collectibles} tokenIds")
    for token_id in range(number_of_collectibles):
        breed = get_breed(advanced_collectible.tokenIdToBreed(token_id))
        if not advanced_collectible.tokenURI(token_id).startswith("https://"):
            print(f"Setting tokenURI of {token_id}")
            set_tokenURI(token_id, advanced_collectible, dog_metadata_dic[breed])

def set_tokenURI(token_id, nft_contract, tokenURI):
    account = get_account()
    tx = nft_contract.setTokenURI(token_id, tokenURI, {"from": account})
    tx.wait(1)
    print(
        f"Awesome! You can view your NFT at {OPENSEA_URL.format(nft_contract.address, token_id)}"
    )
    print("Please wait up to 20 minutes, and hit the refresh metadata button")

dog_metadata_dic에 각 breed에 해당하는 json파일을 매치해주고 있다. 원래는 각 json파일을 직접 ipfs에 올리고 올린 주소를 따오는 게 맞다. 그러나 편의상 chainlink-mix에서 제공하는 주소를 가져왔다.

set_tokenURI부터 보자. token_id, nft_contract, tokenURI를 받아 token_id와 tokenURI를 입력값으로 nft_contract의 setTokenURI 함수를 실행하고 있다.
이 결과로 Opensea에 토큰이 등록된다.
등록된 토큰은 몇 분 기다리면 확인이 가능하다.

main함수를 보자.
최근 deploy된 AdvancedCollectible을 불러와서 collectible이 몇 개 생성되었는지 확인한다.(token이 하나 생성되면 tokenCounter는 1, 두 개 생성되면 2, ..., n개 생성되면 n이 된다.)
breed에 문자 형태의 breed가 저장된다.
'if not advanced_collectible.tokenURI(token_id).startswith("https://"):' 이 문장을 보자. 'token_id에 해당하는 tokenURI가 'https://'로 시작하지 않는다면'이라는 의미이다. 이는 tokenURI가 아직 생성되지 않았다는 의미와 같다. 따라서 이 경우에만 뒤이어 나오는 print문과 set_tokenURI를 실행한다.
문자열A.startswith(문자열B) 함수는 문자열A가 문자열B로 시작되는지 확인하고, 만약 그렇다면 True, 아니라면 False를 반환한다.



마지막으로 tests 작성하고 끝내자.

tests폴더에 unit과 integration 폴더를 만들어주자.
unit 폴더부터 채워주자.
먼저 test_simple_collectible.py를 만들고 아래 코드로 채워주자.

from scripts.helpful_scripts import get_account, LOCAL_BLOCKCHAIN_ENVIRONMENTS
from brownie import network
import pytest
from scripts.simple_collectible.deploy_and_create import deploy_and_create

def test_can_create_simple_collectible():
    if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip()
    simple_collectible = deploy_and_create()
    assert simple_collectible.ownerOf(0) == get_account()

simple_collectible을 생성할 수 있는지 확인하는 테스트이다.
unit 테스트이므로 Local 체인 대상으로 실행한다.
ownerOf(tokenId)는 tokenId의 NFT 주인을 반환한다. get_account()는 accounts[0]를 반환하고 msg.sender, 즉 deploy_and_create를 실행하여 NFT 주인이 된 자도 accounts[0]이므로 'simple_collectible.ownerOf(0) == get_account()'가 참이면 simple_collectible이 제대로 생성되었다고 볼 수 있다.

다음으로 test_advanced_collectible.py를 만들고 아래 코드로 채우자.

from brownie import network, AdvancedCollectible
import pytest
from scripts.helpful_scripts import (
    LOCAL_BLOCKCHAIN_ENVIRONMENTS,
    get_contract,
    get_account,
    get_breed,
)
from scripts.advanced_collectible.deploy_and_create import deploy_and_create

def test_can_create_advanced_collectible():
    # Arrange
    if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip("Only for local testing")
    # Act
    advanced_collectible, creation_transaction = deploy_and_create()
    requestId = creation_transaction.events["requestedCollectible"]["requestId"]
    random_number = 777
    get_contract("vrf_coordinator").callBackWithRandomness(
        requestId, random_number, advanced_collectible.address, {"from": get_account()}
    )
    # Assert
    assert advanced_collectible.tokenCounter() == 1
    assert advanced_collectible.tokenIdToBreed(0) == random_number % 3

def test_get_breed():
    # Arrange / Act
    breed = get_breed(0)
    # Assert
    assert breed == "PUG"

test_can_create_advanced_collectible부터 보자. advanced collectible을 deploy하고 NFT 토큰을 발급하여 생성된 각각을 advanced_collectible과 creation_transaction에 저장해주고 있다.
requestedCollectible로부터 requestId를 가져오고, random_number로는 777을 지정해주었다.
advanced_collectible에 대해 777을 randomnumber로 callBackWithRandomness를 실행했다.
토큰이 생성되었는지를 tokenCounter가 1로 증가했는지로 확인하고 있다.(NFT 생성 확인)
tokenId가 0인 NFT의 Breed가 random_number을 3으로 나눈 나머지로 설정되었는지 확인하고 있다.(Breed가 규칙에 맞게 정해졌는지 확인)

test_get_breed를 보자.
get breed 함수에 0을 넣어 "PUG"가 제대로 반환되는지 확인하고 있다.

tests에 integration 폴더를 만들고 test_advanced_collectible.py를 추가해주자.

from brownie import network, AdvancedCollectible
import time
import pytest
from scripts.helpful_scripts import (
    LOCAL_BLOCKCHAIN_ENVIRONMENTS,
    get_contract,
    get_account,
)
from scripts.advanced_collectible.deploy_and_create import deploy_and_create

def test_can_create_advanced_collectible_integration():
    # Arrange
    if network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip("Only for integration testing")
    # Act
    advanced_collectible, creation_transaction = deploy_and_create()
    time.sleep(60)
    # Assert
    assert advanced_collectible.tokenCounter() == 1

create_advanced_collectible_integration에 대한 integration test도 unit test와 거의 동일하다.
다만 test network에서 수행하기 위해 Local network에 대해 skip을 하고 있다.
또한 time.sleep(60)을 이용하여 네트워크가 transaciton을 confirm할 충분할 시간을 주고 있다. AdvancedCollectible을 deploy하고 NFT를 create한 후 tokenCounter의 값이 0에서 1로 1 증가했는지를 확인하고 있다.

이번 포스팅은 사진을 별로 넣지 않았는데도 꽤 길었다. 군대에서 포스팅하려니 시간이 부족하다. 그러나 열정이 있으면 어떠한 상황과 여건에도 못해낼 일이 없다. 그 일념 하나로 해내고 있다. 불가능해보이는 일도 막상 시작하면 길이 보이고, 결국 해낼 수 있다는 사실을 포스팅하면서 많이 느낀다. 3일 후에 나간다. 나가기 전에 포스팅 한 편 더 하고 마지막 포스팅은 사회에서 하겠다. 남은 기간도 힘내보자!!!

MIT License

Copyright (c) 2021 SmartContract

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
profile
BlockChain Researcher

0개의 댓글