简单Dapp的开发
实验概述
DApp(Decentralized Application)去中心化应用,自 P2P 网络出现以来就已经存在,是一种运行在计算机 P2P 网络而不是单个计算机上的应用程序。
DApp 以一种不受任何单个实体控制的方式存在于互联网中。在区块链技术产生之前,BitTorrent,Popcorn Time,BitMessage等都是运行在P2P网络上的DApp,随着区块链技术的产生和发展,DApp 有了全新的载体和更广阔的发展前景。
DApp 应具备代码开源、激励机制、非中心化共识和无单点故障四个要素,而最为基本的 DApp 结构即为前端+智能合约形式。
本实验以以太坊为基础,首先用 Solidity 语言编写实现会议报名登记功能的智能合约,加深编写智能合约的能力;之后学习以太坊私有链的搭建、以及合约在私有链上的部署,从而脱离 Remix,学习更为强大的 Truffle 开发组件;进而学习 web3.js 库,实现前端对智能合约的调用,构建完整的 DApp;最后可对该DApp 加入个性化机制,例如加入 Token 机制等,作为实验选做项。该实验实现了一个简单的 DApp,但包含了 DApp 开发的必备流程,为将来在以太坊上进行应用开发打下了基础。
实验内容概述如下:
A. 编写实现会议报名功能的智能合约(发起会议,注册,报名会议,委托报名,事件触发)
B. 利用 Truffle 套件将合约部署到以太坊私有链(私有链搭建,合约部署,合约测试)
C. 利用 web3.js 实现前端对合约的调用(账户绑定、合约 ABI、RPC 调用)
实验 6-1 会议报名登记系统的基本功能与实现
完成合约文件Enrollment.sol的编写,实现一个报名系统,系统应具有以下功能:
合约参与方包含一个管理员以及其余参与者,管理员可以发起不止一个会议,并指定会议信息以及总人数。参与者首先需要进行注册,将个人基本信息与以太坊地址相关联,并存储在合约上,之后可进行报名,或委托他人为自己报名。当会议报名人满时,该会议将不再可报名。当合约内某些数据发生变化时,应能够触发事件(event)使前端重新获取并渲染数据,例如当某个会议报名人满时,应触发相应事件使前端及时更新可报名会议列表。
实现委托函数及为受托者报名函数
function delegate(address addr) public{
trustees[addr].push(participants[msg.sender]);
}
function enrollFor(string memory username,string memory title) public returns(string memory){
uint index = 0;
for (uint i = 0; i < trustees[msg.sender].length; i++) {
if (keccak256(bytes(trustees[msg.sender][i].name)) == keccak256(bytes(username))) {
index = i;
break;
}
}
for (uint i = 0; i < conferences.length; i++){
if (keccak256(bytes(conferences[i].title)) == keccak256(bytes(title))){
require(conferences[i].current<conferences[i].max,"Enrolled full");
conferences[i].current += 1;
if(conferences[i].current==conferences[i].max){
emit ConferenceExpire(title);
}
trustees[msg.sender][index].confs.push(title);
}
}
uint len = trustees[msg.sender][index].confs.length;
require(len>0,"Conference does not exist");
return trustees[msg.sender][index].confs[len-1];
}
练习6-1
1)应在合约的哪个函数指定管理员身份?如何指定?
答:在合约的构造函数中制定管理员的身份。通常将创建会议,也就是部署合约的人设置为管理员,所以在此处指定msg.sender
设置为管理员。
2)在发起新会议时,如何确定发起者是否为管理员?简述 require()、assert()、revert()的区别。
答:在上述合约中,通过语句require(msg.sender==admin,"permission denied")
来检查发起者是否为管理员。在该语句中,使用require()
来检查当前交易的发起者是否为合约的管理员,如果不是,则出发一个异常,终止函数执行,并返回错误消息"permission denied"。
require()、assert()、revert()的区别:当语句执行失败时,require()和revert()会返还相应的gas,而assert()则不会返还相应的gas。
3)简述合约中用 memory 和 storage 声明变量的区别。
答:memory:memory 用于在函数内部临时存储数据;变量在 memory 中声明时,它们的值只在函数的执行期间存储,函数执行完毕后,memory 中的数据将被清除;通常用于存储函数调用中的参数或局部变量,临时操作数据。
storage:storage 用于永久性存储数据,通常用于状态变量;变量在 storage 中声明时,其值将永久保存在以太坊的区块链上,并且可以在不同的交易和函数调用之间保持一致;通常用于存储合约的状态,如合约的状态变量或持久化数据。
实验6-2 学习用 Truffle 组件部署和测试合约
利用 truffle 组件对实验 6-1 的合约进行测试并部署到本地私链上。利用 truffle 初始化一个以太坊项目,将合约Enrollment.sol 放入 contracts 文件夹,并编写部署名为 2_deploy_contracts.js 的脚本。然后为合约编写测试文件。利用 Ganache 搭建私链后,在终端进入 truffle 项目 lab8 的目录,输入 truffle test 进行测试。测试完成后在终端输入 truffle migrate 进行合约编译和部署。
6-2.2 新建 truffle 项目并导入合约
初始化一个以太坊项目,并导入相关合约文件。
2_deploy_contracts.js
const Enrollment = artifacts.require("Enrollment");
module.exports = function(deployer) {
deployer.deploy(Enrollment);
};
truffle-config.js
module.exports = {
networks: {
development: {
host: "127.0.0.1", // Localhost (default: none)
port: 7545, // Standard Ethereum port (default: none)
network_id: "*", // Any network (default: none)
},
},
};
6-2.3 为合约编写测试文件
TestEnrollment.sol
function testEnroll() public{
Enrollment test=new Enrollment();
string memory expected="conf1";
test.newConference("conf1","beijing",30);
Assert.equal(test.enroll("conf1"),expected,"Enroll failed");
}
6-2.4 用 Ganache 搭建私链
打开 Ganache 客户端,选择 NEW WORKSPACE,在 ADD PROJECT 处导入上一步配置的 truffle-config.js 文件。此时将会在本地运行一个以太坊私链,并创建 10 个账户供使用。
6-2.5 对合约进行测试和部署
在终端进入 truffle 项目 lab8 的目录,输入“truffle test”,输出结果如下:
使用 Ganache重新搭建一条私链,然后在终端输入“truffle migrate”完成合约的编译和部署。控制台会输出各合约的部署情况以及总共的 gas 花费,Ganache 中的第一个账户即为该合约的部署者。
练习 6-2:观察合约的部署过程
观察部署完成后 Ganache 的 Blocks、Transactions 以及 Logs 记录,记录如下:
详细查看每条交易的信息:
练习 6-2:观察合约的部署过程
答:合约的部署流程:首先对链进行的初始化,然后进行合约的编写,进行详细的合约规定。接下来对合约进行编译,将其转换为字节码。然后选择区块链平台,创建钱包和账户。最后进行合约的部署,将编译后的合约部署到网络。当区块链网络确认合同已被添加到区块链中时,交易成功。
合约的调用流程:首先创建交易,该交易包括调用智能合约的数据。接下来对交易进行签名并将交易广播到区块链网络。接下来等待交易被确认,一但确认,合约中的操作将被执行。
实验 6-3 利用 Web3.js 实现合约与前端的结合
通过Web3.js实现智能合约和前端之间的调用和订阅:通过前端对以太坊节点进行 RPC 调用,执行合约中的函数,并将合约返回的数据,以及订阅的合约事件提醒及时展示在前端界面。按照实例更改相应组件下的 js 文件以实现对组件的交互,将前端项目中的 src/contracts/contract.js 文件中的 ABI 位置代码更改为合约编译后自动生成的 ABI 信息。
6-3.1 前端界面接口 & 6-3.3 通过 Web3.js 实现前端与合约交互 & 练习 6-3 参考上述注册组件代码中交互代码的写法,完成另外四个表单类组件对合约的调用
对表单中7个文件的index.js代码进行修改和补充,完成对合约的调用,实现合约与前端的结合:
conflist
const mapDispatchToProps = (dispatch) => {
return {
submit(title) {
contract.methods.enroll(title) //输入参数
.send({from:window.web3.eth.accounts[0]},function(err,res){console.log(res)}) //function中的res为方法返回值
.then((res)=>console.log(res)); //该res为交易执行完后的具体交易信息,如TxHash等
dispatch({
type: 'submit_enroll'
})
},
handleChange(e) {
dispatch({
type: 'enroll_title',
value: e.target.value
})
},
}
}
delegate
const mapDispatchToProps = (dispatch) => {
return {
submit(address) {
//调用合约
contract.methods.delegate(address) //输入参数
.send({from:window.web3.eth.accounts[0]},function(err,res){console.log(res)}) //function中的res为方法返回值
.then((res)=>console.log(res)); //该res为交易执行完后的具体交易信息,如TxHash等
dispatch({
type: 'submit_delegate'
})
},
handleChange(e) {
dispatch({
type: 'address',
value: e.target.value
})
},
}
}
enroll
const mapDispatchToProps = (dispatch) => {
return {
submit(title) {
contract.methods.enroll(title) //输入参数
.send({from:window.web3.eth.accounts[0]},function(err,res){console.log(res)}) //function中的res为方法返回值
.then((res)=>console.log(res)); //该res为交易执行完后的具体交易信息,如TxHash等
dispatch({
type: 'submit_enroll'
})
},
handleChange(e) {
dispatch({
type: 'enroll_title',
value: e.target.value
})
},
}
}
enrollfor
const mapDispatchToProps = (dispatch) => {
return {
submit(username,title) {
contract.methods.enrollFor(username,title) //输入参数
.send({from:window.web3.eth.accounts[0]},function(err,res){console.log(res)}) //function中的res为方法返回值
.then((res)=>console.log(res)); //该res为交易执行完后的具体交易信息,如TxHash等
dispatch({
type: 'submit_enrollfor'
})
},
handleChange(e) {
if (e.target.placeholder === 'Title of Conference')
dispatch({
type: 'enrollfor_title',
value: e.target.value
})
else
dispatch({
type: 'enrollfor_username',
value: e.target.value
})
},
}
myconf
componentDidMount(){
//先执行一遍查询操作
contract.methods.queryMyConf()
.call({from:window.web3.eth.accounts[0]},(err,res)=>{
//将返回的数组依次压入data中
this.setState({loading: true});
if(res != null){
for(var i=0;i<res.length;i=i+1){
data.push({title:res[i]});
}
}
else{
data.push({'title': 'no'});
}
})
.then(()=>{
//更新状态,使页面数据重新渲染
this.setState({loading: false});
});
contract.events.MyNewConference({
filter: {},
fromBlock: window.web3.eth.getBlockNumber()
}, (error, event)=>{
this.setState({loading: true});
data.push({conf:event.returnValues[0]});
this.setState({loading: false});
})
}
newconf
const mapDispatchToProps = (dispatch) => {
return {
submit(title,detail,limitation) {
contract.methods.newConference(title,detail,limitation) //输入参数
.send({from:window.web3.eth.accounts[0]},function(err,res){console.log(res)}) //function中的res为方法返回值
.then((res)=>console.log(res)); //该res为交易执行完后的具体交易信息,如TxHash等
dispatch({
type: 'submit_newconf'
})
},
handleChange(e) {
switch (e.target.placeholder) {
case 'Title':
dispatch({
type: 'newconf_title',
value: e.target.value
})
break;
case 'Detail':
dispatch({
type: 'detail',
value: e.target.value
})
break;
case 'Limitation':
dispatch({
type: 'limitation',
value: e.target.value
})
break;
default:
break;
}
},
}
}
signup
const mapDispatchToProps = (dispatch) => {
return {
submit(username,extra) {
contract.methods.signUp(username,extra) //调用合约signUp方法
.send({from:window.web3.eth.accounts[0]},function(err,res){console.log(res)}) //function中的res为方法返回值
.then((res)=>console.log(res)); //该res为交易执行完后的具体交易信息,如TxHash等
dispatch({
type: 'submit_signup'
})
},
handleChange(e) {
if (e.target.placeholder === 'username')
dispatch({
type: 'username',
value: e.target.value
})
else
dispatch({
type: 'extra',
value: e.target.value
})
},
}
6-3.2 在前端项目文件中配置合约信息
将/build 文件夹下会生成的json文件中的ABI信息拷贝到contract.js文件中;在 Ganache 的 contracts 中找到部署的 Enrollment 合约,将合约地址复制到contract.js中。
contract.js
import Web3 from 'web3';
//在此粘贴ABI信息
const abi = [
{
"inputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "string",
"name": "title",
"type": "string"
}
],
"name": "ConferenceExpire",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "string",
"name": "title",
"type": "string"
}
],
"name": "MyNewConference",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "string",
"name": "title",
"type": "string"
},
{
"indexed": false,
"internalType": "string",
"name": "detail",
"type": "string"
}
],
"name": "NewConference",
"type": "event"
},
{
"constant": true,
"inputs": [],
"name": "admin",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "conferences",
"outputs": [
{
"internalType": "string",
"name": "title",
"type": "string"
},
{
"internalType": "string",
"name": "detail",
"type": "string"
},
{
"internalType": "uint256",
"name": "max",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "current",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "participants",
"outputs": [
{
"internalType": "string",
"name": "name",
"type": "string"
},
{
"internalType": "string",
"name": "extra",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "string",
"name": "username",
"type": "string"
},
{
"internalType": "string",
"name": "extra",
"type": "string"
}
],
"name": "signUp",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
},
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "string",
"name": "title",
"type": "string"
}
],
"name": "enroll",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "string",
"name": "title",
"type": "string"
},
{
"internalType": "string",
"name": "detail",
"type": "string"
},
{
"internalType": "uint256",
"name": "max",
"type": "uint256"
}
],
"name": "newConference",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "queryConfList",
"outputs": [
{
"internalType": "string[]",
"name": "",
"type": "string[]"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": true,
"inputs": [],
"name": "queryMyConf",
"outputs": [
{
"internalType": "string[]",
"name": "",
"type": "string[]"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "address",
"name": "addr",
"type": "address"
}
],
"name": "delegate",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"internalType": "string",
"name": "username",
"type": "string"
},
{
"internalType": "string",
"name": "title",
"type": "string"
}
],
"name": "enrollFor",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
}
];
// 在此粘贴合约地址(去掉0x)
const address = 'CFE3031dc731DD36eE88B13d6dbD0794Ba633Eae';
// window.web3.currentProvider为当前浏览器的web3 Provider
const web3 = new Web3(window.web3.currentProvider);
// 允许连接到metamask
window.ethereum.enable();
// 导出合约实例
export default new web3.eth.Contract(abi, address);
部署完成后,在lab8-frontend文件夹中打开文件夹,先执行npm install命令,然后再执行npm start命令。执行之后,成功打开部署的智能合约前端界面,并且能够成功打开MetaMask。
在MetaMask中创建一个账户并登录,之后在网页中注册一个用户:
可以看到Chrome支付成功的提示:
在Ganache中也可以查询到交易记录:
新建一个会议:
支付后在Ganache中查询交易记录:
加入刚才创建的会议:
支付后在Ganache中也查询该条交易记录:
刷新页面后可以查询到会议列表和已加入的会议中包含刚才创建的Block Chain会议。
问题:这里的调用,应采用 call()方法还是 send()方法?
对于展示类组件,如可报名会议列表和本人已报名会议,通常应使用 call()
方法来调用合约中的查询类方法。call()
方法用于读取合约的状态,而不会创建交易,因此不需要消耗以太币。上述查询方法仅涉及读取数据,而不涉及修改区块链状态。而 send()
方法用于发送事务,可能涉及到交易费用和修改区块链状态。