This guide walks you through building a complete confidential decentralized application using the Stabletrust SDK and Privy for wallet authentication. By the end, you will have a working Next.js application where users can deposit tokens into an encrypted balance, transfer them privately onchain, and withdraw back to a public balance.
Stabletrust uses Homomorphic Encryption (HE) to give each user a confidential token balance alongside their normal public balance. Once tokens enter the confidential layer, their amounta are encrypted onchain opaque to block explorers and other observers.The user flow:
Connect wallet via Privy.
Initialize a confidential account, which generates the user’s HE keypair through a wallet signature.
Deposit public tokens into the encrypted layer, transfer privately to another address, and withdraw back to a public balance when needed.
Stack used in this guide:
Next.js 16 (App Router, TypeScript)
Privy for embedded wallet and authentication
ethers.js v6 for signer management and amount formatting
@fairblock/Stabletrust for confidential operations
supportedChains and defaultChain lock the app to Base Sepolia so the wallet always targets the correct network.You can see the complete Provider implementation here: app/Providers.tsxThen wrap your layout in app/layout.tsx:
Everything from this point forward lives inside a single React hook: app/hooks/useConfidentialClient.ts. This section builds it piece by piece so each operation is clear before the next one is added.Start by creating the file with its imports and state:
The ConfidentialTransferClient is the entry point to all SDK operations. It connects to the network and resolves the correct Stabletrust contract automatically from the chain ID.Add this useEffect to your hook:
useEffect(() => { const c = new ConfidentialTransferClient( config.rpcUrl, // rpcUrl: JSON-RPC endpoint for the target network config.chainId // chainId: used to auto-resolve the Stabletrust contract address ); setClient(c);}, []);
Parameters for new ConfidentialTransferClient:
Parameter
Type
Description
rpcUrl
string
The HTTP JSON-RPC endpoint of the network
chainId
number
The chain ID. The SDK resolves the Stabletrust contract address from this.
You only need one client instance for the lifetime of the app. Creating it once on mount is sufficient.Custom deployments. If you are pointing at a custom Stabletrust deployment rather than one the SDK resolves automatically from the chain ID, pass the contract address explicitly as a middle argument:
const c = new ConfidentialTransferClient( config.rpcUrl, '0xYourCustomStabletrustContract', // explicit contract address config.chainId);
On the Tempo network (chainId 42431), the SDK uploads ZK proofs to IPFS instead of passing them as calldata. This is handled automatically, so no code change is required.
The Stabletrust SDK expects a standard ethers.Signer. Privy provides its own wallet abstraction, so you need to extract the raw EIP-1193 provider from it and wrap it with ethers.Add this useEffect to your hook it runs whenever authentication state or the wallet list changes:
useEffect(() => { async function setupSigner() { if (authenticated && wallets.length > 0) { const wallet = wallets[0]; // 1. Switch to the correct chain before doing anything await wallet.switchChain(config.chainId); // 2. Get the raw EIP-1193 provider from Privy const provider = await wallet.getEthereumProvider(); // 3. Wrap it in ethers.BrowserProvider const ethersProvider = new ethers.BrowserProvider(provider); // 4. Derive the Signer this is what the SDK expects const s = await ethersProvider.getSigner(); setSigner(s); } else { setSigner(null); } } setupSigner();}, [authenticated, wallets]);
What each step does:
switchChain: forces the wallet onto the correct network before any transaction is signed. Without this, the user could accidentally sign on the wrong chain.
getEthereumProvider(): returns the raw EIP-1193 provider Privy uses internally.
BrowserProvider: the ethers.js v6 wrapper for browser-injected providers.
getSigner(): returns a JsonRpcSigner capable of signing transactions and messages.
The signer produced here is passed into every SDK operation below.
Before any confidential operation can happen, the user must have a confidential account registered onchain with their HE public key. ensureAccount handles the full flow in one call: it prompts a wallet signature, derives the HE keypair from that signature, and registers the public key onchain if it does not yet exist.Add this function to your hook:
const ensureAccount = async () => { if (!client || !signer) throw new Error('Not initialized'); setLoading(true); setError(null); try { const keys = await client.ensureAccount( signer // ethers.Signer the user's signer derived from the Privy wallet ); setUserKeys(keys); } catch (e: any) { setError(e.message); } finally { setLoading(false); }};
Parameters for client.ensureAccount:
Parameter
Type
Description
signer
ethers.Signer
The user’s signer derived from the Privy wallet
Returns:{ publicKey: string, privateKey: string }The privateKey returned here is a derived HE key, not the user’s wallet private key. It never leaves the browser and is re-derived on each session from the wallet signature. Store it in React state and pass it to getConfidentialBalance later.
Account initialization includes an onchain finalization step. Expect this to take approximately 45 seconds on the first call. The method waits for finalization before returning, so loading will be true for the duration.
5.4 Deposit: Move Tokens into the Confidential Layer
Deposit converts public ERC20 tokens into an encrypted confidential balance. The SDK handles the ERC20 approval and the deposit transaction in a single call you do not need to send a separate approve transaction.Add this function to your hook:
const confidentialDeposit = async (humanAmount: string) => { if (!client || !signer) throw new Error('Not initialized'); setLoading(true); setError(null); try { // Convert the human-readable amount to token base units const amount = ethers.parseUnits(humanAmount, tokenDecimals); // e.g. "10" with 6 decimals → 10_000_000n await client.confidentialDeposit( signer, // ethers.Signer the user's wallet signer config.tokenAddress, // string the ERC20 contract address to deposit amount // BigInt amount in token base units ); // Wait briefly for the chain state to settle, then refresh balances setTimeout(() => fetchBalances(), 2000); } catch (e: any) { setError(e.message); } finally { setLoading(false); }};
Parameters for client.confidentialDeposit:
Parameter
Type
Description
signer
ethers.Signer
The user’s wallet signer
tokenAddress
string
The ERC20 token contract address
amount
BigInt
Amount in the token’s base unit always use ethers.parseUnits to convert
Returns: A transaction receipt once the deposit is confirmed and finalized onchain.
Always use ethers.parseUnits(humanAmount, decimals) to convert. Never pass a raw decimal number directly - the SDK expects base units as a BigInt.
Withdraw removes tokens from the encrypted confidential balance and returns them to the user’s standard ERC20 balance. After withdrawal, the amount is visible onchain again.Add this function to your hook:
const withdraw = async (humanAmount: string) => { if (!client || !signer) throw new Error('Not initialized'); setLoading(true); setError(null); try { // Convert to base units, then cast to Number as the withdraw method expects const amount = ethers.parseUnits(humanAmount, tokenDecimals); await client.withdraw( signer, // ethers.Signer the user's wallet signer config.tokenAddress, // string the ERC20 token contract address Number(amount) // number amount in base units, cast to Number ); setTimeout(() => fetchBalances(), 2000); } catch (e: any) { setError(e.message); } finally { setLoading(false); }};
Parameters for client.withdraw:
Parameter
Type
Description
signer
ethers.Signer
The user’s wallet signer
tokenAddress
string
The ERC20 token contract address
amount
number
Amount in token base units pass as Number(ethers.parseUnits(...))
Returns: A transaction receipt.
Unlike confidentialDeposit, withdraw takes a number, not a BigInt. Always cast with Number(ethers.parseUnits(...)).
Confidential transfer sends tokens from the caller’s encrypted balance to another address’s encrypted balance. The amount is encrypted onchain: block explorers show that a transfer occurred and between which addresses, but not the value moved. The sender and recipient addresses stay visible this client hides amounts, not identities. If you also need to hide the sender’s address, use the Anonymous Transfers flow.Add this function to your hook:
const confidentialTransfer = async ( recipientAddress: string, humanAmount: string) => { if (!client || !signer) throw new Error('Not initialized'); setLoading(true); setError(null); try { const amount = ethers.parseUnits(humanAmount, tokenDecimals); await client.confidentialTransfer( signer, // ethers.Signer the sender's wallet signer recipientAddress, // string the recipient's public Ethereum address (0x...) config.tokenAddress, // string the ERC20 token contract address Number(amount) // number amount in base units, cast to Number ); setTimeout(() => fetchBalances(), 2000); } catch (e: any) { setError(e.message); } finally { setLoading(false); }};
Parameters for client.confidentialTransfer:
Parameter
Type
Description
signer
ethers.Signer
The sender’s wallet signer
recipientAddress
string
The recipient’s public Ethereum address (0x...)
tokenAddress
string
The ERC20 token contract address
amount
number
Amount in token base units pass as Number(ethers.parseUnits(...))
Returns: A transaction receipt.
The recipient must have already called ensureAccount and have a registered confidential account before you can transfer to them. If their account does not exist onchain, the transaction fails with "Account does not exist".
The confidential balance is stored encrypted onchain. The SDK decrypts it client-side using the user’s privateKey from ensureAccount. Call this after every operation to reflect the latest state.Add this function to your hook:
const fetchBalances = async () => { if (!client || !signer || !userKeys) return; try { const address = await signer.getAddress(); // Decrypt the confidential balance using the user's HE private key const confidentialBalance = await client.getConfidentialBalance( address, // string the user's wallet address userKeys.privateKey, // string the HE private key from ensureAccount config.tokenAddress // string the ERC20 token contract address ); // confidentialBalance.amount BigInt: total of available + pending // confidentialBalance.available { amount: BigInt, ciphertext: string } // confidentialBalance.pending { amount: BigInt, ciphertext: string } // Also fetch the public ERC20 balance const publicBalance = await client.getPublicBalance( address, config.tokenAddress ); setBalances({ confidential: ethers.formatUnits( confidentialBalance.amount, tokenDecimals ), public: ethers.formatUnits(publicBalance, tokenDecimals), native: '0', // fetch native ETH separately if needed }); } catch (e: any) { // Balance fetch errors are non-blocking don't surface them as UI errors console.error('Balance fetch failed:', e.message); }};
Parameters for client.getConfidentialBalance:
Parameter
Type
Description
address
string
The user’s wallet address
privateKey
string
The HE private key returned by ensureAccount
tokenAddress
string
The ERC20 token contract address
Return fields:
Field
Type
Description
amount
BigInt
Total balance = available + pending combined
available
{ amount: BigInt, ciphertext: string }
Settled, spendable balance
pending
{ amount: BigInt, ciphertext: string }
Incoming balance not yet settled
available is what can be transferred or withdrawn immediately. pending represents amounts that have been deposited but are still finalizing onchain after finalization, pending becomes available. For most display purposes, show amount (the total) and optionally break it down into available and pending.
The UI is a single page component at app/page.tsx. This section builds it piece by piece in the same order as the hook connect, initialize, deposit, withdraw, transfer.Start with the skeleton:
// app/page.tsx'use client';import { usePrivy } from '@privy-io/react-auth';import { useState } from 'react';import { useConfidentialClient } from './hooks/useConfidentialClient';export default function Home() { const { login, logout, authenticated, user } = usePrivy(); const { userKeys, balances, loading, error, tokenSymbol, ensureAccount, confidentialDeposit, confidentialTransfer, withdraw, } = useConfidentialClient(); // Local form state added in the sections below return ( <main className="min-h-screen bg-gray-50 p-8"> <div className="max-w-lg mx-auto space-y-6"> {/* Sections added below */} </div> </main> );}
The first thing the user sees is a connect button. Show it when unauthenticated, and swap it for a disconnect button and the user’s wallet address once connected.Add this block inside <main>:
Privy’s login() opens its built-in modal. user?.wallet?.address is the connected address it only exists after authentication, so the optional chain prevents errors during the pre-auth render.
Once connected, the user must initialize their confidential account before any other action is possible. Show this block only when authenticated but userKeys is still null.Add this block after the connect section:
ensureAccount comes directly from the hook. loading is set to true by the hook for the duration of the call, so the button disables automatically while the transaction is processing.
Once userKeys exists, show the user’s public and confidential balances at the top of the action area. These values are refreshed automatically by the hook’s 10-second polling interval and after each operation.Add this block after the initialize section:
balances.public and balances.confidential are already formatted as human-readable strings by the hook (via ethers.formatUnits) no conversion needed here.
The deposit form moves tokens from the public balance into the confidential layer. Add this state to the top of the component alongside the other state declarations:
The input captures a human-readable amount (e.g. "10"). The hook’s confidentialDeposit function handles the ethers.parseUnits conversion internally, so you pass the raw string directly.
The error string is set by the hook when any operation throws. It resets to null at the start of each new operation, so stale errors clear automatically when the user retries.You can see the complete UI implementation here: app/page.tsx
Click Initialize Confidential Account - the wallet prompts a signature. Wait approximately 45 seconds for onchain finalization.
3
Account becomes active
The balance display and action forms appear once the account is active.
4
Deposit, Withdraw, Transfer
Deposit moves tokens from your public balance into the encrypted layer, Withdraw moves them back to your public ERC20 balance, and Transfer sends them privately to another address (recipient must have an initialized account).
Ensure your wallet has test ETH on Base Sepolia for gas. You can get test ETH from the Base Sepolia faucet.