[#5 Crypto Zombies] ERC721 & 크립토 수집품

cat_dev·2021년 2월 21일
0

Solidity

목록 보기
5/9
post-thumbnail

실습 링크

이더리움 상의 토큰

토큰이란?

  • 이더리움에서 토큰은 기본적으로 몇몇 공통 규약을 따르는 스마트 컨트랙트이다.컨트랙트 안에서 누가 얼마나 많은 토큰을 가지고 있는지 기록하고, 몇몇 함수를 가지고 사용자들이 그들의 토큰을 다른 주소로 전송할 수 있게 한다.
  • 즉, 다른 모든 토큰 컨트랙트가 사용하는 표준 함수 집합을 구현한게 토큰예를 들면 transfer(address _to, uint256 _value)나 balanceOf(address _owner) 같은 함수!

내부적으로 스마트 컨트랙트는 보통 mapping(address => uint256) balances와 같은 매핑을 가지고 있다. 각각의 주소에 잔액이 얼마나 있는지 기록하는 것. 토큰도 스마트 컨트랙트의 일종이므로 저 매핑을 가지고 있다.

즉 기본적으로 토큰은 그냥 하나의 컨트랙트!

ERC20 토큰 상호작용

💵 ERC20 토큰은 화폐처럼 사용되는 토큰으로 적절하다.

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

  • 즉 하나의 ERC20 토큰과 상호작용할 수 있는 애플리케이션 하나를 만들면, 이 앱이 다른 어떤 ERC20 토큰과도 상호작용이 가능하다.
  • 한 거래소에서 새로운 ERC20 토큰을 상장할 때, 실제로는 이 거래소에서 통신이 가능한 또 하나의 스마트 컨트랙트를 추가하는 것이다.
  • 사용자들은 이 컨트랙트에 거래소의 지갑 주소에 토큰을 보내라고 할 수 있고, 거래소에서는 이 컨트랙트에 사용자들이 출금을 신청하면 토큰을 다시 돌려보내라고 할 수 있게 만드는 것!

따라서, 로직은 한번 구현하고, 새로운 토큰을 추가하는 것은 DB에 새 컨트랙트 주소를 추가하기만 하면 된다. (토큰이 스마트 컨트랙트 그 자체이므로)

다른 토큰 표준

다른 종류의 토큰이 필요한 이유

지금 만들고 있는 좀비 게임에서는 아래와 같은 이유로 ERC20을 못쓴다..!

  1. 좀비는 화폐처럼 분할할 수가 없다.
  • 다른 사람에게 0.237ETH를 보낼 수 있지만, 0.237개의 좀비를 보내는 것은 불가능하다.
  1. 모든 좀비가 똑같지 않다.
  • 다른 사람의 레벨2 좀비 Steve는 내 레벨732 좀비 H4XF13LD MORRIS 💯💯😎💯💯와는 완전히 다르다.

ERC721 토큰

☄️ 크립토좀비와 같은 크립토 수집품을 위해 더 적절한 토큰 표준은 ERC721 토큰!

ERC712 토큰을 사용하는 이유

  1. ERC721 토큰은 교체가 불가하다.
  • 각각의 토큰이 유일하고 분할이 불가하다.
  • 각 토큰을 하나의 전체 단위로만 거래할 수 있다.
  • 각각의 토큰은 유일한 ID를 가지고 있다.
  1. 경매나 중계 로직을 직접 구현하지 않아도 된다.
  • 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 토큰을 사용할 수 있다.

다중 상속

contract SatoshiNakamoto is NickSzabo, HalFinney {
  // , 로 간단히 다중 상속 가능
}
  • 다중 상속은 그냥 , 로 구분해서 나란히 쓴다.

ERC721 구현

메소드 구현

balanceOf

  function balanceOf(address _owner) public view returns (uint256 _balance);
  • 이 함수는 단순히 address를 받아, 해당 address가 토큰을 얼마나 가지고 있는지 반환한다.
  • 현재 만들고 있는 게임의 경우, 토큰은 좀비들이 된다.

ownerOf

  function ownerOf(uint256 _tokenId) public view returns (address _owner);
  • 이 함수에서는 토큰 ID를 받아, 이를 소유하고 있는 사람의 address를 반환한다.

전송 로직

첫번째 방법 - transter

function transfer(address _to, uint256 _tokenId) public;
  • 토큰을 보내는 사람이 함수 호출
  • 토큰의 소유자가 전송 상대의 address, 전송하고자 하는 _tokenId와 함께 transfer 함수를 호출하는 것이다.

두번째 방법 - approve

function approve(address _to, uint256 _tokenId) public;
function takeOwnership(uint256 _tokenId) public;
  • 토큰을 받는 사람이 함수 호출
  • 토큰의 소유자가 먼저 위에서 본 정보들을 가지고 approve를 호출하는 형식이다.
  • 그 후, 컨트랙트에 누가 해당 토큰을 가질 수 있도록 허가를 받았는지 저장한다.
  • 보통 mapping (uint256 => address)를 써서 이를 확인한다.
  • 누군가 takeOwnership을 호출하면, 해당 컨트랙트는 이 msg.sender가 소유자로부터 토큰을 받을 수 있게 허가를 받았는지 확인한다. 그리고 허가를 받았다면 해당 토큰을 그에게 전송한다.

transfer와 takeOwnership 모두 동일한 전송 로직을 가지고 있다. 순서만 반대인 것!(전자는 토큰을 보내는 사람이 함수를 호출, 후자는 토큰을 받는 사람이 호출).

그러니 이 로직만의 프라이빗 함수, _transfer를 만들어 추상화하면 두 함수에서 모두에서 효율적으로 사용할 수 있다.

  function _transfer(address _from, address _to, uint256 _tokenId) private {
      ownerZombieCount[_to]++;
      ownerZombieCount[_from]--;
      zombieToOwner[_tokenId] = _to;
      Transfer(_from, _to, _tokenId);
  }

approve 구현

작동 방식 요약

  1. 소유자인 자네가 새로운 소유자의 address와 그에게 보내고 싶은 _tokenId를 사용하여 approve를 호출한다.
  2. 새로운 소유자가 _tokenId를 사용하여 takeOwnership 함수를 호출하면, 컨트랙트는 그가 승인된 자인지 확인하고 그에게 토큰을 전송한다.
  • 함수 호출 사이에 누가 무엇에 대해 승인이 되었는지 저장할 데이터 구조가 필요하다.

컨트랙트 보안

🌝 컨트랙트 보안 고려 요소: 오버플로우, 언더플로우

오버플로우

오버플로우란 무엇인가?

우리가 8비트 데이터를 저장할 수 있는 uint8 하나를 가지고 있다고 해보지. 이 말인즉 우리가 저장할 수 있는 가장 큰 수는 이진수로 11111111(또는 십진수로 2^8 - 1 = 255)가 된다.

uint8 number = 255;
number++;

이 예시에서, 우리는 이 변수에 오버플로우를 만들었다.

  • 위에서 number는 직관과는 다르게 0이 된다.이진수 11111111에 1을 더하면, 이 수는 00000000으로 돌아간다.

언더플로우

언더플로우란?

언더플로우는 오버플로우와 유사하게 0 값을 가진 uint8에서 1을 빼면, 255와 같아지는 것을 말한다. (uint에 부호가 없어, 음수가 될 수 없기 때문에!)

따라서, 미래에 우리의 DApp에 예상치 못한 문제가 발생하지 않도록 컨트랙트에 보호 장치를 두어야 한다! (컨트랙트 한번 올리면 수정도 못함..)

SafeMath 사용하기

SafeMath란?

오버플로우와 언더플로우를 막기 위해, OpenZeppelin에서 기본적으로 이런 문제를 막아주는 SafeMath라고 하는 라이브러리를 만들었다.

솔리디티에서 라이브러리란?

라이브러리(Library)는 솔리디티에서 특별한 종류의 컨트랙트이다. 이게 유용하게 사용되는 경우 중 하나는 기본(native) 데이터 타입에 함수를 붙일 때!

예를 들어, SafeMath 라이브러리를 쓸 때는 using SafeMath for uint256이라는 구문을 사용한다. SafeMath 라이브러리는 4개의 함수를 가지고 있다. - addsubmul,div . 함수 접근은 아래와 같이 이루어진다.

using SafeMath for uint256;

uint256 a = 5;
uint256 b = a.add(3); // 5 + 3 = 8
uint256 c = a.mul(2); // 5 * 2 = 10

SafeMath 내부의 코드

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;
  }
}

