[Solidityのはじめ方] Part24: フロントエンド(React/Vue)とスマコンの接続

Solidity

はじめに

Solidityで作成したスマートコントラクトは、それだけではユーザーが直接操作することは難しいです。分散型アプリケーション(DApp)として機能させるためには、ウェブサイトなどのフロントエンドと接続する必要があります。これにより、ユーザーは使い慣れたインターフェースを通じて、ブロックチェーン上のスマートコントラクトと対話できるようになります。🎉

このステップでは、人気のフロントエンドフレームワークであるReactとVueを使って、デプロイ済みのスマートコントラクトに接続する方法を学びます。

準備するもの

フロントエンドとスマートコントラクトを接続するために、以下の準備が必要です。

  • Node.js と npm/yarn: JavaScriptの実行環境とパッケージマネージャー。
  • React または Vue の基本的な知識: コンポーネント、ステート、プロップスなどの基本概念を理解していること。
  • デプロイ済みのスマートコントラクト: ブロックチェーンネットワーク(ローカル、テストネット、メインネット)にデプロイされていること。
  • スマートコントラクトのABI(Application Binary Interface): コントラクトの関数やイベントの定義情報が記載されたJSONファイル。これは通常、コンパイル時に生成されます。
  • スマートコントラクトのアドレス: コントラクトがデプロイされたネットワーク上のアドレス。
  • ウォレット拡張機能: MetaMaskなどのブラウザ拡張機能ウォレット。ユーザーがトランザクションに署名したり、ネットワークに接続したりするために必要です。🦊

接続の要:Web3ライブラリ (ethers.js / web3.js)

フロントエンド(JavaScript)からEthereumブロックチェーンやスマートコントラクトと通信するには、専用のライブラリが必要です。最も広く使われているのが ethers.jsweb3.js です。

現在、ethers.js がよりモダンで、軽量、TypeScriptとの親和性も高いなどの理由から人気を集めています。このチュートリアルでは主に ethers.js を使用して解説します。

ethers.js と web3.js の比較ポイント:
  • 人気度: web3.jsは歴史が長く、多くのプロジェクトで使用されていますが、新しいプロジェクトではethers.jsが好まれる傾向があります。
  • サイズ: ethers.jsはweb3.jsよりも軽量です。
  • API設計: ethers.jsはよりシンプルで直感的と評価されることが多いです。
  • 機能: ENS(Ethereum Name Service)のネイティブサポートなど、ethers.jsには便利な機能が組み込まれています。
  • ライセンス: ethers.jsはMITライセンス、web3.jsはLGPLライセンスです。

どちらのライブラリも活発に開発されており、基本的な機能は網羅していますが、プロジェクトの要件や好みに合わせて選択すると良いでしょう。

プロジェクトのセットアップとライブラリのインストール

まず、ReactまたはVueのプロジェクトを作成し、ethers.jsをインストールします。

Reactの場合


# Create React App を使用してプロジェクトを作成
npx create-react-app my-dapp
cd my-dapp

# ethers.js をインストール
npm install ethers
# または
yarn add ethers
      

Vueの場合


# Vue CLI を使用してプロジェクトを作成 (Vue 3 推奨)
npm init vue@latest my-dapp
# または
yarn create vue my-dapp

# (プロンプトに従って設定を選択)
cd my-dapp
npm install
# または
yarn

# ethers.js をインストール
npm install ethers
# または
yarn add ethers
      

Ethereumへの接続 🔗

フロントエンドからEthereumネットワークに接続するには、「プロバイダー」が必要です。プロバイダーは、ブロックチェーンへの読み取りアクセスを提供します。MetaMaskなどのウォレット拡張機能がブラウザにインストールされている場合、それがプロバイダーとして機能します。


import { ethers } from "ethers";

async function connectWallet() {
  // MetaMask がインストールされているか確認
  if (typeof window.ethereum !== 'undefined') {
    console.log('MetaMask is installed!');
    try {
      // プロバイダーを取得 (MetaMask経由)
      const provider = new ethers.BrowserProvider(window.ethereum);

      // アカウントへのアクセスを要求
      // eth_requestAccounts を使用すると、ユーザーに接続許可を求めるプロンプトが表示されます。
      const accounts = await provider.send("eth_requestAccounts", []);
      const signer = await provider.getSigner(); // 書き込み操作用のサイナーを取得
      const address = await signer.getAddress();

      console.log("Connected account:", address);
      // ここで接続されたアカウント情報などをStateに保存する

      // ネットワーク変更やアカウント変更を検知するリスナーを設定することも推奨されます
      window.ethereum.on('accountsChanged', (newAccounts) => {
        console.log('Accounts changed:', newAccounts);
        // アカウント変更時の処理
      });
      window.ethereum.on('chainChanged', (chainId) => {
        console.log('Chain changed:', chainId);
        // ネットワーク変更時の処理 (ページのリロードなど)
        window.location.reload();
      });

      return { provider, signer, address };
    } catch (error) {
      console.error("User denied account access or other error:", error);
      // エラー処理
      return null;
    }
  } else {
    console.log('Please install MetaMask!');
    alert('MetaMaskをインストールしてください。');
    return null;
  }
}

