去年年底我们接到一个有点反常规的需求:把一套已经在比特币闪电网络上跑通的通道代码,整体适配到 Setu 这条 DAG 结构的链上,做一次彻底的改造重构。乍一听像是给柴油机装火花塞,但盘完需求之后发现,这其实是一个非常现实的商业场景——Setu 的交易确认是异步的,不依赖区块高度,手续费又低,天然适合跑微支付通道。而闪电网络那套通道状态机的设计,恰好是现成的高频小额结算方案。
这篇文档不是科普闪电网络是什么,也不是介绍 Setu 的整体架构,而是把我们团队从调研、设计、拆解到落地这三个月里踩过的坑和沉淀下来的方案做一个完整记录。如果你是做链改、做 Layer2 适配,或者正在研究 DAG 链上的支付协议,这篇东西应该能帮你省掉一大半的弯路。
1. 为什么要把闪电网络搬到 Setu DAG 链上:起点与目标边界
先交代清楚背景。我们手上有一套基于 Rust 实现的闪电网络节点,通道管理、HTLC 路由、链上监控三大模块都完整,在比特币测试网上跑了小半年,稳定性没什么大问题。客户那边的需求是:他们有一套基于 DAG 结构的自有链(内部代号 Setu),希望我们能在这条链上实现"类闪电网络"的体验——支持即时到账的通道转账、支持多跳路由、最终结算落在 Setu 链上。
当时团队内部开过两次讨论会,最核心的分歧是:是参考闪电网络的思路在 Setu 上从零写一套通道协议,还是直接把现有代码改造成多链适配。最后选了后者,原因其实很实际:
- 闪电网络最值钱的不是比特币,而是那套通道状态机和 HTLC 路由逻辑,这部分与底层链无关的部分占了整个代码量的七成。
- 从零重写意味着要重新验证安全性、重新做形式化分析,周期完全不可控。
- Setu 链的区块概念很弱,交易确认依赖拓扑排序,如果完全重写,等于要把整套账本交互逻辑重新设计一遍,那就不叫适配了,叫再造一条链。
但"直接改"这句话说出来很轻松,动起手来才发现,闪电网络代码里至少有 20 个地方偷偷假设了"比特币式"的链属性。这里我先把改造前必须想清楚的目标边界列出来,后面每一节都会围绕这些边界展开。
| 能力模块 | 改造方式 | 说明 |
|---|---|---|
| 通道状态机 | 完整保留 | 状态转换逻辑与链无关 |
| HTLC 时序控制 | 适配改造 | 时间锁语义从"区块高度"改为"事件序号" |
| 链上监控 | 完全重写 | DAG 链没有区块头,监控模型从"扫块"变"扫引用" |
| 结算交易构造 | 重写 | 脚本系统与比特币不兼容 |
| 路由寻路 | 保留+参数调整 | 手续费和通道权重计算可复用 |
表格里这几项,就是我们这篇重构文档的主线。后面的方案全部围绕"哪些可复用、哪些必须推倒重来"这个核心问题展开。这里也想提醒一句:做这类跨链适配,最容易翻车的就是带着"链都差不多"的心态往下冲,等写到监控模块才发现底层假设全错,回头改架构的代价会非常大。
2. 闪电网络与 Setu DAG 链的底层差异:改造前的必懂功课
把代码落地之前,必须把两个链的账本模型差异彻底搞清楚。我对团队的要求是:每个人都要能画出两张图,一张是比特币的区块确认流程,一张是 Setu 的 DAG 拓扑确认流程,然后对着图讲清楚五处关键差异。这五处差异直接决定了改造方案的设计。
2.1 确认模型差异:区块高度与拓扑序号
比特币是线性的区块账本,每一个区块都有明确的高度,区块之间是严格的前后关系。闪电网络的整个安全模型都建立在"高度"这个锚点上——CSV 相对时间锁说"你要等 144 个区块",CLTV 绝对时间锁说"这个交易在高度 800000 之前无效",通道监控器(ChannelMonitor)的核心工作就是扫每一个新区块,检查是否有对手方恶意广播了旧状态的结算交易。
Setu 是完全不同的模型。它没有区块,每个交易就是一个节点,交易之间通过引用关系形成 DAG。一个交易的"确认"不是等待后续多少个区块,而是看它在 DAG 拓扑中的位置,以及被多少后续交易直接或间接引用。这个差异带来两个直接后果:
- 时间锁不能写"区块高度",因为没有高度这个概念。Setu 提供了类似于"事件序号"(我们内部叫 seq)的全局单调递增标识,每个交易在被 DAG 收敛后都会获得一个排序号。所以 HTLC 里的 CLTV 要改成"seq + 常数"。
- 相对时间锁失去意义。CSV 在比特币里表示"从交易被打包进区块开始计算等待时间",但 DAG 里交易没有"被打包"这个动作,只能改成一个固定延迟轮次,等待拓扑确认。
2.2 账本模型差异:UTXO 与消息状态
闪电网络的通道交易依赖比特币的 UTXO 模型。通道的 funding output 是一笔未花费的输出,ChannelMonitor 要盯的就是这笔输出有没有被花掉。而 Setu 链采用的是类似消息加状态的模型,没有 UTXO 的概念,每一个交易对象维护一组自定义状态字段。
这对通道设计的影响非常大。我举个具体的例子:比特币闪电网络的 commitment transaction 是一笔真正签过名的比特币交易,它花掉 funding output,然后输出给双方。在 Setu 上不存在"花掉一个输出"的操作,只有"提交一笔消息,该消息携带状态变更"。所以我们不能把 commitment transaction 原样搬到 Setu 上,只能把它的逻辑编译成"一个带签名的状态更新消息,该消息被 DAG 记录后视为生效"。
这个改动牵一发而动全身。签名消息的验证逻辑、双花保护、旧状态撤回机制,全部要围绕 Setu 的消息模型重新设计。但好消息是,通道状态机的核心逻辑,也就是"当前是什么状态、对手方给了什么新签名、我能否安全地广播这个状态",这部分完全与链无关,可以百分之百复用。
2.3 最终性语义:概率确认与强确认
比特币有区块重组的概念,但高度确定之后,交易最终性虽然理论上仍有 51% 攻击风险,实务上大家按 6 个区块确认来做强确认。闪电网络的 ChannelMonitor 则依赖"链上交易一旦确认就无法撤回"这个假设来保证安全。
Setu 是异步结算的 DAG 链,最终性语义更接近"概率性确认"。一个交易在被 DAG tip 引用之后,理论上随着引用链加深,被推翻的概率指数下降。但这里有一个安全隐患:闪电网络要求 commitment transaction 一旦上链就必须自治(self-contained),不能再依赖后续交易来补数据。在 DAG 链上做适配时,一个交易可能在拓扑中已经存在,但它引用的父交易还没被最终接受,这就出现了引用悬空(dangling reference)的问题。
我们的处理方案是:结算交易必须引用一组"已经达到强确认深度"的祖先交易,这个深度在 Setu 上我们设成了 8 层引用。也就是说,通道关闭交易提交时,不能引用最新 tip,必须引用 8 层之前的那个祖先节点,确保引用的状态本身已经稳定。代价是关闭通道多等几个确认周期,但对资金安全来说是必须的。
3. 架构重构图:把通道层从记账层中剥出来
方向明确了,接下来是动手重构。我们拿到代码之后先做了一次模块依赖扫描,发现原来的 Lightning Node 把"链的抽象"做得很薄,很多地方直接调用了比特币 RPC 接口。如果只是机械替换,代码会变成一锅粥。我们的思路是引入一层 ChainAdapter,把原有代码里所有直接触链的动作全部收口到这个接口后面。
3.1 原有代码的耦合点分析
扫描结果整理下来,耦合点集中在三个地方:
第一是 ChainMonitor。原来它订阅比特币节点的 block 事件,每来一个新块就遍历所有通道的 CSV/CLTV 检查。这部分的"事件驱动"思路可以保留,但事件源要从"block connected"改成"DAG tip updated"。
第二是 KeysManager。闪电网络里密钥管理和交易签名紧密结合,尤其是一个通道的两个 commitment transaction 需要不同的 revocation key。这个逻辑是链无关的,但我发现原来代码里生成签名时直接用了 Bitcoin 的 sighash 算法,这里的 hash 预处理必须换成 Setu 的消息签名格式。
第三是 ChainWriter,也就是广播交易的那一层。比特币的广播是sendrawtransaction,Setu 是提交消息到节点。这里接口差异反而好处理,就是包一层封装,真正麻烦的是广播之前的交易构造。
我把重构分成三个圈层,从里到外分别是:核心逻辑层(状态机、HTLC、路由)、适配层(ChainAdapter)、链实现层(BitcoinBackend / SetuBackend)。核心逻辑层禁止出现任何与具体链相关的类型和调用,适配层定义好接口后,两个后端各自实现。
3.2 ChainAdapter 的接口设计
ChainAdapter 是这次改造的灵魂,我把它的接口设计贴出来(简化版):
trait ChainAdapter { // 链信息 fn chain_id(&self) -> ChainId; fn current_height_or_seq(&self) -> u64; fn confirm_depth(&self) -> u32; // 交易构造与广播 fn build_settlement_tx(&self, state: &ChannelState) -> Result<Vec<u8>, ChainError>; fn broadcast_tx(&self, raw_tx: &[u8]) -> Result<TxId, ChainError>; // 事件订阅 fn subscribe_tip_updates(&self) -> TipUpdateStream; fn get_tx_status(&self, tx_id: &TxId) -> Result<TxStatus, ChainError>; // 最终性检查 fn wait_for_finality(&self, tx_id: &TxId, depth: u32) -> BoxFuture<'static, Result<(), ChainError>>; }看这个接口就明白,我们不是在原来的 Lightning Node 上打补丁,而是把"链"彻底抽象成了一个可替换的背板。不同的链只需要实现这个 trait,上层的 ChannelManager 完全不用动。这个设计带来的额外好处是测试变得非常好写——我们写了个 MockChain 的实现,用内存模拟 DAG 的拓扑演化,单测从原来的 200 个跑到了 800 多个,速度还快了不少。
3.3 事件驱动的改造逻辑
原来的 ChainMonitor 靠块高驱动检查,我们改成靠 tip 更新驱动。Setu 的节点每接受一个新交易,会把 DAG tip 集合广播出来,我们的 TipUpdateStream 会收到这个通知。收到通知后要做的第一件事不是立刻检查所有通道,而是先把这个 tip 的祖先集合拉下来,看看有没有新的结算交易出现。如果在拓扑中发现了一笔我们没有预期到的通道关闭交易,那就说明对手方可能在做坏事,要立刻启动惩罚流程。
这里有一个很隐蔽的性能坑:比特币的区块是规则的,每 10 分钟一个块,事件密度可控。DAG 链的 tip 更新是随时可能发生的,高峰期一秒钟可能收到几十个 tip 更新通知。如果每个通知都全量扫描所有通道,CPU 和磁盘 IO 都扛不住。我们最后的做法是:热点通道(最近 24 小时有转账的)走实时扫描,冷通道降到 5 秒批量扫描一次。目前跑下来,最坏情况下对热通道的响应延迟不超过 1 秒。
4. 核心模块改造细节:通道状态、HTLC 与结算上链
架构层搞定之后,剩下的是硬骨头。这一节我按模块讲,每一个模块都说明白:原来怎么设计、我们改了什么、为什么这么改。
4.1 通道状态通道的重新设计
闪电网络的通道状态本质上是一条不断被新旧 commitment 交易覆盖的交易链。新状态生成时,双方交换签名,同时旧状态的撤销密钥(revocation key)也要给对方,保证任何一方广播旧状态都会被对手方没收全部资金。
这个机制在 Setu 上有一个天然的适配难点:比特币的 commitment transaction 是独立的交易,可以被单独广播和确认。但 Setu 的消息必须引用已有交易,也就是说"广播一个状态"实际上是要在 DAG 里追加一条消息。为了保证"旧状态被广播后,新状态持有者可以立即惩罚",我们在 Setu 版本里引入了状态锚点(State Anchor)。
每一个通道在 Setu 上有且仅有一个 State Anchor 交易,它是通道生命周期里所有 commitment 消息的公共祖先。任何一方要广播某个 commitment 状态,必须先提交一条引用 State Anchor 的状态声明消息。惩罚交易则引用同一个 State Anchor,检查声明消息里的序列号(commitment number),如果不是最新,就执行罚没。
这样设计的好处是:状态之间的对抗关系变成了 DAG 中同一个祖先下的分支竞争,Setu 的共识规则会保证只有一条分支存活,惩罚逻辑天然成立。我们为此写了一份状态转换的正确性证明概要,核心论点是"如果恶意广播的旧状态被 DAG 接受,那么新状态持有者的惩罚交易必然在拓扑上更接近 tip,最终胜出"。
4.2 HTLC 在 DAG 链上的实现约束
HTLC(Hashed Time-Locked Contract)是闪电网络路由的核心。一个标准的 HTLC 有四个要素:支付哈希、支付预像、CLTV 绝对时间锁、CSV 相对时间锁。在比特币上,这四个要素通过脚本系统组合成一把锁,只有满足条件的人才能解锁。
Setu 的账本模型没有脚本,我们只能把 HTLC 表达成一个状态机对象,在通道内私有状态里流转。具体做法是,在通道状态数据里新增一个pending_htlc数组,每个元素包含:
payment_hash: 32 字节哈希amount_msat: 金额cltv_seq: 解锁的最小时序(对应比特币的 CLTV)htlc_type: 分类为 forward(转发)或 received(接收)preimage_holder: 只有接收方的状态里包含,且对发送方隐藏
关键点在于cltv_seq的过期处理。比特币的 CLTV 是交易级的时间锁,交易不到高度不会被打包。Setu 的 seq 是节点全局推进的,我们可以拿到当前最新的 seq。过期后,原本锁定在 HTLC 里的资金要退还给发送方,同时该 HTLC 必须从通道最新状态里移除。这个逻辑在 ChannelManager 里新增了一个sweep_expired_htlcs定时任务,每 6 秒扫描一次所有通道的待处理 HTLC。
这里有个值得分享的经验:DAG 链上的"时间"并不是均匀流动的。比特币的 10 分钟一个块,CLTV 的推进节奏非常稳定。Setu 的 seq 推进速度取决于网络活跃度,如果链上一段时间没有交易,seq 就停在那里不动,导致 HTLC 的过期时间被无限拉长。我们最终的方案是在cltv_seq的基础上叠加了一个"墙钟时间兜底"规则:如果当前系统的 Unix 时间超过了 HTLC 创建时间加上一个绝对上限(比如 24 小时),即使 seq 没到也允许强制过期。这是对 DAG 链时序特性妥协后的务实做法。
4.3 结算上链的原子性处理
通道关闭是闪电网络里最不能出错的环节。正常关闭、强制关闭、对方违约三种情况,处理方式完全不同,但共同点是都要把通道的最终状态写到链上。
比特币版本里,通道关闭需要广播 commitment transaction,这个交易把通道资金分成两笔输出:一笔给对端,一笔是找零(如果还有本地余额)。如果通道里有未完成的 HTLC,还要附加 HTLC-timeout / HTLC-success 交易。
Setu 版本里,我们把"通道关闭"定义为向 DAG 提交一个 Closing 消息。这个消息必须引用 State Anchor,并且附上通道最新状态的 Merkle 证明和双方签名。如果通道处于 Force Close 状态(比如对方离线太久),则只提交本地签名。
真实改造过程中最耗时的就是 Closing 消息的构造。我们要保证:一个不完整的通道状态不能被伪装成"最新状态"提交上去。为此我们给通道状态加了一个单调递增的版本号(commitment number),并把版本号和状态的 Merkle 根绑定在一起。任何提交上链的 Closing 消息都能被 Setu 节点验证:版本号必须大于所有此前提交过的状态版本号。如果版本号过低,验证直接失败。这就是我们替代比特币脚本锁的逻辑——不需要复杂的脚本语言,靠状态机的强制约束就能保证最终表现。
4.4 安全性的关键设计:惩罚即 DAG 分支竞争
比特币闪电网络的惩罚机制依赖一个精妙的脚本技巧:旧状态的撤销密钥可以构造一个只有违约方才能被没收资金的交易。Setu 没有脚本,但我们可以用"分支偏好"来实现同样效果。
设计是这样的:惩罚消息(Penalty Message)引用 State Anchor,并且携带的信息是"对手方提交了带序列号 N 的旧状态,这是我的惩罚证据"。Setu 的验证规则(写在链的运行时配置里)会检查:如果 N 小于当前最新已提交状态号,则该消息自动获得"没收通道全额保证金"的效果。
也就是说,不需要脚本去描述"如果 A 做坏事,则钱给 B",只需要把旧状态 = 违约这个判定规则内置到链的运行时合约里。这个方案最初被我们团队质疑,因为等于把链的规则和通道逻辑绑死了。但我们和 Setu 的协议层确认过,这个规则是通用的,不针对任何特定通道,只是对"引用同一祖先的分支版本号竞争"做出响应,所以它依然是链的中立性规则。
如果你也想在自己的 DAG 链上实现类似机制,记住一个点:不要在链上做复杂的条件判断,把"条件→结果"映射尽量简化成"提交 A,如果满足 B,则生效 C"这种扁平形式,链上的验证器越简单越好。
5. 踩坑实录:同步延迟、并发冲突与资金安全验证
方案设计归设计,真正动手写代码、跑测试的时候,我们还是翻了好几次车。挑三个比较有代表性的坑记录一下,希望能帮你规避。
5.1 DAG 引用悬空导致的结算交易丢失
第一次集成测试时,我们发现一个诡异的现象:A 和 B 之间的通道,A 发起了正常关闭,Closing 消息已经提交,但 B 那边的监控器始终看不到这笔消息,资金久久不能到账。
排查了两天才定位到原因。A 的 Setu 节点构造 Closing 消息时,引用了当时 DAG 的最新 tip。但消息在网络上传输到 B 节点的几秒里,B 节点的 DAG 视图已经往前走了好几层,A 提交的那个 tip 在 B 的本地 DAG 里已经变成了一个侧链分支。按 Setu 的收敛规则,侧链分支不被认为是最新状态,导致 B 在扫描时根本不会遍历到这条消息。
这个问题的本质是DAG 视图不同步引发的引用悬空。我们的解决方式有两条,都必须做:
- 构造交易时不引用最新 tip,而是引用一个"保守祖先":从当前 tip 往上数 4 层取祖先节点。
- 在 ChainAdapter 的广播逻辑里增加结果确认轮询:广播后第 2 秒、第 5 秒、第 15 秒各查一次交易状态,如果一直处于 unknown 状态,则重新构造并广播。
这也是为什么我在前面说最终性确认要设深度,不只是为了安全,还为了减少这种空引用导致的丢交易问题。
5.2 并发 tip 更新导致的通道状态竞态
第二个坑出现在通道存在大量转账的时候。Setu 的 tip 更新流是异步推送的,多个 tip 可能同时到达。我们的 ChannelMonitor 在收到两个 tip 通知后,如果第一个通知还没来得及把通道状态推进完,第二个通知又来了,就会触发一个seq mismatch的 panic。
我们排查后发现,原来的代码对 event 的处理是"同步阻塞式",一个没处理完不会接下一个。但 DAG 的 tip 事件并发度远大于区块事件,阻塞反而导致了事件堆积,越积越多最后时序错乱。
最后的修复方案是对 ChannelMonitor 做异步队列化改造,所有 tip 事件先进入一个无序队列,处理线程按 seq 升序逐个消费。关键细节是,队列消费前会先做一次 dedup,同一个 seq 的通知如果已经处理过就丢弃。这个改造之后,连续跑了一周的压力测试,没有再现过 panic。
5.3 资金安全验证:用故障注入证明惩罚路径
最后一个坑来自测试的认知偏差。我们最初用"happy path"测试惩罚逻辑,就是正常广播旧状态、正常触发惩罚,结果全绿。但后来顾问问了一个问题:"如果旧状态广播成功之后,链上出现了分叉,惩罚交易走的是另一个分支怎么办?"
这一问提醒了我们,我们重新看了设计,发现如果 DAG 收敛选择了惩罚交易所在的分支,那没问题;但如果在先选择了旧状态分支,然后又因为拓扑权重反悔切到了惩罚分支,那资金在短暂时间内会处于"可被任意一方支配"的状态。对于微支付场景这个窗口可能问题不大,但如果涉及大额通道,这是不可接受的。
我们花了整整两周做故障注入测试:手动构造分叉、人为延迟交易传播、模拟节点崩溃后的重启恢复。最终的结论是:必须在cltv_seq和惩罚交易之间设置一个最小安全间隔。也就是说,惩罚交易不仅要在拓扑上更接近 tip,还要等旧状态分支彻底死亡(引用深度超过 8 层)之后再执行罚没。这个安全间隔会提高惩罚的响应耗时,但从协议安全性角度看是值得的。
写在最后的实际体会
改造做完,回头看整个过程,最深的体会是闪电网络的代码抽象质量确实高,状态机和链的耦合点比预想中少很多,这也是我们能在一个季度内完成适配的前提。但反过来说,正是因为它设计得足够克制,我们才需要在适配层把链的特性差异全部接住,不能有任何含糊。
另外想强调一下测试策略。这种跨链适配,单测覆盖得再多,都不如一次真正的故障注入测试有价值。建议任何做类似项目的团队,在安全模块写完以后,强制安排两周时间专门做破坏性测试:断网、双花、分叉、节点崩溃、消息乱序,全都要模拟一遍。我们可以接受在测试阶段暴露问题,但绝不能接受在主网上线后暴露问题。
最后分享一个小技巧:写适配层代码时,日志打印一定要带上链的上下文标识,比如是来自 BitcoinBackend 还是 SetuBackend,事件序号是多少。我们调试引用悬空问题时,靠的就是逐行对比两个节点的日志顺序,没有这个上下文标识,光靠报错信息真的会绕很多弯路。