2017 年 9 月 6 日
May 27, 2025

开发日记 I:Solidity 中的 PLCR 投票演练

这是AdChain Registry开发团队成员将在未来几个月内发布的一系列常规开发日记中的第一篇文章。第一篇文章是关于AdChain注册局的部分锁定提交/公开投票系统的,由撰写者 迈克·戈尔丁 ConsenSys 的。

投票决定是否允许域名加入AdChain注册管理机构是AdChain激励游戏的核心。为了支持这一点,我们开发并开源了获得Apache-2许可的仿制药 实现部分锁定提交/披露 (PLCR) 投票,用 Solidity 编写。PLCR 投票是一种有效的代币加权投票系统,它使用户能够使用代币同时参与多个投票,同时防止在投票中对代币进行双重投票。重要的是,它允许用户随时提取未积极用于投票的最大数量的代币。

最初描述了 PLCR 投票 在 Aron Fischer 的博客文章中 为... 写作 殖民地项目埃琳娜·迪米特洛娃在构建此实现时,大量引用了有关该主题的博客文章。我们感谢他们的原创作品!

为什么 PLCR 投票?

在 Solidity 中实现 PLCR 投票并非易事,那为什么要费心呢?除了在投票完成之前使用 commit/reveal(这是防止投票过程本身影响投票结果的理想方法)来掩盖投票记录外,PLCR 投票还支持两件事:

  1. 它使用户能够同时参与多个投票,同时防止在民意调查中对代币进行双重投票。
  2. 它允许用户随时提取未积极用于投票的最大数量的代币。

举个例子:用户将10个代币加载到PLCR投票合约中。然后,用户在投票A中提交了10个代币,在轮询B中提交了6个代币。在民意调查A中透露后,有六个代币仍锁定在轮询B中,但用户可以提取四个代币。

在天真的非 PLCR 轮询系统中,用户可能会将代币锁定在描述单次投票的智能合约中。这不是一个理想的解决方案,因为它可以防止用户使用相同的代币同时参与多个投票。如果用户的代币被锁定在某个智能合约中,则另一个智能合约不能 “TransferFrom” 让用户自己锁定它们。

如果合约可以锁定,那么修复这个问题并使用一个智能合约来管理多个民意调查不一定非常复杂 所有 用户已提交令牌时的用户令牌 任何 民意调查。但是,如果用户批准了锁定10个代币的合同,而实际承诺在任何时候进行投票的代币都不到10个,则用户必须等待 所有 民意调查将在撤出之前结束 任何 他们的代币。这实际上可能会阻碍用户参与投票,因为如果他们在他们可能想回应的市场事件发生时有代币参与投票,则会束手无策。投票系统应尽可能最大限度地提高代币流动性。

因此,最大限度地提高代币流动性需要开发人员承担相当多的复杂性,至少在Solidity中是如此。使用 MiniME 代币一种方法。PLCR 投票支持不是 MiniME 代币的代币。

plcrvoting.sol

plcrVoting.sol 的已部署实例 指定一个标记 其表决权可以分配.需要使用该代币进行的任何代币加权投票都可以使用相同的已部署的PLCR投票合约进行,并且这些民意调查不会相互干扰。要使用的令牌被指定为构造函数的唯一参数。

创建投票

startPoll 函数 用于创建新的民意调查。它接受三个参数并返回一个 uint。论点是:

VoteQuorum: 投票被视为通过所必需的 “赞成” 票的必要百分比。例如,某些民意调查主题可能需要绝大多数才能通过。

提交时长: 提交周期的持续时间,以秒为单位。

揭示时长:显示周期的持续时间,以秒为单位。

在函数体中,我们要做的第一件事是 增加合约的 pollNonce,一个存储变量。通过在每次投票开始时将其递增,我们为每个投票创建一个唯一的 ID。因为我们总是先增加 pollNonce,所以请注意 永远不会有 ID 为零的投票

function startPoll(uint _voteQuorum, uint _commitDuration, uint _revealDuration) public returns (uint pollID) {
  pollNonce = pollNonce + 1;

  pollMap[pollNonce] = Poll({
    voteQuorum: _voteQuorum,
    commitEndDate: block.timestamp + _commitDuration,
    revealEndDate: block.timestamp + _commitDuration + _revealDuration,
    votesFor: 0,
    votesAgainst: 0
  });

  PollCreated(pollNonce);
  return pollNonce;
}

