사랑하애오
Published 2022. 2. 25. 12:13
솔리디티 투표 DApp(고급) Solidity

이전 포스팅에서는 html, css, js(jQuery)로 투표 DApp을 구현했는데 이번에는 리액트로 구현해보겠습니다.

 

ReactJS 프로젝트 설정

 
mkdir VotingDapp-React

cd VotingDapp-React

truffle unbox react
.
트러플 모듈 중 하나인 unbox를 활용해 쉽게 구조를 잡을 수 있습니다.
 
위와 같은 방법 이외로는
npx(node package execute) 혹은 yarn을 활용해
 
npx create-react-app VotingDapp-React

or

yarn create react-app VotingDapp-React

로 React앱을 만들고

cd VotingDapp-React

truffle init

이렇게 두번에 걸쳐 만들어야 하기 때문에 위의 방법이 좀 더 빠르고 편하다.

 

Client 내부의 package.json 수정

 

이 포스팅 기준으로 앞으로 펼쳐질 포스팅의 내용을 따라오시다보면 에러가 엄청날겁니다.

이유는 버전이 달라서 compile도 안되고 라이브러리 및 모듈들을 사용할 수 없기 때문인데

강제로 버전을 맞춰주도록 하겠습니다.

cd client

npm uninstall react react-dom react-router-dom react-scripts web3

다 지웁니다.

그리고 package.json에서 dependencise 내부에

"@truffle/contract": "^4.1.11",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router-dom": "^5.1.2",
"react-scripts": "3.4.0",
"rimble-ui": "^0.11.1",
"styled-components": "^5.0.1",
"truffle-contract": "^4.0.31",
"web3": "^1.2.6"

 위의 코드를 추가합니다.

npm install

위의 명령어를 통해 추가한 모듈을 설치할 수 있습니다.(client 디렉토리입니다.)

 

public 디렉토리 내부의 index.html 기존 코드를 수정합니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>VotingDapp</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css"
    />
  </head>

  <body>
    <div id="root"></div>
  </body>
</html>

 

 

src 디렉토리 내부의 App.js 파일의 코드를 수정합니다.

import React from "react";

// 1. Importing other modules

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      web3: null,
      account: null,
      mainInstance: null,
    };
  }

  componentDidMount() {
    this.init();
  }

  async init() {
    // 2. Load web3
    // 3. Load Account
    // 4. Load Smart-Contract instance
  }

  render() {
    return <div>VotingDapp-React application</div>;
  }
}
export default App;

 

src 디렉토리 내부의 index.js 파일의 코드를 수정합니다.

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

밑에서 더 상세히 코딩을 하겠지만 React 프로젝트의 기본 구성이 끝났습니다.

 

Truffle 프로젝트 설정

 

truffle-config.js 파일의 코드를 수정합니다.

module.exports = {
  contracts_build_directory: "./src/build/contracts",
  networks: {
    development: {
      host: "127.0.0.1",
      port: 7545,
      network_id: "*",
    },
  },
  compilers: {
    solc: {
      version: "0.8.0"
    }
  }
};

이제 모든 프로젝트의 기본 설정이 완료되었습니다.

 

npm start

React를 실행해 잘 작동되는지 확인을 합니다.

 

Election Smart Contract 생성

 

contracts 디렉토리 내부에 Election.sol 파일을 만들고 다음 코드를 붙여넣습니다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.0;

