پرش به محتوا

تحلیل‌ها

چک‌لیست امنیتی قراردادهای وستینگ ERC-20

محاسبات وستینگ، لغو، کلیدهای مدیریتی، مدیریت انتقال توکن، تست‌های اینوریانت Foundry و بررسی‌های دیپلوی برای قراردادهای وستینگ امن ERC-20.

تیم مهندسی sigmacode.io9 دقیقه مطالعه

در این صفحه (14)
  1. ۱. محاسبات وستینگ
  2. ۲. ذی‌نفعان و لغو
  3. ۳. کنترل دسترسی و کلیدهای ادمین
  4. ۴. مدیریت انتقال توکن
  5. ۵. ری‌انترنسی و ترتیب فراخوانی‌ها
  6. ۶. تایم‌استمپ‌ها
  7. ۷. رویدادها و شفافیت
  8. ۸. مفاضلات ارتقاپذیری
  9. ۹. کنترل‌های اضطراری
  10. ۱۰. تست
  11. ۱۱. تحلیل استاتیک
  12. ۱۲. دیپلوی و وریفیکیشن
  13. ۱۳. بررسی‌های عملیاتی
  14. نکته پایانی

قراردادهای وستینگ ظاهراً ساده‌اند: توکن‌ها را قفل کنید، به‌مرور زمان آزادشان کنید، تمام. اما در عمل این قراردادها سهم بزرگی از عرضه یک پروژه را برای سال‌ها در خود نگه می‌دارند، بنیان‌گذاران، سرمایه‌گذاران، کارمندان و مولتی‌سیگ‌ها با آن‌ها سر‌وکار دارند و پس از دیپلوی به‌ندرت دوباره بررسی می‌شوند. یک اشتباه کوچک در محاسبات یا در مدل مجوزها برای کل دوره وستینگ فعال باقی می‌ماند. این چک‌لیست سؤالاتی را گرد آورده که هنگام طراحی یا بازبینی یک قرارداد وستینگ ERC-20 می‌پرسیم؛ از ریاضیات تا دیپلوی و عملیات روزمره.

۱. محاسبات وستینگ#

هسته هر قرارداد وستینگ یک تابع است که به این سؤال پاسخ می‌دهد: «در زمان t چند توکن وست شده‌اند؟». تقریباً هر باگ جدی وستینگ همین‌جا قرار دارد.

برنامه خطی و کلیف#

  • برنامه را با پارامترهای صریح تعریف کنید: start، cliff، duration، totalAllocation. از مقادیر ضمنی مشتق‌شده از block.timestamp در زمان دیپلوی بپرهیزید.
  • مشخص کنید کلیف دقیقاً چه معنایی دارد. مدل‌های رایج «هیچ‌چیز پیش از کلیف، سپس جبران خطی از start» و «هیچ‌چیز پیش از کلیف، سپس یک مبلغ یک‌جا، سپس خطی» هستند. مدل انتخاب‌شده را در NatSpec و در تست‌ها بنویسید.
  • در زمان ساخت اعتبارسنجی کنید: duration بزرگ‌تر از صفر، cliff نه بعد از start + duration، totalAllocation بزرگ‌تر از صفر، و ذی‌نفع آدرس صفر نباشد.
  • پس از start + duration، مقدار وست‌شده باید دقیقاً برابر totalAllocation باشد، نه «تقریباً».

گرد کردن#

  • اول ضرب کنید، بعد تقسیم. total * elapsed / duration درست است؛ total / duration * elapsed بی‌صدا در هر آزادسازی بخشی از توکن‌ها را از دست می‌دهد.
  • گرد کردن همیشه باید به نفع قرارداد باشد: مقدار قابل‌برداشت ذی‌نفع را رو به پایین گرد کنید، هرگز رو به بالا. آزادسازی نهایی در پایان برنامه، باقی‌مانده‌های خرد را جمع‌آوری می‌کند.
  • برای تخصیص‌های بزرگ با توکن‌های ۱۸ اعشاری، سرریز را بررسی کنید. Solidity 0.8.x در هنگام سرریز revert می‌کند، اما یک revert داخل vestedAmount می‌تواند همه claimها را قفل کند. اگر حاصل‌ضرب می‌تواند بزرگ شود، از Math.mulDiv متعلق به OpenZeppelin استفاده کنید.

شروع در گذشته یا آینده#

  • start در گذشته قانونی است (گرنت‌های مورخ‌دار کارمندان) اما به این معناست که مقدار بزرگی بلافاصله قابل برداشت است. این را به یک تصمیم صریح و بازبینی‌شده تبدیل کنید، نه حادثه‌ای ناشی از یک پارامتر اشتباه.
  • start بسیار در آینده می‌تواند یک تایپو باشد (میلی‌ثانیه به‌جای ثانیه یک نمونه کلاسیک است). در سازنده یا فکتوری و در اسکریپت دیپلوی محدوده‌های منطقی بگذارید.

