مشروع شامل (الجزء 2): كتابة اختبارات الأمان (Tests) لمنصة التمويل الجماعي

دقائق القراءة: 7

مشروع شامل (الجزء 2): كتابة اختبارات الأمان (Tests) لمنصة التمويل الجماعي

بعد الانتهاء من مشروع شامل (الجزء 1): بناء منصة تمويل جماعي (Crowdfunding) لامركزية – العقد الذكي، تأتي المرحلة الأهم في دورة التطوير الاحترافي: التحقق من صحة المنطق الأمني قبل النشر على أي شبكة عامة. في بيئة Blockchain لا يكفي أن يعمل العقد الذكي وظيفياً؛ بل يجب أن يصمد أمام السيناريوهات العدائية، وسوء الاستخدام، والحالات الطرفية التي قد تؤدي إلى خسارة الأموال.

اختبارات الأمان ليست مجرد طبقة إضافية، بل هي جزء من عملية Secure Development Lifecycle. وبما أن منصة التمويل الجماعي تتعامل مع الإيداعات، استرداد الأموال، وسحب الرصيد من قبل صاحب الحملة، فإن أي خلل في شروط التحقق أو ترتيب تحديث الحالة قبل التحويل قد يفتح الباب أمام ثغرات معروفة مثل Reentrancy أو أخطاء منطقية في الصلاحيات.

سنستخدم في هذا الجزء بيئة الانتقال إلى بيئة العمل الاحترافية: تثبيت إطار عمل Hardhat باستخدام Node.js مع أسلوب الاختبارات الذي تم تأسيسه سابقاً في اختبار العقود الذكية محلياً: كتابة اختبارات الوحدة (Unit Tests) باستخدام Chai & Mocha. الهدف هنا ليس فقط التأكد من نجاح الدوال، بل بناء عقلية تدقيق أمني تفكر في الفشل قبل النجاح.

ما الذي نختبره أمنياً في منصة التمويل الجماعي؟

في عقد Crowdfunding توجد عدة مسارات يجب اختبارها بعناية. بعضها يرتبط بالصلاحيات، وبعضها بالتحويلات المالية، وبعضها بصحة انتقال الحالة من حملة مفتوحة إلى حملة ناجحة أو فاشلة.

  • منع أي مساهمة بقيمة صفرية.
  • منع المساهمة بعد انتهاء المهلة الزمنية.
  • منع سحب الأموال من غير مالك الحملة.
  • منع السحب قبل الوصول إلى الهدف المالي.
  • السماح بالاسترداد فقط إذا فشلت الحملة.
  • ضمان تصفير رصيد المساهم قبل تنفيذ التحويل عند الاسترداد.
  • منع السحب المكرر أو الاسترداد المكرر.

القاعدة الأمنية الذهبية عند اختبار العقود الذكية: لا تختبر فقط ما يجب أن ينجح، بل اختبر أيضاً كل ما يجب أن يفشل مع سبب فشل واضح باستخدام revert ورسالة مفهومة.

تهيئة ملف الاختبار وبنية السيناريوهات

إذا كنت قد طبقت سابقاً مفاهيم الدوال (Functions) في Solidity: من يمكنه قراءة وتعديل بيانات العقد؟ والتعامل مع الأخطاء وإرجاع الأموال: استخدام require, assert, revert، فستلاحظ أن الاختبارات الأمنية هي ترجمة مباشرة لتلك الشروط إلى سيناريوهات قابلة للتنفيذ.

سنفترض أن العقد يحتوي على دوال مثل contribute وwithdrawFunds وclaimRefund. كما سنستخدم حسابات متعددة عبر signers لمحاكاة مالك الحملة، المساهمين، والمهاجم.

