Preskočite na vsebino

Članki

Token vesting ERC-20: varnostni kontrolni seznam

Matematika vestinga, preklic, administratorski ključi, prenosi tokenov, invariantni testi Foundry in preverjanja ob namestitvi za varne vesting pogodbe ERC-20.

Inženirska ekipa sigmacode.io9 min branja

Na tej strani (14)
  1. 1. Matematika vestinga
  2. 2. Upravičenci in preklic
  3. 3. Nadzor dostopa in administratorski ključi
  4. 4. Ravnanje s prenosi tokenov
  5. 5. Reentrancy in vrstni red klicev
  6. 6. Časovni žigi
  7. 7. Dogodki in preglednost
  8. 8. Kompromisi nadgradljivosti
  9. 9. Ukrepi v sili
  10. 10. Testiranje
  11. 11. Statična analiza
  12. 12. Namestitev in verifikacija
  13. 13. Operativna preverjanja
  14. Sklepna opomba

Vesting pogodbe so videti preproste: zakleni tokene, sproščaj jih skozi čas, konec. V praksi pa več let hranijo velik del ponudbe projekta, z njimi imajo opravka ustanovitelji, vlagatelji, zaposleni in multisigi, po namestitvi pa se k njim redko kdo vrne. Majhna napaka v matematiki ali v modelu dovoljenj ostane živa ves čas trajanja vestinga. Ta kontrolni seznam zbira vprašanja, ki si jih zastavljamo, ko snujemo ali pregledujemo vesting pogodbo ERC-20, od aritmetike do namestitve (deployment) in vsakodnevnega obratovanja.

1. Matematika vestinga#

Jedro vsake vesting pogodbe je ena funkcija, ki odgovori na vprašanje „koliko tokenov je sproščenih (vested) v času t?“. Tu se skriva skoraj vsaka resna napaka v vestingu.

Linearni načrt in cliff#

  • Načrt določite z izrecnimi parametri: start, cliff, duration, totalAllocation. Izogibajte se implicitnim vrednostim, izpeljanim iz block.timestamp ob namestitvi.
  • Odločite se, kaj cliff pomeni. Pogosta modela sta „pred cliffom nič, nato se zamujeno nadoknadi linearno od start“ in „pred cliffom nič, nato enkratni znesek, nato linearno“. Izbrani model zapišite v NatSpec in v teste.
  • Ob ustvarjanju preverite: duration je večji od nič, cliff ni po start + duration, totalAllocation je večji od nič, upravičenec ni ničelni naslov.
  • Po start + duration mora biti sproščeni znesek natanko enak totalAllocation, ne „približno“.

Zaokroževanje#

  • Najprej množite, nato delite. total * elapsed / duration je pravilno; total / duration * elapsed pri vsakem izplačilu tiho izgublja tokene.
  • Zaokroževanje naj bo vedno v korist pogodbe: znesek, ki ga upravičenec lahko prevzame, zaokrožite navzdol, nikoli navzgor. Zadnje izplačilo ob koncu načrta pobere ves preostali drobiž.
  • Pri velikih alokacijah tokenov z 18 decimalnimi mesti preverite prekoračitev (overflow). Solidity 0.8.x se ob prekoračitvi razveljavi (revert), vendar lahko revert znotraj vestedAmount zaklene vsak prevzem. Če lahko zmnožek postane velik, uporabite Math.mulDiv iz knjižnice OpenZeppelin.

Začetek v preteklosti ali prihodnosti#

  • start v preteklosti je legitimen (dodelitve zaposlenim z veljavnostjo za nazaj), vendar pomeni, da je velik znesek mogoče prevzeti takoj. To naj bo izrecna, pregledana odločitev in ne naključna posledica napačnega parametra.
  • start daleč v prihodnosti je lahko tipkarska napaka (milisekunde namesto sekund so klasika). V konstruktor ali tovarno (factory) in v namestitveno skripto dodajte smiselne meje.

Strnjena referenčna implementacija načrta:

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. Upravičenci in preklic#

  • Kdo lahko prevzame? Samo upravičenec ali kdor koli v njegovem imenu? Če release() lahko sproži kdor koli, je to v redu, dokler gredo tokeni vedno k upravičencu; pomaga pri reševanju ob izgubljenih ključih in pri avtomatizaciji.
  • Se upravičenec lahko spremeni? Če da, naj spremembo sproži trenutni upravičenec (najbolje kot dvostopenjski prenos s potrditvijo), ob tem pa naj se odda dogodek. Upravičenec, ki je pogodba, mora biti sposoben tokene prejeti in uporabiti.
  • Model preklica. Za vsak načrt določite, ali je preklicljiv. Ob preklicu naj že sproščeni, a še neizplačani znesek ostane upravičencu na voljo za prevzem; v zakladnico se vrne le nesproščeni preostanek. Preklic že sproščenih tokenov je problem zaupanja, ne le problem kode.
  • Preklic mora biti dokončen. Preklicanega načrta ne sme biti mogoče preklicati dvakrat, vesting se v njem ne sme nadaljevati, administrator pa ne sme dvigniti več kot nesproščeni preostanek.
  • Več načrtov na upravičenca. Načrte indeksirajte z identifikatorjem, ne samo z naslovom, da druga dodelitev ne prepiše prve.

