Proxy contract 設計與變形

黃邦庭
Taipei Ethereum Meetup
17 min readAug 5, 2022
photograph by Kasie Schlagel

開發鏈上合約時,由於區塊鏈的特性,合約部署後往往無法再更動。 proxy-implementation 的架構,是一個在這樣的限制下,仍保有一定彈性的作法。本文需要讀者對合約開發與 Solidity 有一定的認識,接著和大家一起探討現在常見的幾種不同 proxy 和他們的變形。

如上所述,區塊鏈的設計上本身就限制了合約在部署之後無法隨意更動,因此當系統有變動的需求時時,往往只能部署新合約,衍生出的問題就是需要 migrate 到新的合約上,而考慮到需要更新的合約資訊可能極大,又由於去中心化的設計理念,合約在設計上就可能沒有辦法由開發方直接廢棄原本的資訊,造成使用者需要自行的 migrate 到新的合約上,像是當年 $LEND rebrand 至 $AAVE 就是一個例子。

又或者乾脆就繼續沿用,衍生的問題就透過一些 workaround 處理,像是 Openzeppelin v1.1.0 以前的 ERC20 token 部分函式缺少回傳值的問題, USDT 的合約至今仍繼續沿用。但若是原合約有更嚴重的問題,又未設計廢棄或緊急停止的功能,就沒有補救手段了。

要如何處理這樣的問題呢?首先我們試著把合約呼叫的動作用更細的單位去理解,大概不外乎

  1. 讀出
  2. 運算
  3. 寫入
  4. 回傳

以上每個步驟都不是必要的,就看這次的呼叫的目的。而其中寫入是實際會在鏈上改變狀態,進而影響之後的讀出結果,而記錄狀態的單位,正是上述發生了需要更改合約邏輯後,需要 migrate 的原因。因此,如果我們將記錄狀態的位置稱為執行單位,定義運算動作的位置稱為邏輯單位,將合約的這兩者分離開來,就可以在不更動執行單位的前提下,透過更動邏輯單位去修補或是擴充原有合約的功能,且同時取用執行單位的狀態,避免 migrate 到新的合約的必要。要達到這樣的效果,delegatecall 是一個關鍵且方便取用的作法。

以下章節介紹的實作大多都有對應的 open source project 做了滿深入的探討,這邊也透過 remix + gist 將他們整理起來,可以透過部署各 .sol 內的 Setup 合約,會連帶部署並初始化相關資訊,相信會對這些設計有更明確的瞭解。
https://remix.ethereum.org/#version=soljson-v0.8.14+commit.80d49f37.js&optimize=false&runs=200&gist=40ec54959994e4c748b04041fddef671&evmVersion=null

Delegatecall(可參考 Delegatecall.sol)
EVM 的合約呼叫在運作上分為三種不同的形式,calldelegatecall 以及 staticcallcallstaticcall 的執行單位和邏輯單位皆以 callee 處理,差別在 staticcall 並不會造成狀態的改變。

Call 和 Staticcall 取用的為 callee 端的 state variable

delegatecall 則是邏輯單位為 callee,但執行單位留在 caller。

Delegatecall 取用的為 caller 端的 state variable

由於這樣的特性,使得合約在邏輯單位得以在不影響執行單位狀態的前提下被異動,也就是把 delegatecall 的對象換掉,就可以達到目的。這讓整體執行邏輯的擴充性和彈性上得到更高的自由度,並在 smart wallet 和 proxy 的架構上扮演了重要的角色。

Proxy
透過 delegatecall,上述將執行單位與邏輯單位切割的想法得以實現。而在實作上,達成這樣效果的作法大致上分為兩大類:

  1. 將部份 state variable 和執行邏輯定義於原合約作為 caller,另一部份的邏輯實作於 delegate 對象合約作為 callee,像是 DSProxy 及其延伸應用(如 DeFiSaver)和 Furucombo proxy
  2. 在作為 caller 的原合約完全不定義 state variable,並透過將 delegatecall 實作於 fallback function 中,讓執行的 function 包含 function name 都完全透過 delegate 對象合約的 callee 來定義,如各式各樣的 proxy contract,一般也會稱為 proxy-implementation 的架構(可參考 Proxy.sol),接下來也會主要針對這個架構來探討。
