Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: low
Likelihood: low

Unhandled "Infinite Approval Amount" Leading to ERC20 Compatibility Issues

Author Revealed upon completion

Unhandled "Infinite Approval Amount" Leading to ERC20 Compatibility Issues

Description

In the standard ERC20 implementation, users can set an "infinite approval amount" via approve(spender, type(uint256).max). After this, the spender can call transferFrom multiple times without needing re-approval, and the allowance value will not decrease.
However, the _spendAllowance function deducts the corresponding amount from the allowance unconditionally during each transfer. Even if the original approval value is type(uint256).max (i.e., "infinite approval"), it is treated as a regular value and decremented, thereby breaking the semantics of "infinite approval."

function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
assembly ("memory-safe") {
// ...
let currentAllowance := sload(allowanceSlot)
if lt(currentAllowance, value) {
// revert logic
}
@> sstore(allowanceSlot, sub(currentAllowance, value)) // Always deducts, even for "infinite" allowance
}
}

Risk

Likelihood:

  • Users frequently use the approve(spender, type(uint256).max) pattern in practice to avoid frequent approvals (especially in DEXs like Uniswap).

  • The contract does not handle the case where currentAllowance == type(uint256).max, causing the state to be modified with each transferFrom.

Impact:

  • Breaks the common usage pattern of ERC20, potentially causing abnormal behavior in third-party protocols or frontend logic that relies on "infinite approval."

  • Although it does not directly lead to financial loss, it increases users' gas costs (due to updating storage with each approval) and may cause user experience issues or integration compatibility risks.

Proof of Concept

  • Add the function test_1_spendAllowance_CantNoLimit in Token.t.sol as follows:

function test_1_spendAllowance_CantNoLimit() public {
address account = makeAddr("account");
token.mint(account, 100e18);
address spender = makeAddr("spender");
address to = makeAddr("to");
vm.prank(account);
token.approve(spender, type(uint256).max);
uint256 allowanceSpender = token.allowance(account, spender);
assertEq(allowanceSpender, type(uint256).max);
vm.prank(spender);
token.transferFrom(account, to, 50e18);
allowanceSpender = token.allowance(account, spender);
assertEq(allowanceSpender, type(uint256).max - 50e18);
}
  • Input command: forge test --mt test_1_spendAllowance_CantNoLimit -vv

Ran 1 test for test/Token.t.sol:TokenTest
[PASS] test_1_spendAllowance_CantNoLimit() (gas: 118738)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 866.50µs (382.30µs CPU time)

Recommended Mitigation

function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
assembly ("memory-safe") {
let ptr := mload(0x40)
let baseSlot := _allowances.slot
mstore(ptr, owner)
mstore(add(ptr, 0x20), baseSlot)
let initialHash := keccak256(ptr, 0x40)
mstore(ptr, spender)
mstore(add(ptr, 0x20), initialHash)
let allowanceSlot := keccak256(ptr, 0x40)
let currentAllowance := sload(allowanceSlot)
+ // Check for infinite approval (currentAllowance == type(uint256).max)
+ if eq(currentAllowance, not(0)) {
+ // Infinite allowance: do nothing
+ } else {
if lt(currentAllowance, value) {
mstore(0x00, shl(224, 0xfb8f41b2))
mstore(add(0x00, 4), spender)
mstore(add(0x00, 0x24), currentAllowance)
mstore(add(0x00, 0x44), value)
revert(0, 0x64)
}
sstore(allowanceSlot, sub(currentAllowance, value))
+ }
}
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!