diff --git a/CLAUDE.md b/CLAUDE.md index 62dc1e8..0983d5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,37 @@ medusa fuzz --target-contracts CryticERC20InternalHarness --config medusa-config 3. **PropertiesHelper** provides `assertEq`, `assertWithMsg`, `clampBetween`, and `LogXxx` events for debugging 4. **Echidna/Medusa configs** use assertion mode (`testMode: assertion`) with deployer `0x10000` +## ERC20 Edge Case Testing + +For protocols that integrate with arbitrary ERC20 tokens, use the edge case helper in `contracts/util/erc20/`: + +```solidity +import "@crytic/properties/contracts/util/erc20/ERC20EdgeCases.sol"; + +contract MyProtocolTest { + ERC20EdgeCases edgeCases; + + constructor() { + edgeCases = new ERC20EdgeCases(); + } + + function test_protocolWithAllTokens() public { + address[] memory tokens = edgeCases.all_erc20(); + // Test with 20 different token types including: + // - Missing return values (USDT, BNB) + // - Fee-on-transfer (STA, PAXG) + // - Reentrant (ERC777, AMP) + // - Admin controls (USDC blocklist, BNB pause) + // - And 15+ more edge cases + } +} +``` + +This deploys 20 tokens with known problematic behaviors so you can test your protocol against all of them at once. See `contracts/util/erc20/README.md` for full documentation and `tests/ERC20EdgeCases/` for examples. + +**Use case**: Testing protocols (DEXs, vaults, lending) that accept any ERC20 token +**Prevents**: Fee-on-transfer bugs (Balancer $500k), reentrancy (imBTC/lendf.me), missing return values (stuck tokens), etc. + ## Adding New Properties 1. Add property to appropriate file in `contracts//internal/properties/` and `external/properties/` diff --git a/contracts/util/erc20/ERC20EdgeCases.sol b/contracts/util/erc20/ERC20EdgeCases.sol new file mode 100644 index 0000000..a4d1ed9 --- /dev/null +++ b/contracts/util/erc20/ERC20EdgeCases.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +import "./tokens/StandardERC20.sol"; +import "./tokens/MissingReturns.sol"; +import "./tokens/ReturnsFalse.sol"; +import "./tokens/TransferFee.sol"; +import "./tokens/Reentrant.sol"; +import "./tokens/BlockList.sol"; +import "./tokens/Pausable.sol"; +import "./tokens/RevertZero.sol"; +import "./tokens/NoRevert.sol"; +import "./tokens/Uint96.sol"; +import "./tokens/LowDecimals.sol"; +import "./tokens/HighDecimals.sol"; +import "./tokens/Bytes32Metadata.sol"; +import "./tokens/ApprovalRaceProtection.sol"; +import "./tokens/ApprovalToZeroAddress.sol"; +import "./tokens/RevertToZero.sol"; +import "./tokens/RevertZeroApproval.sol"; +import "./tokens/TransferFromSelf.sol"; +import "./tokens/TransferMax.sol"; +import "./tokens/PermitNoOp.sol"; + +/** + * @title ERC20 Edge Cases Helper + * @author Crytic (Trail of Bits) + * @notice Deploys and manages all known ERC20 edge case tokens for testing + * @dev Use this helper to test protocols against non-standard ERC20 behaviors + * + * This helper deploys 20 different ERC20 token implementations covering all known + * edge cases and non-standard behaviors found in real tokens. Import this contract + * in your test harness to automatically test your protocol against: + * + * - Missing return values (USDT, BNB, OMG) + * - Transfer fees (STA, PAXG) + * - Reentrant callbacks (AMP, imBTC) + * - Admin controls (USDC blocklist, BNB pause) + * - Approval quirks (USDT race protection, UNI uint96) + * - And many more... + * + * @custom:usage + * ```solidity + * import "@crytic/properties/contracts/util/erc20/ERC20EdgeCases.sol"; + * + * contract MyTest { + * ERC20EdgeCases edgeCases; + * + * constructor() { + * edgeCases = new ERC20EdgeCases(); + * } + * + * function test_protocolWithAllTokens() public { + * address[] memory tokens = edgeCases.all_erc20(); + * for (uint i = 0; i < tokens.length; i++) { + * // Test your protocol with each token + * } + * } + * } + * ``` + * + * @custom:see https://github.com/d-xo/weird-erc20 + * @custom:see https://github.com/crytic/building-secure-contracts/blob/master/development-guidelines/token_integration.md + */ +contract ERC20EdgeCases { + // Arrays to store deployed token addresses + address[] private _standardTokens; + address[] private _nonStandardTokens; + address[] private _allTokens; + + // Named access to specific token types + mapping(string => address) public tokenByName; + + /** + * @notice Constructor deploys all token types + * @dev Tokens are deployed with 1M supply to deployer + */ + constructor() { + _deployAllTokens(); + } + + /** + * @notice Get all standard-compliant ERC20 tokens + * @return Array of token addresses that follow ERC20 standard + */ + function all_erc20_standard() public view returns (address[] memory) { + return _standardTokens; + } + + /** + * @notice Get all non-standard ERC20 tokens with edge case behaviors + * @return Array of token addresses with non-standard behaviors + */ + function all_erc20_non_standard() public view returns (address[] memory) { + return _nonStandardTokens; + } + + /** + * @notice Get all tokens (standard + non-standard) + * @return Array of all deployed token addresses + */ + function all_erc20() public view returns (address[] memory) { + return _allTokens; + } + + /** + * @notice Get categorized tokens by behavior type + * @return Array of token addresses in the specified category + */ + function tokens_missing_return_values() public view returns (address[] memory) { + address[] memory tokens = new address[](2); + tokens[0] = tokenByName["MissingReturns"]; + tokens[1] = tokenByName["ReturnsFalse"]; + return tokens; + } + + function tokens_with_fee() public view returns (address[] memory) { + address[] memory tokens = new address[](1); + tokens[0] = tokenByName["TransferFee"]; + return tokens; + } + + function tokens_reentrant() public view returns (address[] memory) { + address[] memory tokens = new address[](1); + tokens[0] = tokenByName["Reentrant"]; + return tokens; + } + + function tokens_with_admin_controls() public view returns (address[] memory) { + address[] memory tokens = new address[](2); + tokens[0] = tokenByName["BlockList"]; + tokens[1] = tokenByName["Pausable"]; + return tokens; + } + + function tokens_approval_quirks() public view returns (address[] memory) { + address[] memory tokens = new address[](4); + tokens[0] = tokenByName["ApprovalRaceProtection"]; + tokens[1] = tokenByName["ApprovalToZeroAddress"]; + tokens[2] = tokenByName["RevertZeroApproval"]; + tokens[3] = tokenByName["Uint96"]; + return tokens; + } + + /** + * @dev Internal function to deploy all token types + */ + function _deployAllTokens() internal { + // Deploy standard token + address standard = address(new StandardERC20()); + _standardTokens.push(standard); + _allTokens.push(standard); + tokenByName["Standard"] = standard; + + // Deploy MissingReturns (USDT, BNB, OMG-like) + address missingReturns = address(new MissingReturns()); + _nonStandardTokens.push(missingReturns); + _allTokens.push(missingReturns); + tokenByName["MissingReturns"] = missingReturns; + tokenByName["USDT-like"] = missingReturns; + + // Deploy ReturnsFalse (Tether Gold-like) + address returnsFalse = address(new ReturnsFalse()); + _nonStandardTokens.push(returnsFalse); + _allTokens.push(returnsFalse); + tokenByName["ReturnsFalse"] = returnsFalse; + tokenByName["TetherGold-like"] = returnsFalse; + + // Deploy TransferFee (STA, PAXG-like) with 1% fee + address transferFee = address(new TransferFee(100)); + _nonStandardTokens.push(transferFee); + _allTokens.push(transferFee); + tokenByName["TransferFee"] = transferFee; + tokenByName["STA-like"] = transferFee; + tokenByName["PAXG-like"] = transferFee; + + // Deploy Reentrant (ERC777, AMP, imBTC-like) + address reentrant = address(new Reentrant()); + _nonStandardTokens.push(reentrant); + _allTokens.push(reentrant); + tokenByName["Reentrant"] = reentrant; + tokenByName["ERC777-like"] = reentrant; + tokenByName["AMP-like"] = reentrant; + + // Deploy BlockList (USDC, USDT-like) + address blockList = address(new BlockList()); + _nonStandardTokens.push(blockList); + _allTokens.push(blockList); + tokenByName["BlockList"] = blockList; + tokenByName["USDC-blocklist"] = blockList; + + // Deploy Pausable (BNB, ZIL-like) + address pausable = address(new Pausable()); + _nonStandardTokens.push(pausable); + _allTokens.push(pausable); + tokenByName["Pausable"] = pausable; + tokenByName["BNB-like"] = pausable; + + // Deploy RevertZero (LEND-like) + address revertZero = address(new RevertZero()); + _nonStandardTokens.push(revertZero); + _allTokens.push(revertZero); + tokenByName["RevertZero"] = revertZero; + tokenByName["LEND-like"] = revertZero; + + // Deploy NoRevert (ZRX, EURS-like) + address noRevert = address(new NoRevert()); + _nonStandardTokens.push(noRevert); + _allTokens.push(noRevert); + tokenByName["NoRevert"] = noRevert; + tokenByName["ZRX-like"] = noRevert; + + // Deploy Uint96 (UNI, COMP-like) + address uint96 = address(new Uint96()); + _nonStandardTokens.push(uint96); + _allTokens.push(uint96); + tokenByName["Uint96"] = uint96; + tokenByName["UNI-like"] = uint96; + tokenByName["COMP-like"] = uint96; + + // Deploy LowDecimals (USDC, Gemini-like) + address lowDecimals = address(new LowDecimals()); + _nonStandardTokens.push(lowDecimals); + _allTokens.push(lowDecimals); + tokenByName["LowDecimals"] = lowDecimals; + tokenByName["USDC-decimals"] = lowDecimals; + + // Deploy HighDecimals (YAM-V2-like) + address highDecimals = address(new HighDecimals()); + _nonStandardTokens.push(highDecimals); + _allTokens.push(highDecimals); + tokenByName["HighDecimals"] = highDecimals; + tokenByName["YAM-like"] = highDecimals; + + // Deploy Bytes32Metadata (MKR-like) + address bytes32Metadata = address(new Bytes32Metadata()); + _nonStandardTokens.push(bytes32Metadata); + _allTokens.push(bytes32Metadata); + tokenByName["Bytes32Metadata"] = bytes32Metadata; + tokenByName["MKR-like"] = bytes32Metadata; + + // Deploy ApprovalRaceProtection (USDT, KNC-like) + address approvalRace = address(new ApprovalRaceProtection()); + _nonStandardTokens.push(approvalRace); + _allTokens.push(approvalRace); + tokenByName["ApprovalRaceProtection"] = approvalRace; + tokenByName["USDT-approval"] = approvalRace; + + // Deploy ApprovalToZeroAddress (OpenZeppelin-like) + address approvalToZero = address(new ApprovalToZeroAddress()); + _nonStandardTokens.push(approvalToZero); + _allTokens.push(approvalToZero); + tokenByName["ApprovalToZeroAddress"] = approvalToZero; + tokenByName["OpenZeppelin-approval"] = approvalToZero; + + // Deploy RevertToZero (OpenZeppelin-like) + address revertToZero = address(new RevertToZero()); + _nonStandardTokens.push(revertToZero); + _allTokens.push(revertToZero); + tokenByName["RevertToZero"] = revertToZero; + tokenByName["OpenZeppelin-transfer"] = revertToZero; + + // Deploy RevertZeroApproval (BNB-like) + address revertZeroApproval = address(new RevertZeroApproval()); + _nonStandardTokens.push(revertZeroApproval); + _allTokens.push(revertZeroApproval); + tokenByName["RevertZeroApproval"] = revertZeroApproval; + tokenByName["BNB-approval"] = revertZeroApproval; + + // Deploy TransferFromSelf (DSToken, WETH-like) + address transferFromSelf = address(new TransferFromSelf()); + _nonStandardTokens.push(transferFromSelf); + _allTokens.push(transferFromSelf); + tokenByName["TransferFromSelf"] = transferFromSelf; + tokenByName["DSToken-like"] = transferFromSelf; + tokenByName["WETH-like"] = transferFromSelf; + + // Deploy TransferMax (cUSDCv3-like) + address transferMax = address(new TransferMax()); + _nonStandardTokens.push(transferMax); + _allTokens.push(transferMax); + tokenByName["TransferMax"] = transferMax; + tokenByName["cUSDCv3-like"] = transferMax; + + // Deploy PermitNoOp (WETH-like) + address permitNoOp = address(new PermitNoOp()); + _nonStandardTokens.push(permitNoOp); + _allTokens.push(permitNoOp); + tokenByName["PermitNoOp"] = permitNoOp; + tokenByName["WETH-permit"] = permitNoOp; + } +} diff --git a/contracts/util/erc20/README.md b/contracts/util/erc20/README.md new file mode 100644 index 0000000..a70ef75 --- /dev/null +++ b/contracts/util/erc20/README.md @@ -0,0 +1,250 @@ +# ERC20 Edge Case Testing Helper + +This directory contains a comprehensive helper system for testing protocols against non-standard ERC20 token behaviors. Many real-world tokens violate the ERC20 specification in various ways, and these violations have led to numerous exploits in DeFi protocols. + +## Overview + +The `ERC20EdgeCases` contract deploys 20 different ERC20 token implementations, each exhibiting a specific non-standard behavior found in real tokens. Use this helper to ensure your protocol handles all edge cases correctly. + +## Quick Start + +```solidity +import "@crytic/properties/contracts/util/erc20/ERC20EdgeCases.sol"; + +contract MyProtocolTest { + ERC20EdgeCases edgeCases; + MyProtocol protocol; + + constructor() { + edgeCases = new ERC20EdgeCases(); + protocol = new MyProtocol(); + } + + // Test protocol with ALL token types + function test_protocolWithAllTokens(uint256 amount) public { + address[] memory tokens = edgeCases.all_erc20(); + + for (uint i = 0; i < tokens.length; i++) { + // Test your protocol with each token + _testProtocol(tokens[i], amount); + } + } + + // Test with specific problematic behavior + function test_protocolWithFeeTokens() public { + address[] memory feeTokens = edgeCases.tokens_with_fee(); + // Test just with fee-on-transfer tokens + } +} +``` + +## Token Types + +### Standard (Baseline) + +| Token | Behavior | Real Examples | +|-------|----------|---------------| +| **StandardERC20** | Compliant ERC20 implementation | Most well-behaved tokens | + +### Missing Return Values + +| Token | Behavior | Real Examples | Impact | +|-------|----------|---------------|--------| +| **MissingReturns** | `transfer()` and `transferFrom()` don't return `bool` | USDT, BNB, OMG | Contracts expecting return values will fail to decode | +| **ReturnsFalse** | Returns `false` even on successful transfers | Tether Gold (XAUT) | Makes it impossible to correctly handle all return values | + +### Fee on Transfer + +| Token | Behavior | Real Examples | Impact | Exploits | +|-------|----------|---------------|--------|----------| +| **TransferFee** | Deducts fee from transfer amount | Statera (STA), Paxos Gold (PAXG) | Receiver gets less than sent amount | Balancer $500k drain | + +### Reentrant Callbacks + +| Token | Behavior | Real Examples | Impact | Exploits | +|-------|----------|---------------|--------|----------| +| **Reentrant** | Calls back to receiver during transfer | Amp (AMP), imBTC | Enables reentrancy attacks | imBTC Uniswap drain, lendf.me hack | + +### Admin Controls + +| Token | Behavior | Real Examples | Impact | +|-------|----------|---------------|--------| +| **BlockList** | Admin can block addresses from transfers | USDC, USDT | Funds can be frozen in contracts | +| **Pausable** | Admin can pause all transfers | Binance Coin (BNB), Zilliqa (ZIL) | All transfers can be halted | + +### Transfer Quirks + +| Token | Behavior | Real Examples | Impact | +|-------|----------|---------------|--------| +| **RevertZero** | Reverts on zero-value transfers | Aave (LEND) | Breaks contracts that may send zero | +| **RevertToZero** | Reverts on transfer to `address(0)` | Most OpenZeppelin tokens | Can't burn via transfer to zero | +| **NoRevert** | Returns `false` instead of reverting | ZRX, EURS | Must check return value explicitly | +| **TransferFromSelf** | Doesn't decrease allowance if `from == msg.sender` | DSToken (DAI), WETH9 | Different semantics for self-transfers | +| **TransferMax** | Transfers full balance if `amount == type(uint256).max` | Compound v3 USDC | Amount parameter has special meaning | + +### Approval Quirks + +| Token | Behavior | Real Examples | Impact | +|-------|----------|---------------|--------| +| **ApprovalRaceProtection** | Can't change non-zero allowance to different non-zero value | USDT, KNC | Must set to zero first | +| **ApprovalToZeroAddress** | Reverts on `approve(address(0), amount)` | Most OpenZeppelin tokens | Can't use zero address to clear | +| **RevertZeroApproval** | Reverts on `approve(spender, 0)` | Binance Coin (BNB) | Can't clear allowance with zero | +| **Uint96** | Reverts if amount >= 2^96 | Uniswap (UNI), Compound (COMP) | Limited to uint96 range | + +### Metadata Quirks + +| Token | Behavior | Real Examples | Impact | +|-------|----------|---------------|--------| +| **Bytes32Metadata** | `name` and `symbol` are `bytes32` not `string` | MakerDAO (MKR) | String decoders will fail | +| **LowDecimals** | Only 6 decimals (vs standard 18) | USDC, Gemini USD (2) | Precision loss in calculations | +| **HighDecimals** | 24 decimals (vs standard 18) | YAM-V2 | May cause overflows | + +### Permit Issues + +| Token | Behavior | Real Examples | Impact | Exploits | +|-------|----------|---------------|--------|----------| +| **PermitNoOp** | `permit()` doesn't revert but does nothing | Wrapped Ether (WETH) | Allowance not increased | Multichain hack | + +## Helper Functions + +### Basic Access + +```solidity +// Get all tokens (standard + non-standard) +address[] memory allTokens = edgeCases.all_erc20(); + +// Get only standard-compliant tokens +address[] memory standardTokens = edgeCases.all_erc20_standard(); + +// Get only non-standard tokens +address[] memory weirdTokens = edgeCases.all_erc20_non_standard(); + +// Get specific token by name +address usdt = edgeCases.tokenByName("USDT-like"); +address sta = edgeCases.tokenByName("STA-like"); +``` + +### Categorized Access + +```solidity +// Get tokens by behavior category +address[] memory missingReturns = edgeCases.tokens_missing_return_values(); +address[] memory feeTokens = edgeCases.tokens_with_fee(); +address[] memory reentrant = edgeCases.tokens_reentrant(); +address[] memory adminControlled = edgeCases.tokens_with_admin_controls(); +address[] memory approvalQuirks = edgeCases.tokens_approval_quirks(); +``` + +## Common Testing Patterns + +### Pattern 1: Test Against All Tokens + +```solidity +function test_vaultAccountingCorrect(uint256 amount) public { + address[] memory tokens = edgeCases.all_erc20(); + + for (uint i = 0; i < tokens.length; i++) { + IERC20 token = IERC20(tokens[i]); + + // Setup + token.approve(address(vault), amount); + uint256 vaultBalanceBefore = token.balanceOf(address(vault)); + + // Action + vault.deposit(tokens[i], amount); + + // Verify + uint256 vaultBalanceAfter = token.balanceOf(address(vault)); + uint256 actualReceived = vaultBalanceAfter - vaultBalanceBefore; + + // This will FAIL with fee-on-transfer tokens if vault doesn't check! + assertEq( + vault.balances(address(this), tokens[i]), + actualReceived, // Not amount! + "Vault must track actual received amount" + ); + } +} +``` + +### Pattern 2: Test Specific Edge Cases + +```solidity +function test_poolNoReentrancyExploit() public { + address reentrantToken = edgeCases.tokenByName("ERC777-like"); + + uint256 poolBalanceBefore = IERC20(reentrantToken).balanceOf(address(pool)); + + // Try to exploit with reentrant callback + pool.swap(reentrantToken, 1000e18); + + uint256 poolBalanceAfter = IERC20(reentrantToken).balanceOf(address(pool)); + + // Pool should never lose tokens + assertGte(poolBalanceAfter, poolBalanceBefore, "Reentrant attack succeeded!"); +} +``` + +### Pattern 3: Verify Explicit Rejections + +```solidity +function test_vaultRejectsDangerousTokens() public { + // Vault with allowlist should reject problematic tokens + address feeToken = edgeCases.tokenByName("TransferFee"); + address reentrantToken = edgeCases.tokenByName("Reentrant"); + + // These should revert + try vault.addToken(feeToken) { + assertWithMsg(false, "Vault should reject fee-on-transfer tokens"); + } catch {} + + try vault.addToken(reentrantToken) { + assertWithMsg(false, "Vault should reject reentrant tokens"); + } catch {} +} +``` + +## Real-World Exploits Prevented + +This helper would have caught: + +- **Balancer STA Exploit (2020)**: $500k drained due to fee-on-transfer tokens + - Use `TransferFee` token to test +- **imBTC Uniswap Drain**: Reentrancy via ERC777 hooks + - Use `Reentrant` token to test +- **Multichain Hack**: Assumed permit succeeded without checking allowance + - Use `PermitNoOp` token to test +- **Numerous integration bugs**: Missing return value handling, approval race conditions, etc. + +## Integration with Echidna/Medusa + +```bash +# Run fuzzer with edge case tests +echidna . --contract TestProtocolWithEdgeCases --config echidna-config.yaml +``` + +Example `echidna-config.yaml`: +```yaml +testMode: assertion +deployer: "0x10000" +``` + +## Best Practices + +1. **Always test with `all_erc20()`** - Don't assume standard behavior +2. **Check actual received amounts** - Use balance differencing, not transfer amounts +3. **Use SafeERC20** - Or implement similar checks for return values +4. **Verify explicit behavior** - Test that dangerous tokens are properly rejected +5. **Test reentrancy** - Especially for tokens with callbacks + +## Additional Resources + +- [Trail of Bits Token Integration Checklist](https://github.com/crytic/building-secure-contracts/blob/master/development-guidelines/token_integration.md) +- [weird-erc20 Repository](https://github.com/d-xo/weird-erc20) +- [Consensys Diligence Token Integration Checklist](https://consensys.github.io/smart-contract-best-practices/tokens/) + +## See Also + +- `contracts/ERC20/` - Properties for testing token implementations +- `contracts/util/PropertiesHelper.sol` - Assertion helpers +- `tests/` - Example test harnesses diff --git a/contracts/util/erc20/tokens/ApprovalRaceProtection.sol b/contracts/util/erc20/tokens/ApprovalRaceProtection.sol new file mode 100644 index 0000000..5664e7d --- /dev/null +++ b/contracts/util/erc20/tokens/ApprovalRaceProtection.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Approval Race Protection Token + * @notice ERC20 token that prevents changing non-zero allowances to non-zero values + * @dev Mimics tokens like USDT, KNC that protect against approval race conditions + * @custom:example-tokens Tether (USDT), Kyber Network (KNC) + * @custom:impact Must set allowance to zero before changing to a new non-zero value + * @custom:see https://github.com/d-xo/weird-erc20#approval-race-protections + */ +contract ApprovalRaceProtection { + string public name = "Approval Race Protection Token"; + string public symbol = "RACE"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + // Cannot change non-zero allowance to a different non-zero value + // Must first set to zero, then set to new value + require( + allowance[msg.sender][spender] == 0 || amount == 0, + "Must reset allowance to zero first" + ); + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/ApprovalToZeroAddress.sol b/contracts/util/erc20/tokens/ApprovalToZeroAddress.sol new file mode 100644 index 0000000..9a5639e --- /dev/null +++ b/contracts/util/erc20/tokens/ApprovalToZeroAddress.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Approval To Zero Address Token + * @notice ERC20 token that reverts when approving the zero address + * @dev Mimics OpenZeppelin tokens that reject zero address approvals + * @custom:example-tokens Most OpenZeppelin-based tokens + * @custom:impact Breaks contracts that try to clear allowances via approve(address(0), 0) + * @custom:see https://github.com/d-xo/weird-erc20#revert-on-approval-to-zero-address + */ +contract ApprovalToZeroAddress { + string public name = "Approval To Zero Token"; + string public symbol = "AZERO"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + require(spender != address(0), "Approve to zero address"); + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/BlockList.sol b/contracts/util/erc20/tokens/BlockList.sol new file mode 100644 index 0000000..f4e8f7d --- /dev/null +++ b/contracts/util/erc20/tokens/BlockList.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title BlockList Token + * @notice ERC20 token with admin-controlled address blocklist + * @dev Mimics tokens like USDC, USDT that can block addresses + * @custom:example-tokens USDC, USDT + * @custom:impact Admin can freeze funds in contracts at any time + * @custom:risk Regulatory action, compromised admin, or extortion + * @custom:see https://github.com/d-xo/weird-erc20#tokens-with-blocklists + */ +contract BlockList { + string public name = "BlockList Token"; + string public symbol = "BLOCK"; + uint8 public decimals = 18; + uint256 public totalSupply; + + address public admin; + mapping(address => bool) public isBlocked; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + event AddressBlocked(address indexed account); + event AddressUnblocked(address indexed account); + + modifier notBlocked(address account) { + require(!isBlocked[account], "Address is blocked"); + _; + } + + constructor() { + admin = msg.sender; + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function blockAddress(address account) external { + require(msg.sender == admin, "Only admin"); + isBlocked[account] = true; + emit AddressBlocked(account); + } + + function unblockAddress(address account) external { + require(msg.sender == admin, "Only admin"); + isBlocked[account] = false; + emit AddressUnblocked(account); + } + + function transfer(address to, uint256 amount) + public + notBlocked(msg.sender) + notBlocked(to) + returns (bool) + { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) + public + notBlocked(from) + notBlocked(to) + returns (bool) + { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/Bytes32Metadata.sol b/contracts/util/erc20/tokens/Bytes32Metadata.sol new file mode 100644 index 0000000..da1ac40 --- /dev/null +++ b/contracts/util/erc20/tokens/Bytes32Metadata.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Bytes32 Metadata Token + * @notice ERC20 token that uses bytes32 for name and symbol instead of string + * @dev Mimics tokens like MKR that store metadata as bytes32 for gas efficiency + * @custom:example-tokens MakerDAO (MKR), SAI + * @custom:impact Contracts expecting string metadata will fail to decode + * @custom:see https://github.com/d-xo/weird-erc20#bytes32-instead-of-string + */ +contract Bytes32Metadata { + bytes32 public name = "Bytes32 Metadata Token"; + bytes32 public symbol = "B32"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/HighDecimals.sol b/contracts/util/erc20/tokens/HighDecimals.sol new file mode 100644 index 0000000..74e31dd --- /dev/null +++ b/contracts/util/erc20/tokens/HighDecimals.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title High Decimals Token + * @notice ERC20 token with more than 18 decimals + * @dev Mimics tokens like YAM-V2 that have 24 decimals + * @custom:example-tokens YAM-V2 (24 decimals) + * @custom:impact May cause overflow in calculations expecting 18 decimals + * @custom:see https://github.com/d-xo/weird-erc20#high-decimals + */ +contract HighDecimals { + string public name = "High Decimals Token"; + string public symbol = "HIGH"; + uint8 public decimals = 24; // More than standard 18 + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e24; // Note: 24 decimals + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/LowDecimals.sol b/contracts/util/erc20/tokens/LowDecimals.sol new file mode 100644 index 0000000..d18e929 --- /dev/null +++ b/contracts/util/erc20/tokens/LowDecimals.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Low Decimals Token + * @notice ERC20 token with only 6 decimals (vs standard 18) + * @dev Mimics tokens like USDC (6 decimals) or Gemini USD (2 decimals) + * @custom:example-tokens USDC (6), Gemini USD (2) + * @custom:impact Larger precision loss in calculations + * @custom:see https://github.com/d-xo/weird-erc20#low-decimals + */ +contract LowDecimals { + string public name = "Low Decimals Token"; + string public symbol = "LOW"; + uint8 public decimals = 6; // USDC-like decimals + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e6; // Note: 6 decimals + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/MissingReturns.sol b/contracts/util/erc20/tokens/MissingReturns.sol new file mode 100644 index 0000000..7fc6eb9 --- /dev/null +++ b/contracts/util/erc20/tokens/MissingReturns.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Missing Returns Token + * @notice ERC20 token that does not return bool from transfer functions + * @dev Mimics tokens like USDT, BNB, OMG that have no return values + * @custom:example-tokens USDT, BNB, OMG + * @custom:impact Contracts expecting return values will fail when decoding + * @custom:see https://github.com/d-xo/weird-erc20#missing-return-values + */ +contract MissingReturns { + string public name = "Missing Returns Token"; + string public symbol = "MISS"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + // NO RETURN VALUE - violates ERC20 standard + function transfer(address to, uint256 amount) public { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + } + + // NO RETURN VALUE - violates ERC20 standard + function transferFrom(address from, address to, uint256 amount) public { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/NoRevert.sol b/contracts/util/erc20/tokens/NoRevert.sol new file mode 100644 index 0000000..2c5111a --- /dev/null +++ b/contracts/util/erc20/tokens/NoRevert.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title No Revert Token + * @notice ERC20 token that returns false instead of reverting on failure + * @dev Mimics tokens like ZRX, EURS that don't revert on failure + * @custom:example-tokens 0x Protocol Token (ZRX), EURS + * @custom:impact Requires explicit check of return value, easily overlooked + * @custom:see https://github.com/d-xo/weird-erc20#no-revert-on-failure + */ +contract NoRevert { + string public name = "No Revert Token"; + string public symbol = "NOREV"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + // Returns false instead of reverting on insufficient balance + function transfer(address to, uint256 amount) public returns (bool) { + if (balanceOf[msg.sender] < amount) { + return false; // No revert! + } + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + // Returns false instead of reverting on insufficient balance/allowance + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + if (balanceOf[from] < amount || allowance[from][msg.sender] < amount) { + return false; // No revert! + } + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/Pausable.sol b/contracts/util/erc20/tokens/Pausable.sol new file mode 100644 index 0000000..2b0407f --- /dev/null +++ b/contracts/util/erc20/tokens/Pausable.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Pausable Token + * @notice ERC20 token that can be paused by an admin + * @dev Mimics tokens like BNB, ZIL that have pause functionality + * @custom:example-tokens Binance Coin (BNB), Zilliqa (ZIL) + * @custom:impact Admin can freeze all transfers at any time + * @custom:risk Malicious or compromised admin can trap user funds + * @custom:see https://github.com/d-xo/weird-erc20#pausable-tokens + */ +contract Pausable { + string public name = "Pausable Token"; + string public symbol = "PAUSE"; + uint8 public decimals = 18; + uint256 public totalSupply; + + address public admin; + bool public paused; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + event Paused(); + event Unpaused(); + + modifier notPaused() { + require(!paused, "Token is paused"); + _; + } + + constructor() { + admin = msg.sender; + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function pause() external { + require(msg.sender == admin, "Only admin"); + paused = true; + emit Paused(); + } + + function unpause() external { + require(msg.sender == admin, "Only admin"); + paused = false; + emit Unpaused(); + } + + function transfer(address to, uint256 amount) public notPaused returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public notPaused returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/PermitNoOp.sol b/contracts/util/erc20/tokens/PermitNoOp.sol new file mode 100644 index 0000000..007a137 --- /dev/null +++ b/contracts/util/erc20/tokens/PermitNoOp.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Permit No-Op Token + * @notice ERC20 token with a permit function that doesn't revert but does nothing + * @dev Mimics tokens like WETH that have a fallback accepting permit calls + * @custom:example-tokens Wrapped Ether (WETH) + * @custom:impact Permit doesn't increase allowance, breaks integrations expecting EIP-2612 + * @custom:exploit Multichain hack - assumed permit succeeded without checking allowance + * @custom:see https://github.com/d-xo/weird-erc20#tokens-with-permit-function + */ +contract PermitNoOp { + string public name = "Permit NoOp Token"; + string public symbol = "PNOOP"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + // Permit function that accepts calls but doesn't do anything + // Similar to WETH's fallback function + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + // Does nothing! No revert, no allowance increase + // This is the dangerous behavior + } +} diff --git a/contracts/util/erc20/tokens/Reentrant.sol b/contracts/util/erc20/tokens/Reentrant.sol new file mode 100644 index 0000000..1bade3a --- /dev/null +++ b/contracts/util/erc20/tokens/Reentrant.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Reentrant Token + * @notice ERC20 token with ERC777-style hooks that enable reentrancy + * @dev Mimics tokens like AMP, imBTC that call back to receiver on transfer + * @custom:example-tokens Amp (AMP), Tokenized Bitcoin (imBTC) + * @custom:impact Allows reentrancy attacks during transfers + * @custom:exploit Used to drain imBTC Uniswap pool and lendf.me + * @custom:see https://github.com/d-xo/weird-erc20#reentrant-calls + */ +contract Reentrant { + string public name = "Reentrant Token"; + string public symbol = "REENT"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + + // ERC777-style hook - calls back to receiver + _callTokensReceived(msg.sender, to, amount); + + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + + // ERC777-style hook - calls back to receiver + _callTokensReceived(from, to, amount); + + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + // Hook that enables reentrancy + function _callTokensReceived(address from, address to, uint256 amount) internal { + if (to.code.length > 0) { + // Call tokensReceived on the recipient if it's a contract + // This enables reentrancy attacks + (bool success, ) = to.call( + abi.encodeWithSignature( + "tokensReceived(address,address,uint256)", + from, + to, + amount + ) + ); + // Ignore failures to maintain ERC20 compatibility + } + } +} diff --git a/contracts/util/erc20/tokens/ReturnsFalse.sol b/contracts/util/erc20/tokens/ReturnsFalse.sol new file mode 100644 index 0000000..c1a8c06 --- /dev/null +++ b/contracts/util/erc20/tokens/ReturnsFalse.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Returns False Token + * @notice ERC20 token that returns false even on successful transfers + * @dev Mimics tokens like Tether Gold that return false despite success + * @custom:example-tokens Tether Gold (XAUT) + * @custom:impact Makes it impossible to correctly handle return values for all tokens + * @custom:see https://github.com/d-xo/weird-erc20#missing-return-values + */ +contract ReturnsFalse { + string public name = "Returns False Token"; + string public symbol = "FALSE"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + // Returns false even though transfer succeeds + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return false; // Returns false despite successful transfer! + } + + // Returns false even though transfer succeeds + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return false; // Returns false despite successful transfer! + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return false; // Returns false despite successful approval! + } +} diff --git a/contracts/util/erc20/tokens/RevertToZero.sol b/contracts/util/erc20/tokens/RevertToZero.sol new file mode 100644 index 0000000..7a02a65 --- /dev/null +++ b/contracts/util/erc20/tokens/RevertToZero.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Revert to Zero Address Token + * @notice ERC20 token that reverts when transferring to address(0) + * @dev Mimics tokens like OpenZeppelin's ERC20 that prevent burning via transfer + * @custom:example-tokens Most OpenZeppelin-based tokens + * @custom:impact Breaks contracts that may transfer to zero address as a burn mechanism + * @custom:see https://github.com/d-xo/weird-erc20#revert-on-zero-address-transfers + */ +contract RevertToZero { + string public name = "Revert to Zero Token"; + string public symbol = "RTZ"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(to != address(0), "Cannot transfer to zero address"); + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(to != address(0), "Cannot transfer to zero address"); + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/RevertZero.sol b/contracts/util/erc20/tokens/RevertZero.sol new file mode 100644 index 0000000..428f163 --- /dev/null +++ b/contracts/util/erc20/tokens/RevertZero.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Revert on Zero Transfer Token + * @notice ERC20 token that reverts when transferring zero value + * @dev Mimics tokens like LEND that reject zero-value transfers + * @custom:example-tokens Aave (LEND) + * @custom:impact Breaks contracts that may send zero-value transfers + * @custom:see https://github.com/d-xo/weird-erc20#revert-on-zero-value-transfers + */ +contract RevertZero { + string public name = "Revert Zero Token"; + string public symbol = "ZERO"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(amount > 0, "Cannot transfer zero"); + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(amount > 0, "Cannot transfer zero"); + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/RevertZeroApproval.sol b/contracts/util/erc20/tokens/RevertZeroApproval.sol new file mode 100644 index 0000000..66484d6 --- /dev/null +++ b/contracts/util/erc20/tokens/RevertZeroApproval.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Revert on Zero Approval Token + * @notice ERC20 token that reverts when approving zero value + * @dev Mimics tokens like BNB that reject zero-value approvals + * @custom:example-tokens Binance Coin (BNB) + * @custom:impact Breaks contracts that clear allowances via approve(spender, 0) + * @custom:see https://github.com/d-xo/weird-erc20#revert-on-zero-value-approvals + */ +contract RevertZeroApproval { + string public name = "Revert Zero Approval Token"; + string public symbol = "RZAPP"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + require(amount != 0, "Cannot approve zero"); + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/StandardERC20.sol b/contracts/util/erc20/tokens/StandardERC20.sol new file mode 100644 index 0000000..0d22a30 --- /dev/null +++ b/contracts/util/erc20/tokens/StandardERC20.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Standard ERC20 Token + * @notice A compliant ERC20 implementation following the standard + * @dev This serves as a baseline for comparison with non-standard tokens + * @custom:example-tokens Most well-behaved tokens + */ +contract StandardERC20 { + string public name = "Standard ERC20"; + string public symbol = "STD"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/TransferFee.sol b/contracts/util/erc20/tokens/TransferFee.sol new file mode 100644 index 0000000..e6a759c --- /dev/null +++ b/contracts/util/erc20/tokens/TransferFee.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Transfer Fee Token + * @notice ERC20 token that charges a fee on every transfer + * @dev Mimics tokens like STA, PAXG that deduct fees from transfers + * @custom:example-tokens Statera (STA), Paxos Gold (PAXG) + * @custom:impact Receiver gets less than the transfer amount + * @custom:exploit Used to drain $500k from Balancer pools + * @custom:see https://github.com/d-xo/weird-erc20#fee-on-transfer + */ +contract TransferFee { + string public name = "Transfer Fee Token"; + string public symbol = "FEE"; + uint8 public decimals = 18; + uint256 public totalSupply; + uint256 public feePercentage; // in basis points (100 = 1%) + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(uint256 _feePercentage) { + feePercentage = _feePercentage; // Default 100 = 1% fee + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + + // Calculate fee + uint256 fee = (amount * feePercentage) / 10000; + uint256 amountAfterFee = amount - fee; + + balanceOf[msg.sender] -= amount; + balanceOf[to] += amountAfterFee; // Receiver gets LESS than amount + // Fee is burned + totalSupply -= fee; + + emit Transfer(msg.sender, to, amountAfterFee); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + + // Calculate fee + uint256 fee = (amount * feePercentage) / 10000; + uint256 amountAfterFee = amount - fee; + + balanceOf[from] -= amount; + balanceOf[to] += amountAfterFee; // Receiver gets LESS than amount + allowance[from][msg.sender] -= amount; + // Fee is burned + totalSupply -= fee; + + emit Transfer(from, to, amountAfterFee); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/TransferFromSelf.sol b/contracts/util/erc20/tokens/TransferFromSelf.sol new file mode 100644 index 0000000..c66e2ad --- /dev/null +++ b/contracts/util/erc20/tokens/TransferFromSelf.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title TransferFrom Self Token + * @notice ERC20 token that doesn't decrease allowance when sender transfers their own tokens + * @dev Mimics tokens like DSToken where transferFrom doesn't use allowance if from == msg.sender + * @custom:example-tokens DSToken (DAI), WETH9 + * @custom:impact Allowance is not decreased when owner transfers their own tokens via transferFrom + * @custom:see https://github.com/d-xo/weird-erc20#no-allowance-decrease-when-transferring-own-tokens + */ +contract TransferFromSelf { + string public name = "TransferFrom Self Token"; + string public symbol = "TFS"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + + // Only check and decrease allowance if sender is not the owner + if (from != msg.sender) { + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + allowance[from][msg.sender] -= amount; + } + + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/TransferMax.sol b/contracts/util/erc20/tokens/TransferMax.sol new file mode 100644 index 0000000..0493773 --- /dev/null +++ b/contracts/util/erc20/tokens/TransferMax.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Transfer Max Token + * @notice ERC20 token that transfers full balance when amount is type(uint256).max + * @dev Mimics tokens like cUSDCv3 that treat max uint as "transfer all" + * @custom:example-tokens Compound v3 USDC (cUSDCv3) + * @custom:impact Amount of type(uint256).max transfers entire balance instead of literal value + * @custom:see https://github.com/d-xo/weird-erc20#transfer-of-uint256max + */ +contract TransferMax { + string public name = "Transfer Max Token"; + string public symbol = "TMAX"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = totalSupply; + } + + function transfer(address to, uint256 amount) public returns (bool) { + // If amount is max uint, transfer entire balance + uint256 actualAmount = amount == type(uint256).max ? balanceOf[msg.sender] : amount; + + require(balanceOf[msg.sender] >= actualAmount, "Insufficient balance"); + balanceOf[msg.sender] -= actualAmount; + balanceOf[to] += actualAmount; + emit Transfer(msg.sender, to, actualAmount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + // If amount is max uint, transfer entire balance + uint256 actualAmount = amount == type(uint256).max ? balanceOf[from] : amount; + + require(balanceOf[from] >= actualAmount, "Insufficient balance"); + require(allowance[from][msg.sender] >= actualAmount, "Insufficient allowance"); + balanceOf[from] -= actualAmount; + balanceOf[to] += actualAmount; + allowance[from][msg.sender] -= actualAmount; + emit Transfer(from, to, actualAmount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } +} diff --git a/contracts/util/erc20/tokens/Uint96.sol b/contracts/util/erc20/tokens/Uint96.sol new file mode 100644 index 0000000..ab0ee15 --- /dev/null +++ b/contracts/util/erc20/tokens/Uint96.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +/** + * @title Uint96 Token + * @notice ERC20 token that reverts on large approvals/transfers (>= 2^96) + * @dev Mimics tokens like UNI, COMP that use uint96 for amounts + * @custom:example-tokens Uniswap (UNI), Compound (COMP) + * @custom:impact Reverts on large amounts, special case for type(uint256).max + * @custom:see https://github.com/d-xo/weird-erc20#revert-on-large-approvals--transfers + */ +contract Uint96 { + string public name = "Uint96 Token"; + string public symbol = "U96"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint96) public balanceOf; + mapping(address => mapping(address => uint96)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000000e18; + balanceOf[msg.sender] = uint96(totalSupply); + } + + function transfer(address to, uint256 amount) public returns (bool) { + // Reverts if amount >= 2^96 + uint96 amount96 = _safe96(amount, "Amount exceeds 96 bits"); + require(balanceOf[msg.sender] >= amount96, "Insufficient balance"); + balanceOf[msg.sender] -= amount96; + balanceOf[to] += amount96; + emit Transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) public returns (bool) { + // Reverts if amount >= 2^96 + uint96 amount96 = _safe96(amount, "Amount exceeds 96 bits"); + require(balanceOf[from] >= amount96, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount96, "Insufficient allowance"); + balanceOf[from] -= amount96; + balanceOf[to] += amount96; + allowance[from][msg.sender] -= amount96; + emit Transfer(from, to, amount); + return true; + } + + function approve(address spender, uint256 amount) public returns (bool) { + // Special case: uint256(-1) sets allowance to type(uint96).max + uint96 amount96; + if (amount == type(uint256).max) { + amount96 = type(uint96).max; + } else { + amount96 = _safe96(amount, "Amount exceeds 96 bits"); + } + allowance[msg.sender][spender] = amount96; + emit Approval(msg.sender, spender, amount96); + return true; + } + + function _safe96(uint256 n, string memory errorMessage) internal pure returns (uint96) { + require(n < 2**96, errorMessage); + return uint96(n); + } +} diff --git a/tests/ERC20EdgeCases/ExampleVaultTest.sol b/tests/ERC20EdgeCases/ExampleVaultTest.sol new file mode 100644 index 0000000..5b78ece --- /dev/null +++ b/tests/ERC20EdgeCases/ExampleVaultTest.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +import "../../contracts/util/erc20/ERC20EdgeCases.sol"; +import "../../contracts/util/PropertiesAsserts.sol"; + +/** + * @title Example Vault Test + * @notice Example showing how to test a vault protocol with ERC20 edge cases + * @dev This is an educational example demonstrating proper testing patterns + * + * Run with Echidna: + * echidna . --contract ExampleVaultTest --config echidna-config.yaml + */ +contract ExampleVaultTest is PropertiesAsserts { + ERC20EdgeCases edgeCases; + SimpleVault vault; + + constructor() { + edgeCases = new ERC20EdgeCases(); + vault = new SimpleVault(); + + // Give this contract some tokens to test with + address[] memory tokens = edgeCases.all_erc20(); + for (uint i = 0; i < tokens.length; i++) { + // Transfer some tokens to this contract for testing + // (They're minted to the deployer in the edge case constructor) + } + } + + /** + * @notice Test vault accounting is correct with ALL token types + * @dev This will catch fee-on-transfer bugs that drained Balancer + */ + function test_vault_accountingCorrectAllTokens(uint256 amount) public { + address[] memory tokens = edgeCases.all_erc20(); + + for (uint i = 0; i < tokens.length; i++) { + _testVaultAccounting(tokens[i], amount); + } + } + + /** + * @notice Test vault doesn't lose funds with fee-on-transfer tokens + * @dev The Balancer STA exploit ($500k) could have been caught with this + */ + function test_vault_feeOnTransferTokens(uint256 amount) public { + address feeToken = edgeCases.tokenByName("TransferFee"); + require(amount > 0 && amount < 1000e18); + + IERC20 token = IERC20(feeToken); + uint256 userBalanceBefore = token.balanceOf(address(this)); + require(userBalanceBefore >= amount); + + // Approve and deposit + token.approve(address(vault), amount); + uint256 vaultBalanceBefore = token.balanceOf(address(vault)); + + vault.deposit(feeToken, amount); + + // Check vault received correct amount (NOT the transfer amount!) + uint256 vaultBalanceAfter = token.balanceOf(address(vault)); + uint256 actualReceived = vaultBalanceAfter - vaultBalanceBefore; + + // Vault MUST track actualReceived, not amount + assertEq( + vault.getUserBalance(address(this), feeToken), + actualReceived, + "Vault must track actual received amount with fee tokens" + ); + } + + /** + * @notice Test vault isn't vulnerable to reentrancy + * @dev The imBTC Uniswap drain could have been caught with this + */ + function test_vault_noReentrancyExploit(uint256 amount) public { + address reentrantToken = edgeCases.tokenByName("Reentrant"); + require(amount > 0 && amount < 1000e18); + + IERC20 token = IERC20(reentrantToken); + uint256 vaultBalanceBefore = token.balanceOf(address(vault)); + + // Try to exploit with reentrant callback + token.approve(address(vault), amount); + vault.deposit(reentrantToken, amount); + + uint256 vaultBalanceAfter = token.balanceOf(address(vault)); + + // Vault should never have less tokens than before + assertGte( + vaultBalanceAfter, + vaultBalanceBefore, + "Reentrant token drained vault!" + ); + } + + /** + * @notice Test vault handles tokens with missing return values + * @dev USDT, BNB, OMG don't return bool from transfer + */ + function test_vault_missingReturnValues(uint256 amount) public { + address[] memory tokens = edgeCases.tokens_missing_return_values(); + + for (uint i = 0; i < tokens.length; i++) { + // Vault should handle these tokens correctly + // (Use SafeERC20 or similar) + _testVaultAccounting(tokens[i], amount); + } + } + + /** + * @notice Test vault handles tokens with approval quirks + * @dev USDT race protection, BNB zero approval revert, etc. + */ + function test_vault_approvalQuirks() public { + address[] memory tokens = edgeCases.tokens_approval_quirks(); + + for (uint i = 0; i < tokens.length; i++) { + IERC20 token = IERC20(tokens[i]); + + // Try to approve, then change approval + // Some tokens require setting to zero first + token.approve(address(vault), 100e18); + + // This may fail with some tokens - vault must handle it + try token.approve(address(vault), 200e18) { + // Success + } catch { + // Failed - set to zero first + token.approve(address(vault), 0); + token.approve(address(vault), 200e18); + } + } + } + + /** + * @notice Helper function to test vault accounting + */ + function _testVaultAccounting(address tokenAddress, uint256 amount) internal { + IERC20 token = IERC20(tokenAddress); + uint256 userBalance = token.balanceOf(address(this)); + + if (userBalance == 0 || amount == 0) return; + if (amount > userBalance) amount = userBalance; + + // Get balances before + uint256 vaultBalanceBefore = token.balanceOf(address(vault)); + uint256 userVaultBalanceBefore = vault.getUserBalance(address(this), tokenAddress); + + // Approve and deposit + token.approve(address(vault), amount); + vault.deposit(tokenAddress, amount); + + // Check balances after + uint256 vaultBalanceAfter = token.balanceOf(address(vault)); + uint256 actualReceived = vaultBalanceAfter - vaultBalanceBefore; + + // Vault must track actual received amount + assertEq( + vault.getUserBalance(address(this), tokenAddress), + userVaultBalanceBefore + actualReceived, + "Vault accounting incorrect" + ); + } +} + +/** + * @title Simple Vault + * @notice Example vault implementation for testing + * @dev This vault demonstrates CORRECT handling of edge case tokens + */ +contract SimpleVault { + mapping(address => mapping(address => uint256)) public userBalances; + + event Deposit(address indexed user, address indexed token, uint256 amount); + event Withdraw(address indexed user, address indexed token, uint256 amount); + + /** + * @notice Deposit tokens into vault + * @dev Correctly handles fee-on-transfer tokens by checking actual received amount + */ + function deposit(address token, uint256 amount) external { + require(amount > 0, "Cannot deposit zero"); + + // Check actual received amount (handles fee-on-transfer tokens) + uint256 balanceBefore = IERC20(token).balanceOf(address(this)); + + // Use SafeTransferFrom to handle tokens with missing return values + _safeTransferFrom(token, msg.sender, address(this), amount); + + uint256 balanceAfter = IERC20(token).balanceOf(address(this)); + uint256 actualReceived = balanceAfter - balanceBefore; + + // Credit user with actual received amount, not transfer amount + userBalances[msg.sender][token] += actualReceived; + + emit Deposit(msg.sender, token, actualReceived); + } + + /** + * @notice Withdraw tokens from vault + */ + function withdraw(address token, uint256 amount) external { + require(userBalances[msg.sender][token] >= amount, "Insufficient balance"); + + userBalances[msg.sender][token] -= amount; + + _safeTransfer(token, msg.sender, amount); + + emit Withdraw(msg.sender, token, amount); + } + + /** + * @notice Get user balance for a token + */ + function getUserBalance(address user, address token) external view returns (uint256) { + return userBalances[user][token]; + } + + /** + * @notice Safe transferFrom that handles tokens with missing return values + * @dev Based on OpenZeppelin's SafeERC20 + */ + function _safeTransferFrom(address token, address from, address to, uint256 amount) internal { + (bool success, bytes memory data) = token.call( + abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, amount) + ); + + require( + success && (data.length == 0 || abi.decode(data, (bool))), + "TransferFrom failed" + ); + } + + /** + * @notice Safe transfer that handles tokens with missing return values + */ + function _safeTransfer(address token, address to, uint256 amount) internal { + (bool success, bytes memory data) = token.call( + abi.encodeWithSelector(IERC20.transfer.selector, to, amount) + ); + + require( + success && (data.length == 0 || abi.decode(data, (bool))), + "Transfer failed" + ); + } +} + +/** + * @dev Minimal IERC20 interface for testing + */ +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); +}