// 例: ボタンクリックでウォレット接続を実行
// const connection = await connectWallet();
// if (connection) {
//   // 接続成功後の処理
// }
      

window.ethereum は、MetaMaskなどのEthereum互換ブラウザ拡張機能によって注入されるグローバルAPIです。これを通じて、ユーザーのウォレットと対話します。 ethers.BrowserProvider はブラウザ環境でのプロバイダーを抽象化します。 provider.send("eth_requestAccounts", []) は、ユーザーにウォレット接続の許可を求めます。 provider.getSigner() は、トランザクション署名など、書き込み操作に必要な「サイナー」オブジェクトを取得します。

スマートコントラクトの読み込み

接続が確立したら、対話したいスマートコントラクトのインスタンスを作成します。これには、コントラクトのアドレスABIが必要です。


import { ethers } from "ethers";
import YourContractABI from './YourContractABI.json'; // ABIファイルをインポート

// 定数として定義 (環境変数から読み込むのがより安全)
const contractAddress = "0xYourDeployedContractAddress"; // デプロイしたコントラクトのアドレスに置き換える

function getContractInstance(providerOrSigner) {
  // providerOrSigner には読み取り専用なら provider、書き込みも行うなら signer を渡す
  if (!contractAddress || !YourContractABI) {
      console.error("Contract address or ABI is missing.");
      return null;
  }
  try {
      const contract = new ethers.Contract(contractAddress, YourContractABI.abi, providerOrSigner);
      return contract;
  } catch (error) {
      console.error("Failed to create contract instance:", error);
      return null;
  }
}

// --- 使い方 ---

// 読み取り専用のインスタンスを取得する場合 (例: connectWallet() から provider を取得)
// const provider = new ethers.BrowserProvider(window.ethereum); // 接続済みの場合
// const readOnlyContract = getContractInstance(provider);

// 読み書き可能なインスタンスを取得する場合 (例: connectWallet() から signer を取得)
// const signer = await provider.getSigner(); // 接続済みの場合
// const readWriteContract = getContractInstance(signer);
      

ABIファイル(例: YourContractABI.json)は、通常HardhatやTruffleなどの開発フレームワークでコンパイル時に artifacts ディレクトリなどに生成されます。これをフロントエンドプロジェクトにコピーしてインポートします。 ethers.Contract コンストラクタに、コントラクトアドレス、ABI、そしてプロバイダー(読み取り専用)またはサイナー(読み書き可能)を渡すことで、コントラクトオブジェクトが作成されます。

スマートコントラクトとの対話

コントラクトインスタンスがあれば、関数を呼び出したり、イベントを購読したりできます。

データの読み取り (Read Operations)

Solidityコントラクトの viewpure でマークされた関数(状態を変更しない読み取り専用関数)を呼び出すのは簡単です。ガス代はかかりません。


// readOnlyContract または readWriteContract インスタンスを使用
async function readMessage(contract) {
  if (!contract) return;
  try {
    // コントラクトの 'getMessage' 関数 (view関数と仮定) を呼び出す
    const message = await contract.getMessage();
    console.log("Message from contract:", message);
    // 取得したデータをUIに表示するなどの処理
    return message;
  } catch (error) {
    console.error("Error reading message:", error);
  }
}

// 呼び出し例
// const message = await readMessage(readOnlyContract);
      

データの書き込み (Write Operations)

ブロックチェーンの状態を変更する関数(例: 値を設定する、トークンを送るなど)を呼び出すには、トランザクションを送信する必要があります。これにはサイナー(接続されたウォレット)が必要で、ガス代が発生します。


