In June 2020, a Balancer liquidity pool containing the deflationary Statera (STA) token was drained of approximately $500,000. The attacker did not exploit a vulnerability in the STA token contract itself, nor did they leverage an inherent coding flaw in Balancer's core mathematical logic. Instead, the exploit succeeded due to a catastrophic integration mismatch: the liquidity pool assumed standard ERC-20 behavior—where the amount of tokens transferred matches the amount of tokens received—while the token contract deducted a 1% fee on every transfer.
This mismatch represents the classic fee on transfer token vulnerability. In complex decentralized finance (DeFi) architectures, integrating non-standard ERC-20 tokens without proper accounting checks leads directly to protocol insolvency. This article analyzes the mechanics of fee-on-transfer (FoT) and rebasing tokens, details a compilable vulnerable contract, provides the exploit vectors, and presents secure implementation patterns to protect smart contracts from accounting discrepancies.
1. Mechanics of Non-Standard ERC-20 Tokens
DeFi protocols rely heavily on assumptions encoded within the ERC-20 standard. Two major classes of tokens break these assumptions: fee-on-transfer tokens and rebasing tokens.
Fee-on-Transfer (Deflationary) Tokens
Standard ERC-20 tokens execute transfers such that:
$$\text{Balance}{\text{after}} = \text{Balance}{\text{before}} + \text{amount}$$
Fee-on-transfer tokens, however, deduct a tax, burn fee, or protocol fee during the execution of transfer or transferFrom. The actual balance increase of the recipient is less than the requested transfer amount:
$$\text{Balance}{\text{after}} = \text{Balance}{\text{before}} + \text{amount} - \text{fee}$$
When a vault or liquidity pool executes transferFrom(sender, address(this), amount) and updates its internal ledger by adding amount to the sender's balance, it records an artificial surplus. The contract's internal accounting state drifts away from its physical token balance on-chain.
Rebasing (Elastic Supply) Tokens
Rebasing tokens dynamically adjust their total supply to maintain a peg or target price. These supply adjustments are applied globally across all token holders.
* Positive Rebase: The total supply increases, and every token holder's balance increases proportionally without any transfer occurring.
* Negative Rebase: The total supply decreases, and every token holder's balance decreases proportionally.
If a DeFi vault caches the depositor's balance using an internal mapping (e.g., balances[user] = amount) and does not update it during a rebase, the pool becomes vulnerable. In a negative rebase scenario, the actual contract balance falls below the sum of all cached user balances. Early withdrawers can claim their full cached balance, while late withdrawers face reverted transactions due to insufficient funds in the vault.
2. Vulnerable Implementation: Staking and Accounting Code
The contract below represents a typical DeFi staking pool. It tracks user deposits internally, assuming that the amount passed to transferFrom is exactly the amount received by the contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
/**
* @title VulnerableVault
* @notice A staking vault vulnerable to fee-on-transfer and rebasing accounting mismatches.
*/
contract VulnerableVault {
IERC20 public immutable token;
mapping(address => uint256) public balances;
uint256 public totalDeposits;
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
constructor(address _token) {
require(_token != address(0), "Invalid token address");
token = IERC20(_token);
}
/**
* @notice Deposit tokens into the vault.
* @dev Vulnerable to fee-on-transfer tokens. The internal ledger assumes
* the 'amount' parameter is the exact balance increase.
* @param amount The quantity of tokens to deposit.
*/
function deposit(uint256 amount) external {
require(amount > 0, "Amount must be greater than zero");
// VULNERABLE: Does not check the actual balance increase of the vault
require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");
// Internal accounting is credited with the nominal amount, not the actual amount received
balances[msg.sender] += amount; // <--- Vulnerability: Accounting mismatch
totalDeposits += amount;
emit Deposited(msg.sender, amount);
}
/**
* @notice Withdraw tokens from the vault.
* @param amount The quantity of tokens to withdraw.
*/
function withdraw(uint256 amount) external {
require(amount > 0, "Amount must be greater than zero");
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
totalDeposits -= amount;
// The vault attempts to return the full nominal amount
require(token.transfer(msg.sender, amount), "Transfer failed");
emit Withdrawn(msg.sender, amount);
}
}
Vulnerability Analysis
If an auditor or developer deploys VulnerableVault with a token that imposes a 5% transfer fee:
1. User A deposits 100 tokens.
2. The token contract executes the transfer, taking 5 tokens as a fee. The vault receives only 95 tokens.
3. VulnerableVault updates balances[User A] to 100 and totalDeposits to 100.
4. If User A immediately calls withdraw(100), the vault attempts to transfer 100 tokens to User A.
5. If User A is the sole depositor, the vault only holds 95 tokens on-chain. The withdraw call reverts due to insufficient contract balance, locking User A's funds.
6. If there are other depositors, User A successfully withdraws 100 tokens. However, the 5 tokens missing from the vault's physical balance are subsidized by other depositors' funds. The final depositor in the queue will be unable to withdraw their assets.
3. Exploit Walkthrough: How the Pool is Drained
To illustrate how this vulnerability escalates to a complete protocol drain, we analyze a scenario where a vault handles liquidity shares based on its internal records of deposits.
sequenceDiagram
autonumber
actor Attacker
participant Vault as VulnerableVault
participant Token as Fee-On-Transfer Token
Attacker->>Token: approve(Vault, 100)
Attacker->>Vault: deposit(100)
Vault->>Token: transferFrom(Attacker, Vault, 100)
Note over Token: Deducts 5% fee (5 tokens)
Token-->>Vault: Return true (95 tokens received)
Vault->>Vault: Credit Attacker with 100 internal balance
Note over Vault: Accounting state: 100 units<br/>On-chain balance: 95 tokens
Attacker->>Vault: withdraw(100)
Vault->>Token: transfer(Attacker, 100)
Note over Token: Deducts 5% fee (5 tokens)
Token-->>Attacker: 95 tokens received
Vault->>Vault: Debit Attacker 100 internal balance
Note over Vault: Vault Insolvency: 0 units credited,<br/>but only 0 tokens remaining on-chain.
The Arbitrage and Liquidity Exploitation Loop
In automated market makers (AMMs) or lending platforms, this mismatch creates systemic arbitrage vectors:
- Price Manipulation: A liquidity pool calculates asset prices based on the ratio of balances. If the pool uses the cached balance (
totalDepositsor a stored state variable) instead of querying the real balance (balanceOf), the computed price diverges from the real asset ratio. - Infinite Swaps: In the Balancer STA exploit, the attacker executed flash loans to swap STA for other assets repeatedly. Each swap triggered the 1% fee, causing the physical STA balance in the pool to drop to near zero.
- Exploiting the Constant Product Invariant: Because the pool calculated swap rates using the cached STA balance (which remained constant) rather than the actual balance, the pool priced the remaining assets (WETH, LINK) as if the pool was still heavily collateralized with STA. The attacker purchased these valuable assets for a fraction of their market value.
4. Remediation: Secure Accounting and Mitigation Patterns
To secure smart contracts against fee-on-transfer and rebasing bugs, you must implement defensive accounting systems.
The Balance-Difference Pattern
The most robust mechanism to handle fee-on-transfer tokens is to query the contract's token balance immediately before and after the transfer operation. The difference between these two values represents the actual amount received.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
/**
* @title SecureVault
* @notice A vault that implements defensive balance checking to mitigate integration bugs.
*/
contract SecureVault {
IERC20 public immutable token;
mapping(address => uint256) public balances;
uint256 public totalDeposits;
event Deposited(address indexed user, uint256 actualAmount);
event Withdrawn(address indexed user, uint256 amount);
constructor(address _token) {
require(_token != address(0), "Invalid token address");
token = IERC20(_token);
}
/**
* @notice Deposit tokens, calculating the net received amount after any fees.
* @param amount The nominal amount of tokens requested for transfer.
*/
function deposit(uint256 amount) external {
require(amount > 0, "Amount must be greater than zero");
// Record the contract balance prior to the transfer
uint256 balanceBefore = token.balanceOf(address(this));
// Execute the transfer
require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");
// Record the contract balance after the transfer
uint256 balanceAfter = token.balanceOf(address(this));
// Calculate the actual amount received by the contract
uint256 actualAmount = balanceAfter - balanceBefore;
require(actualAmount > 0, "No tokens received");
// Credit the user with the actual tokens received, not the requested amount
balances[msg.sender] += actualAmount;
totalDeposits += actualAmount;
emit Deposited(msg.sender, actualAmount);
}
/**
* @notice Withdraw tokens. Note that the outbound transfer may also trigger a fee.
* @param amount The quantity of tokens the user wishes to withdraw from their credit.
*/
function withdraw(uint256 amount) external {
require(amount > 0, "Amount must be greater than zero");
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
totalDeposits -= amount;
// Perform the outbound transfer. The user will receive: amount - outbound_fee
require(token.transfer(msg.sender, amount), "Transfer failed");
emit Withdrawn(msg.sender, amount);
}
}
Trade-offs of the Balance-Difference Pattern
While the balance-difference pattern successfully mitigates the fee-on-transfer vulnerability, it introduces design and gas trade-offs:
- Gas Cost: Every
depositnow executes two external calls tobalanceOfin addition totransferFrom. In high-throughput protocols, this increases transaction gas fees significantly. - Reentrancy Risk: If the token contract has a callback mechanism (such as ERC-777 hooks), performing balance queries before and after external calls can expose the contract to read-only reentrancy if state variables are updated mid-execution. Always apply the checks-effects-interactions pattern and utilize reentrancy guards.
Alternative Securing Strategies
- Token Whitelisting: If a protocol is optimized for gas and cannot afford defensive balance queries, it must implement an administrative whitelist to ensure only standard, non-fee-on-transfer, non-rebasing ERC-20 tokens are permitted.
- Wrapper Contracts: For rebasing tokens like stETH, protocols often require users to wrap their tokens into a static representation (such as wstETH) before depositing. Wrapped tokens use share-based accounting, keeping the user's balances stable while storing the rebasing value within the wrapper.
5. Case Study: The June 2020 Balancer STA Exploit
The Statera (STA) exploit remains the industry-defining example of fee-on-transfer integration issues. Below is a detailed breakdown of how the attacker manipulated the Balancer pool accounting:
- Pool Composition: The target pool held STA (1% transfer fee), WETH, LINK, SNX, and WBTC.
- Initial Setup: The attacker obtained a flash loan of WETH to establish a high-capital position.
- Execution Loop:
- The attacker swapped WETH for STA.
- The attacker swapped STA back to WETH.
- Because of the 1% transfer fee, each transfer of STA back to the pool resulted in the pool receiving less STA than expected.
- However, Balancer's internal logic did not query the actual token balance of the contract. It calculated prices based on cached state variables that assumed the full STA transfer amount was deposited.
- De-collateralization: As the attacker repeated the swaps, the physical balance of STA inside the pool fell to near zero, while the cached state variable remained extremely high.
- The Drain: With the physical STA balance depleted, the marginal price of all other assets in the pool relative to STA fell precipitously. The attacker executed cheap swaps to trade tiny fractions of STA for the pool’s entire supply of WETH, LINK, SNX, and WBTC, draining $500,000 in assets.
6. Pre-Deployment Security Checklist
Before deploying contracts that interact with arbitrary ERC-20 tokens, smart contract developers and security auditors must verify the following conditions:
- [ ] Dynamic Accounting: Verify that the contract calculates deposits using the balance difference (
balanceOfafter minusbalanceOfbefore) instead of trusting the input parameter. - [ ] Outbound Transfer Fees: Confirm that the protocol is prepared for the recipient to receive less than the requested amount during withdrawals if the token charges a fee on outbound transfers.
- [ ] State Synchronization: Ensure the contract provides a public, permissionless sync mechanism (similar to Uniswap V2's
sync()) to align cached internal balances with on-chain token balances, mitigating rebasing discrepancies. - [ ] Reentrancy Protection: Apply reentrancy guards (
nonReentrantmodifiers) to all functions utilizing the balance-difference pattern to prevent read-only reentrancy during external balance queries. - [ ] Whitelist Constraints: If the system design strictly requires standard ERC-20 behavior, enforce an active whitelist of tokens verified to have no transfer fee or rebasing functions.
7. Frequently Asked Questions
Can I use OpenZeppelin's SafeERC20 to prevent the fee on transfer token vulnerability?
No. OpenZeppelin's SafeERC20 library wrapper (which provides safeTransfer and safeTransferFrom) is designed to handle non-standard ERC-20 tokens that do not return a boolean value (e.g., USDT on Ethereum mainnet). It does not perform balance-difference checks and will not prevent accounting discrepancies caused by fee-on-transfer or rebasing tokens.
How does a negative rebase token affect a standard liquidity pool?
A negative rebase token reduces the balances of all holders globally. If a pool contract caches its asset holdings internally, a negative rebase leaves the pool holding fewer physical tokens on-chain than the sum of its internal balances. Early liquidity providers will be able to withdraw their original share value, while remaining liquidity providers will face failed withdrawals due to insolvencies.
Is the balance-difference pattern sufficient to support rebasing tokens?
No. The balance-difference pattern only secures the deposit step. It does not account for changes in the contract’s balance that occur after the deposit has finished. To fully support rebasing tokens, the contract must either use a share-based accounting system (similar to Lido's stETH share mechanism) or expose a public synchronization function that recalculates the internal pool valuation based on the current actual token balance of the contract.
Before deploying your next smart contract, verify its resistance against the fee on transfer token vulnerability. Use ContractScan to scan your code for integration bugs and protocol-level accounting errors.