下一步 我们实例化一个 Poll 结构 并将其添加到 民意调查地图 使用 PollNonce 作为钥匙。Poll 结构存储作为参数传入的投票参数,并将赞成和反对的票数初始化为零。

最后,我们使用 PollNonce 触发一个 PollCreated 事件,然后返回 PollNonce,供稍后用作本次民意调查的 PollId。很简单!

创建民意调查后可能发生的下一个合乎逻辑的事情是,有人可能想在该民意调查中投票。这个过程有几个步骤,从请求投票权开始。

申请投票权

为了防止代币在民意调查中进行双重投票,PLCR合约需要从用户代币承诺公布之时起对其进行管理。托管代币可以同时在多个民意调查中进行投票,但不能在同一次投票中多次投票。 请求投票权功能 向用户授予投票权,投票权等于所管理代币的权重。

function requestVotingRights(uint numTokens) external {
  require(token.balanceOf(msg.sender) >= numTokens);
  require(token.transferFrom(msg.sender, this, numTokens));
  voteTokenBalance[msg.sender] += numTokens;
}

第一行 在函数体中,我们检查消息发送者的实际代币余额相对于提供的 numTokens 参数是否足够。这个 下一行 调用 TransferFrom,将 numTokens 从消息发送者的余额转移到 PLCR 合约的余额中。第一行是冗余检查:第二个 require 语句在任何情况下都会抛出错误,而第一行语句确实会抛出错误。这只是防御性编程,因为人们可能会在错误的 ERC-20 实现中使用这个合约(尽管在以下情况下这样做没有任何借口 不错的实现 存在)。

另请注意,如果用户在致电requestVotingRights之前未批准PLCR转让NumTokens的合同,则TransferFrom将失败。这太可悲了而且 有人建议改进 “批准并呼叫” 模式,但它们尚未得到广泛实施。

最后,如果前两行成功,我们 按 numTokens 增加消息发送者的 VoteTokenBalance。呼。我们现在可以投票。我们现在也可以提取所有代币,因为所管理的代币都尚未被锁定在民意调查中。

进行投票

Commit-reveal是ENS中用来隐瞒出价的一种模式,也可以用于秘密投票。承诺投票是 加盐的哈希 用户的投票,这意味着用户的偏好(是或否)在被哈希处理和提交之前会与一定的随机性(盐)串联在一起。 提交投票 采用以下参数:

PolliD: 被投票的民意调查的 ID(最初是在调用 startPoll 时返回的)

SecretHash: 选民选择的 keccak256 哈希值和盐(紧密包装且按顺序排列)

NUM 代币: 参与本次投票的代币数量

prevPollid: 用户当前拥有最多代币数量小于或等于提交的 numTokens 的投票的 ID(我们稍后会讨论这个问题)。

好的,让我们来看看函数体。 我们要做的第一件事是做几张检查。我们将调用一个辅助函数,以确保所提供的 PollID 的提交期处于活动状态,消息发送者的 VoteTokenBalance 至少是传入的 numTokens 值,并且提供的 polLID 不为零。

题外话:为什么我们特别对待 PolliD 零的民意调查?我们之前指出,在StartPoll中,永远不会有PollNonce的PollId为零,在这里我们特别检查在PollId零的投票中是否有投票。在 EVM 中,所有数据都初始化为零。如果你声明一个 uint x 但没有对其进行初始化,(x == 0 && x == false) 将为真。对于与PLCR投票合约交互的智能合约,将ID为零的投票引用为一种空值可能会很有用。例如,在AdChain注册表中,PolliD与受到质疑的列表一起存储。默认情况下,这些值将初始化为零。如果我们知道一个 PollID 为零的清单没有活跃的质询,而不是必须存储一个单独的布尔值,那么效率就会很高。出于这个原因,我们希望将索引 0 的民意调查保持在未使用状态。

好的,所以我们完成了支票。现在,我们将做一些时髦的事情并介绍我们的双向链表。

双重关联列表

PLCRVoting 合约使用 双向链接列表 跟踪用户在哪些民意调查中投入了代币。对于计算机科学专业的学生来说,实现双链表是一项大一的家庭作业,但是在 Solidity 中实现一个比在 Python 中实现一个更具挑战性,因为 Solidity 是一种低级语言。双链清单使我们能够高效地向希望撤回投票权且在多次投票中投入的代币少于他们拥有投票权的代币总数的用户发放尽可能多的代币。

