Deciding Where Vyper's @nonreentrant Lock Belongs in a Contract That Moves Funds
An architecture note on where Vyper's @nonreentrant lock belongs: which functions need it, why checks-effects-interactions is still required, and how to verify the lock with a hostile receiver contract.
29 Jul 2025, 19:42 UTC

You have a Vyper contract that sends ETH or calls token contracts, and you need to decide which functions get the @nonreentrant decorator and which are safe without it. The short answer: any function that makes an external call after which an attacker could re-enter and observe stale state gets the lock, and you still write checks-effects-interactions underneath it, because the lock only protects the function it decorates.
Requirements driving the design
The contract holds user funds. Users can deposit and withdraw, and withdrawal sends ETH to an address the caller controls. That single fact creates the classic reentrancy exposure: when the contract sends ETH, the receiver's fallback code runs while your function is still mid-execution. If your state updates happen after the send, the attacker re-enters, sees the old balance, and withdraws again.
Vyper already removes several historical footguns. Arithmetic overflow and underflow revert by default, and array bounds are checked, so there is no SafeMath-style dependency to add. The language also omits inheritance, modifiers, and inline assembly, which narrows the audit surface. The remaining decision is deliberately small: where the reentrancy lock goes, and what discipline surrounds it.
The smallest suitable design
The design has two layers, and neither is optional for functions holding user funds:
- Checks-effects-interactions ordering. Validate inputs, update all contract state, then make the external call last.
@nonreentranton every state-mutating function that makes an external call or that could be reached during another function's external call.
A minimal withdrawal function looks like this (Vyper 0.3.x/0.4.x syntax; pin your compiler and check release notes, since decorator semantics have evolved across versions):
@external
@nonreentrant
def withdraw(amount: uint256):
assert self.balances[msg.sender] >= amount, "insufficient"
# effects: update state BEFORE the external call
self.balances[msg.sender] -= amount
# interaction: external call last
send(msg.sender, amount)
The decorator applies a storage-based lock: entering the function sets it, and any attempt to re-enter the same function before it returns reverts. This is the idiomatic Vyper replacement for Solidity's hand-rolled nonReentrant modifier, and because it is built in, there is no third-party library in your trust boundary for this control.
Trust and data boundaries
Be explicit about what the contract trusts:
- Trusted: the contract's own storage, and the Vyper runtime checks (arithmetic, bounds, the lock itself).
- Untrusted: every external address, every value derived from
msg.senderon a re-entrant path, and every token contract you call. Token callbacks are the sneaky one: ERC-777-style hooks and ERC-721/1155 receiver callbacks execute attacker code mid-transfer even when a plain ETHsendwould not give the receiver enough room to do damage. - The toolchain itself. Vyper has had compiler-level security issues in its history. Treat the compiler as part of the trust boundary: pin an exact version, check its security advisories, and do not upgrade casually without reading the release notes.
Where the lock is not enough
@nonreentrant protects a single function against re-entering itself. It does not stop cross-function reentrancy: if withdraw sends ETH and the attacker's fallback calls a different state-mutating function that reads balances mid-flight, the lock on withdraw is irrelevant. This is why the ordering discipline is not redundant. If the balance is decremented before the send, a cross-function re-entry sees correct state and the attack collapses.
Two more failure modes worth naming:
- A forgotten secondary entry point. You lock
withdrawbut add awithdrawAllor an admin sweep later without the decorator. Enumerate every external-call site during review, not just the obvious one. - Stale assumptions about gas stipends. Older advice treated
transfer-style fixed gas as a reentrancy defense. EVM gas repricing has broken that assumption repeatedly; do not rely on it as a control. - View functions read mid-callback. A
@viewfunction called by another contract during your external call can observe inconsistent state. If other contracts consume your getters, document which states are transient.
Operational checks before deploying
These are verification steps, not optional hardening:
- Pin the compiler. Compile with an exact Vyper version (for example via
vyper --versionin CI) and record it alongside the deployed bytecode. Check that version's release notes and security advisories before mainnet deployment. - Write an attacker contract. Deploy a test contract whose fallback re-calls your
withdraw. Confirm the second call reverts under your pinned compiler. This is the single most direct evidence the lock works as you think it does. - Enumerate external calls. Manually or with static analysis, list every
send,raw_call, and token interaction. For each, confirm state updates precede it or the enclosing function is locked. - Inspect the compiled output. Confirm the lock storage slot is set and cleared around the protected function body in the IR or bytecode. This catches the case where you assumed the decorator was applied but the code path says otherwise.
What would change this design
The two-layer design scales until it doesn't. Revisit it if:
- You integrate tokens with transfer hooks at multiple call sites — at that point consider locking a broader set of functions or routing all external interaction through one locked function.
- You need composability where other contracts call you mid-transaction legitimately; a blanket lock can break intended flows, and you would need to distinguish hostile re-entry from expected callbacks.
- A Vyper upgrade changes decorator semantics — treat any compiler bump as a reason to re-run the attacker-contract test, not just a version string edit.
The decision, reduced to one line: lock every function that calls out while holding user funds, order state changes before the call anyway, and prove it with a hostile receiver before you ship.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.