接上篇:合約升級(jí)模式介紹
筆者改寫了一個(gè)可用于實(shí)踐生產(chǎn)的升級(jí)框架,需要自取。https://github.com/hammewang/Proxy
同時(shí)歡迎討論,微信xiuxiu1998
智能合約升級(jí)的目的
鑒于以太坊智能合約一旦部署,無法修改的原則,所以智能合約升級(jí)應(yīng)當(dāng)遵循如下兩點(diǎn)規(guī)則:
- 邏輯可升級(jí);
- 存儲(chǔ)可繼承;
第一點(diǎn)很好理解,可以把代理合約和邏輯合約看成插座和插頭的關(guān)系,需要升級(jí)的時(shí)候把老的插頭拔下,再插上新的即可。
對(duì)于第二點(diǎn),存儲(chǔ)可繼承,不僅僅是存儲(chǔ)結(jié)構(gòu)的繼承,而且在存儲(chǔ)內(nèi)容上,實(shí)現(xiàn)擴(kuò)展:舊存儲(chǔ)內(nèi)容不變,新存儲(chǔ)內(nèi)容繼續(xù)追加。這個(gè)過程類似于城市化的推進(jìn),城市的邊緣可以一圈一圈擴(kuò)大,但是如果要尋址到老城區(qū)的XX路XX號(hào),無論城市怎么擴(kuò)大,拿著這個(gè)門牌號(hào)依然可以找到那棟老建筑。
升級(jí)方式
升級(jí)目的中的第一點(diǎn)是相對(duì)好實(shí)現(xiàn)的,只要改變調(diào)用的邏輯合約地址就可以了;而為了實(shí)現(xiàn)第二點(diǎn),就要保證合約執(zhí)行環(huán)境上下文保持一致。在介紹合約升級(jí)模式中提到了一個(gè)可以解決這個(gè)問題的方法:delegatecall。把關(guān)鍵代碼再貼一遍:
assembly {
// 獲得自由內(nèi)存指針
let ptr := mload(0x40)
// 復(fù)制calldata到內(nèi)存中
calldatacopy(ptr, 0, calldatasize)
// 使用delegatecall處理calldata
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
// 返回值大小
let size := returndatasize
// 把返回值復(fù)制到內(nèi)存中
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) } // 執(zhí)行失敗
default { return(ptr, size) } // 執(zhí)行成功,返回內(nèi)存中的返回值
}
這樣做,實(shí)現(xiàn)了把邏輯合約(_impl)中的方法拉到代理合約中執(zhí)行,遵循代理合約的上下文(如存儲(chǔ)、余額等),通過這種方式實(shí)現(xiàn)了執(zhí)行上下文一致性。
深度理解delegatecall
注意:
delegatecall為assembly中的低階方法;
下文中出現(xiàn)的delegateCall方法,是我在智能合約中寫的一個(gè)方法名稱,不要混淆。
delegatecall的目的是可以維持執(zhí)行環(huán)境中上下文的一致性,一種很典型的應(yīng)用場(chǎng)景就是調(diào)用library中的方法,用的就是delegatecall。下面來具體介紹一下delegatecall的特點(diǎn)。
1. 可以傳遞msg.sender
假設(shè)personA調(diào)用了contractA中的functionA,這個(gè)方法內(nèi)部同時(shí)使用了delegatecall調(diào)用了contractB中的functionB,那么對(duì)于functionB來說,msg.sender依然是personA,而不是contractA.
2.可以改變同一存儲(chǔ)槽中的內(nèi)容
請(qǐng)看下面的合約:
pragma solidity ^0.4.24;
contract proxy {
address public logicAddress;
function setLogic(address _a) public {
logicAddress = _a;
}
function delegateCall(bytes data) public {
this.call.value(msg.value)(data);
}
function () payable public {
address _impl = logicAddress;
require(_impl != address(0));
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
let size := returndatasize
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
function getPositionAt(uint n) public view returns (address) {
assembly {
let d := sload(n)
mstore(0x80, d)
return(0x80,32)
}
}
}
contract logic {
address public a;
function setStorage(address _a) public {
a = _a;
}
}
這時(shí)分別部署proxy和logic,之后把logic.address賦值給proxy中的logicAddress變量。調(diào)用getPositionAt(0)會(huì)發(fā)現(xiàn)返回的也是logicAddress的值,結(jié)果如下圖:

這時(shí),如果調(diào)用proxy中的delegateCall并傳入0x9137c1a7000000000000000000000000bcb9c87f53878af6dd7a8baf1b24bab6a62fe7aa(9137c1a7是setStorage的方法簽名),意為用delegatecall調(diào)用logic中的setStorage方法,這時(shí)會(huì)發(fā)現(xiàn)proxy中的logicAddress發(fā)生了變化,變成了我們剛剛傳入的值。如下:

這時(shí)我們會(huì)發(fā)現(xiàn),delegatecall并不通過變量名稱來修改變量值,而是修改變量所在的存儲(chǔ)槽。所以當(dāng)在proxy中delegatecallsetStorage方法時(shí),修改的并不是address a,而是address a所在的第0個(gè)存儲(chǔ)槽的值,而proxy中第0個(gè)存儲(chǔ)槽存放的是logicAddress,所以相應(yīng)就會(huì)被覆蓋。
理解到這一步,就可以感受到delegatecall的強(qiáng)大和危險(xiǎn)。但同時(shí)也帶來一層疑問:雖然使用delegatecall可以使用邏輯合約中的方法改變代理合約中相應(yīng)位置的變量,但是并沒有起到存儲(chǔ)可擴(kuò)展呀?不還得事先在代理合約中創(chuàng)建相應(yīng)變量么?這就相當(dāng)于在1949年新中國建立的時(shí)候,就要規(guī)劃以后建設(shè)的所有布局,包括共享單車??奎c(diǎn),這不是有點(diǎn)扯淡么?
這就要說到delegatecall下面一個(gè)特點(diǎn)了。
delegatecall——"無中生有"
delegatecall還有一個(gè)強(qiáng)大的特點(diǎn)就是,可以為proxy中未事先聲明的變量開辟存儲(chǔ)空間。
我們來看下一個(gè)例子,代理合約依然使用上面用過的proxy,我們把邏輯合約 變一下:
contract logic2 {
address public a;
address public b;
function setStorageB(address _a) public {
b = _a;
}
}
新增加一個(gè)address變量,并且只修改第二個(gè)address變量。
這時(shí)依然重復(fù)上一個(gè)例子的第一步,把logic2的地址賦值給代理合約中的logicAddress變量。結(jié)果如下圖:

然后使用代理合約中的detegateCall方法,調(diào)用logic2中的setStorage2方法,傳入data為0x9ea338be0000000000000000000000000dcd2f752394c41875e259e00bb44fd505297caf。之后再調(diào)用getPositionAt(1)和logicAddress()方法,結(jié)果如下圖:

可以看到logicAddress并沒有發(fā)生變化,而第1個(gè)存儲(chǔ)槽中的值變成了我們剛剛傳入的值。
這也再次說明了,delegatecall方法并不是按照變量名稱操作的,而是按照變量所對(duì)應(yīng)的存儲(chǔ)槽的位置,對(duì)該位置中的值進(jìn)行操作。因此,我們是不是事先在代理合約中聲明了變量,就并不重要了。
delegatecall總結(jié)
- 可以傳遞msg.sender
- 不按照變量名進(jìn)行操作,而是去找變量對(duì)應(yīng)的存儲(chǔ)槽進(jìn)行操作(無論變量是否在代理合約中事先聲明)
正因?yàn)榈诙c(diǎn)特性,為合約升級(jí)中的存儲(chǔ)擴(kuò)展提供了可能性;同時(shí),也提出了一個(gè)很嚴(yán)格的要求:
新合約和舊合約之間必須嚴(yán)格遵守繼承的模式,即:
contract newLogic is previousVersionLogic{
...
}
使用存儲(chǔ)繼承模式升級(jí)
原理介紹
------- =========================
| Proxy | ║ UpgradeabilityStorage ║
------- =========================
↑ ↑ ↑
--------------------- -------------
| UpgradeabilityProxy | | Upgradeable |
--------------------- -------------
↑ ↑
---------- ----------
| Token_V0 | ← | Token_V1 |
---------- ----------
代理合約是UpgradeabilityProxy實(shí)例,圖中的Token_V0和Token_V1即是邏輯合約的最初版和升級(jí)版,它們都必須繼承Upgradeable,同時(shí)邏輯合約和代理合約都必須繼承UpgradeabilityStorage,繼承同一套存儲(chǔ)結(jié)構(gòu),以保證邏輯合約在代理合約中執(zhí)行時(shí),不會(huì)出現(xiàn)變量覆蓋的情況。
具體代碼結(jié)構(gòu)