// readWriteContract インスタンス (signerに接続されているもの) を使用
async function updateMessage(contract, newMessage) {
  if (!contract) return;
  try {
    // コントラクトの 'setMessage' 関数 (状態変更関数と仮定) を呼び出す
    console.log(`Updating message to: ${newMessage}...`);
    const tx = await contract.setMessage(newMessage);

    console.log("Transaction sent:", tx.hash);
    // トランザクションがブロックに含まれるのを待つ (任意だが推奨)
    // UIでローディング表示を開始
    const receipt = await tx.wait();
    console.log("Transaction confirmed:", receipt);
    // UIでローディング表示を終了し、成功メッセージを表示
    alert("メッセージが更新されました!");

  } catch (error) {
    console.error("Error updating message:", error);
    // UIでエラーメッセージを表示
    alert(`エラーが発生しました: ${error.message || error}`);
  }
}

// 呼び出し例 (ユーザーが入力したメッセージで更新)
// const userInput = "新しいメッセージ";
// await updateMessage(readWriteContract, userInput);
      

書き込み操作は非同期で行われ、ネットワークの状況によって時間がかかることがあります。tx.wait() を使うことで、トランザクションがブロックチェーンに取り込まれる(承認される)のを待つことができます。ユーザーには、トランザクション送信中であることや、完了したことを明確にフィードバックすることが重要です。🚀

イベントの購読 (Listening to Events)

スマートコントラクトは、特定の出来事が発生したときにイベントを発行(emit)できます。フロントエンドでは、これらのイベントを購読して、リアルタイムで通知を受け取ることができます。


// readOnlyContract または readWriteContract インスタンスを使用
function listenToEvents(contract) {
  if (!contract) return;
  console.log("Listening for MessageUpdated events...");

  // 'MessageUpdated' という名前のイベントをリッスン
  contract.on("MessageUpdated", (oldMessage, newMessage, event) => {
    console.log("Event received: MessageUpdated");
    console.log(" - Old Message:", oldMessage);
    console.log(" - New Message:", newMessage);
    console.log(" - Event details:", event); // イベントの詳細情報 (ブロック番号、トランザクションハッシュなど)

    // イベント受信時の処理 (UIの更新など)
    alert(`メッセージが更新されました: ${newMessage}`);
  });

  // 必要に応じてリスナーを解除する処理も実装する
  // (例: コンポーネントのアンマウント時)
  // contract.off("MessageUpdated", listenerCallback);
  // または
  // contract.removeAllListeners("MessageUpdated");
}

// 呼び出し例
// listenToEvents(readOnlyContract);
      

contract.on("EventName", callback) を使うことで、特定のイベントが発生した際にコールバック関数が実行されるようになります。これは、他のユーザーが行った操作の結果をリアルタイムでUIに反映させたい場合などに非常に便利です。

Reactでの実装例

Reactコンポーネント内で上記のロジックを組み合わせる例です。


import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
import YourContractABI from './YourContractABI.json'; // ABIファイルをインポート

const contractAddress = "0xYourDeployedContractAddress"; // ここにコントラクトアドレス

function DApp() {
  const [provider, setProvider] = useState(null);
  const [signer, setSigner] = useState(null);
  const [contract, setContract] = useState(null);
  const [account, setAccount] = useState(null);
  const [message, setMessage] = useState('');
  const [newMessage, setNewMessage] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const connectWallet = useCallback(async () => {
    setError('');
    if (typeof window.ethereum !== 'undefined') {
      try {
        const web3Provider = new ethers.BrowserProvider(window.ethereum);
        const accounts = await web3Provider.send("eth_requestAccounts", []);
        const web3Signer = await web3Provider.getSigner();
        const currentAccount = accounts[0];

        setProvider(web3Provider);
        setSigner(web3Signer);
        setAccount(currentAccount);

        const contractInstance = new ethers.Contract(contractAddress, YourContractABI.abi, web3Signer);
        setContract(contractInstance);

      } catch (err) {
        console.error(err);
        setError('ウォレット接続に失敗しました。');
      }
    } else {
      setError('MetaMaskをインストールしてください。');
    }
  }, []);

  const fetchMessage = useCallback(async () => {
    if (contract) {
      try {
        const currentMessage = await contract.getMessage();
        setMessage(currentMessage);
      } catch (err) {
        console.error(err);
        setError('メッセージの読み取りに失敗しました。');
      }
    }
  }, [contract]);

  const handleUpdateMessage = async (e) => {
    e.preventDefault();
    if (contract && newMessage) {
      setLoading(true);
      setError('');
      try {
        const tx = await contract.setMessage(newMessage);
        await tx.wait();
        setNewMessage(''); // 入力フィールドをクリア
        await fetchMessage(); // メッセージを再読み込み
        alert('メッセージを更新しました!');
      } catch (err) {
        console.error(err);
        setError(`メッセージの更新に失敗しました: ${err.message || err}`);
      } finally {
        setLoading(false);
      }
    }
  };

  // 初期レンダリング時にメッセージを取得
  useEffect(() => {
    if (contract) {
      fetchMessage();
      // イベントリスナーを設定
      const listener = (oldMsg, newMsg, event) => {
        console.log('MessageUpdated event detected:', newMsg);
        setMessage(newMsg); // UIを更新
      };
      contract.on("MessageUpdated", listener);

      // クリーンアップ関数でリスナーを解除
      return () => {
        contract.off("MessageUpdated", listener);
      };
    }
  }, [contract, fetchMessage]);

  // アカウント変更時の処理
  useEffect(() => {
    if (window.ethereum) {
      const handleAccountsChanged = (accounts) => {
        if (accounts.length === 0) {
          // MetaMask is locked or the user has not connected any accounts
          console.log('Please connect to MetaMask.');
          setAccount(null);
          setSigner(null);
          setContract(null);
        } else if (accounts[0] !== account) {
          setAccount(accounts[0]);
          // 必要に応じてSignerとContractを再初期化
          if (provider) {
             provider.getSigner().then(newSigner => {
               setSigner(newSigner);
               const newContract = new ethers.Contract(contractAddress, YourContractABI.abi, newSigner);
               setContract(newContract);
             });
          }
        }
      };

      window.ethereum.on('accountsChanged', handleAccountsChanged);
      // クリーンアップ
      return () => window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    }
  }, [account, provider]); // 依存配列にaccountとproviderを追加

  return (
    

My DApp

{error &&
{error}
} {account ? (

接続中のアカウント: {account}

現在のメッセージ

{message || '読み込み中...'}

メッセージを更新

setNewMessage(e.target.value)} disabled={loading} required />
) : ( )}
); } export default DApp;

