تخطَّ إلى المحتوى

مقالات

قائمة تحقق أمنية لعقود استحقاق توكنات ERC-20

حسابات الاستحقاق (Vesting)، والإلغاء، ومفاتيح الإدارة، والتعامل مع التوكنات، واختبارات الثوابت في Foundry، وفحوصات النشر لتأمين عقود استحقاق ERC-20.

فريق هندسة sigmacode.io9 دقائق قراءة

في هذه الصفحة (14)
  1. 1. حسابات الاستحقاق
  2. 2. المستفيدون والإلغاء
  3. 3. التحكم في الوصول ومفاتيح الإدارة
  4. 4. التعامل مع تحويلات التوكنات
  5. 5. إعادة الدخول (Reentrancy) وترتيب الاستدعاءات
  6. 6. الطوابع الزمنية
  7. 7. الأحداث والشفافية
  8. 8. المفاضلات في قابلية الترقية
  9. 9. ضوابط الطوارئ
  10. 10. الاختبار
  11. 11. التحليل الساكن
  12. 12. النشر والتحقق
  13. 13. الفحوصات التشغيلية
  14. كلمة أخيرة

تبدو عقود الاستحقاق (Vesting) بسيطة في ظاهرها: اقفل التوكنات، ثم حرّرها تدريجياً مع مرور الوقت، وانتهى الأمر. لكنها في الواقع تحتفظ بحصة كبيرة من معروض المشروع لسنوات، ويتعامل معها المؤسسون والمستثمرون والموظفون والمحافظ متعددة التوقيع (multisig)، ونادراً ما يُعاد النظر فيها بعد نشرها. فأي خطأ صغير في الحسابات أو في نموذج الصلاحيات يبقى قائماً طوال فترة الاستحقاق بأكملها. تجمع قائمة التحقق هذه الأسئلة التي نطرحها عند تصميم عقد استحقاق لتوكنات ERC-20 أو مراجعته، بدءاً من العمليات الحسابية وصولاً إلى النشر والعمليات اليومية.

1. حسابات الاستحقاق#

جوهر كل عقد استحقاق هو دالة واحدة تجيب عن السؤال: «كم عدد التوكنات المستحقة عند اللحظة t؟». وتكمن هنا تقريباً كل ثغرة جدية في عقود الاستحقاق.

الجدول الخطي وفترة السماح (cliff)#

  • عرّف الجدول بمعاملات صريحة: start وcliff وduration وtotalAllocation. تجنّب القيم الضمنية المشتقة من block.timestamp عند النشر.
  • حدّد ما تعنيه فترة السماح. من النماذج الشائعة «لا شيء قبل الـcliff، ثم استحقاق خطي تراكمي من start» و«لا شيء قبل الـcliff، ثم دفعة واحدة مقطوعة، ثم استحقاق خطي». وثّق النموذج المختار في NatSpec وفي الاختبارات.
  • تحقق من الصحة عند الإنشاء: duration أكبر من صفر، وcliff لا يقع بعد start + duration، وtotalAllocation أكبر من صفر، والمستفيد ليس العنوان الصفري.
  • بعد start + duration، يجب أن يساوي المبلغ المستحق totalAllocation تماماً، وليس «تقريباً».

التقريب#

  • اضرب قبل أن تقسم. total * elapsed / duration صحيحة؛ أما total / duration * elapsed فتفقد توكنات بصمت في كل عملية تحرير.
  • يجب أن يكون التقريب دائماً في صالح العقد: قرّب إلى الأسفل ما يمكن للمستفيد المطالبة به، ولا تقرّب إلى الأعلى أبداً. ويتولى التحرير الأخير في نهاية الجدول كنس أي بقايا متبقية.
  • تحقق من تجاوز النطاق (overflow) في المخصصات الكبيرة مع توكنات ذات 18 خانة عشرية. تُرجِع Solidity 0.8.x خطأً (revert) عند التجاوز، لكن حدوث revert داخل vestedAmount قد يجمّد كل عملية مطالبة. استخدم Math.mulDiv من OpenZeppelin إذا كان حاصل الضرب قد يصبح كبيراً.

تاريخ بدء في الماضي أو المستقبل#

  • تاريخ start في الماضي أمر مشروع (منح الموظفين بأثر رجعي)، لكنه يعني أن مبلغاً كبيراً يصبح قابلاً للمطالبة فوراً. اجعل ذلك قراراً صريحاً يخضع للمراجعة، لا حادثاً ناتجاً عن معامل خاطئ.
  • تاريخ start بعيد في المستقبل قد يكون خطأً مطبعياً (المللي ثانية بدلاً من الثواني مثال كلاسيكي). أضف حدوداً منطقية للتحقق في المُنشئ (constructor) أو في المصنع (factory) وفي سكربت النشر.