注:圖中每個(gè)方框的結(jié)構(gòu)從上到下依次是:合約名稱、狀態(tài)變量、function、event、modifier
圖中可以更加清晰地看到,代理合約和邏輯合約都必須繼承registry和_implementation兩個(gè)狀態(tài)變量,并且邏輯合約中沒有修改前兩個(gè)狀態(tài)變量的相應(yīng)方法,因此代理合約中的存儲(chǔ)安全。
升級(jí)操作
1. 如何初始化
- 部署
Registry合約 - 部署邏輯合約的初始版本(V1),并確保它繼承了
Upgradeable合約 - 向
Registry合約中注冊(cè)這個(gè)最初版本(V1)的地址 - 要求
Registry合約創(chuàng)建一個(gè)UpgradeabilityProxy實(shí)例 - 調(diào)用你的
UpgrageabilityProxy實(shí)例來升級(jí)到你最初版本(V1)
2. 如何升級(jí)
- 部署一個(gè)繼承了你最初版本合約的新版本(V2),V2必須繼承V1
- 向
Registry中注冊(cè)合約的新版本V2 - 調(diào)用你的
UpgradeabilityProxy實(shí)例來升級(jí)到最新注冊(cè)的版本
3. 如何轉(zhuǎn)移proxy合約所有權(quán)
調(diào)用Registry中的transferProxyOwnership方法進(jìn)行所有權(quán)轉(zhuǎn)移;
代碼調(diào)用注意事項(xiàng)
須對(duì)代理合約的地址套用當(dāng)前版本的邏輯合約的ABI,方能正常調(diào)用和獲取返回值。