Skip to main content

EVM contract upgradable

· 13 min read

Smartcontract trên ETH hay các hệ tương thích EVM (Etherium Virtual Machine) như BSC hay Avax có tính chất immutable tức là code khi đã được deploy lên 1 địa chỉ xác định thì không thể thay đổi. Điều này giúp tăng tính minh bạch và việc audit hoạt động của smartcontract thuận tiện hơn cho người dùng. Tuy nhiên lại có 1 điểm bất lợi rõ ràng là code không thể thay đổi để sửa lỗi hay thêm tính năng mới cho smartcontract, mà đây lại là những công việc rất quan trọng và diễn ra liên tục trong quá trình phát triển sản phẩm.

Trong 1 số trường hợp chúng ta có thể update contract bằng cách sửa code và deploy lên 1 địa chỉ contract mới, sau đó migrate toàn bộ data cần thiết từ contract cũ sang contract mới và update địa chỉ contract phía ứng dụng frontend. Thông thường data trong contract cũ là do transaction của người dùng tạo ra và do người dùng trả phí vậy nên để migrate toàn bộ data người dùng sang địa chỉ contract mới có thể tốn chi phí rất lớn.

Một số trường hợp khác ví dụ như contract hold fund của người dùng và nhà phát triển không thể tự ý chuyển đi, khi xảy ra lỗi gần như nhà phát triển không thể làm gì để khắc phục. Đây là điểm khó khăn cơ bản của 1 ứng dụng phi tập trung.

Proxy

Solidity cung cấp tính năng delegatecall cho phép từ contract A gọi đến hàm public/external trong contract B nhưng chạy trong context của contract A (hay nói cách khác là chạy hàm của B nhưng truy cập đến data của contract A).

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

// NOTE: Deploy this contract first
contract B {
// NOTE: storage layout must be the same as contract A
uint public num;
address public sender;
uint public value;

function setVars(uint _num) public payable {
num = _num;
sender = msg.sender;
value = msg.value;
}
}

contract A {
uint public num;
address public sender;
uint public value;

function setVars(address _contract, uint _num) public payable {
// A's storage is set, B is not modified.
(bool success, bytes memory data) = _contract.delegatecall(
abi.encodeWithSignature("setVars(uint256)", _num)
);
}
}

Tính năng này cho phép chúng ta thiết kế contract có khả năng update bằng cách:

  • Deploy 1 contract chứa logic ứng dụng như bình thường.
  • Tạo 1 contract gọi là proxy chứa địa chỉ của contract đã deploy ở trên, tất cả các gọi hàm từ bên ngoài được chuyển tiếp đến contract logic dùng delegatecall.

Người dùng tương tác với contract proxy, tất cả dữ liệu đọc ghi sẽ được thực hiện trong context của contract proxy (sử dụng storage của proxy). Như vậy ta có thể update contract bằng cách:

  • Deploy contract logic đã update sang 1 địa chỉ mới.
  • Update biến chứa địa chỉ contract logic trong proxy.

Như vậy khi người dùng tương tác với proxy như bình thường thực chất là đang gọi đến logic mới đã được update, và data tương tác vẫn là data trong proxy như cũ. Ở đây có điểm cần lưu ý, vì mỗi contract đều có state variable (data) của riêng mình nhưng thực chất sử dụng chung 1 storage layout (của contract proxy) nên khi khai báo data ở 2 contract cần phải tương thích với nhau. Ví dụ trong proxy contract chúng ta sử dụng biến chứa địa chỉ contract logic, và trong logic contract sử dụng biến uint256 x ta khai báo như sau:

contract Proxy {
address public implementation; // slot 0
}

contract LogicV1 {
address public implementation; // slot 0
uint256 public x; // slot 1
}

Ta thấy trong logic contract không sử dụng biến implementation nhưng nếu không khai báo biến x sẽ ghi đè lên vị trí của implementation (trong storage layout của proxy). Tham khảo upgradeable-proxy. Đồng thời các bạn tham khảo thêm StorageLayout để hiểu thêm cách smartcontract ghi state variable theo từng slot.

