مشروع شامل (الجزء 1): بناء منصة تمويل جماعي (Crowdfunding) لامركزية – العقد الذكي
مشروع شامل (الجزء 1): بناء منصة تمويل جماعي لامركزية – العقد الذكي
في هذا الجزء سنبني القلب الحقيقي لمنصة Crowdfunding لامركزية: العقد الذكي المسؤول عن إنشاء الحملات، استقبال المساهمات، تتبع الأهداف المالية، وتمكين صاحب الحملة من السحب عند النجاح أو إعادة الأموال عند الفشل. هذا النوع من المشاريع يجمع بين عدة مفاهيم أساسية في مدخل إلى Web3: ما هو البلوكتشين ولماذا يغير شكل الإنترنت والأنظمة المالية؟، ويحوّلها إلى نموذج عملي قابل للبناء والتوسع.
سنستخدم Solidity لتصميم منطق آمن ومرن، مع الاعتماد على struct و mapping و events لتخزين البيانات والتواصل مع الواجهة. إذا كنت قد قرأت سابقاً الهياكل (Structs): تصميم أنواع بيانات مخصصة والقواميس (Mappings): أسرع طريقة لربط عناوين المحافظ بأرصدتها والأحداث (Events): كيف يخبر العقد الذكي واجهة الموقع، فسترى هنا كيف تندمج هذه المفاهيم داخل مشروع حقيقي.
فكرة المنصة ومنطق العمل
المنصة تسمح لأي مستخدم بإنشاء حملة تمويل مع عنوان وصفي، هدف مالي، ومدة زمنية. يرسل الداعمون عملة Ether إلى العقد، فيقوم العقد بتسجيل مساهماتهم بدقة داخل البلوكتشين.
عند انتهاء المدة، توجد حالتان فقط:
- إذا وصل مجموع التمويل إلى الهدف، يمكن لصاحب الحملة تنفيذ السحب.
- إذا انتهت الحملة دون بلوغ الهدف، يستطيع كل مساهم استرداد حصته بنفسه.
هذا المنطق يحقق مبدأ trustless execution لأن القواعد لا تُدار من خادم مركزي، بل من كود منشور على EVM.
تصميم البيانات داخل العقد الذكي
نحتاج أولاً إلى كيان يمثل الحملة الواحدة. سنعرّف بنية بيانات عبر struct تحتوي على المالك، الوصف، الهدف، الرصيد الحالي، وقت النهاية، وحالة السحب.
ثم سنستخدم عدّاداً لإعطاء كل حملة معرفاً رقمياً فريداً، مع mapping لتخزين الحملات، وnested mapping لتسجيل قيمة مساهمة كل عنوان داخل كل حملة.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract DecentralizedCrowdfunding {
struct Campaign {
address payable creator;
string title;
string description;
uint256 goal;
uint256 pledged;
uint256 deadline;
bool claimed;
}
uint256 public campaignCount;
mapping(uint256 => Campaign) public campaigns;
mapping(uint256 => mapping(address => uint256)) public contributions;
event CampaignCreated(
uint256 indexed campaignId,
address indexed creator,
uint256 goal,
uint256 deadline
);
event ContributionMade(
uint256 indexed campaignId,
address indexed contributor,
uint256 amount
);
event FundsClaimed(
uint256 indexed campaignId,
address indexed creator,
uint256 amount
);
event RefundClaimed(
uint256 indexed campaignId,
address indexed contributor,
uint256 amount
);
modifier campaignExists(uint256 _campaignId) {
require(_campaignId < campaignCount, "Campaign does not exist");
_;
}
modifier beforeDeadline(uint256 _campaignId) {
require(block.timestamp < campaigns[_campaignId].deadline, "Campaign ended");
_;
}
modifier afterDeadline(uint256 _campaignId) {
require(block.timestamp >= campaigns[_campaignId].deadline, "Campaign still active");
_;
}
function createCampaign(
string calldata _title,
string calldata _description,
uint256 _goal,
uint256 _durationInDays
) external {
require(_goal > 0, "Goal must be greater than zero");
require(_durationInDays > 0, "Duration must be greater than zero");
uint256 deadline = block.timestamp + (_durationInDays * 1 days);
campaigns[campaignCount] = Campaign({
creator: payable(msg.sender),
title: _title,
description: _description,
goal: _goal,
pledged: 0,
deadline: deadline,
claimed: false
});
emit CampaignCreated(campaignCount, msg.sender, _goal, 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.pledged += msg.value;
contributions[_campaignId][msg.sender] += msg.value;
emit ContributionMade(_campaignId, msg.sender, msg.value);
}
function claimFunds(uint256 _campaignId)
external
campaignExists(_campaignId)
afterDeadline(_campaignId)
{
Campaign storage campaign = campaigns[_campaignId];
require(msg.sender == campaign.creator, "Only creator can claim");
require(campaign.pledged >= campaign.goal, "Funding goal not reached");
require(!campaign.claimed, "Funds already claimed");
campaign.claimed = true;
uint256 amount = campaign.pledged;
(bool success, ) = campaign.creator.call{value: amount}("");
require(success, "Transfer failed");
emit FundsClaimed(_campaignId, campaign.creator, amount);
}
function claimRefund(uint256 _campaignId)
external
campaignExists(_campaignId)
afterDeadline(_campaignId)
{
Campaign storage campaign = campaigns[_campaignId];
require(campaign.pledged < campaign.goal, "Campaign was successful");
uint256 contributedAmount = contributions[_campaignId][msg.sender];
require(contributedAmount > 0, "No contribution to refund");
contributions[_campaignId][msg.sender] = 0;
(bool success, ) = payable(msg.sender).call{value: contributedAmount}("");
require(success, "Refund failed");
emit RefundClaimed(_campaignId, msg.sender, contributedAmount);
}
function getCampaign(uint256 _campaignId)
external
view
campaignExists(_campaignId)
returns (Campaign memory)
{
return campaigns[_campaignId];
}
}
شرح دوال العقد الذكي
1) دالة إنشاء الحملة
الدالة createCampaign تستقبل عنوان الحملة، وصفها، الهدف المالي، والمدة. استخدمنا calldata في النصوص لتقليل نسخ البيانات داخل الذاكرة، وهو مفهوم ناقشناه بالتفصيل في إدارة الذاكرة بذكاء: الفرق الحاسم بين Storage, Memory, و Calldata.
بعد التحقق من المدخلات باستخدام require، ينشئ العقد حملة جديدة ويطلق حدثاً من نوع CampaignCreated. هذا الحدث سيكون مهماً لاحقاً عند ربط الواجهة عبر Ethers.js.
2) دالة المساهمة في التمويل
الدالة contribute هي دالة payable لأنها تستقبل أموالاً. إذا كنت تريد مراجعة الآلية البرمجية وراء استقبال وإرسال العملة داخل العقود، فمقال استلام وإرسال الأموال (Ether) برمجياً: فهم الدوال payable و fallback يشرح الأساس الذي بُنيت عليه هذه الجزئية.
عند نجاح العملية، يتم:
- إضافة قيمة
msg.valueإلى إجمالي الحملة. - تحديث رصيد المساهم داخل
contributions. - إطلاق حدث
ContributionMade.
3) سحب التمويل عند النجاح
الدالة claimFunds لا يمكن استدعاؤها إلا بعد انتهاء المدة، ومن طرف منشئ الحملة فقط. كما نتحقق من أن إجمالي التمويل وصل إلى الهدف، وأنه لم يتم السحب سابقاً.
استخدمنا أسلوب الإرسال بواسطة call لأنه الأكثر مرونة في الإصدارات الحديثة من Solidity، لكن يجب استخدامه بحذر شديد من منظور أمني.
عند نقل الأموال من العقد، الترتيب مهم جداً. قم أولاً بتحديث الحالة الداخلية مثل
claimed = trueقبل إجراء التحويل الخارجي. هذه الفكرة جزء أساسي من نمطChecks-Effects-Interactionsللحماية من هجمات إعادة الدخول، ويمكن التوسع فيها عبر أمن العقود الذكية (1): ثغرة إعادة الدخول ثم الحماية من ثغرة Reentrancy باستخدام ReentrancyGuard.
4) استرجاع الأموال عند فشل الحملة
الدالة claimRefund تمثل جوهر العدالة في نظام التمويل الجماعي اللامركزي. إذا انتهت الحملة دون تحقيق الهدف، يستطيع كل مساهم استرداد مساهمته بنفسه دون وسيط.
قبل إرسال المبلغ، يتم تصفير مساهمة المستخدم داخل mapping. هذا الترتيب يمنع تكرار السحب ويقلل من سطح الهجوم البرمجي. لمراجعة آليات التحقق ومعالجة الأخطاء، يمكنك العودة إلى التعامل مع الأخطاء وإرجاع الأموال: استخدام require, assert, revert.
لماذا استخدمنا modifiers وview؟
استخدام modifiers مثل campaignExists وbeforeDeadline يجعل الكود أكثر نظافة وقابلية للتدقيق. إذا كنت تريد تعميق هذا المفهوم، راجع المعدلات (Modifiers): حماية الدوال برمجياً.
أما الدالة getCampaign فهي من نوع view لأنها تقرأ البيانات فقط دون تعديل الحالة، وبالتالي لا تتسبب في رسوم عند استدعائها من الواجهة خارج المعاملات. هذه نقطة مرتبطة بما شرحناه في أنواع الدوال : فهم view و pure لتوفير رسوم الـ Gas.
اعتبارات الأمان وتحسين استهلاك الغاز
من الأفضل في النسخة الإنتاجية إضافة حماية جاهزة من مكتبة
OpenZeppelinمثلReentrancyGuard، خاصة في دوال السحب والاسترجاع. ويمكنك مراجعة استخدام مكتبة OpenZeppelin لكتابة عقود ذكية آمنة قبل نقل المشروع من بيئة تعليمية إلى بيئة فعلية.
لتقليل استهلاك
Gas Fees، احرص على تقليل عدد الكتابات علىstorage، واستخدمcalldataعند الإمكان، واجمع الشروط المنطقية فيmodifiersقابلة لإعادة الاستخدام. لفهم أثر هذه القرارات مالياً، راجع التكاليف (Gas Fees): كيف يحسب البلوكتشين تكلفة تنفيذ الأكواد؟.
كيف نختبر هذا العقد لاحقاً؟
بعد الانتهاء من كتابة العقد، تأتي المرحلة الاحترافية: الترجمة، الاختبار، ثم النشر. يمكن البدء سريعاً عبر محرر Remix IDE، لكن للمشاريع الجدية يُفضّل العمل باستخدام إطار عمل Hardhat ثم كتابة اختبارات شاملة بالاعتماد على Unit Tests باستخدام Chai & Mocha.
ويجب أن تغطي الاختبارات السيناريوهات التالية:
- إنشاء حملة بمدخلات صحيحة وخاطئة.
- منع المساهمة بعد انتهاء المهلة.
- السحب الناجح عند بلوغ الهدف.
- استرجاع الأموال عند فشل الحملة.
- منع السحب المزدوج أو الاسترداد المتكرر.
خاتمة الجزء الأول
بذلك نكون قد أنشأنا عقداً ذكياً يشكّل الأساس الفعلي لمنصة Crowdfunding DApp لامركزية: إنشاء الحملات، استقبال المساهمات، تنفيذ السحب الآمن، وتمكين الاسترجاع عند الفشل. هذا المشروع يوظف عملياً مفاهيم Structs وMappings وModifiers وEvents وعمليات تحويل Ether داخل تطبيق واحد مترابط.
في الجزء التالي سننتقل إلى طبقة التطوير العملية حول هذا العقد: إعداد المشروع داخل Hardhat، كتابة اختبارات تلقائية، ثم تجهيز خطوات النشر والربط مع الواجهة باستخدام Ethers.js وReact.
5 comments