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

By [0xtomo](https://paragraph.com/@0xtomo) · 2025-12-20

tenderly, japanese, ethereum, solidity

---

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

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

目的と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](https://tenderly.co/)
    
*   レポジトリ・パッケージ: monorepo ([Turborepo](https://turborepo.com/)を採用しましたが何でも良い) , 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_changed` が `true` ならコントラクト変更あり、 `false` ならコントラクト変更無しということでどのステップに進むかを指示するわけですね。

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

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

![](https://storage.googleapis.com/papyrus_images/cc1f3d55b1a616c2bcd5652020cd4c3bebc83f2cfdd6f65cee80647b27b301c3.png)

作成したチェーン情報例

![](https://storage.googleapis.com/papyrus_images/647959bb3633ec5cdc370d4d05e8a8216bc430571b20add157f89a1d22f34b67.png)

チェーンごとの機能一覧。なんとなく察せるっしょ

TenderlyはGitHub Actionsを用意してくれているのでそれを使います。使い方例は以下、レポジトリは[ここ](https://github.com/Tenderly/vnet-github-action)です。

    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.sh` が `1` を返すように) `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のデプロイも自動化で来ます。ここに[公式ドキュメント](https://docs.tenderly.co/virtual-testnets/develop/thegraph)があるのでどうぞ！

* * *

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

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

1.  Tenderly, Vercel, Foundryなど素晴らしいツールが揃っていること
    
2.  AIがコードをすぐに書いてくれること
    
3.  自分がAIに適切な指示ができること
    

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

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

---

*Originally published on [0xtomo](https://paragraph.com/@0xtomo/tenderly-vercel-foundry-monorepo)*
