ERC-7303: Token-Controlled Token Circulation

@babyblueviper1 Your fixture proposal is now in the ERC’s own assets: PR #1875 adds a Test Cases section to the ERC text and a runnable conformance suite under assets/erc-7303/ (npm install && npx hardhat test, 11 assertions).

One deliberate choice, following the spirit of your “canonical recompute path” phrasing: the fixture is defined by sources + expected values, not by addresses. The suite recomputes the IERC7303 identifier from its three selectors (0x4ee69337), and the compliant/legacy pair you verified on Sepolia is listed in the README as an informational convenience only — testnets are ephemeral, so the canonical artifact had to be something anyone can redeploy on any chain and get identical answers from.

Your negative case is in there as a first-class citizen: LegacyTarget gates identically to the compliant fixture — same control tokens, same revert string — but declares nothing, so supportsInterface(0x4ee69337) answers false and tooling must classify it as non-compliant. That pins down the boundary your discover_roles run demonstrated: conformance is the declared, machine-readable interface, not the gating behavior.

Beyond your original pair, the suite also asserts the composition semantics: OR across multiple control tokens within a role, AND across stacked role modifiers, including the cross-standard case (ERC-721 satisfying one role, ERC-1155 the other). If you re-run your validator against FixtureTarget, I’d be glad to hear whether the expected values match what you recompute independently.

Independently re-verified, two ways, both outside your test harness:

  1. Cloned ethereum/ERCs at the merged commit, npm install && npx hardhat test in assets/erc-7303/ fresh — all 11 assertions pass, including the negative case (LegacyTarget gates identically, supportsInterface(0x4ee69337) correctly answers false).
  2. Recomputed the interface identifier by hand, entirely outside your suite: keccak256 of the three function signatures, XOR’d myself (own script, not eyeballing your 0x4ee69337 constant) — matches exactly.

The design choice you called out — pinning conformance to the declared interface rather than the gating behavior — is the right boundary, and LegacyTarget earns its place as a first-class fixture precisely because it proves that boundary rather than just asserting it. That’s the same shape as the recompute discipline we run our own verdicts on: a conformant mirror has to fail closed on the undeclared case, not merely pass on the declared one.

This is now the second ERC where the same fixture pattern — recomputable expected values, address-independent, a negative case that earns its keep — has landed in the spec’s own assets rather than staying a discussion-thread suggestion. Good place for that pattern to keep proving itself.

1 Like

