[BEB] Section4 : EVM 개발 환경 구성과 Solidity로 간단한 게임 구현

jsg_ko·2022년 1월 21일
0
post-custom-banner

EVM 개발 환경 구성

🔥솔리디티 컴파일러 설치

이더리움에서 스마트 컨트랙트를 실행하기 위해서는 솔리디티 코드를 작성하고, solc라는 솔리디티 컴파일러로 컴파일하여 이더리움 블록체인에 배포한다.

  1. 우분투OS

    > sudo add-apt-repository ppa:ethereum/ethereum
    > sudo apt update
    > sudo apt install solc
  1. 예제 코드 작성

    solc_practice라는 폴더를 만들고, 폴더 안에 simpleStorage.sol 파일을 만든다.

    그리고 simpleStorage.sol 파일에 다음의 코드를 입력한다.

    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.4.16 <0.9.0;
    
    contract SimpleStorage {
        uint storedData;
    
        function set(uint x) public { // 데이터 저장 
            storedData = x;
        }
    
        function get() public view returns (uint) { // 데이터 반환 
            return storedData;
        }
    }

솔리디티 코드 컴파일

이제 솔리디티 코드를 배포하기 위해 solc를 사용해 컴파일을 해보자.

../solc_practice 디렉토리로 들어가, 다음의 명령어를 입력한다.

solc --optimize --bin simpleStorage.sol

solc --bin {컴파일할 sol파일 이름} 은 솔리디티 파일을 이진 형식으로 컴파일하는 명령어이다.

  • -optimize 옵션은 컴파일 전, 작성한 솔리디티 코드가 약 200회 실행된다고 가정했을 때를 기준으로 컨트랙트를 최적화한다

명령어를 실행하고 나면 다음과 같은 16진수 이진코드가 출력된다.

이 이진코드는 우리가 작성한 솔리디티 코드를 컴파일한 결과값이며, EVM은 이 코드를 실행한다.

solc를 사용해 ABI 생성하기

어떤 노드가 이더리움 네트워크에 올라가있는 스마트 컨트랙트를 실행하고자 할 때, 바이트코드 형태라면 어떤 함수를 어떻게 실행해야 하는지 이해하기 어려울 것이다.

ABI(Application Binary Interface)는 스마트 컨트랙트 코드에 대한 설명이 담긴 JSON 형식의 인터페이스 이다. 이더리움 네트워크에 있는 각 노드들은 지갑을 통해 상호작용 하는데, 이 때 JSON-RPC 형식의 데이터로 상호작용을 한다. 이 상호작용을 위한 데이터가 바로 ABI이다.

solc의 --abi 옵션을 사용하여 컨트랙트의 ABI를 생성해보자.

다음의 명령어를 입력한다.

solc --abi simpleStorage.sol

배열을 자세히 살펴보면, 컨트랙트 내 함수에 대한 정보가 객체 형태로 들어있는 것을 확인할 수 있다.

🔥개발 환경 구성 - Remix

이더리움 스마트 컨트랙트 코드를 작성하는 데에는 다양한 IDE(Integrated Development Environment, 통합 개발 환경)가 있다. VSCode는 데스크톱 IDE로, solidity 언어 익스텐션을 설치하여 컴파일 할 수 있다.

VSCode를 비롯한 Atom 등 일반적으로 사용하는 데스크톱 IDE에서는 자체적으로 솔리디티 코드를 디버깅하거나 컴파일하는 기능이 없다. 따라서 솔리디티 코드를 컴파일, 배포, 테스트, 디버깅 해주는 Truffle과 로컬 환경에 블록체인 테스트넷을 사용할 수 있게 해주는 Ganache를 함께 사용해야 한다.

웹 기반 IDE인 Remix는 솔리디티 개발을 위한 IDE이다. 자체적으로 솔리디티 개발을 위한 컴파일, 배포, 테스트, 디버깅 기능을 내장하고 있기 때문에 별도의 프레임워크나 라이브러리를 설치하지 않아도 쉽게 솔리디티 코드 작성부터 테스트넷 배포까지 할 수 있다.

Remix의 사용법

  1. remix.ethereum.org 에 접속한다.
  2. 가장 왼쪽에는 파일 익스플로러, 솔리디티 컴파일러, 배포 및 트랜잭션 실행, 플러그인 매니저 탭이 있다.
    • 파일 익스플로러(File Explorers): 새로운 파일, 폴더를 만들거나, 깃허브 연동, 로컬 컴퓨터에서 파일 업로드를 할 수 있다.
    • 솔리디티 컴파일러(Solidity Compiler): 작성한 컨트랙트 코드를 컴파일한다.
    • 배포 및 트랜잭션 실행(Deploy & Run Transactions): 컴파일한 코드를 배포하고, 배포한 컨트랙트를 실행한다.
    • 플러그인 매니저(Plugin Manager): 컨트랙트 개발에 필요한 모듈을 설치 및 관리한다.

