تحلیلها
چکلیست امنیتی قراردادهای وستینگ ERC-20
محاسبات وستینگ، لغو، کلیدهای مدیریتی، مدیریت انتقال توکن، تستهای اینوریانت Foundry و بررسیهای دیپلوی برای قراردادهای وستینگ امن ERC-20.
تیم مهندسی sigmacode.io9 دقیقه مطالعه
در این صفحه (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بسیار در آینده میتواند یک تایپو باشد (میلیثانیه بهجای ثانیه یک نمونه کلاسیک است). در سازنده یا فکتوری و در اسکریپت دیپلوی محدودههای منطقی بگذارید.
یک پیادهسازی مرجع و فشرده از برنامه:
// 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 یا یک توکن مخرب میتوانند به داخل قرارداد برگردند. - فراخوانیهای خارجی را به حداقل برسانید. یک قرارداد وستینگ هیچ دلیلی برای فراخوانی آدرسهای دلخواه ندارد.
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است. - یک برنامه لغوشده پس از آن هرگز مقدار وستشده کسب نمیکند.
// 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، برابریهای دقیق خطرناک روی بالانسها و رویدادهای گمشده توجه کنید.
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. - مالکیت را در همان اسکریپت به مولتیسیگ منتقل کنید و تأیید کنید کلید دیپلویکننده هیچ نقش باقیماندهای ندارد.
۱۳. بررسیهای عملیاتی#
- بهطور منظم تطبیق دهید: مجموع تخصیصهای برنامهها منهای آزادسازیها باید با مقدار متعهد و بالانس قرارداد همخوان باشد.
- رویدادها را مانیتور کنید و برای لغوهای غیرمنتظره، تغییرات نقشها یا توقفهای موقت هشدار بگذارید.
- یک نمای کلی عمومی یا ویژه سرمایهگذاران از برنامهها نگه دارید تا پاسخ سؤالات از دادههای آنچین داده شود.
- رویههای کلیدی را تمرین کنید: چرخش امضاکنندگان مولتیسیگ، چه اتفاقی میافتد اگر ذینفع دسترسی به کیفپولش را از دست بدهد، و چگونه یک توقف اعلام میشود.
نکته پایانی#
یک چکلیست و یک بازبینی خودکار بسیاری از مسائل را زود میگیرند، اما جایگزین یک ممیزی امنیتی مستقل نیستند. پیش از اینکه یک قرارداد وستینگ ارزش واقعی در خود نگه دارد، آن را توسط کسانی بازبینی کنید که آن را ننوشتهاند.
اگر میخواهید ببینید ما در عمل چگونه به این موضوع نزدیک میشویم، نگاهی به نمونهکار مجموعه توکن ما بیندازید که شامل یک قرارداد وستینگ با تست است، یا بیشتر درباره خدمات بلاکچین ما بخوانید. تیم ما توسط یک تکلید با بیش از ۲۰ سال تجربه هدایت میشود و با کمال میل توکنومیکس یا طراحی وستینگ شما را بازبینی میکنیم: کافی است با ما در تماس شوید.