如果你正在用 Solidity 开发合约,又被链上 Gas 费折磨过,那么“状态通道”这四个字大概率进入过你的视野。它把高频交易搬到链下,只在最终结果确定时做链上结算,是解决区块链扩展性瓶颈的经典思路之一。这篇文章我不打算绕弯子,直接用一个双边支付通道的例子,把链下交易、链上结算、挑战期、签名防重放这些关键环节完整跑一遍,并附上可参考的合约代码和实际部署中容易踩的坑。
适合的读者很明确:已经写过简单 Solidity 合约、想理解“状态通道到底怎么落地”的开发者。如果你只是听说过“通道”但还没亲手写过,跟着这篇文章把最小模型做出来,会比读十篇概念文章更有收获。
1. 为什么状态通道能救扩展性:先搞清楚瓶颈在哪
1.1 链上执行的真实成本
在以太坊这类公链上,每一笔交易都要经过全网节点传播、验证、执行,然后永久写入链上状态。这意味着每个节点都要为你的转账付出计算和存储成本,所以 Gas 费不是开发者主动“多付钱”,而是资源定价的结果。
问题是,很多业务场景根本不适合这种模式。比如两个用户之间的小额高频转账,一次转 0.01 ETH,链上手续费可能比这笔钱还贵;再比如游戏里每个回合更新玩家分数,如果每回合都上链,体验完全不可接受。我们说的扩展性瓶颈,不是某个合约写得不好,而是“所有参与者共享同一条链的状态”这个底层约束决定的。
用生活里的例子类比:一群人吃饭,如果每夹一筷子菜都要向全班同学汇报,饭局就变成了表演。正常做法是同桌的人自己记账,最后结账时再把各自应付的钱一次性说清楚。状态通道的思路就跟这个很像。
1.2 状态通道的宏观思路
状态通道不想办法让每一笔交易更便宜,而是让大多数交易根本不上链。它的核心只有两件事:链下交换签名状态,链上执行最终结算。
具体流程是这样的:
- 两个参与者往同一个智能合约里锁定资金,相当于同时把押金放进一个保险箱。
- 之后双方在链下自由交换“经过双方签名的余额分配状态”。每次转账,不是转币,而是生成一条新状态,例如“A 有 0.3 ETH,B 有 1.7 ETH”,然后双方各自签名。
- 当双方想结束通道时,把最后一条双方都签名的状态提交到合约,合约校验签名后按状态金额把锁定的资金分别转给双方。
整个过程里,链上只需要发生两到三次交易:锁定资金、最终关闭。这中间的成千上万次状态更新,都在链下完成,不需要给矿工交手续费,也不需要等待区块确认。
1.3 谁适合用,谁不适合
状态通道不是万能钥匙。它的优势建立在“参与者固定、交互频繁、双方有共同结算意愿”这三个前提上。
适合的场景包括:
- 两个用户之间的大量微支付。
- 一个中心化服务商和固定用户之间的积分结算、订阅计费。
- 双人游戏中频繁的状态更新,比如棋类游戏、回合制对战。
不适合的场景也相当明显。如果你面对的是开放网络,每次都可能和陌生地址交互,状态通道就没法直接用。因为你没法在每次交易前都跟对方部署一个新通道。另外,通道参与者必须有能力监控链上挑战期,如果一方长期离线,可能会被对方用旧状态“偷袭”。
2. 最小可用设计:一个支付通道的状态机长什么样
2.1 通道生命周期的三个阶段
一个支付通道从出生到消亡,可以拆成三个阶段:开启、链下更新、关闭。
| 阶段 | 数据在哪里 | 是否上链 | Gas 成本 | 信任假设 |
|---|---|---|---|---|
| 开启通道 | 链上合约 | 是 | 高 | 双方都先锁定资金 |
| 链下更新 | 双方本地 | 否 | 零 | 双方都遵守 nonce 递增规则 |
| 合作关闭 | 链上合约 | 是 | 低 | 双方都能拿到最新签名状态 |
| 非合作关闭 | 链上合约 | 是 | 中 | 靠挑战期保护诚实方 |
这里最需要理解的是“状态”到底是什么。在公链上,状态通常指账户余额、合约存储这些链上数据。但在状态通道里,链下状态是一份由双方共同签名的结构化数据。合约不会实时看到它,只在关闭时校验并采纳。
2.2 状态里必须有哪些字段
对于最简单的一个双边 ETH 支付通道,状态数据至少需要:
- 双方地址:用来确定通道参与者。
- 双方的余额分配:例如 A 的余额和 B 的余额。
- 一个递增的 nonce:用来排序,确定哪条状态是最新的。
为什么 nonce 这么重要?因为链下状态是双方各自保存的。如果最终结算时允许任意一方随便提交一条旧状态,那另一方就亏了。有了 nonce,合约就能比较两条状态的新旧。在公平的链下协议中,双方只会为同一个 nonce 签一个状态,而且 nonce 只增不减。
2.3 “最新状态”如何被链上合约承认
合约看不到链下双方的消息,所以它只能靠一个聪明但简单的规则:两边都可能提交某一条已签名的状态,合约认为 nonce 更大的状态更可信。
这就是挑战期的由来。诚实方发现问题后,可以在挑战期内提交一条 nonce 更大、更新颖的状态去覆盖旧状态。窗口结束后,合约按照最后被提交的那条有效状态结算。
一个容易忽略的细节是,通道初始状态的 nonce 不应该从 0 开始,而应该从 1 开始。原因后面写合约时会解释:合约默认的 submission nonce 是 0,如果初始状态是 0,第一次提交挑战时会被当成“旧状态”拒绝。
3. Solidity 合约实现:从锁仓到结算的完整代码
这一节我直接给出一个可运行的最简版状态通道合约。它没有引入复杂的依赖,只用了 OpenZeppelin 的 ECDSA 库来恢复签名地址。
3.1 合约存储结构
合约只需要保存四类信息:参与者、锁定总额、挑战期、当前被提交的挑战状态。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; contract StateChannel { using ECDSA for bytes32; address payable[2] public participants; uint256 public totalBalance; uint256 public challengePeriod; bool public joined; bool public settled; struct Submission { uint256 balanceA; uint256 balanceB; uint256 nonce; uint256 deadline; } Submission public submission; event Deposited(address indexed from, uint256 amount); event SubmissionUpdated(uint256 nonce, uint256 balanceA, uint256 balanceB, uint256 deadline); event Settled(uint256 amountA, uint256 amountB); }totalBalance是双方锁定资金之和。settled很关键,它防止合约被重入攻击后二次结算。submission保存的是挑战关闭时“当前最可信”的那条状态。
3.2 开启通道:双方锁定资金
我采用的模式是:A 部署合约时直接发送一部分 ETH,B 之后再调用join()发送自己的那部分。这样可以避免部署前双方都得在同一个交易里协调资金,更接近真实使用场景。
constructor(address payable _counterparty, uint256 _challengePeriod) payable { require(_counterparty != address(0) && _counterparty != msg.sender, "invalid counterparty"); participants = [payable(msg.sender), _counterparty]; challengePeriod = _challengePeriod; totalBalance = 0; } function join() external payable { require(msg.sender == participants[1], "only B"); require(!joined, "already joined"); joined = true; totalBalance = msg.value + address(this).balance; emit Deposited(msg.sender, msg.value); }由于 A 的 ETH 已经在构造函数里进入合约,B 调用join()后,address(this).balance就包含了 A 的资金,所以totalBalance能正确计算。
通道激活后,双方需要立即在链下签署初始状态。初始状态建议是(balanceA = A的存款, balanceB = B的存款, nonce = 1),双方各保存一份带对方签名的状态。这一步不在合约上执行,但它是后续一切链下交易的基础。
3.3 合作关闭:提交最新状态
合作关闭是双方都同意结束通道的情况。任何一方调用close(),只要提交的状态包含双方签名,并且金额总和等于锁定总额,合约就直接转账。
function close( uint256 balanceA, uint256 balanceB, uint256 nonce, bytes calldata sigA, bytes calldata sigB ) external { require(joined, "channel not active"); require(!settled, "already settled"); require(balanceA + balanceB == totalBalance, "invalid total"); bytes32 h = stateHash(balanceA, balanceB, nonce); require(validateSignature(h, sigA, participants[0]), "bad signature A"); require(validateSignature(h, sigB, participants[1]), "bad signature B"); _settle(balanceA, balanceB); }这里没有比较 nonce 大小,因为合作关闭意味着双方共同提交的是最新链下状态。如果一方提交旧状态,另一方只要本地保存了更新状态,就不会配合签名。
3.4 非合作关闭:挑战与应答
非合作关闭是整个状态通道安全性的核心。场景是:一方想用旧状态占便宜,另一方必须能够阻止。
任何一方都可以调用submitChallenge()提交一条双方签名的状态。合约会记录这条状态和它的 deadline。在 deadline 之前,另一方可以调用respondChallenge()提交 nonce 更大的状态,覆盖掉旧状态。
function submitChallenge( uint256 balanceA, uint256 balanceB, uint256 nonce, bytes calldata sigA, bytes calldata sigB ) external { require(joined, "channel not active"); require(!settled, "already settled"); require(balanceA + balanceB == totalBalance, "invalid total"); bytes32 h = stateHash(balanceA, balanceB, nonce); require(validateSignature(h, sigA, participants[0]), "bad signature A"); require(validateSignature(h, sigB, participants[1]), "bad signature B"); require(submission.deadline == 0 || nonce > submission.nonce, "not newer"); submission = Submission({ balanceA: balanceA, balanceB: balanceB, nonce: nonce, deadline: block.timestamp + challengePeriod }); emit SubmissionUpdated(nonce, balanceA, balanceB, submission.deadline); } function respondChallenge( uint256 balanceA, uint256 balanceB, uint256 nonce, bytes calldata sigA, bytes calldata sigB ) external { require(submission.deadline != 0, "no submission"); require(block.timestamp < submission.deadline, "challenge expired"); require(nonce > submission.nonce, "must be newer"); bytes32 h = stateHash(balanceA, balanceB, nonce); require(validateSignature(h, sigA, participants[0]), "bad signature A"); require(validateSignature(h, sigB, participants[1]), "bad signature B"); submission = Submission({ balanceA: balanceA, balanceB: balanceB, nonce: nonce, deadline: block.timestamp + challengePeriod }); emit SubmissionUpdated(nonce, balanceA, balanceB, submission.deadline); }注意respondChallenge()只要求 nonce 更大,不再要求submission.deadline == 0。这样设计是为了让诚实方有机会用更新状态覆盖恶意提交的旧状态。挑战期结束后,任何人都可以调用finalizeChallenge()结算。
function finalizeChallenge() external { require(submission.deadline != 0, "no submission"); require(block.timestamp >= submission.deadline, "still in challenge period"); uint256 balanceA = submission.balanceA; uint256 balanceB = submission.balanceB; _settle(balanceA, balanceB); }3.5 状态哈希与签名校验
合约和链下脚本必须使用完全相同的状态哈希。这里我用abi.encode打包,而不是abi.encodePacked。虽然abi.encodePacked更短,但它存在哈希碰撞风险,尤其是多个动态类型和固定类型混用时,容易被人构造出相同哈希的不同参数。为了减少隐患,生产环境建议直接用 EIP-712 或至少用abi.encode。
function stateHash(uint256 balanceA, uint256 balanceB, uint256 nonce) public view returns (bytes32) { return keccak256(abi.encode(balanceA, balanceB, nonce, block.chainid)); }签名校验使用 ECDSA 恢复地址。注意必须走toEthSignedMessageHash,这样链下用personal_sign签出来的消息才能被正确还原。
function validateSignature(bytes32 h, bytes calldata sig, address signer) public pure returns (bool) { address recovered = h.toEthSignedMessageHash().recover(sig); return recovered == signer; }3.6 结算函数与重入防护
结算函数必须放在所有转账之前把settled置为true,否则如果有人把参与者地址改成恶意合约,就能在收到 ETH 时通过 fallback 重入合约,再次触发结算,把锁定的资金掏空。
function _settle(uint256 balanceA, uint256 balanceB) private { require(!settled, "already settled"); settled = true; require(balanceA + balanceB == address(this).balance, "total mismatch"); emit Settled(balanceA, balanceB); (bool okA, ) = participants[0].call{value: balanceA}(""); require(okA, "transfer to A failed"); (bool okB, ) = participants[1].call{value: balanceB}(""); require(okB, "transfer to B failed"); }一个有趣的点是:close()和finalizeChallenge()都会调用_settle(),而_settle()里require(!settled)天然避免了重复结算。因此合作关闭之后再想挑战是没意义的。
4. 链下交易脚本:签名流程、防重放与并发处理
很多人写状态通道合约很顺利,但一写链下脚本就混乱。问题通常出在三处:状态哈希对不上、nonce 管理不对、签名格式不统一。这一节我拆开讲。
4.1 为什么链下消息必须签名
链下状态本质上是“如果上链,合约应该认可的一条数据”。合约怎么知道这条数据确实是双方同意的?只能靠签名。所以每次链下交易都必须生成一条新状态,然后双方轮流签名。谁手里有双方签名的状态,谁就拥有“上链结算”的凭证。
正因为如此,链下状态带有明显的合同属性。签过名就不能反悔,所以客户端在处理消息时要谨慎。如果收到了一个 nonce 相同的不同状态,应当立刻丢弃并报告异常,因为这说明另一方试图制造状态分叉。
4.2 链下签名与状态哈希的对应关系
如果你直接用了上面的合约,链下状态哈希可以这样生成。注意ethers的solidityPackedKeccak256只是把参数紧密打包,但合约用的是abi.encode,也就是每个 uint256 都占用 32 字节、不足补零。两者并不等价。
正确做法是显式使用AbiCoder编码:
import { ethers } from "ethers"; function computeStateHash(chainId, balanceA, balanceB, nonce) { const encoded = ethers.AbiCoder.defaultAbiCoder().encode( ["uint256", "uint256", "uint256", "uint256"], [balanceA, balanceB, nonce, chainId] ); return ethers.keccak256(encoded); } const hash = computeStateHash(chainId, 300000000000000000n, 1700000000000000000n, 3); const sigA = await walletA.signMessage(ethers.getBytes(hash)); const sigB = await walletB.signMessage(ethers.getBytes(hash));链上合约中的stateHash()内部使用abi.encode,正好和ethers.AbiCoder的encode一致。如果不一致,你会在recover()阶段得到错误的签名者地址,然后整个流程卡住。
4.3 nonce 递增与并发处理
链下状态管理器最容易被忽视的是并发。想象 A 和 B 同时发起一笔转账,二者都从 nonce = 3 构造下一条状态,签名后互相交换,结果就会出现两个 nonce = 4 的不同状态。这条状态分叉会直接导致通道争议。
解决办法也很简单:约定好通道中只有一方能发起状态更新请求,另一方只能签名或拒绝。这个“发起者”角色可以在通道开启时确定,也可以每笔交易轮流担任。双发同时发起很容易出 bug,我一开始实现时就因此在测试环境里复现了状态分叉。
另一个经验是:本地保存链下状态时,最好加上updatedAt时间戳和hash字段。hash用于快速确认双方持有的是同一条状态,时间戳用于排查问题。
4.4 一个完整的链下状态流转过程
我用最简单的方式描述一遍:
- A 部署合约,支付 1 ETH。B 调用
join(),支付 1 ETH。通道总余额 2 ETH。 - A 生成本地初始状态:
(balanceA=1, balanceB=1, nonce=1),发送给 B 签名。 - B 验证初始状态里双方余额和锁仓一致,签名后返回。
- A 也签名这条状态,保存为
latestSignedState。 - 之后每次链下转账,例如 A 转给 B 0.5 ETH,A 构造新状态
(0.5, 1.5, nonce=2),B 签名后返回。A 也签名并保存。 - 当双方想关闭通道时,任意一方调用
close(0.5, 1.5, 2, sigA, sigB)。
如果 B 中途作恶,用 nonce=1 的旧状态去submitChallenge(),A 只要在挑战期内用保存的 nonce=2 状态调用respondChallenge()就能覆盖回去。
5. 实测中最容易踩的坑:安全边界与教训
5.1 挑战期到底设多长
这是所有状态通道设计里最重要的一个参数。如果设得太短,诚实方可能来不及响应;如果设得太长,恶意方可以长期卡住资金。
我一般建议:挑战期必须大于所在链上交易的最终确认时间。在以太坊主网,一个区块约 12 秒,可以考虑把挑战期设置为 100 到 300 个区块,也就是约 20 到 60 分钟。如果部署在测试网,为了调试方便可以设置成几十秒,但生产环境必须按最终性来评估。
另外要特别提醒:通道安全不仅取决于挑战期长度,还取决于参与者是否能持续监控。如果用户手机没电 30 分钟,而对方正好提交了一条旧状态,用户回来时挑战期已经结束,损失就发生了。实际生产项目一般都会引入 watchtower 角色,由第三方帮用户监控,发现可疑提交立即通知或代理响应。
5.2 签名校验里藏着三个经典漏洞
我见过不少自己实现状态通道的项目,在签名校验这层反复出问题。最典型的三个漏洞是:
- 没有限制签名者必须是
participants中的地址。攻击者随便构造一条对自己有利的状态,用自己的地址签名,合约却只验证“签名是否合法”,不验证“签名者是不是参与者”。 - 签名前没有给哈希加上前缀。如果你直接用
ecrecover(hash, ...)而不是toEthSignedMessageHash(hash),攻击者可能把某条 DeFi 交易的签名重放到你的通道里。因为很多签名就是直接对某个 32 字节哈希签名,重放风险很高。 - 没有检查
balanceA + balanceB == totalBalance。如果这条检查缺失,恶意方可以提交一个余额总和大于锁仓金额的状态,导致合约试图转出比实际余额更多的 ETH,最终交易失败,资金卡死。
这些坑都很隐蔽,单独看每一行代码都没有问题,组合起来却可能让资金彻底丢失。建议合约里把所有 require 条件都显式列出来,不要图省事合并成一个require。
5.3 服务离线与状态备份
链下状态是通道里唯一的资金凭证。如果你的手机丢失、服务器磁盘损坏、本地数据库被清空,你手里就没有最新的双签状态,只能眼睁睁看着对方用一个旧状态结算。
所以我强烈建议:
- 链下状态写入本地数据库后,至少备份到另一个位置。
- 每次收到对方签名的状态后,立刻持久化,不要等收到很多条再批量写盘。
- 运行一个简单的日志服务,记录状态 hash、nonce、双方余额,便于审计。
听起来像老生常谈,但在通道场景里,这直接关系到资金安全。链上合约再安全,也救不了链下数据丢光的用户。
5.4 链上 Gas 实测与对比
我在本地测试网跑过一个最简单的支付通道,随便测了几笔,大致数据如下:
| 操作 | 消耗 Gas 估值 | 主要成本点 |
|---|---|---|
| 部署合约 + 锁定 A 资金 | 约 60k | 合约创建和存储 |
| B 调用 join() | 约 50k | 修改状态 |
| 合作关闭 close() | 约 40k | 验证签名、转账 |
| 非合作挑战 submitChallenge() | 约 45k | 存储挑战状态 |
| 挑战响应 respondChallenge() | 约 40k | 更新挑战状态 |
| 最终结算 finalizeChallenge() | 约 30k | 转账 |
也就是说,一个完整通道生命周期,从开启到关闭,大约消耗 150k 到 180k Gas。如果双方在链下跑了 1000 笔交易,平均每笔链下交易只需要承担不到 0.2k Gas 的链上成本。相比之下,1000 笔普通链上转账至少需要 21k x 1000 = 21000k Gas,差距是两个数量级。
当然,这只是最小场景的数字。实际还要把挑战期监控成本、备份成本、通道资金占用成本算进去。通道适合交易量大且双方关系稳定的场景,如果一个月只交易几笔,部署通道本身反而可能比直接上链更贵。
6. 从支付通道到通用状态通道:还有哪些可能性
6.1 把余额分配换成任意状态
支付通道里,状态就是“双方的余额分配”。但状态通道的思想可以推广到任何能够被 hash 化、且双方可以签名确认的状态机。
比如一个国际象棋游戏,状态可以是“棋盘布局 + 当前轮到谁 + 双方剩余时间”。每走一步棋,链下生成新状态,双方签名;如果有一方不认账,就把最新状态提交到合约仲裁。合约甚至不需要理解棋局规则,只需要验证签名和 nonce。
再比如一个双人合作的订单簿,状态可以是“两个账户当前挂单和持仓”。只要状态变更足够频繁、参与者足够固定,都可以用状态通道把高频互动搬到链下。
6.2 虚拟通道与多跳结算
单个通道的局限是只能连接两个固定地址。真实网络中,A 想给 C 转账,但 A 和 C 之间没有通道,只有 A-B 和 B-C 两条通道。这时候可以直接把 A 的钱先从 A 通道转给 B,再由 B 从自己的通道转给 C。但这要求 B 在中间垫付资金,并且要处理好两笔转账的原子性。
更优雅的方案是虚拟通道:A 和 C 通过中介 B 建立一条新的“虚拟通道”,在链上不新增锁定,而是复用已有通道的余额。链下状态可以包含三个参与者的余额分配,最终关闭时由各个通道分别结算。这是很多支付网络和 L2 方案的地基,值得单独研究。
6.3 我对状态通道的实际看法
做完整套实现和测试之后,我的一个感受是:合约本身并不难,难的是链下状态管理和参与者监控。你不能假设用户永远在线,也不能假设本地数据永远不丢。所以生产级状态通道的商业模式,往往不是靠合约代码,而是靠周边服务:监控、状态备份、快速响应挑战。
如果只是想学习,强烈建议先跑通本文这个最小模型,再逐步加上 EIP-712 签名、多通道管理、虚拟路由这些复杂度。把每个非合作关闭的边界条件都手动测试一遍,比看一百篇概念文章都管用。状态通道不是银弹,但它里面那些“链下签名、链上仲裁”的思想,会在很多扩容方案里反复出现。把这个最小模型吃透,后面理解那些更复杂的通道网络和状态机,你会觉得顺很多。