一、投票合约详情
(一)Solidity合约示例介绍
-
官方文档第一个投票合约示例:learnblockchain.cn/docs/solidi…
-
参考版本:0.8.17
该投票合约是一个公开的投票合约,重点展示的是如何进行委托投票以及如何进行计票的过程。它的主要逻辑是:
- 创建提案类型,每个提案有简称和得票数
- 创建投票人类型,每个投票人有投票权重、是否投票标记位、被委托人、投票提案索引
- 投票主席,主席可以为每个投票人分配投票权重
- 投票人可以将自己的投票委托给他人,委托过程可以传递,但不能形成闭环
- 最后可以通过
winnerName()方法获取票数最多的提案名称
(二)合约源码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/// @title 委托投票
contract Ballot {
// 这里声明了一个新的复合类型用于稍后的变量
// 它用来表示一个选民
struct Voter {
uint weight; // 计票的权重
bool voted; // 若为真,代表该人已投票
address delegate; // 被委托人
uint vote; // 投票提案的索引
}
// 提案的类型
struct Proposal {
bytes32 name; // 简称(最长32个字节)
uint voteCount; // 得票数
}
address public chairperson;
// 这声明了一个状态变量,为每个可能的地址存储一个 `Voter`。
mapping(address => Voter) public voters;
// 一个 `Proposal` 结构类型的动态数组
Proposal[] public proposals;
/// 为 `proposalNames` 中的每个提案,创建一个新的(投票)表决
constructor(bytes32[] memory proposalNames) {
chairperson = msg.sender;
voters[chairperson].weight = 1;
//对于提供的每个提案名称,
//创建一个新的 Proposal 对象并把它添加到数组的末尾。
for (uint i = 0; i < proposalNames.length; i++) {
// `Proposal({...})` 创建一个临时 Proposal 对象,
// `proposals.push(...)` 将其添加到 `proposals` 的末尾
proposals.push(Proposal({
name: proposalNames[i],
voteCount: 0
}));
}
}
// 授权 `voter` 对这个(投票)表决进行投票
// 只有 `chairperson` 可以调用该函数。
function giveRightToVote(address voter) external {
// 若 `require` 的第一个参数的计算结果为 `false`,
// 则终止执行,撤销所有对状态和以太币余额的改动。
// 在旧版的 EVM 中这曾经会消耗所有 gas,但现在不会了。
// 使用 require 来检查函数是否被正确地调用,是一个好习惯。
// 你也可以在 require 的第二个参数中提供一个对错误情况的解释。
require(
msg.sender == chairperson,
"Only chairperson can give right to vote."
);
require(
!voters[voter].voted,
"The voter already voted."
);
require(voters[voter].weight == 0);
voters[voter].weight = 1;
}
/// 把你的投票委托到投票者 `to`。
function delegate(address to) external {
// 传引用
Voter storage sender = voters[msg.sender];
require(sender.weight != 0, "You have no right to vote");
require(!sender.voted, "You already voted.");
require(to != msg.sender, "Self-delegation is disallowed.");
// 委托是可以传递的,只要被委托者 `to` 也设置了委托。
// 一般来说,这种循环委托是危险的。因为,如果传递的链条太长,
// 则可能需消耗的gas要多于区块中剩余的(大于区块设置的gasLimit),
// 这种情况下,委托不会被执行。
// 而在另一些情况下,如果形成闭环,则会让合约完全卡住。
while (voters[to].delegate != address(0)) {
to = voters[to].delegate;
// 不允许闭环委托
require(to != msg.sender, "Found loop in delegation.");
}
// `sender` 是一个引用, 相当于对 `voters[msg.sender].voted` 进行修改
Voter storage delegate_ = voters[to];
// Voters cannot delegate to accounts that cannot vote.
require(delegate_.weight >= 1);
// Since `sender` is a reference, this
// modifies `voters[msg.sender]`.
sender.voted = true;
sender.delegate = to;
if (delegate_.voted) {
// 若被委托者已经投过票了,直接增加得票数
proposals[delegate_.vote].voteCount += sender.weight;
} else {
// 若被委托者还没投票,增加委托者的权重
delegate_.weight += sender.weight;
}
}
/// 把你的票(包括委托给你的票),
/// 投给提案 `proposals[proposal].name`.
function vote(uint proposal) external {
Voter storage sender = voters[msg.sender];
require(!sender.voted, "Already voted.");
sender.voted = true;
sender.vote = proposal;
// 如果 `proposal` 超过了数组的范围,则会自动抛出异常,并恢复所有的改动
proposals[proposal].voteCount += sender.weight;
}
/// @dev 结合之前所有的投票,计算出最终胜出的提案
function winningProposal() external view
returns (uint winningProposal_)
{
uint winningVoteCount = 0;
for (uint p = 0; p < proposals.length; p++) {
if (proposals[p].voteCount > winningVoteCount) {
winningVoteCount = proposals[p].voteCount;
winningProposal_ = p;
}
}
}
// 调用 winningProposal() 函数以获取提案数组中获胜者的索引,并以此返回获胜者的名称
function winnerName() public view
returns (bytes32 winnerName_)
{
winnerName_ = proposals[winningProposal()].name;
}
}
(三)知识点
- Solidity各种类型的用法:常用基本值类型、引用类型如结构体和数组,映射类型
- Solidity函数的用法:此合约中多为外部调用
- 传引用时的修改范围:
// 传引用
Voter storage sender = voters[msg.sender];
// sender是传引用方式,因此下面的赋值同时是对 `voters[msg.sender]`的修改
sender.voted = true;
sender.delegate = to;
- 数组索引可以是函数
function winnerName() public view
returns (bytes32 winnerName_)
{
//winningProposal()是返回uint类型的函数
winnerName_ = proposals[winningProposal()].name;
}
(但是此处存在函数不可见的bug,因为示例代码中给出的winningProposal()是external修饰的,对winnerName不可见,编译会报错)。
二、对合约进行优化
按照官网上的提示,原合约中的winningProposal()无法记录平局的情况,因此想办法对此合约计票功能进行优化,修改为:如果计票存在多个相同最多票数的提案,则返回平票的全部提案。
function winningProposal() public view
returns (uint[] memory winningProposals)
{
uint winningVoteCount = 0;
for (uint p = 0; p < proposals.length; p++) {
if (proposals[p].voteCount > winningVoteCount) {
winningVoteCount = proposals[p].voteCount;
}
}
// 如果存在平局,返回所有胜出者
uint count=0;
for(uint p = 0; p < proposals.length; p++){
if(proposals[p].voteCount ==winningVoteCount){
count++;
}
}
uint index = 0;
winningProposals=new uint[](count);
for(uint p = 0; p < proposals.length; p++){
if(proposals[p].voteCount ==winningVoteCount){
winningProposals[index]=p;
index ++;
}
}
}
// 调用 winningProposal() 函数以获取提案数组中获胜者的索引,并以此返回获胜者的名称
function winnerName() public view
returns (bytes32[] memory winnerName_)
{
uint[] memory winners=winningProposal();
winnerName_=new bytes32[](winners.length);
for(uint p=0;p<winners.length;p++){
winnerName_[p] = proposals[winners[p]].name;
}
}
三、用Hardhat进行测试
(一)测试用例
不考虑权限相关校验,仅针对合约功能进行测试,整理出测试用例如下所示:
A. 自己投票类
- 3个投票人,人均一票,3个Proposal:A和B投1,C投2,结果1是winner,有2票
- 3个投票人,人均一票,3个Proposal:A、B、C各投1,2,3,结果平票无winner
- 给投票人A直接赋予2个投票权,授权失败
- D没有投票权,给1投票,执行投票不回滚,但是投票结果计数不会增加
B. 委托投票类
- 3个投票人,人均一票:A未投票时,C委托给A,A再投1,B投2,结果1是winner,有2票
- 3个投票人,人均一票:A已投票给1,A再委托给B,结果:委托失败
- 3个投票人,人均一票:A已投票,A投1,B投2,C委托给A,结果1是winner,有2票
- 3个投票人,人均一票:未投票时,A委托给B,B委托给C,C投票给3,结果3是winner,有3票
- 3个投票人,人均一票:C已投票给3,A再委托给B,B再委托给C,结果3是winner,有3票
- D没有投票权,委托给A,结果:委托失败回滚信息为You have no right to vote
- 3个投票人,人均一票:A委托给B,B委托给C,C再委托给A时发生回滚:Found loop in delegation.
- A将投票权委托给自己,发生回滚:Self-delegation is disallowed.
根据以上用例,在Hardhat中写出对Ballot合约进行测试的测试代码。
(二)经验积累
在用Hardhat测试合约过程中,遇到了一些问题,将其中有用的经验记录下来:
1. 每次执行用例前的状态还原准备
采用Hardhat提供的“@nomicfoundation/hardhat-network-helpers”中的loadFixture方式形成测试前状态的还原准备
定义beforeTest(),作为每个用例运行前的状态还原准备:
async function beforeTest(){
const [chairperson, voterA, voterB,voterC] = await ethers.getSigners();
const proposalNames = [ethers.utils.formatBytes32String("Proposal 1"), ethers.utils.formatBytes32String("Proposal 2"),ethers.utils.formatBytes32String("Proposal 3")];
const ballot = await hre.ethers.getContractFactory("Ballot");
const ballotInstance = await ballot.deploy(proposalNames);
return{ballotInstance,chairperson,voterA, voterB,voterC}
}
每次执行用例时,先进行状态还原,用法如下:
it("[===CASE1===]3个投票人,人均一票,3个Proposal:A和B投1,C投2,结果1是winner,有2票",
async function () {
const {ballotInstance,chairperson,voterA, voterB,voterC}= await loadFixture(beforeTest);
//代码实现
// ......
});
2. 通过JavaScript调用合约的方式
原合约中delegate、vote两个函数都定义为external函数,旨在由外部地址账户来调用,那应该怎么写此调用呢?
如果写作:await voterA.vote(index);
则会出现:TypeError: voterA.vote is not a function
正确的写法应该是:await ballotInstance.connect(voterA ).vote(index);
同理,delegate函数的调用方式为:
await ballotInstance.connect(voterA).delegate(voterB.address);
即合约实例链接(connect)外部Address后,再调用合约中的方法。
3. 断言的用法
用chai的expect断言的时候,预期是会发生回滚,但实际发生Error。原本用法如下:
expect( await ballotInstance.giveRightToVote(voterA.address)).to.be.reverted;
出现的错误:
Error: Transaction reverted without a reason string at Ballot.giveRightToVote (contracts/Ballot.sol:78)
实际上,await keyword should be outside the expect
应该修改为:
await expect(ballotInstance.giveRightToVote(voterA.address)).to.be.reverted;
遇到另一个问题:
AssertionError: Expected transaction to be reverted with You have no right to vote., but other exception was thrown: Error: VM Exception while processing transaction: reverted with reason string 'You have no right to vote’
其实是进入到了合约的require(sender.weight != 0, "You have no right to vote"); 逻辑里,但是因为调用方式是:
await expect(ballotInstance.connect(voterD).delegate(voterA.address)).to.be.revertedWith('You have no right to vote')
属于外部(账户)调用,因此实际的报错是:
Error: VM Exception while processing transaction: reverted with reason string 'You have no right to vote’
正确断言方式一:
let err;
try {
await ballotInstance.connect(voterD).delegate(voterA.address);
} catch (error) {
err=error;
}
console.log('---- The err is :'+ err.message);
assert.equal(err.message,"VM Exception while processing transaction: reverted with reason string 'You have no right to vote'");
正确断言方式二:
revertedWith("VM Exception while processing transaction: reverted with reason string 'You have no right to vote'");
绕路的原因:
(1)最初的写法只断言'You have no right to vote'是不行的
(2)后来加了外层的报错,但却多了“Error:”,我修改后的错误写法是:revertedWith("Error: VM Exception while processing transaction: reverted with reason string 'You have no right to vote'");
经过写法1的error.message的打印,知道了具体错误信息是什么,去掉“Error:”用写法2就能断言成功了。
4. 从外部访问合约的属性
通过JavaScript去访问被测合约Ballot.sol中的一个属性proposals的时候,尝试了很多方式都访问不到,后来google,找到了一个类似的问题,解决办法就是用ballotInstance.proposals(index)去访问具体的proposal,而我原本一直不能正确的写法是ballotInstance.proposals[index]。在我看来proposals它本身就是一个数组啊,为什么不用[]访问,而是要用()访问呢?
Google到的参考答案让去看合约的ABI,可以看到这个proposals属性在ABI里被定义成了一个name是proposals的function,调用这个function的输入应该是个uint256类型的入参。我原本用[]的方式是去访问数组中的元素,而事实上应该以()访问函数的方式去获取proposals中的具体实例。
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "proposals",
"outputs": [
{
"internalType": "bytes32",
"name": "name",
"type": "bytes32"
},
{
"internalType": "uint256",
"name": "voteCount",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
}
5. 遇到Hardhat中的pending test问题
就是很神奇的遇到了test跑完后,还有pending状态的用例:
google查资料,找到answer:stackoverflow.com/questions/4…
其实就是it()后面的括号位置由于手误放错了,本质上是不了解JavaScript、Mocha、Hardhat的工作机制。
按照这个指引,修改it的括号范围,就正常运行了。
经过一系列曲折,终于把12个用例场景搞定了,经过一番测试,证明优化的找出最高平票提案的代码也是逻辑正确的。