この章は概念より実務です。ただし一番多くの人が詰まるところでもあるので、原因まで含めて書きます。

ネットワークとアカウントは直交する

最初に、これを分けて理解してください。

  • ネットワーク(チェーン)= 「いまどのチェーンを見ているか」。MetaMask 全体の設定で、アカウントには紐づきません
  • アカウント(鍵ペア)= 「あなたが誰か」。すべての EVM チェーンに、同じアドレスが存在します

あなたのアドレス 0x3Dc1… は、メインネットにも、Base Sepolia にも、ローカルの anvil にも存在します。存在するけれど、残高がそれぞれ別勘定なだけです。

だから「anvil ネットワークを追加する」と「anvil のアカウントを使う」はまったく別の操作です。ネットワークを追加しても、anvil のアカウントが MetaMask に入るわけではありません。

番号が二重に存在する罠

ここで実際に混乱が起きます。MetaMask には「アカウント 1、アカウント 2、…」という番号があり、anvil にも「account 0、account 1、…」という番号があります。この 2 つは無関係です。

番号の意味由来
MetaMask の「アカウント 4」あなたのシードフレーズから導出した 4 番目あなたの秘密
anvil の「account 4」test test test … junk という公開ニーモニックから導出した 4 番目世界中で同じ値

同じ「4 番目」でも種が違うので、まったく別のアドレスになります。MetaMask の画面に「アカウント 4」と出ていても、それは anvil の 4 番目ではありません。

ステップ 1: ネットワークを追加する

アプリから自動で追加する

Web アプリからは EIP-3085 の wallet_addEthereumChain を呼べます。本書のアプリはこうしています。

export async function addChainToWallet(provider: unknown, chain: Chain) {
  await provider.request({
    method: 'wallet_addEthereumChain',
    params: [{
      chainId: numberToHex(chain.id),
      chainName: chain.name,
      nativeCurrency: chain.nativeCurrency,
      rpcUrls: [...chain.rpcUrls.default.http],
    }],
  });
}

switchChain を先に試し、「そんなネットワークは知らない」と言われたらこれを呼ぶ、という順序にします。新品のウォレットは必ずローカルの anvil を知らないので、これは例外処理ではなく通常のパスです。

MetaMask の確認ダイアログは必ず出ます。ユーザーの同意なしにネットワークを追加する方法は仕様上ありません(あったら攻撃に使われます)。

手動で追加する

MetaMask のバージョンによっては localhost の RPC を自動追加で拒否します。その場合は手動です。

MetaMask → 左上のネットワーク名 → ネットワークを追加 → 手動で追加

項目
ネットワーク名Anvil
RPC URLhttp://127.0.0.1:8545
チェーン ID31337
通貨記号ETH
ブロックエクスプローラー URL空欄

ステップ 2: 残高を用意する

あなたの普段のアドレスは、anvil 上では残高 0 です。 ガス代すら払えないので、何もできません。3 つの方法があります。

方法 1: anvil のアカウントをインポートする

MetaMask → アカウントをインポート → 秘密鍵を貼り付け。

account 0  0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
           0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
account 1  0x70997970C51812dc3A010C7d01b50e0d17dc79C8
           0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

各 10,000 ETH 入っています。2 つ入れれば 1 人で対人戦ができます。すべて公開値なので、本物のネットワークでは絶対に使わないでください

方法 2: 普通に送金する

鍵をインポートしたくない場合はこちらです。anvil の account 0 から、自分の普段のアドレスへ送ります。

cast send 0xあなたのアドレス --value 100ether \
  --private-key 0xac0974... --rpc-url http://127.0.0.1:8545

ごく普通の送金トランザクションです。この方法を既定にしてください。 理由は次に書きます。

方法 3: 残高を直接書き込む(罠あり)

cast rpc anvil_setBalance 0xあなたのアドレス \
  $(cast to-wei 100 | cast to-hex) --rpc-url http://127.0.0.1:8545

anvil_setBalanceテストノード専用の裏口で、残高を直接書き換えます。一見こちらのほうがスマートですが、ウォレットから使うときは落とし穴があります

落とし穴 1: setBalance はブロックを作らない

anvil_setBalance は状態を書き換えるだけで、ブロックを進めません。そしてウォレットは「新しいブロックが来た」を合図に残高を取り直します。

結果、こうなります。

  • チェーンに eth_getBalance で聞くと 100 ETH と返る
  • アプリの画面にも 100 ETH と出る
  • しかし MetaMask は 0 のまま
  • そして MetaMask は自分のキャッシュを見て判断するので、「資金不足」と言って送信を拒否する

私はこれを実際に踏みました。チェーンもアプリも正しい値を返しているのに、ウォレットだけが違う世界を見ている。原因が「ブロックが進んでいないこと」だと気づくまで、しばらくネットワーク設定を疑って時間を潰しました。

