尧图精选

闪电网络适配DAG链:Setu通道改造的架构设计与实践

🕒 发布时间:2026/9/28 8:55:44 📁 来源:尧图网络
去年年底我们接到一个有点反常规的需求把一套已经在比特币闪电网络上跑通的通道代码整体适配到 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也就是广播交易的那一层。比特币的广播是sendrawtransactionSetu 是提交消息到节点。这里接口差异反而好处理就是包一层封装真正麻烦的是广播之前的交易构造。我把重构分成三个圈层从里到外分别是核心逻辑层状态机、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) - ResultVecu8, ChainError; fn broadcast_tx(self, raw_tx: [u8]) - ResultTxId, ChainError; // 事件订阅 fn subscribe_tip_updates(self) - TipUpdateStream; fn get_tx_status(self, tx_id: TxId) - ResultTxStatus, ChainError; // 最终性检查 fn wait_for_finality(self, tx_id: TxId, depth: u32) - BoxFuturestatic, 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 链上的实现约束HTLCHashed Time-Locked Contract是闪电网络路由的核心。一个标准的 HTLC 有四个要素支付哈希、支付预像、CLTV 绝对时间锁、CSV 相对时间锁。在比特币上这四个要素通过脚本系统组合成一把锁只有满足条件的人才能解锁。Setu 的账本模型没有脚本我们只能把 HTLC 表达成一个状态机对象在通道内私有状态里流转。具体做法是在通道状态数据里新增一个pending_htlc数组每个元素包含payment_hash: 32 字节哈希amount_msat: 金额cltv_seq: 解锁的最小时序对应比特币的 CLTVhtlc_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事件序号是多少。我们调试引用悬空问题时靠的就是逐行对比两个节点的日志顺序没有这个上下文标识光靠报错信息真的会绕很多弯路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →