[RFC] CellScript 的包管理:一个 Go 语言风格的、基于 GitHub 的 CKB 合约包管理注册表

Hi,社区

上个月我发布了CellScript的早期预览版,得到了英语社区的一些建设性和温暖的反馈,现在我想用中文来和大家讨论一下未来的包管理的方向。

我在CellScript的后续开发路线 (大约在接下来一两个月) 中做了一个更去中心化也更解决成本的设计,因为我谨慎地认为这个阶段,可能发布和引用智能合约库,不应该需要搭建自定义 API 服务器、维护专门的数据库,或者为仅开发者需要的源代码支付链上存储费用。

CellScript 的第一阶段注册表将采取一种刻意极简的方案:一切都是 Git,一切都在 GitHub 上,而链上只记录那些在运行时真正重要的信息。

本文将介绍整个设计,解释我选择这个模型的原因,并展示如何端到端地使用它。

问题所在

大多数我们用过的包管理注册表:如 crates.io、npm、PyPI, 都采用中心化服务器模式。你向服务器发布,服务器存储你的包,消费者从服务器下载。这对应用开发来说非常有效。但智能合约不同。

CKB 智能合约的依赖不仅仅是下载和编译的源代码。在生产环境中,构建者或钱包需要知道具体的链上事实:该引用哪个 CellDep,data_hash 是什么,该指向哪个 OutPoint,该部署是活跃的还是已弃用的。源码包回答的是 “写了什么代码”,而生产部署回答的是 “你应该实际使用链上的哪个Cell” 。这两个层面都很重要,在新的设计中,它们通过加密哈希绑定在一起,而不是通过命名约定。

同时,CKB 生态系统仍然相对很小。运行一个专门的注册表服务,如果带有 API 端点、存储后端、可用性保证和身份验证,在这个阶段我忖度,暂时属于过度工程。我们更需要一种今天就能工作、且零基础设施的方案,但又能随着生态系统的需求增长而变得更加严谨。

核心理念:两层架构,零服务器

CellScript 的注册表首先会有两个层级,都由 GitHub 上的 Git 仓库提供支持。

第一层级是一个发现索引:一个单一的、轻量级 Git 仓库,将 namespace/name 映射到源码仓库的 URL。把它想象成一本电话黄页。只有当有人注册一个全新的包时,它才会更新。如果你正在发布一个已有包的 1.3.0 版本,发现索引完全不会改变。

第二层级是每个包的版本索引,称为 registry.json,它位于每个源码仓库内部,紧挨着 Cell.toml。当你运行 cellc publish 时,它会计算源码哈希,读取你的构建产物,并将一个新的版本条目追加到这个文件中。然后你提交、打标签、推送。就这样。不需要向任何外部索引提 PR,不需要调用 API,不需要维护服务器。

关键的洞察是 Go 风格的约定:如果没有显式的发现条目,cellscript/amm 会自动解析为 github.com/cellscript/amm。你不需要注册任何东西。你只需要把仓库推送到约定位置,它就能工作。发现索引只存在于打破约定的情况,托管在其他地方的仓库,或者仓库名称与包名不匹配的情况等(暂定)。

为什么这对智能合约有效

这里有一个容易被忽略的微妙之处。在传统的包管理注册表中,包就是身份标识的单元。你安装了 [email protected],故事就结束了。但对于智能合约,包只是第一层。

CellScript 使用我们称之为三层身份模型的机制。一个包存在于三个不同的身份范围内,每个范围回答一个不同的问题:

包身份(Package Identity) 回答"写了什么源代码?"它由 Cell.toml 和注册表索引承载,在编译时验证。关键字段包括命名空间(namespace)、名称(name)、版本(version)和源码哈希(source_hash)。

构建身份(Build Identity) 回答"编译器产生了什么?"它由 Cell.lock 承载,在构建时验证。关键字段包括 compiler_version、artifact_hash、metadata_hash、schema_hash、abi_hash 和 constraints_hash。

部署身份(Deployment Identity) 回答"在哪条链上的哪个Cell?"它由 Deployed.toml 承载,在运行时验证。关键字段包括 network、chain_id、tx_hash、output_index、code_hash、hash_type、data_hash、out_point、dep_type、type_id 和 script_role。

每一层都有独立的意义,但通过锁定文件(lockfile)与上下层进行加密绑定。如果有人在发布后篡改了源代码,source_hash 将不匹配。如果有人调换了构建产物,artifact_hash 将不匹配。如果有人指向了错误的链上Cell,data_hash 将与链上事实不匹配。系统会失败关闭(fail closed)。

这就是为什么我们可以使用基于 Git 的注册表。注册表不需要成为信任锚点。信任锚点是加密哈希,它们在每一层都被独立验证。注册表只是一个发现机制——一种找到源代码的方式。一旦找到,你就验证它。

