Solidityでクラウドファンディングのコントラクトを書き、ローカルで動かしてみる。
目標金額に達したらクリエイターが引き出し、未達なら支援者が返金を受けられる、という仕組みを作る。
全体の流れ
sequenceDiagram
actor Creator as クリエイター
participant CF as Crowdfunding Contract
actor Supporter as 支援者
participant Chain as Blockchain
Note over Creator, Chain: デプロイ
Creator->>Chain: forge create Crowdfunding
Chain-->>Creator: コントラクトアドレス
Note over Creator, Chain: プロジェクト作成
Creator->>CF: createProject("My Project", 1 ETH, 7日)
CF->>CF: Project構造体を保存
CF-->>Creator: ProjectCreated イベント
Note over Creator, Chain: 支援
Supporter->>CF: fund(0) + 1 ETH送金
CF->>CF: totalFunded += msg.value
CF->>CF: contributions[0][支援者] += msg.value
CF-->>Supporter: Funded イベント
Note over Creator, Chain: 期限到来(7日経過)
Chain->>Chain: evm_increaseTime(604800)
alt 目標達成(totalFunded >= goal)
Creator->>CF: claim(0)
CF->>CF: require(目標達成 & 未引出)
CF->>CF: claimed = true
CF->>Creator: transfer(totalFunded)
CF-->>Creator: Claimed イベント
else 目標未達(totalFunded < goal)
Supporter->>CF: refund(0)
CF->>CF: require(目標未達 & 支援額 > 0)
CF->>CF: contributions[0][支援者] = 0
CF->>Supporter: transfer(支援額)
CF-->>Supporter: Refunded イベント
end
前提
- Foundry(forge, cast, anvil)がインストール済みであること
- ローカルで anvil が起動していること
anvil --mnemonic "test test test test test test test test test test test junk"
--mnemonic を指定することで、起動するたびに同じアカウントが生成される。この記事では以下を使う。
| 役割 | アドレス | 秘密鍵 |
|---|---|---|
| クリエイター | 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266(Account 0) | 0xac09...ff80 |
| 支援者 | 0x70997970C51812dc3A010C7d01b50e0d17dc79C8(Account 1) | 0x59c6...5de4 |
コントラクト全体
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Crowdfunding {
struct Project {
address payable creator;
string title;
uint256 goal;
uint256 deadline;
uint256 totalFunded;
bool claimed;
}
uint256 public nextProjectId;
mapping(uint256 => Project) public projects;
mapping(uint256 => mapping(address => uint256)) public contributions;
event ProjectCreated(uint256 indexed id, address creator, uint256 goal, uint256 deadline);
event Funded(uint256 indexed id, address supporter, uint256 amount);
event Claimed(uint256 indexed id, uint256 amount);
event Refunded(uint256 indexed id, address supporter, uint256 amount);
Project 構造体にプロジェクトの情報をまとめている。contributions は二重mappingで、どの支援者がどのプロジェクトにいくら支援したかを記録する。
プロジェクトの作成
function createProject(string calldata title, uint256 goal, uint256 durationDays) external {
uint256 id = nextProjectId++;
projects[id] = Project({
creator: payable(msg.sender),
title: title,
goal: goal,
deadline: block.timestamp + durationDays * 1 days,
totalFunded: 0,
claimed: false
});
emit ProjectCreated(id, msg.sender, goal, block.timestamp + durationDays * 1 days);
}
block.timestamp + durationDays * 1 days で期限を設定する。1 days はSolidityの組み込み単位で 86400(秒)に展開される。
支援する
function fund(uint256 id) external payable {
Project storage p = projects[id];
require(block.timestamp < p.deadline, "Project ended");
require(msg.value > 0, "Must send ETH");
p.totalFunded += msg.value;
contributions[id][msg.sender] += msg.value;
emit Funded(id, msg.sender, msg.value);
}
payable をつけることでETHを受け取れる関数になる。msg.value が送金額。require で期限切れと0送金を弾いている。
目標達成時の引き出し
function claim(uint256 id) external {
Project storage p = projects[id];
require(msg.sender == p.creator, "Not creator");
require(block.timestamp >= p.deadline, "Not ended yet");
require(p.totalFunded >= p.goal, "Goal not reached");
require(!p.claimed, "Already claimed");
p.claimed = true;
uint256 amount = p.totalFunded;
p.creator.transfer(amount);
emit Claimed(id, amount);
}
4つの require で条件を守っている。
- クリエイター本人であること
- 期限が過ぎていること
- 目標金額に達していること
- まだ引き出していないこと
p.claimed = true を transfer の前に設定しているのは、Reentrancy(再入攻撃)対策。状態変更を先にしてから外部送金する、というパターンは Checks-Effects-Interactions と呼ばれる。
目標未達時の返金
function refund(uint256 id) external {
Project storage p = projects[id];
require(block.timestamp >= p.deadline, "Not ended yet");
require(p.totalFunded < p.goal, "Goal was reached");
uint256 amount = contributions[id][msg.sender];
require(amount > 0, "Nothing to refund");
contributions[id][msg.sender] = 0;
payable(msg.sender).transfer(amount);
emit Refunded(id, msg.sender, amount);
}
}
ここでも contributions[id][msg.sender] = 0 を transfer の前に設定している。同じくReentrancy対策。
デプロイと実行
1. プロジェクトをデプロイ
forge create src/Crowdfunding.sol:Crowdfunding \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcast
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
以降、このアドレスを $CF として使う。
CF=0x5FbDB2315678afecb367f032d93F642f64180aa3
2. プロジェクトを作成(クリエイター)
目標 1 ETH、期限 7日のプロジェクトを作る。
cast send $CF \
"createProject(string,uint256,uint256)" \
"My Project" 1000000000000000000 7 \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
プロジェクトID 0 が作成される。確認する。
cast call $CF "projects(uint256)" 0 --rpc-url http://localhost:8545
3. 支援する(支援者)
支援者(Account 1)が 1 ETH を送る。
cast send $CF \
"fund(uint256)" 0 \
--value 1ether \
--rpc-url http://localhost:8545 \
--private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
支援額を確認する。
cast call $CF \
"contributions(uint256,address)" \
0 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
--rpc-url http://localhost:8545
0x0000000000000000000000000000000000000000000000000de0b6b3a7640000
0xde0b6b3a7640000 = 10^18 wei = 1 ETH。
4. 時間を進める
anvil では evm_increaseTime で時間を進められる。7日 = 604800秒。
cast rpc evm_increaseTime 604800 --rpc-url http://localhost:8545
cast rpc evm_mine --rpc-url http://localhost:8545
5-A. 目標達成 → 引き出し(クリエイター)
cast send $CF \
"claim(uint256)" 0 \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
クリエイターの残高を確認する。
cast balance 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --rpc-url http://localhost:8545
5-B. 目標未達 → 返金(支援者)
もし目標金額に達していなかった場合は、支援者が返金を受けられる。
cast send $CF \
"refund(uint256)" 0 \
--rpc-url http://localhost:8545 \
--private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
イベントログの確認
cast logs でイベントを確認できる。
cast logs --from-block 0 --address $CF --rpc-url http://localhost:8545
Funded イベントのトピック(keccak256)でフィルタする場合:
cast logs \
--from-block 0 \
--address $CF \
"Funded(uint256,address,uint256)" \
--rpc-url http://localhost:8545
コントラクトの残高確認
コントラクト自体がETHを保持しているので、残高を確認できる。
cast balance $CF --rpc-url http://localhost:8545
使ったコマンド一覧
| コマンド | 説明 |
|---|---|
anvil | ローカルEthereumノードを起動 |
forge create <contract> | コントラクトをデプロイ |
cast send <to> <sig> <args> | トランザクションを送信(状態変更) |
cast call <to> <sig> <args> | 読み取り専用の呼び出し |
cast balance <address> | ETH残高を取得 |
cast logs | イベントログを取得 |
cast rpc evm_increaseTime <sec> | anvilの時間を進める |
cast rpc evm_mine | anvilで1ブロック採掘 |