파일 생성 및 코드 작성

  • 새로운 파일을 만듭니다. contracts 폴더 아래 simpleStorage.sol을 생성한다. 생성한 파일에 다음의 코드를 작성하고 저장한다.
    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.4.16 <0.9.0;
    
    contract SimpleStorage {
        uint storedData;
    
        function set(uint x) public {
            storedData = x;
        }
    
        function get() public view returns (uint) {
            return storedData;
        }
    }

컨트랙트 코드 컴파일

  1. simpleStorage.sol 파일을 열어둔 상태에서, 두번째 탭인 "SOLIDITY COMPILER" 탭을 들어갑니다. 아래의 그림에 알맞게 옵션이 선택되었는지 확인한다.
    • "COMPILER": 솔리디티 코드를 어떤 버전으로 컴파일할 것인지 지정할 수 있다.
    • "LANGUAGE": 어떤 언어를 컴파일 하는지 지정한다.
    • "EVM VERSION": EVM은 버전에 따라 각기 다른 특징을 가지고 있다. byzantium이 디폴트 이다.
  2. "compile simpleStorage.sol" 버튼을 누르면 솔리디티 파일에 대한 컴파일이 시작된다.
  3. 컴파일이 완료되면 "CONTRACT" 옵션에 우리가 만든 컨트랙트가 컨트랙트명(파일명) 형식으로 출력된다
  4. "Compilation Details" 버튼을 누르면 컨트랙트에 대한 메타데이터 및 ABI 데이터, 바이트코드를 확인할 수 있다.

컨트랙트 코드 배포

  1. simpleStorage.sol 파일을 열어둔 상태에서, 세번째 탭인 "DEPLOY & RUN TRANSACTIONS" 탭을 들어간다.

    각 옵션에 대해 알아보자.

    • "ENVIRONMENT": 컨트랙트 코드를 배포할 네트워크를 의미한다. Injected Web3를 선택하여 지갑과 연동하거나, Web3 Provider를 선택하여 Geth를 통해 접속한 네트워크에 연결할 수 있다. 우리는 실제 네트워크가 아닌 Remix가 제공하는 가상 네트워크를 사용할 예정이기 때문에 JavaScript VM (London)을 선택한다.
    • "ACCOUNT": 컨트랙트 코드를 배포할 계정이다. JavaScript VM에서 사용할 수 있는 가상 계정이 선택되어 있다. 계정 정보는 조금씩 다를 수 있다.
    • "GAS LIMIT": 컨트랙트 실행 시 사용할 가스의 한도이다.
    • "VALUE": 전송할 이더의 양이다.
    • "CONTRACT": 트랜잭션으로 올릴 컨트랙트를 선택한다. 우리는 "SimpleStorage - contracts/simpleStorage.sol"을 배포한다.
  2. 주황색 "Deploy" 버튼을 누르면 배포가 시작된다.

  3. 배포가 성공적으로 완료되면 터미널 창에 트랜잭션에 대한 정보가 출력된다.

  4. "DEPLOYED CONTRACTS"에서는 우리가 배포한 컨트랙트의 함수가 표시된다.

    set 함수는 인자를 받아 컨트랙트 내 state를 변경하는 함수다. 이렇게 상태를 변경하는 함수는 주황색으로 표기된다.

    get 함수는 컨트랙트 내의 state를 반환하는 함수이다.

Remix Plugin 사용하기

VS Code에서 다양한 익스텐션을 사용할 수 있는 것처럼, Remix에서도 다양한 플러그인을 사용할 수 있다.

Remix에서 PLUGIN MANAGER 탭을 선택하면 다양한 플러그인을 확인할 수 있다.

Activate 버튼을 누르면 해당 플러그인이 설치된다.

Remix에 MetaMask 연결하기

Remix에 MetaMask를 연결하여, 웹 브라우저로 스마트 컨트랙트를 빌드하고, 배포할 수 있다.

Remix에 MetaMask를 연결하기 위해, MetaMsk 확장 프로그램에 로그인된 상태여야 한다.

  1. 리믹스에 접속한 다음, 왼쪽 탭에서 Deploy & run transactions 탭을 선택한다.

  2. 왼쪽 상단의 ENVIRONMENT 를 선택하고, Injected Web3 를 선택한다.

  3. 팝업창으로 실행된 MetaMask에서, 연결하려는 지갑을 선택하고 다음 버튼을 누른다

  4. 이어지는 화면에서, 연결 버튼을 눌러 MetaMask와 Remix를 연결한다

  5. MetaMask의 네트워크가 Ropsten 테스트 네트워크 가 맞는지 다시 한 번 확인하자

  6. Remix 화면에 나타난 Account 의 지갑 주소가 MetaMask의 지갑주소와 같다면, 정상적으로 연결이 된 것이다.

