Josh Chou • 2024-05-19
用React開發你的第一個Dapp
Description
撰寫並部署智能合約在本地,並在React的Dapp上操作
網路上關於智能合約的實作很多都是 2020 年以前了,使用的套件很多跑起來不是deprecated就是unsupported,於是決定自己來寫一篇關於如何用 React 去寫一個 Web3 Dapp 可以查看自己的錢包餘額,並呼叫自己寫的 smart contract 功能的教學。
網頁的功能如下
- 第一次載入時會要求取得
MetaMask權限 - 有錢包地址的權限會顯示帳號地址和錢包餘額
- 透過 ABI 操作各個方法都會要求發起者同意,同意之後畫面上的錢包餘額會減少,並且會更新
counter的值
行前準備
此專案使用 npm 作為套件管理工具
- 請於電腦中安裝 NodeJS,版本建議
>=18.16 - 安裝 chrome 瀏覽器錢包 extension
MetaMask,下載網址 ,用來查看測試網路上的錢包餘額 - 新增一個資料夾 Hello-web3
mkdir Hello-web3
- 因為前端框架用 React,所以在開發上採前後端分離,在
Hello-web3內分別新增前端web後端contract
cd Hello-web3
mkdir web
mkdir contract
- 我們所有的操作都是在 local,用 npm global 安裝
ganache,建立本地測試網路,記得不是安裝ganache-cli,會遇到Eip1559NotSupportedError
npm i -g ganache
開發流程
在一般網頁開發中,後端會開發應用程式開出 API,前端開發畫面去串接。
在 Dapp 開發中,可以把智能合約當作是後端應用程式,溝通介面叫做 ABI(Application Binary Interface)。
所以流程如下:
- 開發智能合約
- 用
solc編譯智能合約,把編譯結果寫成json檔,並部署編譯結果的 ABI 和 bytecode 到本地 Ethereum 區塊鏈上 - 開發前端 Dapp,使用
web3.js套件向智能合約 ABI 發起transaction
開發智能合約
進入contract目錄
cd contract
用solidity語言開發一個簡單的智能合約hello.sol
// hello.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract Counter {
uint256 public count;
// Function to get the current count
function get() public view returns (uint256) {
return count;
}
// Function to set new count to current count
function set(uint256 _count) public {
count = _count;
}
// Function to increment count by 1
function inc() public {
count += 1;
}
// Function to decrement count by 1
function dec() public {
// This function will fail if count = 0
count -= 1;
}
}
指定solidity版本
pragma solidity ^0.8.19;
contract 後面的是智能合約的 name
contract Counter {
}
類似 JavsScript class 的 constructor,底下的 function 都可以直接 access,不用加 this
uint256 public count;
function 可以帶參數,型態可以是 expression(有 return)或是 statement(沒有 return)
帶參數
function set(uint256 _count) public {}
expression
function get() public view returns (uint256) {
return count;
}
statement
function inc() public {
count += 1;
}
編譯和部署智能合約
編譯的工具我們選擇使用solc這個套件,npm 連結,比較舊的文章教學是使用truffle,但已經被移除了。
官網上有教學用 web worker 去使用,但我沒研究,有興趣的讀者可以自行研究。
一樣在contract目錄
cd contract
用 npm 管理並安裝solc, web3, fs
npm init
npm i solc web3 fs --save
啟動本地網路ganache
ganache
輸入之後會在終端機上看到以下訊息:
ganache v7.9.2 (@ganache/cli: 0.10.2, @ganache/core: 0.10.2)
Starting RPC server
Available Accounts
==================
(0) 0x184FaAa4C27F2Bc9D913c601A92305907d93B36C (1000 ETH)
(1) 0xa158972a3d14A16e82FA0Ee334a0f2aD50DD719F (1000 ETH)
(2) 0xe56108E5a07cF100D02accCfbC8F475e43172b65 (1000 ETH)
(3) 0xD4C09b8E6e35Ca7670C1571A210b00e5A09D6BA3 (1000 ETH)
(4) 0x511471E8A04b4775867DB2362876678EAF60e053 (1000 ETH)
(5) 0x082f3bc0620117Dfa1996F46B694Cbc0C467b4ad (1000 ETH)
(6) 0x9826BfF2714CB094A535c71911793869a70dAAd2 (1000 ETH)
(7) 0x391A545790422F897a69e3D80267361aF0d74D63 (1000 ETH)
(8) 0x6F5f380e80EcFcE2cf79b2A1534881a0eE717862 (1000 ETH)
(9) 0x5060c8b2C6E661DaA0068C4739B6365fDdb262BB (1000 ETH)
Private Keys
==================
(0) 0x4c265d064b62a39fc5a87d5124fffa37b2005c6189828b9edb9479960032ef16
(1) 0x578c64c08222451f0f2776d501fe5efdcc1a43c8c406278dc6838f3a9c6f6f1e
(2) 0x0605e1497628fce17a1224d2aaa310f07b36ca268efce1da2686f486b06c7d22
(3) 0x75aa7fd9abb78452195e0dbac46f3bacf7107761a23d70a11cc80f77079385c8
(4) 0x7f92ee9a617321e3901fb20b06d72043c876092c1fb6ffac16fc3287db62923c
(5) 0x141f32d73d5d1f942a06915da94ed3f7baf5b834bcf6722d863bc4485877f5ce
(6) 0x5776eef474ca98cb564a27d074290db08bf5515d02787b056ca71de85c958c80
(7) 0xec0adb9874fb3476a699039a59be61283ede9ba550f55f630342cc174970f08e
(8) 0x8c61c64f752b70964013acabd59e5b1692f30afeb2822be6e88958b900ac0bd8
(9) 0x8331b6c06eb6e1c6a193f5bc9aed3fe3c71d49ce64122ca6a68ec49997e141bd
HD Wallet
==================
Mnemonic: caution side number glance mouse antique kingdom solve volcano guard cement urge
Base HD Path: m/44'/60'/0'/0/{account_index}
Default Gas Price
==================
2000000000
BlockGas Limit
==================
30000000
Call Gas Limit
==================
50000000
Chain
==================
Hardfork: shanghai
Id: 1337
RPC Listening on 127.0.0.1:8545
可以看到有 RPC server 在本地的 8545 port 上
還提供 10 組測試用的錢包地址和私鑰,接下來會將其中一組私鑰丟到MetaMask上測試
新增一支檔案web3.js
touch web3.js
連接本地 Etherum 網路取得,這裡的 8545 是剛剛ganache啟動的 server
const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:8545');
讀取寫好的hello.sol檔案
const sourceCode = fs.readFileSync('./hello.sol', 'utf8');
input 的格式是solc套件要求的參數,sources 下的每個 key 都是檔名,content 的 value 放讀取的hello.sol檔案
最後JSON parsecompile 後的結果
const input = {
language: 'Solidity',
sources: {
'hello.sol': {
content: sourceCode
}
},
settings: {
outputSelection: {
'*': {
'*': ['*']
}
}
}
};
const compiledCode = JSON.parse(solc.compile(JSON.stringify(input)));
輸出 JSON 檔案
const outputFile = './compiledCode.json';
fs.writeFileSync(outputFile, JSON.stringify(compiledCode, null, 2), 'utf8');
部署智能合約
const result = compiledCode.contracts['hello.sol']['Counter'];
web3.eth
.getAccounts()
.then((accounts) => {
console.log('====================================');
console.log(accounts);
console.log('====================================');
return new web3.eth.Contract(result.abi).deploy({ data: result.evm.bytecode.object }).send({
from: accounts[0],
gas: 1500000,
gasPrice: '30000000000000'
});
})
.then((contract) => {
console.log('Contract deployed at ', contract.options.address);
})
.catch((error) => {
console.error(error);
});
完整web3.js檔案如下
const fs = require('fs');
const solc = require('solc');
const { Web3 } = require('web3');
// Connect to local Ethereum node
const web3 = new Web3(Web3.givenProvider || 'http://127.0.0.1:8545');
// Compile the source code
const sourceCode = fs.readFileSync('./hello.sol', 'utf8');
// Compile the Solidity code
const input = {
language: 'Solidity',
sources: {
'hello.sol': {
content: sourceCode
}
},
settings: {
outputSelection: {
'*': {
'*': ['*']
}
}
}
};
const compiledCode = JSON.parse(solc.compile(JSON.stringify(input)));
// Check for compilation errors
if (compiledCode.errors) {
compiledCode.errors.forEach((error) => {
console.error(error.formattedMessage);
});
process.exit(1); // Exit with non-zero status code to indicate failure
}
const outputFile = './compiledCode.json';
fs.writeFileSync(outputFile, JSON.stringify(compiledCode, null, 2), 'utf8');
const result = compiledCode.contracts['hello.sol']['Counter'];
web3.eth
.getAccounts()
.then((accounts) => {
console.log('====================================');
console.log(accounts);
console.log('====================================');
return new web3.eth.Contract(result.abi).deploy({ data: result.evm.bytecode.object }).send({
from: accounts[0],
gas: 1500000,
gasPrice: '30000000000000'
});
})
.then((contract) => {
console.log('Contract deployed at ', contract.options.address);
})
.catch((error) => {
console.error(error);
});
執行檔案
node web3.js
部署成功後印出合約地址先記著,等等在前端會使用到。
Contract deployed at 0xfe10485C2DbcD23D543c031306a648F754b73b0B
開發前端 Dapp
從contract回到上一層Hello-web3目錄
cd ..
輸入create-react-app指令,會新增一個 web 資料夾,進入 web 目錄
npx create-react-app web
cd web
必須:安裝web3.js套件
npm i web3
非必須:安裝antd套件,有現成的 UI 可以使用
npm i antd
把剛剛編譯智能合約步驟產出的compiledCode.json複製到src/底下
啟動 React app
npm run start
這時候看到的是預設畫面,接下來會直接修改App.js
寫一個 hooks 負責把 web3 實例和合約實例 new 出來
在/src 下新增 hooks 資料夾,並新增一個檔案useWeb3Abi.js
把上一步驟部署智能合約成功印出來的地址丟到變數中
const CONTRACT_ADDRESS = '0xfe10485C2DbcD23D543c031306a648F754b73b0B';
選用 ref 原因讓變數更新不會觸發畫面 re-render
const web3Ref = useRef(null);
const abiRef = useRef(null);
因為之前有安裝MetaMask,所以瀏覽器中會在 windows 加上`window.ethereum
- 我們要取得在這個 etherum 網路上的 accounts 和餘額,參考
web3.js官網上的說明,要先用window.ethereumnew 出一個Web3實例 - 要操作智能合約 ABI,參考
web3.js官網上的說明,要用智能合約的 ABI 和地址去 new 出一個Contract實例
useEffect(() => {
web3Ref.current = new Web3(window.ethereum);
abiRef.current = new web3Ref.current.eth.Contract(
CONTRACT_JSON['contracts']['hello.sol']['Counter'].abi,
CONTRACT_ADDRESS
);
}, []);
// useWeb3Abi.js
import { useEffect, useRef } from 'react';
import CONTRACT_JSON from '../compiledCode.json';
import Web3 from 'web3';
const CONTRACT_ADDRESS = '0xfe10485C2DbcD23D543c031306a648F754b73b0B';
export default function useWeb3Abi() {
const web3Ref = useRef(null);
const abiRef = useRef(null);
useEffect(() => {
web3Ref.current = new Web3(window.ethereum);
abiRef.current = new web3Ref.current.eth.Contract(
CONTRACT_JSON['contracts']['hello.sol']['Counter'].abi,
CONTRACT_ADDRESS
);
}, []);
return { web3Ref, abiRef };
}
在App.jsx中使用 hooks
import useWeb3Abi from './hooks/useWeb3Abi';
function App() {
const { web3Ref, abiRef } = useWeb3Abi();
}
取得MetaMask授權的帳號地址
因為我只有同意一個帳號,所以回傳的accs陣列值只有一個。
注意使用的方法是requestAccounts,且是使用非同步的方式取回傳值。
const [accounts, setAccounts] = useState('');
useEffect(() => {
if (web3Ref.current) {
web3Ref.current.eth
.requestAccounts()
.then((accs) => {
setAccounts(accs[0]);
})
.catch((error) => {
console.error('Error getting accounts:', error);
});
}
}, [web3Ref]);
return <h1 className="App-title">{`Account: ${accounts}`}</h1>;
取得該錢包地址的餘額 注意:
- 使用的方法是
getBalance(accounts),參數是帳號,回傳值一樣是非同步。 typeof balanceVal是bigint,要用Number去轉- 記得在 accounts 有值之後再拿
const [balance, setBalance] = useState(0);
const balanceGetter = useCallback(async () => {
const balanceVal = await web3Ref.current.eth.getBalance(accounts);
setBalance(Number(balanceVal) / 100);
}, [accounts, web3Ref]);
useEffect(() => {
try {
accounts && balanceGetter();
} catch (err) {
setStatus(err);
}
}, [accounts, balanceGetter]);
return <h1 className="App-title">{`Balance: ${balance} H@`}</h1>;
把智能合約的 ABI 所有方法做一層抽象方便呼叫
call 智能合約的get方法,取得現在counter的值
const [contractCount, setContractCount] = useState(0);
const contractGetter = useCallback(async () => {
const newVal = await abiRef.current.methods.get().call();
setContractCount(Number(newVal));
}, [abiRef]);
useEffect(() => {
if (!abiRef.current) return;
contractGetter();
}, [contractGetter, abiRef]);
return <h1 className="App-title">{`Counter: ${contractCount}`}</h1>;
call 智能合約的set方法,直接改變counter的值,
- call
set()之後記得再get()一次,取得更新後的值 - 這裡的
gas是每次 call 智能合約的燃料,在本地先隨便填個數字即可,不填就是預設。在區塊鏈上每次新增一筆transaction需要其他節點幫你計算此transaction是否有效,這個算是獎勵幫你計算的節點。 gas是從交易發起者的錢包扣除,所以還需要再更新帳號餘額
const [contractValue, setContractValue] = useState(0);
const contractSetter = useCallback(
async (v) => {
await abiRef.current.methods.set(v).send({ from: accounts, gas: your_number });
contractGetter();
balanceGetter();
},
[accounts, contractGetter, balanceGetter, abiRef]
);
return (
<InputNumber
size="small"
placeholder="請輸入數字"
onChange={setContractValue}
/>
<Button onClick={() => contractSetter(contractValue)}>送出</Button>
)
call 智能合約的inc, dec方法,counter值加一或是減一
const contractCaller = useCallback(
async (methodName) => {
if (!abiRef) return;
try {
switch (methodName) {
case "inc":
await abiRef.current.methods.inc().send({ from: accounts });
contractGetter();
balanceGetter();
break;
case "dec":
await abiRef.current.methods.dec().send({ from: accounts });
contractGetter();
balanceGetter();
break;
case "get":
contractGetter();
break;
default:
break;
}
} catch (error) {
setStatus(error);
}
},
[accounts, contractGetter, balanceGetter, abiRef]
);
return (
<Button type="primary" onClick={() => contractCaller("inc")}>
{" "}
+{" "}
</Button>
<Button type="primary" onClick={() => contractCaller("dec")}>
{" "}
-{" "}
</Button>
)
完整的App.jsx檔案
import React, { useState, useEffect, useCallback } from 'react';
import './App.css';
import { Button, InputNumber, Flex } from 'antd';
import useWeb3Abi from './hooks/useWeb3Abi';
function App() {
const { web3Ref, abiRef } = useWeb3Abi();
const [balance, setBalance] = useState(0);
const [status, setStatus] = useState('');
const [accounts, setAccounts] = useState('');
const [contractCount, setContractCount] = useState(0);
const [contractValue, setContractValue] = useState(0);
const contractGetter = useCallback(async () => {
const newVal = await abiRef.current.methods.get().call();
setContractCount(Number(newVal));
}, [abiRef]);
const balanceGetter = useCallback(async () => {
const balanceVal = await web3Ref.current.eth.getBalance(accounts);
setBalance(Number(balanceVal) / 100);
}, [accounts, web3Ref]);
const contractSetter = useCallback(
async (v) => {
await abiRef.current.methods.set(v).send({ from: accounts });
contractGetter();
balanceGetter();
},
[accounts, contractGetter, balanceGetter, abiRef]
);
const contractCaller = useCallback(
async (methodName) => {
if (!abiRef) return;
try {
switch (methodName) {
case 'inc':
await abiRef.current.methods.inc().send({ from: accounts });
contractGetter();
balanceGetter();
break;
case 'dec':
await abiRef.current.methods.dec().send({ from: accounts });
contractGetter();
balanceGetter();
break;
case 'get':
contractGetter();
break;
default:
break;
}
} catch (error) {
setStatus(error);
}
},
[accounts, contractGetter, balanceGetter, abiRef]
);
useEffect(() => {
if (web3Ref.current) {
web3Ref.current.eth
.requestAccounts()
.then((accs) => {
setAccounts(accs[0]);
})
.catch((error) => {
console.error('Error getting accounts:', error);
});
}
}, [web3Ref]);
useEffect(() => {
if (!abiRef.current) return;
contractGetter();
}, [contractGetter, abiRef]);
useEffect(() => {
try {
accounts && balanceGetter();
} catch (err) {
setStatus(err);
}
}, [accounts, balanceGetter]);
return (
<div className="App">
<header className="App-header">
{status && <h1 className="App-title">{status}</h1>}
<h1 className="App-title">{`Balance: ${balance} H@`}</h1>
<h1 className="App-title">{`Account: ${accounts}`}</h1>
<h1 className="App-title">{`Counter: ${contractCount}`}</h1>
<Flex gap={10} align="center">
<h2 className="App-title">Contract ABI</h2>
<Button type="primary" onClick={() => contractCaller('inc')}>
{' '}
+{' '}
</Button>
<Button type="primary" onClick={() => contractCaller('dec')}>
{' '}
-{' '}
</Button>
<Flex gap={5} align="center">
<InputNumber size="small" placeholder="請輸入數字" onChange={setContractValue} />
<Button onClick={() => contractSetter(contractValue)}>送出</Button>
</Flex>
</Flex>
</header>
</div>
);
}
export default App;