Connect flutter to block chain

flutterを用いたdAppの開発方法

ほとんどの方はjavascriptを用いてフロントエンドとスマートコントラクトを接続していると思います。しかし、現在dartという言語を用いたflutterというフレームワーク(javascriptでいうReact Native)はどんどん人気を増しておりflutterを用いてフロントとブロックチェーンの接続ができるようになることはできて損はないことだと思います。

しかし、flutterを用いてスマートコントラクト内の関数を呼び出したりウォレットと接続を行う方法を解説したものはあまりないように思います。

なので今回はflutter(+hardhat+solidity)でのdApp開発の流れを解説していきたいと思います。

solidity+hardhatでのスマートコントラクト開発

まずsolidityでコードを記述して、hardhatを通じてチェーンにdeployするところまでを行います。今回はとても単純なコントラクトを開発します。入力された値を二乗する、その値を求められたら返すという二つのメソッドを持ったコントラクトです。前者はブロックチェーンの値を変えるメソッド、後者はread-onlyなメソッドであるという特徴があります。

まずはhardhatをインストールします。

npm install hardhat dotenv

次に下のコマンドをターミナルで実行することでhardhatの関数を作ります。

npx hardhat 

その後hardhat.config.ts(jsで作成した方はhardhat.config.js)を下のように変えます。

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@typechain/hardhat";
require("dotenv").config();

const config: HardhatUserConfig = {
  solidity: "0.8.0",
  networks: {
    testnet_aurora: {
      url: "https://testnet.aurora.dev",
      accounts: [process.env.PRIVATE_KEY!],
    },
  },
};

export default config;

今回はaurora testnetにdeployすることにします。他のチェーンにdeployしたい方はurlの部分を変更すればOKです。

次に.envファイルを一番上の階層(contractsと同じ階層)に作成して下のように自分のウォレットアドレスのprivate keyをYOUR_PRIVATE_KEYに代入します。

// .env
PRIVATE_KEY="YOUR_PRIVATE_KEY"

コントラクトは下のように記述しましょう。とても簡単なので、コントラクトの状態変数を更新するsquare_num関数と、その状態変数を返すreturn_num関数を定義します。

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Square{

    uint256 state_num = 0;
    function square_num(uint256 num) public{
        state_num = num ** 2;
    }

    function return_num() public view returns (uint256 num){
        return state_num;
    }
}

今回はtestのためのコードは省略して、deployのための関数をscripts/deploy.tsに下のように書いていきます。

require("dotenv").config();
const hre = require("hardhat");

const provider = hre.ethers.provider;
const deployerWallet = new hre.ethers.Wallet(process.env.PRIVATE_KEY, provider);