🔥이더리움 Ropsten 테스트넷 환경

MetaMask를 통해 연동된 이더리움 Ropsten 테스트넷에 우리가 작성한 컨트랙트 코드를 배포하는 과정과 Ropsten 테스트넷에 올린 컨트랙트 코드를 이더스캔(EtherScan)에서 검증하고 등록하는 과정을 알아보자.

우리가 네트워크에 올린 컨트랙트를 다른 사람이 사용하려고 접근하면 바이트코드만 확인할 수 있다. 사용자가 직접 컨트랙트 코드를 확인하여 어떤 컨트랙트인지 확인할 수 있도록 이더스캔에 우리의 컨트랙트의 솔리디티 코드를 등록할 수 있다.

Ropsten 테스트넷에 컨트랙트 배포하기

  1. 현재, Remix에는 MetaMask를 통해 Ropsten 테스트넷에 연동이 되어있는 상태이다.

  2. Deploy 버튼을 누른다.

  3. 다음과 같은 팝업창이 뜬다. 예상되는 gas 수수료가 나오는 것을 확인한다.

  4. Ropsten 네트워크에 스마트 컨트랙트를 배포하는 트랜잭션이 실행되고, 트랜잭션 정보가 터미널에 출력된다.

이더스캔에서 컨트랙트 검증 및 등록하기

  1. 트랜잭션 정보 중 transaction hash 값을 복사해둔다. transaction hash 값으로 우리가 컨트랙트를 배포한 트랜잭션을 식별할 수 있다.

  2. 이더스캔(etherscan.io)에 접속한다.

  3. 이더스캔 우측 상단의 이더리움 아이콘에 마우스를 가져다대고, Ropsten Testnet를 선택한다.

  4. Ropsten Testnet Explorer에 아까 복사해둔 transaction hash를 입력하고 검색 버튼을 누른다.

  5. 테스트넷에 보낸 트랜잭션이 나온다. 이 중 To 부분은 우리가 배포한 컨트랙트 계정(CA) 주소이다. To 부분에 있는 값을 클릭하여 컨트랙트 계정 정보로 이동한다.

  6. 우리가 배포한 컨트랙트에 대한 정보가 나온다. 이 중 Contract 탭으로 들어간다.

  7. Contract 탭에는 우리가 배포한 컨트랙트 코드가 이진 형태로 나와있다. 이 코드를 솔리디티 형태로 이더스캔에 등록하여 다른 사람이 볼 수 있도록 할 것이다.
    Verify & Publish 링크를 누른다.

  8. 컨트랙트 코드를 검증하기 위한 옵션을 다음의 그림과 같이 입력한다. 입력이 완료되면 Continue 버튼을 누른다.

  9. 솔리디티 코드를 입력한다.

  10. Verify & Publish' 버튼을 누른다. 이제 이더스캔은 테스트넷에 올라간 컨트랙트 바이트코드와, 우리가 작성한 솔리디티 코드를 비교하여 동일한 코드인 경우 이더스캔에 해당 트랜잭션에 대한 솔리디티 코드를 등록한다.

  11. 검증과 등록이 완료되면 다음과 같은 화면이 나타난다.

  12. 다시 컨트랙트 계정 페이지로 이동하면, 컨트랙트 소스 코드를 확인하고, 직접 컨트랙트 함수를 호출할 수도 있다.

🔥Geth를 사용해 스마트 컨트랙트 빌드하기

