Nic Lin's Blog

喜歡在地上打滾的 Rails Developer

用 Truffle 開發 DApp 以太坊投票程序應用 Part 2

一般投票泛指普通的選舉,例如透過一人一票來選出國家總統。

有一種加權投票(weighted voting)常常用於上市交易的公司。這些公司股東的投票權取決於其持有的股票數量。

假設說你有 10,000 股的公司股票,那你就有 10,000 個投票權(這就和普通投票一人一票是不同的)

這個應用場景,我們可以實做一個 DAPP 來發行公司股票,該應用允許任何人購買股票成為股東。

股東基於擁有的股票數為候選人投票,例如你有 10,000 股,那你可以一個候選人投 5000 股,另一個 3000 股,第三個候選人 2000 股之類的。

我們預期透過應用,能夠調用 buy() 購買股票,然後利用 voteForCandidate() 為特定人選投票。

合約設計

  • voterInfo: 投票人信息字典
  • totalTokens: 發行股票的總量
  • balanceTokens: 股票剩餘數量
  • tokenPrice: 股票單價
  • buy() payable: 購買股票

投票人信息: solidity 的 struct 類型可以將相關數據組織在一起。用 struct 來儲存投票的信息非常好,如果你不瞭解 struct,可以把他當成沒有 method 的 class。

struct voter {
  address voterAddress; //投票人帳戶地址
  uint tokensBought;    //投票人持有的股票數量
  uint[] tokensUsedPerCandidate; //為每個候選人消耗的股票數量
}

股票 Token

  • totalToken 保證總量
  • balanceTokens 保證餘額
  • tokenPrice 保證價格

任何人都可以調用 buy() 來買 token, 這裡有 payable 的修飾符,在 sodility 的合約中,只有聲明為 payable 的 method 才可以接受支付的貨幣 (msg.value)

example:

contract Voting{
  function buy() payable public returns (uint) {
    //利用 msg.value 來讀取用戶的支付金額,這方法必須要有 payable 聲明。
  }
}

購買 Token

function buy() payable public returns (uint) {
  uint tokensToBuy = msg.value / tokenPrice; //根據購買金額和通證單價,計算出購買量
  require(tokensToBuy <= balanceTokens); //繼續執行合約需要確認合約的通證餘額不小於購買量
  voterInfo[msg.sender].voterAddress = msg.sender; //保存購買人地址
  voterInfo[msg.sender].tokensBought += tokensToBuy; //更新購買人持股數量
  balanceTokens -= tokensToBuy; //將售出的通證數量從合約的餘額中剔除
  return tokensToBuy; //返回本次購買的通證數量
}

call buy()

contract.buy({
  value:web3.toWei('1','ether'), //購買者支付的以太幣金額
  from:web3.eth.accounts[1] //購買者賬戶地址
})

透過 console 調用

truffle(development)> Voting.deployed().then(function(contract) {contract.buy({value: web3.toWei('1', 'ether'), from: web3.eth.accounts[1]})})

初始化

~$ mkdir ~/tkapp
~$ cd ~/tkapp
~/tkapp$ truffle unbox webpack
~/tkapp$ rm contracts/ConvertLib.sol contracts/MetaCoin.sol

修改 app/index.html 如下

<!DOCTYPE html>
<html>
<head>
  <title>Hello World DApp</title>
  <link href='https://fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
  <link href='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css' rel='stylesheet' type='text/css'>
  <style>
    .margin-top-3 {
      margin-top: 3em;
    }
  </style>
