The Zama Wrappers Registry already lives on-chain. The problem is that almost nobody uses it — developers keep spinning up their own ERC-20 test tokens and ERC-7984 wrappers, so the ecosystem fragments and confidential assets stop interoperating.
Macetz is the missing front door. It turns the official registry into a complete dApp where every canonical ERC-20 ↔ ERC-7984 pair is easy to browse, wrap, unwrap, decrypt, and extend — making the official registry the path of least resistance instead of a thing people route around.
Submitting for the Wrappers Registry track, with the Confidential Payroll / TokenOps special track built into the same app.
Links (everything is live)
- Live app: Launch the dApp — Wrap, Unwrap, Decrypt & Faucet | Macetz — Zama Wrappers Registry
- Narrated demo video (3 min): https://youtu.be/IfY9iK8THK8
- Source: GitHub - pramadanif/macetz · GitHub
- Public test report: Test Report — automated suite results | Macetz — Zama Wrappers Registry
- X thread: 0xAlbatroz on X: "The Zama Wrappers Registry already exists on-chain. Nobody turned it into something people actually use, so developers keep shipping their own wrappers and the ecosystem splinters. I built the missing front door. It's called Macetz. Submitting for @zama Developer Program S3. https://t.co/LK4TWxlOuJ" / X
Contents
- Try it in 60 seconds
- How the registry is sourced (hybrid model)
- Registry integrity detection
- Adding a new pair — four documented paths
- Wrap / Unwrap (the honest two-phase unshield)
- Universal Decrypt — any ERC-7984, not just registry pairs
- Faucet
- Confidential Payroll (TokenOps) + honest privacy model
- Developer experience — guided tutorial + in-app docs
- Engineering quality — tests, CI, SEO, dual-network
- Verified on-chain evidence
- Honest limitations
1. Try it in 60 seconds (Sepolia)
Faucet → mint cUSDCMock → Shield to wrap → Decrypt your balance with one signature → Unshield → then paste any ERC-7984 address (even one that isn’t in the registry) into Decrypt and watch it still work.
2. How the registry is sourced (hybrid model)
The on-chain registry is always the source of truth. A local config allows dev-only extensions without fragmenting the canonical set. Every valid on-chain pair renders — being in Zama’s docs table is a badge, never a filter:
// src/lib/registry.ts (simplified)
export async function fetchRegistryPairs(client, chainId) {
const registryAddress = getRegistryAddress(chainId);
const officialAddresses = getOfficialAddresses(chainId);
const rawPairs = await client.readContract({
address: registryAddress,
abi: REGISTRY_ABI,
functionName: "getTokenConfidentialTokenPairs",
});
// registry is the source of truth — keep every valid pair
const validPairs = rawPairs.filter((p) => p.isValid);
const pairs = await Promise.all(validPairs.map(async (raw) => {
const [erc20Meta, erc7984Meta] = await Promise.all([
fetchTokenMetadata(client, raw.tokenAddress),
fetchTokenMetadata(client, raw.confidentialTokenAddress),
]);
return {
erc20Address: raw.tokenAddress,
erc7984Address: raw.confidentialTokenAddress,
// ...metadata...
source: "registry",
isValid: true,
// docs membership is a BADGE, not a filter
docsVerified: officialAddresses.has(
raw.confidentialTokenAddress.toLowerCase()
),
};
}));
return runIntegrityChecks(pairs);
}
On-chain pairs, local config, and browser previews are then merged, with on-chain winning any duplicate:
// onchain > custom > preview, deduped by wrapper address
export function mergeRegistryPairs(onchain, custom, preview) {
const seen = new Set();
const out = [];
for (const pair of [...onchain, ...custom, ...preview]) {
const key = pair.erc7984Address.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(pair);
}
return out;
}
3. Registry integrity detection
Instead of blindly rendering every entry, Macetz checks each pair on every load and annotates it. The subtle part: an official + Mock split (like ctGBP / ctGBPMock) is expected and must NOT be flagged as a duplicate — only genuinely suspicious duplicates are.
// src/lib/registry.ts — runIntegrityChecks (rules, simplified)
if (pair.metadataUnreadable)
reasons.push("Token metadata unreadable on this network");
if (pair.erc20Address.toLowerCase() === ZERO_ADDRESS)
reasons.push("Underlying token address is the zero address");
if (!pair.metadataUnreadable && pair.erc7984Decimals > 6)
reasons.push(`Wrapper has ${pair.erc7984Decimals} decimals (expected <= 6)`);
// duplicate base symbol across entries...
const hasMock = siblings.some((s) => s.erc7984Symbol.toLowerCase().endsWith("mock"));
const hasOfficial = siblings.some((s) => !s.erc7984Symbol.toLowerCase().endsWith("mock"));
const explainableByMockSplit = hasMock && hasOfficial; // ctGBP + ctGBPMock => OK
if (siblings.length > 1 && !explainableByMockSplit)
reasons.push(`Duplicate symbol "${base}" across ${siblings.length} entries`);
Each pair then shows a Verified or Flagged badge, plus a Docs-verified / Registry badge. Flagged pairs stay usable when isValid — they’re marked, not hidden.
4. Adding a new pair — four documented paths
Extensibility is a hard requirement, so Macetz supports four paths from “just register it” to “deploy your own.”
Path A — Official registration (production). Submit the pair to the on-chain Wrappers Registry. Once isValid == true, Macetz surfaces it on next load. Zero code changes.
Path B — Local config (dev). Add an entry to config/custom-pairs.json, keyed by chain id:
{
"11155111": [
{
"erc20": "0xYourUnderlyingERC20Address",
"erc7984": "0xYourERC7984WrapperAddress",
"symbol": "cMYTOKEN",
"decimals": 18,
"source": "local-dev"
}
],
"1": []
}
configExample: true entries are registry display-only — shown in the list but blocked from Shield/Decrypt/Distribute until real contracts exist.
Path C — In-app admin UI (interactive). Paste both addresses; Macetz validates them on the connected network (ERC-165 + decimals), previews the pair instantly (browser-only, chain-scoped in localStorage), and emits a copy-paste snippet:
// src/lib/preview-pairs.ts
export function buildConfigSnippet(chainId, entry) {
const payload = { [String(chainId)]: [entry] };
return JSON.stringify(payload, null, 2);
}
Path D — Deploy your own (dev-guide/). A standalone Hardhat project deploys an ERC-20 + ERC-7984 wrapper, then you drop the addresses into Path B or C. I dogfooded this — the cMTUSD used in the demo’s Universal Decrypt was deployed with it and is not in the official registry.
Whatever path a pair comes from, two gates decide what it can actually do. Distribute is intentionally stricter than Shield/Decrypt (payroll safety):
// src/lib/pair-utils.ts
export function isOperationalPair(pair) { // Shield / Decrypt
if (pair.configOnly) return false;
if (pair.source === "registry") return pair.isValid;
return pair.isValid && pair.integrityStatus === "verified";
}
export function isDistributeOperationalPair(pair) { // TokenOps payroll — stricter
if (pair.configOnly) return false;
if (pair.source === "registry") return pair.isValid && pair.docsVerified === true;
return pair.isValid && pair.integrityStatus === "verified";
}
5. Wrap / Unwrap
Wrap auto-detects allowance, then approves + wraps behind one guided flow with a live Approving -> Wrapping -> Confirmed indicator, using useShield() from @zama-fhe/react-sdk (the amount is encrypted client-side with TFHE).
Unwrap is the honest two-phase flow: unwrap() emits a request, Zama’s relayer decrypts it, then finalizeUnwrap() returns the ERC-20. The UI shows a real Pending Finalization state (~30–90s) instead of faking instant success:
requesting -> pending-finalization -> confirmed
(relayer decrypts; ~30-90s)
6. Universal Decrypt — any ERC-7984, not just registry pairs
This is the part I’m proudest of. You can decrypt the balance of ANY ERC-7984 token — paste an arbitrary address, it’s validated on-chain via ERC-165, then decrypted with a single EIP-712 signature that can only ever reveal your own balance:
// validate before attempting decrypt
const isERC7984 = await publicClient.readContract({
address: pastedAddress,
abi: ERC165_ABI,
functionName: "supportsInterface",
args: ["0x4958f2a4"], // ERC-7984 interface id
});
// then userDecrypt(addr, EIP-712 signature) via the relayer
Two modes: pick from the registry dropdown, or paste any address. Decrypt is read-only + EIP-712, so it works on Sepolia and mainnet.
7. Faucet
All seven official Sepolia cTokenMocks (cUSDC, cUSDT, cWETH, cBRON, cZAMA, ctGBP, cXAUt mocks), mintable individually or with one “Mint All” click. The Faucet nav item is network-aware — it removes itself on mainnet, where public mints don’t apply.
8. Confidential Payroll (TokenOps) + honest privacy model
One CSV in, one transaction out, built on the official TokenOps Disperse singleton (mode: "direct"). A 4-step sender wizard (token → recipients → preflight → disperse) and a recipient view that decrypts only its own allocation via EIP-712.
Distribution amounts are FHE-encrypted end-to-end; each recipient can decrypt only their own amount. Being explicit, because it matters for judging: recipient addresses are visible on-chain — the Disperse singleton routes confidentialTransferFrom to plaintext addresses, so the recipient list is public by construction. I state this consistently on the landing page, in the in-app docs, and in the README rather than overclaiming.
One engineering note worth sharing: the Zama SDK’s encrypt() returns { encryptedValues: hex[], inputProof: hex }, but the TokenOps Encryptor expects { handles: Uint8Array[], inputProof: Uint8Array }. Passing the relayer straight through type-checks but throws “FHE encryption failed during encrypt” at runtime, so Macetz adapts the shape:
useDisperse({
encryptor: () => ({
encrypt: async (params) => {
const r = await zamaSDK.encrypt(params); // { encryptedValues, inputProof } (hex)
return {
handles: r.encryptedValues.map((v) => hexToBytes(v)),
inputProof: hexToBytes(r.inputProof),
};
},
}),
});
9. Developer experience — guided tutorial + in-app docs
- Show Tutorial — an interactive spotlight tour that walks a first-time user through the whole app tab by tab (Faucet → Registry → Shield → Decrypt → Distribute). It highlights each control instead of making you read a manual; it also auto-shows on first visit.
- In-app Docs — a full quickstart from
git cloneto a running app, all four add-pair paths, security notes, and thedev-guide/Hardhat walkthrough — copy-paste, end to end. Docs are structured data insrc/lib/docs-content.ts, so copy changes never touch layout code.
10. Engineering quality (for the judges)
Tested against the real modules. A Vitest suite (37 tests, 7 suites) imports the actual lib/ code — not copies — and locks in the tricky rules. Example:
// tests/integrity.test.ts
it("does NOT flag the legitimate ctGBP / ctGBPMock split", () => {
const r = runIntegrityChecks([
beforeIntegrity({ erc7984Symbol: "ctGBP", isMock: false }),
beforeIntegrity({ erc7984Symbol: "ctGBPMock", isMock: true }),
]);
expect(r.every((p) => p.integrityStatus === "verified")).toBe(true);
});
it("DOES flag a fabricated duplicate (two non-Mock entries, same symbol)", () => {
const r = runIntegrityChecks([
beforeIntegrity({ erc7984Symbol: "cEVIL", erc7984Address: "0x11..11" }),
beforeIntegrity({ erc7984Symbol: "cEVIL", erc7984Address: "0x22..22" }),
]);
expect(r.every((p) => p.integrityStatus === "flagged")).toBe(true);
});
Results are published as a page: Test Report — automated suite results | Macetz — Zama Wrappers Registry
Centralized, chain-aware error handling. Every wallet interaction routes through one formatter that never tells a mainnet user to “switch to Sepolia”:
// src/lib/errors.ts (excerpt)
if (msg.includes("wrong network"))
return chainId === 1
? "Please switch to Ethereum mainnet to continue."
: "Please switch to Sepolia testnet to continue.";
More of what’s under the hood:
- CI on every push — typecheck (
tsc --noEmit) + production build + the full Vitest suite. Nothing merges red. - Reproducible on-chain proof. A scripted Sepolia E2E emits the exact tx hashes linked in the README (mint, wrap, decrypt, unshield both phases, disperse). Clickable, not asserted.
- Dual-network. Sepolia for the full flow; mainnet for browse + relayer-dependent ops behind a real-funds confirmation gate.
- SEO-optimized and discoverable. Keyword-rich metadata, Open Graph / Twitter cards, auto-generated
sitemap.xml+robots.txt, aWebApplicationJSON-LD block, and a Google Search Console file — so the official registry becomes findable, not just usable. - Minimal trusted surface. No custom application contract; all FHE is delegated to the official
@zama-fhe/*SDKs and the TokenOps singleton. TypeScript strict, one component per concern, SSR-safe, backend-free (a thin same-origin relayer proxy is the only server surface). Licensed BSD-3-Clause-Clear, the same as FHEVM.
11. Verified on-chain evidence (Sepolia)
Real transactions from the scripted E2E, all linked in the README:
- Faucet mint (cUSDCMock)
- Wrap / Shield
- Unwrap phase 1 + finalize phase 2
- TokenOps disperse
- dev-guide deploy:
MTUSD(ERC-20) +cMTUSD(ERC-7984 wrapper), the token used for Universal Decrypt
12. Honest limitations
Unaudited, testnet-first. Mainnet FHE ops depend on Zama’s mainnet relayer being provisioned. TokenOps Distribute is one confidential token per batch, and recipient addresses are public (section 8). The full list is in the README, on purpose.
Feedback very welcome — especially on the registry-integrity rules and the Universal Decrypt flow. Thanks to the Zama team for the FHEVM stack and the Wrappers Registry.
Built for the Zama Developer Program, Season 3. #ZamaDeveloperProgram