async function main() {
  console.log("Deploying contracts with the account:", deployerWallet.address);

  console.log(
    "Account balance:",
    (await deployerWallet.getBalance()).toString()
  );

  const contractFactory = await hre.ethers.getContractFactory("Square");
  const SquareContract = await contractFactory.connect(deployerWallet).deploy();
  await SquareContract.deployed();

  const [deployer] = await hre.ethers.getSigners();
  console.log(`deployer address is ${deployer.address}`);

  console.log("Square Contract is deployed to:", SquareContract.address);
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

これでコントラクトをdeployする準備はできたので下のコマンドをターミナルで実行することでdeployしましょう!

npx hardhat run scripts/deploy.ts --network testnet_aurora

これが成功すれば下のようにコンソールにメッセージが返ってくるのでコントラクトのアドレスを控えておきましょう。

Deploying contracts with the account: 0xa9eD1748Ffcda5442dCaEA242603E7e3FF09dD7F
Account balance: 228446304210000000
deployer address is 0xa9eD1748Ffcda5442dCaEA242603E7e3FF09dD7F
Square Contract is deployed to: 0xeeD9c96af6821213b8e0a57e0BC34C602ed3878E

deployが完了すればデプロイ先のアドレスとabiファイルが得られます。

デプロイ先のアドレスはdeploy.tsにコンソールに出すように記述して、そのアドレスを控えておく必要があります。

abiファイルはartifacts/contracts/square.sol/Square.jsonに入っています。

flutterとウォレットの接続

※今回はflutterの環境構築をしている前提で解説しています。

dAppを開発する時に必ずと言っていいほど必要なウォレット接続について解説します。今回は最も有名なウォレットの一つであるMetaMaskとの接続をしていきます。

実装を開始する前に、実機でデバッグする方もエミュレータでデバッグする方もMetaMaskをインストールしましょう。

また、infuraでアカウントを作成して自分の接続するチェーン(ここではAurora Testnet)と接続するためのhttp keyを取得しましょう。

まずは下のコマンドをターミナルで実行させることでflutterのプロジェクトを作ります。

flutter create web3_turorial

次に一番上の階層(libディレクトリ)に.envファイル、smart_contractディレクトリを作成しましょう。きちんと作成できていれば下のようなディレクトリ構造になっているはずです。

.envファイルは下のようにしましょう。

INFURA_KEY_TEST = "INFURA_KEY"

SQUARE_CONTRACT_NAME = "Square"
SQUARE_CONTRACT_ADDRESS = "0xeeD9c96af6821213b8e0a57e0BC34C602ed3878E"

INFURA_KEYにinfuraで取得したkeyを、SQUARE_CONTRACT_ADDRESSには先ほどdeploy.tsで取得したコントラクトのアドレスを入れましょう。

次に下のようにpubspec.yamlファイルに必要なライブラリとファイル・ディレクトリを記述していきます。

// pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  web3_connect: ^0.0.3
  http: ^0.13.5
  web3dart: ^2.3.5
  web_socket_channel: ^2.2.0
  flutter_dotenv: ^5.0.2
  url_launcher: ^6.1.2

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^2.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:
  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  assets:
    - .env
    - smart_contract/

pubspec.yamlファイルではスペースがおかしいときちんと動作しないので注意しましょう。

ではmain.dartに必要なコードを下のように記述していきましょう。コード量はたくさんありますが、後で説明していきます。

// main.dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher_string.dart';
import 'package:web3_connect/web3_connect.dart';
import 'package:web3dart/web3dart.dart';
import 'package:web_socket_channel/io.dart';

Future main() async {
  await dotenv.load(fileName: ".env");
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter web3'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final connection = Web3Connect();
  final String _rpcUrl = "https://testnet.aurora.dev";

  final _client = Web3Client("https://testnet.aurora.dev", http.Client(),
      socketConnector: () {
    return IOWebSocketChannel.connect("wss://testnet.aurora.dev")
        .cast<String>();
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Container(
        height: double.infinity,
        width: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text("Let's learn how to connect Flutter to web3!"),
            ElevatedButton(
                onPressed: () async {
                  connection.enterChainId(1313161555);
                  connection.enterRpcUrl(_rpcUrl);
                  await connection.connect();
                  if (connection.account != "") {
                    if (mounted) {
                      Navigator.of(context).push(
                        MaterialPageRoute(
                          builder: (context) => NextPage(
                            connection: connection,
                          ),
                        ),
                      );
                    }
                  }
                },
                child: const Text("Connect Wallet"))
          ],
        ),
      ),
    );
  }
}

class NextPage extends StatefulWidget {
  NextPage({Key? key, required this.connection}) : super(key: key);
  final Web3Connect connection;

  @override
  State<NextPage> createState() => _NextPageState();
}

class _NextPageState extends State<NextPage> {
  late Web3Client auroraClient;
  Web3Client? _client;
  final String _rpcUrl = "https://testnet.aurora.dev";
  final String _wsUrl = "wss://testnet.aurora.dev";
  String? _abiCode;
  EthereumAddress? _contractAddress;
  final String _deepLink =
      "wc:00e46b69-d0cc-4b3e-b6a2-cee442f97188@1?bridge=https%3A%2F%2Fbridge.walletconnect.org&key=91303dedf64285cbbaf9120f6e9d160a5c8aa3deb67017a3874cd272323f48ae";
  int num = 5;
  TextEditingController numController = TextEditingController(text: "1");