library DLL {
	struct Node {
		uint next;
		uint prev;
	}

	struct Data {
		mapping(uint => Node) dll;
	}

	function getNext(Data storage self, uint curr) returns (uint) {
		return self.dll[curr].next;
	}

	function getPrev(Data storage self, uint curr) returns (uint) {
		return self.dll[curr].prev;
	}

	function insert(Data storage self, uint prev, uint curr, uint next) {
		self.dll[curr].prev = prev;
		self.dll[curr].next = next;

		self.dll[prev].next = curr;
		self.dll[next].prev = curr;
	}

	function remove(Data storage self, uint curr) {
		uint next = getNext(self, curr);
		uint prev = getPrev(self, curr);

		self.dll[next].prev = prev;
		self.dll[prev].next = next;

		self.dll[curr].next = curr;
		self.dll[curr].prev = curr;
	}
}

首先,对一个核心概念的沉思:映射可以直接在 Solidity 中用于寻址内存,就像 C 中的指针一样。这是在 Solidity 中构建复杂数据结构的关键。

在PLCR投票中,每个用户都有一个双向链接列表, 使用用户的 msg.sender 地址寻址。用户 DLL 中的一个节点对应一个 PollID。DLL 始终按用户为与节点对应的投票提交的令牌数量进行排序。数据与节点本身分开存储,并使用以下方法进行寻址 用户地址和节点 ID 的哈希拼接 索引到一个名为字符串键的整数映射 属性存储

library AttributeStore {
    struct Data {
        mapping(bytes32 => uint) store;
    }

    function getAttribute(Data storage self, bytes32 UUID, string attrName) returns (uint) {
        bytes32 key = sha3(UUID, attrName);
        return self.store[key];
    }

    function attachAttribute(Data storage self, bytes32 UUID, string attrName, uint attrVal) {
        bytes32 key = sha3(UUID, attrName);
        self.store[key] = attrVal;
    }
}

澄清一下:用户地址是指特定 DLL。一个节点 ID 可以寻址 DLL 中的特定节点,但是由于节点 ID 对应于 polliID,因此多个 DLL 的节点可以具有相同的节点 ID。通过将用户地址与 nodeID 和哈希连接起来,我们在内存中获得一个可以查找数据的唯一位置。我们将数据与节点分开存储的原因是,我们只需要为所有节点存储一个映射,而不是每个节点存储一个映射。即使映射为空,映射的声明也使用存储。

这是 DLL 工作原理的基本概述。让我们回过头来看看 CommitVote 的工作原理,看看我们在实践中是如何使用 DLL 的。

承诺投票,续

在线 102 我们将一个名为 nextPollid 的 uint 设置为方法 getNext 的结果,该方法是 msg.sender 的 DLL 的方法,该方法采用参数 prevPollid。prevPollid 是节点的 ID,它将是我们正在插入的新节点的上一个节点。然后 nextPollid 将成为我们正在插入的新节点的下一个节点,因为它将位于 prevPollid 之后,因此在 nextPollid 之前。

function commitVote(uint pollID, bytes32 secretHash, uint numTokens, uint prevPollID) external {
  require(commitPeriodActive(pollID));
  require(voteTokenBalance[msg.sender] >= numTokens); // prevent user from overspending
  require(pollID != 0);                // prevent user from committing to zero node placerholder

  uint nextPollID = dllMap[msg.sender].getNext(prevPollID);

  require(validPosition(prevPollID, nextPollID, msg.sender, numTokens));
  dllMap[msg.sender].insert(prevPollID, pollID, nextPollID);

  bytes32 UUID = attrUUID(msg.sender, pollID);

  store.attachAttribute(UUID, "numTokens", numTokens);
  store.attachAttribute(UUID, "commitHash", uint(secretHash));
}

为什么我们要让用户提供 prevPollid 值?我们 可以 在清单中搜索以找到正确的 prevPollid,但是在很长的清单中我们这样做可能会突破汽油限制。最好让用户在通话中进行链下操作,然后提供它,以便交易可以在恒定时间内运行。

104 号线 考虑到新投票中提交的代币数量,以恒定的时间检查所提供的 prevPollid 是否有效。该清单不应该不可能变为未排序。如果支票通过,那就开启了 第 105 行 我们插入新节点!

