대망의 크립토 좀비 lesson5 사실 회사에서 NFT 스터디를 하는데..이것저것 조금씩 기웃기웃 공부하다가 크립토 좀비에 정착했다. 여기에 ERC721 & 크립토 수집품 코스가 있기때문이다.
물론 이더리움이나 클레이튼 독스를 보면 코드에 대한 설명이 잘 나와있다. ERC20은 클레이튼공식문서에 나와있는걸 하나하나 보기도 했다. 하지만 크립토 좀비가 너~무 너무 설명이 잘 나와있어서 이해하기가 훨씬 좋을거같다! 이거 한 번해보고 독스 파먹기도 해야겠다~
이더리움 세상을 조금이라도 접한 적이 있다면, 사람들이 토큰에 대해 이야기 하는 것을 들어 봤을 수도 있다. 구체적으로는 ERC20
이더리움에서 토큰은 기본적으로 그저 몇몇 공통 규약을 따르는 스마트 컨트랙트이다. 즉 다른 모든 토큰 컨트랙트가 사용하는 표준 함수 집합을 구현하는 것이다.
예를 들면 transfer(address _to, uint256 _value)나 balanceOf(address _owner) 같은 함수들이 있다.
내부적으로 스마트 컨트랙트는 보통
mapping(address => uint256) balances 와 같은 매핑을 가지고 있다. 각각의 주소에 잔액이 얼마나 있는지 기록하는 것이다.
즉 기본적으로 토큰은 그저 하나의 컨트랙트 이다. 그 안에서 누가 얼마나 많은 토큰을 가지고 있는지 기록하고, 몇몇 함수를 가지고 사용자들이 그들의 토큰을 다른 주소로 전송할 수 있게 해준다.
모든 ERC20 토큰들이 똑같은 이름의 동일한 함수 집합을 공유하기 때문에, 이 토큰들에 똑같은 방식으로 상호작용이 가능하다.
즉 하나의 ERC20 토큰과 상호작용할 수 있는 애플리케이션을 하나 만들면, 이 앱이 다른 어떤 ERC20 토큰과도 상호작용이 가능하다. 이런 방식으로(그저 새로운 토큰의 컨트랙트 주소만 끼워넣으면) 앱에 더 많은 토큰들을 추가할 수 있다.
그러고나면 앱에서 사용할 수 있는 또 다른 토큰이 생기는 것이다.
한 예로 거래소가 있다. 한 거래소에서 새로운 ERC20 토큰을 상장할 때, 실제로는 이 거래소에서 통신이 가능한 또 하나의 스마트 컨트랙트를 추가하는 것이다. 사용자들은 이 컨트랙트에 거래소의 지갑 주소에 토큰을 보내라고 할 수 있고, 거래소에서는 이 컨트랙트에 사용자들이 출금을 신청하면 토큰을 다시 돌려보내라고 할 수 있게 만드는 것이다.
🙆♀️컨트랙트랑 사용자가 토큰을 주고 받는다!
거래소에서는 이 전송 로직을 한 번만 구현하면 된다. 그리고 새로운 ERC20 토큰을 추가하고 싶으면 데이터베이스에 단순히 새 컨트랙트 주소를 추가하기만 하면 된다.
ERC20 토큰은 화폐처럼 사용되는 토큰으로는 정말 적절하다. 하지만 우리의 크립토 좀비 게임에서 좀비를 표현 할 때에는 그다지 쓸모가 있지 않다.
첫째로, 좀비는 화폐처럼 분할 할 수 없다. 예를들어 0.237ETH를 보낼 수 있지만, 0.237개의 좀비를 보내는 것은 말이 안된다.
둘쨰로, 모든 좀비가 똑같지 않다. 레벨2 좀비 "Steve"와 레벨 723 좀비 "H4CF13LD MORRIS"와는 다르다.
여기에 크립토 좀비와 같은 크립토 수집품을 위해 더 적절한 표준이 있는데 바로 그이름도 유명한 ERC721토큰!
각각의 토큰이 유일하고 분할이 불가하다. 이 토큰을 하나의 전체 단위로만 거래할 수 있고, 각각의 토큰은 유일한 ID를 가지고 있다.
ERC721과 같은 표준을 사용하면 우리의 컨트랙트에서 사용자들이 우리의 좀비를 거래/판매할 수 있도록 하는 경매나 중계 로직을 우리가 직접 구현하지 않아도 된다는 이점이 있다. 우리가 스펙에 맞추기만 하면, 누군가 ERC721 자산을 거래할 수 있도록 하는 거래소 플랫폼을 만들면 우리의 ERC721 좀비들을 그 플랫폼에서 쓸 수 있게 될것 이다. 그러니 우리만의 거래로직을 만드느라 고생하는 것 보다 토큰 표준을 사용하는 것이 명확한 이점이 있다.👾
contract ERC721{
event Transfer(address indexed _from, address indexed _to, uint256 _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 _tokenId);
function balanceOf(address _owner) public view returns (uint256 _balance);
function ownerOf(uint256 _tokenId) public view returns (address _owner);
function transfer(address _to, uint256 _tokenId) public;
function approve(address _to, uint256 _tokenId) public;
function takeOwnership(uint256 _tokenId) public;
}
위의 표준은 ERC721 초안이고 공식 릴리즈가 아님!
토큰 컨트랙트를 구현할 때, 처음 해야할 일은 바로 인터페이스를 솔리디티 파일로 복사하여 저장하고 import "./erc721.sol";을 써서 임포트를 하는 것이다. 그리고 해당 컨트랙트를 상속하는 우리의 컨트랙트를 만들고, 각각의 함수를 오버라이딩하여 정의한다.
그런데 다수의 컨트랙트를 상속 할 수 있나? 운 좋게도 솔리디티에서는 다수의 컨트랙트를 상속할 수 있다!!
contract SatoshiNakamoto is NickSzabo, HalFinney {
}
balanceOf
function balanceOf(address _owner) public view returns (uint256 _balance);
이 함수는 단순히 address를 받아, 해당 address가 토큰을 얼마나 가지고 있는지 반환한다.
이 경우, 우리의 "토큰은" 은 좀비들이 된다. 우리의 DApp에서 어떤 소유자가 얼마나 많은 좀비를 가지는지 저장해놓은 곳을 기억하는가?
ownerOf
function ownerOf(uint256 _tokenId) public view returns (address _owner);
이 함수에서는 토큰ID(우리의 경우에는 좀비 ID)를 받아, 이를 소유하고 있는 사람의 address 를 반환한다.
이전챕터에서 ownerOf라는 modifier를 만들었었다. 이 코드를 컴파일하게 되면 컴파일러가 똑같은 이름의 제어자와 함수를 가질 수 없다고 에러를 보여줄 것이다.
그렇다면 ZombieOwnership contract의 ownerOf를 다른걸로 바꿔야할까?
정답은 그럴 수 없다 이다. 우리는 ERC721토큰 표준을 사용하고 있다. 이 말은 즉 다른 컨트랙트들이 우리의 컨트랙트가 정확한 이름으로 정의된 함수들을 가지고 있을것이라 예상한다는 것이다. 그게 바로 이런 표준이 유용하게끔 하는것이니!
만약 우리 컨트랙트는 ERC721따른 다는 것을 다른 컨트랙트가 안다면, 이 다른 컨트랙트는 우리의 내부 구현 로직을 모르더라도 우리와 통신 할 수 있다.
ERC721 스펙에서는 토큰을 전송할 때 2개의 다른 방식이 있다.
function transfer(address _to, uint256 _tokenId) public;
function approve(address _to, uint256 _tokenId) public;
function takeOwnership(uint256 _tokenId)public;
첫 번째 방법은 토큰의 소유자가 전송 상대의 address, 전송하고자 하는 _tokenId와 함께 transfer함수를 호출하는 것이다.
두 번째 방법은 토큰의 소유자가 먼저 위에서 본 정보들을 가지고 approve를 호출하는 것이다. 그리고 컨트랙트에 누가 해당 토큰을 가질 수 있도록 허가를 받았는지 저장한다. 보통 mapping(uint256 => address)써서 말이다. 이후 누군가 takeOwnership을 호출하면, 해당 컨트랙트는 이 msg.sender가 소유자로 부터 토큰을 받을 수 있게 허가를 받았는지 확인한다. 그리고 허가를 받았다면 해당 토큰을 그에게 전송한다.
transfer와 takeOwnership 모두 동일한 전송 로직이다. 순서만 반대인 것이다. (전자는 토큰을 보내는 사람이 함수를 호출하고 후자는 토큰을 받는 사람이 호출한다.)
그러니 이 로직만의 프라이빗 함수, _transfer를 만들어 추상화 하는 것이 좋다. 두 함수에서 모두 쓸 수 있도록 말이다. 이렇게 하면 똑같은 코드를 두 번씩 쓰지 않는다.
modifier onlyOwnerOf(uint _zombieId) {
require(msg.sender == zombieToOwner[_zombieId]);
_;
}
function _transfer(address _from, address _to, uint256 _tokenId) private {
ownerZombieCount[_to]++;
ownerZombieCount[_from]--;
zombieToOwner[_tokenId] = _to;
Transfer(_from, _to, _tokenId);
}
function transfer(address _to, uint256 _tokenId) public onlyOwnerOf(_tokenId) {
_transfer(msg.sender, _to, _tokenId);
}
approve/takeOwnership을 사용하는 전송은 2단계로 나뉜다.
1. 소유자가 새로운 소유자의 address와 그에게 보내고 싶은 _tokenId를 사용하여 approve를 호출한다.
이처럼 2번의 함수 호출이 발생하기 때문에, 우리는 함수 호출 사이에 누가 무엇에 대해 승인이 되었는지 저장할 데이터 구조가 필요하다.
function approve(address _to, uint256 _tokenId) public onlyOwnerOf(_tokenId) {
zombieApprovals[_tokenId] = _to;
Approval(msg.sender, _to, _tokenId);
}
마지막 함수인 takeOwnership에서는 msg.sender가 이 토큰/좀비를 가질 수 있도록 승인 되었는지 확인하고, 승인 되었다면 _transfer를 호출해야한다.
function takeOwnership(uint256 _tokenId) public {
require(zombieApprovals[_tokenId] == msg.sender);
address owner = ownerOf(_tokenId);
_transfer(owner, msg.sender, _tokenId);
}
사용자들이 의도치 않게 그들의 좀비를 0번 주소로 보내는것 (토큰을 태운다 (burning) 이라고 하는것이다- 이본적으로 누구도 개인키를 가지고 있지 않은 주소로 보내서 복구 할 수 없게 하는것)을 막기 위해 추가적인 확인을 할 수 있다.
더 깊이 있는 구현의 예시를 보기 위해서는 OpenZeppelin ERC721 컨트랙트 참고해라!
우리가 8비트의 데이터를 저장할 수 있는 uint8 하나를 가지고 있다고 해본다. 이말은 즉 우리가 저장할 수 있는 가장 큰 수는 이진수로 11111111 또는 십진수로 2^8 -1 = 255 가 된다.
다음 코드에서 마지막에 number의 값은 무엇이 될까?
uint8 number = 255;
number++;
이 예시에서 우리는 오버플로우를 만들었다. 즉 number가 직관과는 다르게 0이 된다. 11111111 + 1dms 00000000으로 돌아간다.
언더플로우는 이와 유사하게 0 값을 가진 uint8에서 1을 빼면, 255와 같아지는 것이다.(uint에 부호가 없어 음수가 될 수 없다)
우리가 여기서 uint8을 쓰지 않고, 1씩 증가시킨다고 uint256에 오버플로우가 발생하지는 않을것 같지만 미래의 우리 DApp에 예상치 못한 문제가 발생하지 않도록 여전히 우리의 컨트랙트에 보호 장치를 두는 것이 좋다.
이를 막기 위해, OpenZeppelin에서 기본적으로 이런 문제를 막아주는 SafeMath 라고 하는 라이브러리를 만들었다.
라이브러리(Library)는 솔리디티에서 특별한 종류의 컨트랙트 이다. 이게 유용하게 사용되는 경우 중 하나는 기본(native)데이터 타입에 함수를 붙일때 이다.
예를 들어, SafeMath 라이브러리를 쓸 때는 using SafeMath for uint256 이라는 구문을 사용할 것이다. SafeMath 라이브러리는 4개의 함수를 가지고 있다. add, sub, mul, div. 그리고 우리는 uint256에서 다음과 같이 함수들이 접근할 수 있다.
using SafeMath for uint256;
uint256 a =5;
uint256 b = a.add(3);
uint256 c = a.mul(2);
safeMath 내부의 코드 이다.
library SafeMath {
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal pure returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // There is no case in which this doesn't hold
return c;
}
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
assert(b <= a);
return a - b;
}
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
library 키워드는 cotract와 비슷하지만 조금 다른 점이 있다. 우리의 경우레 라이브러리는 우리가 using 키워드를 사용할 수 있게 해 준다. 이를 통해 라이브러리의 메소드들은 다른 데이터 타입에 적용 할 수 있다.
using safeMath for uint;
//우리는 이제 이 메소드들을 아무 uint에서나 쓸 수 있다.
uint test = 2;
test = test.mul(3);
test = test.add(5);
mul과 add 함수는 각각 2개의 인수를 필요로 한다는것에 주목해야한다. 하지만 우리가 using SafeMath for uint를 선언할 때, 우리가 함수를 적용하는 uint(test)는 첫 번째 인수로 자동으로 전달된다.
예시!
function add(uint256 a, uint256 b) internal pure returns(uint256){
uint256 c = a + b;
assert(c >= a);
return c;
}
기본적으로 add는 그저 2개의 uint를 더한다. 하지만 그 안에 assert 구문을 써서 그 합이 a 보다 크도록 보장한다. 이것이 오버 플로우를 막아준다.
assert 는 조건이 만족하지 않으면 에러를 발생시킨다는 점에서 require와 비슷하다. assert와 require의 차이점은, require는 함수실행이 실패하면 남은 가스를 사용자에게 되돌려 주지만, assert는 그렇지 않다. assert는 일반적으로 코드가 심각하게 잘못 실행될 때 사용한다.
간단히 말해, SafeMath의 add,sub,mul,div는 4개의 기본 수학 연산을 하는 함수 이지만, 오버플로우나 언더플로우가 발생하면 에러를 발생시킨다!
야호 lessong 5 까지 완료! 이제 복습좀하구 클레이튼 독스 openzepplin ERC721 코드를 좀보고! 사이드 프로젝트를 시작할 것이다~ 그 과정도 다 정리해 보야야지