[Lesson4] Zombie Battle System

Seokhun Yoon·2022년 3월 2일
0

[Solidity] CryptoZombie

목록 보기
4/5
post-thumbnail

Zombie Battle System

Crypto Zombie lesson4 링크
이제는 우리가 생성한 좀비가 서로 전투를 하는 코드를 만들어보자.

1. Payable

이전 레슨들에서 여러 함수 제어들에 대해서 배우고 사용해보았다.

  • 접근 제어자(visibility modifier) : public, private, internal, external
  • 상태 제어자(state modifier) : view, pure
  • 사용자 정의 제어자(custom modifiers)

여기서는 payable modifiers에 대해서 알아보자.

payable 함수는
이더를 받을 수 있는 함수 유형이다.
일반적인 웹 서버에서 API 함수를 실행할 경우, 함수를 호출과 함께 달러나 비트코인을 전송할 수 없다.

하지만 이더리움에서는 Ether와 데이터(transaction payload), 컨트랙트 코드 모두 이더리움 위에 존재하기 때문에 함수를 실행하는 동시에 Ether를 전송하는 것이 가능하다.
이를 통해 함수를 실행하면 컨트랙트에게 일정 금액을 지불하게 하는 것이 가능하다.
아래 예시를 보자.

/* onlineStore.sol */
contract OnlineStore {
  function buySomething() external payable {
    // 함수 실행에 0.001이더가 보내졌는지 확실히 하기 위해 확인:
    require(msg.value == 0.001 ether);
    // 보내졌다면, 함수를 호출한 자에게 디지털 아이템을 전달하기 위한 내용 구성:
    transferThing(msg.sender);
  }
}
  • msg.value : 컨트랙트로 보낸 Ether의 양
/* web3.js */
OnlineStore.buySomething({from: web3.eth.defaultAccount, value: web3.utils.toWei(0.001)})
  • {from: 보내는 사람의 지갑 주소, value: 이더 수량} : 호출한 컨트랙트의 함수를 사용하기 위해 이더를 지불
  • toWei() : 이더를 wei단위로 변환

NOTE_
만약 payable이 아닌 함수에 이더를 전송한다면, 해당 트랜잭션은 함수에 의해 거절된다.

이를 우리 코드에 적용해보자.

/* zombiehelper.sol */

contract ZombieHelper is ZombieFeeding {
  uint levelUpFee = 0.001 ether;
  
  ...
  
  // 일정 이더를 지불하면 레벨업하는 함수
  function levelUp(uint _zombieId) external payable {
    require(msg.value == levelUpFee); // 지불 금액이 맞는지 확인
    zombies[_zombieId].level++;
  }
  
  ...
}

2. Withdraws

이더를 컨트랙트로 보내고 나면 그 이후엔 어떻게 되는 것일까?
이더를 전송하고 나면 해당 이더가 컨트랙트 이더리움 주소로 저장된다.
그리고 이더를 출금하는 함수를 사용하지 않는 한 이더는 해당 컨트랙트 주소에 갇힌다.
출금하는 함수는 아래와 같이 만들 수 있다.

contract GetPaid is Ownable {
  function withdraw() external onlyOwner {
    address payable _owner = address(uint160(owner()));
    _owner.transfer(address(this).balance);
  }
}
  • 주소.transfer(이더의 양) : 지정된 주소로 입력한 만큼의 이더를 보냄
  • address(this).balance : 현재 컨트랙트의 전체 잔액

NOTE_ address payable

  • address와 마찬가지로 160-bit
  • 기본 제공 constants : msg.sender, tx.origin, block.coinbase
  • 기본 제공 methods : transfer(), send()
    (address는 transfer(), send()를 사용할 수 없다.)
  • Casting from address payable to address
address payable addr1 = msg.sender;
address addr2 = addr1; // This is correct
address addr3 = address(addr1); // This is correct
  • Casting from address to address payable
address addr1 = msg.sender;
address payable addr2 = addr1; // Incorrect
address payable addr3 = address(uint160(addr1)); // Correct since Solidity >= 0.5.0
address payable addr4 = payable(addr1); // Correct since Solidity >= 0.6.0
  • Casting address[] or address payable[] : 배열은 형변환이 불가능하다

이를 이용해서 코드를 작성해보자.