Thông thường các biến storage trong smartcontract được ghi lần lượt theo thứ tự được khai báo ở các vị trí slot 0,1,2... mỗi slot 32 bytes. Tuy nhiên solidity cho phép thay thế vị trí slot mặc định bằng cách gán cho biến storage 1 vị trí slot bất kỳ. Áp dụng cách này vào proxy ở trên ta có thể tách biệt biến storage của smartcontract proxy và biến storage của smartcontract logic ở 2 vị trí riêng để không bị ghi đè lên nhau:

  • Biến storage của smartcontract proxy được set ở 1 ví trị ngẫu nhiên cố định.
  • Biến storage của smartcontract logic giữ nguyên layout mặc định.

Đưa biến cần dùng vào trong 1 struct để có thể quản lý nhiều biến cùng lúc, ta có thể cài đặt như sau

contract Proxy {
bytes32 constant PROXY_STORAGE_POSITION = keccak256("proxy.standard.storage");

struct ProxyStorage {
address public implementation;
// uint256 var2;
// uint256 var3;
}

function proxyStorage() internal pure returns (ProxyStorage storage ps) {
// lấy ra biến storage trỏ vào vị trí đã định (assembly)
assembly {
ps.slot := PROXY_STORAGE_POSITION
}
}

function updateImplAddr(address _impl) external {
ProxyStorage storage ps = proxyStorage();
// ghi đè vào storage
ps.implementation = _impl;
}

function doSomething() external {
ProxyStorage storage ps = proxyStorage();
// ...delegate call cho address 'ps.implementation'
}
}

Sau đó trong smartcontract logic ta có thể khai báo và sử dụng biến storage như bình thường mà không cần bận tâm đến biến storage của proxy (đã được set ở 1 vị trí riêng)

contract LogicV1 {
uint256 public x; // slot 0
uint256 public y; // slot 1
}

contract LogicV2 {
uint256 public x; // slot 0
uint256 public y; // slot 1
address public z; // slot 2
}

Chú ý là ở các version update mới của contract logic storage layout phải tương thích với version cũ để không bị conflict storage slot.

EIP-2535

Chuẩn EIP-2535 được đề xuât bởi Nick Mudgen là 1 thiết kế nâng cấp so với cách cài đặt proxy ở trên:

  • Contract proxy gọi là kim cương (Diamond), bao gồm nhiều contract logic gọi là các mặt cắt (Facet).
  • Diamond lưu giữ data chứa thông tin các facet, mỗi function được map đến 1 facet cụ thể. Data này được khai báo là 1 struct lưu trữ bắt đầu ở 1 slot ngẫu nhiên cố định trong storage của Diamond.
  • Các facet dùng chung 1 struct chứa tất cả các biến dùng trong các facet đó, sử dụng slot mặc định 0 trong storage của Diamond. Ngoài ra facet không khai báo thêm biến riêng nào khác.
  • Diamond chứa 2 facet khởi tạo
    • DiamondCutFacet: sử dụng để update Diamond cho phép thêm bớt hoặc thay thế các facet và các function hiện tại, có thể loại bỏ chính nó để biến thành immutable contract.
    • DiamondLoupeFacet: dùng để quan sát Diamond, liệt kê thông tin các facet, các function bao gồm trong facet.

Thiết kế này cho chúng ta những khả năng sau:

  • Contract upgradable.
  • Có thể tự loại bỏ tính năng upgradable để trở thành immutable.
  • Logic phức tạp có thể được chia thành các module nhỏ dễ quản lý.
  • Không bị hạn chế bởi tính chất mỗi contract không được quá 24kb bytecode.
  • Data được qui về 1 chỗ dễ quản lý (toàn bộ nằm trong storage của Proxy/Diamond).

Tuy nhiên chưa có qui trình chuẩn để vận dụng phương pháp này từ deploy, khởi tạo đến upgrade. Chúng ta cùng đi vào triển khai 1 Diamond cụ thể.

Triển khai