送金トランザクションならブロックが 1 つ進むので、この問題は起きません。 裏口は「残高を 0 に戻したい」といった、送金では表現できない操作にだけ使うのが良いと思います。

落とし穴 2: MetaMask 内蔵の「Localhost 8545」は chain ID が違う

MetaMask にはデフォルトで Localhost 8545 というネットワークが入っています。名前からして anvil 用に見えますが、その chain ID は 1337 で、anvil の 31337 とは別物です

RPC の宛先(http://localhost:8545)は同じなので、選ぶと一見繋がります。しかし chain ID が食い違うため、

  • アプリ側は「非対応のネットワーク」と判定する
  • 残高が表示されない、あるいは更新されない
  • トランザクションを送ろうとすると chain ID の不一致で弾かれる

という中途半端な壊れ方をします。ネットワーク一覧で Anvil (31337) を選んでいるか、必ず確認してください。

なお anvil 側で chain ID を変えることもできます。既存の設定に合わせたいならこれで揃います。

anvil --chain-id 1337

落とし穴 3: anvil を再起動したら MetaMask をリセットする

これが一番よく踏みます。

MetaMask はアカウントごとに「次に使う nonce」(送信済みトランザクション数)をキャッシュします。チェーンを作り直すと nonce が 0 に戻るのに、MetaMask は覚えたままです。結果、

Nonce too high. Expected nonce to be 0 but got 7.

というエラーで送信できなくなります。残高が古いまま表示されることもあります。

直し方:

MetaMask → 設定 → 高度な設定 → 「アクティビティタブのデータをクリア」

(バージョンによっては「アカウントをリセット」)。秘密鍵やアカウントは消えません。ローカルのトランザクション履歴だけが消えます。

anvil を止めて立て直すたびに必要です。

落とし穴 4: デプロイのアドレスは nonce で決まる

コントラクトのアドレスは、デプロイした人のアドレスと、そのときの nonce から決定論的に計算されます。ランダムではありません。

だから新品の anvil に同じ鍵で 1 番目にデプロイすれば、必ず 0x5FbDB2315678afecb367f032d93F642f64180aa3 になります。この決定論性は便利ですが、裏返すと事故の元でもあります。

私自身、本書のコードを書いている最中にこれを踏みました。既存の anvil を再利用したままデプロイスクリプトを流したため、nonce が進んでいてアドレスがずれ、フロントエンドが空のコントラクトを見ていたのです。画面には「ゲームがありません」と出るだけで、何も壊れていないように見えるので、原因に気づくまで時間を無駄にしました。

自動化スクリプトを書くときは、毎回まっさらなチェーンから始めるのを既定にするのが安全です。

if cast block-number --rpc-url "$RPC" >/dev/null 2>&1; then
  print "  Stopping the chain already on $RPC (pass --reuse to keep it)."
  pkill -f '^anvil' || true
  while cast block-number --rpc-url "$RPC" >/dev/null 2>&1; do sleep 0.2; done
fi
anvil --silent &

そしてデプロイスクリプトが出力したアドレスを、そのまま .env.local に書き込む。ハードコードしないことです。

ワンコマンドにまとめる

ここまでの手順(チェーン起動 → デプロイ → .env.local 生成 → ABI 生成 → dev サーバ起動)は毎回同じなので、スクリプトにしておきます。

./scripts/dev-up.zsh 3001

Ctrl-C で全部止まるように trap を入れておくと、後片付けを忘れません。

anvil_pid=""
cleanup() { [[ -n "$anvil_pid" ]] && kill "$anvil_pid" 2>/dev/null || true }
trap cleanup EXIT INT TERM

動作確認の順序

  1. ./scripts/dev-up.zsh 3001 でチェーンとアプリを起動
  2. ブラウザで http://localhost:3001
  3. ウォレットを接続 → Anvil に切り替え
  4. 残高を用意(上の 3 方法のどれか)
  5. ゲームを作ってみる。MetaMask に賭け金の送金が表示されるはず

5 番でガス代だけでなく賭け金の額が出るのを確認してください。payable な関数を呼ぶとは、そういうことです。

切り分けのために、アプリに残高を出しておく

「MetaMask に残高が出ない」とき、原因がウォレットの表示なのかネットワークの選択なのか、外からは分かりません。アプリ自身が RPC 経由で読んだ残高を画面に出しておくと、この切り分けが一瞬で済みます。

const { data: balance } = useBalance({ address, query: { refetchInterval: 5_000 } });
  • アプリに正しい残高が出る → チェーンとの接続は正常。ウォレットのキャッシュが古いだけ
  • アプリにも出ない → ネットワークの選択が間違っている

賭け金を払えるかどうかの判断にも要る情報なので、どのみち出しておいて損はありません。デバッグ用の仮設ではなく、そのまま製品に残る形の可観測性として入れておくのが良いと思います。

次章から、そのゲームの中身を作っていきます。