React Hooks
Obscura’s UI never constructs a raw contract call. Every read, write, and
decryption goes through a typed hook in
apps/web/hooks.
They are deliberately written as plain wagmi + viem + react-query so you can
lift them into your own project with minimal surgery.
Dependencies: wagmi v3, viem v2, @tanstack/react-query v5,
@zama-fhe/relayer-sdk, plus small lib modules the hooks import:
lib/fhevm.ts
(the SDK singleton),
lib/unwrap-store.ts
(persistent unwrap state), and
lib/local-pairs-store.ts
(pairs a visitor added themselves, stored per-browser).
Reads
useRegistryPairs(): use-registry.ts
Every registered pair, paged through the slice functions and enriched with
symbols, names, decimals, rate, and TVS. Refetches every 60 s so new
registrations and revocations appear without a redeploy. Metadata reads use
Promise.allSettled, so one broken token degrades to placeholders instead of
hiding the pair.
Sourcing is hybrid, three tiers deep: the onchain registry is the primary
source of truth; pairs declared in
config/custom-pairs.ts
are merged in next with source: "custom"; pairs a visitor added themselves
through the registry page’s Add a pair form (stored in their browser via
lib/local-pairs-store.ts)
are merged in last with source: "local". Each tier only fills in wrappers
the tiers above it don’t already claim. See “Adding a new pair” in the repo
README.
The registry exists on two networks, and the hook can read either.
network defaults to "sepolia"; pass "mainnet" to browse the Ethereum
mainnet registry (read-only in Obscura, and custom/local pairs are not
merged there since they declare Sepolia addresses).
const { data: pairs, isLoading } = useRegistryPairs();
// pairs: EnrichedPair[], includes isValid, rate, tvs, wrapperDecimals...
const { data: mainnetPairs } = useRegistryPairs({ network: "mainnet" });
// the production pairs: cUSDC, cUSDT, cWETH, ...useUnderlyingBalance(tokenAddress): use-wrap.ts
Live ERC-20 balance of the connected account, 30 s refetch.
useConfidentialBalanceHandle(wrapper): use-decrypt.ts
The bytes32 balance handle for the connected account. Exposes the exported
ZERO_HANDLE constant to detect never-funded accounts.
Writes
useWrapFlow(pair): use-wrap.ts
The full approve-then-wrap sequence behind one function, with per-step state
for the UI (idle → approving → wrapping → success).
const { wrap, step, error, txHash, reset } = useWrapFlow(pair);
await wrap(amount, roundedAmount); // checks allowance, approves if needed, wrapsThe live allowance is checked at the start of every run, so a retry never re-prompts for an approval that already exists, and the approval covers the rounded amount only.
useUnwrapActions(): use-unwrap.ts
The two-step unwrap as a resumable state machine. Each transition persists to
localStorage through lib/unwrap-store, so any step can fail and resume,
even after a page reload.
const { startUnwrap, resumeUnwrap, busyKey } = useUnwrapActions();
await startUnwrap(pair, amountWrapperUnits); // encrypt → unwrap → decrypt → finalize
await resumeUnwrap(record); // pick up a stored record wherever it stoppedresumeUnwrap derives the next action from which fields the record already
carries (request id? cleartext + proof?), recovers the request id from the tx
receipt if the tab closed mid-flight, and detects requests someone else
already finalized via unwrapRequester.
useDecryptBalance(wrapper): use-decrypt.ts
User decryption behind a single call: throwaway keypair, EIP-712 signature, relayer round-trip. The cleartext lives in component state only.
const { decrypt, hide, step, error, decrypted } = useDecryptBalance(wrapper);
await decrypt(handle); // step: idle → signing → decryptingAggregates
| Hook | File | What it gives you |
|---|---|---|
usePortfolio() | use-portfolio.ts | The user’s holdings across every wrapper, decrypt-on-demand, pending unwraps |
useActivity(...) | use-activity.ts | Paginated global activity feed from the REST API |
useStats() | use-stats.ts | Global totals: pairs, valid/revoked, aggregate TVS |
useFaucet(...) | use-faucet.ts | Mints the mock underlying tokens on Sepolia |
Cache invalidation is part of the contract. After a wrap, the hooks
invalidate the erc20-balance, confidential-balance, and registry
query keys; an unwrap burn invalidates confidential-balance immediately
(the balance shrinks at the burn, not at finalization). If you lift a hook,
keep its invalidations; they are what make the UI feel correct.
Lifting them
- Copy
packages/shared(ABIs, addresses, types) or install the equivalents. - Copy
lib/fhevm.tsand, if you take the unwrap hook,lib/unwrap-store.ts. - Copy the hook files you need; they import nothing from Obscura’s UI other than small formatting helpers you can inline.
- Serve your app with the COOP/COEP headers the Relayer SDK needs.