Crypto OS
Technical Crypto OS第2段階 · スマートコントラクト開発

T7 · Solidity の基礎

配備したコントラクトは、チェーン上でどんな形になる?

この章を 3D で体験する →
練習する能力
Builder
実践
最小限の入出金コントラクトを作ってテストネットにデプロイし、エクスプローラーでソースコードを検証して関数を呼び出す。
AI Lab
AI にコントラクトのコメントと NatSpec を書かせ、権限の説明に誤りがないか自分で確認する。

現実の問題

ユーザーが預けた保証金をいつでも戻せ、紛争時にはプラットフォームが介入できる仕組みが必要です。

似たサービスを何十も作ったので二十分で実装できそうですが、今回は普段の公開と違います。

  • コードは公開され、誰でも逆コンパイルできます。多くのプロジェクトはソースも公開します。
  • データベースは公開され、各人の預金を世界中が照会できます。
  • Nginx、ゲートウェイ、ログイン画面はありません。誰でも公開関数を直接呼べます。自作スクリプトでフロントエンドを通さず使えます。
  • 段階公開、ロールバック、hotfix はありません。一行の変更が新アドレスを意味します。

同じ業務ロジックでも、この環境では別物です。書く前に配備後に何になるかを答える必要があります。

思考実験

配備されたコントラクトを、広場の自動販売機と考えます。

位置は固定(アドレス)、機構は溶接済み(不変コード)、商品とお金は機械自身のもの(保存領域)、ボタンが並びます(公開関数)。

現実の機械と四点違います。

  1. 営業時間も店員もいません。 誰でもいつでもどのボタンも押せます。
  2. ガラスは完全に透明です。 金額も各マスの内容も見え、管理者だけが見られる場所はありません。
  3. 順番と回数は押す人が決めます。 設計した手順は保証されず、第三のボタンだけ一万回押せます。
  4. 機械は押した人を知ります。 各操作に偽造できない識別情報が付きます。

第四だけが頼りです。自分だけができると表すには、ボタンが押された時に、自分か確認するしかありません。

保証金をこの機械へ入れます。入金は誰でも可能、出金も誰でも可能ですが自分の分だけです。プラットフォームの介入ボタンはどんな形で、誰が押せ、何が起きるでしょう。

  1. 一アドレス = 固定コード + 公開ストレージ
  2. 誰でもどのボタンを何回でも押せる
  3. 押した後に取消ボタンはない
三点を合わせて、ここでの配備の実際の意味になります。

あなたが決める

介入ボタンの四つの設計から選びます。

結果を観察する

四案に共通する構造です。

設計ユーザーの信頼対象鍵漏洩の最悪事前発見
管理者なしコードだけ資金に無関係不要
管理者の出金運営と鍵管理全資金不可。一瞬で消える
停止だけコードと少しの可用性リスク悪意ある停止可能。影響は限定
公告と遅延運営。ただし観察期間あり全資金、二日後可能。二日で対応

三点を覚えます。

第一に、権限は約束でなくコードです。 資金を動かさないとサイトに書いても意味はありません。関数がないか、二日待つかで表します。読む人はサイトでなくコントラクトを読みます。

第二に、権限の粒度が事故の規模を決めます。 停止と出金を同じ 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;
    }
}

構文を一行ずつ読むより、四つの観点で読みます。

  1. 入口リスト:deposit、withdraw、setDepositsPaused、transferOwner。四つの攻撃面です。公開関数を増やすたび、誰でも呼ぶと何が起きるか問います。
  2. 権限の表現:二関数だけが onlyOwner で、どちらも balanceOf に触れません。これが弁だけを閉じる設計です。
  3. 変更順:withdraw で記帳とイベントを先にし、送金は最後です。スタイルの問題ではありません。
  4. 可観測性:各変更に索引可能な重要フィールド付きのイベントがあり、四種から全員の残高を再構築できます。自分で設計した能力です。

mapping はハッシュ表に似ても走査できません。全預金者の一覧は取れず、別配列が必要で、費用と攻撃面を増やします。バックエンドの直感が通用しない典型です。

