ERC-7303 - Token-Controlled Token Circulation

Created 2023-07-09
Status Draft
Category ERC
Type Standards Track
Authors
Requires

Abstract

This ERC introduces an access control scheme termed Token-Controlled Token Circulation (TCTC). By representing the privileges associated with a role as an ERC-721 or ERC-1155 token (referred to as a control token), the processes of granting or revoking a role can be facilitated through the minting or burning of the corresponding control token.

Motivation

There are numerous methods to implement access control for privileged actions. A commonly utilized pattern is "role-based" access control as specified in ERC-5982. This method, however, necessitates the use of an off-chain management tool to grant or revoke required roles through its interface. Additionally, as many wallets lack a user interface that displays the privileges granted by a role, users are often unable to comprehend the status of their privileges through the wallet.

Use Cases

This ERC is applicable in many scenarios where role-based access control as described in ERC-5982 is used. Specific use cases include:

Mint/Burn Permission: In applications that circulate items such as tickets, coupons, membership cards, and site access rights as tokens, it is necessary to provide the system administrator with the authority to mint or burn these tokens. These permissions can be realized as control tokens in this scheme.

Transfer Permission: In some situations within these applications, it may be desirable to limit the ability to transfer tokens to specific agencies. In these cases, an agency certificate is issued as a control token. The ownership of this control token then provides the means to regulate token transfers.

Address Verification: Many applications require address verification to prevent errors in the recipient's address when minting or transferring target tokens. A control token is issued as proof of address verification to users, which is required by the recipient when a mint or transfer transaction is executed, thus preventing misdeliveries. In some instances, this control token for address verification may be issued by a government agency or specific company after an identity verification process.

Agent Permission: When a task is delegated to an autonomous agent (for example, an AI agent operating its own account), the principal grants a capability by minting a control token to the agent's account, and revokes it instantly by burning that token. The agent's authority is verifiable by anyone through balanceOf, and the principal retains an on-chain kill switch that requires no off-chain permission server.

Specification

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

  1. Smart contracts implementing the ERC-7303 standard MUST represent the privilege required by the role as an ERC-721 token or ERC-1155 token. The tokens that represent privileges are called control tokens in this ERC. The control token can be any type of token, and its transactions may be recursively controlled by another control token.
  2. To associate the required control token with the role, the address of the previously deployed contract for the control token MUST be used.
  3. To ascertain whether an account possesses the necessary role, it SHOULD be confirmed that the balance of the control token exceeds 0, utilizing the balanceOf method defined in ERC-721 or ERC-1155. Note that the typeId must be specified if an ERC-1155 token is used for the balanceOf method.
  4. To grant a role to an account, a control token representing the privilege SHOULD be minted to the account using safeMint method defined in ERC-5679.
  5. To revoke a role from an account, the control token representing the privilege SHOULD be burned using the burn method defined in ERC-5679.
  6. A role in a compliant smart contract is represented in the format of bytes32. It's RECOMMENDED the value of such role is computed as a keccak256 hash of a string of the role name, in this format: bytes32 role = keccak256("<role_name>") such as bytes32 role = keccak256("MINTER").
  7. Compliant smart contracts MUST expose their role structure through the IERC7303 interface defined below, so that the association between roles and control tokens is discoverable on-chain by third parties and tooling.
  8. Compliant smart contracts MUST emit the ERC721ControlTokenAdded or ERC1155ControlTokenAdded event when a control token is associated with a role, so that indexers can track the role structure.
  9. Compliant smart contracts MUST implement ERC-165 and return true for the IERC7303 interface identifier.
interface IERC7303 {
    /// @notice Emitted when an ERC-721 control token is associated with `role`.
    event ERC721ControlTokenAdded(bytes32 indexed role, address indexed contractId);

    /// @notice Emitted when an ERC-1155 control token is associated with `role`.
    event ERC1155ControlTokenAdded(bytes32 indexed role, address indexed contractId, uint256 indexed typeId);

    /// @notice Check whether `account` currently holds `role`, per the
    ///         balance check described in this ERC.
    function hasRole(bytes32 role, address account) external view returns (bool);

    /// @notice Enumerate the ERC-721 control tokens associated with `role`.
    function getERC721ControlTokens(bytes32 role) external view returns (address[] memory contractIds);

    /// @notice Enumerate the ERC-1155 control tokens associated with `role`.
    function getERC1155ControlTokens(bytes32 role) external view returns (address[] memory contractIds, uint256[] memory typeIds);
}

Rationale

The choice to utilize ERC-721 or ERC-1155 token as the control token for privileges enhances visibility of such privileges within wallets, thus simplifying privilege management for users.

