Common failure modes: reentrancy, overflow, and access control mistakes#

← Back to Week 5 (Blockchain track, Vyper): Guided build

Why this note exists before you write anything complex#

It would be easy to treat smart contract security as an advanced topic to worry about later, once the "real" skills are in place. That ordering is backwards, and this program deliberately avoids it. A contract's mistakes are not like a typical Python script's mistakes: there's usually no way to patch a deployed contract, no way to roll back a transaction that already went through, and the thing being protected is often, in a real deployment, actual money. Once you understand what these failure modes actually are, in plain language, you'll notice you're already watching for them every time you write a function, the same automatic way you already watch for an off-by-one error in a loop. That's the goal: a habit, not a scary warning label.

None of this should feel intimidating. Every one of these bugs comes from a small, specific, nameable mistake in ordering or checking, not from some deep mystery. Let's take them one at a time.

Reentrancy: handing over the safe combination before locking the safe#

The setup. Imagine a withdraw function that (1) checks you have enough balance, (2) sends you the money, and only then (3) records that your balance went down:

@external
def withdraw(amount: uint256):
    assert self.balances[msg.sender] >= amount, "AccountVault: insufficient balance"

    raw_call(msg.sender, b"", value=amount)  # step 2: send first, forwarding all remaining gas

    self.balances[msg.sender] -= amount  # step 3: record it after

Why step 2 is dangerous. raw_call(msg.sender, b"", value=amount) doesn't just move funds; by default it forwards essentially all remaining gas, which hands real control of execution to whatever is at that address. If msg.sender is a normal externally owned account (a person's wallet), nothing unusual happens. But if msg.sender is a contract, that contract can define a fallback behavior that runs automatically the instant it receives funds, and inside that fallback, it is free to do anything, including calling withdraw again, immediately, before the original call has finished executing step 3.

Because step 3 (the bookkeeping) hasn't run yet, self.balances[msg.sender] still shows the original, un-decremented balance. The assert at the top of the second call passes, because as far as the contract's state is concerned, no withdrawal has happened yet. The attacker's fallback calls withdraw again. And again. Each nested call sends more funds out, each one still sees the stale, unchanged balance, and the whole chain can keep going, nested many calls deep, until the contract runs out of funds entirely, all before the very first call ever reaches its own step 3.

The analogy this note promised. Picture a bank teller handing you your withdrawal in cash first, and only writing the new balance into the ledger after you've walked away with the money. While the teller's hand is still on the cash, before the ledger is touched at all, you ask "actually, can I withdraw again?" The teller checks the ledger, sees your original, still-unchanged balance, and hands you more cash. You ask again. And again. The ledger never had a chance to catch up, because it was always updated last. That's reentrancy: an external call happens before the contract finishes updating its own records, and the gap between those two moments is where an attacker calls back in and exploits the stale state.

The fix. Update your bookkeeping before making any external call, not after: checks, then effects (state updates), then interactions (external calls). This is called the checks-effects-interactions pattern:

@external
def withdraw(amount: uint256):
    assert self.balances[msg.sender] >= amount, "AccountVault: insufficient balance"  # check

    self.balances[msg.sender] -= amount  # effect, happens BEFORE the external call

    raw_call(msg.sender, b"", value=amount)  # interaction, happens last

Now, if the attacker's fallback calls back into withdraw, the balance has already been reduced, so the second call's assert fails honestly, and the reentrant chain stops immediately.

Two more tools worth knowing about, even though AssetRegistry doesn't need either. First, send(to, amount) is a narrower alternative to raw_call that forwards only 2,300 gas, deliberately too little for a reentrant callback to do anything meaningful; it's a blunt but genuinely effective guard when you only need to move value and nothing more. Second, Vyper offers a @nonreentrant decorator you can attach directly to a function as an explicit lock, an extra guard on top of checks-effects-interactions rather than a replacement for it. Both exist because reentrancy is common enough, across the whole industry, to deserve more than one layer of defense.

Does AssetRegistry have this problem? No, and it's worth understanding exactly why not, rather than just trusting that it doesn't: none of AssetRegistry's functions send funds, call another contract, or make any external call at all. They only read and write the contract's own state. There is no step where control gets handed to someone else's code, so there's no gap for an attacker to jump into. Reentrancy specifically requires an external call; recognizing which of your functions make one (usually anything sending value, or calling a function on another contract or an interface) is the actual skill, more than memorizing the fix.

Integer overflow and underflow: the odometer problem, and why Vyper never had it#

The setup. uint256 (an unsigned integer) can represent a huge range of values, but it is still a fixed-size number under the hood, not an infinitely growing one. If you try to subtract more than a uint256 currently holds, or add past its maximum, the mathematically correct answer doesn't fit. Some languages, in some versions, silently wrap around to the other end of the range instead of raising any error at all when that happens.

