【收集】-聊聊接入Arbitrum的正确姿势

前言

我们知道,目前最主流的 Ethereum Layer2 方案中,主要有 Optimistic Rollup 和 ZK Rollup 两大类。而 Optimistic Rollup 的实现方案中,则是 Optimism 和 Arbitrum 最受关注。而我们最近接入了 Arbitrum,测试了好一段时间了,期间还踩到了一些很重要的坑,会影响安全性和可用性的,所以我觉得有必要分享下我们的这些经验,以便后续想接入 Arbitrum 的项目团队避免重复踩坑。

第一步

我原本以为,Arbitrum 和 Kovan、Rinkeby 等 Layer1 的测试网一样,是可以将智能合约无缝切换的,即运行在 Kovan、Rinkeby 和 Ethereum Mainnet 的智能合约无需任何修改,就可以直接部署到 Arbitrum。但事实证明,我的这个认知是大错特错的。Arbitrum 跟 Layer1 的差异性原来非常关键,如果不特殊处理,有些场景甚至都会变得不可用,而且安全性也会大大降低,具体细节后文会再细说。

因此,接入 Arbitrum 的第一步工作,我的建议是一定要接入 Arbitrum Testnet 进行测试。如果 Arbitrum Testnet 上还缺少什么东西的话,比如没有 UniswapV2 或者 SushiSwap,那可以自己部署一套 UniswapV2 或 SushiSwap 的合约上去。

而要在 Arbitrum Testnet 上进行测试,就需要领取 Arbitrum Testnet 上的测试币用来支付 Gas,即 Arbitrum Testnet 上的 ETH。但是,因为 Arbitrum Testnet 本身并没有可领取 ETH 的 Faucet 水龙头,所以需要先在 Layer1 的测试网领取测试币,再通过 Arbitrum Bridge 将测试币转到 Arbitrum Testnet 上。

Arbitrum Testnet 所使用的 Layer1 测试网络是 Rinkeby,所以就需要先领取 Rinkeby 网络的测试币。说到这,其实 Arbitrum 一开始使用的测试网络是 Kovan 的,但后来不知道为何迁移到了 Rinkeby。而事实上,Kovan 网络比 Rinkeby 网络要稳定很多。就说近一两个月内,Rinkeby 就已经出现了不止一次长时间不出块的问题,每次都长达好几个小时。我们都知道,区块链不出块,那就什么都做不了了,无法交易,无法测试,只能干等网络恢复。这也可以算是接入 Arbitrum 要知道的第一个坑了。

Rinkeby 网络的水龙头,我知道的有三个:

  1. https://faucet.rinkeby.io/

  2. https://faucet.paradigm.xyz/

  3. https://faucets.chain.link/rinkeby

第一个水龙头可以领取到最多币,一次最多可以领取到 18.75 ETH。但我最近几次尝试领取都失败了,说是已经没币可领了。

第二个水龙头每次可以领取到好几种币,包括 1 ETH, 1 wETH, 500 DAI, and 5 NFTs。不过,对推特账号有要求,要求至少有 1 条推文、15 个 followers、注册 1 个月以上。我自己的推特账号目前也才只有 5 个 followers,不满足条件。

第三个水龙头是 Chainlink 提供的,虽然每次只能领取 0.1 ETH,但好在没有推特的要求,也没有时间限制,所以可以连续多次领取。这也是我最常用的水龙头。

从 Layer1 的水龙头领取到 ETH 之后,就可以通过 Arbitrum 桥将 ETH 转到 Layer2 的 Arbitrum Testnet 了。Arbitrum 桥的地址为:

不过,使用 Arbitrum 桥之前,还要先在 MetaMask 钱包中添加 Arbitrum Testnet 的信息,包括 RPC URL、Chain ID、区块浏览器等。Arbitrum Testnet 的信息可配置如下:

通过 Arbitrum 桥就可以将 Token 在 Layer1 和 Layer2 之间转移。不过,需要了解,从 L1 转入 L2 大概需要 10 分钟的时间才确认到账,而从 L2 转回 L1 却需要长达一周左右的时间。转账确认时间比较久,这也是 Optimistic Rollup 的一个弊端。