この例では、useState で状態を管理し、useEffect で初期データの読み込みやイベントリスナーの設定、アカウント変更の監視を行っています。useCallback は関数の再生成を防ぎ、パフォーマンスを最適化します。

Vueでの実装例

Vue 3 の Composition API を使用した例です。


<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { ethers } from 'ethers';
import YourContractABI from './YourContractABI.json'; // ABIファイルをインポート

const contractAddress = "0xYourDeployedContractAddress"; // ここにコントラクトアドレス

const provider = ref(null);
const signer = ref(null);
const contract = ref(null);
const account = ref(null);
const message = ref('');
const newMessage = ref('');
const loading = ref(false);
const error = ref('');

const isConnected = computed(() => !!account.value);

const connectWallet = async () => {
  error.value = '';
  if (typeof window.ethereum !== 'undefined') {
    try {
      const web3Provider = new ethers.BrowserProvider(window.ethereum);
      const accounts = await web3Provider.send("eth_requestAccounts", []);
      const web3Signer = await web3Provider.getSigner();
      const currentAccount = accounts[0];

      provider.value = web3Provider;
      signer.value = web3Signer;
      account.value = currentAccount;

      const contractInstance = new ethers.Contract(contractAddress, YourContractABI.abi, web3Signer);
      contract.value = contractInstance;

      await fetchMessage(); // 接続後にメッセージを取得
      listenForEvents(); // イベントリスナーを設定

    } catch (err) {
      console.error(err);
      error.value = 'ウォレット接続に失敗しました。';
    }
  } else {
    error.value = 'MetaMaskをインストールしてください。';
  }
};

const fetchMessage = async () => {
  if (contract.value) {
    try {
      const currentMessage = await contract.value.getMessage();
      message.value = currentMessage;
    } catch (err) {
      console.error(err);
      error.value = 'メッセージの読み取りに失敗しました。';
    }
  }
};

const handleUpdateMessage = async () => {
  if (contract.value && newMessage.value) {
    loading.value = true;
    error.value = '';
    try {
      const tx = await contract.value.setMessage(newMessage.value);
      await tx.wait();
      newMessage.value = ''; // 入力フィールドをクリア
      await fetchMessage(); // メッセージを再読み込み
      alert('メッセージを更新しました!');
    } catch (err) {
      console.error(err);
      error.value = `メッセージの更新に失敗しました: ${err.message || err}`;
    } finally {
      loading.value = false;
    }
  }
};

const handleAccountsChanged = async (accounts) => {
  if (accounts.length === 0) {
    console.log('Please connect to MetaMask.');
    account.value = null;
    signer.value = null;
    contract.value = null;
  } else if (accounts[0] !== account.value) {
    account.value = accounts[0];
    if (provider.value) {
      try {
         const newSigner = await provider.value.getSigner();
         signer.value = newSigner;
         contract.value = new ethers.Contract(contractAddress, YourContractABI.abi, newSigner);
         await fetchMessage();
         listenForEvents(); // 再度リスナー設定が必要な場合
      } catch (err) {
         console.error("Failed to re-initialize signer/contract:", err);
         // エラー処理
      }
    }
  }
};

