Root + Impact
Description
-
The ERC20 standard convention is that type(uint256).max allowance represents "infinite approval" and should never decrement. The _spendAllowance() function in Token-0x's base implementation unconditionally decrements all allowances, breaking this convention and causing UX issues in DeFi integrations.
-
This violates the standard pattern used by major DeFi protocols like Uniswap, where infinite approvals persist to avoid repeated approval transactions.
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:
-
Any user who approves type(uint256).max to a DEX router or spender will experience this issue
-
DeFi protocols that rely on infinite approval for gas efficiency will break
-
Every transferFrom call with infinite approval triggers the vulnerability
Impact:
-
Users must re-approve after sufficient transfers, wasting gas on repeated approvals
-
DEX integrations fail as they expect infinite approvals to persist
-
Poor user experience in DeFi applications that depend on this convention
Proof of Concept
The test demonstrates that approving type(uint256).max allowance and then making a transfer causes the allowance to be decremented, violating the infinite approval convention that should persist across all transfers.
contract VulnerableToken is ERC20 {
constructor() ERC20("Vuln", "V") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
function test_InfiniteAllowanceDecremented() public {
VulnerableToken baseToken = new VulnerableToken();
address owner = makeAddr("owner");
address spender = makeAddr("spender");
uint256 amount = 100e18;
baseToken.mint(owner, amount);
vm.prank(owner);
baseToken.approve(spender, type(uint256).max);
vm.prank(spender);
baseToken.transferFrom(owner, makeAddr("recipient"), amount);
uint256 finalAllowance = baseToken.allowance(owner, spender);
assertTrue(finalAllowance < type(uint256).max, "Infinite allowance should not decrement");
}
Recommended Mitigation
Add a check for infinite allowance before decrementing. When allowance equals type(uint256).max, it should not be decremented to maintain the infinite approval convention.
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)
}
+ // FIX: Do NOT decrement if allowance is max (infinite approval)
+ let maxUint := not(0)
+ if iszero(eq(currentAllowance, maxUint)) {
sstore(allowanceSlot, sub(currentAllowance, value))
+ }
}
}