Proxy-implementation 架構下,對 proxy 執行的合約呼叫會經由 fallback function 對 implementation 執行 delegatecall

EIP-1967 Standard proxy storage slots

EIP-1967 提出了一系列的 storage slot 來記錄 proxy 需要的資訊,例如紀錄 implementation 合約的地址等等。以下以 Openzepplin 作為例子,基於不同的功能衍生出了不同的實作,定義了

  • _ROLLBACK_SLOT
  • _IMPLEMENTATION_SLOT
  • _ADMIN_SLOT
  • _BEACON_SLOT

並依據以上的 storage slot 實作了下面不同的功能

Transparent vs UUPS proxy

Proxy 本身的核心在於執行單位和執行邏輯的 decouple,而要做到可升級的執行邏輯,有兩種方式可以處理。Transparent 的設計(可參考 TransparentProxy.sol)是把升級的 function 直接定義於 proxy 合約中,並由定義在 _ADMIN_SLOT 的 administrator 來執行這個動作。

Implementation 的升級函式在 TransparentProxy 的架構下會在 Proxy 的合約內被實作

UUPS(可參考 UUPSUpgradable.sol)則不在 proxy 中定義升級的邏輯,而是在執行邏輯的合約中定義。如此的好處是 proxy 本身可以變得更輕量化,但相對的 implementation 就會需要包含這段邏輯。

Implementation 的升級函式在 UUPSUpgradable 的架構下會在 Implementation 的合約內被實作

Beacon

Beacon(可參考 BeaconProxy.sol)用於同時管理大量的 proxy,讓這些 proxy 可以指到同一個 implementation。

BeaconProxy 會和註冊的 Beacon 要當前的 Implementation address 作為 delegatecall 的對象

如某個服務,僅部署了一份 implementation,讓複數的 proxy 去基於同樣的邏輯,但擁有各自的參數去提供各自的功能,此時若要更新所有 proxy 的行為,一種做法是讓所有 proxy 的 administrator 各自去處理,另一種就是讓這些 proxy 的 implementation 參考到 beacon 合約上的值,如此一來就可以透過更新 beacon 合約的內容去執行 implementation 合約。

Infinite-proxy

由 proxy 加上 implementation 的架構,當需要部署大量僅有參數不同的合約時,我們可以把成本從部署包含完整邏輯的合約降低到僅需處理 proxy 的費用。然而 proxy 僅對應一個 implementation,因此當只有部分的邏輯需要被更動時,整份的 implementation 都需要被重新部署,其中也包含了那些不需要被更動的部分。或者是當 implementation 包含了太大量的邏輯,導致合約本身過大時,受限於 contract size limit,合約會無法被部署。

為了處理這樣的情形,一個作法是透過 Instadapp 所提出的 infinite-proxy 的架構(可參考 InfiniteProxy.sol),由於在對 proxy 做呼叫時,calldata 內會包含 function signature 以及呼叫用的 parameter,若能透過 function signature 多做一層查表,讓不同的 function call 分別對應到不同的 implementation,就可以讓 proxy 在不影響使用者操作的狀況下,同時對應到不同的 implementation,進而減小單一合約的大小,也降低當升級時,對處理不需升級的那些邏輯不必要的操作,這些 implementation 被稱為 module。只要這些個別 module 一樣遵守對於 storage 的 layout,就可確保操作的正確性。

透過 lookup function 對 function signature 查詢後,就可以知道 delegatecall 的對象 module

然而這樣的設計依舊會遇到一個狀況,也就是當我們透過類似 Etherscan 等支援 EIP-1967 的服務時,會無法找到這個 proxy contract 後面究竟有什麼合約,也就無法知道 proxy 支援了什麼 function。為此,在原本的 module 之外,需要另外實作一個 dummy implementation,用來記錄所有 user 可以呼叫的 external function,這些 function 不需要實作邏輯,僅需要提供介面讓上述的服務去擷取資訊。如此一來,當使用者呼叫 proxy 的時候,還是可以和原本要呼叫的 module 互動。

EIP-2535 Diamond proxy

