Skip to content

Upgradeable Contract

Posted on:January 1, 2023

Tại sao lại cần upgrade contract?

Chúng ta đã biết rằng smart contract là không thể sửa đổi.

Tuy nhiên, không phải lúc nào mã code cũng hoàn hảo. Hiện tại, chúng ta có thể không nhận thấy lỗi nào, nhưng sau này, nếu chúng ta phát hiện ra lỗi thì phải làm gì? Hoặc nếu chúng ta muốn cập nhật chức năng mà smart contract đã có, thì phải thực hiện thế nào?

Đó chính là lý do tại sao chúng ta cần sử dụng Upgradeable Contract.

Proxy pattern

Trong các pattern để thực hiện việc upgradeable contract, có nhiều phương án đã được đề xuất. Trong số đó, proxy pattern là pattern được sử dụng rộng rãi nhất hiện nay.

Ý tưởng của proxy pattern là sử dụng 2 contract: contract đầu tiên là proxy, đây là contract mà user tương tác trực tiếp, nó có nhiệm vụ forward các lời gọi hàm sang contract thứ hai chứa logic, gọi là logic contract, hoặc implementation contract. Trong pattern này thì proxy contract sẽ không bao giờ thay đổi, khi muốn tiến hành upgrade, ta tiến hành thay thế logic contract, nghĩa là forward lời gọi hàm từ proxy contract sang một logic contract khác.

proxy

Có 2 implementation phổ biến của pattern này là:

Proxy forwarding

Các vấn đề lớn trong thiết kế proxy pattern là:

Để giải quyết vấn đề thứ nhất ta cần một cơ chế forward các lời gọi hàm một cách tự động và tối ưu, đây là lúc ta cần đến delegatecall.

delegatecall

Đầu tiên ta cần hiểu delegatecall hoạt động như thế nào thì ta mới rõ được cách mà proxy forward function call như thế nào.

delegatecall là một low-level function cho phép contract có thể gọi hàm của một contract khác, nhưng với context của contract hiện tại. Có nghĩa là logic sẽ nằm ở contract khác, còn storage thì là storage của contract hiện tại.

Do đó ta có thể sử dụng delegatecall để thiết kế proxy pattern bằng cách cho người dùng tương tác với proxy, trong proxy thì không trực tiếp thực hiện logic, mà nó sẽ thông qua delegatecall để forward lời gọi hàm tới logic contract. Khi này ta đạt được mục đích là có thể forward được tất cả các lời gọi từ proxy qua logic contract một cách tự động mà không cần phải mapping 1-1 từng function giữa hai contract nữa, hơn thế logic sẽ không bị fix cố định vĩnh viễn trong proxy, khi cần upgrade, ta sẽ chỉ cần thay thế logic contract bằng một contract khác mà thôi.

proxy lúc này sẽ trông thế này:

// This code is for "illustration" purposes. To implement this functionality in production it
// is recommended to use the `Proxy` contract from the `@openzeppelin/contracts` library.
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/proxy/Proxy.sol

assembly {
  // (1) copy incoming call data
  calldatacopy(0, 0, calldatasize())

  // (2) forward call to logic contract
  let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

  // (3) retrieve return data
  returndatacopy(0, 0, returndatasize())

  // (4) forward return data back to caller
  switch result
  case 0 {
      revert(0, returndatasize())
  }
  default {
      return(0, returndatasize())
  }
}

code này sẽ được đưa vào bên trong fallback function của proxy, việc này sẽ giúp forward bất cứ lời gọi hàm nào qua logic contract mà không cần biết hàm đó là gì cũng như tham số ra sao.

Unstructure Storage Proxies

Có một vấn đề nữa khi sử dụng proxy, chính là làm thế nào để ta lưu trữ các biến một cách hiệu quả. Ví dụ trong proxy để lưu trữ địa chỉ của logic contract ta sử dụng biến address public _implementation, trong khi đó ở logic contract ta khai báo biến đầu tiên là owner của contract address public _owner. Cả 2 đều có cùng kiểu dữ liệu và chiếm slot đầu tiên trong storage của contract. Do đó khi dùng delegatecall để forward call từ proxy qua logic contract, nếu trong hàm thực hiện thay đổi _owner, thì thực chất nó đã thay đổi _implementation. Vấn đề này được gọi là storage collision.

storage-collision

