Token-0x

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

`_spendAllowance` Decrements Infinite Approvals, Breaking Common DeFi Pattern

Author Revealed upon completion

Description

A widely adopted pattern in the ERC20 ecosystem is to treat an allowance of type(uint256).max as an "infinite" or "unlimited" approval that should not be decremented upon use. This pattern is implemented by OpenZeppelin and expected by many DeFi protocols to reduce gas costs and improve UX for users who want to grant permanent spending permission.

The _spendAllowance function in ERC20Internals.sol does not implement this pattern. All allowances, including type(uint256).max, are decremented on each use. While this is technically valid per ERC20 standard, it deviates from the de-facto industry standard behavior that many protocols and users expect.

// @> In ERC20Internals.sol::_spendAllowance (lines 182-203)
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)
if lt(currentAllowance, value) {
// revert ERC20InsufficientAllowance
mstore(0x00, shl(224, 0xfb8f41b2))
mstore(add(0x00, 4), spender)
mstore(add(0x00, 0x24), currentAllowance)
mstore(add(0x00, 0x44), value)
revert(0, 0x64)
}
// @> Always decrements, even when allowance is type(uint256).max
sstore(allowanceSlot, sub(currentAllowance, value))
}
}

Risk

Likelihood: Medium

  • Users granting "infinite" approval (type(uint256).max) expect it to remain infinite

  • DeFi protocols (routers, aggregators) commonly use this pattern

  • Each transferFrom call reduces the approval, eventually causing unexpected failures

Impact: Low

  • User experience degradation: "infinite" approvals become finite

  • Extra gas costs: users must re-approve more frequently

  • Integration issues: protocols expecting infinite approval pattern may malfunction

  • No direct fund loss, but operational disruption

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract InfiniteApprovalTest is Test {
Token public token;
function setUp() public {
token = new Token();
}
function test_InfiniteApproval_GetsDecremented() public {
address owner = makeAddr("owner");
address spender = makeAddr("spender");
address recipient = makeAddr("recipient");
token.mint(owner, 1000e18);
// Owner gives "infinite" approval
vm.prank(owner);
token.approve(spender, type(uint256).max);
assertEq(token.allowance(owner, spender), type(uint256).max);
// First transfer
vm.prank(spender);
token.transferFrom(owner, recipient, 100e18);
// Check remaining allowance - it was decremented!
uint256 remainingAllowance = token.allowance(owner, spender);
console.log("Remaining allowance after transfer:", remainingAllowance);
// Expected (per common pattern): type(uint256).max (unchanged)
// Actual: type(uint256).max - 100e18
assertEq(remainingAllowance, type(uint256).max - 100e18);
// This means after enough transfers, the "infinite" approval will fail
}
}

Test Output:

[PASS] test_InfiniteApproval_NotHandled() (gas: 120629)
Logs:
Remaining allowance after transfer: 115792089237316195423570985008687907853269984665640564039357584007913129639935

Recommended Mitigation

Add a check for type(uint256).max allowance to skip decrementing:

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)
+ // Skip decrement for infinite approval (type(uint256).max)
+ // This is a common pattern to save gas on repeated transfers
+ if eq(currentAllowance, not(0)) {
+ leave
+ }
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))
}
}

Note: This finding may be considered informational/low since infinite approval is a convention, not a requirement of ERC20. However, given the project's stated goal of being a drop-in replacement for OpenZeppelin's ERC20, this deviation is noteworthy for compatibility.

Support

FAQs

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

Give us feedback!