CryptoZombie[Course 4] - Solidity: Beginner to Intermediate Smart Contracts, Zombie Battle System

Yeonu-Kim·2026년 5월 5일

CryptoZombie

목록 보기
4/5

Chapter 1: Payable

지난 시간까지의 modifier 관련 내용을 리뷰하면 아래와 같습니다.

  1. modifier를 호출할 수 있는 방식은 네가지가 있습니다.
    private: 선언한 contract 안에서만 사용 가능
    internal: 선언한 contract와 그 contract를 상속한 곳에서 사용 가능
    external: 선언한 cotnract 밖에서만 사용 가능
    public: 아무곳에서나 사용 가능
  2. modifier의 종류는 두 가지가 있습니다.
    view: 단순히 읽기만 하고, 주어진 데이터를 저장하거나 변경하지 않음.
    pure: 어떤 데이터도 저장하지 않음.
  3. custom modifier를 사용하면 custom logic을 다양한 함수에 입힐 수 있습니다. 데코레이터와 유사한 개념입니다.

이와 함께 이번 챕터에서는 payable이라는 modifier를 하나 더 배워볼 예정입니다.

payable modifier를 사용하면 ether를 받을 수 있습니다. 만약 일반적인 웹 서버에 API를 요청하면 이와 함께 돈을 보낼수는 없다. 하지만 이더리움에서는 돈과 데이터, contract code가 한 곳에서 관리되고 있기 때문에 함수를 호출하면 ether를 보내게 할 수도 있습니다.

msg.value를 사용하면 contract에 얼마나 많은 ether가 송금되었는지 확인할 수 있습니다. contract를 일종의 봉투라고 생각하면 그 봉투 안에 동전을 넣어둘 수 있고, msg.value를 사용하여 그 동전의 양이 얼마나되는지를 알 수 있는 것과 비슷합니다.

contract OnlineStore {
	function butSomething() external payable {
		require(msg.value == 0.001 ether);
		transferThing(msg.sender);
	}
}
OnlineStore.buySomething({from: web3.eth.defaultAccount, value: web3.utils.toWei(0.001)})
pragma solidity >=0.5.0 <0.6.0;

import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding {

  // 1. Define levelUpFee here
	uint levelUpFee = 0.001 ether;
  modifier aboveLevel(uint _level, uint _zombieId) {
    require(zombies[_zombieId].level >= _level);
    _;
  }

  // 2. Insert levelUp function here
	function levelUp(uint _zombieId) external payable {
		require(msg.value == levelUpFee);
		zombies[_zombieId].level++;
	}
	 
  function changeName(uint _zombieId, string calldata _newName) external aboveLevel(2, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].name = _newName;
  }

  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].dna = _newDna;
  }

  function getZombiesByOwner(address _owner) external view returns(uint[] memory) {
    uint[] memory result = new uint[](ownerZombieCount[_owner]);
    uint counter = 0;
    for (uint i = 0; i < zombies.length; i++) {
      if (zombieToOwner[i] == _owner) {
        result[counter] = i;
        counter++;
      }
    }
    return result;
  }

}

Chapter 2: Withdraws

사용자가 payble 함수로 ether를 보내면 contract 내부에 ether가 있는 것이고 특정한 사용자가 소유한 상태가 아니다. (아직 봉투 안에 동전을 넣는 것만 구현이 되어 있고, 그 봉투 안의 돈을 꺼낼 수 있는 기능은 소개하지 않았다.) 이때 contract 안에 있는 ether를 꺼내는 작업을 withdraw 라고 한다.

