はじめに

『DApps開発入門』という本や色々記事を書いているかるでねです。

今回は、時間経過とともににallowanceしているERC20トークンの額を増加させていく仕組みを提案しているERC5827についてまとめていきます!

以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。

他にも様々なEIPについてまとめています。

概要

ERC5827は、ERC20の承認メカニズムに「renewable allowance」を追加する提案です。 具体的には、recoveryRate というパラメータを導入し、トークンの許可額が時間の経過とともに回復する仕組みになっています。 例えば、1秒ごとに一定量のトークンが元の最大許可額に向かって回復していくイメージです。

動機

ERC20トークンでは、所有者が特定のアカウント(spender)に対して、一定量のトークンを使う許可(allowance)を与えることができます。 しかし、この仕組みは定期的な支払い(例:サブスクリプション、給与、積立投資など)には適していません。

現状では、多くのDAppsがこの問題を回避するために、ユーザーに「必要以上に大きな額」または「無制限のapprove」を求めています。 しかし、これにはセキュリティリスクが伴います。 悪意のあるDAppが、ユーザーの許可範囲内で資金を一気に引き出してしまう可能性があるからです。 多くのユーザーは、この許可を与えることのリスクを十分に理解していない場合もあります。

なぜrenewable allowanceが必要なのか

renewable allowanceを導入することで、クレジットカードの利用限度額やデビットカードのチャージ残高のような仕組みをブロックチェーン上で実現できます。

例えば、アカウントの所有者は以下のようなルールを設定できます。

  • 「1日あたり〇〇トークンまで使える」
  • 「一定期間ごとに使える額が自動で回復する」

これにより、不正利用のリスクを抑えつつ、定期的な支払いをスムーズに行えるようになります。

仕様

ERC5827では、ERC20の承認(allowance)に「自動回復」の仕組みを追加するための仕様を定義しています。 IERC5827という新しいインターフェースを導入し、承認されたトークンの利用可能額が時間とともに回復するようにします。

また、既存のERC-20トークンでもこの仕組みを使えるようにするためのプロキシコントラクトや、一定時間後に自動的に失効する有効期限付きバージョン(IERC5827Expirable)についても規定されています。

js
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);
}

主要機能

自動回復型allowance(IERC5827)

IERC5827では、トークンの承認(approve)にrecoveryRateというパラメータを追加し、指定された額まで毎秒回復する仕組みを提供します。

js
approveRenewable(address _spender, uint256 _value, uint256 _recoveryRate)
  • 指定したアカウント(_spender)に対し、最大 _value のallowanceを付与し、毎秒 _recoveryRate ずつ回復するようにします。
  • _recoveryRate は _value を超えてはいけません。
  • 実行時には RenewableApproval イベントが発行されます。
js
renewableAllowance(address _owner, address _spender)
  • _owner が _spender に与えた最大allowanceと回復速度(recoveryRate)を取得できます。

この仕組みにより、ユーザーは無制限の承認を与えるリスクを避けつつ、定期的な支払い(例:サブスク料金や積立投資)を安全に行うことができます。

既存のERC20トークンとの互換性

既存のERC20の approve(address _spender, uint256 _value) も引き続き使えます。 ただし、この場合、回復速度(recoveryRate)は0になります。

また、allowance() と transferFrom() も、回復型allowanceに対応できるように改修が必要です。

プロキシコントラクト(IERC5827Proxy)

js
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);
}

既存のERC20トークンでも、直接コードを変更せずにこの機能を利用できるようにするための仕組みです。

  • baseToken()
    • もともとのERC20トークンのアドレスを取得するための関数です。
  • transfer()
    • 直接のtransferは行いますが、元のERC20トークンがすでに Transfer イベントを発行しているため、新たに Transfer イベントを発行しない 仕様になっています。

このプロキシを使えばERC20の仕様を変えずに、新しい機能だけを追加できます。

有効期限付きallowance(IERC5827Expirable)

js
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);
}

時間が経過すると自動的にallowanceが失効するバージョンです。

js
approveRenewable(address _spender, uint256 _value, uint256 _recoveryRate, uint64 _expiration)
  • _spender に _value のallowanceを与え、 _recoveryRate で回復させつつ、_expiration の時刻を超えたら失効するように設定します。
js
renewableAllowance(address _owner, address _spender)
  • 最大allowance、回復速度に加えて失効時刻(expiration)も取得できるようになっています。

この仕組みを使えば、例えば「1ヶ月だけ有効な支払い枠」などを設定でき、不要な承認を放置するリスクを減らすことができます。

補足

renewable allowanceは、一定時間ごとにリセットする方法でも実装できます。 しかし、継続的に回復する recoveryRate を採用することでより柔軟に使えるようになります。

この方法により、決まった時間ごとのリセットに縛られずにトークンの利用可能額が常にスムーズに回復するので、シンプルなロジックで管理しやすくなります。

互換性

ERC5827を使うために、新しくERC20トークンを作り直す必要はありません。

すでに発行されているERC20トークンでも、プロキシコントラクト(IERC5827Proxy)を利用すれば、renewable allowanceの機能を後から追加できます。

参考実装

最小限の実装コードが提供されています。

ERC5827.sol
// SPDX-License-Identifier: CC0-1.0
pragma solidity 0.8.17;