  void initState() {
    super.initState();
    final INFURA_KEY_TEST = dotenv.env["INFURA_KEY_TEST"];
    auroraClient = Web3Client(INFURA_KEY_TEST!, http.Client());
    _client = Web3Client(_rpcUrl, http.Client(), socketConnector: () {
      return IOWebSocketChannel.connect(_wsUrl).cast<String>();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.connection.account.toString()),
      ),
      body: Container(
        height: double.infinity,
        width: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              num.toString(),
              style: const TextStyle(
                fontSize: 50,
                fontWeight: FontWeight.bold,
              ),
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () async {
                    await sendTransaction(
                      widget.connection,
                      "square_num",
                      [
                        BigInt.from(int.parse(numController.text)),
                      ],
                    );
                  },
                  child: const Text("set squared num"),
                ),
                const SizedBox(
                  width: 15,
                ),
                SizedBox(
                  width: 40,
                  height: 40,
                  child: TextFormField(
                    controller: numController,
                    decoration: InputDecoration(
                      enabledBorder: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(3),
                        borderSide: const BorderSide(
                          color: Colors.grey,
                          width: 1.0,
                        ),
                      ),
                      focusedBorder: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(3),
                        borderSide: const BorderSide(
                          color: Colors.blue,
                          width: 2.0,
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            ),
            ElevatedButton(
              onPressed: () async {
                final list = await query("return_num", []);
                num = int.parse(list[0].toString());
                setState(() {});
              },
              child: const Text("get state num"),
            ),
          ],
        ),
      ),
    );
  }

  Future<DeployedContract> getContract() async {
    String abi = await rootBundle.loadString('smart_contract/Square.json');
    DeployedContract contract = DeployedContract(
      ContractAbi.fromJson(jsonEncode(jsonDecode(abi)["abi"]),
          dotenv.env["SQUARE_CONTRACT_NAME"]!),
      EthereumAddress.fromHex(dotenv.env["SQUARE_CONTRACT_ADDRESS"]!),
    );
    return contract;
  }

  Future<List<dynamic>> query(String functionName, List<dynamic> args) async {
    DeployedContract contract = await getContract();
    ContractFunction function = contract.function(functionName);
    List<dynamic> result = await auroraClient.call(
      contract: contract,
      function: function,
      params: args,
    );
    return result;
  }

  Future<void> sendTransaction(
      Web3Connect connection, String functionName, List<dynamic> args) async {
    if (widget.connection != null && _client != null) {
      final contract = await getContract();
      ContractFunction function = contract.function(functionName);
      final transaction = Transaction.callContract(
        contract: contract,
        function: function,
        from: EthereumAddress.fromHex(connection.account),
        parameters: args,
      );
      final tra = _client!.sendTransaction(connection.credentials, transaction,
          chainId: 1313161555);
      if (!await launchUrlString(_deepLink)) {
        throw "Could not launch $_deepLink";
      }
      await tra;
    } else {
      print("There is no connection to wallet or no client");
    }
  }
}

まずmain関数で.envファイルから環境変数を利用することを宣言します。

Future main() async {
  await dotenv.load(fileName: ".env");
  runApp(const MyApp());
}

次にウォレットと接続するためのwidgetを作っています。

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final connection = Web3Connect();
  final String _rpcUrl = "https://testnet.aurora.dev";
  final _client = Web3Client("https://testnet.aurora.dev", http.Client(),
      socketConnector: () {
    return IOWebSocketChannel.connect("wss://testnet.aurora.dev")
        .cast<String>();
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Container(
        height: double.infinity,
        width: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text("Let's learn how to connect Flutter to web3!"),
            ElevatedButton(
                onPressed: () async {
                  connection.enterChainId(1313161555);
                  connection.enterRpcUrl(_rpcUrl);
                  await connection.connect();
                  if (connection.account != "") {
                    if (mounted) {
                      Navigator.of(context).push(
                        MaterialPageRoute(
                          builder: (context) => NextPage(
                            connection: connection,
                          ),
                        ),
                      );
                    }
                  }
                },
                child: const Text("Connect Wallet"))
          ],
        ),
      ),
    );
  }
}

Connect Walletボタンを押すことでmetamaskのアプリへ移動して接続の許可を求められます。画面はこのようになります。

ウォレットの接続が完了したら、コントラクトとやりとりする画面に移動します。

その画面のコードは下のようになります。

class NextPage extends StatefulWidget {
  NextPage({Key? key, required this.connection}) : super(key: key);
  final Web3Connect connection;

  @override
  State<NextPage> createState() => _NextPageState();
}

class _NextPageState extends State<NextPage> {
  late Web3Client auroraClient;
  Web3Client? _client;
  final String _rpcUrl = "https://testnet.aurora.dev";
  final String _wsUrl = "wss://testnet.aurora.dev";
  String? _abiCode;
  EthereumAddress? _contractAddress;
  final String _deepLink =
      "wc:00e46b69-d0cc-4b3e-b6a2-cee442f97188@1?bridge=https%3A%2F%2Fbridge.walletconnect.org&key=91303dedf64285cbbaf9120f6e9d160a5c8aa3deb67017a3874cd272323f48ae";
  int num = 5;
  TextEditingController numController = TextEditingController(text: "1");