const handleChainChanged = () => {
  window.location.reload();
};

const eventListener = (oldMsg, newMsg, event) => {
  console.log('MessageUpdated event detected:', newMsg);
  message.value = newMsg; // UIを更新
};

const listenForEvents = () => {
    if (contract.value) {
        // 既存のリスナーがあれば解除
        contract.value.removeAllListeners("MessageUpdated");
        // 新しいリスナーを設定
        contract.value.on("MessageUpdated", eventListener);
    }
};


onMounted(() => {
  if (window.ethereum) {
    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);
    // ページ読み込み時に自動接続を試みる場合
    // connectWallet();
  }
});

onUnmounted(() => {
  if (window.ethereum) {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
  }
   if (contract.value) {
        contract.value.removeAllListeners("MessageUpdated");
    }
});

</script>

<template>
  <div class="container p-4">
    <h1 class="title is-3">My DApp (Vue)</h1>
    <div v-if="error" class="notification is-danger">{{ error }}</div>
    <div v-if="isConnected">
      <p class="mb-3">接続中のアカウント: <strong class="has-text-info">{{ account }}</strong></p>
      <div class="box">
        <h2 class="title is-4">現在のメッセージ</h2>
        <p class="is-size-5">{{ message || '読み込み中...' }}</p>
      </div>
      <div class="box">
        <h2 class="title is-4">メッセージを更新</h2>
        <form @submit.prevent="handleUpdateMessage">
          <div class="field">
            <label class="label" for="newMessageVue">新しいメッセージ:</label>
            <div class="control">
              <input
                id="newMessageVue"
                class="input"
                type="text"
                v-model="newMessage"
                :disabled="loading"
                required
              />
            </div>
          </div>
          <div class="control">
            <button
              type="submit"
              class="button is-primary"
              :class="{ 'is-loading': loading }"
              :disabled="loading || !newMessage"
            >
              更新する
            </button>
          </div>
        </form>
      </div>
    </div>
    <div v-else>
      <button class="button is-link" @click="connectWallet">
        🦊 ウォレットを接続
      </button>
    </div>
  </div>
</template>

      

Vue 3 の Composition API では、ref でリアクティブな状態を定義し、computed で算出プロパティを作成します。onMountedonUnmounted ライフサイクルフック内でイベントリスナーの登録・解除を行います。

重要な考慮事項

  • エラーハンドリング: ユーザーがトランザクションを拒否した場合、ネットワークエラーが発生した場合など、様々なエラーケースを考慮し、ユーザーに分かりやすくフィードバックする必要があります。
  • ユーザーエクスペリエンス (UX): トランザクションの処理には時間がかかるため、ローディング状態を明確に示し、処理が完了したら通知するなど、ユーザーが状況を理解できるように工夫が必要です。
  • セキュリティ: フロントエンドのコードでは秘密鍵を扱わないようにしてください。トランザクションの署名は常にMetaMaskなどのユーザーのウォレットに委譲します。コントラクトアドレスやABIは公開情報ですが、機密情報(APIキーなど)はコードに直接埋め込まず、環境変数などを使って管理しましょう。
  • ネットワークの切り替え: ユーザーがMetaMaskで異なるネットワーク(例: MainnetからSepoliaテストネットへ)に切り替えた場合に、DAppが適切に対応できるようにする必要があります。通常はページをリロードして再接続を促すのが一般的です。

まとめ

このステップでは、ReactまたはVueフレームワークと ethers.js ライブラリを使って、フロントエンドアプリケーションをEthereumスマートコントラクトに接続する方法を学びました。

  1. Web3ライブラリ (ethers.js) を導入する。
  2. MetaMaskなどのウォレットプロバイダーに接続し、ユーザーアカウント情報を取得する。
  3. コントラクトアドレスとABIを使って、スマートコントラクトのインスタンスを作成する。
  4. コントラクトインスタンスを通じて、読み取り関数や書き込み関数を呼び出す。
  5. コントラクトから発行されるイベントを購読し、リアルタイムな更新に対応する。

これで、ユーザーがWebインターフェースを通じてスマートコントラクトと対話できる、本格的なDAppの基礎が完成しました。次のステップでは、より具体的なDAppの例として、トークンやNFTを扱うコントラクトの作成に挑戦してみましょう!💪

参考情報

コメント

タイトルとURLをコピーしました