본문 바로가기

Blockchain/EOSIO

Elemental Battles로 시작하는 EOS dApp 개발 (3)

LESSON 2. Storing State and Login

https://battles.eos.io/tutorial/lesson2/chapter1

 

LESSON1에서는 EOSIO에서 작동하는 스마트 컨트랙트와 실제 게임을 플레이하는 클라이언트 프로그램인 프론트 엔드 프로젝트를 구성해보았습니다. LESSON2에서는 이에 이어서 데이터 저장을 위한 멀티 인덱스 테이블을 생성해보고 테이블의 데이터를 변경하기 위한 액션이 어떻게 작성되는지를 살펴볼 것입니다. 또한 유저들의 게임 플레이를 위한 프론트 엔드 프로그램을 React와 Redux를 이용하여 작성해보겠습니다.

 

게임 플레이를 위해서 가장 먼저 해야할 작업은 바로 로그인을 하는 것입니다. 그렇기 때문에 로그인을 하기 위한 페이지를 프론트 엔드에 구현해보겠습니다. 그리고 로그인 페이지에서 eosjs 라이브러리를 이용하여 login 액션을 호출하여 로그인이 실제로 작동하도록 할 것입니다.

 

Elemental Battles는 사용자 별로 몇 가지 정보를 저장하게 됩니다. 정보를 저장하기 위해서는 사용자 정보를 위한 멀티 인덱스 테이블이 필요합니다. 멀티 인덱스 테이블이라하면 생소하게 들리실 분도 계시겠지만, EOSIO에서 사용하고 있는 인메모리 데이터 베이스라고 이해하시면 될듯합니다. 

 

사용자 정보를 저장하기 위한 user_info struct를 작성합니다. user_info struct는 cardgame.hpp에 위치하며, player name, win count, lost count를 필드로 갖습니다. 이때 name이라는 타입을 사용하게 되는데, 여기서 name은 EOSIO에서 정의한 타입이며, 64-bit unsigned Integer로서 base32로 인코딩된 문자열을 의미합니다. win count와 lost count는 단순하게 승리한 숫자와 패배한 숫자를 의미하는 것으로 정수형으로 정의됩니다.

 

// cardgame.hpp
#include <eosiolib/eosio.hpp>

using namespace std;
using namespace eosio;
class [[eosio::contract]] cardgame : public eosio::contract {
  private:
    struct user_info {
      name            username;
      uint16_t        win_count = 0;
      uint16_t        lost_count = 0;
    };

  public:
    cardgame( name receiver, name code, datastream<const char*> ds ):contract(receiver, code, ds) {}
};

 

멀티 인덱스 테이블은 primary_key() 함수를 포함하게 되어 있습니다. 이 함수는 struct의 첫번째 필드를 사용하며, 컴파일러가 테이블의 기본키가 되도록 설정해줍니다. 

 

// cardgame.hpp
  private:

    struct user_info {
      name            username;
      uint16_t        win_count = 0;
      uint16_t        lost_count = 0;

      auto primary_key() const { return username.value; }
    };

 

user_info struct는 멀티 인덱스 테이블 템플릿을 이용하여 멀티 인덱스 테이블 타입으로 정의해야 합니다. user_info는 멀티 인덱스 테이블에서는 users라는 이름으로 사용되고 코드상에서는 users_table 타입으로 사용될 수 있도록 정의합니다. 이때 naming convention을 준수하는 것이 중요한데, 만일 naming convention을 준수하지 않았을 경우 컴파일에 실패하게 되어, 스마트 컨트랙트 밖의 액션과 데이터가 테이블에 접근할 수 없게 되기 때문입니다. 정의할 때는 앞서 말씀드린 대로 table name과 멀티 인덱스 테이블에서 사용할 struct 정보가 포함되어야 합니다. 

 

// cardgame.hpp
  private:

    struct user_info {
      name            username;
      uint16_t        win_count = 0;
      uint16_t        lost_count = 0;

      auto primary_key() const { return username.value; }
    };

    typedef eosio::multi_index<name("users"), user_info> users_table;

 

멀티 인덱스 테이블 타입으로 정의된 users_table이 준비되면 이를 필드에 선언해줍니다.

 

// cardgame.hpp
  private:

    struct user_info {
      name            username;
      uint16_t        win_count = 0;
      uint16_t        lost_count = 0;

      auto primary_key() const { return username.value; }
    };

    typedef eosio::multi_index<name("users"), user_info> users_table;

    users_table _users;

 

