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.
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) {
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))
}
}
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
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);
vm.prank(owner);
token.approve(spender, type(uint256).max);
assertEq(token.allowance(owner, spender), type(uint256).max);
vm.prank(spender);
token.transferFrom(owner, recipient, 100e18);
uint256 remainingAllowance = token.allowance(owner, spender);
console.log("Remaining allowance after transfer:", remainingAllowance);
assertEq(remainingAllowance, type(uint256).max - 100e18);
}
}
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.