یک پیاده‌سازی مرجع و فشرده از برنامه:

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);
}

۲. ذی‌نفعان و لغو#

  • چه کسی می‌تواند claim کند؟ فقط ذی‌نفع، یا هر کسی از طرف او؟ اجازه دادن به همه برای فراخوانی release() مشکلی ندارد تا وقتی توکن‌ها همیشه به ذی‌نفع برسند؛ این برای بازیابی کلید گمشده و اتوماسیون کمک‌کننده است.
  • آیا ذی‌نفع می‌تواند تغییر کند؟ اگر بله، لازم است ذی‌نفع فعلی آن را آغاز کند (در حالت ایده‌آل یک انتقال دو مرحله‌ای با پذیرش) و یک رویداد منتشر شود. ذی‌نفعی که یک قرارداد است باید بتواند توکن‌ها را دریافت و استفاده کند.
  • مدل لغو. برای هر برنامه تصمیم بگیرید که قابل لغو است یا نه. هنگام لغو، مقدار وست‌شده اما آزادنشده باید همچنان برای ذی‌نفع قابل برداشت بماند؛ فقط باقی‌مانده وست‌نشده به خزانه برمی‌گردد. لغو توکن‌های وست‌شده یک مشکل اعتماد است، نه فقط یک مشکل کد.
  • لغو باید قطعی باشد. یک برنامه لغوشده نباید دوباره قابل لغو باشد، نباید به وستینگ ادامه دهد و نباید به ادمین اجازه دهد بیش از باقی‌مانده وست‌نشده برداشت کند.
  • چند برنامه برای هر ذی‌نفع. برنامه‌ها را با یک id کلید بزنید، نه فقط با آدرس، تا یک گرنت دوم جای اولی را نگیرد.

۳. کنترل دسترسی و کلیدهای ادمین#

  • همه توابع دارای امتیاز را فهرست کنید: ساخت برنامه‌ها، لغو، توقف موقت، برداشت مازاد، ارتقا. هر کدام در صورت لو رفتن کلید، یک سطح حمله‌اند.
  • به‌جای یک مالک همه‌قدرت، از Ownable2Step یا AccessControl با نقش‌های جداگانه استفاده کنید. نقشی که برنامه‌ها را می‌سازد لازم نیست همان نقشی باشد که می‌تواند وجوه را برداشت کند.
  • نقش‌های ادمین را در یک مولتی‌سیگ نگه دارید و برای هر چیزی که توکن را از قرارداد خارج می‌کند، تایم‌لاک در نظر بگیرید.
  • ادمین هرگز نباید بتواند توکن‌هایی را که به برنامه‌ها متعهد شده‌اند برداشت کند. totalCommitted را دنبال کنید و فقط برداشت balance - totalCommitted را برای مازاد مجاز کنید.
  • برای وضعیت نهایی برنامه داشته باشید: آیا پس از ساخت همه برنامه‌ها می‌توان حقوق ادمین را کنار گذاشت؟ کلیدهای فعال کمتر یعنی حالت‌های شکست کمتر.

۴. مدیریت انتقال توکن#

  • برای هر انتقال از SafeERC20 متعلق به OpenZeppelin استفاده کنید. برخی توکن‌ها boolean برنمی‌گردانند و برخی دیگر به‌جای revert مقدار false برمی‌گردانند.
  • توکن‌های fee-on-transfer. اگر قرارداد با توکنی شارژ شود که کارمزد می‌گیرد، کمتر از مبلغ اسمی دریافت می‌کند. بالانس را پیش و پس از شارژ بسنجید و آنچه واقعاً رسیده را ثبت کنید، یا چنین توکن‌هایی را صراحتاً رد کنید.
  • توکن‌های rebasing. بالانس‌هایی که به‌خودی‌خود تغییر می‌کنند، فرض balance == committed + surplus را می‌شکنند. یا مستند کنید که توکن‌های rebasing پشتیبانی نمی‌شوند، یا حسابداری را بر اساس سهم طراحی کنید.
  • اگر قرارداد برای یک توکن واحد است، آدرس توکن را immutable کنید. پذیرفتن آدرس توکن دلخواه برای هر برنامه، سطح حمله را به‌طور قابل‌توجهی گسترده می‌کند.
  • هرگز اجازه ندهید توکن وستینگ از طریق یک تابع عمومی recoverERC20 «نجات» داده شود بدون این‌که مقادیر متعهد کم شوند.

