小额支付渠道
在本节中,我们将学习如何构建支付渠道的示例实现。它使用加密签名在同一方之间的重复以太币转移安全、即时且无需交易费用。
例如,我们需要了解如何签名和验证签名,以及设置支付渠道。
创建和验证签名
假设 Alice 想向 Bob 发送一些 Ether,即 Alice 是发送者,Bob 是接收者。
Alice 只需要在链下(例如通过电子邮件)向 Bob 发送加密签名的消息,这类似于写支票。
Alice 和 Bob 使用签名来授权交易,这可以通过以太坊上的智能合约实现。Alice 将构建一个简单的智能合约,让她传输 Ether,但她不会自己调用函数来发起支付,而是让 Bob 这样做,从而支付交易费用。
该合同将按以下方式运作:
爱丽丝部署
ReceiverPays
合约,附加足够的以太币来支付将要支付的款项。Alice 通过使用她的私钥签署消息来授权付款。
Alice 将加密签名的消息发送给 Bob。消息不需要保密(稍后解释),发送它的机制无关紧要。
Bob 通过向智能合约展示签名消息来索取他的付款,它会验证消息的真实性,然后释放资金。
创建签名
Alice 不需要与以太坊网络交互来签署交易,这个过程是完全离线的。在本教程中,我们将使用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 字节长,因此这个长度前缀总是相同的。
签署什么
对于履行付款的合同,签署的消息必须包括:
收件人的地址。
要转移的金额。
防止重放攻击。
重放攻击是指重复使用已签名的消息来声明第二个操作的授权。为了避免重放攻击,我们使用与以太坊交易本身相同的技术,即所谓的随机数,即账户发送的交易数量。智能合约检查一个随机数是否被多次使用。
ReceiverPays
当所有者部署智能合约,支付一些款项,然后销毁合约时,可能会发生另一种类型的重放攻击。后来,他们决定再次部署RecipientPays
智能合约,但新合约不知道之前部署中使用的随机数,因此攻击者可以再次使用旧消息。
Alice 可以通过在消息中包含合约地址来防止这种攻击,并且只接受包含合约地址本身的消息。claimPayment()
您可以在本节末尾的完整合约函数的前两行中找到一个示例。
打包参数
现在我们已经确定了要包含在签名消息中的信息,我们准备将消息放在一起,散列并签名。为简单起见,我们将数据连接起来。ethereumjs -abi 库提供了一个名为soliditySHA3
模仿 Solidity 函数的行为的函数,该keccak256
函数应用于使用abi.encodePacked
. ReceiverPays
这是一个为示例创建正确签名的
JavaScript 函数:
// 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 ReceiverPays { address owner = msg.sender; mapping(uint256 => bool) usedNonces; constructor() payable {} function claimPayment(uint256 amount, uint256 nonce, bytes memory signature) external { 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); } /// destroy the contract and reclaim the leftover funds. function shutdown() external { require(msg.sender == owner); selfdestruct(payable(msg.sender)); } /// 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 用 Ether 为智能合约提供资金。这“打开”了支付渠道。
爱丽丝签署消息,指定欠接收者多少以太币。每次付款都重复此步骤。
Bob“关闭”支付通道,提取他的部分以太币并将剩余部分发送回发送者。
笔记
只有第 1 步和第 3 步需要以太坊交易,第 2 步意味着发送者通过链下方法(例如电子邮件)向接收者发送加密签名的消息。这意味着只需要两笔交易即可支持任意数量的转账。
Bob 可以保证收到他的资金,因为智能合约托管了以太币并兑现了有效的签名消息。智能合约还强制执行超时,因此即使接收者拒绝关闭通道,爱丽丝也可以保证最终收回她的资金。由支付渠道的参与者决定保持开放多长时间。对于短期交易,例如为每分钟网络访问支付网吧费用,支付渠道可能会在有限的时间内保持开放。另一方面,对于经常性支付,例如支付员工小时工资,支付渠道可能会保持开放数月或数年。
开通支付渠道
为了打开支付通道,Alice 部署了智能合约,附加要托管的 Ether,并指定预期的接收者和通道存在的最长持续时间。这是 SimplePaymentChannel
本节末尾的合约中的功能。
付款
爱丽丝通过向鲍勃发送签名消息来付款。此步骤完全在以太坊网络之外执行。消息由发件人加密签名,然后直接传输给收件人。
每条消息都包含以下信息:
智能合约的地址,用于防止跨合约重放攻击。
到目前为止欠收款人的以太币总量。
在一系列转账结束时,支付通道仅关闭一次。因此,只有一条发送的消息被兑换。这就是为什么每条消息都指定了累积的 Ether 欠款总额,而不是单个小额支付的金额。收件人自然会选择兑换最近的消息,因为那是总数最高的消息。不再需要每条消息的随机数,因为智能合约只接受一条消息。智能合约的地址仍用于防止用于一个支付渠道的消息被用于不同的渠道。
这是修改后的 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
智能合约上的函数来关闭支付通道了。关闭通道会向接收者支付他们所欠的以太币并销毁合约,将剩余的以太币发送回爱丽丝。要关闭通道,Bob 需要提供由 Alice 签名的消息。
智能合约必须验证消息是否包含来自发件人的有效签名。进行此验证的过程与收件人使用的过程相同。Solidity 的功能isValidSignature
和工作方式与上一节中的 JavaScript 对应物一样,后者的功能是从合约中recoverSigner
借用的。ReceiverPays
只有支付渠道接收方可以调用该close
函数,他们自然会传递最新的支付消息,因为该消息携带的总欠款总额最高。如果发件人被允许调用这个函数,他们可以提供一个较低金额的消息,并欺骗收件人他们欠他们的东西。
该函数验证签名消息与给定参数匹配。如果一切顺利,收件人将收到他们的部分以太币,而发件人则通过selfdestruct
. close
您可以在完整的合同中看到该功能。
频道到期
Bob 可以随时关闭支付通道,但如果他们不这样做,Alice 需要一种方法来收回她的托管资金。在合约部署时设置了到期时间。一旦到了那个时间,爱丽丝就可以打电话 claimTimeout
来收回她的资金。claimTimeout
您可以在完整的合同中看到该功能。
调用此函数后,Bob 将无法再接收任何 Ether,因此 Bob 在到期之前关闭通道非常重要。
完整的合同
// SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.7.0 <0.9.0; contract SimplePaymentChannel { 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 { require(msg.sender == recipient); require(isValidSignature(amount, signature)); recipient.transfer(amount); selfdestruct(sender); } /// the sender can extend the expiration at any time function extend(uint256 newExpiration) external { 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 { require(block.timestamp >= expiration); selfdestruct(sender); } 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。以下代码constructPaymentMessage
从上面的签名JavaScript 代码中借用了该函数:
// 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(); }