Why allowance management needs care

ERC20 approvals are deceptively simple: a token holder grants a spender the right to transfer tokens up to a limit. In practice, allowance handling can become error-prone because:

  • Users may accidentally leave large approvals active.
  • Some tokens require resetting allowance to zero before changing it.
  • Spenders may need to spend only part of an allowance, then update the remainder.
  • Frontends often need a reliable way to revoke permissions after a workflow completes.

A dedicated allowance manager helps centralize these rules. It can enforce conservative approval updates, reduce the chance of stale permissions, and provide a cleaner interface for dApps.

Design goals

For this example, the contract should:

  • Support approving a spender for a specific ERC20 token.
  • Support increasing and decreasing allowances safely.
  • Allow revoking an allowance entirely.
  • Optionally support EIP-2612 permit for gasless approvals when the token supports it.
  • Emit events for off-chain tracking.
  • Avoid assuming every token behaves identically.

This is a useful pattern for wallets, treasury tools, payment routers, and admin dashboards that need controlled token spending.

Contract overview

We will implement a contract that acts as an allowance coordinator. It does not hold user funds permanently; instead, it manages approvals from the contract itself to downstream spenders. This is especially useful when the contract is the token holder, such as in treasury automation or vault-like workflows.

Key idea

The contract maintains no custom allowance mapping. It relies on the ERC20 token’s own allowance state and provides safe helper functions around approve, increaseAllowance, decreaseAllowance, and permit.

Example implementation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function approve(address spender, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
}

interface IERC20Permit {
    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external;
}

contract SafeAllowanceManager {
    event AllowanceSet(address indexed token, address indexed spender, uint256 value);
    event AllowanceIncreased(address indexed token, address indexed spender, uint256 addedValue, uint256 newValue);
    event AllowanceDecreased(address indexed token, address indexed spender, uint256 subtractedValue, uint256 newValue);
    event AllowanceRevoked(address indexed token, address indexed spender);
    event PermitUsed(address indexed token, address indexed owner, address indexed spender, uint256 value);

    error ZeroAddress();
    error ApproveFailed();
    error InsufficientAllowance();
    error PermitNotSupported();

    function setAllowance(address token, address spender, uint256 value) external {
        if (token == address(0) || spender == address(0)) revert ZeroAddress();

        _forceApprove(token, spender, value);
        emit AllowanceSet(token, spender, value);
    }

    function increaseAllowance(address token, address spender, uint256 addedValue) external {
        if (token == address(0) || spender == address(0)) revert ZeroAddress();

        uint256 current = IERC20(token).allowance(address(this), spender);
        uint256 newValue = current + addedValue;

        _forceApprove(token, spender, newValue);
        emit AllowanceIncreased(token, spender, addedValue, newValue);
    }

    function decreaseAllowance(address token, address spender, uint256 subtractedValue) external {
        if (token == address(0) || spender == address(0)) revert ZeroAddress();

        uint256 current = IERC20(token).allowance(address(this), spender);
        if (subtractedValue > current) revert InsufficientAllowance();

        uint256 newValue = current - subtractedValue;
        _forceApprove(token, spender, newValue);
        emit AllowanceDecreased(token, spender, subtractedValue, newValue);
    }

    function revokeAllowance(address token, address spender) external {
        if (token == address(0) || spender == address(0)) revert ZeroAddress();

        _forceApprove(token, spender, 0);
        emit AllowanceRevoked(token, spender);
    }

    function usePermit(
        address token,
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        if (token == address(0) || owner == address(0) || spender == address(0)) revert ZeroAddress();

        try IERC20Permit(token).permit(owner, spender, value, deadline, v, r, s) {
            emit PermitUsed(token, owner, spender, value);
        } catch {
            revert PermitNotSupported();
        }
    }

    function _forceApprove(address token, address spender, uint256 value) internal {
        (bool ok, bytes memory data) = token.call(
            abi.encodeWithSelector(IERC20.approve.selector, spender, value)
        );

        if (!ok) revert ApproveFailed();

        if (data.length > 0 && !abi.decode(data, (bool))) {
            revert ApproveFailed();
        }
    }
}

How the contract works

The contract uses a low-level call for approve instead of a direct interface call. This is intentional.

Some ERC20 tokens return false on failure, while others revert. A few older tokens also require an approval to be set to zero before changing it to a non-zero value. The _forceApprove helper abstracts these differences and treats any failed or false response as an error.

Why low-level calls help

A direct call like:

IERC20(token).approve(spender, value);

is fine for well-behaved tokens, but it can be brittle when interacting with non-standard implementations. The low-level approach lets the contract inspect both the success flag and return data, making it more defensive.

