When the Chain Can't Tell You the Amount: Indexing ERC-7984 Confidential Tokens on Zama's fhEVM
When the Chain Can’t Tell You the Amount: Indexing ERC-7984 Confidential Tokens on Zama’s fhEVM
Every blockchain indexer rests on an assumption so basic that nobody bothers to state it: the data is in the logs. You read the event, you parse the amount, you add it to a database. Done.
ERC-7984 breaks that assumption on purpose. It is a confidential token standard where amounts and balances are visible on-chain only as opaque handles. The standard itself is technology-agnostic — it just says amounts are bytes32 pointers — and in the implementation I worked with, Zama’s fhEVM, the ciphertext behind each pointer lives off-chain in a coprocessor. The event for a transfer looks like this:
ConfidentialTransfer(address indexed from, address indexed to, bytes32 indexed amount)
That amount is not a number. It is a handle, a 32-byte pointer to an encrypted value that only certain addresses are allowed to decrypt, and you might not be one of them. Today. That can change tomorrow.
I wanted to understand how this actually behaves on a real network, not in a diagram. So I built the piece of infrastructure that sits in the most uncomfortable spot: an indexer that watches a confidential token and serves a normal, boring, cleartext API — balances and transfer history — to a consumer that doesn’t want to know FHE exists. The code is on GitHub at criadoperez/zama-runway, including scripts that reproduce everything in this post against Sepolia.
The setup:
- Chain: Sepolia, against the cUSDT test wrapper at
0x4E7B06D78965594eB5EF5414c357ca21E1554491(its underlying mock USDT has a publicmint(), so anyone can play) - SDK:
@zama-fhe/sdk@3.1.0-alpha.5— the new high-level SDK, alpha channel - Indexing: Ponder for chain-truth tables
- Decryption: a queue-driven worker on Postgres (embedded PGlite for the demo, a
pgpool for the real thing) - Read API: Hono, serving cleartext JSON
What follows is what ERC-7984 actually is, why indexing it is a different problem, and a few things the deployed contracts do that the documentation didn’t tell me.
ERC-7984 in five minutes
ERC-7984 is the confidential fungible token standard. The deployed implementation I tested comes from OpenZeppelin’s confidential contracts running on Zama’s fhEVM, which lets smart contracts compute on encrypted values: you can add two encrypted balances, compare them, transfer an encrypted amount, all without anything being decrypted on-chain.
The key ideas:
- Amounts are handles. The standard defines amounts as
bytes32pointers; in the fhEVM implementation those are handles toeuint64ciphertexts. The handle is what you see in events and storage. - An ACL decides who can decrypt. A singleton ACL contract records, per handle, which addresses are allowed to decrypt it. When a transfer executes, the token contract grants decryption rights on the amount handle to the sender and the recipient. Nobody else can read it.
- Decryption happens off-chain. An entitled party asks Zama’s relayer to decrypt a handle. The SDK generates an ephemeral keypair, the wallet signs an EIP-712 message authorizing it, and the KMS re-encrypts the value under the ephemeral key. Your wallet key only ever signs; it never decrypts anything itself. From the developer’s point of view it is one awaited HTTP call that returns a
bigint. - Rights can be granted later. An account can delegate decryption rights over a whole contract to another address with
delegateForUserDecryption. This is what makes third-party services possible at all: a user can let an indexer or a custodian read their amounts, after the fact, without giving up keys.
One detail I liked: the handles are not random. The trailing bytes encode metadata — every handle I touched on Sepolia contains aa36a705 near the end, where aa36a7 is Sepolia’s chain id and 05 is the type marker for euint64.
Why indexing this is a different problem
A normal ERC-20 indexer is a pure function of the logs. Replay the events and you can reconstruct every balance at every block.
An ERC-7984 indexer loses that property three times over.
You can’t read the amounts. The thing your users most want — “how much?” — is exactly what the log won’t tell you. Decryption is a network call to a relayer, it can fail or be rate-limited, and it only works if your indexer’s identity has rights on that specific handle.
Entitlement is per-handle and changes over time. Your indexer might have no rights on a transfer today and receive delegation next week. One requirement I treated as non-negotiable: an event you can’t decrypt yet must not be dropped. It has to be stored, stay visible, and become decryptable later when rights arrive — backfill.
You can’t reconstruct balances. Without cleartext deltas, summing transfers only works for amounts you have managed to decrypt. If even one transfer touching an address is still encrypted to you, any “balance” you compute is a guess. An honest API has to admit that.
The conclusion I reached early: the indexer has to treat chain truth and cleartext as two different datasets with two different lifecycles. Events are recorded the moment they are seen, immutably, keyed by block and log index. Cleartext arrives later and gets joined on at read time by handle. Sometimes it arrives seconds later. Sometimes weeks later, when a delegation lands. Sometimes never. I think of it as a ledger of record with a late-arriving projection on top.
That one decision drives everything else:
- Every transfer the API serves carries an
amountStatus:AVAILABLE,PENDING, orNOT_ENTITLED. The amount field is a decimal string or null, and the status always explains the null. A wallet can render a number, a spinner, or a lock icon, and never gets lied to. - Balances carry their own status.
EXACTonly when the indexer has full history coverage and every delta is decrypted. A delta that is merely still pending degrades the balance toESTIMATED; one the indexer may never decrypt degrades it toUNAVAILABLErather than to a wrong number. A wallet showing a wrong balance is worse than one showing “not available yet”. NOT_ENTITLEDis asserted, never assumed. The ACL contract has a view function,isAllowed(handle, account), so the indexer can check entitlement with a freeeth_callinstead of guessing from the parties of a transfer or, worse, interpreting a relayer error.
What the deployed contracts actually do
This is the part I couldn’t have learned from documentation, because some of it contradicts the documentation.
Shields are mints, and the cleartext comes along for free. Wrapping ERC-20 tokens into the confidential representation emits two events. First, a ConfidentialTransfer from the zero address — a mint — whose handle the recipient can decrypt. Second, an event the deployed wrapper calls Wrap(address indexed account, uint256 amount, bytes32 encryptedAmount), which contains the cleartext amount and the same handle as the mint. So for shields, the indexer never needs to call the relayer at all. The amount is disclosed on-chain and joined by handle. My worker skips any handle that already has an on-chain disclosure, because spending a relayer round-trip to learn something the chain already told you is waste.
The SDK’s decoder doesn’t match the deployed event. Zama’s SDK ships log decoders, which is a good idea — except decodeWrapped targets a Wrapped(address,uint256) signature that the deployed Sepolia wrapper does not emit. In my live scan it matched zero logs. I only noticed because I count undecoded logs instead of silently skipping them, and two stubborn entries kept showing up. Hashing candidate event signatures until one matched the unknown topic0 (0xcda691c8...) identified the real Wrap event above. This is alpha software and skew like this is expected. The lesson is that an indexer should treat “decoder returned null” as a counter worth watching.
Delegation works, and you can watch it propagate. This is the flow that makes a third-party indexer viable, so I tested it end to end with two wallets. A second account shielded tokens and made a transfer my indexer had no rights on. The ACL confirmed it: isAllowed(handle, indexer) = false. Then the account called delegateForUserDecryption for my indexer’s address, which emits DelegatedForUserDecryption on the ACL — delegator, delegate, contract address, plus a delegation counter and old/new expiry timestamps. That event is exactly what an indexer can subscribe to as its backfill trigger. The documentation warns that delegations take one or two minutes to propagate from L1 to the gateway, and the SDK has a dedicated error class for it (DelegationNotPropagatedError), so the worker treats it as a scheduled retry rather than a failure. After propagation, the previously unreadable handle decrypted to exactly the amount sent: 2.0 cUSDT.
The receipts, if you want to verify any of this on Sepolia: a transfer of 1.25 cUSDT whose amount handle decrypted to 1250000 through the indexer’s own code path, and the delegation grant for the case above. The repo has three scripts — live, live:indexer, live:delegated — that redo the whole thing with a fresh throwaway key.
The part that needed engineering judgment
The decrypt itself is a slow, rate-limited, failure-prone network call against shared test infrastructure. Putting it inline in the event handler would be the classic mistake: one relayer hiccup and your indexer head stalls. So decryption runs out-of-band. A worker claims handles from a queue in Postgres (FOR UPDATE SKIP LOCKED, a lease, a per-claim token so a stale worker can’t clobber a finished row) and writes cleartext into its own table, joined at read time.
The detail I would defend hardest in a design review: cleartext is keyed by handle, in a table the indexer’s reorg logic never touches. If a block reorganizes, the chain-truth rows roll back and get re-indexed; the cleartext re-links by handle when the same transfer reappears. Decryptions you already paid for survive reorgs, and a reorged-away transfer can’t strand a phantom amount.
Failure classification matters more than usual here, because the failure modes mean different things. “The gateway hasn’t synced your delegation yet” is a scheduled retry. “The relayer returned a 5xx” is exponential backoff. And there is no error class for “you are not entitled to this handle” — which is why entitlement gets decided by the ACL eth_call before the relayer is ever involved. Inferring entitlement from decrypt failures would eventually mislabel a temporarily-unlucky handle as permanently unreadable, which for this kind of service is the worst silent failure available.
One thing worth being explicit about, because the phrase “decrypts confidential amounts” can sound alarming: this is not a privacy bypass. The indexer is a delegated reader. Users explicitly grant it permission to materialize selected cleartext, and from that point it has to be treated as a sensitive-data system in its own right — the API sits behind bearer auth, the ciphertext handles never leave the database, and revocation, retention, and audit policy are part of the design, not afterthoughts.
What I’d expect to break first under real load isn’t the relayer, by the way. It’s a single large delegation flooding the queue and starving live transfers behind thousands of backfill decrypts. That’s why the queue has a priority lane and the health endpoint reports live and backfill pending-age separately. The one thing I demonstrated correct handling for but never measured: the shared relayer under a genuine burst. That, plus revocation semantics and a real retention policy for materialized cleartext, is what I’d test next.
A note on tooling honesty
I built this with heavy AI assistance, and the project turned into a small case study in verification discipline. The model’s training-data memory of Zama’s stack was simply wrong — it predates the current SDK and kept suggesting APIs that no longer exist. Reading the installed package’s type definitions fixed most of that, but not all of it: the types led me to wire up the SDK’s low-level layer, which compiles fine and would have thrown at runtime, because the actual integration surface is a higher-level facade. And no amount of type-reading could have revealed that decodeWrapped matches nothing the deployed contract emits.
The ladder I ended up climbing: memory is worse than types, and types are worse than a live run. Everything load-bearing in the repo traces to a passing test or an on-chain transaction. That rule exists because the first two rungs kept lying to me.
Where this goes
The thing I came away convinced of is that confidential tokens are not waiting on cryptography anymore. For the flows I tested, the cryptography was never the blocker: transfers hid amounts, ACL permissions behaved exactly as documented, and delegation gave users a concrete selective-disclosure mechanism. What’s missing is the boring middle layer: indexers, custody integrations, block explorers that degrade gracefully when they can’t read an amount.
That is also why I find this corner of the space worth betting time on. The standards are young, the deployed contracts and the SDKs still disagree in places, and the interesting work is exactly where those rough edges are.
If you’re building on ERC-7984 or fhEVM and run into something this post doesn’t cover, the repo is a decent starting point — the architecture notes document the design decisions and the places where the live chain surprised me.