Day4:Solidity基礎(型・可視性・イベント・エラー・支払い)
| ← 目次 | 前: Day3 | 次: Day5 |
学習目的
- 主要構文(型・可視性・関数修飾子)とエラーハンドリングを理解し、簡単に説明できるようになる。
- 送金を伴う
payable処理とイベントログを実装し、テストで検証できるようになる。
まず
docs/curriculum/index.mdの「共通の前提」を確認してから進める。
0. 前提
- Day3 までの環境構築が完了している(
npm ci/.env) - Sepolia にデプロイする場合は、
SEPOLIA_RPC_URLとPRIVATE_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 OpenZeppelinOwnable) - 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 を送って「何も起きない」と感じる(
Depositedのamountは 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版を別ブランチで作成し、onlyOwnerとNotOwnerの使い分けを比較。 custom errorsとrequire(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 errorsvsrequireの計測結果(簡潔な表)
7. 実行例
- 実行ログ例:
docs/reports/Day04.md