التعامل مع الأخطاء وإرجاع الأموال: استخدام require, assert, revert

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

التعامل مع الأخطاء وإرجاع الأموال: استخدام require, assert, revert

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

إذا كنت قد قرأت مدخل إلى Web3: ما هو البلوكتشين ولماذا يغير شكل الإنترنت والأنظمة المالية؟ فأنت تعلم أن كل معاملة على الشبكة تُنفذ بشكل حتمي داخل EVM. لذلك فإن أي خطأ منطقي غير مضبوط قد يؤدي إلى قفل أموال، أو تحديث أرصدة بصورة غير صحيحة، أو فتح ثغرات أمنية يمكن استغلالها في DApps.

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

لماذا تعتبر معالجة الأخطاء جزءاً أساسياً من تصميم العقد الذكي؟

العقد الذكي لا يملك واجهة بشرية تشرح للمستخدم ما الذي حدث داخله، لذلك يجب أن يعبّر الكود نفسه عن شروط الصحة بشكل واضح. عندما تفشل المعاملة بسبب شرط غير متحقق، تقوم Solidity بإلغاء جميع تغييرات state التي حدثت أثناء التنفيذ.

وهذا يعني عملياً أن:

  • الأرصدة لا تتحدث إذا فشلت العملية.
  • العناصر الجديدة لا تُحفظ داخل arrays أو mappings.
  • قيمة msg.value لا تُحتجز في العقد إذا تم التراجع عن المعاملة.

لفهم بنية المتغيرات التخزينية بشكل أعمق يمكنك الرجوع إلى أساسيات لغة Solidity: أنواع البيانات والمتغيرات (State Variables)، كما أن فهم الدوال (Functions) في Solidity: من يمكنه قراءة وتعديل بيانات العقد؟ يساعدك على اختيار الموضع الصحيح لوضع شروط التحقق.

أداة require: خط الدفاع الأول

تُستخدم require للتحقق من الشروط الخارجية أو المدخلات أو صلاحيات الاستدعاء قبل متابعة التنفيذ. وهي الأداة الأكثر شيوعاً في العقود الذكية لأنها مناسبة لحالات مثل التحقق من المرسل، المبلغ، أو حالة معينة في العقد.

أمثلة نموذجية لاستخدام require:

  • التأكد من أن msg.sender مخول بالتنفيذ.
  • التحقق من أن msg.value يساوي أو يتجاوز قيمة مطلوبة.
  • التحقق من توافر رصيد كافٍ قبل السحب.
  • رفض المدخلات غير المنطقية مثل الصفر أو عنوان فارغ.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SimpleVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        require(msg.value > 0, "Amount must be greater than zero");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(amount > 0, "Invalid amount");
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;
        payable(msg.sender).transfer(amount);
    }
}

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

أداة revert: إيقاف التنفيذ بمنطق مخصص

تُستخدم revert عندما تريد بناء تفرعات منطقية أكثر تعقيداً من مجرد شرط واحد مختصر. من الناحية السلوكية، النتيجة مماثلة تقريباً لـ require: إلغاء التعديلات وإرجاع الأموال غير المثبتة على السلسلة.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract TicketSale {
    uint256 public constant TICKET_PRICE = 0.1 ether;
    uint256 public totalTickets;
    uint256 public maxTickets = 100;

    function buyTicket() external payable {
        if (msg.value != TICKET_PRICE) {
            revert("Incorrect ticket price");
        }

        if (totalTickets >= maxTickets) {
            revert("Tickets sold out");
        }

        totalTickets += 1;
    }
}

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

استخدام رسائل خطأ طويلة داخل require أو revert قد يزيد حجم bytecode. في المشاريع الكبيرة، فكر في استخدام custom errors كخيار أفضل لتحسين Gas Fees. ولمراجعة مفهوم التكلفة يمكنك قراءة التكاليف (Gas Fees): كيف يحسب البلوكتشين تكلفة تنفيذ الأكواد؟.

أداة assert: للتحقق من الثوابت الداخلية

assert ليست مخصصة لمدخلات المستخدم العادية، بل تُستخدم للتحقق من افتراضات داخلية يجب ألا تنكسر أبداً إذا كان الكود صحيحاً. بمعنى آخر، إذا فشلت assert فهذا مؤشر قوي على وجود خطأ برمجي أو انتهاك لمنطق داخلي في العقد.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract InternalInvariant {
    uint256 public totalDeposited;
    mapping(address => uint256) public balances;

    function deposit() external payable {
        require(msg.value > 0, "Zero deposit");

        balances[msg.sender] += msg.value;
        totalDeposited += msg.value;

        assert(totalDeposited >= balances[msg.sender]);
    }
}

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

