SDK/Building with Stabletrust

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.

Next.js (App Router)TypeScriptPrivyethers.js v6@fairblock/stabletrust

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

Node.js 18 or later
A Privy App ID from dashboard.privy.io
Test ETH on Base Sepolia for gas fees
A Next.js project (App Router)

1. Environment variables

Create a .env.local file at the root of your project:

.env.local
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=84532

2. Install dependencies

bash
npm install @fairblock/stabletrust ethers viem @privy-io/react-auth

3. Set up Privy

Create a providers wrapper and add it to your root layout:

app/providers.tsx
// 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
// 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
// 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

ErrorCause & Fix
Account does not existRecipient has not called ensureAccount(). Both sender and recipient must initialize.
Insufficient balanceavailable balance is less than the requested amount. Check with getConfidentialBalance().
Account finalization timeoutOn-chain registration is still pending. Wait ~45s and retry.

REST API Reference

Prefer HTTP over the SDK? Use the Stabletrust API directly.