メタマスクに接続, アカウント切り替え, ネットワーク切り替え【Next.js】

## はじめに

本記事では、

* メタマスクに接続する方法、
* メタマスクのアカウント切り替えに対応する方法
* ネットワーク切り替えに対応する方法

を書いていきます。

## 利用技術

* Next.js
* Recoil
* TypeScript

## リポジトリ

https://github.com/tokio-dev/qiita-sample-metamask

## Next.js + Recoil 環境構築

Next.js + Recoilで環境構築します。
詳しい説明は省くので各自お願いします。

全く分からないという人は下記折り畳み部分を参考にしてみてください！！(解説なし)

<details><summary>Next.jsの環境構築</summary><div>

今回僕は、昔作ったTemplate Repositoryを利用してNext.jsの環境構築します。
(サンプル用だから適当です。)
https://github.com/tokio-dev/nextjs-template

1. [tokio-dev/nextjs-template](https://github.com/tokio-dev/nextjs-template)を開く
1. 「Use this template」をクリックする
1. Repository name等を入力してリポジトリを作成する
1. 作成したリポジトリをcloneする
1. `yarn` を実行する (ライブラリのインストール)
1. `yarn dev`を実行する (devモードで実行)
1. ブラウザでlocalhost:3000を開く 
1. 開ければ環境構築完了

</div></details>

<details><summary>Recoilを使う準備</summary><div>

1. `yarn add recoil`を実行する (Recoilのインストール)
1. `pages/_app.tsx`を編集する (RecoilRootで囲う) 
1. 


#### `pages/_app.tsx`を編集する (RecoilRootで囲う) 

```.tsx
{getLayout(<Component {...pageProps} />)}
```
↓
```.tsx
<RecoilRoot>{getLayout(<Component {...pageProps} />)}</RecoilRoot>
```

</div></details>

ディレクトリ構成等も同じにする必要はありません！
今回はサンプルなので、分かりやすくする為に、基本的に src/utils/walle の中に作っていきます。

## windowの型を拡張
window.etherumを使ってMetamaskと接続するので、初めにwindowの型を拡張しておきます。

```src/types/window.d.ts
interface Window {
  ethereum: undefined | Record<string, any>;
}
```
いい方法が分かってないので分かる方いましたらコメントください。🙇‍♂️

<details><summary>{ ethereum: any }にしてない理由</summary><div>


こうすることで、`!window.ethereum`の分岐の入れ忘れが防げます。
safari等、window.etherumが使えない場合もあり、分岐し忘れてエラーになることを防いでいます。
`window.ethereum.on`などで警告を出してくれます。

</div></details>

## アカウント(アドレス)を保存

#### global state(atom)の作成

```src/utils/wallet/account.ts
import { atom, useRecoilValue, useSetRecoilState } from "recoil";

type Account =
  | { address: string | undefined; errorMessage: undefined }
  | { address: undefined; errorMessage: string | undefined };

const currentAccountState = atom<Account>({
  key: "currentAccount",
  default: { address: undefined, errorMessage: undefined },
});
```

#### global stateを扱うHooksの作成

```src/utils/wallet/account.ts
import { atom, useRecoilValue, useSetRecoilState } from "recoil";

type Account =
  | { address: string | undefined; errorMessage: undefined }
  | { address: undefined; errorMessage: string | undefined };

const currentAccountState = atom<Account>({
  key: "currentAccount",
  default: { address: undefined, errorMessage: undefined },
});

//↓ 追加
export const useAccount = () => useRecoilValue(currentAccountState);
export const useSetAddress = () => {
  const setState = useSetRecoilState(currentAccountState);
  return (address: string) => setState({ address, errorMessage: undefined });
};
export const useSetAddressError = () => {
  const setState = useSetRecoilState(currentAccountState);
  return (errorMessage: string) => setState({ address: undefined, errorMessage });
};
```

※ 利用する側での扱い方を限定するためにcurrentAccountStateを直接exportせずに、このstateを扱うHooksを作成してexportしています。accout情報の取得(useAccount), addressの設定(useSetAddress), errorの設定(useSetAddressError)の3つの処理が出来ます。

#### walletと接続する関数の作成

```src/utils/wallet/useWallet.ts
import { useCallback } from "react";

import { useAccount, useSetAddress, useSetAddressError } from "./account";

export const useWallet = () => {
  const setAddress = useSetAddress();
  const setError = useSetAddressError();
  const { address, errorMessage } = useAccount();

  const handleConnect = useCallback(async () => {
    try {
      if (!window.ethereum || !window.ethereum.isMetaMask) {
        setError("Metamaskをインストールしてください");
        return;
      }

      if (window.ethereum.chainId !== "0x1") {
        setError("イーサリアムメインネットでのみ利用できます。ネットワークを切り替えてください。");
        return;
      }

      const accounts = await window.ethereum.request({
        method: "eth_requestAccounts",
      });

      if (accounts.length !== 0) {
        setAddress(accounts[0]);
      } else {
        setError("認証済みのアカウントが見つかりませんでした");
      }
    } catch (error) {
      setError("Metamaskの接続に失敗しました");
    }
  }, [setAddress, setError]);

  return { handleConnect, address, error: errorMessage };
};

```

<details><summary>※ サンプルリポジトリでは、useWalletのみをexportするようにしています。</summary><div>

サンプルリポジトリのコードでは、useWalletのみがsrc/utils/wallet以外から利用できるようにしています。

exportしているものに以下を付与し、
```.ts
/**
 * @package
 */
```

src/utils/wallet/index.tsを作成することで実現できます。
```src/utils/wallet/index.ts
export { useWallet } from "src/utils/wallet/useWallet";
```

uhyoさんの[これ](https://zenn.dev/uhyo/articles/eslint-plugin-import-access)です。
これはメインの内容とは関係ないので無視しても構いません。

</div></details>

※ handleConnect, address, errorをuseWalletに纏めなくても構いません。

#### walletに接続してアカウントを保存

関数を作ったので、実際に利用してみます。
```src/pages/index/index.tsx
import type { FC } from "react";
import { useWallet } from "src/utils/wallet";

export const Index: FC = () => {
  const { address, error, handleConnect } = useWallet();

  return (
    <div>
      <button onClick={handleConnect}>Connect Wallet</button>
      {address ? <p>{address}</p> : null}
      {error ? <p>{error}</p> : null}
    </div>
  );
};
```

|何もしてない時|接続に成功した時|ネットワークが違う時|
|-|-|-|
|![スクリーンショット 2022-11-12 23.45.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506867/e9bf2962-e312-8016-3c3c-02c4d35c39ee.png)|![スクリーンショット 2022-11-12 23.46.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506867/ab3198fd-0c47-229d-5573-3f3bbbbe9c3a.png)|![スクリーンショット 2022-11-12 23.45.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506867/806c628c-b58c-e1bb-edd5-a30f8228acef.png)|

無事に接続中のアカウント名を表示することができました。
このままではリロードした時や開き直した時に、接続中のアカウントが消えてしまうので、消えないようにしていきます。

## アカウントの初期値設定

src/utils/wallet/InitializeAccount.tsxに、初期化用のuseEffectを実行するのみのコンポーネントを作っていきます。
Reactであれば、atomにeffectsを追加することで実現できると思いますが、Next.jsではサーバー側でレンダリングされてしまいwindowにアクセスできないので、初期化専用のコンポーネントを作っていきます。
クライアント側で以下に記載する処理の実行さえできれば何を使っても構いません。

より良い方法があったらコメントして欲しいです🙇‍♂️

```src/utils/wallet/InitializeAccount.tsx
import { useEffect } from "react";

import { useSetAddress, useSetAddressError } from "./account";

export const InitializeAccount = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const setAddress = useSetAddress();
  const setError = useSetAddressError();

  useEffect(() => {
    if (!window.ethereum || !window.ethereum.isMetaMask) {
      setError("Metamaskをインストールしてください");
      return;
    }

    window.ethereum
      .request({ method: "eth_accounts" })
      .then((accounts: any) => {
        if (window.ethereum?.chainId !== "0x1") {
          setError("イーサリアムメインネットでのみ利用可能です");
          return;
        }
        if (accounts.length !== 0) {
          setAddress(accounts[0]);
        }
      });
  }, [setAddress, setError]);
  return <>{children}</>;
};

```

InitializeAccountをRecoilRootの下で囲みます。

```pages/_app.tsx
//..省略
<RecoilRoot>
  <InitializeAccount>
    {getLayout(<Component {...pageProps} />)}
  </InitializeAccount>
</RecoilRoot>
//...省略
```

開いたタイミングで、handleConnectの処理と同じようなことをして、その値を使って初期化しています。
これでリロードしてもアカウント名が表示されたままになりました。

## アカウントの切り替え

次にMetamask上でアカウントを切り替えた時に、アカウントが切り替わるようにします。
現在はMetamask上でアカウントを切り替えた後に、Connect Walletボタンをクリックすることで切り替わります。
これをMetamask上での切り替えと同時に自動で切り替わるようにします。

追加するコードは以下です。

```.tsx
// アカウント切り替え時の処理
const accountChanged = async (newAccount: string) => {
  setAddress(newAccount[0]);
};
window.ethereum.on("accountsChanged", accountChanged);
```

src/utils/wallet/InitializeAccount.tsxのuseEffect内に追加します。

```src/utils/wallet/InitializeAccount.tsx
//...省略
  useEffect(() => {
    if (!window.ethereum || !window.ethereum.isMetaMask) {
      setError("Metamaskをインストールしてください");
      return;
    }

    // アカウント切り替え時の処理
    const accountChanged = async (newAccount: string) => {
      setAddress(newAccount[0]);
    };
    window.ethereum.on("accountsChanged", accountChanged);

    window.ethereum
      .request({ method: "eth_accounts" })
      .then((accounts: any) => {
        if (window.ethereum?.chainId !== "0x1") {
          setError("イーサリアムメインネットでのみ利用可能です");
          return;
        }
        if (accounts.length !== 0) {
          setAddress(accounts[0]);
        }
      });
  }, [setAddress, setError]);
//...省略

```


`window.ethereum.on("accountsChanged", accountChanged);` でアカウントを切り替えた時に、切り替えたアカウントをstateにセットするようにします。

これで、アカウント切り替えに対応できるようになりました。

## ネットワークの切り替え

次にネットワークの切り替えに対応していきます。
Metamask上で、メイン以外のネットワークに切り替えた場合に、そのタイミングでエラーを表示するようにします。

追加するコードは以下です。
```.tsx
// ネットワーク切り替え時の処理
    const chainChanged = (chain: string) => {
      if (chain !== "0x1") {
        setError("イーサリアムメインネットでのみ利用可能です");
        return;
      }
      if (!window.ethereum) {
        return;
      }
      window.ethereum
        .request({ method: "eth_accounts" })
        .then((accounts: any) => {
          if (accounts.length !== 0) {
            setAddress(accounts[0]);
          } else {
            setError("認証済みのアカウントが見つかりませんでした");
          }
        });
    };
    window.ethereum.on("chainChanged", chainChanged);
```

src/utils/wallet/InitializeAccount.tsxのuseEffect内に追加します。

```src/utils/wallet/InitializeAccount.tsx
//...省略
  useEffect(() => {
    if (!window.ethereum || !window.ethereum.isMetaMask) {
      setError("Metamaskをインストールしてください");
      return;
    }

    // アカウント切り替え時の処理
    const accountChanged = async (newAccount: string) => {
      setAddress(newAccount[0]);
    };
    window.ethereum.on("accountsChanged", accountChanged);
    
    // ネットワーク切り替え時の処理
    const chainChanged = (chain: string) => {
      if (chain !== "0x1") {
        setError("イーサリアムメインネットでのみ利用可能です");
        return;
      }
      if (!window.ethereum) {
        return;
      }
      window.ethereum
        .request({ method: "eth_accounts" })
        .then((accounts: any) => {
          if (accounts.length !== 0) {
            setAddress(accounts[0]);
          } else {
            setError("認証済みのアカウントが見つかりませんでした");
          }
        });
    };
    window.ethereum.on("chainChanged", chainChanged);

    window.ethereum
      .request({ method: "eth_accounts" })
      .then((accounts: any) => {
        if (window.ethereum?.chainId !== "0x1") {
          setError("イーサリアムメインネットでのみ利用可能です");
          return;
        }
        if (accounts.length !== 0) {
          setAddress(accounts[0]);
        }
      });
  }, [setAddress, setError]);
//...省略

```

`window.ethereum.on("chainChanged", chainChanged);` でネットワークを切り替えた時に、イーサリアムメインネット以外であれば、アカウントを削除して、エラーを設定しています。

これでネットワークの切り替えにも対応できました。

🎉👏