Smart Contracts: Patrones y Seguridad
La seguridad es el aspecto más crítico del desarrollo en blockchain. Un bug en un smart contract puede resultar en la pérdida irreversible de millones de dólares. En esta lección aprenderemos los patrones de diseño seguros y las vulnerabilidades más comunes.
Patrón Checks-Effects-Interactions (CEI)
El patrón más importante para prevenir ataques de reentrancy:
contract PatronCEI {
mapping(address => uint) public balances;
function retirar(uint cantidad) external {
// 1. CHECKS: validar condiciones
require(balances[msg.sender] >= cantidad, "Saldo insuficiente");
// 2. EFFECTS: actualizar estado ANTES de interactuar
balances[msg.sender] -= cantidad;
// 3. INTERACTIONS: interactuar con contratos externos AL FINAL
(bool sent, ) = msg.sender.call{value: cantidad}("");
require(sent, "Fallo en transferencia");
}
}
Reentrancy Attack
El ataque más famoso en la historia de Ethereum (The DAO hack, 2016, $60M robados):
// ❌ CONTRATO VULNERABLE
contract VaultVulnerable {
mapping(address => uint) public balances;
function retirar() external {
uint bal = balances[msg.sender];
require(bal > 0);
// ¡PELIGRO! Interacción ANTES de actualizar estado
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent);
// El atacante ya volvió a llamar retirar() antes de llegar aquí
balances[msg.sender] = 0;
}
}
// ☠️ CONTRATO ATACANTE
contract Atacante {
VaultVulnerable public vault;
receive() external payable {
// Se ejecuta cuando vault envía ETH
// Llama retirar() de nuevo antes de que el balance se actualice
if (address(vault).balance >= 1 ether) {
vault.retirar();
}
}
function atacar() external payable {
vault.depositar{value: 1 ether}();
vault.retirar(); // Inicia el ciclo
}
}
Solución: ReentrancyGuard
// ✅ PROTEGIDO con mutex
contract VaultSeguro {
mapping(address => uint) public balances;
bool private locked;
modifier noReentrant() {
require(!locked, "No reentrant");
locked = true;
_;
locked = false;
}
function retirar() external noReentrant {
uint bal = balances[msg.sender];
require(bal > 0);
balances[msg.sender] = 0; // CEI: effects antes de interactions
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent);
}
}
Access Control
Ownable Pattern
contract Ownable {
address public owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0), "Invalid address");
emit OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
function renounceOwnership() public onlyOwner {
emit OwnershipTransferred(owner, address(0));
owner = address(0);
}
}
Role-Based Access Control
contract AccessControl {
mapping(bytes32 => mapping(address => bool)) private _roles;
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN");
bytes32 public constant MINTER_ROLE = keccak256("MINTER");
modifier onlyRole(bytes32 role) {
require(_roles[role][msg.sender], "Acceso denegado");
_;
}
constructor() {
_roles[ADMIN_ROLE][msg.sender] = true;
}
function grantRole(bytes32 role, address account) public onlyRole(ADMIN_ROLE) {
_roles[role][account] = true;
}
function revokeRole(bytes32 role, address account) public onlyRole(ADMIN_ROLE) {
_roles[role][account] = false;
}
}
Patrón Proxy (Upgradeable Contracts)
Los smart contracts son inmutables por defecto. El patrón Proxy permite "actualizarlos":
Usuario
│
▼
┌──────────────┐
│ Proxy │ ← Storage vive aquí
│ (delegatecall)│
└──────┬───────┘
│
▼
┌──────────────┐
│ Implementation│ ← Lógica vive aquí
│ (v1 → v2) │
└──────────────┘
delegatecall: ejecuta el código de Implementation
pero usa el storage del Proxy
contract Proxy {
address public implementation;
address public admin;
constructor(address _implementation) {
implementation = _implementation;
admin = msg.sender;
}
function upgrade(address _newImpl) external {
require(msg.sender == admin);
implementation = _newImpl;
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
Overflow / Underflow
Desde Solidity 0.8+, los overflows revierten automáticamente:
contract OverflowDemo {
// En Solidity 0.8+: revierte automáticamente
function sumar() public pure returns (uint8) {
uint8 x = 255;
return x + 1; // ¡Revierte! (overflow)
}
// Si necesitas wrapping behavior:
function sumarUnchecked() public pure returns (uint8) {
uint8 x = 255;
unchecked {
return x + 1; // Retorna 0 (wrapping)
}
}
}
Otros Ataques Comunes
Front-running
Escenario:
1. Alice envía tx para comprar token a buen precio
2. Bot ve la tx en el mempool
3. Bot envía la misma tx con gas más alto → se ejecuta primero
4. El precio sube → Alice compra más caro
Mitigación:
- Commit-reveal schemes
- Usar flashbots (private mempool)
- Slippage protection
Denial of Service
// ❌ VULNERABLE: un fallo en un envío bloquea todos
contract VulnerableDoS {
address[] public participantes;
function distribuir() public {
for (uint i = 0; i < participantes.length; i++) {
// Si un participante es un contrato que revierte, bloquea todo
payable(participantes[i]).transfer(1 ether);
}
}
}
// ✅ SEGURO: patrón Pull over Push
contract SeguroDoS {
mapping(address => uint) public pendiente;
function distribuir() public {
for (uint i = 0; i < participantes.length; i++) {
pendiente[participantes[i]] += 1 ether;
}
}
function retirar() public {
uint cantidad = pendiente[msg.sender];
pendiente[msg.sender] = 0;
payable(msg.sender).transfer(cantidad);
}
address[] public participantes;
}
Auditoría y Mejores Prácticas
Checklist de seguridad:
☐ Usar Checks-Effects-Interactions
☐ Aplicar ReentrancyGuard en funciones que envían ETH
☐ Validar todos los inputs con require
☐ Usar custom errors en lugar de strings largos
☐ Implementar pausability para emergencias
☐ Limitar funciones admin con access control
☐ Usar OpenZeppelin contracts cuando sea posible
☐ Escribir tests exhaustivos (incluir edge cases)
☐ Realizar auditoría antes de deploy a mainnet
☐ Usar herramientas: Slither, Mythril, Echidna
Resumen
La seguridad en smart contracts es no negociable. El patrón CEI previene reentrancy, el control de acceso protege funciones sensibles, y el patrón proxy permite upgrades. Los ataques más comunes (reentrancy, front-running, DoS) tienen mitigaciones probadas. Siempre usar OpenZeppelin, escribir tests exhaustivos y auditar antes de deploy a mainnet.