最初に、捨ててもらう必要のある直感があります。
「コントラクト = クラス」ではありません。 ソースコードの見た目が contract Foo { ... } なので、どうしてもクラスに見えます。しかしこの類推で進むと、必ずどこかで詰まります。
正しい類推
デプロイ済みのコントラクトは、固定アドレスに永久に常駐している 1 個のサービスで、自分専用のデータベースを内蔵しているものです。
インスタンスを 2 つ作るものではありません。再起動もできません。デプロイし直すこともできません。バージョン 1.0.1 を出すことすらできません。
対応表にするとこうなります。
| Solidity | Web 開発での相当物 |
|---|---|
.sol ファイル | サービス 1 つ分のソースコード |
| デプロイ済みコントラクト(アドレス) | 本番稼働中のインスタンス + 専用 DB |
| storage 変数 | その DB のテーブル |
Game[] games | games テーブル。gameId は行番号 |
external 関数 | API エンドポイント |
view 関数 | GET。外から呼ぶ分には無料・即答 |
非 view 関数 | POST。課金・非同期。確定まで待つ |
event | 構造化ログ。append-only で、読めるのは外部だけ |
msg.sender | 認証済みユーザー ID。偽装不可・認証層不要 |
require / revert | トランザクション全体のロールバック |
constructor | 起動時初期化。1 回きり、二度と走らない |
| デプロイ | 本番リリース。ロールバック不可・パッチ不可 |
一番小さなコントラクト
言葉より現物です。これ以上削れないところまで削ると、こうなります。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract Step0Number {
uint256 public number;
function setNumber(uint256 newNumber) external {
number = newNumber;
}
}
数を 1 つ覚えるだけです。これでも立派なコントラクトで、デプロイすれば世界中の誰でもこの数を読めますし、誰でも書き換えられます(アクセス制御を書いていないので)。
public に注目してください。変数に public を付けると、コンパイラが同名の読み取り関数を自動生成します。 number() という関数はソースのどこにも書いていませんが、外から呼べます。
そしてコントラクトは消せません。バグがあっても、恥ずかしくても、そのアドレスにそのコードが残り続けます。誰がデプロイしたかも公開されたままです。
状態は「永続する」の意味が違う
number = newNumber; と書いた瞬間に何が起きるか。
その値は、このコントラクトのアドレスに紐づいたストレージスロットに書き込まれます。そしてネットワークに参加しているすべてのノードが同じ値を保持します。バックアップの話ではなく、それが唯一の保存形態です。
そしてこれには費用がかかります。後の章で実測しますが、32 バイトの新規書き込みが 20,000 ガス(初回アクセス料 2,100 を加えると 22,100)、上書きが 2,900 ガス、といった具合に明示的な料金表があります。「とりあえず全部保存しておく」が成立しない世界です。
msg.sender — 認証層が消える
Web アプリなら、リクエストを受けて「この人は誰か」を判定するために、セッションなり JWT なりの仕組みが要ります。Solidity にはありません。代わりにこれがあります。
function setMessage(string calldata newMessage) external {
lastWriter = msg.sender; // 呼び出し元のアドレス
}
msg.sender は、この呼び出しの直接の呼び出し元のアドレスです。人が鍵で直接呼んだときは、そのトランザクションに署名した鍵から復元されたアドレスになります。署名は暗号的に検証済みなので偽装できません。パスワードもトークンも、それを検証するコードも要りません。
これがスマートコントラクトの気持ちよさの半分を占めています。「誰が呼んだか」は、実行環境が保証してくれる前提条件です。
ただし コントラクトがコントラクトを呼んだときの msg.sender は、呼び出し元コントラクトのアドレスであって、署名した人ではありません。「署名した人」を指すのは tx.origin のほうですが、tx.origin を認証に使ってはいけません(利用者を騙して悪意あるコントラクトを呼ばせると誤判定します)。詳しくは第 5 章で扱います。
revert — 中途半端な状態が存在しない
require(bytes(newMessage).length > 0, "message must not be empty");
この条件が偽なら、トランザクションは中止されます。ここで重要なのは、その時点までに行われた変更がすべて巻き戻されることです。
Web アプリなら「3 つのテーブルのうち 2 つ目まで更新して失敗した」という状態を自分で後始末する必要があります。Solidity では、1 つのトランザクションの中ではその状態は存在しません。呼び出しは、丸ごと成功したか、何も起きなかったかのどちらかです。
ただし例外があります。外部呼び出しの失敗は、自動では伝播しません。 低レベル call の戻り値を無視したり try/catch で握りつぶしたりすると、「相手側は巻き戻ったが、こちら側は進んだ」という中途半端な状態が作れます。戻り値は必ず確認してください。
ロールバックが言語レベルで保証されている、と言い換えてもいいです。この性質のおかげで、送金と状態更新を安全に組み合わせられます。
view と非 view — 読み書きの非対称性
function summary() external view returns (string memory, address, uint256) { ... }
view は「状態を変更しない」という約束です。この約束があると、任意の 1 ノードが自分の手元のデータだけで答えられます。合意も要らず、ブロックにも入らず、費用もかかりません。応答は即座です。ただし無料なのは外から eth_call で呼んだときだけで、コントラクトの中から呼べば普通にガスがかかります。
一方、状態を変える関数はトランザクションになります。ネットワーク全体に配られ、ブロックに取り込まれ、確定するまで数秒から数十秒かかります。そして呼び出し元に値を返せません。返す先がもう聞いていないからです。
だから結果は event で告知します。
event MessageChanged(address indexed writer, string message, uint256 writeCount);
イベントはログです。ストレージよりずっと安く、indexed を付けたフィールドで絞り込み検索ができます。ただしコントラクト自身は自分が書いたイベントを読み返せません。読めるのは外部(あなたのフロントエンド、インデクサ、ブロックエクスプローラ)だけです。
この読み書きの非対称性が、Solidity の設計判断の多くを支配しています。「安いから」ではなく「読み返せないから」イベントに何を載せるかを考える必要があります。
まとめ
- コントラクト = クラスではなく、永久常駐する 1 個のサービス + 専用 DB
- デプロイし直せない。だから設計判断が後戻りできない
- 状態の書き込みには明示的な費用がかかる
msg.senderが認証層を不要にするrevertが中途半端な状態を不可能にする- 読みは無料で即座、書きは有料で非同期、そして戻り値を返せない
次章では、これを書いて動かすための道具立て — Foundry を見ます。ここもまた、npm の世界とは違う作法があります。