۵. ری‌انترنسی و ترتیب فراخوانی‌ها#

  • از الگوی checks-effects-interactions پیروی کنید: پیش از فراخوانی safeTransfer مقدار released را به‌روزرسانی کنید.
  • به release، revoke و هر تابع برداشتی nonReentrant اضافه کنید. هوک‌های به سبک 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);
}

۶. تایم‌استمپ‌ها#

  • از block.timestamp استفاده کنید، نه شماره بلاک. زمان بلاک‌ها بین زنجیره‌ها متفاوت است و در طول زمان تغییر می‌کند؛ همان قرارداد ممکن است بعداً روی یک L2 دیپلوی شود.
  • نفوذ اعتبارسنج‌ها روی تایم‌استمپ‌ها به چند ثانیه محدود است. این برای برنامه‌هایی که بر حسب ماه سنجیده می‌شوند اهمیتی ندارد، اما منطقی نسازید که به دقت ثانیه‌ای وابسته باشد.
  • تایم‌استمپ‌ها را به‌صورت uint64 ذخیره کنید. برای هر برنامه واقعی کافی است و در استوریج به‌خوبی پَک می‌شود.
  • مرزها را صراحتاً تست کنید: یک ثانیه پیش از کلیف، دقیقاً در کلیف، دقیقاً در پایان، و مدت‌زمان طولانی پس از پایان.

۷. رویدادها و شفافیت#

هر تغییر وضعیت باید یک رویداد منتشر کند: ScheduleCreated، TokensReleased، ScheduleRevoked، BeneficiaryChanged، تغییرات نقش‌ها و توقف‌های موقت. رویدادها همان چیزی هستند که ایندکسرها، داشبوردها و تیم پشتیبانی خودتان به آن‌ها تکیه می‌کنند. شناسه برنامه و مبالغ را درج کنید، نه فقط آدرس‌ها. سرمایه‌گذاران و کارمندان خواهند پرسید چه مقدار وست شده و رویدادهای آن‌چین معتبرترین پاسخ‌اند.

۸. مفاضلات ارتقاپذیری#

گزینهمزیتریسک
قرارداد immutableقوی‌ترین تضمین برای ذی‌نفعان، ممیزی ساده‌ترباگ‌ها قابل اصلاح نیستند؛ مهاجرت به قرارداد و وجوه جدید نیاز دارد
پراکسی ارتقاپذیرباگ‌ها قابل پچ شدن‌اندکلید ارتقا می‌تواند هر قاعده‌ای را تغییر دهد، خطاهای لایه‌چینی استوریج، اسکوپ ممیزی بزرگ‌تر
immutable به‌علاوه فکتوریهر مجموعه برنامه ایزوله است، نسخه‌های جدید برای گرنت‌های جدیدنمونه‌های قدیمی باگ‌های قدیمی را نگه می‌دارند

برای وستینگ، immutable بودن اغلب پیش‌فرض بهتری است: تمام مزیت قرارداد این است که هیچ‌کس نمی‌تواند بعداً توافق را تغییر دهد. اگر پراکسی انتخاب می‌کنید، نقش ارتقا را پشت یک مولتی‌سیگ و تایم‌لاک بگذارید، از storage gapها یا استوریج فضای‌نامی استفاده کنید و بررسی‌های امنیت ارتقای OpenZeppelin را در CI اجرا کنید.

۹. کنترل‌های اضطراری#

  • یک توقف موقت می‌تواند در برابر یک باگ ناشناخته محافظت کند، اما توقفی که release را برای همیشه مسدود کند راهی برای فریز کردن ذی‌نفعان نیز هست. محدود کردن مدت توقف را در نظر بگیرید، یا اجازه دهید آزادسازی‌ها حتی وقتی ساخت برنامه‌های جدید متوقف است ادامه یابند.
  • مستند کنید چه کسی می‌تواند توقف کند، تحت چه شرایطی، و جامعه چگونه مطلع خواهد شد.
  • از توابع «برداشت اضطراری همه‌چیز» بپرهیزید. اگر یکی از آن‌ها اجتناب‌ناپذیر است، باید پشت تایم‌لاک باشد و در مستنداتی که سرمایه‌گذاران می‌خوانند قابل‌مشاهده باشد.

۱۰. تست#

