[Lesson5] ERC721 & Crypto-Collectibles

Seokhun Yoon·2022년 3월 8일
0

[Solidity] CryptoZombie

목록 보기
5/5
post-thumbnail

ERC721 & Crypto-Collectibles

Crypto Zombie lesson5 링크
이번 레슨에서는 ERC20, ERC721 와 같은 여러 표준 규격에 대해서 알아볼 예정이다.

1. Tokens on Ethereum

이더리움에서 Token 이란 보통 공통 규칙을 따르는 스마트 컨트랙트를 일컫는다.
즉, 다른 토큰 컨트랙트와 공유하는 여러 표준 함수들을 구현한 것이라고 할 수 있다. (e.g. transfer(address _to, address _from, uint256 _value), balanceOf(address _owner))

스마트 컨트랙트는 내부적으로 보통 각 주소의 잔액을 기록하는 mapping(address => uint256) balances 와 같은 매핑을 가지고 있다.

요약하면 토큰은 그저 하나의 컨트랙트이며, 그 안에서 누가 얼마나 많은 토큰을 보유하고 있는지 기록하고, 몇몇 함수를 통해 사용자들이 자신의 토큰을 다른 주소로 전송할 수 있게 한다.

1-1. ERC20

모든 ERC20 토큰은 동일한 함수들을 공유하기 때문에 같은 방식으로 상호작용이 가능하다.

즉, 하나의ERC20 토큰과 상호작용 가능한 앱을 만들면, 이 앱으로 다른 어떤 ERC20 토큰과도 상호작용이 가능해진다. 따라서 커스텀 코드를 추가하지 않고도 토큰의 컨트랙트 주소만 넣으면 다른 토큰들을 손쉽게 추가할 수 있다.

그 대표적인 예로 거래소가 있다.
한 거래소에서 새로운 ERC20 토큰을 상장하는 것은 새로운 컨트랙트를 추가하는 것과 같다. 추가된 컨트랙트를 통해 사용자들은 해당 컨트랙트에서 거래소로 입금을 요청할 수 있고, 거래소는 토큰을 다시 사용자의 주소로 출금을 요청할 수 있게 된다.

거래소는 전송 로직을 오직 한번만 구현하면 되고, 새로운 ERC20 토큰을 추가하려면 그저 새로운 컨트랙트의 주소만 데이터베이스에 입력하면 된다.

1-2. Other token standards

ERC20는 화폐처럼 사용할 토큰에는 매우 좋지만, 우리가 사용할 좀비에는 어울리지 않다. 그 이유는 아래와 같다.

  • 첫째, 좀비는 화폐처럼 분할이 되지 않는다. (이더는 10^19까지 분할이 되지만 좀비는 쪼갤 수 없다)
  • 둘째, 모든 좀비는 다 똑같지 않다. (각자 다른 레벨, 이름, 그리고 DNA를 가진다.)

ERC721 토큰은 각각의 토큰이 유일하고 분할이 불가능하다. 이 토큰을 거래하려면 하나의 전체 단위로만 거래가 가능하며, 각각의 토큰은 유일한 ID를 가지고 있다.
따라서 우리는 ERC721 토큰을 좀비에 적용할 것이다!

NOTE_
ERC721과 같은 표준을 사용하면 우리 컨트랙트에서 토큰을 거래/판매할 수 있도록 경매나 중계 로직을 직접 구현하지 않아도 된다.
누군가 ERC721 자산을 거래할 수 있도록 하는 거래소 플랫폼을 만든다면, 이 플랫폼에서 우리가 만든 ERC721 좀비들을 거래할 수 있게 된다.
(대표적인 거래소로 오픈씨가 있다.)

이젠 직접 ERC721 토큰을 구현해보자.
모든 ERC721을 담아둘 ZombieOwnership 컨트랙트의 틀을 만들어두자.

/* zombieownership.sol */
pragma solidity >=0.5.0 <0.6.0;

import "./zombieattack.sol";

contract ZombieOwnership is ZombieAttack {
  // 아직은 빈칸
}

2. ERC721 Standard, Multiple Inheritance

erc721.sol라는 새로운 파일을 만들고 아래의 코드, 즉 ERC721 표준을 넣어준다.

/* erc721.sol */
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;
}