Geth console과 Web3를 사용해 스마트 컨트랙트를 이더리움 Ropsten Testnet에 배포한다.

  1. simpleStorage.sol 코드를 디렉토리에 저장한다

    // simpleStorage.sol 에 저장
    // SPDX-License-Identifier: GPL-3.0
    pragma solidity 0.8.10;
    
    contract SimpleStorage {
        uint storedData;
    
        function set(uint x) public {
            storedData = x;
        }
    
        function get() public view returns (uint) {
            return storedData;
        }
    }
  2. 배포할 스마트 컨트랙트 코드를 solc를 사용해 컴파일한다.

    solc --abi --bin simpleStorage.sol

    입력하면 콘솔창에 ABI와 바이트코드가 출력된 것을 확인할 수 있다.

  3. geth console을 실행한다. 이더리움 ropsten 테스트넷에 배포할 예정이기 때문에, -ropsten 옵션을 포함한다.

    geth console 2> /dev/null --ropsten
  4. simpleAbi 변수를 선언하고, 컴파일한 결과값 중 ABI로 초기화한다

    var simpleAbi = [{"inputs":[],"name":"get","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"x","type":"uint256"}],"name":"set","outputs":[],"stateMutability":"nonpayable","type":"function"}]
  5. simpleBytecode 변수를 선언하고, 컴파일한 결과값 중 바이트코드로 초기화한다.

    초기화 할 때, 16진수임을 표시하기 위해 앞에 "0x"를 붙인다.

    var simpleBytecode = "0x608060405234801561001057600080fd5b50610150806100206000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c806360fe47b11461003b5780636d4ce63c14610057575b600080fd5b610055600480360381019061005091906100c3565b610075565b005b61005f61007f565b60405161006c91906100ff565b60405180910390f35b8060008190555050565b60008054905090565b600080fd5b6000819050919050565b6100a08161008d565b81146100ab57600080fd5b50565b6000813590506100bd81610097565b92915050565b6000602082840312156100d9576100d8610088565b5b60006100e7848285016100ae565b91505092915050565b6100f98161008d565b82525050565b600060208201905061011460008301846100f0565b9291505056fea2646970667358221220a106ec61c12dfcf801b795067f9f27588e3b1cf9520c77211de033bf4bd2e76664736f6c634300080b0033"
  6. eth.contract()를 사용해 ABI를 설정한다.

    var simpleContract = eth.contract(simpleAbi)

    simpleContract에는 컨트랙트와 관련된 정보가 들어간다.

  7. 컨트랙트를 배포하기 위해서는 가스가 필요하다. 컨트랙트를 배포할 계정의 락을 풀어준다.

    personal.unlockAccount(eth.accounts[0]);

    비밀번호를 입력한 후, true가 반환되면 정상적으로 락이 풀린것이다.

  8. simpleContract.new()를 입력하면 배포가 시작된다.

    new()에는 객체가 인자로 들어가며, 객체에는 from, data, gas가 들어간다.

    • from: 컨트랙트를 배포할 계정
    • data: 컨트랙트 바이트코드
    • gas: 계정에서 소비할 가스
    var contractObj = simpleContract.new({from: eth.accounts[0], data: simpleBytecode, gas: 2000000});

    contractObj 객체를 통해 컨트랙트 정보를 얻을 수 있다.

    addressundefined 인 이유는 아직 컨트랙트를 배포한 트랜잭션이 채굴되지 않았기 때문이다.

    시간이 잠시 지난 후 다시 확인해보면 address와 컨트랙트 내 함수가 정상적으로 나오는 것을 확인할 수 있다.

  1. contractObj의 transactionHashhttp://ropsten.etherscan.io 에 검색하면, 스마트 컨트랙트가 정상적으로 배포된 것을 확인할 수 있다.

Solidity로 가위바위보 게임 구현하기

🔥가위바위보 게임 만들기

솔리디티를 사용해 간단한 가위바위보 게임 컨트랙트를 작성해보자. 두 명의 플레이어가 가위바위보 게임을 진행하고, 이긴 경우 두 참여자의 베팅 금액을 모두 가져간다.

가위바위보 컨트랙트는 다음의 세 가지 퍼블릭 함수를 가지고 있다.

  • createRoom: 가위바위보 게임을 하기 위한 방을 만든다.
  • joinRoom: 만들어진 방에 참가한다.
  • payout: 게임을 마친다. 게임의 결과에 따라 베팅 금액을 송금한다.

가위바위보 컨트랙트는 다음의 플로우로 진행된다.

  1. 방장(originator)가 createRoom을 호출.
    • 방장은 인자로 자신이 낼 가위/바위/보 값과 베팅 금액을 넘겨준다.
    • createRoom은 새로운 방을 만들고, 방의 번호를 리턴한다.
  2. 참가자(taker)는 joinRoom을 호출한다.
    • 참가자는 인자로 참여할 방 번호, 자신이 낼 가위/바위/보 값과 베팅 금액을 넘겨준다.
    • joinRoom은 참가자를 방에 참여시킨다.
    • joinRoom은 방장과 참가자의 가위/바위/보 값을 확인하고 해당 방의 승자를 설정한다.
  3. 방장 또는 참가자가 payout 함수를 호출한다.
    • 인자로 게임을 끝낼 방 번호를 넘겨줍니다.
    • 게임의 결과에 따라 베팅 금액을 송금한다.

1. 사용자와 게임 구조체 생성

먼저, 컨트랙트의 틀을 작성하자.

  • SPDX 라이센스는 MIT로 설정
  • pragma 버전은 0.8.7을 사용.
  • 컨트랙트의 이름은 RPS 이다.
  • 해당 컨트랙트가 송금을 진행하기 위해 생성자 함수에 payable 키워드를 사용해 송금이 가능하다는 것을 명시한다.
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
    constructor () payable {}
}
  • 플레이어 구조체 만들기

게임에서는 각 플레이어의 주소와 베팅 금액을 알고 있어야 한다.

따라서 플레이어 구조체는 다음과 같이 작성할 수 있다.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}

	struct Player {
		address payable addr;  // 주소
		uint256 playerBetAmount;  // 베팅 금액
	}
}