마지막으로 만들어진 users_table을 cardgame에 포함시켜주면 되는데, 이는 cardgame 생성자 함수에서 처리하게 됩니다. 

 

// cardgame.hpp
  public:

    cardgame( name receiver, name code, datastream<const char*> ds ):contract(receiver, code, ds),
                       _users(receiver, receiver.value) {}

 

간단히 요약하자면 멀티 인덱스 테이블은 4가지 정보에 의해 특징된다고 할 수 있습니다. 코드(=스마트 컨트랙트명), 스코프, 테이블명, 기본키가 멀티 인덱스 테이블을 정의할 수 있는데 이는 코드에서 다음과 같이 대응됩니다.

 

  • _users(receiver, receiver.value) provides code and scope (in order).
  • name("users") in the typedef provides the tablename
  • primary_key() - provides the primary key.

생성된 멀티 인덱스 테이블에 state를 저장하기 위해 login 액션을 정의 해보겠습니다. 기능의 구현 자체는 함수의 선언에서 시작합니다. 이때 일반적인 경우와 다른 점은 [[eosio::action]]을 이용하여 선언해야한다는 것입니다. 해당 attribute는 abi generator가 이를 탐지할 수 있도록 도와주는 역할을 합니다. login 액션에서는 require_auth() EOSIO 함수를 사용하여 올바른 사용자 정보인지 확인해줍니다. 그리고 사용자가 게임에 처음 접속한 사용자라면 정보를 앞에서 정의한 users_table 멀티 인덱스 테이블에 생성해줍니다.

 

// cardgame.cpp
#include "gameplay.cpp"

void cardgame::login(name username) {
  // Ensure this action is authorized by the player
  require_auth(username);
  
  // Create a record in the table if the player doesn't exist in our app yet
  auto user_iterator = _users.find(username.value);
  if (user_iterator == _users.end()) {
    user_iterator = _users.emplace(username,  [&](auto& new_user) {
      new_user.username = username;
    });
  } 
}

EOSIO_DISPATCH(cardgame, BOOST_PP_SEQ_NIL)

 

ABI(Application Binary Interface)는 실행중인 스마트 컨트랙트의 데이터와 액션에 접근하는 방법을 정의해줍니다. 그렇기 때문에 스마트 컨트랙트를 위한 ABI 파일을 생성해줄 필요가 있는 것입니다. ABI 파일은 eosio.cdt를 통해 생성이 가능합니다. eosio.cdt가 ABI 파일 생성을 위한 액션 정보를 EOSIO_DISPATCH 매크로를 통해 전달할 수 있습니다. ABI에 대한 자세한 정보는 개발자 문서를 참고하시기 바랍니다.

 

Understanding ABI Files

https://developers.eos.io/eosio-home/docs/the-abi

 

테이블 정보는 [[eosio::table]]을 이용합니다.

// cardgame.hpp
  private:

    struct [[eosio::table]] user_info {
      name            username;
      uint16_t        win_count = 0;
      uint16_t        lost_count = 0;

      auto primary_key() const { return username.value; }
    };

 

 액션에 대한 정보는 앞서 말한 듯이 EOSIO_DISPATCH를 이용합니다. 

// cardgame.cpp

EOSIO_DISPATCH(cardgame, (login))

 

그리고 개발된 소스를 eosio-cpp를 이용하여 빌드해주도록 합니다.

eosio-cpp -o destination.wasm source.cpp

 

로그인을 위한 스마트 컨트랙트 개발은 완료되었습니다. 이어서 스마트 컨트랙트의 로그인 액션을 호출하기 위한 화면을 개발하도록 하겠습니다. 우선 개발에 사용하기 위한 세 개의 모듈을 설치해주도록 합니다.

npm install --save redux
npm install --save react-redux
npm install --save eosjs

 

로그인 화면에서는 계정명과 프라이빗 키를 입력받을 수 있는 두 개의 필드와 입력받은 값으로 로그인을 실행할 버튼이 필요합니다. Login.jsx는 src/components/Login 경로 아래 작성되며, 경로 아래의 index.js에서 export 하는 구조를 갖게 됩니다. 이는 앞의 App.jsx를 작성하였을 떄 취한 방법과 동일하며, export된 컴포넌트는 components 경로 아래의 index.js에서 다시 import되어 src경로 아래에 있는 index.js에서 사용할 수 있도록 export해줍니다. 