위의 메서드들이 앞으로 우리가 이번 레슨에서 구현해야할 함수들이다.

Implementing a token contract

토큰 컨트랙트를 구현할 때 가장 먼저 해야하는 것은 인터페이스를 솔리디티 파일로 복사하여 저장하고 'import'를 이용해서 불러오는 것이다. 그리고는 우리 컨트랙트가 이를 상속하고 각각의 함수 선언들을 오버라이딩해야 한다.

여러 컨트랙트를 상속할 때는 is 뒤에 쉼표와 함께 나열하면 된다.
아래와 같이 적어보자.

/* zombieownership.sol */
pragma solidity >=0.5.0 <0.6.0;

import "./zombieattack.sol";
import "./erc721.sol";

contract ZombieOwnership is ZombieAttack, ERC721 {
  // 아직은 빈칸
}

3. balanceOf & ownerOf

3-1. balanceOf

function balanceOf(address _onwer) external view returns (uint256 _balance);

이 함수는 주소를 인자로 받고 해당 주소가 얼마나 많은 토큰을 소유한지 출력한다.
(우리 앱에서는 좀비가 토큰이다.)

3-2. ownerOf

function ownerOf(uint256 _tokenId) external view returns (address _owner);

이 함수는 토큰 아이디를 입력하면 해당 토큰 소유자의 주소를 반환한다.

위의 함수들을 ZombieOwnership에 구현해보자.

/* zombieownership.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];
  }
}

4. Refactoring

우리가 이전에 ZombieFeeding 컨트랙트에서 ownerOf라는 modifier를 만들었는데, ZombieOwnership 컨트랙트 내부 함수의 이름과 중복된다.
따라서 우리는 이전에 만든 modifier의 이름을 변경해주어야 한다.

ZombieOwnership 컨트랙트의 함수 이름을 바꾸지 않았을까? 그 이유는 ERC721 토큰 표준에 있다.
ZombieOwnership 컨트랙트의 함수는 ERC721 토큰 표준을 사용하기 때문에 함수 이름 변경해서는 안된다! (함수 이름이 동일해야만 ERC721 토큰 표준을 따르는 다른 컨트랙트와 상호작용이 가능하다.)

/* zombiefeeding.sol */
contract ZombieFeeding is ZombieFactory {

  KittyInterface kittyContract;

  // 제어자 이름을 `ownerOf`에서 `onlyOwnerOf`로 바꾼다.
  modifier onlyOwnerOf(uint _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    _;
  }
  ...
  
  // `ownerOf`를 사용한 곳도 바꿔주자 
  function feedAndMultiply(uint _zombieId, uint _targetDna, string _species) internal onlyOwnerOf(_zombieId) {
    ...
  }
    
  ...
  

** ZombieAttack과 ZombieHelper 컨트랙트에서도 ownerOfonlyOwnerOf로 바꿔주자


5. ERC721: Transfer Logic

이번엔 ERC721 토큰의 소유권을 이전하는 로직을 구현해보자.
여기에는 두 가지 방법이 있다.

  1. 첫 번째는 토큰 소유자가 토큰을 전송하는 방법이다.
    토큰 소유자가 자신의 주소 _from, 전달하려는 주소 _to, 그리고 보내려는 토큰 아이디 _tokenId와 함께 transferFrom 함수를 호출하는 방식이다.
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
  1. 두 번째는 토큰 소유자가 허가한 사람이 토큰을 전송하는 방법니다.
    먼저 보내려는 주소 _approved와 토큰 아이디 _tokenId와 함께 approve 함수를 호출한다. 그러면 컨트랙트는 누가 허가를 받았는지를 저장한다. (보통은 매핑을 활용한다. : mapping (uint256 => address))
    그런 뒤 소유자 또는 허가받은 주소가 transferFrom 함수를 호출할 때, 컨트랙트는 msg.sender가 소유자인지 또는 소유자로 부터 허가받은 주소인지를 확인하고, 맞다면 토큰을 전달하게 된다.
function approve(adderss _approved, uint256 _tokenId) external payable;
function transferFrom(address _from, address _to, uint256 _tokenId) external payable;

두 방법 모두 같은 전송 로직을 가지고 있다. 한 방법은 토큰을 보내는 사람이 transferFrom 함수를 호출하고, 다른 방법은 소유자 혹은 확인된 받는이가 함수를 호출한다.

5-1. _transfer

위에서 살펴본 로직을 private 함수인 _transfer를 만들고 transferFrom 함수에서 사용할 수 있도록 코드를 작성해보자.

/* zombieownership.sol */
contract ZombieOwnership is ZombieAttack, ERC721 {
  ...

  function ownerOf(uint256 _tokenId) external view returns (address) {
    return zombieToOwner[_tokenId];
  }
	
  // transferFrom 내부 전송 로직을 담은 private 함수
  function _transfer(address _from, address _to, uint256 _tokenId) private {
    ownerZombieCount[_to]++;              // 전달 받은 사람의 좀비수 +1
    ownerZombieCount[_from]--;            // 보내는 사람의 좀비수 -1
    zombieToOwner[_tokenId] = _to;        // 전송된 좀비의 소유자 변경
    emit Transfer(_from, _to, _tokenId);  // 전송 이벤트 발생
  }
}

5-2. transferFrom

5-1에서 만든 _transfer 함수를 활용해서 transferFrom 함수를 작성해보자.

contract ZombieOwnership is ZombieAttack, ERC721 {
  mapping (uint => address) zombieApprovals;  // 좀비 아이디 => 허가할 주소
  ...
  
  // transferFrom 내부 전송 로직을 담은 private 함수
  function _transfer(address _from, address _to, uint256 _tokenId) private {
    ...
  } 
  
  // _from에서 _to로 좀비를 전송하는 함수
  function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
    // 전송할 좀비의 소유자 인지 또는 소유자가 허가한 주소인지 확인
    require (zombieToOwner[_tokenId] == msg.sender || zombieApprovals[_tokenId] == msg.sender);
    _transfer(_from, _to, _tokenId);
  }
}