我们已经插入了一个节点,但请注意,我们还没有实际添加数据。开启 第 107 行 我们会用一个助手 函数 attruUID 创建一个新的通用唯一 ID,即用户地址和 nodeID 的 sha3 哈希值。 最后 我们存储了为投票承诺的代币数量和我们投票的 SecretHash。

哇,那太难了!

公布投票

既然我们知道提交是如何工作的,那么揭示实际上会相对容易。此时我们已经学到了所有困难的东西,所以让我们来看看吧 显示投票!revealVote 采用三个参数:

PolliD: 该民意调查的 PolLID 已公布。

VoteOption:用户在投票中的选择。1 表示投赞成票,0 表示反对。

: 连接到 VoteOption 的随机数字,用于从 CommitVote 生成 SecretHash。

此时你已经绕过了街区,所以你知道我们要先做什么: 检查。我们将通过计算这两个项目的sha3哈希值并将结果与存储在用户DLL中的SecretHash进行比较来检查所提供的PollID的披露期是否有效,我们将确保提供的投票选项和盐确实与所提交的SecretHash相匹配。

function revealVote(uint pollID, uint voteOption, uint salt) external {
  // Make sure the reveal period is active
  require(revealPeriodActive(pollID));
  require(!hasBeenRevealed(msg.sender, pollID));                        // prevent user from revealing multiple times
  require(sha3(voteOption, salt) == getCommitHash(msg.sender, pollID)); // compare resultant hash from inputs to original commitHash

  uint numTokens = getNumTokens(msg.sender, pollID); 

  if (voteOption == 1) // apply numTokens to appropriate poll choice
    pollMap[pollID].votesFor += numTokens;
  else
    pollMap[pollID].votesAgainst += numTokens;

  dllMap[msg.sender].remove(pollID); // remove the node referring to this vote upon reveal
}

开启 第 140 行 我们将获得用户在本次民意调查中提交的代币数量。然后,根据用户在民意调查中的选择,我们将 更新民意调查的全球 VotesFor 或 votesAgainst 计数。

终于开启了 第 147 行 我们会 移除该节点 在用户的 DLL 中进行此次投票。

哇,那很简单!

谁赢了?

好的,民意调查的披露期已经结束,我们想知道进展如何。这个很简单。这个 isPassed 函数 以 polliD 作为参数然后 如果返回 true 相对于反对票而言,赞成票符合法定人数要求的票数。在平局中,民意调查未获通过。请注意,法定人数要求不是总代币的法定人数 必须 投票,这只是代币的法定人数 做到了 投票。

拿出我们的代币

这是我们为之努力的部分。假设我们有10个代币的投票权,但目前只有7个是投票。使用 撤回投票权 我们应该能够拿出三个。addrackVotingRights以我们希望提取的代币数量作为论据。

function withdrawVotingRights(uint numTokens) external {
  uint availableTokens = voteTokenBalance[msg.sender] - getLockedTokens(msg.sender);
  require(availableTokens >= numTokens);
  require(token.transfer(msg.sender, numTokens));
  voteTokenBalance[msg.sender] -= numTokens;
}

魔法发生了 69 号线。我们通过从用户的 VoteTokenBalance 中减去 a 的结果来计算可提取的代币 辅助函数 getLockedTokens 以用户地址为参数。这个包裹了另一个 辅助函数 getnumTokens 它以我们的用户地址和另一个用户地址的结果为参数 辅助函数 getLastNode

现在想一想:我们的 DLL 始终按提交给投票的代币数量排序。getLastNode 索引到用户 DLL 的零节点(根节点),并获取前一个节点,该节点应该是用户锁定代币数量最多的投票。只需从用户在PLCR合约中加载的代币总数中减去该数字,我们就知道他们可以提取多少代币。

我们使用 DLL 所做的所有辛勤工作,都是我们为此而做的。

回来撤回投票权, 都是簿记: 我们将代币发回给用户并减少他们的 VoteTokenBalance。

仅此而已!

希望本演练对您了解我们的PLCR投票合同的运作方式有所帮助!你可以随意使用它来满足你所有的代币投票需求!

为 ConsenSys 2017 实习生提供大量道具 约克·罗德斯Cem Ozer阿斯宾·帕拉特尼克 感谢你今年夏天为实现PLCR的投票梦想而努力工作。