文章目录

投票
以下合约实现了一个相对复杂的投票机制,旨在展示 Solidity 的多种功能用法。
电子投票面临的核心挑战在于如何合理分配投票权,以及如何防止投票被操控。本文虽无法涵盖所有问题的解决方案,但会重点演示如何实现投票委托机制,以此达到自动化计票和全流程透明的目的。
基本设计思路
1.每次发起投票时,都会部署一个新的合约,并为每个投票选项设置一个简洁的名称。
2.合约的创建者(即“主席”)会逐个为指定地址授予投票权。
3.拥有投票权的用户可以选择直接投票,也可以将自己的投票权委托给他人。
4.投票结束后,winningProposal() 函数会返回得票数最高的提案名称。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/// @title 具有委托功能的投票合约
contract Ballot {
// 定义一个新的复杂数据类型,将用于后续变量
// 它表示一个选民
struct Voter {
uint weight; // 通过委托累积的投票权重
bool voted; // 是否已经投票(true 表示已投票)
address delegate; // 被委托投票的人
uint vote; // 投给的提案索引
}
// 定义一个提案的数据结构
struct Proposal {
bytes32 name; // 提案的名称(最多 32 字节)
uint voteCount; // 该提案获得的投票数
}
address public chairperson; // 投票的主席
// 状态变量,存储每个地址对应的选民信息
mapping(address => Voter) public voters;
// 动态数组,存储所有提案
Proposal[] public proposals;
/// 创建一个新的投票,提供一组提案名称
constructor(bytes32[] memory proposalNames) {
chairperson = msg.sender;
voters[chairperson].weight = 1;
// 遍历提供的提案名称,为每个提案创建一个 Proposal 对象并添加到数组中
for (uint i = 0; i < proposalNames.length; i++) {
// 创建临时 Proposal 对象并添加到 proposals 数组中
proposals.push(Proposal({
name: proposalNames[i],
voteCount: 0
}));
}
}
// 赋予 `voter` 投票权,仅限主席调用
function giveRightToVote(address voter) external {
// `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.");
// 处理委托链,避免死循环
while (voters[to].delegate != address(0)) {
to = voters[to].delegate;
require(to != msg.sender, "Found loop in delegation.");
}
Voter storage delegate_ = voters[to];
// 确保被委托人具有投票权
require(delegate_.weight >= 1);
// 记录委托关系
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.weight != 0, "Has no right to vote");
require(!sender.voted, "Already voted.");
sender.voted = true;
sender.vote = proposal;
// 若 `proposal` 超出范围,自动抛出异常并回滚
proposals[proposal].voteCount += sender.weight;
}
/// @dev 计算当前得票最多的提案
function winningProposal() public 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() external view
returns (bytes32 winnerName_)
{
winnerName_ = proposals[winningProposal()].name;
}
}
可以看出,分配投票权时需要发起多笔交易,这在效率上并不理想。另一方面,如果多个提案获得了相同的票数,winningProposal() 函数将无法处理这种平票情况。你是否能想到改进这些问题的方案?
盲拍卖
接下来,我们将展示如何在以太坊上创建一个完全盲拍的拍卖合约。我们会从一个基础的公开拍卖开始,在这种拍卖中,所有参与者都可以看到彼此的出价。随后,我们将对合约进行扩展,实现一个盲拍机制,使得在竞拍阶段结束前,没人能看到实际出价内容。
1. 简单的公开拍卖
以下代码实现了简单的公开拍卖功能:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract SimpleAuction {
// 拍卖的参数。时间可以是:
// - 绝对的 Unix 时间戳(自 1970-01-01 起的秒数)
// - 以秒为单位的时间段。
address payable public beneficiary; // 受益人(拍卖收益接收者)
uint public auctionEndTime; // 拍卖结束时间
// 拍卖的当前状态。
address public highestBidder; // 最高出价者
uint public highestBid; // 最高出价
// 允许被退还的之前的出价
mapping(address => uint) pendingReturns;
// 拍卖结束标志,设置为 true 后不允许修改
// 默认初始化为 `false`。
bool ended;
// 发生状态变更时触发的事件。
event HighestBidIncreased(address bidder, uint amount); // 最高出价增加事件
event AuctionEnded(address winner, uint amount); // 拍卖结束事件
// 定义错误类型,描述可能的失败情况。
// 三斜杠注释(`///`)是 natspec 注释。
// 在用户确认交易或显示错误信息时,这些注释会被展示。
/// 拍卖已经结束。
error AuctionAlreadyEnded();
/// 目前已有更高或相等的出价。
error BidNotHighEnough(uint highestBid);
/// 拍卖尚未结束。
error AuctionNotYetEnded();
/// `auctionEnd` 函数已经被调用过了。
error AuctionEndAlreadyCalled();
/// 创建一个简单的拍卖,拍卖时长为 `biddingTime` 秒,
/// 受益人地址为 `beneficiaryAddress`。
constructor(
uint biddingTime,
address payable beneficiaryAddress
) {
beneficiary = beneficiaryAddress;
auctionEndTime = block.timestamp + biddingTime;
}
/// 竞拍者可以调用该函数进行出价,并随交易一起发送金额。
/// 如果竞拍未获胜,则出价会被退还。
function bid() external payable {
// 该函数不需要参数,所有信息都包含在交易中。
// 关键字 `payable` 允许该函数接收 Ether。
// 如果竞拍时间已结束,则终止执行。
if (block.timestamp > auctionEndTime)
revert AuctionAlreadyEnded();
// 如果出价未超过当前最高出价,则撤销交易并返还 Ether。
if (msg.value <= highestBid)
revert BidNotHighEnough(highestBid);
if (highestBid != 0) {
// 直接使用 `highestBidder.send(highestBid)` 退还资金是不安全的,
// 因为可能会执行一个不受信任的合约。
// 更安全的做法是让收款人主动提现。
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
emit HighestBidIncreased(msg.sender, msg.value);
}
/// 允许竞标失败的用户提取他们的出价。
function withdraw() external returns (bool) {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// 先将待提取金额置零,防止重入攻击。
pendingReturns[msg.sender] = 0;
// `msg.sender` 不是 `address payable` 类型,
// 需要显式转换为 `payable(msg.sender)` 才能调用 `send()`。
if (!payable(msg.sender).send(amount)) {
// 发送失败时恢复余额,不抛出异常。
pendingReturns[msg.sender] = amount;
return false;
}
}
return true;
}
/// 结束拍卖,并将最高出价转给受益人。
function auctionEnd() external {
// 通常与外部合约交互的函数应该按照以下三步结构:
// 1. 检查条件
// 2. 执行状态变更
// 3. 与外部合约交互
// 如果这三步混合在一起,外部合约可能会回调当前合约,
// 修改状态或导致 Ether 付款多次执行。
// 如果内部调用的函数涉及与外部合约交互,它们也必须被视为外部交互。
// 1. 检查条件
if (block.timestamp < auctionEndTime)
revert AuctionNotYetEnded();
if (ended)
revert AuctionEndAlreadyCalled();
// 2. 状态变更
ended = true;
emit AuctionEnded(highestBidder, highestBid);
// 3. 与外部合约交互
beneficiary.transfer(highestBid);
}
}
基本思路是:每位参与者都可以在竞拍期内提交自己的出价。
通常,出价会附带一定数量的押金(例如 Ether),以确保竞拍者会履行其出价承诺。如果有更高的出价被提交,之前的最高出价者将自动退还其押金。
竞拍期结束后,合约需要由受益人手动调用,才能领取拍卖所得。由于智能合约无法自行触发执行,因此这个步骤无法自动完成。
接下来,我们将在此基础上扩展功能,实现盲拍卖机制。
2. 盲拍(Blind Auction)
盲拍的优势在于:在竞拍期间,参与者不会因为看到他人的出价而感到时间上的压力或被迫加价。在一个信息完全公开的区块链平台上实现盲拍听起来像是个悖论,但密码学为我们提供了可行的解决方案。
在盲拍阶段,竞拍者并不会直接提交出价金额,而是提交该出价的哈希值。由于当前的密码学哈希函数在实际应用中几乎不可能让两个不同的值生成相同的哈希,因此我们可以认为出价已被“隐藏”在哈希中。
待竞拍期结束后,进入揭示阶段。此时,竞拍者需要以明文形式公开他们的出价,合约会验证所提交的哈希值是否与此前存储的一致,从而确认该出价的有效性。
但这里也存在一个新的挑战:如何既保证拍卖的“盲目性”,又确保其“约束性”?
如果允许竞拍者在赢得拍卖之后再支付 Ether,他们可能会反悔不付款。因此,唯一可靠的方案是:在提交出价时就一并转入 Ether。
然而,问题来了 —— 以太坊的交易金额是公开可见的。如果用户在提交哈希的同时支付了实际出价金额,那么其他人就能推测出实际出价,进而破坏了盲拍的初衷。
为了解决这个矛盾,下面这个合约采用了一种策略:允许竞拍者提交任意金额(只需大于预期出价),并在揭示阶段再验证其出价是否有效。
这种设计允许竞拍者提交多个无效出价(过高或过低),以扰乱其他竞争者的判断,从而提升了盲拍机制的不可预测性与策略性。
以下是具体实现代码:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract BlindAuction {
struct Bid {
bytes32 blindedBid; // 存储加密后的出价
uint deposit; // 存储出价时发送的以太币
}
address payable public beneficiary; // 受益人地址
uint public biddingEnd; // 竞拍结束时间
uint public revealEnd; // 出价揭示阶段的结束时间
bool public ended; // 标志拍卖是否结束
mapping(address => Bid[]) public bids; // 存储每个竞拍者的出价信息
address public highestBidder; // 最高出价者
uint public highestBid; // 最高出价
// 允许被覆盖的出价可提现的金额
mapping(address => uint) pendingReturns;
event AuctionEnded(address winner, uint highestBid);
// 错误信息描述拍卖过程中可能的失败原因
/// 该函数被过早调用
/// 请在 `time` 之后再尝试。
error TooEarly(uint time);
/// 该函数被过晚调用
/// 它不能在 `time` 之后被调用。
error TooLate(uint time);
/// 拍卖已经结束
error AuctionEndAlreadyCalled();
// 修饰符用于验证函数的调用时间
// `onlyBefore` 适用于 `bid` 函数
// `_` 代表被修饰的函数体
modifier onlyBefore(uint time) {
if (block.timestamp >= time) revert TooLate(time);
_;
}
modifier onlyAfter(uint time) {
if (block.timestamp <= time) revert TooEarly(time);
_;
}
/// 创建一个盲拍
/// `biddingTime` 为竞拍时长
/// `revealTime` 为出价揭示时长
/// `beneficiaryAddress` 为受益人地址
constructor(
uint biddingTime,
uint revealTime,
address payable beneficiaryAddress
) {
beneficiary = beneficiaryAddress;
biddingEnd = block.timestamp + biddingTime;
revealEnd = biddingEnd + revealTime;
}
/// 提交一个盲拍出价
/// `blindedBid` = keccak256(abi.encodePacked(value, fake, secret))
/// 只有在揭示阶段正确揭示出价,该出价的保证金才会被退还
/// 出价在以下情况下有效:
/// - 发送的以太币至少等于 `value`
/// - `fake` 不能为 `true`
/// 通过设置 `fake` 为 `true` 或者发送不准确的金额,竞拍者可以隐藏真实的出价,同时仍然支付保证金。
/// 同一个地址可以提交多个出价。
function bid(bytes32 blindedBid)
external
payable
onlyBefore(biddingEnd)
{
bids[msg.sender].push(Bid({
blindedBid: blindedBid,
deposit: msg.value
}));
}
/// 揭示之前提交的盲拍出价
/// 你将会收到退还的保证金(如果该出价无效或未被采纳)
function reveal(
uint[] calldata values,
bool[] calldata fakes,
bytes32[] calldata secrets
)
external
onlyAfter(biddingEnd)
onlyBefore(revealEnd)
{
uint length = bids[msg.sender].length;
require(values.length == length);
require(fakes.length == length);
require(secrets.length == length);
uint refund;
for (uint i = 0; i < length; i++) {
Bid storage bidToCheck = bids[msg.sender][i];
(uint value, bool fake, bytes32 secret) =
(values[i], fakes[i], secrets[i]);
if (bidToCheck.blindedBid != keccak256(abi.encodePacked(value, fake, secret))) {
// 出价未正确揭示,不退还保证金
continue;
}
refund += bidToCheck.deposit;
if (!fake && bidToCheck.deposit >= value) {
if (placeBid(msg.sender, value))
refund -= value;
}
// 避免重复使用相同的出价
bidToCheck.blindedBid = bytes32(0);
}
payable(msg.sender).transfer(refund);
}
/// 提取被超过的出价
function withdraw() external {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// 在发送之前先将金额置零,防止可重入攻击
pendingReturns[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
}
/// 结束拍卖,并将最高出价转给受益人
function auctionEnd()
external
onlyAfter(revealEnd)
{
if (ended) revert AuctionEndAlreadyCalled();
emit AuctionEnded(highestBidder, highestBid);
ended = true;
beneficiary.transfer(highestBid);
}
// 内部函数,仅能在合约内部调用
function placeBid(address bidder, uint value) internal
returns (bool success)
{
if (value <= highestBid) {
return false;
}
if (highestBidder != address(0)) {
// 退还之前的最高出价
pendingReturns[highestBidder] += highestBid;
}
highestBid = value;
highestBidder = bidder;
return true;
}
}
远程购买
在当前的线上交易中,远程购买通常需要多个彼此信任的参与方。最基本的模式仅涉及卖家和买家:买家希望如约收到商品,而卖家则希望及时收到付款(例如以太币)。
问题的关键在于物流环节 —— 没有一种确凿可靠的方法能确保买家确实收到了商品。这一信任缺失,成为限制远程交易效率与安全性的核心瓶颈。虽然现实中已有多种解决方案(如平台担保、第三方托管等),但各有局限。
我们不妨设想一个基于智能合约的替代方案,其大致思路如下:
1.买家和卖家各自向合约支付相当于商品价值两倍的以太币,作为托管保证金(escrow)。
2.仅当双方都成功存入押金后,资金才会被合约锁定,进入交易状态,直到买家确认收货。
3.一旦买家确认收到商品:
- 买家将拿回一半的押金(即等值于商品价格);
- 卖家则收到三倍商品价值的以太币(包含其原始押金 + 买家的支付款项)。
4.如果买家迟迟不确认收货,资金将保持锁定状态,双方都无法取回,形成“共损”机制。
这种机制的核心在于:双方都有经济动机去促成交易的顺利完成,否则各自押金将被永久锁定。
下方的示例合约便基于这一思路进行实现。虽然它并不能彻底解决远程交易中的所有信任问题,但它展示了如何利用智能合约中类似状态机的设计模式,为现实世界中的复杂博弈构建更公平、自动化的解决方案。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract Purchase {
uint public value;
address payable public seller;
address payable public buyer;
enum State { Created, Locked, Release, Inactive }
// state 变量的默认值为第一个成员 `State.Created`
State public state;
modifier condition(bool condition_) {
require(condition_);
_;
}
/// 仅买家可以调用此函数。
error OnlyBuyer();
/// 仅卖家可以调用此函数。
error OnlySeller();
/// 当前状态下无法调用此函数。
error InvalidState();
/// 提供的金额必须是偶数。
error ValueNotEven();
modifier onlyBuyer() {
if (msg.sender != buyer)
revert OnlyBuyer();
_;
}
modifier onlySeller() {
if (msg.sender != seller)
revert OnlySeller();
_;
}
modifier inState(State state_) {
if (state != state_)
revert InvalidState();
_;
}
event Aborted();
event PurchaseConfirmed();
event ItemReceived();
event SellerRefunded();
// 确保 `msg.value` 是一个偶数。
// 如果是奇数,除法会截断。
// 通过乘法检查它不是一个奇数。
constructor() payable {
seller = payable(msg.sender);
value = msg.value / 2;
if ((2 * value) != msg.value)
revert ValueNotEven();
}
/// 中止购买并取回以太币。
/// 仅在合约被锁定之前,卖家可以调用此函数。
function abort()
external
onlySeller
inState(State.Created)
{
emit Aborted();
state = State.Inactive;
// 这里直接使用 transfer。它是防重入的,
// 因为它是此函数中的最后一个调用,且状态已更改。
seller.transfer(address(this).balance);
}
/// 买家确认购买。
/// 交易必须包含 `2 * value` 的以太币。
/// 这些以太币将被锁定,直到调用 confirmReceived。
function confirmPurchase()
external
inState(State.Created)
condition(msg.value == (2 * value))
payable
{
emit PurchaseConfirmed();
buyer = payable(msg.sender);
state = State.Locked;
}
/// 确认已收到商品(由买家确认)。
/// 这将释放锁定的以太币。
function confirmReceived()
external
onlyBuyer
inState(State.Locked)
{
emit ItemReceived();
// 重要的是先更改状态,因为
// 否则使用 `send` 调用的合约可以再次调用此函数。
state = State.Release;
buyer.transfer(value);
}
/// 退款给卖家,即
/// 支付卖家锁定的资金。
function refundSeller()
external
onlySeller
inState(State.Release)
{
emit SellerRefunded();
// 重要的是先更改状态,因为
// 否则使用 `send` 调用的合约可以再次调用此函数。
state = State.Inactive;
seller.transfer(3 * value);
}
}
微支付通道
在本节中,我们将学习如何实现一个简单的支付通道示例。通过加密签名机制,该方案使得在同一发送方与接收方之间进行反复的以太币转账变得更加高效 —— 即时到账、无需手续费、链下完成。
为了理解这个过程,我们需要掌握两件事:
- 如何创建和验证签名;
- 如何构建一个基本的支付通道合约。
1. 创建和验证签名
假设 Alice 想将一些以太币支付给 Bob,即 Alice 是付款方,Bob 是收款方。
在传统的区块链转账中,Alice 需要向链上发起交易并支付 Gas 费。但在支付通道中,Alice 只需将签名后的消息发给 Bob(例如通过电子邮件发送),就像开一张支票一样。
核心理念是:Alice 用自己的私钥签名授权支付;而 Bob 将这条消息发送给智能合约,由合约验证签名有效性后释放资金。
整个流程如下:
1.Alice 部署一个 ReceiverPays 合约,并一次性充值足够的以太币用于后续支付。
2.Alice 使用她的私钥,对一条特定格式的消息进行签名,表示授权某个支付行为。
3.Alice 将签名后的消息发送给 Bob(该消息无需保密,发送方式也无所谓)。
4.Bob 将这条签名消息提交给合约,合约验证签名是否有效,若通过,则将相应金额发送给 Bob。
2. 创建签名
值得注意的是,Alice 在生成签名时并不需要与以太坊网络进行交互,整个过程可以完全离线完成。
在本节示例中,我们使用 web3.js 与 MetaMask 在浏览器中生成签名,采用的是 EIP-712 提出的结构化签名方案 —— 这能提供更强的安全性与用户可读性。
下面是一个简化的签名示例:
// 首先对消息进行哈希处理,使签名过程更简洁
var hash = web3.utils.sha3("message to sign");
web3.eth.personal.sign(
hash,
web3.eth.defaultAccount,
function () {
console.log("Signed");
}
);
注意:注意:web3.eth.personal.sign 方法会在签名数据前自动加上长度前缀。由于我们签名前先对消息做了哈希(固定为 32 字节),因此这个长度前缀始终是固定的。
3. 要签署的内容
为了让智能合约验证支付请求的合法性,签名的消息中必须包含以下几项信息:
-
收款人的地址(即将接收以太币的一方);
-
支付金额;
-
防止重放攻击的机制。
重放攻击(Replay Attack) 指的是:攻击者重复使用一条已经签名过的消息,来多次发起授权操作。为了防止此类攻击,我们可以借鉴以太坊交易中的机制 —— 使用 nonce(随机数)。每个 nonce 只能使用一次,合约在处理请求时会校验该 nonce 是否已被使用。
此外,还有另一种潜在的重放攻击风险:如果 Alice 曾部署过一个 ReceiverPays 合约并进行过支付操作,然后将其销毁(例如使用 selfdestruct),再重新部署一个新的同名合约,由于新合约并不知道旧的 nonce 历史,攻击者就可能重用原先的签名信息。
解决方法是: Alice 在签名的消息中加入合约地址,确保签名仅对特定合约有效。合约在验证消息时,会检查这个合约地址是否匹配。你可以在第八点中的 claimPayment() 函数的前两行看到相关逻辑实现。
附注:为了进一步避免合约被销毁带来的问题,本示例中我们不会使用 selfdestruct,而是通过“冻结”合约的方式来禁用其功能。一旦被冻结,所有函数调用都会被回滚(revert)。
4. 打包参数
现在我们已经确定了签名消息中需要包含的字段,接下来就可以将这些字段组合起来,对其进行哈希处理,并生成签名。
为了简化这一过程,我们将所有数据字段连接后统一处理。ethereumjs-abi 库提供了一个名为 soliditySHA3 的函数,它模拟了 Solidity 中 keccak256(abi.encodePacked(…)) 的行为,用于生成与合约中一致的哈希值。
下面是一个 JavaScript 函数示例,用于为 ReceiverPays 合约创建正确格式的签名:
// recipient 是应该收到支付的地址。
// amount,单位为 wei,指定应该发送多少以太币。
// nonce 可以是任何唯一的数字,用于防止重放攻击。
// contractAddress 用于防止跨合约重放攻击。
function signPayment(recipient, amount, nonce, contractAddress, callback) {
var hash = "0x" + abi.soliditySHA3(
["address", "uint256", "uint256", "address"],
[recipient, amount, nonce, contractAddress]
).toString("hex");
web3.eth.personal.sign(hash, web3.eth.defaultAccount, callback);
}
5. 在 Solidity 中恢复消息签名者
通常,ECDSA 签名由两个参数 r 和 s 组成,而在以太坊中还包含一个额外的参数 v。这个参数可以用来确定签名所使用的私钥对应的账户地址,从而识别出交易的发送者。
Solidity 提供了内置函数 ecrecover,它接收消息的哈希值和签名参数 r、s、v,并返回生成该签名的地址。通过这个函数,我们可以在合约中验证某个地址是否真正签署了某条消息。
6. 提取签名参数
Web3.js 生成的签名结果是将 r、s 和 v 三个部分拼接后的字节串。在客户端解析这些参数比较简单,但如果在智能合约中完成拆解,就只需要传递一个签名参数,而不是分别传入三个,有利于减少数据传输和调用成本。
由于 Solidity 中处理字节数据的能力较为有限,我们通常通过内联汇编(inline assembly)来解析签名。这部分逻辑封装在合约中的 splitSignature 函数中(通常是合约中的第三个函数)。
7. 计算消息哈希
为了在智能合约中验证签名,必须使用与签名时完全一致的参数重新生成消息哈希。
这包括将签名用到的所有字段(如收款地址、金额、nonce、合约地址)按特定顺序编码,并通过 keccak256 进行哈希。为了符合以太坊签名标准,我们还需要添加一个前缀(prefix)来防止签名被用于其他用途。
prefixed 和 recoverSigner 函数共同完成了这一系列操作,并被集成在合约中的 claimPayment 函数前半部分中。
8. 代码示例
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// 定义一个 Owned 合约,表示合约的所有者
contract Owned {
address payable owner;
// 构造函数,部署合约时设置合约的所有者
constructor() {
owner = payable(msg.sender);
}
}
// 定义一个可冻结的合约,继承自 Owned
contract Freezable is Owned {
bool private _frozen = false; // 冻结状态,默认为 false(未冻结)
// 修饰符:确保合约未被冻结
modifier notFrozen() {
require(!_frozen, "Inactive Contract."); // 如果已冻结,则抛出错误
_;
}
// 内部函数:冻结合约(只能由所有者调用)
function freeze() internal {
if (msg.sender == owner)
_frozen = true;
}
}
// ReceiverPays 合约,继承自 Freezable
contract ReceiverPays is Freezable {
mapping(uint256 => bool) usedNonces; // 记录已使用的 nonce 值,防止重放攻击
// 构造函数,允许合约在部署时接收以太币
constructor() payable {}
// 领取支付的函数,msg.sender 需要提供正确的签名
function claimPayment(uint256 amount, uint256 nonce, bytes memory signature)
external
notFrozen
{
require(!usedNonces[nonce]); // 确保 nonce 未被使用
usedNonces[nonce] = true; // 标记 nonce 已使用
// 重新创建客户端签名的消息
bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, amount, nonce, this)));
require(recoverSigner(message, signature) == owner); // 验证签名是否由所有者创建
// 转账给调用者(msg.sender)
payable(msg.sender).transfer(amount);
}
/// 冻结合约并提取剩余资金
function shutdown()
external
notFrozen
{
require(msg.sender == owner); // 只有所有者可以调用
freeze(); // 冻结合约
payable(msg.sender).transfer(address(this).balance); // 提取合约剩余资金
}
/// 签名处理函数
// 拆分签名,将 `sig` 分割成 v, r, s 三个参数
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65); // 确保签名长度为 65 字节
assembly {
// 前 32 字节是 r
r := mload(add(sig, 32))
// 接下来的 32 字节是 s
s := mload(add(sig, 64))
// 最后的 1 字节是 v(位于下一个 32 字节的第一个字节)
v := byte(0, mload(add(sig, 96)))
}
return (v, r, s);
}
// 通过消息哈希和签名恢复签名者地址
function recoverSigner(bytes32 message, bytes memory sig)
internal
pure
returns (address)
{
(uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
return ecrecover(message, v, r, s); // 使用 ecrecover 还原签名者地址
}
/// 构造带前缀的哈希值,以模拟 `eth_sign` 的行为
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}
编写一个简单的支付通道
Alice 计划构建一个简单而完整的支付通道,该支付通道需要结合加密签名技术,使得以太币的多次转账变得安全、即时,并且几乎无需支付链上交易费用。
1. 什么是支付通道?
支付通道允许交易双方在不频繁使用链上交易的情况下进行多次支付。这样可以大幅减少延迟和交易成本。我们将在此探索一种简单的单向支付通道,仅涉及两方(Alice 和 Bob)。
整个流程分为三个步骤:
1.Alice 向智能合约存入以太币,开启支付通道。
2.Alice 向 Bob 发送签名消息,声明应支付的金额。这个过程可以多次进行,每次支付都发送一条新签名消息。
3.Bob 关闭支付通道,从合约中提取他应得的金额,同时剩余的资金返还给 Alice。
2. 说明
在上述流程中,只有 步骤 1 和 步骤 3 涉及链上交易,步骤 2 完全在线下通过加密签名完成。因此,不论进行多少次支付,该流程只需要两次链上交互。
智能合约确保 Bob 能够收到付款:它托管了 Alice 存入的资金,并在收到 Alice 有效签名消息时向 Bob 支付相应金额。此外,为避免 Bob 拒绝关闭通道,合约还设置了一个过期时间,在到期后 Alice 可取回所有剩余资金。
支付通道的持续时间由参与双方设定。例如,在网吧按分钟计费的场景下,通道可能只需开放几小时;而在按月薪支付员工的场景中,支付通道可以持续开放几个月甚至更长时间。
3. 打开支付通道
为了开启支付通道,Alice 会部署一个智能合约,并在部署时向其中转入一定量的以太币。她还需在部署时指定:
- 接收方地址(即 Bob);
- 支付通道的最长期限。
后文将展示该逻辑的完整实现(SimplePaymentChannel 合约)。
4. 执行支付
Alice 通过发送签名消息的方式向 Bob 支付,以太坊网络对此过程无感,也不会产生任何交易费用。该消息由 Alice 离线签名后直接发送给 Bob(例如通过电子邮件、聊天软件等方式)。
每条签名消息包含:
- 合约地址:用于防止跨合约重放攻击;
- 累计支付金额:代表 Alice 截至该消息所累计支付给 Bob 的总金额(而非单笔支付)。
由于整个支付过程只在最后兑现一次,因此合约只会根据 Bob 提交的最后一条有效签名消息释放资金。Bob 会选择其中金额最大的一条,以确保他获得最大收益。
这一设计无需为每条消息设置 nonce,因为合约始终只认可一条最终消息。但为了防止签名消息被用于其他支付通道,合约地址必须包含在签名内容中。
合约地址的引入可确保消息仅适用于特定的支付通道。只有当签名中包含的合约地址与当前合约一致时,签名才会被识别为有效,并用于最终支付。这一机制有效防止了签名消息在其他通道中被滥用或伪造。
示例如下:
1.Alice 决定与 Bob 进行一系列交易,并希望通过支付通道来完成。她在以太坊网络上部署了一个智能合约,并调用了合约中的 SimplePaymentChannel 函数。
2.在部署合约时,Alice 向合约中存入了一定数量的以太币(例如 100 个 ETH),这部分资金将用于支付给 Bob。
3.Alice 指定 Bob 为接收方,并设定了支付通道的最长存续时间为 30 天。
4.Alice 和 Bob 事先约定,第一次交易中,Bob 将为 Alice 提供价值 20 个 ETH 的服务。
5.Alice 在以太坊网络外生成并加密签名一条消息。该消息包含以下信息:
-
智能合约地址(防止跨合约重放攻击)。
-
到目前为止欠 Bob 的总金额(20 个 ETH)。
6.Alice 将这条签名信息直接发送给 Bob。
7.随后,Bob 为 Alice 提供了价值 15 个 ETH 的服务,使 Alice 欠 Bob 的总金额增至 35 个 ETH。
8.Alice 对新一条消息进行加密签名。该消息包括:
-
智能合约地址。
-
当前欠 Bob 的总金额(35 个 ETH)。
9.Alice 将这条签名信息发送给 Bob。
10.在支付通道存在期间,Alice 和 Bob 继续进行交易。每次交易后,Alice 都会根据新的欠款总金额,生成并发送新的签名信息。
11.最终,Alice 和 Bob 完成了所有交易,或者支付通道的最大存续时间 30 天到期。
12.Bob 会查看 Alice 发给他的所有签名信息,并选择其中欠款金额最高的一条签名信息(假设是最后一次交易时 Alice 发送的,欠款金额为 80 个 ETH)。
13.Bob 使用这条签名信息向智能合约发起赎回操作。智能合约验证签名信息和其他条件(如通道是否在有效期内等)。如果验证通过,合约将从托管的 100 个 ETH 中支付 80 个 ETH 给 Bob,并关闭支付通道。
通过这种方式,Alice 和 Bob 可以在没有链上交易费用和延迟的情况下,完成多次以太币的转账,最终通过一笔链上交易进行结算。
下面是修改后的 JavaScript 代码,用于对支付消息进行加密签名:
// 构造支付消息
function constructPaymentMessage(contractAddress, amount) {
return abi.soliditySHA3(
["address", "uint256"],
[contractAddress, amount]
);
}
// 对消息进行签名
function signMessage(message, callback) {
web3.eth.personal.sign(
"0x" + message.toString("hex"),
web3.eth.defaultAccount,
callback
);
}
// 进行支付签名
// contractAddress(合约地址)用于防止跨合约重放攻击。
// amount(金额,以 wei 为单位)指定应该支付的以太币数量。
function signPayment(contractAddress, amount, callback) {
var message = constructPaymentMessage(contractAddress, amount);
signMessage(message, callback);
}
5. 关闭支付通道
当 Bob 准备领取自己的资金时,他需要调用智能合约的 close 函数来关闭支付通道。关闭支付通道后,智能合约会将 Bob 应得的以太币支付给他,同时冻结合约,并将剩余的以太币退还给 Alice。
为了能够关闭支付通道,Bob 需要提供 Alice 签名的支付消息。
智能合约需要验证这条消息是否包含 Alice 的有效签名。这个签名验证过程与 Bob 验证签名时的过程相似,合约中的 isValidSignature 和 recoverSigner 函数将进行相应的验证操作。recoverSigner 函数的实现与之前在 ReceiverPays 合约中的代码相同。
注意: 只有支付通道的接收方(Bob)才能调用 close 函数。这是为了防止 Alice 提交一条金额较低的消息,试图欺骗 Bob 使他无法获得完整的支付金额。如果 Alice 能够关闭支付通道,可能会让 Bob 无法领取全部应得的资金。
6. 关闭支付通道的逻辑
1.智能合约需要确认消息确实是由 Alice 签署的,确保交易的合法性。
2.合约会检查消息中所写的支付金额与实际应支付的金额是否一致。
3.如果验证通过,合约会将应支付的以太币(根据签名消息中的金额)转账给 Bob。
4.智能合约将剩余的以太币(Alice 未支付的部分)退还给她。
7. 通道到期
Bob 可以随时关闭支付通道,但如果他未能及时操作,Alice 也需要一种方式来取回托管的资金。因此,合约会在部署时设置一个过期时间。
一旦支付通道的过期时间到达,Alice 可以调用 claimTimeout 函数来取回她的资金。
注意: 一旦 Alice 调用了 claimTimeout,Bob 将无法再领取任何资金。因此,为了确保自己能够领取应得的款项,Bob 必须在支付通道过期之前尽早关闭通道。如果 Bob 错过了这个时机,资金将被退还给 Alice。
8. 代码示例
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// 可冻结合约基类
contract Frozeable {
bool private _frozen = false;
modifier notFrozen() {
require(!_frozen, "合约已冻结。");
_;
}
function freeze() internal {
_frozen = true;
}
}
// 简单支付通道合约
contract SimplePaymentChannel is Frozeable {
address payable public sender; // 付款方地址
address payable public recipient; // 收款方地址
uint256 public expiration; // 过期时间(如果收款方未关闭通道)
// 构造函数,指定收款方地址和通道持续时间,并存入以太币
constructor (address payable recipientAddress, uint256 duration)
payable
{
sender = payable(msg.sender);
recipient = recipientAddress;
expiration = block.timestamp + duration;
}
/// 收款方可以随时关闭支付通道
/// 需提供付款方签名的金额,合约会支付该金额给收款方,并将剩余资金退还给付款方
function close(uint256 amount, bytes memory signature)
external
notFrozen
{
require(msg.sender == recipient, "只有收款方可以关闭通道。");
require(isValidSignature(amount, signature), "无效签名。");
recipient.transfer(amount);
freeze();
sender.transfer(address(this).balance);
}
/// 付款方可以随时延长支付通道的到期时间
function extend(uint256 newExpiration)
external
notFrozen
{
require(msg.sender == sender, "只有付款方可以延长通道时间。");
require(newExpiration > expiration, "新过期时间必须晚于当前过期时间。");
expiration = newExpiration;
}
/// 如果在过期时间内收款方未关闭通道,则付款方可以取回资金
function claimTimeout()
external
notFrozen
{
require(block.timestamp >= expiration, "支付通道未过期。");
freeze();
sender.transfer(address(this).balance);
}
/// 验证提供的签名是否有效
function isValidSignature(uint256 amount, bytes memory signature)
internal
view
returns (bool)
{
bytes32 message = prefixed(keccak256(abi.encodePacked(this, amount)));
// 检查签名是否来自付款方
return recoverSigner(message, signature) == sender;
}
/// 以下函数用于处理和验证签名
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65, "签名格式错误。");
assembly {
// 读取签名的 r, s, v 值
r := mload(add(sig, 32))
s := mload(add(sig, 64))
v := byte(0, mload(add(sig, 96)))
}
return (v, r, s);
}
function recoverSigner(bytes32 message, bytes memory sig)
internal
pure
returns (address)
{
(uint8 v, bytes32 r, bytes32 s) = splitSignature(sig);
return ecrecover(message, v, r, s);
}
/// 构造带有前缀的哈希,模拟 `eth_sign` 的行为
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}
如前所述,在实际开发中,手动编写签名验证代码可能容易出错。因此,建议使用经过严格测试的库来处理签名验证和消息恢复。OpenZeppelin 提供的签名验证工具就是一个很好的选择,它是经过安全审计并广泛使用的。
9. 支付验证
由于支付通道中的消息不会立即兑换,收款方(Bob)需要跟踪最新的支付消息,并在关闭支付通道时兑换它。因此,收款方必须自己验证每条消息,否则无法保证最终能够收到款项。
收款方验证消息的步骤:
1.确保消息中包含的智能合约地址与当前的支付通道合约地址匹配,防止跨合约重放攻击。
2.检查消息中声明的支付金额是否符合预期,防止无效或篡改的数据。
3.确保支付金额没有超过智能合约中托管的以太币总额,防止超额支付。
4.检查签名的有效性,并确保签名确实来自付款方(Alice),防止伪造签名。
我们将使用 ethereumjs-util 库来实现上述支付验证。以下是示例代码,该代码复用了前文的 constructPaymentMessage 函数来构造支付消息并进行验证:
// 这个函数模仿了 eth_sign JSON-RPC 方法的前缀行为。
function prefixed(hash) {
return ethereumjs.ABI.soliditySHA3(
["string", "bytes32"],
["\x19Ethereum Signed Message:\n32", hash]
);
}
// 恢复签名者的公钥
function recoverSigner(message, signature) {
var split = ethereumjs.Util.fromRpcSig(signature);
var publicKey = ethereumjs.Util.ecrecover(message, split.v, split.r, split.s);
var signer = ethereumjs.Util.pubToAddress(publicKey).toString("hex");
return signer;
}
// 验证签名是否有效
function isValidSignature(contractAddress, amount, signature, expectedSigner) {
var message = prefixed(constructPaymentMessage(contractAddress, amount));
var signer = recoverSigner(message, signature);
return signer.toLowerCase() ==
ethereumjs.Util.stripHexPrefix(expectedSigner).toLowerCase();
}
模块化合约
通过模块化的方式构建智能合约可以显著降低复杂度,提高代码的可读性,这对于开发和代码审查过程中的错误检测和漏洞修复非常有帮助。如果能够独立地定义和控制每个模块的行为,那么你就可以专注于各模块之间的交互,而不必担心合约中其他部分的复杂交互。
在以下示例中,合约利用了 Balances 库中的 move 方法来检查不同地址之间的余额转移是否符合预期。
Balances 库是一个独立的组件,专门用来精确追踪账户余额。该库能够有效防止出现负余额或溢出的情况,且合约中的所有余额总和始终保持不变,确保合约在生命周期内的资金正确性。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;
// 定义 Balances 库
library Balances {
// 余额转移方法
function move(mapping(address => uint256) storage balances, address from, address to, uint amount) internal {
// 确保发送方余额足够
require(balances[from] >= amount);
// 确保接收方余额不会溢出
require(balances[to] + amount >= balances[to]);
// 执行转账操作
balances[from] -= amount;
balances[to] += amount;
}
}
contract Token {
// 存储每个地址的余额
mapping(address => uint256) balances;
// 使用 Balances 库的方法
using Balances for *;
// 存储批准的转账额度
mapping(address => mapping(address => uint256)) allowed;
// 转账事件
event Transfer(address from, address to, uint amount);
// 授权事件
event Approval(address owner, address spender, uint amount);
// 转账函数
function transfer(address to, uint amount) external returns (bool success) {
// 调用 Balances 库中的 move 函数进行余额转移
balances.move(msg.sender, to, amount);
// 触发转账事件
emit Transfer(msg.sender, to, amount);
return true;
}
// 从授权账户转账函数
function transferFrom(address from, address to, uint amount) external returns (bool success) {
// 确保授权额度足够
require(allowed[from][msg.sender] >= amount);
// 扣除授权额度
allowed[from][msg.sender] -= amount;
// 调用 Balances 库中的 move 函数进行余额转移
balances.move(from, to, amount);
// 触发转账事件
emit Transfer(from, to, amount);
return true;
}
// 授权函数
function approve(address spender, uint tokens) external returns (bool success) {
// 确保没有重复授权
require(allowed[msg.sender][spender] == 0, "");
// 设置授权额度
allowed[msg.sender][spender] = tokens;
// 触发授权事件
emit Approval(msg.sender, spender, tokens);
return true;
}
// 查询某个地址的余额
function balanceOf(address tokenOwner) external view returns (uint balance) {
return balances[tokenOwner];
}
}
854

被折叠的 条评论
为什么被折叠?