contract GetPaid is Ownable {
	// contract 소유자만 호출할 수 있음.
	function withdraw() external onlyOwner {
		// owner는 address타입을 반환하는데, 이더를 전송하기 위해서는 address payable이 필요함.
		// 그냥 owner()를 사용하는 대신 캐스팅하여 우회함.
		// 0.8 버전 이상에서는 payable(owner())로 간단하게 작성할 수 있음.
		address payable _owner = address(uint160(owner()));
		// addess(this).balance: this는 현재 contract
		// 현재 contract 안에 얼마나 많은 ether가 있는지 반환함.
		// 이후 .transfer 함수로 해당 금액을 _owner 주소로 전송
		// 이때 .transfer는 가스비가 2300으로 제한이 되어 있어서
		// 수신측이 복잡한 로직을 가지고 있으면 실패할 수있음.
		// 다른 방법으로는 .call{value: ...}("")가 있다고 함.
		_owner.transfer(address(this).balance);
	}
}
// 번외: 최신 버전으로 보완한 솔리디티 코드
pragma solidity ^0.8.20;

contract GetPaid is Ownable {
	event Withdrawn(address indexed to, uint256 amount);
	
	// 잔액이 없는 경우
	error NoBalance();
	// ETH 전송이 실패했을 때
	error TransferFailed();
	
	constructor(address initialOwner) Ownable(initialOwner) {}
	
	function withdraw() external onlyOwner {
		uint balance = address(this.balance);
		if (balance == 0) {
			revert NoBalance();
		}
		
		// "": 특정 함수를 호출하게 하는 인자
		// 수신 이후에 수신측 컨트랙트의 특정 함수를 실행하고 싶을 때 사용
		(bool success,) = payable(owner()).call{value: balance}("");
		
		if (!success) {
			revert TransferFailed();
		}
		
		emit Withdrawn(owner(), balance);
	}
}
// calldata를 사용하는 경우
// 수신 측 컨트랙트
contract Receiver {
    uint256 public value;
    
    function setValue(uint256 _value) external payable {
        value = _value;
    }
}

// 송신 측 컨트랙츠
contract GetPaid {
	...
	// setValue(42)
	bytes memory data = abi.encodedWithSignature("setValue(uint256)", 42);
	(bool success,) = payable(owner()).call{value: balance}(data);
	...
}

// ETH 없이 함수만 호출할수도 있음.
(bool success,) = payable(owner()).call(data);
// 또는 여러개의 함수를 하나의 트랜잭션으로 묶을 때 사용하기도 함.(multi call)
pragma solidity >=0.5.0 <0.6.0;

import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding, Ownable {

  uint levelUpFee = 0.001 ether;

  modifier aboveLevel(uint _level, uint _zombieId) {
    require(zombies[_zombieId].level >= _level);
    _;
  }

  // 1. Create withdraw function here
  function withdraw() external onlyOwner{
	  address payable _owner = address(uint160(owner()));
	  _owner.transfer(address(this).balance);
  }

  // 2. Create setLevelUpFee function here
  function setLevelUpFee(uint _fee) external onlyOwner{
	  levelUpFee = _fee;
  } 

  function levelUp(uint _zombieId) external payable {
    require(msg.value == levelUpFee);
    zombies[_zombieId].level++;
  }

  function changeName(uint _zombieId, string calldata _newName) external aboveLevel(2, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].name = _newName;
  }

  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) {
    require(msg.sender == zombieToOwner[_zombieId]);
    zombies[_zombieId].dna = _newDna;
  }

  function getZombiesByOwner(address _owner) external view returns(uint[] memory) {
    uint[] memory result = new uint[](ownerZombieCount[_owner]);
    uint counter = 0;
    for (uint i = 0; i < zombies.length; i++) {
      if (zombieToOwner[i] == _owner) {
        result[counter] = i;
        counter++;
      }
    }
    return result;
  }

}

Chapter 3: Zombie Battles

이제 좀비 배틀을 구현해봅시다.

pragma solidity >=0.5.0 <0.6.0;

import "./zombiehelper.sol";

contract ZombieAttack is ZombieHelper {

}

Chapter 4: Random Numbers

게임에는 랜덤한 요소가 있어야 합니다. 솔리디티에서 랜덤한 값을 생성하는 방법을 알아봅시다. (사실은 완전히 랜덤한 값은 생성할 수 없습니다.)