  void initState() {
    super.initState();
    final INFURA_KEY_TEST = dotenv.env["INFURA_KEY_TEST"];
    auroraClient = Web3Client(INFURA_KEY_TEST!, http.Client());
    _client = Web3Client(_rpcUrl, http.Client(), socketConnector: () {
      return IOWebSocketChannel.connect(_wsUrl).cast<String>();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.connection.account.toString()),
      ),
      body: Container(
        height: double.infinity,
        width: double.infinity,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              num.toString(),
              style: const TextStyle(
                fontSize: 50,
                fontWeight: FontWeight.bold,
              ),
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: () async {
                    await sendTransaction(
                      widget.connection,
                      "square_num",
                      [
                        BigInt.from(int.parse(numController.text)),
                      ],
                    );
                  },
                  child: const Text("set squared num"),
                ),
                const SizedBox(
                  width: 15,
                ),
                SizedBox(
                  width: 40,
                  height: 40,
                  child: TextFormField(
                    controller: numController,
                    decoration: InputDecoration(
                      enabledBorder: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(3),
                        borderSide: const BorderSide(
                          color: Colors.grey,
                          width: 1.0,
                        ),
                      ),
                      focusedBorder: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(3),
                        borderSide: const BorderSide(
                          color: Colors.blue,
                          width: 2.0,
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            ),
            ElevatedButton(
              onPressed: () async {
                final list = await query("return_num", []);
                num = int.parse(list[0].toString());
                setState(() {});
              },
              child: const Text("get state num"),
            ),
          ],
        ),
      ),
    );
  }

画面はこのようになります。タイトルの部分に接続したウォレットのアドレスがでてくるはずです。

上のボタンはsquare_num関数が呼ばれて、右の入力欄の数字の2乗がコントラクト内の状態変数を更新します。

下のボタンはコントラクト内の状態変数を引き出して、上の数字を更新します。

ここでどちらもコントラクトの関数を呼び出しているところで同じようですが、スマートコントラクトの最初の部分に記述したようにコントラクト内の値を変える関数と読み取りのみの関数で違いがあります。

これらの違いから作成した関数が下のものです。

Future<DeployedContract> getContract() async {
    String abi = await rootBundle.loadString('smart_contract/Square.json');
    DeployedContract contract = DeployedContract(
      ContractAbi.fromJson(jsonEncode(jsonDecode(abi)["abi"]),
          dotenv.env["SQUARE_CONTRACT_NAME"]!),
      EthereumAddress.fromHex(dotenv.env["SQUARE_CONTRACT_ADDRESS"]!),
    );
    return contract;
  }

  Future<List<dynamic>> query(String functionName, List<dynamic> args) async {
    DeployedContract contract = await getContract();
    ContractFunction function = contract.function(functionName);
    List<dynamic> result = await auroraClient.call(
      contract: contract,
      function: function,
      params: args,
    );
    return result;
  }

  Future<void> sendTransaction(
      Web3Connect connection, String functionName, List<dynamic> args) async {
    if (widget.connection != null && _client != null) {
      final contract = await getContract();
      ContractFunction function = contract.function(functionName);
      final transaction = Transaction.callContract(
        contract: contract,
        function: function,
        from: EthereumAddress.fromHex(connection.account),
        parameters: args,
      );
      final tra = _client!.sendTransaction(connection.credentials, transaction,
          chainId: 1313161555);
      if (!await launchUrlString(_deepLink)) {
        throw "Could not launch $_deepLink";
      }
      await tra;
    } else {
      print("There is no connection to wallet or no client");
    }
  }

getContarct関数はコントラクトの情報を呼び出すものです。これはどちらの関数

にも使われます。

query関数は読み取り専用の関数のために呼び出されます。

sendTransaction関数はコントラクトの書き込みを行うための関数のために呼び出されます。

これらの関数にコントラクトに記述した関数名と必要な変数を代入することで適切に関数を呼び出すことができます。下の画像は値をセットする時の入力画面とコントラクトの状態変数を読み込んでUIに反映させたものです。

 ソースコードはこちらにあります。

コントラクト:https://github.com/honganji/flutter-web3-tutorial-contract

フロントエンド:https://github.com/honganji/flutter-web3-tutorial-dApp

上級者の方向けの教材

ここまで読んでいただきありがとうございます。

今回記述した内容はflutterとコントラクトを接続するための基本的な教材です。最低限の記述しかしていませんが、より高いレベルのものを求める方はUnchainで公開している、Auroraチェーン上で開発したdAppで教材MulPayに取り組んでみてください!

solidityでのスマートコントラクトについても、flutterでのスマートコントラクトとの接続についてもより難しい記述をしていますのでやる気のある方にはぜひおすすめです。少しネタバレすると、flutterで引数にアドレスを用いる時には単なるString型のものではなくて、少し変換しないといけません。

このようにflutterでスマートコントラクトと接続するには色々考えなければいけないことがあります。なのでぜひこの教材を試してみて、flutter x web3を体験してみてください!

Subscribe to UNCHAIN書庫
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.