三个文件

CellScript 使用三个文件来分离关注点。这受到了 Move/Sui 的 Move.toml / Move.lock / Published.toml 分离方式的启发,但针对 CKB 的 CellDep 和 OutPoint 模型进行了适配,而非 Sui 的原生包对象模型。

Cell.toml — 部署意图

Cell.toml 是源码包声明。它描述的是开发者打算部署什么,而不是实际部署了什么。针对注册表的关键新增字段是 namespace

[package]
name = "amm_pool"
version = "1.2.0"
namespace = "cellscript"

[dependencies]
token = { version = "0.3.0", namespace = "cellscript" }

[build]
target_profile = "ckb"

依赖可以从注册表(通过命名空间和版本)、本地路径或 Git URL 解析。解析优先级是 路径 > Git > 注册表,这意味着你总可以用本地检出覆盖注册表依赖以进行开发,而无需更改任何配置。

Cell.lock — 构建身份

Cell.lock 是源码与部署之间的加密绑定点。它记录精确的依赖版本、Git 修订版本、源码哈希和构建哈希。它对重新验证是自包含的——urlrevision 字段让你无需重新查询发现索引就能重新克隆精确的源码提交。

version = 1

[package]
name = "amm_pool"
version = "1.2.0"
namespace = "cellscript"
source_hash = "blake2b:0xabcd..."

[package.build]
compiler_version = "0.19.0"
target_profile = "ckb"
artifact_hash = "blake2b:0x1234..."

[dependencies.token]
version = "0.3.2"
namespace = "cellscript"
source = { registry = "cellscript/token", url = "https://github.com/cellscript/token", revision = "f7e8d9c0..." }
source_hash = "blake2b:0x2222..."

[deployment.ckb.aggron4]
status = "deployed"
record = "ckb-testnet:0xaaaa..."

这类似于 go.sum——它用哈希值固定精确版本,使构建可独立复现。

Deployed.toml — 部署事实

Deployed.toml 记录从链上推导出的不可变的部署事实。它在部署交易确认后自动生成,且不得手动编辑。

version = 1

[package]
name = "amm_pool"
version = "1.2.0"
source_hash = "blake2b:0xabcd..."

[build]
compiler_version = "0.19.0"
artifact_hash = "blake2b:0x1234..."

[[deployments]]
network = "aggron4"
chain_id = "ckb-testnet"
script_role = "type"
tx_hash = "0xaaaa..."
output_index = 0
code_hash = "0xbbbb..."
hash_type = "data1"
dep_type = "code"
out_point = "0xaaaa...:0"
data_hash = "0xcccc..."
type_id = "0xdddd..."

这种分离很重要。Cell.toml 说"我想要 hash_type = data1"。Deployed.toml 说"位于 0xaaaa…:0 的Cell实际上有 hash_type = data1,这是链上证明"。一个是意图,另一个是事实。混淆这两者会导致正是智能合约系统应该避免的那种供应链漏洞。

教程:端到端流程

让我们走一遍一个包的完整生命周期,从编写到经过验证的链上部署。

第一步:创建包

cellc init amm_pool --namespace cellscript

这会生成一个带有 namespace = "cellscript"Cell.toml 和一个起始源文件。此时,没有 Cell.lock,没有 registry.json,没有 Deployed.toml。这个包纯粹是本地的。

第二步:添加依赖

编辑 Cell.toml 以添加注册表依赖:

[dependencies]
token = { version = "0.3.0", namespace = "cellscript" }

当你构建时,解析器开始工作:

发现索引告诉解析器在哪里找到源码。源码仓库内的 registry.json 提供版本元数据。该元数据中的 source_hash 会针对实际源码树进行验证。如果有任何内容被篡改,构建将失败。

第三步:发布

cellc publish

这会从你当前的源码树计算源码哈希,读取构建产物的哈希,并将一个新的版本条目追加到 registry.json。然后你提交并推送:

git add registry.json
git commit -m "publish v1.2.0"
git tag v1.2.0
git push --tags

注意没有发生什么:你没有向发现索引提 PR,你没有调用 API,你没有向服务器上传任何东西。版本元数据存在于你的源码仓库中。发现索引只需要更新一次,就是当包首次注册时。

第四步:部署到 CKB

这是工具链变得真正严肃的地方。我们不只是把数据推送到链上,我们要经过一个验证过的流水线。

构建步骤产生一个真正的 RISC-V ELF 二进制文件。cellc ckb-hash 计算该二进制文件的 CKB Blake2b 哈希。cellc deploy-plan 生成一个部署计划。cellc verify-deploy 验证该计划。然后,cellscript-ckb-adapter crate 中的 build_deploy_transaction() 构建一个带有 TYPE_ID、占用容量计算和找零输出的正规 CKB 交易——所有这些都在无头(headless)模式下计算,无需 RPC 连接即可构建交易本身。

