DApp和App的区别和联系
DApp是工作在链上的应用,外行人乍一听起来,会以为用户直接通过区块链交互,不再需要传统App的前后端了。但其实不是这样的。DApp仍然需要同传统的App一样,需要一个网页或者移动应用作为前端界面,需要一个后端来同前端交互。
DApp需要一个前端,这个很容易理解,因为直接同区块链交互需要手写脚本,这个门槛太高了,只有程序员才能做到。普通用户需要前端的辅助,才好向链上发消息。
DApp为什么还需要一个后端呢?链本身其实已经是后端了啊。对于一些特别简单的DApp,的确可以用链节点本身作为后端,但是应用更加复杂之后,链节点本身提供的信息就不够了。需要一个专门的、链外的后端,根据链上的信息,做进一步的分析和处理,以便更好地向前端提供服务。
比如Uniswap能够帮助用户寻找最佳的兑换路径,有些时候用A兑换C,最佳的方式不是直接兑换,而是先用A换B,再用B换C。Uniswap的后端只有通过实时地对链上交易对的数据做进一步分析,才能够找到价格最优的兑换路径。又比如,OneSwap在撤单和挂单的时候,需要提供新订单在订单簿单链表中的建议位置,如果这个位置提供得不对,有可能造成非常大的Gas损耗。OneSwap的后端就必须自行维护一个订单簿的数据库,以便帮助用户提供建议位置。
DApp比起传统的App,在前端和后端之外,还额外需要一个链外的部分。这是不是多此一举呢?不是,正是因为核心逻辑在链上执行,才使得DApp有了公开透明的特性,同时也允许DApp有多个独立开发的前端和后端。从原理上讲,https://app.uniswap.org和https://oneswap.net并非使用Uniswap和Oneswap的唯一方式,任何人都可以开发自己的网站或者移动应用,来配合uniswap/oneswap的链上合约来给用户提供服务。这是一种强烈的制约:uniswap和oneswap的项目方并不能控制链上的合约,如果他们开发的网站做恶或者不好用,那么就会有其他人来替代。
链上的信息,如何被送到链外的后端程序,从而被进一步地分析处理呢?我们需要借助event机制和查询机制。
event的意义
区块链中记载了所有已经上链的交易,这些交易不可篡改,永远的留在链上,任何人都可以查看这些交易,获取自己想要的信息。
然而获取这些信息却并不容易。
想象我们要了解一笔智能合约调用交易发生了什么,首先我们本地运行一个同步了的以太坊全节点,然后按顺序重新跑一遍所有交易直到关心的交易出现,记录下该交易前后的以太坊状态变化,我们还需要仔细的阅读智能合约代码。最后才能推敲出这笔交易发送到链上后究竟发生了什么。想想这是多么痛苦曲折的一件事吧。
另外一件有意义的事是我们经常需要知道谁调用了我们的智能合约,想要办到这一点就更加复杂了。
链上的繁荣离不开链上链下低摩擦,低成本的交互方式。庆幸的是以太坊早已有了应对策略。
event作为一个以太坊链上日志监测协议,可以在智能合约执行过程中将开发者预先设计好的日志内容保存到区块链的收据当中,用户可以按照一定的topic查询这些日志,获取到日志内容,合约地址,调用交易哈希,块ID等信息,而这种查询成本是非常低的,开发者只需要连接到一些节点服务商就可以轻松获取某一笔交易相关的日志。
event的底层实现
EVM在底层提供了LOG0、LOG1、LOG2、LOG3、LOG4共5条指令,这5条指令可以把任意长度的data segment作为日志推送给以太坊节点的监控程序,同时附加0、1、2、3、4个Topic,每个Topic是256位的哈希值,监控程序可以要求节点只给自己推送包含指定Topic的日志,从而把自己不关心的日志给过滤出去。
在Solidity当中,需要先声明event,然后再emit:
event AnonymousEventExample(uint start, uint middle, uint end) anonymous;
event Mint(address indexed sender, uint stockAndMoneyAmount, address indexed to);
...
emit Mint(msg.sender, (moneyAmount<<112)|stockAmount, to);
如果一个Event没有被声明为anonymous的话,Event本身的签名(其名称和参数列表)经过Keccak哈希后,得到第一个Topic,Event参数中被标注为indexed
的参数也被作为Topic(这种indexed
参数不能超过3个),Event参数中没有被标注为indexed
的,被编码为一个字节串,即上述的data segment。最后,根据Topic的数量,Solidity使用LOG0、LOG1、LOG2、LOG3或LOG4指令来发射Event。上面的AnonymousEventExample是一个使用LOG0的例子,因为Event自身是anonymous的,而且它没有indexed
参数;Mint则使用LOG3指令,因为它不是anonymous的,而且有两个indexed
参数。
一次记录日志函数调用需要收取375gas,编码后的data segment,按照每字节8 Gas收费,另外一个indexed参数也就是一个topic要额外收取375gas。
换句话说就是一条log中,参数编码后的data segment越长,indexed参数越多,则消耗的gas就越多,这方面的gas消耗很多时候开发者是容易忽略掉的,但是由于一次合约调用有可能发送多条日志,累积起来的gas消耗仍是不容忽视的。
event压缩
OneSwap作为链上order book和AMM的结合体,追求极致的gas efficient,低廉的交易成本。而solidity本身对于event的压缩还有很大的优化空间,这给了OneSwap的开发者们一定的施展余地。
举个例子,当用户新建一个市价单时,OneSwap会推出一个NewMarketOrder的日志。这条日志中要记录用户的订单信息,它可能是这个样子:
event NewMarketOrder(address indexed user, uint orderAmount, string orderSide);
很显然,上面这个event将编码为更多的字节数,而且用到了gas较为昂贵的indexed标记,其中orderSide字段是string类型,这将占用更多的编码字节数。
经过OneSwap开发者的改进,它最终变成了下面这个样子,将可以在链上获取的冗余信息略去,其余必要字段采取了按有效最大bit数的方式压缩,最终将所有信息装载到一个uint中。
event NewMarketOrder(uint data);
压缩函数如下:
function _emitNewMarketOrder(
uint136 addressLow, /*255~120*/
uint112 amount, /*119~8*/
bool isBuy /*7~0*/
) private {
uint data = uint(addressLow);
data = (data<<112) | uint(amount);
data = data<<8;
if(isBuy) {
data = data | 1;
}
emit NewMarketOrder(data);
}
再来看一下下限价单时推出的日志
event NewLimitOrder(uint data);
这条日志将更多的信息压缩到uint中,它的压缩函数时这样的
function _emitNewLimitOrder(
uint64 addressLow, /*255~193*/
uint64 totalStockAmount, /*192~128*/
uint64 remainedStockAmount, /*127~64*/
uint32 price, /*63~32*/
uint32 orderID, /*31~8*/
bool isBuy /*7~0*/) private {
uint data = uint(addressLow);
data = (data<<64) | uint(totalStockAmount);
data = (data<<64) | uint(remainedStockAmount);
data = (data<<32) | uint(price);
data = (data<<32) | uint(orderID<<8);
if(isBuy) {
data = data | 1;
}
emit NewLimitOrder(data);
}
细看这两个压缩函数,会发现一件有趣的事情:它们都没有包含挂单者的地址的全部bits,只包含了一部分。这是不是一个缺陷呢?并不是。Event从来不是孤立的,当你发现一个Event之后,必然意味着它所在的external函数被调用了,或者被外部账户调用,或者被其他智能合约调用。通过debug.traceTransaction(txHash, {tracer: "callTracer"})
,可以查询一个Tx内部所有的合约调用,进而找到我们所关注的函数调用及其参数。从这种方法,就可以追查到Event当中缺失的信息。这两个压缩函数其实可以完全不保留挂单者地址的任何信息,之所以保留一些,是为了Debug的方便。
类似地,_emitNewLimitOrder中只保留了下单量totalStockAmount和挂单量remainedStockAmount,要想了解这两个量之间的差值是如何的,就需要去追查OrderChanged和DealWithPool这两个Event。
数据压缩不应当被滥用,在并非经常调用的函数中,event应保留丰富的信息,以及合理的参数索引,便于链下查询,比如
LockSend合约中的LockSend event:
event Locksend(address indexed from,address indexed to,address token,uint amount,uint32 unlockTime);
用户可以根据from和to地址索引相关的转账行为。
代码类似下面这样,订阅从0xa01212312312231111dd1111aaaa合约所传出的所有event。
lockSendContract.events.LockSend({
filter: {from: '0xa01212312312231111dd1111aaaa'},
fromBlock: 0
}, function(error, event) { console.log(event);})
.on("connected", function(subscriptionId){
console.log(subscriptionId);
})
.on('data', function(event){
console.log(event);
})
.on('changed', function(event){})
.on('error', function(error, receipt) {});
从链外查询链上信息
Event是在链上执行的交易所能够传递到链外的唯一信息渠道。这里您可能会问,交易所调用的函数返回值,难道就无法传递信息吗?理论上可以,但以太坊节点提供的RPC接口eth_sendRawTransaction,的确不支持链外获取这个返回值。这个设计让人感到很奇怪,但事实如此,开发者只能适应。
以太坊节点提供了另外一个RPC接口eth_call,它可以获得被调用函数的返回值,但是它和eth_sendRawTransaction有一个重大不同:后者是把交易广播到P2P网络,期待这个交易最终被矿工打包进块得到执行,从而改变链上状态;而它仅仅是在节点内部执行一下被调用函数,函数对节点状态的修改会被扔掉、不生效,交易也不会被广播出去。eth_call的唯一作用就是让调用者可以拿到函数的返回值,它甚至会将函数执行过程中产生的Events也都扔掉,而不是返回给调用者。
智能合约往往都会设计很多只读的external函数,它们不改变合约的内部状态,仅仅为了其他合约查询自己的内部状态,以及为了后端程序查询链上信息的方便。OneSwap的合约中也有大量的此类函数。比如如下这些函数:
function internalStatus() external view returns(uint[3] memory res);
function getReserves() external view returns (uint112 reserveStock, uint112 reserveMoney, uint32 firstSellID);
function getBooked() external view returns (uint112 bookedStock, uint112 bookedMoney, uint32 firstBuyID);
function stock() external returns (address);
function money() external returns (address);
function getPrices() external returns (
uint firstSellPriceNumerator,
uint firstSellPriceDenominator,
uint firstBuyPriceNumerator,
uint firstBuyPriceDenominator,
uint poolPriceNumerator,
uint poolPriceDenominator);
function getOrderList(bool isBuy, uint32 id, uint32 maxCount) external view returns (uint[] memory);
其中,getOrderList用来查询订单簿的内容,它从id开始向后遍历订单簿的单链表,最多返回maxCount个订单。您可能会问,为什么还要限制返回的订单数量?直接返回订单簿的全集不行吗?这里需要考虑到,虽然eth_call只是在单一的节点上执行,但仍然有最大Gas消耗的限制,如果订单簿太大,返回订单簿的全集就可能会超过最大Gas的限制,导致查询失败。
您可能还会问,既然后端程序可以通过跟踪event来建立链外的订单簿,为何还需要去查询链上订单簿的内容?因为event的订阅和推送不一定是100%可靠的,由于网络的问题,可能会落下一些event,导致链外的订单簿同链上的不同步。所以定期去查询链上订单簿的全集,是更加稳妥的做法。
总之,必须同时很好地利用eth_sendRawTransaction所返回的events和eth_call所返回的函数返回值,才能够让后端程序抓取到足够和准确的信息,以便做进一步的分析,服务好前端。
总结
本文解释了DApp中链上逻辑同链外的后端程序进行交互的必要性,同时介绍了后端程序用以获知链上状态的两种机制:event推送和合约状态的查询。
原文:《OneSwap Series 10 - Every Contact Leaves a Trace: In-chain and Out-of-chain Interaction》
翻译:OneSwap中文社区