모든 플레이어는 자신이 낸 가위/바위/보 값이 있을 것이다.

플레이어가 낸 값은 "아직 내지 않은 상태", "가위", "바위", "보" 외에는 있어서는 안된다. 따라서 가위/바위/보 값은 enum으로 저장하여, 위의 값 외의 값을 내는 경우 예외가 발생하도록 한다.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}

	enum Hand {  // 가위/바위/보 값에 대한 enum
		rock, paper, scissors
	}

	struct Player {
		address payable addr;
		uint256 playerBetAmount;
		Hand hand;  // 플레이어가 낸 가위/바위/보 값
	}
}

또한 플레이어는 게임의 결과에 따른 상태가 있을 것이다. 상태에는 "대기중", "이김", "비김", "짐" 총 4가지의 상태가 있으며, 이 외의 상태는 있을 수 없다.

따라서 게임에 따른 상태 역시 enum으로 지정한다.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}

	enum Hand {
	 rock, paper, scissors
	}

	enum PlayerStatus {  // 플레이어의 상태
		STATUS_WIN, STATUS_LOSE, STATUS_TIE, STATUS_PENDING
  }

	struct Player {
		address payable addr;
		uint256 playerBetAmount;
		Hand hand;
		PlayerStatus playerStatus;  // 사용자의 현 상태
	}
}

게임 구조체 만들기

컨트랙트에는 게임을 진행하는 여러 방(room)이 있으며, 각 방은 모두 같은 형식을 가지고 있다.

방에는 방을 만든 방장 정보, 방에 참여한 참가자 정보, 총 베팅 금액이 있을 것이다.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}

	struct Player { // ... }

	struct Game {
		Player originator;  // 방장 정보
		Player taker;  // 참여자 정보
		uint256 betAmount;  // 총 베팅 금액
	}

	mapping(uint => Game) rooms; // rooms[0], rooms[1] 형식으로 접근할 수 있으며, 각 요소는 Game 구조체 형식입니다.
	uint roomLen = 0; // rooms의 키 값입니다. 방이 생성될 때마다 1씩 올라갑니다.
}

각 게임은 방장이 방을 만들어둔 상태일 수도 있으며, 참여자가 참여하여 게임 결과가 나온 상태일 수도 있으며, 게임 결과에 따라 베팅 금액을 분배한 상태일 수도 있다. 또는 게임 중간에 에러가 발생했을 수도 있다.

게임의 상태는 위의 네 가지 상태만 있어야 하기 때문에, 게임 상태를 enum 형식으로 지정한다.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}
	// ...

	enum GameStatus { // 게임의 상태
		STATUS_NOT_STARTED, STATUS_STARTED, STATUS_COMPLETE, STATUS_ERROR
	}

	struct Game {
		Player originator;
		Player taker;
		uint256 betAmount;
		GameStatus gameStatus; // 게임의 현 상태
	}
}

2. createRoom - 게임 생성하기

createRoom은 게임을 한다. 게임을 생성한 방장은 자신이 낼 가위/바위/보 값을 인자로 보내고, 베팅 금액은 msg.value로 설정한다.

msg는 솔리디티에 정의된 글로벌 변수이다.
따라서 msg.value는 함수를 사용할 때 입력받지만, 함수 내에서는 파라미터로 설정할 필요가 없다.

contract RPS {
	// ...

	function createRoom (Hand _hand) public payable { // 베팅금액을 설정하기 때문에 payable 키워드를 사용한다

	}
}

게임을 만들고 나면, 해당 게임의 방 번호를 반환한다.

contract RPS {
	// ...

	function createRoom (Hand _hand) public payable returns (uint roomNum) {
    // 변수 roomNum 의 값을 반환합니다
	}
}

게임을 만들기 위해서는 rooms 에 새로운 Game 구조체의 인스턴스를 할당해야 한다.

Game 구조체의 인스턴스를 만든다.

  • betAmount: 아직 방장만 있기 때문에 방장의 베팅 금액을 넣는다.
  • gameStatus: 아직 시작하지 않은 상태이기 때문에 GameStatus.STATUS_NOT_STARTED 값을 넣는다.
  • originator: Player 구조체의 인스턴스를 만들어, 방장의 정보를 넣어준다.
  • taker: Player 구조체 형식의 데이터로 초기화되어야 하기 때문에 addr에는 방장의 주소를, handHand.rock으로 할당해둔다..