何と呼ぶか

State Variable状態変数

コントラクト層で宣言して保存し、取引後も残る全記憶です。

public は同名の getter を自動生成します。ただし便利な入口でしかなく、付けなくても誰でも読めます。保存領域は公開です。

Mappingマッピング

key から value への保存構造です。長さも走査も存在判定もなく、未書き込み key は型のゼロ値を返します。

最も一般的な記帳構造で、バックエンドの Map と同じだと誤解しやすいものです。

Function Visibility関数の可視性

external と public は誰でも呼べる入口を作り、internal と private は作りません。

private は入口なしという意味で、秘密ではありません。 保存データは誰でも読めます。

Modifier修飾子

関数シグネチャに付ける再利用可能な事前検査です。_; の位置へ元の関数本体が入ります。

シグネチャの行に権限制約が現れるため、レビューで権限付き関数を一目で探せます。

Eventイベント

実行時に取引レシートへ書く構造化記録で、オフチェーンが読みます。

コントラクト自身は読めず、発行しなければ存在しません。indexed は効率よく絞り込め、最大三フィールドです。何を再構築するか設計します。

Revert巻き戻し

実行を止め、取引内の状態変化をすべて取り消しますが、Gas は払います。

唯一の拒否方法です。カスタム error は文字列より安く、呼び出し側で型別に処理できます。

実践

実践最小の入出金コントラクトを作り、テストネットへ配備してソースを検証するSolidity 開発フレームワーク + エクスプローラー0 円、すべてテストネット。フォーセットで取得

複雑さよりも、コードがアドレスになる全過程と公開後の姿を見ることが目的です。

Vault を自分で入力します。 貼り付けず入力し、コンパイラの訂正から学びます。

四経路をローカルで試します。正常入金、正常出金、残高超過の拒否、別アドレスの setDepositsPaused 拒否です。

後二つが重要です。品質の上限は拒否能力で決まります。T12 で方法にします。

テストネットへ配備します。 通貨を取得し、アドレス、配備取引ハッシュ、作成ブロック高を記録します。

実資産を持つ秘密鍵を使いません。 練習専用のウォレットを作り、後で捨てます。

ソースを検証します。 ソース、コンパイラ版、最適化設定を提出し、バイトコードと照合します。

完了後は読み書きの画面が自動生成されます。自分が作っていないフロントエンドを誰でも使えるようになります。入口が攻撃面という実演です。

その画面で呼びます。 入出金後、別ウォレットで setDepositsPaused を呼び、失敗とエラーの形を見ます。

保存領域を読みます。 RPC の eth_getStorageAt でスロット 0 を読めば owner です。

address private secretAdmin; を追加して再配備し、読みます。これも公開保存され誰でも読めます。一度試せば private を秘密と考えなくなります。

イベントを取得します。 eth_getLogs で絞り、Deposited と Withdrawn だけから各残高を再構築し、balanceOf と比較します。

一致なら設計は完全、不一致なら変更の記録が抜けています。メインネット配備前の今なら直せます。

AI Lab

AI LabAI に NatSpec を書かせ、誤った権限説明を見つけるLevel 2 · AI Copilot

作った Vault をそのまま渡し、二段階で聞きます。

第一段階:
NatSpec を補ってください。各関数で処理、呼べる人、巻き戻し条件、
状態変更、イベントを明記し、ロジックを変更しないでください。

第二段階:
監査者として、見知らぬアドレスが任意の引数と回数で各関数を呼ぶと、
最悪何が起きるか答えてください。実際のコードだけを扱い、改善案は不要です。

専門的な見た目が危険です。モデルはコードを読むより、文書らしい形を補完します。 onlyOwner のない関数を所有者専用と書けば、監査者や未来の自分まで誤解させます。

setDepositsPaused の onlyOwner だけを外して再度書かせ、気づくか試します。

第二段階は価値があります。攻撃者の視点で入口と最悪結果の表を作ります。自分も書けるべきで、書けない関数は存在理由を整理できていません。