The analogy. A car's mechanical odometer with six digits rolls from 999999 back to 000000 once the car has driven one more mile. The car hasn't suddenly become brand new, but the display now says it has zero miles on it. Anyone trusting that number at face value, without knowing wraparound is possible, would draw a wildly wrong conclusion. Integer overflow, applied to a balance, a supply count, or any other on-chain number, is the exact same phenomenon, except the "wildly wrong conclusion" might be "this account has an enormous amount of a valuable token," rather than just an odd mileage reading.

Where Vyper stands, and has always stood. Vyper checks every arithmetic operation for overflow and underflow automatically, for every integer size, and this has been true since the language's first versions, not something added later. If an operation would overflow or underflow, the transaction reverts, exactly the same way a failed assert does, instead of silently wrapping. Unlike Solidity, which only added this protection in version 0.8 and still lets you explicitly opt back out with an unchecked { ... } block, Vyper has no unchecked keyword, no equivalent escape hatch, at all. There is no way, in any version of Vyper, to turn this protection off. That's a deliberate, permanent design choice: the language would rather cost you a small, fixed amount of extra gas on every arithmetic operation than ever let a silent wraparound happen.

Why the concept still matters even though you can't turn the protection off. Two reasons, both concrete:

  1. An enormous amount of real value still sits in contracts written in other languages, or older versions of them, where this protection either doesn't exist or has to be added by hand. Reading unfamiliar contract code without understanding what you're looking for here means missing a real, historically exploited bug class the moment you step outside Vyper.
  2. Understanding why the compiler reverts is the same thing as understanding what it's reverting to prevent. "Vyper handles it" is a satisfying answer only if you already know what "it" is.

Does AssetRegistry have this problem? update_balance takes a complete new balance as an argument and simply assigns it (self.holders[holder].balance = new_balance); there's no addition or subtraction happening, so there's nothing to overflow. If you ever extend this contract with a function like "add this many tokens to the existing balance" (self.holders[holder].balance += amount), Vyper's automatic checks would already catch a case where amount would push the balance past what a uint256 can hold, or where a subtraction would try to go below zero, with no extra code from you, and no way to accidentally silence that protection later either.

Access control mistakes: the check you forgot, or forgot to copy#

This one was covered in depth in the access-control note, so this is a short, deliberate recap, because it belongs in the same list as the other two failure modes, not as a footnote to a different topic.

The mistake: writing a function that changes sensitive state, register_holder, update_balance, set_eligibility, or anything like them, without an assert msg.sender == self.owner, ... line. Without it, the function is fully public to anyone on the network, and "anyone on the network" for a public testnet or mainnet contract genuinely means anyone, not "anyone on our team."

The subtler version: protecting most of your sensitive functions, but missing one, often because it was added later, in a hurry, and the check line didn't get copied over. A contract that protects two out of three sensitive functions is not "mostly safe." It's exactly as exploitable, for that one function, as a contract with no access control at all. Vyper's lack of a modifier means there's no shared abstraction to forget to attach, but there's also no shared abstraction guaranteeing every function got the check either; the discipline still has to be yours, function by function.

The habit: for every function you write that changes state, stop and explicitly answer "who is allowed to call this," and write that answer into the code as a check, rather than assuming it's obvious or that a nearby function's protection covers it too. This is genuinely the cheapest of the three failure modes to prevent, because the fix is one line you write deliberately, not a subtle reordering of operations or a numeric edge case. That's exactly why it's worth making into reflex before moving on to anything more complex than AssetRegistry.

The pattern across all three#

Notice what all three failure modes have in common: each one is a small, specific, checkable question you can ask about a piece of code, not a vague sense of "something might be wrong." Does this function make an external call before updating its own state? Could this arithmetic operation overflow or underflow, and would Vyper's automatic check actually catch it here? Does this state-changing function have an explicit, correct check on msg.sender? Asking those three questions about every function you write, from here on, is most of what "security is a habit, not an afterthought" actually means in practice.

TL;DR#

This page covers three specific, nameable contract bugs and the habit that prevents each one, plus what Vyper already does for you before you even think about them.

  • Every state-changing function needs an explicit, correct check on who can call it. Don't assume a neighboring function's check covers this one too.
  • If a function sends funds (or otherwise hands control to another contract) before it finishes updating its own state, an attacker's contract can call back in while your records still look unchanged, and drain more than it should. That's reentrancy.
  • The fix is checks, then effects, then interactions: confirm the conditions first, update your own state second, and only make the external call last, once nothing stale is left for anyone to exploit.
  • Vyper reverts a transaction automatically if arithmetic would overflow or underflow (like a subtraction dropping below zero), for every integer size, in every version, with no way to opt out at all, unlike Solidity's unchecked escape hatch.
@external
def withdraw(amount: uint256):
    assert self.registered[msg.sender], "AccountVault: not a registered user"    # access check: who can call this at all
    assert self.balances[msg.sender] >= amount, "AccountVault: insufficient balance"  # check: confirm the conditions first

    self.balances[msg.sender] -= amount  # effect: state updated before any external call (Vyper would also
                                          # auto-revert here if this ever underflowed past zero, no unchecked exists)

    send(msg.sender, amount)  # interaction: happens last, so a reentrant callback from msg.sender
                               # would already see the reduced balance