để giải quyết vấn đề này, OpenZeppelin đã đưa ra cách lưu trữ unstructured storage. Thay vì lưu trữ _implementation ở slot đầu tiên của proxy, nó sẽ chọn một slot nào đó, đủ ngẫu nhiên để không bao giờ xảy ra việc logic contract có một biến được lưu trữ ở slot tương đương. Ngoài ra bất kì biến nào khác của proxy cũng sẽ được lưu trữ với cách tương tự, ví dụ admin address chẳng hạn (dùng để update giá trị của _implementation).

randomized-slot

Một ví dụ về cách tạo ra slot ngẫu nhiên để lưu trữ biến trong proxy, dựa theo EIP-1967

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));

Khi này logic contract hoàn toàn không cần phải care về việc nó sẽ lỡ ghi đè lên biến của proxy contract. Có một vài giải pháp khác khi gặp vấn đề storage collision này thường sẽ xử lý bằng cách biết rõ ràng cấu trúc biến được lưu trữ trong proxy và logic contract từ khi thiết kế, để khi code sẽ tránh được việc trùng lặp xảy ra. Trong khi giải pháp ta vừa nêu bên trên thì sử dụng một slot nhớ ngẫu nhiên, đó là lý do tại sao nó được gọi là unstructured storage, logic contract không cần biết về cấu trúc lưu trữ của proxy và ngược lại.

Storage collision giữa những version của logic contract

Như bên trên ta đã giới thiệu phương án để tránh xảy ra storage collision giữa proxy và logic contract. Tuy nhiên storage collision có thể xảy ra ngay giữa những vesion khác nhau của logic contract khi ta tiến hành upgrade. Ví dụ khi nâng cấp lên version mới ta định nghĩa thêm một biến nữa lên đầu contract như thế này:

storage-collision-version

khi này nếu ta tiến hành gọi delegatecall forward call từ proxy sang, _lastContributor sẽ có dữ liệu là _owner của version trước, và tương tự với các biến khác. Tức thứ tự của các biến trong logic contract bị thay đổi, trong khi storage vẫn giữ nguyên (là storage của proxy), nên dẫn đến logic sẽ trả về kết quả sai.

Để tránh việc này xảy ra, ta sẽ chỉ nên thêm các biến mới vào sau các biến đã được định nghĩa ở version trước mà thôi, giống như này:

storage-collision-version-solution

Xử lý constructor

Với solidity, code nằm trong constructor sẽ được thực thi một lần duy nhất tại bước deploy mà thôi, nó sẽ không được nằm trong bytecode được lưu trữ trên blockchain. Do đó, nếu ta viết constructor cho logic contract, thì nó cũng chỉ được chạy 1 lần duy nhất tại bước deploy logic contract, và sẽ không bao giờ được gọi bởi proxy, nói cách khác constructor sẽ trở nên vô dụng.

Để giải quyết vấn đề này, ta sẽ thay thế hàm constructor của logic contract bởi một hàm initialize, và để cho nó giống với constructor ta cũng sẽ giới hạn hàm này chỉ được phép gọi 1 lần duy nhất khi kết nối với proxy mà thôi.

Ta có thể sử dụng OpenZeppelin Upgrades để dễ dàng tích hợp initialize như sau:

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
    function initialize(
        address arg1,
        uint256 arg2,
        bytes memory arg3
    ) public payable initializer {
        // "constructor" code...
    }
}

Chỉ cần đơn giản là kế thừa Initializable và đặt modifier initializer cho hàm initialize mà thôi.

Function selector clashing

Chúng ta có một vấn đề nữa, đấy là selector clashing.

Ngoài việc tiến hành forward tất cả các lời gọi hàm khác bằng delegatecall, proxy cũng cần phải có những hàm riêng của nó, ví dụ upgradeTo để có thể upgrade logic contract lên một version mới. Câu hỏi đặt ra là sẽ thế nào nếu trong logic contract cũng có một hàm upgradeTo thì sao? khi ta gọi upgradeTo thì nó sẽ gọi hàm ở proxy hay là gọi hàm ở logic contract?

Hơn thế nữa, ta biết rằng ở bytecode level, các hàm trong contract được định danh bằng 4bytes đầu tiên của function hash (hay còn gọi là selector), do đó khả năng 2 function tuy tên khác nhau nhưng có chung selector là rất cao, ví dụ

$ cast sig "collate_propagate_storage(bytes16)"
0x42966c68

$ cast sig "burn(uint256)"
0x42966c68

Điều này dẫn đến một rủi ro tiềm ẩn, đấy chính là proxy dev có thể gài backdoor vào trong proxy, khiến việc gọi hàm tưởng như được delegatecall sang contract logic, nhưng thực chất lại trực tiếp thực hiện hàm backdoor trong proxy, dẫn đến thiệt hại cho user.