만든 Game 인스턴스를 room[roomLen]에 할당한다.

contract RPS {
	// ...

	function createRoom (Hand _hand) public payable returns (uint roomNum) {
    rooms[roomLen] = Game({
        betAmount: msg.value,
        gameStatus: GameStatus.STATUS_NOT_STARTED,
        originator: Player({
            hand: _hand,
            addr: payable(msg.sender),
            playerStatus: PlayerStatus.STATUS_PENDING,
            playerBetAmount: msg.value
        }),
        taker: Player({
            hand: Hand.rock,
            addr: payable(msg.sender),
            playerStatus: PlayerStatus.STATUS_PENDING,
            playerBetAmount: 0
        })
    });
   roomNum = roomLen;  // roomNum은 리턴된다.
	}
}

새롭게 만들어진 게임의 방 번호는 roomLen이 된다.

다음에 만들어질 게임을 위해 roomLen의 값을 1 올려준다.

contract RPS {
	// ...

	function createRoom (Hand _hand) public payable returns (uint roomNum) {
    rooms[roomLen] = Game({
        //...
    });
    roomNum = roomLen; // 현재 방 번호를 roomNum에 할당시켜 반환
    roomLen = roomLen+1;  // 다음 방 번호를 설정
	}
}

그런데, 방장이 createRoom을 실행했을 때, 가위/바위/보 값에 가위, 바위, 보가 아니라 다른 값이 지정될 수도 있다.

따라서 createRoom이 실행되기 전에는 방장이 낸 가위/바위/보 값이 올바른 값인지 확인해야 한다.

이를 위해 isValidHand 라는 함수 제어자를 만들어, createRoom 실행 시 확인하도록 한다.

modifier isValidHand (Hand _hand) {
    require((_hand  == Hand.rock) || (_hand  == Hand.paper) || (_hand == Hand.scissors));
    _;
}

function createRoom (Hand _hand) public payable isValidHand(_hand) returns (uint roomNum) {
  // ...
}

3. joinRoom - 방에 참가하기

joinRoom은 기존에 만들어진 방에 참가한다.

참가자는 참가할 방 번호와 자신이 낼 가위/바위/보 값을 인자로 보내고, 베팅 금액은 msg.value로 설정한다.

가위/바위/보 값을 내기 때문에 마찬가지로 isValidHand 함수 제어자를 사용한다.

contract RPS {
	// ...

	function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {

	}
}

입력받은 방의 Game 구조체 인스턴스의 taker를 설정한다.

contract RPS {
	// ...

	function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {
    rooms[roomNum].taker = Player({
        hand: _hand,
        addr: payable(msg.sender),
        playerStatus: PlayerStatus.STATUS_PENDING,
        playerBetAmount: msg.value
    });
	}
}

참가자가 참여하면서 게임의 베팅 금액이 추가되었기 때문에, Game 인스턴스의 betAmount 역시 변경해줍니다.

contract RPS {
	// ...

	function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {
    rooms[roomNum].taker = Player({
        hand: _hand,
        addr: payable(msg.sender),
        playerStatus: PlayerStatus.STATUS_PENDING,
        playerBetAmount: msg.value
    });
		rooms[roomNum].betAmount = rooms[roomNum].betAmount + msg.value;
	}
}

compareHands() - 게임 결과 업데이트

joinRoom 함수가 끝나는 시점에서, 방장과 참가자가 모두 가위바위보 값을 냈기 때문에 게임의 승패를 확인할 수 있다.

게임의 결과에 따라 게임의 상태와 참여자들의 상태를 업데이트하는 함수 compareHands()를 작성해보자

게임의 결과는 joinRoom이 완료된 시점에서 확인할 수 있기 때문에 joinRoom 함수의 맨 마지막에 compareHands 함수를 호출합니다.

compareHands는 인자로 게임의 결과를 확인할 방 번호를 받습니다.

contract RPS {
	// ...

	function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {
    rooms[roomNum].taker = Player({
        hand: _hand,
        addr: payable(msg.sender),
        playerStatus: PlayerStatus.STATUS_PENDING,
        playerBetAmount: msg.value
    });
		rooms[roomNum].betAmount = rooms[roomNum].betAmount + msg.value;
		compareHands(); // 게임 결과 업데이트 함수 호출
	}

	function compareHands(uint roomNum) private {
		// ...
	}
}

compareHands를 작성하기 전, enum Hands 를 다시 한번 살펴봅시다.

enum Hand {
  rock, paper, scissors
}

enum Hands의 각 값은 순서에 따라 0부터 숫자가 메겨진다. 방장과 참가자가 가지고 있는 값은 0(rock), 1(paper), 2(scissors) 중 하나이다.

