From external-call-safety
Detects unchecked low-level calls, fee-on-transfer/rebasing tokens, ERC-777 risks, USDT issues, approve races in smart contracts. For auditing external interactions and ERC20 integrations.
npx claudepluginhub quillai-network/quillshield_skills --plugin external-call-safetyThis skill uses the workspace's default tool permissions.
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.
Detects reentrancy vulnerabilities in smart contracts including classic, cross-function, cross-contract, and read-only variants. Verifies CEI pattern, builds call graphs, traces state changes around external calls.
Provides Solidity security patterns, common vulnerabilities like reentrancy and decimal mismatches, defensive code fixes, and pre-deploy audit checklists for contracts handling value.
Analyzes token implementations/integrations for ERC20/ERC721 conformity, 20+ weird patterns, owner privileges, scarcity via Slither and on-chain queries.
Share bugs, ideas, or general feedback.
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.
call, delegatecall, staticcall)Low-level calls (call, delegatecall, staticcall) return a boolean indicating success. If unchecked, failed calls are silently ignored.
// VULNERABLE: Return value not checked
function withdraw(uint256 amount) external {
balances[msg.sender] -= amount;
payable(msg.sender).call{value: amount}(""); // Can fail silently!
// User's balance decreased but ETH not sent
}
// SAFE: Check return value
function withdraw(uint256 amount) external {
balances[msg.sender] -= amount;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");
}
Detection Algorithm:
For each low-level call expression:
1. Is the return value captured? (bool success, bytes memory data) = ...
2. Is the success boolean checked? require(success) or if(!success) revert
3. If not captured or not checked → UNCHECKED RETURN VALUE
Severity:
- ETH transfer unchecked → CRITICAL (funds lost)
- Token operation unchecked → HIGH (state desync)
- Non-financial call unchecked → MEDIUM
// DANGEROUS: transfer() and send() forward only 2300 gas
payable(recipient).transfer(amount); // Reverts if recipient needs > 2300 gas
payable(recipient).send(amount); // Returns false, often unchecked
// SAFE: Use call() with gas
(bool success, ) = payable(recipient).call{value: amount}("");
require(success, "Transfer failed");
Why 2300 gas is dangerous:
receive() or fallback() that do more than emit an event will failSLOAD gas cost, breaking some existing contractsA malicious contract can return extremely large data to consume the caller's gas.
// Vulnerable to return data bomb
(bool success, bytes memory data) = untrustedContract.call(calldata);
// If untrustedContract returns 1MB of data, copying it costs massive gas
// SAFE: Limit return data or ignore it
(bool success, ) = untrustedContract.call(calldata); // Ignore return data
// Or use assembly to limit return data size
// CRITICAL: delegatecall executes untrusted code in OUR storage context
function execute(address target, bytes calldata data) external {
target.delegatecall(data); // Untrusted code can overwrite ANY storage
}
// delegatecall should ONLY be used with trusted, immutable targets
Some tokens deduct a fee during transfer() and transferFrom(). The recipient receives less than the specified amount.
// VULNERABLE: Assumes received amount equals input amount
function deposit(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount; // Credits MORE than actually received!
}
// SAFE: Check actual balance change
function deposit(uint256 amount) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.transferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = token.balanceOf(address(this));
uint256 actualReceived = balanceAfter - balanceBefore;
balances[msg.sender] += actualReceived; // Credits actual amount
}
Known fee-on-transfer tokens: STA, PAXG, USDT (fee currently 0 but can be activated), RFI/SAFEMOON forks.
Rebasing tokens change all balances proportionally without transfers. Protocol's accounting desynchronizes from actual balances.
// VULNERABLE: Stores absolute balance amounts
function deposit(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
userDeposit[msg.sender] = amount; // After rebase, actual balance differs!
}
// Mitigation options:
// 1. Store shares instead of amounts
// 2. Wrap rebasing token (wstETH pattern)
// 3. Explicitly state: "rebasing tokens not supported"
Known rebasing tokens: stETH, AMPL, OHM, YAM, BASED.
Some tokens don't return a boolean from transfer()/transferFrom()/approve(), breaking the ERC20 standard.
// VULNERABLE: Assumes return value exists
bool success = token.transfer(recipient, amount); // Reverts if token returns nothing
// SAFE: Use SafeERC20
using SafeERC20 for IERC20;
token.safeTransfer(recipient, amount); // Handles missing return values
Known tokens with missing returns: USDT, BNB, OMG, KNC (legacy versions).
ERC-777 tokens trigger tokensToSend() on the sender and tokensReceived() on the recipient during transfers, enabling reentrancy.
ERC-777 callback hooks:
transfer() → calls tokensReceived() on recipient
transferFrom() → calls tokensToSend() on sender, tokensReceived() on recipient
send() → calls tokensToSend() on sender, tokensReceived() on recipient
ANY of these can re-enter the calling contract!
Cross-reference: See reentrancy-pattern-analysis for detailed ERC-777 reentrancy detection.
// VULNERABLE: Approve race condition
token.approve(spender, newAmount);
// Between the approval TX and the spending TX, the spender can:
// 1. Spend the OLD allowance
// 2. Then spend the NEW allowance
// Total spent: oldAmount + newAmount (double spending)
// SAFE: Reset to zero first, or use increaseAllowance
token.approve(spender, 0); // Reset
token.approve(spender, newAmount); // Set new
// Or use SafeERC20
token.safeIncreaseAllowance(spender, amount);
// ALSO DANGEROUS: Some tokens (USDT) revert on non-zero to non-zero approve
token.approve(spender, newAmount); // REVERTS if current allowance != 0
// MUST reset to 0 first for USDT
Some tokens can blacklist addresses, causing transfers to/from those addresses to revert.
// VULNERABLE: Assumes transfer always succeeds for valid amounts
function distribute(address[] calldata users, uint256[] calldata amounts) external {
for (uint i = 0; i < users.length; i++) {
token.transfer(users[i], amounts[i]); // Reverts if ANY user is blacklisted
// Entire batch fails!
}
}
// SAFE: Handle per-user failures
function distribute(address[] calldata users, uint256[] calldata amounts) external {
for (uint i = 0; i < users.length; i++) {
try IERC20(token).transfer(users[i], amounts[i]) {
// Success
} catch {
// Log failure, skip this user, don't block others
}
}
}
Known blacklist tokens: USDC, USDT, TUSD.
Some tokens have maximum transfer amounts per transaction or maximum holding amounts per address.
// Protocol may assume any amount can be transferred
// But some tokens: require(amount <= maxTransferAmount)
// This can brick protocols that batch large transfers
PUSH (Dangerous):
Contract sends funds TO recipients
- Can fail if recipient is a contract that reverts
- Can be DoS'd by one malicious recipient
- Gas costs unpredictable
PULL (Safe):
Recipients claim funds FROM contract
- Each claim is independent
- One user's failure doesn't affect others
- Gas costs predictable per claim
Detection:
For each function that sends ETH or tokens to external addresses:
If sending to user-supplied addresses in a loop → PUSH pattern
If sending to individual addresses via claim function → PULL pattern
PUSH pattern with untrusted recipients → HIGH risk of DoS
Task Progress:
- [ ] Step 1: Find all external calls (call, delegatecall, staticcall, transfer, send)
- [ ] Step 2: Verify return values are checked for all external calls
- [ ] Step 3: Identify all token interactions and classify token assumptions
- [ ] Step 4: Check for fee-on-transfer compatibility (balance before/after pattern)
- [ ] Step 5: Check for rebasing token compatibility
- [ ] Step 6: Verify SafeERC20 usage for tokens with missing return values
- [ ] Step 7: Check approve patterns for race conditions and USDT compatibility
- [ ] Step 8: Analyze payment distribution pattern (push vs pull)
- [ ] Step 9: Score findings and generate report
## External Call Safety Report
### Finding: [Title]
**Function:** `functionName()` at `Contract.sol:L42`
**Category:** [Unchecked Return | Fee-on-Transfer | Rebasing | Missing Return | Callback | Approve Race | DoS]
**Severity:** [CRITICAL | HIGH | MEDIUM]
**Issue:**
[Description of the unsafe external call or token integration issue]
**Affected Tokens:**
[List of known tokens that trigger this issue, e.g., USDT, USDC, stETH]
**Vulnerable Code:**
[Code snippet]
**Attack Scenario:**
1. [Step-by-step exploitation]
**Recommendation:**
[Use SafeERC20, balance-before-after, pull pattern, etc.]
call return values checked (require(success))?SafeERC20 for all token interactions?approve() reset to 0 before setting new allowance (USDT compatibility)?delegatecall only used with trusted, immutable targets?For weird ERC20 catalog, see {baseDir}/references/weird-erc20.md. For call safety patterns, see {baseDir}/references/call-safety-patterns.md.