Solidity 实例
投票
以下合约相当复杂,但展示了 Solidity 的许多功能。它实现了一个投票合约。当然,电子投票的主要问题是如何将投票权分配给正确的人,以及如何防止操纵。我们不会在这里解决所有问题,但至少我们会展示如何实现委托投票,以便在投票结束后,**自动且完全透明**地进行计票。
思路是为每个投票创建一份合约,为每个选项提供一个简短的名称。然后,作为主席的合约创建者将分别授予每个地址投票权。
这些地址背后的个人可以选择自己投票,也可以将他们的投票权委托给一个他们信任的人。
在投票时间结束时,winningProposal()
将返回票数最多的提案。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
/// @title Voting with delegation.
contract Ballot {
// This declares a new complex type which will
// be used for variables later.
// It will represent a single voter.
struct Voter {
uint weight; // weight is accumulated by delegation
bool voted; // if true, that person already voted
address delegate; // person delegated to
uint vote; // index of the voted proposal
}
// This is a type for a single proposal.
struct Proposal {
bytes32 name; // short name (up to 32 bytes)
uint voteCount; // number of accumulated votes
}
address public chairperson;
// This declares a state variable that
// stores a `Voter` struct for each possible address.
mapping(address => Voter) public voters;
// A dynamically-sized array of `Proposal` structs.
Proposal[] public proposals;
/// Create a new ballot to choose one of `proposalNames`.
constructor(bytes32[] memory proposalNames) {
chairperson = msg.sender;
voters[chairperson].weight = 1;
// For each of the provided proposal names,
// create a new proposal object and add it
// to the end of the array.
for (uint i = 0; i < proposalNames.length; i++) {
// `Proposal({...})` creates a temporary
// Proposal object and `proposals.push(...)`
// appends it to the end of `proposals`.
proposals.push(Proposal({
name: proposalNames[i],
voteCount: 0
}));
}
}
// Give `voter` the right to vote on this ballot.
// May only be called by `chairperson`.
function giveRightToVote(address voter) external {
// If the first argument of `require` evaluates
// to `false`, execution terminates and all
// changes to the state and to Ether balances
// are reverted.
// This used to consume all gas in old EVM versions, but
// not anymore.
// It is often a good idea to use `require` to check if
// functions are called correctly.
// As a second argument, you can also provide an
// explanation about what went wrong.
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;
}
/// Delegate your vote to the voter `to`.
function delegate(address to) external {
// assigns reference
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.");
// Forward the delegation as long as
// `to` also delegated.
// In general, such loops are very dangerous,
// because if they run too long, they might
// need more gas than is available in a block.
// In this case, the delegation will not be executed,
// but in other situations, such loops might
// cause a contract to get "stuck" completely.
while (voters[to].delegate != address(0)) {
to = voters[to].delegate;
// We found a loop in the delegation, not allowed.
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) {
// If the delegate already voted,
// directly add to the number of votes
proposals[delegate_.vote].voteCount += sender.weight;
} else {
// If the delegate did not vote yet,
// add to her weight.
delegate_.weight += sender.weight;
}
}
/// Give your vote (including votes delegated to you)
/// to proposal `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;
// If `proposal` is out of the range of the array,
// this will throw automatically and revert all
// changes.
proposals[proposal].voteCount += sender.weight;
}
/// @dev Computes the winning proposal taking all
/// previous votes into account.
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;
}
}
}
// Calls winningProposal() function to get the index
// of the winner contained in the proposals array and then
// returns the name of the winner
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 {
// Parameters of the auction. Times are either
// absolute unix timestamps (seconds since 1970-01-01)
// or time periods in seconds.
address payable public beneficiary;
uint public auctionEndTime;
// Current state of the auction.
address public highestBidder;
uint public highestBid;
// Allowed withdrawals of previous bids
mapping(address => uint) pendingReturns;
// Set to true at the end, disallows any change.
// By default initialized to `false`.
bool ended;
// Events that will be emitted on changes.
event HighestBidIncreased(address bidder, uint amount);
event AuctionEnded(address winner, uint amount);
// Errors that describe failures.
// The triple-slash comments are so-called natspec
// comments. They will be shown when the user
// is asked to confirm a transaction or
// when an error is displayed.
/// The auction has already ended.
error AuctionAlreadyEnded();
/// There is already a higher or equal bid.
error BidNotHighEnough(uint highestBid);
/// The auction has not ended yet.
error AuctionNotYetEnded();
/// The function auctionEnd has already been called.
error AuctionEndAlreadyCalled();
/// Create a simple auction with `biddingTime`
/// seconds bidding time on behalf of the
/// beneficiary address `beneficiaryAddress`.
constructor(
uint biddingTime,
address payable beneficiaryAddress
) {
beneficiary = beneficiaryAddress;
auctionEndTime = block.timestamp + biddingTime;
}
/// Bid on the auction with the value sent
/// together with this transaction.
/// The value will only be refunded if the
/// auction is not won.
function bid() external payable {
// No arguments are necessary, all
// information is already part of
// the transaction. The keyword payable
// is required for the function to
// be able to receive Ether.
// Revert the call if the bidding
// period is over.
if (block.timestamp > auctionEndTime)
revert AuctionAlreadyEnded();
// If the bid is not higher, send the
// Ether back (the revert statement
// will revert all changes in this
// function execution including
// it having received the Ether).
if (msg.value <= highestBid)
revert BidNotHighEnough(highestBid);
if (highestBid != 0) {
// Sending back the Ether by simply using
// highestBidder.send(highestBid) is a security risk
// because it could execute an untrusted contract.
// It is always safer to let the recipients
// withdraw their Ether themselves.
pendingReturns[highestBidder] += highestBid;
}
highestBidder = msg.sender;
highestBid = msg.value;
emit HighestBidIncreased(msg.sender, msg.value);
}
/// Withdraw a bid that was overbid.
function withdraw() external returns (bool) {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// It is important to set this to zero because the recipient
// can call this function again as part of the receiving call
// before `send` returns.
pendingReturns[msg.sender] = 0;
// msg.sender is not of type `address payable` and must be
// explicitly converted using `payable(msg.sender)` in order
// use the member function `send()`.
if (!payable(msg.sender).send(amount)) {
// No need to call throw here, just reset the amount owing
pendingReturns[msg.sender] = amount;
return false;
}
}
return true;
}
/// End the auction and send the highest bid
/// to the beneficiary.
function auctionEnd() external {
// It is a good guideline to structure functions that interact
// with other contracts (i.e. they call functions or send Ether)
// into three phases:
// 1. checking conditions
// 2. performing actions (potentially changing conditions)
// 3. interacting with other contracts
// If these phases are mixed up, the other contract could call
// back into the current contract and modify the state or cause
// effects (ether payout) to be performed multiple times.
// If functions called internally include interaction with external
// contracts, they also have to be considered interaction with
// external contracts.
// 1. Conditions
if (block.timestamp < auctionEndTime)
revert AuctionNotYetEnded();
if (ended)
revert AuctionEndAlreadyCalled();
// 2. Effects
ended = true;
emit AuctionEnded(highestBidder, highestBid);
// 3. Interaction
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;
// Allowed withdrawals of previous bids
mapping(address => uint) pendingReturns;
event AuctionEnded(address winner, uint highestBid);
// Errors that describe failures.
/// The function has been called too early.
/// Try again at `time`.
error TooEarly(uint time);
/// The function has been called too late.
/// It cannot be called after `time`.
error TooLate(uint time);
/// The function auctionEnd has already been called.
error AuctionEndAlreadyCalled();
// Modifiers are a convenient way to validate inputs to
// functions. `onlyBefore` is applied to `bid` below:
// The new function body is the modifier's body where
// `_` is replaced by the old function body.
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;
}
/// Place a blinded bid with `blindedBid` =
/// keccak256(abi.encodePacked(value, fake, secret)).
/// The sent ether is only refunded if the bid is correctly
/// revealed in the revealing phase. The bid is valid if the
/// ether sent together with the bid is at least "value" and
/// "fake" is not true. Setting "fake" to true and sending
/// not the exact amount are ways to hide the real bid but
/// still make the required deposit. The same address can
/// place multiple bids.
function bid(bytes32 blindedBid)
external
payable
onlyBefore(biddingEnd)
{
bids[msg.sender].push(Bid({
blindedBid: blindedBid,
deposit: msg.value
}));
}
/// Reveal your blinded bids. You will get a refund for all
/// correctly blinded invalid bids and for all bids except for
/// the totally highest.
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))) {
// Bid was not actually revealed.
// Do not refund deposit.
continue;
}
refund += bidToCheck.deposit;
if (!fake && bidToCheck.deposit >= value) {
if (placeBid(msg.sender, value))
refund -= value;
}
// Make it impossible for the sender to re-claim
// the same deposit.
bidToCheck.blindedBid = bytes32(0);
}
payable(msg.sender).transfer(refund);
}
/// Withdraw a bid that was overbid.
function withdraw() external {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// It is important to set this to zero because the recipient
// can call this function again as part of the receiving call
// before `transfer` returns (see the remark above about
// conditions -> effects -> interaction).
pendingReturns[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
}
/// End the auction and send the highest bid
/// to the beneficiary.
function auctionEnd()
external
onlyAfter(revealEnd)
{
if (ended) revert AuctionEndAlreadyCalled();
emit AuctionEnded(highestBidder, highestBid);
ended = true;
beneficiary.transfer(highestBid);
}
// This is an "internal" function which means that it
// can only be called from the contract itself (or from
// derived contracts).
function placeBid(address bidder, uint value) internal
returns (bool success)
{
if (value <= highestBid) {
return false;
}
if (highestBidder != address(0)) {
// Refund the previously highest bidder.
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 }
// The state variable has a default value of the first member, `State.created`
State public state;
modifier condition(bool condition_) {
require(condition_);
_;
}
/// Only the buyer can call this function.
error OnlyBuyer();
/// Only the seller can call this function.
error OnlySeller();
/// The function cannot be called at the current state.
error InvalidState();
/// The provided value has to be even.
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();
// Ensure that `msg.value` is an even number.
// Division will truncate if it is an odd number.
// Check via multiplication that it wasn't an odd number.
constructor() payable {
seller = payable(msg.sender);
value = msg.value / 2;
if ((2 * value) != msg.value)
revert ValueNotEven();
}
/// Abort the purchase and reclaim the ether.
/// Can only be called by the seller before
/// the contract is locked.
function abort()
external
onlySeller
inState(State.Created)
{
emit Aborted();
state = State.Inactive;
// We use transfer here directly. It is
// reentrancy-safe, because it is the
// last call in this function and we
// already changed the state.
seller.transfer(address(this).balance);
}
/// Confirm the purchase as buyer.
/// Transaction has to include `2 * value` ether.
/// The ether will be locked until confirmReceived
/// is called.
function confirmPurchase()
external
inState(State.Created)
condition(msg.value == (2 * value))
payable
{
emit PurchaseConfirmed();
buyer = payable(msg.sender);
state = State.Locked;
}
/// Confirm that you (the buyer) received the item.
/// This will release the locked ether.
function confirmReceived()
external
onlyBuyer
inState(State.Locked)
{
emit ItemReceived();
// It is important to change the state first because
// otherwise, the contracts called using `send` below
// can call in again here.
state = State.Release;
buyer.transfer(value);
}
/// This function refunds the seller, i.e.
/// pays back the locked funds of the seller.
function refundSeller()
external
onlySeller
inState(State.Release)
{
emit SellerRefunded();
// It is important to change the state first because
// otherwise, the contracts called using `send` below
// can call in again here.
state = State.Inactive;
seller.transfer(3 * value);
}
}
微支付渠道
在本节中,我们将学习如何构建支付渠道的示例实现。它使用密码签名来确保同一双方之间多次以太币转账安全、即时且无需交易费用。对于这个例子,我们需要了解如何签名和验证签名,以及如何设置支付渠道。
创建和验证签名
假设爱丽丝想给鲍勃发送一些以太币,即爱丽丝是发送方,鲍勃是接收方。
爱丽丝只需要将密码签名消息发送给鲍勃(例如通过电子邮件),就像写支票一样。
爱丽丝和鲍勃使用签名来授权交易,这可以通过以太坊上的智能合约实现。爱丽丝将构建一个简单的智能合约,让她可以传输以太币,但不是自己调用一个函数来启动支付,而是让鲍勃来做,因此支付交易费用。
合约的工作原理如下
爱丽丝部署
ReceiverPays
合约,并附加上足够的以太币来支付即将进行的支付。爱丽丝使用她的私钥签署一条消息来授权支付。
爱丽丝将密码签名消息发送给鲍勃。消息不需要保密(稍后解释),发送机制也不重要。
鲍勃通过向智能合约展示签名消息来认领他的支付,合约会验证消息的真实性,然后释放资金。
创建签名
爱丽丝不需要与以太坊网络交互来签署交易,这个过程完全是离线的。在本教程中,我们将使用 web3.js 和 MetaMask 在浏览器中签署消息,使用的是 EIP-712 中描述的方法,因为它提供了许多其他安全优势。
/// Hashing first makes things easier
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 字节长,因此这个长度前缀总是相同的。
签名内容
对于执行支付的合约,签名消息必须包含
接收方的地址。
要转账的金额。
防止重放攻击。
重放攻击是指重复使用签名消息来授权第二次操作。为了避免重放攻击,我们使用与以太坊交易本身相同的技术,即所谓的 nonce,它表示一个账户发送的交易数量。智能合约会检查 nonce 是否被重复使用。
另一种类型的重放攻击可能发生在所有者部署了 ReceiverPays
智能合约,进行了一些支付,然后销毁了合约之后。后来,他们决定再次部署 RecipientPays
智能合约,但新合约不知道上一次部署中使用的 nonce,因此攻击者可以再次使用旧消息。
爱丽丝可以通过在消息中包含合约的地址来防止这种攻击,只有包含合约地址本身的消息才会被接受。你可以在本节末尾的完整合约中 claimPayment()
函数的前两行中找到一个示例。
此外,我们不会通过调用 selfdestruct
来销毁合约(当前已弃用),而是通过冻结合约来禁用合约的功能,从而导致在合约被冻结后任何调用都将被回滚。
打包参数
现在我们已经确定了要包含在签名消息中的信息,我们就可以将消息组合在一起、对其进行哈希运算,并对其进行签名。为了简单起见,我们将数据串联起来。 ethereumjs-abi 库提供了一个名为 soliditySHA3
的函数,它模仿了 Solidity 的 keccak256
函数的行为,该函数应用于使用 abi.encodePacked
编码的参数。以下是一个 JavaScript 函数,用于为 ReceiverPays
示例创建正确的签名
// recipient is the address that should be paid.
// amount, in wei, specifies how much ether should be sent.
// nonce can be any unique number to prevent replay attacks
// contractAddress is used to prevent cross-contract replay attacks
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 签名由两个参数组成,r
和 s
。以太坊中的签名包含第三个参数,称为 v
,您可以使用它来验证哪个账户的私钥用于签署消息,以及交易的发送者。Solidity 提供了一个内置函数 ecrecover,它接受一条消息以及 r
、s
和 v
参数,并返回用于签署消息的地址。
提取签名参数
由 web3.js 生成的签名是 r
、s
和 v
的串联,因此第一步是将这些参数分开。您可以在客户端执行此操作,但在智能合约中执行此操作意味着您只需要发送一个签名参数,而不是三个。将字节数组拆分为其组成部分是一件很麻烦的事情,因此我们在 splitSignature
函数中使用 内联汇编 来完成这项工作(本节末尾的完整合约中的第三个函数)。
计算消息哈希
智能合约需要确切地知道签署了哪些参数,因此它必须从参数中重新创建消息并将其用于签名验证。函数 prefixed
和 recoverSigner
在 claimPayment
函数中执行此操作。
完整的合约
// 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;
// this recreates the message that was signed on the client
bytes32 message = prefixed(keccak256(abi.encodePacked(msg.sender, amount, nonce, this)));
require(recoverSigner(message, signature) == owner);
payable(msg.sender).transfer(amount);
}
/// freeze the contract and reclaim the leftover funds.
function shutdown()
external
notFrozen
{
require(msg.sender == owner);
freeze();
payable(msg.sender).transfer(address(this).balance);
}
/// signature methods.
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65);
assembly {
// first 32 bytes, after the length prefix.
r := mload(add(sig, 32))
// second 32 bytes.
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes).
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);
}
/// builds a prefixed hash to mimic the behavior of eth_sign.
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}
编写一个简单的支付通道
Alice 现在构建了一个简单但完整的支付通道实现。支付通道使用加密签名以安全、即时且无需交易费用地重复转账以太币。
什么是支付通道?
支付通道允许参与者重复转账以太币,而无需使用交易。这意味着您可以避免与交易相关的延迟和费用。我们将探讨两个参与者(Alice 和 Bob)之间的简单单向支付通道。它涉及三个步骤
Alice 用以太币资助一个智能合约。这将“打开”支付通道。
Alice 签署消息,指定欠收款人的以太币金额。此步骤会针对每次付款重复执行。
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 is used to prevent cross-contract replay attacks.
// amount, in wei, specifies how much Ether should be sent.
function signPayment(contractAddress, amount, callback) {
var message = constructPaymentMessage(contractAddress, amount);
signMessage(message, callback);
}
关闭支付通道
当 Bob 准备好接收资金时,就可以通过调用智能合约上的 close
函数来关闭支付通道。关闭通道会将收款人应得的以太币支付给他们,并通过冻结合约来停用合约,并将任何剩余的以太币返还给 Alice。为了关闭通道,Bob 需要提供由 Alice 签名的消息。
智能合约必须验证消息是否包含来自发送者的有效签名。执行此验证的过程与收款人使用的过程相同。Solidity 函数 isValidSignature
和 recoverSigner
的工作方式与其上一节中的 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; // The account sending payments.
address payable public recipient; // The account receiving the payments.
uint256 public expiration; // Timeout in case the recipient never closes.
constructor (address payable recipientAddress, uint256 duration)
payable
{
sender = payable(msg.sender);
recipient = recipientAddress;
expiration = block.timestamp + duration;
}
/// the recipient can close the channel at any time by presenting a
/// signed amount from the sender. the recipient will be sent that amount,
/// and the remainder will go back to the sender
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);
}
/// the sender can extend the expiration at any time
function extend(uint256 newExpiration)
external
notFrozen
{
require(msg.sender == sender);
require(newExpiration > expiration);
expiration = newExpiration;
}
/// if the timeout is reached without the recipient closing the channel,
/// then the Ether is released back to the sender.
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)));
// check that the signature is from the payment sender
return recoverSigner(message, signature) == sender;
}
/// All functions below this are just taken from the chapter
/// 'creating and verifying signatures' chapter.
function splitSignature(bytes memory sig)
internal
pure
returns (uint8 v, bytes32 r, bytes32 s)
{
require(sig.length == 65);
assembly {
// first 32 bytes, after the length prefix
r := mload(add(sig, 32))
// second 32 bytes
s := mload(add(sig, 64))
// final byte (first byte of the next 32 bytes)
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);
}
/// builds a prefixed hash to mimic the behavior of eth_sign.
function prefixed(bytes32 hash) internal pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
}
}
注意
函数 splitSignature
不会使用所有安全检查。真正的实现应该使用经过更严格测试的库,例如 openzepplin 的 代码版本。
验证付款
与上一节不同,支付通道中的消息不会立即兑换。收款人会跟踪最新的消息,并在需要关闭支付通道时兑换它。这意味着收款人必须对每条消息进行自己的验证至关重要。否则,无法保证收款人最终能够获得报酬。
收款人应使用以下过程验证每条消息
验证消息中的合约地址是否与支付通道匹配。
验证新的总额是否为预期金额。
验证新的总额是否没有超过托管的以太币金额。
验证签名是否有效,并且来自支付通道发送者。
我们将使用 ethereumjs-util 库来编写此验证。最后一步可以使用多种方法完成,我们使用 JavaScript。以下代码借用了上述签名JavaScript 代码中的 constructPaymentMessage
函数
// this mimics the prefixing behavior of the eth_sign JSON-RPC method.
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();
}
模块化合约
采用模块化方法构建合约有助于降低复杂度并提高可读性,从而帮助在开发和代码审查期间识别错误和漏洞。如果以隔离的方式指定和控制每个模块的行为,则您需要考虑的交互仅限于模块规范之间的交互,而不是合约中所有其他活动部分之间的交互。在下面的示例中,合约使用 move
方法以及 Balances
库 来检查地址之间发送的余额是否与您预期的一致。通过这种方式,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];
}
}