---
title: "Smart Contracts and Crypto Engineering"
book: "Interviews III: Code"
subject: quant
language: en
chapter: 8
exercises: 0
source: https://one-course.com/books/quant/20/en/chapter/8-smart-contracts-and-crypto-engineering
license: CC-BY-NC-SA-4.0
credit: "One Quant Book, One Course (one-course.com)"
---

# Chapter 8 — Smart Contracts and Crypto Engineering

What a trading engineer at a crypto firm should know: how the EVM charges for work, the classic contract flaws and their fixes, oracles, MEV, and the plumbing of an exchange. Each vulnerable contract is shown with its flaw and its repair, on a local in-memory EVM or a Python model, and none refers to anything deployed. Solidity here is version 0.8.28, compiled by solc for the Cancun fork, unoptimised.

**Question 8.1 ★ QD, Dev • crypto • junior.**

Alice holds two unspent coins, worth 500 and 300 units, on a chain that uses unspent transaction outputs. She pays 600 with a fee of 10. In Python, write the spend: what goes in, what comes out, and what does she hold after? How would the same payment look on a chain with accounts? Name one thing each model makes easier.

**Answer to Question 8.1.**

Both coins go in, whole. Out come 600 to the payee and 190 as a new coin back to Alice, and the missing 10 is the fee. She then holds one coin of 190. On an account chain, her balance simply falls by 610, and the payee’s rises by 600.

```python
"""Paying from coins: inputs are used up whole, and change is a new coin."""


def spend(coins, pay, fee):
    """Largest coins first. Returns (inputs used, change, coins left), or ValueError."""
    used, total = [], 0
    for coin in sorted(coins, reverse=True):
        if total >= pay + fee:
            break
        used.append(coin)
        total += coin
    if total < pay + fee:
        raise ValueError("not enough")
    left = sorted(coins)
    for coin in used:
        left.remove(coin)
    return used, total - pay - fee, left
```

***Listing 8.13.** Paying from coins. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_utxo.py*

A coin can’t be partly spent, which is why change exists. A transaction proves the right to spend each input, and validity needs only those inputs, so transactions on separate coins cannot conflict, and they can be checked in parallel. A double spend is two transactions using the same coin, and only one can be accepted.

An account chain keeps a balance and a nonce for each address. That suits contracts well, as contracts need shared mutable state, and it gives simple payments and small transactions. It also means transactions on one account have to be ordered, which is what the nonce is for.

For an exchange, the choice affects its plumbing: on a coin chain it must select coins and manage change addresses, and on an account chain it must manage nonces.

**Classic wrong answer.** “The coin of 500 pays 500, and 100 of the coin of 300 pays the rest.” Coins are consumed whole, and the rest comes back as change.

**Question 8.2 ★ QD, Dev, QT • crypto • junior.**

On Ethereum, a plain transfer uses 21 000 gas. The base fee is 20 gwei, and you add a priority fee of 1 gwei. One gwei is $10^{-9}$ ETH. What is the fee in ETH, and who gets each part? If the block is full, how far can the next base fee rise?

**Answer to Question 8.2.**

The fee is $21\,000 \times 21$ gwei $= 441\,000$ gwei, or 0.000441 ETH. The base-fee part, 0.00042 ETH, is burned, and the priority fee, 0.000021 ETH, goes to the block’s producer. After a full block, the next base fee can rise by 1/8, or 12.5%, here to 22.5 gwei.

Gas measures work, and it isn’t a currency. The price per gas moves with demand, so the same transfer can cost much more in a busy hour, while using the same 21 000 gas. A sender gives a maximum fee and a tip, and pays the base fee at inclusion plus the tip, up to that maximum, with the rest kept.

The target for a block is half its limit. Above the target, the base fee rises, by up to 1/8 for a block that is completely full, and below it the fee falls. So a sudden rise in demand is priced in over several blocks, and the tip decides order within a block when many want in.

A trading system thus treats gas as a real and volatile cost. A transaction that fails still pays for the gas that it used.

**Classic wrong answer.** “The whole fee goes to the validator.” Under this rule, the base fee is burned, and only the tip reaches the block producer.

**Question 8.3 ★ QD, Dev • crypto • junior.**

