substrate 合約模塊簡要剖析(一)

本文主要介紹 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, 但是目前還沒有實施。

合約模塊一共有三個與外部交互的接口:

dispatchable
  • put_code: 上傳代碼, 將準備好的 WASM 合約代碼存儲到鏈上, 如果執行成功,會返回一個 code_hash, 然后可以通過這個 code_hash 創建合約。先將代碼存儲到鏈上的好處是,對于合約內部邏輯相同而只有初始化參數不一樣的合約,比如很多以太坊上的很多 ERC20 合約,鏈上只需要存儲一份代碼,而不需要每次新建一個合約的時候,都要存儲一份重復的代碼,這顯然是冗余的。

  • instantiate: 實例化合約, 通過 put_code 返回的 code_hash 并傳入初始化參數創建一個合約賬戶,實例化過程會調用合約內部的 deploy 函數對合約進行初始化,初始化只有一次。最近 substrate 將合約模塊的實例化方法從之前的 create 重命名為了 instantiate, 見:PR: 3645。

  • call: 調用合約。在這里需要注意的是 substrate 有個存儲收費的邏輯,如果調用的時候合約賬戶余額不足,合約就會被刪除 evict, 很多人應該遇到過這種情況。

put_code: 上傳合約代碼

  1. 調用 gas::buy_gas 根據 gas_limit 預收取手續費。這一步是預先收取交易發起人的手續費。如果最后執行完成后,如果 Gas 沒用完,會將剩余的 Gas 返還給用戶。buy_gas 的代碼在 srml/contracts/src/gas.rs。
收取手續費 = gas_price * gas_limit
  1. 將代碼存儲到鏈上,調用 wasm::code_cache::save 執行存儲代碼的邏輯, save 代碼位于 srml/contracts/src/wasm/code_cache.rs
save_code

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.

  1. 返回剩余的 gas.

instantiate: 創建合約

通過 execute_wasm 構建 wasm 的基本執行過程。外部接口 instantiatecall 實際上都是要走 execute_wasm,粗線條來講,execute_wasm 第一步還是根據 gas_price * gas_limit 收取手續費, 然后構造一個頂層的執行環境 ExecutionContext 執行 wasm ,根據執行結果判斷是否寫入狀態,返還剩余 Gas, 執行延遲動作,這里的延遲動作包括對于 runtime 模塊的方法調用,拋出事件, 恢復合約等。ExecutionContext 是一個主要的結構體。

execute_wasm

之所以會將 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 中,首先判斷調用深度,然后收取實例化的費用,接著計算合約地址, 地址計算公式:

contract_address
合約地址 = 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>,
}
create_account

創建合約這里主要是向合約默認值注入了兩項內容,一個是 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_initload_main 實際上都是調用的 load_code, 它會比較 schedule 的版本,還記得我們之前在 put_code 的最后是寫入了兩個存儲,一個是原始代碼,一個是原始代碼預處理后的 prefab_module. 如果當前版本大于已經預處理好的版本, 那么需要重新預處理,否則直接返回已經存儲的 prefab_module。load_init 最終返回 WasmExecutable 結構體 executable

然后將返回的 executable 放到 WasmVm 執行 executeWasmVm 實現了 Vm trait, 這個 trait 定義了 execute 方法,代碼在 srml/contracts/src/wasm/mod.rsexecute 首先會在沙盒sandbox中開辟一段新的存儲用于執行 wasm 代碼. execute 在最后是構建一個 sandbox::Instance, 調用了 Instanceinvoke 方法, 這部分代碼在 core/sr-sandbox/src/lib.rs,

sandbox_imp

core/sr-sandbox/src/lib.rs 中的 Instance::invoke 實際調用的是 srml/sr-sandbox/src/with_std.rs 或者 srml/sr-sandbox/src/without_std.rsInstance::invoke。std 下調用的是 wasmi 庫, wasmi::ModuleInstanceinvoke_export.

執行完 deploy 初始化以后,檢查合約賬戶余額是否足夠,如果低于賬戶存在的最小額,返回錯誤。

如果一切順利,OverlayAccountDb 進行 commit, 注意這里還沒有正式寫入存儲。回到最外層的 execute_wasm, 如果這里執行正確,DirectAccountDb 進行 commit,這里才是真正寫到存儲里面。然后又是正常的返回剩余 Gas, 和執行延后的 runtime 調用等等。

簡單回顧一下,GasMeter 負責在合約執行過程中扣手續費,所有操作都是先收費. ExecutionContext 是外部接口 instantiatecall 的具體執行環境。OverlayAccountDb 是合約執行過程的臨時存儲,用來支持合約回滾。DirectAccountDb 在合約最終執行完畢后,負責真正寫入存儲。以上就是上傳合約代碼和實例化合約的大概流程,下一篇會主要介紹合約調用,合約恢復以及合約存儲收費的主要內容。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374