跳至內容

了解如何撰寫可升級的智能合約

探索可升級智能合約在去中心化應用中的重要性。了解它們如何透過代理模式實現更新、錯誤修復和增強安全性。

閱讀時間 8 分鐘
了解如何撰寫可升級的智能合約

智能合約透過引入自動執行程式,消除了中介機構的需求,從而改變了我們對去中心化應用程式的思考方式。它們在去中心化區塊鏈網路上透明地執行,提供無需信任和提升安全性等優勢。然而,其不可變性曾被視為一大優點,如今卻帶來了重大挑戰:智能合約一旦部署,便無法更改。這種僵化性使得實施必要的更新或修復漏洞變得困難。

在本文中,我們將探討可升級智能合約的概念,說明其重要性,並深入解析其運作方式,特別聚焦於在 Linea zkEVM 上的實作。

什麼是可升級智能合約?

可升級智能合約透過允許在部署後進行修改和改進,解決了傳統智能合約的限制。雖然傳統智能合約以不可變性著稱,但可升級合約將不可變性的安全性與靈活適應的能力相結合。這使開發者能夠在不影響使用者或要求其遷移至新合約的情況下實施更新。

在傳統合約中,漏洞或安全缺陷一旦部署便成為永久性問題。可升級合約透過允許部署後進行更新來解決這一問題,從而解決以下關鍵議題:

1. 漏洞修復與安全漏洞:開發者可在部署後修補漏洞,保護使用者資產。

2. 適應性:可升級合約允許與新協議無縫整合,使應用程式能夠跟上技術進步的步伐。

3. 安全性強化:面對不斷演變的網路威脅,這些合約使得無需重新部署整個合約即可輕鬆實施新的安全措施。

4. 治理靈活性:去中心化自治組織(DAO)可透過社群投票來管理升級流程,確保透明度和利害關係人的參與。

5. 簡化更新:與需要複雜重新部署和資料遷移的傳統合約不同,可升級合約簡化了更新流程,提供更高效且對使用者友善的體驗。

可升級智能合約如何運作?

建立可升級智能合約最常見的方法是代理模式。這種方法將合約的邏輯與資料分離,讓開發者能夠在保持相同資料儲存的同時更新邏輯。

其運作方式如下:

  1. 代理合約:此合約持有儲存空間(狀態變數),並作為使用者的介面。

  2. 實作合約:此合約包含可執行程式碼(業務邏輯)。

  3. 委派:代理合約使用以太坊的 delegatecall 將函式呼叫委派給實作合約。這允許代理執行實作的程式碼,同時保留相同的儲存空間。

當需要升級時,代理合約會被導向新的實作合約,從而在不更改合約儲存空間或影響使用者的情況下實現邏輯更新。以下是使用 DAO 代理合約來說明此流程的範例:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract DAOProxy {
    // Storage slot for the address of the implementation contract
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    // Storage slot for the admin address
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");

    // Modifier to restrict access to admin
    modifier onlyAdmin() {
        require(msg.sender == _admin(), "Caller is not admin");
        _;
    }

    // Constructor to set the initial implementation and admin
    constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }

    // Fallback function to delegate calls to the implementation
    fallback() external payable {
        _delegate(implementation());
    }

    // Receive function to handle plain Ether transfers
    receive() external payable {
        _delegate(implementation());
    }

    // Internal function to delegate the call to the implementation
    function _delegate(address _implementation) internal {
        assembly {
            // Copy msg.data
            calldatacopy(0, 0, calldatasize())
            // Call the implementation
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            // Copy the returned data
            returndatacopy(0, 0, returndatasize())
            // Handle result
            switch result
            case 0 {
                // Revert if the call failed
                revert(0, returndatasize())
            }
            default {
                // Return data if the call succeeded
                return(0, returndatasize())
            }
        }
    }

    // Getter for the implementation address
    function implementation() internal view returns (address impl) {
        bytes32 slot = implementationSlot;
        assembly {
            impl := sload(slot)
        }
    }

    // Getter for the admin address
    function _admin() internal view returns (address adm) {
        bytes32 slot = adminSlot;
        assembly {
            adm := sload(slot)
        }
    }

    // Function to upgrade the implementation contract
    function upgradeTo(address newImplementation) external onlyAdmin {
        _setImplementation(newImplementation);
    }

    // Internal function to set the implementation address
    function _setImplementation(address newImplementation) internal {
        bytes32 slot = implementationSlot;
        assembly {
            sstore(slot, newImplementation)
        }
    }

    // Function to change admin
    function changeAdmin(address newAdmin) external onlyAdmin {
        _setAdmin(newAdmin);
    }

    // Internal function to set the admin address
    function _setAdmin(address newAdmin) internal {
        bytes32 slot = adminSlot;
        assembly {
            sstore(slot, newAdmin)
        }
    }
}
複製
    bytes32 private constant implementationSlot = keccak256("simple.dao.proxy.implementation");
    bytes32 private constant adminSlot = keccak256("simple.dao.proxy.admin");
複製

有兩點重要事項需要注意:實作合約地址的儲存槽,以及管理員地址的儲存槽。這些是特意設計的唯一識別碼,用於計算代理合約中實作地址的儲存槽。

當使用帶有 delegatecall 的代理模式時(我們稍後將更仔細地了解此函式),實作合約在代理儲存空間的上下文中運作。這意味著:

  • 共享儲存空間:兩個合約共享相同的儲存佈局。

  • 覆寫風險:若未謹慎管理,代理合約中的變數可能會被實作合約覆寫。

透過使用雜湊唯一識別碼:

  • 隔離性:確保代理使用的儲存槽不太可能與實作合約中的任何變數發生衝突。

  • 一致性:在合約中需要實作地址的任何地方,始終使用相同的儲存槽。

