EIP-5827: Auto-renewable allowance extension

Abstract

This extension adds a renewable allowance mechanism to EIP-20 allowances, in which a recoveryRate defines the amount of token per second that the allowance regains towards the initial maximum approval amount.

Motivation

Currently, EIP-20 tokens support allowances, with which token owners can allow a spender to spend a certain amount of tokens on their behalf. However, this is not ideal in circumstances involving recurring payments (e.g. subscriptions, salaries, recurring direct-cost-averaging purchases).

Many existing DApps circumvent this limitation by requesting that users grant a large or unlimited allowance. This presents a security risk as malicious DApps can drain users’ accounts up to the allowance granted, and users may not be aware of the implications of granting allowances.

An auto-renewable allowance enables many traditional financial concepts like credit and debit limits. An account owner can specify a spending limit, and limit the amount charged to the account based on an allowance that recovers over time.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

pragma solidity ^0.8.0;

interface IERC5827 /* is ERC20, ERC165 */ {
    /*
     * Note: the ERC-165 identifier for this interface is 0x93cd7af6.
     * 0x93cd7af6 ===
     *   bytes4(keccak256('approveRenewable(address,uint256,uint256)')) ^
     *   bytes4(keccak256('renewableAllowance(address,address)')) ^
     *   bytes4(keccak256('approve(address,uint256)') ^
     *   bytes4(keccak256('transferFrom(address,address,uint256)') ^
     *   bytes4(keccak256('allowance(address,address)') ^
     */

    /**
     * @notice  Thrown when the available allowance is less than the transfer amount.
     * @param   available       allowance available; 0 if unset
     */
    error InsufficientRenewableAllowance(uint256 available);

    /**
     * @notice  Emitted when any allowance is set.
     * @dev     MUST be emitted even if a non-renewable allowance is set; if so, the
     * @dev     `_recoveryRate` MUST be 0.
     * @param   _owner          owner of token
     * @param   _spender        allowed spender of token
     * @param   _value          initial and maximum allowance granted to spender
     * @param   _recoveryRate   recovery amount per second
     */
    event RenewableApproval(
        address indexed _owner,
        address indexed _spender,
        uint256 _value,
        uint256 _recoveryRate
    );

    /**
     * @notice  Grants an allowance of `_value` to `_spender` initially, which recovers over time 
     * @notice  at a rate of `_recoveryRate` up to a limit of `_value`.
     * @dev     SHOULD cause `allowance(address _owner, address _spender)` to return `_value`, 
     * @dev     SHOULD throw when `_recoveryRate` is larger than `_value`, and MUST emit a 
     * @dev     `RenewableApproval` event.
     * @param   _spender        allowed spender of token
     * @param   _value          initial and maximum allowance granted to spender
     * @param   _recoveryRate   recovery amount per second
     */
    function approveRenewable(
        address _spender,
        uint256 _value,
        uint256 _recoveryRate
    ) external returns (bool success);

    /**
     * @notice  Returns approved max amount and recovery rate of allowance granted to `_spender` 
     * @notice  by `_owner`.
     * @dev     `amount` MUST also be the initial approval amount when a non-renewable allowance 
     * @dev     has been granted, e.g. with `approve(address _spender, uint256 _value)`.
     * @param    _owner         owner of token
     * @param   _spender        allowed spender of token
     * @return  amount initial and maximum allowance granted to spender
     * @return  recoveryRate recovery amount per second
     */
    function renewableAllowance(address _owner, address _spender)
        external
        view
        returns (uint256 amount, uint256 recoveryRate);

    /// Overridden ERC-20 functions

    /**
     * @notice  Grants a (non-increasing) allowance of _value to _spender and clears any existing 
     * @notice  renewable allowance.
     * @dev     MUST clear set `_recoveryRate` to 0 on the corresponding renewable allowance, if 
     * @dev     any.
     * @param   _spender        allowed spender of token
     * @param   _value          allowance granted to spender
     */
    function approve(address _spender, uint256 _value)
        external
        returns (bool success);

    /**
    * @notice   Moves `amount` tokens from `from` to `to` using the caller's allowance.
    * @dev      When deducting `amount` from the caller's allowance, the allowance amount used 
    * @dev      SHOULD include the amount recovered since the last transfer, but MUST NOT exceed 
    * @dev      the maximum allowed amount returned by `renewableAllowance(address _owner, address 
    * @dev      _spender)`. 
    * @dev      SHOULD also throw `InsufficientRenewableAllowance` when the allowance is 
    * @dev      insufficient.
    * @param    from            token owner address
    * @param    to              token recipient
    * @param    amount          amount of token to transfer
    */
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) external returns (bool);

    /**
     * @notice  Returns amount currently spendable by `_spender`.
     * @dev     The amount returned MUST be as of `block.timestamp`, if a renewable allowance 
     * @dev     for the `_owner` and `_spender` is present.
     * @param   _owner         owner of token
     * @param   _spender       allowed spender of token
     * @return  remaining allowance at the current point in time
     */
    function allowance(address _owner, address _spender)
        external
        view
        returns (uint256 remaining);
}

Base method approve(address _spender, uint256 _value) MUST set recoveryRate to 0.

