ERC-7303: Token-Controlled Token Circulation

Discussion thread for ERC-7303.

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.

After draft PR merged: ERC-7303: Token-Controlled Token Circulation
Original PR: Add EIP: Token-Controlled Token Circulation by kofujimura · Pull Request #7303 · ethereum/EIPs · GitHub

For additional documentation on the use cases of the TCTC, see here.

3 Likes

Interesting. Access rights represented as NFTs can have some social / statutory / marketing vibe.

However - NFTs are user transferable by design. I doubt that it fits for the access management. Soulbound tokens could be used instead - e.g. using ERC 5192 or 5727.

Regarding reference implementation, it is better to avoid arrays + for loops.

Thank you for your valuable feedback.

ERC-5192 is ERC-721, so it’s not explicitly stated in the current draft of ERC-7303 (TCTC), but it is possible to use ERC-5192’s Soulbound as a control token. In fact, I believe that ERC-5192 is desirable in many use cases. However, if ERC-5192 is REQUIRED, transferable tokens can no longer be used for access control. Depending on the application, there may be cases where you want to implement permissions as tokens and temporarily allow them to be transferred to others. For these reasons, I decided to REQUIRE ERC-721 instead of ERC-5192. I think that the issuer of the target token should choose ERC-5192 or ERC-721 as the control token at their discretion.

On the other hand, since ERC-5727 does not inherit ERC-721, it does not provide balanceOf(), but instead manages the amount per slot. Therefore, ingenuity is required to use it as a control token. If many needs are anticipated, it may be possible to use ERC-5727’s verify() instead of balanceOf(). It may be difficult to incorporate it into TCTC as a generic implementation, so I gave up in this draft. However, if there are many requests, I would like to make ERC-5727 one of the control tokens that TCTC can use.

In real-world business transactions, there are many use cases where you want to grant permissions when you own any of multiple control tokens. Therefore, in TCTC, we made it possible to call _grantRoleByToken() multiple times. Is it better to avoid this? I would appreciate more explanation.

ERC5192 and 5727 were given just as an example, ERC 4671 could have been better one.

transferable tokens can no longer be used for access control

Indeed, transferability should be the primary conceptual discussion.
You mentioned This ERC introduces an access control scheme , for such schemes it is common some authority grants / revokes the access rights.

Can you please identify the case where transferability of the access rights without authority’s approval is desired?

arrays + for loops
In real-world business transactions, there are many use cases where you want to grant permissions when you own any of multiple control tokens.

Can I assume the concept of 1 NFT = 1 (access) right?

While I think ERC-4671 is a good specification, currently the mainstream implementation of SBT is ERC-5192. As TCTC, I thought it would be sufficient to cover SBTs that inherit ERC-721. If there is a majority opinion that ERC-4671 should also be usable as a control token for TCTC, I would like to modify the specification.

For instance, consider a franchised shop. Suppose the franchisor issues the right to distribute coupons to the franchisee as a Minter Cert token. What about cases where the store manager of the franchisee temporarily delegates the right to issue coupons to a deputy manager due to illness or travel? In this case, if it’s non-transferable, they would have to request the franchisor to issue or invalidate the Minter Cert every time they go on a trip or fall ill. This would be very inconvenient.

It depends on the application, so it can’t be generally assumed. Of course, in certain applications, it’s possible to deploy a new control token for each role. However, there are also scenarios you could imagine, such as various applications designating an individual authentication token issued by a government agency as the necessary control token for the recipient of the target token.

Based on our discussion about using transferable tokens as control tokens, I’ve added Rationale part to the draft.

Nice idea. Have you considered integrating ERC-5982: Role-based Access Control so the events and roles can be checked in the same way as contracts which implements non-token access control?

1 Like

Thank you for your feedback. I believe the point you raised is precisely where the rationale of the ERC was lacking in explanation.

The most significant feature of TCTC is that the contract defining the control token can be deployed separately from the contract of the target token being controlled. Therefore, in order to implement grantRole() or revokeRole() within the contract of the target token, it’s necessary to mint or burn the control token. For this purpose, it’s required to grant the mint/burn privilege of the control token to the contract of the target token. However, in many cases, the control token is deployed first, which makes it extremely complicated and impractical to achieve this.

