Saltar al contenido

Artículos

Checklist de seguridad para contratos de vesting ERC-20

Checklist de seguridad para contratos de vesting ERC-20: cálculo del vesting, revocación, claves de administración, invariantes con Foundry y despliegue.

Equipo de ingeniería de sigmacode.io9 min de lectura

En esta página (14)
  1. 1. Cálculo del vesting
  2. 2. Beneficiarios y revocación
  3. 3. Control de acceso y claves de administración
  4. 4. Gestión de las transferencias de tokens
  5. 5. Reentrada y orden de las llamadas
  6. 6. Marcas de tiempo
  7. 7. Eventos y transparencia
  8. 8. Ventajas e inconvenientes de la actualizabilidad
  9. 9. Controles de emergencia
  10. 10. Tests
  11. 11. Análisis estático
  12. 12. Despliegue y verificación
  13. 13. Controles operativos
  14. Nota final

Los contratos de vesting parecen sencillos: bloqueas tokens, los liberas con el tiempo y listo. En la práctica custodian durante años una gran parte del suministro de un proyecto, los tocan fundadores, inversores, empleados y wallets multisig, y casi nunca se revisan una vez desplegados. Un pequeño error en los cálculos o en el modelo de permisos sigue activo durante todo el periodo de vesting. Esta checklist reúne las preguntas que nos hacemos cuando diseñamos o revisamos un contrato de vesting ERC-20, desde la aritmética hasta el despliegue y la operación diaria.

1. Cálculo del vesting#

El núcleo de todo contrato de vesting es una función que responde a «¿cuántos tokens están consolidados en el instante t?». Casi todos los bugs graves de vesting viven aquí.

Calendario lineal y cliff#

  • Define el calendario con parámetros explícitos: start, cliff, duration, totalAllocation. Evita valores implícitos derivados de block.timestamp en el momento del despliegue.
  • Decide qué significa el cliff. Los modelos habituales son «nada antes del cliff y, después, recuperación lineal desde start» y «nada antes del cliff, después un pago único y, a continuación, lineal». Deja por escrito el modelo elegido en NatSpec y en los tests.
  • Valida en el momento de la creación: duration mayor que cero, cliff no posterior a start + duration, totalAllocation mayor que cero y beneficiario distinto de la dirección cero.
  • Después de start + duration, el importe consolidado debe ser exactamente totalAllocation, no «aproximadamente».

Redondeo#

  • Multiplica antes de dividir. total * elapsed / duration es correcto; total / duration * elapsed pierde tokens en silencio en cada liberación.
  • El redondeo debe favorecer siempre al contrato: redondea a la baja lo que el beneficiario puede reclamar, nunca al alza. La última liberación, al final del calendario, barre cualquier resto.
  • Comprueba el desbordamiento con asignaciones grandes en tokens de 18 decimales. Solidity 0.8.x revierte en caso de overflow, pero un revert dentro de vestedAmount puede bloquear todas las reclamaciones. Usa Math.mulDiv de OpenZeppelin si el producto puede llegar a ser grande.

Inicio en el pasado o en el futuro#

  • Un start en el pasado es legítimo (asignaciones a empleados con fecha retroactiva), pero implica que un importe grande se puede reclamar de inmediato. Haz que sea una decisión explícita y revisada, no el accidente de un parámetro equivocado.
  • Un start muy lejano en el futuro puede ser una errata (milisegundos en lugar de segundos es un clásico). Añade límites de plausibilidad en el constructor o en la factory y en el script de despliegue.

Una implementación de referencia compacta del calendario:

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. Beneficiarios y revocación#

  • ¿Quién puede reclamar? ¿Solo el beneficiario, o cualquiera en su nombre? Permitir que cualquiera lance release() no es un problema siempre que los tokens vayan siempre al beneficiario; ayuda en la recuperación ante claves perdidas y en la automatización.
  • ¿Puede cambiar el beneficiario? Si es así, exige que lo inicie el beneficiario actual (lo ideal es una transferencia en dos pasos con aceptación) y emite un evento. Un beneficiario que sea un contrato debe poder recibir y usar los tokens.
  • Modelo de revocación. Decide para cada calendario si es revocable. Al revocar, el importe ya consolidado pero aún no liberado debe seguir siendo reclamable por el beneficiario; solo el resto no consolidado vuelve a la tesorería. Revocar tokens consolidados es un problema de confianza, no solo de código.
  • La revocación debe ser definitiva. Un calendario revocado no debe poder revocarse dos veces, no debe seguir consolidando y no debe permitir que el administrador retire más que el resto no consolidado.
  • Varios calendarios por beneficiario. Indexa los calendarios por un id, no solo por dirección, para que una segunda asignación no sobrescriba la primera.