3. Nadzor dostopa in administratorski ključi#

  • Naštejte vse privilegirane funkcije: ustvarjanje načrtov, preklic, zaustavitev (pause), dvig presežka, nadgradnje. Vsaka je napadalna površina, če je ključ kompromitiran.
  • Uporabite Ownable2Step ali AccessControl z ločenimi vlogami namesto enega vsemogočnega lastnika. Vloga, ki ustvarja načrte, ni nujno tista, ki lahko dviguje sredstva.
  • Administratorske vloge hranite v multisigu in razmislite o timelocku za vse, kar tokene premika iz pogodbe.
  • Administrator ne sme nikoli dvigniti tokenov, ki so zavezani načrtom. Spremljajte totalCommitted in za presežek dovolite le dvig balance - totalCommitted.
  • Načrtujte končno stanje: se je mogoče administratorskim pravicam odpovedati, ko so vsi načrti ustvarjeni? Manj živih ključev pomeni manj načinov odpovedi.

4. Ravnanje s prenosi tokenov#

  • Za vsak prenos uporabite OpenZeppelinov SafeERC20. Nekateri tokeni ne vrnejo logične vrednosti, drugi vrnejo false, namesto da bi se razveljavili.
  • Tokeni s provizijo ob prenosu (fee-on-transfer). Če pogodbo financirate s tokenom, ki ob prenosu odtegne provizijo, prejme manj od nominalnega zneska. Izmerite stanje pred financiranjem in po njem ter zabeležite, koliko je dejansko prispelo, ali pa takšne tokene izrecno zavrnite.
  • Rebasing tokeni. Stanja, ki se spreminjajo sama od sebe, porušijo predpostavko balance == committed + surplus. Ali dokumentirajte, da rebasing tokeni niso podprti, ali pa računovodstvo zasnujte v deležih.
  • Naslov tokena določite kot immutable, če pogodba služi enemu samemu tokenu. Sprejemanje poljubnih naslovov tokenov za vsak načrt občutno razširi napadalno površino.
  • Nikoli ne dovolite, da bi se token v vestingu „reševal“ prek splošne funkcije recoverERC20, ne da bi odšteli zavezane zneske.

5. Reentrancy in vrstni red klicev#

  • Držite se vzorca checks-effects-interactions: released posodobite pred klicem safeTransfer.
  • Funkcijam release, revoke in vsaki funkciji za dvig dodajte nonReentrant. Hooki v slogu ERC-777 ali zlonameren token lahko pokličejo nazaj v pogodbo.
  • Zunanje klice omejite na najmanjšo možno mero. Vesting pogodba nima nobenega razloga, da bi klicala poljubne naslove.
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. Časovni žigi#

  • Uporabljajte block.timestamp, ne številk blokov. Časi blokov se med verigami razlikujejo in se sčasoma spreminjajo; ista pogodba bo morda pozneje nameščena na L2.
  • Vpliv validatorjev na časovne žige je omejen na sekunde. Za načrte, merjene v mesecih, je to nepomembno, vendar ne gradite logike, ki je odvisna od sekundne natančnosti.
  • Časovne žige hranite kot uint64. To zadošča za vsak realističen načrt in se dobro pakira v shrambo.
  • Meje testirajte izrecno: eno sekundo pred cliffom, natanko ob cliffu, natanko ob koncu in dolgo po koncu.

7. Dogodki in preglednost#

Vsaka sprememba stanja naj odda dogodek: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, spremembe vlog in zaustavitve. Na dogodke se zanašajo indekserji, nadzorne plošče in vaša lastna podpora. Vključite identifikator načrta in zneske, ne samo naslovov. Vlagatelji in zaposleni bodo spraševali, koliko je že sproščenega, on-chain dogodki pa so najbolj verodostojen odgovor.

8. Kompromisi nadgradljivosti#

MožnostPrednostTveganje
Nespremenljiva pogodbaNajmočnejše jamstvo za upravičence, preprostejša revizija (audit)Napak ni mogoče popraviti; migracija zahteva novo pogodbo in sredstva
Nadgradljiv proxyNapake je mogoče popravitiKljuč za nadgradnjo lahko spremeni katero koli pravilo, napake v razporeditvi shrambe, širši obseg revizije
Nespremenljiva pogodba in tovarna (factory)Vsak nabor načrtov je izoliran, nove različice za nove dodelitveStare instance ohranijo stare napake

Pri vestingu je nespremenljivost pogosto boljša privzeta izbira: ves smisel pogodbe je v tem, da dogovora pozneje nihče ne more spremeniti. Če izberete proxy, vlogo za nadgradnje postavite za multisig in timelock, uporabite storage gaps ali imenski prostor shrambe (namespaced storage) ter v CI poganjajte OpenZeppelinova preverjanja varnosti nadgradenj.