On the other hand, hasRole() can be easily implemented using the _checkHasToken() provided by ERC7303.sol. While I think it wouldn’t be beneficial to provide only hasRole() without being able to offer grantRole() and revokeRole(), what do you think?

@xinbenlv Thank you for your comment. Indeed, the previous version could not be said to support ERC-1155. In the new version, I have made changes to allow the use of ERC-1155 tokens in addition to ERC-721 as control tokens, and I have adjusted the reference implementation accordingly. For ERC-1155 tokens, a typeId is required as a parameter for balanceOf, so I made the function _grantRoleByToken() separate from that of ERC-721.

1 Like

Thanks for your explanation and outlining the challenges.

Correct me if I am wrong, but currently to grant a role, you would have to mint a control token and execute _grantRoleByERC721(). However to revoke a role, you would have to burn the control token. The makes it difficult to track the control tokens which have roles - you would have to check for all tokens that you have granted a role previously whether they still exist.

How about an alternative where granting and revoking the roles require a call from the contract itself? The control tokens can still be deployed separately, but are not tied to mint / burn. And you could revoke role without burning the token.

Managing privileges and token ownership separately can increase flexibility in some cases, but I generally think that it only complicates management.

For example, if you decide to grant a store manager the privilege of minting coupon tokens, under the TCTC schema, you would issue a token that proves the manager’s position, and grant the privilege of minting coupons to that token. In other words, it’s a privilege given to a position holder. If the position token becomes invalidated due to the manager’s resignation, you can automatically remove the privilege of minting coupons. It’s very simple.

On the other hand, if the minting/burning of position tokens is not directly associated, you need to revoke the privileges related to the store manager’s position one by one. If a single position has multiple privileges, management becomes complicated.

There are many use cases where it becomes simpler to directly issue privileges as a token and specify that token for the transaction’s execution privilege.

A control token is typically something like the position token in the above example, and whether or not a specific address holds a position can be easily verified by simply querying the blockchain. Indeed, it might be cumbersome if many control tokens are associated with one role, but in the use cases I envision, most are of one type, and at most two or three types.

1 Like

It’s been a while since this proposal was published, and I’d like to share an update: the rise of autonomous AI agents has made the problem ERC-7303 addresses much more pressing.

ERC-7303 has always been about access control through token ownership — grant a role by minting a control token, revoke it by burning. What has changed is who holds the permissions. When the holder is an AI agent acting on your behalf, the properties of this design become critical:

  • Instant revocation: burn the control token and the agent loses the capability at its next transaction — a kill switch with no permission server, since the on-chain onlyHasToken modifier is the enforcement point.

  • Public verifiability: anyone can audit an agent’s authority with a balanceOf call.

Relationship to ERC-8004 (Trustless Agents): the two are complementary. ERC-8004 answers who is this agent (identity, reputation, validation) and deliberately leaves authorization undefined; ERC-7303 answers what is it allowed to do. Because an agent’s controlling addresses are mutable (setAgentWallet, NFT transfers), the recommended binding is to mint control tokens to the agent NFT’s ERC-6551 Token Bound Account — this works today, with zero changes to any of the three standards. Details: CONCEPT.md (tctc-mcp/docs/CONCEPT.md at main · kofujimura/tctc-mcp · GitHub)

To make this concrete, I’ve shipped:

Building this surfaced a few spec-level lessons — I’ll post those separately. Feedback welcome, especially from anyone working on agent authorization.

Clean framing, and the who/what split with ERC-8004 holds up. Worth naming the layer that sits above both: 7303 gates whether the agent can perform the action class at all, but not whether this specific instance of it is sound right now (position sizing, a stale input, a prompt-injected instruction that’s still within the granted role). Instant revocation via burn is the right blunt tool for a compromised key; it doesn’t catch a well-behaved key making a bad call inside its own grant. Complementary problem, not a competing one - happy to dig into where a pre-action check could plug into the tctc-mcp flow if useful.

Agreed on all points — and this maps cleanly onto how tctc-mcp is already structured, so let me name the seams where a pre-action check would plug in:

1. Agent-side (MCP flow). The delegation rules shipped with the skill mandate check_role before any privileged action, so the flow is: check the role, build the transaction, send it. A per-instance check slots naturally between building and sending: a validate_action(role, target, calldata, context) call answered by a policy/validation server. I’d keep that out of tctc-mcp itself — it’s deliberately scoped to authorization — but MCP makes the composition trivial: two servers, one mandated ordering in the agent’s rules.

