Solidity 示例

投票

以下合约相当复杂,但展示了许多 Solidity 的特性。它实现了一个投票合约。 当然,电子投票的主要问题是如何将投票权分配给正确的人,以及如何防止操控。 我们不会在这里解决所有问题,但至少我们会展示如何进行委托投票,以便投票计数是 自动且完全透明 的。

这个想法是为每个选票创建一个合约,为每个选项提供一个简短的名称。 然后,作为主席的合约创建者将逐个地址授予投票权。

地址背后的人可以选择自己投票或将他们的投票委托给他们信任的人。

在投票时间结束时,winningProposal() 将返回获得最多票数的提案。

// 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.");
        }

        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.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() 无法记录平局。 你能想到解决这些问题的方法吗?

盲拍

在本节中,我们将展示如何在以太坊上创建一个完全盲拍合约是多么简单。 我们将从一个公开拍卖开始,所有人都可以看到出价,然后将该合约扩展为一个盲拍,在拍卖期间结束之前无法看到实际出价。

简单的公开拍卖

以下简单拍卖合约的一般思路是,所有人可以在拍卖期间发送他们的出价。 出价已经包括发送一些资金,例如以太,以便将竞标者绑定到他们的出价上。 如果最高出价被提高,之前的最高出价者将能够取回他们的以太币。 在拍卖结束后,必须手动调用合约,以便受益人能够收到他们的以太 - 合约无法自我激活。

// 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);

    // Errors 用来定义失败

    // 三斜杠注释是所谓的 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 是必需的,以便函数能够接收以太。

        // 如果拍卖时间已过,则撤销调用。
        if (block.timestamp > auctionEndTime)
            revert AuctionAlreadyEnded();

        // 如果出价不高,则将以太币退回(撤销语句将撤销此函数执行中的所有更改,包括它已接收以太币)。
        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) {
            // 将其设置为零很重要,因为接收者可以在 `send` 返回之前再次调用此函数作为接收调用的一部分。
            pendingReturns[msg.sender] = 0;

            // msg.sender 不是 `address payable` 类型,必须显式转换为 `payable(msg.sender)` 以便使用成员函数 `send()`。
            if (!payable(msg.sender).send(amount)) {
                // 这里不需要调用 throw,只需重置未付款
                pendingReturns[msg.sender] = amount;
                return false;
            }
        }
        return true;
    }

    /// 结束拍卖并将最高出价发送给受益人。
    function auctionEnd() external {
        // 这是一个好的指导原则,将与其他合约交互的函数(即它们调用函数或发送以太)结构化为三个阶段:
        // 1. 检查条件
        // 2. 执行操作(可能更改条件)
        // 3. 与其他合约交互
        // 如果这些阶段混合在一起,其他合约可能会回调当前合约并修改状态或导致效果(以太支付)被多次执行。
        // 如果内部调用的函数包括与外部合约的交互,它们也必须被视为与外部合约的交互。

        // 1. 条件
        if (block.timestamp < auctionEndTime)
            revert AuctionNotYetEnded();
        if (ended)
            revert AuctionEndAlreadyCalled();

        // 2. 生效
        ended = true;
        emit AuctionEnded(highestBidder, highestBid);

        // 3. 交互
        beneficiary.transfer(highestBid);
    }
}

盲拍

之前的公开拍卖在以下内容中扩展为盲拍。 盲拍的优点在于,在拍卖期间结束时没有时间压力。 在透明计算平台上创建盲拍听起来像是自相矛盾,但密码学可以实现它。

拍卖期间,竞标者实际上并不发送他们的出价,而只是发送其哈希版本的出价。 由于目前被认为几乎不可能找到两个(足够长的)值,其哈希值相等,因此竞标者通过此方式承诺出价。 在拍卖结束后,竞标者必须揭示他们的出价:他们以未加密的方式发送他们的值,合约检查哈希值是否与拍卖期间提供的相同。 另一个挑战是如何使拍卖 具有约束力且保密:防止竞标者在赢得拍卖后不发送以太币的唯一方法是让他们在出价时一起发送。 由于以太坊中无法对价值转移进行保密,任何人都可以看到该价值。

以下合约通过接受任何高于最高出价的值来解决此问题。 由于这只能在揭示阶段进行检查,因此某些出价可能是 无效的,这是故意的(它甚至提供了一个显式标志,以便进行高价值转移的无效出价):竞标者可以通过提交多个高或低的无效出价来迷惑竞争对手。

