Diagnosing Missing @nonreentrant in Vyper Smart Contracts
Learn how to spot missing or misapplied @nonreentrant decorators in Vyper contracts, diagnose the root cause, apply fixes, and know when to seek an audit.
07 Aug 2025, 00:24 UTC

Recognizable Condition
A Vyper contract that holds ether or tokens and exposes an @external function which transfers value (send, raw_call, or an external token call) before updating internal balances, and does not use the @nonreentrant decorator, is vulnerable to classic reentrancy attacks. The same risk appears if the contract updates state after the external call (violating checks‑effects‑interactions) even when a lock is present.
Short Cause / Diagnostic Table
| Symptom | Likely Cause | Why It Matters |
|---|---|---|
External value‑transfer function lacks @nonreentrant | Missing decorator | Allows an attacker to re‑enter the same function before balances are cleared. |
| Only some value‑transfer functions are decorated | Inconsistent lock coverage | An unprotected function can be called from within a protected one, bypassing the lock. |
| State updates occur after the external call | Checks‑effects‑interactions violation | The lock prevents re‑entry but logic may still assume outdated balances. |
@view functions read state during a callback | Read‑only reentrancy | Even with a lock, a malicious caller can observe intermediate state and influence downstream protocols. |
Ordered Checks
- List external entry points: Run
grep -n '^@external' contracts/*.vy(requires read access to source). - Identify value‑moving calls: For each
@externalfunction, search forsend,raw_call, or external token interfaces (e.g.,ERC20.transfer). - Verify decorator presence: Confirm each function from step 2 has
@nonreentrantdirectly above its definition. - Check order of operations: Ensure any balance or accounting variable is updated before the external call. Look for patterns like
balances[msg.sender] -= amountprecedingsend. - Review view functions: Examine
@viewfunctions that read balances or accounting data; note whether they assume the contract cannot be re‑entered. - Static analysis (optional): Run a Vyper‑aware scanner such as
slither . --vyperto detect missing@nonreentranton external state‑changing functions.
Fixes Tied to Findings
- Add missing decorator: Place
@nonreentranton every@externalfunction that transfers value or modifies critical state. - Reorder to checks‑effects‑interactions: Move all state updates (balances, approvals, accounting) before any external call. Example:
@nonreentrant
@external
def withdraw(amount: uint256):
require(self.balances[msg.sender] >= amount, "Insufficient balance")
self.balances[msg.sender] -= amount # effects first
send(msg.sender, amount) # interaction last
- Prefer limited‑gas primitives: Use Vyper's built‑in
send(forwards 2300 gas) instead ofraw_callwhen possible, but treat it as a mitigation, not a replacement for the lock. - Address read‑only reentrancy: Expose a read‑only lock flag, e.g.,
@view def locked() -> bool: return self._lock, and document that integrators must not rely on state reads during an ongoing external call.
Escalation Criteria
Consider an independent security audit when any of the following apply:
- The contract manages a significant amount of value (e.g., > 10 k ETH or equivalent tokens).
- External calls involve multiple contracts or complex DeFi composability (flash loans, yield aggregators, etc.).
- The contract uses upgradeability patterns (proxy, delegatecall) that could change the lock storage layout.
- You observe any of the diagnostic symptoms above and cannot confirm fixes with the ordered checks.
Limitations and Practical Verification
The @nonreentrant decorator only protects functions that share the same storage lock; it does not prevent re‑entry via a different lock or read‑only reentrancy. To verify that your fix works:
- Deploy a test attacker contract whose fallback (or
receive) function calls the target's withdraw function again. - Run the test with the original vulnerable version; the attacker should be able to drain funds.
- Repeat with the patched version; the call should revert because the lock blocks the second entry.
- Inspect the compiled bytecode (e.g.,
vyper -f bytecode contract.vy) for theSLOAD/SSTOREpattern that implements the lock; ensure it appears on each protected function.
Record the exact Vyper compiler version used (e.g., vyper --version) and lock that version in your build pipeline to avoid future semantic changes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.