第 2 章の Step0Number を実際に動かします。ここでやることは、1 つのコントラクトをあらゆる角度から観察することだけです。ゲームのロジックは出てきません。

私見では、これがスマートコントラクトを理解する最短経路です。抽象的な説明を 10 読むより、cast disassemble の出力を 1 回見るほうが速い。

用意するもの

mkdir tictactoe && cd tictactoe
forge init contracts
cd contracts

src/Counter.sol などが生成されるので消して、代わりにこれを置きます。

// src/learn/Step0Number.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract Step0Number {
    uint256 public number;

    function setNumber(uint256 newNumber) external {
        number = newNumber;
    }
}

foundry.toml に 1 行足しておきます。ストレージ配置を後で見るためです。

[profile.default]
extra_output = ["storageLayout"]

1. 外から見えるインターフェース

forge inspect Step0Number abi
| Type     | Signature                       | Selector   |
| function | number() view returns (uint256) | 0x8381f58a |
| function | setNumber(uint256) nonpayable   | 0x3fb5c1cb |

number() という関数は書いていません。public を付けた変数からコンパイラが生成しました。

右端の セレクタ に注目してください。関数のシグネチャ文字列を keccak256 したハッシュの先頭 4 バイトです。EVM には関数名という概念がなく、この 4 バイトだけが関数の識別子です。

2. 値が物理的にどこに置かれるか

forge inspect Step0Number storageLayout
| Name   | Type    | Slot | Offset | Bytes |
| number | uint256 | 0    | 0      | 32    |

スロット 0 の、先頭から 32 バイト。コントラクトのストレージは 32 バイト単位のスロットが 2^256 個並んだ配列だと思ってください。変数はこのスロットに順番に割り当てられます。

Offset の列が後で効いてきます。小さい型を並べると 1 スロットに詰め込まれるからです(第 13 章で扱います)。

3. コンパイル結果を逆アセンブルする

ここが一番面白いところです。

forge inspect Step0Number deployedBytecode | cast disassemble | sed -n '19,36p'
00000017: PUSH0
00000018: CALLDATALOAD          ← 呼び出しデータの先頭 32 バイトを読む
00000019: PUSH1 0xe0
0000001b: SHR                   ← 224 ビット右シフト = 先頭 4 バイトを取り出す
0000001c: DUP1
0000001d: PUSH4 0x3fb5c1cb      ← setNumber(uint256) のセレクタ
00000022: EQ
00000023: PUSH1 0x34
00000025: JUMPI                 ← 一致したらそこへジャンプ
00000026: DUP1
00000027: PUSH4 0x8381f58a      ← number() のセレクタ
0000002c: EQ
0000002d: PUSH1 0x45
0000002f: JUMPI
00000030: JUMPDEST
00000031: PUSH0
00000032: PUSH0
00000033: REVERT                ← どれとも一致しなければ失敗

コントラクトの関数呼び出しの正体が、この巨大な if-else です。 呼び出しデータの先頭 4 バイトを取り出し、既知のセレクタと順に比較し、一致した場所へジャンプする。これをディスパッチャと呼びます。

最後の REVERT も重要です。存在しない関数を呼ぶと、ここに落ちて失敗します。「関数が見つかりません」という親切なエラーは出ません。

4. デプロイする

別のターミナルで anvil を起動してから、

forge create src/learn/Step0Number.sol:Step0Number \
  --rpc-url http://127.0.0.1:8545 \
  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
  --broadcast

この秘密鍵は anvil が起動時に画面に出す開発用アカウントのもので、世界中で共有されている公開値です。本物のネットワークでは絶対に使わないでください。

Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 のように出ます。

5. 読む(無料・即座)

cast call 0x5FbD... "number()(uint256)" --rpc-url http://127.0.0.1:8545
# → 0

トランザクションではありません。ブロックにも入りません。1 ノードが手元のデータを見て答えているだけなので、費用も待ち時間もゼロです。

6. 書き込みが実際に送るバイト列

cast calldata "setNumber(uint256)" 42
0x3fb5c1cb000000000000000000000000000000000000000000000000000000000000002a
  ~~~~~~~~ セレクタ (4 バイト)
          ~~~~ ... ~~~~ 引数 4232 バイトに右詰め

