第 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 バイト)
~~~~ ... ~~~~ 引数 42 を 32 バイトに右詰め
これがトランザクションの中身のすべてです。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 inspect を Step1Storage に対しても走らせてみてください。string がスロットをどう使うか、event が ABI にどう出るかが観察できます。テストは test/learn/Step1Storage.t.sol にあります。