每周三,在杭州举办的固定线下技术交流活动中,来自秘猿科技的高级区块链工程师冯开开分享了对智能合约设计结构的思考。智能合约的设计和传统的应用设计有点不同。传统应用一般为了快速迭代是在产品之后考虑安全,但是 DApp 则需要在产品出来之前就考虑安全问题,它将会关系到账户资产、用户数据等问题,而且对 DApp 来讲,升级是个比较麻烦的事情,因此在智能合约设计时,结构是非常重要的部分。本文将为大家阐释如何来设计这个结构。Enjoy ~:)
目前 DApp 面临问题
首先,是关于 DApp 和 App。事物发展将会遵循技术为王、产品为王、最后到运营为王三个发展阶段。现在,区块链和 DApp 正处于技术为王阶段。整个市场上的 DApp,在性能和用户友好性上,都不如 App。DApp 的优势显而易见:去中心化,它是依附区块链的应用。但是我们认为很多 DApp 的短板,其实是因为底层区块链的限制。
其次,是关于安全。现在 DApp 爆发的安全漏洞很多,主要原因是区块链仍处于发展早期。开发 DApp 的基础设施和相关工具都很不成熟,但是黑客是很成熟的,在互联网上久经沙场,对 DApp 世界影响很大。所以,在设计 DApp 时,要了解区块链相关知识,这些是出于安全考虑。
最后,是关于成本。在以太坊中就是 Gas,部署智能合约将消耗一定 Gas。这是因为 DApp 很消耗 Gas,特别是部署一个大型 DApp(包括后面的维护、升级)。Gas 是什么?是资金。那么,有没有一种结构能够暂时忽略 Gas。这就分成两种方向,一是思考节约 gas 到细微处,用一种怪异不太舒服的写法来节约 Gas;第二种是走向宏观,整个结构是清晰明了的,但是可能会存在浪费 Gas 的行为。
解决方法
第一,是优美结构。一个优美的结构会带来 Gas 的节约,这是我一直相信的。那整个结构是包含哪些方面呢?最宏观的说指分层。这里分层和一般的 app 分层是相通的,比如应用层、逻辑层、数据层。
第二,是友好。因为现在一个大型的 DApp,如果有很好的模块化,可能会有十几个智能合约,他们中间可能还有依赖。那就要求你在部署时,需要格外小心。(友好就是说能够一键部署。 )
第三,是支持升级。这个是在 App 上很常见的,因为我们在开发一个 App 时,必须要进行版本迭代,新功能的增加,Bug fix...这个就是升级。而我们知道 DApp 在区块链上,由于区块链的不可篡改性,部署的合约就在那里了。这里面涉及升级的问题就比较复杂。简而言之,我们这个结构采取了支持升级的方式,升级比较简单的部分是算法部分,算法部分是纯粹的逻辑,替换可以做到无感知,只需要修改逻辑合约的地址即可。比较麻烦的是对数据结构的升级,数据结构涉及到实际数据,如果轻易改动就要兼容以前的数据(这种需求很常见)。数据迁移是一个比较麻烦的事情,在设计数据结构之初尽量确定数据结构,避免频繁的变动。数据结构确定之后,包括 CURD 以及一些 Check 的接口其实也可以确定。这些可以作为一个最小的数据合约单元。
第四,是访问控制。要对整个结构中的数据流转进行控制,这是出于安全性的考虑。
第五,是模块化。一方面,是出于安全考虑,所有东西在一个合约里会增加合约的复杂性,会对安全造成隐患。毕竟复杂是安全的天敌,越简单越安全,这是软件开发中的常识。所以我们要保证合约的简洁,一眼看过去就知道这个合约是干什么。另一方面,是保证函数的简单。一样的道理,函数简单意味着组成的合约也是简单的。
整个分层就是应用层、逻辑层和数据层,这个结构里面的三层,整个 DApp 是偏向后端的,这里三层是智能合约里面的三层。
结构设计
根据之前 hackathon 上做的项目,叫做 summerWar(区块链沙盒游戏),这个项目里的结构基本上遵循这种设计。
summer War GitHub 仓库地址:
https://github.com/CryptapeHackathon/SummerWars
an Arch : layer
这个是我们整个游戏的结构图,最左边的是 off-chain,是关于链下用户操作。register 是应用层、Permission 是访问控制、后面是逻辑层和数据层,后面是数据流转和调用的图 。
分层:register 是应用层,operator 是单纯逻辑 ,和数据没有关系。后面是数据层,整个数据单独和逻辑拆分。
权限刚才已经阐述了。在整个结构流转中,如果 operator 是操作层的话,用户需要先访问操作层合约,然后由操作层访问底层数据合约。这里限制访问控制,一是指控制各个层之间的调用关系,比如数据类合约严格控制只能让操作类合约来控制,不可能是随便的合约就能访问的。这里数据不仅是指数据,还包括数据的简单存取接口,相当于一个数据库的概念,包含存储和查询。 二是可以控制具体用户的操作。
An arch: auth
auth 模块实现了上述说的两种访问控制,在结构上是散布在各个层级之间及整个结构与外部用户之间。
对用户进行访问控制是指:假设有 id,就会先检查哪些地址可以操作,这里 id 是指整个 DApp 的身份,和区块链身份不影响,因为区块链由于去中心化原因是没有身份的。但是开发的 DApp 里面可以定义一个用户名,这个在 DApp 里是可行的,和区块链没有关系。所以这里可以对这个地址进行检查,允许哪些地址进行操作。至于层之间的访问控制,和用户访问控制类似,用户 id 是Dapp 通行的身份,各层合约可以在 register 查询到也可以把这些合约地址作为整个结构中通行的身份。
具体在底层实现要用到两种特性,modifier 和 函数的可见性。通过这两种特性结合能够达到以上效果:某个合约只能某些合约调用,某些合约只能由某些 sender 调用,这样的控制。
modifier:
https://solidity.readthedocs.io/en/latest/contracts.html#function-modifiers
函数的可见性:
https://solidity.readthedocs.io/en/latest/contracts.html#visibility-and-getters
整个下来,代码的组织结构可能就如下图所示:
App: register
整个结构的分层,我们先从上往下我们开始讲。首先是 App 层的 register。regitser 是什么角色?他是注册中心,是一个 hub,我们期望它能够保存所有 DApp 智能合约的相关信息(地址等),这也是一个用户接入的入口。在这里,除 operator 后续升级和注册外,它相当于一个交互枢纽,可以进行部署、初始化、注册和升级。这里能够实现在 regiter 部署之后,整个 DApp 的智能合约部分也就部署上去了。我们看一下 register 在整个结构中的位置,是在 off-chain 与 后面操作、数据层之间。
为什么要这样做呢?不能直接逻辑层+数据层?这样一个中心化的东西会不会影响整个 DApp 的去中心化?
DApp 去中心化属性是依靠后面区块链实现的,有一个中心化的东西并不影响。它的一个优点是简单。通过一个智能合约能够管理所有模块,这个 register 是不变的,相当于一个不变的点,用来链接各个模块,保证稳定,相当于 DApp 在区块链上一直会有一个稳定的地址长期进行服务。如果,需要支持升级那么很多模块都可改变。然而,如果所有东西可以改变,则会变得很难维护,所以使用 register,能够随时通过这个东西进行查询和操作。还有一个优点是能够节约 Gas。只部署 register,然后就完成了整个 DApp 的部署。是怎么实现的?这里合约可以用 new 来生成其他智能合约。new 并不节约 Gas,节约的是交易相关的消耗。
Register 包含三类接口
第一类接口:初始化
也就是 Solidity 里面的 constructor,合约的构造函数。它的功能是在部署智能合约时,一次性执行然后销毁。所以初始化时,要存入什么?刚说 register 是一个稳定的东西,那就能把整个结构中,一些相对稳定的东西放在这里初始化。比如多个用户的操作层合约是固定的,而数据层的合约随着用户的注册注销而变化,那么就可以把操作层合约在这里初始化,随着 register 的部署由其进行创建。
第二类接口:注册
注册是 web 调用,由外部用户来使用的。在初始完之后,相当于整个 DApp 上线了,在用户使用时,可能就有些功能上的注册操作。比如 identity,用户需要注册一个用户名之类。在 register 部署之后,你能够完成初始化的其他操作,类比我们现在的应用运行之后提供的功能。
第三类接口:查询
这类接口的使用者分为两类:
外部用户可以查询一些 register 保存的各种模块信息。
当一个合约与其他模块通信时,它只知道 register 地址,而更多模块合约地址可能是通过 register 来生成的随机地址,这个时候就可以通过 register 获得其他模块的地址进行之间的交互。
Logic:operator
接着往下看是操作层的合约。这里可以做一些模块化的东西,支持升级也是在这里做的,是因为简单。我们通常讲的支持升级包括两个方面,一个是函数或者接口的升级。另外一个是数据的升级或者说迁移。接口的升级比较容易,在区块链上数据升级比较困难,因为数据复制的操作很贵。存数据一字大概 2w gas。我们这里优先考虑 operator 的升级。
升级有两种方式,对应 evm 的智能合约里面进行交互两种指令,分别有两种升级方式
一个是 call,是消息调用的一种。调用 call 时,相当于把主动权交给另一个合约了,这个合约在一个新的 evm 执行之后返回一个结果给我。利用这个指令可以完成一个支持升级的方式,就是在 register 做一个类似 router 的东西,记录每一个操作类合约的版本号,然后用户就在访问操作类合约的时候选择版本进行下一次的调用,或者 register 帮你转。
第二个是用 delegate call,相对于 call,用 delegate 时主动权并没有交出去,整个智能合约代码还是在我现在的执行环境中执行,这是智能合约库使用的基本指令,很多库的实现都是基于 delegate call 的方式来做的。支持升级就是用一个代理类的合约,用户调用时帮你进行转发。这里会有一个副作用,必须把操作的数据留在 proxy 里面,这是 delegate 的属性。因为这个属性就需要支持升级的合约。有两个要求,一个是纯逻辑的,没有对状态的改变,第二个是没有在对数据留存在外面的要求,没有对数据进行分开的要求,所有版本的数据在 proxy 保存 。
我更推荐前者。后者把数据都存在 proxy 里面,前者是把数据也分开了,更模块化。我个人觉得是比较清晰的用法,这里用的也是这个。
DATA: data
关于数据,这方面的升级其实比较麻烦,会有一些问题。所以我们设计数据结构时尽量稳定一点,变动小一些,提前预留好以后要用的字段,避免以后的升级。要升级的话也有两种方法
一种方法是类似于插件的东西,旧的比如是 map 结构,是地址结构体,后面要多加一个字段。那么涉及旧数据怎么办?我可以定义一个插件类的合约,定义一个多余字段加一个指针指回原来的地方,相当于数据分开存。,但是保存一个指针指向旧数据并且能够找到他,能够做一些操作,这样的好处是不会变动数据,但是会增加操作的逻辑,比较复杂,而且不是所有的数据结构都能做的。
第二个是迁移,如果很有钱的话,可以直接拷贝过来,如果不在乎钱这是最简单的方式。
整个结构大概是这样。
对于升级的一点建议:升级时 copy 数据很贵,所以我们尽量避免这样的消耗,前期 gas 消耗也是注意的一个点。第二个是使用库来封装这些逻辑,就是说模块化。尽可能逻辑都能成库,可以找比较好的库来用。就是说很多模块交互需要用接口,让合约不依赖模块本身实现而依赖接口,这样保持接口不变的前提下就能升级合约。
网友评论