게임상에서 NFT 민팅해보기!!

Ryan Ham·2024년 9월 14일
2

Unreal Engine

목록 보기
27/27

NFT Minting

오늘은 게임내에서 NFT를 민팅하는 마법같은 일을 해볼 것이다. NFT의 가격 거품이 많이 빠진 지금이지만, 필자는 게임 NFT는 실제 가치를 기반으로 하는 아이템을 베이스로 하고 있다는 점과 나중에 게임과 게임을 이어주는 거대한 메타버스에서 빈번히 쓰일 것이라는 믿음으로 아주 낙관적으로 생각하고 있다. 이 때문인지 이번 프로젝트를 하면서 정말 즐거웠다.

Bitcoin과 Ethereum의 각기 다른 탄생 배경

NFT는 처음 ERC-721기반으로 세상에 등장하였다. ERC는 이더리움 기반인데 왜 비트코인을 기반으로 NFT를 만들지 않았을까? 그 이유를 알기 위해서는 BitcoinEthereum의 각기 다른 탄생 배경을 알아보아야 한다.

Bitcoin의 탄생 철학은 2008년 리만 브라더스 사태로 거슬러 올라간다. 이 사태를 짧막하게 요약하자면, 여러 미국 은행들이 국내 집 값 상승에 배팅하고자 자신들이 보유할 안전 자산에 정크 채권들이 마구 섞인 CDO를 넣기 시작하였고, 채권 심사 기관들은 뒷돈을 받고 이런 쓰레기 채권들에게 최고 안전 등급인 AAA급 기준을 부여하였다. 이들은 미국의 기준 금리가 올라가는 신호탄에 맞춰 도미노로 붕괴되기 시작했고, 이 때 미국 연준의 의장이였던 벤 버냉키라는 사람이 달러를 마음껏 찍어내는 방법인 듣도 보지 못한 양적완화(QE)를 도입하였다.

이 사태를 이해한다면 왜 비트코인의 최종 수량이 정해져 있는지 알 수 있을 것이다. 이에 반해 이더리움은 그 철학이 Programmable Contract에 있다. 즉, 통화보다는 계약 자체를 탈중앙화하는 것에 초점이 정해져 있다.

많이 들어봤을 NFT도 이더리움의 등장으로 만들어진 토큰인데, 이번에 직접 NFT Contract를 Sepolia Network 상에서 배포해보고, Unreal 상에서 직접 NFT 민팅을 할 수 있는 기능을 만들어보았다. 이번 프로젝트의 플로우는 다음과 같다.

Overall Workflow

  1. NFT 에셋(Metadata 포함)IPFS 환경 설정
  2. NFT Minting 하는 Smart Contract 작성 및 Sepolia Testnet에 배포
  3. Unreal에서 Smart Contract의 Minting 함수 호출

1. NFT 에셋 준비

NFT의 정보는 어디에 저장될까?

NFT의 이미지 정보가 어떻게 블록체인에 올라가는지 처음 접하는 사람은 충격을 받을 수 밖에 없다 . 그 이유는 NFT의 창조 철학 자체가 디지털 에셋 대한 소유권을 주장하는 것이지만, 정작 에셋의 크기를 다 블록체인에 올릴수가 없어 AWSIPFS와 같은 분산 네트워크에 에셋을 올린 다음에 그 링크만 블록체인에 올리기 때문이다. 물론 디지털 에셋을 올리면 이를CID(Content Identifier)로 변환하는 작업을 하는데 에셋이 이미지라 가정하면 원본 이미지로부터 1 pixel만 달라져도 이 CID가 달라지기 때문에 위조 작품을 걸러내기는 용이하다. 하지만, AWS나 IPFS와 같은 분산 네크워크가 날라가거나 해킹당하는 경우에는 디지털 에셋의 저장 주소 자체가 타격을 받는 것이기 때문에 아주 위험하다.

Ethereum이 아닌 가스비가 상대적으로 낮은 Polygon이나 Solana 같은 경우는 이미지 원본을 올리는 경우도 있지만 나는 Ethereum이 강력한 팬이기 때문에 Sepolia Testnet에서 IPFS를 통해 NFT를 minting하는 대중적인 방법을 사용해보겠다.

NFT Metadata 준비