2. On-chain. onlyHasToken gates the target side; account-side execution modules (spend caps, HITL — like the DailyBudgetModule work in the ERC-8004 thread) gate the instance. They enforce independently and compose without touching either standard. Role gate says may, module says this one, now, this size.

Yes to digging in — the interface between check_role and a pre-action validation call is the piece worth specifying. What would the check consume: raw calldata, a structured intent, or both?

Both, and not interchangeably – structured intent is what the check reasons over, calldata is what it binds to.

A check that only sees structured intent has no guarantee the calldata actually sent matches what was judged: the same intent can be encoded multiple equivalent ways, and a decoupled intent object can silently drift from the bytes that execute (the same authorized-args != executed-args gap that shows up whenever a review step runs before the real transaction is assembled). A check that only sees raw calldata has to re-derive intent from bytes on every call, which either duplicates decode logic that already lives in the agent, or pushes reasoning down to a level too low to judge soundness cleanly.

Concretely: validate_action(structured_intent, calldata, …) where the validator’s first move is recomputing that calldata actually encodes structured_intent (not trusting the pairing), then reasoning over the intent for everything else. Binding first, judgment second. Skipping the binding step is exactly how a pre-action check gets bypassed without anyone noticing – the check ran, it just checked the wrong thing.

Binding first, judgment second — agreed. One simplification at this seam: if the structured intent is at function-argument level, canonical ABI encoding is deterministic, so binding is a pure recomputation (encodeFunctionData(intent) must byte-match the calldata) — a binding mismatch is then a distinct failure class from a policy denial, and the drift you describe survives only above that level, where the judgment belongs.
Since the validate_action interface is tooling rather than the ERC itself, I’ll move that iteration to the tctc-mcp repo — I’ll open an issue with the draft tool contract (schema, failure classes, mandated ordering) and link it from here, so this thread stays focused on the standard.
What this exchange does contribute to ERC-7303 itself: the layering should be stated in the spec’s Security Considerations — role gating is class-level by design; instance-level soundness (amounts, staleness, injected-but-in-role instructions) belongs to a composing pre-action/policy layer, not to the role gate. I’ll include that in the spec-update proposal I’m about to post in this thread.

Issue the tool contract: validate_action (added on Jul 10)

As mentioned above, implementing a real consumer (tctc-mcp) against the current spec surfaced gaps I’d like to address while this ERC is still in Draft. The concrete diff is now merged: Update ERC-7303: Add IERC7303 introspection interface and agent-delegation security considerations by kofujimura · Pull Request #1872 · ethereum/ERCs · GitHub. Summary:

1. Introspection (new spec items 7–9). The spec keeps role → control-token bindings in internal mappings, with no getters and no events, so third-party tooling cannot discover a contract’s role structure — tctc-mcp v1 must be config-driven precisely because of this. The PR adds an IERC7303 interface: hasRole(bytes32, address), getERC721ControlTokens(bytes32), getERC1155ControlTokens(bytes32), configuration events, and ERC-165 support so compliance is machine-detectable. Enumeration is per-standard rather than one combined getter because ERC-721 entries carry no typeId, and any sentinel value would be ambiguous with a legitimate ERC-1155 typeId.

2. Security considerations for agent delegation. Three additions:

  • Control tokens granted to autonomous agents SHOULD be non-transferable, and MUST be burnable by the issuer without the holder’s cooperation — otherwise revocation depends on the agent, and the kill switch fails. (Hit in practice: the original reference deployment used a holder-initiated burnable extension, which does not satisfy this.)

  • The check applies to accounts, not keys: under ERC-4337 accounts with session keys the modifier sees only msg.sender, so a positive check authorizes whichever key currently controls the account. Where controlling addresses rotate, binding control tokens to a stable identity-derived account (e.g. an ERC-6551 TBA) is RECOMMENDED.

  • Role gating is class-level authorization by design: it does not judge whether a specific invocation is sound (an oversized amount, stale inputs, an adversarial instruction still within the granted role). Deployments delegating to agents SHOULD compose it with instance-level controls — account-side execution policies, pre-action validation, human-in-the-loop. This is the layering that came directly out of the exchange upthread; the pre-action seam itself has meanwhile been specified in the open: docs/VALIDATE_ACTION.md.