EIP-2535 是一個已經發展好一段時間的 EIP(可參考 DiamondProxy.sol),上面的 infinite-proxy 從功能的觀點來看可以理解為簡化版的 diamond proxy。參考「鑽石」的一些特性,在 terminology 上使用了很多不同的名詞,頗有 MakerDAO 的有巧思卻又難懂的感覺,大致上可以這樣理解

  • Diamond -> Proxy
  • Facet(s) -> Implementation(s)
  • Cut -> Upgrade
  • Loupe -> Function list

除了前面已經描述過關於在不同的 implementation 去處理不同的 function 的特性之外,Diamond proxy 另外多處理的一個部分是關於 storage layout。

Storage layout

在先前的實作中,會要求所有的 implementation 對於 storage layout 是完全一致的,也就是當一個 implementation 需要使用到 storage 時,就算其他 implementation 不需要共用,他依舊會影響到整體的 layout。針對 storage layout 的處理和設計,EIP-2535 以下面幾個不同的角度做了分析:

Inherited storage

這個處理方式相對直觀,也是目前比較常見的做法,就是將 storage 的參數獨立出來,另外以一個合約來處理,所有的 implementation 在實作的時候,會直接繼承該合約,以確保所有的合約用同樣的 storage layout 來處理邏輯。

繼承 storage contract 以確保所有 storage layout 一致

Diamond storage

會有上述的做法,正是因為所有的 storage 預設都是由 location 0 開始處理,而 diamond storage 作出的一個改變,就是結合上面提到的 EIP-1967 的概念,讓 storage 被指定到一個特定的位置,並且讓後續的 state variable 也接續其後。

Storage library 範例。state variable 被定義在 DiamondStorage 的 struct 中,並以 diamondStorage() 回傳 storage pointer

一種做法是,功能和其相關的 state variables 會以 library 中的 struct 的方式被宣告,並在該 library 裡面實作一個 storage getter,用以回傳該 struct 的 storage pointer,並且該 library 內的 function 也可以取用這些 state variables。而當外部的合約需要取用的時候,只需要以該 library 內的 storage getter 去宣告變數,就可以存取到這些參數。

透過取用定義在各 lib 中的 storage location,各 facet 可以存取各自的 state variable

AppStorage

透過上述的 Diamond storage,不難想像的是各個功能可以變得更為模組化,而針對這個服務中不同的 facet 間共用的 state variable,一個作法是依循上面描述的 inherited storage,但這樣的狀況下介面就會和上述的 Diamond storage 有所不同。因此另一個作法是把 AppStorage 作為 diamond storage 的一個 special case,會同原本 storage slot 的設計,從 0 開始操作,並把要操作的這些 variable 都放在 struct 中,所有需要取用的合約都應該在 storage 的地方宣告一個唯一的 state variable,如此一來就會用同樣的方式處理這些資料。

各個 facet、app storage 和 diamond 本身的 state variable,透過定位在不同的 storage slot 位置避免衝突

Upgrade

EIP-2535 透過 diamond cut 來處理關於 upgrade 的相關動作,包含 add/remove/replace 任意數量的 facet 和 function,並可以同步執行相關的動作,用以初始化需要的 state。另外定義了 Upgradable Diamond、Finished Diamond、Single Cut Diamond,大致上就是描述了可升級的 proxy、移除了 upgrade function 的 proxy、以及一開始就沒有 upgrade function 的 proxy。值得另外一提的是 Diamond Loupe,介面定義了如何 query 所有的 facet 和他支援的 function selectors,而為了滿足這部分的介面,不可避免地在 cut 的時候也需要更新額外的資訊。

Diamond 的相關基礎功能也透過 facet 實作,相關 state variable 定義在 LibDiamond。LibAppStorage 定義了各 facet 可以共用的 state variable

綜上所述,Diamond proxy 的一個優勢是透過 facet 的高度模組化,可以去利用現有的 facet 而不需自己 deploy。比較主要的 overhead 則產生在管理的困難,以及設定上 gas 的消耗。

隨著 Dapp 的發展,合約的業務邏輯變得越來越複雜,proxy 的需求上升也出現了各種設計。較為基礎的幾種設計大致上都已經發展出了 best practice,diamond proxy 由於較為複雜,遲遲還沒有收斂,但今年的 ETHCC Openzeppelin 也宣佈了會將 diamond proxy 的設計加入他們的 contract library 裡面,大家可以期待一下。

感謝 Cyan HoNIC Lin 協助文章的 review。

Reference

--

--