NFT를 구성하는 정보를 Metadata라고 한다. 가장 메인이 되는 정보(이미지, 동영상, 음악 파일 등등)이 있고 그 정보가 어떤 이름인지, 몇 번의 창작물인지, 어떤 collection에 속해있는지에 대한 텍스트 정보가 있다.

이 정보들을 하나의 링크로 만들기 위해서는 IPFS에 두번의 파일 업로드 과정을 거쳐야 한다. 첫 번째 작업은 메인이 되는 정보 파일의 업로드이다. 두 번째 작업은 첫 번째로 얻은 링크 정보 + 부가적인 텍스트 정보를 다 포함한 JSON 파일의 업로드이다.

생성형 AI로 이미지 제작

먼저, NFT로 사용될 이미지를 준비한다. 그 전에, 이더리움 기반 NFT 토큰의 종류에 대한 이해가 필요하다. 이더리움 기반의 코인에는 그 성격에 따라서 ERC-(숫자)가 붙는다. 이더리움 Native TokenERC-20기반이고 NFTERC-721, ERC-1155가 보편적인 기반으로 쓰인다. ERC-721은 이 세상에 하나밖에 없는 재화(보통 미술작품)에 많이 사용되고 ERC-1155는 하나는 아니지만 소유권을 부여하고 싶을때(한정판 명품, 희귀한 게임 아이템)을 제작할 때 사용한다.

필자는 게임에서 사용될 NFT를 만드려고 함으로 ERC-1155기반을 만들 것이다. 게임 아이템 중 기밀 정보에 해당하는 아이템이 있는데 이를 나타내는 이미지를 무료 이미지 생성형 AI 툴을 사용해서 위와 같이 뽑아내었다.

IPFS에 NFT meta 정보가 들어간 JSON 업로드

이미지를 얻었으면 이를 위에서 설명한대로 IPFS에 올리고 그 image URI를 포함한 MetaData를 JSON 형식으로 만들어 다시 IPFS에 올린다. 이 JSON URI가 NFT Smart Contract에 들어갈 최종적인 input이 된다.


2. NFT Smart Contract 작성 및 배포

블록체인에 올릴 Token URI는 준비되었으니 이제 이를 올리는 Smart Contract를 만들고 배포하면 된다. 하지만, Contract을 작성하는 데 몇 번의 우여곡절이 있었다(사실 현재 진행중...). 초반 Contract(Minting Contract Version 1라고 부르겠다)으로는 ERC-1155로 민팅은 되는데 Opensea에서 IPFS에 대한 URI, Thumbnail에 대한 어떤 정보도 얻을 수 없었다. 이를 위해서 Contract이 인자로 받는 JSON과 uri getter 함수를 손봐야 했다.

Opensea NFT Json 규약

{
  "name": "MarsEscape Final",
  "description": "This is an ERC-1155 NFT with an animated GIF thumbnail for OpenSea.",
  "image": "ipfs://QmRuZNxjMwKmW1UVot8wBwVpkbMVtFgQVkWxJ6HhVRYXv1",
  "animation_url": "ipfs://QmRuZNxjMwKmW1UVot8wBwVpkbMVtFgQVkWxJ6HhVRYXv1",
  "external_url": "https://velog.io/@ryan_ur/posts",
  "attributes": [
    {
      "trait_type": "Rarity",
      "value": "Legendary"
    },
    {
      "trait_type": "Artist",
      "value": "Ryan"
    }
  ]
}

우선, Opensea가 요구하는 JSON의 property를 위와 같이 확실하게 작성해 주어야 했다. 제일 중요한 부분이 Thumbnail로 활용될 Image값과 메타 정보인 Collection, Description 필드값이다.

Minting Contract

다음으로는 Contract 부분이다. NFT Minting의 경우에는 워낙 많이 쓰이기 때문에 아예 OpenZeppelin이라는 library에서 Minting에 필요한 많은 함수들을 지원한다. 예를 들어, ERC-1155 클래스를 지원해서 ERC-1155 토큰 베이스를 만들고 싶다면 단순히 상속하면 된다. 또한, 접근 권한성에 해당하는 Ownable modifier도 제공하지만 Contract의 Compile과 배포를 했던 Remix 환경에서는 계속 에러가 나서 Ownable은 사용하지 못하고 권한이 있는 계정은 Contract에 주소를 하드 코딩 해두었다.

Minting Contract Version 1

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

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