In [Listing 8.1](#lst-iv3-cr-fixed), in Solidity 0.8, what do `divFirst(1, 3, 3)`, `mulFirst(1, 3, 3)`, `wmul(1.5e18, 2e18)` and `wmul(1, 1)` return? What does `mulFirst` do on $(2^{255}, 2, 2)$, although the true answer fits in 256 bits? Why does Solidity have no floating point, and how do you write 1.5 instead?

```solidity
contract Fixed {
    uint256 constant ONE = 1e18;

    function divFirst(uint256 a, uint256 b, uint256 c) external pure returns (uint256) {
        return a / b * c;
    }

    function mulFirst(uint256 a, uint256 b, uint256 c) external pure returns (uint256) {
        return a * c / b;
    }

    function wmul(uint256 a, uint256 b) external pure returns (uint256) {
        return a * b / ONE;
    }
}
```

***Listing 8.1.** Integer arithmetic. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Fixed.sol*

**Answer to Question 8.3.**

They return 0, 1, 3e18 and 0. `divFirst` truncates $1/3$ to 0 before multiplying. On $(2^{255}, 2, 2)$, `mulFirst` reverts, as $a \times c$ overflows 256 bits on the way to an answer that fits. Solidity has no usable floating point, since all nodes must get identical results and integers guarantee that. You write 1.5 as $1.5 \times 10^{18}$.

So amounts are integers in the smallest unit: 1 ETH is $10^{18}$ wei. The common form is fixed point with 18 decimals, where `wmul` multiplies and then divides by $10^{18}$ to get the scale back. `wmul(1, 1)` shows the loss: the true result, $10^{-18}$ of a unit, rounds down to 0.

The rule is to multiply before dividing, to keep precision, and to watch for overflow in the middle. In version 0.8, overflow reverts, so a large intermediate gives a failed transaction and not a wrong number. A multiply-then-divide with a full 512-bit intermediate is the way round that.

Every division also rounds, and you must decide in whose favour. A contract should normally round against the user, or small repeated rounding errors can be turned into a slow drain.

**Classic wrong answer.** “Use a type like `fixed128x18`.” Such types can be declared, though not yet assigned to or from, so libraries over integers are what people use.

**Question 8.4 ★ QD, Dev • crypto • junior.**

A new deployment of [Listing 8.2](#lst-iv3-cr-locations) has `p.x` equal to 1. What does `viaMemory()` return, and `viaStorage()` afterwards? What do `memory`, `storage` and `calldata` mean, and how long does each one last? Which is the expensive one?

```solidity
contract Locations {
    struct Point { uint256 x; }
    Point public p = Point(1);

    function viaMemory() external returns (uint256) {
        Point memory m = p;
        m.x = 2;
        return p.x;
    }

    function viaStorage() external returns (uint256) {
        Point storage s = p;
        s.x = 2;
        return p.x;
    }
}
```

***Listing 8.2.** Data locations. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Locations.sol*

**Answer to Question 8.4.**

`viaMemory()` returns 1, and `viaStorage()` afterwards returns 2. Assigning storage to a `memory` variable copies it, so the change is lost when the call ends. A `storage` variable is a reference, so writing to it changes the contract’s state. Storage is the expensive one, by a wide margin.

The three locations differ in lifetime. `storage` is the contract’s persistent state on the chain, and it lasts until changed. `memory` is scratch space, which lasts for the current external call. `calldata` is the read-only input of the call. Using `calldata` for an array argument avoids a copy, so it is often cheapest.

The trap here is that `Point memory m = p` looks like a handle on `p`. A function that updates a position through such a copy does nothing, with no error. It may cost little gas as well, which you may take for good news. And the reverse bug exists: an assumed copy that is in fact a storage reference, so that a change intended to be temporary is saved.

**Classic wrong answer.** “Both return 2.” That treats `memory` as if it were an alias. In this direction, the compiler makes a copy.

**Question 8.5 ★ QD, Dev • crypto • junior.**

What is an account’s nonce on Ethereum? What stops someone from broadcasting your signed transfer a second time? Your account’s next nonce is 0, and your transactions with nonces 0, 1 and 3 are pending. Which can be included, and what happens to the last one? What happens if two transactions share a nonce?

**Answer to Question 8.5.**

A nonce is a counter for each account, and a transaction must carry the next value. That stops a rebroadcast: your signed transfer with nonce 7 is invalid once 7 is used. Nonces 0 and 1 can go in, and 3 waits until 2 appears. Of two with one nonce, at most one can be included.

So the nonce gives one strict order per account, and each signed transaction can take effect only once. Because a signature covers the nonce, nobody can alter it without making the signature invalid.

The waiting has consequences. A missing nonce holds back everything behind it, even with a high fee. So a sender that issues many transactions must track nonces itself and fill gaps quickly.

Two transactions with one nonce are the normal way to replace something pending. You send the same nonce with a higher fee, and most nodes take the replacement. Whichever one is included first wins, and the other can never be included. So you can cancel a pending payment only by getting a replacement in first, for instance a zero-value transfer to yourself, and that isn’t sure to work.

**Classic wrong answer.** “A nonce makes a transaction private or random.” Here it is just a sequence number, in plain view.

**Question 8.6 ★ QD, Dev, QT • crypto • junior.**

Why does an exchange not credit a deposit as soon as it sees it in a block? On Ethereum proof of stake, with 12-second slots and 32 slots in an epoch, finality takes about two epochs. How long is that? What does reversing a finalised block cost an attacker, and what about a block that is only one slot deep?

**Answer to Question 8.6.**

A block seen now can be replaced by another chain, so a credited deposit could vanish after the user has withdrawn or traded it. Two epochs at $12 \times 32 = 384$ seconds each is 12.8 minutes, and Ethereum’s own documentation quotes about 15. Reversing a finalised block needs an attacker to give up at least a third of all staked ETH.

A block one slot deep has no such protection. It can drop out through an ordinary short reorganisation, with no attack and at no cost to anyone. The cost of reversal rises with depth, and on proof of stake it jumps once a block is finalised.

So an exchange chooses a depth per chain and per amount, trading the user’s wait against its risk. On a proof-of-work chain, there is no final point: security grows with depth, and a chain with little hash power is cheap to attack, so it needs more confirmations. For a chain with a fast sequencer on a layer 2, a soft confirmation is a promise by one party, and the settled state on layer 1 is the real thing, which may be far later.

Internally, you can credit trading balance earlier than withdrawable balance. It lets a user trade, but the funds can’t leave until they’re final, so that a reversal costs at most a recoverable position.

**Classic wrong answer.** “It is in a block, so it happened.” A block near the tip is a proposal that usually survives.

**Question 8.7 ★★ QD, Dev • crypto • junior, experienced.**

On [Listing 8.3](#lst-iv3-cr-slot), under the Cancun fork’s gas rules, the program sends four separate transactions: `set(1)`, `set(2)`, `set(2)` and `set(0)`, starting from `v` equal to 0. The call’s own overhead is the same each time. Taking out that overhead, what does each write cost in gas, and is anything refunded? Why are the costs what they are?

```solidity
contract Slot {
    uint256 public v;

    function set(uint256 x) external {
        v = x;
    }
}
```

***Listing 8.3.** One storage slot. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Slot.sol*

**Answer to Question 8.7.**

A first write from zero to non-zero costs 22 100 gas. A write from one non-zero value to another costs 5000. Rewriting the same value costs 2200. A write to zero costs 5000, and it earns a 4800 refund. We measured these on the local EVM, net of an overhead of 392 gas for the call.

Each cost has two parts. The first access to a slot in a transaction is cold, at 2100, and later ones are warm. Then the write: 20 000 to bring a new slot into being, since every node must then keep it for good; 2900 to change one that exists; 100 if nothing changes. So $22\,100 = 20\,000 + 2100$, $5000 = 2900 + 2100$, and $2200 = 100 + 2100$.

Storage is priced so, since state is a permanent burden on every node, while computation only passes through. So clearing a slot earns a refund, as it reduces that burden. Refunds are limited to one fifth of the gas used in a transaction.

In the same transaction, a slot already touched is warm, and rewriting it costs far less: that’s why the program sends separate transactions: within one, the later writes would find the slot warm, and the figures would differ. So group your writes, and read a slot into a local variable if you need it more than once. Don’t store what you can emit as an event: logs are far cheaper, though contracts can’t read them.

**Classic wrong answer.** “Writing a word costs a fixed amount.” The cost depends on the old value, the new value, and whether the slot was touched already.

**Question 8.8 ★★ QD, Dev • crypto • junior, experienced.**

How many storage slots does each contract in [Listing 8.4](#lst-iv3-cr-packing) use? Draw slot 0 of `Tight`. What is the saving worth at deployment? When would you not reorder your variables? Why can you not simply reorder them in a new version of an upgradeable contract?

```solidity
contract Loose {
    uint128 a = 1;
    uint256 b = 2;
    uint128 c = 3;
}

contract Tight {
    uint128 a = 1;
    uint128 c = 3;
    uint256 b = 2;
}
```

***Listing 8.4.** Two layouts. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Packing.sol*

**Answer to Question 8.8.**

`Loose` uses 3 slots, and `Tight` uses 2. In `Tight`, slot 0 holds `a` in its low 16 bytes and `c` in its high 16, reading `0x03` then zeros then `01`. The saving at deployment is one fresh slot, about 22 000 gas going by what a write from zero costs. We read the slots directly from the local EVM, and did not measure the deployments themselves.

The rule is to fill slots in the order of declaration. A variable that doesn’t fit in the rest of the current slot starts a new one. A `uint256` needs a full slot, so in `Loose` it splits the two `uint128` variables apart.

Packing has a cost and a benefit beyond deployment. Two variables in one slot are written with one store if you update them together, and reading one makes the other warm. But if one changes often and the other does not, each update must read, mask and write the slot, and shared slots can also make code harder to read. Group variables by what is used together, and not only by size.

In an upgradeable contract, storage layout belongs to its data. The deployed state stays where it is, and a new implementation reads the same slots by position. So reordering makes the new code read `b` where `a` used to be, with no error shown. You may only append.

**Classic wrong answer.** “The compiler reorders variables for you.” It keeps declaration order, because contracts rely on that layout.

**Question 8.9 ★★ QD, Dev • crypto • junior, experienced.**

What do `checkedDec(0)`, `uncheckedDec(0)` and `narrow(300)` do in [Listing 8.5](#lst-iv3-cr-overflow)? Which of them does the compiler protect, and which not? Why would anyone write `unchecked`? Give one case in which it is safe, and one in which it is not.

```solidity
contract Overflow {
    function checkedDec(uint256 x) external pure returns (uint256) {
        return x - 1;
    }

    function uncheckedDec(uint256 x) external pure returns (uint256) {
        unchecked { return x - 1; }
    }

    function narrow(uint256 x) external pure returns (uint8) {
        return uint8(x);
    }
}
```

***Listing 8.5.** Overflow and narrowing. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Overflow.sol*

**Answer to Question 8.9.**

`checkedDec(0)` reverts with panic code 0x11. `uncheckedDec(0)` wraps to $2^{256} - 1$. `narrow(300)` returns 44, as an explicit cast simply cuts off the high bits, with no check at all. So arithmetic is protected by default since version 0.8, unless you opt out, but casts are not protected.

Before 0.8, all arithmetic wrapped, so a subtraction below zero could give a huge balance. The checks now cost a little gas on each operation.

`unchecked` exists to avoid that cost where the check can’t fail. A loop counter bounded by an array length can’t overflow. A difference that you have just compared is safe: if you have checked $a \ge b$, then $a - b$ is fine. Hash and time-difference code may want wrapping on purpose.

It is not safe wherever a user’s value goes in. In a token transfer without a check of the balance, `unchecked { balance -= amount; }` recreates the old bug in full.

The cast is the one to look out for in review. A value from a user, cast down to `uint96` to save space, may pass all its checks at 256 bits and then lose its high bits on storing. Use a checked cast from a library, or an explicit comparison, on every narrowing.

**Classic wrong answer.** “Version 0.8 fixed overflow.” It fixed arithmetic operators outside `unchecked`. Casts and assembly never had the check.

**Question 8.10 ★★ QD, Dev • crypto • junior, experienced.**

The local `Vault` in [Listing 8.6](#lst-iv3-cr-vault) holds 10 ETH of Bob’s. `Attacker.attack()` is called with 1 ETH. How much do the vault and the attacker hold afterwards? Explain the flaw step by step. Give two ways to fix it, and say which you would pick. Does Bob’s withdrawal still work after each fix?

```solidity
contract Vault {
    mapping(address => uint256) public balance;

    function deposit() external payable {
        balance[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balance[msg.sender];
        require(amount > 0);
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok);
        balance[msg.sender] = 0;
    }
}

contract Attacker {
    Vault vault;

    constructor(Vault v) { vault = v; }

    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= msg.value) vault.withdraw();
    }
}
```

***Listing 8.6.** A vault and a caller. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Vault.sol*

**Answer to Question 8.10.**

The vault ends with 0, and the attacker with 11 ETH, which is what our local run shows. Bob’s withdrawal then fails. The flaw is that `withdraw` sends the money before it clears the balance. Fix it by clearing first (checks, effects, interactions), or add a lock. Clear first, and add a lock as well where calls are complex.

Step by step: the attacker deposits 1 and calls `withdraw`. The vault reads a balance of 1 and sends it. Sending to a contract runs its `receive`, and that calls `withdraw` again. The balance is still 1, as the line that clears it hasn’t been reached, so the vault pays again. This repeats while the vault has at least 1 ETH left. Only as the calls return are the zero writes performed, all of them too late.

The code that is shown has no error in any single line. The flaw lies in giving control to an unknown contract while the state is out of date. The Solidity documentation says that an external call must be the very last step of a function.

We ran both repairs against the same attacker. With the clearing line moved up, the inner `withdraw` finds nothing and reverts, which undoes the whole attack, and Bob withdraws his 10. With a lock on the function, the inner call reverts on the lock, with the same result. The first fix is at the root, and costs no storage. A lock also protects against paths you did not think of, across functions, though a lock on a single function misses a reentry through another. The vault here is a local contract in a test, and nothing else.

**Classic wrong answer.** “Use `transfer`, since its gas limit stops reentrancy.” That relies on gas costs staying as they are, and upgrades have changed them.

**Question 8.11 ★★ QD, Dev • crypto • junior, experienced.**

`Payout` in [Listing 8.7](#lst-iv3-cr-pay) pays a contract that refuses all payments. After `payUnchecked` with 1 ETH, what do `paid`, the recipient’s balance and `Payout`’s balance show? What does `payChecked` do? What other faults does a raw `call` invite? A contract pays 100 recipients in a loop: why is that a poor design?

```solidity
contract Rejecter {
    receive() external payable { revert(); }
}

contract Payout {
    mapping(address => bool) public paid;

    function payUnchecked(address to) external payable {
        to.call{value: msg.value}("");
        paid[to] = true;
    }

    function payChecked(address to) external payable {
        (bool ok, ) = to.call{value: msg.value}("");
        require(ok);
        paid[to] = true;
    }
}
```

***Listing 8.7.** Paying someone who refuses. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Pay.sol*

**Answer to Question 8.11.**

`paid` shows true, the recipient holds 0, and `Payout` keeps the 1 ETH: the call failed, and nothing noticed. `payChecked` reverts, so nothing is recorded. Low-level calls return a flag and don’t revert, and the unchecked version ignores it, which the compiler warns about. Paying in a loop lets one recipient block all the others.

Other failures of a low-level call, besides the ignored flag: it succeeds on an address with no code, so a call meant for a contract that doesn’t exist looks fine, unless you check. It forwards nearly all gas by default, so the callee can spend it, or reenter. Its return data can be large and costly to copy. A `delegatecall` runs someone else’s code on your storage.

In a loop, any single recipient that reverts will revert the whole payout if you check each call, and a loop that grows may exceed the block’s gas limit, so that it can never finish. If you don’t check, you record payments that didn’t happen.

The answer is the withdrawal pattern, which the Solidity documentation recommends: record what each is owed, and each recipient pulls its own. A failure then affects only that recipient. The cost is an extra transaction for each recipient, and someone has to pay for it.

**Classic wrong answer.** “It is a payment, and a failed one will revert.” That holds for `transfer`. A low-level `call` simply returns false.

**Question 8.12 ★★ QD, Dev • crypto • junior.**

The owner of the local `Wallet` in [Listing 8.8](#lst-iv3-cr-origin), holding 5 ETH, is persuaded to call `Trap.claimPrize`. What happens with `byOrigin` true, and with it false? What is the difference between `tx.origin` and `msg.sender`? Why does the check not protect the owner?

```solidity
contract Wallet {
    address owner;

    constructor() payable { owner = msg.sender; }

    function sendAllByOrigin(address payable to) external {
        require(tx.origin == owner);
        to.transfer(address(this).balance);
    }

    function sendAllBySender(address payable to) external {
        require(msg.sender == owner);
        to.transfer(address(this).balance);
    }
}

contract Trap {
    function claimPrize(Wallet w, bool byOrigin) external {
        address payable me = payable(address(this));
        if (byOrigin) w.sendAllByOrigin(me);
        else w.sendAllBySender(me);
    }

    receive() external payable {}
}
```

***Listing 8.8.** Who is calling? code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Origin.sol*

**Answer to Question 8.12.**

With `byOrigin` true, the trap takes all 5 ETH. With it false, the call reverts, and the wallet keeps its 5. `tx.origin` is the account that signed the transaction, and `msg.sender` is the immediate caller. The owner signed the transaction, so `tx.origin` still says owner, even at the end of a chain that passes through the trap.

So `tx.origin` answers “who started all this?”, and that is not what an authorisation asks. The owner meant to call the trap and not the wallet. A check on `msg.sender` sees the trap as the caller, and refuses. The Solidity documentation says plainly never to use `tx.origin` for authorisation.

The attack needs only that the owner be tricked into any transaction that reaches a hostile contract, which might be a claim for a prize, or a token with a hook. The owner sees nothing odd in what they signed, unless the wallet’s software simulates the transaction.

`tx.origin` also breaks the other way: a wallet that is itself a contract, such as a multisignature wallet, never signs, so `tx.origin` isn’t that wallet, and it can’t pass this check at all.

**Classic wrong answer.** “`tx.origin` is stronger, as only a real person can be the origin.” Being a person at the far end does not show what that person intended.

**Question 8.13 ★★ QD, Dev • crypto • junior, experienced.**

In a standard token, `approve(spender, m)` overwrites the allowance. An owner has allowed 100 and sends a transaction that lowers it to 60. The spender watches pending transactions and can have its own executed first. What is the most it can take? Show it in Python. How would you repair it, in the contract and in the client?

**Answer to Question 8.13.**

The most it can take is 160: 100 before the change, then 60 after, since the change simply overwrites. In the contract, make the change conditional on the old value, so a stale update fails, or offer increase and decrease functions. In the client, set the allowance to 0 first, check what was spent, and only then set the new value.

```python
"""An allowance lowered from N to M, while the spender watches the pending transaction."""


def most_taken(n, m):
    """The spender moves before or after the owner's change. Returns the most it can take."""
    best = 0
    for before in range(n + 1):              # taken while the old allowance stands
        allowance = m                        # the owner's change simply overwrites
        best = max(best, before + allowance)
    return best


def most_taken_with_compare(n, m):
    """The same, where the change only applies if the allowance is still n."""
    best = 0
    for before in range(n + 1):
        allowance = m if before == 0 else n - before
        best = max(best, before + allowance)
    return best
```

***Listing 8.14.** An allowance, overwritten or compared. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_approve.py*

The flaw is that `approve` carries no knowledge of what has been spent. The owner meant “from 100 to 60 total”, and the contract hears “60 from now on”. So a spender who sees the pending transaction spends all 100 first, then has 60 more. The test confirms $n + m$ for any allowances, and shows that a compare gives $\max(n, m)$.

With a compare, the spender can still take whatever it could before the change, which is unavoidable, since it was allowed. It just can’t gain from the change. Going through 0 has the same effect only if the owner checks between the two transactions what was spent, and lowers or drops the new value accordingly. Done blindly, it still gives the spender 160, as the test shows. It costs two transactions and a check, and the token standard recommends that clients do so for this reason. It also tells the contracts not to enforce it, for compatibility with earlier deployments.

So on a public chain, anything visible while pending can be acted on first. Don’t write a state change that assumes nothing happens between signing and inclusion. A newer style is signature-based permits with a nonce and a deadline, so the allowance is used up in one transaction, and unlimited approvals to contracts carry their own risk.

**Classic wrong answer.** “Lowering an allowance is always safe, as it reduces risk.” As written, it gives the spender a chance at both amounts.

**Question 8.14 ★★ QD, Dev, QT • crypto • junior, experienced.**

A constant-product pool with a 0.3% fee holds 1000 ETH and 2 000 000 dollars of a stablecoin, each with 18 decimals. Write `amountOut` in Solidity using integers. How many dollars does 1 ETH buy, and how does that split between fee and price impact? Why does the division round down, and whom does that favour? How do you check that the contract agrees with a Python model?

**Answer to Question 8.14.**

1 ETH buys 1992.01 dollars, against a spot price of 2000. That is 0.40% worse: 0.3% fee, and about 0.1% price impact. [Listing 8.15](#lst-iv3-cr-amm) does this on integers. Division rounds down, so the buyer gets slightly less and the pool slightly more, which protects the pool. The contract and a Python model agree to the wei on 101 cases.

```solidity
contract Amm {
    function amountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
        external pure returns (uint256)
    {
        uint256 afterFee = amountIn * 997;
        return afterFee * reserveOut / (reserveIn * 1000 + afterFee);
    }
}
```

***Listing 8.15.** The output of a swap. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Amm.sol*

The pool keeps $x y$ from falling. Taking the fee off the input gives $a' = 0.997 a$, and then $(x + a')(y - b) = xy$ gives $b = a' y / (x + a')$. The code multiplies by 997 and uses 1000 in the denominator, to stay in integers, and multiplies before dividing, to keep precision.

Rounding down the output means that $x y$ can only rise. The direction matters: if it rounded up, an attacker could make very many tiny swaps, gaining a wei each time. The test asserts on 2000 random pools that the invariant never falls, and that a round trip cannot make a profit.

To check that the contract agrees with the model, the test compiles the contract, runs it on the in-memory EVM with random inputs, and compares it exactly with a Python implementation that has the same formula. The two share the formula, so that check catches a slip in writing it or an overflow. It wouldn’t catch a wrong formula, and that’s what the property tests are for.

With 18 decimals, the numerator here is about $2 \times 10^{45}$, well below $2^{256} \approx 10^{77}$, though tokens with extreme supplies may come close, so a real contract may need a full-width multiply.

**Classic wrong answer.** “The price is $y / x$, so 1 ETH buys 2000.” That is the marginal price, for an infinitely small trade before the fee.

**Question 8.15 ★★ QD, Dev, Risk • crypto • junior, experienced.**

A lender values collateral at the spot price of a thin constant-product pool, at 1000 tokens and 1000 dollars, with a 0.3% fee taken from the input, and lends up to 50% of that value. Someone who owns 100 tokens, worth 100 dollars, can borrow 9000 dollars within one transaction, at no cost, and puts all of it into the pool. In Python, model the sequence and find the profit. What is the profit if the lender uses a fair price instead? What makes the attack possible, and what are the defences?

**Answer to Question 8.15.**

Profit is about 4881 dollars. The 9000 pushes the pool’s price to about 100 times its true value. The lender values 100 tokens at about 9973, and lends 4986.5. Selling back recovers 8995 of the 9000. With a fair price, the attacker loses 55. The pool’s spot price is the flaw: for one transaction, it can be anything you can pay for.

```python
"""A loan valued at a pool's spot price, with the price pushed up for one transaction."""
from iv_amm import Pool

UNIT = 10**18           # a pool counts in 18 decimals, not in whole tokens


def attack(x, y, flash, own, ltv, fee_bps=30, fair_oracle=None):
    """Own `own` tokens, flash-borrow `flash` dollars. Returns profit at the true price."""
    fair = y / x
    pool = Pool(x * UNIT, y * UNIT, fee_bps)
    tokens = pool.buy_x(int(flash * UNIT))
    oracle = fair_oracle if fair_oracle is not None else pool.price()
    borrowed = own * oracle * ltv            # the collateral stays with the lender
    back = pool.buy_y(tokens) / UNIT
    return borrowed + back - flash - own * fair
```

***Listing 8.16.** One transaction against a spot price. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_oracle.py*

The steps all happen in one transaction: borrow, buy, post collateral, borrow against it, sell back, repay. The attacker walks away with the loan and abandons collateral worth 100, while the lender holds a bad debt. Because the first loan is repaid in the same transaction, the attacker needed no capital, and if any step fails, the whole thing reverts, and nothing is lost but the gas. So the attack is free to try.

A flash loan isn’t the root cause, as it just removes the need for capital. The root cause is treating an easily moved price as the truth.

Defences: use a price that one transaction cannot move. That may be an average over time from the pool, or a feed that aggregates many venues, or a limit on the size of a move. Size matters too: on a deep pool, the same attack loses money, as the test shows. A lender can also limit how much can be borrowed against thin collateral, and delay a borrow by a block, though this has its costs.

The model has a zero-cost loan and no gas, and it is a sketch of the mechanism on local numbers, with no contract involved.

**Classic wrong answer.** “It only affects the attacker’s own trade, since the price comes straight back.” The lender read the price at the moment it was false, and that loan stays.

**Question 8.16 ★★ QD, Dev • crypto • experienced.**

Instead of reading a pool’s spot price, a contract can keep a running sum of price times elapsed seconds. In Python, write that accumulator, and the average price between two readings. If the price spikes from 1 to 100 for one of 100 seconds, what is the average? What does an attacker now have to do? What does the window trade off? What does an arithmetic average do badly here?

**Answer to Question 8.16.**

The average is 1.99, against a spot of 100 during the spike. A single transaction moves the sum by little, since it lasts little time. To move the average, an attacker must hold the price away for much of the window, while others trade against that price. A longer window is harder to move and slower to follow the market.

```python
"""A cumulative price, the sum of price times seconds. Two readings give the average."""


class Accumulator:
    def __init__(self, t0, price):
        self.t, self.price, self.total = t0, price, 0

    def update(self, t, price):
        self.total += self.price * (t - self.t)
        self.t, self.price = t, price

    def read(self, t):
        return self.total + self.price * (t - self.t), t


def twap(first, second):
    return (second[0] - first[0]) / (second[1] - first[1])
```

***Listing 8.17.** A price accumulator. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_twap.py*

The accumulator is cheap. It stores one running total, updated at the first touch of a block, so a contract reads the sum at two times, and divides by the elapsed time. Nothing is stored per second. The test compares this with the slow average of every second, for 300 random paths.

To hold the price, an attacker has to keep it high across blocks, so that it pays arbitrageurs in each block, and the cost grows with depth and with time. And within a single block, a read at the start doesn’t include the changes made in that block, which is a key part of the design. It is a defence, and not a guarantee: a producer of several consecutive blocks can avoid the arbitrage in between, and a very thin pool stays cheap to push.

The window trades safety against lag. In a fast crash, a one-hour average lags behind the market, so a lender would value collateral too highly just when it matters, and that is exploitable as well.

The arithmetic average gives a spike too much weight: in our example, a 100-fold spike for 1% of the time doubles it. An average of the logarithm of price, in other words a geometric mean, counts a move up and the same-sized move down equally, and gives $100^{0.01} = 1.05$ here. So a design can store that instead.

**Classic wrong answer.** “Averages cannot be manipulated.” They only cost more to manipulate, and how much more depends on the pool’s depth and the window.

**Question 8.17 ★★ QD, Dev • crypto • junior, experienced.**

A local price feed in [Listing 8.9](#lst-iv3-cr-feed) last reported 200 000 at time $T$. `careful` is called with a limit of one hour at $T + 60$ s and at $T + 7200$ s. Then the feed reports $-1$. What do `trusting` and `careful` return in each of these cases? What else should a careful consumer check? What should the protocol do when it refuses a price?

```solidity
contract Feed {
    int256 public answer;
    uint256 public updatedAt;

    function push(int256 a, uint256 t) external {
        answer = a;
        updatedAt = t;
    }
}

contract Consumer {
    function trusting(Feed f) external view returns (uint256) {
        return uint256(f.answer());
    }

    function careful(Feed f, uint256 maxAge) external view returns (uint256) {
        int256 a = f.answer();
        require(a > 0, "bad answer");
        require(block.timestamp - f.updatedAt() <= maxAge, "stale");
        return uint256(a);
    }
}
```

***Listing 8.9.** Two consumers of a price feed. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Feed.sol*

**Answer to Question 8.17.**

`trusting` returns 200 000 in the first two cases, stale or not. After $-1$, it returns $2^{256} - 1$, as a cast of a negative to unsigned wraps. `careful` returns 200 000 at $T + 60$, and reverts at $T + 7200$ and on $-1$. Also check decimals, the round’s completeness, and the feed’s own status.

Staleness is the common failure. Feeds report when the price moves by a set amount or a set time has passed, so the right limit depends on that feed’s heartbeat. A limit much longer than the heartbeat adds risk, and one shorter than it reverts during normal quiet periods.

Other checks: the decimals of the feed, which differ between feeds, and between tokens, since a mistake there is off by powers of ten. A feed’s minimum or maximum bound, at which an aggregator may stop following the market and just report the bound. On a layer 2, whether the sequencer is up, as nobody could post updates while it was down, and prices may be old when it returns.

What to do after a refusal needs thought, and reverting isn’t always right. A lender that reverts on a bad price has also blocked repayments and liquidations, just when these are needed. So pause only the risky actions, such as new borrowing, and let users reduce risk. A second source may be used as a fallback, and that source then needs the same scrutiny.

**Classic wrong answer.** “The feed is a trusted contract, so its answer is fine.” It reports what it reports, and whether that’s fresh and usable is your code’s job.

**Question 8.18 ★★ QD, QT • crypto • junior, experienced.**

A constant-product pool holds 1000 tokens and 2 000 000 dollars, with a 0.3% fee taken from the input. A pending swap spends 20 000 dollars, and it will revert if it receives more than 1% less than quoted now. In Python, model a trader who buys first and sells straight after, and find the largest first trade that lets the victim through, and its profit. How does this vary with the limit? What is this called, and what can the user do about it? Why not set the limit at 0?

**Answer to Question 8.18.**

With a 1% limit, the largest first trade is about 10 141 dollars, and the profit about 140 dollars. Profit grows with the limit: about 70 at 0.5% and 420 at 3%. This is a sandwich attack, a form of MEV. A user can set a tight limit, or send privately, or split the trade. A limit of 0 reverts on nearly any other trade.

```python
"""A victim's swap with a limit on its price, and a trader who moves first and last."""


def out(a, r_in, r_out, keep=0.997):
    return a * keep * r_out / (r_in + a * keep)


def victim_gets(front, victim_in, x, y):
    bought = out(front, x, y)
    return out(victim_in, x + front, y - bought)


def profit(front, victim_in, x, y):
    bought = out(front, x, y)
    x1, y1 = x + front, y - bought
    got = out(victim_in, x1, y1)
    return out(bought, y1 - got, x1 + victim_in) - front


def largest_front(victim_in, x, y, slippage):
    """The most the front-runner can trade with the victim still inside its limit."""
    floor = out(victim_in, x, y) * (1 - slippage)
    if victim_gets(0.0, victim_in, x, y) < floor:
        return 0.0
    low, high = 0.0, 100.0 * x
    for _ in range(200):
        mid = (low + high) / 2
        low, high = (mid, high) if victim_gets(mid, victim_in, x, y) >= floor else (low, mid)
    return low
```

***Listing 8.18.** A trade before and after the victim. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_sandwich.py*

The trader buys first, which pushes up the price the victim then pays. The victim’s own trade pushes it higher still, and the trader sells into that. The victim’s limit sets how far the trader can go: bisection finds the first trade that leaves the victim exactly at its minimum, 9.77 tokens against 9.87 quoted. A grid search confirms that this size gives the most profit in that range, and the test shows that with no victim, the two legs only pay fees.

The limit is thus the attacker’s budget. It is roughly how much the user gives away, so a loose limit is an invitation.

A limit at 0 protects completely against this, and it means any ordinary trade landing first makes yours revert, and a reverted transaction still pays gas. So the choice trades failure against loss. Private submission through a block builder hides the transaction from public view, at the price of trusting that channel. Splitting a trade, or using an auction venue, reduces what can be taken.

For a market maker, the same logic is relevant. Your own pending swaps are visible, so size and limits are part of execution, and the profit figures here ignore the gas and any bribe needed to win the position, which is often most of it.

**Classic wrong answer.** “Set the slippage high so that it always goes through.” It goes through, and that margin goes to whoever is watching.

**Question 8.19 ★★ QD, Dev • crypto • experienced.**

Under the 16-and-4 rule, a non-zero byte of transaction data costs 16 gas and a zero byte costs 4. In Python, compute the cost of a standard ABI-encoded `transfer(address, uint256)` of 1 token with 18 decimals to an address with no zero bytes, and of a tightly packed version: a 1-byte selector, the 20-byte address, and the amount with its leading zeros dropped. Why do rollups care so much? What makes up a fee on a layer 2, and what is different about the code you write for one?

**Answer to Question 8.19.**

The ABI form is 68 bytes and 632 gas of data. A packed one is 29 bytes and 440 gas. Rollups care since data posted to layer 1 has long been the main part of their cost. A layer 2 fee is its own execution cost plus a share of the layer 1 cost of publishing that data.

```python
"""What a transaction's bytes cost: 16 gas for a non-zero byte, and 4 for a zero byte."""


def calldata_gas(data):
    return sum(16 if b else 4 for b in data)


def abi_transfer(to, amount):
    return bytes.fromhex("a9059cbb") + to.rjust(32, b"\0") + amount.to_bytes(32, "big")


def packed_transfer(to, amount):
    return b"\x01" + to + amount.to_bytes(12, "big").lstrip(b"\0")
```

***Listing 8.19.** What a transaction’s bytes cost. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_calldata.py*

In the ABI form, every argument takes 32 bytes: the selector’s 4, then 12 zeros and an address of 20, then a mostly zero number. Zeros are cheaper, which softens it: the count of bytes falls by 57%, and the gas by only 30%. The rest is the address, and these non-zero bytes cost full price. The packed form needs its own decoder in the contract, and it can leave the amount’s length implied since the amount comes last. A real layer 2 may also compress before posting, which further changes the sums.

The rules change, and this is one to re-check. The 16-and-4 rule is the older one, and since then, a floor has been added for data-heavy transactions, at 10 gas per token where a non-zero byte is 4 tokens. Our transfer is 158 tokens, so its floor is 1580 gas, less than one write to a slot, and the floor doesn’t bite. Separately, blobs give rollups a cheaper channel, with their own fee market, for data that doesn’t stay available forever and that contracts can’t read. So how much the size of calldata matters on a given layer 2 depends on how it publishes, and you should read its current fee formula.

What changes in your code: on a layer 2, execution is often cheap and data costly, so the storage-versus-calldata instincts of layer 1 may reverse, and short encodings pay. Block numbers and timestamps may mean something different, and the sequencer sets order, so finality and MEV look different. A fee quote also needs a read of the current layer 1 cost, and may be stale by the time you are included.

**Classic wrong answer.** “Gas per opcode decides the cost on a layer 2, just as on layer 1.” A simple transfer’s fee there may be mostly the data.

**Question 8.20 ★★ QD, Dev • crypto • junior, experienced.**

In Python, write a deposit processor for an exchange. It sees blocks with their parents, and it credits a deposit once it has $N$ confirmations on the best chain. A reorganisation can replace recent blocks, and the same transaction may reappear on the new branch. What makes a credit happen once only? What must happen if a reorganisation goes deeper than $N$? How do you test it?

**Answer to Question 8.20.**

[Listing 8.20](#lst-iv3-cr-deposits) rebuilds the best chain from all blocks seen. A deposit is credited once it is $N$ deep, keyed by its own ID, not by block, so a transaction that reappears on the new branch can’t be credited twice. A reorganisation deeper than $N$ removes a credited deposit, and that raises an alarm for a person.

```python
"""Crediting deposits after N confirmations, on a chain that can reorganise."""


class Deposits:
    def __init__(self, confirmations):
        self.need = confirmations
        self.blocks = {}                     # block hash -> (parent hash, height, deposits)
        self.head = None
        self.credited = {}                   # deposit id -> amount
        self.alarms = []

    def on_block(self, block, parent, height, deposits):
        self.blocks[block] = (parent, height, deposits)
        if self.head is None or height > self.blocks[self.head][1]:
            self.head = block
        self._settle()

    def _settle(self):
        safe = set()
        block, depth = self.head, 1
        while block in self.blocks:
            parent, _, deposits = self.blocks[block]
            if depth >= self.need:
                for deposit_id, amount in deposits:
                    safe.add(deposit_id)
                    self.credited.setdefault(deposit_id, amount)   # once only
            elif any(d in self.credited for d, _ in deposits):
                safe.update(d for d, _ in deposits)
            block, depth = parent, depth + 1
        for deposit_id in set(self.credited) - safe:
            self.alarms.append(deposit_id)   # credited, and now not on the best chain
            del self.credited[deposit_id]
```

***Listing 8.20.** Deposits and reorganisations. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_deposits.py*

The key is what makes it idempotent. A block hash isn’t enough, as the same deposit in another block has another hash. In a real system, the ID is the transaction hash with its log index, since one transaction may hold several deposits. The processor’s state and the credit should go to the ledger in one transaction, and its restart resumes from the stored chain.

A shallow reorganisation before $N$ needs no action at all, as nothing was yet credited. That’s why $N$ is chosen against the depths actually seen on that chain. Past $N$, the deposit may have gone for good, and the user may have traded or withdrawn it. The processor removes the credit and raises the alarm, and only a person or a policy can decide what comes next. That’s why large or low-security deposits should wait longer, and withdrawing should come later than trading.

The test has fixed cases: too few confirmations, one credit only, a deep reorganisation with an alarm, and a transaction mined again on the new branch. It also builds 300 random trees with forks shorter than $N$, and asserts that every deposit at depth $N$ or more on the final best chain has been credited.

The model takes the highest block as best, and a real client follows the chain’s fork rule. It walks the whole chain per block for clarity, and a real one would keep only a window. It also assumes that it saw every block: after downtime, a real system must backfill, and treat a gap as an error, or a missed deposit never gets credited.

**Classic wrong answer.** “Listen for the deposit event, and credit it.” An event from a block that later vanishes is a credit with nothing behind it.

**Question 8.21 ★★ QD, Dev • crypto • junior, experienced.**

An exchange pays withdrawals from one hot account, using nonces in order. A transaction is stuck, as its fee is too low, and the operator wants to retry. In Python, write a sender and a model chain. Why must a retry keep its nonce, and not get a new one? What does a stuck nonce do to the requests behind it? How do you bump a fee? What must be stored before anything is broadcast?

**Answer to Question 8.21.**

A retry must keep its nonce, since a new nonce gives a second transaction that can also be mined, and the user is paid twice. With the same nonce, only one can ever land. A stuck nonce blocks every request behind it. Bump the fee by resending that nonce at a higher fee. Store the request-to-nonce mapping before broadcasting.

```python
"""Withdrawals from one account: one nonce per request, for good. A fee bump keeps it."""


class Sender:
    def __init__(self):
        self.next_nonce = 0
        self.by_request = {}                 # request id -> nonce
        self.fee = {}                        # nonce -> current fee

    def submit(self, request, fee):
        """Returns the transaction to broadcast: (nonce, request, fee)."""
        if request not in self.by_request:
            self.by_request[request] = self.next_nonce
            self.next_nonce += 1
        nonce = self.by_request[request]
        self.fee[nonce] = max(fee, self.fee.get(nonce, 0))
        return nonce, request, self.fee[nonce]


class Chain:
    """Mines transactions in nonce order, one per nonce, if the fee meets the market."""

    def __init__(self):
        self.next_nonce = 0
        self.mined = []
        self.pool = {}

    def broadcast(self, tx):
        nonce, _, fee = tx
        if nonce >= self.next_nonce and fee > self.pool.get(nonce, (0, 0, -1))[2]:
            self.pool[nonce] = tx            # a replacement must pay more

    def mine(self, market_fee):
        while self.next_nonce in self.pool and self.pool[self.next_nonce][2] >= market_fee:
            self.mined.append(self.pool.pop(self.next_nonce))
            self.next_nonce += 1
```

***Listing 8.21.** Withdrawals and fee bumps. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_withdraw.py*

So each request is bound to a nonce once and for all. The test’s random run bumps fees at random, with a moving market fee, and asserts that each request is paid exactly once. It also shows the block: a cheap low nonce stops a high-fee one behind it, until the fix. The broken twin, with a fresh nonce for the retry, pays twice.

Saving first matters for crashes. If you broadcast and crash before saving, a restart may not know what went out. So write down the request, nonce and fee, then broadcast, and on restart rebroadcast whatever is not confirmed. Rebroadcasting is harmless, as the same nonce and contents can only be mined once.

With one account, one stuck transaction delays everyone, so operators may use several hot accounts in parallel. A high-fee policy, with bumps at set intervals, keeps the queue moving. The replacement in practice must beat the old fee by a margin, which depends on the node, and a very low fee can be dropped from the pool altogether, so a rebroadcast loop is needed.

Confirmation handling joins this with the deposit logic: a mined withdrawal may be reorganised out, and may come back, or need sending again with the same nonce. Only mark a withdrawal final after enough depth. And a contract target may make the transaction fail, using up the nonce, without paying the user: read the receipt, and don’t only check that it was mined.

**Classic wrong answer.** “It seems stuck, so send it again with the next nonce.” That is how exchanges pay out twice.

**Question 8.22 ★★★ QD, Dev • crypto • experienced.**

In [Listing 8.10](#lst-iv3-cr-proxy), someone calls `setX(0xdead)` through the proxy. Where does that write go? What does `impl()` return afterwards, and what does a call of `x()` through the proxy do then? Explain `delegatecall`. How do you lay out storage to avoid this? What must an upgraded implementation respect?

```solidity
contract Impl {
    uint256 public x;

    function setX(uint256 v) external {
        x = v;
    }
}

contract Proxy {
    address public impl;

    constructor(address i) { impl = i; }

    fallback(bytes calldata data) external returns (bytes memory) {
        (bool ok, bytes memory out) = impl.delegatecall(data);
        require(ok);
        return out;
    }
}
```

***Listing 8.10.** A proxy and its implementation. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Proxy.sol*

**Answer to Question 8.22.**

The write goes to the proxy’s slot 0, which is its `impl`, as the implementation thinks slot 0 is `x`. So `impl()` then returns `0xdead`. A call of `x()` through the proxy now delegates to an address with no code, which succeeds and returns nothing. Put the implementation address in a slot chosen by hash, and only append variables on upgrade.

`delegatecall` runs another contract’s code using the caller’s storage, balance and `msg.sender`. The code finds its variables by slot number, knowing nothing of what the proxy keeps there. So `x = v` means “write slot 0”, wherever the code runs. That’s the flaw: two contracts with one storage and two ideas of its layout.

The local run confirms it. The proxy ends with its address replaced, and with no error reported at any step, and worse, every later call appears to work. A target chosen by an attacker would give them all the proxy’s funds.

The standard repair uses a slot derived from a hash, which no compiler-assigned variable will reach. Our `SafeProxy` does so, and the same call then writes `x` in slot 0, while the implementation address stays put. Such a standard exists so that tools can read that slot as well.

An upgraded implementation must keep the layout of the old one: no reordering, no changed types, and appending only. Constructors do not run in the proxy’s context, so state is set by an initialiser, which must be callable only once, as an open one is a takeover. A function on the proxy can also share a selector with one on the implementation, and the upgrade function needs an access check.

**Classic wrong answer.** “The implementation has its own storage, so the proxy is safe.” Under `delegatecall`, the implementation’s storage is never touched.

**Question 8.23 ★★★ QD, Dev • crypto • experienced.**

In [Listing 8.11](#lst-iv3-cr-readonly), Bob has joined with 100 ETH and `Reader.run()` is called with another 100 ETH, on a local chain. What price does `Reader` see in `receive`, and what is it just before and after? No state of the pool is changed during the callback, so why is this a form of reentrancy? Why is a lock on `leave` alone not enough? What would a lender reading this price lose? Fix it.

```solidity
contract Pool {
    uint256 public shares;

    function join() external payable {
        shares += msg.value;
    }

    function price() public view returns (uint256) {
        return address(this).balance * 1e18 / shares;
    }

    function leave(uint256 n) external {
        (bool ok, ) = msg.sender.call{value: n * price() / 1e18}("");
        require(ok);
        shares -= n;
    }
}

contract Reader {
    Pool pool;
    uint256 public seen;

    constructor(Pool p) { pool = p; }

    function run() external payable {
        pool.join{value: msg.value}();
        pool.leave(msg.value);
    }

    receive() external payable {
        seen = pool.price();                 // a lender would value collateral here
    }
}
```

***Listing 8.11.** A price read in the middle of a call. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/ReadOnly.sol*

**Answer to Question 8.23.**

`Reader` sees 0.5, against 1.0 before and after. In the callback, the ETH has left, and `shares` has not yet fallen, so `price` divides the smaller balance by the old supply. This is reentrancy as the pool is entered while it is out of step. A lender using that price would undervalue collateral, or overvalue it in a mirror design. Fix it by updating first, or by having a view refuse.

No state changes in the callback, since `price` is a view. That’s why it is called read-only reentrancy, and why it’s easy to miss: a lock on `leave` and `join` stops those being re-entered, and it has no effect on a view that doesn’t check the lock. The pool itself loses nothing. The victim is someone else’s contract, which trusts the pool’s price.

What would a lender lose? If it reads a low price, then healthy positions look undercollateralised, and they can be liquidated at a false price within that callback. In a mirror-image design, where the price spikes up mid-call, an attacker can borrow too much. The attacker’s cost is only the gas and the temporary capital, which may come from a flash loan.

Our repair does both: `shares` falls before the call, so the state is correct throughout, and `price` reverts while the lock is set. The test’s `SafeReader` is refused, and the run still finishes with a price of 1.

A consumer also has a defence of its own: check the other contract’s lock, if it exposes one, before trusting its view. Reviews should treat any view function used by others as part of the contract’s safety, and not as a harmless read.

**Classic wrong answer.** “It is a view function, so it cannot be part of an attack.” It changes nothing itself. The harm comes from someone acting on what it said.

**Question 8.24 ★★★ QD, Dev • crypto • experienced.**

[Listing 8.12](#lst-iv3-cr-relay) lets anyone submit a user’s signed payment and pay the gas for it. The signer has funded 10 ETH in each deployment, and a signature pays Mallory 1 ETH. If Mallory submits it twice, and then once to another deployment of the same contract, what happens? What is missing from the signed message, and why does each missing field matter? Write the repair. Why can a user pay without holding gas this way?

```solidity
contract Relay {
    mapping(address => uint256) public balance;

    function fund(address who) external payable {
        balance[who] += msg.value;
    }

    function pay(address to, uint256 amount, uint8 v, bytes32 r, bytes32 s) external {
        bytes32 digest = keccak256(abi.encodePacked(to, amount));
        address from = ecrecover(digest, v, r, s);
        require(from != address(0) && balance[from] >= amount);
        balance[from] -= amount;
        balance[to] += amount;
    }
}
```

***Listing 8.12.** A relayed payment. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Relay.sol*

**Answer to Question 8.24.**

All three submissions succeed: the first pays, the replay pays again, and the other deployment pays as well. The signed message has only the recipient and amount. It lacks a nonce, so it can repeat. It lacks the contract’s address and chain ID, so it works elsewhere. A user can pay with no gas since the relayer submits and pays, recovering costs some other way.

```solidity
contract SafeRelay {
    mapping(address => uint256) public balance;
    mapping(address => uint256) public nonce;

    function fund(address who) external payable {
        balance[who] += msg.value;
    }

    function digestFor(address from, address to, uint256 amount)
        public view returns (bytes32)
    {
        return keccak256(
            abi.encode(block.chainid, address(this), from, nonce[from], to, amount)
        );
    }

    function pay(address from, address to, uint256 amount, uint8 v, bytes32 r, bytes32 s)
        external
    {
        require(from != address(0));
        require(ecrecover(digestFor(from, to, amount), v, r, s) == from);
        require(balance[from] >= amount);
        nonce[from] += 1;
        balance[from] -= amount;
        balance[to] += amount;
    }
}
```

***Listing 8.22.** The repair. code/interviews-3/08-smart-contracts-and-crypto-engineering/solidity/Relay.sol*

A signature proves that someone approved these exact bytes. It says nothing of how often or where, unless those bytes say so. So each field stops one kind of replay. The nonce stops repeats on this contract, and the contract’s address stops use on another deployment. The chain ID stops use on another chain, say after a fork, or a copy at the same address elsewhere. The signer goes in too, so `ecrecover` must return that address. Typed-data standards add a domain separator with exactly these fields, and they leave replay protection to the application, hence the nonce.

We ran both versions on the local EVM with a real signature from a test key. The first relay paid Mallory twice, and also on the second copy. The repaired one paid once, and refused both the replay and the other deployment, which proves the digest binds them.

A few further details. `ecrecover` returns zero on a bad signature, so an unchecked zero can match an uninitialised account, and the repair refuses that. Signatures are malleable: another valid $(r, s)$ exists for the same message, so don’t use the signature itself as a replay key, as a nonce does this properly. Add a deadline too, as an unused signature otherwise stays valid for ever.

The relayer pays gas, and is repaid out of the transfer, or by a sponsor. This is what “no gas for the user” means: someone still pays.

**Classic wrong answer.** “The signature is valid, so the payment is authorised.” Valid for this message, and that message doesn’t say how many times.

**Question 8.25 ★★★ QD, Dev, Risk • crypto • experienced.**

A key that controls funds must survive the loss of one holder, and resist the theft of one. In Python, write Shamir secret sharing, $t$ of $n$ over a prime field. Why do $t - 1$ shares say nothing? If each of 3 key holders is independently compromised with probability 1%, what is the chance of a theft under 2-of-3, and what is it with one key? How does this differ from a multisignature contract and from threshold signing?

**Answer to Question 8.25.**

Under 2-of-3, the theft probability is $3p^2 - 2p^3 \approx 0.03\%$, against 1% for one key, and the funds survive one lost holder. With $t - 1$ shares, every secret remains possible, since a polynomial of degree $t - 1$ can still pass through any value at $0$. [Listing 8.23](#lst-iv3-cr-shamir) is the code.

```python
"""Shamir's scheme over a prime field: t of n shares give the secret, t - 1 say nothing."""
import secrets

P = 2**521 - 1                               # a prime well above a 32-byte key


def split(secret, t, n, p=P, rand=secrets.randbelow):
    if not 0 <= secret < p:
        raise ValueError("the field must be larger than the secret")
    coefficients = [secret] + [rand(p) for _ in range(t - 1)]

    def poly(x):
        return sum(c * pow(x, k, p) for k, c in enumerate(coefficients)) % p

    return [(x, poly(x)) for x in range(1, n + 1)]


def recover(shares, p=P):
    """Lagrange interpolation at x = 0."""
    total = 0
    for i, (xi, yi) in enumerate(shares):
        top, bottom = 1, 1
        for j, (xj, _) in enumerate(shares):
            if i != j:
                top = top * -xj % p
                bottom = bottom * (xi - xj) % p
        total = (total + yi * top * pow(bottom, -1, p)) % p
    return total
```

***Listing 8.23.** Secret sharing. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_shamir.py*

The secret is the polynomial’s value at 0, with the other coefficients random. Each share is a point on it. $t$ points fix a polynomial of degree $t - 1$, by interpolation. With fewer, for each candidate secret there’s exactly one polynomial through the known points, so in a finite field all secrets are equally likely. That’s not just hard but impossible to break, whatever the computing power. The test enumerates this for $t = 2$ over the field of size 257. The same test recovers the secret from every subset of 3 of 5. The field must be larger than the secret: a 32-byte key needs a prime above $2^{256}$, so the listing uses $2^{521} - 1$, and `split` refuses a secret that does not fit, as reducing it would silently lose the key.

The sum for theft is an enumeration: 3 ways of two compromised at $p^2(1-p)$, and one way of all three at $p^3$. Independence is the assumption to attack, as holders may share software, a building or a process.

Shamir’s weakness is that the key is rebuilt in one place when used, and at that instant one machine holds it. A multisignature contract has separate keys, and each signs, so there’s never a single key, and the rule is visible on-chain, at more gas, and only where the chain supports it. Threshold signing (MPC) has the parties jointly produce one normal-looking signature without the key ever existing in one place. That needs a much more complex protocol, whose implementation is a risk in itself, and that’s where a real custody system should rely on audited code. Whichever you pick, this code is for teaching: `secrets` supplies the randomness, but side channels and storage of shares aren’t dealt with.

**Classic wrong answer.** “Split the 32-byte key in three pieces, and give each holder two.” Then a holder knows part of the key, and each theft narrows the search.

**Question 8.26 ★★★ QD, Dev, Risk • crypto • experienced.**

An exchange publishes the root of a Merkle tree of account balances, so that a user can check their own. In Python, write the tree, an inclusion proof and its check. Why do leaves and inner nodes get different prefixes? How long, at most, is a proof for 23 accounts? What does this prove, and what does it not? What else must be shown for a claim of solvency?

**Answer to Question 8.26.**

[Listing 8.24](#lst-iv3-cr-merkle) is one answer, with proofs of at most 5 hashes for 23 accounts. The prefixes keep a leaf from being taken for an inner node. A proof shows that this balance was included in the tree behind the published root. It doesn’t show that the exchange holds the assets, or that all liabilities are in the tree. Solvency also needs proof of assets, and a total.

```python
"""A Merkle tree of (account, balance) leaves, with an inclusion proof for one of them."""
import hashlib


def leaf(account, balance):
    return hashlib.sha256(b"\x00" + f"{account}:{balance}".encode()).digest()


def node(a, b):
    return hashlib.sha256(b"\x01" + a + b).digest()


def build(leaves):
    """Returns the levels, leaves first. An odd node goes up unpaired."""
    levels = [leaves]
    while len(levels[-1]) > 1:
        row = levels[-1]
        levels.append([node(row[i], row[i + 1]) if i + 1 < len(row) else row[i]
                       for i in range(0, len(row), 2)])
    return levels


def proof(levels, index):
    path = []
    for row in levels[:-1]:
        sibling = index ^ 1
        if sibling < len(row):
            path.append((row[sibling], index % 2 == 0))
        index //= 2
    return path


def verify(root, account, balance, path):
    current = leaf(account, balance)
    for sibling, we_are_left in path:
        current = node(current, sibling) if we_are_left else node(sibling, current)
    return current == root
```

***Listing 8.24.** A tree of balances. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_merkle.py*

A proof is one sibling per level, so its length is at most $\lceil \log_2 23 \rceil = 5$. A user recomputes up from their own leaf, and compares it with the published root. The exchange can’t change a balance without changing the root, and the user needs nothing but the hashes.

Without prefixes, an inner node’s input of two 32-byte hashes is also a legal leaf input of 64 bytes, so someone could show an inner node as if it were a leaf. Separate prefixes make these two kinds of hash disjoint.

What it does not prove matters more. Only users who check are protected, as the exchange might omit those it thinks won’t look. The tree doesn’t sum up, so add a running total to each node, so that a parent’s total is the sum of its children, and the root commits to total liabilities. An account with a negative balance could reduce the total, so a proof should show that all are non-negative, and zero-knowledge proofs can do so with privacy. And a snapshot is one moment: funds could be borrowed for that moment. Privacy is also at stake, as a leaf holding a plain account name and balance leaks, so use a salted hash.

Solvency also needs assets: a signature from the wallets or a movement on-chain, proving control. Loans against those assets aren’t shown either. An audit, repeated often, is what ties these together. The test verifies all 23 proofs, rejects each with a changed balance, and covers a one-leaf tree. The chain’s own view of those assets isn’t in this model.

**Classic wrong answer.** “Proof of reserves means the exchange is solvent.” Assets without liabilities prove nothing, and this tree alone shows only some of the liabilities.

**Question 8.27 ★★★ QD, QT, Risk • crypto • experienced.**

A long perpetual position of 1 token at 2000, with isolated margin of 200 and a maintenance rate of 0.5%, is closed when equity falls to maintenance times notional at the mark. In Python, find the liquidation price. Why use a mark price and not the last trade? The price gaps and the close fills at 1790: who pays? What happens if the insurance fund runs out?

**Answer to Question 8.27.**

The liquidation price is $(2000 - 200) / (1 - 0.005) = 1809.05$. A mark price resists a single odd trade or a thin book, which could otherwise trigger closures. If the close fills at 1790, equity is $-10$, and the insurance fund pays that gap. If the fund runs out, profitable traders on the other side can get reduced, by automatic deleveraging or a share of the loss.

```python
"""A long perpetual with isolated margin: when is it closed, and who pays for a gap?"""


def liquidation_price(entry, qty, margin, maintenance):
    """The mark at which equity falls to maintenance times the notional at that mark."""
    return (entry * qty - margin) / (qty * (1 - maintenance))


class Position:
    def __init__(self, entry, qty, margin, maintenance):
        self.entry, self.qty, self.margin, self.maintenance = entry, qty, margin, maintenance

    def equity(self, mark):
        return self.margin + (mark - self.entry) * self.qty

    def must_close(self, mark):
        return self.equity(mark) <= self.maintenance * mark * self.qty

    def close(self, fill_price, insurance):
        """Closes at the fill. Returns the fund after it. Below zero, others must pay."""
        left = self.equity(fill_price)
        return insurance + min(left, 0.0)
```

***Listing 8.25.** Liquidation. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_liquidation.py*

Equity is margin plus unrealised profit: $m + (P - E) q$. Setting that to $r P q$ and solving gives $P = (E q - m) / (q (1 - r))$. The test also finds it by bisection on `must_close` for 500 random positions, and they agree. Starting with 10% margin, a fall of about 9.5% closes the position.

The mark price usually comes from an index of several spot venues, plus a damped basis. If liquidation were on the last trade, someone could push one venue’s thin book for a moment, close others’ positions, and take the other side. Fair marks remove most of that game, though the index itself must be robust against one venue’s failure.

The sequence is that the engine closes in the market with an aggressive order, ideally well before equity reaches 0, and any margin left goes back to the trader or the fund. The maintenance margin is the buffer for a fall during closing. With a gap, or a thin book, the fill is below bankruptcy, and someone must absorb the shortfall, since the winner on the other side is owed the money. That’s why exchanges fund a pool from liquidation surpluses.

An exchange has to run this under the heaviest load it will see, when many positions are closed at once, and every closure pushes the price further. So engines close in stages, limit how fast they sell, and can raise margins. These closures are the classic cascade. Any wrong mark price or a stale feed can do great damage here, so handle it as you handle the hot-path risk checks. This covers isolated margin; cross margin nets positions.

**Classic wrong answer.** “Liquidation happens at equity zero, so the exchange never loses.” By the time the order fills, the price can be well past that.

**Question 8.28 ★★★ QD, QT • crypto • experienced.**

A constant-product pool holds 1000 tokens and 2 000 000 dollars, with a 0.3% fee taken from the input, and a centralised venue quotes the token at 2020. Ignoring that venue’s costs, derive the best size to buy from the pool and sell there, and find the profit. Below what outside price is there no trade at all? Write it in Python. What risks does the formula leave out, in execution and in inventory?

**Answer to Question 8.28.**

The best size is about 6979 dollars, with a profit of about 24.28 before gas and venue costs. Below $2000 / 0.997 = 2006.02$, there is no trade at all, as the fee takes more than the difference. The formula leaves out timing, the risk of failure or of being beaten to it, gas, and what you hold while you wait.

```python
"""How much to buy from a constant-product pool to sell at an outside price, after its fee."""
import math


def best_input(x, y, outside, fee=0.003):
    """Dollars to spend on a pool of x tokens and y dollars, to resell at `outside`."""
    keep = 1 - fee
    return max(0.0, (math.sqrt(keep * x * y * outside) - y) / keep)


def profit(dollars, x, y, outside, fee=0.003, gas=0.0):
    keep = 1 - fee
    tokens = dollars * keep * x / (y + dollars * keep)
    return tokens * outside - dollars - gas
```

***Listing 8.26.** The size of an arbitrage. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_arb_size.py*

With $k = 1 - f$, tokens received for $d$ dollars are $k d x / (y + k d)$. Profit is the outside price $S$ times that, minus $d$. Setting the derivative to 0 gives $(y + k d)^2 = k x y S$, so $d^* = (\sqrt{k x y S} - y) / k$. The test compares this with a grid search, and it confirms the threshold, as $d^*$ is positive only if $k S x > y$. After the trade, the pool’s marginal price, net of the fee, equals the outside price.

Gas is a fixed cost, so it doesn’t change $d^*$. It only decides whether to trade: here a gas cost above 24.28 means no. The test covers that. A centralised venue’s fee would effectively lower $S$, and move $d^*$.

The risks are the point of an interview. The legs can’t be simultaneous, as a block arrives every few seconds and the venue trades continuously, so the outside price can move, and you might sell first and buy later, or the reverse. Others see the same chance, and the priority auction may take most of the profit as a bid. A failed transaction still costs gas. You must hold inventory on both sides, and moving it takes time, with chain risk during transfer. So a real system marks all these and has a limit for each, and it may hedge the difference until both legs are done. We ran only the Python model above.

**Classic wrong answer.** “Buy until the pool’s price equals the outside price.” That ignores the fee. The optimum leaves a gap the size of the fee.

**Question 8.29 ★★★★ QD, Dev, LLE • crypto • experienced.**

Design a centralised crypto exchange with deposits, withdrawals, limit orders and a live book. How are balances kept and held for open orders? In Python, write the balance core. Where does the matching engine meet the ledger? How do deposits, withdrawals and custody work, and how much goes in a hot wallet? What must never be possible, and how would you check that it never happened?

**Answer to Question 8.29.**

Keep an internal double-entry ledger. An open order holds funds from a user’s free balance, so they can’t be spent twice. [Listing 8.27](#lst-iv3-cr-holds) is the core. The engine owns matching, the ledger owns money, and they join through a sequenced stream. Keep little in hot wallets. It must never be possible to create money, spend held funds, or pay a withdrawal twice.

```python
"""An exchange's balances: an open order holds funds, so they cannot be spent twice."""


class Balances:
    def __init__(self):
        self.free = {}
        self.held = {}

    def deposit(self, who, amount):
        self.free[who] = self.free.get(who, 0) + amount

    def hold(self, who, amount):
        if amount <= 0 or self.free.get(who, 0) < amount:
            return False
        self.free[who] -= amount
        self.held[who] = self.held.get(who, 0) + amount
        return True

    def release(self, who, amount):
        self.held[who] -= amount
        self.free[who] += amount

    def settle(self, payer, payee, amount):
        """A trade pays from the payer's hold to the payee's free balance."""
        self.held[payer] -= amount
        self.free[payee] = self.free.get(payee, 0) + amount

    def withdraw(self, who, amount):
        if self.free.get(who, 0) < amount:
            return False
        self.free[who] -= amount
        return True
```

***Listing 8.27.** Free and held balances. code/interviews-3/08-smart-contracts-and-crypto-engineering/python/iv_holds.py*

An order first passes a hold in the ledger, and then goes to the matching engine. A hold must be done before the engine sees it. The engine’s trades come back on a sequenced stream, each settling from the payer’s hold to the payee’s free balance, and a cancel releases the rest. The test throws random operations at this, and asserts that free plus held plus withdrawn is constant, and that nothing goes negative. For speed, an engine usually keeps its own in-memory balances within the sequenced path, with the ledger following from the same log, so that the two are reconciled and can be rebuilt.

Deposits go through a chain watcher to the ledger, and are credited only once they are deep enough that a reorganisation is unlikely to undo them, a depth set per chain. Withdrawals go through risk checks and limits, are held in the ledger, and then are signed by a separate service, with no direct path from a web server to a key. Each is bound by request ID to one nonce for good, so a retry can only raise the fee, and can’t pay twice.

Hot wallets hold only what recent net outflows need, and a sweep to warm or cold storage follows a schedule. Suppose net withdrawals peak at 2% of funds in a day, and refilling from cold takes 6 hours: the hot float has to hold about 0.5%, plus a margin. You should measure these. Cold keys are offline under multiple holders. A breach of a hot wallet then loses only that float.

Check the invariants continually. The sum of user balances must equal the total held on all chains, per asset, within the tolerances of what’s in flight. Each ledger row must trace back to a deposit, a trade or a withdrawal. Run this reconciliation on a schedule, and stop withdrawals automatically when it breaks. Withdrawals should be pausable, without stopping trading. Also audit balances from outside, and treat admin access as the main risk.

**Classic wrong answer.** “Keep the balance in the matching engine, and that’s the ledger.” You then can’t reconcile, and a bug in matching is a bug in the accounts.

**Question 8.30 ★★★★ QD, QT, LLE • crypto • experienced.**

Design a system that makes markets on an on-chain pool and hedges on centralised venues. What does it read from the chain, and at what stage of a block? How do reorganisations, failed transactions and gas change the design? How do you handle the time between a decision and inclusion? What is the state of a transaction you have sent, and how is your position kept? What would you monitor? What must a test environment be able to do?

**Answer to Question 8.30.**

Read pool state and pending transactions from your own nodes, at the start of each block, and on pending data if you can use it. Treat every sent transaction as uncertain until it’s deep enough, and keep separate figures for your confirmed position and your expected one. Hedge on the centralised venues against the expected one. Costs, reverts and reorganisations are all part of the model.

The chain view comes from more than one node, as a single one can lag or be wrong. For each block, you must have the pool’s reserves and ticks, your own balances, the base fee, and the time since the last block. Block events should be keyed by block hash, so that a reorganisation can be undone, and you should keep a few blocks of undo state. Treat a small reorganisation as an everyday event, as you do a late fill in a central venue.

A decision takes time to land. A quote you compute at the start of a slot may be included at the end, or later, so include the deadline and a price limit in each transaction, so that it reverts when the world has moved. A revert still costs gas. So you bid to reach a good position, and you may use a private channel to avoid being sandwiched. Your own nonces need the same care as anywhere: each decision keeps one nonce for good, so a retry can’t act twice, and a stuck nonce holds back everything behind it.

A sent transaction has states: created, signed, broadcast, seen in the pool, included, reverted or succeeded, deep enough, and possibly reorganised out. The position record has a row for each, and reconciles with the chain’s receipts. On the venue side, the same applies with its own fills. Risk limits apply to the worst case across all pending items.

Monitor the difference between expected and realised profit for each trade, the rate of inclusion and of reverts, gas spent as a share of profit, node lag, inventory on each chain, and the hedge’s slippage. Adverse selection shows up as trades that lose straight after, so measure that as well: the flow that trades with a pool is often better informed.

The test environment needs a local chain or fork: replaying real blocks with known reorganisations, reverting on demand, and adjusting gas. No real funds should go through it. It’s the only safe way to check that an undo works. No such system was run here, so the design above is unmeasured.

**Classic wrong answer.** “Treat the chain as just another venue with a slow API.” A venue tells you that an order filled. A chain can change its mind about that for a while.