تست‌های واحد حداقل لازم‌اند. برای قراردادهای وستینگ، تست‌های مبتنی بر ویژگی ارزش زیادی اضافه می‌کنند، زیرا محاسبات باید برای هر زمان و هر مقدار صادق باشند.

  • تست‌های واحد: هر مسیر revert، هر تایم‌استمپ مرزی، لغو پیش از کلیف، لغو پس از وست کامل، چند برنامه برای یک ذی‌نفع.
  • تست‌های fuzz: total، duration و timestamp تصادفی؛ بررسی کنید که vestedAmount یکنوا است و هرگز از total فراتر نمی‌رود.
  • تست‌های اینوریانت: بگذارید 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()
        );
    }
}

۱۱. تحلیل استاتیک#

Slither را روی هر تغییر اجرا کنید و خروجی آن را به‌عنوان صف بازبینی در نظر بگیرید، نه سیگنال قبولی یا رد. برای قراردادهای وستینگ به یافته‌های ری‌انترنسی، انتقال‌های unchecked، برابری‌های دقیق خطرناک روی بالانس‌ها و رویدادهای گمشده توجه کنید.

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

یک پیش‌بازبینی مبتنی بر هوش مصنوعی، مانند بازبین هوش مصنوعی قراردادهای هوشمند ما، نیز یک پاس اولیه سریع است که پیش از نگاه یک انسان به کد، الگوهای مشکوک را برجسته می‌کند.

۱۲. دیپلوی و وریفیکیشن#

  • دیپلوی را با اسکریپت‌های Foundry اسکریپتی کنید، نه تراکنش‌های دستی. پارامترها در فایل‌های کانفیگ تحت کنترل نسخه زندگی می‌کنند و مثل کد بازبینی می‌شوند.
  • ابتدا با دقیقاً همان اسکریپت و پارامترها روی تست‌نت دیپلوی کنید، سپس یک اجرای آزمایشی روی فورک مین‌نت انجام دهید.
  • بلافاصله پس از دیپلوی، سورس‌کد را روی بلاک اکسپلورر وریفای کنید، با همان نسخه کامپایلر و تنظیمات آپتیمایزر.
  • اعشار را دوباره چک کنید: تخصیص 1,000,000 توکن با ۱۸ اعشار برابر 1_000_000e18 است، نه 1_000_000.
  • مالکیت را در همان اسکریپت به مولتی‌سیگ منتقل کنید و تأیید کنید کلید دیپلوی‌کننده هیچ نقش باقی‌مانده‌ای ندارد.

۱۳. بررسی‌های عملیاتی#

  • به‌طور منظم تطبیق دهید: مجموع تخصیص‌های برنامه‌ها منهای آزادسازی‌ها باید با مقدار متعهد و بالانس قرارداد هم‌خوان باشد.
  • رویدادها را مانیتور کنید و برای لغوهای غیرمنتظره، تغییرات نقش‌ها یا توقف‌های موقت هشدار بگذارید.
  • یک نمای کلی عمومی یا ویژه سرمایه‌گذاران از برنامه‌ها نگه دارید تا پاسخ سؤالات از داده‌های آن‌چین داده شود.
  • رویههای کلیدی را تمرین کنید: چرخش امضاکنندگان مولتی‌سیگ، چه اتفاقی می‌افتد اگر ذی‌نفع دسترسی به کیف‌پولش را از دست بدهد، و چگونه یک توقف اعلام می‌شود.

نکته پایانی#

یک چک‌لیست و یک بازبینی خودکار بسیاری از مسائل را زود می‌گیرند، اما جایگزین یک ممیزی امنیتی مستقل نیستند. پیش از این‌که یک قرارداد وستینگ ارزش واقعی در خود نگه دارد، آن را توسط کسانی بازبینی کنید که آن را ننوشته‌اند.

اگر می‌خواهید ببینید ما در عمل چگونه به این موضوع نزدیک می‌شویم، نگاهی به نمونه‌کار مجموعه توکن ما بیندازید که شامل یک قرارداد وستینگ با تست است، یا بیشتر درباره خدمات بلاک‌چین ما بخوانید. تیم ما توسط یک تک‌لید با بیش از ۲۰ سال تجربه هدایت می‌شود و با کمال میل توکنومیکس یا طراحی وستینگ شما را بازبینی می‌کنیم: کافی است با ما در تماس شوید.

پروژه‌ای در ذهن دارید؟

بگویید چه می‌سازید. یک مهندس ارشد در روزهای کاری ظرف یک ساعت پاسخ می‌دهد — سپس یک ارزیابی صادقانه، دامنه مشخص و پیشنهاد با قیمت ثابت یا مرحله‌ای دریافت می‌کنید، معمولاً ظرف چند روز کاری.

ترجیح می‌دهید اول بنویسید؟ برایمان بنویسید

مستقیم با Ing. Ismet Mesic، سرپرست فنی، صحبت خواهید کرد.