block.number 的坑

熟悉 Solidity 的同学们都知道,在智能合约中可以通过调用 block.number 获取当前的区块高度。

智能合约部署在 Ethereum 主网,就获取到主网的区块高度;部署在 Kovan 测试网,就获取到 Kovan 网络的区块高度;部署在 Rinkeby 测试网,就获取到 Rinkeby 网络的区块高度。因此,直觉上会认为 block.number 获取到的就是当前网络的区块高度。

但在 Arbitrum 中发现,原来并非如此。在 Arbitrum 中运行的智能合约,block.number 读取的并非当前 Arbitrum 网络的区块高度,而是 Layer1 的区块高度。而且,读取 Layer1 的区块高度还不是连续的,会隔几个区块才读取一次。

比如,在 Arbitrum Testnet 中,block.number 实际读取到的是 Rinkeby 网络的区块高度;在 Arbitrum Mainnet 中,则读取到的是 Ethereum Mainnet 的区块高度。而且,假设 block.number 当前读取到的区块高度为 9992886,那下一次读取到有变化的区块高度不是 9992887,而是 9992890。经过测试,在 Arbitrum Testnet 中会隔 4 个 Layer1 的区块才更新一次,这个间隔可能会跨越 Layer2 的 10 几到 30 几个区块。

这是一个大坑啊,还是反直觉的,我至今也不明白为什么不直接读取当前 Layer2 网络的区块高度?因为 Layer2 的合约,是无法直接读取 Layer1 的合约的,那么广泛使用的 block.number 返回 Layer1 的非连续的区块高度有什么用呢?我也想不到在什么样的场景下,Layer2 的智能合约需要去读取 Layer1 的区块高度?

这种情况下,很多使用 block.number 作为条件判断或计算的 Dapp,都会大大降低可用性和安全性。

以 Compound 为例子,CToken 合约中有下面这段代码,用来累加计算最新产生的利息的:

function accrueInterest() public returns (uint) { 
  /* Remember the initial block number */ 
  uint currentBlockNumber = getBlockNumber(); 
  uint accrualBlockNumberPrior = accrualBlockNumber; 
  /* Short-circuit accumulating 0 interest */ 
  if (accrualBlockNumberPrior == currentBlockNumber) { 
    return uint(Error.NO_ERROR); 
  } 
  ......
}

因为 Compound 的利息是按区块计算的,所以只要发生了存取借还,每个区块都会计算一次利息并累加更新。以上代码就是获取当前区块和上一次更新的区块,如果是同个区块则不再计算了。这在 Layer1 上是没有任何问题的,但在 Arbitrum 上,就会导致连续几十个区块都不会计算利息,这期间就给黑客提供很多想象空间了,可用性和安全性都大大降低。

再说说我目前负责的 DEX 的一个场景,为了防范闪电贷攻击,我们限制了同个账户不能在同个区块内同时开平仓,所以,开仓和平仓函数,都会有这样一个判断:

require( 
  traderLatestOperation[trader] != block.number,
  "ONE_BLOCK_TWICE_OPERATION"
);

traderLatestOperation[trader] 会保存 trader 上一次开仓或平仓的时间。原本的这段逻辑只会限制在同个区块内不能多次操作,但如今却变成了用户将在几十个区块内都无法操作,这大大降低了可用性,自然不是我们想要的结果。

那如何解决这个问题呢?咨询了 Arbitrum 的团队之后,终于有了解决方案。原来 Arbitrum 中有自己封装了一个合约叫 ArbSys,合约地址为 0x0000000000000000000000000000000000000064,其中有个 arbBlockNumber() 函数可以读取到 Arbitrum 网络本身的当前区块高度。

ArbSys(100).arbBlockNumber() // returns Arbitrum block number

因此,只要将使用 block.number 的地方,替换成调用 ArbSys(100).arbBlockNumber() 就可以解决问题了。

虽然问题解决了,但这样的话,对于需要部署到多链的 Dapp 来说,就需要根据不同的链进行兼容适配了,无法做到一套代码完全通用。