contract Election {
  // Election details will be stored in these variables
  string public name;
  string public description;

  // Structure of candidate standing in the election
  struct Candidate {
    uint256 id;
    string name;
    uint256 voteCount;
  }

  // Storing candidates in a map
  mapping(uint256 => Candidate) public candidates;

  // Storing address of those voters who already voted
  mapping(address => bool) public voters;

  // Number of candidates in standing in the election
  uint256 public candidatesCount = 0;

  // Setting of variables and data, during the creation of election contract
  constructor(string[] memory _nda, string[] memory _candidates) public {
    require(_candidates.length > 0, "There should be atleast 1 candidate.");
    name = _nda[0];
    description = _nda[1];
    for (uint256 i = 0; i < _candidates.length; i++) {
      addCandidate(_candidates[i]);
    }
  }

  // Private function to add a candidate
  function addCandidate(string memory _name) private {
    candidates[candidatesCount] = Candidate(candidatesCount, _name, 0);
    candidatesCount++;
  }

  // Public vote function for voting a candidate
  function vote(uint256 _candidate) public {
    require(!voters[msg.sender], "Voter has already Voted!");
    require(
      _candidate < candidatesCount && _candidate >= 0,
      "Invalid candidate to Vote!"
    );
    voters[msg.sender] = true;
    candidates[_candidate].voteCount++;
  }
}

 

MainContract Smart Contract 생성

 

contracts 디렉토리 내부에 MainContract.sol 파일을 만들고 다음 코드를 붙여넣습니다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.0;

import './Election.sol';

contract MainContract {
  uint public electionId = 0;
  mapping (uint => address) public Elections;

  function createElection (string[] memory _nda, string[] memory _candidates) public {
    Election election = new Election(_nda, _candidates);
    Elections[electionId] = address(election);
    electionId++;
  }
}

 

마이그레이션을 위한 파일 생성

 

migrations 디렉토리 내부에 2_deploy_contracts.js 파일을 만들고 다음 코드를 추가합니다.

const MainContract = artifacts.require("MainContract");

module.exports = function(deployer) {
  deployer.deploy(MainContract);
};

 

가나슈 서버를 실행하고 아래 명령어를 치면

truffle compile

truffle migrate --reset

src/build/contracts 디렉토리에 JSON파일로 저장된 ABI 및 기타 필요한 메타데이터들이 컴파일됩니다.

 

사용자 인터페이스 구축

 

BlockchainUtils 구성 요소

src 디렉토리 내부에 BlockchainUtil.js 파일을 만들고 코드를 추가합니다.

import React from "react";
import Web3 from "web3";
import TruffleContract from "@truffle/contract";

export class GetWeb3 extends React.Component {
  async getWeb3() {
    let web3 = window.web3;

    if (typeof web3 !== "undefined") {
      // Setup Web3 Provider
      this.web3Provider = web3.currentProvider;
      this.web3 = new Web3(web3.currentProvider);
      return this.web3;
    } else {
      this.isWeb3 = false;
    }
  }
}

export class GetContract extends React.Component {
  async getContract(web3, contractJson) {
    // Setup Contract
    this.contract = await TruffleContract(contractJson);
    this.contract.setProvider(web3.currentProvider);
    return await this.contract.deployed();
  }
}

export class GetAccount extends React.Component {
  async getAccount(web3) {
    return await web3.eth.getAccounts();
  }
}

 

App.js 업데이트

번호로 주석처리된 부분에 다음 코드를 추가하합니다.

// 1. Importing other modules
import {GetWeb3, GetContract, GetAccount} from './BlockchainUtil';

 

// 2. Load web3
const Web3 = new GetWeb3();
this.web3 = await Web3.getWeb3();
this.setState({web3: this.web3});

// 3. Load Account
const Account = new GetAccount();
this.account = await Account.getAccount(this.web3);
this.setState({account: this.account[0]});

// 4. Load Contract
const Contract = new GetContract();
this.mainInstance = await Contract.getContract(this.web3, contractJson);
this.setState({mainInstance: this.mainInstance});

 

CreateElection 구성 요소

src 디렉토리 내부에 CreateElection.js 파일을 생성하고 다음 코드를 추가합니다.

import React, { Component } from "react";
import App from "./App";

class CreateElection extends Component {
  constructor(props) {
    super(props);

    this.onChangeElectionName = this.onChangeElectionName.bind(this);
    this.onChangeDescription = this.onChangeDescription.bind(this);
    this.onSubmit = this.onSubmit.bind(this);

    // These state variables would maintain inputs of the form
    this.state = {
      electionname: "",
      description: "",
      candidates: [],
    };
  }