9. Ukrepi v sili#

  • Zaustavitev lahko zaščiti pred neznano napako, vendar je zaustavitev, ki za vedno blokira release, tudi način, kako zamrzniti upravičence. Razmislite o omejitvi trajanja zaustavitve ali o tem, da izplačila ostanejo mogoča tudi, ko je ustvarjanje novih načrtov zaustavljeno.
  • Dokumentirajte, kdo lahko zaustavi pogodbo, pod katerimi pogoji in kako bo skupnost obveščena.
  • Izogibajte se funkcijam tipa „v sili dvigni vse“. Če je takšna funkcija neizogibna, mora biti za timelockom in vidna v dokumentaciji, ki jo berejo vlagatelji.

10. Testiranje#

Enotski testi so minimum. Pri vesting pogodbah veliko dodajo testi na podlagi lastnosti (property-based), saj mora matematika držati za vsak čas in vsak znesek.

  • Enotski testi: vsaka pot z revertom, vsak mejni časovni žig, preklic pred cliffom, preklic po popolni sprostitvi, več načrtov za istega upravičenca.
  • Fuzz testi: naključni total, duration in timestamp; preverite, da je vestedAmount monoton in nikoli ne preseže total.
  • Invariantni testi: Foundry naj kliče release, revoke, createSchedule in vm.warp v naključnem vrstnem redu, nato preverite globalne lastnosti.

Uporabne invariante:

  • Vsota izplačanih zneskov posameznega načrta nikoli ne preseže njegove alokacije.
  • Stanje tokenov v pogodbi je vedno vsaj totalCommitted.
  • Preklicani načrt pozneje nikoli ne pridobi dodatnega sproščenega zneska.
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. Statična analiza#

Slither poženite ob vsaki spremembi in njegov izpis obravnavajte kot čakalno vrsto za pregled, ne kot signal uspešno/neuspešno. Pri vesting pogodbah bodite pozorni na ugotovitve o reentrancyju, nepreverjene prenose, nevarne stroge enakosti pri stanjih in manjkajoče dogodke.

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

Predpregled s pomočjo AI, kakršen je naš AI pregledovalnik pametnih pogodb, je še en hiter prvi prehod, ki izpostavi sumljive vzorce, preden kodo pogleda človek.

12. Namestitev in verifikacija#

  • Namestitev izvedite s skriptami Foundry, ne z ročnimi transakcijami. Parametri živijo v konfiguracijskih datotekah pod nadzorom različic in se pregledujejo kot koda.
  • Najprej namestite na testno omrežje s povsem isto skripto in parametri, nato opravite poskusni zagon na forku glavnega omrežja.
  • Izvorno kodo takoj po namestitvi verificirajte v raziskovalcu blokov, z isto različico prevajalnika in istimi nastavitvami optimizatorja.
  • Dvakrat preverite decimalna mesta: alokacija 1.000.000 tokenov z 18 decimalnimi mesti je 1_000_000e18, ne 1_000_000.
  • Lastništvo v isti skripti prenesite na multisig in potrdite, da ključ, s katerim ste nameščali, nima več nobene vloge.

13. Operativna preverjanja#

  • Redno usklajujte: vsota alokacij načrtov, zmanjšana za izplačila, se mora ujemati z zavezanim zneskom in stanjem pogodbe.
  • Spremljajte dogodke in nastavite opozorila za nepričakovane preklice, spremembe vlog ali zaustavitve.
  • Vzdržujte javen ali vlagateljem namenjen pregled načrtov, da je na vprašanja mogoče odgovoriti z on-chain podatki.
  • Vadite ključne postopke: menjavo podpisnikov multisiga, ravnanje, ko upravičenec izgubi dostop do denarnice, in način obveščanja o zaustavitvi.

Sklepna opomba#

Kontrolni seznam in samodejni pregled zgodaj ujameta veliko težav, vendar nista nadomestek za neodvisno varnostno revizijo. Preden vesting pogodba hrani resnično vrednost, naj jo pregledajo ljudje, ki je niso napisali.

Če želite videti, kako se tega lotevamo v praksi, si oglejte naš predstavitveni komplet pogodb za tokene, ki vključuje vesting pogodbo s testi, ali preberite več o naših storitvah na področju blockchaina. Našo ekipo vodi tehnični vodja z več kot 20 leti izkušenj. Če želite, da pregledamo vašo tokenomiko ali zasnovo vestinga, nam pišite.

Imate v mislih projekt?

Povejte nam, kaj razvijate. Senior inženir odgovori v 24 urah ob delovnih dneh, v nekaj delovnih dneh pa prejmete iskreno oceno, jasen obseg in ponudbo s fiksno ceno ali po mejnikih.

Bi raje najprej pisali? Pišite nam

Vaš sogovornik: Ing. Ismet Mesic, Tehnični vodja.