メインコンテンツまでスキップ

「smart-contract」タグの記事が1件件あります

全てのタグを見る

IOST V8VM の設計

· 約16分
Sherwin Li
IOST コアエンジニア

Ethereum Virtual Machine(EVM)は「準チューリング完全」な 256bit 仮想マシンで、Ethereum ネットワークの最も重要な構成要素のひとつです。Ethereum の登場以降、EVM ベースのスマートコントラクト開発は徐々に成熟し、CryptoKitties や最近話題の Fomo3D など多くの DApp が登場しました。スマートコントラクトと仮想マシンの重要性はほぼすべてのブロックチェーン開発者の共通認識となり、VM の使いやすさと開発体験は各パブリックチェーンが競い合う主戦場となり、最終的にチェーンが到達できる高さを大きく左右します。

1. EVM はチューリング完全ではない

「EVM はチューリング完全である」という誤解がネット上には広く見られますが、実際にはそうではありません。

チューリングマシンは 1936 年にアラン・チューリングが提案した数学モデルで、無限長のテープ、有限のアルファベット、読み書きヘッド、状態レジスタ、有限の命令セットからなります。初期構成から始まり、ヘッドは命令セットに従って一歩ずつ動作と書き込みを行い、状態が「停止」に達するまで計算を続けます。計算分野で扱うあらゆる問題は計算問題であり、チューリング完全であるということは任意の計算可能問題が解けることを意味します。プログラミング言語や仮想マシンは本質的にチューリングマシンであり、チューリング完全であればチューリングマシンが行えることをすべて行える、つまりあらゆる計算可能問題を解くことができます。

ところが EVM の設計では、命令の実行は gas によって制約されており、実行可能な計算回数が制限されています。これはチューリング不完全になる代表的な原因のひとつで、ループ・再帰・計算がすべて有界になることでプログラムの終了が保証されます。したがって EVM 上で動かせるプログラムは多くの制約を受け、EVM はチューリング完全ではありません。

2. EVM の不合理な設計

EVM は最初期の準チューリング完全な仮想マシンとして、スマートコントラクト指向の DApp 開発を切り開きました。一方でブロックチェーン応用の広がりとともに、EVM 初期の不合理な設計が徐々に表面化し、深刻なセキュリティ問題を引き起こす設計まで含まれていました。VM レイヤだけを見ても、設計面・セキュリティ面に次の課題があります:

2.1 スマートコントラクトの設計面

  • 標準ライブラリの不足:EVM には十分な標準ライブラリ支援がなく、もっとも基本的な string 型まわりですら扱いにくく、文字列の連結・分割・検索などはすべて開発者自身で実装する必要があります。結果として、開発者は本来のビジネスロジック以外の細部に手を取られ、本業に集中できません。自前のライブラリは時間計算量・空間計算量が過大になり無駄な gas を消費しがちで、オープンソースから借用すれば今度はセキュリティ上の懸念を抱え込み、コントラクト監査の手間が増えるなど、得失が見合いません。
  • デバッグ・テストが困難:EVM はデバッグもテストも難しく、OutOfGas 例外を投げる以外に開発者へ情報を返してくれません。ログ出力もなく、ブレークポイントもステップ実行もありません。event 機構はある程度の代替になりますが、そもそもデバッグツールとして設計されたものではなく、優雅で使いやすいとは言いがたいです。
  • 浮動小数点の非サポート:EVM は浮動小数点をサポートせず、Ethereum は Wei を最小単位とし整数しか扱いません。これにより浮動小数の精度問題は避けられますが、実際の開発では eth 量を表すのに変数の後ろに 0 を大量に並べることになり、コードが極めて保守しづらくなります。特定の場面では浮動小数は依然として有用であり、一律に切り捨てて良いものではありません。
  • コントラクトをアップグレードできない:EVM はコントラクトのアップグレードに対応しておらず、アップグレード可能性はスマートコントラクト開発における必須要件のひとつです。セキュリティパッチ適用や機能拡張に必要となりますが、EVM は一切これをサポートせず、開発者は新しいコントラクトを再デプロイするほかなく、時間と労力を浪費します。

