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

  • 2019 年 10 月 8 日
  • 筆記

本文主要介紹 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_moduleload_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 在合約最終執行完畢後,負責真正寫入存儲。以上就是上傳合約程式碼和實例化合約的大概流程,下一篇會主要介紹合約調用,合約恢復以及合約存儲收費的主要內容。