تنفيذ مرجعي مكثّف للجدول:

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

import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

function vestedAmount(
    uint256 total,
    uint64 start,
    uint64 cliff,
    uint64 duration,
    uint64 timestamp
) pure returns (uint256) {
    if (timestamp < cliff) return 0;
    if (timestamp >= start + duration) return total;
    // Multiply before divide; mulDiv avoids intermediate overflow.
    return Math.mulDiv(total, timestamp - start, duration);
}

2. المستفيدون والإلغاء#

  • من يمكنه المطالبة؟ المستفيد فقط، أم أي شخص نيابة عنه؟ السماح لأي شخص بتفعيل release() مقبول ما دامت التوكنات تذهب دائماً إلى المستفيد؛ وهو يساعد في استعادة الوصول عند فقدان المفاتيح وفي الأتمتة.
  • هل يمكن تغيير المستفيد؟ إن كان الجواب نعم، فاشترط أن يبادر المستفيد الحالي إلى ذلك (ويُفضَّل تحويل من خطوتين يتطلب القبول)، مع إصدار حدث (event). وإذا كان المستفيد عقداً، فيجب أن يكون قادراً على استقبال التوكنات واستخدامها.
  • نموذج الإلغاء. قرّر لكل جدول ما إذا كان قابلاً للإلغاء. وعند الإلغاء، يجب أن يبقى المبلغ المستحق وغير المُحرَّر قابلاً للمطالبة من قبل المستفيد؛ فالجزء غير المستحق وحده هو الذي يعود إلى الخزينة. إن إلغاء توكنات مستحقة مشكلة ثقة، لا مجرد مشكلة في الكود.
  • يجب أن يكون الإلغاء نهائياً. لا يجوز إلغاء جدول مُلغى مرة ثانية، ولا أن يواصل الاستحقاق، ولا أن يسمح للمسؤول بسحب أكثر من الرصيد غير المستحق.
  • جداول متعددة لكل مستفيد. استخدم معرّفاً (id) كمفتاح للجداول بدلاً من العنوان وحده، حتى لا تكتب المنحة الثانية فوق الأولى.

3. التحكم في الوصول ومفاتيح الإدارة#

  • عدّد كل دالة ذات صلاحيات: إنشاء الجداول، والإلغاء، والإيقاف المؤقت، وسحب الفائض، والترقية. كل واحدة منها سطح هجوم إذا تعرّض مفتاحها للاختراق.
  • استخدم Ownable2Step أو AccessControl بأدوار منفصلة بدلاً من مالك واحد يملك كل الصلاحيات. فالدور الذي ينشئ الجداول لا يحتاج أن يكون هو الدور القادر على سحب الأموال.
  • احتفظ بأدوار الإدارة في محفظة متعددة التوقيع، وفكّر في إضافة قفل زمني (timelock) لأي عملية تنقل التوكنات خارج العقد.
  • يجب ألا يتمكن المسؤول أبداً من سحب التوكنات الملتزم بها للجداول. تتبّع totalCommitted ولا تسمح إلا بسحب balance - totalCommitted بوصفه فائضاً.
  • خطّط للحالة النهائية: هل يمكن التنازل عن حقوق الإدارة بعد إنشاء جميع الجداول؟ كلما قلّ عدد المفاتيح النشطة، قلّت سيناريوهات الفشل.

4. التعامل مع تحويلات التوكنات#

  • استخدم SafeERC20 من OpenZeppelin في كل تحويل. فبعض التوكنات لا تُعيد قيمة منطقية، وبعضها يُعيد false بدلاً من إرجاع خطأ.
  • التوكنات التي تقتطع رسوماً على التحويل (Fee-on-transfer). إذا مُوّل العقد بتوكن يقتطع رسوماً، فإنه يستلم أقل من المبلغ الاسمي. قِس الرصيد قبل التمويل وبعده وسجّل ما وصل فعلاً، أو ارفض هذه التوكنات صراحةً.
  • التوكنات المعاد تأسيسها (Rebasing). الأرصدة التي تتغير من تلقاء نفسها تنسف الافتراض القائل balance == committed + surplus. فإما أن توثّق عدم دعم توكنات rebasing، أو تصمّم المحاسبة على أساس الحصص (shares).
  • ثبّت عنوان التوكن كـimmutable إذا كان العقد يخدم توكناً واحداً. فقبول عناوين توكنات عشوائية لكل جدول يوسّع سطح الهجوم كثيراً.
  • لا تسمح أبداً بـ«إنقاذ» التوكن المستحق عبر دالة recoverERC20 عامة دون طرح المبالغ الملتزم بها.

