일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- cookie-parser 만들어보기
- buffer.from
- JWT 만들어보기
- 라우트 매개변수
- 라우터 분리
- ws 라이브러리
- express session
- JWT 하드코딩
- nodejs파일업로드
- css기초
- useContext
- next 매개변수
- 세션으로 로그인 구현
- javascript기초
- OAuth 카카오
- useEffect clean up
- express router
- Uncaught Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider>
- FormData()
- 라우터미들웨어 분리
- node.js path
- express실행
- 비동기파일업로드
- 시퀄라이즈 기본설정
- JWT 로그인 기능 구현
- 아이디 중복체크기능
- express.static
- 블록 만들기
- mysql wsl
- 라우터와 미들웨어
- Today
- Total
즐코
ERC-721 살펴보기 본문
이더리움에서 제공해주는 토큰 규격 문서를 참고하여 https://eips.ethereum.org/EIPS/eip-721
ERC-721 을 직접 작성해보면서 각 함수가 어떤 동작을 하는지 익혀보려고 한다.
1/ ERC-721 인터페이스 작성 : IERC-721
// truffle/contracts/ERC721/IERC721.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
interface IERC721 {
// 토큰 전송 시 호출하는 이벤트
event Transfer(address indexed _from, address indexed _to, uint indexed _tokenId);
// 특정 토큰 위임 시 호출하는 이벤트
event Approval(address indexed _from, address indexed _approved, uint indexed _tokenId);
// owner의 모든 토큰 위임 시 호출하는 이벤트
event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);
// 토큰 갯수 조회
function balanceOf(address _owner) external view returns(uint);
// 특정 id의 토큰 소유 계정 조회
function ownerOf(uint _tokenId) external view returns(address);
// 위임받은 대리 계정이 특정 id의 토큰을 다른 계정에 전송할때 사용하는 함수
function transferFrom(address _from, address _to, uint _tokenId) external;
// 특정 id의 토큰을 다른 계정에 위임할때 사용하는 함수
function approve(address _to, uint _tokenId) external;
// 특정 id의 토큰이 위임된 대리 계정 조회
function getApproved(uint _tokenId) external view returns(address);
// owner(msg.sender)가 가진 모든 토큰을 operator 계정으로 모두 위임하는 함수
function setApprovalForAll(address _operator, bool _approved) external;
// owner-operator간에 전체 위임된 토큰이 있는지 없는지 조회
function isApprovedForAll(address _owner, address _operator) external view returns(bool);
}
2/ ERC-721 메타데이터 인터페이스 작성 : IERC721Metadata
tokenURI() 에서 반환해주는 값은 URI 값인데 해당 URI는 특정 JSON 파일을 가리킨다.
// 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);
}
URI가 가리키는 json 파일 내의 데이터를 metadata라고 하는데, 이 metadata는 내 NFT 내의 자산에 대한 이름과 세부사항들을 제공한다. json 스키마는 보통 아래와 같다. metadata 세부 속성들은 규격이 따로 정해져있는 게 아니라 플랫폼에 따라 이 metadata 표준이 달라서 각 플랫폼에 맞게 작성하면 된다. 아래의 json 스키마는 오픈씨 기준이라고 한다.
//https://gateway.pinata.cloud/ipfs/QmUsEKtVS5Gn4rZWbYfD7D4qLLKPf1YbsBWYaSqtmCPBzf/1/2.json
{
"name": "ingoo NFT",
"description": "ingoo NFT description",
"image": "https://gateway.pinata.cloud/ipfs/QmVs28uP4MgFMHRrndLDXsoAKMQ4EGjkgFxJUEr1KCPxrU/1/1.jpg",
"attributes": [
{
"trait_type": "Rank",
"value": 1
},
{
"trait_type": "Type",
"value": 2
}
]
}
3/ ERC-721 작성 - 기본적인 기능
하나의 컨트랙트는 다중 상속이 가능하므로 위에서 만든 두 인터페이스 IERC721, IERC721Metadata 를 가져와서 상속한다.
ERC-20 과 크게 다른 점은 위임(approve) 쪽이라고 생각한다.
ERC-20 은 각각의 토큰의 의미가 같았기 때문에 토큰-이더 스왑 구현 시 이더 스왑의 기능을 가진 컨트랙트 계정(CA)에 스왑할 토큰의 양을 위임했었다. 반면, ERC-721은 각각의 토큰이 고유값을 가지고 있기 때문에 특정 토큰의 양을 위임하는 것이 아니라, 대리 계정에 특정 토큰 아이디를 위임한다. 또한 각각의 토큰이 아닌 특정 계정이 소유한 전체 토큰을 위임할 수도 있다. 아래 각 위임에 따른 함수, 변수, 이벤트를 정리해보았다.
위임 종류 | 위임 실행 함수 | 위임 조회 함수 | 위임 관련 mapping 변수 | 위임 이벤트 |
각각의 토큰 위임 | approve | getApproved | _tokenApprovals | Approval |
전체 토큰 위임 | setApprovalForAll | isApprovedForAll | _operatorApprovals | ApprovalForAll |
// 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{
string public override name;
string public override symbol;
mapping(address=>uint) private _balances; // 보유한 총 토큰 개수
mapping(uint=>address) private _owners; // 토큰 소유자 조회
mapping(uint=>address) private _tokenApprovals; // 토큰 대리인 조회
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 balance for the zero address");
return _balances[_owner];
}
// 특정 토큰 보유자 조회
function ownerOf(uint _tokenId) public override view returns(address){
address owner = _owners[_tokenId];
require(owner != address(0), "ERC721: no owner for this token");
return owner;
}
// 특정 id의 토큰을 다른 계정에 위임할때 사용하는 함수
function approve(address _to, uint _tokenId) external override {
address owner = _owners[_tokenId];
require(owner != _to, "ERC721: owner cannot self-approve");
require(msg.sender == owner || isApprovedForAll(owner, msg.sender));
// 해당 함수 실행자가 토큰 보유자이거나 전체 토큰들을 위임 받은 대리인일경우
// 전체 토큰을 위임받은 대리인은 다른 계정에게 하나의 토큰을 위임할 수 있다. (복대리)
_tokenApprovals[_tokenId] = _to;
emit Approval(owner, _to, _tokenId);
}
// 특정 id의 토큰이 위임된 대리 계정 조회
function getApproved(uint _tokenId) public override view returns(address) {
require(_owners[_tokenId] != address(0), "ERC721: no owner for this token");
return _tokenApprovals[_tokenId];
}
// owner(msg.sender)가 가진 모든 토큰을 operator 계정으로 다 위임하는 함수
function setApprovalForAll(address _operator, bool _approved) external override{
require(msg.sender != _operator, "ERC721: owner cannot be operator");
_operatorApprovals[msg.sender][_operator] = _approved;
emit ApprovalForAll(msg.sender, _operator, _approved);
}
// owner-operator간에 전체 위임된 토큰이 있는지 없는지 조회
function isApprovedForAll(address _owner, address _operator) public override view returns(bool){
return _operatorApprovals[_owner][_operator];
}
//
approve 함수에서 주목할 부분은 두번째 require문이다.
require(msg.sender == owner || isApprovedForAll(owner, msg.sender))
위임 함수 approve 실행자(msg.sender)가 해당 토큰 소유자이거나, 토큰 소유자가 전체 위임을 준 대리 CA 일 경우여야 한다.
해당 토큰 소유자가 토큰을 특정 계정에 위임해주는 건 당연한 부분이다. ERC-721에서의 특이한 점은 토큰 소유자에게 전체 위임을 받은 대리 계정 또한 특정 계정으로 토큰을 위임할 수 있다는 점이다. (복대리) 다만, 전체 토큰은 아니고 하나의 토큰만을 위임할 수 있다.
이제, 본격적으로 토큰 전송 및 민팅 관련 함수들을 추가 작성해야한다.
그런데 자세히 보면 현재 ERC721 컨트랙트 상에는 특정 계정이 가지고 있는 토큰 아이디 리스트를 뽑아낼 수 없다. 오픈씨에서 내 계정으로 들어가면 내가 발행하거나 보유한 nft를 쫙 볼 수가 있는데 이렇게 내가 보유한 nft 리스트를 조회할 수 있는 기능이 있어야 한다.
또한, 각 토큰마다 id를 자동으로 생성해주는 함수 또한 존재해야한다. 이런 기능들은 별도의 컨트랙트로 따로 빼주어서 ERC721 컨트랙트와 상호작용하는 방식으로 가야한다. 그래서 만든 컨트랙트가 ERC721Enumerable이다!
4/ ERC721Enumerable 작성
해당 컨트랙트는 enumerable 이름그대로 숫자, 열거하는 것과 관련이 있다. 아래 2가지 기능을 중점적으로 함수를 작성해나간다.
- 특정 계정이 보유한 NFT 리스트 조회 및 토큰 전송 시 토큰 목록과 인덱스 수정
- 토큰마다 각 토큰의 고유값(tokenId) 자동 생성
우선, ERC721 의 속성을 가져가므로 이를 상속해와서 기본적인 call 함수들을 만들어주었다.
계정별로 토큰 리스트 내의 인덱스값을 사용하여 토큰아이디를 조회하거나, 전체 토큰 리스트 상에서 인덱스로 토큰 아이디 조회 등..
이때, ERC721 컨트랙트를 import해와서 상속했기때문에 사실상 ERC721의 함수를 그대로 갖다 쓸 수 있는데, 이렇게 컨트랙트 내에서 상속받은 자신의 view 함수를 호출하면 가스비가 나간다. 따라서, 아래 tokenOfOwnerByIndex() 함수 상에서 owner의 토큰 전체 밸런스를 구해올 때, 이미 ERC721을 상속했기에 balanceOf(_owner)를 그대로 쓸수도 있겠지만, 대신 ERC721.balanceOf(_owner)로 써주면 상속한 컨트랙트가 아닌 다른 컨트랙트의 함수를 쓰는 꼴이 되므로 가스비를 절약할 수 있다!
// truffle/contracts/ERC721/ERC721Enumerable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import "./ERC721.sol";
contract ERC721Enumerable is ERC721{
uint[] private _allTokens; // 모든 토큰아이디 리스트
mapping(address => mapping(uint => uint)) private _ownedTokens;
// 계정으로 아이디 => 토큰리스트 상의 인덱스로 토큰 아이디 조회
mapping(uint => uint) private _ownedTokensIndex;
// 토큰 아이디로 토큰리스트 상 인덱스 조회
constructor(string memory _name, string memory _symbol ) ERC721 (_name, _symbol){}
// 토큰 총 보유량 조회
function totalSupply() public view returns(uint){
return _allTokens.length;
}
// 계정별 토큰 리스트 내 인덱스별 토큰 아이디 조회
function tokenOfOwnerByIndex(address _owner, uint _index) public view returns(uint){
require(_index < ERC721.balanceOf(_owner));
return _ownedTokens[_owner][_index];
}
// 전체 토큰 리스트 상에서 인덱스로 토큰아이디 조회
function tokenByIndex(uint _index) public view returns(uint){
require(_index < _allTokens.length);
return _allTokens[_index];
}
}
그런데 만약 보유한 토큰 리스트 상에서 특정 토큰을 다른 계정으로 전송할 경우 새로운 배열이 만들어져야한다. 맨 마지막 토큰이 빠지는건 다른 토큰의 인덱스에 영향을 주지 않지만, 맨 처음이나 중간의 토큰을 빼서 전송한다면, 다른 토큰들의 인덱스에 영향을 준다. 따라서, 토큰 전송 시 다른 토큰들의 인덱스에 최대한 영향을 주지 않게끔 효율적으로 리스트에서 빼서 전송해줘야한다.
이 점을 염두에 두고 계정별 토큰 리스트를 나타내는 _ownedTokens 와 토큰 아이디별 인덱스 리스트를 나타내는 _ownedTokensIndex 를 업데이트해주는 함수를 추가로 만들어준다. 그게 바로 _beforeTokenTransfer() 이다.
_beforeTokenTransfer()
해당 _beforeTokenTransfer() 함수는 ERC721 컨트랙트 상의 _mint() 함수와 transferFrom() 함수에서 아래와 같이 다르게 작동한다. 즉, 토큰을 새로 발행할때와 발급된 토큰을 다른 계정으로 전송할 때 다르게 작동해야한다.
- ERC721 / _mint 함수는 가지고 있는 토큰의 전송이 아니라 새로운 발행이므로 전체 발행된 토큰 리스트에 추가만 해주면 된다!
- ERC721 / transferFrom 함수의 기능은 가지고 있는 토큰을 다른 계정에 전송하는 것이므로, 전송 계정과 수취 계정의 토큰 리스트를 둘 다 조절해줘야한다.
따라서, ERC721 내에서 _beforeTokenTransfer() 함수는 virtual 함수로 미리 정의만 해주고, 각 mint 및 transferFrom 함수 내에서 시점만 잡아서 실행시켜준다. 함수의 바디 내용은 자식 컨트랙트인 ERC721Enumerable에서 작성하는 것이다.
// truffle/contracts/ERC721/ERC721.sol
function _beforeTokenTransfer(address _from, address _to, uint tokenId) internal virtual{}
// truffle/contracts/ERC721/ERC721Enumerable.sol
function _beforeTokenTransfer(address _from, address _to, uint _tokenId) internal override{
// mint 함수 실행 시
if(_from == address(0)){
_allTokens.push(_allTokens.length);
// transferFrom 함수 실행 시
} else{
uint latestTokenIndex = ERC721.balanceOf(_from) - 1; // 마지막 토큰 인덱스
uint tokenIndexForTransfer = _ownedTokensIndex[_tokenId]; // 전송할 토큰의 인덱스
// 전송할 토큰이 마지막 토큰이 아닐경우
if(tokenIndexForTransfer != latestTokenIndex){
uint latestTokenId = _ownedTokens[_from][latestTokenIndex];
// 전송할 계정의 마지막 토큰 아이디 가져옴
_ownedTokens[_from][tokenIndexForTransfer] = latestTokenId;
// 전송 계정의 전송할 토큰의 인덱스 자리에 위에서 가져온 마지막 토큰 아이디를 넣어주고
_ownedTokensIndex[latestTokenId] = tokenIndexForTransfer;
// 토큰 인덱스 리스트 상에서 마지막 토큰 아이디의 인덱스 자리를 전송할 토큰의 인덱스 자리로 바꿔준다.
}
delete _ownedTokens[_from][latestTokenIndex];
// 전송 계정의 토큰 리스트 상에 남아있는 마지막 토큰 아이디를 지워준다.
// (왜냐면 위에서 전송할 토큰의 인덱스 자리에 마지막 토큰 아이디값을 덮어씌웠기 때문에
// 마지막 토큰 아이디값은 전송할 토큰의 인덱스 자리 + 리스트의 마지막 인덱스 자리에 중복되게 존재하기 때문이다.)
delete _ownedTokensIndex[_tokenId];
// 토큰 인덱스 리스트 상에서 전송할 토큰의 인덱스를 인덱스 리스트 상에서 지워준다.
}
// 전송받은 계정 (_to)의 토큰 리스트에 전송받은 토큰 추가해주기
uint length = ERC721.balanceOf(_to); // 수취계정의 토큰 개수 (추가될 토큰의 인덱스자리)
_ownedTokens[_to][length] = _tokenId; // 수취계정의 마지막 자리에 전송한 토큰 아이디 추가
_ownedTokensIndex[_tokenId] = length; // 해당 토큰 아이디의 인덱스 변경
}
위의 토큰 전송 시 코드를 간단히 설명하자면, 전송 계정의 전송할 토큰 인덱스 자리에 리스트의 마지막 토큰 아이디를 가져와 덮어씌우고 그 전송할 토큰 아이디를 수취 계정의 마지막 배열에 넣는 과정인 것이다.
좀 더 자세히 설명, 계정 1의 tk2를 계정 2에게 전송한다고 가정하자. 아래의 step1 => step6까지의 흐름을 보면 이해 삽가능
계정별 토큰아이디 리스트인 _ownedTokens의 인덱스가 꼬이지 않게끔 토큰이 이동했단걸 확인 할 수 있다.
// 계정1의 tk2를 계정 2에게 전송 시
// 마지막 토큰 인덱스 : 3
// 전송할 토큰 인덱스 : 1
// 마지막 토큰 아이디 : tk6
const _ownedTokens = {
계정1: {
0: "tk1",
// 1: "tk2",
1: "tk6", // step1 : 1번 자리에 tk6를 덮어 넣어줌
2: "tk3",
// 3: "tk6", // step3 : 3번 자리를 지워준다!
},
계정2: {
0: "tk4",
1: "tk5",
2: "tk2", // step5 : 계정 1의 tk2를 받아 마지막 자리에 껴주기
},
};
const _ownedTokensIndex = {
tk1: 0,
// tk2: 1, // step4 : tk2를 지워준다!
tk3: 2,
tk4: 0,
tk5: 1,
// tk6: 3,
tk6: 1, // step2 : tk6의 인덱스를 1로 바꿔줌
tk2: 2, // step6 : 받은 tk2의 인덱스를 계정2의 토큰리스트 인덱스로 수정!
};
mint() 함수
상속받은 컨트랙트인 ERC721의 _mint 함수 실행을 위한 함수이다.
또한, tokenId 값을 자동으로 생성해준다! (전체 토큰 길이 == 기존 토큰 리스트에 추가될 새로운 토큰의 인덱스)
ex) 기존 토큰 리스트 = [ 1, 2, 3 ] 가 있다면 _allTokens.length는 3이고, 인덱스는 각각 0, 1, 2로 다음 토큰이 추가되면 그 새로운 토큰의 인덱스는 현재 토큰 리스트의 길이가 되는 것이다.
function mint(address _to) public {
_mint(_to, _allTokens.length);
}
5/ ERC-721 작성 - 토큰 전송과 민팅에 대한 함수 추가 작성
- 토큰 전송 : TransferFrom()
ERC-20 에선 토큰 전송과 관련된 함수를 두 종류로 작성했었다. 하나는 계정간의 기본적인 토큰 전송 역할의 transfer() 함수, 또 다른 하나는 위임받은 계정이 다른 계정으로 토큰을 전송해주는 역할의 transferFrom() 함수였다.
ERC-721 에선 transfer와 transferFrom을 하나로 합쳐 transferFrom() 로 작성하였다.
ERC-721에선 토큰 전송 주체가 아래와 같이 3개의 케이스이다. 토큰 전송 주체가 이 셋 중 하나이면 토큰을 전송해도 되므로 이 조건에 대한 함수를 따로 빼주었다. (isApprovedOrOwner)
- 토큰 전송 주체가 owner (실제 토큰 소유자)일 경우
- 토큰 전송 주체가 operater (전체 토큰 대리인)일 경우
- 토큰 전송 주체가 approvedAccount (한 토큰에 대한 대리인)일 경우
function isApprovedOrOwner(address _spender, uint _tokenId) private view returns(bool){
address owner = _owners[_tokendId];
require(owner != address(0));
return (_spender == owner || isApprovedForAll(owner, _spender) || getApproved(_tokenId) == _spender);
}
위 함수를 사용해서 토큰 전송 주체가 맞는지 확인하고나서, 토큰 전송 관련 코드를 작성해주면 된다.
해당 함수 내에서 _beforeTokenTransfer 함수도 실행시켜 토큰 리스트를 업데이트 해주었다.
function transferFrom(address _from, address _to, uint _tokenId) external override{
require(isApprovedOrOwner(_from, _tokenId)); // 토큰 전송 주체 미리 체크
require(_from != _to); // 전송(소유) 계정, 수취 계정이 다른지 확인
_beforeTokenTransfer(_from, _to, _tokenId); // 토큰 리스트 업데이트 함수 추가
_balances[_from] -= 1;
_balances[_to] += 1;
_owners[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
- 토큰 발행 : _mint()
현재 토큰 아이디의 보유자가 없을 때 실행, 즉 새로운 토큰의 발행을 의미한다.
여기서 쓰인 _beforeTokenTransfer의 인자를 보면 TransferFrom와 달리 from의 인자가 없다. 특정 계정에서 다른 계정에서 전송을 하는 개념이 아니기 때문에 from에 해당하는 계정이 없어서이다. 이렇게 같은 함수여도 인자에 따라 다르게 동작하는 것이다.
function _mint(address _to, uint _tokenId) public {
require(_to != address(0), "ERC721 : there's no recipient");
address owner = _owners[_tokenId];
require(owner == address(0));
// 현재 토큰에 owner 가 없을 때 / 새로운 발행
_beforeTokenTransfer(address(0), _to, _tokenId);
_balances[_to] += 1;
_owners[_tokenId] = _to;
emit Transfer(address(0), _to, _tokenId);
}
** 토큰의 소유계정 조회 시 만들어둔 ownerOf() 함수 대신 _owners 상태변수를 쓴 이유
1. 같은 컨트랙트 안에서 작성된 view 함수 호출은 가스비를 내야한다.
반면, 다른 컨트랙트 안에서 선언한 view 함수를 호출하는 건 가스비가 들지 않는다.
2. 실제로 토큰의 오너가 없어야만 _mint 함수가 본격적으로 작동되어 mint를 요청한 계정에 토큰을 발행해주는 방식인데, _owners 상태변수는 특정 토큰 아이디에 해당하는 오너가 없다면 address(0)을 반환하기 때문에 그 아래 코드들이 의도한대로 실행되는 반면, ownerOf()는 에러를 발생시켜 _mint함수 자체가 작동하지 않고 종료되기 때문이다.
'BlockChain' 카테고리의 다른 글
POA 기반 프라이빗 네트워크 구축 (feat. docker) (2) | 2022.08.13 |
---|---|
ERC721 / nft 민팅, 거래 기능 컨트랙트 (0) | 2022.08.02 |
ERC-721 / NFT / Remix로 컨트랙트 배포 및 실행 테스트 (0) | 2022.07.27 |
ether-token 스왑 / 토큰발행 컨트랙트+스왑전용 컨트랙트 (0) | 2022.07.25 |
ERC-20 나만의 토큰 작성 / fallback, receive? / 이더로 토큰 사기 (0) | 2022.07.22 |