</head>
<body class="container">
  <h1>A Simple Hello World Voting Application</h1>
  <div class="col-sm-7 margin-top-3">
    <h2>Candidates</h2>
    <div class="table-responsive">
      <table class="table table-bordered">
        <thead>
          <tr>
            <th>Candidate</th>
            <th>Votes</th>
          </tr>
        </thead>
        <tbody id="candidate-rows">
        </tbody>
      </table>
    </div>
    <div class="container-fluid">
      <h2>Vote for Candidate</h2>
      <div id="msg"></div>
      <input type="text" id="candidate" placeholder="Enter the candidate name"/>
      <br>
      <br>
      <input type="text" id="vote-tokens" placeholder="Total no. of tokens to vote"/>
      <br>
      <br>
      <a href="#" onclick="voteForCandidate()" class="btn btn-primary">Vote</a>
    </div>
  </div>
  <div class="col-sm-offset-1 col-sm-4 margin-top-3">
    <div class="row">
      <h2>Token Stats</h2>
      <div class="table-responsive">
        <table class="table table-bordered">
          <tr>
            <td>Tokens For Sale</td>
            <td id="tokens-total"></td>
          </tr>
          <tr>
            <td>Tokens Sold</td>
            <td id="tokens-sold"></td>
          </tr>
          <tr>
            <td>Price Per Token</td>
            <td id="token-cost"></td>
          </tr>
          <tr>
            <td>Balance in the contract</td>
            <td id="contract-balance"></td>
          </tr>
        </table>
      </div>
    </div>
    <div class="row margin-top-3">
      <h2>Purchase Tokens</h2>
      <div class="col-sm-12">
        <div id="buy-msg"></div>
        <input type="text" id="buy" class="col-sm-8" placeholder="Number of tokens to buy"/>&nbsp;
        <a href="#" onclick="buyTokens()" class="btn btn-primary">Buy</a>
      </div>
    </div>
    <div class="row margin-top-3">
      <h2>Lookup Voter Info</h2>
      <div class="col-sm-12">
        <input type="text" id="voter-info", class="col-sm-8" placeholder="Enter the voter address" />&nbsp;
        <a href="#" onclick="lookupVoterInfo()" class="btn btn-primary">Lookup</a>
        <div class="voter-details row text-left">
          <div id="tokens-bought" class="margin-top-3 col-md-12"></div>
          <div id="votes-cast" class="col-md-12"></div>
        </div>
      </div>
    </div>
  </div>
</body>
<script src="https://code.jquery.com/jquery-3.1.1.slim.min.js"></script>
<script src="app.js"></script>
</html>

修改 app/scripts/index.js

// Import the page's CSS. Webpack will know what to do with it.
import "../styles/app.css";

// Import libraries we need.
import { default as Web3} from 'web3';
import { default as contract } from 'truffle-contract'

/*
 * When you compile and deploy your Voting contract,
 * truffle stores the abi and deployed address in a json
 * file in the build directory. We will use this information
 * to setup a Voting abstraction. We will use this abstraction
 * later to create an instance of the Voting contract.
 * Compare this against the index.js from our previous tutorial to see the difference
 * https://gist.github.com/maheshmurthy/f6e96d6b3fff4cd4fa7f892de8a1a1b4#file-index-js
 */

import voting_artifacts from '../../build/contracts/Voting.json'

var Voting = contract(voting_artifacts);

let candidates = {}

let tokenPrice = null;

window.voteForCandidate = function(candidate) {
  let candidateName = $("#candidate").val();
  let voteTokens = $("#vote-tokens").val();
  $("#msg").html("Vote has been submitted. The vote count will increment as soon as the vote is recorded on the blockchain. Please wait.")
  $("#candidate").val("");
  $("#vote-tokens").val("");

  /* Voting.deployed() returns an instance of the contract. Every call
   * in Truffle returns a promise which is why we have used then()
   * everywhere we have a transaction call
   */
  Voting.deployed().then(function(contractInstance) {
    contractInstance.voteForCandidate(candidateName, voteTokens, {gas: 140000, from: web3.eth.accounts[0]}).then(function() {
      let div_id = candidates[candidateName];
      return contractInstance.totalVotesFor.call(candidateName).then(function(v) {
        $("#" + div_id).html(v.toString());
        $("#msg").html("");
      });
    });
  });
}

/* The user enters the total no. of tokens to buy. We calculate the total cost and send it in
 * the request. We have to send the value in Wei. So, we use the toWei helper method to convert
 * from Ether to Wei.
 */

window.buyTokens = function() {
  let tokensToBuy = $("#buy").val();
  let price = tokensToBuy * tokenPrice;
  $("#buy-msg").html("Purchase order has been submitted. Please wait.");
  Voting.deployed().then(function(contractInstance) {
    contractInstance.buy({value: web3.toWei(price, 'ether'), from: web3.eth.accounts[0]}).then(function(v) {
      $("#buy-msg").html("");
      web3.eth.getBalance(contractInstance.address, function(error, result) {
        $("#contract-balance").html(web3.fromWei(result.toString()) + " Ether");
      });
    })
  });
  populateTokenData();
}