AI の回答後、自分で必ず検証すること

  • 各権限説明を本体と比較する。owner 専用なら実際に検査がある
  • 検査のない関数を保護済みと説明していない
  • コードにない前提や制約を作っていない
  • 存在しないライブラリ、インターフェース、EIP を引用しない
  • return と実際の返却、引数説明と順序が一致する
  • コメント追加後にコンパイルできる。ただし説明の正しさは別に検証する

実例

private を秘密と考える

未公開リスト、抽選シード、未発効パラメータを private に保存して隠せたと思ったプロジェクトが複数あります。

任意スロットは RPC で読め、先にシードを知って当選したり、リストを知って二次市場で使ったりできます。

コントラクトに秘密はありません。 ハッシュを保存し、原文は公開時に置きます。

誰でも初期化を呼べる2017 年、あるマルチシグのライブラリ

共通ライブラリの所有者設定関数に、一度限りや特定人という制約がありませんでした。

呼んだ人が所有者となって破壊し、依存ウォレットが一斉に使えなくなり、資金が永久に凍結しました。

公開入口は誰でも呼ぶと仮定します。ライブラリ用途は保護ではありません。破壊が他を止める理由は、T8 の delegatecall で学びます。

権限移転の条件を逆にする

条件が逆、またはゼロアドレスを検証せず owner にした例があります。

管理権限が永久に失われ、停止、更新、設定変更ができなくなります。盗難はなくても製品が死にます。

通常のテストでは完全に見逃します。ゼロアドレスへ移し壊れると確認するケースを書かないためです。新 owner の受諾を要求する二段階移転が多くを防ぎます。

イベントを忘れ、照合が永遠に合わない

更新で資金経路を追加してもイベントを付けませんでした。

イベントで残高を再構築するオフチェーン記帳には変化が見えず、三週間後の財務照合まで全帳票が誤っていました。

イベントは外部への唯一の能動的通路です。 変更ごとに、外部がどう知るか問います。

変数を一つ変える

もしwithdraw の external を internal にする

入口から消え、外部から起動できず、ユーザーは出金できません。

可視性は外部に約束する能力の定義です。レビューでは external と public の一覧を最初に作ります。それが全約束で全攻撃面です。

もしmapping を配列にして全預金者を走査する

走査と二つの問題を得ます。

ユーザー増加で Gas 上限を超え、関数が永久に実行不能になります。また一万アドレスから少額を預ける攻撃で配列を膨らませ、重要機能を止められます。

走査が必要なら設計を見直し、オフチェーンで走査してチェーンは記帳だけにするのが一般的です。

もしすべてのイベントを削除する

機能と状態変化は正常でも、通知、帳票、照合、監視が見えなくなります。

T1 のEvent は自発的に発行するものという意味です。発行側に立つと、イベントのないコントラクトに索引器の担当者が困る理由が分かります。

もし更新可能にする

コードと保存を分け、ユーザーのアドレスは保存と転送だけを担当し、別の交換可能なアドレスにロジックを置きます。

修正能力と、ロジックを変える人が全規則を変える最高権限を得ます。不変のリスクと変更可能のリスクは別物で、一方が他方を消すわけではありません。

技術基盤は T8 の delegatecall、リスクは T28 です。

持ち帰る問い

1
どんな問題を解決するか?

なぜチェーンが必要かを先に問います。独立検証と発行者も変更できない性質が不要なら、サーバーのほうが安く速く修正可能です。コントラクトにされたものの多くは、そうする必要がありません。

9
誰がリスクを負うか?

四案は鍵漏洩の損失負担を決め、ほぼ常にユーザーが負います。権限設計はユーザーの代わりにするリスク配分で、公開し検証可能にします。

16
エージェントにはどんな権限があるか?

owner を Agent に置き換えても、範囲、金額、遅延付きリストという構造は同じです。権限を約束でなくコードにする習慣が、T32 の基礎です。

章末チェック

要点

コントラクトは永続的な保存領域を持つ公開コードだ。誰でも呼び出せるため、入口はすべて攻撃面になる。

この章の実践課題を終えたらチェックしてください進捗をすべて見る

このページの内容