Preventing Reentrancy in Vyper: When to Use @nonreentrant vs Manual Mutex
A decision guide for using Vyper's @nonreentrant decorator vs manual mutex patterns, with version constraints, gas trade-offs, a concrete withdrawal example, and validation steps using Foundry and bytecode inspection.
02 Sept 2025, 18:48 UTC

The Problem: Reentrancy in Smart Contracts
Reentrancy remains one of the most exploited vulnerability classes in Ethereum smart contracts. An attacker calls a vulnerable function, which makes an external call before updating state, allowing the attacker to re-enter the same function and drain funds. Vyper addresses this with a built-in @nonreentrant decorator that enforces protection at compile time with minimal boilerplate.
Decision: Default to @nonreentrant on External State-Changing Functions
Use @nonreentrant('lock_name') on every @external function that modifies storage and calls untrusted contracts (including send, raw_call, or ERC-20 transfer). This gives you compile-time enforced protection with ~200 gas overhead per call. Reserve manual mutex patterns for the rare case where you need conditional reentrancy after state updates.
Constraints and Version Requirements
- Vyper 0.3.0+ required for named locks (
@nonreentrant('withdraw')). Older versions support only a single unnamed lock per contract. - One storage slot per named lock; multiple independent critical sections can use different lock names.
- Cannot apply to
@internal,@private,__init__, or__default__functions. - Lock state persists across external calls but not across
delegatecallorcreate2— consider additional checks if using those patterns. - No inheritance in Vyper means each contract declares its own locks; you cannot extract into a base contract.
Comparison: Protection Approaches
| Approach | Boilerplate | Gas Overhead | Flexibility | Auditability |
|---|---|---|---|---|
Vyper @nonreentrant | Very low | ~200 gas | Medium | High |
| Manual mutex (bool) | Medium | ~100 gas | High | Medium |
| Solidity ReentrancyGuard | Medium | ~300 gas | High | High |
| No protection | None | 0 | N/A | None |
Trade-offs Explained
@nonreentrant adds a storage boolean per lock. At function entry it loads the slot, reverts if true, sets it true, executes the body, then clears it. This pattern is visible in bytecode as an SLOAD/SSTORE pair around the external call. The decorator cannot be bypassed accidentally — the compiler inserts the checks.
Manual mutex (a bool storage variable you set/clear yourself) saves ~100 gas by letting you update state before the external call, then clear the lock. But you must remember to clear it on every exit path including reverts, and auditors must verify each usage. A missed lock = False bricks the contract.
Solidity's ReentrancyGuard (OpenZeppelin) works similarly but requires inheritance and compiles to more bytecode. Vyper's lack of inheritance means you cannot share a guard across contracts anyway.
Concrete Implementation: Withdrawal with Named Lock
The following contract uses a named lock 'withdraw' protecting the withdrawal function. A separate 'deposit' lock could protect deposit logic independently.
# @version 0.3.10
balances: HashMap[address, uint256]
@external
@nonreentrant('withdraw')
def withdraw() -> bool:
amount: uint256 = self.balances[msg.sender]
self.balances[msg.sender] = 0
sent: bool = send(msg.sender, amount)
assert sent
return True
@external
def deposit():
self.balances[msg.sender] += msg.value
Compile with gas estimates to verify the overhead:
vyper --show-gas-estimates withdraw.vy
Run this in a terminal with Vyper 0.3.10+ installed (pip install vyper==0.3.10). Requires Python 3.8+. The output shows estimated gas for each function; withdraw will include the SLOAD/SSTORE pair for the lock.
Validation: Testing Reentrancy Reverts
Write a Foundry test that deploys the contract, funds it, and calls withdraw from a malicious contract that re-enters withdraw in its fallback. Expect a revert with the reason reentrant call.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
interface IVyperContract {
function withdraw() external returns (bool);
function deposit() external payable;
}
contract Malicious {
IVyperContract target;
constructor(address _target) { target = IVyperContract(_target); }
function attack() external { target.withdraw(); }
fallback() external payable { target.withdraw(); }
}
contract VyperReentrancyTest is Test {
IVyperContract vyper;
Malicious attacker;
function setUp() public {
vyper = IVyperContract(address(new VyperContract()));
attacker = new Malicious(address(vyper));
vm.deal(address(vyper), 1 ether);
vyper.deposit{value: 1 ether}();
}
function testReentrancyReverts() public {
vm.expectRevert(bytes("reentrant call"));
attacker.attack{value: 0}();
}
}
Run with forge test -vv in the Foundry project root. Requires Foundry installed (curl -L https://foundry.paradigm.xyz | bash && foundryup). The test passes if the revert reason matches.
Inspecting the Bytecode
Verify the compiler emitted the lock checks by disassembling the bytecode:
vyper -f bytecode withdraw.vy | evm disasm
Look for SLOAD at the function entry (loading the lock slot), a JUMPI to revert if non-zero, SSTORE setting it to 1, the external CALL (or SEND), then SSTORE clearing it to 0. Requires evm from go-ethereum (go install github.com/ethereum/go-ethereum/cmd/evm@latest).
Static Analysis Confirmation
Run Slither or Mythril on the compiled artifact to confirm no reentrancy findings on protected functions:
slither withdraw.vy --detect reentrancy
myth analyze withdraw.vy
Both tools should report zero reentrancy issues on withdraw. Slither requires Python 3.8+ and pip install slither-analyzer. Mythril requires Docker or Python install.
Limitations and When to Choose Manual Mutex
- Cross-contract reentrancy via delegatecall/create2:
@nonreentrantdoes not protect these. If your contract usesraw_callwithdelegatecallor deploys viacreate2, add explicit checks. - Extreme gas sensitivity: In high-frequency trading or batch operations where ~100 gas matters, manual mutex with early state update (checks-effects-interactions) saves the lock clear
SSTORE. - Conditional reentrancy: If you intentionally allow reentry after state update (rare, e.g., callback patterns), manual mutex is the only option.
- Vyper < 0.3.0: Only a single unnamed
@nonreentrantper contract. Upgrade or use manual mutex for multiple critical sections.
Practical Checklist
- Identify all
@externalfunctions that call untrusted code or send ETH. - Add
@nonreentrant('descriptive_name')to each. Use distinct names for independent state sections. - Compile with
vyper --show-gas-estimatesto confirm overhead is acceptable. - Write a reentrancy test per protected function using a malicious caller.
- Run Slither/Mythril on the bytecode as a final audit step.
Verification Steps Summary
vyper --versionconfirms 0.3.10+vyper --show-gas-estimates contract.vyshows gas for lock checks- Foundry test expects
reentrant callrevert on reentry vyper -f bytecode contract.vy | evm disasmshowsSLOAD/SSTOREpatternslither contract.vy --detect reentrancyreports zero findings
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.