Description
-
In widely used ERC‑20 implementations (e.g., OpenZeppelin), the _spendAllowance helper does not decrease allowance when it is set to type(uint256).max. This enables the “infinite approval” pattern: users approve MAX_UINT once and keep spending without needing to re‑approve, which many protocols (e.g., DEXes like Uniswap) rely on.
-
In this codebase, _spendAllowance always decreases the allowance, even when it equals type(uint256).max. This breaks the infinite approval pattern: the allowance is reduced after each transferFrom, eventually hitting zero and requiring users to re‑approve, thereby degrading UX and potentially breaking integrations expecting infinite approvals to remain infinite.
function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
assembly ("memory-safe") {
let currentAllowance := sload(allowanceSlot)
if lt(currentAllowance, value) {
}
sstore(allowanceSlot, sub(currentAllowance, value))
}
}
Risk
Likelihood: Medium
-
Users commonly set infinite approvals on DEXes and DeFi protocols to avoid repeated approvals.
-
Any protocol that assumes “MAX_UINT means do not decrease” will hit unexpected reverts or forced re‑approvals during normal operation.
Impact: Medium
-
Integration breakage / UX regression: Workflows expecting a permanent approval will stop working after several transferFrom calls as allowance drains.
-
Re-approval friction and costs: Users must re‑approve, incurring additional on-chain transactions, gas costs, and potential confusion, which can reduce protocol adoption or cause support load.
Proof of Concept
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "../test/Token.sol";
import {IERC20Errors} from "../src/helpers/IERC20Errors.sol";
contract InfiniteAllowanceTest is Test {
Token internal token;
address owner = address(0xA11CE);
address spender = address(0xB0B);
address to = address(0xCAFE);
function setUp() public {
token = new Token();
token.mint(owner, 1_000 ether);
}
function test_infiniteApproval_isIncorrectlyDecreased() public {
vm.prank(owner);
bool ok = token.approve(spender, type(uint256).max);
assertTrue(ok);
assertEq(token.allowance(owner, spender), type(uint256).max, "expect infinite approval");
vm.prank(spender);
token.transferFrom(owner, to, 5 ether);
uint256 afterAllowance = token.allowance(owner, spender);
assertEq(
afterAllowance,
type(uint256).max - 5 ether,
"BUG: infinite approval was decreased; should remain MAX_UINT"
);
vm.prank(spender);
token.transferFrom(owner, to, 10 ether);
assertEq(
token.allowance(owner, spender),
type(uint256).max - 15 ether,
"BUG: infinite approval keeps decreasing; expected to remain unchanged"
);
}
}
Output:
[⠊] Compiling...
[⠘] Compiling 1 files with Solc 0.8.30
[⠃] Solc 0.8.30 finished in 679.68ms
Compiler run successful!
Ran 1 test for test/poc1.t.sol:InfiniteAllowanceTest
[PASS] test_infiniteApproval_isIncorrectlyDecreased() (gas: 90980)
Traces:
[90980] InfiniteAllowanceTest::test_infiniteApproval_isIncorrectlyDecreased()
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [24985] Token::approve(0x0000000000000000000000000000000000000B0b, 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ ├─ emit Approval(owner: 0x00000000000000000000000000000000000A11cE, spender: 0x0000000000000000000000000000000000000B0b, value: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ └─ ← [Return] true
├─ [0] VM::assertTrue(true) [staticcall]
│ └─ ← [Return]
├─ [1312] Token::allowance(0x00000000000000000000000000000000000A11cE, 0x0000000000000000000000000000000000000B0b) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77], "expect infinite approval") [staticcall]
│ └─ ← [Return]
├─ [0] VM::prank(0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [30742] Token::transferFrom(0x00000000000000000000000000000000000A11cE, 0x000000000000000000000000000000000000cafE, 5000000000000000000 [5e18])
│ ├─ emit Transfer(from: 0x00000000000000000000000000000000000A11cE, to: 0x000000000000000000000000000000000000cafE, value: 5000000000000000000 [5e18])
│ └─ ← [Return] true
├─ [1312] Token::allowance(0x00000000000000000000000000000000000A11cE, 0x0000000000000000000000000000000000000B0b) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039452584007913129639935 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039452584007913129639935 [1.157e77], 115792089237316195423570985008687907853269984665640564039452584007913129639935 [1.157e77], "BUG: infinite approval was decreased; should remain MAX_UINT") [staticcall]
│ └─ ← [Return]
├─ [0] VM::prank(0x0000000000000000000000000000000000000B0b)
│ └─ ← [Return]
├─ [4042] Token::transferFrom(0x00000000000000000000000000000000000A11cE, 0x000000000000000000000000000000000000cafE, 10000000000000000000 [1e19])
│ ├─ emit Transfer(from: 0x00000000000000000000000000000000000A11cE, to: 0x000000000000000000000000000000000000cafE, value: 10000000000000000000 [1e19])
│ └─ ← [Return] true
├─ [1312] Token::allowance(0x00000000000000000000000000000000000A11cE, 0x0000000000000000000000000000000000000B0b) [staticcall]
│ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039442584007913129639935 [1.157e77]
├─ [0] VM::assertEq(115792089237316195423570985008687907853269984665640564039442584007913129639935 [1.157e77], 115792089237316195423570985008687907853269984665640564039442584007913129639935 [1.157e77], "BUG: infinite approval keeps decreasing; expected to remain unchanged") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.21ms (321.90µs CPU time)
Ran 1 test suite in 15.32ms (1.21ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
Skip decreasing the allowance when it is type(uint256).max.
In Yul, type(uint256).max can be represented as not(0) (i.e., all bits set to 1). Only subtract when not infinite:
function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
assembly ("memory-safe") {
// ... compute allowanceSlot ...
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))
+ // Infinite allowance optimization: if currentAllowance == type(uint256).max, do not decrease.
+ // In Yul, type(uint256).max is represented by not(0).
+ if iszero(eq(currentAllowance, not(0))) {
+ sstore(allowanceSlot, sub(currentAllowance, value))
+ }
}
}