T7 · Solidity の基礎
配備したコントラクトは、チェーン上でどんな形になる?
- 練習する能力
- Builder
- 実践
- 最小限の入出金コントラクトを作ってテストネットにデプロイし、エクスプローラーでソースコードを検証して関数を呼び出す。
- AI Lab
- AI にコントラクトのコメントと NatSpec を書かせ、権限の説明に誤りがないか自分で確認する。
現実の問題
ユーザーが預けた保証金をいつでも戻せ、紛争時にはプラットフォームが介入できる仕組みが必要です。
似たサービスを何十も作ったので二十分で実装できそうですが、今回は普段の公開と違います。
- コードは公開され、誰でも逆コンパイルできます。多くのプロジェクトはソースも公開します。
- データベースは公開され、各人の預金を世界中が照会できます。
- Nginx、ゲートウェイ、ログイン画面はありません。誰でも公開関数を直接呼べます。自作スクリプトでフロントエンドを通さず使えます。
- 段階公開、ロールバック、hotfix はありません。一行の変更が新アドレスを意味します。
同じ業務ロジックでも、この環境では別物です。書く前に配備後に何になるかを答える必要があります。
思考実験
配備されたコントラクトを、広場の自動販売機と考えます。
位置は固定(アドレス)、機構は溶接済み(不変コード)、商品とお金は機械自身のもの(保存領域)、ボタンが並びます(公開関数)。
現実の機械と四点違います。
- 営業時間も店員もいません。 誰でもいつでもどのボタンも押せます。
- ガラスは完全に透明です。 金額も各マスの内容も見え、管理者だけが見られる場所はありません。
- 順番と回数は押す人が決めます。 設計した手順は保証されず、第三のボタンだけ一万回押せます。
- 機械は押した人を知ります。 各操作に偽造できない識別情報が付きます。
第四だけが頼りです。自分だけができると表すには、ボタンが押された時に、自分か確認するしかありません。
保証金をこの機械へ入れます。入金は誰でも可能、出金も誰でも可能ですが自分の分だけです。プラットフォームの介入ボタンはどんな形で、誰が押せ、何が起きるでしょう。
- 一アドレス = 固定コード + 公開ストレージ
- 誰でもどのボタンを何回でも押せる
- 押した後に取消ボタンはない
あなたが決める
介入ボタンの四つの設計から選びます。
結果を観察する
四案に共通する構造です。
| 設計 | ユーザーの信頼対象 | 鍵漏洩の最悪 | 事前発見 |
|---|---|---|---|
| 管理者なし | コードだけ | 資金に無関係 | 不要 |
| 管理者の出金 | 運営と鍵管理 | 全資金 | 不可。一瞬で消える |
| 停止だけ | コードと少しの可用性リスク | 悪意ある停止 | 可能。影響は限定 |
| 公告と遅延 | 運営。ただし観察期間あり | 全資金、二日後 | 可能。二日で対応 |
三点を覚えます。
第一に、権限は約束でなくコードです。 資金を動かさないとサイトに書いても意味はありません。関数がないか、二日待つかで表します。読む人はサイトでなくコントラクトを読みます。
第二に、権限の粒度が事故の規模を決めます。 停止と出金を同じ owner に置くと、小さな権限と大きな権限を同じ鍵に束ねます。
第三に、チェーンは出来事を自動通知しません。 公告が有用なのは自分で記録を書くからです。変数だけ変えてイベントを出さなければ監視もユーザーも見えません。T1 のとおり、可観測性は設計する機能です。この章で実現します。
モデルを作る
配備済みコントラクトは、一アドレスに四つの要素を持ちます。
0x1234…(アドレス)
├─ コード 配備時に固定。事前の更新経路がなければ変更不可
├─ ストレージ 永続、公開、高価。秘密のフィールドはない
├─ 入口 公開関数リスト = 攻撃面リスト
└─ 記録 実行時のイベント。オフチェーンだけが読めるバックエンドの習慣を置き換えると違いが見えます。
| バックエンド | コントラクト |
|---|---|
| private フィールドと内部メソッド | 入口がなくても秘密でない。スロットは公開 |
| データベース表 | 保存スロット。書き込みは計算の百倍以上の場合もある(T8) |
| ミドルウェアの認可 | 各関数が呼び出し元を確認 |
| ログ | 自分でフィールドを設計するイベント。コントラクトは再読できない |
| 段階公開、巻き戻し、hotfix | 事前に用意したスイッチと更新経路だけ |
| フロントエンドの検証 | 迂回可能なのでコントラクトでも検証 |
最小の保証金コントラクトへ落とし込みます。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4; // カスタム error は 0.8.4 から使用可能
contract Vault {
// ── 保存領域:この四変数が全記憶 ──
address public owner;
bool public depositsPaused;
mapping(address => uint256) public balanceOf;
uint256 public totalDeposits;
// ── 記録:オフチェーンが聞ける唯一の声 ──
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
event DepositsPausedSet(bool paused);
event OwnerTransferred(address indexed from, address indexed to);
// ── 拒否理由:require 文字列より省 Gas で構造的に識別可能 ──
error NotOwner();
error DepositsArePaused();
error ZeroAmount();
error ZeroAddress();
error InsufficientBalance(uint256 requested, uint256 available);
error TransferFailed();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor() {
owner = msg.sender;
emit OwnerTransferred(address(0), msg.sender);
}
function deposit() external payable {
if (depositsPaused) revert DepositsArePaused();
if (msg.value == 0) revert ZeroAmount();
balanceOf[msg.sender] += msg.value;
totalDeposits += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
uint256 available = balanceOf[msg.sender];
if (amount > available) revert InsufficientBalance(amount, available);
// 記帳を先にし、支払いを後にする。逆にすると危険。T28 を参照
balanceOf[msg.sender] = available - amount;
totalDeposits -= amount;
emit Withdrawn(msg.sender, amount);
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
}
// 管理者は弁を閉じるだけで、残高には触れない
function setDepositsPaused(bool paused) external onlyOwner {
depositsPaused = paused;
emit DepositsPausedSet(paused);
}
function transferOwner(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
emit OwnerTransferred(owner, newOwner);
owner = newOwner;
}
}構文を一行ずつ読むより、四つの観点で読みます。
- 入口リスト:
deposit、withdraw、setDepositsPaused、transferOwner。四つの攻撃面です。公開関数を増やすたび、誰でも呼ぶと何が起きるか問います。 - 権限の表現:二関数だけが
onlyOwnerで、どちらもbalanceOfに触れません。これが弁だけを閉じる設計です。 - 変更順:
withdrawで記帳とイベントを先にし、送金は最後です。スタイルの問題ではありません。 - 可観測性:各変更に索引可能な重要フィールド付きのイベントがあり、四種から全員の残高を再構築できます。自分で設計した能力です。
mapping はハッシュ表に似ても走査できません。全預金者の一覧は取れず、別配列が必要で、費用と攻撃面を増やします。バックエンドの直感が通用しない典型です。
何と呼ぶか
コントラクト層で宣言して保存し、取引後も残る全記憶です。
public は同名の getter を自動生成します。ただし便利な入口でしかなく、付けなくても誰でも読めます。保存領域は公開です。
key から value への保存構造です。長さも走査も存在判定もなく、未書き込み key は型のゼロ値を返します。
最も一般的な記帳構造で、バックエンドの Map と同じだと誤解しやすいものです。
external と public は誰でも呼べる入口を作り、internal と private は作りません。
private は入口なしという意味で、秘密ではありません。 保存データは誰でも読めます。
関数シグネチャに付ける再利用可能な事前検査です。_; の位置へ元の関数本体が入ります。
シグネチャの行に権限制約が現れるため、レビューで権限付き関数を一目で探せます。
実行時に取引レシートへ書く構造化記録で、オフチェーンが読みます。
コントラクト自身は読めず、発行しなければ存在しません。indexed は効率よく絞り込め、最大三フィールドです。何を再構築するか設計します。
実行を止め、取引内の状態変化をすべて取り消しますが、Gas は払います。
唯一の拒否方法です。カスタム error は文字列より安く、呼び出し側で型別に処理できます。
実践
複雑さよりも、コードがアドレスになる全過程と公開後の姿を見ることが目的です。
Vault を自分で入力します。 貼り付けず入力し、コンパイラの訂正から学びます。
四経路をローカルで試します。正常入金、正常出金、残高超過の拒否、別アドレスの setDepositsPaused 拒否です。
後二つが重要です。品質の上限は拒否能力で決まります。T12 で方法にします。
テストネットへ配備します。 通貨を取得し、アドレス、配備取引ハッシュ、作成ブロック高を記録します。
実資産を持つ秘密鍵を使いません。 練習専用のウォレットを作り、後で捨てます。
ソースを検証します。 ソース、コンパイラ版、最適化設定を提出し、バイトコードと照合します。
完了後は読み書きの画面が自動生成されます。自分が作っていないフロントエンドを誰でも使えるようになります。入口が攻撃面という実演です。
その画面で呼びます。 入出金後、別ウォレットで setDepositsPaused を呼び、失敗とエラーの形を見ます。
保存領域を読みます。 RPC の eth_getStorageAt でスロット 0 を読めば owner です。
address private secretAdmin; を追加して再配備し、読みます。これも公開保存され誰でも読めます。一度試せば private を秘密と考えなくなります。
イベントを取得します。 eth_getLogs で絞り、Deposited と Withdrawn だけから各残高を再構築し、balanceOf と比較します。
一致なら設計は完全、不一致なら変更の記録が抜けています。メインネット配備前の今なら直せます。
AI Lab
作った Vault をそのまま渡し、二段階で聞きます。
第一段階:
NatSpec を補ってください。各関数で処理、呼べる人、巻き戻し条件、
状態変更、イベントを明記し、ロジックを変更しないでください。
第二段階:
監査者として、見知らぬアドレスが任意の引数と回数で各関数を呼ぶと、
最悪何が起きるか答えてください。実際のコードだけを扱い、改善案は不要です。専門的な見た目が危険です。モデルはコードを読むより、文書らしい形を補完します。 onlyOwner のない関数を所有者専用と書けば、監査者や未来の自分まで誤解させます。
setDepositsPaused の onlyOwner だけを外して再度書かせ、気づくか試します。
第二段階は価値があります。攻撃者の視点で入口と最悪結果の表を作ります。自分も書けるべきで、書けない関数は存在理由を整理できていません。
AI の回答後、自分で必ず検証すること
- 各権限説明を本体と比較する。owner 専用なら実際に検査がある
- 検査のない関数を保護済みと説明していない
- コードにない前提や制約を作っていない
- 存在しないライブラリ、インターフェース、EIP を引用しない
- return と実際の返却、引数説明と順序が一致する
- コメント追加後にコンパイルできる。ただし説明の正しさは別に検証する
実例
未公開リスト、抽選シード、未発効パラメータを private に保存して隠せたと思ったプロジェクトが複数あります。
任意スロットは RPC で読め、先にシードを知って当選したり、リストを知って二次市場で使ったりできます。
コントラクトに秘密はありません。 ハッシュを保存し、原文は公開時に置きます。
共通ライブラリの所有者設定関数に、一度限りや特定人という制約がありませんでした。
呼んだ人が所有者となって破壊し、依存ウォレットが一斉に使えなくなり、資金が永久に凍結しました。
公開入口は誰でも呼ぶと仮定します。ライブラリ用途は保護ではありません。破壊が他を止める理由は、T8 の delegatecall で学びます。
条件が逆、またはゼロアドレスを検証せず owner にした例があります。
管理権限が永久に失われ、停止、更新、設定変更ができなくなります。盗難はなくても製品が死にます。
通常のテストでは完全に見逃します。ゼロアドレスへ移し壊れると確認するケースを書かないためです。新 owner の受諾を要求する二段階移転が多くを防ぎます。
更新で資金経路を追加してもイベントを付けませんでした。
イベントで残高を再構築するオフチェーン記帳には変化が見えず、三週間後の財務照合まで全帳票が誤っていました。
イベントは外部への唯一の能動的通路です。 変更ごとに、外部がどう知るか問います。
変数を一つ変える
入口から消え、外部から起動できず、ユーザーは出金できません。
可視性は外部に約束する能力の定義です。レビューでは external と public の一覧を最初に作ります。それが全約束で全攻撃面です。
走査と二つの問題を得ます。
ユーザー増加で Gas 上限を超え、関数が永久に実行不能になります。また一万アドレスから少額を預ける攻撃で配列を膨らませ、重要機能を止められます。
走査が必要なら設計を見直し、オフチェーンで走査してチェーンは記帳だけにするのが一般的です。
機能と状態変化は正常でも、通知、帳票、照合、監視が見えなくなります。
T1 のEvent は自発的に発行するものという意味です。発行側に立つと、イベントのないコントラクトに索引器の担当者が困る理由が分かります。
コードと保存を分け、ユーザーのアドレスは保存と転送だけを担当し、別の交換可能なアドレスにロジックを置きます。
修正能力と、ロジックを変える人が全規則を変える最高権限を得ます。不変のリスクと変更可能のリスクは別物で、一方が他方を消すわけではありません。
技術基盤は T8 の delegatecall、リスクは T28 です。
持ち帰る問い
なぜチェーンが必要かを先に問います。独立検証と発行者も変更できない性質が不要なら、サーバーのほうが安く速く修正可能です。コントラクトにされたものの多くは、そうする必要がありません。
四案は鍵漏洩の損失負担を決め、ほぼ常にユーザーが負います。権限設計はユーザーの代わりにするリスク配分で、公開し検証可能にします。
owner を Agent に置き換えても、範囲、金額、遅延付きリストという構造は同じです。権限を約束でなくコードにする習慣が、T32 の基礎です。
章末チェック
読めます。private は getter を作らないだけで、秘密ではありません。スロットを一回読むと原文が得られます。
ハッシュだけ保存して後で原文を公開するか、そもそもチェーンに置かないかです。
送金は受取先へ実行権を渡します。コントラクトなら受取中に再度 withdraw を呼べます。
残高をまだ引いていなければ繰り返し出金できます。先に引けば二度目の経路が自然に無効になります。
T28 で攻撃と変種を学びます。ここでは順番を覚えます。
実行効果は同じでも、可読性と監査性が違います。
シグネチャの行に権限が見えるので、一覧を作りやすくなります。本体内の検査は全関数を読む必要があります。
書く回数より、監査、引き継ぎ、半年後の再読の回数のほうが多いものです。
基準は外部が何を再構築するかです。
残高、総量、操作人などから逆算します。絞り込みに使う住所や ID に indexed を付け、最大三つです。金額の完全一致で探すことは通常ないので不要です。
イベントだけで現在状態を完全に再構築できるかが、実用的な合格基準です。
唯一の正解はありません。次を確認します。
- 与えないとどうなるか説明できる。できなければ削除。
- 停止と資金移動を同じ役割へ混ぜない。
- 最高権限の鍵の場所、単独か複数か、漏洩時の影響が明確。
- 権限の使用ごとに公開記録が残る。
- 自分が消えても正常に動く。
5 は見落としやすく、隠れた集中化の仮定を最もよく表します。
要点
コントラクトは永続的な保存領域を持つ公開コードだ。誰でも呼び出せるため、入口はすべて攻撃面になる。