constructor(address _implementation) {
        _setImplementation(_implementation);
        _setAdmin(msg.sender);
    }
複製

另一個重要方面是代理合約的建構函式。它包含一個 _implementation 參數,這意味著必須先部署實作合約,並在部署代理合約時將其地址作為引數傳入。

 fallback() external payable {
        _delegate(implementation());
    }

    receive() external payable {
        _delegate(implementation());
    }
複製

現在我們有了 fallback 和 receive 函式,這些是 Solidity 函式,在將呼叫和以太幣轉帳轉發至實作合約方面扮演著關鍵角色。它們處理合約接收到不符合任何明確定義函式的呼叫或資金的情況。以下是這些函式功能的深入說明:

1. fallback 函式

  • Fallback 機制:Solidity 中的 fallback 函式是一個特殊函式,在以下兩種主要情況下執行:- 當呼叫合約中不存在的函式時。

  • 當資料被發送至合約但不符合任何函式簽名時。

  • 委派:在 DAOProxy 合約的上下文中,fallback 函式將所有此類呼叫委派給當前的實作合約。

在 DAOProxy 中的運作方式:

  • 函式呼叫處理:- 當使用者呼叫代理合約中未定義的函式時,fallback 函式會被觸發。

  • 這很常見,因為代理合約主要包含管理函式,如 upgradeTo 和 changeAdmin,而非實作合約的業務邏輯函式。

  • 委派至實作:- fallback 函式呼叫 _delegate(implementation()),將呼叫轉發至實作合約。

  • _delegate 是一個內部函式,使用 delegatecall 在代理儲存空間的上下文中執行實作的程式碼。

  • 執行上下文:- 所有狀態變更都發生在代理的儲存空間中。

  • msg.sender 和 msg.value 保持不變,保留了原始呼叫者的上下文。

2. receive 函式

  • 以太幣接收: receive 函式於 Solidity 0.6.0 中引入,當合約在沒有任何附帶資料的情況下接收以太幣時執行(即當 msg.data 為空時)。

  • 以太幣轉帳的委派: 在 DAOProxy 合約中,純以太幣轉帳會被委派至實作合約。

在 DAOProxy 中的運作方式:

  • 以太幣轉帳處理:- 當有人在沒有資料的情況下向代理合約地址發送以太幣時,receive 函式會被觸發。

  • 這透過僅指定 to 地址和 value(以太幣金額)而不呼叫任何特定函式的交易來實現。

  • 委派至實作:- 與 fallback 函式類似,receive 呼叫 _delegate(implementation()),將以太幣轉帳轉發至實作合約。

  • 實作合約隨後定義如何處理收到的以太幣(例如,更新餘額、發出事件)。

 function _delegate(address _implementation) internal {
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }
複製

現在我們有了 _delegate 函式,它使代理能夠將函式呼叫轉發至實作合約,同時保留原始呼叫的上下文。這允許合約邏輯隨時間升級,而無需更改合約地址或影響其狀態。

其他函式是 getter 和 setter,幫助我們管理代理合約的狀態變數。為此,我們使用 assembly 關鍵字,允許開發者在合約中直接包含低階組合語言程式碼。此功能提供對以太坊虛擬機器(EVM)指令的直接存取,能夠更精確地控制合約行為、優化 gas 消耗,以及執行在 Solidity 高階程式碼中無法直接使用或效率較低的操作。

現在,最關鍵的問題是:我們如何使用這個代理合約與實作智能合約互動?

為此,我們需要實作智能合約的 ABI。由於代理將呼叫委派給實作合約,您需要其 ABI 才能知道可以呼叫哪些函式。此外,在與區塊鏈互動時,我們將使用代理合約的地址,而非實作合約的地址。代理持有狀態並將呼叫委派給實作,因此所有互動都應透過代理進行。

假設我們有一個 JavaScript 專案,並使用 Ethers.js 與區塊鏈互動。其呈現方式如下:

const proxyAddress = "0xProxyContractAddress";
const implementationABI = [ /* ABI array of the implementation contract */ ];

const proxyContract = new ethers.Contract(proxyAddress, implementationABI, provider);

// For a function 'setValue(uint256 newValue)'
await proxyContract.connect(signer).setValue(42);

// For a function 'getValue()'
const value = await proxyContract.getValue();
console.log("Value:", value);
複製

這樣,我們就能使用代理合約呼叫實作合約中假設的 setValue 函式。

既然我們正在討論可升級合約,另一個重要問題隨之而來:如何升級我的合約?

首先,只有管理員(部署代理合約的人)才能升級實作合約。為此,管理員必須先將新版本的實作合約部署至區塊鏈。之後,他們應呼叫代理合約中的 upgradeTo 函式,並將新合約地址作為參數傳入。其呈現方式如下:

await proxyContract.connect(adminSigner).upgradeTo(newImplementationAddress);

擁抱智能合約的未來

可升級智能合約在區塊鏈開發中至關重要,因為它們允許在部署後進行變更和改進。透過利用代理模式,開發者可以無縫引入新功能、修復漏洞,並在不影響使用者體驗或遷移資產的情況下回應不斷變化的需求。

然而,能力越大,責任越大。以深入理解其機制、安全影響和最佳實踐的態度來處理可升級合約至關重要。確保儲存佈局的一致性、實施強健的存取控制,以及徹底測試升級,是防範潛在風險的必要步驟。

當您著手建立可升級合約時,請考慮使用 OpenZeppelin 的 Upgrades Plugin 等成熟工具來簡化流程並增強安全性。祝您編碼愉快!

AI 翻譯。可能包含錯誤。請務必核實資訊。

評價此翻譯

本文作者: