资讯动态

Solidity 微支付通道(Micropayment Channel)实战:基于签名的链下支付与 ecrecover 签名验证

发布时间:2026/9/12 18:18:00 来源:尧图企业网站定制
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 是发送方senderBob 是接收方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 字节因此这个长度前缀始终相同。签什么签名消息必须包含的内容对于一个履行支付的合约被签名的消息必须包含接收方的地址recipients 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-L2984ecrecover 的输出区被放在输入区前 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、vweb3.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 字节rmload(add(sig, 64))读取接下来的 32 字节smload(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——这正是下一部分支付通道要解决的痛点。第二部分编写一个简单的支付通道SimplePaymentChannelAlice 现在构建一个简单但完整的支付通道实现。支付通道利用加密签名使 Ether 的重复转账变得安全、即时且无需交易手续费。什么是支付通道支付通道允许参与者在不发起交易的情况下重复转账 Ether从而避免与交易相关的延迟和费用。本文探讨的是两方Alice 和 Bob之间的简单单向unidirectional支付通道包含三个步骤Alice 用 Ether 为智能合约注资这打开了支付通道Alice 签名指定该 Ether 中应支付给接收方的累计金额的消息。此步骤对每次支付重复执行Bob关闭支付通道提取属于他的那部分 Ether并把剩余部分退还给发送方。Bob 能保证拿到钱因为智能合约托管了 Ether 并兑现有效的签名消息同时智能合约还强制一个超时timeout因此即使接收方拒绝关闭通道Alice 最终也一定能收回自己的资金。通道保持开放多久由参与者自行决定对于短期交易如按分钟支付网吧上网费通道可以只开放很短时间对于周期性支付如按小时支付员工工资通道可以保持开放数月甚至数年。打开支付通道要打开支付通道Alice 部署智能合约时附带被托管的 Ether并指定预期接收方和通道存在的最大时长。这就是SimplePaymentChannel合约中的constructorconstructor (address payable recipientAddress, uint256 duration) payable { sender payable(msg.sender); recipient recipientAddress; expiration block.timestamp duration; }payable关键字使构造函数能够接收并托管 Alice 的 Etherexpiration到期时间被设定为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 与 extendBob 可以随时关闭支付通道但如果他不关闭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.rstecrecover的编译器底层实现作为预编译合约的 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),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价