SKIP TO CONTENT
Protocol11 minStandards That Ship / Ep. 5

Revert Becomes Zero: Confidential Balances and Permissioned Token Compliance

Adam Boudjemaa
SHARE

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.
11
MUST REQUIREMENTS IN MY STANDARD
written in 2021, when a balance was a number anybody could read
0
COMPLIANCE FUNCTIONS IN ERC-7984
no freeze, no forced transfer, no plaintext read. Draft, and deliberately minimal.

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.

WHAT EACH POWER READS
ERC-3643 powers, sorted by what they read
isVerifiedaddress -> bool

Reads an address and the claims attached to its identity. Never touches a balance.

setAddressFrozenaddress, bool

Reads an address. The whole wallet stops moving. No amount is involved anywhere.

freezePartialTokensaddress, uint256

Stores a number, and every later transfer compares against it. Partial means numeric or it means nothing.

getFrozenTokensaddress -> uint256

Hands that number back in plaintext, to anyone who asks, forever.

forcedTransferfrom, to, uint256 -> bool

The agent has to know what position is there before it can take it. A remedy needs a size.

canTransferfrom, to, uint256 -> bool

Global rules: a maximum number of holders, a maximum amount per investor. Counting and comparing.

balanceOfaddress -> uint256

Inherited from ERC-20. The cap table an issuer files is assembled out of this one.

The top two read an address. Everything below them reads a number. That line is the whole article, and nobody drew it until the number went dark.

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.

Obligation
What it reads
Under an encrypted balance
Eligibility check
An address and its claims
Survives unchanged. The claim was never in the balance.
Whole-wallet freeze
An address
Survives. One boolean per address stays one boolean per address.
Partial freeze
An amount, compared against a balance
Re-derived. The comparison still happens; the failure changes shape.
Forced transfer
A recoverable position
Re-derived, and the agent may not be able to see the size of what it is taking.
Pre-check before sending
A plaintext yes or no about a balance
This is the one that turns into a leak.
Holder accounting and cap table
Every balance, in plaintext
Leaves the token entirely and becomes a disclosure design.

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.

THE SAME RULE, TWICE
erc-3643.sol / ERC7984Freezable.sol
// 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)
);
Top: the plaintext comparison from my standard, with a revert attached. Bottom: the identical rule on a ciphertext, where require has become select and the failure branch is the number zero.

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

The transaction reverts with a named reason string. The wallet shows a failure, the holder knows immediately, support can read the revert in an explorer, and the compliance log writes itself. Being blocked is a public, legible event.

A blocked transfer on a confidential token

The transaction succeeds. Status one, gas spent, an event emitted with an encrypted amount in it. Nothing moved. The holder cannot tell a completed transfer from a blocked one without decrypting, and neither can your support desk.

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.

THE FORK THE CRYPTOGRAPHY FORCES
Flow diagram: 4 stepsFlow diagram: 4 steps. A transfer arrives, then Compare, homomorphically. Compare, homomorphically, then Act on the result. Act on the result branches into 2: If decrypt, then revert, then Publishes the answer. If substitute zero, then Keeps the secret. Both paths continue to The transaction succeeds either way.DECRYPT, THENREVERTSUBSTITUTE ZERO1A transfer arrivesThe amount is a ciphertext handle. Thecontract cannot read it either, whichis the part people forget.2Compare, homomorphicallyFHE.le(amount, unfrozen). The resultis an encrypted boolean, not aboolean. Nothing in the EVM canbranch on it.3Act on the resultTwo ways to finish, and the choice isa policy decision, not a technicalone.Publishes theanswerWhether atransactionreverted ispublic. Revertexactly when asecret comparisonfails and you haveannounced thecomparison.Keeps thesecretThe transfercompletes andmoves nothing.This is thebranch thelibrary takes.4The transaction succeeds eitherwayOne of these two outcomes leaks abalance. The other makes a successfultransaction stop meaning a successfultransfer.
Step three is a genuine fork, and both arms compile. One publishes the answer to a secret comparison. The other keeps the secret and makes success meaningless.

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.

