实战练习Solidity(1)——投票合约

2,213 阅读5分钟

一、投票合约详情

(一)Solidity合约示例介绍

  • 官方文档第一个投票合约示例:learnblockchain.cn/docs/solidi…

  • 参考版本:0.8.17

    该投票合约是一个公开的投票合约,重点展示的是如何进行委托投票以及如何进行计票的过程。它的主要逻辑是:

    1. 创建提案类型,每个提案有简称和得票数
    2. 创建投票人类型,每个投票人有投票权重、是否投票标记位、被委托人、投票提案索引
    3. 投票主席,主席可以为每个投票人分配投票权重
    4. 投票人可以将自己的投票委托给他人,委托过程可以传递,但不能形成闭环
    5. 最后可以通过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;
    }
}

(三)知识点

  1. Solidity各种类型的用法:常用基本值类型、引用类型如结构体和数组,映射类型
  2. Solidity函数的用法:此合约中多为外部调用
  3. 传引用时的修改范围:
// 传引用
Voter storage sender = voters[msg.sender];

// sender是传引用方式,因此下面的赋值同时是对 `voters[msg.sender]`的修改
sender.voted = true;
sender.delegate = to;
  1. 数组索引可以是函数
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. 自己投票类

  1. 3个投票人,人均一票,3个Proposal:A和B投1,C投2,结果1是winner,有2票
  2. 3个投票人,人均一票,3个Proposal:A、B、C各投1,2,3,结果平票无winner
  3. 给投票人A直接赋予2个投票权,授权失败
  4. D没有投票权,给1投票,执行投票不回滚,但是投票结果计数不会增加

B. 委托投票类

  1. 3个投票人,人均一票:A未投票时,C委托给A,A再投1,B投2,结果1是winner,有2票
  2. 3个投票人,人均一票:A已投票给1,A再委托给B,结果:委托失败
  3. 3个投票人,人均一票:A已投票,A投1,B投2,C委托给A,结果1是winner,有2票
  4. 3个投票人,人均一票:未投票时,A委托给B,B委托给C,C投票给3,结果3是winner,有3票
  5. 3个投票人,人均一票:C已投票给3,A再委托给B,B再委托给C,结果3是winner,有3票
  6. D没有投票权,委托给A,结果:委托失败回滚信息为You have no right to vote
  7. 3个投票人,人均一票:A委托给B,B委托给C,C再委托给A时发生回滚:Found loop in delegation.
  8. 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状态的用例:

aaa.png google查资料,找到answer:stackoverflow.com/questions/4…

其实就是it()后面的括号位置由于手误放错了,本质上是不了解JavaScript、Mocha、Hardhat的工作机制。

bbb.png 按照这个指引,修改it的括号范围,就正常运行了。

经过一系列曲折,终于把12个用例场景搞定了,经过一番测试,证明优化的找出最高平票提案的代码也是逻辑正确的。

ccc.png