Both allowance() and transferFrom() MUST be updated to include allowance recovery logic.

approveRenewable(address _spender, uint256 _value, uint256 _recoveryRate) MUST set both the initial allowance amount and the maximum allowance limit (to which the allowance can recover) to _value.

supportsInterface(0x93cd7af6) MUST return true.

Additional interfaces

Token Proxy

Existing EIP-20 tokens can delegate allowance enforcement to a proxy contract that implements this specification. An additional query function exists to get the underlying EIP-20 token.

interface IERC5827Proxy /* is IERC5827 */ {

    /*
     * Note: the ERC-165 identifier for this interface is 0xc55dae63.
     * 0xc55dae63 ===
     *   bytes4(keccak256('baseToken()')
     */

    /**
     * @notice   Get the underlying base token being proxied.
     * @return   baseToken address of the base token
     */
    function baseToken() external view returns (address);
}

The transfer() function on the proxy MUST NOT emit the Transfer event (as the underlying token already does so).

Automatic Expiration

interface IERC5827Expirable /* is IERC5827 */ {
    /*
     * Note: the ERC-165 identifier for this interface is 0x46c5b619.
     * 0x46c5b619 ===
     *   bytes4(keccak256('approveRenewable(address,uint256,uint256,uint64)')) ^
     *   bytes4(keccak256('renewableAllowance(address,address)')) ^
     */

    /**
     * @notice  Grants an allowance of `_value` to `_spender` initially, which recovers over time 
     * @notice  at a rate of `_recoveryRate` up to a limit of `_value` and expires at 
     * @notice  `_expiration`.
     * @dev     SHOULD throw when `_recoveryRate` is larger than `_value`, and MUST emit 
     * @dev     `RenewableApproval` event.
     * @param   _spender        allowed spender of token
     * @param   _value          initial allowance granted to spender
     * @param   _recoveryRate   recovery amount per second
     * @param   _expiration     Unix time (in seconds) at which the allowance expires
     */
    function approveRenewable(
        address _spender,
        uint256 _value,
        uint256 _recoveryRate,
        uint64 _expiration
    ) external returns (bool success);

    /**
     * @notice  Returns approved max amount, recovery rate, and expiration timestamp.
     * @return  amount initial and maximum allowance granted to spender
     * @return  recoveryRate recovery amount per second
     * @return  expiration Unix time (in seconds) at which the allowance expires
     */
    function renewableAllowance(address _owner, address _spender)
        external
        view
        returns (uint256 amount, uint256 recoveryRate, uint64 expiration);
}

Rationale

Renewable allowances can be implemented with discrete resets per time cycle. However, a continuous recoveryRate allows for more flexible use cases not bound by reset cycles and can be implemented with simpler logic.

Backwards Compatibility

Existing EIP-20 token contracts can delegate allowance enforcement to a proxy contract that implements this specification.

Security Considerations

This EIP introduces a stricter set of constraints compared to EIP-20 with unlimited allowances. However, when _recoveryRate is set to a large value, large amounts can still be transferred over multiple transactions.

Applications that are not EIP-5827-aware may erroneously infer that the value returned by allowance(address _owner, address _spender) or included in Approval events is the maximum amount of tokens that _spender can spend from _owner. This may not be the case, such as when a renewable allowance is granted to _spender by _owner.

Changelog

  • 28 Oct: Renamed event & approve function, change recoveryRate type & added extended interfaces
  • 1 Nov: Added InsufficientRenewableAllowance error, clarified Transfer event within proxy
  • 4 Nov: Align to natspec, rephrase text copy
5 Likes

Sharing some comments & considerations being discussed out of this forum:

  • Using the same approve function can be confusing and breaks existing DX and tests. We are consider renaming it to approveRenewable
  • Renaming event name SetRenewableAllowance → RenewableApproval
  • "While reading the EIP, “initial maximum approval” and “initial and maximum allowance given to spender” wasn’t immediately clear to me. It took me a few minutes to get what it meant. The confusing part is the “initial”. which combine with “maximum” may cause some confusion. "
  • Should the interface inherit from IERC20?
  • uint192 vs uint256 for _recoveryRate
1 Like

Super interesting! Opens up possible innovations on how we treat crypto, more of as a spending asset i/o solely for degen plays.

I can see a future where each of us has a main cold wallet, with multiple “off-shoot” wallets each with auto-renewing allowance that enables spending from the main wallet without pre-funding.

Imagine having segregated wallet for degen plays, yield farming, gaming etc without having to manually transfer tokens everywhere.

1 Like

This is great as it enables the Direct Debit process (regular collections of different amount within limit) as it is know in TradFi.

Is there any other EIP, that would be relevant for Direct Debit? (i.e. it has some relationship with this one?)

@zlace Do you expect the new ERC20 tokens to rather implement the extension natively or to be the base token for Subterra Funnels?

Introducing a continuous recovery rate for allowances might result in higher gas costs for users, particularly if frequent updates are required. It’s important to consider the balance between security, convenience, and efficiency, especially given the potential impact on users’ experience and adoption.

We expect funnels to be the most common way to use this standard unless the dapp has full control over ERC20 token deployment.

Can you be more concrete with some case example?