使用 Solidity 和 Truffle 在 Ethereum 區塊鏈開發智能合約

前言

本文使用 Ethereum 智能合約實作一個「To-Do List」應用程式。

前置作業

  1. 安裝 Ganache 測試工具,並啟動應用程式。
  2. 安裝 MetaMask 錢包。

安裝依賴

安裝 Truffle 命令列工具。

1
npm install -g truffle@5.0.2

查看版本。

1
2
3
4
truffle version
Truffle v5.0.2 (core: 5.0.2)
Solidity v0.5.0 (solc-js)
Node v14.17.3

建立專案

建立專案。

1
2
mkdir eth-todo-list
cd eth-todo-list

新增 .gitignore 檔。

1
/node_modules

新增 package.json 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"name": "eth-todo-list",
"version": "0.1.0",
"description": "",
"main": "truffle-config.js",
"directories": {
"test": "test"
},
"scripts": {
"dev": "lite-server",
"test": "echo \"Error: no test specified\" && sexit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"bootstrap": "4.1.3",
"chai": "^4.1.2",
"chai-as-promised": "^7.1.1",
"chai-bignumber": "^2.0.2",
"lite-server": "^2.3.0",
"nodemon": "^1.17.3",
"truffle": "5.0.2",
"truffle-contract": "3.0.6",
"web3": "^0.20.0"
}
}

安裝依賴套件。

1
npm install

使用 truffle 指令初始化專案。

1
truffle init

新增 contracts/TodoList.sol 檔。

1
2
3
4
5
6
// SPDX-License-Identifier: MIT
pragma solidity >=0.5.0;

contract TodoList {
uint public taskCount = 0;
}

修改 truffle-config.js 檔,將網路指向 Ganache 的端點。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
networks: {
development: {
host: "127.0.0.1",
port: 7545,
network_id: "*",
},
},
solc: {
optimizer: {
enabled: true,
runs: 200,
},
},
};

新增 migrations/2_deploy_contracts.js 檔。

1
2
3
4
5
const TodoList = artifacts.require("TodoList");

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

編譯智能合約。

1
truffle compile

執行部署腳本,將合約部署到本地測試鏈上。

1
truffle migrate

互動介面

進入 Truffle 互動介面,與合約進行互動。

1
truffle console

取得 TodoList 合約的內容。

1
> todoList = await TodoList.deployed()

取得 TodoList 合約的地址。

1
2
> todoList.address
'0x21875AacaeDbE8F9CF0ce0a72cEF4665BF25e058'

取得 TodoList 合約中,變數 taskCount 的值。

1
2
> (await todoList.taskCount()).toNumber()
0

離開互動介面。

1
> .exit

合約實作

修改 contracts/TodoList.sol 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// SPDX-License-Identifier: MIT
pragma solidity >=0.5.0;

contract TodoList {
uint public taskCount = 0;

struct Task {
uint id;
string content;
bool completed;
}

mapping(uint => Task) public tasks;

event TaskCreated(
uint id,
string content,
bool completed
);

event TaskCompleted(
uint id,
bool completed
);

constructor() public {
createTask("Check out https://github.com/memochou1993");
}

function createTask(string memory _content) public {
taskCount++;
uint id = taskCount;
tasks[id] = Task(id, _content, false);
emit TaskCreated(id, _content, false);
}

function toggleCompleted(uint _id) public {
Task memory _task = tasks[_id];
_task.completed = !_task.completed;
tasks[_id] = _task;
emit TaskCompleted(_id, _task.completed);
}
}

編譯智能合約。

1
truffle compile

重新執行部署腳本。

1
truffle migrate --reset

設置錢包

在 MetaMask 錢包新增一個測試網路:

將 Ganache 中帳戶的私鑰匯入至 MetaMask 錢包。

實作前端

新增 bs-config.json 檔,用來配置 lite-server 伺服器。

1
2
3
4
5
6
7
8
9
10
11
{
"server": {
"baseDir": [
"./src",
"./build/contracts"
],
"routes": {
"/vendor": "./node_modules"
}
}
}

新增 src/index.html 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>To-Do List</title>
<link href="vendor/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
main {
margin-top: 60px;
}
#content {
display: none;
}
form {
width: 350px;
margin-bottom: 10px;
}
ul {
margin-bottom: 0px;
}
#completedTaskList .content {
color: grey;
text-decoration: line-through;
}
</style>
</head>
<body>
<nav class="navbar navbar-dark fixed-top bg-dark flex-md-nowrap p-0 shadow">
<a class="navbar-brand col-sm-3 col-md-2 mr-0" href="https://github.com/memochou1993" target="_blank">To-Do List</a>
<ul class="navbar-nav px-3">
<li class="nav-item text-nowrap d-none d-sm-none d-sm-block">
<small><a class="nav-link" href="#"><span id="account"></span></a></small>
</li>
</ul>
</nav>
<div class="container-fluid">
<div class="row">
<main role="main" class="col-lg-12 d-flex justify-content-center">
<div id="loader" class="text-center">
<p class="text-center">Loading...</p>
</div>
<div id="content">
<form onSubmit="App.createTask(); return false;">
<input id="newTask" type="text" class="form-control" placeholder="Add task..." required>
<input type="submit" hidden="">
</form>
<ul id="taskList" class="list-unstyled">
<div class="taskTemplate" class="checkbox" style="display: none">
<label>
<input type="checkbox" />
<span class="content">Task content goes here...</span>
</label>
</div>
</ul>
<ul id="completedTaskList" class="list-unstyled">
</ul>
</div>
</main>
</div>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
<script src="vendor/bootstrap/dist/js/bootstrap.min.js"></script>
<script src="vendor/web3/dist/web3.min.js"></script>
<script src="vendor/truffle-contract/dist/truffle-contract.js"></script>
<script src="app.js"></script>
</body>
</html>

新增 src/app.js 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
App = {
loading: false,
contracts: {},

load: async () => {
await App.loadWeb3()
await App.loadAccount()
await App.loadContract()
await App.render()
},

loadWeb3: async () => {
if (typeof web3 !== 'undefined') {
App.web3Provider = web3.currentProvider
web3 = new Web3(web3.currentProvider)
} else {
window.alert("Please connect to Metamask.")
}
// Modern dapp browsers...
if (window.ethereum) {
window.web3 = new Web3(ethereum)
try {
// Request account access if needed
await ethereum.enable()
// Acccounts now exposed
web3.eth.sendTransaction({/* ... */})
} catch (error) {
// User denied account access...
}
}
// Legacy dapp browsers...
else if (window.web3) {
App.web3Provider = web3.currentProvider
window.web3 = new Web3(web3.currentProvider)
// Acccounts always exposed
web3.eth.sendTransaction({/* ... */})
}
// Non-dapp browsers...
else {
console.log('Non-Ethereum browser detected. You should consider trying MetaMask!')
}
},

loadAccount: async () => {
App.account = web3.eth.accounts[0]
web3.eth.defaultAccount = App.account;
},

loadContract: async () => {
const todoList = await $.getJSON('TodoList.json')
App.contracts.TodoList = TruffleContract(todoList)
App.contracts.TodoList.setProvider(App.web3Provider)
App.todoList = await App.contracts.TodoList.deployed()
},

render: async () => {
if (App.loading) {
return
}

App.setLoading(true)

$('#account').html(App.account)

await App.renderTasks()

App.setLoading(false)
},

renderTasks: async () => {
const taskCount = await App.todoList.taskCount()
const $taskTemplate = $('.taskTemplate')

for (var i = 1; i <= taskCount; i++) {
const task = await App.todoList.tasks(i)
const taskId = task[0].toNumber()
const taskContent = task[1]
const taskCompleted = task[2]

const $newTaskTemplate = $taskTemplate.clone()
$newTaskTemplate.find('.content').html(taskContent)
$newTaskTemplate.find('input')
.prop('name', taskId)
.prop('checked', taskCompleted)
.on('click', App.toggleCompleted)

if (taskCompleted) {
$('#completedTaskList').append($newTaskTemplate)
} else {
$('#taskList').append($newTaskTemplate)
}

$newTaskTemplate.show()
}
},

createTask: async () => {
App.setLoading(true)
const content = $('#newTask').val()
await App.todoList.createTask(content)
window.location.reload()
},

toggleCompleted: async (e) => {
App.setLoading(true)
const taskId = e.target.name
await App.todoList.toggleCompleted(taskId)
window.location.reload()
},

setLoading: (boolean) => {
App.loading = boolean
const loader = $('#loader')
const content = $('#content')
if (boolean) {
loader.show()
content.hide()
} else {
loader.hide()
content.show()
}
}
}

$(() => {
$(window).load(() => {
App.load()
})
})

啟動介面。

1
npm run dev

撰寫測試

新增 test/TodoList.test.js 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const { assert } = require("chai")

const TodoList = artifacts.require('./TodoList.sol')

contract('TodoList', (accounts) => {
before(async () => {
this.todoList = await TodoList.deployed()
})

it('deploys successfully', async () => {
const address = this.todoList.address
assert.notEqual(address, 0x0)
assert.notEqual(address, '')
assert.notEqual(address, null)
assert.notEqual(address, undefined)
})

it('lists tasks', async () => {
const taskCount = await this.todoList.taskCount()
const task = await this.todoList.tasks(taskCount)
assert.equal(task.id.toNumber(), taskCount.toNumber())
assert.equal(task.content, 'Check out https://github.com/memochou1993')
assert.equal(task.completed, false)
assert.equal(taskCount.toNumber(), 1)
})

it('creates tasks', async () => {
const result = await this.todoList.createTask('A new task')
const taskCount = await this.todoList.taskCount()
assert.equal(taskCount.toNumber(), 2)
const event = result.logs[0].args
assert.equal(event.id.toNumber(), 2)
assert.equal(event.content, 'A new task')
assert.equal(event.completed, false)
})

it('toggles task completion', async () => {
const result = await this.todoList.toggleCompleted(1)
const task = await this.todoList.tasks(1)
assert.equal(task.completed, true)
const event = result.logs[0].args
assert.equal(event.id.toNumber(), 1)
assert.equal(event.completed, true)
})
})

執行測試。

1
truffle test

參考資料