StakingVault.sol · Migración 0.7.6 → 0.8.20 + Refactorización de retiros
main..feat/optimize-staking-v2 |
Timeline: 2026-06-10 → 2026-06-15
| Archivo | +Líneas | −Líneas | Riesgo | Radio de Impacto |
|---|---|---|---|---|
contracts/StakingVault.sol |
+89 | −34 | CRÍTICO | CRÍTICO — 34 llamadores |
contracts/NUTRToken.sol |
+12 | −3 | MEDIO | MEDIO — 8 llamadores |
contracts/PriceOracle.sol |
+5 | −0 | BAJO | BAJO — solo lectura |
test/StakingVault.test.js |
+45 | −20 | BAJO | N/A |
La nueva función emergencyExit() viola el patrón CEI (Checks-Effects-Interactions): ejecuta el call externo antes de actualizar el estado balances[msg.sender] = 0. Un atacante con un contrato malicioso puede llamar recursivamente a emergencyExit() durante el callback ETH y vaciar el vault completo.
commit def9a23 (2025-11-14) — "Fix critical reentrancy CVE-2025-0847 in withdraw()"withdraw() hace 7 meses. La nueva función emergencyExit() repite exactamente el mismo error.
emergencyExit()
emergencyExit(): el vault transfiere 1 ETH vía .call{value: bal}("")
emergencyExit() — balances[attacker] sigue siendo 1 ETH (no se ha zeroeado)
// Contrato atacante contract ReentrancyAttack { StakingVault vault; uint public attackCount; receive() external payable { if (attackCount < 10) { attackCount++; vault.emergencyExit(); // Recursive call — balance no se ha zeroeado } } function attack() external payable { vault.deposit{value: msg.value}(); vault.emergencyExit(); // Dispara la cadena } }
Aplicar patrón CEI (Checks-Effects-Interactions): actualizar el estado antes del call externo. Alternativamente, usar el modificador nonReentrant de OpenZeppelin ReentrancyGuard.
function emergencyExit() external nonReentrant { uint256 bal = balances[msg.sender]; require(bal > 0, "No balance"); + balances[msg.sender] = 0; // Estado primero (CEI) (bool success,) = msg.sender.call{value: bal}(""); require(success, "Transfer failed"); - balances[msg.sender] = 0; // ❌ Estado DESPUÉS del call }
La migración a 0.8.20 eliminó el check require(msg.sender == owner) de la función withdraw(). Aunque la lógica "optimizada" usa balances[msg.sender] para limitar el retiro al balance del caller, la eliminación del check de ownership rompe el modelo de confianza en contratos que delegan en StakingVault asumiendo que sólo el owner puede retirar fondos del protocolo.
commit a91c44b (2025-03-22) — "Add owner-only restriction per audit finding #31 (NutriChain Audit Q1-2025)"withdraw() necesitaba restricción por ownership. El PR actual revierte silenciosamente esta corrección sin mencionar la auditoría.
YieldAggregator.sol llama a withdraw(amount) asumiendo restricción de ownership
withdraw() ignorando timelock y restricciones de gobernanza del protocolo padre
- function withdraw(uint256 amount) external { - require(msg.sender == owner, "Unauthorized"); // ❌ ELIMINADO - require(amount > 0, "Zero amount"); // ❌ ELIMINADO - require(balances[msg.sender] >= amount, "Insufficient"); - balances[msg.sender] = balances[msg.sender].sub(amount); - NUTR.safeTransfer(msg.sender, amount); // ❌ SafeERC20 eliminado - } + function withdraw(uint256 amount) external { + require(balances[msg.sender] >= amount, "Insufficient"); + balances[msg.sender] -= amount; + NUTR.transfer(msg.sender, amount); // Retorno no verificado + }
Restaurar el check de ownership o, si el diseño requiere retiros por cualquier staker, documentar explícitamente el cambio de modelo de confianza y coordinar con todos los contratos integradores. Usar SafeERC20.safeTransfer en lugar de .transfer.
El refactor de distributeYield() introduce un loop for sobre el array stakers[] sin límite de iteraciones. Con 200+ stakers, la función superará el gas limit de Ethereum (30M gas) y se volverá permanentemente inutilizable, bloqueando la distribución de yields del protocolo.
stakers.length
distributeYield() excede el gas block limit. El owner no puede distribuir rewards → usuarios legítimos pierden yields
Migrar a patrón pull-over-push: almacenar rewards acumulados por usuario y dejar que cada staker llame a claimReward() individualmente. Alternativa rápida: añadir paginación con start/end índices.
| Función | Riesgo | Tests existentes | Impacto del gap |
|---|---|---|---|
emergencyExit() |
CRÍTICO | ❌ Ninguno | Reentrancia no detectable en CI |
withdraw() — retiro parcial |
CRÍTICO | ⚠️ Solo happy path | Auth bypass no cubierto |
distributeYield() |
ALTO | ❌ Ninguno | DoS por gas no detectable |
deposit() |
BAJO | ✅ Completo | — |
a91c44b como corrección de Auditoría Q1-2025, hallazgo #31. El PR revierte esta protección sin justificación documentada.safeTransfer fue introducido como buena práctica en commit c7d8e12. La migración lo eliminó asumiendo que tokens ERC-20 "normales" no necesitan SafeERC20, ignorando que NUTR puede pausarse.emergencyExit(): mover balances[msg.sender] = 0 antes del call externorequire(msg.sender == owner) en withdraw() o documentar y coordinar el cambio de modelo de confianza con todos los integradoresnonReentrant (OpenZeppelin ReentrancyGuard) a todas las funciones que ejecuten calls externosSafeERC20.safeTransfer() para transfers de NUTRdistributeYield() a patrón pull-over-push con claimReward()emergencyExit() con contrato mock maliciosorequire(amount > 0) en withdraw()withdraw()FOCUSED — 4 archivos, codebase pequeño. Cobertura 100% en archivos HIGH/CRITICAL, análisis DEEP en StakingVault.sol.
Git blame en todas las eliminaciones · Cálculo de radio de impacto · Análisis de cobertura de tests · Modelado adversarial en funciones HIGH RISK
Archivos revisados: 4/4 (100%)
HIGH RISK: 100% cobertura
MEDIUM RISK: 100% cobertura
LOW RISK: Análisis de superficie
Análisis estático — no se ejecutó el protocolo en testnet. Las dependencias externas (OpenZeppelin, oráculos) no se incluyeron en el scope. Confianza en la revisión: ALTA.