ローカルで動いたら、公開テストネットに出します。メインネットには出しません。 本書のコードは学習用で、監査も受けていません。
デプロイスクリプト
Foundry では、デプロイも Solidity で書きます。
contract Deploy is Script {
function run() external returns (TicTacToe game, SoloTicTacToe solo) {
uint16 feeBps = uint16(vm.envOr("FEE_BPS", uint256(0)));
vm.startBroadcast();
game = new TicTacToe(feeBps);
solo = new SoloTicTacToe();
vm.stopBroadcast();
console2.log("TicTacToe: ", address(game));
console2.log("SoloTicTacToe:", address(solo));
console2.log("");
console2.log("Add to web/.env.local:");
console2.log(string.concat(
"NEXT_PUBLIC_TICTACTOE_ADDRESS_", vm.toString(block.chainid), "=", vm.toString(address(game))
));
}
}
vm.startBroadcast() と vm.stopBroadcast() に挟まれた部分だけが、実際のトランザクションとして送られます。それ以外はローカルで評価されるだけです。
最後に .env.local にそのまま貼れる行を出力しているのがポイントです。アドレスを手で写すのは事故の元で、私は実際にこれで 1 度詰まりました(アドレスがずれてフロントが空のコントラクトを見ていた)。出力をそのまま使える形にしておくと、写し間違いが原理的に起きません。
秘密鍵をシェル履歴に残さない
forge script script/Deploy.s.sol --rpc-url https://sepolia.base.org \
--broadcast --private-key 0xabc123... # ← やってはいけない
コマンドライン引数に書くと ~/.zsh_history に残ります。テストネットの鍵でも、同じ鍵を使い回していれば漏洩です。
1Password などから注入するのが安全です。
PRIVATE_KEY=$(op read "op://Personal/tictactoe-deployer/private_key") \
forge script script/Deploy.s.sol --rpc-url https://sepolia.base.org --broadcast
Foundry には keystore を使う方法もあります。
cast wallet import deployer --interactive # 一度だけ。暗号化して保存される
forge script script/Deploy.s.sol --rpc-url ... --broadcast --account deployer
テストネット用の鍵は、本番用と絶対に分けてください。 テストネットの鍵は faucet に入力したりブロックエクスプローラに晒したりする機会が多く、扱いが雑になりがちです。
faucet で ETH を入手する
デプロイにはガス代が要ります。テストネットの ETH は faucet で配られています。
| チェーン | 入手先 |
|---|---|
| Base Sepolia | Coinbase Developer Platform、Alchemy の faucet |
| Sepolia | Google Cloud の faucet、Alchemy、Infura |
多くの faucet はアカウント登録か、メインネットに一定の残高があることを要求します。ボット対策です。
Sepolia の ETH は Base Sepolia では使えません。 別のチェーンなので、必要なら公式ブリッジで移します。
デプロイと verify
forge script script/Deploy.s.sol \
--rpc-url https://sepolia.base.org \
--broadcast --verify
--verify を付けると、Basescan にソースコードを提出します。API キーが要ります。
export ETHERSCAN_API_KEY=... # Basescan / Etherscan で無料取得
foundry.toml に書いておくこともできます。
[etherscan]
base_sepolia = { key = "${ETHERSCAN_API_KEY}", chain = 84532 }
キー名は [rpc_endpoints] 側と揃えます。ハイフンではなくアンダースコアです。 base-sepolia と書くと --chain base_sepolia から引けません。
ただし、このセクションを置くこと自体にも落とし穴があります(この章の後半で書きます)。Sourcify だけで済ませるなら、置かないほうが安全です。
verify は必ずやってください。 理由をこの後の節で詳しく書きます。
ソースを公開しないという選択肢は、実はない
「ソースコードを公開すべきか」と迷うところですが、verify しないことで隠せるものはほとんどありません。デプロイした時点で、次のものは誰でも取得できます。
cast code 0x6995... # バイトコード全体
cast storage 0x6995... 0 # ストレージのスロット 0
バイトコード: 14,885 文字
slot 0: 0x0000000000000000000000003dc1e16ac0cc3e3fc0c96f2a1e6a3b3551fe1125
→ 下位 20 バイトが owner のアドレス
private 修飾子はデータを隠しません。 あれは Solidity の言語機能であって、暗号化ではない。スロット番号さえ分かれば誰でも読めます。オンチェーンに秘密は置けません。
では calldata はどうか。ABI がなければ、こう見えるだけです。
0x5cd7692e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004
しかし先頭 4 バイトは公開データベースから逆引きできます。
$ cast 4byte 0x5cd7692e
play(uint256,uint8)
関数シグネチャは世界中で共有されているので、ありふれた名前ならまず一致します。さらに Dedaub や heimdall のような逆コンパイラを通せば、読める疑似ソースが出てきます。攻撃者にとってバイトコードはソースコードそのものです。
つまり「verify しない」で得られるのは、善意の利用者にとっての不便だけです。
verify で何が変わるか
| 未 verify | verify 済み | |
|---|---|---|
| エクスプローラの表示 | 生のバイトコード | Solidity のソース |
| トランザクションの入力 | 0x5cd7692e0000…0004 | play(gameId: 2, cell: 4) |
| イベントログ | 生の topics と data | MovePlayed(player: 0x3Dc1…, cell: 4) |
| Read / Write タブ | なし | 関数のフォームが自動生成される |
| ABI | 自分で復元する必要あり | ダウンロードできる |
| ウォレットの確認画面 | 「コントラクト操作」 | 関数名と引数が出る |
最後の行が実務では一番効きます。MetaMask が「これから何が起きるか」を利用者に説明できるかどうかが変わります。未 verify のコントラクトに送金を求める画面は、受け取る側から見て単純に不気味です。
そして 「資金を預かるのに未 verify」は、それ自体が危険信号として扱われます。 中身を確認できないコントラクトに金を入れる人はいません。
公開しないほうがよい場合
ほぼありませんが、強いて挙げると、
- ローンチ前のステルス期間 — MEV(トランザクションの並び順から利益を抜く行為。第 19 章)戦略や裁定ボットのように、ロジック自体が競争優位である場合
- 監査前で、脆弱性が残っている自覚がある間
どちらも遅延であって防御ではありません。デプロイした瞬間からバイトコードは公開されており、自動スキャナが未 verify のコントラクトを常時解析しています。
Sourcify と Etherscan
verify 先は 2 系統あります。
| Sourcify | Etherscan / Basescan | |
|---|---|---|
| API キー | 不要 | 必要(無料) |
| 仕組み | メタデータとソースを IPFS に保存 | 中央集権のデータベース |
| Read / Write タブ | なし | あり |
| 実際に見られる頻度 | 低い | 高い |
両方に出すのが理想です。Sourcify は検証可能性のため、Etherscan は人が実際に見るため。
Etherscan V2 からは 1 つの API キーが全チェーン共通になりました。Sepolia も Base も同じキーで通ります。
export ETHERSCAN_API_KEY=...
forge verify-contract <address> src/TicTacToe.sol:TicTacToe --chain-id 11155111 --watch
落とし穴: ETHERSCAN_API_KEY が定義されているだけで Etherscan に切り替わる
実際に踏んだので書いておきます。ETHERSCAN_API_KEY が環境に存在すると、--verifier sourcify を明示していても forge が Etherscan 側を選びます。
ETHERSCAN_API_KEY is set, defaulting to Etherscan verifier.
Unset it or pass `--verifier sourcify` (or another provider) to override.
メッセージは「--verifier sourcify を渡せ」と言っていますが、渡しているのに切り替わりません。
最初は foundry.toml の [etherscan] セクションが原因だと思い込んでいたのですが、切り分けたら違いました。セクションをコメントアウトしたまま、環境変数の有無だけを変えて 2 回実行すると、こうなります。
# A: 変数あり → Etherscan に行ってしまう
ETHERSCAN_API_KEY=dummy forge verify-contract 0x837b... SoloTicTacToe \
--chain-id 11155111 --verifier sourcify
# ETHERSCAN_API_KEY is set, defaulting to Etherscan verifier.
# B: 変数なし → 素直に Sourcify へ行く
env -u ETHERSCAN_API_KEY forge verify-contract 0x837b... SoloTicTacToe \
--chain-id 11155111 --verifier sourcify
# Attempting to verify on Sourcify.
引き金は環境変数の存在だけです。 セクションを消しても、シェルに変数が残っていれば同じことが起きます。逆に、env -u ETHERSCAN_API_KEY を付ければセクションがあっても Sourcify に行きます。本書のデプロイスクリプトが Sourcify の呼び出しだけ env -u で包んでいるのはこのためです。
[etherscan] セクションには別の落とし穴があります。${ETHERSCAN_API_KEY} を参照していて変数が未定義だと、verify が始まる前に落ちます。
Error: environment variable `ETHERSCAN_API_KEY` not found
つまり、変数があると Etherscan に飛ばされ、無いとセクションの解決に失敗する。Sourcify だけで済ませたいなら、セクションをコメントアウトし、かつ変数を渡さないのが確実です。
verify はデプロイと切り離す
もう一つ実務的な話です。デプロイスクリプトで --verify を付けると、verify の失敗がデプロイ全体の失敗として扱われます。verify は第三者のサービスへの通信なので、DNS やレート制限で普通に失敗します。
実際、私の初回実行はこうなりました。
Warning: Failed to request verification status; ... dns error ...
Error: Checking verification result failed
コントラクトは正常にデプロイされているのに、スクリプトが異常終了してアドレスの記録処理まで飛びました。 対処は単純で、broadcast と verify を分けることです。
forge script script/Deploy.s.sol --rpc-url "$RPC" --account "$ACCOUNT" --broadcast | tee "$log"
# アドレスを記録してから、verify は best effort で
verify() {
if forge verify-contract "$1" "$2" --chain-id "$CHAIN_ID" --verifier sourcify --watch >/dev/null 2>&1; then
print " $2 verified"
else
print " $2 could not be verified. Retry: forge verify-contract $1 $2 --chain-id $CHAIN_ID --verifier sourcify"
fi
}
取り返しのつく処理(verify)で、取り返しのつかない処理(デプロイ)の記録を失わない。 これはスマートコントラクトに限らない原則ですが、デプロイし直せない世界では特に効きます。
なお、ステータス確認が失敗しても提出自体は成功していることがあります。ジョブ ID が発行されていれば、後から直接照会できます。
curl -s https://sourcify.dev/server/v2/contract/11155111/0x6995...
{"match":"exact_match","creationMatch":"exact_match","runtimeMatch":"exact_match", ...}
exact_match は、メタデータハッシュまで含めてバイトコードが完全一致したという意味です。match が返るだけの場合は、末尾のメタデータハッシュが違う(コンパイラ設定やパスが異なる)が、実行されるコードは同じ、という状態です。
デプロイ記録
broadcast/ に、実行したトランザクションの記録が残ります。
broadcast/Deploy.s.sol/84532/run-latest.json
デプロイしたアドレス、トランザクションハッシュ、ガス使用量が入っています。.gitignore に入れるのが一般的ですが、デプロイ履歴として残す運用もあります。少なくとも、デプロイしたブロック番号は控えておいてください。前章のイベント検索で fromBlock に使います。
NEXT_PUBLIC_DEPLOY_BLOCK_84532=12345678
フロントを Vercel に出す
普通の Next.js アプリなので特別なことはありません。ただし 1 点だけ注意があります。
環境変数がすべて NEXT_PUBLIC_* です。 これはビルド時にクライアントバンドルへ焼き込まれ、ブラウザから丸見えになります。
コントラクトのアドレスは元々公開情報なので問題ありません。しかし Alchemy や Infura の有料 RPC キーをここに入れてはいけません。誰でも抜き出して使えます。
有料エンドポイントを使うなら、Route Handler でサーバ側プロキシを立て、キーは NEXT_PUBLIC_ の付かない変数に置きます。
// app/api/rpc/route.ts
export async function POST(request: Request) {
const upstream = await fetch(process.env.ALCHEMY_URL!, { // ← サーバ専用
method: 'POST',
headers: { 'content-type': 'application/json' },
body: await request.text(),
});
return new Response(upstream.body, { status: upstream.status });
}
そして wagmi の transport をこちらに向けます。書き込みは MetaMask 経由なので影響を受けません。
Vercel に設定する変数はこれだけです。
NEXT_PUBLIC_SITE_URL=https://your-app.vercel.app
NEXT_PUBLIC_TICTACTOE_ADDRESS_84532=0x...
NEXT_PUBLIC_SOLO_ADDRESS_84532=0x...
NEXT_PUBLIC_DEPLOY_BLOCK_84532=12345678
デプロイ後にできること・できないこと
できること
setFeeBpsで手数料を変える(上限 5% はコードで固定されているので、それを超えることはできない)transferOwnershipで所有者を移す
できないこと
- コードの修正。バグがあっても直せない
- イベント定義の変更。
indexedを後から足せない - ストレージ構造の変更
- 進行中のゲームの移行
アップグレード可能にする方法(プロキシパターン)はありますが、それは「管理者がロジックを差し替えられる」ということでもあります。信頼を要求しない設計とのトレードオフです。本書のコントラクトは意図的に不変にしてあります。
チェックリスト
出す前に確認することを並べておきます。
-
forge testが全部通る -
forge coverageで未検証の分岐がないか見た - オーナー権限でできてしまうことを列挙した
- 資金が出ていく経路を列挙し、どのゲームも必ずどれかに到達することを確認した
- 秘密鍵をコマンドライン引数に書いていない
- テストネット用の鍵と本番用の鍵が別
-
--verifyしてブロックエクスプローラで Read/Write を叩いた - デプロイしたブロック番号を控えた
-
NEXT_PUBLIC_*に秘密が入っていない - フロントの chain 一覧にメインネットが入っていない
- 本物の資金を賭ける仕組みを本番に出そうとしていない(第 19 章。日本では賭博罪の問題があります)
最後の項目は学習用アプリでは特に大事です。ウォレットが誤ってメインネットに繋がっていても、アプリ側が弾く。ブラウザから普通に遊んでいる限り本物の資金は賭けられないようにしておきます。