Vyper's @nonreentrant Decorator: Built-In Reentrancy Protection That Trades Gas for Safety
Vyper's @nonreentrant decorator gives you a storage-backed mutex with one line of code. This post shows a worked withdraw() example comparing manual checks-effects-interactions vs. the decorator, the gas/composability trade-offs, and a verification checklist to prove the lock actually holds.
09 Mar 2026, 13:11 UTC

The Problem: Reentrancy Still Burns Contracts
Reentrancy remains one of the most expensive bug classes in Ethereum smart contracts. The classic withdrawal pattern—send ETH before zeroing the user's balance—lets a malicious contract call back into withdraw() repeatedly, draining the pool. Solidity developers reach for OpenZeppelin's ReentrancyGuard library. Vyper takes a different approach: reentrancy protection is a first-class language feature via the @nonreentrant decorator.
Takeaway: @nonreentrant gives you a storage-backed mutex with a single line of code, but it costs extra gas and can break DeFi composability if applied too broadly. This post shows how it works, a worked comparison against checks-effects-interactions, and how to verify the protection actually holds.
How @nonreentrant Works: A Storage-Backed Mutex
When you annotate a function with @nonreentrant('lock'), the Vyper compiler inserts prologue and epilogue code that:
- Reads a boolean storage slot keyed by the string
'lock'. - Reverts if the slot is already
true(reentrant call detected). - Sets the slot to
trueon entry. - Resets it to
falseon normal exit and on revert (via atry/catch-style cleanup in the generated bytecode).
The lock key is a developer-chosen string. Multiple functions sharing the same key share one mutex. This is deliberate: if withdraw() and emergencyWithdraw() both touch balances[msg.sender], they should use @nonreentrant('withdraw_lock') so a reentrant call into either function reverts. Forgetting to share the key leaves a gap.
Note: Exact storage layout and cleanup semantics have changed across Vyper releases. Pin your compiler version (e.g., vyper==0.4.0) and check the release notes for your version.
Worked Example: Withdraw Three Ways
Below is a minimal vault contract written for Vyper 0.4.0. Three variants show the vulnerability, the manual fix, and the decorator fix.
# vault_vulnerable.vy — vulnerable to reentrancy
# @version 0.4.0
balances: public(HashMap[address, uint256])
external def deposit():
self.balances[msg.sender] += msg.value
# BUG: sends ETH before zeroing balance
external def withdraw():
amount: uint256 = self.balances[msg.sender]
assert amount > 0
send(msg.sender, amount) # external call HERE
self.balances[msg.sender] = 0 # state update AFTER
An attacker contract with a receive() that calls back into withdraw() drains the vault.
# vault_manual_fix.vy — checks-effects-interactions
# @version 0.4.0
balances: public(HashMap[address, uint256])
external def deposit():
self.balances[msg.sender] += msg.value
external def withdraw():
amount: uint256 = self.balances[msg.sender]
assert amount > 0
self.balances[msg.sender] = 0 # state update FIRST
send(msg.sender, amount) # external call LAST
This pattern works, but it relies on developer discipline. Every function that moves funds must follow the ordering.
# vault_nonreentrant.vy — decorator protection
# @version 0.4.0
balances: public(HashMap[address, uint256])
external def deposit():
self.balances[msg.sender] += msg.value
@nonreentrant('withdraw_lock')
external def withdraw():
amount: uint256 = self.balances[msg.sender]
assert amount > 0
send(msg.sender, amount) # order no longer matters for reentrancy
self.balances[msg.sender] = 0
The decorator lets you keep the natural (but unsafe) ordering while still blocking reentrancy. If you add emergencyWithdraw(), give it the same key:
@nonreentrant('withdraw_lock')
external def emergencyWithdraw():
# ... same protection
Trade-Offs: Gas, Composability, and What It Doesn't Catch
Gas Overhead
Each protected call does an SLOAD (read lock), an SSTORE (set true), and another SSTORE (reset false). On current mainnet gas prices that's roughly 5,000–8,000 extra gas per call depending on whether the slot is warm or cold. Measure in your test harness; don't quote fixed numbers.
Composability Friction
DeFi protocols often rely on reentrancy for callbacks (e.g., Uniswap V3 flashLoan, ERC-777 tokensReceived). If you blanket-protect every external call with a shared lock, legitimate callbacks revert. Scope locks narrowly: one key per logical state group, not one global key for the whole contract.
Read-Only Reentrancy
@nonreentrant only blocks state-changing reentrant calls. A view function that reads stale state during a reentrant window can still return incorrect values to an off-chain oracle or another contract. If your protocol depends on consistent reads during external calls, you still need checks-effects-interactions or a separate read-lock pattern.
Cross-Function Logic Errors
The decorator doesn't prevent logic bugs where two functions modify related state without a shared lock. If withdraw() uses 'withdraw_lock' but transfer() uses no lock, a reentrant transfer() can corrupt accounting.
Verification: Prove the Lock Actually Works
Don't trust the decorator blindly. Add these steps to your CI:
- Pin the compiler.
vyper==0.4.0(or your chosen version) inrequirements.txtorfoundry.toml. - Write an attacker test. Using Titanoboa, Foundry, or Hardhat, deploy the vault, deposit ETH, then call
withdraw()from an attacker contract whosereceive()re-enterswithdraw(). Assert the second call reverts. - Inspect storage slots. In the test, after the first
withdraw()enters, read the lock slot (usually slot 0 for the first@nonreentrantkey) viaeth_getStorageAtand confirm it's0x1. After the call returns, confirm it's0x0. - Compare gas. Run the same withdraw with and without the decorator in your test harness. Record the difference; that's your real overhead.
- Check advisories. Search the Vyper GitHub security advisories for your pinned version. A compiler-level bug in the reentrancy guard was disclosed in 2023; confirm your version is not affected.
Closing Checklist
- Use
@nonreentrant('key')on every function that makes an external call and touches shared state. - Share the same key across all functions that mutate the same state variables.
- Keep keys narrow—one per logical operation group, not one per contract.
- Pair with checks-effects-interactions for defense in depth; the decorator is a safety net, not a design excuse.
- Run the attacker test on every PR. If it passes, the lock works for that version.
Vyper's philosophy trades flexibility for auditability. @nonreentrant embodies that trade: you lose a few thousand gas and some composability freedom, but you gain a compiler-enforced guarantee that the classic reentrancy bug class is blocked by default. Verify it once, pin the version, and move on.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.