Using Vyper’s @nonreentrant Decorator to Stop Reentrancy Attacks
Learn how Vyper’s built‑in @nonreentrant decorator provides compile‑time reentrancy protection, see a concrete vault example, and understand its gas cost and scope limits.
03 Aug 2025, 03:10 UTC

The problem: reentrancy when state updates lag behind calls
In Vyper, just like in Solidity, a contract that sends Ether or calls another contract before updating its internal balances opens a window for a reentrancy attack. A malicious caller can re‑enter the same function, drain funds repeatedly, and leave the contract in an inconsistent state.
Thesis: @nonreentrant gives a compile‑time lock that removes the need for manual mutexes
The @nonreentrant decorator, available since Vyper 0.3.0, inserts a storage‑based lock at the start of each decorated function. While the lock is set, any call to the same function—or to any other function also marked @nonreentrant—reverts with the error "Nonreentrant lock". This guarantees that state updates happen before any external call can re‑enter the protected code.
Worked example: a simple vault with protected withdraw
# Vyper ^0.3.0
# Storage: user balances
balances: public(HashMap[address, uint256])
@external
@nonreentrant
def deposit() -> None:
"""Add msg.sender's sent Ether to their balance."""
balances[msg.sender] += msg.value
@external
@nonreentrant
def withdraw(amount: uint256) -> None:
"""Send Ether to msg.sender after reducing their balance."""
require(balances[msg.sender] >= amount, "Insufficient balance")
balances[msg.sender] -= amount # state update first
# External call – now safe from re‑entry
raw_call(msg.sender, amount, outsize=0)
@external
def get_balance(addr: address) -> uint256:
return balances[addr]
The withdraw function follows the checks‑effects‑interactions pattern: it updates balances[msg.sender] before the raw_call. If an attacker tries to call withdraw again from their fallback or receive function while the first call is still executing, the @nonreentrant lock is still active and the transaction reverts.
Trade‑offs and limitations
- Gas overhead: Each decorated function performs an
SLOADto read the lock and anSSTORE to set/clear it, adding roughly 200‑300 gas per call. - Scope: The lock only protects against re‑entrancy within the same contract. Cross‑contract re‑entrancy (e.g., calling a function in another contract that then calls back) is not prevented unless every relevant function in every involved contract also uses
@nonreentrant. - Consistency required: If you leave any external‑calling function undecorated, an attacker can still re‑enter through that path. All functions that modify state and make external calls should share the decorator.
- View/pure functions: Applying
@nonreentrantto avieworpurefunction is allowed by the compiler but wastes gas because those functions cannot modify state and therefore never need the lock.
Actionable steps to verify the protection
- Compile: Run
vyper >=0.3.0 contract.vyand inspect the bytecode. Look for the patternSLOAD(read lock) followed bySSTORE(set lock) at the function start, and a matchingSSTOREto clear it before returning. - Deploy to a local testnet: Use Ganache, Hardhat, or any EVM‑compatible node. Deploy the vault contract.
- Attempt a re‑entrancy attack: Write a malicious contract that calls
vault.withdraw(...)in its fallback function, then invokevault.withdrawfrom an externally owned account with a balance sufficient for the attack. The second call should revert with the error data encoding "Nonreentrant lock". - Measure gas: In a test script, record
gasleft()before and after awithdrawcall with and without the@nonreentrantdecorator. The difference should be in the 200‑300 gas range, confirming the overhead.
By following these steps you can confirm that @nonreentrant is active, understand its cost, and ensure you have applied it consistently across all state‑changing, external‑calling functions in your Vyper contracts.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.