Exercises: read and predict#
← Back to Week 5 (Blockchain track, Vyper): Guided build
These are not full contracts, and you're not asked to fix or deploy anything here. Each snippet below is a small, isolated piece of Vyper. For each one:
- Read it slowly, out loud if that helps.
- Write down, in your own words, what it's supposed to do.
- Write down what's actually wrong with it (or, for a couple of these, why it's actually already safe), and what a specific outcome would look like either way.
Only after you've done that for a snippet, read the explanation underneath it. If you find yourself wanting to peek early, that's a sign to slow down, not a sign the exercise is too hard. Snippets 1 through 3 map directly onto the three failure modes from notes/04-failure-modes.md, in the same order. Snippets 4 through 6 are a second pass over the same three failure modes, each one a genuinely different concrete scenario from its first-pass counterpart, so recognizing the shape matters more than remembering the specific example.
Snippet 1#
eligible: public(HashMap[address, bool])
@external
def set_eligible(holder: address, value: bool):
self.eligible[holder] = value
What does this function do, and what's wrong with it?
<details> <summary>Explanation (read only after you've made your own guess)</summary>
The function does exactly what it looks like: it sets whether an address is eligible. The problem is that literally anyone can call it, for any address, including addresses that aren't their own. There is no assert checking msg.sender against anything at all. A stranger could call set_eligible(their_own_address, True) and grant themselves eligibility with no permission check whatsoever, or call set_eligible(someone_elses_address, False) purely to mess with another holder's status. This is the "access control mistake" failure mode: a state-changing function with no lock on the door at all. Vyper's lack of a modifier keyword doesn't cause this bug; the bug is simply that nobody wrote any check, in any form, anywhere in the function.
</details>
Snippet 2#
balances: public(HashMap[address, uint256])
@external
def withdraw(amount: uint256):
assert self.balances[msg.sender] >= amount, "insufficient balance"
raw_call(msg.sender, b"", value=amount)
self.balances[msg.sender] -= amount
Predict specifically: what happens if msg.sender is a contract whose fallback behavior calls withdraw again, before the first call finishes?
<details> <summary>Explanation (read only after you've made your own guess)</summary>
The balance check passes, the funds get sent via raw_call (which, with no gas= argument, forwards essentially all remaining gas), and control passes to the caller before self.balances[msg.sender] -= amount ever runs. If that caller's contract calls withdraw again immediately, the assert at the top checks the same, still-unchanged balance, and passes again, so more funds go out. This can repeat multiple times, nested, before the very first call ever reaches its own bookkeeping line. This is reentrancy: the external call (raw_call(..., value=amount)) happens before the effect (decrementing the balance), leaving a window where stale state gets read and trusted again. The fix is to reorder the last two lines: decrement self.balances[msg.sender] before making the external call.
</details>
Snippet 3#
tokens_by_index: public(HashMap[uint256, uint256])
@external
def decrease_tokens(index: uint256, amount: uint256):
self.tokens_by_index[index] -= amount
Predict specifically: what happens if amount is larger than the current value of tokens_by_index[index]?
<details> <summary>Explanation (read only after you've made your own guess)</summary>
This one reverts, safely, with no state change at all, and it's worth being precise about why that's guaranteed rather than assumed. uint256 can't represent a negative number, so if the true, honest result of the subtraction would be below zero, Vyper's automatic underflow check catches it before the assignment ever happens, and the whole transaction reverts. There is no unchecked-style block in Vyper, no keyword or setting anywhere that would let this line silently wrap around to a huge number instead. That's different from some other languages, where this exact code, written without a protective wrapper, would wrap around to a value near the top of uint256's range. In Vyper, this snippet is already safe, in every version of the language, by default, with nothing extra for you to add.
</details>
Snippet 4#
owner: public(address)
credits: public(HashMap[address, uint256])
@external
def grant_credits(who: address, amount: uint256):
assert msg.sender == self.owner, "not owner"
self.credits[who] += amount
@external
def revoke_credits(who: address, amount: uint256):
self.credits[who] -= amount
What's wrong here, and how is it different from Snippet 1's problem?
<details> <summary>Explanation (read only after you've made your own guess)</summary>
grant_credits is properly protected: the assert runs first, so only the owner can hand out credits. revoke_credits has no assert at all, so any address on the network can call it against any other address's credits. This is the "subtler version" the access-control note named directly: a contract that looks protected at a glance, since one of its two sensitive functions clearly has a check on it, but is exactly as exploitable as having no access control at all for the one function that was missed. A stranger could call revoke_credits(someone_elses_address, someone_elses_entire_balance) and drain another address's recorded credits down to zero, with zero permission to touch that address's record at all. Unlike Snippet 1, where no function had any lock, the bug here is not "forgot access control entirely," it's "protected the function that looked more important, and forgot its sibling." Vyper having no modifier to copy over doesn't change this risk at all; the missing line is just as missing either way.
</details>
Snippet 5#
claimed: public(HashMap[address, bool])
reward_amount: public(uint256)
@external
def claim_reward():
assert not self.claimed[msg.sender], "already claimed"
raw_call(msg.sender, b"", value=self.reward_amount)
self.claimed[msg.sender] = True
Predict specifically: what happens if msg.sender is a contract whose fallback behavior calls claim_reward again, before the first call finishes? Is this the same bug as Snippet 2, or a different one?
<details> <summary>Explanation (read only after you've made your own guess)</summary>
Same failure mode as Snippet 2 (reentrancy), a different concrete shape. Here the state being protected isn't a numeric balance, it's a boolean flag meant to mean "this address has already claimed, exactly once." self.claimed[msg.sender] is still False at the moment the external call goes out, because self.claimed[msg.sender] = True is the very last line, after the transfer. A contract calling in as msg.sender can call claim_reward() again before that last line ever runs, and assert not self.claimed[msg.sender], ... still passes, since the flag hasn't flipped yet. Each reentrant call sends another reward_amount, potentially many times, all before the first call ever marks the claim as used. The fix is the same reordering principle as Snippet 2's, checks, then effects, then interactions: set self.claimed[msg.sender] = True immediately after the assert and before the external call, so any reentrant attempt sees the claim already marked used and reverts on the very first line.
</details>
Snippet 6#
daily_visitor_count: public(uint8)
@external
def record_visitor():
self.daily_visitor_count += 1
@external
def reset_daily():
self.daily_visitor_count = 0
Predict specifically: daily_visitor_count is currently 255, its maximum possible value as a uint8, and nobody has called reset_daily() yet. What happens when record_visitor() is called one more time? Is this the same outcome as Snippet 3, or a different one?
<details> <summary>Explanation (read only after you've made your own guess)</summary>
Same outcome as Snippet 3, and that repetition is the entire point of this snippet: Vyper's automatic overflow protection applies to every integer size, not just uint256, uint8 included. Adding 1 to 255 (the largest value a uint8 can hold) overflows, so the call reverts, with no state change, exactly the way a failed assert would. daily_visitor_count stays at 255 until someone calls reset_daily(). This is worth sitting with precisely because Snippet 3 might have felt like a special case tied to subtraction; it isn't. There is no integer size, no operation, and no version of Vyper where this protection can be silently missing or deliberately switched off. That's the whole difference from a language that added this protection later and kept an opt-out: here, "checked by default" simply has no counterpart to remember or watch out for.
</details>
Before you move on#
If any of your six guesses were wrong, don't just note that and move on. Go back to the matching section of notes/04-failure-modes.md and re-read it with that specific snippet in mind. The goal of this exercise isn't a score, it's making sure these three failure shapes, and the one habit Vyper builds in for you automatically, are recognizable on sight before you read or write project/contracts/AssetRegistry.vy, which was written specifically to avoid all three.