// src/components/Login/Login.jsx
import React, { Component } from 'react'
import { Button } from 'components'

class Login extends Component {
  render() {
    return (
      <div className="Login">
        <div className="title">Elemental Battles - powered by EOSIO</div>
        <div className="description">Please use the Account Name and Private Key generated in the previous page to log into the game.</div>
        <form name="form">
          <div className="field">
            <label>Account name</label>
            <input
              type="text"
              name="username"
              placeholder="All small letters, a-z, 1-5 or dot, max 12 characters"
              pattern="[\.a-z1-5]{2,12}"
              required
            />
          </div>
          <div className="field">
            <label>Private key</label>
            <input
              type="password"
              name="key"
              pattern="^.{51,}$"
              required
            />
          </div>
          <div className="bottom">
            <Button type="submit" className="green">
              { "CONFIRM" }
            </Button>
          </div>
        </form>
      </div>
    )
  }
}

export default Login

 

작성된 위 프로그램에서 우리는 아직 작성하지 않은 Button을 사용하고 있고, Login 화면의 스타일을 정의해준 css가 없다는 것을 알 수 있습니다. 이러한 여타 프로그램까지 포스팅에서 언급하지는 않겠습니다. 중요한 로직을 제외한 부분은 적절히 구현하시거나, eosio에서 제공하고 있는 코드를 추가하시는 방식으로 진행하시기 바랍니다.

 

Elemental Battles Tutorial Lesson 2

https://github.com/EOSIO/eosio-card-game-repo/tree/lesson-2

 

Login.jsx가 작성되었다면, 이제 Login 컴포넌트를 App.jsx에 추가해줍니다.

// src/components/App/App.jsx
import React, { Component } from 'react'
import { Login } from 'components'

class App extends Component {
  render() {
    return (
      <div className="App">
        <Login />
      </div>
    );
  }
}

export default App

 

여기까지가 Login의 화면을 구성하는 단계였습니다. 이제 직접 스마트 컨트랙트에 로그인 액션을 요청하기 위한 로직을 추가해보도록 하겠습니다.

 

로그인을 하기 위한 account와 private key를 입력받기 위한 handler를 Login.jsx에 추가합니다.

import React, { Component } from 'react';
import { Button } from 'components';

class Login extends Component {
  constructor(props) {
    super(props);
    this.state = {
      form: {
        username: '',
        key: '',
      },
    }
  }

  handleChange = (event) => {
    const { name, value } = event.target
    const { form } = this.state

    this.setState({
      form: {
        ...form,
        [name]: value,
        error: '',
      },
    })
  }

  handleSubmit = (event) => {
    event.preventDefault()

    // TODO: submit transactions to smart contract
  }

  render() {
    const { form } = this.state

    return (
      <div className="Login">
        <div className="title">Elemental Battles - powered by EOSIO</div>
        <div className="description">Please use the Account Name and Private Key generated in the previous page to log into the game.</div>
        <form name="form" onSubmit={ this.handleSubmit }>
          <div className="field">
            <label>Account name</label>
            <input
              type="text"
              name="username"
              value={ form.username }
              placeholder="All small letters, a-z, 1-5 or dot, max 12 characters"
              onChange={ this.handleChange }
              pattern="[\.a-z1-5]{2,12}"
              required
            />
          </div>
          <div className="field">
            <label>Private key</label>
            <input
              type="password"
              name="key"
              value={ form.key }
              onChange={ this.handleChange }
              pattern="^.{51,}$"
              required
            />
          </div>
          <div className="bottom">
            <Button type="submit" className="green">
              { "CONFIRM" }
            </Button>
          </div>
        </form>
      </div>
    )
  }
}

export default Login

handleChange는 입력받는 account와 private key를 state에 입력, 저장하기 위한 로직이며, handleSubmit은 eosio에 로그인 액션을 요청하기 위한 로직이 추가될 것입니다. 로그인 액션을 요청하기 위해서는 eosio 상의 cardgame 컨트랙트와 통신하기 위한 기본적인 설정과 로직이 추가되어야 합니다. 

 

#.env
NODE_PATH=src

REACT_APP_EOS_CONTRACT_NAME="cardgameacc"
REACT_APP_EOS_HTTP_ENDPOINT="http://localhost:8888"

