T28 · Smart Contract Security
If no line of code was wrong, why were the funds still stolen?
- Capabilities practiced
- Builder
- Hands on
- Write a contract with a reentrancy vulnerability, exploit it with your own attack contract, then fix it.
- AI Lab
- Have AI audit your contract. Classify each finding as a real issue, false positive, or missed issue, and calculate the proportions.
A Real-World Problem
A vault contract has twenty lines: deposit funds, then withdraw them.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}Read it line by line:
First line: read this person's balance. Correct. Second line: reject a zero balance. Correct. Third line: transfer the money to them. Correct. Fourth line: revert if the transfer fails. Correct. Fifth line: clear their balance. Correct.
Each of the five lines is individually correct. The compiler raises no warnings, every unit test passes, and branch coverage is 100%.
The day after launch, the vault is drained. Not merely one person's funds—all users' funds.
The transaction trace is strange: within one transaction, withdraw is entered dozens of times. Each call succeeds and transfers the same amount, even though the initiating account deposited only a small sum.
The question is no longer “which line is wrong?” No individual line is wrong.
The question is: what happened between these five lines that reading them one by one hid from me?
Thought Experiment
Translate this code into a teller's procedure.
You go to a bank to withdraw money. The teller does three things:
1. Open the ledger and check your balance
2. Count out that much cash from the drawer and hand it to you
3. Cross out your balance in the ledgerThat order seems natural, and usually it causes no problem.
Now add one condition: at the instant they hand you the cash in step 2, you can immediately submit another request, and the teller must handle it before returning to step 3.
Then:
Teller checks ledger: you have 100 → Counts out 100
Another request arrives right now → Teller checks ledger: still 100 (not crossed out!)
→ Counts out another 100
Yet another request arrives → Ledger still says 100
→ Counts out another 100
...
The drawer is empty; the teller finally returns and crosses out the 100The ledger is updated only once, while cash leaves dozens of times.
The teller did not miscalculate a single entry. Every time, they checked the ledger and paid the recorded balance. They made one mistake:
At the instant after paying but before recording the payment, they allowed a new request to interrupt.
That is the entire problem in the opening code. The third line, msg.sender.call, does more than “send the money”: it gives control of execution to the recipient. Their code runs while the ledger still has its old value.
Now consider a second thought experiment, for the other half of this chapter.
An office building checks identification at the front door. The guard never makes a mistake, and everyone inside feels safe. But nobody guards the side door because the designers assumed “only employees know it exists.”
The guard's check never failed, but someone still entered the building. Security depends not only on how strict the check is, but on whether another route bypasses it.
Together, the experiments make the chapter's point:
A vulnerability may lie in the order between lines, or in a route that no one guards, rather than in any single line.
Your Decision
Make one first change to the opening vault. Which do you choose?
What Happens
Compare the four changes:
| Root-cause fix? | What it cannot stop | Can it expire? | Cost | |
|---|---|---|---|---|
| Mutex | No; safety net | Cross-function and read-only routes | No | A little Gas |
| Change order | Yes | Inconsistent state across contracts | No | None |
| Limit Gas | No | Anything once pricing changes | Yes | Legitimate contracts cannot receive funds |
| Pull model | Yes, and addresses other issues | None, but changes the interaction | No | Two transactions |
The first conclusion:
Ordering is the root-cause fix; a lock is insurance. Use both.
Do not ignore order because you added a lock; locks have blind spots. Do not omit the lock because order is correct; a later change may accidentally reverse the order, and the lock can save you then.
The second, more important conclusion is this chapter's main point:
Every call to an external address yields control of execution. Before yielding control, your state must be internally consistent.
This applies to far more than native-token transfers. List every place control can leave and you will find more than you expect—the list that T7, T9, and T12 have been foreshadowing.
The third conclusion comes from the second thought experiment:
Security depends less on how strict a check is than on whether some path bypasses it.
So audit a contract by asking who guards each reachable path, not just by reading one line at a time.
Build a Model
Where execution control can leave
First, make the complete list. This is the table to keep next to your monitor.
| Location | Why control leaves |
|---|---|
Native-token transfer (call) | The recipient's receive or fallback executes |
Any call / delegatecall / staticcall to an external address | That is how these calls work |
| NFT transfer to a contract | The standard requires a recipient callback to confirm receipt |
| Single and batch transfers under multi-token standards | Same; batch callbacks fire only once, which is easy to misjudge |
| Tokens with transfer hooks | The standard may notify sender or recipient before or after a transfer |
| Any callback-based design | Flash loans, flash swaps, auction bid notices |
| An address supplied by a user | It may be a contract rather than a person |
The last row sums up the table:
Every address parameter might be a contract.
T9 noted that an NFT's recipient callback prevents tokens from being trapped in a contract that cannot receive them. It also means the recipient gets control during the transfer. Now complete the picture:
// An ordinary-looking mint function
function mint(uint256 qty) external payable {
require(qty <= MAX_PER_TX, "too many");
require(minted[msg.sender] + qty <= MAX_PER_WALLET, "wallet limit");
require(msg.value == qty * PRICE, "wrong value");
for (uint256 i = 0; i < qty; i++) {
_safeMint(msg.sender, nextId++); // This calls back into the recipient
}
minted[msg.sender] += qty; // Count updated only after the callback
}The recipient calls mint again in its callback. At that point minted[msg.sender] is still the old value, making the per-wallet cap ineffective. Every line is right; the order is wrong.
Four forms of the same pattern
Most people recognize only the first. The third often causes real losses.
| Form | What happens | Does a mutex help? |
|---|---|---|
| Same function | Callback enters the same function again | Yes |
| Cross-function | Callback enters another function sharing the same state | Only if both functions share the lock |
| Read-only | During callback, another contract reads an intermediate value from your view function | Not at all |
| Cross-transaction | No callback, but state is still updated after it is used | Not applicable |
The third deserves its own discussion: it has caused many recent incidents, and the victim is often an integrator rather than you.
// A vault quotes total assets divided by total shares
function pricePerShare() external view returns (uint256) {
return (address(this).balance * 1e18) / totalShares;
}
function redeem(uint256 shares) external nonReentrant {
uint256 amount = (address(this).balance * shares) / totalShares;
sharesOf[msg.sender] -= shares;
(bool ok, ) = msg.sender.call{value: amount}(""); // Control leaves here
require(ok, "transfer failed");
totalShares -= shares; // Denominator changes only after this point
}redeem is locked, so no one can enter it again. But during that call:
Numerator (contract balance): already reduced
Denominator (totalShares): not yet reduced
→ pricePerShare returns a value below the true oneRather than call this vault again, the external contract calls a lending protocol that uses pricePerShare as an oracle. The protocol sees the depressed price, opening the oracle-manipulation route from T21.
Three lessons:
viewfunctions do not have locks and cannot be protected by them. The only protection is to ensure their state is always consistent.- The “state” in CEI includes every externally visible derived value, not just variables explicitly modified in this function.
- Integrators must ask whether a value they read is an intermediate state at that exact moment. This is the previous chapter's rule to place the defense on your side when upstream is untrusted.
CEI: an order in three letters
Checks Validate every condition and parameter first
Effects Then update this contract's state to reflect what happened
Interactions Only then interact with the outside worldCompare:
| Flawed order | Correct order | |
|---|---|---|
| Withdrawal | Transfer → clear balance | Clear balance → transfer |
| Minting | Callback mint → increase count | Increase count → callback mint |
| Swap | Send tokens → update reserves | Update reserves → send tokens |
| Liquidation | Give collateral to liquidator → reduce debt | Reduce debt → give collateral |
T19 described an exception: real AMMs often transfer out first and check the invariant afterward to support flash swaps. That does not simply ignore CEI; it replaces the Effects step with “check the invariant after the interaction.” This works only if you can state the invariant precisely and check it when control returns.
You may close the books after an interaction only if you can write down and verify the invariant. Otherwise, follow CEI.
Permission boundaries
The previous section concerns order; this one concerns boundaries. Together, they account for many vulnerabilities.
| Pitfall | Concrete form |
|---|---|
| Missing modifier | An administrative function lacks a permission check: basic and common |
| Authorize by transaction origin, not caller | Checking tx.origin lets a phishing contract act with a user's identity |
| Unprotected initialization | Between deployment and initialization, anyone can become administrator |
| Implementation can be called directly | The contract behind a proxy has its own address, as T8 showed |
| Transitive permission | A trusts only B, but B accepts calls from everyone; A effectively trusts everyone |
| Default allow | A new function without a check is open to everyone |
The fifth is subtle because it spans two contracts; auditing either alone may miss it:
// Vault: trusts only the strategy contract
function pull(uint256 amount) external {
require(msg.sender == strategy, "only strategy");
token.transfer(strategy, amount);
}
// Strategy: a rebalance function anyone can trigger
function rebalance(address to, uint256 amount) external { // No check
vault.pull(amount);
token.transfer(to, amount);
}The vault's check is entirely correct. It trusts the strategy's address. The strategy passes that trust on to everyone without restriction.
To audit permissions, draw a graph instead of merely reading modifiers: who can reach this function, and through how many hops? If any hop admits everyone, the whole path admits everyone.
One final practical rule:
Deny by default. When adding an external function, assume it needs a permission check, then justify making it public—not the reverse.
Precision and rounding direction
These flaws need no special attack trick—only someone who checks the arithmetic more carefully than you did.
Integer division always rounds down. That is not a bug. The question is who bears the error.
A rule you can reuse directly:
Every rounding error must favor the protocol, not the user.
In detail:
| What is being calculated | Direction | Reason |
|---|---|---|
| Shares from deposited assets | Down | Give the user slightly fewer shares |
| Assets from redeemed shares | Down | Give the user slightly fewer assets |
| Debt owed by a user | Up | Count a little more debt |
| Value of a user's collateral | Down | Count a little less collateral |
| Fees | Up | Let the protocol collect a little more |
| Collateral a liquidator may take | Down | Do not overpay the liquidator |
Another hard rule: multiply before dividing. Dividing first loses precision before the result is scaled up, a common way to harm your own accounting.
Now combine these issues in a classic outcome: the first-depositor problem.
Initial state: totalShares = 0 and the vault is empty
1. The first depositor deposits 1 wei
The first deposit is often 1:1 → gets 1 share; totalShares = 1
2. The same person sends 10000 tokens directly to the contract, NOT through deposit
Contract balance = 10000e18 + 1, while totalShares stays 1
3. The next person deposits 10000 tokens
Shares = 10000e18 * totalShares / contract balance
= 10000e18 * 1 / (10000e18 + 1)
= 0 ← integer division rounds down
4. That person gets 0 shares. Their 10000 tokens become vault assets,
while the first depositor owns the vault's only shareThree conditions must hold at once, each seemingly harmless by itself:
- Shares are assets times total shares divided by total assets, rounded down
- Total assets comes straight from the contract balance, so a direct transfer can distort it
- Tiny initial deposits are permitted, allowing totalShares to equal 1Three corresponding defenses are usually combined:
- Internal accounting: maintain total assets in your own variable instead of reading the balance. Ignore direct donations in the share calculation.
- Virtual shares and assets: add a fixed offset to numerator and denominator, making a zero-share result uneconomic to induce.
- Dead shares: mint initial shares to an irretrievable address so
totalSharesnever falls into a dangerous range.
Add a final safeguard: revert if calculated shares equal zero. One line prevents the last step of this exploit.
Upgrade risk
T8 established three rules for proxies. Apply each, then add a fourth.
First, storage layout may only grow at the end. Do not insert, change types, reorder, or remove fields.
Code review alone cannot enforce this. CI must do it: export and commit every version's storage layout, then compare on upgrade and permit only additions. This check can be fully automated.
Second, put the proxy's own variables in fixed pseudorandom slots. Do not declare them starting at slot 0 or they will collide with the implementation's layout.
Third, the implementation contract is itself a directly callable address. Its constructor should mark it initialized so nobody else can initialize it, and it must have no path that destroys it.
Fourth, the upgrade action itself needs an audit, not just its code. Ask:
- Who can trigger an upgrade: one person, a multisig, or a governance vote?
- Is there a timelock? How long?
- Does an upgrade emit an event that external monitors can see?
- Is there an “initialize the new version” step, and is it protected?
- Can an erroneous upgrade be rolled back?Chapter 24 made a point worth repeating: an upgradeable contract without a timelock effectively hands all funds to the holder of the upgrade key. Even pristine code reviews mean little if this condition fails.
Invariants: bring the issues together
You have seen four classes of problem. Though they look different, they share a feature:
Each breaks a statement that should always be true, at some particular moment.
| Problem | Statement that fails |
|---|---|
| Repeated withdrawals from bad ordering | Assets held by the contract are at least the sum of user balances |
| Read-only access to an intermediate state | Every externally readable derived value reflects consistent state |
| Excess minting | The amount minted for each address never exceeds its cap |
| Share inflation | Someone depositing a positive amount receives a positive number of shares |
| Transitive permission | Only authorized addresses can reach this function |
These statements are invariants. Their value is not in sounding sophisticated. It is this:
An invariant is a safety statement a machine can check repeatedly.
“I think this function is safe” cannot be checked. “The contract balance is always at least the sum of user balances” can. You can generate millions of random operation sequences and check it after each operation.
That is the work in the next stage. For now, build the habit of writing invariants before features whenever you write a stateful contract. T30 covers the full method and automated checks.
What It Is Called
After an external call yields control, control flow enters this contract again before its state is updated.
The essence is not “called twice” but state updated after it is used. The read-only form is easiest to overlook, and a mutex cannot stop it.
A standard function order: perform all checks, update this contract's state, and only then interact externally.
It fixes reentrancy at its root rather than merely reducing risk. There is one exception: close the state by verifying an invariant after interaction, provided you can state that invariant.
A state flag that prevents another entry while a function runs.
It is insurance, not a cure. Two blind spots: functions without a shared lock can enter each other, and it never protects view functions.
Rules governing who can call which function.
Audit it by drawing a reachability graph, not merely reading modifiers: who can reach a function and through how many hops? One “everyone” hop makes the entire path open to everyone. Deny by default.
Integer division necessarily loses precision. The question is who absorbs the error.
One rule: rounding error should always favor the protocol. Round down what users receive and round up what they owe. Also multiply before dividing.
The risks of an upgradeable contract include storage-layout collision, seized initialization, and the fundamental fact that whoever can upgrade can rewrite all logic at once.
Compare layouts in CI, and protect upgrade authority with a timelock and events. Without both, a code audit loses much of its value.
A statement that must remain true at every point and after every sequence of operations.
Its value is that a machine can check it repeatedly. A contract without expressible invariants relies on human inspection alone; one with them can be tested against millions of generated sequences.
Hands-on
Do this exercise only in your own local environment. The point is to see how every line can look right while the whole program is wrong, and remember the fix. Do not deploy this code on any public chain or try it against any real contract. Everything on your local node belongs to you, and the exercise needs nothing else.
These may be the most important thirty minutes of this stage. Watching your own vault fail in a test teaches more than ten incident reports.
There are three acceptance criteria, each a test that can change from green to red:
Acceptance 1: A test proves an account that deposits a small amount gets back more than it put in
Acceptance 2: After fixing the order, that same test must change from passing to failing
Acceptance 3: Explain what the lock and order fix each stop, and where each has a blind spotWrite this vulnerable vault exactly as shown.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
// This line yields control to msg.sender
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
// By the time control returns here, the code above may have been reentered many times
balances[msg.sender] = 0;
}
function totalAssets() external view returns (uint256) {
return address(this).balance;
}
}Deploy it locally and deposit from two or three ordinary addresses. These deposits represent other honest users' money, the funds that will be lost in this exercise. Record totalAssets().
Write a probe contract to reproduce the flaw.
This is a test fixture, not an attack on anyone. It calls withdraw again when a payment arrives, turning “bad order allows repeated withdrawal” into a testable assertion.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
interface IVault {
function deposit() external payable;
function withdraw() external;
}
// Test fixture: used locally only to prove the flaw
contract ReentrancyProbe {
IVault public immutable vault;
uint256 public unit;
constructor(address v) {
vault = IVault(v);
}
function start() external payable {
unit = msg.value;
vault.deposit{value: msg.value}();
vault.withdraw();
}
// The vault triggers this on payment, before clearing its ledger entry
receive() external payable {
if (address(vault).balance >= unit) {
vault.withdraw();
}
}
}Before deploying, trace it on paper. Write down balances[probe] and the vault balance on the first three entries. Then run the test. The trace is the point of the exercise; do not skip it.
Assert the loss in a test.
Have honest addresses deposit first. Then call the probe's start() with a small amount and assert two things:
- The vault's totalAssets() falls to zero or nearly zero
- The probe receives substantially more than its original small depositThe test should now pass: it has proved that money is lost. That is stronger evidence than any verbal description. The standard is the same one from T8: an executable test beats an explanation.
Fix the order and make that same test turn red.
Change withdraw to CEI order:
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "nothing to withdraw");
balances[msg.sender] = 0; // Update accounting first
(bool ok, ) = msg.sender.call{value: amount}(""); // Interact second
require(ok, "transfer failed");
}The same test must fail now. On its second entry, the probe finds a zero balance and require stops it.
A successful compile is not proof of a fix. The change from green to red is the central acceptance condition.
Add a mutex separately to see how it differs from the order fix.
Restore the vulnerable vault, add only a lock, and watch the test turn red as well.
Then answer: if two vault functions both transfer funds and share balances, but only one is locked, what happens? Write a test for the cross-function route. That is the lock's first blind spot.
Reproduce the read-only route to see the lock's second blind spot.
Use the share-price example in this chapter: lock redeem, but update totalShares after the transfer. In a second contract, read the vault's pricePerShare() during the payment callback.
You will see a wrong price despite the lock on redeem. The lock cannot protect a view function.
Move totalShares -= shares before the transfer to fix it. This returns to the main rule: every externally readable quantity must be consistent before control leaves.
Summarize the repairs. Make a table of each fix used in the exercise:
| Fix | What it stops | Blind spot |
|---|---|---|
| Reorder (CEI) | Same-function, cross-function, and read-only routes | Cross-contract state consistency needs separate analysis |
| Mutex | Reentry into locked functions | Both cross-function routes need the lock; view is never protected |
| Pull model | Removes transfers from stateful logic | Changes the interaction model |
Once you can fill this in, you understand the first conclusion: ordering fixes the root cause, a lock is insurance, and you need both.
AI Lab
The output is not “AI found some bugs.” It is your report on the limits of trusting an AI audit. Work in three steps.
Step one:
Here are my contracts: the vault from the hands-on exercise, a share contract
with a read-only route, and a two-contract design I wrote with one transitive
permission flaw. Find every security issue. For each, give:
- The exact function and line
- Preconditions and call sequence
- Consequence
- Fix
Do not give vague advice such as “get an audit” or “watch for reentrancy.”
Step two:
For every issue you reported, write a test that FAILS on the current code.
If you cannot produce such a test, mark the claim as uncertain.
Step three:
I deliberately left at least two unannounced problems in the code:
a read-only route and transitive permission across contracts.
Audit it again with a focus on those two classes.The first step exposes a typical pattern: AI is good at named, textbook issues such as same-function reentrancy or a missing modifier, but much worse at reasoning across functions and contracts. It also fills reports with theoretical possibilities that do not apply in context. That is why you classify its findings.
Step two is the strictest filter, the same one used in the T8 delegatecall Lab: a test that fails on the bad code is evidence that the issue exists. Ask for one test per finding. Invented findings become visible when the model cannot write a test or the test unexpectedly passes. Downgrade any claim lacking a failing test to a false positive.
Step three measures omissions, which are more dangerous than false positives: false positives waste time; omissions make you believe you are safe. The read-only route you reproduced by hand is an ideal probe. If the model misses it in both passes, you have a precise trust boundary: this tool cannot be your guard for read-only paths.
Finally, calculate the three rates—real findings, false positives, and omissions—and write a conclusion yourself. For example: “It catches many named patterns, but I must audit read-only reentrancy and transitive permission myself.” That statement tells you whether and where the tool belongs in your workflow.
One firm limit: AI can generate audit leads; it cannot replace your audit conclusion. This is the same rule as T32's warning against using a model to validate its own output. The actual gate must be deterministic: a test that can turn red or green.
Verify these points yourself after using AI
- For every “real issue” it reports, write a failing test that proves it; otherwise downgrade it to a false positive
- Each finding must identify a function, line, and trigger path; “possible reentrancy” without a location is noise, not a finding
- Hide the read-only route deliberately left in the hands-on code and see whether it finds it; if not, count one omission
- Check whether it declares a mutex sufficient without explaining whether the ordering flaw was fixed
- Check every rounding-direction claim against the rule that error must fall on the protocol side
- Verify every cited library, modifier, and standard number yourself rather than trusting it
- Calculate the rates of real findings, false positives, and omissions yourself, then state the tool’s trust boundary
Real-World Case
A contract that raised substantial funds had a withdrawal function shaped like the opening example: transfer first, clear balance later. Someone exploited that order to enter the withdrawal flow repeatedly within one transaction and take far more than their share.
The effect was large enough to cause a chain fork. The root cause was astonishingly simple: the order of two lines of code.
This incident began the industry's muscle memory to “record state before interacting.” It also illustrates the opening point: no single line was wrong; the error was between the lines.
A vault exposes a view function quoting the value of each share. Its state-changing function uses a reentrancy lock and looks safe. But it updates the share count after transferring funds, so the quote is distorted during the callback.
Another lending protocol relies on that quote as a price source. During the callback, it reads the depressed price and makes loans it should not make.
Both protocols had passed audits individually; the flaw appeared in their combination. Two lessons follow: view functions are attack surfaces, and T21's warning applies—treating another protocol's instantaneous state as an oracle is a common entry point.
T27 covered this from the account side. From the contract side, a permission intended for a narrow operation did not restrict the callable function. It was used to invoke an approval function, granting an attacker-controlled address an allowance. The later transfers bypassed the original check entirely.
The amount limit never fired because the approval itself transferred zero value.
The recurring rule is: cover every operation that can change control of funds, not only transfers. Approvals, delegation, administrator changes, and upgrades all matter.
T8 described the incident in detail. It belongs here because it combines two models from this chapter: an unprotected initialization path (permission boundary) and the implementation contract's own directly callable address (upgrade risk).
Someone called the library contract's initializer directly, became its owner, and triggered self-destruction. Wallets depending on it became unusable at once.
The lesson: when auditing an upgradeable system, audit the implementation separately from its proxy, assuming anyone may call it directly.
Change One Variable
You block same-function and cross-function reentry at the cost of some Gas on every function. It looks safe.
But two openings remain: the read-only route (a lock cannot protect view) and inconsistent state across contracts (integrators can read the intermediate state exposed during transfer).
Over time, the lock may dull attention to order. In a later iteration, a new function may omit the lock or someone may remove it to save Gas. Then order is your only defense—and you never maintained it. That is why you need both.
Reentrancy largely disappears as a class. The Gas limit on batches and one failed recipient blocking everyone else are addressed too.
The cost is a changed interaction model: almost every action takes two transactions, raising product complexity and harming the experience. It also creates new “pending claim” state that belongs in your invariants: total pending plus total paid must equal total owed.
A pull model is therefore a tradeoff: exchange immediacy for the removal of a class of ordering risk. It fits cases that do not require instant delivery.
The first-depositor case where a deposit yields zero shares nearly disappears, along with many self-inflicted precision losses.
Yet every rounding-direction rule still applies. A tiny error is not zero. If one operation rounds the wrong way, an attacker can repeat it enough times to accumulate the error—many extraction attacks have precisely this shape.
Higher precision also does not fix the root cause of reading total assets directly from the balance. Rounding is only one of the three conditions behind share inflation. Internal accounting and virtual shares are still needed.
The upgrade-related class of risk disappears: no one can change logic, collide storage layouts, seize an initializer, or rewrite everything with one key.
But risk shifts elsewhere: you cannot repair a discovered vulnerability. If a publicly known bug exists in an immutable contract, often all you can do is urge users to withdraw while funds continue to be taken.
The choice is about which loss of control concerns you more: an upgradeable contract can have its upgrade key abused; an immutable one cannot patch a bug. Mature designs often compromise: upgrades remain possible, but timelocked governance makes changes slow, transparent, and visible early enough for users to exit.
Takeaway Questions
Who bears the risk? This chapter gives a harsh answer: the deployer and the users who trust the contract. A contract bug cannot be hotfixed like a server bug; once live, it is public, irreversible, and under constant scrutiny. Each fix here should be judged by a test that proves it, not by whether it looks right.
If a token price goes to zero, can the product keep running? Reframe the question: what happens if an external contract you depend on behaves unexpectedly? That is this chapter's main concern. Any external call can return an unexpected result or perform unexpected work in a callback. Treat every external dependency as a potential source of failure to make your contract resilient.
Who bears the loss when AI is wrong? In this chapter's AI Lab, the most dangerous audit error is not a false positive but an omission that makes you think the code is safe. Read-only routes and transitive permission across contracts are frequent omissions. Responsibility remains with the person deploying the contract: AI can suggest leads, but the conclusion needs a test that can turn red or green.
Chapter Check
Because vulnerabilities often lie in the order between two lines or a path with no guard, rather than in a single line.
Every line in the opening vault was locally correct. The flaw was yielding control after paying out but before recording the payment. This is a state-ordering flaw, not a syntax error.
None of this chapter's four classes—order, permission, precision, upgrade—reduces to “one line was typed incorrectly.” Each leaves a structural opening.
CEI—check first, change state second, interact last—fixes the cause. If state is consistent before control leaves, callback behavior cannot exploit a stale value. It covers same-function, cross-function, and read-only forms.
A mutex is insurance: it blocks reentry into functions that use the lock, but it has two blind spots. Functions without a shared lock can call each other, and the lock never protects a view function.
Use both. Correct order may be reversed during a later change, when the lock can save you.
It often harms an integrator rather than the contract exposing the value, and a mutex cannot stop it.
A view function may return a derived value such as share price. If its numerator and denominator are updated at different times around an external call, another contract can read an intermediate value through that view during the callback. The lock does not protect view, so it cannot block this route.
The only defense is to extend state consistency to every derived value external callers can read, not just variables this function explicitly modifies.
Rounding error should always favor the protocol.
Round down what users receive—shares, redeemed assets, collateral value—and round up what they owe—debt and fees. Also multiply before dividing so you do not lose precision before scaling.
This protects against small, one-sided errors amplified by repetition. Many extraction attacks repeat a wrongly rounded operation hundreds of thousands of times.
There is no single answer. Check these questions:
- Is there an asset-conservation invariant, such as contract balance being at least the sum of user balances? This is the most basic defense against ordering flaws.
- Is there an invariant for externally readable derived values, such as share price remaining in a reasonable range at every moment? This targets read-only routes.
- Is there a permission-reachability invariant, such as no path from an unauthorized address to a critical state change?
- Can each invariant be written as a function and checked after every step in millions of randomly generated operation sequences? If not, it is still an intuition rather than an invariant.
- Can you deliberately violate it and confirm that the checker fails? A checker that misses a violation is no checker.
Turning intuition into a proposition a machine can attack repeatedly is the bridge from this chapter to T30.
Key idea
Most vulnerabilities arise from state ordering and permission boundaries, not syntax errors.