Przejdź do treści

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. 1. Matematyka vestingu
  2. 2. Beneficjenci i odwołanie
  3. 3. Kontrola dostępu i klucze admina
  4. 4. Obsługa transferów tokenów
  5. 5. Reentrancy i kolejność wywołań
  6. 6. Znaczniki czasu
  7. 7. Zdarzenia i przejrzystość
  8. 8. Upgrade’owalność: kompromisy
  9. 9. Mechanizmy awaryjne
  10. 10. Testy
  11. 11. Analiza statyczna
  12. 12. Wdrożenie i weryfikacja
  13. 13. Kontrole operacyjne
  14. 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 z block.timestamp w 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: duration większe od zera, cliff nie później niż start + duration, totalAllocation większe od zera, beneficjent inny niż adres zerowy.
  • Po start + duration nabyta kwota musi być równa dokładnie totalAllocation, a nie „mniej więcej”.

Zaokrąglenia#

  • Najpierw mnóż, potem dziel. total * elapsed / duration jest poprawne; total / duration * elapsed po 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 vestedAmount może zablokować każdą wypłatę. Jeśli iloczyn może być duży, użyj Math.mulDiv z OpenZeppelin.

Start w przeszłości albo w przyszłości#

  • start w 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.
  • start w 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:

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. 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 Ownable2Step albo AccessControl z 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ź totalCommitted i pozwalaj wypłacać jako nadwyżkę wyłącznie balance - 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 SafeERC20 z OpenZeppelin. Niektóre tokeny nie zwracają wartości boolean, inne zwracają false zamiast 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ę recoverERC20 bez odjęcia kwot przypisanych do harmonogramów.

5. Reentrancy i kolejność wywołań#

  • Trzymaj się checks-effects-interactions: zaktualizuj released przed wywołaniem safeTransfer.
  • Dodaj nonReentrant do release, revoke i 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.
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. 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#

OpcjaZaletaRyzyko
Kontrakt niezmiennyNajsilniejsza gwarancja dla beneficjentów, prostszy audytBłędów nie da się naprawić; migracja wymaga nowego kontraktu i środków
Proxy upgradeableBłędy można załataćKlucz upgrade’u może zmienić dowolną regułę, błędy w układzie storage, szerszy zakres audytu
Niezmienny plus fabrykaKażdy zestaw harmonogramów jest odizolowany, nowe wersje dla nowych grantówStare 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 release na 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, duration i timestamp; asercja, że vestedAmount jest monotoniczne i nigdy nie przekracza total.
  • Testy niezmienników: niech Foundry wywołuje release, revoke, createSchedule i vm.warp w 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.
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. 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ń.

bash
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 nie 1_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.

Masz pomysł na projekt?

Opowiedz nam, co budujesz. Zwykle w ciągu kilku dni roboczych dostajesz uczciwą ocenę, jasny zakres i ofertę w stałej cenie albo z rozliczeniem za kamienie milowe.

Wolisz najpierw napisać? Napisz do nas

Twój rozmówca: Ing. Ismet Mesic, Tech lead.