Protecting Vyper Contracts with the nonreentrant Decorator: A Task Guide
A practical task guide for adding Vyper's @nonreentrant decorator to state-changing functions, verifying it blocks reentrant calls on a local node, and planning recovery if the lock triggers in production.
04 Jan 2026, 13:39 UTC

Problem and Takeaway
Reentrancy remains one of the most exploited vulnerability classes in EVM smart contracts. Vyper ships with a built-in @nonreentrant decorator that provides a contract-wide mutual-exclusion lock for state-changing external functions. This guide walks through adding the decorator, verifying it blocks reentrant calls, and planning recovery if the protection triggers in production. The key takeaway: @nonreentrant is a defense-in-depth layer, not a substitute for checks-effects-interactions ordering.
Prerequisites
- Vyper compiler installed (version 0.3.x or 0.4.x). Confirm with
vyper --version. - Local execution environment such as Anvil (
anvilfrom Foundry) or a testnet RPC endpoint. - Deployment tooling: Foundry (
forge), Hardhat with the Vyper plugin, or a rawcast sendworkflow. - Target contract with at least one external function that writes storage before making an external call (the classic reentrancy pattern).
Adding the Decorator
Open the contract source and locate the state-changing external function you want to protect. Add @nonreentrant immediately above the @external decorator. The decorator must precede @external; order matters.
# vault.vy
from vyper.interfaces import ERC20
token: ERC20
balances: HashMap[address, uint256]
@nonreentrant
@external
def withdraw(amount: uint256) -> bool:
assert balances[msg.sender] >= amount
balances[msg.sender] -= amount # effect
success: bool = token.transfer(msg.sender, amount) # interaction
assert success
return True
Compile to verify syntax:
vyper vault.vy --output-formats abi,bytecode,ir
Run this in the project root where vault.vy resides. No special permissions beyond file read/write. Expected check: compiler exits 0 and emits ABI/bytecode artifacts.
Deploying to a Local Node
Start Anvil in a separate terminal:
anvil --port 8545
Deploy the contract using cast (Foundry) or your preferred deployer. Example with cast:
# Assumes bytecode file vault.bin from compilation step
cast send --rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--create $(cat vault.bin)
Record the deployed address. Fund the contract with the ERC20 token used in withdraw so the transfer succeeds.
Verifying Normal Execution
Call withdraw directly from an EOA. The call should succeed, storage updates persist, and the token transfer completes.
cast send --rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
<CONTRACT_ADDRESS> \
"withdraw(uint256)" 100
Expected checks:
- Transaction receipt shows
status = 1. balances[msg.sender]decreased by 100.- Token balance of caller increased by 100.
Testing Reentrancy Block
Deploy a malicious contract that re-enters withdraw during the external call. Minimal attacker:
// Attacker.sol
pragma solidity ^0.8.20;
interface IVault {
function withdraw(uint256) external returns (bool);
}
contract Attacker {
IVault public vault;
constructor(address _vault) { vault = IVault(_vault); }
function attack() external { vault.withdraw(100); }
fallback() external { vault.withdraw(100); }
receive() external payable { vault.withdraw(100); }
}
Compile and deploy Attacker, then call attack() with enough token balance approved. The first withdraw enters the vault, the token transfer triggers the fallback, the second withdraw hits the nonreentrant lock and reverts. The outer call also reverts because the inner revert bubbles up (Vyper's lock reverts the entire frame).
cast send --rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
<ATTACKER_ADDRESS> \
"attack()"
Expected checks:
- Transaction receipt shows
status = 0. - No state changes persisted (balance unchanged).
- Revert reason contains the nonreentrant error selector (Vyper 0.3+:
NonReentrantLockAlreadyHeld()).
Understanding Lock Scope and Gas Impact
The nonreentrant lock is a single boolean stored at a fixed slot (typically slot 0 in Vyper 0.3+, compiler-dependent). All functions marked @nonreentrant share this flag. If you need independent locks per function, you must implement custom logic (e.g., a mapping of function selectors to booleans). Each protected call adds two SLOAD and one SSTORE (set + reset), costing roughly 5,000–6,000 extra gas versus an unprotected call.
Limitations
- Not a replacement for CEI: If the function reads stale state before the lock check (impossible in Vyper because the lock is first), or if the external call is to an untrusted contract that can call back before the lock is set (not possible with the decorator), CEI still matters for other vectors.
- View/pure functions: Do not apply
@nonreentrantto@viewor@purefunctions; the decorator is only valid on state-changing@externalfunctions. - Cross-contract reentrancy: The lock is per-contract. A reentrant call into a different contract that shares state (e.g., a proxy) is not blocked.
- Version variance: Storage slot layout, error message, and gas cost differ between Vyper 0.3.x and 0.4.x. Always check the release notes for your exact compiler version.
Recovery Options
If the lock triggers in production (legitimate nested call pattern, e.g., a multicall or batch operation), you have three practical paths:
- Emergency pause: If the contract inherits a pause mechanism (e.g., OpenZeppelin
Pausablevia an interface), the admin can pause withdrawals, deploy a fixed version, and migrate funds. - Redeploy with corrected ordering: Refactor the function to follow checks-effects-interactions strictly, then redeploy. The lock remains but the reentrancy window disappears.
- Governed upgrade: For proxy-based deployments, propose a timelock multisig upgrade to a new implementation that either removes the decorator for the specific function or splits the lock per function selector.
Rollback note: The nonreentrant decorator itself does not mutate persistent state beyond the transient lock. No on-chain rollback is needed for the decorator; only the function's own state changes are reverted automatically when the lock triggers.
Verification Checklist
vyper --versionmatches the version referenced in your CI/CD pipeline.- Compiled bytecode contains the nonreentrant lock preamble (inspect with
vyper vault.vy --output-formats irand search fornonreentrant). - Direct call succeeds; reentrant call from attacker reverts.
- Post-revert storage matches pre-call state (query
balances[attacker]and token balances). - Gas overhead measured:
cast run --gas-reportorforge test --gas-reportshows expected delta.
Next Steps
Integrate the reentrancy test into your CI pipeline. Add a Foundry test that deploys both contracts and asserts the revert. Document the lock scope in your security audit checklist so reviewers know all @nonreentrant functions share a single flag.