5-3. approve

토큰 소유자가 특정 주소에게 좀비 전송을 허락할 수 있도록 approve 함수를 작성해보자.

contract ZombieOwnership is ZombieAttack, ERC721 {
  mapping (uint => address) zombieApprovals;  // 좀비 아이디 => 허가할 주소
  ...
  
  // _from에서 _to로 좀비를 전송하는 함수
  function transferFrom(address _from, address _to, uint256 _tokenId) external
  	...
  }
    
  // 소유자가 _approved 에게 소유한 좀비의 전송을 허가하는 함수
  function approve(address _approved, uint256 _tokenId) external payable onlyOwnerOf(_tokenId) {
    zombieApprovals[_tokenId] = _approved;
    emit Approval(msg.sender, _approved, _tokenId);
  }
}

6. Preventing Overflows

솔리디티에서는 숫자의 타입을 지정할 때 크기를 설정한다. 예를 들어 아래 코드와 같이 255를 초과하면 어떻게 될까?

uint8 number = 255;
number++;

256가 될 것 같지만, uint8 은 0~255까지의 숫자만을 가지기 때문에 오버플로우되어 최종적으로 0이 된다.

이렇게 오버플로우가 발생하게 되면 시스템에 심각한 오류가 발생하기 때문에 이를 사전에 예방하는 코드가 필요하다.

여러 방법이 있겠지만, 여기서는 OpenZepplin에 있는 SafeMath라는 library를 사용할 것이다.

library란?

  • 컨트랙트와 매우 유사하다.
  • state variable를 선언할 수 없다.
  • ether를 전송할 수도 없다.
  • 모든 함수가 internal 이어야만 컨트랙트에서 사용될 수 있다.
  • 하나라도 internal이 아니면, 먼저 library를 배포한 뒤, 이를 사용할 컨트랙트가 배포되기 전에 연결해야 한다.

우리는 라이브러리SafeMath 의 코드는 아래와 같다.

/* safemath.sol */

pragma solidity >=0.5.0 <0.6.0;

/**
 * @title SafeMath
 * @dev Math operations with safety checks that throw on error
 */
library SafeMath {

  /**
  * @dev Multiplies two numbers, throws on overflow.
  */
  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;
  }

  /**
  * @dev Integer division of two numbers, truncating the quotient.
  */
  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;
  }

  /**
  * @dev Subtracts two numbers, throws on overflow (i.e. if subtrahend is greater than minuend).
  */
  function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  /**
  * @dev Adds two numbers, throws on overflow.
  */
  function add(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }
}

