クラウドファンディングは、クリエイターが支援者と直接つながり、従来の金融機関を介さずに資金を調達できるようにすることで、プロジェクト、活動、ビジネスの資金調達のあり方を変えてきました。ブロックチェーン技術は、透明性・分散化・不変性を加えることでこのモデルをさらに強化し、クリエイターと支援者の双方にとってより公平なシステムを実現します。
この記事では、LineaなどのEVM互換ブロックチェーン向けに設計された、Solidityベースのクラウドファンディングスマートコントラクトの構築方法を解説します。このコントラクトにより、ユーザーはキャンペーンの作成、安全な資金の拠出、目標達成時の資金引き出し、キャンペーンが目標未達の場合の返金申請が可能になります。スマートコントラクトの自動化を活用することで、プラットフォームは仲介者を排除し、説明責任を担保します。
コントラクトの実装・機能・特徴を詳しく見ていくことで、開発者は分散型クラウドファンディングプラットフォームの構築に関する貴重な知見を得られます。ブロックチェーン愛好家、開発者、起業家を問わず、このガイドはスマートコントラクトがクラウドファンディングをより安全・効率的・トラストレスにすることで、いかに革新できるかを示します。
前提条件
Solidity の基本的な理解が必要です。
PC に Nodejs と Foundry がインストールされている必要があります。
コントラクトの概要
このコントラクトで実現できること:
キャンペーン作成: ユーザーが目標金額と期間を設定して、クラウドファンディングキャンペーンを開始できます。
拠出: ユーザーがアクティブなキャンペーンに資金を拠出できます。
資金引き出し: キャンペーン作成者がキャンペーン期限後に目標金額を達成した場合、資金を引き出せます。
返金: 期限内に目標が達成されなかった場合、支援者は拠出金を取り戻せます。
プロジェクトのセットアップ
以下のコマンドを実行して Foundry プロジェクトを開始します。スマートコントラクトの構築には Foundry フレームワークを使用します。
forge init crowdfunding
VS Code またはお好みのコードエディタで crowdfunding フォルダを開き、scripts/counter.s.sol、src/counter.sol、test/counter.t.sol を削除してください。
すべての依存関係をインストール
forge install foundry-rs/forge-std --no-commit
クラウドファンディングスマートコントラクトの全コード
src フォルダに crowdfunding.sol ファイルを作成し、以下のコードを追加してください。
pragma solidity 0.8.18;
contract Crowdfunding {
struct Campaign {
address creator;
uint256 goal;
uint256 deadline;
uint256 amountRaised;
bool isWithdrawn;
mapping(address => uint256) contributions;
}
uint256 public campaignCount = 0;
mapping(uint256 => Campaign) public campaigns;
event CampaignCreated(uint256 campaignId, address creator, uint256 goal, uint256 deadline);
event ContributionMade(uint256 campaignId, address contributor, uint256 amount);
event FundsWithdrawn(uint256 campaignId, uint256 amount);
event RefundIssued(uint256 campaignId, address contributor, uint256 amount);
modifier campaignExists(uint256 campaignId) {
require(campaignId < campaignCount, "Campaign does not exist");
_;
}
modifier onlyCreator(uint256 campaignId) {
require(msg.sender == campaigns[campaignId].creator, "Only the campaign creator can perform this action");
_;
}
modifier beforeDeadline(uint256 campaignId) {
require(block.timestamp <= campaigns[campaignId].deadline, "Campaign deadline has passed");
_;
}
modifier afterDeadline(uint256 campaignId) {
require(block.timestamp > campaigns[campaignId].deadline, "Campaign deadline has not passed yet");
_;
}
function createCampaign(uint256 goal, uint256 duration) external {
require(goal > 0, "Goal must be greater than zero");
require(duration > 0, "Duration must be greater than zero");
Campaign storage newCampaign = campaigns[campaignCount];
newCampaign.creator = msg.sender;
newCampaign.goal = goal;
newCampaign.deadline = block.timestamp + duration;
emit CampaignCreated(campaignCount, msg.sender, goal, newCampaign.deadline);
campaignCount++;
}
function contribute(uint256 campaignId) external payable campaignExists(campaignId) beforeDeadline(campaignId) {
require(msg.value > 0, "Contribution must be greater than zero");
Campaign storage campaign = campaigns[campaignId];
campaign.amountRaised += msg.value;
campaign.contributions[msg.sender] += msg.value;
emit ContributionMade(campaignId, msg.sender, msg.value);
}
function withdrawFunds(uint256 campaignId) external campaignExists(campaignId) onlyCreator(campaignId) afterDeadline(campaignId) {
Campaign storage campaign = campaigns[campaignId];
require(campaign.amountRaised >= campaign.goal, "Funding goal not reached");
require(!campaign.isWithdrawn, "Funds already withdrawn");
campaign.isWithdrawn = true;
(bool sent, ) = campaign.creator.call{value: campaign.amountRaised}("");
require(sent, "Failed to send Ether");
emit FundsWithdrawn(campaignId, campaign.amountRaised);
}
function requestRefund(uint256 campaignId) external campaignExists(campaignId) afterDeadline(campaignId) {
Campaign storage campaign = campaigns[campaignId];
require(campaign.amountRaised < campaign.goal, "Campaign succeeded, refunds not allowed");
uint256 contribution = campaign.contributions[msg.sender];
require(contribution > 0, "No contributions found for this campaign");
campaign.contributions[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: contribution}("");
require(sent, "Failed to send Ether");
emit RefundIssued(campaignId, msg.sender, contribution);
}
}
コードの主な機能
キャンペーン構造体 コントラクトはキャンペーンの詳細を格納するために Campaign という名前の struct を使用します。
struct Campaign {
address creator;
uint256 goal;
uint256 deadline;
uint256 amountRaised;
bool isWithdrawn;
mapping(address \=\> uint256) contributions;
}
キャンペーンカウント 各キャンペーンはインクリメントされる campaignId によって一意に識別され、スケーラビリティを確保しながら複数キャンペーンの管理を簡素化します。
uint256 public campaignCount = 0;
mapping(uint256 => Campaign) public campaigns;
キャンペーン作成
createCampaign 関数により、ユーザーはキャンペーンを開始できます。goal と duration の2つのパラメータを受け取ります。goal はユーザーが調達したい目標金額を表し、duration はその目標達成に割り当てられた時間(秒単位)を指定します。
この関数は、無効なエントリを防ぐために goal と duration の両方がゼロより大きいことを確認します。キャンペーンの詳細は Campaign 構造体に格納され、campaignId が適切に割り当てられます。
新しく作成されたキャンペーンが一意の campaignId を持ち、以前のキャンペーンと重複しないよう、campaignCount がインクリメントされます。
function createCampaign(uint256 goal, uint256 duration) external {
require(goal > 0, "Goal must be greater than zero");
require(duration > 0, "Duration must be greater than zero");
Campaign storage newCampaign = campaigns[campaignCount];
newCampaign.creator = msg.sender;
newCampaign.goal = goal;
newCampaign.deadline = block.timestamp + duration;
emit CampaignCreated(campaignCount, msg.sender, goal, newCampaign.deadline);
campaignCount++;
}
拠出メカニズム
contribute 関数により、ユーザーはアクティブなキャンペーンに資金を提供できます。各キャンペーンの一意の識別子として機能し、拠出先を決定する campaignId という1つのパラメータを受け取ります。
この関数はユーザーがゼロ金額を拠出しないことを確認します。キャンペーンの amountRaised を追跡し、各ユーザーの合計拠出額を記録します。
function contribute(uint256 campaignId) external payable campaignExists(campaignId) beforeDeadline(campaignId) {
require(msg.value > 0, "Contribution must be greater than zero");
Campaign storage campaign = campaigns[campaignId];
campaign.amountRaised += msg.value;
campaign.contributions[msg.sender] += msg.value;
emit ContributionMade(campaignId, msg.sender, msg.value);
}
contribute 関数内の campaignExists モディファイアは、campaignId が有効かどうかを検証し、キャンペーンが存在しない場合はエラーをスローします。
modifier campaignExists(uint256 campaignId) {
require(campaignId < campaignCount, "Campaign does not exist");
_;
}
beforeDeadline モディファイアはキャンペーンの期限が過ぎているかどうかを検証し、過ぎている場合はエラーをスローします。
modifier beforeDeadline(uint256 campaignId) {
require(block.timestamp <= campaigns[campaignId].deadline, "Campaign deadline has passed");
_;
}
資金の引き出し
キャンペーン作成者は withdrawFunds 関数を使用して資金を引き出せます。この関数は各キャンペーンの一意の識別子として機能する campaignId という1つのパラメータを受け取ります。
資金を引き出せるのはキャンペーン作成者のみです。他のユーザーが試みた場合、関数はエラーをスローします。
この関数は、資金調達目標が達成されていること、および資金がまだ引き出されていないことを確認します。目標が達成されていない場合、または資金がすでに引き出されている場合、関数はエラーをスローします。
引き出しが成功すると、関数は isWithdrawn ステータスを true に更新し、キャンペーン作成者に資金を送金します。
``Solidity function withdrawFunds(uint256 campaignId) external campaignExists(campaignId) onlyCreator(campaignId) afterDeadline(campaignId) { Campaign storage campaign = campaigns[campaignId]; require(campaign.amountRaised >= campaign.goal, "Funding goal not reached"); require(!campaign.isWithdrawn, "Funds already withdrawn");
campaign.isWithdrawn = true;
(bool sent, ) = campaign.creator.call{value: campaign.amountRaised}("");
require(sent, "Failed to send Ether");
emit FundsWithdrawn(campaignId, campaign.amountRaised);
}
The `onlyCreator` modifier verifies whether the caller is the campaign creator and throws an error if they are not.
``Solidity
modifier onlyCreator(uint256 campaignId) {
require(msg.sender == campaigns[campaignId].creator, "Only the campaign creator can perform this action");
_;
}
afterDeadline モディファイアはキャンペーンの期限が過ぎていることを確認し、過ぎていない場合はエラーをスローします。
modifier afterDeadline(uint256 campaignId) {
require(block.timestamp > campaigns[campaignId].deadline, "Campaign deadline has not passed yet");
_;
}
失敗したキャンペーンの返金
キャンペーンが失敗した場合、支援者は拠出金を引き出せます。この関数は各キャンペーンを一意に識別する campaignId という1つのパラメータを受け取ります。
キャンペーンが資金調達目標を達成しなかったこと、およびユーザーが拠出を行っていることを検証します。両方の条件が満たされた場合、関数はユーザーの拠出をゼロにリセットし、返金を処理します。
function requestRefund(uint256 campaignId) external campaignExists(campaignId) afterDeadline(campaignId) {
Campaign storage campaign = campaigns[campaignId];
require(campaign.amountRaised < campaign.goal, "Campaign succeeded, refunds not allowed");
uint256 contribution = campaign.contributions[msg.sender];
require(contribution > 0, "No contributions found for this campaign");
campaign.contributions[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: contribution}("");
require(sent, "Failed to send Ether");
emit RefundIssued(campaignId, msg.sender, contribution);
}
イベント
コントラクトは主要なアクションに対してイベントを発行します:
CampaignCreated:新しいキャンペーンの詳細をログに記録します。
ContributionMade:キャンペーンへの拠出をログに記録します。
FundsWithdrawn:資金の引き出し成功をログに記録します。
RefundIssued:支援者への返金をログに記録します。
event CampaignCreated(uint256 campaignId, address creator, uint256 goal, uint256 deadline);
event ContributionMade(uint256 campaignId, address contributor, uint256 amount);
event FundsWithdrawn(uint256 campaignId, uint256 amount);
event RefundIssued(uint256 campaignId, address contributor, uint256 amount);
テスト
test フォルダに crowdfunding.t.sol ファイルを作成し、以下のコードを追加してください。
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Crowdfunding} from "../src/Crowdfunding.sol";
contract CounterTest is Test {
Crowdfunding public crowd;
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
function setUp() public {
crowd = new Crowdfunding();
}
function test_createCampaign() public {
vm.startPrank(user1);
crowd.createCampaign(10 ether, 10 hours);
}
function test_contributeCampaign() public {
vm.startPrank(user1);
crowd.createCampaign(10 ether, 10 hours);
deal(user2, 10 ether);
deal(user3, 10 ether);
vm.startPrank(user2);
crowd.contribute{value: 6 ether}(0);
vm.startPrank(user3);
crowd.contribute{value: 6 ether}(0);
}
function test_withdrawCampaign() public {
vm.startPrank(user1);
crowd.createCampaign(10 ether, 10 hours);
deal(user2, 10 ether);
deal(user3, 10 ether);
vm.startPrank(user2);
crowd.contribute{value: 6 ether}(0);
vm.startPrank(user3);
crowd.contribute{value: 6 ether}(0);
skip(11 hours);
console.log("b4", user1.balance);
vm.startPrank(user1);
crowd.withdrawFunds(0);
console.log("after",user1.balance);
}
function test_requestCampaign() public {
vm.startPrank(user1);
crowd.createCampaign(10 ether, 10 hours);
deal(user2, 10 ether);
deal(user3, 10 ether);
vm.startPrank(user2);
crowd.contribute{value: 2 ether}(0);
vm.startPrank(user3);
crowd.contribute{value: 2 ether}(0);
skip(11 hours);
vm.startPrank(user2);
crowd.requestRefund(0);
vm.startPrank(user3);
crowd.requestRefund(0);
console.log("after",user2.balance);
console.log("after",user3.balance);
}
}
テストスイートは、Crowdfunding コントラクトがコア機能全体にわたって正しく動作することを確認します。以下にテストの内訳を示します:
test_createCampaign
このテストは、ユーザーがクラウドファンディングキャンペーンを正常に作成できることを検証します。キャンペーンはエラーなしで作成され、関数が期待通りに動作することを確認します。
test_contributeCampaign
このテストは、複数のユーザーが特定のキャンペーンに拠出できることを検証します。テストでは user1 がキャンペーンを作成し、user2 と user3 はそれぞれ Foundry の deal 関数を使用して 10 ether を受け取ります。user2 と user3 の両方がキャンペーンに 6 ether を拠出し、コントラクトはキャンペーンの合計調達額を正しく更新します。
test_withdrawCampaign
このテストは、キャンペーンが資金調達目標を達成し期限が過ぎた後に、キャンペーン作成者が資金を引き出せることを検証します。user2 と user3 がキャンペーンに拠出し、11時間が経過した後(skip を使用してシミュレート)、user1 は withdrawFunds を呼び出して調達した資金を引き出します。資金は正常に送金され、コントラクトの残高に引き出しが反映されます。
test_requestCampaign
このテストは、キャンペーンが資金調達目標を達成できなかった場合に、支援者が返金を申請できることを検証します。user2 と user3 がキャンペーンに拠出し、11時間が経過した後(skip を使用してシミュレート)、user2 と user3 の両方が requestRefund を呼び出します。各ユーザーは拠出した ether を正常に受け取ります。
テストの実行
forge test
Linea へのデプロイと検証
プロジェクトのルートレベルに配置された .env ファイルに必要な環境変数を追加します。これらの変数はブロックチェーンとのやり取りに必要な機密認証情報を格納します。
LINEA_RPC_URL=https://linea-mainnet.infura.io/v3/
PRIVATE_KEY=*************************************************
以下の詳細を foundry.toml に追加します。
[etherscan]
linea = { key = "${LINEA_API_KEY}", url = "https://api-sepolia.lineascan.build/api" }
Makefile を作成し、以下の詳細を追加します。
deploy:
forge create src/Crowdfunding.sol:Crowdfunding --rpc-url $(LINEA_RPC_URL) --private-key $(PRIVATE_KEY)
verify:; forge verify-contract --rpc-url $(LINEA_RPC_URL) --chain linea <contract address> src/Crowdfunding.sol:Crowdfunding
デプロイ
make deploy
検証
make verify
まとめ
このガイドでは、Linea ブロックチェーン上で分散型クラウドファンディングスマートコントラクトを構築・デプロイするための基礎を解説しました。キャンペーン作成、拠出、引き出し、返金などの主要なコントラクト機能を探り、コントラクトの動作を検証するためのテストの作成と実行についても取り上げました。最後に、コントラクトを Linea 上で公開するためのデプロイと検証のプロセスを順を追って説明しました。
ブロックチェーン開発に関するより実践的なチュートリアルや詳細な解説については、MetaMask Developer YouTube チャンネルをご覧ください。また、最新情報は Twitter でフォローしてお確かめください。