keccak256 해시를 사용하면 랜덤한 값을 생성할 수 있습니다. 따라서 timestamp now 를 사용하거나 msg.sender 를 사용하면 랜덤한 값이 나올 것입니다. 이때 0~99의 값을 꺼내고 싶을 때는 modulos 연산을 사용해서 처리할 수 있습니다.

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

이더리움에서 컨트랙트의 함수를 호출하면 다양한 호출들이 트랜잭션 형태로 네트워크의 노드들에 전달됩니다. 네트워크의 노드들은 이 트랜잭션을 여러개 모아서 PoW라는 계산 문제 빨리풀기 경쟁을 벌입니다. PoW로 검증이 되면 트랜잭션 묶음을 불록을 만들어서 나머지 네트워크에 전파합니다.

이때 한 노드가 PoW 수행을 처음으로 성공하면, 다른 노드들은 연산을 멈추고 첫번째 노드의 정답을 기반으로 검증을 진행합니다.

이런 구조로 인해 랜덤 함수가 매우 취약해질 수 있습니다. 모든 노드가 같은 입력에 대해 같은 결과를 내야 블록이 유효하다고 검증되기 때문에 예측 불가능한 값을 만든다는 것이 원천적으로 어렵습니다.

현실적으로는 랜덤한 값을 생성하는 외부 오라클 값을 사용하여 이를 해결합니다. 하지만 현재 게임에서는 실제 돈이 오가지 않고 데모용이므로 취약한 랜덤 함수를 사용합니다.

pragma solidity >=0.5.0 <0.6.0;

import "./zombiehelper.sol";

contract ZombieAttack is ZombieHelper {
  // Start here
  uint randNonce = 0;
  
  function randMod(uint _modulus) internal returns(uint) {
    randNonce++;
	  return uint(keccak256(abi.encodePacked(now, msg.sender, randNonce))) % _modulus;
  }
}

Chapter 5: Zombie Fightin’

이제 랜덤한 값을 좀비 전투에 사용해봅시다.

  1. 사용자는 자신의 좀비 하나와 상대방의 좀비를 고를 수 있습니다.
  2. 만약 좀비를 공격하면, 70%의 확률로 이기고, 30%의 확률로 다음 턴으로 넘어갈 수 있습니다.
  3. 모든 좀비들은 winCount와 lossCount를 저장하여 모든 배틀에 대한 승률을 저장하고 있습니다.
  4. 이기면 레벨이 오르고 새로운 상대 좀비가 스폰됩니다.
  5. 만약 졌다면 아무 일도 발생하지 않으면 lossCount만 증가합니다.
  6. 승패유무와 상관 없이 공격한 좀비는 반드시 쿨다운 타임을 거치게 됩니다.
pragma solidity >=0.5.0 <0.6.0;

import "./zombiehelper.sol";

contract ZombieAttack is ZombieHelper {
  uint randNonce = 0;
  // Create attackVictoryProbability here
  uint attackVictoryProbability = 70;

  function randMod(uint _modulus) internal returns(uint) {
    randNonce++;
    return uint(keccak256(abi.encodePacked(now, msg.sender, randNonce))) % _modulus;
  }

  // Create new function here
  function attack(uint _zombieId, uint _targetId) external {
  
  }
}

Chapter 6: Refactoring Common Logic

누구나 attack 함수를 실행할 수 있으면 안됩니다. zombie의 owner만이 이 함수를 호출할 수 있어야 합니다.

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. Create modifier here
  modifier ownerOf(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. Add modifier to function definition:
  function feedAndMultiply(uint _zombieId, uint _targetDna, string memory _species) internal ownerOf(_zombieId){
    // 3. Remove this line
    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");
  }
}

Chapter 7: More Refactoring

이전 챕터에서 생성한 ownerOf modifier를 다른 곳에서도 사용해봅시다.