  // To store App.js instance
  app = null;

  // Connect application with Metamask and create smart-contract's instance
  async init() {
    this.app = new App();
    await this.app.init();
  }

  componentDidMount() {
    this.init();
  }

  onChangeElectionName(e) {
    this.setState({
      electionname: e.target.value,
    });
  }

  onChangeDescription(e) {
    this.setState({
      description: e.target.value,
    });
  }

  // Function to be called when the form is submitted
  async onSubmit(e) {
    e.preventDefault();

    // Structuring Election details from the form before submitting transaction to the smart-contract
    const electionDetails = {
      electionname: this.state.electionname,
      description: this.state.description,
      candidateObjects: document.getElementsByName("candidate").values(),
      candidates: [],
    };

    var i = 0;

    for (var value of electionDetails.candidateObjects) {
      electionDetails.candidates[i] = value.value;
      i++;
    }

    // Making transaction to the MainContract instance, for creating a new election
    await this.app.mainInstance.createElection(
      [electionDetails.electionname, electionDetails.description],
      electionDetails.candidates,
      { from: this.app.account[0] }
    );

    window.location = "/active";
  }

  render() {
    return (
      <div className="container card">
        <h3>Create New Election</h3>

        {/* New Election Form */}
        <form onSubmit={this.onSubmit}>
          <div className="form-group">
            <label>Name</label>
            <input
              type="text"
              required
              className="form-control"
              placeholder="Enter election name"
              onChange={this.onChangeElectionName}
            />
          </div>

          <div className="form-group">
            <label>Description</label>
            <textarea
              type="text"
              required
              className="form-control"
              placeholder="Describe your Election here"
              onChange={this.onChangeDescription}
            ></textarea>
          </div>

          <table>
            <tr>
              <td id="1" className="form-group">
                <label>Candidate 1</label>
                <td>
                  <input
                    type="text"
                    required
                    className="form-control"
                    placeholder="Candidate Name"
                    name="candidate"
                  />
                </td>

                <br />
                <label>Candidate 2</label>
                <td>
                  <input
                    type="text"
                    required
                    className="form-control"
                    placeholder="Candidate Name"
                    name="candidate"
                  />
                </td>
              </td>
            </tr>
          </table>

          <br />

          <div>
            <button
              className="btn btn-success grid-item"
              style={{ width: 100 }}
              type="submit"
            >
              Submit
            </button>
          </div>

          <br />
        </form>
      </div>
    );
  }
}

export default CreateElection;

 

ActiveElections 구성 요소

src 디렉토리 내부에 ActiveElections.js 파일을 생성하고 다음 코드를 추가합니다.

import React, { Component } from "react";
import { Loader } from "rimble-ui";
import { Link } from "react-router-dom";

import App from "./App";
import ElectionJSON from "./build/contracts/Election.json";
import VoteModal from "./VoteModal";

// Election component for organising election details
var Election = (props) => (
  <tr>
    <td>{props.election.electionId}</td>

    <td>
      {props.election.electionName} <br />
      <font className="text-muted" size="2">
        <b>{props.election.electionDescription}</b>
      </font>
      <br />
      <font className="text-muted" size="2">
        {props.election.electionAddress}
      </font>
    </td>

    <td style={{ textAlign: "center" }}>{props.candidateComponent}</td>

    <td style={{ textAlign: "center" }}>
      {!props.election.hasVoted ? (
        // Vote Modal would be mounted if the user has not voted
        <VoteModal election={props.election} candidates={props.candidates} />
      ) : (
        <font size="2" color="green">
          You have voted!
        </font>
      )}
    </td>
  </tr>
);

// Candidate component for organising candidate details of each candidate
var Candidates = (props) => (
  <font size="2">
    <b>{props.name}</b> ({props.voteCount}) <br />
  </font>
);

