$npx -y skills add quillai-network/quillshield_skills --skill external-call-safetyDetects unsafe external call patterns and token integration vulnerabilities in smart contracts. Covers unchecked call/delegatecall/staticcall return values, fee-on-transfer tokens, rebasing tokens, tokens with missing return values (USDT), ERC-777 callback risks, unsafe approve r
| 1 | # External Call Safety |
| 2 | |
| 3 | Detect vulnerabilities arising from **unsafe interactions with external contracts** and **non-standard token behaviors** that break protocol assumptions. Covers OWASP SC06 (Unchecked External Calls) plus the entire "weird ERC20" problem space. |
| 4 | |
| 5 | ## When to Use |
| 6 | |
| 7 | - Auditing any contract that calls external contracts (token transfers, cross-contract interactions) |
| 8 | - Reviewing protocols that support arbitrary/user-supplied ERC20 tokens |
| 9 | - Analyzing ETH payment distribution logic (airdrops, reward distribution, refunds) |
| 10 | - Verifying low-level call safety (`call`, `delegatecall`, `staticcall`) |
| 11 | - When a protocol claims to support "any ERC20 token" |
| 12 | |
| 13 | ## When NOT to Use |
| 14 | |
| 15 | - Reentrancy-specific analysis (use reentrancy-pattern-analysis — though there is overlap) |
| 16 | - Oracle/price feed analysis (use oracle-flashloan-analysis) |
| 17 | - Pure access control review (use semantic-guard-analysis) |
| 18 | |
| 19 | ## Part 1: External Call Safety |
| 20 | |
| 21 | ### Vulnerability Class 1: Unchecked Return Values |
| 22 | |
| 23 | Low-level calls (`call`, `delegatecall`, `staticcall`) return a boolean indicating success. If unchecked, failed calls are silently ignored. |
| 24 | |
| 25 | ```solidity |
| 26 | // VULNERABLE: Return value not checked |
| 27 | function withdraw(uint256 amount) external { |
| 28 | balances[msg.sender] -= amount; |
| 29 | payable(msg.sender).call{value: amount}(""); // Can fail silently! |
| 30 | // User's balance decreased but ETH not sent |
| 31 | } |
| 32 | |
| 33 | // SAFE: Check return value |
| 34 | function withdraw(uint256 amount) external { |
| 35 | balances[msg.sender] -= amount; |
| 36 | (bool success, ) = payable(msg.sender).call{value: amount}(""); |
| 37 | require(success, "Transfer failed"); |
| 38 | } |
| 39 | ``` |
| 40 | |
| 41 | **Detection Algorithm:** |
| 42 | |
| 43 | ``` |
| 44 | For each low-level call expression: |
| 45 | 1. Is the return value captured? (bool success, bytes memory data) = ... |
| 46 | 2. Is the success boolean checked? require(success) or if(!success) revert |
| 47 | 3. If not captured or not checked → UNCHECKED RETURN VALUE |
| 48 | |
| 49 | Severity: |
| 50 | - ETH transfer unchecked → CRITICAL (funds lost) |
| 51 | - Token operation unchecked → HIGH (state desync) |
| 52 | - Non-financial call unchecked → MEDIUM |
| 53 | ``` |
| 54 | |
| 55 | ### Vulnerability Class 2: Gas Stipend Limitations |
| 56 | |
| 57 | ```solidity |
| 58 | // DANGEROUS: transfer() and send() forward only 2300 gas |
| 59 | payable(recipient).transfer(amount); // Reverts if recipient needs > 2300 gas |
| 60 | payable(recipient).send(amount); // Returns false, often unchecked |
| 61 | |
| 62 | // SAFE: Use call() with gas |
| 63 | (bool success, ) = payable(recipient).call{value: amount}(""); |
| 64 | require(success, "Transfer failed"); |
| 65 | ``` |
| 66 | |
| 67 | **Why 2300 gas is dangerous:** |
| 68 | - Contracts with `receive()` or `fallback()` that do more than emit an event will fail |
| 69 | - EIP-1884 changed `SLOAD` gas cost, breaking some existing contracts |
| 70 | - Multi-sig wallets and smart contract wallets often need more gas |
| 71 | |
| 72 | ### Vulnerability Class 3: Return Data Bomb |
| 73 | |
| 74 | A malicious contract can return extremely large data to consume the caller's gas. |
| 75 | |
| 76 | ```solidity |
| 77 | // Vulnerable to return data bomb |
| 78 | (bool success, bytes memory data) = untrustedContract.call(calldata); |
| 79 | // If untrustedContract returns 1MB of data, copying it costs massive gas |
| 80 | |
| 81 | // SAFE: Limit return data or ignore it |
| 82 | (bool success, ) = untrustedContract.call(calldata); // Ignore return data |
| 83 | // Or use assembly to limit return data size |
| 84 | ``` |
| 85 | |
| 86 | ### Vulnerability Class 4: Delegatecall to Untrusted Contract |
| 87 | |
| 88 | ```solidity |
| 89 | // CRITICAL: delegatecall executes untrusted code in OUR storage context |
| 90 | function execute(address target, bytes calldata data) external { |
| 91 | target.delegatecall(data); // Untrusted code can overwrite ANY storage |
| 92 | } |
| 93 | |
| 94 | // delegatecall should ONLY be used with trusted, immutable targets |
| 95 | ``` |
| 96 | |
| 97 | ## Part 2: Token Integration Safety ("Weird ERC20" Tokens) |
| 98 | |
| 99 | ### Issue 1: Fee-on-Transfer Tokens |
| 100 | |
| 101 | Some tokens deduct a fee during `transfer()` and `transferFrom()`. The recipient receives less than the specified amount. |
| 102 | |
| 103 | ```solidity |
| 104 | // VULNERABLE: Assumes received amount equals input amount |
| 105 | function deposit(uint256 amount) external { |
| 106 | token.transferFrom(msg.sender, address(this), amount); |
| 107 | balances[msg.sender] += amount; // Credits MORE than actually received! |
| 108 | } |
| 109 | |
| 110 | // SAFE: Check actual balance change |
| 111 | function deposit(uint256 amount) external { |
| 112 | uint256 balanceBefore = token.balanceOf(address(this)); |
| 113 | token.transferFrom(msg.sender, address(this), amount); |
| 114 | uint256 balanceAfter = token.balanceOf(address(this)); |
| 115 | uint256 actualReceived = balanceAfter - balanceBefore; |
| 116 | balances[msg.sender] += actualReceived; // Credits actual amount |
| 117 | } |
| 118 | ``` |
| 119 | |
| 120 | ** |