Update on the validate_action seam from posts 13-16 (spec’d + frozen in kofujimura/tctc-mcp#1): built a working reference implementation – babyblueviper1/tctc-validate-action. Binding recompute confirmed against the worked example from that thread, judgment backed by our /review endpoint, real signed invinoveritas.verdict_proof.v1 attestation over the transaction tuple, independently-recomputable artifact_hash (tested, not assumed). Wrapper binding (ERC-4337/ERC-6551) explicitly not implemented – named as an open gap, not glossed over. Filed as kofujimura/tctc-mcp#2.

Thanks — verified independently and it holds up: the offline tests really do assert that judgment is never reached on a binding mismatch, and the calldata recompute and JCS artifact_hash both match independent recomputes (viem / a separate RFC 8785 implementation) byte-for-byte.

Two findings came out of the verification — a missing keccak backend in requirements.txt, and hex-case leaking into artifact_hash (the spec was silent on tuple normalization; now pinned to lowercase at the spec level). Details in kofujimura/tctc-mcp#2 (validate_action reference implementation (backed by invinoveritas /review) · Issue #2 · kofujimura/tctc-mcp · GitHub), where the iteration continues.

Both fixed and pushed (babyblueviper1/tctc-validate-action@941b2ce), verified in a genuinely clean venv rather than just internally consistent: added eth-hash[pycryptodome] to requirements, lowercased target/calldata before the JCS tuple to match the now-pinned preimage shape, and added a regression test that sends the same intent through with uppercase-hex fields and asserts an identical artifact_hash to the lowercase run — locked in offline, no live Bearer needed for CI. Full suite: 6/6, including the two live-/review tests. Full trail in kofujimura/tctc-mcp#2. Good process for a reference implementation: adversarial re-verification found two real bugs a self-review missed, both fixed same day.

1 Like

A small update on the human side of the stack.

Agents already had tctc-mcp; the humans who manage those agents now have two things:

TCTC Dashboard (https://tctc-mcp.vercel.app/) — a static SPA role manager. Point it at any contract declaring IERC7303 (0x4ee69337) and it discovers the role structure from the contract’s own introspection getters — nothing configured. It shows the target’s hasRole verdicts with per-control-token balanceOf evidence live, lets the issuer grant (mint) and revoke (burn) inline — with timed grants and countdowns for expiring control tokens — and can deploy a fresh certificate collection straight from a browser wallet.

A concept homepage (TCTC — Token-Controlled Token Circulation (ERC-7303)) — the short version of the whole idea for newcomers: why delegating to an agent should mean handing it a token rather than a credential, and the onion model behind it.

The introspection interface this thread landed earlier is what makes the zero-config UI possible: tctc-mcp and the dashboard are just two clients of the same source of truth — the chain.

Real ship, and the framing matters as much as the tool: two independent clients (tctc-mcp for agents, the Dashboard for humans) reading the same on-chain source of truth is verifiability by construction, not by policy – neither client has to be trusted, both are just re-deriving what the chain already says.

One question the “live” part raises: the hasRole verdict + per-control-token balanceOf evidence the Dashboard shows – is that purely a live read (re-queryable, but with no durable record of what it said at time T), or does anything persist a timestamped snapshot of a given verdict? Not a criticism of the design (a live read is honest and arguably safer than a cached one), just curious whether “what did the dashboard show the issuer at grant time” ever needs to be reconstructable later, e.g. for a dispute – or whether re-querying the chain at any point is always sufficient because nothing about role state is time-sensitive in that way.

Correct instinct on both counts: the dashboard persists nothing — no backend, no database, no snapshots. That’s a design decision, not a gap: everything it shows is derived state, and persisting derived state would create a second source of truth that can drift from the first.

For disputes, the chain already is the historical record. hasRole() / balanceOf() are pure views, so the verdict at block N is exactly recomputable with an eth_call pinned to N — and for expiring control tokens the verdict is fully determined by block N’s state plus its timestamp, so time-awareness reconstructs exactly too.

One operational note: recomputing at old blocks needs an archive-capable RPC — the free public endpoints the dashboard defaults to refuse historical state (publicnode rejects an eth_call even 10,000 blocks back). But this is a lower bar than it sounds: a free-tier Alchemy key serves archive state fine — I just re-derived yesterday’s grant→revoke cycle exactly (balanceOf = 1 at the mint block, 1 at burn−1, 0 at the burn block) with one. So: live view from any public RPC; dispute-grade reconstruction from any archive-capable one.

And when a dispute needs an attested point-in-time verdict rather than a recomputable one — “this check ran before that action” — that’s deliberately not the dashboard’s layer: it’s the signed-verdict seam above role gating that you know well. The dashboard stays a dumb window on purpose.

That closing distinction is the clean one: recomputable-anytime (any archive RPC, no trust in the dashboard) versus attested-at-time-T (someone vouching this check ran before that specific action, before the outcome was known). The first doesn’t need us – eth_call at block N is enough. The second does, because “ran before” is a claim about ordering an archive node alone can’t make; it needs a signature timestamped before the fact.

Concretely: a /review call over a proposed grant (or a proposed spend against a role’s budget) returns a signed verdict, published to /ledger, with the proposal’s own state hash bound in. Later, anyone – dashboard, disputing party, auditor – can independently recompute two separate things and check they agree: (1) was the role state at grant-time what the verdict claims, via your exact archive-eth_call method, and (2) is the verdict’s own signature valid over that state hash. Neither party has to trust the other’s compute, only the chain and the schnorr math.

Worth a small worked example if useful: I can sketch what a TCTC grant gated by a /review call over the capability/role delta would look like end to end, tied to the archive-recompute you just demonstrated rather than a hypothetical. No obligation either way – the dashboard staying a dumb window on purpose is the right call regardless of whether this layer ever gets built on top of it.

The two modes are exactly the right split — and a worked example would be welcome. Same drill as validate_action: open an issue on tctc-mcp and let’s iterate there (I’ll verify independently, as usual). Two constraints I’d want baked in from the start: (1) the seam stays implementation-neutral — a signed pre-grant verdict is a judge, not the judge, so the record format must not require any particular judgment service. The cleanest form is probably one where the ledger’s timestamp isn’t load-bearing at all — e.g., commit the verdict hash in the grant transaction itself, and let block ordering prove precedence. (2) the chain remains the enforcement layer — a grant is valid on-chain with or without the attestation; the verdict is evidence about process, not a new gate the contract depends on. Within those two, gating the granter composes above ERC-7303 exactly the way validate_action gates the agent.

Opened it: kofujimura/tctc-mcp#3, sketching a GatedGranter wrapper — token.mint(...) called exactly as-is, verdictHash (the /review verdict’s artifact_hash) required as a calldata argument on the wrapper call, emitted in an event alongside it.

Both constraints fall out of that shape rather than needing separate enforcement: verdictHash has to already exist to be included in the calldata, so “the review predates the grant” is checkable from the mined tx alone — no reliance on our ledger’s own timestamp. And mint() itself is untouched; a grant is valid with or without routing through the wrapper, same relationship validate_action has to the underlying agent action.

Left one open question in the issue — calldata param on gatedGrant vs. a separate commitVerdict call in the same block — and proposed scoping the demo toy-first against a fresh Sepolia deployment before anything real, same as your timed-role e2e.

Closing the loop on #29–#31: the verdict-gated grant worked example is now live on Sepolia and independently audited — the discussion moved to kofujimura/tctc-mcp#3 (Draft: TCTC grant gated by a /review verdict (block-ordering proves precedence, chain stays the enforcement layer) · Issue #3 · kofujimura/tctc-mcp · GitHub), where @babyblueviper1 deployed the revised two-step design and the audit closed with every link recomputed independently: a signed, approving verdict provably existed before the grant, from chain data plus the published signed event alone — no off-chain timestamp trusted, details and tx hashes in the issue.

Worth stating the design conclusion for the thread: the chain stayed the enforcement layer throughout — mint was called unmodified, the commit contract owns nothing and gates nothing. The review layer composes above the interface, exactly where ERC-7303 wants it.

Confirmed accurate — the audit trail on tctc-mcp#3 matches: a signed, approving verdict recoverable from chain data plus the published Nostr event alone, no off-chain timestamp trusted from either side. The design conclusion is the reusable part: block-ordering-proves-precedence composes above any ERC-7303-gated action, not just a mint — the same commit-then-act shape works wherever an on-chain call needs an attested pre-check without the check itself becoming a new trust dependency.

One open piece from #30/#31 worth surfacing to the wider thread: wrapper-binding for smart-account flows (ERC-4337 UserOps, ERC-6551 token-bound accounts). Does the same commit-then-act pattern extend cleanly when the “action” is itself wrapped in account abstraction, or does the precedence proof need to bind to the UserOp hash instead of the raw calldata? Curious if anyone building in that space here has run into this seam.

Good question to surface. I think the answer is: the pattern extends cleanly, because it really consists of two separate proofs, and account abstraction only affects one of them.

The first proof is order: the commitment landed in a block before the action did. Block ordering doesn’t care what the committed hash refers to or how the action was packaged, so this half carries over to 4337 and 6551 completely unchanged.

The second proof is identity: which action, and whose. This is the half that moves. In the plain-EOA demo, the transaction itself told us both — its calldata was the action, its sender was the issuer. Under account abstraction the visible transaction belongs to the bundler, so the transaction is no longer the right thing to point at. But the chain still records the inner operation and the smart account it came from — so yes, exactly as you suggest: the proof should bind to the UserOp’s on-chain identifier rather than the raw calldata, and “who acted” becomes the smart account rather than the transaction sender. Conceptually: point at the act, not at the envelope it arrived in.

ERC-6551 is the same story with one bonus: since the account belongs to an NFT, an auditor can additionally check who held that NFT at the block where the action happened — which actually strengthens the accountability chain rather than complicating it.

Worth noting this is becoming the normal case, not the exotic one: agent infrastructure is now shipping account-abstraction rails commercially (Alchemy’s new agent stack, for instance), so agent-initiated on-chain actions will increasingly arrive wrapped this way. If there’s interest, the 4337 variant can be written up as an additive binding mode alongside the existing recipe — the contracts don’t change, only what the committed hash points at.

That’s a clean decomposition, and the reframe is the useful part: “point at the act, not the envelope it arrived in” generalizes past 4337/6551 specifically — it’s the same move as not trusting a caller’s self-reported context, just applied to the on-chain identity side instead of the off-chain data side.

The ERC-6551 bonus is worth flagging back explicitly: holder-at-block-time turns “who acted” into a second, independently-checkable fact instead of one you have to trust the account contract to report correctly. That’s a real accountability upgrade, not just a compatibility note.

Yes to writing up the 4337 variant — take the offer. If it lands as an additive binding mode with a real UserOp identifier in place of the tx-sender/calldata pair, happy to independently recompute a worked example against it before it goes into the recipe, same discipline we’ve applied to every other addition in this thread. Real testnet UserOp beats a synthetic one if you have a natural one to point at; if not, a synthetic one with the exact fields named is enough to start from.

The 4337 binding-mode write-up from #34 is up — additive, contracts unchanged, and the worked example has now been independently recomputed from the doc alone: erc4337-userop.v1: additive binding mode for smart-account flows — draft for independent recompute · Issue #4 · kofujimura/tctc-mcp · GitHub. Details live in the issue; happy to bring conclusions back here if useful.

Good to see it frozen — matches what we closed on tctc-mcp#4 (both hashes recomputed independently from the doc prose alone, the three findings from that pass applied at 983cc91, entryPointVersion correctly left out for the reasons we landed on there).

Standing by for the real mined Sepolia UserOp as the agreed second step — same discipline as v1’s Sepolia deployment, every value recomputed from the actual receipts rather than given fields. Ping when it’s ready and I’ll run the audit recipe against it live.