설정파일은 frontend 프로젝트 바로 아래 .env라는 이름으로 위치하며, 여기서 프로젝트 기본 path 정보인 NODE_PATH 값과 컨트랙트 명, 호출할 end point 정보 등을 기록합니다. 그다음 src아래에 services라는 폴더를 생성한 후 ApiService.js라는 파일을 생성해주는데 이 파일 내에 eosjs를 이용하여 블록체인과 통신하는 로직이 담기게 됩니다.

 

// src/services/ApiService.js
import { Api, JsonRpc } from 'eosjs'
import { JsSignatureProvider } from 'eosjs/dist/eosjs-jssig'

async function takeAction(action, dataValue) {
  const privateKey = localStorage.getItem("cardgame_key")
  const rpc = new JsonRpc(process.env.REACT_APP_EOS_HTTP_ENDPOINT)
  const signatureProvider = new JsSignatureProvider([privateKey])
  const api = new Api({ rpc, signatureProvider, textDecoder: new TextDecoder(), textEncoder: new TextEncoder() })

  try {
    const resultWithConfig = await api.transact({
      actions: [{
        account: process.env.REACT_APP_EOS_CONTRACT_NAME,
        name: action,
        authorization: [{
          actor: localStorage.getItem("cardgame_account"),
          permission: 'active',
        }],
        data: dataValue,
      }]
    }, {
      blocksBehind: 3,
      expireSeconds: 30,
    })
    return resultWithConfig
  } catch (err) {
    throw(err)
  }
}

class ApiService {
  static login({ username, key }) {
    return new Promise((resolve, reject) => {
      localStorage.setItem("cardgame_account", username)
      localStorage.setItem("cardgame_key", key)

      takeAction("login", { username: username })
        .then(() => {
          resolve()
        })
        .catch(err => {
          localStorage.removeItem("cardgame_account")
          localStorage.removeItem("cardgame_key")
          reject(err)
        })
    })
  }
}

export default ApiService

 

로직을 살펴보면 사용자가 account와 private key를 입력하고, submit를 하게되었을 때, ApiService의 login 함수를 호출하게 됩니다. 이때 각각 local storage의 cardgame_account, cardgame_key라는 이름으로 값을 저장하고, takeAction 함수를 호출하게 됩니다. takeAaction함수는 첫번째 파라미터로 action 명을 받게 되는데, 여기서는 "login"이 액션명임을 알 수 있습니다. 그다음 설정에서 저장해놓은 end point 정보를 이용하여 rpc 객체를 생성하고, 사용자가 입력한 private key를 이용하여 signatureProvider를 생성합니다. 그리고 이들을 이용하여 Api 객체를 선언합니다. 이 api의 transact 함수에 컨트랙트, 액션, 권한, 데이터 등을 포함시켜 실행하였습니다. 함수의 결과값으로 받게되는 resultWithConfig를 반환해줌으로써 작동을 종료하게 됩니다. 

 

그다음 로그인을 위하여 입력한 사용자 정보가 저장, 관리될 수 있도록 이벤트를 작성합니다. 이벤트는 UserAction에 setUser라는 이름으로 작성됩니다. 그리고 이 이벤트를 통하여 state에 반영될 수 있도록 UserReducer도 작성해줍니다. UserReducer는 combineReducers를 통하여 여러 reducer들과 결합됩니다. 결합되는 로직은 reducers 경로 아래의 index.js에서 처리해줍니다.

 

// src/actions/UserAction.js
import { ActionTypes } from 'const';

class UserAction {

  static setUser({ name, win_count, lost_count, game }) {
    return {
      type: ActionTypes.SET_USER,
      name,
      win_count,
      lost_count,
      game,
    }
  }
}

export default UserAction

 

// src/reducers/UserReducer.js
import { ActionTypes } from 'const'

const initialState = {
  name: "",
  win_count: 0,
  lost_count: 0,
  game: null,
}

export default function (state = initialState, action) {
  switch (action.type) {
    case ActionTypes.SET_USER: {
      return Object.assign({}, state, {
        name: typeof action.name === "undefined" ? state.name : action.name,
        win_count: action.win_count || initialState.win_count,
        lost_count: action.lost_count || initialState.lost_count,
        game: action.game || initialState.game,
      })
    }
    default:
      return state
  }
}

 