كيف يحدث إرجاع الأموال عند فشل المعاملة؟

من أهم النقاط التي يسيء كثيرون فهمها أن العقد الذكي لا يحتاج غالباً إلى كتابة منطق يدوي لإعادة المبلغ المرسل إذا فشلت المعاملة قبل تثبيتها. عندما تفشل الدالة عبر require أو revert أو assert، يتم التراجع عن كامل الاستدعاء وكأنه لم يحدث.

لكن هناك فرق مهم:

  1. المبلغ المرسل ضمن msg.value لا يُثبت داخل العقد إذا حصل revert.
  2. رسوم التنفيذ المستهلكة حتى لحظة الفشل لا تُعاد بالكامل، لأن موارد الشبكة استُخدمت فعلاً.
  3. إذا أرسلت الأموال يدوياً لاحقاً عبر تحويل منفصل ثم فشل منطق آخر، فالمعاملة الجديدة لها سياق مختلف ويجب التعامل معها بحذر.

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

مثال عملي يجمع بين الشروط والأمان

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract RefundableCrowdfunding {
    address public owner;
    uint256 public goal;
    uint256 public deadline;
    uint256 public totalRaised;
    mapping(address => uint256) public contributions;

    constructor(uint256 _goal, uint256 _durationInDays) {
        owner = msg.sender;
        goal = _goal;
        deadline = block.timestamp + (_durationInDays * 1 days);
    }

    function contribute() external payable {
        require(block.timestamp < deadline, "Campaign ended");
        require(msg.value > 0, "Zero contribution");

        contributions[msg.sender] += msg.value;
        totalRaised += msg.value;

        assert(totalRaised >= contributions[msg.sender]);
    }

    function withdrawFunds() external {
        require(msg.sender == owner, "Only owner");
        require(block.timestamp >= deadline, "Campaign still active");
        require(totalRaised >= goal, "Goal not reached");

        payable(owner).transfer(address(this).balance);
    }

    function claimRefund() external {
        require(block.timestamp >= deadline, "Campaign still active");
        require(totalRaised < goal, "Goal was reached");

        uint256 amount = contributions[msg.sender];
        require(amount > 0, "No contribution to refund");

        contributions[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }
}

في هذا السيناريو نرى ثلاث طبقات واضحة:

  • require لفحص الوقت والصلاحية وحالة الهدف.
  • assert لمراقبة الثبات الداخلي للأرصدة.
  • منطق استرداد واضح عندما لا يتحقق هدف الحملة.

عند إرسال الأموال إلى المستخدمين، اتبع نمط Checks-Effects-Interactions: تحقق أولاً، ثم عدّل الحالة الداخلية، ثم نفذ التحويل الخارجي. هذا يقلل مخاطر هجمات Reentrancy ويُعد من أساسيات Security Auditing.

متى تستخدم كل واحدة باختصار؟

require

استخدمها مع مدخلات المستخدم، فحص الرصيد، الصلاحيات، التوقيت، والتحقق من أن حالة النظام تسمح بالمتابعة. وهي الخيار الافتراضي في معظم الدوال العامة والخارجية، خاصة مع المعدلات (Modifiers): حماية الدوال برمجياً.

revert

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

assert

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

الخاتمة

التمييز الدقيق بين require وrevert وassert ليس مجرد تفصيل نحوي في Solidity، بل هو جزء من هندسة الأمان نفسها. الاختيار الصحيح لكل أداة يجعل العقد أكثر وضوحاً، أسهل في التدقيق، وأقل عرضة للأخطاء المنطقية المكلفة.

وعند بناء عقودك عبر محرر Remix IDE: كتابة ونشر أول عقد ذكي (Smart Contract) على المتصفح مباشرة أو باستخدام Hardhat، اجعل قاعدة العمل الدائمة هي: تحقق مبكراً، أوقف التنفيذ عند الشك، وعدّل الحالة فقط عندما تكون جميع الشروط صحيحة. بهذه المقاربة ستضمن سلوكاً آمناً للأموال وتفاعلاً موثوقاً لمستخدمي تطبيقات Web3.

32 comments

اترك تعليقاً

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