3. Control de acceso y claves de administración#

  • Enumera todas las funciones privilegiadas: crear calendarios, revocar, pausar, retirar excedentes, actualizar. Cada una es una superficie de ataque si la clave se ve comprometida.
  • Usa Ownable2Step o AccessControl con roles separados en lugar de un único owner todopoderoso. El rol que crea calendarios no tiene por qué ser el que puede retirar fondos.
  • Mantén los roles de administración en una multisig y plantéate un timelock para todo lo que saque tokens del contrato.
  • El administrador nunca debería poder retirar tokens comprometidos en calendarios. Lleva la cuenta de totalCommitted y permite retirar como excedente únicamente balance - totalCommitted.
  • Planifica el estado final: ¿se puede renunciar a los derechos de administración una vez creados todos los calendarios? Menos claves activas significa menos modos de fallo.

4. Gestión de las transferencias de tokens#

  • Usa SafeERC20 de OpenZeppelin en todas las transferencias. Algunos tokens no devuelven un booleano y otros devuelven false en lugar de revertir.
  • Tokens fee-on-transfer. Si el contrato se financia con un token que cobra una comisión, recibe menos que el importe nominal. Mide el saldo antes y después de la financiación y registra lo que ha llegado realmente, o rechaza esos tokens de forma explícita.
  • Tokens rebasing. Los saldos que cambian por sí solos rompen el supuesto de que balance == committed + surplus. Documenta que los tokens rebasing no se admiten o diseña la contabilidad en participaciones (shares).
  • Fija la dirección del token como immutable si el contrato sirve a un único token. Aceptar direcciones de token arbitrarias por calendario amplía considerablemente la superficie de ataque.
  • No permitas nunca que el token en vesting se «rescate» mediante una función genérica recoverERC20 sin restar los importes comprometidos.

5. Reentrada y orden de las llamadas#

  • Sigue checks-effects-interactions: actualiza released antes de llamar a safeTransfer.
  • Añade nonReentrant a release, a revoke y a cualquier función de retirada. Los hooks al estilo ERC-777 o un token malicioso pueden volver a llamar al contrato.
  • Reduce las llamadas externas al mínimo. Un contrato de vesting no tiene ningún motivo para llamar a direcciones arbitrarias.
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. Marcas de tiempo#

  • Usa block.timestamp, no números de bloque. Los tiempos de bloque difieren entre cadenas y cambian con el tiempo; el mismo contrato puede desplegarse más adelante en una L2.
  • La influencia de los validadores sobre las marcas de tiempo se limita a unos segundos. Es irrelevante para calendarios que se miden en meses, pero no construyas lógica que dependa de una precisión de segundos.
  • Guarda las marcas de tiempo como uint64. Basta para cualquier calendario realista y se empaqueta bien en el storage.
  • Prueba los límites de forma explícita: un segundo antes del cliff, justo en el cliff, justo al final y mucho después del final.

7. Eventos y transparencia#

Cada cambio de estado debería emitir un evento: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, cambios de rol y pausas. De los eventos dependen los indexadores, los dashboards y tu propio equipo de soporte. Incluye el id del calendario y los importes, no solo las direcciones. Inversores y empleados preguntarán cuánto está consolidado, y los eventos on-chain son la respuesta más creíble.

8. Ventajas e inconvenientes de la actualizabilidad#

OpciónVentajaRiesgo
Contrato inmutableLa garantía más sólida para los beneficiarios, auditoría más sencillaLos bugs no se pueden corregir; migrar exige un contrato nuevo y nuevos fondos
Proxy actualizableLos bugs se pueden parchearLa clave de actualización puede cambiar cualquier regla, errores en el layout de storage, mayor alcance de auditoría
Inmutable con factoryCada conjunto de calendarios queda aislado, versiones nuevas para asignaciones nuevasLas instancias antiguas conservan los bugs antiguos

En vesting, la inmutabilidad suele ser la mejor opción por defecto: la razón de ser del contrato es que nadie pueda cambiar el trato más adelante. Si eliges un proxy, pon el rol de actualización detrás de una multisig y un timelock, usa storage gaps o namespaced storage y ejecuta en CI las comprobaciones de seguridad de actualizaciones de OpenZeppelin.

