从 Polkadot 转向 Cell 模型时,哪些经验不再适用,哪些可以沿用,又有哪些旧习惯需要放下。

CKB 要你做的第一件事,是亲手构造一笔交易。
不是提交 extrinsic,也不是调用合约方法。你得自己构建输入,找到想要消耗的 Cell,决定要创建哪些输出,算好容量,编码数据,附上正确的脚本,然后签名并广播。这些事,链都不会代劳。它接收你提出的状态转换(state transition),然后接受或拒绝。
如果你来自 Polkadot,会觉得这像是反过来了。在 Substrate 上,构造状态转换是运行时的工作。你编写 pallet 来定义有效的转换,FRAME 将它接入区块执行流程,PAPI 则为前端提供类型完备的 API 来调用。分工很清楚:开发者定义规则,链负责执行并强制遵守规则。你从不需要手动拼装一次存储写入,只需调用转账函数,运行时就会处理底下的一切。
那么,CKB 为什么把钥匙交给你,让你自己开车?
不是因为 CKB 的工具更差,也不是刻意增加学习难度。答案在于架构:对于逻辑应该放在哪里、谁应该负责状态(state),CKB 和 Polkadot 的看法根本不同。在 Polkadot 上,逻辑与状态融为一体:pallet 拥有自己的存储映射,运行时掌管所有状态转换,中继链负责共识。在 CKB 上,它们则完全分离。逻辑(脚本)和状态(Cell)是不同的对象,只在验证时短暂交会,之后各自独立。没有脚本拥有 Cell,也没有运行时掌管全链状态。开发者需要自己构造转换,是因为没有运行时可以代劳。
这种分离,就是本文的核心。其余一切,无论 Cell 模型、脚本架构、查询模型还是升级方式,都由此而来。
Polkadot 为你提供了什么,而你可能没意识到自己一直依赖着它
讨论 Cell 和脚本之前,先把 Polkadot 的模型说清楚会有所帮助,因为大多数学过 Substrate 的开发者,从未需要站在体系之外描述它。
Polkadot 的开发模型建立在三个紧密结合的层次上:
运行时是一个单独的 WASM 二进制文件。 链上的每个 pallet 都编译进同一个 WASM blob。这个 blob 就是规则:它定义所有有效的状态转换、所有可调用函数,以及每一种存储布局。节点执行它,以对状态达成一致。部署平行链,就是部署运行时;升级则是通过链上治理投票,用新的 WASM 二进制文件替换运行时。这种升级无需分叉(无需协调硬分叉,也无需节点下线),因为升级本身只是旧运行时批准的又一次状态转换。
Pallet 拥有自己的存储。 Pallet 中的存储映射不是谁都能写入的原始数据库表。它属于该 pallet 的命名空间,只有该 pallet 的 extrinsic 和钩子才能合法地修改它。外部调用者无法绕过 pallet 定义的 API,直接进入另一个 pallet 的存储并更改值。所有权在编译时得到强制保障。
中继链替你承担共识工作。 作为平行链开发者,你不需要实现共识。你编写运行时,Polkadot 的收集人/验证者机制负责区块生产和最终性(finality)。你的工作到“定义有效状态转换”为止,其余都是基础设施的事。
单一运行时、归属明确的存储和共享共识,这三者共同定义了 Polkadot 开发者的体验。它们太基础了,以至于大多数 Substrate 开发者都不会把它们列为前提假设:区块链本来就是这样工作的。
CKB 打破了这三个前提。这不是意外,也不是局限,而是有其理由的设计选择。
第一部分:写给链上开发者
你编写 pallet 或运行时逻辑,习惯从存储映射、可分派调用和 extrinsic 的角度思考。
这里没有运行时
CKB 没有 Substrate 意义上的运行时。没有一个由所有节点执行、用来判定有效状态转换的 WASM 二进制文件,没有一个掌管存储的单一权威,也没有地方放你的 pallet 调用和存储宏。
CKB 取而代之的是 Cell 模型。
Cell 是 CKB 状态的原子单位,包含四个字段:
capacity:Cell 持有多少 CKB,也由此决定它可以占用多少字节的链上空间(1 CKB = 1 字节链上状态)lock_script:锁定脚本(Lock Script),用来控制谁能花费这个 Cell 的程序,即所有权规则type_script:类型脚本(Type Script),用来规定 Cell 数据含义的可选程序,即逻辑规则data:任意字节,可以存放你想存储的内容
Cell 只有未花费和已花费两种状态,不能就地修改。要“更新”状态,你需要将一个或多个 Cell 作为交易输入消耗,再生成新的 Cell 作为输出。被消耗的 Cell 销毁,新的 Cell 出现。这是比特币 UTXO 模型的扩展,只是用可编程脚本取代了比特币脚本(Bitcoin Script)中有限的栈操作。
对链上开发者而言,这具体意味着:没有一个地方会把你的逻辑和状态绑定在一起。
在 Substrate 中,pallet 是一个直接引用自身存储的 Rust 模块。它访问存储并修改存储,拥有那份存储,而修改正是它的职责。
在 CKB 上,脚本不修改任何东西。它是一个验证者(validator),接收所提议交易的完整视图,包括所有输入、所有输出和所有 Cell 数据,然后返回成功(有效,放行)或错误码(拒绝)。它没有能力伸手去改变任何东西。状态变更由交易构造方提出,也就是你的钱包、dApp 或 SDK;被消耗和被创建的 Cell 上的脚本,决定是否允许这次变更。
根本转变就在这里:从修改状态的程序转向验证状态的程序。
例如,CKB 上的代币转账脚本并不移动代币,只问一个问题:“在这笔提议的交易中,代币总量是否正确?”如果正确,交易就有效;如果不正确,就拒绝。代币数量本身以原始字节的形式,存在 Cell 的 data 字段中。没有程序“拥有”这些数据。类型脚本可以验证涉及它们的转换,却不能伸手改变它们。
什么取代了 pallet
Substrate 开发者问“如何在 CKB 上构建一种代币”时,本能地会寻找相当于 pallet 的东西。但这里没有对应物。你要构建的是类型脚本。
xUDT(可扩展用户自定义代币,extensible User Defined Token)标准,是 CKB 上最接近 ERC-20 的东西。它不是一个带有状态的合约,而是一个 RISC-V 二进制文件,强制执行一条规则:输入中的代币总量必须大于或等于输出中的代币总量。每个携带 xUDT 代币的 Cell 都把这个二进制文件引用为自身的类型脚本。你转移代币时,CKB-VM 会执行 xUDT 二进制文件,作为验证交易的一部分。
这个二进制文件作为一个 Cell 部署在链上,只是 Cell 中的数据,结构上与其他 Cell 没有区别。其他交易通过 cellDeps 引用它,CKB-VM 由此知道验证时应从哪里加载脚本。
这把“自备密码学和共识原语”(bring your own cryptography and consensus primitives)的思路推到了逻辑上的终点。这里没有硬编码的预编译合约,也没有享有特权的 pallet 注册表。每个系统脚本,包括 CKB 地址使用的默认 secp256k1 锁,都只是链上一个任何人都可以引用的 Cell。你可以编写一个竞争实现,部署后今天就能用,无需治理投票,也无需硬分叉。
无分叉升级怎么办?
Substrate 的无分叉升级是 Polkadot 开发者最引以为傲的能力之一,也确实值得自豪。运行时是存储在链上的 WASM 二进制文件,因此升级只是一笔经治理批准、替换该二进制文件的 extrinsic。无需协调硬分叉,也无需节点下线,链会继续运行,只是换上新逻辑。
CKB 没有这一机制。没有运行时可供替换,也没有一个链上 WASM 二进制文件来管理所有状态转换。
但 CKB 不需要无分叉升级的原因,与它不需要运行时的原因相同:逻辑不拥有状态。想升级 CKB 上的代币逻辑时,你部署一个新的类型脚本二进制文件,再迁移现有 Cell,让它们引用新脚本。这种迁移本身也只是一笔交易:消耗旧 Cell,产生指向新类型脚本的新 Cell。你可以逐步迁移,在迁移窗口期间让新旧逻辑并行运行。
这没有 Substrate 一键升级运行时那么方便,需要你仔细考虑迁移。但这也意味着,你不必先通过治理推动某个统一二进制文件的升级,才能改变行为。不同 Cell 可以同时引用脚本的不同版本。你不必说服整条链升级,只需升级自己的 Cell。
给 Solidity 开发者的一点说明
如果你习惯为 pallet-revive 或任何 EVM 链编写合约,这种转变比对 pallet 开发者而言还要剧烈。合约拥有的存储、事件发出机制和 msg.sender,在 CKB 上都没有直接对应。你会编写的是一个类型脚本:一个 RISC-V 验证程序,检查提议的交易,返回成功或错误。“你的合约余额”对应的是“这笔特定交易中,携带你的类型脚本的 Cell 的总和”。没有隐藏状态,没有副作用,也没有 EVM 意义上的重入,因为根本没有什么可供重新进入。
第二部分:写给 dApp 开发者
你构建前端、SDK、索引器或链下工具,习惯从 RPC 调用、签名交易、事件订阅和合约方法调用的角度思考。
这里没有合约方法
Polkadot dApp 开发者的思维模型是面向服务的。如今的标准是 PAPI(Polkadot-API),一个以轻客户端为优先的 TypeScript 库,其类型直接由链上元数据生成。运行一条命令,编辑器就立刻知道链上的每个存储键、每个 extrinsic 和每个事件。
PAPI 提供的关键能力是:链暴露的整个接口,都能根据元数据获得静态类型。你还没运行一行代码,IDE 就知道存储查询会返回什么。链是一个供你调用的、带有类型定义的服务。
在 CKB 上,这些都不存在。没有 api.tx.token.transfer(),没有元数据,也没有模式定义。dApp 开发者的工作从调用有类型定义的服务,变成了从原始字节构造状态转换。
在 CKB 上转移代币,需要:
- 收集输入 Cell:扫描活跃 Cell 集,找出属于发送者、且类型脚本与所要发送的代币类型相匹配的 Cell
- 计算数量:将这些 Cell 的
data字段中的代币数量相加 - 构造输出 Cell:创建新的 Cell,一个给接收者,并锁定到其密钥;如果输入数量超过转账数量,再创建一个 Cell 找零给发送者
- 附加 cellDeps:包含对 xUDT 脚本 Cell 的引用,让 CKB-VM 能加载验证逻辑
- 签名并提交:使用发送者的密钥签署交易,满足输入 Cell 上的锁定脚本,然后广播
这里没有“转账”(transfer)函数。你不是在调用某个合约,而是在自己构建完整的状态转换,提交给网络,再由网络的验证层,也就是脚本,接受或拒绝。
CCC(CKBers' Codebase)等现代 SDK 提供了辅助工具,可以替你选择输入 Cell 和计算手续费。但输出仍需你手动声明。开发者思考的基本单位,仍然是交易,而不是方法调用。
查询状态
在 Polkadot 上,查询状态意味着读取运行时暴露的、定义清晰的存储布局。PAPI 直接从元数据生成 TypeScript 类型,让这一过程顺畅无阻。链把自身的模式定义告诉 PAPI,PAPI 再告诉 IDE。运行时升级、存储发生变化时,只需重新运行一条命令,描述符就会更新。类型系统会在编译时发现不兼容的变化。
在 CKB 上,没有元数据,也没有链发布的模式定义。CKB 节点提供 RPC,可按锁定脚本、类型脚本或容量范围等条件筛选并查询活跃 Cell 集。但每个 Cell 的 data 字段中的字节意味着什么,完全需要你自己了解和解码。
任何状态查询的结果都是一组活跃 Cell。你需要将其 data 字段中的数量相加,才能得到余额。这里没有一个带有类型定义、直接返回单个数字的查询。余额是从原始字节计算出来的派生量,这与比特币 UTXO 模型完全相同,却与 PAPI 在编译时就确定类型的存储读取截然不同。
生产环境中的 dApp 便会用到索引器。CKB 的 Lumos SDK 和 CKB Explorer 等服务,提供针对 Cell 集的索引视图,使你无需在前端直接扫描 Cell。但理解底层模型,也就是余额来自 Cell 之和,而非数据库中的一行记录,会改变你对应用状态设计的思考方式。
所有权模型是显式的,而非隐式的
在 Polkadot 上,对状态的访问控制主要由运行时的 origin 系统处理。一个可分派调用检查调用者的身份后,只要运行时逻辑允许,就有完整权限修改它引用的任何存储。
在 CKB 上,每个 Cell 的锁定脚本强制执行所有权规则,网络中的每个全节点都独立求值。这里没有享有特权访问能力的运行时。如果锁定脚本规定交易需要签名 X,那么任何消耗该 Cell 的交易,没有签名 X 就不会有效。这不是因为节点守规矩,而是因为每个节点都会独立运行脚本,拒绝任何未通过检查的交易。
这让所有权可以按 Polkadot 模型所不具备的方式组合。你可以用多重签名锁定脚本锁住一个 Cell,要求两个不同参与方的密钥,无需中心合约协调。你也可以用时间锁(time-lock)锁住 Cell,到达特定区块高度后才解锁。任何能够编译为 RISC-V、返回成功或错误的代码,都可以成为有效的锁定脚本。
对于 dApp 开发者,这意味着锁定逻辑位于资产中,而不是位于 dApp 调用的合约里。设计应用的访问控制时,你是在设计嵌入 Cell 的脚本,而不是设计要调用的合约函数。
一次讲清思维模型如何转变
如果你来自 Polkadot,理解 CKB 最困难的一点就是:
在 Polkadot 上,逻辑拥有状态。在 CKB 上,逻辑验证状态,没有什么拥有状态。
在 Substrate 中,pallet 是一个掌管自身存储命名空间的模块。外部代码只有通过它定义的 API,才能合法修改存储。pallet 就是其状态的管理权威。
在 CKB 上,脚本是一个纯函数:输入交易,输出成功或错误。它运行、检查,然后退出。退出后,它验证过的 Cell 仍然存在。未来的一笔交易会消耗这些 Cell,调用与该交易相关的脚本;这些脚本也会运行、检查,然后退出。没有脚本积累状态,也没有脚本拥有 Cell。所有权编码在 Cell 自身的锁中,而非任何程序的模块边界里。
初次接触时,你会觉得结构不见了。在 Substrate 上,pallet 是天然的组织方式:一个管代币,一个管治理,一个管质押。CKB 没有对应物。应用逻辑分布在以 Cell 形式部署的脚本中,交易时调用,之后又归于沉寂。
但你换来的能力也很可观:
并行是自然的。 两笔交易只要不消耗相同的输入 Cell,就能完全独立、同时验证。不存在需要围绕其串行处理的共享可变运行时状态。因此,CKB 最终可以在基础层支持并行交易执行(parallel transaction execution),而无需 EVM 链那样复杂的调度器。
可组合性(composability)来自结构,而非合约之间的约定。 在 Polkadot 上,组合两个 pallet,要求它们的类型一致、存储布局兼容。在 CKB 上,组合两个脚本,只需构建一笔同时满足两者的交易。组合发生在交易层面,而非代码层面。不同团队独立编写的脚本,无需事先协调,也能组合。
密码学没有特权。 CKB 的原生地址格式使用 secp256k1,与比特币和以太坊相同。但 secp256k1 并未硬编码在协议层,而是作为锁定脚本实现在系统 Cell 中。JoyID 基于通行密钥(passkey)的钱包,则通过完全不同的锁定脚本使用 WebAuthn 签名。两者地位平等,都是一等公民。没有预编译合约,无需硬分叉,也无需许可。
为什么值得考虑转向 CKB 或在其上构建
CKB 不是 Polkadot 的替代品,也没有试图成为替代品。它为一类特定问题提供了另一组原语。即使你继续交付 Polkadot 代码,也值得把它加入工具箱。
组合无需跨链开销。 如果你用过 XCM,在平行链之间路由资产或调用,就知道其中需要多少设计工作。CKB 基于 Cell 的单链模型绕开了这些。一个结合代币协议、身份协议和治理协议的应用,只需构建一笔涉及所有相关 Cell 的交易,就能把它们组合起来。无需消息路由,也没有协议开销。代价也确实存在:Polkadot 的平行链模型为每个领域提供独立自主的执行环境,CKB 则没有。但对于不需要领域自主权的应用,组合就简单得多。
所有权是一等原语。 在 Polkadot 上,访问控制主要经过运行时。在 CKB 上,所有权就是 Cell 的锁定脚本,由每个节点独立求值。多重签名、时间锁、基于通行密钥的认证(JoyID),或完全自定义的访问规则,都可以直接部署,无需触碰运行时或等待治理升级。Polkadot 并没有真正与此对应的设计空间。
执行模型可以直接检查。 在 CKB 上,交易体列出了它消耗和生成的每个 Cell。没有隐藏的副作用,没有在区块层面悄悄修改你未要求修改的状态的运行时钩子,也没有在幕后触发的 on_initialize。你仍需审计脚本本身,因为它们从 cellDeps 加载,而非内联在交易中。但任何交易的状态影响,也就是哪些 Cell 如何变化,都在交易体里完整可见。Substrate 和 PAPI 的设计很清晰,而这种将状态影响明确列出的思维模型,对审计、dApp 安全评审和协议分析尤其有用。
从 Polkadot 转向 CKB,不是简单换个同类平台。工具不同,思维模型不同,可组合性的实现方式也不同。但 CKB 正在回答的问题值得理解:如何构建一条可编程的 UTXO 链,让每个 Cell 都携带自己的规则,规定谁能花费它、它能如何变化。无论你最终在哪里交付代码,这都值得了解。
延伸阅读与入门
官方文档
- Nervos 文档:主要入口
- Nervos CKB 简介:基本技术概念和术语讲解
- CKB 入门:CKB 的工作原理及动手配置
社区学习资源
- Learning CKB:分为 5 个阶段的 24 节课程
- 45 分钟学会 CKB:快速入门仓库