群眾募資改變了專案、公益事業和企業籌集資金的方式,讓創作者能夠直接與支持者建立連結,繞過傳統金融機構。區塊鏈技術透過加入透明度、去中心化與不可竄改性來強化這個模式,為創作者和貢獻者打造更公平的系統。
本文將引導您建立一個基於 Solidity 的群眾募資智能合約,專為 EVM 相容的區塊鏈(如 Linea)所設計。此合約將允許使用者建立募資活動、安全地投入資金、在成功達成目標後提領資金,以及在活動未達標時申請退款。透過智能合約自動化,平台消除了中間人並強制落實問責機制。
透過深入探索合約的實作、功能與特性,開發者將獲得建立去中心化群眾募資平台的寶貴見解。無論您是區塊鏈愛好者、開發者還是創業者,本指南將示範智能合約如何透過更安全、更高效且無需信任的方式,徹底革新群眾募資。
前置條件
您應具備 Solidity 的基本知識。
您的電腦應已安裝 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。goal 代表使用者希望籌集的目標金額,而 duration 則指定達成目標所分配的時間(以秒為單位)。
此函式確保 goal 和 duration 均大於零,以防止無效輸入。活動詳細資訊隨後儲存在 Campaign struct 中,並相應地指定 campaignId。
campaignCount 會遞增,以確保新建立的活動具有唯一的 campaignId,且不會與先前的活動重疊。
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,作為每個活動的唯一識別碼,並決定貢獻資金的分配去向。
此函式確保使用者不會貢獻零金額。它追蹤活動的 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,作為每個活動的唯一識別碼。
只有活動創建者才能提領資金。若其他使用者嘗試提領,函式將拋出錯誤。
此函式確保募資目標已達成,且資金尚未被提領。若目標未達成或資金已被提領,函式將拋出錯誤。
成功提領後,函式會將 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,用於唯一識別每個活動。
它會驗證活動是否未達成募資目標,以及使用者是否已進行貢獻。若兩個條件均成立,函式會將使用者的貢獻重置為零並處理退款。
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 上追蹤我們,掌握最新資訊。