Vyper's @nonreentrant Decorator: Lock Keys, Storage Costs, and Safe Withdrawals
Vyper's @nonreentrant decorator adds a storage-backed lock to prevent reentrancy, but only for functions sharing the same key. Learn how to apply it correctly alongside checks-effects-interactions, what it costs, and how to verify it on a pinned compiler.
29 Aug 2026, 10:39 UTC

The Check-Then-Transfer Trap
Reentrancy remains one of the most expensive bug classes on the EVM. The pattern is simple: a contract checks a condition, makes an external call, and only then updates its state. Because the external call hands control to another address, that callee can call back into the original contract before the first invocation finishes. If the state update has not happened yet, the second call sees stale data and the attacker drains the contract.
The durable fix is ordering your logic as checks-effects-interactions: validate conditions, update storage, and only then interact with external addresses. Vyper's built-in @nonreentrant decorator adds a second layer on top of that ordering, but it is not a substitute for it.
How Vyper's Lock-Key Guard Works
Vyper ships with a built-in @nonreentrant decorator that requires a string lock key. When a decorated function starts, the compiler inserts code that writes a lock marker to storage. If any other function requesting the same key runs while that marker is set, the call reverts. The marker is cleared when the function returns normally, and a revert rolls back the state that set it.
The string key is the unit of sharing. Apply the same key to every function that touches the same funds or state—for example, deposit and withdraw—to get cross-function reentrancy protection. Distinct keys are independent locks; they do not protect one another.
| Scenario | Protection |
|---|---|
Same key on deposit and withdraw | Cross-function reentrancy blocked |
Different keys on deposit and withdraw | No protection between the two |
| Undecorated public function | No protection |
A Worked Withdraw Example
Consider a vault that lets users withdraw their deposited ETH. The function below zeroes the balance before sending funds, and it carries the @nonreentrant("lock") decorator as defense-in-depth.
# Vault.vy
balances: HashMap[address, uint256]
@external
@nonreentrant("lock")
def withdraw():
amt: uint256 = self.balances[msg.sender]
self.balances[msg.sender] = 0
raw_call(msg.sender, b"", value=amt)
The ordering matters. By the time raw_call transfers control to the recipient, the balance is already zero. If the recipient tries to re-enter withdraw, the guard rejects the call because the "lock" key is still held. Even if the guard were absent, the zeroed balance would cause the re-entrant call to withdraw nothing.
To verify this behavior, pin a Vyper compiler version in your project and compile the contract. Run the following in your project's virtual environment:
# Run in your activated virtualenv
pip install vyper==X.Y.Z # replace X.Y.Z with your pinned version
vyper contracts/Vault.vy
Expected check: the compiler accepts the decorator and the raw_call builtin without errors. If the signature differs in your pinned release, the compiler will reject the code—consult the changelog for that version before adjusting arguments.
Testing the Guard
Compilation alone does not prove the guard works. Add a test where a malicious receiver's fallback function re-enters withdraw. Assert that the second call reverts and that the victim's balance was already set to zero by the first call. The exact test framework depends on your project, but the logic is:
# Conceptual test outline (adapt to your test framework)
# 1. Deploy Vault and a malicious receiver.
# 2. Fund the receiver's balance in the vault.
# 3. Call withdraw from the receiver.
# 4. Assert the outer call succeeds.
# 5. Assert the inner re-entrant call reverts.
# 6. Assert the vault balance for the receiver is 0.
Risk: if you test against an unpinned compiler, decorator or builtin signatures may differ, producing false results.
Trade-offs and Limitations
The guard is not free. Each locked call writes a storage slot on entry and clears it on exit, adding gas and a per-key storage footprint. Because the lock lives in storage, it can interact with proxy or upgrade storage layouts; confirm the lock slot for your chosen key does not collide with other variables.
More importantly, the decorator only covers functions that share the same key. It does not protect undecorated entry points, functions using different keys, or read-only reentrancy performed through staticcall. Treat it as a safety net, not a complete reentrancy proof.
Practical Verification Steps
- Pin the compiler. Record an exact Vyper version in your project configuration and install it locally.
- Compile the guarded contract. Confirm the decorator and
raw_callarguments are accepted. - Inspect the storage layout. Locate the lock slot for your key and verify it does not overlap application state.
- Run a reentrancy test. Use a malicious callee to confirm the second call reverts and state is rolled back correctly.
When you upgrade Vyper, diff the decorator and raw_call behavior across two adjacent versions before changing the contract. The lock-key model has been stable, but builtin signatures have shifted between releases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.