系列第一篇,介绍如何保证 DAS 账户的唯一性。本系列将向大家阐述DAS 创始人 TimYang (杨敏)的设计思路和开发历程,让大家了解如何在世界上第一个基于 UTXO 架构的公链 CKB 上构建产品级应用。
近日,DAS 创始人 TimYang (杨敏)在 Nervos CKB 上开发了 DAS 去中心化账户服务。借着这次的产品开发,TimYang 将通过《从 DAS 开始了解 CKB 应用开发》系列文章,向大家阐述他的设计思路和开发历程,让大家了解如何在世界上第一个基于 UTXO 架构的公链 CKB 上构建产品级应用。 在第一篇文章中,Tim 将向大家介绍他们在设计 DAS 时面临的第一个大问题 —— 如何保证 DAS 账户的唯一性。欢迎阅读及体验。
DAS(Decentralized Account Services),是基于 CKB 构建的去中心化账户服务。DAS 项目本身,旨在为新世界提供一套兼具抗审查性、唯一性、可识别性的账户体系。在 DAS 的第一阶段,它看起来像是以太坊的 ENS,并且具备一些比 ENS 更为优秀的特性。但 DAS 要做的,不仅仅是更好的 ENS,而是试图为加密世界的「去中心化账户/身份」这块拼图,带来新的定义。
DAS 不是一个概念性产品,它目前已经运行在 CKB 测试网上,并预计于近期上线主网。可以通过 https://da.services 体验测试版本。
DAS 是基于 CKB 开发的区块链应用。在诸多公链中,为什么我们要选择基于 CKB 来进行开发呢?原因有二:
CKB 是少有的在 UTXO 模型之上构建智能合约环境,并主张「链下计算,链上验证」的公链平台。这些主张和设计经过了充分的考量,非常具有前瞻性,但同时也带来了全新的去中心化应用开发范式。习惯了中心化应用开发和以太坊智能合约开发的开发者,在刚开始接触 CKB 开发时,会充满不适应。加之目前尚没有标杆应用出现,这让开发者们对于 CKB 到底能做什么,是不是真的值得花精力去学习 CKB,充满疑问。
《从 DAS 开始了解 CKB 应用开发》系列文章的目的,也正在于此。我们将我们在 DAS 实践过程中的问题、思考,以及解决方案整理成一系列文章,让大家了解我们是如何基于 CKB 构建产品级应用的。希望借此给更多开发者带来启发,了解 CKB 能做什么,应该怎么做。
需要说明的是:
我们将在第一篇文章中,探讨 DAS 面临的第一个棘手问题:
每个 DAS 账户都需要一个 Cell 来存储其数据,Cell 是通过不同交易来创建的,这意味着 DAS 系统的全局状态数据是分散存储在各个角落的。同时每个 DAS 账户又必须具有唯一性。那么,当一个 DAS 账户注册行为发生时,我们如何判断该账户是否已经存在呢? 我们把这个问题一般化:对于分散存储的数据集,在插入数据时,如何保证每条数据的唯一性?
对于习惯了中心化应用开发和以太坊智能合约开发的开发者而言,要保证注册的账户不重复,这一件几乎不用思考的事情,你可以把所有的数据都放入合约的存储空间,由于这些数据是集中存储的,所以在插入数据之前,你只需要先检索一下数据是否存在即可。
但鉴于 CKB 的 Cell 模型,数据分散存储在用户自己的空间中,我们无法在链上去检索所有数据。毕竟我们不可能在一笔交易的输入中,放下所有已经存在的 Cell。即便能放下,链上脚本也无法知晓这笔交易在构造时,交易发起人是否真的将所有需要 Cell 都放到了输入中。
我们将列举所有我们曾考虑过的保证唯一性的方案。之所以把最终没采用的方案都拿出来分析,是希望大家可以通过观察我们走过的「弯路」,开始适应 CKB 的开发范式,避免以后自己走「弯路」。
在讨论方案之前,我们应先明确我们的设计原则。正是这些原则,最终决定了我们采用什么样的方案。这些原则,优先级从高到低依次为:
如果你只关心最终方案,可以直接跳转到「方案六」开始阅读。
这是最符合直觉的一种方案,毕竟以太坊的智能合约就可以这么干。创建一个 GlobalStatusCell
,在 GlobalStatusCell
的 data 中存放所有已注册的账户。当新的注册发生时,在交易中把这个 GlobalStatusCell
作为输入,修改后的 GlobalStatusCell
作为输出。type 脚本检查新注册的账户是否已经存在,如果存在就返回非 0,交易失败;如果不存在,那就检查输出的 GlobalStatusCell
中是否包含了新账户,然后返回 0,交易成功,注册完成。
这种思路不可行的原因在于:
GlobalStatusCell
作为输入花费掉,而一个 Live Cell 只能花费一次,那意味着同一时刻,永远只能处理一个注册请求。竞争 Cell 失败的用户,不得不一遍又一遍的签署交易,直到成功的竞争到 Cell。事实上我们会发现,「Cell 竞争问题」是在 CKB 上开发应用时,要时刻警惕的问题。它对用户体验的影响可能是致命的。
既然一个 GlobalStatusCell
放所有账户会导致竞争,那我们把账户分散到多个账户呢?比如,对账户名做 hash,将所有 hash 值前 3 位相同的已注册账户放到同一个 SubStatusCell
里。当一个新的注册产生时,必须将对应的 SubStatusCell
消费,以修改其内部数据。
这个方案仍存在一些问题:
SubStatusCell
,需要提前创建 4096 个 SubStatusCell
,假定在一个周期内有 50 个并发的注册请求,按照「抽屉原理」,仍有 26% 的概率出现 Cell 竞争。尽管 50 的并发请求稍显苛刻,在早期可能根本达不到,但应该认识到:SubStatusCell
数量固定,这种竞争的概率,无论在哪个阶段都是一样的SubStatusCell
初始时只需要 100 CKB 作为其 capacity,那初始化所有的 SubStatusCell
就需要 409,600 个 CKB。再次强调:在 CKB 上开发应用时,应该时刻关注你的应用会占用多少 CKB 存储空间,因为总的状态空间是极其有限的。
所有的注册都要通过 DAS 官方的服务进行,DAS 官方判定可注册后,用官方私钥签名一笔交易,向用户发放 DAS 账户 Cell。这个方案在实现上非常简单,但问题也很明显:
比如,找 7 个「可以信任」的组织作为超级节点,管理各自的私钥。超级节点们运行超级节点服务程序,将所有已注册的账户存储在自己的中心化数据库中,当一个注册请求产生时(指用户构造一个包含注册信息的 Cell),各个超级节点将判断其是否已经注册过。如果未注册过,那就用私钥签名一笔交易,释放一个表明「本超级节点认为这个账户可以注册」的 Cell,当有 4 个以上的超级节点都释放了这样的 Cell 时,其中一个节点就会汇聚所有的这些 Cell,作为依据去创建 DAS 账户。
这种思路,看似可以很好的解决方案三中的一些问题,但却引入了更多的问题:
既然要实现注册时去重这么复杂,那干脆注册时就不去重了。任何人在任何时候都可以「注册」任何账户,然后在用户要查询一个账户的解析记录时,由解析程序去找出那个最早「注册」的账户,将其作为合法的账户返回给用户。
这种独特的思路,存在的问题主要是如何保证客户端运行「合理」的解析程序:
如果不能保证大家始终运行相同的最新的解析程序,整个系统势必会在应用层面上不一致。由此会引发各种形式的欺诈,最终大家会对这个系统失去信心。
最后,我们来介绍 DAS 最终所采用的方案 —— 有序链表。
我们将我们要解决的问题,做更一般化的表述:
对于分散存储的数据集,在插入数据时,如何保证每条数据的唯一性?
答案是,使用逻辑上的有序链表。感谢 @guiqing** **的启发。
每个已注册的 DAS 账户,都有一个 Cell 用来存储其相关的信息,称为 AccountCell
。我们要求所有的 AccountCell
按某种顺序排序,比如按账户名做字典序升序。当要注册一个新的 DAS 账户时,其 AccountCell
必须插入到合适的位置,以保证不破坏这种顺序。
AccountCell
的简化结构如下:
注意:account_id 取值为账户名,仅仅是为了表述方便,实际上 DAS 使用的是其账户名 hash 的前 10 位。
我们假定链上已经有 a.bit,b.bit
,现在一个用户要注册 d.bit
,注册前链表结构如下:
注册后的链表结构如下:
随后,有一个用户要注册 c.bit
,那么注册后的链表结构如下:
从上面我们可以看到,当需要注册一个新账户时,需要对链表中处于其前方的 AccountCell
的 next_account_id
字段进行修改。这也意味着,需要构造一笔交易,能消费掉其前方的 Cell,并创建相对应的新 Cell。对于应该修改哪个 Cell,也即新的 DAS 账户应该插入在链表的哪个位置,这些都是由用户使用的注册程序根据链上的状态,自动帮用户完成的(看,链下计算)。
那如果注册程序不小心(或者用户恶意的)构造一笔交易,试图创建重复账户,或者将账户插入错误的位置,会怎么样呢。这时候我们的 type 脚本就起作用了,会导致这类交易失败不被打包进区块(看,链上验证)。
Cell 的 type 脚本会在 Cell 作为输入和输出时都运行。我们的 type 脚本就可以做一些判断,比如:
AccountCell
的 account_id
是否小于新注册的账户的 account_id
AccountCell
的 next_account_id
是否大于新注册的账户的 account_id
AccountCell
的 next_account_id
是否等于新注册账户的 account_id
next_account_id
是否等于 inputs 中引入的父 AccountCell
的 next_account_id
所以上述的这些判断结果如果都为真,且整个交易结构也满足其他一些必要的条件,那么 type 脚本就会返回 0 ,意味着这是一笔合法的交易,当这笔交易纳入区块之后,账户也就注册完整了,DAS 系统的状态完成了更新。而对于不满足这些条件的交易,根本就是不合法的交易,也就不会注册成功。
可以看到,这个方案满足我们前面设定的 4 个设计原则。
我们要理解,之所以「复杂」,其背后的本质原因是 UTXO 模型,是 UTXO 模型导致了数据的分散存储。
那为什么 CKB 要采用 UTXO 模型,ETH 的账户模型不就很好吗?
UTXO 模型和账户模型各有优劣,UTXO 模型的部分优势在于:
我们更应理解,感受上的「复杂」,更多的来自于我们对新范式的不适应。
可以看到,type 脚本的约束,更像是一种协议。他规定了一笔交易应该有什么样的输入和输出,但谁来创建交易,以什么方式创建交易,并不是协议所关心的问题。
是的,如果多个新的注册的账户都应该直接插入到某个 AccountCell 的后面,那就会面临 Cell 竞争的问题。所以,我们将在下一篇文章中介绍,如何通过一种我们称作 「Keeper」的机制,在方案六的基础上,彻底解决 Cell 竞争问题。
最后,如我们在开头提到的那样:
在面对一个问题时,我们采用的思路和解决方案,不一定是最优解,甚至大概率不是。但这些满足我们场景的思路和解决方案,若能给大家带来启发,目的便已达到。
未完待续……
在下一篇文章中,Tim 将为我们介绍一种称为「Keeper」的机制,来处理 Cell 竞争问题。欢迎大家前往 https://talk.nervos.org/t/das-ckb-das/5669 催更。
如若您有更多关于 DAS 产品的使用心得,以及在 CKB 上开发的见解,欢迎前往 Nervos Talk 论坛讨论:https://talk.nervos.org/
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!