14 Chapter의 상당 부분이 옛날 방식의 JS로 작성되어 있어서 현대식으로 변경함.
Lesson1에서는 좀비를 생성하는 것을 배웠다면, Lesson2에서는 실제 게임처럼 멀티 플레이어나 랜덤 생성 등의 기능을 추가할 예정이다.
다른 생명체가 좀비에 물리면 새로운 좀비가 생성된다.
이더리움 블록체인은 account들로 구성되어 있다.
account 안에는 Ether라는 화폐의 양이 작성되어 있어서 다른 account들에 전송하거나 전달받을 수 있다.
이때 account끼리 서로 전송이 가능하려면 address를 알고 있어야 한다.
address는 account를 구분하는 고유한 식별자이다.
좀비 게임에서는 각각의 좀비를 구분하는 ID로 사용할 수 있다.
mapping은 파이썬의 딕셔너리와 비슷한 자료형이다.
key와 value 타입을 지정해야 한다.
mapping (address => uint) public accountBalance;
mapping (uint => string) userIdToName;
pragma solidity >=0.5.0 <0.6.0;
contract ZombieFactory {
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
struct Zombie {
string name;
uint dna;
}
Zombie[] public zombies;
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
function _createZombie (string memory _name, uint _dna) private {
uint id = zombies.push(Zombie(_name, _dna)) - 1;
emit NewZombie(id, _name, _dna);
}
function _generateRandomDna (string memory _str) private view returns (uint) {
uint rand = uint(keccak256(abi.encodePacked(_str)));
return rand % dnaModulus;
}
}
mapping 기능을 사용하도록 _createZombie를 수정해야 한다. 이를 위해서는 msg.sender를 알아야 한다.
솔리디티에서 address 값은 msg.sender를 통해 가져올 수 있다.
contract 안의 function은 항상 외부에 의해 호출되기 때문에 messenger에 대한 값이 들어가게 되므로 msg.sender를 사용할 수 있다.
mapping (address => uint) favoriteNumber;
function setMyNumber(uint _myNumber) public {
favoriteNumber[msg.sender] = _myNumber;
}
function whatIsMyNumber() public view returns (uint) {
return favorateNumber[msg.sender];
}
msg 객체: sender(함수 호출자의 주소), value(보낸 이더리움의 양), data(집어넣은 데이터 전체, calldata), sig(caldata 앞의 네 바이트)
block 객체: timestamp(블록 생성 시간), number(블록 번호), difficulty(난이도), gaslimit(가스 한도), coinbase(블록 채굴자의 주소)
tx 객체: origin(최초 시작자의 주소), gasprice(가스 가격)
이때 tx.origin은 꼭 msg.sender와 같지 않을 수 있음.
유저 → 컨트랙트A → 컨트랙트B 호출 시
컨트랙트 B입장에서는 tx.origin은 유저(최초 시작자), msg.sender는 컨트랙트 A
pragma solidity >=0.5.0 <0.6.0;
contract ZombieFactory {
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
struct Zombie {
string name;
uint dna;
}
Zombie[] public zombies;
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
function _createZombie (string memory _name, uint _dna) private {
uint id = zombies.push(Zombie(_name, _dna)) - 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;
}
}
한번만 좀비를 생성할 수 있도록 게임 로직을 설정할 수 있음. → require를 사용하면 됨.
require는 파이썬에서의 assert와 비슷함. 특정한 조건이 갖춰지지 않는다면 에러를 던지며 프로세스가 중단됨.
function sayHiToVitalik(string memory _name) public returns (string memory) {
require(keccak256(abi.encodePacked(_name)) == keccak256(abi.encodePacked("Vitalik")));
return "Hi!";
}
❓왜 _name == “Vitalik”은 안되나요?
Solidity에서는 문자열을 직접 비교할 수 없음. 비교하려고 하면 컴파일 에러가 나타남.
따라서 해시를 사용해서 숫자 비교로 우회하는 것
해시 방식이 거의 표준으로 사용됨.
pragma solidity >=0.5.0 <0.6.0;
contract ZombieFactory {
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
struct Zombie {
string name;
uint dna;
}
Zombie[] public zombies;
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
function _createZombie (string memory _name, uint _dna) private {
uint id = zombies.push(Zombie(_name, _dna)) - 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);
_createZombie(_name, randDna);
}
}
contract가 너무 길어지면 가독성이 떨어지고 유지보수가 어려울 수 있음. 따라서 상속을 잘 사용해서 이러한 문제를 해결할 수 있음.
contract Doge {
function catchphrase() public returns (string memory) {
return "So Wow CryptoDoge";
}
}
contract BabyDoge is Doge {
function anotherCatchphrase() public returns (string memory) {
return "Such Moon BabyDoge";
}
}
BabyDoge가 Doge를 상속받으면서 BabyDoge는 catchphrase와 anotherCatchphrase를 모두 사용할 수 있음.
pragma solidity >=0.5.0 <0.6.0;
contract ZombieFactory {
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
struct Zombie {
string name;
uint dna;
}
Zombie[] public zombies;
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
function _createZombie (string memory _name, uint _dna) private {
uint id = zombies.push(Zombie(_name, _dna)) - 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);
_createZombie(_name, randDna);
}
}
contract ZombieFeeding is ZombieFactory {
}
코드는 분리한 뒤 모듈을 임포트하는 방식으로도 관리할 수 있음.
import "./other-contract.sol";
contract newContract is OtherContract {
}
pragma solidity >=0.5.0 <0.6.0;
import "zombiefactory.sol";
contract ZombieFeeding is ZombieFactory {
}
솔리디티에서는 변수를 storage 또는 memory에 저장할 수 있음.
Storage: 블록체인에 영구적으로 저장되는 변수
Memory: 일시적으로 저장되는 변수. contract의 function이 수행된 이후에는 사라지는 변수
솔리디티가 자동으로 관리하기 떄문에 이러한 키워드를 직접 사용할 필요는 없음.
하지만 structs나 array같은 reference type을 사용할 때에는 storage나 memory로 명시적으로 지정할 필요가 있음.
contract SandwichFactory {
struct Sandwich {
string name;
string status;
}
Sandwich[] sandwiches;
function eatSandwich(uint _index) public {
// 원본을 직접 참조함. (포인터)
Sandwich storage mySandwich = sandwiches[_index];
mySandwich.status = "EATEN";
// 원본을 복사해서 임시로 들고옴.
Sandwich memory anotherSandwich = sandwiches[_index+1];
anotherSandwich.status = "EATEN";
// 따라서 명시적으로 다시 storage에 입력해줘야 함.
sandwiches[_index + 1] = anotherSandwich;
}
}
pragma solidity >=0.5.0 <0.6.0;
import "zombiefactory.sol";
contract ZombieFeeding is ZombieFactory {
function feedAndMultiply(uint _zombieId, uint _targetDna) public {
require(msg.sender == zombieToOwner[_zombieId]);
Zombie storage myZombie = zombies[_zombieId];
}
}
_createZombie 함수를 ZombieFactory 안의 private function이므로 상속을 받았다고 하더라도 접근할 수 없다.
public이나 private외에도 internal과 external이라는 키워드도 있다.
public: 선언된 컨트랙트, 외부 컨트랙트, 모든 상황에서 호출 가능
private: 선언된 컨트랙트 안에서만 호출 가능
internal: 선언된 컨트랙트와 상속받은 컨트랙트에서만 호출 가능
external: 외부에서만 호출 가능 (선언된 컨트랙트, 상속받은 컨트랙트에서는 호출 불가능)
이때 가장 핵심은 internal로 선언된 것은 상속이 가능하지만 external로 선언된 것은 상속이 불가능하다는 것이다.
contract Sandwich {
uint private sandwichesEaten = 0;
function eat() internal {
sandwichesEaten++;
}
}
contract BLT is Sandwich {
uint private baconSandwichesEaten = 0;
function eatWithBacon() public {
baconSandwichesEaten++;
eat(); // 상속 받은 곳에서도 eat 함수를 사용할 수 있음.
}
}
pragma solidity >=0.5.0 <0.6.0;
contract ZombieFactory {
event NewZombie(uint zombieId, string name, uint dna);
uint dnaDigits = 16;
uint dnaModulus = 10 ** dnaDigits;
struct Zombie {
string name;
uint dna;
}
Zombie[] public zombies;
mapping (uint => address) public zombieToOwner;
mapping (address => uint) ownerZombieCount;
function _createZombie (string memory _name, uint _dna) internal {
uint id = zombies.push(Zombie(_name, _dna)) - 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);
_createZombie(_name, randDna);
}
}
contract ZombieFeeding is ZombieFactory {
}
이제 좀비가 먹이를 먹는 것을 구현한다. 먹이는 CryptoKitties로 설정한다 ㅜㅜ
다른 contract와 상호작용하기 위해서는 interface를 미리 설정해두어야 한다. (마치 자바나 cpp같은 객체지향 언어들에서 했던 것과 동일하다.
아래는 자신의 address에 당첨 번호를 등록하는 contract이다.
contract LuckNumber {
mapping(address => uint) numbers;
function setNum(uint _num) public {
numbers[msg.sender] = _num;
}
function getNum(address _myAddress) public view returns (uint) {
return numbers[_myAddress];
}
}
이 contract에 대한 interface를 작성하면 아래와 같다.
이때 외부의 contract에서는 다른 사람들의 당첨 번호를 가져오는 것만 사용하면 되므로 필요한 것만 interface에 등록하면 된다.
또한 implement된 부분은 생략해야 한다.
contract NumberInterface {
function getNum(address _myAddress) public view returns (uint);
}
이제 getKitty의 인터페이스를 작성해보자.
이때 solidity에서는 객체나 배열 이외에 여러값을 바로 반환할 수 있다.
또한 받을 때에도 필요한 것을 전부 받거나 필요한 것만 받을 수 있다.
return에서 변수명을 작성하는 것도 가능하다.
// 전부 받기
(bool isGestating, bool isReady, , , , , , , , uint256 genes) = kittyContract.getKitty(_kittyId);
// 필요한 것만 받기 (나머지는 빈칸으로 skip)
(, , , , , , , , , uint256 genes) = kittyContract.getKitty(_kittyId);
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 LuckyNumber {
mapping(address => uint) numbers;
function setNum(uint _num) public {
numbers[msg.sender] = _num;
}
function getNum(address _myAddress) public view returns (uint) {
return numbers[_myAddress];
}
}
contract NumberInterface {
function getNum(address _myAddress) public view returns (uint);
}
contract MyContract {
// 1. LuckNumber contract의 주소를 가져온다.
address NumberInterfaceAddress = 0xab38...
// 2. 주어진 인터페이스에 LuckNumber contract 주소와 연결한다.
// 이제 외부 contract를 MyContract 안에서 포인팅할 수 있다.
NumberInterface numberContract = NumberInterface(NumberInterfaceAddress);
function someFunction() public {
// 3. 인터페이스에서 선언한 것을 잘 활용할 수 있다.
uint num = numberContract.getNum(msg.sender);
// 나머지 로직들
}
}
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 {
address ckAddress = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d;
KittyInterface kittyContract = KittyInterface(ckAddress);
function feedAndMultiply(uint _zombieId, uint _targetDna) public {
require(msg.sender == zombieToOwner[_zombieId]);
Zombie storage myZombie = zombies[_zombieId];
_targetDna = _targetDna % dnaModulus;
uint newDna = (myZombie.dna + _targetDna) / 2;
_createZombie("NoName", newDna);
}
}
getKitty는 많은 값들을 return하고 있음.
이를 잘 핸들링할 수 있어야 함.
function multipleReturns() internal returns(uint a, uint b, uint c) {
return (1, 2, 3);
}
function processMultipleReturns() external {
uint a;
uint b;
uint c;
// This is how you do multiple assignment:
(a, b, c) = multipleReturns();
}
// Or if we only cared about one of the values:
function getLastReturnValue() external {
uint c;
// We can just leave the other fields blank:
(,,c) = multipleReturns();
}
contract ZombieFeeding is ZombieFactory {
address ckAddress = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d;
KittyInterface kittyContract = KittyInterface(ckAddress);
function feedAndMultiply(uint _zombieId, uint _targetDna) public {
require(msg.sender == zombieToOwner[_zombieId]);
Zombie storage myZombie = zombies[_zombieId];
_targetDna = _targetDna % dnaModulus;
uint newDna = (myZombie.dna + _targetDna) / 2;
_createZombie("NoName", newDna);
}
// define function here
function feedOnKitty(uint _zombieId, uint _kittyId) public {
uint kittyDna;
(, , , , , , , , , kittyDna) = kittyContract.getKitty(_kittyId);
feedAndMultiply(_zombieId, kittyDna);
}
}
kitty들은 마지막 두자리만 99로 교체하고 싶음.
if문을 사용할수도 있음.
function eatBLT(string memory sandwich) public {
if (keccak256(abi.encodePacked(sandwich)) == keccak256(abi.encodePacked("BLT"))) {
eat();
}
}
contract ZombieFeeding is ZombieFactory {
address ckAddress = 0x06012c8cf97BEaD5deAe237070F9587f8E7A266d;
KittyInterface kittyContract = KittyInterface(ckAddress);
function feedAndMultiply(uint _zombieId, uint _targetDna, string memory _species) public {
require(msg.sender == zombieToOwner[_zombieId]);
Zombie storage myZombie = zombies[_zombieId];
_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);
}
// define function here
function feedOnKitty(uint _zombieId, uint _kittyId) public {
uint kittyDna;
(, , , , , , , , , kittyDna) = kittyContract.getKitty(_kittyId);
feedAndMultiply(_zombieId, kittyDna, "kitty");
}
}
이제 CryptoKitty들을 감염시켜보자
import Web3 from 'web3';
const web3 = new Web3(window.ethereum);
const ABI = [
// ... sol 컴파일 결과물들
];
const CONTRACT_ADDRESS = "0x1234...abcd";
const zombieFedding = new web3.eth.Contract(ABI, CONTRACT_ADDRESS);
// 좀비와 고양이 id를 변수로 선언
let zombieId = null;
let kittyId = 1;
let kittyIdList = [1, 2, 3];
// 고양이 이미지 불러오기
const fetchKitty = async (kittyId) => {
const res = await fetch(`https://api.cryptokitties.co/kitties/${kittyId}`);
if (!res.ok) {
throw new Error("Failed to fetch Kitty");
}
return res.json();
}
const renderKitty = (kitty) => {
const img = document.createElement("img");
img.src = kitty.image_url;
img.className = 'kittyImage';
document.body.appendChild(img);
return img;
}
const connectKittyContract = async (kittyId, zombieId) => {
try {
const kitty = await fetchKitty(kittyId);
const img = renderKitty(kitty);
img.addEventListener("click", async () => {
// 연결된 모든 계정 주소 목록을 배열로 가져옴.
if (zombieId === null) {
console.log("좀비 정보가 없습니다.");
return;
}
const accounts = await web3.eth.getAccounts();
try {
await zombieFeeding.methods
.feedOnKitty(zombieId, kittyId)
.send({ from: accounts[0] });
} catch (e) {
console.error('트랜잭션 실패: ', e);
}
})
} catch (e) {
console.error('kitty 정보 불러오기 실패: ', e);
}
}
// 각 고양이마다 contract 추가
kittyIdList.forEach((kittyId) => {
connectKittyContract(kittyId, zombieId);
});
// 좀비 생성하기
const createZombie = async (name) => {
try {
const accounts = await web3.eth.getAccounts();
await zombieFeeding.methods
.createRandomZombie(name)
.send({ from: accounts[0] });
} catch (e) {
console.error('좀비 생성 실패:', e);
}
}
zomebieFeeding.events.NewZombie()
.on('data', (event) => {
const { zombieId: newZombieId } = event.returnValues;
zombieId = newZombieId;
});
❓왜 항상 account[0]을 가져올까?
현재 활성화된 계정을 0번째에 놓는 규칙이 있기 때문. 이때 활성화된 계정은 내 계정 하나밖에 없음.
내가 선택한 계정이 항상 활성화된 계정으로 설정되는 것임.
내 MetaMask에서는 계정 1이 활성 상태라고 하면, 다른사람의 MetaMask에서는 계정 2가 활성상태임. 따라서 msg.sender를 썼을 때 현재 활성상태인 계좌의 주소로 들어간다고 생각하면 됨.
만약 버튼을 누르게 되면 내 계정에 대해서 contract에서 정의된 일들이 실행되는 것임. 정확하게 말하면 트랜잭션을 하나 실행하는 것과 동일