2.2 スマートコントラクトのセキュリティ面

  • オーバーフロー攻撃:EVM の safeMath ライブラリはデフォルトで使われません。Solidity で uint256 を計算した際、結果が uint256 の最大値を超えるとオーバーフローして極端に小さな値に化け、脆弱性が生じます。BEC や SMT といったトークンはいずれもオーバーフロー攻撃を受け、深刻な被害が出ました。BEC のオーバーフロー脆弱性は次のような形でした:
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value; // ここでオーバーフローが発生
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount); // オーバーフロー後 require が常に成立し、脆弱性が成立する

balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
  • 再入攻撃:Solidity の大きな特徴は外部コントラクトを呼び出せる点ですが、ETH を外部アドレスに送るときや外部コントラクトを呼ぶときには外部呼び出しが必要です。送金先が悪意あるコントラクトの場合、fallback 関数に悪意あるコードを仕込んでおけば、送金時に fallback が呼ばれて悪意あるコードが脆弱な関数を再度呼び、送金処理を二重に実行できてしまいます。最も有名な再入攻撃が Ethereum 初期の DAO 事件です。以下のスニペットがその構造を示しています:
contract weakContract {
mapping (address => uint) public balances;
...... // その他のコントラクトコード
function withdraw() {
// 呼び出し元の残高を送金し、その後 balance マップを 0 にする。
// !!! 0 にする処理が走るまで、msg.sender を何度でも呼べてしまい、ここで再入が発生する。
if (!msg.sender.call.value(balances[msg.sender])()) {
throw;
}
balances[msg.sender] = 0;
}
..... // その他のコントラクトコード
}

contract attack{
weakContract public weak;
...... // その他のコード
// 以下の関数は fallback。外部送金時に呼ばれ、weakContract.withdraw を繰り返し呼び出して再入攻撃を行う。
function () payable {
if (weak.balance >= msg.value) {
weak.withdraw();
}
}
...... // その他のコード
}
  • 想定外の関数実行:EVM は関数呼び出しを厳密にはチェックしません。コントラクトアドレスを引数として受け取り、かつそれを呼び出し側が操作できる場合、想定外の挙動を招くことがあります:
contract A {
function withdraw(uint) returns (uint);
}

// B を実行する際、A コントラクトに withdraw メソッドがあるかだけを確認し、あれば呼び出す。
// 渡された a に withdraw が無ければ、a の fallback 関数が呼ばれ、想定外の挙動が起きる。