5. إعادة الدخول (Reentrancy) وترتيب الاستدعاءات#

  • اتبع نمط checks-effects-interactions: حدّث released قبل استدعاء safeTransfer.
  • أضف nonReentrant إلى release وrevoke وأي دالة سحب. فخطافات على نمط ERC-777 أو توكن خبيث قد يعاود الاتصال بالعقد.
  • قلّل الاستدعاءات الخارجية إلى الحد الأدنى. فليس لدى عقد الاستحقاق أي سبب للاتصال بعناوين عشوائية.
solidity
function release(uint256 scheduleId) external nonReentrant {
    Schedule storage s = schedules[scheduleId];
    uint256 amount = _releasable(s);
    require(amount > 0, "Vesting: nothing to release");

    s.released += amount;          // effects first
    totalCommitted -= amount;

    token.safeTransfer(s.beneficiary, amount); // interaction last
    emit TokensReleased(scheduleId, s.beneficiary, amount);
}

6. الطوابع الزمنية#

  • استخدم block.timestamp لا أرقام الكتل. فأزمنة الكتل تختلف بين السلاسل وتتغير مع الوقت؛ وقد يُنشر العقد نفسه لاحقاً على شبكة L2.
  • تأثير المدققين (validators) في الطوابع الزمنية محدود بثوانٍ معدودة. وهذا غير مهم للجداول المقاسة بالأشهر، لكن لا تبنِ منطقاً يعتمد على دقة بمستوى الثانية.
  • خزّن الطوابع الزمنية بصيغة uint64. فهي كافية لأي جدول واقعي وتُحزَم بكفاءة في التخزين.
  • اختبر الحدود صراحةً: قبل ثانية واحدة من الـcliff، وعند الـcliff تماماً، وعند النهاية تماماً، وبعد فترة طويلة من النهاية.

7. الأحداث والشفافية#

كل تغيير في الحالة يجب أن يُصدر حدثاً: ScheduleCreated وTokensReleased وScheduleRevoked وBeneficiaryChanged وتغييرات الأدوار وحالات الإيقاف المؤقت. فالأحداث هي ما يعتمد عليه المفهرسون (indexers) ولوحات المعلومات وفريق الدعم لديك. ضمّن معرّف الجدول والمبالغ، لا العناوين فقط. سيسأل المستثمرون والموظفون عن حجم الاستحقاق، والأحداث على السلسلة هي الإجابة الأكثر مصداقية.

8. المفاضلات في قابلية الترقية#

الخيارالميزةالمخاطرة
عقد غير قابل للتغييرأقوى ضمانة للمستفيدين، وتدقيق أبسطلا يمكن إصلاح الأخطاء؛ ويتطلب الترحيل عقداً جديداً وأموالاً جديدة
وكيل قابل للترقية (Upgradeable proxy)يمكن ترقيع الأخطاءمفتاح الترقية قد يغيّر أي قاعدة، وأخطاء تخطيط التخزين، ونطاق تدقيق أكبر
عقد غير قابل للتغيير مع مصنع (factory)كل مجموعة جداول معزولة، وإصدارات جديدة للمنح الجديدةالنسخ القديمة تحتفظ بأخطائها القديمة

في حالة الاستحقاق، تكون عدم القابلية للتغيير غالباً هي الخيار الافتراضي الأفضل: فالغاية الكاملة من العقد هي ألا يستطيع أحد تغيير الاتفاق لاحقاً. وإذا اخترت الوكيل (proxy)، فضع دور الترقية خلف محفظة متعددة التوقيع وقفل زمني، واستخدم فجوات التخزين (storage gaps) أو التخزين بمساحات الأسماء، وشغّل فحوصات أمان الترقية من OpenZeppelin ضمن CI.

9. ضوابط الطوارئ#

  • قد يحمي الإيقاف المؤقت من خطأ مجهول، لكن إيقافاً يمنع release إلى الأبد هو أيضاً وسيلة لتجميد المستفيدين. فكّر في تحديد أقصى مدة للإيقاف، أو في السماح بالتحرير حتى أثناء إيقاف إنشاء الجداول الجديدة.
  • وثّق من يمكنه الإيقاف، وفي أي ظروف، وكيف سيتم إبلاغ المجتمع.
  • تجنّب دوال «سحب كل شيء في حالة الطوارئ». وإن كانت إحداها لا مفر منها، فيجب أن تكون خلف قفل زمني وأن تظهر بوضوح في الوثائق التي يقرأها المستثمرون.

