이번 포스팅에서는 OpenSea 와 같은 NFT 마켓플레이스에서 사용자들 간 NFT 판매 및 구매에 사용되는 스마트 컨트랙트를 작성해보고자 한다. openzeppelin-solidity 라이브러리를 사용하여 컨트랙트를 작성했으며 토큰 관련 컨트랙트는 contract JwToken , NFT 판매 및 구매 관련 컨트랙트는 contract SaleToken 이라는 이름으로 제작하였다. 각각의 컨트랙트 안에서 정의된 함수들의 기능에 대한 설명을 중점으로 해서 작성해보고자 한다.
< 목차 >
- contract JwToken
- contract SaleToken
1. contract JwToken
JwToken 컨트랙트는 minting(민팅)과 관련된 기능들이 구현되어 있는 컨트랙트이며 해당 컨트랙트 안에서 구현된 주요 기능은 다음과 같다.
- NFT minting
- 발행된 NFT에 매칭되는 tokenURI 생성
전체 코드를 살펴본 다음, 세부적인 내용들에 대해 알아보도록 하자.
/* JwToken.sol 파일 */
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import "./node_modules/openzeppelin-solidity/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "./node_modules/openzeppelin-solidity/contracts/access/Ownable.sol";
// 상속 받는 것만으로 컨트랙트 배포를 진행할 때 알아서 owner 상태변수에 컨트랙트 배포자의 EOA 계정값을 넣어준다.
import "./node_modules/openzeppelin-solidity/contracts/utils/Strings.sol";
// uint => bytes => string 의 형변환 함수 제공
contract JwToken is ERC721Enumerable, Ownable{
uint constant public MAX_TOKEN_COUNT = 1000;
// NFT 발행량을 제한하고 싶은 경우
// Solidity에서 상수 선언은 constant
uint public mint_price = 1 ether;
// 연산으로 양을 표현하게 될 경우 가스비 소모
// Solidity에서는 1 ether 라고 표현하면 알아서 10**18으로 표현해준다.
string public metadataURI;
constructor(string memory _name, string memory _symbol, string memory _metadataURI) ERC721(_name, _symbol) {
metadataURI = _metadataURI;
}
// tokenId 값에 따라 랜덤한 Rank, Type을 부여하기 위한 구조체
struct TokenData {
uint Rank;
uint Type;
}
mapping(uint => TokenData) public TokenDatas;
// tokenId => TokenData
uint[4][4] public tokenCount;
// 사용자에게 NFT 발행 상황을 보여주기 위한 용도의 상태변수
function mintToken() public payable {
// mintToken() 을 실행할 때 이더를 지급하게끔 한다. CA에게 이더를 지급해서 NFT를 사는 개념.
require(msg.value >= mint_price);
require(MAX_TOKEN_COUNT > ERC721Enumerable.totalSupply());
uint tokenId = ERC721Enumerable.totalSupply() + 1;
// 총발행량 + 1 로 tokenId 값 형성
// _tokenId 에 따라 metadata의 Rank 와 Type을 랜덤하게 생성하여 TokenDatas 상태변수에 저장
TokenData memory random = getRandomTokenData(msg.sender, tokenId); // 함수 실행 종료시 메모리 데이터 날라감.
TokenDatas[tokenId] = random;
tokenCount[TokenDatas[tokenId].Rank-1][TokenDatas[tokenId].Type-1] += 1;
// 해당 Rank 와 Type의 토큰이 몇개가 생성되었는지 확인 가능하도록 하기위해 tokenCount 상태변수 업데이트
// CA -> 컨트랙트 배포자 계정으로 지급받은 이더 전송
payable(Ownable.owner()).transfer(msg.value);
// mintToken() 을 호출한 계정에게 NFT 발행
_mint(msg.sender, tokenId);
}
function tokenURI(uint _tokenId) public override view returns (string memory) {
// if metadataURI : http://localhost:3000/metadata
// return : http://localhost:3000/metadata/1/4.json
// uint -> string 형태로 바로 형변환 불가능
// uint -> bytes -> string 으로 형변환
// utils 디렉토리 안에 존재하는 Strings.sol 파일 활용
string memory Rank = Strings.toString(TokenDatas[_tokenId].Rank);
string memory Type = Strings.toString(TokenDatas[_tokenId].Type);
// abi.encodePacked("http://localhost:3000/metadata", "/", Rank, "/", Type, ".json")
return string(abi.encodePacked(metadataURI, "/", Rank, "/", Type, ".json"));
}
// TokenData를 랜덤하게 만들어주는 함수
function getRandomTokenData(address _owner, uint _tokenId) private pure returns (TokenData memory) {
// Solidity에는 random 함수 부재
// 특정한 값을 해싱한 다음 나머지 연산을 통해 랜덤한 값을 뽑아오는 방식으로 구현
// abi.encodePacked(_owner, _tokenId); // 타입과 상관없이 합쳐주는 메소드
uint randomNum = uint(keccak256(abi.encodePacked(_owner, _tokenId)))%100; // Solidity에서 주로 사용되는 랜덤값 구하는 방법
// keccak256 -> 32 byte
// 주의 : keccak256() 에 같은 string 값을 전달하면 안된다.
// 상태변수를 사용한 게 아니라 메모리 상에 잠시 데이터를 저장한 것.
TokenData memory data;
// 메모리 상에 data라는 객체를 만든 것
if (randomNum < 5) {
if (randomNum == 1) {
data.Rank = 4;
data.Type = 1;
} else if (randomNum == 2) {
data.Rank = 4;
data.Type = 2;
} else if (randomNum == 3) {
data.Rank = 4;
data.Type = 3;
} else {
data.Rank = 4;
data.Type = 4;
}
} else if (randomNum < 13) {
if (randomNum < 7) {
data.Rank = 3;
data.Type = 1;
} else if (randomNum < 9) {
data.Rank = 3;
data.Type = 2;
} else if (randomNum < 11) {
data.Rank = 3;
data.Type = 3;
} else {
data.Rank = 3;
data.Type = 4;
}
} else if (randomNum < 37) {
if (randomNum < 19) {
data.Rank = 2;
data.Type = 1;
} else if (randomNum < 25) {
data.Rank = 2;
data.Type = 2;
} else if (randomNum < 31) {
data.Rank = 2;
data.Type = 3;
} else {
data.Rank = 2;
data.Type = 4;
}
} else {
if (randomNum < 52) {
data.Rank = 1;
data.Type = 1;
} else if (randomNum < 68) {
data.Rank = 1;
data.Type = 2;
} else if (randomNum < 84) {
data.Rank = 1;
data.Type = 3;
} else {
data.Rank = 1;
data.Type = 4;
}
}
return data;
}
// metadataURI를 수정할 수 있는 함수
// onlyOwner : owner(컨트랙트 배포자)만 실행시킬 수 있도록 하는 접근제한자
function setMetadataURI(string memory _uri) public onlyOwner {
metadataURI = _uri;
}
// TokenData의 Rank 조회
function getTokenRank(uint _tokenId) public view returns (uint) {
return TokenDatas[_tokenId].Rank;
}
// TokenData의 Type 조회
function getTokenType(uint _tokenId) public view returns (uint) {
return TokenDatas[_tokenId].Type;
}
// 배열 전체를 return 하기 위한 view 함수
// getter 함수로 배열 전체를 조회하는 것은 불가능.
// getter 함수는 요소 하나만 return 하기 때문에 배열 전체를 return하는 view 함수를 따로 만들어준다.
function getTokenCount() public view returns (uint[4][4] memory) {
return tokenCount;
}
}
👉 import
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import "./node_modules/openzeppelin-solidity/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "./node_modules/openzeppelin-solidity/contracts/access/Ownable.sol";
// 상속 받는 것만으로 컨트랙트 배포를 진행할 때 알아서 owner 상태변수에 컨트랙트 배포자의 EOA 계정값을 넣어준다.
import "./node_modules/openzeppelin-solidity/contracts/utils/Strings.sol";
// uint => bytes => string 의 형변환 함수 제공
우선 openzeppelin-solidity에서 제공하는 contract ERC721Enumerable 과 contract Ownable 을 상속받아 JwToken 컨트랙트를 작성하였다. ERC721Enumerable은 ERC721 을 상속받아 작성된 컨트랙트로 JwToken 컨트랙트 안에서 NFT 민팅과 관련된 기능들을 구현하기 위해서는 해당 컨트랙트의 내용이 필요하다. Ownable 컨트랙트의 경우 JwToken 컨트랙트 안에서 owner 상태변수를 사용하기 위해 상속 받은 컨트랙트이다. Ownable 컨트랙트를 상속받는 것만으로도 컨트랙트 배포를 진행할 때 자동으로 owner 상태변수에 컨트랙트 배포자의 EOA 계정값이 들어가게 된다.
또한 import 구문을 통해 openzeppelin-solidity에서 제공하는 Strings.sol 파일을 가져왔는데 이는 string 타입으로의 형변환을 위한 toString( ) 함수를 사용하기 위함이다. 해당 내용은 toString( ) 함수를 사용하는 부분에서 다시 한번 언급하도록 하겠다.
👉 상태변수
- MAX_TOKEN_COUNT : NFT 총 발행량을 제한하고자 만들어준 상태변수. Solidity에서 상수를 선언할 떄는 constant 키워드를 사용한다. uint constant 타입으로 만들어줬기 때문에 값을 수정하는 것이 불가능하다.
- mint_price : NFT 민팅시 지불해야 하는 이더(ether). 즉, 민팅 가격을 나타내는 상태변수. uint public mint_price = 1 ether; 와 같이 나타내었는데 Solidity에서는 1 ether 라고 작성할 경우 자동으로 10**18 으로 표현해준다. 연산식으로 양(quantity)을 표현하게 될 경우 가스비가 소모될 수 있기 때문에 이와 같은 표기법을 사용한다.
- metadataURI : 발행된 NFT의 tokenId 값에 매칭되는 tokenURI 의 앞부분을 나타내는 상태변수. tokenURI 의 baseURL 이라고 생각하면 된다.
- struct TokenData : tokenId 값에 따라 랜덤한 Rank 와 Type 을 부여하기 위한 구조체.
- TokenDatas : tokenId 값에 매칭되는 TokenData 구조체를 mapping(uint => TokenData) 타입으로 나타낸 상태변수.
- tokenCount : 프론트 화면에서 사용자들에게 NFT 발행 상황을 보여주기 위한 용도로 만들어 놓은 상태변수. 여기서 발행하고자 하는 NFT 에는 4개의 Rank 가 존재하며 각각의 Rank 는 4개의 Type 을 가지고 있다. 따라서 uint[4][4] 타입으로 tokenCount 상태변수를 만들어주었다.
👉 function
function mintToken() public payable {
// mintToken() 을 실행할 때 이더를 지급하게끔 한다. CA에게 이더를 지급해서 NFT를 사는 개념.
require(msg.value >= mint_price);
require(MAX_TOKEN_COUNT > ERC721Enumerable.totalSupply());
uint tokenId = ERC721Enumerable.totalSupply() + 1;
// 총발행량 + 1 로 tokenId 값 생성
// _tokenId 에 따라 metadata의 Rank 와 Type을 랜덤하게 생성하여 TokenDatas 상태변수에 저장
TokenData memory random = getRandomTokenData(msg.sender, tokenId); // 함수 실행 종료시 메모리 데이터 날라감.
TokenDatas[tokenId] = random;
tokenCount[TokenDatas[tokenId].Rank-1][TokenDatas[tokenId].Type-1] += 1;
// 해당 Rank 와 Type의 토큰이 몇개가 생성되었는지 확인 가능하도록 하기위해 tokenCount 상태변수 업데이트
// CA -> 컨트랙트 배포자 계정으로 지급받은 이더 전송
payable(Ownable.owner()).transfer(msg.value);
// mintToken() 을 호출한 계정에게 NFT 발행
_mint(msg.sender, tokenId);
}
mintToken( ) 함수 안에서는 _mint( ) 함수를 실행시켜 NFT 를 발행하게 된다. 상속 받은 컨트랙트 안에 정의되어 있는 함수를 override 하는 형태가 아닌 새롭게 mintToken( ) 이라는 함수를 만들어주는 방향으로 진행하였다. mintToken( ) 함수를 payable 함수로 만들어서 NFT를 민팅할 수 있도록 하기 위함이다. 우선 require( ) 구문을 사용해 민팅을 하는 사용자가 지불하는 이더(ether)가 mint_price 보다 크거나 같을 때와 발행된 NFT가 MAX_TOKEN_COUNT 보다 작을 때만 mintToken( ) 함수가 실행될 수 있도록 하였다.
인자값을 따로 받지 않으면서 NFT를 발행할 수 있도록 하기 위해 발행되는 NFT의 tokenId 값은 totalSupply( ) + 1 (총발행량 + 1) 으로 값을 만들어주었다. 이후 TokenData 구조체 타입의 random 변수를 만들어준 다음 getRandomTokenData( ) 함수를 통해 tokenId 값에 매칭되는 TokenData를 만들었다. 그리고 TokenDatas 상태변수를 업데이트해 tokenId 값에 TokenData가 mapping 될 수 있도록 하였다. 이 때 TokenData 타입의 random 변수를 memory 로 지정한 것을 확인할 수 있는데 memory 로 지정한 변수의 데이터는 함수가 실행되는 동안 메모리 공간 안에 잠시 저장되었다가 함수 실행 종료시 삭제된다. (함수 종료와 함께 메모리 상의 데이터는 날라가게 된다.) 우리가 만들어주는 random 값의 경우 TokenDatas 상태변수에 tokenId 값에 mapping 되는 TokenData를 만들어주기 위해 일시적으로 생성한 데이터이기 때문에 이와 같이 memory 형태로 메모리 공간 안에 잠시 저장해 놓았다가 함수가 실행 완료되는 시점에 삭제해주는 것이 적절하다. 일시적으로 사용되는 데이터는 storage가 아닌 memory 공간에 잠시 저장해 놓고 사용하는 경우가 많다는 점을 기억해두도록 하자.
이후 어떠한 Rank 와 Type 을 가진 NFT가 발행되었는지 확인 가능하도록 하기 위해 앞서 만들어준 tokenCount 상태변수를 업데이트 해주었으며 payable(Ownable.owner( )).transfer(msg.value) 를 통해 컨트랙트 배포자 계정으로 지급받은 이더(ether)를 전송해주었다. JwToken 컨트랙트가 상속받은 Ownable 컨트랙트 안의 owner( ) 함수를 사용하면 컨트랙트 배포자의 EOA를 조회할 수 있다. payable(Ownable.owner( )) 를 통해 payable address 로 형 변환을 진행한 다음 transfer( ) 함수를 통해 msg.value 만큼의 이더를 전송한 것이다.
이전에 만들어보았던 Swap 컨트랙트와 다르게 민팅(minting) 관련 컨트랙트에서는 해당 컨트랙트의 CA가 이더(ether)를 보관하고 있을 필요가 없다. 민팅을 진행하는 사용자가 CA에게 이더를 지불한다는 개념은 같지만 CA가 계속해서 이더를 보관하고 있는 것이 아닌 민팅 컨트랙트 배포자의 EOA로 지급받은 이더를 전송해주게 된다. 따라서 mintToken( ) 함수는 payable 속성을 필요로하게 되는 것이다.
마지막으로 _mint( ) 함수를 이용해 mintToken( ) 함수를 호출한 msg.sender 계정으로 NFT를 발행해주면 민팅(minting) 관련 함수가 완성된다.
function tokenURI(uint _tokenId) public override view returns (string memory) {
// if metadataURI : http://localhost:3000/metadata
// return : http://localhost:3000/metadata/1/4.json
// uint -> string 형태로 바로 형변환 불가능
// uint -> bytes -> string 으로 형변환
// utils 디렉토리 안에 존재하는 Strings.sol 파일 활용
string memory Rank = Strings.toString(TokenDatas[_tokenId].Rank);
string memory Type = Strings.toString(TokenDatas[_tokenId].Type);
// abi.encodePacked("http://localhost:3000/metadata", "/", Rank, "/", Type, ".json")
return string(abi.encodePacked(metadataURI, "/", Rank, "/", Type, ".json"));
}
우선 contract ERC721 안에 tokenURI( ) 함수가 virtual 로 선언되어 있기 때문에 tokenURI( ) 함수를 override 하여 작성해주었다. tokenURI( ) 함수는 인자값으로 _tokenId 값을 전달받으며 TokenDatas 상태변수를 조회해 _tokenId 값에 매칭되는 tokenURI 를 만들어서 return 해주게 된다. 이때 import 해온 Strings.sol 파일 안의 toString( ) 메소드를 사용하게 되는데 이는 Solidity에서는 uint 타입에서 string 타입으로의 직접적인 형 변환이 불가능하기 때문이다. 사실 string 타입은 Solidity에서 제공하는 데이터 타입이 아니다. 따라서 uint 에서 bytes 타입으로 형 변환을 진행한 다음 다시 bytes에서 string 타입으로 형 변환을 진행해줘야만 한다. 이러한 번거로움을 피하기 위해 openzeppelin-solidity의 utils 디렉토리 안에 존재하는 Strings.sol 파일에서는 toString( ) 메소드를 제공해주고 있으며 toString( ) 메소드를 활용하면 uint 에서 string 타입으로의 형 변환이 가능해진다.
Rank 변수와 Type 변수를 memory 로 지정한 이유 역시 두 변수는 tokenURI( ) 함수 안에서만 일시적으로 사용되기 때문이다. mintToken( ) 함수 안에서 NFT 가 발행될 때 TokenDatas 상태변수에 tokenId 값에 mapping 되는 TokenData를 저장해 놓았으므로 TokenDatas 를 통해 Rank 와 Type 변수를 만들어 메모리 상에 잠시 저장해 놓은 것이다. 최종적으로 Rank 와 Type 변수를 사용해 tokenURI 를 만들어 return 해주게 되는데 함수 실행 종료와 함께 메모리 상에 저장되어 있던 데이터들은 사라지게 된다.
참고로 abi.encodePacked( ) 에 대해 잠시 짚고 넘어가면, abi.encodePacked( ) 는 인자값으로 전달된 값들을 타입과 상관없이 합쳐주는 메소드라고 생각하면 된다.
// TokenData를 랜덤하게 만들어주는 함수
function getRandomTokenData(address _owner, uint _tokenId) private pure returns (TokenData memory) {
// Solidity에는 random 함수 부재
// 특정한 값을 해싱한 다음 나머지 연산을 통해 랜덤한 값을 뽑아오는 방식으로 구현
// abi.encodePacked(_owner, _tokenId); // 타입과 상관없이 합쳐주는 메소드
uint randomNum = uint(keccak256(abi.encodePacked(_owner, _tokenId)))%100; // Solidity에서 주로 사용되는 랜덤값 구하는 방법
// keccak256 -> 32 byte
// 주의 : keccak256() 에 같은 string 값을 전달하면 안된다.
// 상태변수를 사용한 게 아니라 메모리 상에 잠시 데이터를 저장한 것.
TokenData memory data;
// 메모리 상에 data라는 객체를 만든 것
/*
{
Rank: uint,
Type: uint
}
*/
if (randomNum < 5) {
if (randomNum == 1) {
data.Rank = 4;
data.Type = 1;
} else if (randomNum == 2) {
data.Rank = 4;
data.Type = 2;
} else if (randomNum == 3) {
data.Rank = 4;
data.Type = 3;
} else {
data.Rank = 4;
data.Type = 4;
}
} else if (randomNum < 13) {
if (randomNum < 7) {
data.Rank = 3;
data.Type = 1;
} else if (randomNum < 9) {
data.Rank = 3;
data.Type = 2;
} else if (randomNum < 11) {
data.Rank = 3;
data.Type = 3;
} else {
data.Rank = 3;
data.Type = 4;
}
} else if (randomNum < 37) {
if (randomNum < 19) {
data.Rank = 2;
data.Type = 1;
} else if (randomNum < 25) {
data.Rank = 2;
data.Type = 2;
} else if (randomNum < 31) {
data.Rank = 2;
data.Type = 3;
} else {
data.Rank = 2;
data.Type = 4;
}
} else {
if (randomNum < 52) {
data.Rank = 1;
data.Type = 1;
} else if (randomNum < 68) {
data.Rank = 1;
data.Type = 2;
} else if (randomNum < 84) {
data.Rank = 1;
data.Type = 3;
} else {
data.Rank = 1;
data.Type = 4;
}
}
return data;
}
getRandomTokenData( ) 함수는 mintToken( ) 함수 안에서 호출되었던 함수로 tokenId 값에 mapping 되는 TokenData를 랜덤하게 만들어주는 함수이다. Solidity에는 random 함수가 존재하지 않기 때문에 특정한 값을 해싱한 다음 나머지 연산을 통해 랜덤한 값을 뽑아오는 방식으로 랜덤값을 만들어주게 된다.
uint randomNum = uint(keccak256(abi.encodePacked(_owner, _tokenId)))%100;
abi.encodePacked( ) 메소드를 사용해 인자값으로 전달받은 _owner 와 _tokenId 값을 합쳐준 다음, keccak256( ) 메소드를 사용해 해싱을 진행해주게 된다. 그리고 해싱한 값을 uint 타입으로 형변환하여 나머지 연산을 통해 랜덤한 값인 randomNum을 만들어 주었다. 위와 같은 방식은 Solidity에서 랜덤한 값을 만들어줄 때 주로 사용하는 방법이므로 익혀두도록 하자.
이후 TokenData memory data; 를 통해 TokenData 타입의 data 변수를 만들어주었다. data 변수 역시 getRandomTokenData( ) 함수 안에서만 사용될 변수이기 때문에 memory 로 지정하여 메모리 상에 잠시 저장해 놓고 사용하게끔 하였다. getRandomTokenData( ) 함수의 return 값을 TokenData memory 타입으로 하여 data 변수를 return 함과 동시에 메모리 상에 잠시 저장되었던 data 값을 지워주도록 처리하였다.
if 구문은 randomNum의 크기에 따라 TokenData 의 Rank 와 Type 값을 지정해주는 로직이다. random 함수의 부재로 인해 이와같이 하드코딩을 통해 랜덤값을 만들어주게 되는 것이라고 생각하면 된다.
// metadataURI를 수정할 수 있는 함수
// onlyOwner : owner(컨트랙트 배포자)만 실행시킬 수 있도록 하는 접근제한자
function setMetadataURI(string memory _uri) public onlyOwner {
metadataURI = _uri;
}
setMetadataURI( ) 함수는 _uri 를 인자값으로 전달받아 상태변수에 저장되어 있는 metadataURI 를 수정할 수 있는 함수이다. JwToken 컨트랙트의 constructor( ) 함수를 살펴보면 인자값으로 _metadataURI 를 받아 metadataURI 상태변수에 할당하고 있는 것을 확인할 수 있다. 컨트랙트 배포시 지정해준 metadataURI 를 변경하고 싶은 경우가 발생할 수 있으므로 이와같이 setMetadataURI( ) 함수를 만들어 metadataURI를 수정할 수 있도록 해주었다.
그리고 setMetadataURI( ) 함수에 onlyOwner 접근제한자가 붙어있는 것을 볼 수 있는데 이는 import 해온 Ownable.sol 파일 안에 정의되어 있는 함수이다. onlyOwner 함수를 접근제한자로 사용할 시 컨트랙트 배포자인 owner 계정으로만 해당 함수를 호출할 수 있게 된다.
// TokenData의 Rank 조회
function getTokenRank(uint _tokenId) public view returns (uint) {
return TokenDatas[_tokenId].Rank;
}
// TokenData의 Type 조회
function getTokenType(uint _tokenId) public view returns (uint) {
return TokenDatas[_tokenId].Type;
}
// 배열 전체를 return 하기 위한 view 함수
// getter 함수로 배열 전체를 조회하는 것은 불가능.
// getter 함수는 요소 하나만 return 하기 때문에 배열 전체를 return하는 view 함수를 따로 만들어준다.
function getTokenCount() public view returns (uint[4][4] memory) {
return tokenCount;
}
getTokenRank( ) 함수와 getTokenType( ) 함수는 인자값으로 _tokenId 값을 전달받아 TokenDatas 상태변수에서 _tokenId 값에 mapping 되어 있는 TokenData 의 Rank 와 Type을 조회할 수 있는 view 함수이다. getTokenCount( ) 역시 tokenCount 상태변수를 return 해주는 view 함수인데 해당 함수에 대해서는 부가적인 설명이 필요하다.
상태변수를 public 으로 지정하여 만들어줄 경우 자동적으로 getter 함수가 만들어지게 된다. 하지만 배열과 같은 데이터 타입을 갖는 상태변수의 경우 getter 함수를 통해 배열 전체를 조회하는 것이 불가능하다. getter 함수를 통해서는 배열 안의 요소 하나하나의 값을 조회하는 것만이 가능하기 때문이다. 이에 배열 전체를 조회할 수 있는 view 함수를 따로 만들어주게 되는데 getTokenCount( ) 함수가 그것이다. 현재 tokenCount 상태변수의 경우 uint[4][4] 타입으로 이중배열 형태를 띄고있다. 따라서 getTokenCount( ) 라는 view 함수를 따로 만들어주었으며 이 때 return 타입이 uint[4][4] memory 로 되어있는 것을 확인할 수 있다. 배열 전체를 return 해줄 때는 이와 같이 memory 를 사용해 메모리 상에 다시 전체 배열을 만들어준 다음 return 해준다는 것에 유념하도록 하자.
2. contract SaleToken
다음으로 살펴볼 컨트랙트는 contract SaleToken 이다. SaleToken 컨트랙트 안에서는 사용자 간 NFT 판매 및 구매에 관한 기능들이 구현될 것이다. NFT 마켓플레이스와 같은 Dapp을 제작할 때 사용되는 컨트랙트라고 생각하면 된다. 마찬가지로 전체 코드를 살펴본 다음, 세부적인 내용들에 대해 짚어보도록 하자.
/* SaleToken.sol 파일 */
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import "./JwToken.sol";
// 사용자 간 NFT 판매 및 구매에 관한 컨트랙트
contract SaleToken {
JwToken public Token;
// JwToken 컨트랙트와 상호작용하기 위한 상태변수
constructor(address _tokenAddress) {
Token = JwToken(_tokenAddress);
}
struct TokenInfo {
uint tokenId;
uint Rank;
uint Type;
uint price;
}
mapping(uint => uint) public tokenPrices;
// tokenId => price
// 토큰 가격 맵핑
uint[] public SaleTokenList;
// 판매중인 NFT의 tokenId 값을 배열 안에 담아 놓은 상태변수
// 판매 등록 함수
function SalesToken(uint _tokenId, uint _price) public {
address tokenOwner = Token.ownerOf(_tokenId); // tokenId 소유자 계정
// 1. NFT 소유자만 판매 가능
require(tokenOwner == msg.sender);
// 2. 판매 가격이 0보다 큰값인가
require(_price > 0);
// 3. OpenSea 플랫폼에서는 이미 setApprovalForAll() 함수가 실행되어 있는 상태 (메타마스크 연결할 때 실행됨)
// OpenSea isApprovedForAll
require(Token.isApprovedForAll(msg.sender, address(this)));
// owner가 판매 컨트랙트에게 모든 토큰을 위임했는지 확인
// msg.sender : 판매하는 사람 (토큰 소유자)
// 두번째 인자값 : 대리인 (OpenSea 계정)
tokenPrices[_tokenId] = _price;
SaleTokenList.push(_tokenId);
}
// 토큰 구매 함수
function PurchaseToken(uint _tokenId) public payable {
address tokenOwner = Token.ownerOf(_tokenId);
// 1. 판매자가 자신의 토큰을 구매하는 것 방지
require(tokenOwner != msg.sender);
// 2. 판매 중인 토큰만 구매할 수 있도록 조건 체크
// tokenPrices 값이 0 보다 클 경우 판매중인 상품으로 인식
require(tokenPrices[_tokenId] > 0);
// 3. 구매자가 지불한 이더가 판매가격 이상인지 체크
require(tokenPrices[_tokenId] <= msg.value);
// CA 가 판매자 계정에게 이더 전송
payable(tokenOwner).transfer(msg.value);
// Token.transferFrom() 실행 주체는 CA
// 여기서 msg.sender는 PurchaseToken() 을 실행한 구매자
Token.transferFrom(tokenOwner, msg.sender, _tokenId);
// 판매 가격을 0으로 바꾸고 SaleTokenList 에서 값 제거
tokenPrices[_tokenId] = 0;
popSaleToken(_tokenId);
}
function cancelSaleToken(uint _tokenId) public {
address tokenOwner = Token.ownerOf(_tokenId);
// 1. 판매자인가
require(tokenOwner == msg.sender);
// 2. 판매중인가
require(tokenPrices[_tokenId] > 0);
tokenPrices[_tokenId] = 0;
popSaleToken(_tokenId);
}
function popSaleToken(uint _tokenId) private returns (bool) {
for (uint i = 0; i < SaleTokenList.length; i++) {
if (SaleTokenList[i] == _tokenId) {
// i == index
SaleTokenList[i] = SaleTokenList[SaleTokenList.length - 1];
SaleTokenList.pop();
return true;
}
}
return false;
}
// 전체 판매 리스트 view 함수
function getSaleTokenList() public view returns (TokenInfo[] memory) {
// [{tokenId: 1, Type: 1, Rank: 2, price: ..}] 형태로 만들어서 return
require(SaleTokenList.length > 0);
TokenInfo[] memory list = new TokenInfo[](SaleTokenList.length); // length 길이 만큼의 빈 값을 갖는 배열
// const arr = new Array(4)
for (uint i = 0; i < SaleTokenList.length; i++) {
uint tokenId = SaleTokenList[i];
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
list[i] = TokenInfo(tokenId, Rank, Type, price); // list 배열 안에 구조체 넣기
}
return list;
}
// 소유하고 있는 NFT 리스트 view 함수
function getOwnerTokens(address _tokenOwner) public view returns (TokenInfo[] memory) {
uint balance = Token.balanceOf(_tokenOwner);
require(balance != 0);
TokenInfo[] memory list = new TokenInfo[](balance);
for (uint i = 0; i < balance; i++) {
uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner, i);
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
list[i] = TokenInfo(tokenId, Rank, Type, price);
}
return list;
}
// 소유하고 있는 최신 NFT view 함수, minting 직후 사용자에게 보여주기 위한 용도
function getLatestToken(address _tokenOwner) public view returns (TokenInfo memory) {
uint balance = Token.balanceOf(_tokenOwner);
uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner, balance-1);
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
return TokenInfo(tokenId, Rank, Type, price);
}
}
👉 상태변수
- Token : import 해온 JwToken.sol 파일 안의 JwToken 컨트랙트를 타입으로 하여 지정한 상태변수. SaleToken 컨트랙트를 배포할 때 constructor( ) 함수의 인자값으로 전달하는 _tokenAddress를 Token 상태변수에 할당하게 된다. 이미 배포된 JwToken 컨트랙트의 CA를 SaleToken 컨트랙트의 Token 상태변수에 할당한 후 SaleToken 컨트랙트 안에서 Token 상태변수를 사용해 JwToken 컨트랙트와 상호작용 하게 된다.
- struct TokenInfo : 판매 등록된 NFT에 대한 정보를 담고 있는 구조체. tokenId , Rank , Type , price 를 속성값으로 갖는다.
- tokenPrices : 특정 tokenId 값을 갖는 NFT의 가격(price)을 mapping(uint => uint) 타입으로 나타낸 상태변수. tokenId => price 형태로 나타낸 것.
- SaleTokenList : 판매중인 NFT의 tokenId 값을 배열 안에 담아 uint[ ] 타입으로 나타낸 상태변수.
👉 function
// 판매 등록 함수
function SalesToken(uint _tokenId, uint _price) public {
address tokenOwner = Token.ownerOf(_tokenId); // tokenId 소유자 계정
// 1. NFT 소유자만 판매 가능
require(tokenOwner == msg.sender);
// 2. 판매 가격이 0보다 큰값인가
require(_price > 0);
// 3. OpenSea 플랫폼에서는 이미 setApprovalForAll() 함수가 실행되어 있는 상태 (메타마스크 연결할 때 실행됨)
// OpenSea isApprovedForAll
require(Token.isApprovedForAll(msg.sender, address(this)));
// owner가 판매 컨트랙트에게 모든 토큰을 위임했는지 확인
// msg.sender : 판매하는 사람 (토큰 소유자)
// 두번째 인자값 : 대리인 (OpenSea 계정)
tokenPrices[_tokenId] = _price;
SaleTokenList.push(_tokenId);
}
SalesToken( ) 함수는 _tokenId 와 _price 를 인자값으로 받으며 자신이 소유하고 있는 NFT를 판매 등록하는 함수이다. 우선 Token 상태변수를 사용해 JwToken 컨트랙트 안의 ownerOf( ) 함수를 호출하여 _tokenId 값에 해당하는 NFT 소유자의 계정을 address 타입의 tokenOwner 변수에 할당해 주었다. 그런 다음 require( ) 구문을 사용해 다음의 세가지 내용을 체크하였다.
- require(tokenOwner == msg.sender) : SaleToken( ) 함수를 호출한 msg.sender가 NFT 소유자일 경우 판매 등록이 가능하게끔 하기 위해 tokenOwner == msg.sender 인지 체크.
- require(_price > 0) : 인자값으로 전달받은 _price 즉, 판매 가격이 0 보다 큰 값인지 체크.
- require( Token.isApprovedForAll( msg.sender, address(this) ) ) : JwToken 컨트랙트 안의 isApprovedForAll( ) 함수를 사용하여 SaleToken( ) 함수를 호출하는 msg.sender 가 SaleToken 컨트랙트의 CA에 자신이 소유한 모든 NFT에 대한 권한을 위임했는지 체크.
isApprovedForAll( ) 함수를 사용해 위임에 관한 체크를 하는 부분에 대해 조금 더 짚고 넘어가자면, OpenSea와 같은 NFT 마켓플레이스에 메타마스크를 연결하는 순간 setApprovalForAll( ) 함수가 실행되면서 자신이 소유한 모든 NFT 에 대한 권한을 OpenSea에게 위임하게 된다. 따라서 require( ) 구문을 통해 SaleToken 컨트랙트의 CA가 SalesToken( ) 함수를 호출한 msg.sender의 대리인인지 확인하는 작업을 거쳐주게 된다.
모든 확인 작업이 정상적으로 완료되었다면 tokenPrices 상태변수와 SaleTokenList 상태변수를 업데이트 해주면 된다. tokenPrices 상태변수에서는 _tokenId 와 _price 를 mapping 해주고 SaleTokenList 상태변수에서는 _tokenId 값을 배열에 추가해준다.
// 토큰 구매 함수
function PurchaseToken(uint _tokenId) public payable {
address tokenOwner = Token.ownerOf(_tokenId);
// 1. 판매자가 자신의 토큰을 구매하는 것 방지
require(tokenOwner != msg.sender);
// 2. 판매 중인 토큰만 구매할 수 있도록 조건 체크
// tokenPrices 값이 0 보다 클 경우 판매중인 상품으로 인식
require(tokenPrices[_tokenId] > 0);
// 3. 구매자가 지불한 이더가 판매가격 이상인지 체크
require(tokenPrices[_tokenId] <= msg.value);
// CA 가 판매자 계정에게 이더 전송
payable(tokenOwner).transfer(msg.value);
// Token.transferFrom() 실행 주체는 CA
// 여기서 msg.sender는 PurchaseToken() 을 실행한 구매자
Token.transferFrom(tokenOwner, msg.sender, _tokenId);
// 판매 가격을 0으로 바꾸고 SaleTokenList 에서 값 제거
tokenPrices[_tokenId] = 0;
popSaleToken(_tokenId);
}
PurchaseToken( ) 함수는 인자값으로 _tokenId 값을 받고 있으며 NFT 구매시 호출되는 함수이다. NFT 판매자의 계정으로 이더(ether)를 전송해줘야 하기 때문에 payable 함수로 만들어주었으며 require( ) 구문을 통해 다음의 세가지 내용을 체크해주었다.
- require(tokenOwner != msg.sender) : 판매자가 자신의 NFT를 구매하는 것을 방지하기 위한 용도. PurchaseToken( ) 함수를 호출한 msg.sender가 인자값으로 전달받은 _tokenId 값에 해당하는 NFT 의 소유자인지 체크.
- require(tokenPrices[_tokenId] > 0) : 판매중인 NFT만 구매할 수 있도록 하기 위한 용도. tokenPrices 상태변수를 사용해 _tokenId 값과 mapping 되는 값이 0보다 큰 값인지 체크.
- require(tokenPrices[_tokenId] <= msg.value) : PurchaseToken( ) 함수를 호출한 구매자가 지불한 이더(ether)가 판매자가 등록한 판매가격 이상인지 체크.
모든 확인 작업이 정상적으로 완료되었다면 payable(tokenOwner).transfer(msg.value) 를 통해 구매자가 지불한 이더(ether)에 해당하는 msg.value 만큼의 금액을 tokenOwner 계정에게 전송해준다. 구매자가 CA에게 이더를 지불하면 SaleToken 컨트랙트의 CA가 판매자 계정으로 이더를 전송해주는 것이라고 생각하면 된다. 판매자 계정으로 이더를 전송하고 난 다음에는 Token.transferFrom(tokenOwner, msg.sender, _tokenId) 를 통해 판매자 계정이 소유하고 있던 NFT의 소유권을 구매자 계정으로 이전시켜준다. Token.transferFrom( ) 함수를 호출하는 주체는 SaleToken 컨트랙트의 CA이며 transferFrom( ) 함수가 실행되기 위해서는 SaleToken CA가 tokenOwner 계정으로부터 토큰 사용 권한을 위임받은 상태여야만 한다는 점에 유의하도록 하자.
이후 tokenPrices 상태변수를 업데이트 해주어 소유권이 이전된 NFT가 더 이상 판매중이 아닌 것으로 처리해주고 popSaleToken( ) 함수를 실행해 SaleTokenList 상태변수에서 소유권이 이전된 NFT의 _tokenId 값을 제거해준다.
function cancelSaleToken(uint _tokenId) public {
address tokenOwner = Token.ownerOf(_tokenId);
// 1. 판매자인가
require(tokenOwner == msg.sender);
// 2. 판매중인가
require(tokenPrices[_tokenId] > 0);
tokenPrices[_tokenId] = 0;
popSaleToken(_tokenId);
}
cancelSaleToken( ) 함수는 판매 등록된 NFT를 판매 취소하고 싶을 때 호출하게 되는 함수이다. _tokenId 값을 인자값으로 받으며 _tokenId 에 해당하는 NFT 소유자의 계정을 tokenOwner 변수에 할당해주었다. 이후 require( ) 구문을 통해 다음의 두가지 내용을 체크해주게 된다.
- require(tokenOwner == msg.sender) : cancelSaleToken( ) 함수를 호출한 msg.sender가 NFT 소유자의 계정인지 체크.
- require(tokenPrices[_tokenId] > 0) : tokenPrices 상태변수를 통해 판매 등록된 NFT인지 체크.
확인 작업을 완료한 다음에는 tokenPrices 상태변수를 업데이트 해주어 _tokenId 값에 해당하는 NFT가 더 이상 판매중이 아닌 것으로 처리해주고 popSaleToken( ) 함수를 실행해 SaleTokenList 상태변수에서 판매 취소된 NFT의 _tokenId 값을 제거해준다.
function popSaleToken(uint _tokenId) private returns (bool) {
for (uint i = 0; i < SaleTokenList.length; i++) {
if (SaleTokenList[i] == _tokenId) {
// i == index
SaleTokenList[i] = SaleTokenList[SaleTokenList.length - 1];
SaleTokenList.pop();
return true;
}
}
return false;
}
popSaleToken( ) 함수는 SaleTokenList 상태변수를 완전탐색하여 인자값으로 전달받은 _tokenId 값에 해당하는 요소를 제거해주는 함수이다.
// 전체 판매 리스트 view 함수
function getSaleTokenList() public view returns (TokenInfo[] memory) {
// [{tokenId: 1, Type: 1, Rank: 2, price: ..}] 형태로 만들어서 return
require(SaleTokenList.length > 0);
TokenInfo[] memory list = new TokenInfo[](SaleTokenList.length); // length 길이 만큼의 빈 값을 갖는 배열
// const arr = new Array(4)
for (uint i = 0; i < SaleTokenList.length; i++) {
uint tokenId = SaleTokenList[i];
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
list[i] = TokenInfo(tokenId, Rank, Type, price); // list 배열 안에 구조체 넣기
}
return list;
}
getSaleTokenList( ) 함수는 판매중인 전체 NFT의 리스트를 return 하는 함수이다. 프론트 쪽에서 사용자들에게 마켓플레이스에 올라와 있는 판매중인 NFT들을 보여주기 위한 용도로 사용되는 view 함수라고 생각하면 된다. 주의할 것은 return 타입이 TokenInfo[ ] memory 가 된다는 점이다.
TokenInfo[] memory list = new TokenInfo[](SaleTokenList.length);
위와 같이 TokenInfo 구조체를 요소로 갖는 배열을 데이터 타입으로 하는 list 변수를 memory 로 지정하여 만들어주었다. 이 때 new TokenInfo[ ](SaleTokenList.length)에 의해 list 변수는 SaleTokenList.length 길이만큼의 빈 값을 갖는 배열로 만들어진다.
getSaleTokenList( ) 함수는 판매 등록된 NFT에 대한 정보를 TokenInfo 구조체를 통해 객체 형태로 만들어 준 다음 배열 안에 담아서 return 해주는 형태인데, 이 때 memory 키워드를 사용해 TokenInfo 데이터를 담고 있는 배열을 메모리 상에 일시적으로 저장해 놓은 다음 return 해주는 방식으로 만들어주었다. 함수 실행이 완료되었을 때 메모리 상에 저장되었던 데이터는 지워지기 때문에 메모리 상에 일시적으로 배열을 만들어서 TokenInfo 데이터를 집어넣고 해당 배열을 return 해주는 방식을 사용하게 되는 것이다.
판매 등록된 NFT들의 정보를 담을 list 배열을 만들어주었다면 for 문을 사용해 SaleTokenList 상태변수를 완전탐색한 다음 TokenInfo 객체를 만들어서 list 배열 안에 담아놓고 list를 return 해주기만 하면 된다.
// 소유하고 있는 NFT 리스트 view 함수
function getOwnerTokens(address _tokenOwner) public view returns (TokenInfo[] memory) {
uint balance = Token.balanceOf(_tokenOwner);
require(balance != 0);
TokenInfo[] memory list = new TokenInfo[](balance);
for (uint i = 0; i < balance; i++) {
uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner, i);
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
list[i] = TokenInfo(tokenId, Rank, Type, price);
}
return list;
}
// 소유하고 있는 최신 NFT view 함수, minting 직후 사용자에게 보여주기 위한 용도
function getLatestToken(address _tokenOwner) public view returns (TokenInfo memory) {
uint balance = Token.balanceOf(_tokenOwner);
uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner, balance-1);
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
return TokenInfo(tokenId, Rank, Type, price);
}
getOwnerTokens( ) 함수는 _tokenOwner 를 인자값으로 받으며 특정 계정이 소유하고 있는 NFT 리스트를 조회하는 용도의 view 함수이다. 프론트 쪽에서 사용자들이 자신이 소유하고 있는 NFT를 조회할 수 있도록 하기 위해 만들어준 함수라고 생각하면 된다.
getLatestToken( ) 함수 역시 _tokenOwner 를 인자값으로 받으며 특정 계정이 소유하고 있는 NFT들 중에서 가장 최신 NFT의 정보를 조회할 수 있는 용도의 view 함수이다. 해당 함수 역시 프론트 쪽에서 사용자가 민팅(minting)을 진행했을시 사용자에게 발행된 NFT를 바로 보여주기 위해 만들어 놓은 함수이다.
두 함수 모두 getSaleTokenList( ) 함수와 똑같은 로직으로 구현되어 있기 때문에 기능 구현 방법에 대한 자세한 설명은 생략하도록 하겠다.
'Ethereum' 카테고리의 다른 글
Ethereum/이더리움 - Fundraising 컨트랙트 (0) | 2022.10.23 |
---|---|
Ethereum/이더리움 - NFT / contract ERC721 (0) | 2022.07.27 |
Ethereum/이더리움 - NFT / Remix로 컨트랙트 배포하기 / OpenSea에 NFT 올리기 (2) | 2022.07.26 |
Ethereum/이더리움 - OpenZeppelin / 토큰 컨트랙트 / 스왑 컨트랙트 (2) | 2022.07.23 |
Ethereum/이더리움 - 인터페이스 & ERC-20 / 토큰 발행하기 (0) | 2022.07.22 |