import "openzeppelin-contracts/token/ERC20/ERC20.sol";
import "./IERC5827.sol";

contract ERC5827 is ERC20, IERC5827 {
    struct RenewableAllowance {
        uint256 amount;
        uint192 recoveryRate;
        uint64 lastUpdated;
    }

    // owner => spender => renewableAllowance
    mapping(address => mapping(address => RenewableAllowance))
        private rAllowance;

    constructor(
        string memory name_,
        string memory symbol_
    ) ERC20(name_, symbol_) {}

    function approve(
        address _spender,
        uint256 _value
    ) public override(ERC20, IERC5827) returns (bool success) {
        address owner = _msgSender();
        _approve(owner, _spender, _value, 0);
        return true;
    }

    function approveRenewable(
        address _spender,
        uint256 _value,
        uint256 _recoveryRate
    ) public override returns (bool success) {
        address owner = _msgSender();
        _approve(owner, _spender, _value, _recoveryRate);
        return true;
    }

    function _approve(
        address _owner,
        address _spender,
        uint256 _value,
        uint256 _recoveryRate
    ) internal virtual {
        require(
            _recoveryRate <= _value,
            "recoveryRate must be less than or equal to value"
        );

        rAllowance[_owner][_spender] = RenewableAllowance({
            amount: _value,
            recoveryRate: uint192(_recoveryRate),
            lastUpdated: uint64(block.timestamp)
        });

        _approve(_owner, _spender, _value);
        emit RenewableApproval(_owner, _spender, _value, _recoveryRate);
    }

    /// @notice fetch amounts spendable by _spender
    /// @return remaining allowance at the current point in time
    function allowance(
        address _owner,
        address _spender
    ) public view override(ERC20, IERC5827) returns (uint256 remaining) {
        return _remainingAllowance(_owner, _spender);
    }

    /// @dev returns the sum of two uint256 values, saturating at 2**256 - 1
    function saturatingAdd(
        uint256 a,
        uint256 b
    ) internal pure returns (uint256) {
        unchecked {
            uint256 c = a + b;
            if (c < a) return type(uint256).max;
            return c;
        }
    }

    function _remainingAllowance(
        address _owner,
        address _spender
    ) private view returns (uint256) {
        RenewableAllowance memory a = rAllowance[_owner][_spender];
        uint256 remaining = super.allowance(_owner, _spender);

        uint256 recovered = uint256(a.recoveryRate) *
            uint64(block.timestamp - a.lastUpdated);
        uint256 remainingAllowance = saturatingAdd(remaining, recovered);
        return remainingAllowance > a.amount ? a.amount : remainingAllowance;
    }

    /// @notice fetch approved max amount and recovery rate
    /// @return amount initial and maximum allowance given to spender
    /// @return recoveryRate recovery amount per second
    function renewableAllowance(
        address _owner,
        address _spender
    ) public view returns (uint256 amount, uint256 recoveryRate) {
        RenewableAllowance memory a = rAllowance[_owner][_spender];
        return (a.amount, uint256(a.recoveryRate));
    }

    /// @notice transfers base token with renewable allowance logic applied
    /// @param from owner of base token
    /// @param to recipient of base token
    /// @param amount amount to transfer
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) public override(ERC20, IERC5827) returns (bool) {
        address spender = _msgSender();
        _spendAllowance(from, spender, amount);
        _transfer(from, to, amount);
        return true;
    }

    function _spendAllowance(
        address owner,
        address spender,
        uint256 amount
    ) internal virtual override {
        (uint256 maxAllowance, ) = renewableAllowance(owner, spender);
        if (maxAllowance != type(uint256).max) {
            uint256 currentAllowance = _remainingAllowance(owner, spender);
            if (currentAllowance < amount) {
                revert InsufficientRenewableAllowance({
                    available: currentAllowance
                });
            }

            unchecked {
                _approve(owner, spender, currentAllowance - amount);
            }
            rAllowance[owner][spender].lastUpdated = uint64(block.timestamp);
        }
    }

    function supportsInterface(
        bytes4 interfaceId
    ) public view virtual returns (bool) {
        return interfaceId == type(IERC5827).interfaceId;
    }
}

セキュリティ

ERC5827では、無制限のallowaceを許可するERC20よりもより厳格なルールを導入しています。 しかし、recoveryRate が大きく設定されると、短時間で大量のトークンを送ることが可能になるため注意が必要です。

また、ERC5827に対応していないアプリは、allowance() の返り値や Approval イベントに記録された値を最大利用可能額だと誤解する可能性があります。 しかし、実際にはトークンのallowanceは時間とともに回復するため、これらのアプリでは誤った判断をする恐れがあります。

アプリ開発者は、ERC5827の特性を理解して誤った残高計算をしないように設計することが重要です。

引用

zlace (@zlace0x), zhongfu (@zhongfu), edison0xyz (@edison0xyz), "ERC-5827: Auto-renewable allowance extension [DRAFT]," Ethereum Improvement Proposals, no. 5827, October 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5827.

最後に

今回は「時間経過とともににallowanceしているERC20トークンの額を増加させていく仕組みを提案しているERC5827」についてまとめてきました! いかがだったでしょうか?

質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!

他の媒体でも情報発信しているのでぜひ他も見ていってください!