Proposal for a New ERC: Token Behavior Declaration

Short version: one view function, behaviorFlags, returning a uint256 in which each bit names a way the token departs from plain ERC-20 or ERC-721. Fee-on-transfer, rebasing, transfer hook, pausable, blocklist, non-transferable, upgradeable, mintable, seizable, and two that only mean anything for 721, operator-restricted and metadata-mutable.

The function is not the interesting part. Two rules hang off it, one of which I am confident about and one of which I am not, and there is a third problem underneath both that I have not solved and would mostly like help with.

The first rule is that a flag is set when the behavior is installed, not when it is currently active. A token whose fee rate is sitting at zero still declares fee-on-transfer, because whoever holds the fee key can raise it tomorrow. The obvious objection is that this makes the word useless, since issuers will install modules they never switch on, everything will declare everything, and integrators will go back to ignoring it. I think that objection is about half right and I do not have a clean answer to it. What the rule buys is that a false negative becomes impossible, and the false negative is the one that costs money. I would rather have a signal people over-read than one they cannot lean on. But this is the part of the design I hold least firmly, and if someone has a better shape I want to hear it.

The second rule is the one I actually care about. The word is fixed at deployment.

Configuration moves. Declarations do not. That sounds like a small thing and I think it is the whole point, because it turns a call into a fact. Every discovery mechanism I looked at answers per call or per caller, which means an integrator can never stop asking. There is no moment where they are done. Under an immutability rule they read the word once, store it next to the address, and are finished with that question permanently. Everything else in this proposal is downstream of wanting that property.

It has one exception, and the exception is doing a lot of work: upgradeable. If the code can be replaced then nothing about it is fixed, so the vocabulary has to name that case itself and callers have to check that bit before trusting any of the others. Which is fine in principle and slightly uncomfortable in practice, because upgradeable proxies are extremely common, and for those tokens the immutability rule collapses back into “as durable as the upgrade authority”. I do not think this sinks it. Most of the tokens where the question matters most are the ones deployed by people who wanted them to be immutable. But I notice I am arguing for the rule using the subset of cases where it works.

I got the vocabulary wrong on the first pass, and the way I got it wrong seems worth reporting.

My original flags described what a transfer does. Fee, hook, pause, freeze, non-transferable. Which meant a token that installed no extensions returned zero, and I had defined zero as indistinguishable from a plain ERC-20. Except that token could still mint without limit and burn any balance it liked, because mint and burn sit on the base contract and are not optional. It was answering “nothing to see here” while holding two of the powers that matter most to anyone holding it, and neither shows up in a transfer simulation. Ever. I added mintable and seizable.

The general version, which I would want written into any spec that comes out of this: a vocabulary assembled only from what calls do will systematically miss what the deployer can do, and those are precisely the properties no simulation will ever surface.

One smaller rule, which I would like adopted even if everything else here is wrong. A failed call must not be read as zero. The tempting shape is a try/catch with zero in the catch branch and I am fairly sure that is what most integrations would write. It collapses “declares nothing” into “does nothing”. Most fee-on-transfer tokens have never heard of this interface and their silence denies nothing, and an uninitialised contract reverts today and can be initialised with a fee tomorrow. Unknown has to be its own state.

Then there is the objection everyone arrives at, which is that a token can simply lie.

ERC-5269 got there first. Its thread raised the objection, concluded that verification fell outside that proposal’s scope, and as far as I can tell nothing since has picked it up. I think it is answerable without touching the interface at all. If canonical tokens are EIP-1167 clones of a shared runtime then the deployed code is 45 bytes, a fixed prologue and an address and a fixed epilogue, with nowhere to hide behavior. One EXTCODECOPY establishes that a contract runs an implementation you already decided to trust. No call to the token, no registry in the loop, and since EIP-6780 the result cannot change afterwards. That does not make declarations trustworthy in general. It moves the trust onto a single address the caller chose, which is a far smaller thing to audit than every token they might meet. The counter that came up there, that access-controlled contracts behave differently depending on who is asking, is correct, and it is an argument about what belongs in the declaration rather than against verification: code identity tells you the rules are the published ones, not that you will like them, and nothing at all about who holds the keys. Which is how I arrived at mintable and seizable from the other direction.

The clone check is a pattern, not part of the interface. I am not proposing it for standardisation.

Now the part I am actually stuck on.

Who assigns bit 11?

Bit values get copied into integrator code. That is the entire point, since the value of a bitmask over an interface hierarchy is that reading it is cheap and local. But it means the moment a new bit is assigned, every deployed copy of the vocabulary is stale, and a caller holding an old copy silently drops a behavior it has never heard of. Silently is the problem. Not “reverts”, not “returns unknown”, just quietly treats a token as understood when a bit it cannot name is set.

The partial fix is a rule that a caller reading a word containing bits outside its known mask must treat the token as not fully classified, which pushes it into the unknown tier rather than letting it pass. I believe that is necessary. I do not think it is sufficient, because it converts every vocabulary expansion into a compatibility break for every integrator who has not updated, and that is a strong incentive to never expand, which in turn is a strong incentive for implementers to squat unassigned bits with private meanings.

