Revert Becomes Zero: Confidential Balances and Permissioned Token Compliance
KEY TAKEAWAYS
- ERC-3643 has eleven MUST requirements. They were written when a balance was a number anyone could read, and several of them stop meaning what they say once it is not.
- The obligations that read an ADDRESS survive encryption untouched. The obligations that read a NUMBER get re-derived, and their failure mode changes from a revert to a transfer that succeeds and moves nothing.
- A partial freeze still exists under encryption. In OpenZeppelin ERC7984Freezable the require becomes an FHE.select, and the documented behaviour is that 0 tokens are transferred instead of the call reverting.
- The requirement I am proudest of is the one that breaks hardest. A pre-check returning a plaintext yes or no about a secret balance is a decryption oracle, and bisection reads the balance out of it.
- ERC-7984 is a Draft interface with no freeze, no forced transfer and no plaintext read. That is correct scoping, and it means the whole compliance layer is code you own.
ERC-3643 has a requirements section. I helped write the standard, so I can still quote the awkward one from memory.
The token MUST be able to freeze tokens on the wallet of investors if needed, partially or totally.
Partially. That single word carries the weight. A partial freeze needs a number, somebody has to compare that number against a balance, and the comparison has to be able to say no.
Now encrypt the balance.
Every step in that sentence needs re-deriving. Most of them can be. One of them cannot, in the shape the standard specifies, and it is the one I was proudest of.
WHERE I STAND
The boundary first, because it decides how to read the rest. I have never deployed a confidential token. I have not benchmarked fully homomorphic encryption, I have never run one in production, and wherever the cryptography is load-bearing below I am reporting what a specification or a library says rather than what I measured. What I have is the other half: I am one of five named authors of ERC-3643, so the obligations are mine, and I can read the code currently trying to satisfy them under encryption.
So this is not an FHE article. It is a compliance article that happens to have encryption on the other side of the table.
The question is narrow and answerable: does an obligation written for a plaintext balance still mean anything when the balance is a ciphertext handle? Some obligations survive untouched. Some come back in a different shape with a different failure mode. One turns into a leak.
What my own standard demands you be able to read
Eleven MUST requirements, and a transfer that has to satisfy six separate conditions before it moves. That is a lot of surface, and the useful way to look at it is not by importance.
Sort the powers by what each one actually reads.
Reads an address and the claims attached to its identity. Never touches a balance.
Reads an address. The whole wallet stops moving. No amount is involved anywhere.
Stores a number, and every later transfer compares against it. Partial means numeric or it means nothing.
Hands that number back in plaintext, to anyone who asks, forever.
The agent has to know what position is there before it can take it. A remedy needs a size.
Global rules: a maximum number of holders, a maximum amount per investor. Counting and comparing.
Inherited from ERC-20. The cap table an issuer files is assembled out of this one.
Two kinds of obligation, and only one survives
Once the list is sorted that way, the answer stops being a research question and starts being bookkeeping.
An obligation that reads an address survives encryption completely, because eligibility was never stored in the balance. An obligation that reads a number has to be rebuilt, and rebuilding it changes something the original never had to think about: what happens when the answer is no.
Four of those six are ordinary engineering. One is a leak. One stops being a smart-contract problem at all. Take them in that order.
The line that changes, and what it changes into
Start with the ordinary one, because it is the clearest and because somebody has already written it.
OpenZeppelin ships an extension called ERC7984Freezable. It stores an encrypted frozen amount per account, computes the available balance as a saturating subtraction of frozen from total, and gates every transfer on it. That is freezePartialTokens, rebuilt on a ciphertext, by people who read the same requirement I did.
Here is the rule in both worlds, side by side. It is four lines and it is the entire thesis.
// ERC-3643 (Final). From the example transfer() in the specification.
// A plaintext comparison, and a revert when it fails.
require(
_amount <= balanceOf(msg.sender) - (_frozenTokens[msg.sender]),
"ERC-3643: Insufficient Balance"
);
// OpenZeppelin ERC7984Freezable._update. The same rule on a ciphertext.
// No require. No revert. The amount is quietly replaced with zero.
euint64 unfrozen = _confidentialAvailable(from);
encryptedAmount = FHE.select(
FHE.le(encryptedAmount, unfrozen),
encryptedAmount,
FHE.asEuint64(0)
);The library's own comment states the consequence without flinching: the from account must have sufficient unfrozen balance, otherwise 0 tokens are transferred. Not reverted. Transferred, in the amount of zero.
A blocked transfer on ERC-3643
A blocked transfer on a confidential token
Why it cannot simply revert
The obvious objection is that the library could simply revert, and it is worth taking seriously because it is wrong for an interesting reason.
The Zama Protocol documentation is direct about the mechanics. Encrypted booleans do not support standard boolean operations like if statements or logical operators, so conditional logic has to go through FHE.select, described as a ternary operator for encrypted values. I am reporting that, not testing it. It selects between two encrypted values rather than branching execution, which is why both possible outcomes have to exist before the choice is made.
You could still decrypt the comparison and revert on the result. That is where the interesting failure lives.
That is the whole trade, and it is not a bug in anybody Solidity. It is what happens when a rule about a secret number has to produce a public effect.
The library picked correctly. Silence is the safe failure here, and you can rebuild the missing signal off-chain by letting the holder decrypt their own available balance. What you cannot rebuild is the assumption every integration in this industry makes, which is that a successful transaction moved the amount in it.
The pre-check was the point. It is the first casualty.
Now the requirement that costs me something to write.
ERC-3643 says the token MUST have a standard interface to pre-check if a transfer is going to pass or fail before sending it to the blockchain. The rationale explains exactly why, and it is a good argument. For a plain ERC-20 you check balanceOf and allowance and you know whether a transfer will work. A security transfer can fail for reasons a balance does not explain, so the standard adds canTransfer as a general-purpose way to achieve the same thing.
Read that sentence again with an encrypted balance in mind. The pre-check exists specifically because reading the balance was not sufficient. Encrypt the balance and you have removed both halves of the answer.
And a pre-check that still returns a plaintext boolean about a secret number is not a compromise. It is an oracle.
To be clear about what I am claiming: I have not seen this done to a live token, and I am not reporting an incident. This is reasoning from two specifications, one of which I helped write. The reasoning is short enough to check, which is the only reason I am comfortable publishing it.
What actually survives, and why
The good news is genuinely good, and it is the half nobody talks about because it is undramatic.
OpenZeppelin also ships ERC7984Restricted, a blocklist that can be inverted into an allowlist. Look at what it stores: an ordinary Solidity enum, in plaintext, per address. Its check returns a plain bool, and when it fails it reverts with a named error. No ciphertext, no select, no ambiguity.
That is not a shortcut. It is correct. The restriction was never secret in the first place, because it is a property of the address rather than a property of the money. Which is precisely the shape of isVerified, and it means the entire identity layer of ERC-3643 ports across almost verbatim.
The fourth band deserves its own sentence, because it is quietly the worst one.
ERC-3643 lets an issuer cap the number of holders, including per country. A holder count is the number of addresses with a non-zero balance. Under encryption a zero balance and a large one are the same opaque handle, so the count cannot be taken from the chain at all without decrypting every position. That is my reading of the two specifications placed next to each other rather than something either of them states, and I would want it disproved before I built on it.
Somebody still has to be able to read it
Which lands on the question every issuer eventually asks, usually in a room with a lawyer in it. If the balance is secret, who reads the cap table?
The answer is that somebody holds a key, and that sentence is doing more work than it looks. getFrozenTokens was a getter. Its confidential equivalent is a decryption right. Those are not the same kind of object: a getter is stateless and you can change it by redeploying, while a persistent decryption grant is a key that somebody now has and that you may not be able to take back.
Three designs are already shipping, and they distribute that key very differently.
Notice what none of the three is. None of them is a function on the token that an agent calls to read a position. The remedy in ERC-3643 assumed a getter. Confidentiality replaces the getter with a key ceremony, and a key ceremony is an organisational problem wearing a cryptographic hat.
ERC-7984 itself has no opinion on any of this, and that is the correct scope for an interface. It has no freeze, no forced transfer, no plaintext read, no identity. It is a Draft, created in July 2025, and it does exactly one job.
Which puts a confidential permissioned token in a place this series has already described: a clean core with the compliance logic left entirely to you. That is the CMTAT trade, and its warning. An empty hook is a security token with no compliance, and an unbuilt confidential compliance layer is the same thing with better cryptography.
What I would actually do
None of this is an argument against confidential balances. It is an argument for deciding in the right order. Here is the version I would use in a first meeting.
It is not an edge case, it is the default failure of every amount rule on a confidential token. Custody software, transfer agents and reconciliation jobs all assume a successful transaction moved the amount it named. On a confidential token that assumption is false by design, and it is false quietly. Test the blocked path before you test the happy one.
canTransfer will compile happily over an encrypted balance if you decrypt the comparison before returning. It will pass review, because it looks exactly like the function the standard requires. It is also a free, unauthenticated, unlimited read of every position on the token, and nobody notices until somebody publishes a cap table you thought was private.
Teams pick a confidentiality scheme the way they pick a database, then discover the reporting obligation six months later and bolt a disclosure mechanism onto a design that had no place for one. Who can decrypt, under what authority, and how a grant is revoked are the first three questions, not the last three. They are also the questions your regulator will ask first.
The honest summary of my own position is short. I helped write a standard that assumes the issuer can read the number, and that assumption is load-bearing in six places I can name.
It is not a defect in ERC-3643 and it is not a defect in ERC-7984. It is a seam, and a seam is where the next standard gets written. What is missing is not more cryptography. It is a compliance vocabulary for amounts nobody can read: a freeze whose failure is legible to the holder, a pre-check that answers without publishing, a holder count that survives opacity.
That is a specification somebody should write, and I would rather name the gap than watch it get discovered in production.
This is the second time I have written this sentence about my own work. Last time the balance was still readable and the ledger had moved somewhere the token could not see. This time the ledger stays put and the number goes dark. Same lesson from the opposite direction: a compliance rule is worth exactly what it can still read.
FAQ
Not as written. ERC-3643 is Final and its requirements assume the token can read a number: it MUST be able to freeze tokens partially, it MUST be able to force transfers from an agent wallet, and the compliance contract it delegates to MUST expose a pre-check that says whether a transfer will pass. The identity half of the standard ports over to a confidential token almost unchanged, because eligibility is a property of an address rather than of an amount. The amount half has to be re-derived, and one requirement, the plaintext pre-check, has no confidential version that is not a leak.
Three things, in order of how much they hurt. The pre-check breaks first: a plaintext yes or no about a secret balance is an oracle you can bisect. Holder accounting breaks second, because you cannot count how many holders have a non-zero balance without decrypting each one. And every amount-level rule changes its failure mode: instead of reverting, the transfer succeeds and moves zero. The rule is still enforced. It stops announcing itself.
Because a revert is public and the balance is not. The Zama Protocol documentation is explicit that encrypted booleans do not support if statements or logical operators, so conditional logic goes through FHE.select, which picks between two encrypted values rather than branching execution. You could still revert on the decrypted result, and that is exactly the problem: whether the transaction reverted is visible to everyone, so a contract that reverts precisely when a secret comparison fails has published the answer to that comparison.
Yes, and the mechanism already exists. OpenZeppelin ships ERC7984Freezable, which stores an encrypted frozen amount per account and computes the available balance as a saturating subtraction. Its _update gates the transfer with FHE.select rather than require, and the contract's own documentation says the account must have sufficient unfrozen balance, otherwise 0 tokens are transferred. So the power survives. What changes is that the frozen amount is now a ciphertext, and the holder learns nothing from a successful transaction.
By holding a key, not by calling a getter, and that is the part issuers underestimate. There are three shipped designs. The FHEVM access-control list grants a named address long-term decryption rights on a specific handle. Solana Token-2022 puts an optional auditor ElGamal public key on the mint, so every confidential transfer must additionally encrypt the amount under it. And ERC-7984 itself defines only AmountDisclosed, which its text describes as a public disclosure. A getter is revocable by redeploying. A decryption right is a key somebody now holds.
No, and it does not claim to be. ERC-7984 is a Draft interface for confidential fungible tokens, created in July 2025, and it contains no freeze, no forced transfer, no plaintext balance read and no identity concept at all. That is correct scoping for an interface, in the same way ERC-7943 is correctly scoped. It also means that if you build a permissioned instrument on it, every compliance obligation lives in extension code you own and must keep correct.
Standards That Ship
Episode 5 · 5 published
Enjoyed this post?
Get more like it in your inbox every Tuesday.