contract GameAssetNFT is ERC1155 {
    // MINTER의 주소는 하드코딩했다. 
    address public constant MINTER = 0x6655551b6C9C53C6a7da81118a9227c0F0c54e80;

	// TokenId 인덱스를 만들어 private mapping의 key값으로 활용한다. 
    uint256 public nextTokenId; 
    mapping(uint256 => string) private _tokenURIs; 

    constructor() ERC1155("") {
        nextTokenId = 1; 
    }

    // Minter 주소를 가진 사람만이 minting 함수에 접근하도록 modifier 생성 
    modifier onlyMinter() {
        require(msg.sender == MINTER, "Caller is not the minter");
        _;
    }
    
    function mintGameAsset(address recipient, uint256 amount, string memory tokenURI) public onlyMinter {
        uint256 tokenId = nextTokenId;
        _mint(recipient, tokenId, amount, "");
        _setTokenURI(tokenId, tokenURI); 
        nextTokenId++;
    }

    function _setTokenURI(uint256 tokenId, string memory tokenURI) internal {
        require(bytes(tokenURI).length > 0, "Invalid token URI");
        _tokenURIs[tokenId] = tokenURI;
    }

    // Index로 IPFS 링크에 접근할 수 있는 getter 함수 생성
    function uri(uint256 tokenId) public view override returns (string memory) {
        return _tokenURIs[tokenId];
    }
}

하지만, 이렇게 만들고 테스트를 해보았더니 ERC-1155 기반으로 민팅은 되지만, 정작 Opensea에 들어가보니 이미지와 상세 설명이 모두 공란이었다. 조사를 해보니 Contract에 있는 uri getter 함수의 리턴값이 Opensea에서 요구하는 값과 달라서 생긴 일이었다.

Minting Contract Version 2

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

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract GameAssetNFT is ERC1155 {
    address public constant MINTER = 0x6655551b6C9C53C6a7da81118a9227c0F0c54e80;
    uint256 public nextTokenId;

    constructor() ERC1155("ipfs://QmbyNrycL7fJoWEHWe5kXSpCoKoqDtxrdJdbV15ybqGoLT/{id}.json") {
        nextTokenId = 1; 
    }

    modifier onlyMinter() {
        require(msg.sender == MINTER, "Caller is not the minter");
        _;
    }

    function mintGameAsset(address recipient, uint256 amount) public onlyMinter {
        uint256 tokenId = nextTokenId;
        _mint(recipient, tokenId, amount, "");
        nextTokenId++;
    }

    // URI 함수 수정
    function uri(uint256 tokenId) public pure override returns (string memory) {
        return string(abi.encodePacked("ipfs://QmbyNrycL7fJoWEHWe5kXSpCoKoqDtxrdJdbV15ybqGoLT/", Strings.toString(tokenId), ".json"));
    }
}

초기 Contract에서 uri getter 함수를 수정한 Minting Contract Version 2를 만들고 배포하였다. URI 함수의 리턴값을 IPFS 주소 + tokenID 값 + .json으로 만든 다음에 abi.encodePacked 을 최종적으로 거치게 하였더니 Opensea에서 원하는 대로 성공적인 토큰이 리스팅되었다 .

Opensea에서 확인한 minting 된 ERC-1155 토큰들

빨간색 박스 안 토큰들은 Minting Contract Version 1의 결과물들이고, 초록색 박스 안 토큰들은 Minting Contract Version 2의 결과물들이다.

Contract 정보 저장

Contract 정보를 저장하는데도 약간의 생각이 필요했다. Contract를 배포하고 이의 함수를 사용하기 위해서는 Contract의 ABI 정보주소 정보가 필요하다. Minting Contract Version Etherscan에서는 Contract의 "Verify and Publish"를 하면 ByteCode로만 보여주고 있던 코드를 ABI를 포함해, 원래 배포했던 solidity 코드 원본의 정보까지 보여준다. 하지만, 이를 하려고 하니 Etherscan에 compile 체크를 할때 OpenZeppelin 라이브러리를 지원하지 않는지 계속 실패했다.

어쩔 수 없이 이 정보들을 저장하기 위해 Contract의 기본 정보가 들어간 Struct를 BP를 사용해서 만들고, 이 Struct를 기본 자료형으로 하는 DataTable을 추가적으로 만들었다.

Contract 정보를 관리하는 custom DataTable 제작

