Build a confidential app
A complete tutorial for building a Next.js application with encrypted token balances, private transfers, and withdrawals Stabletrust powered by Stabletrust and Privy.
Note: This walkthrough uses Privy to demonstrate a production-ready authentication flow. However, the Stabletrust SDK is wallet-agnostic and can be seamlessly integrated with any wallet provider or custom interface.
Overview
01
Install dependencies
Add the SDK and wallet libraries.
02
Configure Privy
Set up wallet authentication with your Privy App ID.
03
Create the hook
Wrap the SDK in a React hook for easy state management.
04
Build the UI
Connect wallet, display encrypted balance, and handle transactions.
Prerequisites
1. Environment variables
Create a .env.local file at the root of your project:
NEXT_PUBLIC_PRIVY_APP_ID=your_privy_app_id
NEXT_PUBLIC_RPC_URL=https://base-sepolia.drpc.org
NEXT_PUBLIC_TOKEN_ADDRESS=0x036CbD53842c5426634e7929541eC2318f3dCF7e
NEXT_PUBLIC_CHAIN_ID=845322. Install dependencies
npm install @fairblock/stabletrust ethers viem @privy-io/react-auth3. Set up Privy
Create a providers wrapper and add it to your root layout:
// app/providers.tsx
'use client';
import { PrivyProvider } from '@privy-io/react-auth';
import { baseSepolia } from 'viem/chains';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider
appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID!}
config={{
defaultChain: baseSepolia,
supportedChains: [baseSepolia],
embeddedWallets: { createOnLogin: 'users-without-wallets' },
}}
>
{children}
</PrivyProvider>
);
}4. Create the confidential hook
This hook wraps the SDK, extracts the Privy EIP-1193 provider, and exposes deposit, transfer, and withdraw actions with automatic balance polling:
// hooks/useConfidential.ts
'use client';
import { useState, useEffect, useCallback } from 'react';
import { usePrivy, useWallets } from '@privy-io/react-auth';
import { ethers } from 'ethers';
import { ConfidentialTransferClient } from '@fairblock/stabletrust';
const TOKEN = process.env.NEXT_PUBLIC_TOKEN_ADDRESS!;
const CHAIN_ID = Number(process.env.NEXT_PUBLIC_CHAIN_ID!);
const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL!;
export function useConfidential() {
const { authenticated } = usePrivy();
const { wallets } = useWallets();
const [balance, setBalance] = useState({ total: '0', available: '0', pending: '0' });
const [loading, setLoading] = useState(false);
// Wrap Privy's EIP-1193 provider → ethers signer
const getSigner = useCallback(async () => {
const wallet = wallets[0];
const provider = await wallet.getEthereumProvider();
const web3Provider = new ethers.BrowserProvider(provider);
return web3Provider.getSigner();
}, [wallets]);
const getClient = useCallback(async () => {
const signer = await getSigner();
const client = new ConfidentialTransferClient({ rpcUrl: RPC_URL, chainId: CHAIN_ID });
return { client, signer };
}, [getSigner]);
const refreshBalance = useCallback(async () => {
try {
const { client, signer } = await getClient();
const b = await client.getConfidentialBalance({ signer, tokenAddress: TOKEN });
setBalance(b);
} catch {
// Account may not exist yet
}
}, [getClient]);
// Poll balance every 10 seconds
useEffect(() => {
if (!authenticated) return;
refreshBalance();
const interval = setInterval(refreshBalance, 10_000);
return () => clearInterval(interval);
}, [authenticated, refreshBalance]);
const initAccount = useCallback(async () => {
setLoading(true);
try {
const { client, signer } = await getClient();
await client.ensureAccount(signer);
await refreshBalance();
} finally {
setLoading(false);
}
}, [getClient, refreshBalance]);
const deposit = useCallback(async (amount: string) => {
setLoading(true);
try {
const { client, signer } = await getClient();
await client.confidentialDeposit({
signer,
tokenAddress: TOKEN,
amount: ethers.parseUnits(amount, 6), // USDC has 6 decimals
});
await refreshBalance();
} finally {
setLoading(false);
}
}, [getClient, refreshBalance]);
const transfer = useCallback(async (recipient: string, amount: string) => {
setLoading(true);
try {
const { client, signer } = await getClient();
await client.confidentialTransfer({
signer,
tokenAddress: TOKEN,
recipientAddress: recipient,
amount: Number(amount),
});
await refreshBalance();
} finally {
setLoading(false);
}
}, [getClient, refreshBalance]);
const withdraw = useCallback(async (amount: string) => {
setLoading(true);
try {
const { client, signer } = await getClient();
await client.withdraw({
signer,
tokenAddress: TOKEN,
amount: Number(amount),
});
await refreshBalance();
} finally {
setLoading(false);
}
}, [getClient, refreshBalance]);
return { balance, loading, initAccount, deposit, transfer, withdraw };
}Signer extraction: Privy EIP-1193 provider must be wrapped with ethers.BrowserProvider to produce an ethers-compatible signer for the SDK.
Balance polling: The hook polls every 10 seconds and refreshes after each transaction.
5. Build the UI
A minimal page that connects the wallet, shows the encrypted balance, and allows deposit, transfer, and withdrawal:
// app/page.tsx
'use client';
import { usePrivy } from '@privy-io/react-auth';
import { useState } from 'react';
import { useConfidential } from '@/hooks/useConfidential';
export default function App() {
const { login, logout, authenticated, user } = usePrivy();
const { balance, loading, initAccount, deposit, transfer, withdraw } = useConfidential();
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
if (!authenticated) {
return (
<div className="flex min-h-screen items-center justify-center">
<button onClick={login} className="rounded-lg bg-black px-6 py-3 text-white">
Connect Wallet
</button>
</div>
);
}
return (
<main className="mx-auto max-w-md p-8 space-y-6">
<div className="rounded-xl border p-5 space-y-1">
<p className="text-xs text-zinc-400">Encrypted balance</p>
<p className="text-3xl font-bold">{balance.available}</p>
<p className="text-xs text-zinc-400">Pending: {balance.pending}</p>
</div>
<button onClick={initAccount} disabled={loading}
className="w-full rounded-lg border py-2.5 text-sm">
Initialize Account
</button>
<div className="space-y-3">
<input value={amount} onChange={e => setAmount(e.target.value)}
placeholder="Amount" className="w-full rounded-lg border px-3 py-2 text-sm" />
<input value={recipient} onChange={e => setRecipient(e.target.value)}
placeholder="Recipient address (for transfer)"
className="w-full rounded-lg border px-3 py-2 text-sm" />
<div className="grid grid-cols-3 gap-2">
<button onClick={() => deposit(amount)} disabled={loading}
className="rounded-lg bg-black py-2.5 text-sm text-white">Deposit</button>
<button onClick={() => transfer(recipient, amount)} disabled={loading}
className="rounded-lg border py-2.5 text-sm">Transfer</button>
<button onClick={() => withdraw(amount)} disabled={loading}
className="rounded-lg border py-2.5 text-sm">Withdraw</button>
</div>
</div>
<button onClick={logout} className="text-xs text-zinc-400 hover:text-zinc-600">
Disconnect
</button>
</main>
);
}Account initialization
One-time setup required: Each wallet must call ensureAccount() once before using the confidential layer.
This generates homomorphic encryption keypairs via a wallet signature and registers public keys onchain. Finalization takes approximately 45 seconds.
Subsequent operations (deposit, transfer, withdraw) do not require re-initialization.
Error reference
| Error | Cause & Fix |
|---|---|
Account does not exist | Recipient has not called ensureAccount(). Both sender and recipient must initialize. |
Insufficient balance | available balance is less than the requested amount. Check with getConfidentialBalance(). |
Account finalization timeout | On-chain registration is still pending. Wait ~45s and retry. |