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 SDK Demo

The complete source code for this guide.

What You Are Building

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 amounts are encrypted onchain opaque to block explorers and other observers. The user flow:
  1. Connect wallet via Privy.
  2. Initialize a confidential account, which generates the user’s HE keypair through a wallet signature.
  3. 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
  • viem for chain definitions

Prerequisites

  • Node.js 18 or higher
  • A Privy App ID created at dashboard.privy.io
  • A wallet funded with test ETH on Base Sepolia for gas

Step 1: Create the Next.js Application

When prompted, choose: TypeScript, ESLint, Tailwind CSS, App Router.

Step 2: Install Dependencies

Step 3: Environment Variables

Create .env.local in the root of your project:
  • NEXT_PUBLIC_PRIVY_APP_ID: from your Privy dashboard
  • NEXT_PUBLIC_RPC_URL: the JSON-RPC endpoint for Base Sepolia
  • NEXT_PUBLIC_TOKEN_ADDRESS: the ERC20 token for this demo (test USDC on Base Sepolia)
  • NEXT_PUBLIC_CHAIN_ID: 84532 is Base Sepolia

Step 4: Add the Privy Provider

Privy must wrap the entire application so its authentication context is available everywhere. Create app/Providers.tsx:
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.tsx Then wrap your layout in app/layout.tsx:
All components inside now have access to usePrivy() and useWallets(). You can see the complete Providers.tsx used in the demo here: app/Providers.tsx

Step 5: Building the Hook, Step by Step

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:
Each of the following sections adds one piece to this hook.

5.1 Initialize the SDK Client

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:
Parameters for new ConfidentialTransferClient: 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:

5.2 Wrap the Privy Wallet into an Ethers Signer

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:
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.

5.3 Initialize the Confidential Account

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:
Parameters for client.ensureAccount: 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:
Parameters for client.confidentialDeposit: 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.

5.5 Withdraw: Move Tokens Back to Public

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:
Parameters for client.withdraw: Returns: A transaction receipt.
Unlike confidentialDeposit, withdraw takes a number, not a BigInt. Always cast with Number(ethers.parseUnits(...)).

5.6 Transfer Send Tokens Privately

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 Unlinkable Transfers flow. Add this function to your hook:
Parameters for client.confidentialTransfer: 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".

5.7 Fetch the Confidential Balance

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:
Parameters for client.getConfidentialBalance: Return fields: 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.

5.8 Finish the Hook

Wire up balance polling and export everything. Add this to the bottom of the hook, just before the closing return:
The hook is now complete. You can see the complete implementation here: app/hooks/useConfidentialClient.ts Components import it like this:
You can see the complete implementation of this hook here: app/hooks/useConfidentialClient.ts

Step 6: Building the UI Step by Step

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:

6.1 Connect Wallet

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.

6.2 Initialize Confidential Account

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.

6.3 Balance Display

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.

6.4 Deposit Form

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:
Then add this block after the balance display:
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.

6.5 Withdraw Form

The withdraw form moves tokens from the confidential balance back to the public ERC20 balance. Add this state:
Then add this block:

6.6 Transfer Form

The transfer form sends tokens privately to another address. It requires a recipient address in addition to an amount. Add this state:
Then add this block:
The button stays disabled until both recipient and transferAmount have values, preventing accidental empty submissions.

6.7 Error Display

Add a global error banner that surfaces errors from any hook operation. Place this at the bottom of the <main> content, after all the action blocks:
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

Step 7: Run the Application

Open http://localhost:3000. Expected flow:
1

Connect Wallet

Click Connect Wallet - Privy opens a modal.
2

Initialize Confidential Account

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.

Further Reading

Architecture

The cryptographic design and system layers behind confidential transfers.

Stabletrust

The browser-based interface for confidential stablecoins and transfers.

Confidential Transfers

How the core confidential operations work end to end.

Common Errors

Troubleshoot errors returned by the SDK.