pragma solidity >=0.5.0 <0.6.0;

import "./zombiefeeding.sol";

contract ZombieHelper is ZombieFeeding {

  uint levelUpFee = 0.001 ether;

  modifier aboveLevel(uint _level, uint _zombieId) {
    require(zombies[_zombieId].level >= _level);
    _;
  }

  function withdraw() external onlyOwner {
    address _owner = owner();
    _owner.transfer(address(this).balance);
  }

  function setLevelUpFee(uint _fee) external onlyOwner {
    levelUpFee = _fee;
  }

  function levelUp(uint _zombieId) external payable {
    require(msg.value == levelUpFee);
    zombies[_zombieId].level++;
  }

  // 1. Modify this function to use `ownerOf`:
  function changeName(uint _zombieId, string calldata _newName) external aboveLevel(2, _zombieId) ownerOf(_zombieId) {
    zombies[_zombieId].name = _newName;
  }

  // 2. Do the same with this function:
  function changeDna(uint _zombieId, uint _newDna) external aboveLevel(20, _zombieId) ownerOf(_zombieId) {
    zombies[_zombieId].dna = _newDna;
  }

  function getZombiesByOwner(address _owner) external view returns(uint[] memory) {
    uint[] memory result = new uint[](ownerZombieCount[_owner]);
    uint counter = 0;
    for (uint i = 0; i < zombies.length; i++) {
      if (zombieToOwner[i] == _owner) {
        result[counter] = i;
        counter++;
      }
    }
    return result;
  }

}

Chapter 8: Back to Attack!

attack 함수를 만들어봅시다.

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;

  }

  // 1. Add modifier here
  function attack(uint _zombieId, uint _targetId) external ownerOf(_zombieId){
    // 2. Start function definition here
    Zombie storage myZombie = zombies[_zombieId];
    Zombie storage enemyZombie = zombies[_targetId];
    uint rand = randMod(100);
  }
}

Chapter 9: Zombie Wins and Losses

이제 어떤 좀비가 이기고 졌는지 나타나는 리더보드를 만들어봅시다.

zombie struct 안에 해당 정보를 넣을 수도 있고, leaderboard Struct를 새롭게 만들 수도 있습니다. 장단점은 어떻게 상호작용하는지에 따라 달라집니다. 이 게임에서는 구현의 편의를 위해 winCount와 lossCount를 zombie Struct 안에 둡니다.

pragma solidity >=0.5.0 <0.6.0;

import "./ownable.sol";

contract ZombieFactory is Ownable {

    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;
      // 1. Add new properties here
      uint16 winCount;
      uint16 lossCount;
    }

    Zombie[] public zombies;

    mapping (uint => address) public zombieToOwner;
    mapping (address => uint) ownerZombieCount;

    function _createZombie(string memory _name, uint _dna) internal {
        // 2. Modify new zombie creation here:
        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);
    }

}

Chapter 10: Zombie Victory

winCount와 lossCount를 업데이트하도록 하겠습니다.

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 ownerOf(_zombieId) {
    Zombie storage myZombie = zombies[_zombieId];
    Zombie storage enemyZombie = zombies[_targetId];
    uint rand = randMod(100);
    // Start here
    if (rand <= attackVictoryProbability) {
		  myZombie.winCount++;
		  myZombie.level++;
		  enemyZombie.lossCount++;
		  feedAndMultiply(_zombieId, enemyZombie.dna, "zombie");
    }
  }
}

Chapter 11: Zombie Loss

좀비가 졌을 때도 구현해봅시다. 별다른 일은 발생하지 않고 lossCount만 증가하면 됩니다. 이때 else 블록을 사용하면 쉽게 구현할 수 있습니다.

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 ownerOf(_zombieId) {
    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");
    } // start here
    else {
	    myZombie.lossCount++;
	    enemyZombie.winCount++;
	    _triggerCooldown(myZombie);
    }
  }
}

0개의 댓글