9. Controles de emergencia#

  • Una pausa puede proteger frente a un bug desconocido, pero una pausa que bloquea release para siempre también es una forma de congelar a los beneficiarios. Plantéate limitar cuánto puede durar una pausa, o permitir las liberaciones incluso mientras la creación de calendarios nuevos está en pausa.
  • Documenta quién puede pausar, en qué condiciones y cómo se informará a la comunidad.
  • Evita las funciones de «retirada de emergencia de todo». Si alguna es inevitable, debe estar detrás de un timelock y figurar en la documentación que leen los inversores.

10. Tests#

Los tests unitarios son el mínimo. En los contratos de vesting, los tests basados en propiedades aportan mucho valor, porque los cálculos deben cumplirse para cualquier instante y cualquier importe.

  • Tests unitarios: cada camino de revert, cada marca de tiempo límite, revocación antes del cliff, revocación tras el vesting completo y varios calendarios para el mismo beneficiario.
  • Tests de fuzzing: total, duration y timestamp aleatorios; comprueba que vestedAmount es monótona y nunca supera total.
  • Tests de invariantes: deja que Foundry llame a release, revoke, createSchedule y vm.warp en orden aleatorio y comprueba después las propiedades globales.

Invariantes útiles:

  • La suma de los importes liberados de cada calendario nunca supera su asignación.
  • El saldo de tokens del contrato es siempre, como mínimo, totalCommitted.
  • Un calendario revocado no gana importe consolidado después.
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. Análisis estático#

Ejecuta Slither con cada cambio y trata su salida como una cola de revisión, no como una señal de apto o no apto. En los contratos de vesting, presta atención a los hallazgos de reentrada, las transferencias sin comprobar, las igualdades estrictas peligrosas sobre saldos y los eventos que faltan.

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

Una revisión previa asistida por IA, como nuestro Revisor de smart contracts con IA, es otra primera pasada rápida que señala patrones sospechosos antes de que una persona mire el código.

12. Despliegue y verificación#

  • Automatiza el despliegue con scripts de Foundry, no con transacciones manuales. Los parámetros viven en archivos de configuración bajo control de versiones y se revisan como si fueran código.
  • Despliega primero en una testnet con exactamente el mismo script y los mismos parámetros, y después haz un simulacro en un fork de mainnet.
  • Verifica el código fuente en el explorador de bloques inmediatamente después del despliegue, con la misma versión del compilador y los mismos ajustes del optimizador.
  • Comprueba dos veces los decimales: una asignación de 1.000.000 de tokens con 18 decimales es 1_000_000e18, no 1_000_000.
  • Transfiere la propiedad a la multisig en el mismo script y confirma que la clave de despliegue no conserva ningún rol.

13. Controles operativos#

  • Concilia con regularidad: la suma de las asignaciones de los calendarios menos las liberaciones debe coincidir con el importe comprometido y con el saldo del contrato.
  • Monitoriza los eventos y alerta ante revocaciones, cambios de rol o pausas inesperados.
  • Mantén un resumen de los calendarios, público o accesible para los inversores, de modo que las preguntas puedan responderse con datos on-chain.
  • Ensaya los procedimientos clave: la rotación de firmantes de la multisig, qué ocurre si un beneficiario pierde el acceso a su wallet y cómo se comunicaría una pausa.

Nota final#

Una checklist y una revisión automatizada detectan pronto muchos problemas, pero no sustituyen a una auditoría de seguridad independiente. Antes de que un contrato de vesting custodie valor real, haz que lo revisen personas que no lo hayan escrito.

Si quieres ver cómo lo abordamos en la práctica, echa un vistazo a nuestro showcase de la suite de tokens, que incluye un contrato de vesting con tests, o lee más sobre nuestros servicios de Blockchain y Web3. Nuestro equipo lo dirige un tech lead con más de 20 años de experiencia, y revisaremos con gusto tu tokenomics o tu diseño de vesting: solo tienes que escribirnos.

¿Tienes un proyecto en mente?

Cuéntanos qué estás construyendo. Normalmente en pocos días laborables recibes una valoración honesta, un alcance claro y una propuesta a precio fijo o por hitos.

¿Prefieres escribirnos primero? Escríbenos

Hablarás directamente con Ing. Ismet Mesic, Tech lead.