Vyper's @nonreentrant Lock: One Decorator, One Whole Class of Bugs Gone
Vyper's @nonreentrant decorator turns reentrancy from a code-review convention into a compiler-enforced guarantee. Here's how it works, a vault example, and where it falls short.
18 Apr 2026, 15:05 UTC

If you've written Solidity for any length of time, you've written the checks-effects-interactions dance by hand: update state first, send funds second, and hope nobody reorders the two lines during a refactor six months later. Reentrancy — where an external call lets an attacker call back into your function before it finishes — remains one of the most expensive bug classes in Ethereum history.
Vyper takes a different stance: instead of trusting you to remember the pattern, it gives you a compiler-enforced lock. One decorator, @nonreentrant, and a function physically cannot be re-entered before it returns. This post looks at how that works, where it fits in Vyper's broader "secure by default" philosophy, and where it falls short.
What Vyper removes so you can't hurt yourself
Vyper is a Python-flavored language that compiles to EVM bytecode, and its defining trait is subtraction. There are no modifiers, no inheritance, no function overloading, no inline assembly, and no recursive calls. Every one of those omissions deletes a place where bugs hide — inheritance chains obscure which code actually runs; modifiers scatter logic away from the function body.
The same philosophy shows up in defaults: arithmetic reverts on overflow and underflow without you importing a SafeMath library, and array access is bounds-checked. The @nonreentrant decorator is the same idea applied to control flow: the safe behavior is one keyword away instead of a convention you have to enforce by review.
The vault example
Here's a minimal ETH vault with a withdrawal function. Note the ordering: the balance is zeroed before the external send.
# @version ^0.3.0
balances: public(HashMap[address, uint256])
@external
@payable
def deposit():
self.balances[msg.sender] += msg.value
@external
@nonreentrant
def withdraw():
amount: uint256 = self.balances[msg.sender]
assert amount > 0, "nothing to withdraw"
self.balances[msg.sender] = 0 # effects before interactions
send(msg.sender, amount)Two layers of defense are at work. The checks-effects-interactions ordering means that even if msg.sender is a malicious contract whose fallback function calls withdraw() again, the balance is already zero and the re-entrant call reverts on the assert. The @nonreentrant decorator adds a second layer: the compiler emits a storage-backed lock that is set on entry and cleared on exit, so any re-entry reverts immediately, before your function body even runs.
Why both? Because the ordering discipline is fragile. A future edit adds a second external call, or a helper function that sends ETH, and suddenly the invariant you relied on is gone. The lock keeps protecting you even when the code stops being textbook-clean.
Verifying it actually works
Don't take the decorator on faith — write the attack. Deploy the vault, then deploy a malicious receiver contract whose fallback calls withdraw() again, and confirm the whole transaction reverts. With a Python-based framework like Titanoboa, Ape, or Brownie, the test is short:
# run in your project's test environment, e.g. `ape test`
# attacker contract's fallback: vault.withdraw()
with boa.reverts():
attacker.attack(value=deposit_amount)Run this against a pinned compiler version (vyper --version, and pin it in your project's config). Vyper's syntax and decorator behavior have shifted across releases, and past compiler bugs have made version pinning part of the security story, not an afterthought. Check the official docs for your pinned version to confirm the decorator's exact spelling and semantics before shipping.
The trade-offs you still own
The lock is not free and not total:
- Gas. Setting and clearing a storage slot costs real gas on every call — thousands of gas on a cold slot. For a hot function, measure the difference (
--gasoutput from your test runner, or compile with and without the decorator and compare) before deciding it's worth it. - Coarse granularity. The lock blocks all re-entry into the protected function, including legitimate cross-function call patterns your design might want.
- Coverage gaps. Historically the lock has not covered every reentrancy pattern — read-only reentrancy through view functions is a known example where the lock offers no protection. Treat the decorator as one layer, not a cure-all.
- Compiler risk. The lock is only as correct as the compiler version emitting it. Pin, and review advisories for that version.
A practical rule of thumb
Audit every external call in your contract — every send, raw_call, and call into an untrusted contract. Any state-mutating function that makes one gets @nonreentrant unless you have a measured reason to skip it. Then prove it: write the malicious-receiver test, pin the compiler, and check the gas delta. The decorator won't design your contract for you, but it will make one entire failure mode structurally impossible — and that's a trade worth making most of the time.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.