Generally, when realizing privileges as tokens, specifications like Soulbound Token (e.g., ERC-5192) are used. Given that ERC-5192 inherits from ERC-721, this ERC has chosen ERC-721 as the requirement for the control token.

Employing a transferable control token can cater to scenarios where role delegation is necessary. For example, when an authority within an organization is replaced or on vacation, the ability to transfer their privileges to another member becomes possible. The decision to designate the control token as transferable will depend on the specific needs of the application.

Earlier versions of this ERC deliberately defined no interface: the party that configured a contract's roles was assumed to know its role structure, having designed it. Autonomous agents break this assumption — an agent exercising delegated authority is not the designer of the permission structure it operates under — so the contract itself must describe its role structure machine-readably. The IERC7303 interface exists for this purpose.

The hasRole(bytes32, address) signature deliberately matches the function of the same name in widely deployed role-based access control implementations (e.g., OpenZeppelin AccessControl), so that consumers can query role membership without knowing whether the answer is backed by an internal mapping or by control token balances; only the enumeration getters expose the token-based structure to consumers that need it. Note, however, that IERC7303 intentionally contains no grantRole / revokeRole: granting and revoking are mint and burn on the separately deployed control token contracts, an authority the target contract does not — and should not — hold. The interface exposes only what the target contract can truthfully answer. Enumeration is deliberately per-standard (getERC721ControlTokens / getERC1155ControlTokens) rather than a single combined getter, because ERC-721 entries carry no typeId and any sentinel value would be ambiguous with a legitimate ERC-1155 typeId.

Backwards Compatibility

This ERC is designed to be compatible for ERC-721, ERC-1155, and ERC-5679 respectively.

Reference Implementation

ERC-7303 provides a modifier to facilitate the implementation of TCTC access control in applications. This modifier checks if an account possesses the necessary role. ERC-7303 also includes a function that grants a specific role to a designated account, and implements the IERC7303 introspection interface.

// SPDX-License-Identifier: Apache-2.0

pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "./IERC7303.sol";

abstract contract ERC7303 is IERC7303 {
    struct ERC721Token {
        address contractId;
    }

    struct ERC1155Token {
        address contractId;
        uint256 typeId;
    }

    mapping (bytes32 => ERC721Token[]) private _ERC721_Contracts;
    mapping (bytes32 => ERC1155Token[]) private _ERC1155_Contracts;

    modifier onlyHasToken(bytes32 role, address account) {
        require(_checkHasToken(role, account), "ERC7303: not has a required token");
        _;
    }

    /**
     * @notice Check whether `account` currently holds `role`.
     */
    function hasRole(bytes32 role, address account) public view returns (bool) {
        return _checkHasToken(role, account);
    }

    /**
     * @notice Enumerate the ERC-721 control tokens associated with `role`.
     */
    function getERC721ControlTokens(bytes32 role) public view returns (address[] memory contractIds) {
        ERC721Token[] memory tokens = _ERC721_Contracts[role];
        contractIds = new address[](tokens.length);
        for (uint i = 0; i < tokens.length; i++) {
            contractIds[i] = tokens[i].contractId;
        }
    }

    /**
     * @notice Enumerate the ERC-1155 control tokens associated with `role`.
     */
    function getERC1155ControlTokens(bytes32 role) public view returns (address[] memory contractIds, uint256[] memory typeIds) {
        ERC1155Token[] memory tokens = _ERC1155_Contracts[role];
        contractIds = new address[](tokens.length);
        typeIds = new uint256[](tokens.length);
        for (uint i = 0; i < tokens.length; i++) {
            contractIds[i] = tokens[i].contractId;
            typeIds[i] = tokens[i].typeId;
        }
    }

    /**
     * @notice Grant a role to user who owns a control token specified by the ERC-721 contractId. 
     * Multiple calls are allowed, in this case the user must own at least one of the specified token.
     * @param role byte32 The role which you want to grant.
     * @param contractId address The address of contractId of which token the user required to own.
     */
    function _grantRoleByERC721(bytes32 role, address contractId) internal {
        require(
            IERC165(contractId).supportsInterface(type(IERC721).interfaceId),
            "ERC7303: provided contract does not support ERC721 interface"
        );
        _ERC721_Contracts[role].push(ERC721Token(contractId));
        emit ERC721ControlTokenAdded(role, contractId);
    }

    /**
     * @notice Grant a role to user who owns a control token specified by the ERC-1155 contractId. 
     * Multiple calls are allowed, in this case the user must own at least one of the specified token.
     * @param role byte32 The role which you want to grant.
     * @param contractId address The address of contractId of which token the user required to own.
     * @param typeId uint256 The token type id that the user required to own.
     */
    function _grantRoleByERC1155(bytes32 role, address contractId, uint256 typeId) internal {
        require(
            IERC165(contractId).supportsInterface(type(IERC1155).interfaceId),
            "ERC7303: provided contract does not support ERC1155 interface"
        );
        _ERC1155_Contracts[role].push(ERC1155Token(contractId, typeId));
        emit ERC1155ControlTokenAdded(role, contractId, typeId);
    }

    function _checkHasToken(bytes32 role, address account) internal view returns (bool) {
        ERC721Token[] memory ERC721Tokens = _ERC721_Contracts[role];
        for (uint i = 0; i < ERC721Tokens.length; i++) {
            if (IERC721(ERC721Tokens[i].contractId).balanceOf(account) > 0) return true;
        }

        ERC1155Token[] memory ERC1155Tokens = _ERC1155_Contracts[role];
        for (uint i = 0; i < ERC1155Tokens.length; i++) {
            if (IERC1155(ERC1155Tokens[i].contractId).balanceOf(account, ERC1155Tokens[i].typeId) > 0) return true;
        }

        return false;
    }
}

