在 CKB 上设计一个 UDT 标准的方法:Part 1

本文是基于 Nervos Talk 上发表的「关于UDT 标准评估规范的讨论」(https://talk.nervos.org/t/discussion-on-udt-standard-evaluation-criterion/3774) 一文中提出的可能的问题和可行的方案。
本文主要讨论有哪些不同种类的用户会使用 UDT 接口,UDT 接口应该设置在哪一抽象层,我们应该支持哪些操作和查询功能,以及我们的标准为什么需要我们设定一套确定的架构和一系列所需的 Cell,而不是像 ERC20 这样提供简单的功能性 API。然后,我将简要地讨论一些架构方案,并提供一份关于 Cell 的初步、非正式和上层的描述性文档,支持 UDT 操作和查询功能。
作者:Tannr,Nervos 工程师,拥有分布式和 P2P 网络的开发经验,喜欢学习分析哲学和形式系统。
原文链接:https://talk.nervos.org/t/approach-to-designing-a-user-defined-token-standard-on-ckb-part-1/3855
译者:史迪仔
抽象与编程接口
CKB 的编程模型和其他大多数智能合约平台有很大的不同,因为状态生成并不会发生在 CKB 链上;在 CKB 上,合约只是脚本,用于验证执行它们的转账是否有效。这样的设定,对于设计 UDT 标准来说,有两点意义值得注意:
意义 1:查询接口 → 标准化数据定位
第一个影响是,合约不会提供可由外部调用的请求—响应或基于事件的接口。这些合约不会作为查询接口返回查询信息,也不会作为转账接口产生链上副反应。这意味着在 CKB 上,为智能合约设置查询接口是没有意义的。毫无疑问,查询 Token 信息的功能是至关重要的,因此就提出了一个问题:如果不是由智能合约提供的 API,那应该是什么?查询程序可以访问节点提供的 RPC API,只要向这些 RPC 提供参数,并通知 API 服务器具体的查找位置,节点就可以访问链数据。所以,标准化智能合约的逻辑是必要的,但这并不足以支持在 CKB 上进行 UDT 查询:我们必须标准化关键 UDT 数据的存储位置,以便服务器可以轻松地查找到该数据。
为了标准化 UDT 数据的存储位置,我们必须标准化由 Cell 组成的最小必要架构。在这个设计过程中,我们应该记住,标准应该是可扩展的,这样才可以适应各种用例。在像 ERC20 这样的标准中,智能合同的功能签名和预期返回值都是指定的,而实现细节几乎完全由开发人员决定。相比之下,CKB-UDT 标准要求我们至少需要指定一个基础结构(以及某些具体的实现细节),这限制了开发人员构建自定义 Token。灵活性和可扩展性是 Nervos Network 高度重视的价值观,所以尽管基本架构设计规范是必须的,但还是要非常注意,尽量不要牺牲灵活性和可扩展性。
意义 2 :编程接口 → 转账规则集
第二个影响是 CKB 上的智能合约,虽然写在 Cell 内,但实际上是转账层面的规则集。这些合约 —— 从现在开始称为「脚本」 —— 在转账环境中执行。它们可以访问转账的所有信息,而不仅限于它们所连接的 Cell。当然,转账可以在其不同的 Cell 中包含多个脚本。因此,可以将脚本看作是转账的整个规则集的特定子集,其中规则集是每个脚本内规则的集合。转账,而不是脚本,在网络上实现状态变化。
当开发人员希望在 Ethereum 上部署自定义编程行为时,他们的重点在智能合约的行为。而在 CKB 上,把重点放在转账行为上(转账描述了一组状态的更改或行为),然后提供开发脚本来执行转账规则,这样做更有意义。换句话说,虽然许多智能合约平台关注于实现自定义编程行为的智能合约,但 CKB 采用转账优先的方法。转账的设计是最重要的;脚本的存在仅仅是为了确保遵循交易转账设计的规则。因此,虽然在一个典型的智能合约平台上的自定义 Token 标准将为智能合约定义一个编程接口,通过不同的合约函数与 Token 操作相关联,但在 CKB 上的自定义 Token 标准将定义一个转账优先的接口,通过不同的转账结构与特定的 UDT 操作相关。
UDT 标准的要求
在设计 CKB-UDT 标准的第一步是要了解谁将使用它和为什么使用它。这将告知我们需要支持哪些具体操作和查询功能。具体操作将被映射到转账规则集,而查询将被映射到标准化的数据位置、大小和格式。由于转账受到规则集的约束,规则集是转账执行时在结构和内容上的规则,因此指定转账规则集还需要指定最小必要结构。正如我在上面一节中提到的,鉴于 CKB 的编程模型,指定标准化结构是不可避免的。
用户
用户主要是 DApp 开发人员、钱包、交易所和其他代表终端用户执行状态生成或查询的服务。我在这里没有提到 Holder,也没有提到 DApp 用户,因为前者和后者都是通过其他界面使用 UDT 的。因此,实际上,只有 Token、DApp 以及离链软件服务的开发人员才会直接与 CKB-UDT 进行交互。
再将用户分为三种不同类型中的一种或多种:开发人员、生成器和查询器。此上下文中的开发人员是那些直接从事某个自定义 Token 的开发人员。生成器是指提交 UDT-specific 转账的任一外部服务,而查询器是指查询 UDT-specific 信息的任一外部服务。
在进一步讨论 UDT 标准应该支持的实际查询和状态更改之前,理解在 Nervos Network 上提出的任何特定规则都需要受到整个系统的约束,这十分重要。
成本限制
UDT-specific 转账所需执行脚本的计算开销。
在链上存储 UDT-specific 状态的容量成本。
转账成本是指执行一项操作需要多少笔转账,以及每笔转账的大小(这会影响转账手续费)。
可用性约束
可扩展性,使开发人员能够在标准兼容的前提下构建和自定义他们的 Token。
查询复杂度,用于查询器收集和解析相关信息,包括收集需要包含在转账中的信息。
UDT 采用度,取决于加入新的功能来支持一种新的 UDT,需要进行多少工作。
我们在 UDT 标准中支持的操作和查询功能,最终需要满足各种不同的用例,包括定制货币政策、为产品定制发行计划以及与其他 DApp 的互操作性。
注意:鉴于 CKB 的编程模型,将 UDT-specific 的转账执行规则的脚本和用户实际持有 Token 解耦。用户在 Cell 中持有的实际 Token,我将称之为「UDT 实例」,而负责存储系统级信息和执行转账规则集的元数据和脚本集合是我将统称为「UDT 定义」。后面将详细介绍。
查询和操作
查询
查询某地址下 UDT 余额
获取 UDT 元数据
获取 UDT 转账的依赖项
获得 UDT 实例的实际使用者
获得 UDT 定义的实际创建者
从定义中获得 UDT 实例的实际创建器
汇集持有该 UDT 实例下的地址
操作(状态更改)
发送 UDT 实例
批准 UDT 实例的使用者
烧毁 UDT 实例
创建新的 UDT 实例
更新 UDT 定义
创建带有初始 UDT 实例供应的新 UDT 定义
架构规范
设计这个规范的挑战之一是每一个决策都约束着后面的决策。基础的架构规范对于说明转账结构和规则,以及说明应该如何进行数据查找、查询都至关重要。我们需要架构规范来提出转账生成和查询规则(因为转账和查询并不意味着需要指定所涉及的 Cell),但是我们又需要交易生成和查询规则来理解哪些架构决策,从一开始就是最有意义的……这似乎是循环的。
所以我要做的是确定一些初步的架构,使用这个初步的架构来设计转账规则集和查询操作,然后标明修改这个架构的哪些部分,可以更好地支持实际的规则。
CKB 编程模型的第一个架构决策 —— 或者可能是架构特征 —— 是状态变更的逻辑和受该逻辑约束的实际状态的分离。对于状态的任何组成部分(例如,Cell 或 Cell 集合),在将来可能会发生变化,必须有另一个包含验证逻辑的状态组件。这里注意,「状态组件」是一个逻辑组件,因为它指的是一个或多个 Cell。
因此,如果我们有用于人们进行转账处理的 UDT 实例,那么我们必须有第二个 Cell,它包含这些 UDT 实例的验证逻辑。我称之为「UDT 定义」。由于我们希望能够更新验证逻辑以减轻某些情况(如安全漏洞或在 UDT 用户批准下允许我们进行策略更改),因此我们必须有第三个 Cell,其中包含更新这些 UDT 定义的验证逻辑,我称之为「UDT 标准」。
除了元数据之外:上面我提到过,UDT 定义不仅仅是验证逻辑,而且还包括所有不属于 UDT 实例的元数据。问题是: 在哪里存储这些元数据?元数据应该有自己的 Cell 吗?是否应该将其作为 agrs 存储在 UDT 定义的脚本(即「UDT 标准」)内?即使某些 UDT 的元数据和验证逻辑被分割成单独的 Cell,这些 Cell 仍然统称为「UDT 定义」。因此,我使用的 UDT 分类法不一定 1:1 映射到实际的 Cell;它是一种逻辑分类法,而不是物理分类法。
因此,这个架构分为三层:
UDT 实例
UDT 定义
UDT 标准
UDT 标准限制了涉及 UDT 定义的有效转账,而 UDT 定义限制了各自 UDT 实例的有效转账。
Nervos 提供与开发人员提供的 UDT 标准 Cell
在 UDT 标准层面,1个 UDT 标准会有很多 UDT 定义。在 UDT 定义层面,一个 UDT 定义会有很多 UDT 实例。
问题是:如果开发人员负责实现 UDT 定义,那么他们是否也负责构建 UDT 标准,还是应该由 Nervos Network 提供?
我们的目标是允许灵活的货币、治理和发行政策,因此有两种方式可以推进。第一种方法是为社区开发人员提供一个 UDT 标准 Cell,并通过 args 使其具有足够的可配置性。第二种方法是提供 UDT 标准必须强制执行的最小规则集,然后开发人员可以以他们希望的任何方式构建 UDT 标准 Cell,只要它满足必要的规则。
如果我们最终希望将元数据存储为 args 发送到 DUT 标准 Cell,那么 args 的顺序和含义需要标准化,以便其他服务器可以检索元数据。如果标准 Cell 是由 Nervos Network 提供的,这会更容易,因为 args 可以通过编程实现。但是,如果允许开发人员构建自己的 UDT 标准 Cell,他们可以省略某些可选功能,而不需要通过 args 将其配置为禁用。
想象一下下面的场景:Nervos Network 提供的 UDT 标准 Cell,允许在初始供应之后铸造新的 Token,通过启用或禁用 w/a 布尔型 arg,并设置铸造的额外参数(例如一次多少,是否有利益相关者投票,批准某些铸造者作为签署者等)。他们的确需要可升级性,所以他们将可升级性设置为 true,并使用另一个 arg & include 进行额外的配置。
现在,无论何时升级 UDT 定义,UDT 标准 Cell 都包含这一段冗余代码,从而增加了不必要的转账大小。但是,如果他们构建自己的 UDT 标准 Cell,他们可以省略与其用例无关的任何功能。一个可能的解决方案是标准化 args,而不管在其 UDT 标准脚本中实际包含了哪些代码。因此,即使标准脚本不将第三个 arg 视为「可删除的标志」,它们仍然需要在那里包含一个「false」值,以表明它不可删除,并且必须编写脚本以忽略该标志。然而,这是一个「陈腐」的解决方案。
元数据作为 UDT 定义与元数据 Cell 的参数
但是,想象一下另一种情况,即元数据(例如总供应量)存储在元数据 Cell 中,而不是脚本中。这些元数据中的一部分是脚本所必需的。例如,在增加或减少供应(铸造或销毁)的情况下,对脚本进行访问获取总供应量非常重要。因此,将总供应量作为 args 包含到脚本中比存储在完全不同的 Cell 中更容易。
对此有两种解决方案。第一种是创建一个元数据 Cell,并通过一个 arg 提供带有 outpoint 的脚本。那么转账必须在 deps 字段中将这个 outpoint 设置为 dep。脚本将以这种方式加载元数据并进行验证更新或创建。这样做的问题在于,加载数据需要额外的计算开销。幸运的是,CKB 编程支持部分数据加载。
第二种解决方案是拆分元数据;一部分存储在 args 中,一部分存储在元数据 Cell 中。Args 将存储对状态更改逻辑至关重要的元数据,而其他元数据将存储在元数据 Cell 中。但是,这使得整个事情变得复杂,因为现在元数据存储在两个地方,一些与一个脚本的验证逻辑相关的元数据可能与其他脚本的验证逻辑相关。例如,totalSupply 对铸造和销毁十分重要,但是销毁也可以由 Holder 执行。在 Cell 中存储元数据还提供了更大的可扩展性,因为开发人员可以将额外的元数据存储在更复杂的数据结构中。因此,对包含脚本相关数据的 Cell(元数据 Cell)的引用可以作为 arg 进行传递。
在设计元数据 Cell 的结构时,我们需要确保脚本相关字段是序列化的,这样脚本可以部分加载数据,而不必加载元数据 Cell 的全部内容,因为一些元数据片段将与某些特定的脚本相关,而另一些则不会。例如,如果数据存储为从键到值的映射,为了加载一个或两个相关键,脚本必须加载整个映射。列表结构允许部分加载,但是我们必须确定如何处理可选数据。在映射中,键可以被省略。在列表结构中,被省略的键会改变其他元数据的字节地址,因此仍然需要加载整个列表。
一种解决方案是标准化字节地址及其含义(例如,第一个 x 位是专门用于名称的),省略一个可选的元数据片段用「null 字节序列」表示。这里的权衡是,如果开发人员希望从元数据 Cell 中省略某一部分数据,他们仍然需要占用相当于存储每一部分元数据所需容量的容量,而不管某些部分是否被认为是可选的。
Hashmaps 从一开始就占用了更多的空间。带有两个键值对的 Hashmaps 比带有两个元素的数组占用更多的空间。因此,在最坏的情况下,当包含所有可选的元数据时,基于数组的结构占用的空间较少,并且支持部分加载,而映射则不支持。我们必须决定使用哪种数据结构,无论是列表、映射还是其他数据结构。正确的序列化方法可以解决这些问题中的一些问题,但是在这种情况下,脚本和查询器都会有额外的反序列化开销。
作为锁和类型脚本的 UDT 标准
UDT 标准 Cell 用于为在 UDT 定义上执行的操作提供验证逻辑。包括权限管理以及确保有效的状态更改和数据一致性。根据 CKB 的编程模型,有效的状态更改是由类型脚本进行处理的,而权限是由锁脚本处理的。因此 UDT 标准在物理上由两个 Cell 组成 —— 一个锁脚本和一个类型脚本。
不幸的是,这使得开发人员的工作变得复杂,因为他们需要存储的 Cell 越多,开发越多的 UDT 就需要首先拥有更多的容量。但是,允许他们开发自己的 UDT 标准 Cell 可以删除冗余代码,从而节省容量。所以这是一个权衡。也许最好的解决方案是为想要使用标准 Cell 的开发人员提供标准 Cell(因为这样也节省了开发时间),而开发人员也可以自由地开发符合他们想要支持的可选操作的 UDT 标准 Cell,只要所需的规则集是由他们的自定义脚本强制执行的。
到目前为止的架构概述
UDT 实例
UDT 定义 —— 类型脚本 Cell;元数据单元
UDT 标准 —— 类型脚本 Cell;锁定脚本 Cell
Part 1 总结
在本文中,我说明了转账是 CKB 开发的主要编程接口,而不是脚本。我提出了一组 UDT 标准应该支持的操作和查询,解释了为什么我们的 UDT 标准需要为社区开发的 UDT 指定一个特定架构,然后描述了一个上层的 UDT 架构,这个架构仅仅是支持所需操作和查询所必需的一组单元。
在下一篇文章中,我将深入研究转账规则集,然后基于这些规则集,更深入地描述 UDT 架构。例如,如果更新了 UDT 定义,那么 UDT 实例对 UDT 定义的引用就不应该保持有效,因此,UDT 标准必须强制执行类似于 Type ID 功能的操作。另一个例子,UDT 定义应该提供防伪保护,但是初始供应的 UDT 创建必须绕过这一点。如何设计 UDT 创建操作(通过提供特殊权限、使用一次性授权等)这会影响哪些 args 并传递给哪些脚本,等等。