不过,block.number 的坑其实还不是最大的,我们遇到最大的坑其实在于 block.timestamp。

block.timestamp 的坑

和 block.number 一样,在 Arbitrum 读取的 block.timestamp 也不是当前网络的区块时间。那是否和 block.number 一样,是取自 Layer1 的区块时间呢?其实也不是,咨询过 Arbitrum 的技术人员,说是比 Layer1 的区块时间要稍微早一些。而且,也因为 Arbitrum 并不会从 Layer1 连续读取每个区块,所以,timestamp 的更新也是同样有着高时延。经过测试,Arbitrum Testnet 的 block.timestamp 更新时延为 1 分钟。

那么,是否和 block.number 一样,Arbitrum 自身提供了合约函数可以读取当前网络的当前区块时间呢?**结果是没有,Arbitrum 提供的 ArbSys 合约只提供了方法查询 Layer2 的区块高度和 chainid,但却没有提供方法查询 Layer2 的当前区块时间。**连解决方案都没有提供,所以才说这是最大的坑。我也是没想明白,既然都提供了查询 Layer2 的区块高度,为何就不提供查询区块时间呢?是技术上有难度吗?

因为没有方法可获取到 Arbitrum 当前网络的区块时间,就会导致很多依赖于 block.timestamp 的 Dapp 面临可用性和安全性降低的可能。其中包括 Uniswap TWAP 价格预言机,包括 UniswapV2 的,也包括 UniswapV3 的。

我们知道,TWAP 价格的计算,数据来源于 UniswapV2Pair 合约或 UniswapV3Pool 合约所保存的累计价格或累计 Tick 值。而在合约实现中,累计值只会在 block.timestamp 不一样时才会更新, UniswapV2Pair 就是在以下函数中更新累计值 price0CumulativeLast 和 price1CumulativeLast:

// update reserves and, on the first call per block, price accumulatorsfunction _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
  require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
  uint32 blockTimestamp = uint32(block.timestamp % 2**32);
  uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
  if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
    // * never overflows, and + overflow is desired
    price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
    price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
  }
  reserve0 = uint112(balance0);
  reserve1 = uint112(balance1);
  blockTimestampLast = blockTimestamp;
  emit Sync(reserve0, reserve1);
}

因此,在 Arbitrum Testnet 中,累计值至少 1 分钟才会更新一次,Arbitrum 主网中没精确测试过,但应该是差不多的。因为 Arbitrum 的出块时间大概为 2~6 秒,所以累计值可能长达 30 个 Arbitrum 区块才会更新一次。如此严重的高时延,那计算出来的 TWAP 的准确性自然也大幅降低了。

同为 Optimistic Rollup 的 Optimism 其实也存在同样的问题,所以在 Uniswap 的官方文档中还有下面这段说明:

不过,Optimism 的时延只有 20 多秒,没有 Arbitrum 的这么高时延。另外,也不知道 Optimism 有没有提供方法查询 Layer2 的区块时间,我目前没找到。

总而言之,这种情况下,对于想要接入 Arbitrum 的项目来说,当需要使用到 block.timestamp 作为判断条件时,没有太优雅的解决方案,我只能提供一些思路。

首先,思考下是否可以不用区块时间而改用区块高度,那就可以用 ArbSys(100).arbBlockNumber() 方案解决问题。

其次,如果业务上的时间周期比较长,比如 30 分钟、几小时甚至几天,那延后 1 分钟还是可以接受的。比如,假设读取的是 1 小时内的 TWAP 价格,那 1 分钟的时延倒是影响没那么大。

最后,若实在必须要求低时延,那也许只能等未来 Arbitrum 在这方面有所优化了。

总结

目前,在 Arbitrum 上主要遇到的问题就是这些了,block.number 和 block.timestamp 是最大的两个坑,其他问题都是小问题。其他项目在接入 Arbitrum 之前,可以先考虑好对应问题的解决方案。也希望 Arbitrum 能尽快优化自身,以能达到所有 Dapp 的智能合约真的能够无需修改地从 Layer1 无缝迁移到 Layer2。

Subscribe to 0x00pluto
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.