DataTable에서 정보를 fetch하는 모습


3. Blockchain Interaction

Smart Contract까지 배포했으면 이제 게임상에서 Minting된 Contract에 접근해 Mint 함수를 부르기만 하면 된다. 앞선 블로그에서 코인 전송을 하기 위해 Sign TransactionsendRawTransaction을 차례로 보냈던 것을 기억해보자(링크). 코인 전송에 있어서는 Sign Transaction의 Data Field가 공란이었다면, 배포된 Contract와 Interaction 하기 위해서는 Sign Transaction의 Data Field에 Contract ABI 정보를 추가적으로 넣어주기만 하면 된다.

ABI 정보를 인코딩해서 DataField에 넣기

JSON으로 되어있는 ABI정보를 그냥 넣을 수 없으므로 인코딩하는 작업이 필요하다. 3SGameStudio에서 제공하는 Encode Smart Contract ABI 함수를 사용해서 이 작업을 수행했다.


Inventory 시스템 구현

추후에는 게임내에서 아이템을 주으면 사용자가 자기가 원하는 아이템을 민팅하는 기능을 만들 계획이다. 이를 위해서 NFT 아이템 클래스와 Inventory 기능을 만들었다.

아이템 클래스는 BP_Item로 만들었고 여기에 이 아이템의 수량, 이름, Thumbnail, StaticMesh에 대한 정보(Struct로 관리)를 넣었다. 충돌 부분은 Multi Sphere Trace By Channel을 사용해 Hit Result에 들어온 물체들이 BP_Item이면 이를 Inventory에 넣는 작업을 했다. 처음에는 Sphere Trace를 BP_Item에 달았는데, 이렇게 하면 Player Character에 정보를 접근하는게 번거로워서 Sphere Trace를 처리하는 Actor Component를 만들고 이를 캐릭터에 붙였다.

UI는 각각의 인벤토리에 해당하는 Sub widget과 Inventory에 해당하는 Main-widget을 만들었다. Sub Widget에서는 Overlay위에 Image와 TextBox를 함께 넣어 적재한 아이템의 Thumbnail과 수량을 표시할 수 있게 하였다. Main-widget의 경우에는 획득한 아이템들을 보여주고 선택한 아이템을 Minting할 수 있도록 버튼을 따로 만들어주었다.

E key를 누르면 Inventory에 적재


추후 남은 과제

Opensea에서 Thumbnail로 정적인 이미지만 들어가는 것이 아쉬워 Thumbnail을 GIF 형식으로 만들고 싶었다. 나중에 다른 아이템들도 이런 Thumbnail을 만들어야 할 것 같아 아예 레벨안에서 스튜디오를 만들어보기로 하였다. 사실 정말 간단한데, 하얀색 판자로 뼈대를 만들고 Spotlight를 위에 달아서 메인 물체에 대한 강조 효과를 주었다.

GIF Thumbnail을 촬영하기 위해 제작한 스튜디오

스튜디오에서 촬영한 결과

3D 아이템의 모든 부분을 보여주고 싶어 아이템 클래스의 Tick에서 Set Actor Rotation를 통해 Yaw값을 계속 변하게 만들어서 아이템이 공중에서 회전하는 이펙트를 만들었다.

하지만... 아쉽게도 GIF 형식으로 Thumbnail을 만드는 데는 실패하였다. 찾아본 결과 JSON에서 image가 아닌 animated-url이라는 key값에 GIF에 해당하는 IPFS를 연결하면 된다고 하는데 잘 되지 않았다. 계속 한번 도전해 봐야겠다.

추가적으로 2개의 작업을 해보고 싶다. 하나는, 현재 Remix에서 Contract을 배포후 게임상에서 interaction을 하는데, 발전시킨다면 게임 상에서 직접 Contract 배포까지 만드는 것이다.

두번째는 게임내에서 랜덤 박스를 먹었을때 확률에 따라서 에픽/희귀/보통에 해당하는 아이템을 민팅해보는 것이다. 이를 위해서는 각각의 아이템에 해당하는 사진 혹은 GIF 파일의 제작과 랜덤 함수가 들어간 발전된 Contract을 다시 제작&배포 하는 과정이 필요할 것이다.

profile
🏦KAIST EE | 🏦SNU AI(빅데이터 핀테크 전문가 과정) | 📙CryptoHipsters 저자

0개의 댓글