交易提交并确认后,Deployed.toml 从本地计算的证据加上链上的 tx_hash 生成。生成过程不需要链上重新推导——适配器已经知道所有哈希字段。验证(一个单独的步骤)才是发生链上读取的地方。

第五步:交叉验证所有三层

部署后,你可以验证完整的身份链:

cellc package verify   # source_hash 匹配
cellc verify-artifact  # artifact_hash 匹配真实二进制文件
cellc registry verify  # data_hash 匹配链上Cell

或者以编程方式,如我会在端到端测试所做的:

// 包身份:source_hash
let computed = compute_source_hash(&pkg_dir).unwrap();
assert_eq!(computed, read_lock.package.source_hash.as_deref().unwrap());

// 构建身份:artifact_hash
let lock_artifact = read_lock.package_build.as_ref().unwrap().artifact_hash.as_ref().unwrap();
let deployed_artifact = read_deployed.build.as_ref().unwrap().artifact_hash.as_ref().unwrap();
assert_eq!(lock_artifact, deployed_artifact);

// 部署身份:链上 data_hash
let on_chain_data_hash = live_cell["cell"]["data"]["hash"].as_str().unwrap();
let computed_data_hash = format!("0x{}", hex::encode(ckb_data_hash(&artifact_binary)));
assert_eq!(on_chain_data_hash, computed_data_hash);

这三个断言验证了三个不同的事情:自发布以来源码没有改变,构建产物与编译结果匹配,链上Cell包含部署时的确切二进制文件。这个链条中的任何断裂都意味着出了问题,系统会失败关闭,而不是默默接受不匹配。

设计原理:为什么用 Git,为什么用 GitHub,为什么是现在

有几个设计决策值得更多解释。

为什么用 Git,而不用自定义 API? 因为 Git 已经解决了我们需要解决的问题:内容寻址存储、通过提交哈希实现的加密完整性、通过本地克隆实现的离线缓存,以及每个开发者都已经熟悉的工作流。构建自定义 API 服务器会解决同样的问题,但会增加运营负担、认证复杂性和单点故障,而所有这些都是为了一个目前服务CKB社区较小生态系统的注册表。

为什么用两层模型,而不是单一的单仓库索引? 因为发布新版本应该是向你自己的仓库执行 git push,而不是向别人的仓库提 PR。单仓库索引(如 crates.io 的索引仓库)要求每次版本发布都更新一个共享仓库。这会产生摩擦:CI 冲突、合并竞争、权限管理。我们的模型让版本元数据随源码一起传播,就像 Go 的 go.mod。发现索引只在新包注册时才会改变,这是一个罕见事件。

为什么专门用 GitHub? 我们并不锁定在 GitHub。发现索引映射到源码 URL,而这些 URL 可以指向任何 Git 托管服务。但 GitHub 正是 CKB 生态系统开发已经在进行的地方,它提供免费的仓库托管、可靠的可用性和熟悉的工作流。如果有人想自托管他们的源码,发现索引只需要一个 git clone 能访问到的 URL。

为什么用链下部署记录,而不用链上? CKB 的容量成本使得大多数全量的链上源码包存储不具吸引力。在链上存储版本元数据、模式清单和 ABI 索引会成倍增加成本,却没有共识层面的收益。这些是开发者产物,不是运行时状态。链上应该记录紧凑的部署事实(CellDep、OutPoint、data_hash),而不是取代整个源码分发系统。

代理怎么办? 第三阶段可以添加一个可选的缓存层,如 proxy.cellscript.xyz,但基于 Git 的路径是永久的规范机制,不是临时占位符。代理将是一个透明缓存,用于更快的安装和可用性保证,而不是替代品。如果代理宕机,cellc install 会回退到直接 Git 克隆。

接下来的计划

第一阶段是刻意保持极简的。我将交付两层 Git 注册表、三文件分离和三层身份模型。以下是我尚未严密计划的内容,以及原因:

链上类型脚本索引(第二阶段):一个通过 code_hash 或 TYPE_ID 索引部署的链上脚本。对于希望无需读取链下文件即可发现部署的钱包和构建者来说很有用。但 CKB 生态系统尚未证明对此有需求,而且容量成本是真实的。有需要时我们会构建它。

注册表代理(第三阶段):一个像 proxy.golang.org 那样的缓存层,用于更快的安装和可用性保证。基于 Git 的路径始终是主要的解析机制。代理是透明缓存,不是替代品。

审计签名和发布者身份(第二阶段):以后包或许可以携带可选的审计报告哈希和准入门槛状态(但是这里有安全性和去中心化的trade-off,可能需要一个利维坦和风险之间的折中。)。要求审计员在将部署标记为生产就绪之前提供加密签名是一个自然的扩展,但这首先需要一套密钥管理方案。

