Članci
Sigurnosni kontrolni popis za ERC-20 vesting ugovore
Matematika vestinga, opoziv, administratorski ključevi, prijenosi tokena, Foundry testovi invarijanti i provjere implementacije za ERC-20 vesting ugovore.
Inženjerski tim sigmacode.io9 min čitanja
Na ovoj stranici (14)
- 1. Matematika vestinga
- 2. Korisnici i opoziv
- 3. Kontrola pristupa i administratorski ključevi
- 4. Rukovanje prijenosima tokena
- 5. Reentrancy i redoslijed poziva
- 6. Vremenske oznake
- 7. Događaji i transparentnost
- 8. Kompromisi nadogradivosti
- 9. Mehanizmi za hitne slučajeve
- 10. Testiranje
- 11. Statička analiza
- 12. Implementacija i verifikacija
- 13. Operativne provjere
- Završna napomena
Ugovori za vesting izgledaju jednostavno: zaključaš tokene, oslobađaš ih tijekom vremena i gotovo. U praksi oni godinama drže velik dio ponude tokena nekog projekta, njima se koriste osnivači, investitori, zaposlenici i multisig novčanici, a nakon implementacije rijetko ih tko ponovno pogleda. Mala pogreška u izračunu ili u modelu ovlasti ostaje aktivna tijekom cijelog razdoblja vestinga. Ovaj kontrolni popis okuplja pitanja koja postavljamo kada dizajniramo ili pregledavamo ERC-20 vesting ugovor, od aritmetike do implementacije i svakodnevnog rada.
1. Matematika vestinga#
Srž svakog vesting ugovora jedna je funkcija koja odgovara na pitanje „koliko je tokena vestirano u trenutku t?”. Gotovo svaka ozbiljna pogreška u vestingu nalazi se upravo ovdje.
Linearni raspored i cliff#
- Definirajte raspored eksplicitnim parametrima:
start,cliff,duration,totalAllocation. Izbjegavajte implicitne vrijednosti izvedene izblock.timestampu trenutku implementacije. - Odlučite što cliff znači. Uobičajeni modeli su „ništa prije clifa, zatim linearno nadoknađivanje od
start” i „ništa prije clifa, zatim jednokratni iznos, pa linearno”. Odabrani model zapišite u NatSpec i u testove. - Provjerite pri stvaranju:
durationveći od nule,cliffne nakonstart + duration,totalAllocationveći od nule, korisnik nije nulta adresa. - Nakon
start + durationvestirani iznos mora biti točnototalAllocation, a ne „otprilike”.
Zaokruživanje#
- Najprije množite, zatim dijelite.
total * elapsed / durationje ispravno;total / duration * elapsedpri svakom oslobađanju neprimjetno gubi tokene. - Zaokruživanje uvijek treba ići u korist ugovora: iznos koji korisnik može preuzeti zaokružuje se prema dolje, nikada prema gore. Posljednje oslobađanje na kraju rasporeda preuzima preostale ostatke.
- Provjerite preljev kod velikih alokacija s tokenima od 18 decimala. Solidity 0.8.x prekida izvršavanje pri preljevu, ali revert unutar
vestedAmountmože blokirati svako preuzimanje. KoristiteMath.mulDiviz OpenZeppelina ako umnožak može postati velik.
Početak u prošlosti ili budućnosti#
startu prošlosti legitiman je (retroaktivne dodjele zaposlenicima), ali znači da je velik iznos odmah dostupan. Neka to bude izričita, provjerena odluka, a ne slučajnost zbog pogrešnog parametra.startdaleko u budućnosti može biti tipfeler (milisekunde umjesto sekundi klasičan su primjer). Dodajte granice razumnosti u konstruktor ili factory te u skriptu za implementaciju.
Sažeta referentna implementacija rasporeda:
// 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. Korisnici i opoziv#
- Tko može preuzeti tokene? Samo korisnik ili bilo tko u njegovo ime? Dopustiti bilo kome da pokrene
release()u redu je sve dok tokeni uvijek idu korisniku; to olakšava automatizaciju i oporavak pri izgubljenom ključu. - Može li se korisnik promijeniti? Ako može, promjenu mora pokrenuti trenutačni korisnik (idealno kao prijenos u dva koraka s prihvaćanjem) i mora se emitirati događaj. Ako je korisnik ugovor, mora moći primati i koristiti tokene.
- Model opoziva. Za svaki raspored odlučite je li opoziv moguć. Pri opozivu već vestirani, a neoslobođeni iznos treba ostati dostupan korisniku; samo nevestirani ostatak vraća se u treasury. Opoziv već vestiranih tokena problem je povjerenja, a ne samo koda.
- Opoziv mora biti konačan. Opozvani raspored ne smije se moći opozvati dvaput, ne smije nastaviti vestirati i ne smije administratoru omogućiti povlačenje više od nevestiranog ostatka.
- Više rasporeda po korisniku. Rasporede vodite po ID-u, a ne samo po adresi, kako druga dodjela ne bi prebrisala prvu.
3. Kontrola pristupa i administratorski ključevi#
- Popišite svaku privilegiranu funkciju: stvaranje rasporeda, opoziv, pauziranje, povlačenje viška, nadogradnju. Svaka od njih predstavlja površinu napada ako je ključ kompromitiran.
- Koristite
Ownable2StepiliAccessControls odvojenim ulogama umjesto jednog svemoćnog vlasnika. Uloga koja stvara rasporede ne mora biti ista kao uloga koja može povlačiti sredstva. - Administratorske uloge držite u multisig novčaniku i razmislite o timelocku za sve što premješta tokene iz ugovora.
- Administrator nikada ne smije moći povući tokene koji su obećani rasporedima. Vodite
totalCommittedi za višak dopustite samo povlačenje iznosabalance - totalCommitted. - Planirajte završno stanje: mogu li se administratorska prava odreći kada su svi rasporedi stvoreni? Manje aktivnih ključeva znači manje mogućih kvarova.
4. Rukovanje prijenosima tokena#
- Za svaki prijenos koristite OpenZeppelin
SafeERC20. Neki tokeni ne vraćaju boolean, a drugi vraćajufalseumjesto da prekinu izvršavanje. - Tokeni s naknadom pri prijenosu. Ako se ugovor financira tokenom koji naplaćuje naknadu, primit će manje od nominalnog iznosa. Izmjerite stanje prije i nakon financiranja i evidentirajte ono što je stvarno stiglo ili takve tokene izričito odbijte.
- Rebasing tokeni. Stanja koja se mijenjaju sama od sebe narušavaju pretpostavku
balance == committed + surplus. Ili dokumentirajte da rebasing tokeni nisu podržani ili računovodstvo temeljite na udjelima. - Ako ugovor služi jednom tokenu, adresu tokena postavite kao
immutable. Prihvaćanje proizvoljnih adresa tokena po rasporedu znatno povećava površinu napada. - Nikada ne dopustite „spašavanje” vestiranog tokena generičkom funkcijom
recoverERC20bez oduzimanja obećanih iznosa.
5. Reentrancy i redoslijed poziva#
- Slijedite obrazac checks-effects-interactions: ažurirajte
releasedprije pozivasafeTransfer. - Dodajte
nonReentrantnarelease,revokei svaku funkciju za povlačenje. Hookovi u stilu ERC-777 ili zlonamjerni token mogu ponovno pozvati ugovor. - Vanjske pozive svedite na minimum. Vesting ugovor nema razloga pozivati proizvoljne adrese.
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. Vremenske oznake#
- Koristite
block.timestamp, a ne brojeve blokova. Vremena blokova razlikuju se među lancima i mijenjaju se tijekom vremena; isti ugovor možda će kasnije biti implementiran na L2 mreži. - Utjecaj validatora na vremenske oznake ograničen je na sekunde. Za rasporede koji se mjere mjesecima to je nevažno, ali ne gradite logiku koja ovisi o preciznosti na razini sekunde.
- Vremenske oznake pohranjujte kao
uint64. To je dovoljno za svaki realan raspored i dobro se pakira u storage. - Granice testirajte izričito: sekundu prije clifa, točno na cliffu, točno na kraju i dugo nakon kraja.
7. Događaji i transparentnost#
Svaka promjena stanja treba emitirati događaj: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, promjene uloga i pauze. Na događaje se oslanjaju indekseri, nadzorne ploče i vaš vlastiti tim za podršku. Uključite ID rasporeda i iznose, a ne samo adrese. Investitori i zaposlenici pitat će koliko je vestirano, a događaji na lancu najvjerodostojniji su odgovor.
8. Kompromisi nadogradivosti#
| Opcija | Prednost | Rizik |
|---|---|---|
| Nepromjenjiv ugovor | Najjače jamstvo za korisnike, jednostavniji audit | Pogreške se ne mogu ispraviti; migracija zahtijeva novi ugovor i sredstva |
| Nadogradivi proxy | Pogreške se mogu zakrpati | Ključ za nadogradnju može promijeniti svako pravilo, pogreške u rasporedu storagea, veći opseg audita |
| Nepromjenjiv ugovor uz factory | Svaki skup rasporeda je izoliran, nove verzije za nove dodjele | Stare instance zadržavaju stare pogreške |
Za vesting je nepromjenjivost često bolji zadani izbor: cijela svrha ugovora jest da nitko naknadno ne može promijeniti dogovor. Ako odaberete proxy, ulogu za nadogradnju stavite iza multisiga i timelocka, koristite storage gaps ili namespaced storage te u CI-ju pokrećite OpenZeppelinove provjere sigurnosti nadogradnje.
9. Mehanizmi za hitne slučajeve#
- Pauza može zaštititi od nepoznate pogreške, ali pauza koja trajno blokira
releaseujedno je način da se korisnici zamrznu. Razmislite o ograničavanju trajanja pauze ili o dopuštanju oslobađanja čak i dok je pauzirano samo stvaranje novih rasporeda. - Dokumentirajte tko može pauzirati, pod kojim uvjetima i kako će zajednica biti obaviještena.
- Izbjegavajte funkcije tipa „u hitnom slučaju povuci sve”. Ako je takva funkcija neizbježna, mora biti iza timelocka i vidljiva u dokumentaciji koju čitaju investitori.
10. Testiranje#
Jedinični testovi su minimum. Kod vesting ugovora testovi temeljeni na svojstvima donose veliku dodanu vrijednost jer matematika mora vrijediti za svako vrijeme i svaki iznos.
- Jedinični testovi: svaki put do reverta, svaka granična vremenska oznaka, opoziv prije clifa, opoziv nakon potpunog vestinga, više rasporeda za istog korisnika.
- Fuzz testovi: nasumične vrijednosti
total,durationitimestamp; provjerite da jevestedAmountmonoton i da nikada ne premašujetotal. - Testovi invarijanti: neka Foundry poziva
release,revoke,createScheduleivm.warpnasumičnim redoslijedom, a zatim provjerite globalna svojstva.
Korisne invarijante:
- Zbroj oslobođenih iznosa po rasporedu nikada ne premašuje njegovu alokaciju.
- Stanje tokena u ugovoru uvijek je najmanje
totalCommitted. - Opozvani raspored nakon toga nikada ne dobiva dodatni vestirani iznos.
// 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čka analiza#
Slither pokrenite pri svakoj promjeni i njegove rezultate tretirajte kao popis za pregled, a ne kao signal prolaza ili pada. Kod vesting ugovora obratite pozornost na nalaze vezane uz reentrancy, neprovjerene prijenose, opasne stroge jednakosti nad stanjima i nedostajuće događaje.
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary
Predpregled uz pomoć umjetne inteligencije, primjerice naš Smart Contract AI Reviewer, još je jedan brz prvi prolaz koji ističe sumnjive obrasce prije nego što kod pogleda čovjek.
12. Implementacija i verifikacija#
- Implementaciju skriptirajte Foundry skriptama, a ne ručnim transakcijama. Parametri se nalaze u konfiguracijskim datotekama pod kontrolom verzija i pregledavaju se kao kod.
- Najprije implementirajte na testnu mrežu s potpuno istom skriptom i parametrima, a zatim napravite probni rad na forku glavne mreže.
- Izvorni kod verificirajte na block exploreru odmah nakon implementacije, s istom verzijom kompajlera i istim postavkama optimizatora.
- Dvaput provjerite decimale: alokacija od 1.000.000 tokena s 18 decimala iznosi
1_000_000e18, a ne1_000_000. - Vlasništvo prenesite na multisig u istoj skripti i potvrdite da ključ kojim je obavljena implementacija više nema nijednu ulogu.
13. Operativne provjere#
- Redovito usklađujte: zbroj alokacija rasporeda umanjen za oslobođene iznose treba odgovarati obećanom iznosu i stanju ugovora.
- Pratite događaje i postavite upozorenja za neočekivane opozive, promjene uloga ili pauze.
- Održavajte javni pregled rasporeda ili pregled za investitore, kako bi se na pitanja moglo odgovoriti podacima s lanca.
- Uvježbajte ključne postupke: rotaciju potpisnika multisiga, što učiniti ako korisnik izgubi pristup novčaniku i kako bi se komunicirala pauza.
Završna napomena#
Kontrolni popis i automatizirani pregled rano otkrivaju mnoge probleme, ali nisu zamjena za neovisni sigurnosni audit. Prije nego što vesting ugovor počne upravljati stvarnom vrijednošću, neka ga pregledaju ljudi koji ga nisu napisali.
Ako želite vidjeti kako to radimo u praksi, pogledajte naš prikaz token suitea, koji uključuje vesting ugovor s testovima, ili saznajte više o našim blockchain uslugama. Naš tim vodi tehnički voditelj s više od 20 godina iskustva i rado ćemo pregledati vašu tokenomiku ili dizajn vestinga, samo nam se javite.