Cài đặt dựa trên sample Diamond-1 của tác giả nhưng đầy đủ hơn ở đây mình sử dụng Typescript và thêm vào chi tiết ở các bước

  • Tạo global struct dùng trong các facet.
  • Tạo facet và khởi tạo giá trị ban đầu.
  • Deploy Diamond, Facet.
  • Upgrade Diamond.

Các bạn check source code sample ở đây.

Dùng global struct

Tạo contracts/libraries/LibAppStorage.sol, khai báo tất cả các biến sẽ dùng trong các facet vào trong struct AppStorage

pragma solidity ^0.8.0;

struct AppStorage {
// Test1Facet
uint256 x;
uint256 y;
// Test2Facet
uint256 z;
}

Sử dụng AppStorage trong facet contracts/facets/Test1Facet

pragma solidity ^0.8.0;

import { AppStorage } from "../libraries/LibAppStorage.sol";

contract Test1Facet {
event TestEvent(address something);

AppStorage s;

function test1Func1() external {}

function changeX() external {
s.x += 1;
}

function getX() external view returns (uint256) {
return s.x;
}
}

Tạo facet và khởi tạo

Thông thường khởi tạo cho các state variable giá trị ban đầu sẽ được cài đặt trong constructor của contract. Tuy nhiên đối với các facet, khởi tạo biến trong constructor không có giá trị vì constructor được chạy trong context của bản thân facet (dùng storage của riêng facet), trong khi data thực dùng là data nằm trong context của Diamond (dùng storage của Proxy). Do đó việc khởi tạo các giá trị ban đầu cho facet sẽ được thực thi riêng, sau khi đã deploy facet. Các bước để tạo facet từ 1 contract thông thường:

  • Đem tất cả các biến dùng trong facet vào global struct, trong facet chỉ khai báo 1 biến state duy nhất AppStorage s;.
  • Thay thế tất cả các vị trí dùng biến state của facet. Ví dụ x => s.x.
  • Xóa bỏ contructor, thay vào đó đưa code trong constructor vào hàm init của DiamondInit contract

DiamondInit là contract được tạo ra chỉ để thực thi code 1 lần duy nhất khi deploy facet để khởi gán các giá trị ban đầu của facet, code trong constructor của facet sẽ được đưa vào đây.

contract DiamondInit {
AppStorage s;

// You can add parameters to this function in order to pass in
// data to set your own state variables
function init() external {
// adding ERC165 data
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
ds.supportedInterfaces[type(IERC165).interfaceId] = true;
ds.supportedInterfaces[type(IDiamondCut).interfaceId] = true;
ds.supportedInterfaces[type(IDiamondLoupe).interfaceId] = true;
ds.supportedInterfaces[type(IERC173).interfaceId] = true;

// add your own state variables
// EIP-2535 specifies that the `diamondCut` function takes two optional
// arguments: address _init and bytes calldata _calldata
// These arguments are used to execute an arbitrary function using delegatecall
// in order to set state variables in the diamond during deployment or an upgrade
// More info here: https://eips.ethereum.org/EIPS/eip-2535#diamond-interface
s.x = 100;
}
}

Deploy và upgrade Diamond

Diamond được deploy theo từng bước sau

  • Deploy DiamondCutFacet.
  • Deploy Diamond, truyền vào địa chỉ DiamondCutFacet đã deploy ở trên.
  • Deploy các facet còn lại: DiamondLoupeFacet, OwnershipFacet và các facet của ứng dụng.
  • Đưa initialize code vào trong DiamondInit, deploy DiamondInit contract.
  • Gọi hàm diamondCut của Diamond truyền vào thông tin: các facet ở trên, DiamondInit address và selector hàm init của DiamondInit.

Hàm deployDiamond để deploy diamond lần đầu tiên, sử dụng như sau

// scripts/deploy/deployDiamond.ts
export async function deployDiamond(facetNames: string[], init = ''): Promise<string>
  • Tham số:
    • facetNames: các facet của ứng dụng sẽ triển khai ngoài DiamondLoupeFacet, OwnershipFacet.
    • init: tên hàm sẽ gọi trong DiamondInit contract, để xâu rỗng nếu không cần init.
  • Trả về: địa chỉ diamond (proxy)

Ví dụ để deploy Diamond với Test1Facet và Test2Facet cùng khởi tạo

const diamondAddress = await deployDiamond(['Test1Facet', 'Test2Facet'], 'init')

Các bước để thêm 1 facet mới hoặc update 1 facet cũ

  • Deploy các facet muốn thêm hoặc update.
  • Tạo DiamondCut data bằng cách duyệt qua selectors của các facet
    • selector đã tồn tại: gán operator replace.
    • selector chưa tồn tại: gán operator add.
  • Đưa initialize code vào trong DiamondInit ví dụ hàm initddmmyy, deploy DiamondInit contract.
  • Gọi hàm diamondCut của Diamond truyền vào thông tin: các facet ở trên, DiamondInit address và selector hàm initddmmyy của DiamondInit.

Hàm upgradeDiamond để upgrade Diamond, sử dụng như sau

export async function upgradeDiamond(diamonAddress: string, facetNames: string[], init = ''): Promise<void>
  • Tham số:
    • diamonAddress: địa chỉ Diamond đang sử dụng
    • facetNames: các facet được thêm mới hoặc update
    • init: tên hàm init muốn gọi trong DiamondInit contract, để xâu rỗng nếu không cần init.

Ví dụ để upgrade Diamond với Test1V2Facet cùng khởi tạo

await upgradeDiamond(diamondAddress, ['Test1V2Facet'], 'init2')

Ở đây khi upgrade chúng ta duyệt qua các function của contract và kiểm tra trong Diamond

  • Nếu function đã tồn tại: thay thế bằng function của contract mới (trỏ đến contract mới, kể cả khi code của hàm này không update).
  • Nếu function chưa tồn tại: thêm mới vào Diamond.

Đối với các hàm không thay đổi không nhất thiết phải thay thế trỏ đến địa chỉ contract mới chúng ta có thể thêm tham số chỉ định những hàm nào muốn update. Tuy nhiên để cho dễ quản lý (các function trong 1 contract code trỏ đến 1 địa chỉ) và code đỡ phức tạp mình chọn cách update toàn bộ những function có trong facet muốn upgrade. Các hàm nào có khả năng update cao có thể nên tách ra 1 facet riêng để tiện update về sau.

Tham khảo cách sử dụng trong test/index.ts

import { expect } from 'chai'
import { ethers } from 'hardhat'

import { deployDiamond, upgradeDiamond } from '../scripts/deploy'

describe('Diamond', () => {
it('Should deploy and upgrade', async () => {
const diamondAddress = await deployDiamond(['Test1Facet', 'Test2Facet'], 'init')

const test1Facet = await ethers.getContractAt('Test1Facet', diamondAddress)
expect(await test1Facet.getX()).to.equal(100)

// x: 100 => 101
await test1Facet.changeX()
expect(await test1Facet.getX()).to.equal(101)

// upgrade
await upgradeDiamond(diamondAddress, ['Test1V2Facet'], 'init2')
const test1V2Facet = await ethers.getContractAt('Test1V2Facet', diamondAddress)
expect(await test1V2Facet.getY()).to.equal(200)

// x: 101 => 111
await test1Facet.changeX()
expect(await test1Facet.getX()).to.equal(111)
})
})

Sample code

Có 1 vấn đề là sẽ hơi bất tiện trong việc áp dụng lại các contract có sẵn của openzeppelin ví dụ như khi bạn muốn tạo ERC721Facet cần làm như sau:

  • Copy code ERC721.sol vào ERC721Facet.sol
  • Chuyển tất cả state variable vào struct AppStorage, update trong code tất cả các vị trí sử dụng các biến này.
  • Xoá bỏ constructor, chuyển code vào trong hàm init của DiamondInit contract.

Trên đây là giải thích và cách triển khai cài đặt EIP2535 Contract upgradable của mình các bạn có thể tham khảo và đưa ra phương án riêng phù hợp.