// 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);

    // Errors 用来定义失败

    /// 函数被调用得太早。
    /// 请在 `time` 再试一次。
    error TooEarly(uint time);
    /// 函数被调用得太晚。
    /// 不能在 `time` 之后调用。
    error TooLate(uint time);
    /// 函数 auctionEnd 已经被调用。
    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);
        _;
    }

    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" 不为真,则出价有效。
    /// 将 "fake" 设置为真并发送不准确的金额是隐藏真实出价的方式,但仍然满足所需的存款。
    /// 相同地址可以提交多个出价。
    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) {
            // 将其设置为零是重要的,
            // 因为,作为接收调用的一部分,
            // 接收者可以在 `transfer` 返回之前重新调用该函数。(可查看上面关于“条件 -> 生效 -> 交互”的标注)
            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;
    }
}

安全地远程购买

远程购买商品目前需要多个相互信任的参与方。 最简单的配置涉及卖方和买方。买方希望从卖方那里收到一个物品,而卖方希望获得一些补偿,例如以太币。 这里的问题在于运输:没有办法确定物品是否确实到达了买方手中。

有多种方法可以解决这个问题,但都或多或少存在不足之处。 在以下示例中,双方必须将物品价值的两倍放入合约作为托管。 一旦发生状况,以太币将被锁定在合约中,直到买方确认他们收到了物品。 之后,买方将获得价值(他们存款的一半),而卖方将获得三倍的价值(他们的存款加上价值)。 其背后的想法是双方都有动力来解决这种情况,否则他们的以太币将永远被锁定。

这个合约当然不能解决问题,但概述了如何在合约中使用类似状态机的结构。

// 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.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;
        // 我们在这里直接使用转账。
        // 可用于防止重入,因为它是此函数中的最后一个调用,我们已经改变了状态。
        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);
    }
}

微支付通道

在本节中,我们将学习如何构建一个支付通道的示例实现。 它使用加密签名使得在同一方之间重复转移以太币变得安全、即时且没有交易费用。 对于这个示例,我们需要了解如何签名和验证签名,以及如何设置支付通道。

创建和验证签名

想象一下,Alice 想要向鲍勃发送一些以太币,即 Alice 是发送者,Bob 是接收者。

Alice 只需要向 Bob 发送离线的加密签名消息(例如,通过电子邮件),这类似于开支票。

Alice 和 Bob 使用签名来授权交易,这在以太坊的智能合约中是可能的。 Alice 将构建一个简单的智能合约,让她可以传输以太币,但她不会自己调用函数来发起支付,而是让 Bob 来做,从而支付交易费用。

合约的工作流程如下:

  1. Alice 部署 ReceiverPays 合约,并附加足够的以太币以覆盖将要进行的支付。

  2. Alice 通过用她的私钥签名一条消息来授权支付。

  3. Alice 将加密签名的消息发送给 Bob。该消息不需要保密(稍后解释),发送机制无关紧要。

  4. Bob 通过向智能合约提交签名消息来索取他的支付,合约验证消息的真实性,然后释放资金。

创建签名

Alice 不需要与以太坊网络交互来签署交易,整个过程完全离线。 在本教程中,我们将使用 web3.jsMetaMask 在浏览器中签名消息,使用 EIP-712 中描述的方法,因为它提供了一些其他的安全好处。

///  先计算一个 hash,让事情变得简单
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 字节,因此这个长度前缀始终是相同的。

要签署的内容

对于一个满足支付的合约,签名消息必须包含:

  1. 接收者的地址。

  2. 要转移的金额。

  3. 防止重放攻击的保护。

重放攻击是指重用签名消息以声明第二个操作的授权。 为了避免重放攻击,我们使用与以太坊交易本身相同的技术,即所谓的随机数(nonce),它是由账户发送的交易数量。 智能合约检查随机数是否被多次使用。

另一种重放攻击可能发生在所有者部署 ReceiverPays 智能合约,进行一些支付,然后销毁合约。 之后,他们决定再次部署 RecipientPays 智能合约,但新合约不知道之前部署中使用的随机数,因此攻击者可以再次使用旧消息。

Alice 可以通过在消息中包含合约的地址来保护自己免受此攻击,只有包含合约地址的消息才会被接受。 可以在本节末尾完整合约的 claimPayment() 函数的前两行中找到这个示例。

