Crypto OS
Technical Crypto OSStage 5 · Infrastructure & Security

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 ledger

That 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 100

The 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 stopCan it expire?Cost
MutexNo; safety netCross-function and read-only routesNoA little Gas
Change orderYesInconsistent state across contractsNoNone
Limit GasNoAnything once pricing changesYesLegitimate contracts cannot receive funds
Pull modelYes, and addresses other issuesNone, but changes the interactionNoTwo 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.

LocationWhy control leaves
Native-token transfer (call)The recipient's receive or fallback executes
Any call / delegatecall / staticcall to an external addressThat is how these calls work
NFT transfer to a contractThe standard requires a recipient callback to confirm receipt
Single and batch transfers under multi-token standardsSame; batch callbacks fire only once, which is easy to misjudge
Tokens with transfer hooksThe standard may notify sender or recipient before or after a transfer
Any callback-based designFlash loans, flash swaps, auction bid notices
An address supplied by a userIt 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.

FormWhat happensDoes a mutex help?
Same functionCallback enters the same function againYes
Cross-functionCallback enters another function sharing the same stateOnly if both functions share the lock
Read-onlyDuring callback, another contract reads an intermediate value from your view functionNot at all
Cross-transactionNo callback, but state is still updated after it is usedNot 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 one

Rather 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:

  1. view functions do not have locks and cannot be protected by them. The only protection is to ensure their state is always consistent.
  2. The “state” in CEI includes every externally visible derived value, not just variables explicitly modified in this function.
  3. 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 world

Compare:

Flawed orderCorrect order
WithdrawalTransfer → clear balanceClear balance → transfer
MintingCallback mint → increase countIncrease count → callback mint
SwapSend tokens → update reservesUpdate reserves → send tokens
LiquidationGive collateral to liquidator → reduce debtReduce 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.

PitfallConcrete form
Missing modifierAn administrative function lacks a permission check: basic and common
Authorize by transaction origin, not callerChecking tx.origin lets a phishing contract act with a user's identity
Unprotected initializationBetween deployment and initialization, anyone can become administrator
Implementation can be called directlyThe contract behind a proxy has its own address, as T8 showed
Transitive permissionA trusts only B, but B accepts calls from everyone; A effectively trusts everyone
Default allowA 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 calculatedDirectionReason
Shares from deposited assetsDownGive the user slightly fewer shares
Assets from redeemed sharesDownGive the user slightly fewer assets
Debt owed by a userUpCount a little more debt
Value of a user's collateralDownCount a little less collateral
FeesUpLet the protocol collect a little more
Collateral a liquidator may takeDownDo 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 share

Three 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 1

Three corresponding defenses are usually combined:

  1. Internal accounting: maintain total assets in your own variable instead of reading the balance. Ignore direct donations in the share calculation.
  2. Virtual shares and assets: add a fixed offset to numerator and denominator, making a zero-share result uneconomic to induce.
  3. Dead shares: mint initial shares to an irretrievable address so totalShares never 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.

ProblemStatement that fails
Repeated withdrawals from bad orderingAssets held by the contract are at least the sum of user balances
Read-only access to an intermediate stateEvery externally readable derived value reflects consistent state
Excess mintingThe amount minted for each address never exceeds its cap
Share inflationSomeone depositing a positive amount receives a positive number of shares
Transitive permissionOnly 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

ReentrancyReentrancy

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.

Checks–Effects–InteractionsChecks-Effects-Interactions

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.

Reentrancy GuardReentrancy Guard

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.

Access ControlAccess Control

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.

Rounding DirectionRounding Direction

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.

Upgrade RiskUpgrade Risk

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.

InvariantInvariant

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

Hands onWrite a vulnerable vault, prove the loss with a test, then fix itAny Solidity framework + local nodeFree. The entire exercise runs on your own local network

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 spot

Write 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 deposit

The 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:

FixWhat it stopsBlind spot
Reorder (CEI)Same-function, cross-function, and read-only routesCross-contract state consistency needs separate analysis
MutexReentry into locked functionsBoth cross-function routes need the lock; view is never protected
Pull modelRemoves transfers from stateful logicChanges 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

AI LabAsk AI to audit your contract, then classify and count every finding as a real issue, false positive, or omissionLevel 2 · AI Copilot

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

Every line was right, but the order was wrong: repeated withdrawals drained a vault2016, a well-known DAO

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 read-only quote served as an oracle, and a lending protocol was drainedRepeated in recent years

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.

An unrestricted approve function granted an allowance to an attackerRecurring in account and authorization designs

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.

An unprotected initializer let someone take and destroy an implementation contract2017, a multisignature wallet library

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

What ifPut a mutex on every function but never check CEI order

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.

What ifReplace every external call with a pull model

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.

What ifUse high-precision fixed-point arithmetic for every amount so rounding errors approach zero

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.

What ifMake the contract immutable to eliminate upgrade risk

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

9
Who bears the risk?

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.

11
Can the product still run if its token price goes to zero?

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.

17
Who bears the loss when AI gets it wrong?

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

Key idea

Most vulnerabilities arise from state ordering and permission boundaries, not syntax errors.

Finished this chapter’s lab? Mark it completeView all progress

On this page