Token-0x

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

_spendAllowance() Incorrectly Decrements Infinite Approvals Breaking ERC20 Convention

Author Revealed upon completion

Root + Impact

Description

  • When an allowance is set to type(uint256).max, the _spendAllowance() function should skip decrementing to preserve the infinite approval. This convention allows users to approve once and avoid gas costs on subsequent transactions, which is standard practice across major DeFi protocols.

  • The assembly implementation unconditionally decrements all allowances regardless of value. When transferFrom is called with an infinite approval of type(uint256).max, the function subtracts the transfer amount resulting in a value like type(uint256).max - 100. Users lose the infinite approval benefit and must re-approve after approximately 1e59 transfers, breaking the expected behavior.

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)) // No check for infinite approval
}
}

Risk

Likelihood:

  • Infinite approvals are widely used across DeFi platforms for improved user experience, with major protocols like Uniswap and 1inch implementing this pattern as default behavior for token interactions.

  • Every transferFrom transaction using infinite approval immediately decrements the allowance, causing the deviation from expected behavior to surface during normal protocol operations.

Impact:

  • Automated trading bots and smart contract systems designed around infinite approvals fail unexpectedly when allowances decrease, requiring manual intervention and causing transaction failures in production environments.

  • Users experience higher gas costs due to repeated approval transactions and protocols face increased complexity handling non-standard allowance behavior, reducing adoption and creating poor user experience.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {Token} from "./Token.sol";
contract InfiniteApprovalTest is Test {
Token public token;
address public owner;
address public spender;
address public receiver;
function setUp() public {
token = new Token();
owner = makeAddr("owner");
spender = makeAddr("spender");
receiver = makeAddr("receiver");
}
function test_InfiniteApprovalGetsDecremented() public {
// Mint tokens to owner
token.mint(owner, 1000e18);
// Owner approves spender with infinite allowance (max uint256)
vm.prank(owner);
token.approve(spender, type(uint256).max);
console.log("Initial allowance (infinite):", token.allowance(owner, spender));
assertEq(token.allowance(owner, spender), type(uint256).max);
// Spender transfers tokens using transferFrom
vm.prank(spender);
token.transferFrom(owner, receiver, 100e18);
// Check allowance after transfer
uint256 finalAllowance = token.allowance(owner, spender);
console.log("Allowance after transferFrom:", finalAllowance);
// Vulnerability: Infinite approval was decremented
// ERC20 standard: type(uint256).max should remain unchanged
assertTrue(finalAllowance != type(uint256).max, "Infinite approval was decremented");
assertEq(finalAllowance, type(uint256).max - 100e18);
console.log("Vulnerability confirmed: Infinite approval decremented by 100000000000000000000");
console.log("Expected behavior: Should remain at type(uint256).max");
}
}

Result:

forge test --match-path test/InfiniteApproval.t.sol -vvv
[⠢] Compiling...
No files changed, compilation skipped
Ran 1 test for test/InfiniteApproval.t.sol:InfiniteApprovalTest
[PASS] test_InfiniteApprovalGetsDecremented() (gas: 128493)
Logs:
Initial allowance (infinite): 115792089237316195423570985008687907853269984665640564039457584007913129639935
Allowance after transferFrom: 115792089237316195423570985008687907853269984665640564039357584007913129639935
Vulnerability confirmed: Infinite approval decremented by 100000000000000000000
Expected behavior: Should remain at type(uint256).max
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 401.39µs (91.61µs CPU time)
Ran 1 test suite in 5.21ms (401.39µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Add a check to skip allowance decrement when the current allowance equals type(uint256).max. This preserves infinite approvals according to ERC20 convention and matches the behavior of standard implementations like OpenZeppelin.

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
+ 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))
}
}

Support

FAQs

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

Give us feedback!