// ActiveElections component would fetch and display all the the elections deployed by the MainContract.sol
class ActiveElections extends Component {
  constructor(props) {
    super(props);
    this.state = {
      data: [],
      loading: false,
    };
  }

  // To store App.js instance
  app = null;

  // Connect application with Metamask and create smart-contract's instance
  async init() {
    this.app = new App();
    await this.app.init();
    await this.loadData();
  }

  loader = false;

  componentDidMount() {
    this.init();
  }

  loadData = async () => {
    this.setState({ loading: true });

    // electionId maps to total elections created
    var eCount = await this.app.mainInstance.electionId();
    var elections = [], electionDetails = [], electionComponents = [];

    // Election details of every election created by MainContract
    for (var i = 0; i < eCount; i++) {
      elections[i] = await this.app.mainInstance.Elections(i);
      var election = await new this.app.web3.eth.Contract(ElectionJSON.abi, elections[i]);

      electionDetails[i] = [];

      // Account address of the voter
      electionDetails[i].account = this.app.account[0];

      // Each contract's instance
      electionDetails[i].contractInstance = election;

      // Address of each election contract
      electionDetails[i].electionAddress = elections[i];

      // Boolean indicating wether the contract address has voted or not
      electionDetails[i].hasVoted = await election.methods.voters(this.app.account[0]).call();

      // Name of the election
      electionDetails[i].electionName = await election.methods.name().call();

      // Description of the election
      electionDetails[i].electionDescription = await election.methods.description().call();

      // Election id
      electionDetails[i].electionId = i;

      // Organising candidates into components
      var candidatesCount = await election.methods.candidatesCount().call();
      var candidates = [], candidateComponents = [];
      candidates[i] = [];
      candidateComponents[i] = [];

      for (var j = 0; j < candidatesCount; j++) {
        candidates[i].push(await election.methods.candidates(j).call());
        candidateComponents[i].push(
          <Candidates
            name={candidates[i][j][1]}
            voteCount={candidates[i][j][2]}
          />
        );
      }

      // Saving the electionDetails in the form of a component
      electionComponents[i] = (
        <Election
          election={electionDetails[i]}
          candidates={candidates[i]}
          candidateComponent={candidateComponents[i]}
        />
      );
    }

    this.setState({
      data: electionComponents,
      loading: false,
    });
  };

  render() {
    return (
      // Simple container to store table with election data
      <div className="container">
        <div style={{ float: "right", marginBottom: "10px" }}>
          <img
            style={{ width: "25px", marginRight: "20px", cursor: "pointer" }}
            onClick={this.loadData}
            src="https://img.icons8.com/color/50/000000/synchronize.png"
          />
          <Link to="/createElection">
            <img
              style={{ width: "25px", cursor: "pointer" }}
              src="https://img.icons8.com/color/48/000000/plus-math.png"
            />
          </Link>
        </div>

        <table className="table table-hover table-bordered">
          <thead>
            <tr>
              <th style={{ width: "120px" }}>Election ID</th>
              <th>Election Name</th>
              <th style={{ textAlign: "center" }}>Candiates</th>
              <th style={{ textAlign: "center" }}>Vote</th>
            </tr>
          </thead>

          <tbody>{this.state.data}</tbody>
        </table>

        <center>{this.state.loading ? <Loader size="40px" /> : <></>}</center>
      </div>
    );
  }
}

export default ActiveElections;

 

VoteModal 구성 요소

src 디렉토리 내부에 VoteModal.js 파일을 생성하고 다음 코드를 추가합니다.

import React, { useState } from "react";
import { Box, Flex, Modal, Button, Text, Card, Radio, Field, Loader } from "rimble-ui";

