Č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. Matematika vestinga
- 2. Upravičenci in preklic
- 3. Nadzor dostopa in administratorski ključi
- 4. Ravnanje s prenosi tokenov
- 5. Reentrancy in vrstni red klicev
- 6. Časovni žigi
- 7. Dogodki in preglednost
- 8. Kompromisi nadgradljivosti
- 9. Ukrepi v sili
- 10. Testiranje
- 11. Statična analiza
- 12. Namestitev in verifikacija
- 13. Operativna preverjanja
- 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 izblock.timestampob 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:
durationje večji od nič,cliffni postart + duration,totalAllocationje večji od nič, upravičenec ni ničelni naslov. - Po
start + durationmora biti sproščeni znesek natanko enaktotalAllocation, ne „približno“.
Zaokroževanje#
- Najprej množite, nato delite.
total * elapsed / durationje pravilno;total / duration * elapsedpri 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
vestedAmountzaklene vsak prevzem. Če lahko zmnožek postane velik, uporabiteMath.mulDiviz knjižnice OpenZeppelin.
Začetek v preteklosti ali prihodnosti#
startv 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.startdaleč 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:
// 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
Ownable2StepaliAccessControlz 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
totalCommittedin za presežek dovolite le dvigbalance - 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 vrnejofalse, 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:
releasedposodobite pred klicemsafeTransfer. - Funkcijam
release,revokein vsaki funkciji za dvig dodajtenonReentrant. 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.
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žnost | Prednost | Tveganje |
|---|---|---|
| Nespremenljiva pogodba | Najmočnejše jamstvo za upravičence, preprostejša revizija (audit) | Napak ni mogoče popraviti; migracija zahteva novo pogodbo in sredstva |
| Nadgradljiv proxy | Napake je mogoče popraviti | Ključ 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 dodelitve | Stare 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,durationintimestamp; preverite, da jevestedAmountmonoton in nikoli ne presežetotal. - Invariantni testi: Foundry naj kliče
release,revoke,createScheduleinvm.warpv 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.
// 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.
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, ne1_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.