前章の最後に挙げた問題のうち、まず「1 ゲームにつき 1 デプロイ」を扱います。これは単なる最適化の話ではなく、スマートコントラクトの設計単位をどう決めるかという一番大きな問いに直結します。

「1 レコード = 1 コントラクト」は高い

Step2MinimalTicTacToe は 1 デプロイ 1 ゲームでした。Web 開発の感覚だと「ゲームごとにインスタンスを作る」のは自然に見えます。しかしチェーン上では、デプロイとはバイトコードを永久にチェーンへ書き込む作業です。

| Step2MinimalTicTacToe | Runtime 2,164 B | Initcode 2,404 B |

ゲームを始めるたびにこれを払う。対して、ゲームを配列に持てば デプロイは 1 回きりで、以後はストレージへの書き込みだけです。

Game[] private games;

function createGame(...) external returns (uint256 gameId) {
    gameId = games.length;
    games.push(Game({ ... }));
}

これが本書の TicTacToe.sol の形です。gameId は配列の添字、つまりテーブルの主キーです。

実際のプロダクトの粒度

この判断は、実在のプロトコルを見ると腑に落ちます。

対象粒度なぜ
ERC-20 トークン1 トークン = 1 コントラクト全員の残高は mapping(address => uint256) の中。保有者ごとには作らない
NFT コレクション1 コレクション = 1 コントラクトtokenId は単なるキー。NFT 1 点ごとには作らない
Uniswap の取引ペア1 ペア = 1 コントラクト各ペアが自分の資金を独立して抱えるため。Factory が量産する
Safe(マルチシグ財布)1 財布 = 1 コントラクト各財布が独立した資金と権限を持つため

分岐点は 「そのレコードが独立して資金や権限を抱える必要があるか」 です。

  • 抱えない → 1 コントラクトの中の 1 行(ERC-20 の残高、本書のゲーム)
  • 抱える → 1 レコード 1 コントラクト + 量産用の Factory(Uniswap のペア、Safe の財布)

まるばつのゲームは、賭け金を預かるとはいえ、すべてのゲームの資金を 1 つのコントラクトがまとめて持って構いません。ゲーム同士を隔離する理由がないからです。だから配列で十分です。

分割を強制される 3 つの要因

1. バイトコードの上限

EIP-170 により、デプロイ済みコントラクトのランタイムバイトコードは 24,576 バイトが上限です。

forge build --sizes
| Contract      | Runtime Size (B) | Runtime Margin (B) |
| SoloTicTacToe | 5,424            | 19,152             |
| TicTacToe     | 7,441            | 17,135             |

本書のコントラクトは余裕がありますが、実際の DeFi プロトコルはここに当たります。当たると、ロジックを外部ライブラリ(delegatecall で呼ぶ形)に切り出すか、Diamond パターンのような分割方式を導入することになります。設計上の都合ではなく、物理的な壁で分割させられるわけです。

2. 不変性

デプロイしたコントラクトは書き換えられません。後から変わりそうなものは、別アドレスに逃がしておくのが定石です(プロキシパターン)。何を同じアドレスに置くかは、「これを一緒に捨てる覚悟があるか」という問いでもあります。

3. 爆発半径

お金を持つコントラクトは、可能な限り小さく退屈にする。これはセキュリティの原則です。

本書が 3 ファイルに分かれている理由

最終的なリポジトリの src/ はこうなっています。

src/
  Board.sol            盤面のビット演算(ライブラリ)
  TicTacToe.sol        対人戦。賭け金を預かる
  SoloTicTacToe.sol    CPU 戦。賭けなし

  learn/               読むためだけのコード。アプリからは呼ばれない
    Step0Number.sol4 章。変数 1 つだけ
    Step1Storage.sol            コントラクトの構成要素をひと通り並べたもの
    Step2MinimalTicTacToe.sol9 章。1 デプロイ 1 ゲーム

learn/ は教材で、デプロイもされず、アプリからも参照されません。アプリの実体は上の 3 ファイルです。

この 3 つは相互に呼び合っていません。 そもそも種類が違います。

ファイル種類デプロイ実行時の関係
Board.solライブラリinternal のみ)されないなし。コンパイル時に各コントラクトへ埋め込まれる
TicTacToe.solコントラクトアドレス ASolo とは無関係
SoloTicTacToe.solコントラクトアドレス BTicTacToe とは無関係

ライブラリはデプロイされない

Board.sol は関数がすべて internal です。

library Board {
    uint16 internal constant FULL = 0x1FF;

    function isWin(uint16 board) internal pure returns (bool) { ... }
    function bit(uint8 cell) internal pure returns (uint16) { ... }
}

internal なライブラリ関数は、コンパイラが呼び出し元のバイトコードにインライン展開します。デプロイも不要、リンクも不要です。証拠がサイズ表に出ています。

| Board | Runtime Size 85 B |   ← 実質カラの残骸。誰もこれをデプロイしない

バンドラが util モジュールを各バンドルにインライン化するのと同じで、TicTacToeSoloTicTacToeそれぞれ自分のコピーを持っています。共有しているのはソースコードだけで、実行時には何も共有していません。

public / external なライブラリ関数を使うと話が変わります。その場合ライブラリは実際にデプロイされ、delegatecall 経由で呼ばれ、リンクが必要になります。バイトコード上限を回避したいときの手段です。)

なぜ CPU 戦を別コントラクトにしたか

理由は 2 つです。

お金を持つのは TicTacToe だけ。 CPU 戦は賭けなしなので、対戦ロジックにバグがあっても資金は 1 円も危険に晒されません。逆に、お金を扱うコントラクトに CPU の 300 行を同居させると、監査対象がまるごと増えます。

後から追加できたから。 本書のコードは実際、対人戦を先に作り、後から CPU 戦を足しました。もし 1 つのコントラクトに詰め込んでいたら、既にデプロイ済みの対人戦を作り直して、進行中のゲームを捨てるしかありませんでした。別コントラクトなら、新しいものを 1 つデプロイするだけです。

これは机上の話ではなく、不変性という制約の下では「後から機能を足す」が「新しいアドレスにデプロイする」を意味するという、実務そのものです。

もし連携させるなら

2 つのコントラクトを協調させたい場合は、片方が相手のアドレスを保持して呼び出します。

interface ITicTacToe {
    function getGame(uint256 gameId) external view returns (Game memory);
}

contract Something {
    ITicTacToe public immutable GAME;
    constructor(address game) { GAME = ITicTacToe(game); }

    function doThing(uint256 gameId) external {
        Game memory g = GAME.getGame(gameId);   // 外部呼び出し
    }
}

これは外部呼び出しで、ガスを消費し、失敗しうるし、相手が任意のコードを実行できるのでリエントランシー攻撃(呼び出した相手から自分を呼び返される攻撃。第 11 章で扱います)の入口にもなります。必要がなければやらないのが健全です。

まとめ

  • 1 コントラクト = 1 つの状態のかたまり + それを守るルール + 1 つの信頼境界
  • レコードごとにデプロイするのは、そのレコードが独立して資金や権限を抱える場合だけ
  • バイトコード上限(24,576 バイト)、不変性、爆発半径が分割を強制する
  • internal なライブラリはデプロイされない。コンパイル時にインライン展開される
  • コントラクト同士の呼び出しは、できれば避ける

次章から、いよいよお金を扱います。ここが一番、事故の起きやすい領域です。