מאמרים
רשימת בדיקות אבטחה לחוזי vesting של ERC-20
חישובי vesting, ביטול, מפתחות ניהול, טיפול בטוקנים, בדיקות invariant ב-Foundry ובדיקות פריסה לחוזי vesting מאובטחים של ERC-20.
צוות ההנדסה של sigmacode.io9 דקות קריאה
בעמוד הזה (14)
חוזי vesting נראים פשוטים: נועלים טוקנים, משחררים אותם לאורך זמן, וסיימנו. בפועל הם מחזיקים נתח גדול מההיצע של הפרויקט למשך שנים, נוגעים בהם מייסדים, משקיעים, עובדים ו-multisig-ים, וכמעט לא חוזרים אליהם אחרי הפריסה. טעות קטנה בחשבון או במודל ההרשאות נשארת חיה לכל תקופת ה-vesting. רשימת הבדיקות הזו מרכזת את השאלות שאנחנו שואלים כשאנחנו מעצבים או סוקרים חוזה vesting של ERC-20, מהחשבון ועד הפריסה והתפעול השוטף.
1. חישובי ה-vesting#
הלב של כל חוזה vesting הוא פונקציה אחת שעונה על השאלה "כמה טוקנים הבשילו לשחרור בזמן t?". כמעט כל באג רציני ב-vesting חי כאן.
לוח זמנים לינארי ו-cliff#
- הגדירו את לוח הזמנים עם פרמטרים מפורשים:
start,cliff,duration,totalAllocation. הימנעו מערכים משתמעים שנגזרים מ-block.timestampבזמן הפריסה. - החליטו מה המשמעות של ה-cliff. מודלים נפוצים הם "כלום לפני ה-cliff, ואז השלמה לינארית מ-
start" ו-"כלום לפני ה-cliff, אז סכום חד-פעמי, ואז לינארי". תעדו את המודל שנבחר ב-NatSpec ובבדיקות. - ודאו תקינות בזמן היצירה:
durationגדול מאפס,cliffלא אחריstart + duration,totalAllocationגדול מאפס, והמוטב אינו כתובת האפס. - אחרי
start + duration, הסכום ה-vested חייב להיות שווה בדיוק ל-totalAllocation, לא "בערך".
עיגול#
- כפל לפני חילוק.
total * elapsed / durationהוא הנוסחה הנכונה;total / duration * elapsedמאבדת טוקנים בשקט בכל שחרור. - העיגול צריך תמיד לטובת החוזה: מעגלים כלפי מטה את מה שהמוטב יכול לדרוש, לעולם לא כלפי מעלה. השחרור האחרון בסוף לוח הזמנים גורף את שאריות ה-dust שנותרו.
- בדקו overflow בהקצאות גדולות עם טוקנים בעלי 18 ספרות עשרוניות. Solidity 0.8.x מבצעת revert על overflow, אבל revert בתוך
vestedAmountיכול לנעול כל דרישה. השתמשו ב-Math.mulDivשל OpenZeppelin אם המכפלה עלולה להיות גדולה.
start בעבר או בעתיד#
startבעבר הוא לגיטימי (הקצאות עובדים במועד רטרואקטיבי), אבל משמעותו שסכום גדול ניתן לדרישה באופן מיידי. הפכו את זה להחלטה מפורשת ומבוקרת, ולא לתוצאה מקרית של פרמטר שגוי.startרחוק בעתיד יכול להיות שגיאת הקלדה (מילישניות במקום שניות זו קלאסיקה). הוסיפו גבולות שפיות ב-constructor או ב-factory ובסקריפט הפריסה.
מימוש ייחוס קומפקטי של לוח הזמנים:
// 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. מוטב שהוא חוזה חייב להיות מסוגל לקבל טוקנים ולהשתמש בהם.
- מודל הביטול. החליטו לכל לוח זמנים אם הוא ניתן לביטול. בעת ביטול, הסכום שכבר הבשיל אך טרם שוחרר צריך להישאר ניתן לדרישה על ידי המוטב; רק היתרה שלא הבשילה חוזרת לקופה. ביטול של טוקנים שכבר vested הוא בעיית אמון, לא רק בעיית קוד.
- הביטול חייב להיות סופי. לוח זמנים שבוטל לא יכול להיות ניתן לביטול פעם נוספת, לא ממשיך לצבור vesting, ולא מאפשר לאדמין למשוך יותר מהיתרה שלא הבשילה.
- מספר לוחות זמנים למוטב. השתמשו ב-id כמפתח ללוחות הזמנים, ולא בכתובת בלבד, כדי שהקצאה שנייה לא תדרוס את הראשונה.
3. בקרת גישה ומפתחות ניהול#
- רשמו את כל הפונקציות בעלות ההרשאות: יצירת לוחות זמנים, ביטול, השהיה, משיכת עודפים, שדרוג. כל אחת מהן היא שטח תקיפה אם המפתח נחשף.
- השתמשו ב-
Ownable2Stepאו ב-AccessControlעם תפקידים נפרדים במקום בעלים יחיד כל-יכול. התפקיד שיוצר לוחות זמנים לא חייב להיות התפקיד שיכול למשוך כספים. - החזיקו את תפקידי הניהול ב-multisig, ושקלו timelock לכל פעולה שמוציאה טוקנים מהחוזה.
- האדמין לעולם לא אמור להיות מסוגל למשוך טוקנים שמחויבים ללוחות זמנים. עקבו אחר
totalCommittedואפשרו משיכה שלbalance - totalCommittedבלבד כעודף. - תכננו את מצב הסיום: האם אפשר לוותר על זכויות הניהול אחרי שכל לוחות הזמנים נוצרו? פחות מפתחות חיים משמעותה פחות מצבי כישלון.
4. טיפול בהעברות טוקנים#
- השתמשו ב-
SafeERC20של OpenZeppelin לכל העברה. חלק מהטוקנים לא מחזירים boolean, ואחרים מחזיריםfalseבמקום revert. - טוקנים עם fee-on-transfer. אם החוזה ממומן בטוקן שגובה עמלה, הוא מקבל פחות מהסכום הנומינלי. מדדו את היתרה לפני ואחרי ההזנה ורשמו מה באמת הגיע, או דחו טוקנים כאלה במפורש.
- טוקני rebase. יתרות שמשתנות מעצמן שוברות את ההנחה ש-
balance == committed + surplus. או שתתעדו שטוקני rebase לא נתמכים, או שתעצבו את הנהלת החשבונות ב-shares. - נעצו את כתובת הטוקן כ-
immutableאם החוזה משרת טוקן יחיד. קבלת כתובות טוקן שרירותיות לכל לוח זמנים מרחיבה את שטח התקיפה באופן ניכר. - לעולם אל תאפשרו "חילוץ" של הטוקן ה-vested באמצעות פונקציית
recoverERC20גנרית מבלי להפחית את הסכומים המחויבים.
5. Reentrancy וסדר קריאות#
- עקבו אחר דפוס checks-effects-interactions: עדכנו את
releasedלפני הקריאה ל-safeTransfer. - הוסיפו
nonReentrantל-release, ל-revokeולכל פונקציית משיכה. hooks בסגנון ERC-777 או טוקן זדוני יכולים לקרוא חזרה אל החוזה. - צמצמו את הקריאות החיצוניות למינימום. לחוזה vesting אין שום סיבה לקרוא לכתובות שרירותיות.
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. זה מספיק לכל לוח זמנים ריאלי ונארז היטב ב-storage. - בדקו את הגבולות במפורש: שנייה אחת לפני ה-cliff, בדיוק ב-cliff, בדיוק בסוף, והרבה אחרי הסוף.
7. אירועים ושקיפות#
כל שינוי מצב צריך לשדר event: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, שינויי תפקידים והשהיות. אירועים הם מה ש-indexers, דשבורדים וצוות התמיכה שלכם מסתמכים עליו. כללו את ה-id של לוח הזמנים ואת הסכומים, לא רק כתובות. משקיעים ועובדים ישאלו כמה vested, ואירועים on-chain הם התשובה האמינה ביותר.
8. שיקולי upgradeability#
| אפשרות | יתרון | סיכון |
|---|---|---|
| חוזה immutable | ההבטחה החזקה ביותר למוטבים, אודיט פשוט יותר | אי אפשר לתקן באגים; מיגרציה דורשת חוזה וכספים חדשים |
| פרוקסי upgradeable | אפשר לתקן באגים | מפתח השדרוג יכול לשנות כל כלל, שגיאות ב-storage layout, היקף אודיט גדול יותר |
| immutable בתוספת factory | כל סט לוחות זמנים מבודד, גרסאות חדשות להקצאות חדשות | מופעים ישנים שומרים על הבאגים הישנים שלהם |
ל-vesting, immutability הוא לרוב ברירת המחדל הטובה יותר: כל מטרת החוזה היא שאף אחד לא יכול לשנות את העסקה אחר כך. אם בחרתם בפרוקסי, שימו את תפקיד השדרוג מאחורי multisig ו-timelock, השתמשו ב-storage gaps או namespaced storage, והריצו את בדיקות הבטיחות לשדרוגים של OpenZeppelin ב-CI.
9. בקרות חירום#
- השהיה יכולה להגן מפני באג לא ידוע, אבל השהיה שחוסמת את
releaseלנצח היא גם דרך להקפיא מוטבים. שקלו להגביל כמה זמן השהיה יכולה להימשך, או לאפשר שחרורים גם כשיצירת לוחות זמנים חדשים מושהית. - תעדו מי יכול להשהות, באילו תנאים, ואיך הקהילה תקבל עדכון.
- הימנעו מפונקציות "משיכת חירום של הכול". אם אי אפשר להימנע מכזו, היא חייבת להיות מאחורי timelock וחייבת להופיע בתיעוד שהמשקיעים קוראים.
10. בדיקות#
בדיקות יחידה הן המינימום. בחוזי vesting, בדיקות מבוססות תכונות מוסיפות ערך רב, כי החשבון חייב להחזיק לכל זמן ולכל סכום.
- בדיקות יחידה: כל נתיב revert, כל חותמת זמן גבולית, ביטול לפני ה-cliff, ביטול אחרי vesting מלא, מספר לוחות זמנים לאותו מוטב.
- בדיקות fuzz:
total,durationו-timestampאקראיים; לוודא ש-vestedAmountמונוטונית ולעולם לא עולה עלtotal. - בדיקות invariant: תנו ל-Foundry לקרוא ל-
release,revoke,createScheduleו-vm.warpבסדר אקראי, ואז בדקו תכונות גלובליות.
invariants שימושיים:
- סכום הסכומים ששוחררו לכל לוח זמנים לעולם לא עולה על ההקצאה שלו.
- יתרת הטוקנים של החוזה היא תמיד לפחות
totalCommitted. - לוח זמנים שבוטל לעולם לא צובר סכום vested לאחר מכן.
// 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 על כל שינוי והתייחסו לפלט שלו כתור סקירה, לא כאות עבר/נכשל. בחוזי vesting, שימו לב לממצאי reentrancy, העברות לא מבוקרות, שוויונות קשיחים מסוכנים על יתרות ואירועים חסרים.
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary
סקירה מקדימה בסיוע AI, כמו ה-Smart Contract AI Reviewer שלנו, היא מעבר ראשון מהיר נוסף שמדגיש דפוסים חשודים לפני שבן אדם מסתכל על הקוד.
12. פריסה ואימות#
- בצעו את הפריסה עם סקריפטים של Foundry, לא עם טרנזקציות ידניות. הפרמטרים חיים בקבצי קונפיגורציה בניהול גרסאות ונסקרים כמו קוד.
- פרסו קודם ל-testnet עם אותו סקריפט ואותם פרמטרים בדיוק, ואז הריצו dry run על fork של ה-mainnet.
- אמתו את קוד המקור ב-block explorer מיד אחרי הפריסה, עם אותה גרסת קומפיילר ואותן הגדרות optimizer.
- בדקו פעמיים את ה-decimals: הקצאה של 1,000,000 טוקנים עם 18 ספרות עשרוניות היא
1_000_000e18, לא1_000_000. - העבירו את הבעלות ל-multisig באותו סקריפט, וודאו שמפתח ה-deployer לא מחזיק תפקידים נותרים.
13. בדיקות תפעוליות#
- בצעו התאמה באופן קבוע: סכום ההקצאות של לוחות הזמנים פחות השחרורים צריך להיות זהה לסכום המחויב וליתרת החוזה.
- נטרו אירועים והתריעו על ביטולים בלתי צפויים, שינויי תפקידים או השהיות.
- שמרו על סקירה ציבורית או פונה למשקיעים של לוחות הזמנים, כדי שאפשר יהיה לענות על שאלות מנתוני on-chain.
- תרגלו את הנהלים המרכזיים: רוטציית חותמי multisig, מה קורה אם מוטב מאבד גישה לארנק שלו, ואיך תתבצע הודעה על השהיה.
הערה לסיום#
רשימת בדיקות וסקירה אוטומטית תופסות בעיות רבות מוקדם, אבל הן לא תחליף לאודיט אבטחה עצמאי. לפני שחוזה vesting מחזיק ערך אמיתי, תנו לאנשים שלא כתבו אותו לסקור אותו.
אם אתם רוצים לראות איך אנחנו ניגשים לזה בפועל, הציצו ב-שואו-קייס של ה-token suite שלנו, שכולל חוזה vesting עם בדיקות, או קראו עוד על שירותי הבלוקצ'יין שלנו. הצוות שלנו מובל על ידי tech lead עם 20+ שנות ניסיון, ונשמח לסקור את הטוקנומיקה או את עיצוב ה-vesting שלכם: פשוט צרו קשר.