撤回和替代yanked 标志已经在 registry.json 的模式中。第一阶段记录它;第二阶段在解析器层面强制执行。

重要的是,这些未来的补充都不需要改变基本架构。添加代理不会改变发现索引的模式。添加链上索引不会改变 Deployed.toml 的生成方式。两层 Git 模型是永久的规范路径,其他一切都分层在其上。


CellScript 是 Nervos CKB 智能合约的领域特定语言。实现仓库位于GitHub - a19q3/CellScript: Domain-specific language for the Cell model. · GitHub

4 Likes

toml 中有 package.namespace 是否意味着, 其实也可以管理其他的 cellscript 之外的软件包?

比如 @xxuejie 和我有一个未完成的工作 ckb-bootstrapper, 试图实现只依赖 ckb 自身就能 reproducible build 出正确的 ckb binary. 很好奇你的看法?是否可以把 ckb 本身作为一个需要 e2e reproducible 的包在这个框架下管理?

很合理。不过成本也要相对收益来衡量,不同代码的价值也不同。我觉得最终会有一些代码元数据是需要保存在链上的——链上做索引,链下做存储。

Hi Jan,

这个问题很有意思。

我的理解是:目前的架构上确实已经留下了这个空间,但第一阶段我会先把 scope 收在 CellScript packages 上。

package.namespace 确实不必永远只服务 CellScript 包。更抽象地说,它是在给一个 package / artifact 一个稳定命名空间,然后用 source_hash → build/artifact_hash → deployment/artifact identity 把名字和真实的编译产物绑定起来。

对 CellScript 合约来说,这第三层是 CKB deployed Cell:OutPoint / code_hash / data_hash / CellDep。

对 CKB 本身或 ckb-bootstrapper 来说,它也可以被建模成同一套 identity framework 下的 package-like verifiable artifact。它的 identity chain 可能是:

source identity
→ reproducible build recipe identity
→ release binary / bootstrap artifact identity

当然,这可能需要一个不同的 artifact profile,而不是直接混进第一阶段的 CellScript registry scope.

我也同意你对链上 metadata 的判断:成本要视乎收益,而且不同代码的价值不同。

比较干净的边界如你说可能是:

  • on-chain: compact commitment + discovery/index facts
  • off-chain: full source, manifests, build logs, schemas, proofs, and larger metadata

这样 CKB 不需要变成 package database,但高价值 artifact 仍然可以获得 CKB-native 的 verifiable identity。

按照个想法发展,我原来的 Phase 2 主要是 CellScript 合约部署索引,你说的、更通用的 artifact commitment/index layer方向中,Phase2可以是其中一个子集。

也欢迎其他更详细的边界设计建议。

1 Like

我补充更新一个关于 registry 边界的想法,未来也可能和前面 Jan 提到的 package.namespace 是否可以扩展到 CellScript 之外的 artifact / ckb-bootstrapper 这类问题有关。

如果 registry 未来不只管理 CellScript 源码包,而是也管理 verifier、deployable contract、deployed artifact record,甚至更一般的 reproducible artifact,那么 resolver 的边界就需要更清楚。

我倾向于区分两类东西。

第一类是会进入 build / verification / deployment / TCB chain 的对象。比如 source library、runtime verifier、deployable contract、deployed artifact record、resolver-safe profile library。它们可以被 registry resolver 解析,但必须能被 source_hashartifact_hashABI hashdata_hashOutPoint以及CellDep 等可验证事实约束。

比如一个 BTC 风格的 verifier:

cellscript_btc_bip340_verifier_riscv

它不是 template,也不是示例代码;它是 runtime verifier / TCB artifact。如果某个合约依赖它,它就会影响运行时验证边界,所以必须声明 verifier_id、IPC ABI、artifact hash、build profile、security / audit status,以及生产环境下的 deployment CellDep facts。

第二类是 starter example、cookbook example、contract scaffold、protocol skeleton 这类起手材料。它们当然可以被发现、展示和复制,但语义应该是“复制成一个本地起手项目”,而不是作为 dependency 进入 build / verification chain。

对于这一类,入口更应该是 copy-style command,例如:

cellc new --template novaseal/mvb-starter
cellc cookbook copy novaseal/agreement-profile ./my-project

前者创建一个新的本地 starter project,后者把一个 cookbook / profile example 复制到已有项目中。两者复制完成之后,代码都变成本地项目材料,而不是 resolver dependency。

所以规则压成两句话就是:

Anything resolved as a dependency must be dependency-safe, artifact-safe,
deployment-fact-safe, or declared-TCB-safe.

Anything scaffold-only should be copied, not resolved as a dependency.
3 Likes