Thực hành — viết Ethereum smart contracts có thể nâng cấp được

Dũng Trần
tradahacking
Published in
6 min readJul 17, 2019

--

Tính chất của blockchain căn bản ta có append only, immutable (chỉ được viết thêm, tính bất biến). Hai tính chất căn bản này vô cùng quan trọng và cần thiết cho các ứng dụng liên quan tài chính. Với Smart Contracts nó đảm bảo nội dung hợp đồng không bị thay đổi. Đứng từ phía các bên tham gia vào một contract nó là điều tốt. Nhưng contract đó chứa các lỗi bảo mật thì đây gần như là ác mộng vì chúng ta không thể cập nhật các bản vá lỗi. Câu hỏi đặt ra, liệu ta có thể viết smart contract bản thân nó có thể nâng cấp được hay không?.

Dành cho các bạn sắp vào sọc dưa, bài viết của mình tham khảo từ nguồn này https://blog.zeppelinos.org/proxy-patterns/. Bài viết có mục đích học tập xin hãy góp ý thêm để làm nội dung bài viết tốt hơn.

Mình tìm kiếm các implement từ blog của ZeppelinOS phần lớn hơi khó hiểu vì được implement bằng assembly (gas cost sẽ ít hơn và nhiều feature hơn), mình implement lại dưới dạng PoC giúp mọi người dễ tiếp cận và áp dụng.

Concept cơ bản

Về cơ bản chúng ta cùng bắt đầu suy nghĩ về smart contracts, nó có hai phần chính data và logic: Chẳng hạn với một ERC20 token, data chính là token balance của từng addresss, logic là những methods làm nhiệm vụ transfer, allowance…. Nếu chúng ta có thể tách data ra riêng và chỉ cần update logic thì bài toán chúng ta sẽ có lời giải.

Dưới đây là contract đơn giản chứa data và method để đọc data:

pragma solidity ^ 0.5.10;contract Data {    //Data store and accessible
uint256 internal _data;
//Method to access data
function data() public view returns(uint256) {
return _data;
}
}

Mình implement thêm phần logic:

pragma solidity ^ 0.5.10;import "./Data.sol";contract Target1 is Data {    event T1UpdateData(uint256 data);    function calculate(uint256 a, uint256 b) external {
_data = a + b;
emit T1UpdateData(_data);
}
}

Giả sử contract Target1 sai và mình cần thay logic trên bằng Target2 như bên dưới:

pragma solidity ^ 0.5.10;import "./Data.sol";contract Target2 is Data {    event T2UpdateData(uint256 data);    function calculate(uint256 a, uint256 b) external {
_data = a * b;
emit T2UpdateData(_data);
}
}

Nếu mình deploy Target2, address của nó sẽ khác với Target1. Mỗi lần mình deploy smart contract mình sẽ luôn nhận được địa chỉ mới. Điều mình cần là phải có một địa chỉ cố định. Chúng ta có thể nghĩ ngay tới concept khá quen thuộc là proxy. Chúng ta có thể tạo một proxy contract nhận transaction và truyền nó tới target contract.

pragma solidity ^ 0.5.10;import "./Data.sol";contract Proxy is Data {    //Owner's address
address private _owner;
//Contract's address
address private _contractAddress;
//Only owner modifier
modifier onlyOwner {
require(_owner == msg.sender, "Proxy: Caller wasn't owner");
_;
}
//Update new smart contract
event UpdateContractAddress(address indexed newContractAddress);
//Get contract's address
function getContract_58378af393c6() external view returns(address) {
return _contractAddress;
}
//Get owner's address
function getOwner_8e15c28d0fdc() external view returns(address) {
return _owner;
}
//Change owner address
function changeOwner_c6636e06d221(address newOwner) external onlyOwner {
_owner = newOwner;
}
//Change smart contract
function changeContract_75b73cc2c1eb(address newContractAddress) external onlyOwner {
_contractAddress = newContractAddress;
emit UpdateContractAddress(newContractAddress);
}
//Constructor function
constructor (address contractAddress) public {
_owner = msg.sender;
_contractAddress = contractAddress;
emit UpdateContractAddress(contractAddress);
}
//Fallback function
function () external payable {
bool successCall = false;
bytes memory retData;
(successCall, retData) = _contractAddress.delegatecall(msg.data);
require(successCall, "Proxy: Internal call was failed");
}
}

