可升級合約是一個開發團隊最常使用的一項技術之一,因為智能合約的不可竄改性就像雙面刃一樣,導致上鏈後的合約維護和更新變得十分麻煩或反人性。可升級合約在某種程度上還不錯地解決許多問題,但也會帶來一些問題(用戶如何確保升級後的合約是可信任的),接下來幾天我們的主題都會圍繞在可升級合約,除了基本的操作和開發之外,還會包含應用(MultiSig, Address Precomputing)、底層架構(Memory Layouts, Call Types)以及不同的可升級合約 Patterns(OpenZeppelin, UUPS, Minimal, Diamond)。
Source: Proxy Patterns
簡單來說可升級合約便是透過佈署 Proxy Contract(代理合約)和 Logic Contract(實作合約)來運作,所有的狀態(變數、資料、記憶體)都會存在 Proxy Contract,而 Proxy Contract 的程式碼也是不變的,真正的運算邏輯 / 功能實作則是儲存在 Logic Contract 中。
我們需要使用 Proxy Contract 來紀錄 Logic Contract(當前合約版本)的地址,同時指向 Logic Contract 的運算 Function Selector。有點像是製作 NFT 盲盒的概念,藉由轉換 IPFS 地址來更換 NFT 顯示的圖片或 Metadata。
在用戶需要與合約互動的時候,便是由 EOA 送出一筆交易至 Proxy Contract,Proxy Contract 再直接指名 Function Selector 或其他方式利用 Internal Delegate Call 去呼叫 Logic Contract 裡面的實作函式得到正確的運算結果,再將結果儲存回 Proxy Contact。
Proxy Contract 要如何去呼叫 Logic Contract 是一個需要探討的問題,如果我們直接使用 Interface 的方式去定義函式映射,將 Selector Hard-Coding 在 Proxy Contract 裡面,其實會大大限縮 Logic Contract 的實作彈性,不僅很難維護而且還有可能因為參數等各種原因出錯。所以我們需要替 Proxy 和 Logic 中間設計一個動態的訪問機制,以下是我們的需求:
於是我們可以在 Proxy Contract 中有一段這樣的轉發機制:
contract Proxy {
uint256 public number;
address public proxyAddress;
function setProxyAddr(address _proxy) external {
proxyAddress = _proxy;
}
function decode(bytes memory _output) external pure returns(bytes memory, uint, bool){
return abi.decode(_output, (bytes, uint, bool));
}
fallback(bytes calldata input) external returns(bytes memory){
(bool success, bytes memory returnData) = proxyAddress.delegatecall(msg.data);
require(success);
return returnData;
}
}
contract LogicContract{
uint256 public number; // Storage is same with the proxy
function changeUint(uint256 _n) external returns(bytes memory, uint, bool){
number = _n;
return("Hello World", 100, true);
}
}
contract ProxyWithLowLevel {
uint256 public number;
address public proxyAddress;
function setProxyAddr(address _proxy) external {
proxyAddress = _proxy;
}
function decode(bytes memory _output) external pure returns(bytes memory, uint, bool){
return abi.decode(_output, (bytes, uint, bool));
}
fallback(bytes calldata input) external returns(bytes memory){
address localProxy = proxyAddress;
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), localProxy, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
calldatacopy(0, 0, calldatasize())
: 複製所有的傳入的 call data 到記憶體中,將其覆寫再 0x00 的位置
calldatasize()
: 可以知道 msg.data
的 sizecalldatacopy(t, f, s)
: 將記憶體中第 f 個位置的 s bytes calldata 複製到位置 tresult := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
: 使用 delegate call 呼叫 logic contract,並且特別標註 calldata 的 MEMORY location 和 sizereturndatacopy(0, 0, returndatasize())
: 回傳 data size 到記憶體,接收呼叫完 logic contract 的回傳值,並且複製到 proxy contract 中switch result
: 將回傳值傳回給調用者(EOA),記憶體位置也是 0。對 Assembly 還不熟悉的朋友可以等之後的文章會更詳細的介紹!
以上的程式碼是透過
ProxyWithLowLevel
來呼叫Proxy
中的fallback()
間接呼叫到logicContract
。
使用 delegate call
的原因是我們需要使用 Proxy Contract 的狀態,並無視 Logic Contract 的狀態,也就是說我們在運算邏輯時要在 Logic Contract 裡面去使用 Proxy Contract 的狀態。
我們梳理一下交易流動的過程:
fallback()
呼叫 Logic Contract有了以上的先備知識之後我們就可以正式來看一個完整的可升級合約會長什麼樣子。
EOA | Proxy (Contract A) | Logic (Contract B) |
---|---|---|
msg.sender | EOA | EOA |
Context | Proxy | Proxy |
Proxy Contract 常見的 Function,需要注意的是在 Logic Contract 裡面不可以有相同的 function signatures:
function admin()
function implementation()
function changeAdmin(address newAdmin)
function upgradeTo(address newImplementation)
function upgradeToAndCall(address newImplementation, bytescalldata data)
upgradeTo(<LogicContractVer1_Addr>)
upgradeTo(<LogicContractVer2_Addr>)
升級的時候需要特別注意 Solidity 版本的差異,以及 Data Collision 的問題(見下文)
在使用 Proxy 合約呼叫 Logic 合約時,由於變數實際上是儲存在 Proxy 中,就等於 Logic 合約需要避免在代理合約的環境裡面發生 存儲衝突(storage collision),可以看以下例子:
Before Upgrade:
contract ContractC {
uint256 public val1;
uint256 public val2;
uint256 private dummy0;
constructor () {
val1 = 11;
val2 = 12;
}
}
contract ContractD {
uint256 public val4;
uint256 public val5;
uint256 private dummy1;
constructor () {
val4 = 21;
val5 = 22;
}
}
contract ContractE is ContractC, ContractD {
uint256 public val7;
uint256 public val8;
constructor () {
val7 = 31;
val8 = 32;
}
}
Memory Slot | Proxy | Contract E v1 |
---|---|---|
0 | val1 | |
1 | val2 | |
2 | dummy0 | |
3 | val4 | |
4 | val5 | |
5 | dummy1 | |
6 | val7 | |
7 | val8 | |
0x360894a1... | Implementation |
After Upgrade:
contract ContractC {
uint256 public val1;
uint256 public val2;
uint256 public val3;
constructor () {
val1 = 11;
val2 = 12;
val3 = 13;
}
}
contract ContractD {
uint256 public val4;
uint256 public val5;
uint256 private dummy1;
constructor () {
val4 = 21;
val5 = 22;
}
}
contract ContractE is ContractC, ContractD {
uint256 public val7;
uint256 public val8;
constructor () {
val7 = 31;
val8 = 32;
}
}
Memory Slot | Proxy | Contract E v1 | Contract E v2 |
---|---|---|---|
0 | val1 | val1 | |
1 | val2 | val2 | |
2 | dummy0 | val3 | |
3 | val4 | val4 | |
4 | val5 | val5 | |
5 | dummy1 | dummy1 | |
6 | val7 | val7 | |
7 | val8 | val8 | |
0x360894a1... | Implementation |
以上的例子中,implementation 的位置如果是跟著從 0 開始的 slot,就有可能會與其他合約的資料碰撞,OpenZeppelin 的可升級合約使用了 非結構化存儲(unstructure storage)的方式,將第 0 個 slot,也就是原本 Implementation Contract 的地址,改存在一個假的隨機位置。這個位置必須讓 Logic 合約的變數不太可能碰到,那 Proxy 合約中其他必須要使用的變數也會用同樣的原理,例如 ownder 的 address,或者 selector 的字串等。如此一來 Logic 就可以大方宣告變數不用害怕覆蓋到 Proxy 中的必要變數們。會叫做非結構化存儲的原因就是合約之間不用知道彼此的記憶體儲存結構。
然而不只是 Logic 可能與 Proxy 變數產生記憶體儲存衝突,不同版本的 Logic Contract 也是會彼此發生衝突(上述例子就有發生)。然而非結構話存儲的升級合約 Pattern 不能解決這個情況,OpenZeppelin 的插件會在這種記憶體衝突情況發生時給予開發者警告。
最後歡迎大家拍打餵食大學生
0x2b83c71A59b926137D3E1f37EF20394d0495d72d