ReentrancyGuard¶
Source: fourier/stdlib/reentrancy_guard.fou.
A mutex flag that prevents a function from being re-entered during its own execution. Set the flag before any external call, clear it after.
Storage¶
| Slot | Name | Type | Purpose |
|---|---|---|---|
0 | _locked | uint | 0 = unlocked, 1 = in protected section |
1 | balances | map[address, uint] | Example user balances |
The shipped contract includes a balances mapping at slot 1 as a demonstration. When you inherit-by-copy, keep _locked (it's the mutex you need) and replace the rest with your own storage.
Source¶
contract ReentrancyGuard {
storage _locked: uint @ 0;
storage balances: map[address, uint] @ 1;
pub fn withdraw(amount: uint) -> uint {
// ── Reentrancy guard: enter ──
require(_locked == 0);
_locked = 1;
let bal: uint = balances[caller()];
require(bal >= amount);
balances[caller()] = bal - amount;
// External call (the danger zone)
let cd: bytes = pack_sel(1, amount);
let ok: uint = call_b(caller(), cd, amount, 50000);
require(ok == 1);
// ── Reentrancy guard: exit ──
_locked = 0;
return 1;
}
pub fn deposit() -> uint {
balances[caller()] = balances[caller()] + callvalue();
return 1;
}
pub fn balance_of(addr: address) -> uint {
return balances[addr];
}
}
Selectors¶
| Selector | Function |
|---|---|
0x01 | withdraw(uint) -> uint |
0x02 | deposit() -> uint |
0x03 | balance_of(address) -> uint |
Why this matters¶
A classic reentrancy attack works like this:
- Attacker calls a victim contract's
withdraw(100). - Victim reads attacker's balance (100), then sends 100 to attacker via
call_b. - The "send" reaches the attacker's contract, whose fallback / selector-matching function calls
withdraw(100)again. - Victim's balance read still shows 100 (the first decrement hasn't happened yet) and pays out another 100.
- Step 3–4 repeats until victim is drained.
The classic fix is the "checks-effects-interactions" pattern: do all state mutations before the external call. ReentrancyGuard is a belt-and-suspenders second line.
Pattern: minimal guard¶
For any function that makes an external call_b / delegatecall_b, copy this skeleton:
storage _locked: uint @ 0;
pub fn risky_action(...) -> uint {
require(_locked == 0); // enter
_locked = 1;
// ... read state, mutate state ...
let ok: uint = call_b(target, cd, value, gas);
require(ok == 1);
// ... possibly more state changes ...
_locked = 0; // exit
return 1;
}
What the guard prevents¶
- A reentrant call into the same function (the
_locked == 0precondition fails on the second entry). - A reentrant call into a different function that shares the same
_lockedflag — useful for inter-function protection.
What it does not prevent:
- Read-only reentrancy. A
staticcall_binto the contract during the external call will still succeed, exposing inconsistent state to the caller. If you care about read-consistency, restructure so the external call happens last. - Cross-function attacks where another contract you call back into yourself with a different selector — only if you don't share the
_lockedflag across functions. The standard is to use one flag for every state-mutating function on the contract.
Gas notes¶
The guard adds:
- 1×
SLOAD(200 gas) + 1× equality check on entry. - 1×
SSTOREnon-zero → existing-non-zero (5000 gas) on entry. - 1×
SSTOREnon-zero → zero (5000 gas, no refund) on exit.
Roughly 10,200 gas per protected call. Cheap relative to the cost of an external call (700+ forwarded gas), free relative to the cost of a reentrancy-induced drain.
Combining with Pausable¶
If you've also copied Pausable (slot 0 = owner, slot 1 = paused), move _locked to a free slot:
storage owner: address @ 0; // from Pausable
storage paused: uint @ 1; // from Pausable
storage _locked: uint @ 2; // from ReentrancyGuard, relocated
storage balances: map[address, uint] @ 3;
The protected-function pattern remains identical — just reference the relocated _locked.