// Data like election and candidate details will be passed in the props by ActiveElections.js (parent)
function VoteModal(props) {
  // These are React Hooks and are used only for UX like opening and closing of Voting Modal and loaders
  const [isOpen, setIsOpen] = useState(false);
  const [loading, isLoading] = useState(false);

  // This Hook will be used to maintain the selected candidate ID by a voter
  const [cid, changeCid] = useState(0);

  const closeModal = (e) => {
    e.preventDefault();
    setIsOpen(false);
  };

  const openModal = (e) => {
    e.preventDefault();
    setIsOpen(true);
  };

  const onRadioChange = (e) => {
    changeCid(e.target.value);
  };

  // vote() function would be used to transact a vote
  const vote = async (eid) => {
    isLoading(true);
    await props.election.contractInstance.methods.vote(cid).send({ from: props.election.account });
    isLoading(false);
  };

  var candid = [],
    candidVote = [];
  for (var i = 0; i < props.candidates.length; i++) {
    var candidDetail = props.candidates[i][1] + " (" + props.candidates[i][2] + ")";

    candid.push(
      <Radio
        name="candidate"
        key={i}
        label={candidDetail}
        my={2}
        value={props.candidates[i][0]}
        onChange={onRadioChange}
      />
    );

    candidVote.push(props.candidates[i][2]);
  }

  return (
    // This is a rimble-ui builtin modal for triggering vote() function
    <Box className="App" p={0}>
      <Box>
        <Button onClick={openModal}>Vote</Button>

        <Modal isOpen={isOpen}>
          <Card width={"420px"} p={0}>
            {/* Close icon to close the modal */}
            <Button.Text
              icononly
              icon={"Close"}
              color={"moon-gray"}
              position={"absolute"}
              top={0}
              right={0}
              mt={3}
              mr={3}
              onClick={closeModal}
            />

            {/* List of candidates with their vote count */}
            <Box p={4} mb={3}>
              <h3>{props.election.electionName}</h3>
              <Field label="Choose candidate from below">{candid}</Field>
            </Box>

            {/* Vote button to cast a vote */}
            <Flex
              px={4}
              py={3}
              borderTop={1}
              borderColor={"#E8E8E8"}
              justifyContent={"flex-end"}
            >
              {loading ? (
                <Loader size="40px" />
              ) : (
                <Button.Outline
                  onClick={() => {
                    vote(props.election.electionId);
                  }}
                >
                  Vote
                </Button.Outline>
              )}
            </Flex>
          </Card>
        </Modal>
      </Box>
    </Box>
  );
}

export default VoteModal;

 

구성 요소를 App.js에 통합

App.js에 지금까지 만든 모든 구성 요소 파일을 업데이트합니다.

// 1. Importing other modules
import {GetWeb3, GetContract, GetAccount} from './BlockchainUtil';
import { BrowserRouter as Router, Route, Link, Redirect } from "react-router-dom";
import CreateElection from "./CreateElection"
import ActiveElections from "./ActiveElections";
import contractJson from './build/contracts/MainContract.json';

 

return() 함수에 <div> 태그 내의 텍스트를 다음 구성 요소 코드로 수정합니다.

<Router>
  {/* Default route to ActiveElections component */}
  <Route path="/" exact>
    <Redirect to="/active"/>
  </Route>

  {/* Navbar */}
  <nav className="navbar navbar-dark shadow" style={{backgroundColor: "#1b2021", height: "60px", color: "white", marginBottom: "50px"}}>
    {/* Link to Active election page (nav-header) */}
    <Link to = "/active"><b style = {{cursor: "pointer", color: "white"}}>VotingDapp-React Elections</b></Link>

    {/* Account address on the right side of the navbar  */}
    <span style={{float: "right"}}>{this.state.account}</span>
  </nav>

  {/* Route to CreateElection page */}
  {<Route path="/createElection" exact component={() => <CreateElection account={this.state.account}/>}/>}

  {/* Route to Active election page */}
  {<Route path="/active" exact component={() => <ActiveElections account={this.state.account}/>}/>}
</Router>

 

이로써 프로젝트는 완성되었고 

npm start

를 통하여 앱을 실행하여 기능들을 확인하시면 됩니다.

profile

사랑하애오

@사랑하애

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!