이제 로그인을 실제로 처리할 수 있는 기본적인 코드들은 모두 작성이 완료되었습니다. Login.jsx로 이동하여 // TODO로 주석처리하고 넘어갔던, handleSubmit 부분에 ApiService를 호출하여 컨트랙트의 login 액션을 호출하는 로직을 추가해줍니다. 그리고 redux 로직을 추가하여 최종적으로 아래와 같은 코드가 나오도록 작성합니다.

 

// src/components/Login/Login.jsx
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { Button } from 'components'
import { UserAction } from 'actions'
import { ApiService } from 'services'

class Login extends Component {
  constructor(props) {
    super(props)

    this.state = {
      form: {
        username: '',
        key: '',
        error: ''
      },
    }
  }

  handleChange = (event) => {
    const { name, value } = event.target
    const { form } = this.state

    this.setState({
      form: {
        ...form,
        [name]: value,
        error: '',
      },
    })
  }

  handleSubmit = (event) => {
    event.preventDefault()

    const { form } = this.state
    const { setUser } = this.props

    return ApiService.login(form)
      .then(() => {
        setUser({ name: form.username })
      })
      .catch(err => {
        this.setState({ error: err.toString() })
      })
  }

  render() {
    const { form, error } = this.state

    return (
      <div className="Login">
        <div className="title">Elemental Battles - powered by EOSIO</div>
        <div className="description">Please use the Account Name and Private Key generated in the previous page to log into the game.</div>
        <form name="form" onSubmit={ this.handleSubmit }>
          <div className="field">
            <label>Account name</label>
            <input
              type="text"
              name="username"
              placeholder="All small letters, a-z, 1-5 or dot, max 12 characters"
              pattern="[\.a-z1-5]{2,12}"
              onChange={ this.handleChange }
              required
            />
          </div>
          <div className="field">
            <label>Private key</label>
            <input
              type="password"
              name="key"
              pattern="^.{51,}$"
              onChange={ this.handleChange }
              required
            />
          </div>
          <div className="field form-error">
            { error && <span className="error">{ error }</span> }
          </div>
          <div className="bottom">
            <Button type="submit" className="green">
              { "CONFIRM" }
            </Button>
          </div>
        </form>
      </div>
    )
  }
}

const mapStateToProps = state => state

const mapDispatchToProps = {
  setUser: UserAction.setUser,
}
export default connect(mapStateToProps, mapDispatchToProps)(Login)

 

그리고 게임을 위한 화면인 Game.jsx를 아래와 같이 작성한 후, App.jsx에 사용자명이 저장되었는지, 즉 정상적으로 로그인이 되었는지를 확인하여 정상적으로 로그인된 경우, Game화면이 나오고 그렇지 않은 경우 Login 화면이 나오도록 로직을 수정해줍니다. 그리고 App.jsx를 출력해주는 src/index.js를 아래와 같이 수정해줍니다.

 

// src/components/Game/Game.jsx
import React, { Component } from 'react'

class Game extends Component {
  render() {
    return (
      <section className="Game">
        Welcome!
      </section>
    )
  }
}

export default Game

 

// src/components/App/App.jsx
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { Game, Login } from 'components'

class App extends Component {
  render() {
    const { user: { name } } = this.props

    return (
      <div className="App">
        { name && <Game /> }
        { !name && <Login /> }
      </div>
    )
  }
}

const mapStateToProps = state => state
export default connect(mapStateToProps)(App)

 

// src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { App } from './components'
import { Provider } from 'react-redux'
import store from './store'

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

 

작성을 완료하였다면, npm start나 yarn start로 프로젝트를 실행시킨 뒤, 미리 만들어 놓은 테스트용 eos account와 private key를 입력하여 로그인합니다. 만일 정상적으로 로그인 되었다면 Game화면으로 넘어가면서 Welcome!이 출력되있는 것을 확인하실 수 있습니다.

 

처음 제대로 컨트랙트와 클라이언트를 개발하다보니 내용이 길어지게 되었습니다. 이후에는 세팅하는 부분이 없이 바로 개발하게 되기 때문에 오히려 내용이 간단하게 느껴지실 수 있습니다.

 

다음 포스팅에서는 블록체인에 사용자 정보를 조회하여 가져오고, 이를 화면에 어떻게 출력할 수 있는지 알아보도록 하겠습니다.