Flutterアドベントカレンダーの23日目です。この記事ではEthereumのWalletとFlutterアプリの接続やトランザクションの送信について情報をまとめています。
The Greeting - Your web3 postcards. Simple but Authentic. (GitHub)
Flutter 3.3.7 Dart 2.18.4で開発。web2サーバなし。スマートコントラクトと直接通信なアーキテクチャ。
web3界隈ではJavaScript(ほとんどはReactJS)でクライアントアプリが作られることがほとんどです。実際にETHSanFranciscoで開発されたプロジェクトでもほぼ全てがJavaScript製でした。ただ、Flutterでも開発できればFlutterでwebやモバイルアプリを作れる人がweb3アプリの開発も行えるようになって開発者層が広がります。マルチプラットフォーム向けであって高い生産性を誇るFlutterがweb3系アプリでも活躍できるようになると、ますますweb3界隈が盛り上がるのではと期待しています。
WalletConnectとweb3dart
walletconnect_dartとwalletconnect_qrcode_modal_dartを使用しました。モバイルアプリではWalletアプリに遷移して接続を承認し、自動で元のアプリに返ってくるという動作が実現できました。細かい接続に関するステータスも取得できているのでこれは実用的だと感じました。webでは、QRコードを表示させてスマホ上のWalletアプリからスキャンすると接続してログイン状態にさせることができました。
このWalletConnectの接続を使って、デプロイしたスマートコントラクトと直接通信したりトランザクションを送信したりできます。ただし、トランザクションの送信では、手動でWalletアプリに切り替える必要があります。(この部分は作り込めば、接続中のWalletアプリを判定して自動でアプリ間を遷移するようにできるのかもしれません。ただ、トランザクション送信後に返って来れるかどうかは難しそう)
こちらの動画中のリストの内容や送信ではスマートコントラクトに対してcallContract
やsendTransaction
を直接呼び出しています。walletconnect_dartのセッション情報をweb3dartと組み合わせることで実現できます。コード中では ethereum_connector.dart が参考になると思います。
例えば、次のようなコードでデータのフェッチとEntityへのマッピングを実装しました。
Future<Message> getMessageById(String campaignId, BigInt id) async {
logger.info('🙋 getMessageById: $id of campaign: $campaignId');
final result = await _interactWithCallContractGuard(
() => connector.callContract(
contract,
'getMessageByIdOfCampaign',
params: <dynamic>[
EthereumAddress.fromHex(campaignId),
id,
],
),
);
final messagesRaw = result;
if (messagesRaw.isEmpty) {
throw NotFound();
}
final messageRaw = messagesRaw[0] as List<dynamic>;
return Message(
id: messageRaw[0].toString(),
description: 'Retrieve from IPFS',
dateCreated: DateTime.now(),
sender: WalletAccount(
id: messageRaw[1].toString(),
name: messageRaw[1].toString(),
),
recipient: WalletAccount(
id: messageRaw[2].toString(),
name: messageRaw[2].toString(),
),
greetingWord: messageRaw[3].toString(),
status: MessageStatus.fromValue((messageRaw[5] as BigInt).toInt()),
isResonanced: messageRaw[6] as bool,
);
}
上記のconnector
は、デプロイしたコントラクトのABIをソースに埋め込んでロードするなどして生成しています。
DeployedContract(
ContractAbi.fromJson(theGreetingContractAbi, 'The Greeting'),
contractAddress,
);
トランザクションの送信はこんな感じになります
Future<void> sendTransactionViaContract(
DeployedContract contract,
String functionName, {
EtherAmount? value,
List<dynamic> params = const <dynamic>[],
}) async {
final sender = EthereumAddress.fromHex(_connector.connector.session.accounts[0]);
final credentials = WalletConnectEthereumCredentials(provider: _provider);
try {
final result = await _ethereum.sendTransaction(
credentials,
Transaction.callContract(
contract: contract,
function: contract.function(functionName),
from: sender,
value: value,
nonce: await _ethereum.getTransactionCount(
sender,
atBlock: const BlockNum.pending(),
),
parameters: params,
),
chainId: AppConstant.chainId,
);
logger.info(result);
// ignore: avoid_catches_without_on_clauses
} catch (e) {
logger.severe('Error: $e');
rethrow;
}
}
callContractとsendTransactionができるので、スマートコントラクトと直接通信するアプリでもFlutterで実装できることが分かります。これらはwebでもモバイルアプリでもプラットフォーム関係なく動作します。
[web] ethers.js - ChromeのMetaMask対応するには
上記の方法ではネイティブアプリ向けのUniversal LinkやQRCodeを用いたウォレットとの接続はできますが、Flutterでweb書き出しをしたとしても、PCでChromeブラウザのMetaMaskなどとは接続することができません。
ブラウザのwindow.ethereum
オブジェクトにアクセスする必要がありwebだけ独自の実装が必要になります。
私はまだ試せていませんが、flutter_web3というパッケージがweb専用に作られており、組み合わせて使えばwebでもモバイルアプリでも理想的な方法でWalletとの接続ができると思います。
不安定なところ
- WalletアプリとしてRainbowとMetaMaskを使用していたところ、WalletConnectで接続まではどちらのアプリとでもできるが、RainbowではTransactionの送信でエラーが発生して結局対応を諦めた。MetaMaskでは正常に動作した。
- エラーの詳細は
- 2022年11月6日頃は正常に動作していたMetaMaskも同11月20日頃に久しぶりに動かしたらTransactionの送信でエラーが発生して進行不能になってしまうことがある様子😂。コードは変えていないのでMetaMask側の変化なのかと予想。接続関係の不安定さはちょっと困りますね😓。と思ったら12月23日に試したら成功した。なんだった。
-
ens_dart 1.0.0はEthereumアドレス -> ENS nameの解決はできたけれど、ENS name -> Ethereumアドレスの解決はできなかった。
- やり方か設定が間違っていれば知りたい。あるいはMainnetではできるのかも。
Run JS on Dartという選択肢
WebViewからDartへ通信
Walletとの接続はできるのですが、web3のエコシステムは現在JavaScriptが中心です。ほとんどのweb3系プロジェクトのSDKはJavaScriptにのみ提供されています。今回の開発でも WorldcoinというプロジェクトはJSのSDKしかなく困っていたところ、認証コードを取得するだけでそれ以降はREST APIでも連携可能だったため、Flutter内のWebViewで公式提供のJS SDKを実行してWebViewからDartに認証コードを渡すという実装を行いました。
WebView -> Dartのデータ受け渡しのためにwebviewxを利用しました。proof_of_humanity.page.dart
...
class WebViewXPageState extends State<WebViewXPage> {
late WebViewXController<dynamic> webviewController;
final initialContent = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset=utf-8>
<title>World ID</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="text/javascript" src="https://unpkg.com/@worldcoin/id/dist/world-id.js"></script>
</head>
<body>
<div id="world-id-container"></div>
<script>
document.addEventListener("DOMContentLoaded", async function () {
console.log("From World-ID");
worldID.init('world-id-container', {
debug: true, // to aid with debugging, remove in production
enable_telemetry: true,
action_id: 'wid_staging_16a48f71d4da3d5afda8350c4d33d148', // obtain this from developer.worldcoin.org
signal: 'your_signal',
on_success: (proof) => {
console.log(JSON.stringify(proof))
passProofJsonString(JSON.stringify(proof))
},
on_error: (error) => console.error(error),
})
});
</script>
</body>
</html>
''';
final executeJsErrorMessage =
'Failed to execute this task because the current content is (probably) URL that allows iframe embedding, on Web.\n\n'
'A short reason for this is that, when a normal URL is embedded in the iframe, you do not actually own that content so you cant call your custom functions\n'
'(read the documentation to find out why).';
Size get screenSize => MediaQuery.of(context).size;
@override
void dispose() {
webviewController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Worldcoin Humanity Verification'),
),
body: Center(
child: Container(
padding: const EdgeInsets.all(10),
child: Column(
children: <Widget>[
_buildWebViewX(),
],
),
),
),
);
}
Widget _buildWebViewX() {
return WebViewX(
key: const ValueKey('webviewx'),
initialContent: widget.initialContent ?? initialContent,
initialSourceType: SourceType.html,
height: screenSize.height * 2 / 3,
width: min(screenSize.width * 0.8, 1024),
onWebViewCreated: (controller) => webviewController = controller,
onPageStarted: (src) => debugPrint('A new page has started loading.'),
onPageFinished: (src) => debugPrint('The page has finished loading.'),
dartCallBacks: {
DartCallback(
name: 'passProofJsonString',
callBack: (dynamic msg) {
// TODO(knaoe): verify with REST API
widget.onVerified?.call();
return {showAlertDialog(msg.toString(), context)};
},
),
},
navigationDelegate: (navigation) {
debugPrint(navigation.content.sourceType.toString());
return NavigationDecision.navigate;
},
);
}
}
(Worldcoin認証コードを取得してからの活用についてはハッカソンならではの時間切れ感がありますが😓) FlutterとJS SDK、こんなやり方もできるんだなと作っていて思いました。
純粋に Run JS on Dart
webなら直接JS SDK実行してDartと通信
前述の flutter_web3
は何をしているかというと、jsというDart公式パッケージでDartからブラウザ上のJSと通信して実現しています。つまりethers.jsのDart Wrapper。
web向けにはこの方法が使えます。
モバイルアプリなら
まだ未踏の領域だと思います。私も調査止まりで実際に試せてはいません。flutter_jsというパッケージを使えばそれぞれのOSのJavaScriptエンジン(AndroidはサードパーティのQuickJS)を使ってネイティブコード実行のような形でDartからweb3系JS SDKも実行できると想像しています。
ただし、node_module的な依存関係は全て埋め込む必要があったり、実行環境が違うことによるOS依存のエラーが想像できたりと手軽ではなさそうです。最終手段としてこういう方法があることで安心はします。
将来、web3系プロジェクト側がFlutter向けSDKも提供してくれるようになるように、Flutter製web3モバイルアプリ開発が発展していくことを期待します。