Chúng ta có thể xem qua bài viết nàyproof-of-concept về function clashing trong proxy. User muốn thực hiện hàm burn nhưng thực chất lại bị chuyển tiền sang cho proxy owner.

pragma solidity ^0.5.0;

contract Proxy {

    address public proxyOwner;
    address public implementation;

    constructor(address implementation) public {
        proxyOwner = msg.sender;
        _setImplementation(implementation);
    }

    modifier onlyProxyOwner() {
        require(msg.sender == proxyOwner);
        _;
    }

    function upgrade(address implementation) external onlyProxyOwner {
        _setImplementation(implementation);
    }

    function _setImplementation(address imp) private {
        implementation = imp;
    }

    function () payable external {
        address impl = implementation;

        assembly {
            calldatacopy(0, 0, calldatasize)
            let result := delegatecall(gas, impl, 0, calldatasize, 0, 0)
            returndatacopy(0, 0, returndatasize)

            switch result
            case 0 { revert(0, returndatasize) }
            default { return(0, returndatasize) }
        }
    }

    function collate_propagate_storage(bytes16) external {
        implementation.delegatecall(abi.encodeWithSignature(
            "transfer(address,uint256)", proxyOwner, 1000
        ));
    }

Transparent proxy

Để giải quyết vấn đề selector clashing bên trên, Openzeppelin giới thiệu transparent proxy pattern. Pattern này cho phép việc có hai hàm có selector giống nhau trong proxy và logic contract, tuy nhiên nó sẽ quyết định xem contract nào sẽ thực hiện hàm dựa trên địa chỉ của người gọi:

Ví dụ ta có một proxy có 2 hàm ownerupgradeTo, logic contract là một ERC-20 token contract cũng có 2 hàm ownerupgradeTo, khi này với mỗi người gọi khác nhau sẽ có các kịch bản như sau:

selector-clashing

Nếu ta sử dụng thư viện OpenZeppelin Upgrade, thư viện sẽ tự động handle cho ta việc upgrade bằng cách tạo một contract ProxyAdmin có vai trò là admin của tất cả những proxy mà account của ta tạo ra thông qua upgrade plugin. Khi này dù ta tiến hành deploy từ account của ta, nhưng thực chất ProxyAdmin mới là admin của tất cả các proxy đó. Điều đó có nghĩa là ta có thể tương tác với các proxy bằng bất cứ account nào của mình mà không hề phải lo lắng về các giới hạn lời gọi hàm trong transparent proxy pattern.

ProxyAdmin là một ownable contract với owner chính là account của ta. Khi cần gọi các hàm sử dụng admin của proxy như upgradeTo hay upgradeAndCall, ta sẽ gọi nó thông qua hàm trong ProxyAdmin, các hàm này sẽ trigger chúng, và đương nhiên chỉ có owner của ProxyAdmin (là ta) mới được gọi mà thôi.

/**
 * @dev Changes the admin of `proxy` to `newAdmin`.
 *
 * Requirements:
 *
 * - This contract must be the current admin of `proxy`.
 */
function changeProxyAdmin(ITransparentUpgradeableProxy proxy, address newAdmin) public virtual onlyOwner {
    proxy.changeAdmin(newAdmin);
}

/**
 * @dev Upgrades `proxy` to `implementation`. See {TransparentUpgradeableProxy-upgradeTo}.
 *
 * Requirements:
 *
 * - This contract must be the admin of `proxy`.
 */
function upgrade(ITransparentUpgradeableProxy proxy, address implementation) public virtual onlyOwner {
    proxy.upgradeTo(implementation);
}

/**
 * @dev Upgrades `proxy` to `implementation` and calls a function on the new implementation. See
 * {TransparentUpgradeableProxy-upgradeToAndCall}.
 *
 * Requirements:
 *
 * - This contract must be the admin of `proxy`.
 */
function upgradeAndCall(
    ITransparentUpgradeableProxy proxy,
    address implementation,
    bytes memory data
) public payable virtual onlyOwner {
    proxy.upgradeToAndCall{value: msg.value}(implementation, data);
}

UUPS

UUPS proxy pattern về cơ bản hầu như giống transparent proxy pattern, ngoại trừ việc nó chuyển logic upgrade sang logic contract thay vì nằm trong proxy contract.

Điều này có thể do các lý do:

Tham khảo