10. الاختبار#

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

  • اختبارات الوحدة: كل مسار revert، وكل طابع زمني حدّي، والإلغاء قبل الـcliff، والإلغاء بعد اكتمال الاستحقاق، وجداول متعددة للمستفيد نفسه.
  • اختبارات Fuzz: قيم عشوائية لـtotal وduration وtimestamp؛ وتحقق من أن vestedAmount رتيبة ولا تتجاوز total أبداً.
  • اختبارات الثوابت (invariants): اجعل Foundry يستدعي release وrevoke وcreateSchedule وvm.warp بترتيب عشوائي، ثم تحقق من الخصائص العامة.

ثوابت مفيدة:

  • مجموع المبالغ المُحرَّرة لكل جدول لا يتجاوز مخصصاته أبداً.
  • رصيد العقد من التوكنات دائماً لا يقل عن totalCommitted.
  • الجدول المُلغى لا يزداد مبلغه المستحق بعد ذلك.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";

contract VestingInvariants is Test {
    VestingHandler handler;

    function setUp() public {
        handler = new VestingHandler(); // deploys token + vesting, exposes bounded actions
        targetContract(address(handler));
    }

    function invariant_releasedNeverExceedsAllocation() public view {
        uint256 n = handler.vesting().scheduleCount();
        for (uint256 i; i < n; i++) {
            (, uint256 total, uint256 released) = handler.vesting().scheduleInfo(i);
            assertLe(released, total);
        }
    }

    function invariant_balanceCoversCommitments() public view {
        assertGe(
            handler.token().balanceOf(address(handler.vesting())),
            handler.vesting().totalCommitted()
        );
    }
}

11. التحليل الساكن#

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

bash
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary

إن المراجعة الأولية بمساعدة الذكاء الاصطناعي، مثل أداة Smart Contract AI Reviewer لدينا، هي مسح أولي سريع آخر يسلّط الضوء على الأنماط المشبوهة قبل أن ينظر إنسان إلى الكود.

12. النشر والتحقق#

  • اكتب النشر كسكربتات Foundry لا كمعاملات يدوية. تُحفظ المعاملات في ملفات إعداد خاضعة للتحكم في الإصدارات وتُراجَع كما يُراجَع الكود.
  • انشر على شبكة اختبار أولاً بالسكربت والمعاملات نفسها تماماً، ثم نفّذ تجربة جافة (dry run) على نسخة متفرعة من الشبكة الرئيسية (mainnet fork).
  • تحقق من الكود المصدري على مستكشف الكتل فور النشر، بإصدار المترجم وإعدادات المُحسِّن نفسها.
  • تحقق مرتين من الخانات العشرية: فمخصصات قدرها 1,000,000 توكن بـ18 خانة عشرية تُكتب 1_000_000e18 وليس 1_000_000.
  • انقل الملكية إلى المحفظة متعددة التوقيع في السكربت نفسه، وتأكد أن مفتاح الناشر لم يعد يحمل أي أدوار.

13. الفحوصات التشغيلية#

  • طابِق بانتظام: مجموع مخصصات الجداول مطروحاً منه المُحرَّر يجب أن يطابق المبلغ الملتزم به ورصيد العقد.
  • راقب الأحداث ونبّه على الإلغاءات أو تغييرات الأدوار أو حالات الإيقاف غير المتوقعة.
  • احتفظ بنظرة عامة عامة أو موجهة للمستثمرين على الجداول، حتى يمكن الإجابة عن الأسئلة من بيانات السلسلة.
  • تدرّب على الإجراءات الأساسية: تدوير موقّعي المحفظة متعددة التوقيع، وماذا يحدث إذا فقد مستفيد الوصول إلى محفظته، وكيف سيتم إبلاغ الآخرين بحالة إيقاف مؤقت.

كلمة أخيرة#

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

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

لديك مشروع في ذهنك؟

أخبرنا بما تبنيه. يرد مهندس senior خلال ساعة في أيام العمل — ثم تحصل على تقييم صادق ونطاق واضح وعرض بسعر ثابت أو مرحلي، عادة خلال أيام عمل قليلة.

تفضّل الكتابة أولًا؟ راسلنا

ستتحدث مباشرة مع Ing. Ismet Mesic، Tech lead.