前言
本文使用 Ethereum 智能合約實作一個「To-Do List」應用程式。
前置作業
- 安裝 Ganache 測試工具,並啟動應用程式。
- 安裝 MetaMask 錢包。
安裝依賴
安裝 Truffle 命令列工具。
查看版本。
| 12
 3
 4
 
 | truffle versionTruffle v5.0.2 (core: 5.0.2)
 Solidity v0.5.0 (solc-js)
 Node v14.17.3
 
 | 
建立專案
建立專案。
| 12
 
 | mkdir eth-todo-listcd eth-todo-list
 
 | 
新增 .gitignore 檔。
新增 package.json 檔。
| 12
 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"
 }
 }
 
 | 
安裝依賴套件。
使用 truffle 指令初始化專案。
新增 contracts/TodoList.sol 檔。
| 12
 3
 4
 5
 6
 
 | // SPDX-License-Identifier: MITpragma solidity >=0.5.0;
 
 contract TodoList {
 uint public taskCount = 0;
 }
 
 | 
修改 truffle-config.js 檔,將網路指向 Ganache 的端點。
| 12
 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 檔。
| 12
 3
 4
 5
 
 | const TodoList = artifacts.require("TodoList");
 module.exports = function(deployer) {
 deployer.deploy(TodoList);
 };
 
 | 
編譯智能合約。
執行部署腳本,將合約部署到本地測試鏈上。
互動介面
進入 Truffle 互動介面,與合約進行互動。
取得 TodoList 合約的內容。
| 1
 | > todoList = await TodoList.deployed()
 | 
取得 TodoList 合約的地址。
| 12
 
 | > todoList.address'0x21875AacaeDbE8F9CF0ce0a72cEF4665BF25e058'
 
 | 
取得 TodoList 合約中,變數 taskCount 的值。
| 12
 
 | > (await todoList.taskCount()).toNumber()0
 
 | 
離開互動介面。
合約實作
修改 contracts/TodoList.sol 檔。
| 12
 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: MITpragma 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);
 }
 }
 
 | 
編譯智能合約。
重新執行部署腳本。
設置錢包
在 MetaMask 錢包新增一個測試網路:
將 Ganache 中帳戶的私鑰匯入至 MetaMask 錢包。
實作前端
新增 bs-config.json 檔,用來配置 lite-server 伺服器。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | {"server": {
 "baseDir": [
 "./src",
 "./build/contracts"
 ],
 "routes": {
 "/vendor": "./node_modules"
 }
 }
 }
 
 | 
新增 src/index.html 檔。
| 12
 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 檔。
| 12
 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.")
 }
 
 if (window.ethereum) {
 window.web3 = new Web3(ethereum)
 try {
 
 await ethereum.enable()
 
 web3.eth.sendTransaction({})
 } catch (error) {
 
 }
 }
 
 else if (window.web3) {
 App.web3Provider = web3.currentProvider
 window.web3 = new Web3(web3.currentProvider)
 
 web3.eth.sendTransaction({})
 }
 
 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()
 })
 })
 
 | 
啟動介面。
撰寫測試
新增 test/TodoList.test.js 檔。
| 12
 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)
 })
 })
 
 | 
執行測試。
參考資料