FlutterでTodo管理dAppを作る
やること
web3dart / Truffle / Ganache を使って簡易的なtodo flutterアプリを作成します。
スマートコントラクトのイベントをsubscribeする実装サンプルがあんまりなかったためそこも含めてやってみました。
Web3初心者のため、間違っていることを言っている可能性がありますのでご容赦ください。
また動作確認はAndroidのみで行っています。
作るもの
概要
全体像を把握できてなかったので整理しました。
より詳細はNTT Dataのコラムに乗っている図がわかりやすいです。
Truffle
Solidity
で書いたスマートコントラクトをコンパイルしてローカルのチェーンネットワークにデプロイするためのツールです。コンパイルされたときに生成されたjsonファイルをFlutterプロジェクトに取り込んでweb3dart
からチェーンネットワークに接続できるようにします。
web3dart
- イーサリアムネットワークとのやりとりをwrapしてくれるライブラリです。
JSON RPC
- イーサリアムネットワークと
https/http
でAPIのやり取りをする際にRPC(Remote Procedure Call)
を実現するプロトコルの一つです。別のマシンにあるメソッドを直接叩くようなインタフェースが特徴です。他に比較されるAPI設計としてはREST APIなどがありますがこちらはプロコトルではなくあくまでも設計思想です。
WebSocket
Ganache
スマートコントラクト
- ネット上で検索するとスマートコントラクトとは、「特定の条件が満たされた場合に、決められた処理が自動的に実行される契約履行の管理自動化」とでてきますが、Web2.0におけるAPIサーバーみたいな役割と捉えるとわかりやすいと思います。
EVM
Ethereum Virtual Machine
の略で「イーサリアム仮想マシン」です。Solidityのような高級言語で書かれたコードをバイトコードに変換したりステートの保持や更新をする役割を担います。ただこのEVMは実行速度が遅い、Solidity
のようなイーサリアム専用の独自言語じゃないと動かないなどのデメリットがあり、Ethereum2.0
ではeWASM
というWeb Assembly
ベースの新しいVMに置き換わるそうです。
Blockchain
ローカル環境の構築
- スマートコントラクトはWeb2.0におけるAPIサーバーだと考えると理解しやすいのですが、まずはスマートコントラクトが動く環境を構築し、そこにコントラクトをデプロイすることでアプリからの呼び出しが可能になります。
今回はこのローカル環境の構築にGanacheというツールを使います。インストール後に起動しておきます。 - コントラクトの
Ganache
へのデプロイにはTruffleというツールを使います。truffle init
でプロジェクトを新規作成しておきます。 - iOS/Androidの実機から接続する場合は、Ganacheのホストをlocalhostではなく自身のPCのローカルIPにして下さい。ターミナルで
ifconfig
すれば確認できると思います。Ganache上からは「設定(歯車マーク)」 -> 「Server」から変更ができます。
スマートコントラクトの作成
truffle init
で生成されたcontracts/
配下に以下のコードを作成します。
// TodoContract.sol // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.9.0; contract TodoContract { uint256 public totalTasksCount = 0; struct Task { uint256 id; string name; bool isComplete; } mapping(uint256 => Task) public todos; event TaskCreated(uint256 id, string name); event TaskUpdated(uint256 id, string name); event TaskIsCompleteToggled(uint256 id, string name, bool isComplete); event TaskDeleted(uint256 id); function createTask(string memory _name) public { uint256 id = totalTasksCount; todos[id] = Task(totalTasksCount, _name, false); totalTasksCount++; emit TaskCreated(id, _name); } function updateTask(uint256 _id, string memory _name) public { Task memory currentTask = todos[_id]; todos[_id] = Task(_id, _name, currentTask.isComplete); emit TaskUpdated(_id, _name); } function toggleTaskIsComplete(uint256 _id) public { Task memory currentTask = todos[_id]; todos[_id] = Task(_id, currentTask.name, !currentTask.isComplete); emit TaskIsCompleteToggled( _id, currentTask.name, !currentTask.isComplete ); } function deleteTask(uint256 _id) public { delete todos[_id]; emit TaskDeleted(_id); } }
スマートコントラクトのdeploy
- コンパイルして
Ganache
にアップロードします。
truffle compile truffle migrate
Flutterのセットアップ
- 前項で
TodoContract.json
というファイルが生成されるのでこれをTodoContract.abi.json
にリネームしてFlutterのlib/
の任意の場所に置きます。 - ビルドランナーを走らせてコードを自動生成します。
flutter pub run build_runner build --delete-conflicting-outputs
TodoContract.g.dart
というファイルがジェネレートされるので基本的にアプリからはこのクラスのメソッドを呼び出して使う形になります- 詳細は公式を参照してください。
Contractインスタンスの取得
- 生成されたファイルに
TodoContract
というクラスがあるのでこのインスタンスを取得します
final abiStringFile = await rootBundle.loadString('lib/TodoContract.abi.json'); final jsonAbi = jsonDecode(abiStringFile); final contractAddress = EthereumAddress.fromHex(jsonAbi["networks"]["5777"]["address"]); _todoContract = TodoContract(address: contractAddress, client: _client);
各メソッドの実行
- 以下のような形でメソッド呼びだしをすることが可能で、これによってJSON RPC経由でトランザクションが送信されます。
- 秘密鍵はGanacheの「Accounts」で表示された行の横にある鍵マークをタップすると取得できます。(ローカル環境のものなので公開してます)
final _credentials = EthPrivateKey.fromHex(dotenv.env['PRIVATE_KEY']!); _todoContract.createTask(name, credentials: _credentials)); _todoContract.updateTask(todo.id, todo.name, credentials: _credentials)); _todoContract.toggleTaskIsComplete(id, credentials: _credentials)); _todoContract.deleteTask(id, credentials: _credentials));
イベントの結果通知を受け取る
- スマートコントラクト上で定義したイベントをアプリ側で受け取れるようにします。イベントストリームも先ほど生成された Contractクラスの中に含まれているのでそれを使います。
- イベントストリームの取得に
toBlock
/fromBlock
という引数がありますが、これは指定したブロック間に発生したEventを受け取るようにするためのものです。なにも指定しないと過去に発生したイベントが全て通知されるので、今回のように今後発生するEventのみを購読したい場合はtoBlock
にBlockNum.genesis()
を指定します。 API docs: eth_getLogs - listenすると
Subscription
クラスが返却されるので、画面遷移等でEventを受け取る必要なくなった際にdispose
するのを忘れないようにしてください。
final subscription = _todoContract .taskCreatedEvents(toBlock: const BlockNum.genesis()) .listen((event) { // handle event });
すべてのTodoを取得する
- トータルのTodoの数を取得したのちに、ループを回して指定したindexのTodoを取得するというコードになっています。
- 一回で全件とれないのかと思ったのですが、どうやらSolidityのmappingはkey-valueのペアをループする仕組みがないのと、
private
internal
のみでしか返り値として指定できないそうです。もしかしたら配列で返すようなgetterをContract側に生やした方がいいかもしれません。
- 一回で全件とれないのかと思ったのですが、どうやらSolidityのmappingはkey-valueのペアをループする仕組みがないのと、
- 最後に
where
で空文字を弾いているのですが、これはdeleteしたデータも取得できてしまい、そのデータのnameが空になっているからです。なぜなのかまだ追えてません。
void _getAllTodos() async { final count = await _todoContract.totalTasksCount(); final allTodos = [for (var i = 0; i < count.toInt(); i++) await _getTodo(i)] .where((element) => element.name.isNotEmpty) .toList(); ); } Future<Todo> _getTodo(int index) async { final masterTodo = await _todoContract.todos(BigInt.from(index)); return Todo( id: masterTodo.id, name: masterTodo.name, completed: masterTodo.isComplete); }
感想
非構造化されたバイトデータしか扱えないのが辛い
例えばですが、本当だったらupdateTodo()
という関数の引数にはユーザーが定義したTodo
オブジェクトを渡したいですが、それができません。
updateTodo(string name, int id)
みたいにする必要があります。
返り値やユーザー定義のオブジェクトのネストみたいな場合も同様です。
これはちゃんとデータモデリングをしていたり規模が大きいアプリになると辛くなってくる気がします。
グローバルステートの排他制御
クライアント側でstateの排他制御は必要ないのか?という疑問が思い浮かんで調べてみました。
結論としてはEVMがトランザクションを1個流しで処理している(厳密には並行処理)ので大丈夫なようですが、このため処理速度が遅いというデメリットもあるみたいです。(理解が間違っていたらすいません)
Concurrency and Parallelism in Smart Contracts, Part 1
一方でAptosやSuiで採用されているMove言語
はユーザー定義の型が扱えるかつステートアクセスへのアトミック性が言語レベルで担保されていて、そのため並列処理が可能になっていて速いとのことです。とても気になったので時間があるときに触ってみたいです。
Why Move? Move vs Solidity
最後に
意味不明だったスマートコントラクトやEVM等の概念とdappとの関係性理解がすごく深まりました。
アプリからの利用という側面を考えたときにまだまだ発展途上という印象ですが、これからどのように進化していくのかとても楽しみです!
次回はこのコードをベースに WalletConnect x Metamaskでウォレット連携をやってみようと思います。