이더리움에서 토큰은 그냥 “몇 가지의 공통 규칙을 따르는 smart contract”입니다. 모든 토큰 턴트랙트가 공유하는 표준 함수들이 있고 이를 따르는 형태라고 생각하시면 됩니다. 이때 내부적으로 스마트 컨트랙트 안에 매핑을 통해 각 주소가 얼마만큼의 잔액을 보유하고 있는지 추적할 수 있습니다.
즉 토큰은 아래의 조합이라고 할 수 있습니다.
이때 ERC20 토큰들은 동일한 이름을 가진 동일한 함수를 공유하기 때문에 서로 같은 방식으로 상호작용할 수 있습니다. 그렇게 되면 하나의 ERC 20 토큰과 상호작용할 수있는 앱을 만들면, 그 앱은 다른 모든 ERC20 토큰과도 상호작용할 수 있게 됩니다. 따라서 새로운 토큰을 추가하기도 쉽습니다.
거래소에서 특히 이러한 기능을 매우 잘 활용하고 있습니다. 전송 로직을 한번만 잘 구현하면 기존 코드의 변경 없이 새로운 ERC20 토큰을 추가하기만 하면 되기 때문입니다.
ERC20 이외에도 다양한 토큰 방식이 있습니다. 대표적인 것이 erc721 토큰입니다. ERC721 토큰은 발행된 각각이 고유하다고 간주되므로 서로 교환하거나 분할할 수 없습니다. 오로지 단일 단위로만 거래할 수 있고 고유한 id를 가지고 있습니다.
좀비게임을 만들 때에는 ERC721 방식을 사용하는 것이 훨씬 유리합니다.
따라서 ERC721 방식이 좀비 게임에 훨씬 더 적합함을 알 수 있습니다. ERC721 표준을 사용하면 전송 등의 로직을 직접 구현하지 않아도 된다는 장점이 있습니다. 또한 다른사람이 ERC721 자산을 위한 거래 플랫폼을 구축했을 때 우리가 만든 좀비도 올려놓을 수 있다는 장점도 있고요. 따라서 직접 거래 로직을 만들지 않고 ERC721 표준을 사용하여 구현해봅시다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
contract ZombieOwnership is ZombieAttack {
}
현재 ERC721 표준은 아래와 같습니다. 아래 메서드를 컨트랙트에 구현하면 됩니다.
contract ERC721 {
event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
function balanceOf(address _owner) external view returns (uint256);
function ownerOf(uint256 _tokenId) external view returns (address);
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
function approve(address _approved, uint256 _tokenId) external payable;
}
토큰 컨트랙트를 구현할 때 먼저 ERC721 표준에 대한 값을 import해온 뒤 override해야 합니다. 하지만 이미 ZombieAttack을 상속하고 있는 상황에서 ERC721을 한번 더 상속받을 수 있을까요? 다행히도 솔리디티에서는 다중 상속을 지원합니다.
contract SatoshiNakamoto is NickSzabo, HalFinney {
// Omg, the secrets of the universe revealed!
}
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
// Import file here
import "./erc721.sol";
// Declare ERC721 inheritance here
contract ZombieOwnership is ZombieAttack, ERC721 {
}
이제 ERC721 표준에 맞게 구현해봅시다.
balanceOf는 address에 해당하는 지갑이 얼마나 많은 토큰을 가지고 있는지를 출력하는 함수입니다. 우리의 게임에서 token은 좀비와 같습니다.
ownerOf는 주어진 토큰의 ID를 가지고 있는 지갑이 어디있는지를 알려주는 함수입니다. 우리는 이미 DApp 안에서 mapping을 사용해서 정보를 저장하고 있기 때문에 balanceOf나 ownerOf를 쉽게 구현할 수 있습니다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
import "./erc721.sol";
contract ZombieOwnership is ZombieAttack, ERC721 {
function balanceOf(address _owner) external view returns (uint256) {
// 1. Return the number of zombies `_owner` has here
return ownerZombieCount[_owner];
}
function ownerOf(uint256 _tokenId) external view returns (address) {
// 2. Return the owner of `_tokenId` here
return zombieToOwner[_tokenId];
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
}
function approve(address _approved, uint256 _tokenId) external payable {
}
}
실제로 파일을 컴파일해보면 에러가 나타납니다. 이전 챕터에서 ZombieFeeding에서 ownerOf라는 함수를 선언한 적이 있기 때문입니다. ERC721 표준을 지키기 위해서는 기존의 modifier 함수명을 수정해야 합니다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombiefactory.sol";
contract KittyInterface {
function getKitty(uint256 _id) external view returns (
bool isGestating,
bool isReady,
uint256 cooldownIndex,
uint256 nextActionAt,
uint256 siringWithId,
uint256 birthTime,
uint256 matronId,
uint256 sireId,
uint256 generation,
uint256 genes
);
}
contract ZombieFeeding is ZombieFactory {
KittyInterface kittyContract;
// 1. Change modifier name to `onlyOwnerOf`
modifier onlyOwnerOf(uint _zombieId) {
require(msg.sender == zombieToOwner[_zombieId]);
_;
}
function setKittyContractAddress(address _address) external onlyOwner {
kittyContract = KittyInterface(_address);
}
function _triggerCooldown(Zombie storage _zombie) internal {
_zombie.readyTime = uint32(now + cooldownTime);
}
function _isReady(Zombie storage _zombie) internal view returns (bool) {
return (_zombie.readyTime <= now);
}
// 2. Change modifier name here as well
function feedAndMultiply(uint _zombieId, uint _targetDna, string memory _species) internal onlyOwnerOf(_zombieId) {
Zombie storage myZombie = zombies[_zombieId];
require(_isReady(myZombie));
_targetDna = _targetDna % dnaModulus;
uint newDna = (myZombie.dna + _targetDna) / 2;
if (keccak256(abi.encodePacked(_species)) == keccak256(abi.encodePacked("kitty"))) {
newDna = newDna - newDna % 100 + 99;
}
_createZombie("NoName", newDna);
_triggerCooldown(myZombie);
}
function feedOnKitty(uint _zombieId, uint _kittyId) public {
uint kittyDna;
(,,,,,,,,,kittyDna) = kittyContract.getKitty(_kittyId);
feedAndMultiply(_zombieId, kittyDna, "kitty");
}
}
ERC721에서 transfer 관련 함수들을 모두 구현해봅시다.
transferFrom은 토큰을 소유하고 있는 지갑에서 호출합니다. 이때 내 지갑에서 상대 지갑에 어떤 토큰을 보낼지 선택할수 있습니다.
transferFrom을 호출하기 전에 approve를 띄우기도 합니다. approve 함수에서는 송금을 원하는 지갑과 보낼 토큰 ID를 보내볼 수 있습니다. 함수 안에서는 토큰을 가져갈 권한이 부여된 주소를 mapping으로 저장합니다. 이후에 transferFrom이 호출되면 msg.sender가 소유자 또는 그 소유자로부터 토큰을 가져갈 승인을 받은 주소인지 확인하고 토큰을 전송합니다.
이 방법을 사용하면 첫번째에서는 토큰을 보내는 사람이 trasferFrom을 전송할 수있고, 두번째에서는 승인을 받은 소유자 또는 승인된 수신자가 호출할 수 있습니다.
따라서 내부적인 mapping을 변경하는 것은 _transfer로 추상화하고, transferFromd에서 _transfer를 호출하도록 하는 것이 합리적입니다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
import "./erc721.sol";
contract ZombieOwnership is ZombieAttack, ERC721 {
function balanceOf(address _owner) external view returns (uint256) {
return ownerZombieCount[_owner];
}
function ownerOf(uint256 _tokenId) external view returns (address) {
return zombieToOwner[_tokenId];
}
// Define _transfer() here
function _transfer(address _from, address _to, uint256 _tokenId) private {
ownerZombieCount[_to]++;
ownerZombieCount[_from]--;
zombieToOwner[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
}
function approve(address _approved, uint256 _tokenId) external payable {
}
}
이제 transferFrom을 구현해봅시다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
import "./erc721.sol";
contract ZombieOwnership is ZombieAttack, ERC721 {
// 1. Define mapping here
mapping (uint => address) zombieApprovals;
function balanceOf(address _owner) external view returns (uint256) {
return ownerZombieCount[_owner];
}
function ownerOf(uint256 _tokenId) external view returns (address) {
return zombieToOwner[_tokenId];
}
function _transfer(address _from, address _to, uint256 _tokenId) private {
ownerZombieCount[_to]++;
ownerZombieCount[_from]--;
zombieToOwner[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
// 2. Add the require statement here
require(zombieToOwner[_tokenId] == msg.sender || zombieApprovals[_tokenId] == msg.sender);
// 3. Call _transfer
_transfer(_from, _to, _tokenId);
}
function approve(address _approved, uint256 _tokenId) external payable {
}
}
approve를 구현해봅시다. 먼저 소유자만 approve를 호출할 수 있어야 합니다. 이후 approved된 주소를 mapping에 저장합니다. 이후 새로운 주인이 transferFrom을 호출하면 approved된 주소에 등록되어 있는지 확인하고 토큰을 이전합니다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
import "./erc721.sol";
contract ZombieOwnership is ZombieAttack, ERC721 {
mapping (uint => address) zombieApprovals;
function balanceOf(address _owner) external view returns (uint256) {
return ownerZombieCount[_owner];
}
function ownerOf(uint256 _tokenId) external view returns (address) {
return zombieToOwner[_tokenId];
}
function _transfer(address _from, address _to, uint256 _tokenId) private {
ownerZombieCount[_to]++;
ownerZombieCount[_from]--;
zombieToOwner[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
require (zombieToOwner[_tokenId] == msg.sender || zombieApprovals[_tokenId] == msg.sender);
_transfer(_from, _to, _tokenId);
}
// 1. Add function modifier here
function approve(address _approved, uint256 _tokenId) external payable onlyOwnerOf(_tokenId){
// 2. Define function here
zombieApprovals[_tokenId] = _approved;
}
}
approve가 되었을 때에는 approve event가 발생해야 합니다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
import "./erc721.sol";
contract ZombieOwnership is ZombieAttack, ERC721 {
mapping (uint => address) zombieApprovals;
function balanceOf(address _owner) external view returns (uint256) {
return ownerZombieCount[_owner];
}
function ownerOf(uint256 _tokenId) external view returns (address) {
return zombieToOwner[_tokenId];
}
function _transfer(address _from, address _to, uint256 _tokenId) private {
ownerZombieCount[_to]++;
ownerZombieCount[_from]--;
zombieToOwner[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
require (zombieToOwner[_tokenId] == msg.sender || zombieApprovals[_tokenId] == msg.sender);
_transfer(_from, _to, _tokenId);
}
function approve(address _approved, uint256 _tokenId) external payable onlyOwnerOf(_tokenId) {
zombieApprovals[_tokenId] = _approved;
//Fire the Approval event here
emit Approval(msg.sender, _approved, _tokenId);
}
}
이제 ERC721 표준 구현을 완료했습니다. 많은 이더리움 앱들이 이러한 과정을 거쳐서 구현됩니다. 하지만 가장 간단한 구현을 보여준 것이고, 몇가지 더 추가해야 할 구현들이 있습니다. 사용자의 실수를 잡고 방지하거나 경매 로직을 추가하는 등의 구현도 필요할 수 있습니다.
contract의 보안을 높이기 위해서는 overflow나 underflow를 고려해야 합니다. OpenZepplin에서는 이러한 오버플로우나 언더플로우를 방지할 수 있는 SateMath를 지원합니다.
using SafeMath for uint256;
uint256 a = 5;
uint256 b = a.add(3); // 5 + 3 = 8
uint256 c = a.mul(2); // 5 * 2 = 10
pragma solidity >=0.5.0 <0.6.0;
import "./ownable.sol";
// 1. Import here
import "./safemath.sol";
contract ZombieFactory is Ownable {
// 2. Declare using safemath here
using SafeMath for uint256;
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
uint cooldownTime = 1 days;
struct Zombie {
string name;
uint dna;
uint32 level;
uint32 readyTime;
uint16 winCount;
uint16 lossCount;
}
Zombie[] public zombies;
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
function _createZombie(string memory _name, uint _dna) internal {
uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime), 0, 0)) - 1;
zombieToOwner[id] = msg.sender;
ownerZombieCount[msg.sender]++;
emit NewZombie(id, _name, _dna);
}
function _generateRandomDna(string memory _str) private view returns (uint) {
uint rand = uint(keccak256(abi.encodePacked(_str)));
return rand % dnaModulus;
}
function createRandomZombie(string memory _name) public {
require(ownerZombieCount[msg.sender] == 0);
uint randDna = _generateRandomDna(_name);
randDna = randDna - randDna % 100;
_createZombie(_name, randDna);
}
}
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는 contract와 비슷하지만 using 키워드를 사용할 수 있습니다. using 키워드를 사용하면 라이브러리에서 정의한 모든 메서드를 다른 데이터타입에 자동으로 붙여줍니다.
using SafeMath for uint;
// 이제 모든 uint에서 이 메서드들을 사용할 수 있습니다
uint test = 2;
test = test.mul(3);
test = test.add(5);
assert는 require과 비슷하게 조건이 거짓이면 에러를 발생시킵니다. 단 require에서는 함수가 실패했을 때 남은 가스를 사용자에게 환불해주지만, assert에서는 그렇지 않습니다. 따라서 정말 심각한 문제에 대해서는 assert, 그렇지 않은 대부분의 경우에서는 require를 사용합니다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
import "./erc721.sol";
import "./safemath.sol";
contract ZombieOwnership is ZombieAttack, ERC721 {
using SafeMath for uint256;
mapping (uint => address) zombieApprovals;
function balanceOf(address _owner) external view returns (uint256) {
return ownerZombieCount[_owner];
}
function ownerOf(uint256 _tokenId) external view returns (address) {
return zombieToOwner[_tokenId];
}
function _transfer(address _from, address _to, uint256 _tokenId) private {
// 1. Replace with SafeMath's `add`
ownerZombieCount[_to] = ownerZombieCount[_to].add(1);
// 2. Replace with SafeMath's `sub`
ownerZombieCount[_from] = ownerZombieCount[_from].sub(1);
zombieToOwner[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
require (zombieToOwner[_tokenId] == msg.sender || zombieApprovals[_tokenId] == msg.sender);
_transfer(_from, _to, _tokenId);
}
function approve(address _approved, uint256 _tokenId) external payable onlyOwnerOf(_tokenId) {
zombieApprovals[_tokenId] = _approved;
emit Approval(msg.sender, _approved, _tokenId);
}
}
현재 winCount, lossCount 등도 overflow나 underflow를 방지해야 합니다. 하지만 uint256이 아닌 상황에서 사용할 수 있을까요? 이를 위해서는 자료형마다 SafeMath를 적용해줘야 합니다.
pragma solidity >=0.5.0 <0.6.0;
import "./ownable.sol";
import "./safemath.sol";
contract ZombieFactory is Ownable {
using SafeMath for uint256;
// 1. Declare using SafeMath32 for uint32
using SafeMath32 for uint32;
// 2. Declare using SafeMath16 for uint16
using SafeMath16 for uint16;
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
uint cooldownTime = 1 days;
struct Zombie {
string name;
uint dna;
uint32 level;
uint32 readyTime;
uint16 winCount;
uint16 lossCount;
}
Zombie[] public zombies;
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
function _createZombie(string memory _name, uint _dna) internal {
// Note: We chose not to prevent the year 2038 problem... So don't need
// worry about overflows on readyTime. Our app is screwed in 2038 anyway ;)
uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime), 0, 0)) - 1;
zombieToOwner[id] = msg.sender;
// 3. Let's use SafeMath's `add` here:
ownerZombieCount[msg.sender] = ownerZombieCount[msg.sender].add(1);
emit NewZombie(id, _name, _dna);
}
function _generateRandomDna(string memory _str) private view returns (uint) {
uint rand = uint(keccak256(abi.encodePacked(_str)));
return rand % dnaModulus;
}
function createRandomZombie(string memory _name) public {
require(ownerZombieCount[msg.sender] == 0);
uint randDna = _generateRandomDna(_name);
randDna = randDna - randDna % 100;
_createZombie(_name, randDna);
}
}
나머지도 모두 수정해봅시다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombiehelper.sol";
contract ZombieAttack is ZombieHelper {
uint randNonce = 0;
uint attackVictoryProbability = 70;
// 상속받은 곳에서 이미 선언했으므로 따로 다시 선언할 필요 없음.
// using SafeMath for uint;
// using SafeMath32 for uint32;
// using SafeMath16 for uint16;
function randMod(uint _modulus) internal returns(uint) {
// Here's one!
randNonce = randNonce.add(1);
return uint(keccak256(abi.encodePacked(now, msg.sender, randNonce))) % _modulus;
}
function attack(uint _zombieId, uint _targetId) external onlyOwnerOf(_zombieId) {
Zombie storage myZombie = zombies[_zombieId];
Zombie storage enemyZombie = zombies[_targetId];
uint rand = randMod(100);
if (rand <= attackVictoryProbability) {
// Here's 3 more!
myZombie.winCount = myZombie.winCount.add(1);
myZombie.level = myZombie.level.add(1);
enemyZombie.lossCount = enemyZombie.lossCount.add(1);
feedAndMultiply(_zombieId, enemyZombie.dna, "zombie");
} else {
// ...annnnd another 2!
myZombie.lossCount = myZombie.lossCount.add(1);
enemyZombie.winCount = enemyZombie.winCount.add(1);
_triggerCooldown(myZombie);
}
}
}
솔리디티에는 주석을 달 수 있습니다. // 를 사용하면 한 줄짜리 주석을 달 수 있습니다. 더 긴 주석을 달기 위해서는 /* */ 를 사용하면 됩니다.
또한 matspec이라는 주석 방식도 있습니다. @title @author @notice @param @return 등을 사용하면 주어진 함수에 대한 다양한 설명을 작성할 수 있습니다.
pragma solidity >=0.5.0 <0.6.0;
import "./zombieattack.sol";
import "./erc721.sol";
import "./safemath.sol";
/// TODO: Replace this with natspec descriptions
// @title A contract that manages transferring zombie ownership
// @author Yeonu-Kim
// @dev Compliant with OpenZeppelin's implementation of the ERC721 spec draft
contract ZombieOwnership is ZombieAttack, ERC721 {
using SafeMath for uint256;
mapping (uint => address) zombieApprovals;
function balanceOf(address _owner) external view returns (uint256) {
return ownerZombieCount[_owner];
}
function ownerOf(uint256 _tokenId) external view returns (address) {
return zombieToOwner[_tokenId];
}
function _transfer(address _from, address _to, uint256 _tokenId) private {
ownerZombieCount[_to] = ownerZombieCount[_to].add(1);
ownerZombieCount[msg.sender] = ownerZombieCount[msg.sender].sub(1);
zombieToOwner[_tokenId] = _to;
emit Transfer(_from, _to, _tokenId);
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
require (zombieToOwner[_tokenId] == msg.sender || zombieApprovals[_tokenId] == msg.sender);
_transfer(_from, _to, _tokenId);
}
function approve(address _approved, uint256 _tokenId) external payable onlyOwnerOf(_tokenId) {
zombieApprovals[_tokenId] = _approved;
emit Approval(msg.sender, _approved, _tokenId);
}
}