Token-0x

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

Infinite Allowance Incorrectly Decremented in _spendAllowance()

Author Revealed upon completion

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)) // Line 201: Always decrements
}
}

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.

// Added to test/Token.t.sol
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);
// Approve infinite allowance
vm.prank(owner);
baseToken.approve(spender, type(uint256).max);
// Transfer using allowance
vm.prank(spender);
baseToken.transferFrom(owner, makeAddr("recipient"), amount);
// Vulnerability: infinite allowance was decremented
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))
+ }
}
}

Support

FAQs

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

Give us feedback!