Safe approval patterns

The most important rule for allowance management is to avoid leaving unnecessary permissions active. The contract above supports three common patterns.

PatternWhen to use itBenefit
Set exact allowanceOne-time or bounded workflowsMinimizes over-approval
Increase allowanceRepeated operations with a growing limitAvoids resetting from scratch
Revoke allowanceAfter a workflow completesRemoves stale permissions

Example workflow

A treasury contract may need to approve a DEX router to swap tokens, execute the swap, and then revoke the approval immediately afterward. This limits the blast radius if the router address is later compromised or misconfigured.

Using permit for gasless approvals

EIP-2612 permit allows a token holder to sign an approval off-chain, then have a relayer submit it on-chain. This is useful when users should not need to spend gas just to authorize a transfer.

The usePermit function in the example contract calls permit through the token interface and reverts if the token does not support it.

Practical notes

  • Not all ERC20 tokens implement permit.
  • Signatures are token-specific and must match the token’s domain separator.
  • The deadline should be short-lived to reduce replay risk.
  • Frontends should clearly display the approved spender and amount before the user signs.

Best practices for allowance safety

1. Approve only what you need

Avoid large “infinite approvals” unless there is a strong operational reason. Exact approvals are safer because they reduce the amount a spender can move if something goes wrong.

2. Revoke after use

If a spender only needs temporary access, revoke the allowance once the operation is complete. This is especially important for admin tools and backend automation.

3. Prefer explicit spender addresses

Never derive the spender from untrusted user input without validation. A typo or malicious redirect can grant approval to the wrong address.

4. Handle non-standard ERC20 behavior

Some tokens are quirky:

  • Some return no value on approve.
  • Some require zeroing first.
  • Some revert on unusual state transitions.

A wrapper contract should be defensive and test against the exact tokens it will support.

5. Emit events for every state change

Events make it easier to audit allowance changes and build dashboards that show active permissions. This is valuable for operations teams and security monitoring.

Extending the contract for real applications

The example is intentionally small, but it can be extended in several useful ways.

Add token whitelisting

If your application should only manage approved assets, add a whitelist mapping and restrict token to known addresses. This reduces the risk of interacting with malicious or unsupported tokens.

Add spender whitelisting

You may also want to restrict approvals to a fixed set of routers, vaults, or payment processors. This is common in enterprise treasury systems.

Add batch operations

For operational efficiency, you can add a batch function that updates allowances for multiple token-spender pairs in one transaction. Be careful to keep the function atomic so partial updates do not leave the system in an inconsistent state.

Add access control

In the current example, any caller can trigger allowance updates for the contract’s own token holdings. In a production system, you would typically restrict these functions with onlyOwner, role-based access control, or a governance module.

Common pitfalls

Confusing owner and spender

The allowance belongs to the token owner, not the spender. If your contract is the token holder, the contract itself is the owner in the token’s allowance mapping.

Assuming all tokens behave the same

Never assume approve will behave identically across tokens. Always test with the specific assets your application will support.

Leaving approvals active indefinitely

A forgotten approval is a latent security issue. Build revocation into your workflow, not as an afterthought.

Ignoring race conditions in UI flows

If a user changes an allowance from one non-zero value to another, there can be a race condition where the spender uses both the old and new allowance in separate transactions. A safer pattern is to set allowance to zero first, then set the new amount, or use a token-specific safe approval helper.

Testing recommendations

When testing an allowance manager, cover these scenarios:

  • Approving a standard ERC20 token.
  • Approving a token that returns false on failure.
  • Revoking an existing allowance.
  • Decreasing allowance below zero and confirming revert behavior.
  • Using permit with a supported token.
  • Calling permit on a token that does not support it and confirming the expected revert.

A good test suite should also verify emitted events and final allowance values after each operation.

When to use this pattern

This contract is a good fit when your application needs a controlled and auditable way to manage ERC20 permissions. Typical use cases include:

  • Treasury automation
  • Payment routing
  • On-chain operations dashboards
  • Vault administration
  • Relayer-assisted approval flows

It is less useful if your application never needs to approve third-party spenders or if token transfers can be performed directly by the holder without any delegated access.

Conclusion

Allowance management is a small part of ERC20 integration, but it has outsized security and usability impact. A dedicated allowance manager helps standardize approval workflows, reduce stale permissions, and support both traditional approvals and gasless permit flows.

The key takeaway is simple: treat approvals as sensitive permissions, not as boilerplate. By approving only what is needed, revoking promptly, and handling non-standard token behavior carefully, you can build token workflows that are much safer in production.

Learn more with useful resources