THE PRE-CHECK AS AN ORACLE
Sequence diagram: 3 participants: Any caller, Compliance contract, Encrypted balanceSequence diagram: 3 participants: Any caller, Compliance contract, Encrypted balance. Step 1: Any caller to Compliance contract, canTransfer(from, to, 1000). Step 2: Compliance contract to Encrypted balance, compare 1000 against the free balance. Step 3: Encrypted balance to Compliance contract, an encrypted boolean. Step 4: Compliance contract to Any caller, true. Step 5: Any caller to Compliance contract, the same call at 2000, then 4000, then 3000. Step 6: Compliance contract to Any caller, true, false, true.ANY CALLER > COMPLIANCE CONTRACTcanTransfer(from, to, 1000)A pre-check, exactly as my standard requires.It lives on the compliance contract, not thetoken. A view call, free, no signature, notrace.COMPLIANCE CONTRACT > ENCRYPTEDBALANCEcompare 1000 against the free balanceThe comparison is homomorphic. The contractstill cannot see the number.ENCRYPTED BALANCE > COMPLIANCECONTRACTan encrypted booleanCOMPLIANCE CONTRACT > ANY CALLERtrueAnd there it is. A plaintext answer about asecret number, returned to anyone.ANY CALLER > COMPLIANCE CONTRACTthe same call at 2000, then 4000, then3000Each answer halves the range. This isbisection, not an exploit, and it needs noprivileged access.COMPLIANCE CONTRACT > ANY CALLERtrue, false, trueA few dozen view calls and the confidentialbalance is a number again.
Nothing here is an attack on the cryptography. Every call is the interface my own standard MUST expose, used exactly as documented. Note where it lives: in ERC-3643 the pre-check sits on the compliance contract and the token delegates to it.

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.

WHAT SURVIVES THE ENCRYPTION
Layered stack diagram: 5 layers, top to bottomLayered stack diagram: 5 layers, top to bottom. Layer 1, IDENTITY: Claims, country, trusted issuers, Ports across unchanged. Layer 2, ACCOUNT RESTRICTION: Blocked or allowed, per address, Still a plain bool, still reverts by name. Layer 3, AMOUNT RULES: Partial freeze, free balance, maximum per investor, Re-derived as select, not require. Layer 4, HOLDER ACCOUNTING: How many holders, and who holds what, A zero balance is indistinguishable without decryption. Layer 5, REPORTING: The cap table the issuer actually files, Leaves the contract, becomes key management.IDENTITYClaims, country, trusted issuersPorts across unchangedACCOUNT RESTRICTIONBlocked or allowed, per addressStill a plain bool, still reverts bynameAMOUNT RULESPartial freeze, free balance, maximumper investorRe-derived as select, not requireHOLDER ACCOUNTINGHow many holders, and who holds whatA zero balance is indistinguishablewithout decryptionREPORTINGThe cap table the issuer actuallyfilesLeaves the contract, becomes keymanagement
The dividing line is not cryptographic sophistication. It is whether the obligation reads an address or reads a number, and the two bands under the line are where all the work is.

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.

Disclosure design
Disclosure design
Who can read
What it costs you
Per-handle grant (FHEVM access-control list)
Exactly the addresses you granted, long-term
You are running key custody for a supervisor, and a persistent grant is hard to withdraw
Auditor key on the mint (Solana Token-2022)
One auditor, every transfer amount, from the first one
A single key that reads the whole book. The proof system enforces it, so it is not optional per transfer.
Public disclosure (ERC-7984 AmountDisclosed)
Everybody
It is the only disclosure event in the standard and it is not selective. Using it for reporting undoes the reason you encrypted.
The Solana design is the one I would study first, because it makes the trade explicit at the protocol level. If the mint carries an auditor public key, every confidential transfer must also encrypt the amount under that key, and a validity proof enforces it. The supervisor never touches an account secret. That is reported from the documentation, not measured.

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.

DECIDE BEFORE YOU ENCRYPT
Decide: Should this instrument have confidential balances?Decision tree: Should this instrument have confidential balances? If Amounts are commercially sensitive and your holders are already permissioned, then Yes, and keep identity in plaintext. If Your integrations need to know in advance whether a transfer will pass, then Redesign the check, do not port it. If A regulator needs a live cap table and you have no key custody plan, then Not yet. If You are doing it because privacy sounded like a feature, then No.DECIDEShould this instrument haveconfidential balances?IF Amounts are commerciallysensitive and your holders arealready permissionedYes, and keep identity inplaintextEncrypt the money, not themembership. The address checkssurvive untouched, so do not pay tohide them.IF Your integrations need to knowin advance whether a transfer willpassRedesign the check, do notport itA plaintext answer about a secretbalance is an oracle. Move thedecision inside the transaction andtell the holder afterwards.IF A regulator needs a live captable and you have no key custodyplanNot yetThe reporting obligation does notdisappear because the balance did.Settle who holds the decryptionright before you pick a scheme.IF You are doing it because privacysounded like a featureNoEvery remedy in your stack getsre-derived, and the new failuremode is a transfer that succeedsand moves nothing.
Only the first branch is a clean yes. The other three are decided by obligations that exist whether or not the balance is readable, which is the point.

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

Adam Boudjemaa

Adam Boudjemaa

Former CTO of Integra. Named author (1 of 5) of ERC-3643, first author of ERC-6960, co-author of ERC-7410, and co-author of ERC-8203, which is still a draft. Building production AI and regulated Web3 systems.

Enjoyed this post?

Get more like it in your inbox every Tuesday.