Also added: an “Agent Permission” use case, and the reference implementation updated to implement the interface.

Why the interface was deliberately absent until now. ERC-7303 realizes on Ethereum a scheme I published in 1999 — Digital-Ticket-Controlled Digital Ticket Circulation (8th USENIX Security Symposium); the name TCTC is its direct descendant. In that model, whoever configures a token gated by pre-issued control tokens knows the control-token structure, because they designed it — so the contract never needed to explain its own role structure, and I kept the ERC a pure convention over balanceOf rather than a new interface. Autonomous agents break that assumption: an agent exercising delegated authority is not the designer of the permission structure it operates under, and needs to discover it at run time. The structure’s designer no longer coincides with the structure’s user — so the contract itself must become the explainer. (Note the interface still contains no grantRole/revokeRole, for the same reason discussed here back in 2023: grant and revoke are mint and burn on the separately deployed control-token contracts, an authority the target does not hold. The interface exposes only what the target can truthfully answer.)

One deliberate call I’d welcome pushback on: the introspection items are MUST, which makes pre-existing deployments formally non-compliant. Machine-detectable compliance is the point of the addition, and Draft status is exactly when such a change is cheap — but if anyone sees a reason to keep them SHOULD, now is the time.

Verified PR #1872 directly (diff, not just the summary above) – the IERC7303 interface, the events, ERC-165 support, and the agent-delegation security considerations are exactly as described, and the merge is real. Good outcome from the exchange, and the layering point stated plainly in the Security Considerations (class-level role gate vs. instance-level soundness) is the right place for it – that’s precisely the seam our own pre-action layer sits on, so having it named in-spec rather than left implicit is a real improvement for anyone building a consuming tool, not just us.

On MUST vs SHOULD for the introspection items: MUST is the right call, and I’d push back on softening it. A SHOULD only pays off if a consumer can afford to special-case “introspection present” vs “introspection absent” – but the entire value of adding IERC7303 is that a third-party validator (ours or anyone else’s) can assume the interface is there and reason over hasRole/getERC721ControlTokens/getERC1155ControlTokens directly, rather than falling back to config-driven guesses the way tctc-mcp v1 had to. A SHOULD keeps that fallback path alive forever, which defeats the machine-detectability goal the addition exists for. Draft status is exactly the moment to pay the one-time compliance cost across the (currently small) deployed set rather than carry an optional path indefinitely. Non-compliance for a handful of Draft-era deployments is a cheaper, one-time cost than permanent ambiguity for every future consumer.

Thanks for verifying the diff directly — and for the sharpest formulation of the MUST argument so far: a SHOULD keeps the fallback path alive forever, which defeats the machine-detectability the addition exists for.

The interface now has a consumer: tctc-mcp v0.2 (on npm) ships IERC7303 discovery. A role config can name just the target contract — the server reads which control tokens gate the role from the contract itself, and the verdict is the target’s own hasRole(). A new discover_roles tool introspects any address with no role configuration at all; non-compliant contracts report supportsIERC7303: false and static config remains the fallback. Recomputable on a public RPC: scripts/e2e-discovery.mjs (tctc-mcp/scripts/e2e-discovery.mjs at main · kofujimura/tctc-mcp · GitHub) runs the whole cycle against Sepolia, including the negative case against the pre-IERC7303 deployment.

Verified independently before replying: npm shows 0.2.0 as tctc-mcp’s latest, and I read scripts/e2e-discovery.mjs directly off the repo – a real, read-only E2E against Sepolia that checks list_roles/check_role/discover_roles against both an IERC7303-compliant target and the pre-IERC7303 legacy contract, asserting supportsIERC7303 true/false correctly on each. This is the loop’s next stage: not just merged spec text, a live reference consumer anyone can run against a public RPC with no keys.

One further sharpening worth naming: your legacy-contract negative case (discover_roles asserting supportsIERC7303: false) is itself a portable conformance fixture – any future IERC7303 implementer can run the same two-target check (one compliant, one not) against their own deployment and get a pass/fail that isn’t a self-report. Worth landing that pair of addresses (or a testnet equivalent) as a named fixture in the ERC’s own repo/assets, not just this consumer’s test script, so “does my contract actually implement IERC7303” has one canonical recompute path instead of each implementer inventing their own smoke test.