My working position is that the ERC text is the registry, new bits arrive only by amendment, unassigned bits stay reserved. I am not confident. ERC-165 avoids this problem entirely by making the identifier derive from the interface, at the cost of the thing I am trying to get. I would genuinely like to be argued out of the bitmask, if someone can see a shape that keeps one cacheable read without a central allocation authority.

A smaller version of the same question: my implementation refuses to deploy a token declaring both non-transferable and fee-on-transfer, on the grounds that an unreachable transfer path cannot charge a fee. Having lived with it I suspect that kind of refusal is over-reach for a standard, which should describe rather than forbid, but I have not convinced myself either way.

Prior art, briefly, and I am happy to go into any of these properly in replies. ERC-7943 is the one I take most seriously since it is Final and canSend, canReceive and canTransfer already cover restriction, but that is a per-transfer question about specific parties and this is a cached fact about the contract, and I do not think they conflict. ERC-165 gets asked about immediately: it tells you an interface exists rather than whether a behavior is reachable, costs one call per interface so nothing is cacheable as a unit, and supportsInterface lies exactly as easily. ERC-1404 is where detectTransferRestriction comes from and I use its signatures, though it never became a finalised ERC and I would rather say so than let the number imply otherwise. ERC-721C enforces operator policy where this only declares one exists, so a 721C collection setting the bit is more useful to a marketplace rather than less. The Operator Filter Registry being retired in 2023 after everyone routed around it reads to me as evidence that enforcement was the contested part and discoverability never really got tried.

Security considerations need a real section in any draft. The two that worry me most: under-declaring is the dangerous direction and the interface cannot prevent it, only make the claim explicit and checkable; and a zero word is not an all-clear, since a token can be perfectly honest about every bit here and still be broken in ways this vocabulary does not name.

There is a reference implementation on OpenZeppelin v5 covering both token types, 334 tests, four rounds of internal review that each turned up something the tests had been passing over. No external audit, nothing deployed anywhere, no integrator has touched it.

LINK

Last thing, and it is the doubt underneath all of the above. I am not sure this should be an ERC. If bit meanings cannot be allocated centrally then what I have is a convention, and conventions do not obviously belong in the EIP process. If they can, then the allocation mechanism is the actual standard and the function signature is trivia. I have been unable to decide which of those I am proposing, and that is probably the most useful thing anyone could tell me.

From a wallet-user perspective, an “unknown” state feels important. A wallet should not show an unrecognised token as having “no special behaviour,” because users may take that as a safety signal.

Clear wording such as “behaviour not declared” or “declaration unavailable” would be more honest. It would also help to distinguish what a token is capable of doing from what is active today. For example, a fee may be zero now but still be enabled later.

Showing both clearly could prevent surprises without making the experience complicated for ordinary users.

thank you, this is the part of the original post I was least
confident about, so a wallet perspective on it is what I was hoping for.

On the unknown state, the proposal does carry that rule, but one layer below
where you are describing it. What I specified is an integration rule: a caller
must not wrap the call in a try/catch that returns zero on failure, because that
collapses “declares nothing” into “does nothing”. You are describing a
presentation rule, and on that the proposal says nothing at all. That is a gap.

It is also three states rather than two. A zero word from a contract that
implements the function is a positive claim of plainness. No declaration is the
absence of a claim. But a claim is worth whatever the thing making it is worth,
and any contract can return any word, so a declaration you have not otherwise
verified is a third case. In the reference implementation tokens are clones of a
fixed runtime, so that one separates cleanly from the bytecode, but I kept
verification out of the interface deliberately and still would. Which is why I
think the spec should name the states and leave the strings alone. “Behaviour not
declared” is better than anything I had written down and I will use it, but which
words a user sees is your call, not the ERC’s.

On capability versus current configuration, that is the objection I called half
right in the post and had no clean answer to. Your framing is better than mine.
One correction in your favour though: there are three tiers available, not two,
and the middle one is the one I think a wallet actually wants.

Take the fee module. The flag says a fee module is installed. feeBasisPoints says
what the rate is right now. But there is also a compile-time constant,
MAX_FEE_BASIS_POINTS, which is the highest rate that deployment will ever accept
and which no authority can move.

That third figure is why I would push back gently on your last line. I do not
think raw capability belongs in front of an ordinary user at all. “This token can
charge a fee but does not today” is unbounded and reads as a warning about
nothing. “0% today, capped at 5% by the contract” is the same fact, actionable,
and not frightening. The scary version is the one missing the bound.

It also decides how you fetch them. The flag and the ceiling are both fixed at
deployment, so a wallet reads them once when the token is added and never again.
The current rate changes block to block, and ERC-7943 explicitly allows checks
like these to depend on msg.sender, so that tier cannot be cached at all.

You ship a wallet and I do not, so if the bound turns out to be one number too
many, I would take your read over mine.

That distinction makes sense. For an ordinary wallet screen, I think the current fee is the most important thing to show, with an optional detail such as “up to 5%” when there is a fixed limit.

This gives users something clear and actionable without making the main screen feel alarming. If there is no fixed maximum, that should be communicated more cautiously, since a user cannot judge how large a future change could be.

Naming the three states also feels useful: declared plain behaviour, behaviour not declared, and declared but not independently verified. The wording shown to users can stay simple, but wallets should avoid treating any of those last two cases as an all-clear.