The following is a simple example of utilizing ERC7303 within an ERC-721 token to define "minter" and "burner" roles. Accounts possessing these roles are allowed to create new tokens and destroy existing tokens, facilitated by specifying ERC-721 or ERC-1155 control tokens:

// SPDX-License-Identifier: Apache-2.0

pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "./ERC7303.sol";

contract MyToken is ERC721, ERC7303 {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC721("MyToken", "MTK") {
        // Specifies the deployed contractId of ERC721 control token.
        _grantRoleByERC721(MINTER_ROLE, 0x...);
        _grantRoleByERC721(BURNER_ROLE, 0x...);

        // Specifies the deployed contractId and typeId of ERC1155 control token.
        _grantRoleByERC1155(MINTER_ROLE, 0x..., ...);
        _grantRoleByERC1155(BURNER_ROLE, 0x..., ...);
    }

    function safeMint(address to, uint256 tokenId)
        public onlyHasToken(MINTER_ROLE, msg.sender)
    {
        _safeMint(to, tokenId);
    }

    function burn(uint256 tokenId) 
        public onlyHasToken(BURNER_ROLE, msg.sender) 
    {
        _burn(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public view override returns (bool)
    {
        return interfaceId == type(IERC7303).interfaceId || super.supportsInterface(interfaceId);
    }
}

Security Considerations

The security of tokens subject to circulation depends significantly on the security of the control tokens. Careful consideration must be given to the settings regarding the administrative privileges, mint/transfer/burn permissions, and the possibility of contract updates of control tokens.

In particular, making control tokens transferable allows for flexible operations, such as the temporary delegation of administrative rights. However, it also raises the possibility that the rights to circulate tokens could fall into the hands of inappropriate third parties. Therefore, control tokens should generally be made non-transferable. If control tokens are to be made transferable, at the very least, the authority to burn these tokens should be retained by a trusted administrator.

When a control token is granted to an autonomous agent (for example, an AI agent operating its own account), the control token SHOULD be non-transferable, and MUST be burnable by its issuer — or by another account controlled by the principal — without the cooperation of the holder. Otherwise the principal cannot unilaterally revoke the delegated capability, and the kill switch this scheme provides is lost. Note that a holder-initiated burn function alone (as provided by common burnable-token extensions) does not satisfy this requirement, since revocation would then depend on the agent's cooperation.

The check defined in this ERC applies to accounts, not keys. Under a smart-contract account (for example, ERC-4337) operated with session keys, the modifier observes only msg.sender; a positive check therefore authorizes whichever key currently controls that account. Similarly, when the holder's controlling addresses can rotate — as with agent identity registries whose operational wallets are mutable — it is RECOMMENDED to bind control tokens to a stable account derived from the identity (for example, an ERC-6551 token bound account of an identity NFT) rather than to an operational wallet.

Finally, role gating is class-level authorization: it determines whether an account may perform a class of actions at all, and it deliberately does not judge whether a specific invocation is sound — an amount that is too large, an action based on stale inputs, or an instruction that is adversarial yet within the granted role will all pass the check. Deployments that delegate authority to autonomous agents SHOULD therefore compose this scheme with instance-level controls, such as account-side execution policies (for example, spending caps), pre-action validation, or human-in-the-loop escalation. These controls operate independently of this ERC and require no changes to it.

Copyright

Copyright and related rights waived via CC0.