これがトランザクションの中身のすべてです。0x2a は 42 の 16 進表記。関数名も引数名もどこにもなく、4 バイトのハッシュと 32 バイトの数値だけが送られます。

逆に言えば、ABI(インターフェース定義)がなければ、このバイト列を人間が解釈することはできません。ABI がコード生成にとって重要なのは、これが理由です。

7. 書く(有料・ブロックが作られる)

cast send 0x5FbD... "setNumber(uint256)" 42 \
  --rpc-url http://127.0.0.1:8545 --private-key 0xac09...
status 0x1  gas 43491  block 2

43,491 ガス。読み取りが 0 だったのと比べてください。この非対称性が、Solidity の設計判断のほとんどを支配します。

内訳はこうです。トランザクションの基本料金が 21,000、calldata が 204、新しいストレージスロットへの書き込みが 20,000 とスロット初回アクセスの 2,100 で 22,100、残り約 190 がディスパッチャと引数処理。合計 43,491 でぴったり合います。「32 バイト書くと 20,000 ガス」という料金表が存在することを、頭の片隅に置いておいてください。

8. 生のストレージスロットを直接読む

cast storage 0x5FbD... 0 --rpc-url http://127.0.0.1:8545
# → 0x000000000000000000000000000000000000000000000000000000000000002a

number() を経由していません。コントラクトのストレージは全部公開されていて、誰でも直接読めます。

これは非常に重要な性質です。private という修飾子は Solidity の言語機能であって、データの秘匿ではありませんprivate uint256 secret と書いても、スロット番号さえ分かれば誰でも読めます。オンチェーンに秘密は置けない、と覚えてください。

まとめてスクリプトに

以上 8 つを 1 コマンドで流すスクリプトを、本書のリポジトリに置いてあります。

./scripts/walkthrough-step0.zsh

Step0Number.sol を書き換えて再実行すると、セレクタが変わり、ストレージ配置が変わり、ガス代が変わるのが観察できます。変数を 1 つ足すとスロットが 1 つ増える関数名を変えるとセレクタが変わるといったことを、手で確かめてみてください。

RPC は Web API です

ここまで cast を使ってきましたが、これは JSON-RPC over HTTP のラッパーにすぎません。curl でも同じことができます。

curl -s -X POST http://127.0.0.1:8545 -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call",
       "params":[{"to":"0x5FbD...","data":"0x8381f58a"},"latest"]}'
{"jsonrpc":"2.0","id":1,"result":"0x000...02a"}

data にセレクタを入れて POST すると、32 バイト右詰めの 16 進が返る。ブロックチェーンへのアクセスは、この程度に素朴な HTTP API です。

GUI が欲しい場合は、ローカルなら Otterscan(anvil 用のブロックエクスプローラ)、テストネットなら Basescan / Etherscan が使えます。テストネットにデプロイしてソースを verify すると、ブラウザ上に関数のフォームが自動生成され、フロントを書く前に手で動作確認できます。

次に読むもの: Step1Storage.sol

Step0Number は変数 1 つだけでした。ここから一段だけ広げた src/learn/Step1Storage.sol を置いてあります。動かすためではなく、読むためだけのファイルです。

contract Step1Storage {
    string public message;        // getter が勝手に生える
    address public lastWriter;
    uint256 public writeCount;

    event MessageChanged(address indexed writer, string message, uint256 writeCount);

    constructor(string memory initialMessage) { ... }        // 一度だけ動く
    function setMessage(string calldata newMessage) external { ... }   // 書く。ガスが要る
    function summary() external view returns (...) { ... }             // 読む。タダ
}

コントラクトの構成要素が、これでひと通り揃います

出てくるものweb2 で言うと
public な状態変数テーブルの列。ただし getter が自動で生える
event / indexed構造化ログ。コントラクトからは読み返せない
constructor一度きりの初期化。終わるとコード自体が捨てられる
external / view書き込み(要ガス・要ブロック)と読み取り(無料・即時)の境界
msg.sender認証済みのリクエスト元。req.user に相当するが、偽装できない
require失敗したらトランザクション全体が無かったことになる。部分的な書き込みが残らない

ファイルの中身はほぼコメントです。この章で見た forge inspectStep1Storage に対しても走らせてみてください。string がスロットをどう使うか、event が ABI にどう出るかが観察できます。テストは test/learn/Step1Storage.t.sol にあります。