跳到正文

代理儲存衝突:升級如何破壞合約狀態

代理保留狀態,而實作程式碼可以變更。了解不相容的儲存布局如何覆寫餘額、擁有者和升級控制,以及如何安全驗證升級。

更新於

僅供教育參考,不構成投資建議;投資可能產生損失。

直接答案

當實作程式碼以不同於現有狀態建立時布局所賦予的含義,讀取或寫入代理的儲存槽,就會發生代理儲存衝突。升級可能因此把餘額當作位址、清空擁有者、破壞映射的根槽,或覆寫升級控制資料。

使用 delegatecall 時,實作位元組碼在代理的上下文中執行:儲存、餘額和 address(this) 都屬於代理。變數名稱不會儲存在鏈上,因此 EVM 只按新程式碼計算出的槽位和位元組偏移操作。

因此,升級必須保持已部署布局相容,而不只是成功編譯或公開相同函式。授權升級之前,應將編譯器產生的布局與確切的已部署版本比較。

運作原理

Solidity 通常在繼承關係經過 C3 線性化後,按宣告順序從槽 0 起放置狀態變數。小於 32 位元組的值可能共用一個槽;結構和陣列還有額外的放置規則,而映射和動態陣列會根據基礎槽推導資料位置。基礎槽一旦移動,其衍生資料的位置也會改變。

需要稽核以下四類不同的衝突邊界:

  • 代理與實作之間: 實作位址、管理員等代理自有欄位不得占用應用狀態所用的槽。ERC-1967 為實作、信標和管理員資料規定了編譯器常規分配不會觸及的標準槽。
  • 舊實作與新實作之間: 現有應用變數必須保持相容的槽、偏移和型別。在末尾追加變數可能安全,但插入、重排、刪除或改變變數型別會重新解釋已有儲存字。
  • 繼承關係: 向基底合約新增狀態或改變繼承順序,可能移動衍生合約宣告的儲存,即使衍生合約原始碼沒有變化。
  • 預留或命名空間儲存: 正確使用儲存間隙可以為基底合約預留空間,ERC-7201 風格的命名空間可以隔離布局。但兩者都不允許任意修改既有布局內部。

範例

假設版本 1 的布局如下:

uint256 totalAssets; // slot 0
address owner;       // slot 1

版本 2 錯誤地在開頭插入一個變數:

bool paused;         // slot 0, offset 0
uint256 totalAssets; // slot 1
address owner;       // slot 2

升級後,paused 會讀取舊 totalAssets 的低位位元組,新 totalAssets 會把舊 owner 儲存字當作整數讀取,而 owner 會讀取槽 2 中原有的內容,通常為零。原始儲存字仍然存在,但新程式碼賦予了它們不同含義。交易可能成功執行,卻以錯誤解釋後的狀態進行授權或記帳。

風險與升級檢查

  • 為兩個實作產生儲存布局輸出,並以實際部署的參考合約為準,比較槽、偏移、型別和繼承資訊。
  • 對傳統線性布局,只在末尾追加新變數。除非已證明相容,否則不要重排現有欄位、改變型別、刪除後複用,也不要修改基底合約。
  • 使用儲存間隙時,應按實際占用的預留槽數精確縮減間隙。使用命名空間儲存時,須確保命名空間識別碼唯一,並驗證每個現有命名空間內的變更。
  • 不要把 ERC-1967 當作完整保護。它將代理中繼資料與編譯器分配的應用槽分開,但不能保證兩個實作布局彼此相容。
  • 在分叉網路或狀態快照上測試升級及任何重新初始化函式。執行前後都要核對擁有者、角色、餘額、授權額度、映射項目、暫停狀態、實作槽以及回復或緊急控制。

如果升級已疑似造成衝突,在治理權限允許時,應暫停後續升級和狀態變更呼叫。保留升級前區塊號與實作位元組碼,比較受影響槽位的原始儲存,並由合格的合約工程師設計遷移方案、接受獨立審查。沒有經驗證的儲存映射就反覆升級,可能破壞更多原本可恢復的狀態。

常見誤解

  • 「變數名稱沒變,布局就是安全的。」 名稱不決定儲存位置,型別、順序、打包、繼承和命名空間規則才決定。
  • 「刪除變數就能釋放槽位供複用。」 代理儲存會持續存在。除非經過嚴格審查的遷移將舊值清除或轉換,否則複用槽位只是給舊儲存字賦予新含義。
  • 「一筆測試交易成功就證明升級相容。」 測試可能只觸及少量槽位。布局驗證和狀態差異測試必須涵蓋特權欄位、打包值、映射、陣列和繼承儲存。

相關主題

來源

導覽

搜尋知識庫...