クラウドファンディング

カテゴリ
DeFi
チェーン
Ethereum
タグ
SolidityFoundryスマートコントラクト

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

前提

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 = truetransfer の前に設定しているのは、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] = 0transfer の前に設定している。同じく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_mineanvilで1ブロック採掘