Cover photo

Tenderly + Vercel + Foundry + monorepoでのEthereum開発が楽すぎる話

こんにちは。TanéでCTOをしていますTomo(X: HAIL)です。この記事はWeb3 Advent Calendar 2025 18日目の記事になります。2記事目の寄稿なんですが、1記事目はQiitaに直接書き、今回はこのParagraph(旧Mirrors.xyz)に書いてみます。一体どこがいいやら。

ついこの間まで5年以上にわたり同じプロダクトをメンテしていてそのtipsをまとめたりしたんですが、今久しぶりに完全新規の開発をしていて、ハードルの下がり方がすごいのでシェアします。想定読者はEVMチェーン上(Ethereum, Base, Arbitrum, Polygon, ...)でサービス開発をしている、もしくはしようと思っているエンジニアです。別段超最新のstackでもないので、知っとるわ! という方もいるかとは思いますが、そういう人は連絡ください。採用しますw

目的とStack

果たしたい目的は以下を考えます:

  • EVM上で動作するwebアプリを爆速で開発したい

  • フロントエンドもコントラクトも頻繁に実装が変わる中、それに対応するQA環境を爆速で整備したい

EVM上で動作するwebアプリのシステム構成としては、

  1. フロントエンド

  2. スマートコントラクト

  3. オンチェーンデータをクエリできるAPI(The Graphなど)

  4. 必要であればバックエンドやデータベース

だと思いますが、この記事では1, 2をメインでカバーし、3は軽く触れます。

Stack:

  • フロントエンド: Next.js

    • ホスティング: Vercel

  • Solidity開発、デプロイ: Foundry

  • CI/CD: GitHub Actions

  • テストネット: Tenderly

  • レポジトリ・パッケージ: monorepo (Turborepoを採用しましたが何でも良い) , pnpm packages

作ったプロセス

  1. monorepoをそのままLLMが使えるIDEで開き、爆速開発する

  2. GitHubに変更をpushする

  3. GitHub Actionが走る。この時点ではVercelのデプロイは行われない

  4. コントラクトに変更がある場合は5, ない場合、つまりフロントエンドだけの変更の場合は8に進む

  5. 新規のプライベートなテストネットが作成される

  6. そのテストネットに必要なコントラクトがデプロイされる

  7. デプロイされたコントラクトのアドレスがVercelの環境変数に記述される

  8. Vercelのデプロイが走る

  9. 変更で入った新機能をテストできるQA環境のできあがり!

主要プロセスの解説

monorepoをそのままIDEで開き、爆速開発する

これはなんていうか別にEVM開発に限らず、従来のフロントエンド、バックエンド開発でも同じですが、複数のシステムをまたいでLLMにファイルをメンションし、「このコントラクトの変更をもとにこのUIの挙動をこう変更してくれよな!」と命令するのが圧倒的に楽だと言うだけの話です。僕はIDEはCursor、モデルはGPT 5.2, Gemini 3あたりを使っていますが何を使おうがこの点に関してはほぼ一緒かなと思います。
参考までにファイル構成は以下のようになります。

# 省略しているフォルダやファイルも多くあります
.github/ # GitHub Actions
  workflows/
apps/
  web/ # Next.js
    src/
      app/ # Next.js App Router
packages/
  contracts/ ## Foundryプロジェクトのtop
    broadcast/
    script/
    src/
    subgraph/
pnpm-workspace.yaml # pnpm packagesの設定ファイル
turbo.json # Turborepoの設定ファイル

GitHub Actionが走る。この時点でVercelデプロイは行われない

コントラクトの変更やそれが動くテストネットのRPC URLなどを反映させたフロントエンドを自動でデプロイしたいので、push時点でのVercelデプロイはignore(キャンセル)させます。VercelはNext.jsアプリ直下に vercel.json を置くとそれを読んでくれます。

# apps/web/vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "ignoreCommand": "bash ./scripts/vercel-ignore.sh"
}

scripts/vercel-ignore.sh にはどういうときにgit pushだけでデプロイが動いてほしいか、ほしくないかを記述しました。例えば、本番環境やステージングは動いてほしいとか、この環境変数が立っているときはしてほしいとか、開発のステージや要件次第。

コントラクトに変更がある場合は5, ない場合、つまりフロントエンドだけの変更の場合は8に進む

