Day4:Solidity基礎(型・可視性・イベント・エラー・支払い)

← 目次 前: Day3 次: Day5

学習目的

  • 主要構文(型・可視性・関数修飾子)とエラーハンドリングを理解し、簡単に説明できるようになる。
  • 送金を伴う payable 処理とイベントログを実装し、テストで検証できるようになる。

まず docs/curriculum/index.md の「共通の前提」を確認してから進める。


0. 前提

  • Day3 までの環境構築が完了している(npm ci / .env
  • Sepolia にデプロイする場合は、SEPOLIA_RPC_URLPRIVATE_KEY を設定し、少額のテストETHを入れておく
  • 先に読む付録:docs/appendix/glossary.md(用語に迷ったとき)
  • 触るファイル(主なもの):contracts/WalletBox.sol / test/walletbox.ts / scripts/deploy-walletbox.ts
  • 今回触らないこと:複雑な権限設計(AccessControl)やアップグレード(Proxy)
  • 最短手順(迷ったらここ):2.1 の WalletBox.sol を追加 → 2.2 のテストを追加 → npm test で確認

1. 理論解説(教科書)

1.1 代表的な型

  • 値型:uint256 / int256 / bool / address / bytesN
  • 参照型:string / bytes / array / mapping / struct
  • storage(永続)とmemory(一時)とcalldata(読み取り専用、外部入力)

1.2 可視性と関数属性

  • 可視性:public / external / internal / private
  • 関数属性:view / pure / payable
  • エラー:require(condition, message), revert CustomError(args), assert()
  • イベント:event Name(type indexed a, type b) → ログで検索容易

1.3 受領関数

  • receive():ETH受領専用。payable必須。データ無しの送金時に呼ばれる。
  • fallback():未定義関数呼び出し時などに実行。

1.4 アクセス制御(最小)

  • オーナーのみ許可:onlyOwner(自作 or OpenZeppelin Ownable
  • Checks–Effects–Interactions(CEI)原則を守る。

2. ハンズオン(即実行)

2.1 コントラクト実装

この章の WalletBox は「状態を持つ」「イベントを出す」「ETHを受け取る」「権限付きで引き出す」を最小でまとめた練習用コントラクトだ。

2.1.1 概念(何を作るか)

  • owner:デプロイ時に固定する(immutable)。引き出し権限の判定に使う。
  • note:文字列メモ。空文字はエラーにする(EmptyMessage)。
  • イベント:状態変更や入出金をログとして残す(NoteChanged / Deposited / Withdrawn)。
  • receive():ETHを受け取ったときにイベントを出す。
  • withdraw():オーナーだけが引き出せる。送金は call で行い、失敗時は revert する。

2.1.2 最小コード(contracts/WalletBox.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

error NotOwner();
error EmptyMessage();

contract WalletBox {
    // ---- 状態 ----
    address public immutable owner;           // デプロイ時に固定
    string  private _note;                    // メモ

    event NoteChanged(address indexed caller, string note);
    event Deposited(address indexed from, uint256 amount);
    event Withdrawn(address indexed to, uint256 amount);

    constructor(string memory initNote) {
        owner = msg.sender;
        _note = initNote;
    }

    // ---- 読み取り ----
    function note() external view returns (string memory) { return _note; }
    function balance() public view returns (uint256) { return address(this).balance; }

    // ---- 変更 ----
    function setNote(string calldata newNote) external {
        if (bytes(newNote).length == 0) revert EmptyMessage();
        _note = newNote;
        emit NoteChanged(msg.sender, newNote);
    }

    // ---- ETH受領 ----
    receive() external payable {
        emit Deposited(msg.sender, msg.value);
    }

    // ---- 引出(オーナーのみ) ----
    function withdraw(address payable to, uint256 amount) external {
        if (msg.sender != owner) revert NotOwner();
        require(amount <= address(this).balance, "insufficient");
        // CEI: 先に状態更新は不要。残高は参照のみ。
        (bool ok, ) = to.call{value: amount}("");
        require(ok, "send failed");
        emit Withdrawn(to, amount);
    }
}

2.1.3 結果の見方(ここが確認できればOK)

  • note() を呼ぶとデプロイ時の文字列が返る。
  • setNote("ok")NoteChanged イベントが出る(空文字は EmptyMessage で revert)。
  • コントラクトにETHを送ると Deposited イベントが出る。
  • withdraw() はオーナー以外だと NotOwner で revert する。

2.1.4 よくある失敗

  • 送金額が残高より大きく、insufficient で revert する。
  • オーナー以外で withdraw() して NotOwner になる(意図通り)。
  • 0 ETH を送って「何も起きない」と感じる(Depositedamount は 0 になる)。

2.2 テスト(Hardhat / TypeScript)

2.2.1 何をテストするか

  • デプロイ直後の状態(owner / note)が正しいこと
  • setNote の revert 条件とイベント
  • ETHの入金と、オーナーのみ引き出せること

2.2.2 最小コード(test/walletbox.ts

import { expect } from "chai";
import { ethers } from "hardhat";

describe("WalletBox", () => {
  it("deploys with owner and note", async () => {
    const [owner] = await ethers.getSigners();
    const F = await ethers.getContractFactory("WalletBox");
    const c = await F.deploy("init");
    await c.waitForDeployment();
    expect(await c.note()).to.eq("init");
    expect(await c.owner()).to.eq(owner.address);
  });

  it("reverts on empty note and emits on change", async ()=>{
    const [owner, alice] = await ethers.getSigners();
    const c = await (await ethers.getContractFactory("WalletBox")).deploy("n");
    await c.waitForDeployment();
    await expect(c.setNote("")).to.be.revertedWithCustomError(c, "EmptyMessage");
    await expect(c.connect(alice).setNote("ok")).to.emit(c, "NoteChanged");
  });

  it("accepts ether via receive and allows owner withdraw", async ()=>{
    const [owner, alice] = await ethers.getSigners();
    const c = await (await ethers.getContractFactory("WalletBox")).deploy("n");
    await c.waitForDeployment();

    // deposit 0.1 ETH
    const addr = await c.getAddress();
    await owner.sendTransaction({ to: addr, value: ethers.parseEther("0.1") });
    expect(await c.balance()).to.eq(ethers.parseEther("0.1"));

    // non-owner cannot withdraw
    await expect(c.connect(alice).withdraw(alice.address, 1)).to.be.revertedWithCustomError(c, "NotOwner");

    // owner withdraws
    await expect(c.withdraw(owner.address, ethers.parseEther("0.05"))).to.emit(c, "Withdrawn");
  });
});

2.2.3 実行

npx hardhat test

成功の判定:WalletBox のテストが passing になっていればOK(数字は追加テストで増減する)。

2.3 デプロイ(Sepolia)

scripts/deploy-walletbox.ts

import { ethers } from "hardhat";
async function main(){
  const F = await ethers.getContractFactory("WalletBox");
  const c = await F.deploy("hello");
  await c.waitForDeployment();
  console.log("WalletBox:", await c.getAddress());
}
main().catch(e=>{console.error(e);process.exit(1)});
npx hardhat run scripts/deploy-walletbox.ts --network sepolia

2.4 送金とイベント確認

# 0.01 ETH送金(任意)
# - ウォレット(MetaMask 等)で、`<DEPLOYED_ADDR>` 宛に 0.01 ETH を送る
# - CLIで送る場合は、次のように Hardhat 経由で Tx を送る
TO=<DEPLOYED_ADDR> VALUE_ETH=0.01 npx hardhat run scripts/measure-fee.ts --network sepolia

Etherscan(Sepolia)で Deposited/Withdrawn イベントを確認する。


3. 追加課題

  • fallback()を実装し未定義データ到着時の挙動をログ化。
  • OpenZeppelin Ownable版を別ブランチで作成し、onlyOwnerNotOwnerの使い分けを比較。
  • custom errorsrequire(message) のgas差をhardhat-gas-reporterで計測。

4. つまずきポイント

| 症状 | 原因 | 対処 | |—|—|—| | execution reverted / reverted with custom error | 前提条件を満たしていない(空文字、権限、残高など) | テストの期待値(revertedWithCustomError/イベント)を手がかりに、どの条件で落ちたか切り分ける | | 送金が失敗する | コントラクト残高不足 / 送金額が大きい | balance()amount を確認し、まずは少額で試す | | Sepoliaへデプロイできない | .env 未設定 / 手数料不足 | SEPOLIA_RPC_URL / PRIVATE_KEY と残高を確認する |


5. まとめ

  • WalletBox を題材に、状態・エラー・イベント・ETH受領/引出の最小構成を実装した。
  • Hardhatテストで「revertする条件」「イベント」「ETHの入出金」を検証する流れを確認した。
  • テストネットにデプロイし、TxHash/イベントをエクスプローラで追跡できる状態にした。

理解チェック(3問)

  • Q1. revert とイベント(event)の役割の違いを、1〜2文で説明してみる。
  • Q2. custom error を使うと何が嬉しいか?(require との比較で)
  • Q3. withdraw のような関数で、最低限チェックしたい前提条件を2つ挙げる。

解答例(短く)

  • A1. revert は処理を失敗として巻き戻し、状態を更新しない。イベントは状態とは別にログとして残り、オフチェーンで追跡しやすくする。
  • A2. 失敗理由を型として表現でき、文字列よりガスを抑えやすい。テストでも「どのエラーで落ちたか」を明確にできる。
  • A3. 例:呼び出し元がownerか、引き出し額が残高以下か(空文字禁止などの入力チェックも同様に重要)。

確認コマンド(最小)

npx hardhat test test/walletbox.ts

# 任意(テストネット:要 .env)
npx hardhat run scripts/deploy-walletbox.ts --network sepolia

6. 提出物

  • テスト出力スクリーンショット(3 passed など)
  • デプロイアドレス、Txハッシュ、イベントのキャプチャ
  • custom errors vs require の計測結果(簡潔な表)

7. 実行例