智能合約開發

代理升級模式

已部署的合約程式碼不可變,但你可以把系統一分為二來偽裝出可升級性:一個薄薄的代理永久持有資料與位址,以及一個實作(邏輯)合約持有程式碼。使用者永遠與代理對話;要「升級」系統,你只需把代理指向新的實作。資料與公開位址從不移動,而行為可以改變。

其機制是 delegatecall。代理的 fallback 函數把每一個進來的呼叫 DELEGATECALL 到當前的實作位址,於是實作的程式碼執行,卻讀寫的是代理的儲存。升級只是重寫所存的實作位址,該位址放在 EIP-1967 定義的固定偽隨機槽,以避免與應用程式變數衝突。由於實作的建構子會在實作自身情境(而非代理的)中執行,可升級合約以一個初始化函數取代建構子,並加以防護,使它恰好能對代理的儲存執行一次。

這些危險是真實的,並造成過著名的損失。儲存佈局在各版本之間必須是僅可附加的:重新排序、插入或改變現有變數的型別,會使新程式碼在錯誤的槽位讀取舊位元組——一次默默損毀狀態的儲存碰撞。代理與實作不能爭奪同樣的記帳槽位(因此有 EIP-1967 的命名空間)。而一個未被初始化的實作,或一個可經 delegatecall 觸及 self-destruct 的實作,都是未癒的傷口——Parity 多簽凍結事件正是以這種方式鎖死了數十萬枚 ETH。

fallback() external payable {
  address impl = _implementation();   // EIP-1967 slot
  assembly {
    calldatacopy(0, 0, calldatasize())
    let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
    returndatacopy(0, 0, returndatasize())
    switch ok
    case 0 { revert(0, returndatasize()) }
    default { return(0, returndatasize()) }
  }
}

儲存留在代理;程式碼住在實作。

可升級合約使用初始化函數而非建構子,因為建構子的程式碼在實作的情境中執行,從不觸及代理的儲存。忘了鎖住初始化函數,會讓任何人奪取該合約。