1(paper)는 0(rock)을 이기고, 2(scissors)는 1(paper)를 이기고, 0(rock)은 2(scissors)를 이긴다.

즉, 상대방의 값 x와 나의 값 y에 대해, 다음의 조건이 만족하면 자신이 이긴 것이다.

(x + 1) % 3 == y

따라서 방장이 참가자를 이긴 상황을 코드로 작성하면 다음과 같다.

if ((takerHand + 1) % 3 == originatorHand) {
	// originator Win!
}

이제 전체 코드를 작성해보자.

먼저, 해당 게임의 방장과 참가자의 가위바위보 값은 enum 값이기 때문에 정수형으로 바꿔준다.

또한 게임을 본격적으로 비교하기 때문에, 게임의 상태를 GameStatus.STATUS_STARTED로 변경한다.

function compareHands(uint roomNum) private{
  uint8 originator = uint8(rooms[roomNum].originator.hand);
  uint8 taker = uint8(rooms[roomNum].taker.hand);

  rooms[roomNum].gameStatus = GameStatus.STATUS_STARTED;

}

가위바위보 값에 따라, 방장과 참가자의 playerStatus를 설정한다.

function compareHands(uint roomNum) private{
  uint8 originator = uint8(rooms[roomNum].originator.hand);
  uint8 taker = uint8(rooms[roomNum].taker.hand);

  rooms[roomNum].gameStatus = GameStatus.STATUS_STARTED;

  if (taker == originator){ // 비긴 경우
      rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_TIE;
      rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_TIE;
  }
  else if ((taker +1) % 3 == originator) { // 방장이 이긴 경우
      rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_WIN;
      rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_LOSE;
  }
  else if ((originator + 1)%3 == taker){  // 참가자가 이긴 경우
      rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_LOSE;
      rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_WIN;
  } else {  // 그 외의 상황에는 게임 상태를 에러로 업데이트한다
      rooms[roomNum].gameStatus = GameStatus.STATUS_ERROR;
  }
}

4. payout - 베팅 금액 송금하기

payout 함수는 방 번호를 인자로 받아, 게임 결과에 따라 베팅 금액을 송금하고, 게임을 종료한다.

컨트랙트에 있는 금액을 송금하기 위해서는 솔리디티에 내장되어 있는 transfer 함수를 사용한다. transfer 함수는 다음과 같이 사용할 수 있다.

ADDRESS.transfer(value)  // ADDRESS로 value 만큼 송금한다

가위바위보 컨트랙트에서는 비긴 경우에는 자신의 베팅 금액을 돌려받고, 이긴 경우에는 전체 베팅 금액을 돌려받는다.

다음과 같이 payout 함수를 작성한다.

function payout(uint roomNum) public payable {
  if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_TIE && rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_TIE) {
      rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.playerBetAmount);
      rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
  } else {
      if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_WIN) {
          rooms[roomNum].originator.addr.transfer(rooms[roomNum].betAmount);
      } else if (rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_WIN) {
          rooms[roomNum].taker.addr.transfer(rooms[roomNum].betAmount);
      } else {
          rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.playerBetAmount);
          rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
      }
  }
   rooms[roomNum].gameStatus = GameStatus.STATUS_COMPLETE; // 게임이 종료되었으므로 게임 상태 변경
}

한 가지 중요한 것은 payout 함수를 실행하는 주체는 방장 또는 참가자여야 한다는 점이다. 참가자는 중간에 자신이 낸 값을 변경할 수도 있기 때문이다.

따라서 payout 을 실행하기 전 해당 함수를 실행하는 주체가 방장 또는 참가자인지 확인하는 함수 제어자 isPlayer를 만들어야 한다.

isPlayer는 방 번호와 함수를 호출한 사용자의 주소를 받는다. 그리고 사용자의 주소가 방장 또는 참가자의 주소와 일치하는 지 확인한다.

modifier isPlayer (uint roomNum, address sender) {
  require(sender == rooms[roomNum].originator.addr || sender == rooms[roomNum].taker.addr);
  _;
}

function payout(uint roomNum) public payable isPlayer(roomNum, msg.sender) { ... }