window.lookupVoterInfo = function() {
  let address = $("#voter-info").val();
  Voting.deployed().then(function(contractInstance) {
    contractInstance.voterDetails.call(address).then(function(v) {
      $("#tokens-bought").html("Total Tokens bought: " + v[0].toString());
      let votesPerCandidate = v[1];
      $("#votes-cast").empty();
      $("#votes-cast").append("Votes cast per candidate: <br>");
      let allCandidates = Object.keys(candidates);
      for(let i=0; i < allCandidates.length; i++) {
        $("#votes-cast").append(allCandidates[i] + ": " + votesPerCandidate[i] + "<br>");
      }
    });
  });
}

/* Instead of hardcoding the candidates hash, we now fetch the candidate list from
 * the blockchain and populate the array. Once we fetch the candidates, we setup the
 * table in the UI with all the candidates and the votes they have received.
 */
function populateCandidates() {
  Voting.deployed().then(function(contractInstance) {
    contractInstance.allCandidates.call().then(function(candidateArray) {
      for(let i=0; i < candidateArray.length; i++) {
        /* We store the candidate names as bytes32 on the blockchain. We use the
         * handy toUtf8 method to convert from bytes32 to string
         */
        candidates[web3.toUtf8(candidateArray[i])] = "candidate-" + i;
      }
      setupCandidateRows();
      populateCandidateVotes();
      populateTokenData();
    });
  });
}

function populateCandidateVotes() {
  let candidateNames = Object.keys(candidates);
  for (var i = 0; i < candidateNames.length; i++) {
    let name = candidateNames[i];
    Voting.deployed().then(function(contractInstance) {
      contractInstance.totalVotesFor.call(name).then(function(v) {
        $("#" + candidates[name]).html(v.toString());
      });
    });
  }
}

function setupCandidateRows() {
  Object.keys(candidates).forEach(function (candidate) {
    $("#candidate-rows").append("<tr><td>" + candidate + "</td><td id='" + candidates[candidate] + "'></td></tr>");
  });
}

/* Fetch the total tokens, tokens available for sale and the price of
 * each token and display in the UI
 */
function populateTokenData() {
  Voting.deployed().then(function(contractInstance) {
    contractInstance.totalTokens().then(function(v) {
      $("#tokens-total").html(v.toString());
    });
    contractInstance.tokensSold.call().then(function(v) {
      $("#tokens-sold").html(v.toString());
    });
    contractInstance.tokenPrice().then(function(v) {
      tokenPrice = parseFloat(web3.fromWei(v.toString()));
      $("#token-cost").html(tokenPrice + " Ether");
    });
    web3.eth.getBalance(contractInstance.address, function(error, result) {
      $("#contract-balance").html(web3.fromWei(result.toString()) + " Ether");
    });
  });
}

$( document ).ready(function() {
  if (typeof web3 !== 'undefined') {
    console.warn("Using web3 detected from external source like Metamask")
    // Use Mist/MetaMask's provider
    window.web3 = new Web3(web3.currentProvider);
  } else {
    console.warn("No web3 detected. Falling back to http://localhost:8545. You should remove this fallback when you deploy live, as it's inherently insecure. Consider switching to Metamask for development. More info here: http://truffleframework.com/tutorials/truffle-and-metamask");
    // fallback - use your fallback strategy (local node / hosted node + in-dapp id mgmt / fail)
    window.web3 = new Web3(new Web3.providers.HttpProvider("http://localhost:8545"));
  }

  Voting.setProvider(web3.currentProvider);
  populateCandidates();

});

建立合約 touch contracts/Voting.sol

pragma solidity ^0.4.18;

