[區塊鏈-以太坊] 募資平台建立-Part 1. 提案

Chiwen Lai
22 min readMay 31, 2018

--

先看一下提案完成的畫面:

  1. 首頁中會列出所有募資中的計畫,即我們等等在Smart Contract定義的Campaign。按下「提案」的按鈕到新增提案的畫面。
  2. 輸入計劃名稱、每人最低贊助金額按下新增。
  3. 出現MetaMask畫面扣除以太幣,按下SUBMIT。
  4. 計畫建立成功,畫面導回首頁,即可看到新建立的計畫。

STEP 1: Backend

▼新增一個crowdfunding的目錄,在這個目錄下面利用npm init初始化我們的node專案。

$ mkdir crowdfunding
$ cd crowdfunding
$ npm init

接著安裝以太坊的client 端工具:ganache-cli

$ npm install --save ganache-cli

▼在crowdfunding下面先新增一個ethereum目錄,後續將把區塊鏈相關的程式放在這邊。在ethereum下面再新增一個contracts目錄,其下再新增一個檔案Campaign.sol。

// 1 - 宣告Solidity版本
pragma solidity ^0.4.17;
// 2 - 宣告Campaign為contract
contract Campaign {
// 3 - 宣告owner變數為address型別
address public owner;
uint public minimumContribution;
string public campaignName;
// 4 - Constructor構造函數
function Campaign(uint minimum, string name) public {
// 5 - 指定合約起始者、最小贊助金額、計畫名稱
owner = msg.sender;
minimumContribution = minimum;
campaignName = name;
}
}

// 1 — 我們使用Solidity版本0.4.17,確保編譯程式時使用該版本功能作編譯。
// 2 — 宣告Campaign為一個contract,類似我們在OOP提過的class概念,屬於抽象的定義。
// 3 — 宣告owner變數,也就是第一位起始合約的人,address為型別。
// 4 — 與我們的Contract同名的函數我們稱為Constructor,Constructor在合約一開始的時候便會執行,而且只執行一次,適合用於我們合約的初始化。public則表示任何人(不限此合約內)均可以呼叫這個function。
// 5 — 在Constructor加入參數,指定該募資活動的合約起始者、最小贊助金額、計畫名稱。

▼接下來我們將利用另一個合約CampaignFactory來呼叫合約Campaign,以便可以新增多個募資計畫,並將這些計畫的合約位址記錄下來。

