Approfondimenti
Contratti di vesting ERC-20: checklist di sicurezza
Matematica del vesting, revoca, chiavi admin, gestione dei token, invariant test con Foundry e controlli di deployment per contratti di vesting ERC-20 sicuri.
Team di engineering di sigmacode.io9 min di lettura
In questa pagina (14)
- 1. La matematica del vesting
- 2. Beneficiari e revoca
- 3. Controllo degli accessi e chiavi admin
- 4. Gestione dei trasferimenti di token
- 5. Reentrancy e ordine delle chiamate
- 6. Timestamp
- 7. Eventi e trasparenza
- 8. Upgradeability: i compromessi
- 9. Controlli di emergenza
- 10. Test
- 11. Analisi statica
- 12. Deployment e verifica
- 13. Controlli operativi
- Nota finale
I contratti di vesting sembrano semplici: si bloccano i token, li si rilascia nel tempo, fine. In pratica custodiscono per anni una quota importante della supply di un progetto, vengono usati da founder, investitori, dipendenti e multisig, e dopo il deployment raramente qualcuno li riguarda. Un piccolo errore nei calcoli o nel modello dei permessi resta attivo per tutta la durata del vesting. Questa checklist raccoglie le domande che ci poniamo quando progettiamo o revisioniamo un contratto di vesting ERC-20, dall'aritmetica fino al deployment e alla gestione quotidiana.
1. La matematica del vesting#
Il cuore di ogni contratto di vesting è una funzione che risponde alla domanda «quanti token sono maturati al tempo t?». Quasi tutti i bug seri di vesting si annidano qui.
Piano lineare e cliff#
- Definite il piano con parametri espliciti:
start,cliff,duration,totalAllocation. Evitate valori impliciti derivati dablock.timestampal momento del deployment. - Decidete che cosa significa il cliff. I modelli più comuni sono «nulla prima del cliff, poi recupero lineare a partire da
start» e «nulla prima del cliff, poi un importo una tantum, poi lineare». Mettete per iscritto il modello scelto in NatSpec e nei test. - Validate alla creazione:
durationmaggiore di zero,cliffnon successivo astart + duration,totalAllocationmaggiore di zero, beneficiario diverso dall'indirizzo zero. - Dopo
start + durationl'importo maturato deve essere esattamente pari atotalAllocation, non «all'incirca».
Arrotondamento#
- Prima si moltiplica, poi si divide.
total * elapsed / durationè corretto;total / duration * elapsedperde token in silenzio a ogni rilascio. - L'arrotondamento deve sempre favorire il contratto: ciò che il beneficiario può riscuotere si arrotonda per difetto, mai per eccesso. Il rilascio finale, al termine del piano, raccoglie gli eventuali residui.
- Verificate l'overflow sulle allocazioni elevate con token a 18 decimali. Solidity 0.8.x va in revert in caso di overflow, ma un revert dentro
vestedAmountpuò bloccare qualsiasi riscossione. UsateMath.mulDivdi OpenZeppelin se il prodotto può diventare grande.
Start nel passato o nel futuro#
- Uno
startnel passato è legittimo (assegnazioni retrodatate ai dipendenti), ma comporta che un importo consistente sia riscuotibile da subito. Deve essere una decisione esplicita e revisionata, non l'effetto accidentale di un parametro sbagliato. - Uno
startmolto lontano nel futuro può essere un errore di battitura (millisecondi al posto dei secondi è un classico). Aggiungete limiti di plausibilità nel costruttore o nella factory e nello script di deployment.
Un'implementazione di riferimento compatta del piano:
// 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. Beneficiari e revoca#
- Chi può riscuotere? Solo il beneficiario, oppure chiunque per suo conto? Permettere a chiunque di attivare
release()va bene, purché i token finiscano sempre al beneficiario; aiuta in caso di chiavi smarrite e nell'automazione. - Il beneficiario può cambiare? Se sì, richiedete che sia il beneficiario attuale ad avviare il cambio (idealmente un trasferimento in due passaggi con accettazione) ed emettete un evento. Un beneficiario che è un contratto deve essere in grado di ricevere e utilizzare i token.
- Modello di revoca. Decidete per ogni piano se è revocabile. In caso di revoca, l'importo già maturato ma non ancora rilasciato deve restare riscuotibile dal beneficiario; solo il residuo non maturato torna alla treasury. Revocare token già maturati è un problema di fiducia, non soltanto di codice.
- La revoca deve essere definitiva. Un piano revocato non deve poter essere revocato due volte, non deve continuare a maturare e non deve consentire all'admin di prelevare più del residuo non maturato.
- Più piani per beneficiario. Identificate i piani con un id, non con il solo indirizzo, così una seconda assegnazione non sovrascrive la prima.
3. Controllo degli accessi e chiavi admin#
- Elencate ogni funzione privilegiata: creazione dei piani, revoca, pausa, prelievo dell'eccedenza, upgrade. Ognuna è una superficie d'attacco se la chiave viene compromessa.
- Usate
Ownable2StepoAccessControlcon ruoli separati invece di un unico owner onnipotente. Il ruolo che crea i piani non deve per forza coincidere con quello che può prelevare fondi. - Tenete i ruoli admin in un multisig e valutate un timelock per tutto ciò che sposta token fuori dal contratto.
- L'admin non deve mai poter prelevare token impegnati nei piani. Tenete traccia di
totalCommittede consentite, per l'eccedenza, solo il prelievo dibalance - totalCommitted. - Pianificate lo stato finale: si può rinunciare ai diritti di admin una volta creati tutti i piani? Meno chiavi attive significa meno modi in cui le cose possono andare storte.
4. Gestione dei trasferimenti di token#
- Usate
SafeERC20di OpenZeppelin per ogni trasferimento. Alcuni token non restituiscono un booleano, altri restituisconofalseinvece di andare in revert. - Token fee-on-transfer. Se il contratto viene finanziato con un token che trattiene una commissione, riceve meno dell'importo nominale. Misurate il saldo prima e dopo il finanziamento e registrate quanto è arrivato davvero, oppure rifiutate esplicitamente questi token.
- Token rebasing. Saldi che cambiano da soli rompono l'assunzione
balance == committed + surplus. O documentate che i token rebasing non sono supportati, o progettate la contabilità in quote. - Fissate l'indirizzo del token come
immutablese il contratto serve un solo token. Accettare indirizzi di token arbitrari per ogni piano amplia notevolmente la superficie d'attacco. - Non permettete mai di «recuperare» il token in vesting tramite una funzione generica
recoverERC20senza sottrarre gli importi impegnati.
5. Reentrancy e ordine delle chiamate#
- Seguite il pattern checks-effects-interactions: aggiornate
releasedprima di chiamaresafeTransfer. - Aggiungete
nonReentrantarelease,revokee a ogni funzione di prelievo. Hook in stile ERC-777 o un token malevolo possono richiamare il contratto. - Riducete al minimo le chiamate esterne. Un contratto di vesting non ha motivo di chiamare indirizzi arbitrari.
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. Timestamp#
- Usate
block.timestamp, non i numeri di blocco. I tempi di blocco variano da una chain all'altra e cambiano nel tempo; dello stesso contratto si potrebbe fare in seguito il deployment su una L2. - L'influenza dei validatori sui timestamp è limitata a pochi secondi. Per piani misurati in mesi è irrilevante, ma non costruite logiche che dipendano da una precisione al secondo.
- Memorizzate i timestamp come
uint64. Basta per qualsiasi piano realistico e si impacchetta bene nello storage. - Testate esplicitamente i casi limite: un secondo prima del cliff, esattamente al cliff, esattamente alla fine e molto dopo la fine.
7. Eventi e trasparenza#
Ogni cambiamento di stato dovrebbe emettere un evento: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, cambi di ruolo e pause. Sugli eventi fanno affidamento gli indexer, le dashboard e il vostro stesso team di supporto. Includete l'id del piano e gli importi, non solo gli indirizzi. Investitori e dipendenti chiederanno quanto è maturato, e gli eventi on-chain sono la risposta più credibile.
8. Upgradeability: i compromessi#
| Opzione | Vantaggio | Rischio |
|---|---|---|
| Contratto immutabile | La garanzia più forte per i beneficiari, audit più semplice | I bug non si possono correggere; la migrazione richiede un nuovo contratto e nuovi fondi |
| Proxy aggiornabile | I bug si possono correggere | La chiave di upgrade può cambiare qualsiasi regola, errori nel layout dello storage, perimetro di audit più ampio |
| Immutabile più factory | Ogni insieme di piani è isolato, nuove versioni per le nuove assegnazioni | Le vecchie istanze conservano i vecchi bug |
Per il vesting l'immutabilità è spesso la scelta predefinita migliore: il senso stesso del contratto è che nessuno possa cambiare l'accordo in un secondo momento. Se scegliete un proxy, mettete il ruolo di upgrade dietro un multisig e un timelock, usate storage gap o namespaced storage ed eseguite in CI i controlli di sicurezza degli upgrade di OpenZeppelin.
9. Controlli di emergenza#
- Una pausa può proteggere da un bug sconosciuto, ma una pausa che blocca
releaseper sempre è anche un modo per congelare i beneficiari. Valutate di limitare la durata massima di una pausa, oppure di consentire i rilasci anche quando la creazione di nuovi piani è in pausa. - Documentate chi può mettere in pausa, a quali condizioni e come verrà informata la community.
- Evitate funzioni del tipo «prelievo di emergenza di tutto». Se una è inevitabile, deve stare dietro un timelock ed essere visibile nella documentazione che gli investitori leggono.
10. Test#
Gli unit test sono il minimo. Per i contratti di vesting i test property-based aggiungono molto valore, perché la matematica deve reggere per qualsiasi istante e qualsiasi importo.
- Unit test: ogni percorso di revert, ogni timestamp limite, revoca prima del cliff, revoca a vesting completato, più piani per lo stesso beneficiario.
- Fuzz test:
total,durationetimestampcasuali; verificate chevestedAmountsia monotona e non superi maitotal. - Invariant test: lasciate che Foundry chiami
release,revoke,createScheduleevm.warpin ordine casuale, poi controllate le proprietà globali.
Invarianti utili:
- La somma degli importi rilasciati per ciascun piano non supera mai la sua allocazione.
- Il saldo in token del contratto è sempre almeno pari a
totalCommitted. - Un piano revocato non accumula più alcun importo maturato dopo la revoca.
// 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. Analisi statica#
Eseguite Slither a ogni modifica e trattate il suo output come una coda di revisione, non come un segnale di promosso/bocciato. Nei contratti di vesting prestate attenzione ai rilievi di reentrancy, ai trasferimenti non controllati, alle uguaglianze strette pericolose sui saldi e agli eventi mancanti.
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary
Una pre-review assistita dall'AI, come il nostro Reviewer AI per smart contract, è un altro primo passaggio rapido che mette in evidenza i pattern sospetti prima che una persona guardi il codice.
12. Deployment e verifica#
- Automatizzate il deployment con gli script di Foundry, non con transazioni manuali. I parametri stanno in file di configurazione sotto controllo di versione e vengono revisionati come il codice.
- Fate prima il deployment su una testnet con lo stesso identico script e gli stessi parametri, poi fate una prova generale su un fork della mainnet.
- Verificate il codice sorgente sul block explorer subito dopo il deployment, con la stessa versione del compilatore e le stesse impostazioni dell'optimizer.
- Ricontrollate i decimali: un'allocazione di 1.000.000 di token con 18 decimali è
1_000_000e18, non1_000_000. - Trasferite l'ownership al multisig nello stesso script e verificate che alla chiave del deployer non resti alcun ruolo.
13. Controlli operativi#
- Riconciliate con regolarità: la somma delle allocazioni dei piani meno i rilasci deve corrispondere all'importo impegnato e al saldo del contratto.
- Monitorate gli eventi e fate scattare un alert in caso di revoche, cambi di ruolo o pause inattesi.
- Mantenete una panoramica dei piani, pubblica o riservata agli investitori, così che alle domande si possa rispondere con i dati on-chain.
- Provate le procedure chiave: la rotazione dei firmatari del multisig, che cosa succede se un beneficiario perde l'accesso al proprio wallet e come verrebbe comunicata una pausa.
Nota finale#
Una checklist e una revisione automatizzata intercettano molti problemi in anticipo, ma non sostituiscono un audit di sicurezza indipendente. Prima che un contratto di vesting custodisca valore reale, fatelo esaminare da persone che non lo hanno scritto.
Se volete vedere come affrontiamo tutto questo in pratica, date un'occhiata al nostro showcase della token suite, che include un contratto di vesting con i relativi test, oppure scoprite di più sui nostri servizi blockchain. Il nostro team è guidato da un tech lead con oltre 20 anni di esperienza, e saremo lieti di esaminare la vostra tokenomics o il vostro design di vesting: basta contattarci.