/**
 * @title SafeMath32
 * @dev SafeMath library implemented for uint32
 */
library SafeMath32 {

  function mul(uint32 a, uint32 b) internal pure returns (uint32) {
    if (a == 0) {
      return 0;
    }
    uint32 c = a * b;
    assert(c / a == b);
    return c;
  }

  function div(uint32 a, uint32 b) internal pure returns (uint32) {
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint32 c = a / b;
    // assert(a == b * c + a % b); // There is no case in which this doesn't hold
    return c;
  }

  function sub(uint32 a, uint32 b) internal pure returns (uint32) {
    assert(b <= a);
    return a - b;
  }

  function add(uint32 a, uint32 b) internal pure returns (uint32) {
    uint32 c = a + b;
    assert(c >= a);
    return c;
  }
}

/**
 * @title SafeMath16
 * @dev SafeMath library implemented for uint16
 */
library SafeMath16 {

  function mul(uint16 a, uint16 b) internal pure returns (uint16) {
    if (a == 0) {
      return 0;
    }
    uint16 c = a * b;
    assert(c / a == b);
    return c;
  }

  function div(uint16 a, uint16 b) internal pure returns (uint16) {
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint16 c = a / b;
    // assert(a == b * c + a % b); // There is no case in which this doesn't hold
    return c;
  }

  function sub(uint16 a, uint16 b) internal pure returns (uint16) {
    assert(b <= a);
    return a - b;
  }

  function add(uint16 a, uint16 b) internal pure returns (uint16) {
    uint16 c = a + b;
    assert(c >= a);
    return c;
  }
}

SafeMath를 활용해서 overflow 를 예방해보자.

6-1. zombiefactory.sol

/* zombiefactory.sol */
pragma solidity >=0.5.0 <0.6.0;

import "./ownable.sol";
import "./safemath.sol";

contract ZombieFactory is Ownable {
  using SafeMath for uint256;
  using SafeMath32 for uint32;
  using SafeMath16 for uint16;
  ...
  
  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] = ownerZombieCount[msg.sender].add(1);
    emit NewZombie(id, _name, _dna);
  }

}

6-2. zombieownership.sol

/* zombieownership.sol */

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;
  
  ...
  
  // transferFrom 내부 전송 로직을 담은 private 함수
  function _transfer(address _from, address _to, uint256 _tokenId) private {
    ownerZombieCount[_to] = ownerZombieCount[_to].add(1);              // 전달 받은 사람의 좀비수 +1
    ownerZombieCount[_from] = ownerZombieCount[_from].sub(1);          // 보내는 사람의 좀비수 -1
    zombieToOwner[_tokenId] = _to;        // 전송된 좀비의 소유자 변경
    emit Transfer(_from, _to, _tokenId);  // 전송 이벤트 발생
  }
}

6-3. zombieattack.sol

/* zombieattack.sol */
contract ZombieAttack is ZombieHelper {

  uint randNonce = 0;
  uint attackVictoryProbability = 70;

  // 난수 생성 함수
  function randMod(uint _modulus) internal returns(uint) {
    randNonce = randNonce.add(1);
    return uint(keccak256(abi.encodePacked(now, msg.sender, randNonce))) % _modulus;
  }

  function attack(uint _zombieId, uint _targetId) external onlyOwnerOf(_zombieId) {
    ...
    if (rand <= attackVictoryProbability) {						// 이겼을 때
      myZombie.winCount = myZombie.winCount.add(1);				// 내 좀비 승리 수 +1
      myZombie.level = myZombie.level.add(1);					// 내 좀비 레벨 +1
      enemyZombie.lossCount = enemyZombie.lossCount.add(1);		// 상대 좀비 패배 수  +1
      feedAndMultiply(_zombieId, enemyZombie.dna, "zombie");
    } else {													// 졌을 때
      myZombie.lossCount = myZombie.lossCount.add(1);			// 내 좀비 패배 수 +1 
      enemyZombie.winCount = enemyZombie.winCount.add(1);		// 적 좀비 승리 수 +1
    }
    _triggerCooldown(myZombie);
  }
}

전체 코드

전체 코드는 Github 링크에서 확인 가능합니다.

profile
블록체인 개발자를 꿈꾸다

0개의 댓글