此外,我们将通过冻结合约来禁用合约的功能,而不是通过调用 selfdestruct 来销毁合约,后者目前已被弃用,这样在冻结后任何调用都将被回滚。

打包参数

现在我们已经确定了要包含在签名消息中的信息,我们准备将消息组合在一起,进行哈希处理并签名。 为了简单起见,我们将数据连接在一起。ethereumjs-abi 库提供了一个名为 soliditySHA3 的函数,它模仿了应用于使用 abi.encodePacked 编码的参数的 Solidity 的 keccak256 函数的行为。 以下是一个创建 ReceiverPays 示例的正确签名的 JavaScript 函数:

// 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);
}

在 Solidity 中还原消息签名者

一般来说,ECDSA 签名由两个参数 rs 组成。 以太坊中的签名包括第三个参数 v,你可以使用它来验证哪个账户的私钥用于签署消息,以及交易的发送者。 Solidity 提供了一个内置函数 ecrecover,它接受一条消息以及 rsv 参数,并返回用于签署消息的地址。

提取签名参数

web3.js 生成的签名是 rsv 的连接,因此第一步是将这些参数分开。 可以在客户端进行此操作,但在智能合约内部进行此操作意味着只需发送一个签名参数而不是三个。 将字节数组拆分为其组成部分是一项繁琐的工作,因此我们在 splitSignature 函数中使用 inline assembly 来完成这项工作(本节末尾完整合约中的第三个函数)。

计算消息的哈希值

智能合约需要确切知道哪些参数被签名,因此它必须从参数中重建消息,并使用该消息进行签名验证。 函数 prefixedrecoverSignerclaimPayment 函数中执行此操作。

完整合约

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract Owned {
    address payable owner;
    constructor() {
        owner = payable(msg.sender);
    }
}

contract Freezable is Owned {
    bool private _frozen = false;

    modifier notFrozen() {
        require(!_frozen, "Inactive Contract.");
        _;
    }

    function freeze() internal {
        if (msg.sender == owner)
            _frozen = true;
    }
}

contract ReceiverPays is Freezable {
    mapping(uint256 => bool) usedNonces;

    constructor() payable {}

    function claimPayment(uint256 amount, uint256 nonce, bytes memory signature)
        external
        notFrozen
    {
        require(!usedNonces[nonce]);
        usedNonces[nonce] = true;

        // 这重建了在客户端签名的消息
        bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, amount, nonce, this)));
        require(recoverSigner(message, signature) == owner);
        payable(msg.sender).transfer(amount);
    }

    /// 冻结合约并回收剩余资金。
    function shutdown()
        external
        notFrozen
    {
        require(msg.sender == owner);
        freeze();
        payable(msg.sender).transfer(address(this).balance);
    }

    /// 签名方法。
    function splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8 v, bytes32 r, bytes32 s)
    {
        require(sig.length == 65);

        assembly {
            // 前 32 个字节,在长度前缀之后。
            r := mload(add(sig, 32))
            // 第二个 32 个字节。
            s := mload(add(sig, 64))
            // 最后一个字节(下一个 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);
    }

    /// 构建一个带前缀的哈希以模仿 eth_sign 的行为。
    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

编写一个简单的支付通道

Alice 现在构建一个简单但完整的支付通道实现。支付通道使用加密签名安全、即时且无交易费用地进行重复的以太币转账。

什么是支付通道?

支付通道允许参与者进行重复的以太币转账,而无需使用交易。这意味着可以避免与交易相关的延迟和费用。 我们将探讨一个简单的单向支付通道,涉及两个参与方(Alice 和 Bob)。它包括三个步骤:

  1. Alice 用以太币为智能合约提供资金。这“打开”了支付通道。

  2. Alice 签署指定欠收款人的以太币金额的消息。此步骤对每笔付款重复进行。

  3. Bob “关闭”支付通道,提取他应得的以太币,并将剩余部分发送回发送者。

备注

只有步骤 1 和 3 需要以太坊交易,步骤 2 意味着发送者通过链下方法(例如电子邮件)向接收者传输加密签名的消息。 这意味着只需要两笔交易即可支持任意数量的转账。

Bob 保证会收到他的资金,因为智能合约托管了以太币并尊重有效的签名消息。 智能合约还强制执行超时,因此即使接收者拒绝关闭通道,Alice 也保证最终能收回她的资金。 支付通道的参与者可以决定保持通道开放的时间长度。 对于短期交易,例如为每分钟的网络访问支付互联网咖啡馆,支付通道可以保持开放有限的时间。 另一方面,对于定期付款,例如按小时支付员工工资,支付通道可以保持开放几个月或几年。

打开支付通道

要打开支付通道,Alice 需要部署智能合约,附上要托管的以太币,并指定预期的接收者和通道存在的最大持续时间。 这是合约中的 SimplePaymentChannel 函数,在本节末尾。

进行支付

Alice 通过 Bob 发送签名消息来进行付款。该步骤完全在以太坊网络之外执行。 消息由发送者进行加密签名,然后直接传输给接收者。

每条消息包括以下信息:

  • 智能合约的地址,用于防止跨合约重放攻击。

  • 到目前为止欠接收者的以太币总额。

支付通道仅在一系列转账结束时关闭一次。因此,只能赎回发送的其中一条消息。 这就是为什么每条消息都指定了应付的以太币累计总额,而不是单个微支付的金额。 接收者自然会选择赎回最新的消息,因为那条消息的总额最高。 每条消息的 nonce 不再需要,因为智能合约只会处理一条消息。 智能合约的地址仍然被用于防止针对一个支付通道的消息被用于不同的通道。

这是修改后的 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);
}