contract Voting {

 struct voter {
  address voterAddress;
  uint tokensBought;
  uint[] tokensUsedPerCandidate;
 }

 mapping (address => voter) public voterInfo;

 mapping (bytes32 => uint) public votesReceived;

 bytes32[] public candidateList;

 uint public totalTokens;
 uint public balanceTokens;
 uint public tokenPrice;

 constructor(uint tokens, uint pricePerToken, bytes32[] candidateNames) public {
  candidateList = candidateNames;
  totalTokens = tokens;
  balanceTokens = tokens;
  tokenPrice = pricePerToken;
 }

 function buy() payable public returns (uint) {
  uint tokensToBuy = msg.value / tokenPrice;
  require(tokensToBuy <= balanceTokens);
  voterInfo[msg.sender].voterAddress = msg.sender;
  voterInfo[msg.sender].tokensBought += tokensToBuy;
  balanceTokens -= tokensToBuy;
  return tokensToBuy;
 }

 function totalVotesFor(bytes32 candidate) view public returns (uint) {
  return votesReceived[candidate];
 }

 function voteForCandidate(bytes32 candidate, uint votesInTokens) public {
  uint index = indexOfCandidate(candidate);
  require(index != uint(-1));

  if (voterInfo[msg.sender].tokensUsedPerCandidate.length == 0) {
   for(uint i = 0; i < candidateList.length; i++) {
    voterInfo[msg.sender].tokensUsedPerCandidate.push(0);
   }
  }

  uint availableTokens = voterInfo[msg.sender].tokensBought - totalTokensUsed(voterInfo[msg.sender].tokensUsedPerCandidate);
  require (availableTokens >= votesInTokens);

  votesReceived[candidate] += votesInTokens;
  voterInfo[msg.sender].tokensUsedPerCandidate[index] += votesInTokens;
 }

 function totalTokensUsed(uint[] _tokensUsedPerCandidate) private pure returns (uint) {
  uint totalUsedTokens = 0;
  for(uint i = 0; i < _tokensUsedPerCandidate.length; i++) {
   totalUsedTokens += _tokensUsedPerCandidate[i];
  }
  return totalUsedTokens;
 }

 function indexOfCandidate(bytes32 candidate) view public returns (uint) {
  for(uint i = 0; i < candidateList.length; i++) {
   if (candidateList[i] == candidate) {
    return i;
   }
  }
  return uint(-1);
 }

 function tokensSold() view public returns (uint) {
  return totalTokens - balanceTokens;
 }

 function voterDetails(address user) view public returns (uint, uint[]) {
  return (voterInfo[user].tokensBought, voterInfo[user].tokensUsedPerCandidate);
 }

 function transferTo(address account) public {
  account.transfer(address(this).balance);
 }

 function allCandidates() view public returns (bytes32[]) {
  return candidateList;
 }
}

準備就緒

  1. 先 compile truffle compile
  2. 編譯網頁 webpack
  3. 確認 ganache-cli 已經開啟
  4. 部屬合約 truffle migrate

測試合約

  • 一個候選人(比如 Nick)有多少投票?
truffle(development)> Voting.deployed().then(function(instance) {instance.totalVotesFor.call('Nick').then(function(i) {console.log(i)})})
  • 一共初始化發行了多少 Token?
truffle(development)> Voting.deployed().then(function(instance) {console.log(instance.totalTokens().then(function(v) {console.log(v)}))})
  • 已經售出了多少 Token?
truffle(development)> Voting.deployed().then(function(instance) {console.log(instance.tokensSold().then(function(v) {console.log(v)}))})
  • 購買 100 個 Token
truffle(development)> Voting.deployed().then(function(instance) {console.log(instance.buy({value: web3.toWei('1', 'ether')}).then(function(v) {console.log(v)}))})
  • 購買以後賬戶餘額是多少?
truffle(development)> web3.eth.getBalance(web3.eth.accounts[0])
  • 已經售出了多少 Token?
Voting.deployed().then(function(instance) {console.log(instance.tokensSold().then(function(v) {console.log(v)}))})
  • 給 Jose 投 25 個 Token,給 Rama 和 Nick 各投 10 個 Token。
truffle(development)> Voting.deployed().then(function(instance) {console.log(instance.voteForCandidate('Jose', 25).then(function(v) {console.log(v)}))})
truffle(development)> Voting.deployed().then(function(instance) {console.log(instance.voteForCandidate('Rama', 10).then(function(v) {console.log(v)}))})
truffle(development)> Voting.deployed().then(function(instance) {console.log(instance.voteForCandidate('Nick', 10).then(function(v) {console.log(v)}))})
  • 查詢你所投賬戶的投票人信息(除非用了其他賬戶,否則你的賬戶默認是 web3.eth.accounts[0])
truffle(development)> Voting.deployed().then(function(instance) {console.log(instance.voterDetails('0x004ee719ff5b8220b14acb2eac69ab9a8221044b').then(function(v) {console.log(v)}))})
  • 現在候選人 Rama 有多少投票?
truffle(development)> Voting.deployed().then(function(instance) {instance.totalVotesFor.call('Rama').then(function(i) {console.log(i)})})

如果到這邊都 ok, 那就只剩下網站的交互了

不過前面的 index 都一次放上了,直接 npm run dev 後,將 metamask 指向 localhost:8545 應該就可以直接操作了

comments powered by Disqus