// test/Crowdfunding.security.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Crowdfunding Security Tests", function () {
  let crowdfunding;
  let owner, contributor1, contributor2, attacker;
  const goal = ethers.parseEther("5");
  const durationInSeconds = 7 * 24 * 60 * 60;

  beforeEach(async function () {
    [owner, contributor1, contributor2, attacker] = await ethers.getSigners();

    const Crowdfunding = await ethers.getContractFactory("Crowdfunding");
    crowdfunding = await Crowdfunding.deploy(goal, durationInSeconds);
    await crowdfunding.waitForDeployment();
  });

  async function increaseTime(seconds) {
    await ethers.provider.send("evm_increaseTime", [seconds]);
    await ethers.provider.send("evm_mine", []);
  }

  it("should reject zero-value contributions", async function () {
    await expect(
      crowdfunding.connect(contributor1).contribute({ value: 0 })
    ).to.be.revertedWith("Contribution must be greater than zero");
  });

  it("should reject contributions after deadline", async function () {
    await increaseTime(durationInSeconds + 1);

    await expect(
      crowdfunding.connect(contributor1).contribute({
        value: ethers.parseEther("1")
      })
    ).to.be.revertedWith("Campaign has ended");
  });

  it("should allow only owner to withdraw funds", async function () {
    await crowdfunding.connect(contributor1).contribute({
      value: ethers.parseEther("5")
    });

    await expect(
      crowdfunding.connect(contributor1).withdrawFunds()
    ).to.be.revertedWith("Only owner can call this function");
  });

  it("should reject withdrawal before reaching goal", async function () {
    await crowdfunding.connect(contributor1).contribute({
      value: ethers.parseEther("1")
    });

    await expect(
      crowdfunding.connect(owner).withdrawFunds()
    ).to.be.revertedWith("Funding goal not reached");
  });

  it("should allow refund only after failed campaign", async function () {
    await crowdfunding.connect(contributor1).contribute({
      value: ethers.parseEther("1")
    });

    await increaseTime(durationInSeconds + 1);

    await expect(
      crowdfunding.connect(contributor1).claimRefund()
    ).to.not.be.reverted;
  });

  it("should prevent double refund", async function () {
    await crowdfunding.connect(contributor1).contribute({
      value: ethers.parseEther("1")
    });

    await increaseTime(durationInSeconds + 1);

    await crowdfunding.connect(contributor1).claimRefund();

    await expect(
      crowdfunding.connect(contributor1).claimRefund()
    ).to.be.revertedWith("No contribution to refund");
  });
});

اختبار ثغرات السحب والاسترداد من منظور هجومي

في العقود التي تتعامل مع Ether، يجب الانتباه جيداً إلى ترتيب تنفيذ العمليات. إذا كان العقد يحول المال قبل تعديل الرصيد الداخلي، فقد يصبح عرضة لثغرة Reentrancy. وقد شرحنا هذا المفهوم بالتفصيل في أمن العقود الذكية (1): ثغرة إعادة الدخول (Reentrancy Attack) الشهيرة وكيفية استغلالها، ثم كيفية تحصينه في أمن العقود الذكية (2): الحماية من ثغرة Reentrancy باستخدام ReentrancyGuard.

حتى لو كنت تستخدم ReentrancyGuard من استخدام مكتبة OpenZeppelin لكتابة عقود ذكية آمنة ومختبرة مسبقاً، يجب أن تختبر السلوك عملياً. الحماية النظرية لا تكفي دون إثبات أن الاستدعاء المعاود لا يستطيع كسر منطق الرصيد.

// contracts/AttackerMock.sol
pragma solidity ^0.8.20;

interface ICrowdfunding {
    function contribute() external payable;
    function claimRefund() external;
}

contract AttackerMock {
    ICrowdfunding public target;

    constructor(address _target) {
        target = ICrowdfunding(_target);
    }

    function attack() external payable {
        target.contribute{value: msg.value}();
        target.claimRefund();
    }

    receive() external payable {
        // محاولة إعادة الدخول أثناء الاسترداد
        try target.claimRefund() {
        } catch {
        }
    }
}

ثم نكتب اختباراً يثبت أن المحاولة الهجومية لا تنجح في سحب الأموال أكثر من مرة، وأن رصيد المهاجم داخل العقد يصبح صفراً قبل الإرسال.

it("should resist reentrancy during refund", async function () {
  const Attacker = await ethers.getContractFactory("AttackerMock", attacker);
  const attackerContract = await Attacker.deploy(await crowdfunding.getAddress());
  await attackerContract.waitForDeployment();

  await attacker.sendTransaction({
    to: await attackerContract.getAddress(),
    value: ethers.parseEther("1")
  });

  await attackerContract.connect(attacker).attack({
    value: ethers.parseEther("1")
  });

  await increaseTime(durationInSeconds + 1);

  await expect(
    attackerContract.connect(attacker).attack({
      value: ethers.parseEther("1")
    })
  ).to.be.reverted;
});