关闭支付通道

当 Bob 准备好接收他的资金时,是时候通过调用智能合约上的 close 函数来关闭支付通道。 关闭通道将支付接收者应得的以太币,并通过冻结合约来停用它,将任何剩余的以太币发送回 Alice。 要关闭通道,Bob 需要提供一条由 Alice 签署的消息。

智能合约必须验证消息是否包含来自发送者的有效签名。进行此验证的过程与接收者使用的过程相同。 Solidity 函数 isValidSignaturerecoverSigner 的工作方式与上一节中的 JavaScript 对应函数相同,后者函数借用自 ReceiverPays 合约。

只有支付通道的接收者可以调用 close 函数,接收者自然会传递最新的付款消息,因为该消息携带最高的付款总额。 如果允许发送者调用此函数,他们可能会提供一条金额较低的消息,从而欺骗接收者,剥夺他们应得的款项。

该函数验证签名消息是否与给定参数匹配。如果一切正常,接收者将收到他们应得的以太币,发送者将通过 transfer 发送剩余资金。 可以在完整合约中查看 close 函数。

通道过期

Bob 可以随时关闭支付通道,但如果他没有这样做,Alice 需要一种方法来恢复她托管的资金。 在合约部署时设置了一个 过期 时间。一旦达到该时间,Alice 可以调用 claimTimeout 来恢复她的资金。 可以在完整合约中查看 claimTimeout 函数。

在调用此函数后,Bob 将无法再接收任何以太币,因此在到期之前,Bob 关闭通道是很重要的。

完整合约

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract Frozeable {
    bool private _frozen = false;

    modifier notFrozen() {
        require(!_frozen, "Inactive Contract.");
        _;
    }

    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 {
            // 前 32 个字节,长度前缀后
            r := mload(add(sig, 32))
            // 第二个 32 个字节
            s := mload(add(sig, 64))
            // 最后一个字节(下一个 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);
    }

    /// 构建一个带前缀的哈希,以模仿 eth_sign 的行为。
    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

备注

函数 splitSignature 没有做足够的安全检查。 例如 openzeppelin 的 版本

验证支付

与上一节不同,支付通道中的消息不会立即兑现。接收者跟踪最新消息,并在关闭支付通道时兑现。 这意味着接收者必须对每条消息进行自己的验证。否则,接收者无法保证最终能够获得支付。

接收者应使用以下过程验证每条消息:

  1. 验证消息中的合约地址是否与支付通道匹配。

  2. 验证新总金额是否为预期金额。

  3. 验证新总金额是否不超过托管的以太币金额。

  4. 验证签名是否有效,并且来自支付通道发送者。

我们将使用 ethereumjs-util 库来编写此验证。 最后一步可以通过多种方式完成,我们使用 JavaScript。 以下代码借用了上面签名 JavaScript 代码 中的 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 librarymove 方法来检查在地址之间发送的余额是否符合预期。 通过这种方式,Balances 库提供了一个隔离的组件,能够正确跟踪账户的余额。 很容易验证 Balances 库从不产生负余额或溢出,并且所有余额的总和在合约的生命周期内是一个不变的量。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

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;
    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(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(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];
    }
}