pragma solidity ^0.4.17;// 1 - 宣告CampaignFactory為contract
contract CampaignFactory {
// 2 - 新增募資計畫
function createCampaign(uint minimum, string name) public {
// 3 - 新增Campaign Contract
address newCampaignAddress = new Campaign(minimum, name, msg.sender);
}contract Campaign {
address public owner;
uint public minimumContribution;
string public campaignName;
// 4 - 合約起始者改由參數帶入
function Campaign(uint minimum, string name, address creator) public {
owner = creator;
minimumContribution = minimum;
campaignName = name;
}
}

// 1 — 宣告CampaignFactory為另一個contract。
// 2 — 宣告一個新增募資計劃的Function,帶入最低贊助金額、計畫名稱為參數。
// 3 — 利用new Campaign()就可以呼叫新增一個Campaign Contract,並回傳該合約部署的位址。要注意的是,我們在Campaign的Constructor已加入第3個參數合約起始者,因此需將CampaignFactory的msg.sender帶入。
// 4 — 記得將Campaign Contract的合約起始者改由參數帶入。因為由CampaignFactory呼叫Campaign,則Campaign裡的msg.sender將變成CampaignFactory本身的位址,這並非我們所謂的owner。將合約的起始者改由CampaignFactory帶入真正呼叫CampaignFactory的人,也就是起始募資計畫的人。

Solidity Contract Call Contract

msg 是一個全域物件,當合約被啟動時會自動記錄以下:

  • msg.sender:呼叫合約的帳戶位址。
  • msg.data:合約的原始碼、參數等資料。
  • msg.gas:啟動合約需要的gas。
  • msg.value:啟動合約需要的以太幣(以wei為單位)。

因此藉由紀錄CampaignFactory的msg.sender我們可以得知原始起始合約的人。

▼為了將所有的募資計劃於我們的首頁顯示出來,提案的時候我們就得將計畫記錄下來。

contract CampaignFactory {
// 1 - 定義募資計畫的基本資料
struct CampaignStruct {
string campaignName;
uint minimumContribution;
address campaignAddress;
}
// 2 - 所有的募資計畫
CampaignStruct[] public campaigns;
function createCampaign(uint minimum, string name) public {
address newCampaignAddress = new Campaign(minimum, name, msg.sender);
// 3 -
CampaignStruct memory newCampaign = CampaignStruct({
campaignName: name,
minimumContribution: minimum,
campaignAddress: newCampaignAddress
});
// 4 -
campaigns.push(newCampaign);

}
// 5 -
function getCampaignsCount() public view returns(uint) {
return campaigns.length;
}

}

// 1 — 利用struct型態定義我們募資計畫的基本資料,包含計畫名稱、最低贊助金額、計畫的位址。注意,每個欄位後面是接分號而不是逗號。
// 2 — 宣告一個屬於CampaignStruct型態的陣列,campaigns存放所有的募資計畫。
// 3 — 設定newCampaign為CampaignStruct型態的變數,如果未加上memory這個關鍵字會出現以下的錯誤訊息:

TypeError: Type struct CampaignFactory.CampaignStruct memory is not implicitly convertible to expected type struct CampaignFactory.CampaignStruct storage pointer.

這是因為變數newCampaign預設是存放在Storage,因此無法將Memory內的CampaignStruct指定給他。在newCampaign之前加入memory這個keyword即可。

// 4 — 將新增的募資計畫放進campaigns陣列裡。
// 5 — 回傳總共有多少募資計畫。因為Solidity目前不支援回傳Struct型態的陣列,因此這邊改用變通的方式,改為回傳募資計畫的數量,之後到前台做處理。

STEP 2: 部署

▼安裝Solidity Compiler — solc、檔案系統工具fs-extra

$ npm install --save solc fs-extra

▼在ethereum目錄下建立新檔案compile.js

// compile.js
const path = require("path");
const fs = require("fs-extra");
const solc = require("solc");
const buildPath = path.resolve(__dirname, "build");
// 1 - 移除build目錄
fs.removeSync(buildPath);
// 2 - 指定合約檔案位置
const campaignPath = path.resolve(__dirname, "contracts", "Campaign.sol");
// 3 - 讀取合約
const source = fs.readFileSync(campaignPath, "utf8");
// 4 - 編譯合約
const output = solc.compile(source, 1).contracts;
// 5 - 建立build目錄
fs.ensureDirSync(buildPath);
// 6 - 將編譯過的合約內容寫出,分別是Campaign及CampaignFactory
for (let contract in output) {
fs.outputJsonSync(
path.resolve(buildPath, contract.replace(":", "") + ".json"),
output[contract]
);
}
// 2 - solc編譯合約
module.exports = solc.compile(source, 1).contracts[":Campaign"];

▼安裝web3,我們使用1.0.0版本。web3 1.x版本才有支援Javascript async/await非同步做法。目前web3也被認為是Javascript與Ethereum溝通的終極解決方案。

$ npm install --save web3@1.0.0-beta.26

▼安裝Provider — truffle-hdwallet-provider,我們將利用此連結Ethereum

$ npm install --save truffle-hdwallet-provider@0.0.3

▼在ethereum目錄下建立新檔案deploy.js

// deploy.js
// 1 - Wallet Provider
const HDWalletProvider = require("truffle-hdwallet-provider");
const provider = new HDWalletProvider(
"hand marriage power edge absent menu palace predict acid raccoon industry casino",
"https://rinkeby.infura.io/7Bne1tRZiiPnG4Wz8hcO"
);
const Web3 = require("web3");
const web3 = new Web3(provider);
const compiledFactory = require("./build/CampaignFactory.json");
// 2 - 部署合約
const deploy = async () => {
const accounts = await web3.eth.getAccounts();
const result = await new web3.eth.Contract(
JSON.parse(compiledFactory.interface)
)
.deploy({ data: compiledFactory.bytecode })
.send({ from: accounts[0], gas: "1000000" });
// 3 - 輸出address
console.log("Contract deployed to", result.options.address);
};
deploy();

// 1 — 利用我們安裝的truffle-wallet-provider,新增一個物件,第一個參數是我們在安裝MetaMask時記下來的Mnemonic,第二個則是在Infura註冊的Rinkeby測試節點連結。(TODO)
// 2 — 利用web3部署合約,須將我們在compile.js寫好匯出的interface、bytecode帶入作為參數,並使用非同步寫法。
// 3 — 將合約部署好的位址輸出,記下後在前端程式寫入。

▼編譯合約

$ cd ethereum
$ node compile.js

執行過後在ethereum目錄下面會發現新增了一個資料夾build,裡面有兩個檔案,分別是Campaign.json、CampaignFactory.json。

▼執行部署合約,將合約部署好的位址記起來,等等會用到。

$ node deploy.js
Contract deployed to 0xAA1D1eE686EeF26a0Cc96215397Ae22c1533d8C9

▼在ethereum目錄下面新增檔案web3.js

import Web3 from "web3";let web3;// 1 - 程式於瀏覽器中執行且MetaMask執行中
if (typeof window !== "undefined" && typeof window.web3 !== "undefined") {
web3 = new Web3(window.web3.currentProvider);
} else {
// 2 - 利用Infura連接上Rinkeby測試網路
const provider = new Web3.providers.HttpProvider(
"https://rinkeby.infura.io/7Bne1tRZiiPnG4Wz8hcO"
);
web3 = new Web3(provider);
}
export default web3;

▼在ethereum目錄下面新增檔案factory.js。我們將建立一個CampaignFactory的物件,以便讓後續程式可以直接取用。記得將上面合約部署好的位址作為以下web3.eth.Contract的第2個參數。

import web3 from "./web3";
import CampaignFactory from "./build/CampaignFactory.json";
const instance = new web3.eth.Contract(
JSON.parse(CampaignFactory.interface),
"0xAA1D1eE686EeF26a0Cc96215397Ae22c1533d8C9"
);
export default instance;

STEP 3: Frontend

▼接下來我們將首先安裝前端需要的元件。

$ npm install --save next react react-dom semantic-ui-react next-routes

▼在crowdfunding目錄下面新增檔案server.js

const { createServer } = require("http");
const next = require("next");
const app = next({
dev: process.env.NODE_ENV !== "production"
});
const routes = require("./routes");
const handler = routes.getRequestHandler(app);
app.prepare().then(() => {
createServer(handler).listen(3000, err => {
if (err) throw err;
console.log("Ready on localhost:3000");
});
});

▼在crowdfunding目錄下面新增檔案routes.js

const routes = require("next-routes")();routes
.add("/campaigns/new", "/campaigns/new");
module.exports = routes;

packages.json

"dev": "node server.js"

▼將我們的專案加入SPA架構,先新增一個components目錄,在下面新增一個檔案Layout.js。

import React from "react";
import { Container } from "semantic-ui-react";
import Head from "next/head";
import Header from "./Header";
export default props => {
return (
<Container>
<Head>
<link
rel="stylesheet"
href="//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.12/semantic.min.css"
/>
</Head>
<Header />
{props.children}
</Container>
);
};

▼在components目錄下面再新增一個檔案Header.js。

import React from "react";
import { Menu } from "semantic-ui-react";
import { Link } from "../routes";
export default () => {
return (
<Menu inverted style={{ marginTop: "10px" }}>
<Link route="/">
<a className="item">群眾募資</a>
</Link>
<Menu.Menu position="right">
<Link route="/">
<a className="item">募資計畫</a>
</Link>
<Link route="/campaigns/new">
<a className="item">提案</a>
</Link>
</Menu.Menu>
</Menu>
);
};

▼在crowdfunding目錄下面新增目錄pages,在pages之下再新增檔案index.js。因為我們套用了NextJS架構,所有的頁面在pages可以直接使用NextJS routing功能。記得”pages”別拼錯。

import React, { Component } from "react";
import { Card, Button } from "semantic-ui-react";
import factory from "../ethereum/factory";
import Layout from "../components/Layout";
import { Link } from "../routes";
class CampaignIndex extends Component {
static async getInitialProps() {
// 1 - 獲得所有的募資計畫
const campaignsCount = await factory.methods.getCampaignsCount().call();
const campaigns = await Promise.all(
Array(parseInt(campaignsCount))
.fill()
.map((element, index) => {
return factory.methods.campaigns(index).call();
})
);
return { campaigns, campaignsCount };
}
renderCampaigns() {
const items = this.props.campaigns.map((campaign, index) => {
return {
header: campaign.campaignName,
description: campaign.campaignAddress,
fluid: true
};
});
return <Card.Group items={items} />;
}
render() {
return (
<Layout>
<div>
<h3>募資中計畫</h3>
<Link route="/campaigns/new">
<a>
<Button
floated="right"
content="提案"
icon="add circle"
primary
/>
</a>
</Link>
{this.renderCampaigns()}
</div>
</Layout>
);
}
}
export default CampaignIndex;

// 1 — 在STEP 1: Backend的最後一個步驟提過,因為Solidity目前尚未支援可以回傳Struct型態的陣列,改用getCompaignsCount()回傳總共有多少個募資計畫。再利用Javascript語法Array().fill().map()得出所有的campaigns。

▼在pages目錄之下新增資料夾campaigns,在campaigns之下再新增檔案new.js。此為提案的畫面。

import React, { Component } from "react";
import { Form, Button, Input, Message } from "semantic-ui-react";
import Layout from "../../components/Layout";
import factory from "../../ethereum/factory";
import web3 from "../../ethereum/web3";
import { Router } from "../../routes";
class CampaignNew extends Component {
state = {
minimumContribution: "",
campaignName: "",
errorMessage: "",
loading: false
};
onSubmit = async event => {
event.preventDefault();
this.setState({ loading: true, errorMessage: "" });
try {
const accounts = await web3.eth.getAccounts();
await factory.methods
.createCampaign(this.state.minimumContribution, this.state.campaignName)
.send({
from: accounts[0]
});

Router.pushRoute("/");
} catch (err) {
this.setState({ errorMessage: err.message });
}
this.setState({ loading: false });
};
render() {
return (
<Layout>
<h3>提案</h3>
<Form onSubmit={this.onSubmit} error={!!this.state.errorMessage}>
<Form.Field>
<label>計劃名稱</label>
<Input
value={this.state.campaignName}
onChange={event =>
this.setState({ campaignName: event.target.value })
}
/>
</Form.Field>
<Form.Field>
<label>每人最低贊助金額</label>
<Input
label="wei"
labelPosition="right"
value={this.state.minimumContribution}
onChange={event =>
this.setState({ minimumContribution: event.target.value })
}
/>
</Form.Field>
<Message error header="Oops!" content={this.state.errorMessage} />
<Button loading={this.state.loading} primary>
新增
</Button>
</Form>
</Layout>
);
}
}
export default CampaignNew;

▼接下來直接在終端機crowdfunding目錄下面輸入

$ npm run dev

即可在瀏覽器http://localhost:3000觀看到我們的募資畫面。

>>> 下一篇:Part 2. 贊助

--

--