本文首發于公眾號:Keegan小鋼
前言
我們知道,目前最主流的 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 網絡的水龍頭,我知道的有三個:
- https://faucet.rinkeby.io/
- https://faucet.paradigm.xyz/
- 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 橋的地址為:
- https://bridge.arbitrum.io/
不過,使用 Arbitrum 橋之前,還要先在 MetaMask 錢包中添加 Arbitrum Testnet 的信息,包括 RPC URL、Chain ID、區塊瀏覽器等。Arbitrum Testnet 的信息可配置如下:
- Network Name: Arbitrum Testnet
- New RPC URL: https://rinkeby.arbitrum.io/rpc
- Chain ID: 421611
- Currency Symbol: ETH
- Block Explorer URL: https://testnet.arbiscan.io/
通過 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 accumulators
function _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 desiredif (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {// * never overflows, and + overflow is desiredprice0CumulativeLast += 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。