라이브러리는 using 키워드를 사용할 수 있게 해준다. 이를 통해 라이브러리의 메소드들을 다른 데이터 타입에 적용할 수 있다.

safemath 적용 예시

using SafeMath for uint;
// 우리는 이제 이 메소드들을 아무 uint에서나 쓸 수 있다.
uint test = 2;
test = test.mul(3); // test는 이제 6이 된다
test = test.add(5); // test는 이제 11이 된다
  • mul과 add 함수는 각각 2개의 인수를 필요로 한다.하지만 우리가 using SafeMath for uint를 선언할 때, 우리가 함수를 적용하는 uint(test)는 첫 번째 인수로 자동으로 전달된다.

safemath 작동 방식 - add함수

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의 역할

  • assert는 조건을 만족하지 않으면 에러를 발생시킨다는 점에서 require와 비슷하다.
  • assert와 require의 차이점은, require는 함수 실행이 실패하면 남은 가스를 사용자에게 되돌려 주지만, assert는 그렇지 않다는 것이다.
  • 따라서, 대부분 코드에 require를 쓰고, assert는 일반적으로 코드가 심각하게 잘못 실행될 때 사용한다.

코드에 SafeMath 사용하기

오버플로우나 언더플로우를 막기 위해, 우리의 코드에서 +-* 또는 /을 쓰는 곳을 찾아 addsubmuldiv로 교체한다.

예를 들어, 아래처럼 하는 대신

myUint++;

이렇게 사용해 오버플로우와 언더플로우를 방지한다.

myUint = myUint.add(1);

솔리디티 주석 - natspec

/// @title 기본적인 산수를 위한 컨트랙트
/// @author H4XF13LD MORRIS 💯💯😎💯💯
/// @notice 지금은 곱하기 함수만 추가한다.
contract Math {
  /// @notice 2개의 숫자를 곱한다.
  /// @param x 첫 번쨰 uint.
  /// @param y 두 번째 uint.
  /// @return z (x * y) 곱의 값
  /// @dev 이 함수는 현재 오버플로우를 확인하지 않는다.
  function multiply(uint x, uint y) returns (uint z) {
    // 이것은 일반적인 주석으로, natspec에 포함되지 않는다.
    z = x * y;
  }
}
  • @title과 @author : 말 그대로 제목, 쓴사람
  • @notice : 사용자에게 컨트랙트/함수가 무엇을 하는지 설명한다.
  • @dev : 개발자에게 추가적인 상세 정보를 설명한다.
  • @param과 @return : 함수에서 어떤 매개 변수와 반환값을 가지는지 설명한다.
  • 모든 태그가 필수는 아니지만, @dev는 대부분 남겨야 함!
profile
devlog

0개의 댓글