GitHub Actionの設定でこんな感じで記述しました。まあこんなの要件を伝えればAIが書いてくれるんで、新規で作っても以下をコピペして「こういう風に変えたい」とか言っても数分で終わるでしょう。

# 例えば .github/workflows/qa-main.yml 
jobs:
  changes:
    if: github.event_name == 'workflow_dispatch' || vars.QA_MAIN_ENABLED == 'true'
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      contracts_changed: ${{ steps.diff.outputs.contracts_changed }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: diff
        run: |
          set -euo pipefail
          BEFORE="${{ github.event.before }}"
          AFTER="${{ github.sha }}"
          if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
            echo "contracts_changed=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          if git diff --name-only "$BEFORE" "$AFTER" | grep -q '^packages/contracts/'; then
            echo "contracts_changed=true" >> "$GITHUB_OUTPUT"
          else
            echo "contracts_changed=false" >> "$GITHUB_OUTPUT"
          fi

これで contracts_changedtrue ならコントラクト変更あり、 false ならコントラクト変更無しということでどのステップに進むかを指示するわけですね。

新規のプライベートなテストネットが作成される

ここからはTenderlyのVirtual TestNetsというプロダクトを使います。これは自分たち専用のテストネットを、既存のチェーンをforkする形で作れるプロダクトです。例えば「Base mainnetの最新blockをforkしたチェーン作って」みたいな感じです。

post image
作成したチェーン情報例
post image
チェーンごとの機能一覧。なんとなく察せるっしょ

TenderlyはGitHub Actionsを用意してくれているのでそれを使います。使い方例は以下、レポジトリはここです。

jobs:
  qa:
    steps:
      - name: Create Tenderly Virtual TestNet
        if: inputs.run_tenderly || inputs.run_foundry || (inputs.run_vercel && inputs.vercel_update_env) # 走らせる条件。要件次第
        uses: Tenderly/vnet-github-action@v1.0.17
        with:
          mode: CD
          access_key: ${{ secrets.tenderly_access_token }}
          account_name: ${{ inputs.tenderly_account_name }}
          project_name: ${{ inputs.tenderly_project_name }}
          testnet_name: ${{ inputs.testnet_name }} # 特に何も入れないでもユニークな名前が割り当てられます
          network_id: |
            ${{ inputs.tenderly_network_id }}
          chain_id_prefix: ${{ inputs.chain_id_prefix }}
          state_sync: ${{ inputs.state_sync }}
          public_explorer: ${{ inputs.public_explorer }}

これで自分たち専用の、どこかのチェーンをforkしたテストネットが作成されました。

そのテストネットに必要なコントラクトがデプロイされる

まずデプロイにはgasが必要なので、それを送ります。自分たちのテストネットでやりたい放題して大丈夫ですから、なんのトークンでも送れる機能があります。fork元のチェーンにあるトークンならなんでもいけます。下はシンプルにETHを送るときですね。 tenderly_setBalance という命令を使っているのが分かると思います。

jobs:
  qa:
    steps:
      ...

      - name: Fund deployer (Tenderly unlimited faucet)
        if: inputs.run_foundry
        env:
          ADMIN_RPC_URL: ${{ env.ADMIN_RPC_URL }}
          DEPLOYER_ADDRESS: ${{ inputs.deployer_address }}
          DEPLOYER_BALANCE_WEI_HEX: ${{ inputs.deployer_balance_wei_hex }}
        run: |
          set -euo pipefail
          [ -n "${ADMIN_RPC_URL:-}" ] && echo "::add-mask::$ADMIN_RPC_URL"
          curl -sS "${ADMIN_RPC_URL}" \
            -X POST \
            -H "Content-Type: application/json" \
            -d '{
              "jsonrpc": "2.0",
              "method": "tenderly_setBalance",
              "params": [["'"${DEPLOYER_ADDRESS}"'"], "'"${DEPLOYER_BALANCE_WEI_HEX}"'"],
              "id": 1
            }'

そしたらコントラクトをデプロイしましょう。デプロイするスクリプトは一つにまとめておいたほうが楽かなとは思いますが、複数実行してもいいでしょう。