/* zombiehelper.sol */

  ...
  
  // 현재 컨트랙트의 잔액을 Owner에게 전송
  function withdraw() external onlyOwner {
    address payable _onwer = address(uint160(owner()));
    _onwer.transfer(address(this).balance);
  }

  // 레벨업에 필요한 이더의 양 수정
  function setLevelUpFee(uint _fee) external onlyOwner {
    levelUpFee = _fee;
  }
  
  ...

3. Random Numbers

앞서 배운 내용들을 토대로 좀비 전투 코드를 작성해보자.
전투 게임에서는 랜덤 함수가 필요하지만 솔리디티에는 제공하는 랜덤 함수가 없다.

가장 간단한 방법은keccak256을 이용해서 유사 랜덤값을 출력하는 것이다.
아래 코드처럼 현재 시간, 사용자 지갑 주소, 그리고 nonce 값을 이용해서 0~99까지의 랜덤 값을 출력할 수 있다.

uint randNounce = 0;
uint random = uint(keccak256(abi.encodePacked(now, msg.sender, randNounce))) % 100;
randNonce++;
uint random2 = uint(keccak256(abi.encodedPacked(now, msg.sender, randNounce))) % 100;

하지만 위 방법은 안전하지 못하다.
이더리움에서는 컨트랙트 함수를 실행하려면 트랜잭션을 통해 노드에게 실행을 알린다.
그 후 작업 증명 방식을 통해 트랜잭션을 블록에 저장한다.
작업 증명 방식은 채굴 노드들이 트랜잭션을 모으며 새로운 블록의 해쉬를 찾기 위해 컴퓨팅 파워를 소모하고 가장 먼저 새 블록의 해쉬를 찾은 노드가 보상을 받는 방식이다.
한 노드가 채굴에 성공하게 되면 다른 노드는 채굴을 잠시 멈추고 새로 생성된 블록을 검증한다.
그리고 검증된 블록의 데이터를 통해 노드들은 각자의 트랜잭션 리스트를 업데이트하고 다시 채굴을 시작한다.
이 과정이 오히려 위에서 작성한 난수 코드를 취약하게 만든다.
왜그런지 아래 예시를 살펴보자.

앞면이면 2배의 이익이 생기고, 뒷면이면 모든 금액을 잃는 동전 던지기 컨트랙트를 사용한다고 가정해보자. (앞면과 뒷면이 나올 확률은 각각 50%)
이때 노드로 참여한 사람이 이 컨트랙트를 실행하고 뒷면이 나올 때는 트랜잭션을 공유하지 않고 앞면인 경우에만 공유한다면, 손실은 없이 계속해서 이득만 취할 수 있다.
물론 한 사용자가 매번 블록을 채굴할 확률은 매우 낮겠지만, 빠른 채굴을 위해 투자하는 자원보다 보상이 크다면 누군가는 충분히 이 방법을 통해 정당하지 않은 이득을 취할 수도 있다.

그렇다면 난수를 생성하는 안전한 방법은 무엇이 있을까?
여러 방법 중 하나는 oracle을 사용하여 이더리움 블록체인 외부의 랜덤 숫자 함수를 사용하는 것이다. (다른 해결 방법이 궁금하면 StackOverflow 글을 참고하세요)

이 튜토리얼에서는 시연 목적이기 때문에 oracle을 사용하지 않고 keccak256함수로 난수를 생성할 것이다. (이 방법이 안전하지 않은 방법이란 것만 알아두자!)

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

import "./zombiehelper.sol";

contract ZombieAttack is ZombieHelper {
  uint randNonce = 0;					// 난수 생성시 넣는 값
  uint attackVictoryProbability = 70 ;	// 공격시 이길 확률 

  // 난수 생성 함수
  function randMod(uint _modulus) internal returns (uint) {
    randNonce++;
    return uint(keccak256(abi.encodePacked(now, msg.sender, randNonce))) % _modulus;
  }
  
  // 공격 함수
  function attack(uint _zombieId, uint _targetId) external {
    Zombie storage myZombie = zombies[_zombieId];
    Zombie storage enemyZombie = zombies[_targetId];
    uint rand = randMod(100);
    // 아직 완성은 아님!
  }
}

4. Refactorying Common Logic

이제 이전 코드들을 다듬어보자

