Wiedza
Token vesting w ERC-20: checklista bezpieczeństwa kontraktu
Matematyka vestingu, odwołanie, klucze admina, obsługa tokenów, testy niezmienników w Foundry i kontrole wdrożenia bezpiecznych kontraktów vestingowych ERC-20.
Zespół inżynierów sigmacode.io9 min czytania
Na tej stronie (14)
- 1. Matematyka vestingu
- 2. Beneficjenci i odwołanie
- 3. Kontrola dostępu i klucze admina
- 4. Obsługa transferów tokenów
- 5. Reentrancy i kolejność wywołań
- 6. Znaczniki czasu
- 7. Zdarzenia i przejrzystość
- 8. Upgrade’owalność: kompromisy
- 9. Mechanizmy awaryjne
- 10. Testy
- 11. Analiza statyczna
- 12. Wdrożenie i weryfikacja
- 13. Kontrole operacyjne
- Uwaga końcowa
Kontrakty vestingowe wyglądają prosto: zablokuj tokeny, wypłacaj je w czasie, gotowe. W praktyce przez lata trzymają dużą część podaży projektu, korzystają z nich founderzy, inwestorzy, pracownicy i multisigi, a po wdrożeniu rzadko ktokolwiek do nich wraca. Drobny błąd w matematyce albo w modelu uprawnień pozostaje aktywny przez cały okres vestingu. Ta checklista zbiera pytania, które zadajemy, projektując lub przeglądając kontrakt vestingowy ERC-20: od arytmetyki po wdrożenie i codzienną obsługę.
1. Matematyka vestingu#
Sercem każdego kontraktu vestingowego jest jedna funkcja, która odpowiada na pytanie „ile tokenów jest nabytych w chwili t?”. Tu właśnie mieszka niemal każdy poważny błąd vestingu.
Harmonogram liniowy i cliff#
- Zdefiniuj harmonogram jawnymi parametrami:
start,cliff,duration,totalAllocation. Unikaj wartości niejawnych, wyprowadzanych zblock.timestampw chwili wdrożenia. - Zdecyduj, co oznacza cliff. Popularne modele to „nic przed cliffem, potem liniowe nadganianie od
start” oraz „nic przed cliffem, potem jednorazowa kwota, potem liniowo”. Wybrany model zapisz w NatSpec i w testach. - Waliduj przy tworzeniu:
durationwiększe od zera,cliffnie później niżstart + duration,totalAllocationwiększe od zera, beneficjent inny niż adres zerowy. - Po
start + durationnabyta kwota musi być równa dokładnietotalAllocation, a nie „mniej więcej”.
Zaokrąglenia#
- Najpierw mnóż, potem dziel.
total * elapsed / durationjest poprawne;total / duration * elapsedpo cichu gubi tokeny przy każdej wypłacie. - Zaokrąglenia powinny zawsze działać na korzyść kontraktu: to, co beneficjent może odebrać, zaokrąglaj w dół, nigdy w górę. Ostatnia wypłata na końcu harmonogramu zgarnia cały pozostały pył.
- Sprawdź przepełnienie przy dużych alokacjach tokenów z 18 miejscami dziesiętnymi. Solidity 0.8.x robi revert przy przepełnieniu, ale revert wewnątrz
vestedAmountmoże zablokować każdą wypłatę. Jeśli iloczyn może być duży, użyjMath.mulDivz OpenZeppelin.
Start w przeszłości albo w przyszłości#
startw przeszłości jest uzasadniony (granty pracownicze z datą wsteczną), ale oznacza, że duża kwota jest do odebrania od razu. Niech to będzie jawna, zweryfikowana decyzja, a nie przypadkowy efekt błędnego parametru.startw dalekiej przyszłości może być literówką (klasyk: milisekundy zamiast sekund). Dodaj granice zdroworozsądkowe w konstruktorze lub fabryce oraz w skrypcie wdrożeniowym.
Zwięzła referencyjna implementacja harmonogramu:
// 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. Beneficjenci i odwołanie#
- Kto może odbierać? Tylko beneficjent czy każdy w jego imieniu? Pozwolenie każdemu na wywołanie
release()jest w porządku, o ile tokeny zawsze trafiają do beneficjenta; pomaga to przy odzyskiwaniu dostępu po utracie klucza i w automatyzacji. - Czy beneficjenta można zmienić? Jeśli tak, wymagaj, by zmianę inicjował obecny beneficjent (najlepiej dwuetapowy transfer z akceptacją), i emituj zdarzenie. Beneficjent będący kontraktem musi umieć przyjąć tokeny i z nich skorzystać.
- Model odwołania. Zdecyduj dla każdego harmonogramu, czy jest odwoływalny. Przy odwołaniu kwota już nabyta, ale jeszcze niewypłacona powinna pozostać do odebrania przez beneficjenta; do treasury wraca wyłącznie nienabyta reszta. Odbieranie nabytych tokenów to problem zaufania, a nie tylko problem kodu.
- Odwołanie musi być ostateczne. Odwołanego harmonogramu nie może dać się odwołać drugi raz, nie może on dalej naliczać vestingu i nie może pozwalać adminowi wypłacić więcej niż nienabyta reszta.
- Wiele harmonogramów na beneficjenta. Identyfikuj harmonogramy po id, a nie po samym adresie, żeby drugi grant nie nadpisał pierwszego.
3. Kontrola dostępu i klucze admina#
- Wypisz każdą uprzywilejowaną funkcję: tworzenie harmonogramów, odwoływanie, pauzowanie, wypłata nadwyżki, upgrade. Każda z nich to powierzchnia ataku, jeśli klucz zostanie przejęty.
- Zamiast jednego wszechmocnego ownera użyj
Ownable2StepalboAccessControlz osobnymi rolami. Rola tworząca harmonogramy nie musi być rolą, która może wypłacać środki. - Role admina trzymaj w multisigu i rozważ timelock dla wszystkiego, co wyprowadza tokeny z kontraktu.
- Admin nigdy nie powinien móc wypłacić tokenów przypisanych do harmonogramów. Śledź
totalCommittedi pozwalaj wypłacać jako nadwyżkę wyłączniebalance - totalCommitted. - Zaplanuj stan końcowy: czy po utworzeniu wszystkich harmonogramów można zrzec się uprawnień admina? Mniej aktywnych kluczy to mniej scenariuszy awarii.
4. Obsługa transferów tokenów#
- Do każdego transferu używaj
SafeERC20z OpenZeppelin. Niektóre tokeny nie zwracają wartości boolean, inne zwracająfalsezamiast zrobić revert. - Tokeny fee-on-transfer. Jeśli kontrakt jest zasilany tokenem, który pobiera opłatę, otrzymuje mniej niż kwota nominalna. Zmierz saldo przed zasileniem i po nim i zapisz to, co faktycznie dotarło, albo jawnie odrzucaj takie tokeny.
- Tokeny rebasing. Salda, które zmieniają się same, łamią założenie
balance == committed + surplus. Albo udokumentuj, że tokeny rebasing nie są obsługiwane, albo zaprojektuj księgowość w udziałach. - Jeśli kontrakt obsługuje jeden token, przypnij jego adres jako
immutable. Przyjmowanie dowolnych adresów tokenów dla poszczególnych harmonogramów znacząco poszerza powierzchnię ataku. - Nigdy nie pozwalaj „ratować” tokena objętego vestingiem przez ogólną funkcję
recoverERC20bez odjęcia kwot przypisanych do harmonogramów.
5. Reentrancy i kolejność wywołań#
- Trzymaj się checks-effects-interactions: zaktualizuj
releasedprzed wywołaniemsafeTransfer. - Dodaj
nonReentrantdorelease,revokei każdej funkcji wypłaty. Hooki w stylu ERC-777 albo złośliwy token mogą wywołać kontrakt zwrotnie. - Ogranicz wywołania zewnętrzne do minimum. Kontrakt vestingowy nie ma powodu wywoływać dowolnych adresów.
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. Znaczniki czasu#
- Używaj
block.timestamp, a nie numerów bloków. Czasy bloków różnią się między sieciami i zmieniają się z czasem; ten sam kontrakt może później trafić na L2. - Wpływ walidatorów na znaczniki czasu ogranicza się do sekund. Dla harmonogramów liczonych w miesiącach to bez znaczenia, ale nie buduj logiki zależnej od dokładności co do sekundy.
- Znaczniki czasu przechowuj jako
uint64. To wystarcza dla każdego realistycznego harmonogramu i dobrze się pakuje w storage. - Jawnie przetestuj granice: sekundę przed cliffem, dokładnie w chwili cliffu, dokładnie na końcu i długo po końcu.
7. Zdarzenia i przejrzystość#
Każda zmiana stanu powinna emitować zdarzenie: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, zmiany ról i pauzy. To na zdarzeniach polegają indeksery, dashboardy i Twój własny zespół wsparcia. Umieszczaj w nich id harmonogramu i kwoty, a nie same adresy. Inwestorzy i pracownicy będą pytać, ile już nabyli, a zdarzenia on-chain są najbardziej wiarygodną odpowiedzią.
8. Upgrade’owalność: kompromisy#
| Opcja | Zaleta | Ryzyko |
|---|---|---|
| Kontrakt niezmienny | Najsilniejsza gwarancja dla beneficjentów, prostszy audyt | Błędów nie da się naprawić; migracja wymaga nowego kontraktu i środków |
| Proxy upgradeable | Błędy można załatać | Klucz upgrade’u może zmienić dowolną regułę, błędy w układzie storage, szerszy zakres audytu |
| Niezmienny plus fabryka | Każdy zestaw harmonogramów jest odizolowany, nowe wersje dla nowych grantów | Stare instancje zachowują stare błędy |
W przypadku vestingu niezmienność jest często lepszym wyborem domyślnym: cały sens tego kontraktu polega na tym, że nikt nie może później zmienić umowy. Jeśli wybierasz proxy, schowaj rolę upgrade’u za multisigiem i timelockiem, stosuj storage gaps albo namespaced storage i uruchamiaj w CI kontrole bezpieczeństwa upgrade’ów od OpenZeppelin.
9. Mechanizmy awaryjne#
- Pauza może ochronić przed nieznanym błędem, ale pauza, która blokuje
releasena zawsze, jest też sposobem na zamrożenie beneficjentów. Rozważ ograniczenie maksymalnego czasu pauzy albo dopuszczenie wypłat nawet wtedy, gdy tworzenie nowych harmonogramów jest wstrzymane. - Udokumentuj, kto może pauzować, w jakich warunkach i jak zostanie poinformowana społeczność.
- Unikaj funkcji typu „awaryjnie wypłać wszystko”. Jeśli takiej funkcji nie da się uniknąć, musi stać za timelockiem i być widoczna w dokumentacji, którą czytają inwestorzy.
10. Testy#
Testy jednostkowe to minimum. W kontraktach vestingowych dużo wnoszą testy oparte na właściwościach, bo matematyka musi się zgadzać dla dowolnego czasu i dowolnej kwoty.
- Testy jednostkowe: każda ścieżka revertu, każdy graniczny znacznik czasu, odwołanie przed cliffem, odwołanie po pełnym nabyciu, wiele harmonogramów dla tego samego beneficjenta.
- Testy fuzz: losowe
total,durationitimestamp; asercja, żevestedAmountjest monotoniczne i nigdy nie przekraczatotal. - Testy niezmienników: niech Foundry wywołuje
release,revoke,createScheduleivm.warpw losowej kolejności, a potem sprawdź właściwości globalne.
Przydatne niezmienniki:
- Suma kwot wypłaconych z harmonogramu nigdy nie przekracza jego alokacji.
- Saldo tokenów kontraktu zawsze wynosi co najmniej
totalCommitted. - W odwołanym harmonogramie nabyta kwota nigdy już nie rośnie.
// 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. Analiza statyczna#
Uruchamiaj Slither przy każdej zmianie i traktuj jego wynik jako kolejkę do przeglądu, a nie sygnał zaliczone/niezaliczone. W kontraktach vestingowych zwróć uwagę na znaleziska dotyczące reentrancy, niesprawdzonych transferów, niebezpiecznych ścisłych równości na saldach i brakujących zdarzeń.
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary
Wstępny przegląd wspomagany przez AI, taki jak nasz Przegląd smart kontraktów z AI, to kolejne szybkie pierwsze sito, które wskazuje podejrzane wzorce, zanim na kod spojrzy człowiek.
12. Wdrożenie i weryfikacja#
- Wdrożenie oskryptuj w Foundry, zamiast wysyłać transakcje ręcznie. Parametry żyją w plikach konfiguracyjnych pod kontrolą wersji i przechodzą review jak kod.
- Najpierw wdróż na testnecie dokładnie tym samym skryptem i z tymi samymi parametrami, a potem zrób próbne uruchomienie na forku mainnetu.
- Zweryfikuj kod źródłowy w eksploratorze bloków zaraz po wdrożeniu, z tą samą wersją kompilatora i tymi samymi ustawieniami optymalizatora.
- Sprawdź dwa razy miejsca dziesiętne: alokacja 1 000 000 tokenów z 18 miejscami dziesiętnymi to
1_000_000e18, a nie1_000_000. - W tym samym skrypcie przekaż własność multisigowi i potwierdź, że klucz deployera nie ma już żadnych ról.
13. Kontrole operacyjne#
- Regularnie uzgadniaj stany: suma alokacji harmonogramów pomniejszona o wypłaty powinna zgadzać się z kwotą przypisaną do harmonogramów i z saldem kontraktu.
- Monitoruj zdarzenia i ustaw alerty na nieoczekiwane odwołania, zmiany ról i pauzy.
- Prowadź publiczny albo przeznaczony dla inwestorów przegląd harmonogramów, żeby na pytania dało się odpowiadać na podstawie danych on-chain.
- Przećwicz kluczowe procedury: rotację sygnatariuszy multisiga, postępowanie, gdy beneficjent straci dostęp do portfela, oraz sposób komunikowania pauzy.
Uwaga końcowa#
Checklista i automatyczny przegląd wcześnie wyłapują wiele problemów, ale nie zastąpią niezależnego audytu bezpieczeństwa. Zanim kontrakt vestingowy zacznie trzymać realną wartość, oddaj go do przeglądu ludziom, którzy go nie pisali.
Jeśli chcesz zobaczyć, jak podchodzimy do tego w praktyce, zajrzyj do naszego pokazowego zestawu tokenowego, w którym znajdziesz kontrakt vestingowy z testami, albo przeczytaj więcej o naszych usługach blockchain. Nasz zespół prowadzi tech lead z ponad 20-letnim doświadczeniem. Chętnie przejrzymy Twoją tokenomię albo projekt vestingu: napisz do nas.