jobs:
  qa:
    steps:
      ...

      - name: Install Foundry
        if: inputs.run_foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Deploy contracts with Forge Script
        if: inputs.run_foundry
        working-directory: ${{ inputs.foundry_dir }}
        env:
          PRIVATE_KEY: ${{ inputs.deployer_private_key }} # 基本使って捨てるQA環境用なので、漏れてもいい前提の鍵を利用するのをおすすめします。漏れちゃいけない鍵の管理は各自考えましょう
          RPC_URL: ${{ env.RPC_URL }}
        run: |
          set -euo pipefail
          forge script script/${{ inputs.forge_script_file }} \
            --rpc-url "$RPC_URL" \
            --private-key "$PRIVATE_KEY" \
            --broadcast \
            -vvv

デプロイされたコントラクトのアドレスがVercelの環境変数に記述される

forge script コマンドは broadcast/{{Chain ID}}/{{スクリプトファイル名}}/run-latest.jsonにデプロイ結果を記述するので、そこからデプロイされたコントラクトのアドレスを抜き出します。

jobs:
  qa:
    steps:
      ...

      - name: Extract deployed contract addresses
        if: inputs.run_foundry
        env:
          FOUNDRY_DIR: ${{ inputs.foundry_dir }}
          CHAIN_ID: ${{ env.CHAIN_ID }}
          FORGE_SCRIPT_FILE: ${{ inputs.forge_script_file }}
        run: |
          set -euo pipefail
          CONTRACTS_ENV_FILE="/tmp/contracts.env"
          : > "$CONTRACTS_ENV_FILE"

          bash ./scripts/ci/extract-foundry-address.sh "${FOUNDRY_DIR}" "${CHAIN_ID}" "${FORGE_SCRIPT_FILE}" "${CONTRACTS_ENV_FILE}"

          # Export for subsequent steps as environment variables.
          cat "$CONTRACTS_ENV_FILE" >> "$GITHUB_ENV"

          echo "Extracted:"
          cat "$CONTRACTS_ENV_FILE" || true

scripts/ci/extract-foundry-address.sh で抜き出しているわけですが、このコードはちょっと長いので割愛します。まあAIに言えば秒で100行書いてくれますから。注意点としては、proxyパターンを使っているときに、例えば ABCToken をデプロイしたので NEXT_PUBLIC_CONTRACT_ABCTOKEN 環境変数を作成したいのに、 NEXT_PUBLIC_ERC1967PROXY なんてproxyコントラクト名の環境変数を作成されても困ります。なので使ってるパターンやproxyの種類に応じてスクリプトを調整しましょう。大丈夫です、まあAIに言えば略

さて、環境変数一覧ができあがりましたから、それを実際にVercelに反映しましょう。反映するのは

  • 作られたテストネットのRPC URL

  • 作られたテストネットのChain ID

  • デプロイされたコントラクトのアドレス(複数)

です。ちょっと長いのでまあ参考程度に。一点気をつけるところとしては、 FORCE_BUILD_KEY というのをいじっていますが、これは上の vercel.json でデプロイする条件を厳しくしているので、その条件をこの後通過するよう(e.g. ./scripts/vercel-ignore.sh1 を返すように) 1 true などの値に設定しています。