attack 함수를 호출하는 사용자는 해당 좀비의 소유자여야만 한다.
즉 내 좀비를 다른 사람이 사용하지 못하도록 해야한다.
함수제어자 ownerOf를 만들어서 소유자를 확인해보자.
그리고 이전에 좀비의 소유자가 누구인지 함수마다 require를 통해 따로 검증을 했다.
중복해서 적은 부분들을 ownerOf로 바꿔주자.

/* zombiefeeding.sol */
...

contract ZombieFeeding is ZombieFactory {

  KittyInterface kittyContract;

  // 좀비 소유자 확인
  modifier ownerOf(uint _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    _;
  }
  ...
  
  function feedAndMultiply(uint _zombieId, uint _targetDna, string memory _species) internal ownerOf(_zombieId) {  // ownerOf를 통해 좀비 소유자 확인
    Zombie storage myZombie = zombies[_zombieId];     // 내 좀비를 zombies 배열에서 가져옴(storage 사용!)
    _targetDna = _targetDna % dnaModulus;             // target DNA를 16자리 수로 만듦
    uint newDna = (myZombie.dna + _targetDna) / 2;    // 물려서 변한 좀비의 DNA 계산
    require(_isReady(myZombie));
    if (keccak256(abi.encodePacked(_species)) == keccak256(abi.encodePacked("kitty"))) {
      newDna = newDna - newDna % 100 + 99;            // 만약 _species가 "kitty" 라면 DNA의 맨 뒷자리를 99로 변환
    }
    _createZombie("NoName", newDna);                  // 임시로 "NoName"이라는 이름을 가진 좀비 생성
    _triggerCooldown(myZombie);
  }
  
  ...
}
/* zombieattack.sol */
  ...
  // 공격 함수
  function attack(uint _zombieId, uint _targetId) external ownerOf(_zombieId) { // ownerOf 추가
    Zombie storage myZombie = zombies[_zombieId];
    Zombie storage enemyZombie = zombies[_targetId];
    uint rand = randMod(100);
  }

  ...
/* zombiehelper.sol */
...

  // 레벨 2이상이면 좀비 이름 변경 가능
  function changeName(uint _zombieId, string calldata _newName) external aboveLevel(2, _zombieId) ownerOf(_zombieId){	// ownerOf 추가
    zombies[_zombieId].name = _newName;
  }

  // 레벨 20이상이면 좀비 DNA 변경 가능
  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) ownerOf(_zombieId) {	// ownerOf 추가
    zombies[_zombieId].dna = _newDna;
  }

...

5. Zombie Wins and Losses

이제는 좀비의 승률을 보여주는 시스템을 만들어보자.
좀비 구조체 자체에 승패 타입을 추가해주고 생성 코드도 수정해주자.

이때 승리, 패배 카운트 타입을 uint16으로 한다.
(공격 쿨타임이 하루이고 매일 공격을 해도 2^16 = 65536일, 즉 179년이기 때문에 16-bit로 선언해도 충분하다.)

/* zombiefactory.sol */
  ...
  
  // Zombie : name, dna 값을 가짐
  struct Zombie {
    ... 
    uint16 winCount;
    uint16 lossCount;
  }

  ...
  
  function _createZombie(string memory _name, uint _dna) internal {
    uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime),0 ,0)) - 1;	// 승리, 패배 카운트 0으로 생성
    ...
  }
  

6. Zombie Victory and Loss

어떤 좀비가 이기는지 attack 함수의 코드를 마무리 지어보자.

/* zombieattack.sol */
  ...
  // 공격 함수
  function attack(uint _zombieId, uint _targetId) external ownerOf(_zombieId) { // ownerOf 추가
    Zombie storage myZombie = zombies[_zombieId];
    Zombie storage enemyZombie = zombies[_targetId];
    uint rand = randMod(100);
	
    if (rand <= attackVictoryProbability) { // 이겼을 때
      myZombie.winCount++;
      myZombie.level++;
      enemyZombie.lossCount++;
      feedAndMultiply(_zombieId, enemyZombie.dna, "zombie");
    } else {	// 졌을 때
      myZombie.lossCount++;
      enemyZombie.winCount++;
    }
    _triggerCooldown(myZombie); // 이기든 지든 공격 쿨타임 초기화
  }

  ...


전체 코드

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

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

0개의 댓글