46
社区成员




注:本文系原创,亦发表于作者微信公众号,转载请注明出处。
0x协议,全称0xProject。0x是基于以太坊区块链的去中心化交易所开源协议,借助它能够让ERC20代币在以太坊区块链上交易。这个协议是使用以太坊的智能合约来实现的,可以让任何人都能开设和运行去中心化交易所。
当前的去中心化交易所存在普遍多种缺点,比如交易速度慢、交易量小、费用高。设计0x协议的一个目标就是要解决这些问题,它把交易订单移出区块链,在结算时才移入链内,实现所谓的“链下交易,链上结算”,从而提升交易效率,降低交易费用。0x协议的使用许可本身是免费的,使用0x协议的各去中心化交易所的创建者可以自由决定自己的收费方式。
鉴于以上特点,0x协议变得越来越流行,本系列文章旨在对0x协议的实现进行比较详细的分析, 既是记录笔者自己学习的心得,也希望借此分享能给相关工程人员提供参考,从而帮助读者快速掌握0x协议,为己所用。
本文对0x协议基础研究,参考资料都来自0x协议说明文档和实现代码,如无特别说明,不再注明参考资料的出处,相关代码和文档请参考网址:https://github.com/0xProject 。
图1. 0x协议系统架构图
基于0x协议构建的区中心化交易所,需要建立代理(Proxy)合约和一系列特征(Feature)合约,通过代理合约委托呼叫(delegate-call,call可以称作呼叫或者调用,如无特别说明,本文将混用这两种名称)对应特征合约的方法完成交易。在0x协议的说明书中,给出的系统架构如图1所示。
可以看到,整个系统的入口就是一个代理合约,在协议代码实现中,对应的就是ZeroEx.sol合约。本文介绍的就是在主入口代理合约部署后,系统执行初始化的工作过程,通过一个自举启动(Bootstrap)过程,会加载一系列特征,这里特征可以理解为实现各种功能的智能合约。这些特征提供了一系列方法,方法对应的呼叫地址会填入代理合约的映射表中(有点像C++语言的虚表)。每次呼叫服务,先从代理合约进入,根据函数签名,在映射表中查找到对应特征合约地址,然后再使用委托呼叫执行相应的方法。
本文其余部分按照如下组织:第二部分列出一些基本概念,第三部分介绍delegatecall机制, 第四部分对自举启动流程进行总体概括,第五部分深入代码内部,分析自举启动流程在智能合约中的实现,具体看看每一步操作都做了什么,第六部分是全文总结。
图1的系统架构中,涉及到若干术语命名的模块,0x协议的说明书中,给出了这些术语的定义,详见表1。
Term |
Definition |
Proxy |
The point of entry into the system. This contract delegate-calls into Features. |
Features |
These contracts implement the core feature set of the 0x Protocol. They are trusted with user allowances and permissioned by the 0x Governor. |
Transformers |
These contracts extend the core protocol. They are trustless and permissioned by the Transformer Deployer. |
Flash Wallet |
The Flash Wallet is a sandboxed escrow contract that holds funds for Transformers to operate on. For example, the |
Allowance Target |
Users set their allowances on this contract. It is scheduled to be deprecated after the official V4 release in January, 2021. After which point allowances will be set directly on the Proxy. |
Governor |
A MultiSig that governs trusted contracts in the system: Proxy, Features, Flash Wallet. |
Transformer Deployer |
Deploys Transformers. A transformer is authenticated using a nonce of the Transformer Deployer. |
Fee Collectors |
Protocol fees are paid into these contracts at time-of-fill. |
PLP Sandbox |
PLP liquidity providers are called from this sandbox. |
表1. 基本术语
本文主要涉及代理和特征两个主要实体。根据定义,代理是系统的入口,代理通过委托呼叫方式执行特征提供的方法。特征是0x协议实现的一系列核心功能,它们可以受信任的收取服务费用并且受到0x主管(0x协议中的管理角色)的许可。
在0x协议实现中,特征实现的相关代码都在protocol/contracts/zero-ex/contracts/src/features/ 路径下,大概总共有20余个特征。在自举初始化过程中,主要涉及BootstrapFeature.sol,SimpleFunctionRegistryFeature.sol和OnwerableFeature.sol这3个功能。
在0x协议项目中,大量使用了delegatecall操作进行跨合约方法呼叫,这里介绍一下Solidity为智能合约提供的call和delegatecall机制(还有一种callcode机制,很少使用,有兴趣可以自行查阅),以便更好理解项目的实现。
call和delegatecall都是相对底层的合约方法呼叫方式,直接使用合约地址加上方法签名和参数实现。两者区别主要在呼叫时候的运行环境上下文和内置变量msg的值:
在这里我编写了一个例子来说明二者的区别,示例代码如图2所示,读者可以把图中所示代码输入到REMIX开发环境中,在本地虚拟机部署并运行generalMethod方法,观察输出结果,加深体会。
图2. 测试call和delegatecall机制的合约
这个例子使用3个智能合约——General、TestA和TestB。每个合约里面定义了一个事件,进入方法调用后,会触发相应事件,用来输出调试信息。
测试总入口是generalMethod方法,编译并部署General合约后,执行这个方法。它分别使用直接调用方式,底层接口call方式和delegatecall方式呼叫TestA合约实例的testAMethod方法,之后使用两层delegatecall方式,通过合约TestA合约呼叫TestB合约实例的testBMethod方法(第29行)。
call和delegatecall机制都需要方法签名(selector)加上参数方式呼叫,在本文后面会简单介绍。
另外,图2中49~56行是将msg.data传入的字节流提取出来解析成合约TestB的地址,至于为何不直接使用abi.decode方法直接从msg.data解析,是因为智能合约的一个特性,具体讨论参考这里:https://github.com/ethereum/solidity/issues/6012 。
最后,TestA中提供了fallback和recieve两个方法,具体作用在本文后面也会有介绍,这里可以理解成图2第29行首先触发的就是TestA的fallback方法呼叫,然后在fallback内部,又发起了对b.testBMehod的委托呼叫。
条目 |
地址 |
发起人账户(owner) |
0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
General合约 |
0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47 |
TestA |
0xD9eC9E840Bb5Df076DBbb488d01485058f421e58 |
TestB |
0xaa96CB8107828e584C4FbC37a41754333DfFD206 |
表2. 测试合约部署后的地址信息
呼叫方式 |
msg.sender (msg值) |
this指针(运行环境) |
说明 |
a.testAMethod() |
0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47 |
0xD9eC9E840Bb5Df076DBbb488d01485058f421e58 |
msg: General caller this: TestA callee |
call(TestA.testAMethod.selector) |
0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47 |
0xD9eC9E840Bb5Df076DBbb488d01485058f421e58 |
msg: General caller this: TestA callee |
delegatecall(TestA.testAMethod.selector) |
0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47 |
msg: owner this: General |
delegatecall(TestA.fallback.selector) |
0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47 |
msg: owner this: General |
delegatecall(test.A.delegatecall(TestB.testBMethod.selector)) |
0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 |
0x7EF2e0048f5bAeDe046f6BF797943daF4ED8CB47 |
msg: owner this: General |
表3. 测试结果,不同呼叫方式对应的运行时msg信息和环境上下文
由表3测试结果可见,不论delegatecall嵌套几层,msg都是最初始一层delegatecall发起者相关内容,运行环境也是最初一层delegatecall呼叫者的相关内容。
ZeroEx合约作为系统总入口,维护了一个功能映射表,每个功能通过一个单独的智能合约(特征)提供的方法呼叫实现,这些功能所在的智能合约在初始化时候被注册到代理合约中。这样实现的一个目的是为了系统解耦,允许特征智能合约可以单独更新。
图3. ZeroEx合约工作原理
图3摘自0x协议说明书,形象给出了ZeroEx的工作原理:
在初始化时,ZeroEx只加载一个特征合约,就是BootstrapFeautre,通过BootstrapFeaure,则又会加载一系列其他方法,完成整个初始化过程。整个初始化过程包括:加载BootstrapFeaure,执行方法注册管理,所有权初始化和转换,迁移等几个步骤,整个流程如图4所示。
图4. ZeroEx部署后,触发初始化操作后的工作流程
梳理流程大致如下:
0x协议项目中,触发初始化相关操作在protocol/contracts/zero-ex/test/initial_migration_test.ts中,截取代码片段见图5。
图5. 部署ZeroEx合约,并发起初始化
红色方框部分内容是合约部署操作,上来先部署两个合约(24行)—— 分别是称作registry的SimpleFunctionRegistryFeature和称作ownable的OwnableFeature合约,两个合约实例地址一起作为结构体features的成员变量值,留作后面使用。剩下比较重要的是这里部署了BootstrapFeature合约和ZeroEx合约,然后由migrator发起对ZeroEx的初始化操作(绿色框部分)。这里面,migrator对应的就是图4左上角,是InitialMigration合约的一个实例,下一步,我们就到智能合约内部,看一看InitialMigration的实现。这里面注意,呼叫initializeZeroEx方法,传入的参数有3个,分别是:
图7是InitialMigration.sol的代码,为节省篇幅,图中对非核心功能做了一些删除,这个合约的功能就是对部署的ZeroEx合约完成一系列基本配置。从头浏览一遍合约实现,分析一下重点:
方法内部首先对角色进行检查,只有允许的初始化操作者才能执行后续操作,这里被限定为协议网络主管有此权限。
图6. initializeZeroEx的实现
后面是一个重点操作,见图6和图7中红色方框部分, 这里看上去是呼叫了zeroEx合约的bootstrap方法,实际上是运用了一个小技巧,查看ZeroEx合约代码(下一节会分析)并没有实现这个bootstrap方法。那又是怎么回事呢?实际上是运用了 Solidity语言的一个功能——fallback方法,具体内容可以查看Solidity的说明。简单来讲,fallback是一个兜底方法,这个方法在一个合约是唯一的且没有名字,如果某个合约定义了fallback方法,当呼叫合约中不存在的方法,最后就会执行这个fallback方法。在上红框中的代码执行时,就进入了ZeroEx合约的fallback方法,在这个方法里面,会有一个映射表,映射到实际要执行的bootstrap方法,通过委托呼叫方式执行。
再下一步是执行自毁操作die,将合约InitialMigration销毁,账户上的代币转给协议部署发起人(这里作为owner)传入。
4. 顺着代码跟踪下来,发现还有一个方法bootstrap没有涉及到。从initializeZeroEx进入执行完合约就自毁了,那剩下一个boostrap方法有什么用呢?其实在initializeZeroEx中层层跟进会发现,执行完一系列初始化后,执行流程会跳转回到InitialMigration的boostrap方法中,把这个方法执行完,就结束了整个初始化过程。这里的bootstrap方法完成的工作很明确:
a. 委托代理方式呼叫SimpleFunctionRegistryFeature的bootstrap方法完成自举初始化,实际上完成初始化注册器的工作;
b. 委托代理方式呼叫OwnableFeature的bootstrap方法完成自举初始化;
c. 注销SimpleFunctionRegistryFeature中的_extendSelf方法;
d. 将合约所有人转给owner参数传入的真正所有人。
图7. InitializeMigration.sol的完整实现
图7绿色方框部分是代理呼叫的实现,这是智能合约支持的一种特性,通过合约地址和方法签名就可以执行某个合约开放出来的方法(当然要通过权限检测和支付必要Gas费用)。这里的方法签名实际上就是方法原型声明做SHA-3 (一般是keccak256运算)签名,再去前4个字节,然后紧跟后面是方法参数的字节编码。比如bootstrap()方法的签名就是 bytes4(keccak256("bootstrap()"))。selector就等价于这个方法的签名。
前文介绍过ZeroEx合约是0x协议的总代理,作为整个协议的入口,其实现并不复杂,只有几十行代码,如图8所示。合约一共有六个方法,包括一个构造方法,两个私有方法供合约内部使用,剩下三个外部和公共方法供调用。其中receive方法并没有实现内容,只是用于fallback被调用时候的转账代币。
图8. ZeroEx合约,0x协议的总代理合约实现
首先看构造方法,这里面创建了一个BootstrapFeature合约实例,并将地址填入到impls映射表中,这个映射表以bootstrap方法的签名为索引,bootstrap合约地址为值。待会呼叫bootstrap方法时候会用到这个映射表。实际上,这里创建的BoostrapFeature合约在一次性使用之后会被销毁。
其次看一下getFunctionImplementation方法,从名字就可以猜测这个方法提供一个查询功能,根据传入的方法签名,查找对应的实现合约地址并返回。0x协议维护了一个方法映射表,在内存中分配一系列连续区域,这些区域被称作bucket(存储桶),每个桶对应着一个特征合约存储区。每个bucket内部的存储区被称作slot(插槽),存放每个特征合约的地址(以方法签名为索引)。
这里重点是fallback方法,fallback作为兜底方法在5.2节介绍过,我们看一下这里的实现。在进入fallback后,首先从msg.data中读取前4字节,就是呼叫方法的签名,然后执行getFunctionImplementation方法得到特征合约地址,用这个特征合约地址发起委托呼叫,然后等待结果。两个私有方法_revertWithData和_returnWithData分别是在出错和成功执行情景下做对应处理,这里不再展开分析。需要强调一下,根据第三部分介绍,凡是从fallback发出的委托呼叫,运行环境的this值始终指向ZeroEx合约,msg.sender则是指向InitialMigration。
我们再结合5.2节中执行场景模拟执行一下这个代码。在ZeroEx部署后,构造方法被执行,执行的产生的结果就是:Proxy合约(就是ZeroEx)的存储区,以bootstrap方法签名为地址的位置存放了BootstrapFeature合约的地址。
当初始化执行到图7的44行时,ZeroEx合约的bootstrap方法被呼叫,但是由于合约并没实现这个方法,转而执行fallback。
进入fallback方法,首先通过impl变量的赋值操作,拿到在构造方法执行时候写入的BootstrapFeatures合约地址,然后使用委托呼叫方式,执行 BootstrapFeature合约实现的bootstrap方法。到这里,我们就对自举启动的过程有个大致的了解,实际上这个fallback方法充当了代理方法的入口角色,0x协议每样功能呼叫都是从这里进入然后再分发映射到具体的实现合约接口中,后面我们深入到BootstrapFeature合约看看都做了什么。
BootstrapFeature(图9)也不复杂,一共就有三个方法:一个构造方法在实例时自动执行,一个boostrap方法被代理合约委托呼叫,还有一个die方法在执行完bootstrap后执行,即bootstrap方法只会被执行一次,之后合约就会自我销毁,不再存在。
图9. BootstrapFeature合约实现
先来看构造方法,构造方法中给3个状态变量赋值,分别是:
然后我们进入这个合约的主角——boostrap方法看看做了什么:
本节分析一下SimpleFunctionRegistryFeature的主要功能,这个合约与初始化操作最相关的部分是bootstrap、_extend和_extendSelf三个方法,在图7中所列代码里面都有涉及到,这里简单介绍一下,相关实现参见图10。
图10. SimpleFunctionRegistryFeature的主要方法
bootstrap()这个方法主要完成一系列方法注册,通过执行_extend方法,将传入的方法签名和对应的合约地址写到代理合约的映射表中,可以看到,先后将合约中的extend,_extendself,rollback,getRollbackLength和getRollbackEntryAtIndex几个方法的签名作为映射索引,_implementation指向的地址作为映射值,传递给_extend方法,这里_implementation指向的地址其实就是SimpleFunctionRegistryFeature合约自己的地址。
_extend()方法具体执行注册操作,先分别找到SimpleFunctionRegistryFeature和ZeroEx两个合约的存储桶地址, 然后将代理合约中原先映射关系取出,存入特征合约的历史记录history中,再把代理合约的映射表更新成最新的特征合约实现地址,并记录一次代理合约更新事件。
rollback()方法是恢复到历史的某个版本实现,这个版本如果存在历史记录history中,就成功恢复,如果历史记录中查不到则报错,这里不再赘述。
终于来到自举初始化流程中的最后一个特征合约——OwnableFeature,这里主要用到两个方法——bootstrap和transferOwnership。
图11. OnwershipFeature合约的主要方法
bootstrap依然是完成一系列方法注册,将OwnableFeature合约的transferOwnership,owner,migrate几个方法写入到代理合约注册表中。这部分代码看起来有点让人迷惑——明明是在OwnableFeature合约中,为何this能被强转成SimpleFunctionRegistryFeature类型并成功执行_extendSelf方法而不出错?要知道原始OwnableFeature合约定义中并没有这个方法,也没有提供fallback方法兜底,是不是程序写错了?
这里就需要结合第三部分介绍的内容加以解释。因为bootstrap方法一路是从ZeroEx经过delegaecall过来的,这个this实际上是指向ZeroEx合约,故而在执行图11的bootstrap时候,就进入到了ZeroEx合约的fallback方法中。还记得上面分析的fallback方法,会执行一个查表操作,根据方法签名找到合约入口。而在5.5节已经分析过,此时_extendSelf方法已经被更新到了映射表中,因此合约地址能被找到,有了合约地址和方法签名,对应的在SimpleFunctionRegistryFeature中的_extendSelf方法就能被执行,从而完成一系列方法注册操作。
transferOnwership()将Feature合约owner属性更新为参数传入的新值。这里就不详细分析了。
至此,整个自举初始化基本流程就走完了。
本文对0x协议自举初始化流程做了介绍,特别是对跨合约方法调用、方法映射表构建等技巧性比较强的部分做了详尽解读,以期能够帮助读者更好了解智能合约开发技术和0x协议的工作机制。因为作者水平所限,可能存在部分内容解释不够清楚,甚至存在错误,在此欢迎读者指出。后面我们会继续不断学习,随着对项目理解逐步深入,会保持不断输出,争取形成系列文章,将0x协议实现研究透彻。