Solidity 微支付通道(Micropayment Channel)实战:基于签名的链下支付与 ecrecover 签名验证
【免费下载链接】soliditySolidity, the Smart Contract Programming Language项目地址: https://gitcode.com/GitHub_Trending/so/solidity
本文是 Solidity 官方文档 docs/examples/micropayment.rst 的深度技术解读与实战指南。文章围绕两个核心主题展开:如何创建并验证加密签名(ReceiverPays合约),以及如何构建一个完整的单向微支付通道(SimplePaymentChannel合约),并在每一步结合 Solidity 编译器源码(libsolidity/codegen/ExpressionCompiler.cpp)与官方文档(docs/units-and-global-variables.rst、docs/assembly.rst)说明底层原理。读完本文,你将掌握 ECDSA 签名(r/s/v)的生成与链上恢复、ecrecover与abi.encodePacked的正确用法、防止重放攻击(replay attack)的完整策略,以及如何用仅两笔链上交易支撑任意次数的链下转账。
支付通道的核心思想:用签名替代交易
在以太坊上,每一笔普通转账都是一次链上交易,需要支付 Gas 并等待确认。微支付通道(Micropayment Channel)改变了这一模式:参与者之间通过加密签名进行链下转账,只有开通道和关通道两笔交易真正上链。
设想 Alice 要向 Bob 支付:Alice 是发送方(sender),Bob 是接收方(recipient)。Alice 只需在链下(例如通过电子邮件)向 Bob 发送经加密签名的消息,这与"写支票"非常相似。双方使用签名来授权交易——这是智能合约在以太坊上实现的能力:
- Alice 部署
ReceiverPays合约,并附上足够的 Ether 以覆盖将要支付的款项; - Alice 用她的私钥对一条消息签名,以此授权一笔支付;
- Alice 将签名后的消息发送给 Bob。消息本身无需保密(原因下文解释),发送机制也不重要;
- Bob 向智能合约出示签名消息来申领款项,合约验证消息的真实性后释放资金。
注意第 4 步:由 Bob 调用合约函数来触发转账,因此 Gas 费用由 Bob 承担,Alice 只负责链下签名。
这种模式带来的核心收益是:只有步骤 1 和 3 需要以太坊交易,步骤 2 意味着发送方通过链下方式(如电子邮件)向接收方传递加密签名的消息。因此只需两笔交易即可支撑任意次数的转账。
第一部分:创建与验证签名(ReceiverPays)
在动手实现支付通道之前,需要先掌握签名(signature)的创建与验证。这一部分先讲解一个较简单的ReceiverPays合约。
创建签名:完全离线的浏览器签名
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 字节,因此这个长度前缀始终相同。
签什么:签名消息必须包含的内容
对于一个履行支付的合约,被签名的消息必须包含:
- 接收方的地址(recipient's address);
- 要转账的金额(the amount to be transferred);
- 防止重放攻击的保护(protection against replay attacks)。
重放攻击(replay attack)指重复使用一条已签名的消息来授权第二次操作。为了避免重放攻击,我们使用与以太坊交易本身相同的技术——nonce,即某个账户已发送的交易数量。智能合约会检查某个 nonce 是否被重复使用。
还存在另一种类型的重放攻击:当 owner 部署了一个ReceiverPays合约、完成若干笔支付后销毁了该合约,之后又再次部署ReceiverPays合约——但新合约并不知道此前部署中已经使用过的 nonce,于是攻击者可以再次使用旧消息。Alice 可以通过在消息中嵌入合约自身的地址来防御此类攻击:只有包含该合约地址本身的消息才会被接受。这一点可见于本节末尾完整合约claimPayment()函数的前几行。
此外,文档强调:与其通过调用selfdestruct来销毁合约(该操作码目前已被弃用,详见 docs/units-and-global-variables.rst),不如通过"冻结"(freezing)来停用合约的功能,冻结后的任何调用都会回滚。
打包参数:构造待签名的消息
确定消息包含的信息后,需要把消息组装起来、做哈希并签名。为简单起见,这里将数据拼接(concatenate)。ethereumjs-abi库提供的soliditySHA3函数,其行为等同于对使用abi.encodePacked编码的参数应用 Solidity 的keccak256函数。以下是创建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); }需要提醒的是,abi.encodePacked对多个动态类型参数进行拼接式编码时存在哈希碰撞的理论风险(多个参数的拼接与单参数等同,参见 docs/types/reference-types.rst 附近的讨论),所以实践中应遵循官方安全建议:不要在同一调用中混用动态类型与静态类型,或在拼接前给动态类型加上长度前缀。示例中的四个参数(address、uint256、uint256、address)均为定长类型,因此abi.encodePacked的使用是安全的。
在 Solidity 中恢复消息签名者:ecrecover
一般而言,ECDSA 签名由两个参数r和s组成。以太坊中的签名还包含第三个参数v,用于验证是哪一账户的私钥签署了消息,以及交易发送者是谁。Solidity 提供了内建函数ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address),它接受一条消息与r、s、v参数,并返回用于签署该消息的地址。其完整签名与说明见 docs/units-and-global-variables.rst。
从编译器源码可以印证ecrecover的底层实现:它并不是一条 EVM 指令,而是调用预编译合约。在 libsolidity/codegen/ExpressionCompiler.cpp 中,ecrecover被建模为FunctionType::Kind::ECRecover,并作为一次外部CALL被编译;由于ecrecover的所有参数都是值类型,其编码方式走标准的encodeToMemory流程。更关键的细节在该文件 L2863-L2870 与 L2977-L2984:ecrecover 的输出区被放在输入区前 32 字节处,且由于 ecrecover 失败时无法被检测到(预编译合约失败返回空/零),编译器会在调用前主动清零输出内存,以便失败时得到明确的零地址结果。这也是为什么代码中总是需要检查recoverSigner(...) == owner而非直接信任返回值。
此外,官方文档对ecrecover给出三点重要警告:
ecrecover返回的是address而非address payable,如需转账须自行转换(payable(...));- 签名可变性(malleability)问题:一条有效签名可以在不改变签名者的情况下被改写成另一条同样有效的签名(将
s翻转为n - s、v取反,n为椭圆曲线阶),因此不要用 ecrecover 的结果来验证消息的唯一性,更稳妥的做法是使用 OpenZeppelin 的 ECDSA helper 库(其对s做了限制); - 在私有链上调用
sha256、ripemd160或ecrecover可能遇到 Out-of-Gas:这些函数是以"预编译合约"形式实现的,只有在收到第一条消息后才"真正存在"(尽管其合约代码是硬编码的)。向不存在的合约发消息成本更高,因此执行可能耗尽 Gas。变通方案是先向这些合约地址各发送 1 wei 再在实际合约中使用它们——主网和测试网不存在此问题。
提取签名参数:用内联汇编拆分 r、s、v
web3.js 生成的签名是r、s、v三者的拼接(共 65 字节)。第一步是把这三个参数拆分开。这可以在客户端完成,但在智能合约内部拆分意味着只需要向合约传递一个签名参数而不是三个。逐字节拆分字节数组很繁琐,因此文档使用内联汇编(inline assembly,语法详见 docs/assembly.rst 附近的说明)在splitSignature函数中完成这项工作。
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); }要点解析:
bytes是动态数组,其内存布局为"长度前缀 + 数据",数据从偏移 32 开始;mload(add(sig, 32))读取偏移 32 处开始的 32 字节,即签名数据的前 32 字节r;mload(add(sig, 64))读取接下来的 32 字节s;mload(add(sig, 96))读取最后一个 32 字节字(第 65 字节位于其首位),再用byte(0, ...)取出该字的第一字节,即v;- 前置的
require(sig.length == 65)保证签名格式合法。
计算消息哈希:prefixed 与 recoverSigner
智能合约必须精确知道被签名的参数是什么,因此它必须从参数重新构造消息并用其进行签名验证。prefixed和recoverSigner两个函数在claimPayment函数中完成这一工作。
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)); }prefixed的作用是模拟eth_signJSON-RPC 方法的行为:web3.eth.personal.sign在内部会对"\x19Ethereum Signed Message:\n32" + hash再次做 keccak256 哈希。因此链上验证时也必须加上同样的前缀并重新哈希,否则恢复出的签名者地址将不匹配。
完整的 ReceiverPays 合约
将以上所有片段组合起来,得到本部分的完整合约。它可被任意多个支付人复用,但受限于 nonce 机制:
// 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); (bool success, ) = payable(msg.sender).call{value: amount}(""); require(success); } /// freeze the contract and reclaim the leftover funds. function shutdown() external notFrozen { require(msg.sender == owner); freeze(); (bool success, ) = payable(msg.sender).call{value: address(this).balance}(""); require(success); } /// 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)); } }设计要点:
claimPayment中签名的消息使用msg.sender作为接收方(而非显式传入 recipient 参数),天然绑定到实际申领人,避免冒领;- 消息中嵌入
this(合约自身地址),防御跨合约重放; usedNonces[nonce]确保每个 nonce 只能用一次;- 该模式下每条消息都需要一笔链上交易来兑现,支付方仍需为每一笔支付承担 Gas——这正是下一部分"支付通道"要解决的痛点。
第二部分:编写一个简单的支付通道(SimplePaymentChannel)
Alice 现在构建一个简单但完整的支付通道实现。支付通道利用加密签名,使 Ether 的重复转账变得安全、即时且无需交易手续费。
什么是支付通道?
支付通道允许参与者在不发起交易的情况下重复转账 Ether,从而避免与交易相关的延迟和费用。本文探讨的是两方(Alice 和 Bob)之间的简单单向(unidirectional)支付通道,包含三个步骤:
- Alice 用 Ether 为智能合约注资,这"打开"了支付通道;
- Alice 签名指定该 Ether 中应支付给接收方的累计金额的消息。此步骤对每次支付重复执行;
- Bob"关闭"支付通道,提取属于他的那部分 Ether,并把剩余部分退还给发送方。
Bob 能保证拿到钱,因为智能合约托管了 Ether 并兑现有效的签名消息;同时智能合约还强制一个超时(timeout),因此即使接收方拒绝关闭通道,Alice 最终也一定能收回自己的资金。通道保持开放多久由参与者自行决定:对于短期交易(如按分钟支付网吧上网费),通道可以只开放很短时间;对于周期性支付(如按小时支付员工工资),通道可以保持开放数月甚至数年。
打开支付通道
要打开支付通道,Alice 部署智能合约时附带被托管的 Ether,并指定预期接收方和通道存在的最大时长。这就是SimplePaymentChannel合约中的constructor:
constructor (address payable recipientAddress, uint256 duration) payable { sender = payable(msg.sender); recipient = recipientAddress; expiration = block.timestamp + duration; }payable关键字使构造函数能够接收并托管 Alice 的 Ether;expiration(到期时间)被设定为block.timestamp + duration,是后续claimTimeout的依据。
进行支付:累计金额与签名
Alice 通过向 Bob 发送签名消息来付款。这一步完全在以太坊网络之外进行:消息由发送方加密签名,然后直接传输给接收方。
每条消息包含以下信息:
- 智能合约的地址:用于防止跨合约重放攻击;
- 到目前为止应支付给接收方的 Ether 累计总额。
为什么是"累计总额"而不是单笔金额?因为支付通道在一系列转账结束时只关闭一次,因此只有其中一条消息会被兑现。每条消息指定的是累计应付总额,接收方自然会选择兑现最新的那条消息——它的总额最高。这里不再需要逐消息的 nonce,因为智能合约只兑现一条消息。合约地址仍然被用于防止某条为特定通道准备的消息被用在另一个通道上。
以下是修改后的 JavaScript 签名代码(相对上一节的signPayment精简了参数):
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函数来关闭支付通道。关闭通道会向接收方支付其应得的 Ether,并通过冻结合约来停用通道,把剩余 Ether 退回给 Alice。要关闭通道,Bob 需要提供一条由 Alice 签名的消息。
智能合约必须验证消息包含发送方的有效签名。验证过程与接收方使用的过程相同——Solidity 函数isValidSignature和recoverSigner的工作方式与上一节中的 JavaScript 对应函数一致,其中recoverSigner直接沿用自ReceiverPays合约。
只有支付通道的接收方才能调用close函数,他自然会传入最新的支付消息(总额最高)。如果允许发送方调用此函数,他可能会提供一笔金额更低的消息,从而欺骗接收方应得的款项。
/// 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)); freeze(); (bool success, ) = recipient.call{value: amount}(""); require(success); (success, ) = sender.call{value: address(this).balance}(""); require(success); }close验证签名消息与给定参数匹配后,向接收方转出其应得部分,并通过低层call把剩余资金退还给发送方。
通道过期:claimTimeout 与 extend
Bob 可以随时关闭支付通道,但如果他不关闭,Alice 需要一种方式收回被托管的资金。合约部署时设定了一个到期时间(expiration)。一旦到达该时间,Alice 可以调用claimTimeout收回资金:
/// 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(); (bool success, ) = sender.call{value: address(this).balance}(""); require(success); }claimTimeout被调用后,Bob 将再也无法收到任何 Ether,因此 Bob 必须在到期之前关闭通道。
作为补充设计,合约还提供了extend函数,允许发送方在任意时刻延长到期时间(例如双方协商延长通道期限):
/// the sender can extend the expiration at any time function extend(uint256 newExpiration) external notFrozen { require(msg.sender == sender); require(newExpiration > expiration); expiration = newExpiration; }完整的 SimplePaymentChannel 合约
// 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)); freeze(); (bool success, ) = recipient.call{value: amount}(""); require(success); (success, ) = sender.call{value: address(this).balance}(""); require(success); } /// 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(); (bool success, ) = sender.call{value: address(this).balance}(""); require(success); } 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)); } }注意与ReceiverPays的差异:
Frozeable的freeze()不再限定只有 owner 能冻结——因为close/claimTimeout均以"谁调用谁冻结"的方式工作,冻结发生在资金转移之前,天然安全;isValidSignature将签名消息构造为abi.encodePacked(this, amount)的 prefixed 哈希,并把恢复出的签名者与sender比对;- 两个完整合约共用同一套
splitSignature/recoverSigner/prefixed工具函数,可视为可复用的签名工具模板。
重要安全提示:文档特别指出,
splitSignature函数并未使用全部安全检查。真实生产实现应当使用经过更严格测试的库,例如 OpenZeppelin contracts 中utils/cryptography/ECDSA.sol的对应代码,它额外处理了s值范围限制(防范签名可变性攻击)与v值校验等问题。
接收方验证:在链下预先验证每条消息
与上一节不同,支付通道中的消息不会立即被兑现:接收方会保存最新消息,并在关闭通道时再兑现。这意味着接收方必须自己验证每一条消息,否则就无法保证最终能拿到钱。
接收方应按以下流程验证每条消息:
- 验证消息中的合约地址与支付通道匹配;
- 验证新的总额是预期金额;
- 验证新的总额不超过被托管的 Ether 数量;
- 验证签名有效且来自支付通道的发送方。
使用ethereumjs-util库实现上述验证(其中第 4 步用 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(); }这段代码与链上 Solidity 验证逻辑一一对应:prefixed复刻eth_sign的前缀行为,ethereumjs.Util.ecrecover对应链上的ecrecover预编译合约,而最终比较的expectedSigner就是通道的sender地址。
安全要点与最佳实践总结
综合文档与仓库中的佐证材料,本教程涉及的签名方案可以提炼出以下必须遵守的安全原则:
- 消息必须包含防重放字段:
ReceiverPays用 nonce 逐笔防重放,SimplePaymentChannel用"只兑现一条消息 + 累计金额"的机制,二者择一; - 消息必须绑定合约地址:在消息中嵌入
this/contractAddress,防止签名被用于其他合约实例(跨合约重放); - 链上链下哈希必须一致:双方都必须遵循
"\x19Ethereum Signed Message:\n32"前缀规则(prefixed),否则ecrecover恢复出的地址会不匹配; - 警惕
ecrecover的签名可变性:不要依赖 ecrecover 结果做消息唯一性判断,生产代码应使用经过审计的 ECDSA 封装库(如 OpenZeppelin); - 正确处理低层调用的返回值:合约中使用
(bool success, ) = ...call{value: ...}(""); require(success);显式检查转账成功与否,避免静默失败; - 用冻结代替自毁:
selfdestruct已被弃用(见 docs/units-and-global-variables.rst),本教程统一采用notFrozen修饰符 +freeze()的冻结模式来停用合约,任何后续调用都会回滚。
进一步阅读
- 签名验证函数
ecrecover的完整说明与警告:docs/units-and-global-variables.rst - 内联汇编语法与内存布局:docs/assembly.rst
ecrecover的编译器底层实现(作为预编译合约的 CALL 调用):libsolidity/codegen/ExpressionCompiler.cpp- 本教程其他 Solidity 官方示例:盲拍 docs/examples/blind-auction.rst、安全远程购 docs/examples/safe-remote.rst、投票 docs/examples/voting.rst、模块化 docs/examples/modular.rst
【免费下载链接】soliditySolidity, the Smart Contract Programming Language项目地址: https://gitcode.com/GitHub_Trending/so/solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考