為了讓智能合約「可升級」,常見作法是把邏輯(Logic/Implementation)與資料(Proxy / Storage)分離,利用 delegatecall 在代理合約(Proxy)上下文執行邏輯合約程式碼。
這個模式很強大,但 若沒處理好 storage layout(儲存槽位對齊)或升級權限,會導致資料被覆寫或被惡意替換邏輯而產生嚴重風險。
🔍 call vs delegatecall
[Proxy Contract Storage] <--- delegatecall (執行時使用 Proxy 的 storage)
|
+-- slot 0: owner
+-- slot 1: someValue
+-- slot 2: balanceMapping ...
Proxy.delegatecall(implementationAddress, data) --> implementation's code runs,
but reads/writes Proxy's storage slots.
• call: 以被呼叫合約的 context(storage)執行 → 不會影響呼叫者 storage。
• delegatecall: 以呼叫者(Proxy)的 context 執行被呼叫合約的程式碼 → 會讀寫呼叫者 storage(因此很容易發生槽位錯配)。
🧾 問題來源:Storage Layout(槽位對齊)
Solidity 對 state 變數按宣告順序分配 storage slot(每 32 bytes 為一 slot)。
若 Proxy 與 Implementation 在變數宣告順序或數量不同,delegatecall 會把 Implementation 的變數寫到 Proxy 不相干的槽位,導致資料錯亂或某些關鍵變數(例如 owner、admin)被覆寫。
❌ 典型錯誤範例(易造成 storage 被覆寫)
Proxy(儲存 owner)
// Proxy (simplified)
contract Proxy {
address public owner; // slot 0
address public implementation; // slot 1
function fallback() external payable {
(bool success, ) = implementation.delegatecall(msg.data);
require(success);
}
}
Implementation V1(邏輯合約)
contract LogicV1 {
uint256 public counter; // slot 0 <-- BUT when delegatecall runs, it will map to Proxy.slot0 (owner)!
function inc() public { counter += 1; }
}
結果:counter(implementation.slot0)在 delegatecall 時會寫入 Proxy 的 owner 槽位,導致 owner 被覆寫成數值(錯誤),合約變為不可預期狀態。
✅ 正確做法與防護策略
固定 Proxy 的 storage 佈局(使用 unstructured storage / EIP-1967、EIP-1822)
• 不在 Proxy 中直接宣告常規 state 變數(如 owner、implementation)於容易衝突的槽位。
• 使用指定 slot(例如 keccak-256 標識符)來儲存 implementation 地址與 admin,避免與 implementation 的槽位對撞。
• 例如:bytes32 constant IMPLEMENTATION_SLOT = keccak256("my.proxy.implementation");
使用成熟框架(如 OpenZeppelin Upgrades)
• OpenZeppelin 已針對 Proxy 升級模式實作並處理 storage layout(EIP-1967)。採用成熟套件可大幅減少踩雷機率。
嚴格管理升級權限(Upgrade Admin)
• 升級功能應該只給可信任的多簽(multi-sig)或 DAO。
• 在升級前需審計及驗證新的 Implementation 合約碼(checksum / bytecode verification)。
保守的 storage 佈局協定(Storage Gap)
• Implementation 合約在末端保留若干 uint256[50] private __gap ; 做為未來變數擴充空間,避免未來升級時對齊錯誤。
測試升級路徑(Upgrade Tests)
• 在本地做完整升級流程測試:V1 → V2,檢查所有重要 state(owner、balances、mappings)是否一致。
範例:使用 Unstructured Storage(簡化示意)
// Proxy: use specific slot to store implementation
contract SimpleProxy {
// keccak256("example.proxy.implementation") 存 implementation address
bytes32 private constant IMPLEMENTATION_SLOT = keccak256("example.proxy.implementation");
function _getImpl() internal view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly { impl := sload(slot) }
}
function _setImpl(address impl) internal {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly { sstore(slot, impl) }
}
fallback() external payable {
address impl = _getImpl();
(bool ok, ) = impl.delegatecall(msg.data);
require(ok);
}
}
🔑 常見實務注意點(Checklist)
• 是否使用 EIP-1967 或 OpenZeppelin 的 proxy 標準?
• 升級權限是否由 multi-sig 或 DAO 控管?
• Implementation 是否保留 storage gap?
• 是否在升級前做完整的升級測試(state 保持一致)?
• 是否驗證新的 implementation bytecode / source code?
Proxy + delegatecall 是讓智能合約「能變」的好方法,但同時也把「資料一致性」的重擔丟給了開發者。
實際上,很多升級事故不是因為惡意,而是因為「槽位對齊沒設計好」或「升級流程沒控管好」。
建議是:若非必要,不要輕易自造升級機制;若要升級,就用成熟工具並把升級權交給多簽/治理。