Overview
suitz is the canonical home for Zama ERC-20 ↔ ERC-7984 wrapper pairs on Sepolia. It renders the official onchain Wrappers Registry as a product: browse every canonical pair, faucet the official cTokenMocks, wrap an ERC-20 into its confidential form, decrypt any confidential balance you own, and unwrap back, in one place, built so that using existing wrappers is the path of least resistance.
Three principles shape the whole app:
- Registry-native, not registry-aware. The onchain registry is the source of truth; the UI is a faithful, real-time mirror of it, with a documented local-override layer for dev pairs. suitz doesn’t fork the data; it renders it.
- Honest about FHE async. Unwrapping is not instant on FHEVM: there is a decryption-oracle round trip before the ERC-20 is released. suitz models that explicitly (
encrypting → submitting → finalizing → settled) instead of pretending it’s atomic. - Decrypt anything you own. The Decrypt tab reveals the connected wallet’s balance on any ERC-7984 address, pasted or auto-detected, even if it was never registered. One EIP-712 signature, no gas, revealed only to you.
Concepts
ERC-7984
Zama’s confidential fungible token standard. Balances and transfer amounts are encrypted onchain with fully homomorphic encryption (FHE), so amounts stay private while remaining verifiable. Reading a balance returns an encrypted handle (a pointer to ciphertext), not a number. Tokens advertise the standard via ERC-165 supportsInterface(0x4958f2a4).
Wrapper pair
A canonical pairing of a public ERC-20 with its confidential ERC-7984 wrapper. The wrapper holds deposited ERC-20 and mints an equal confidential balance; unwrapping burns the confidential balance and releases the ERC-20. Wrappers expose their underlying via an onchain underlying() getter.
The Wrappers Registry
An onchain contract on Sepolia that enumerates every canonical pair as (tokenAddress, confidentialTokenAddress, isValid) tuples. Registration is onlyOwner (Zama curates the canonical list), and revoked pairs stay in the array with isValid = false. suitz treats this contract as the single source of truth (see Registry sourcing).
FHEVM & the Zama SDK
The FHEVM coprocessor executes FHE operations offchain and a KMS controls decryption. In the browser, the @zama-fhe/sdk handles the three cryptographic jobs suitz needs: encrypting inputs (the unwrap amount), EIP-712 user decryption (private, for your own balances, behind a reusable permit), and public decryption (oracle-backed, used to finalize an unwrap).
User vs. public decryption
User decryption reveals a value only to the wallet that signs an EIP-712 grant: one signature, no gas, decrypted locally in the browser. Public decryption reveals a value to everyone via the decryption oracle with a KMS proof; that’s what an unwrap needs before the ERC-20 can be released, and why unwrapping is asynchronous.
Getting started
You need an injected wallet (MetaMask, Rabby, Brave…) and a little Sepolia ETH for gas; any public Sepolia faucet works. No account, no API key, no WalletConnect project id.
The 60-second tour
- 1Open the console and connect your wallet. If you’re on the wrong network, suitz offers a one-click switch to Sepolia.
- 2In Faucet, claim an official cTokenMock, say USDCMock. Your ERC-20 balance updates live.
- 3In Wrap, approve (only if needed) and wrap it. Your new confidential balance arrives as an encrypted handle, deliberately unreadable.
- 4In Decrypt, sign once (EIP-712, no gas) to reveal the balance. It decrypts locally, visible only to you.
- 5Back in Unwrap, send it back to plain ERC-20 and watch the honest
finalizingstate while the decryption oracle does its round trip.
Running it locally instead? Jump to Development & deploy.
The console
Everything operational lives at /console behind four tabs: one mental model, no hidden pages.
01
Registry
The canonical list. Every ERC-20 ↔ ERC-7984 pair from the onchain registry plus your local pairs, with full metadata and both addresses. Search, filter by official/local, add a pair, and jump straight into wrapping.
02
Wrap / Unwrap
The conversion workbench for a selected pair. Approval → wrap as a stepper; unwrap as the async state machine with an explicit oracle-finalize stage and a claim path if anything stalls.
03
Decrypt
EIP-712 user decryption of the connected wallet’s balance on any ERC-7984: paste an address or pick one auto-detected from your wallet. Non-token addresses are caught by an onchain probe.
04
Faucet
Claim the official cTokenMocks so a brand-new wallet can try everything in under a minute, then jump straight to wrapping.
The four flows
Faucet
Each official cTokenMock exposes a public mint(to, amount). suitz calls it and re-reads the ERC-20 balance, so the update is live, not optimistic.
Wrap: ERC-20 → ERC-7984
The classic two-step, with the approval surfaced as its own state instead of a surprise revert:
- 1Check
allowance(owner → wrapper). - 2If it’s short,
approve(wrapper, amount)runs as its own step in the stepper. - 3
wrap(to, amount): a plaintext amount in, an encrypted balance out.
After wrapping, the balance is an encrypted handle. suitz never pretends to know the number; decrypting it is one click away in the Decrypt tab.
Decrypt: EIP-712 user decryption
handle = confidentialBalanceOf(you) // onchain read
permit = grantPermit(yourTokens) // ONE EIP-712 signature, no gas —
// covers every token, reused all session
balance = decryptValues(handle) // re-encrypted by the relayer,
// decrypted locally & cachedThe first decrypt of a session takes one signature — and that single permit covers every token in the registry at once, so switching tokens afterwards is silent (a brand-new address outside the registry costs one more signature to fold it in). The permit is held in memory only (never in long-lived storage), so closing the tab revokes it. The UI is honest about which path it took: it only says awaiting signature when it actually signs, and session key found when it reuses the saved permit with no prompt. The cleartext is scoped to the connected wallet and never leaves the browser. This works on any ERC-7984 address, in the registry or not; suitz first probes the address onchain (underlying() / ERC-165) and gives a precise error if it isn’t an ERC-7984 token. Transient relayer hiccups are retried automatically before you ever see an error.
Unwrap: ERC-7984 → ERC-20, the async one
Unwrapping is not atomic on FHEVM: the amount must be publicly decrypted by the oracle before the ERC-20 can move. suitz drives it as a two-step, dApp-driven flow:
// 1: encrypting → submitting
enc, proof = encrypt(amount) // client-side, in a worker
unwrap(from, to, enc, proof) // burns cToken,
// emits UnwrapRequested(handle)
// 2: finalizing → settled
cleartext, kmsProof = publicDecrypt(handle) // awaited while the
// coprocessor ingests
finalizeUnwrap(handle, cleartext, kmsProof) // releases the ERC-20If the second transaction fails or is abandoned, nothing is lost: the burned balance surfaces as a claimable pending unwrap — persisted in your browser, so the claim survives a page reload — that you can retry from the UI at any time.
Recovering a stuck unwrap from the CLI
finalizeUnwrap is permissionless (the ERC-20 always goes to the recipient recorded at unwrap time), so a stuck unwrap can also be finalized standalone:
# from the unwrap tx hash (extracts the handle automatically):
node scripts/finalize-unwrap.mjs --tx 0x<unwrapTxHash>
# print calldata only (default), or submit with a funded testnet key:
PRIVATE_KEY=0x... node scripts/finalize-unwrap.mjs --tx 0x<unwrapTxHash> --sendRegistry sourcing (hybrid)
- 1Primary: onchain.
lib/registry.tsreads the official registry at0x2f07…128e(an ERC-1967 proxy) and filters out revoked pairs (isValid == false). Token metadata (name/symbol/decimals, both sides) is hydrated via multicall. - 2Augment: local.
config/pairs.tsplus anything added live through the in-app Add pair modal (persisted tolocalStorage) are merged on top, clearly badged local. - 3Resolve.
resolvePairs()merges all sources keyed by confidential address. On collision the onchain record always wins; a local pair’s badge flips to official automatically once it appears onchain. Local never masquerades as official.
If the onchain read fails (RPC down, ABI drift), the app degrades gracefully to a verified in-repo seed of the same pairs, so the registry is never empty. That seed doubles as the coverage baseline asserted by the unit tests, so an official mock can’t silently disappear from the app.
One more honesty detail: ERC-7984 wrappers report their own decimals (capped at 6 to stay uint64-safe), distinct from the underlying ERC-20’s. suitz reads it onchain rather than mirroring the ERC-20’s.
Adding a pair
Three routes, one underlying mechanism. You only ever supply the ERC-7984 wrapper address: the underlying ERC-20 is derived onchain from the wrapper’s underlying() getter and all metadata is read onchain (probeConfidentialToken()), so a pair can never carry wrong or stale metadata.
A: Live, zero-code (works on the deployed URL)
In the Registry tab, click Add pair, paste the wrapper address, confirm the preview. It’s validated onchain, badged local, persisted to your browser, and immediately wrap/unwrap/decrypt-able. The modal’s Copy config turns it into a permanent config/pairs.ts edit.
B: Committed dev pair
export const LOCAL_PAIRS: LocalPairInput[] = [
{
confidential: "0xYourErc7984WrapperAddress", // that's it: metadata is read onchain
note: "my hackathon token", // optional
},
];Save, and it appears in the Registry tab badged local. Pass underlying: "0x…" only if your wrapper doesn’t expose underlying().
C: Canonical onchain pair (the real path)
Register it in the official registry. The confidential token must advertise ERC-165 supportsInterface(0x4958f2a4), and registration is onlyOwner (Zama curates canonical pairs):
registry.registerConfidentialToken(
tokenAddress, // your ERC-20
confidentialTokenAddress // its ERC-7984 wrapper
);On the next refresh suitz reads it as official with zero app-code changes, which is the point of being registry-native.
Error handling
The principle: you should never see an unparsed revert string. Every failure mode maps to a sentence a non-expert understands, with the raw error behind a details disclosure when it helps.
| Condition | What suitz does |
|---|---|
| Wrong network | Blocking banner with a one-click “Switch to Sepolia”. |
| Missing or low allowance | Approval becomes its own stepper step; wrap never reverts on allowance. |
| Insufficient ERC-20 balance | Wrap is disabled with a pointer to the Faucet tab. |
| Address isn’t an ERC-7984 token | The onchain interface probe fails with a precise message before any transaction. |
| Transient relayer / oracle hiccup | User decryption retries automatically behind a consistent, human message. |
| Unwrap finalize fails or stalls | The burned balance surfaces as a claimable pending unwrap; retry from the UI or the CLI script. |
| You reject a signature or tx | Soft reset to the previous state. No dead spinners. |
Contract reference
Wrappers Registry (Sepolia)
proxy0x2f07…128e, an ERC-1967 proxy in front of the Sourcify-verified ConfidentialTokenWrappersRegistry implementation.
getTokenConfidentialTokenPairsLength() → uint256
getTokenConfidentialTokenPairs()
→ (address tokenAddress, address confidentialTokenAddress, bool isValid)[]
getTokenConfidentialTokenPair(uint256 index) → (token, confidentialToken, isValid)
getConfidentialTokenAddress(address token) → (bool, address)
getTokenAddress(address confidentialToken) → (bool, address)
registerConfidentialToken(address token, address confidentialToken) // onlyOwnerThe isValid flag is not optional: revoked pairs stay in the array with isValid = false, and omitting the field from the ABI silently mis-decodes the whole array.
Official pairs
The live list is always read onchain; this table is the verified in-repo seed that mirrors it (also the graceful-degradation fallback). Confidential wrappers all report 6 decimals (uint64-safe) regardless of the underlying’s.
Development & deploy
Local setup
npm install
cp .env.example .env.local # optional: public defaults ship out of the box
npm run dev # http://localhost:3000Requires Node 18.18+ (Next.js 14). Connect any injected wallet on Sepolia; you’ll need a little Sepolia ETH for gas.
Scripts
| Command | Purpose |
|---|---|
| npm run dev | Local dev server |
| npm run build | Production build |
| npm run start | Serve the production build |
| npm run typecheck | tsc --noEmit |
| npm run lint | next lint (eslint-config-next) |
| npm run format | prettier --write |
| npm test | Vitest unit tests |
Environment variables
Everything runs on public defaults; no configuration is required to try it. Override only what you need (see .env.example):
| Variable | Purpose |
|---|---|
| NEXT_PUBLIC_SEPOLIA_RPC | A keyed RPC (Alchemy/Infura/…). Recommended for any deployment; the public endpoint rate-limits under registry multicall traffic. |
| NEXT_PUBLIC_REPO_URL | Your repo URL. When set, the Add-pair modal offers a one-click “Suggest on GitHub” pre-filled issue; unset, the button is hidden. |
Deploying
A standard Next.js 14 project; it deploys cleanly to Vercel or any Node host. One thing matters: the Zama SDK’s WASM uses SharedArrayBuffer and worker threads for input-proof generation, which requires cross-origin isolation. The required Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless headers are already configured in next.config.mjs and honoured by Vercel automatically. On other hosts, make sure those response headers survive.
Security & trust
- Non-custodial, always. suitz never holds funds and never sees your balances. Every write goes through your own wallet; every read is public onchain state.
- Decryption happens in your browser. User decryption is authorized by an EIP-712 signature scoped to the connected wallet and performed locally. The cleartext never touches a server, suitz included.
- Testnet only, no value. All tokens are official cTokenMocks on Sepolia and hold no value. This is a place to learn and build, not to store anything.
- No secrets, open source. The app needs no API keys to run, the injected connector needs no WalletConnect project id, and the full source is MIT-licensed on GitHub.
Found something off? Open an issue on GitHub.