전체 코드

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {

    constructor () payable {}

    /*
    event GameCreated(address originator, uint256 originator_bet);
    event GameJoined(address originator, address taker, uint256 originator_bet, uint256 taker_bet);
    event OriginatorWin(address originator, address taker, uint256 betAmount);
    event TakerWin(address originator, address taker, uint256 betAmount);
   */

    enum Hand {
        rock, paper, scissors
    }

    enum PlayerStatus{
        STATUS_WIN, STATUS_LOSE, STATUS_TIE, STATUS_PENDING
    }

    enum GameStatus {
        STATUS_NOT_STARTED, STATUS_STARTED, STATUS_COMPLETE, STATUS_ERROR
    }

    // player structure
    struct Player {
        Hand hand;
        address payable addr;
        PlayerStatus playerStatus;
        uint256 playerBetAmount;
    }

    struct Game {
        uint256 betAmount;
        GameStatus gameStatus;
        Player originator;
        Player taker;
    }

    mapping(uint => Game) rooms;
    uint roomLen = 0;

    modifier isValidHand (Hand _hand) {
        require((_hand  == Hand.rock) || (_hand  == Hand.paper) || (_hand == Hand.scissors));
        _;
    }

    modifier isPlayer (uint roomNum, address sender) {
        require(sender == rooms[roomNum].originator.addr || sender == rooms[roomNum].taker.addr);
        _;
    }

    function createRoom (Hand _hand) public payable isValidHand(_hand) returns (uint roomNum) {
        rooms[roomLen] = Game({
            betAmount: msg.value,
            gameStatus: GameStatus.STATUS_NOT_STARTED,
            originator: Player({
                hand: _hand,
                addr: payable(msg.sender),
                playerStatus: PlayerStatus.STATUS_PENDING,
                playerBetAmount: msg.value
            }),
            taker: Player({ // will change
                hand: Hand.rock,
                addr: payable(msg.sender),
                playerStatus: PlayerStatus.STATUS_PENDING,
                playerBetAmount: 0
            })
        });
        roomNum = roomLen;
        roomLen = roomLen+1;

       // Emit gameCreated(msg.sender, msg.value);
    }

    function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {
       // Emit gameJoined(game.originator.addr, msg.sender, game.betAmount, msg.value);

        rooms[roomNum].taker = Player({
            hand: _hand,
            addr: payable(msg.sender),
            playerStatus: PlayerStatus.STATUS_PENDING,
            playerBetAmount: msg.value
        });
        rooms[roomNum].betAmount = rooms[roomNum].betAmount + msg.value;
        compareHands(roomNum);
    }

    function payout(uint roomNum) public payable isPlayer(roomNum, msg.sender) {
        if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_TIE &&                   
            rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_TIE) {
            rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.pl
            ayerBetAmount);
            rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
        } else {
            if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_WIN) {
                rooms[roomNum].originator.addr.transfer(rooms[roomNum].betAmount);
            } else if (rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_WIN) {
                rooms[roomNum].taker.addr.transfer(rooms[roomNum].betAmount);
            } else {
                rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.playerBetAmount);
                rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
            }
        }
         rooms[roomNum].gameStatus = GameStatus.STATUS_COMPLETE;
    }

    function compareHands(uint roomNum) private{
        uint8 originator = uint8(rooms[roomNum].originator.hand);
        uint8 taker = uint8(rooms[roomNum].taker.hand);

        rooms[roomNum].gameStatus = GameStatus.STATUS_STARTED;

        if (taker == originator){ //draw
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_TIE;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_TIE;

        }
        else if ((taker +1) % 3 == originator) { // originator wins
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_WIN;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_LOSE;
        }
        else if ((originator + 1)%3 == taker){
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_LOSE;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_WIN;
        } else {
            rooms[roomNum].gameStatus = GameStatus.STATUS_ERROR;
        }

    }
}

🔥Solidity 버전에 따른 변경점과 클레이튼에서의 솔리디티

솔리디티는 2014년 8월 처음 제안된 이후로 계속해서 업그레이드 되고 있다. 현재 솔리디티의 최신 버전은 0.8.10 이다.

클레이튼에서의 솔리디티

솔리디티는 이더리움 외에도 이더리움 클래식, 클레이튼, 텐더민트, 헤데라 해시그래프에서도 사용되며, 각 블록체인 플랫폼마다 사용하는 솔리디티 버전은 조금씩 다르다.

작성한 RPS 컨트랙트는 0.8.7 버전을 사용하고 있다. 만약, 클레이튼에서 사용하는 0.5.6 버전으로 낮추어 컴파일하면 에러가 발생한다. 솔리디티는 0.5.6 이후에도 계속 업그레이드 되면서 문법적 차이가 생겼기 때문이다.

따라서 사용하는 블록체인 플랫폼이 지원하는 솔리디티 버전을 확인하고, 해당 버전의 문법에 맞게 코드를 작성해야 한다.

0.5.6과 0.8.7 주요 차이점

클레이튼에서 사용하는 0.5과 이더리움에서 사용하는 0.8의 차이점 중 반드시 알아야하는 내용은 다음과 같다

profile
디버깅에서 재미를 추구하면 안되는 걸까
post-custom-banner

0개의 댓글