本文主要介紹 substrate 合約模塊的實現邏輯,srml/contracts
提供了部署和執行 WASM 智能合約的功能。作為一個模塊化的區塊鏈框架,不管是未來的波卡平行鏈還是基于 substrate 擁有獨立共識的鏈,比如 ChainX, 只要引入其合約模塊,就具備了合約功能,可以成為一個智能合約平臺。ChainX 目前就計劃引入合約功能,對區塊鏈智能合約開發者提供支持, 歡迎有興趣的同學持續關注。
substrate 的合約模塊將會分兩篇文章進行解讀,本篇主要介紹基本概念,substrate 合約與以太坊合約的一些聯系與區別,還會介紹一下上傳合約代碼 put_code
和實例化合約 instantiate
兩個外部接口的實現。合約模塊一共有 3 個接口,第二篇將會介紹第三個外部接口合約調用 call
的基本邏輯,并且會詳細介紹下 substrate 關于合約存儲收費的設計。
以下代碼分析基于 substrate 的 9 月 21 日 4117bb9ff 版本。
基本概念
substrate 上的合約與以太坊合約有很多聯系。首先普通賬戶和合約賬戶在外部表現上沒有任何區別,都是一個哈希. 合約賬戶可以創建新的合約,也可以調用其他合約賬戶和普通賬戶。如果是合約賬戶調用普通賬戶,就是一個普通的轉賬。當合約賬戶被刪除時,關聯的代碼和存儲也會被刪除。用戶調用合約時,必須指定 Gas limit, 每次調用都需要花費 Gas 手續費, 合約內部調用的指令也會消耗 Gas.
當然也有一些區別。以太坊在合約調用中,如果出現任何問題,整個狀態都會回滾。但是在 substrate 的合約中如果出現了合約嵌套調用,比如合約 A 調用了合約 B, 合約 B 調用了合約 C,B 在調用 C 的過程中發生錯誤,那么只有 B 這一層的狀態回滾,A 調用產生的狀態修改仍然保留。當以太坊出現類似情況時,整個合約調用鏈的狀態都會回滾,也就是 A 調用的狀態修改不會保留,而是會被丟棄。另外除了 Gas 費用,substrate 的合約還有一個 rent
費用, 也就是對于合約存儲也進行了收費. 以太坊雖然已經有個相關的 EIP 針對存儲收費的討論 EIP 103, 但是目前還沒有實施。
合約模塊一共有三個與外部交互的接口:
put_code
: 上傳代碼, 將準備好的 WASM 合約代碼存儲到鏈上, 如果執行成功,會返回一個code_hash
, 然后可以通過這個code_hash
創建合約。先將代碼存儲到鏈上的好處是,對于合約內部邏輯相同而只有初始化參數不一樣的合約,比如很多以太坊上的很多 ERC20 合約,鏈上只需要存儲一份代碼,而不需要每次新建一個合約的時候,都要存儲一份重復的代碼,這顯然是冗余的。instantiate
: 實例化合約, 通過put_code
返回的code_hash
并傳入初始化參數創建一個合約賬戶,實例化過程會調用合約內部的deploy
函數對合約進行初始化,初始化只有一次。最近 substrate 將合約模塊的實例化方法從之前的create
重命名為了instantiate
, 見:PR: 3645。call
: 調用合約。在這里需要注意的是 substrate 有個存儲收費的邏輯,如果調用的時候合約賬戶余額不足,合約就會被刪除evict
, 很多人應該遇到過這種情況。
put_code
: 上傳合約代碼
- 調用
gas::buy_gas
根據gas_limit
預收取手續費。這一步是預先收取交易發起人的手續費。如果最后執行完成后,如果 Gas 沒用完,會將剩余的 Gas 返還給用戶。buy_gas
的代碼在srml/contracts/src/gas.rs
。
收取手續費 = gas_price * gas_limit
- 將代碼存儲到鏈上,調用
wasm::code_cache::save
執行存儲代碼的邏輯,save
代碼位于srml/contracts/src/wasm/code_cache.rs
。
在 save
中, 第一步是先收取 PutCode
操作的費用, 如果手續費不夠直接返回。gas_meter
中就像是一個"Gas 小管家",這個管家管理的錢就是我們上一步預先收取的費用。在整個執行過程中,如果需要支付手續費,就從 gas_meter
中扣除,如果支付失敗,直接返回。
關于手續費收取標準,也就是 gas_meter.charge(..)
接受兩個參數,一個是 Token
trait 的關聯類型 Token::Metadata
和實現了 Token 的 trait object, Token 有一個方法 calculate_amount
返應當收取的 Gas 費。srml/contracts/src/wasm/runtime.rs
中定義了一個枚舉 RuntimeToken
, 它實現了 Token
trait, 針對不同的操作,收取不同的費用, 比如讀內存,寫內存,返回數據等等。在這里用到的 PutCodeToken(u32)
并不是 RuntimeToken
的成員,而是定義了一個元組結構體并實現了 Token
的 trait.
第二步是調用 srml/contracts/src/wasm/prepare.rs
中的 prepare_contract
函數對上傳的原始代碼進行校驗和做一些預處理,如果全部校驗通過,那么就會存儲到鏈上。在這里會校驗:
a. 入口函數是否存在: call
, deploy
b. 是否有定義內部存儲
c. 內存使用是否超過閾值
d. 是否有浮點數
第三步將校驗通過的代碼組裝成一個結構體 PrefabWasmModule
, 這個結構可以直接放到 WasmExecutable
里面, 然后寫入存儲。這里寫入了兩個存儲,key 都是 code_hash
, 一個是原始代碼 original_code
, 一個是 original_code
預處理后的 prefab_module
.
- 返回剩余的 gas.
instantiate
: 創建合約
通過 execute_wasm
構建 wasm 的基本執行過程。外部接口 instantiate
和 call
實際上都是要走 execute_wasm
,粗線條來講,execute_wasm
第一步還是根據 gas_price * gas_limit
收取手續費, 然后構造一個頂層的執行環境 ExecutionContext
執行 wasm ,根據執行結果判斷是否寫入狀態,返還剩余 Gas, 執行延遲動作,這里的延遲動作包括對于 runtime 模塊的方法調用,拋出事件, 恢復合約等。ExecutionContext
是一個主要的結構體。
之所以會將 runtime 模塊的方法調用放在最后執行,是因為目前的 runtime 模塊中不支持狀態回滾,這也是為什么目前所有 substrate 模塊的寫法都是先 verify, 各種 ensure!(...)
, 然后 write 寫入存儲, 因為一旦在 write 的過程中出現問題,已經 write 的部分狀態已經改變,并且不可回滾。因此, 必須將所有的判斷放在前面,保證所有判斷通過,最后才執行寫入動作。不過這個問題 substrate 已經在著手解決了,見: Substrate Issue: 2980, 估計再過一段時間應該就會支持 runtime 調用的狀態回滾了。
execute_wasm
本質上是要執行 ExecutionContext 的方法, 代碼在 srml/contracts/src/exec.rs
.
pub struct ExecutionContext<'a, T: Trait + 'a, V, L> {
pub parent: Option<&'a ExecutionContext<'a, T, V, L>>, // 是否有上層 context, 即是不是嵌套調用
pub self_account: T::AccountId, // 合約調用者
pub self_trie_id: Option<TrieId>, // 合約存儲的 key
pub overlay: OverlayAccountDb<'a, T>, // 對于state的改動, 這里只是一個臨時的存儲,只有當合約執行完成后才會寫到鏈上
pub depth: usize, // 合約嵌套深度
pub deferred: Vec<DeferredAction<T>>, // 延遲動作,因為現在 runtime 是一個先 verify 然后 write 并且不可回滾的原因,所有對于 runtime 的調用必須等合約完全成功后才能調用 runtime 里面的東西。
pub config: &'a Config<T>,
pub vm: &'a V, // WasmVm::execute()
pub loader: &'a L, // WasmLoader::load_init(), WasmLoader::load_main()
pub timestamp: T::Moment, // 當前時間戳
pub block_number: T::BlockNumber, // 當前塊高
}
ExecutionContext
有兩個 public 方法對應兩個外部接口的內部實現。
-
call
: 合約調用邏輯 -
instantiate
: 合約創建邏輯。
在 ExecutionContext::instantiate
中,首先判斷調用深度,然后收取實例化的費用,接著計算合約地址, 地址計算公式:
合約地址 = blake2_256(blake2_256(code) + blake2_256(data) + origin)
- code: 合約代碼,
blake2_256(code)
就是 put_code 返回的 code_hash. - data: 合約初始化參數
- origin: 合約創建者賬戶
然后通過 nested.overlay.create_contract(..)
創建合約, overlay 的類型是 OverlayAccountDb
, 所以實際上調用的是 OverlayAccountDb::create_contract
, 代碼在 srml/contracts/src/account_db.rs
.
pub struct OverlayAccountDb<'a, T: Trait + 'a> {
local: RefCell<ChangeSet<T>>,
underlying: &'a dyn AccountDb<T>,
}
創建合約這里主要是向合約默認值注入了兩項內容,一個是 code_hash
, 另一個是 rent_allowance
, 這個 rent_allowance
會在之后收取存儲費用的時候用到, 默認是最大值。
然后剛剛創建好的合約賬戶進行 transfer 的動作, 緊接著 nested.loader.load_init(..)
加載合約的構造函數 delopy
進行初始化。loader
的類型是 WasmLoader
, 也就是調用 WasmLoader::load_init
, 代碼在 srml/contracts/src/wasm/mod.rs
。
load_init
和 load_main
實際上都是調用的 load_code
, 它會比較 schedule 的版本,還記得我們之前在 put_code
的最后是寫入了兩個存儲,一個是原始代碼,一個是原始代碼預處理后的 prefab_module
. 如果當前版本大于已經預處理好的版本, 那么需要重新預處理,否則直接返回已經存儲的 prefab_module
。load_init
最終返回 WasmExecutable
結構體 executable
。
然后將返回的 executable
放到 WasmVm
執行 execute
。WasmVm
實現了 Vm
trait, 這個 trait 定義了 execute
方法,代碼在 srml/contracts/src/wasm/mod.rs
。execute
首先會在沙盒sandbox
中開辟一段新的存儲用于執行 wasm 代碼. execute
在最后是構建一個 sandbox::Instance
, 調用了 Instance
的 invoke
方法, 這部分代碼在 core/sr-sandbox/src/lib.rs
,
core/sr-sandbox/src/lib.rs
中的 Instance::invoke
實際調用的是 srml/sr-sandbox/src/with_std.rs
或者 srml/sr-sandbox/src/without_std.rs
的 Instance::invoke
。std 下調用的是 wasmi 庫, wasmi::ModuleInstance
的 invoke_export
.
執行完 deploy
初始化以后,檢查合約賬戶余額是否足夠,如果低于賬戶存在的最小額,返回錯誤。
如果一切順利,OverlayAccountDb
進行 commit, 注意這里還沒有正式寫入存儲。回到最外層的 execute_wasm
, 如果這里執行正確,DirectAccountDb
進行 commit,這里才是真正寫到存儲里面。然后又是正常的返回剩余 Gas, 和執行延后的 runtime 調用等等。
簡單回顧一下,GasMeter
負責在合約執行過程中扣手續費,所有操作都是先收費. ExecutionContext
是外部接口 instantiate
和 call
的具體執行環境。OverlayAccountDb
是合約執行過程的臨時存儲,用來支持合約回滾。DirectAccountDb
在合約最終執行完畢后,負責真正寫入存儲。以上就是上傳合約代碼和實例化合約的大概流程,下一篇會主要介紹合約調用,合約恢復以及合約存儲收費的主要內容。