Insights
Token vesting smart contract (ERC-20): security-checklist
Vestingberekening, herroeping, admin-keys, tokentransfers, invarianttests met Foundry en deploymentchecks voor veilige ERC-20 token vesting contracts.
Engineeringteam van sigmacode.io9 min leestijd
Op deze pagina (14)
- 1. De vestingberekening
- 2. Begunstigden en herroeping
- 3. Toegangscontrole en admin-keys
- 4. Omgaan met tokentransfers
- 5. Reentrancy en volgorde van calls
- 6. Timestamps
- 7. Events en transparantie
- 8. Afwegingen rond upgradeability
- 9. Noodmaatregelen
- 10. Testen
- 11. Statische analyse
- 12. Deployment en verificatie
- 13. Operationele controles
- Tot slot
Vestingcontracts lijken eenvoudig: tokens vastzetten, ze in de loop van de tijd uitkeren, klaar. In de praktijk houden ze jarenlang een groot deel van de supply van een project vast, komen founders, investeerders, medewerkers en multisigs eraan, en kijkt er na de deployment zelden nog iemand naar. Een kleine fout in de berekening of in het rechtenmodel blijft de hele vestingperiode live. Deze checklist bundelt de vragen die wij stellen wanneer we een ERC-20-vestingcontract ontwerpen of reviewen, van de rekenkunde tot de deployment en het dagelijkse beheer.
1. De vestingberekening#
De kern van elk vestingcontract is één functie die antwoord geeft op de vraag ‘hoeveel tokens zijn er op tijdstip t gevest?’. Bijna elke serieuze vestingbug zit hier.
Lineair schema en cliff#
- Definieer het schema met expliciete parameters:
start,cliff,duration,totalAllocation. Vermijd impliciete waarden die bij de deployment vanblock.timestampworden afgeleid. - Bepaal wat de cliff betekent. Gangbare modellen zijn ‘niets vóór de cliff, daarna lineair inlopen vanaf
start’ en ‘niets vóór de cliff, dan een bedrag ineens, daarna lineair’. Leg het gekozen model vast in NatSpec en in tests. - Valideer bij het aanmaken:
durationgroter dan nul,cliffniet nastart + duration,totalAllocationgroter dan nul, begunstigde niet het zero address. - Na
start + durationmoet het geveste bedrag exact gelijk zijn aantotalAllocation, niet ‘ongeveer’.
Afronding#
- Eerst vermenigvuldigen, dan delen.
total * elapsed / durationis correct;total / duration * elapsedverliest bij elke release ongemerkt tokens. - Afronding hoort altijd in het voordeel van het contract uit te vallen: rond wat de begunstigde kan claimen naar beneden af, nooit naar boven. De laatste release aan het einde van het schema veegt het overgebleven dust mee.
- Controleer op overflow bij grote allocaties met tokens met 18 decimalen. Solidity 0.8.x revert bij overflow, maar een revert binnen
vestedAmountkan elke claim blokkeren. GebruikMath.mulDivvan OpenZeppelin als het product groot kan worden.
Start in het verleden of in de toekomst#
- Een
startin het verleden is legitiem (grants aan medewerkers met terugwerkende kracht), maar betekent dat er direct een groot bedrag te claimen is. Maak daar een expliciete, gereviewde beslissing van, geen ongelukje door een verkeerde parameter. - Een
startver in de toekomst kan een typefout zijn (milliseconden in plaats van seconden is een klassieker). Bouw sanity-grenzen in de constructor of factory en in het deploymentscript in.
Een compacte referentie-implementatie van het schema:
// 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. Begunstigden en herroeping#
- Wie mag claimen? Alleen de begunstigde, of iedereen namens de begunstigde? Iedereen
release()laten aanroepen is prima zolang de tokens altijd naar de begunstigde gaan; het helpt bij herstel na een verloren key en bij automatisering. - Kan de begunstigde wijzigen? Zo ja, eis dan dat de huidige begunstigde het initieert (liefst een overdracht in twee stappen met acceptatie), en emit een event. Een begunstigde die een contract is, moet tokens kunnen ontvangen en gebruiken.
- Herroepingsmodel. Bepaal per schema of het herroepbaar is. Bij herroeping hoort het al geveste maar nog niet uitgekeerde bedrag opeisbaar te blijven voor de begunstigde; alleen het nog niet geveste restant gaat terug naar de treasury. Geveste tokens herroepen is een vertrouwensprobleem, niet alleen een codeprobleem.
- Herroeping moet definitief zijn. Een herroepen schema mag niet nog een keer herroepbaar zijn, mag niet verder vesten en mag de admin niet meer laten opnemen dan het nog niet geveste restant.
- Meerdere schema's per begunstigde. Identificeer schema's met een id, niet alleen met het adres, zodat een tweede grant de eerste niet overschrijft.
3. Toegangscontrole en admin-keys#
- Zet elke geprivilegieerde functie op een rij: schema's aanmaken, herroepen, pauzeren, surplus opnemen, upgraden. Elk daarvan is een aanvalsoppervlak als de key wordt gecompromitteerd.
- Gebruik
Ownable2StepofAccessControlmet gescheiden rollen in plaats van één almachtige owner. De rol die schema's aanmaakt hoeft niet de rol te zijn die geld kan opnemen. - Beheer adminrollen in een multisig, en overweeg een timelock voor alles wat tokens uit het contract verplaatst.
- De admin hoort nooit tokens te kunnen opnemen die aan schema's zijn toegezegd. Houd
totalCommittedbij en sta voor surplus alleen opname vanbalance - totalCommittedtoe. - Denk na over de eindtoestand: kunnen de adminrechten worden opgegeven zodra alle schema's zijn aangemaakt? Minder actieve keys betekent minder manieren waarop het mis kan gaan.
4. Omgaan met tokentransfers#
- Gebruik voor elke transfer
SafeERC20van OpenZeppelin. Sommige tokens geven geen boolean terug, andere gevenfalseterug in plaats van te reverten. - Fee-on-transfer-tokens. Als het contract wordt gefinancierd met een token dat een fee inhoudt, ontvangt het minder dan het nominale bedrag. Meet het saldo voor en na de financiering en leg vast wat er werkelijk is binnengekomen, of weiger zulke tokens expliciet.
- Rebasing tokens. Saldi die uit zichzelf veranderen, breken de aanname dat
balance == committed + surplus. Documenteer dat rebasing tokens niet worden ondersteund, of zet de boekhouding op in shares. - Leg het tokenadres vast als
immutableals het contract één token bedient. Willekeurige tokenadressen per schema accepteren vergroot het aanvalsoppervlak aanzienlijk. - Sta nooit toe dat het geveste token via een generieke
recoverERC20-functie wordt ‘gered’ zonder de toegezegde bedragen ervan af te trekken.
5. Reentrancy en volgorde van calls#
- Volg checks-effects-interactions: werk
releasedbij vóórdat jesafeTransferaanroept. - Zet
nonReentrantoprelease,revokeen elke opnamefunctie. Hooks in ERC-777-stijl of een kwaadaardig token kunnen terugbellen naar het contract. - Beperk externe calls tot het minimum. Een vestingcontract heeft geen reden om willekeurige adressen aan te roepen.
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. Timestamps#
- Gebruik
block.timestamp, geen blocknummers. Blocktijden verschillen per chain en veranderen in de loop van de tijd; hetzelfde contract kan later ook op een L2 worden gedeployd. - De invloed van validators op timestamps blijft beperkt tot seconden. Voor schema's die in maanden worden gemeten is dat irrelevant, maar bouw geen logica die afhangt van precisie op de seconde.
- Sla timestamps op als
uint64. Dat is genoeg voor elk realistisch schema en laat zich goed packen in storage. - Test de grenzen expliciet: één seconde vóór de cliff, precies op de cliff, precies op het einde en lang na het einde.
7. Events en transparantie#
Elke statewijziging hoort een event te emitten: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, rolwijzigingen en pauzes. Op events vertrouwen indexers, dashboards en je eigen supportteam. Neem de schema-id en de bedragen op, niet alleen adressen. Investeerders en medewerkers gaan vragen hoeveel er gevest is, en on-chain events zijn het geloofwaardigste antwoord.
8. Afwegingen rond upgradeability#
| Optie | Voordeel | Risico |
|---|---|---|
| Immutable contract | Sterkste garantie voor begunstigden, eenvoudigere audit | Bugs kunnen niet worden gefixt; migratie vraagt een nieuw contract en nieuwe financiering |
| Upgradeable proxy | Bugs kunnen worden gepatcht | De upgrade-key kan elke regel veranderen, fouten in de storage-layout, grotere auditscope |
| Immutable plus factory | Elke set schema's staat geïsoleerd, nieuwe versies voor nieuwe grants | Oude instanties houden oude bugs |
Voor vesting is immutability vaak de betere standaard: het hele punt van het contract is dat niemand de afspraak later kan veranderen. Kies je toch voor een proxy, zet de upgraderol dan achter een multisig en een timelock, gebruik storage gaps of namespaced storage, en draai de upgrade safety checks van OpenZeppelin in CI.
9. Noodmaatregelen#
- Een pauze kan beschermen tegen een onbekende bug, maar een pauze die
releasevoorgoed blokkeert is ook een manier om begunstigden te bevriezen. Overweeg om te begrenzen hoe lang een pauze kan duren, of om releases toe te staan terwijl alleen het aanmaken van nieuwe schema's is gepauzeerd. - Documenteer wie mag pauzeren, onder welke voorwaarden, en hoe de community wordt geïnformeerd.
- Vermijd functies van het type ‘emergency withdraw everything’. Als er toch een onvermijdelijk is, moet die achter een timelock zitten en zichtbaar zijn in de documentatie die investeerders lezen.
10. Testen#
Unittests zijn het minimum. Bij vestingcontracts voegen property-based tests veel toe, omdat de berekening voor elk tijdstip en elk bedrag moet kloppen.
- Unittests: elk revert-pad, elke grens-timestamp, herroeping vóór de cliff, herroeping na volledige vesting, meerdere schema's voor dezelfde begunstigde.
- Fuzztests: willekeurige
total,durationentimestamp; assert datvestedAmountmonotoon is en nooit boventotaluitkomt. - Invarianttests: laat Foundry
release,revoke,createScheduleenvm.warpin willekeurige volgorde aanroepen en controleer daarna globale eigenschappen.
Nuttige invarianten:
- De som van de uitgekeerde bedragen per schema komt nooit boven de allocatie uit.
- Het tokensaldo van het contract is altijd minstens
totalCommitted. - Een herroepen schema krijgt er daarna nooit meer gevest bedrag bij.
// 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. Statische analyse#
Draai Slither bij elke wijziging en behandel de output als een reviewwachtrij, niet als een signaal voor geslaagd of gezakt. Let bij vestingcontracts op reentrancy-bevindingen, ongecontroleerde transfers, gevaarlijke strikte gelijkheden op saldi en ontbrekende events.
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary
Een pre-review met AI, zoals onze AI-reviewer voor smart contracts, is nog een snelle eerste ronde die verdachte patronen markeert voordat een mens naar de code kijkt.
12. Deployment en verificatie#
- Script de deployment met Foundry-scripts, niet met handmatige transacties. Parameters staan in configuratiebestanden onder versiebeheer en worden gereviewd als code.
- Deploy eerst naar een testnet met exact hetzelfde script en dezelfde parameters, en doe daarna een dry run op een mainnet-fork.
- Verifieer de broncode direct na de deployment op de block explorer, met dezelfde compilerversie en optimizer-instellingen.
- Controleer de decimalen dubbel: een allocatie van 1.000.000 tokens met 18 decimalen is
1_000_000e18, niet1_000_000. - Draag ownership in hetzelfde script over aan de multisig, en bevestig dat de deployer-key geen rollen meer heeft.
13. Operationele controles#
- Reconcilieer regelmatig: de som van de allocaties per schema min de releases hoort overeen te komen met het toegezegde bedrag en het contractsaldo.
- Monitor events en stel alerts in op onverwachte herroepingen, rolwijzigingen of pauzes.
- Houd een overzicht van de schema's bij, openbaar of voor investeerders, zodat vragen met on-chain data kunnen worden beantwoord.
- Oefen de belangrijkste procedures: rotatie van multisig-signers, wat er gebeurt als een begunstigde de toegang tot zijn wallet verliest, en hoe een pauze zou worden gecommuniceerd.
Tot slot#
Een checklist en een geautomatiseerde review vangen veel problemen vroeg af, maar ze zijn geen vervanging voor een onafhankelijke security-audit. Laat een vestingcontract, voordat het echte waarde vasthoudt, reviewen door mensen die het niet zelf hebben geschreven.
Wil je zien hoe we dit in de praktijk aanpakken, bekijk dan onze token-suite-showcase, met daarin een vestingcontract met tests, of lees meer over onze blockchaindiensten. Ons team wordt geleid door een tech lead met meer dan 20 jaar ervaring, en we kijken graag mee naar je tokenomics of vestingontwerp. Neem gerust contact op.