Crypto Zombie lesson4 링크
이제는 우리가 생성한 좀비가 서로 전투를 하는 코드를 만들어보자.
이전 레슨들에서 여러 함수 제어들에 대해서 배우고 사용해보았다.
public
, private
, internal
, external
view
, pure
여기서는 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++;
}
...
}
이더를 컨트랙트로 보내고 나면 그 이후엔 어떻게 되는 것일까?
이더를 전송하고 나면 해당 이더가 컨트랙트 이더리움 주소로 저장된다.
그리고 이더를 출금하는 함수를 사용하지 않는 한 이더는 해당 컨트랙트 주소에 갇힌다.
출금하는 함수는 아래와 같이 만들 수 있다.
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
toaddress
address payable addr1 = msg.sender; address addr2 = addr1; // This is correct address addr3 = address(addr1); // This is correct
- Casting from
address
toaddress 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;
}
...
앞서 배운 내용들을 토대로 좀비 전투 코드를 작성해보자.
전투 게임에서는 랜덤 함수가 필요하지만 솔리디티에는 제공하는 랜덤 함수가 없다.
가장 간단한 방법은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);
// 아직 완성은 아님!
}
}
이제 이전 코드들을 다듬어보자
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;
}
...
이제는 좀비의 승률을 보여주는 시스템을 만들어보자.
좀비 구조체 자체에 승패 타입을 추가해주고 생성 코드도 수정해주자.
이때 승리, 패배 카운트 타입을 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으로 생성
...
}
어떤 좀비가 이기는지 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 링크에서 확인 가능합니다.