Contract này rất đơn giản:
1. Contract này có owner đóng vai trò quan trọng điều hành contract
2. Owner có thể thay đổi target contract address
3. Chuyển tiếp transaction's data tới target contract

(1) và (2) chúng ta khỏi bàn vì cái này được bàn quá nhiều rồi. Quan trọng nhất là (3). Chú ý trong source code ta thấy:

    //Fallback function
function () external payable {

Function không tên này được gọi là fallback function, function này được gọi khi EVM không tìm thấy function signature phù hợp, function signature được tính như sau:

bytes4(keccak256(abi.encode("functionName(address,uint256)")))

Nó lấy 4 bytes từ hashes của function và parameter types. Đó cũng là lý do sao mình lại có tên các function như bảng bên dưới.

{
"0000000e": "changeContract_75b73cc2c1eb(address)",
"0000000a": "changeOwner_c6636e06d221(address)",
"00000005": "getContract_58378af393c6()",
"00000006": "getOwner_8e15c28d0fdc()"
}

Bản thân hash function được thiết kế để tránh hash collision, nên thường digest sẽ có các bit 0 và bit 1 phân bố đều không phụ thuộc vào input (đây là assumption của mình nó có thể sai). Mình cố ý tạo ra các function với function signature rất đặc biệt. Tránh nó bị trùng với các methods được implement trong target contract.

Bạn có thể dùng đoạn code này của mình để tìm các special function signature, hoặc tìm các function signature trùng nhau:

const keccak256 = require('keccak256');
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
const crypto = require('crypto');
function codeBreaker(abi) {
let functionSig;
let count = 0;
while (true) {
functionSig = `${abi.name}_${crypto.randomBytes(6).toString('hex')}(${abi.params.join(',')})`;
if (parseInt(keccak256(functionSig).toString('hex').substr(0, 8), 16) <= 0xff) {
process.send({
tried: count,
name: functionSig,
signature: keccak256(functionSig).toString('hex').substr(0, 8)
});
return;
}
count++;
}
}
if (cluster.isMaster) {
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('message', function (worker, message, handle) {
if (arguments.length === 2) {
handle = message;
message = worker;
worker = undefined;
}
console.log('Found:', message);
for (let id in cluster.workers) {
cluster.workers[id].kill('SIGKILL');
}
process.exit(0);
});
} else {
codeBreaker({ name: 'changeContract', params: ['address'] });
}

Sau khi cài module cần thiết và chờ khoảng 2 phút mình có kết quả sau:

chiro-hiro@unknow ~/Gits/lab-js $ npm i keccak256
chiro-hiro@unknow ~/Gits/lab-js $ node keccak.js
Found: { tried: 893442,
name: 'changeContract_083fd0f42a94(address)',
signature: '00000045' }

Máy mình có 12 threads, 893,442*12 = 10,721,304 (thử 10 triệu phát mới trúng 1 phát).

Mổ xẻ phần core của Proxy

Trong EVM có opcode là DELEGATECALL (0xf4). Nó cho phép giữ nguyên caller trong msg.sender.

Ví dụ: address của bạn là A, có 2 contracts là B, C
A gọi B -> msg.sender = A
A gọi B sau đó B gọi C -> msg.sender trong contract C là B. Chỉ với DELEGATECALL thì msg.sender giữ nguyên là A.

//Fallback function
function () external payable {
bool successCall = false;
bytes memory retData;
(successCall, retData) = _contractAddress.delegatecall(msg.data);
require(successCall, "Proxy: Internal call was failed");
}

Trong contract của mình, khi call một function signature không support bởi proxy thì nó sẽ được xử lý tại fallback function và được chuyển tiếp tới target contract.

Viết test case

Mình sẽ implement test case theo hướng như sau:

  1. Target contract phải là Target1
  2. accounts[0] phải có quyền owner vì nó deploy proxy
  3. Có thể call method của Target1 thông qua Proxy (logic 4+5 = 9)
  4. Có thể nâng cấp logic của smart contract từ Target1 lên Target2
  5. Có thể call method của Target 2 thông qua Proxy (logic 4*5 = 20)
const ProxyContract = artifacts.require('Proxy');
const Target1 = artifacts.require('Target1');
const Target2 = artifacts.require('Target2');
var iTarget1, iTarget2, iProxyContract;contract('ProxyContract', function (accounts) {it('contract address in ProxyContract should equal to Target1', async function () {
iProxyContract = await ProxyContract.deployed();
iTarget1 = await Target1.deployed();
iTarget2 = await Target2.deployed();
assert.equal(await iProxyContract.getContract_58378af393c6(), iTarget1.address);
});
it('proxy owner should be accounts[0]', async function () {
assert.equal(await iProxyContract.getOwner_8e15c28d0fdc(), accounts[0]);
});
it('should able to call Target1 through ProxyContract', async function () {
let ProxiedTarget1 = new web3.eth.Contract(iTarget1.abi, iProxyContract.address);
await ProxiedTarget1.methods.calculate(4, 5).send({
from: accounts[0],
to: iProxyContract.address,
gas: 5000000
});
ProxiedTarget1.getPastEvents('allEvents', { fromBlock: 0, toBlock: 'latest' }, function (e, r) {
if (!e) {
for (let i in r) {
if (typeof r[i].event !== 'undefined') {
console.log("\t", r[i].event, r[i].returnValues);
}
}
}
})
assert.equal(await iProxyContract.getContract_58378af393c6(), iTarget1.address);
assert.equal((await iProxyContract.data()).valueOf(), 9);
});
it('accounts[0] should able to change Target1 to Target2 in ProxyContract', async function () {
await iProxyContract.changeContract_75b73cc2c1eb(Target2.address);
assert.equal(await iProxyContract.getContract_58378af393c6(), iTarget2.address);
});
it('should able to call Target2 through ProxyContract', async function () {
let ProxiedTarget2 = new web3.eth.Contract(iTarget2.abi, iProxyContract.address);
await ProxiedTarget2.methods.calculate(4, 5).send({
from: accounts[0],
to: iProxyContract.address,
gas: 5000000
});
ProxiedTarget2.getPastEvents('allEvents', { fromBlock: 0, toBlock: 'latest' }, function (e, r) {
if (!e) {
for (let i in r) {
if (typeof r[i].event !== 'undefined') {
console.log("\t", r[i].event, r[i].returnValues);
}
}
}
})
assert.equal(await iProxyContract.getContract_58378af393c6(), iTarget2.address);
assert.equal((await iProxyContract.data()).valueOf(), 20);
});
});
Kết quả test case

Test case khá đơn giản để đọc và hiểu, phần quan trọng chỉ là trick này:

let ProxiedTarget2 = new web3.eth.Contract(iTarget2.abi, iProxyContract.address);

Mình load contract với ABI của Target2 tại address của Proxy contract.

Kết luận

  • Mình hoàn tất được mục tiêu đề ra ở đầu bài
  • Test case hoạt động như dự đoán
  • PoC khá dễ hiểu và dễ áp dụng
  • Các bạn có thể phát triển bài toán bằng cách chuyển owner thành multisig contract và update target contract bằng weighted voting

Ưu thế:

  • Nội dung smart contract đơn giản dễ hiểu
  • Sữ dụng các function signature "hiếm" để tránh trùng lặp cho các contract cơ bản

Hạn chế:

  • Viết bằng Solidity nên gas cost tốn nhiều hơn so với contract của Zeppelin OS
  • Chỉ hoạt động tốt nếu contract Data không bị thay đổi
  • Bị giới hạn bởi ABI

--

--