contract B {
function put(A a){ a.withdraw(42);
}

3. もうひとつの注目チェーン EOS が抱える課題

EOS は Ethereum に続くもうひとつの注目パブリックチェーンで、WebAssembly ベースの独自スマートコントラクトエンジンを持ちます。しかし EOS のコントラクト開発には次のような目立つ問題があります:

  • アカウントシステムが扱いにくい:アカウント作成の難易度が高く、コントラクトを公開するにはアカウントが必要です。EOS では既存アカウントから新規アカウントを作る必要があり、すでに EOS アカウントを持っている知人や第三者を見つけるのは誰にとっても容易ではありません。アカウント作成には RAM の購入も必要、つまり実費がかかり、第三者経由だと資金の取扱リスクも生まれます。アカウントを作っても、その後 EOS をステーキングして CPU 時間とネットワーク帯域を確保しなければ何もできません。開発者にとっては手間が多すぎます。
  • RAM 価格:RAM は高額です。コントラクト実行に RAM が必要で、EOS は RAM 売買市場を開きましたが、投機的売買により価格が高騰しています。
  • 開発難易度が高い:契約言語が C++ であることが開発のハードルを大きく押し上げています。C++ 自体が複雑なうえに EOS.IO の C++ API も使いこなす必要があり、開発者個々のスキル要求が極めて高いです。

これらの理由から、EOS のスマートコントラクト開発は開発者にとって魅力が薄く、むしろ EOS を見限る理由にすらなり得ます。

4. IOST 仮想マシンの誕生

良い仮想マシンとは、設計が優雅であると同時に使いやすさと安全性を満たす必要があると考えています。EVM、EOS、C Lua、V8 など各種 VM の長所と短所を比較したうえで、EVM や EOS に存在した不合理な設計を根本から見直し、Node.js と Chrome において優れた実績を持つ V8 をベースに IOST 仮想マシンを構築しました。

4.1 IOST V8VM のアーキテクチャと設計

V8VM アーキテクチャの中核は VMManager で、主に次の三つの機能を担います:

  • VM エントリ:他モジュールからのリクエスト(RPC リクエスト、ブロック検証、Tx 検証など)を受け取り、前処理・整形を行ったうえで VMWorker に実行を委譲します。
  • VMWorker のライフサイクル管理:現在のシステム負荷に応じて worker 数を柔軟に調整し、worker を再利用します。さらに worker 内部で JavaScript コードのホットスタートとホット Sandbox スナップショットの永続化を実装することで、VM の頻繁な作成や同一コードの再ロードによる高負荷・メモリ膨張を回避し、システム消費を抑えつつスループットを大きく引き上げました。Fomo3D のように単一コントラクトに大量のユーザーアクセスが集中する典型的なワークロードでも、IOST V8VM は余裕を持って処理できます。
  • State データベースとの相互作用:IOST のすべてのトランザクションに対して原子性を保証し、コントラクト実行失敗時や gas 不足時にはトランザクション全体をロールバックします。State データベース側では二段階のメモリキャッシュを実装したうえで RocksDB へ flush します。

4.2 Sandbox のコア設計

Sandbox は JavaScript スマートコントラクトを実際に実行する場で、上位の V8VM を受け、下位の Chrome V8 をラップして呼び出します。Compile 段階と Execute 段階に分かれます:

Compile 段階

主にコントラクト開発とオンチェーン公開向けの段階で、次の二つの主要機能があります:

  • Contract Pack:スマートコントラクトのバンドル。webpack を用いて実装され、対象プロジェクト下のすべての JavaScript コードをパッケージ化し、依存関係の自動インストールも行います。これにより IOST V8VM 上で大規模なコントラクトプロジェクトを構築できます。IOST V8VM は Node.js のモジュールシステムと完全互換で、require / module.exports / exports をそのまま利用でき、ネイティブな JavaScript 開発体験を提供します。
  • Contract Snapshot:V8 のスナップショット機能を使い、JavaScript コードを事前にコンパイルします。コンパイル済みコードによって Chrome V8 の isolate と context の生成効率が向上し、実行時にはスナップショットを deserialize するだけで済むため、ロード速度・実行速度ともに大幅に改善します。

Execute 段階

実際にオンチェーンでコントラクトを実行する段階で、次の二つの主要機能があります:

  • LoadVM:VM の初期化を行います。Chrome V8 オブジェクトの生成、システム実行パラメータの設定、関連 JavaScript ライブラリのインポートなど、コントラクト実行までの準備をすべて整えます。主な JavaScript ライブラリ:
ライブラリ役割
BlockchainNode.js 風のモジュールシステム。モジュールキャッシュ、事前コンパイル、循環参照の取り扱いなど。
EventJavaScript から State データベースへの読み書きと、失敗・例外時のロールバック。
NativeModuleブロックチェーン関連のプリミティブ。transferwithdraw、現在のブロック・トランザクション情報取得など。
Storageイベント実装。JavaScript コントラクト内で発火したイベントは、オンチェーン確定後にコールバックとして配送されます。
  • Execute:最終的な実行ステップ。IOST V8VM は専用スレッドでコントラクトを実行し、現在の実行状況を監視します。例外発生・リソース超過・実行時間超過のいずれかが起きた場合は Terminate を呼び出して実行を打ち切り、エラー結果を返します。

4.3 IOST V8VM のパフォーマンス

仮想マシンはパブリックチェーンの最重要インフラのひとつであり、パフォーマンスが優れていなければなりません。IOST では設計段階・VM 選定段階から、パフォーマンスを最重要指標のひとつに位置付けてきました。

Chrome V8 は JIT、インラインキャッシュ、遅延ロードなどによって JavaScript の解釈実行を高速化しています。Chrome V8 の高性能のおかげで、IOST V8VM の JavaScript 実行速度は劇的に向上しました。再帰 fibonacci、メモリコピー、複雑な CPU 演算の三方面で EVM、EOS、C Lua、V8VM を比較した結果は次のとおりです:

  • テスト環境
項目設定
SystemAWS EC2
CPU2 CPU
Memory8 GB
  • 結果
evmlua ceos.binaryeneos.wavmIOST V8VM
cpu calculate (8000)92 ms6.34 ms3.34 ms10.28 ms6.26 ms
fibonacci (32)8.25 s470 ms5.5 s541 ms45 ms
string concat (10000)2.18 s370 ms316 ms96.3 ms9.3 ms

実測の結果、IOST V8VM は主要 VM 実装の中でも優れたパフォーマンスを示しました。上の数値には VM 起動と設定ロードの時間も含まれており、コールドスタートの段階で既に有利であることが分かります。今後は VM オブジェクトプールや LRU キャッシュなどを導入し、CPU・メモリ使用率をさらに改善して、IOST のスマートコントラクト処理能力を一段引き上げる予定です。

4.4 まとめ

現時点で IOST V8VM の第一版を完成させ、当初予定したすべての機能を実装しました。今後の反復では 安全性と使いやすさ を最優先に、次の三方向で改善を続けます:

  • 高性能。コントラクトをより高速に実行できるように。
  • 開発体験の向上。標準ライブラリを拡充・整備。
  • 大規模プロジェクトのビルドとデバッグを完全にサポートする充実したツールチェーン。

第一版の IOST V8VM 開発では 投票、コントラクト名、トークン など多くのアイデアを初期検証できました。新機能・新特性は今後のテストネット更新で順次提供していきます。

付録: VM ベンチマークプログラム

    1. EVM コード
package evm

import (
"math/big"
"testing"

"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/accounts/abi/bind/backends"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/crypto"
)

var bm *Benchmark

func init() {
key, err := crypto.GenerateKey()

auth := bind.NewKeyedTransactor(key)
gAlloc := map[common.Address]core.GenesisAccount{
auth.From: {Balance: big.NewInt(1000000)},
}
sim := backends.NewSimulatedBackend(gAlloc)

_, _, bm, err = DeployBenchmark(auth, sim)

if err != nil {
panic(err)
}
sim.Commit()
}

func BenchmarkFibonacci(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := bm.Fibonacci(nil, big.NewInt(32))
if err != nil {
b.Fatalf("fibonacci run error: %v\n", err)
}
}
}

func BenchmarkStrConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := bm.StrConcat(nil, "This is vm benchmark, tell me who is slower", big.NewInt(10000))
if err != nil {
b.Fatal(err)
}
}
}

func BenchmarkCalculate(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := bm.Calculate(nil, big.NewInt(5000))
if err != nil {
b.Fatal(err)
}
}
}
    1. Lua コード
function fibonacci(number)
if number == 0
then
return 0
end

if number == 1
then
return 1
end

return fibonacci(number - 1) + fibonacci(number - 2)
end

function strConcat(str, cycles)
local result = ""
for i = 1, cycles do
result = result .. str
end

return result
end

function calculate(cycles)
local rs = 0
for i = 0, cycles-1 do
rs = rs + math.pow(i, 5)
end

return rs
end
    1. EOS コード
class fibonacci : public eosio::contract {
public:
using contract::contract;

/// @abi action
void calcn(int64_t n) {
int64_t r = calc(n);
print(r);
}
int calc( int64_t n ) {
if (n < 0)
{
return -1;
}
if (n == 0 || n == 1)
{
return n;
}
return calc(n - 1) + calc(n - 2);
}
};

EOSIO_ABI( fibonacci, (calcn) )


class stringadd : public eosio::contract {
public:
using contract::contract;

/// @abi action
void calcn(std::string s, int64_t cycles) {
std::string ss(s.size() * cycles, '\0');
int32_t k = 0;
for (int i = 0; i < cycles; ++i)
{
for (int j = 0; j < s.size(); ++j)
{
ss[k++] = s[j];
}
}
print(ss);
}
};

EOSIO_ABI( stringadd, (calcn) )


class calculate : public eosio::contract {
public:
using contract::contract;

/// @abi action
void calcn(uint64_t cycles) {
uint64_t rs = 0;
for (uint64_t i = 0; i < cycles; ++i)
{
rs = rs + i * i * i * i * i;
}
print(rs);
}
};

EOSIO_ABI( calculate, (calcn) )