jobs:
  qa:
    steps:
      ...

      - name: Upsert Vercel env (RPC/ChainId/Contracts + force-build=1)
        if: inputs.run_vercel && inputs.vercel_update_env
        env:
          VERCEL_TOKEN: ${{ secrets.vercel_token }}
          VERCEL_PROJECT: ${{ secrets.vercel_project }}

          VERCEL_ENV_TARGET: ${{ inputs.vercel_env_target }}
          FORCE_BUILD_KEY: ${{ inputs.force_build_key }}
          NEXT_PUBLIC_QA_RPC_URL_KEY: ${{ inputs.next_public_rpc_key }}
          NEXT_PUBLIC_QA_CHAIN_ID_KEY: ${{ inputs.next_public_chain_key }}

          RPC_URL: ${{ env.RPC_URL }}
          CHAIN_ID: ${{ env.CHAIN_ID }}
        run: |
          set -euo pipefail

          if [ -z "${VERCEL_TOKEN:-}" ] || [ -z "${VERCEL_PROJECT:-}" ]; then
            echo "ERROR: missing Vercel secrets (vercel_token/vercel_project)." >&2
            exit 1
          fi

          if ! command -v jq >/dev/null 2>&1; then
            sudo apt-get update -y
            sudo apt-get install -y jq
          fi

          # Read contract envs from the persisted file (preferred) or from current environment (fallback).
          CONTRACT_KVS="$(cat /tmp/contracts.env 2>/dev/null | grep '^CONTRACT_' || true)"
          if [ -z "$CONTRACT_KVS" ]; then
            CONTRACT_KVS="$(env | grep '^CONTRACT_' || true)"
          fi

          payload=$(jq -n \
            --arg target "${VERCEL_ENV_TARGET}" \
            --arg forceKey "${FORCE_BUILD_KEY}" \
            --arg rpcKey "${NEXT_PUBLIC_QA_RPC_URL_KEY}" \
            --arg chainKey "${NEXT_PUBLIC_QA_CHAIN_ID_KEY}" \
            --arg rpcVal "${RPC_URL}" \
            --arg chainVal "${CHAIN_ID}" \
            '[
              {key:$forceKey, value:"1", type:"encrypted", target:[$target]},
              {key:$rpcKey,   value:$rpcVal, type:"encrypted", target:[$target]},
              {key:$chainKey, value:$chainVal, type:"encrypted", target:[$target]}
            ]'
          )

          while IFS= read -r line; do
            [ -z "$line" ] && continue
            key="${line%%=*}"
            val="${line#*=}"
            payload=$(jq \
              --arg target "${VERCEL_ENV_TARGET}" \
              --arg k "NEXT_PUBLIC_${key}" \
              --arg v "${val}" \
              '. + [{key:$k, value:$v, type:"encrypted", target:[$target]}]' \
              <<<"$payload"
            )
          done <<<"$CONTRACT_KVS"

          echo "$payload" > /tmp/vercel-env-payload.json

          curl -sS -X POST "https://api.vercel.com/v10/projects/${VERCEL_PROJECT}/env?upsert=true" \
            -H "Authorization: Bearer ${VERCEL_TOKEN}" \
            -H "Content-Type: application/json" \
            --data-binary @/tmp/vercel-env-payload.json

Vercelのデプロイが走る

Vercelにはgit pushやVercel CLI/UIでの手動デプロイの他に、Deploy Hookを呼ぶことでデプロイするという方法が用意されているので、それを使います。 Settings → Gitにあります。

      - name: Trigger Vercel Deploy Hook
        if: inputs.run_vercel
        env:
          HOOK: ${{ secrets.vercel_deploy_hook_url }}
        run: |
          set -euo pipefail
          if [ -z "${HOOK:-}" ]; then
            echo "ERROR: missing vercel_deploy_hook_url secret." >&2
            exit 1
          fi
          curl -sS -X POST "$HOOK"

これでデプロイされたNext.jsアプリは新しく作られた専用テストネットを向いていて、新規追加や変更されたコントラクトにアクセスできています。一度仕組み化してしまえば、Git pushだけで全てが起こります。

一応、フロントエンド上の設定例は以下です。

import type { Chain } from 'viem'
...

  const rpcUrl = process.env.NEXT_PUBLIC_QA_RPC_URL
  const chainIdRaw = process.env.NEXT_PUBLIC_QA_CHAIN_ID
  if (!rpcUrl || !chainIdRaw) return null

  const id = Number(chainIdRaw)
  if (!Number.isFinite(id) || id <= 0) return null

  const chain: Chain = {
    id,
    name: 'Tenderly QA',
    nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: {
      default: { http: [rpcUrl] },
      public: { http: [rpcUrl] },
    },
    testnet: true,
  }

The Graph連携

詳細は割愛しますが、その新規テストネット上で動くsubgraphのデプロイも自動化で来ます。ここに公式ドキュメントがあるのでどうぞ!


以上、Ethereum開発が楽になった話でした。

余談ですが、この仕組みの構築にかかった時間は数時間でした。数時間でこれが達成できる理由は言わずもがなですが、

  1. Tenderly, Vercel, Foundryなど素晴らしいツールが揃っていること

  2. AIがコードをすぐに書いてくれること

  3. 自分がAIに適切な指示ができること

です。適切な指示にはやはり経験が物を言います。できるかどうかも見当がつかないことを指示したり質問するのは人間には難しいためです。こういう記事を読んで、新しくEthereum開発の世界に入ってくる人の知識・経験の足しになり、サービスを開発していく助けになれば嬉しいです。

最後に、TanéではEthereumエコシステム上でサービスを作る仲間を募集しています。興味のある方はXのDMあたりで連絡ください。よろしくお願いします!