اختبار الأحداث والحالة الداخلية بعد التنفيذ

الاختبار الأمني الجيد لا يكتفي بفحص نجاح أو فشل المعاملة، بل يتحقق أيضاً من تطابق الحالة الداخلية مع التوقعات. هنا تظهر أهمية الأحداث (Events): كيف يخبر العقد الذكي واجهة الموقع (React) بأن شيئاً ما قد حدث؟، لأنها تساعدنا على التأكد من أن كل عملية مساهمة أو سحب أو استرداد تم توثيقها بشكل صحيح.

كذلك من المفيد فحص متغيرات مثل totalRaised وcontributions، خاصة إذا كان العقد يعتمد على القواميس (Mappings): أسرع طريقة لربط عناوين المحافظ بأرصدتها (Key-Value) والهياكل (Structs): تصميم أنواع بيانات مخصصة (مثل: كائن يمثل موظف أو منتج).

it("should emit contribution event and update internal balance", async function () {
  const amount = ethers.parseEther("2");

  await expect(
    crowdfunding.connect(contributor1).contribute({ value: amount })
  )
    .to.emit(crowdfunding, "ContributionReceived")
    .withArgs(contributor1.address, amount);

  const contribution = await crowdfunding.contributions(contributor1.address);
  expect(contribution).to.equal(amount);
});

اختبارات الحالات الطرفية وتحسين جودة الكود

الحالات الطرفية Edge Cases هي المنطقة التي تسقط فيها كثير من العقود الذكية. مثلاً: ماذا لو تم الوصول إلى الهدف المالي في آخر ثانية؟ ماذا لو حاول المالك السحب مرتين؟ ماذا لو ساهم المستخدم ثم انتهت الحملة وفشلت جزئياً؟ هذه الأسئلة يجب أن تتحول إلى اختبارات مستقلة، لا إلى افتراضات ذهنية.

  • اختبر الانتقال من حالة active إلى successful بدقة.
  • اختبر السحب بعد نجاح الحملة مرة واحدة فقط.
  • اختبر استرداد مساهم واحد دون التأثير على مساهم آخر.
  • اختبر الرسائل الخطأ النصية لتسهيل التدقيق وواجهة المستخدم.

من منظور Gas Optimization، الاختبارات تساعدك أيضاً على اكتشاف العمليات الزائدة. إذا لاحظت أن دالة الاسترداد أو السحب تنفذ قراءات وكتابات متكررة على storage، فارجع إلى مقال التكاليف (Gas Fees): كيف يحسب البلوكتشين تكلفة تنفيذ الأكواد؟ وإدارة الذاكرة بذكاء: الفرق الحاسم بين Storage, Memory, و Calldata لتحسين التصميم قبل النشر.

لماذا تعتبر هذه الاختبارات خطوة أساسية قبل النشر؟

قبل الانتقال إلى أتمتة نشر العقود (Deployment): كتابة سكربت لرفع العقد إلى شبكة Ethereum و Polygon، يجب أن تكون واثقاً أن العقد لا ينجح فقط في الظروف المثالية، بل يفشل بأمان في الظروف الخاطئة. هذا هو الفارق الحقيقي بين نموذج تعليمي بسيط وتطبيق DApp يمكن الوثوق به.

في المشاريع اللامركزية، أي خطأ في منطق الأموال قد يصبح دائماً وغير قابل للتراجع. لذلك فإن كتابة اختبارات الأمان لمنصة التمويل الجماعي ليست مهمة ثانوية، بل هي امتداد مباشر لهندسة العقد نفسه. وكلما كانت اختباراتك أوضح، معزولة، وتغطي سيناريوهات الهجوم والفشل، اقتربت أكثر من مستوى التطوير الذي يحترمه المستخدمون والمدققون معاً.

في الجزء التالي، سيكون من الطبيعي الانتقال إلى اختبارات أكثر تقدماً مثل قياس استهلاك Gas، فحص التغطية Coverage، وربط النتائج لاحقاً بواجهة مبنية عبر هندسة الويب اللامركزي (Web3.js & Ethers.js): كيف نربط الواجهات بالعقود الذكية؟ لضمان أن المنصة متماسكة أمنياً ووظيفياً من العقد حتى الواجهة.

2 comments

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *