- Tại sao lại cần upgrade contract?
- Proxy pattern
- Proxy forwarding
- Unstructure Storage Proxies
- Storage collision giữa những version của logic contract
- Xử lý constructor
- Function selector clashing
- Transparent proxy
- UUPS
- Tham khảo
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.
Có 2 implementation phổ biến của pattern này là:
- Transparent proxy
- UUPS proxy
Proxy forwarding
Các vấn đề lớn trong thiết kế proxy pattern là:
- làm sao proxy contract có thể gọi được tất cả các hàm bởi logic contract mà không cần phải mapping 1-1 giữa mỗi hàm của 2 contract với nhau?
- khi upgrade ta muốn mở rộng thêm các hàm mới trong logic nữa thì sao?
Để 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
.
để 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
).
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:
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:
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ày và proof-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:
- khi người gọi là proxy admin: sẽ gọi trực tiếp hàm bên trong proxy nếu hàm đó tồn tại, và nếu hàm không tồn tại thì revert.
- khi người gọi không phải là proxy admin: thực hiện delegatecall sang logic contract
Ví dụ ta có một proxy có 2 hàm owner
và upgradeTo
, logic contract là một ERC-20 token contract cũng có 2 hàm owner
và upgradeTo
, khi này với mỗi người gọi khác nhau sẽ có các kịch bản như sau:
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:
- không phải triển khai upgrade trong proxy nữa, do đó contract proxy cũng sẽ nhẹ hơn, tối ưu gas cho việc deploy proxy hơn
- linh hoạt hơn trong việc phân quyền cho việc upgrade. Không nhất thiết phải là proxy admin gọi
upgradeTo
ở proxy nữa, mà có thể sử dụng cách authorize khác trongupgradeTo
do lúc này nó nằm ở logic contract và được gọi bởi user thông thường. - có thể remove hoàn toàn việc upgrade bằng việc xoá đi hàm
upgradeTo
ở phía logic contract.
Tham khảo
- https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
- https://blog.logrocket.com/using-uups-proxy-pattern-upgrade-smart-contracts/
- https://medium.com/coinmonks/transparent-proxy-pattern-uups-d7416916789f
- https://blog.openzeppelin.com/proxy-patterns
- https://medium.com/nomic-foundation-blog/malicious-backdoors-in-ethereum-proxies-62629adf3357