SDK Structure
The SDK is a TypeScript library that app developers integrate to use the privacy system. It handles key management, transaction construction, proof generation, balance decryption, and state synchronization behind a high-level API. The SDK is compatible with web browsers, Node.js, and mobile (React Native on iOS and Android).
Architecture
The SDK follows a factory pattern with dependency injection. Three platform-sensitive subsystems — proof generation, discrete-log solving, and persistent storage — are abstracted behind injected interfaces. Platform-specific factories wire the correct implementations automatically.
Backend Interfaces
All platform-specific behavior is expressed through three interfaces:
interface ISolverBackend {
init(): Promise<{ ready: true }>;
solve(xHex: string, yHex: string): Promise<SolveResult>;
dispose?(): void;
}
interface IProverBackend {
prove(request: ProveRequest): Promise<ProveResult>;
init?(): Promise<void>;
destroy?(): Promise<void>;
}
interface IStorageAdapter {
get(key: string): Promise<any | null>;
set(key: string, value: any): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
}
Implementations ship per platform:
| Interface | Web / Node | iOS (React Native) | Test |
|---|---|---|---|
ISolverBackend | DlogSolver (WASM) | NativeSolverAdapter (Rust FFI via Expo modules) | MockSolverBackend |
IProverBackend | WasmProverBackend (NoirJS + bb.js) | MoProAdapter (native Rust) | MockProverBackend |
IStorageAdapter | IndexedDBStorage / FileStorage | MMKVStorage (encrypted, via react-native-mmkv) | MemoryStorage |
Services
Two high-level services wrap backends with domain logic:
DecryptServicewrapsISolverBackend— performs ElGamal decryption followed by discrete-log solving, with lazy initialization.ProofServicewrapsIProverBackend— handles circuit loading, caching, witness mapping, and prover prewarming.
This two-level split (backend -> service) keeps the client platform-agnostic. Adding a new platform requires only a backend implementation, not changes to service logic.
Configuration
NetworkConfig
A NetworkConfig describes a single deployment target — a contract address, circuit version, and chain. All deployment and feature configuration lives here:
interface NetworkConfig {
chainId: number;
rpcUrl: string;
contract: `0x${string}`;
tokens: Record<string, `0x${string}`>;
relayer: { url: string; timeout?: number };
chain?: Chain; // viem Chain object (optional, inferred from chainId)
swap?: { registry: `0x${string}` };
l3?: {
stakeCircuit: CircuitSource;
unstakeCircuit: CircuitSource;
};
circuits?: {
transfer?: CircuitSource;
unshield?: CircuitSource;
};
}
Feature availability is determined entirely by the shape of NetworkConfig: swap is enabled when network.swap exists, l3 when network.l3 provides circuit sources. The relayer is always required — every operation defaults to relayer submission (gasless), and stealth operations cannot function without one.
ClientConfig
ClientConfig holds only infrastructure concerns — DI slots for platform backends, telemetry, and callbacks. No feature configuration lives here:
interface ClientConfig {
network?: NetworkConfig; // defaults to NETWORKS.BASE
// DI slots (factories provide defaults per platform)
solver?: ISolverBackend;
prover?: IProverBackend;
storage?: IStorageAdapter;
stakeStoreFactory?: (id: string) => IStakeStore;
threads?: number;
// Callbacks
onProgress?: (...) => void;
onLog?: (...) => void;
onAssetLoading?: (...) => void;
}
SDK-Shipped Network Defaults
The SDK ships a NETWORKS registry with known deployments:
export const NETWORKS = {
BASE: {
chainId: 8453,
rpcUrl: 'https://mainnet.base.org',
contract: '0xDea0882f6026e7c4458fbdD67296D89FF849279b',
tokens: {
USD: '0x9f6d30758b85bd2f4b6107550756162e04ce1650',
EUR: '0x06f9706c8defcebd9cafe7c49444fc768e89d7a7',
PLN: '0xbdcdbe9a1ee3ce45b6eea8ec4d7cb07cd8444720',
},
swap: { registry: '0x667d6c4d1e69399a8b881b474100dccf73ce42a0' },
relayer: { url: 'https://eb-relayer.zkprivacy.dev' },
},
ANVIL: {
chainId: 31337,
rpcUrl: 'http://127.0.0.1:8545',
contract: '0x5FbDB2315678afecb367f032d93F642f64180aa3',
tokens: { USD: '0x5FbDB2315678afecb367f032d93F642f64180aa3' },
relayer: { url: 'http://127.0.0.1:3001' },
},
} as const;
When no network is passed, factories default to NETWORKS.BASE.
Platform Factories
Multi-step creation collapses to one async call per platform:
const client = await createWebClient({ network: NETWORKS.BASE });
const client = await createNodeClient({ network: NETWORKS.BASE });
const client = await createMobileClient(); // zero-config, defaults to NETWORKS.BASE
Each factory selects the appropriate backends for its platform, applies the NETWORKS.BASE default, initializes services, creates network resources, and returns a ready-to-use client.
| Factory | Solver | Prover | Storage |
|---|---|---|---|
createWebClient | WASM (DlogSolver) | NoirJS + bb.js (WasmProverBackend) | IndexedDB |
createNodeClient | WASM (DlogSolver, node build) | NoirJS + bb.js (WasmProverBackend) | Filesystem |
createMobileClient | Native Rust (NativeSolverAdapter) | MoPro (MoProAdapter) | MMKV |
Direct client construction via new EBClient(config) is available for custom setups where a factory does not fit.
Client and Wallet Lifecycle
Client (long-lived)
The client is the long-lived object that owns all persistent resources. It is bound to a single network (deployment target):
const client = await createWebClient({ network: NETWORKS.BASE });
The client owns two tiers of resources:
SharedInfra — heavyweight, chain-agnostic services (shareable across clients via DI):
interface SharedInfra {
readonly decrypt: DecryptService;
readonly proof: ProofService;
readonly config: Readonly<ClientConfig>;
}
NetworkResources — scoped to the configured network:
interface NetworkResources {
readonly chain: ChainReader;
readonly relayer?: RelayerClient;
readonly tree?: ITreeService;
readonly stakeStores: Map<string, IStakeStore>;
}
Wallet (lightweight session)
A wallet is a user identity bound to the client's network resources — a session, not a long-lived object:
const wallet = client.createWallet({ spendingKey, walletClient });
The consumer passes a viem WalletClient, preserving their transport config, middleware, and signer (browser extension, hardware wallet, etc.). The client validates that walletClient.chain.id matches config.network.chainId and throws on mismatch.
When a wallet is garbage-collected, only its in-memory caches (balance, history) are lost. No cleanup is needed.
Client (app lifetime) — bound to one network
├── SharedInfra: DecryptService, ProofService
├── NetworkResources
│ ├── ChainReader
│ ├── ITreeService
│ ├── RelayerClient
│ └── stakeStores: Map<(contract, address) -> IStakeStore>
│
Wallet (session lifetime) — lightweight, borrows from client
├── identity: spendingKey, BPK
├── walletClient (from consumer)
├── ref -> NetworkResources
├── ref -> SharedInfra
├── balance cache (ephemeral)
└── feature modules: L3Module, SwapModule, ...
Feature Modules
Each feature is a self-contained module, accessed via namespaced properties on the wallet:
wallet.l3 // L3Module | null — Shielded Layer (shielded deposit/withdrawal)
wallet.swap // SwapModule | null — atomic swaps
wallet.stealth // StealthModule | null — stealth addresses
wallet.history // HistoryModule — always present
A module is null when NetworkConfig lacks the required sub-config (e.g., swap is null unless network.swap is configured; l3 is null unless network.l3 provides circuit sources).
Consumer access is idiomatic:
await wallet.l3?.stake(100n);
if (wallet.swap) {
const { offerId } = await wallet.swap.createOffer({...});
}
Core concealed-layer operations (transfer, shield, unshield, getBalance) live directly on the wallet object.
Prepare/Submit Split
Complex operations use an internal prepare/submit split for testability:
class L3ModuleImpl implements L3Module {
prepareStake(balance, amount, treeSnapshot, arPK): PreparedStake { ... } // pure
private async submitStake(prepared, options): Promise<`0x${string}`> { ... } // side effects
async stake(amount, options?): Promise<Stake> {
const prepared = this.prepareStake(...);
const txHash = await this.submitStake(prepared, options);
return this.recordStake(prepared, txHash);
}
}
prepareStake() is a pure function — unit-testable with zero mocks. The public API remains a single wallet.l3.stake() call that handles everything.
Multi-Network Support
Each client targets a single network. Multi-network scenarios use multiple clients with shared backends via DI:
const solver = new DlogSolver();
const prover = new WasmProverBackend({ threads: 4 });
await solver.init();
await prover.init();
const baseClient = await createWebClient({ network: NETWORKS.BASE, solver, prover });
const arbClient = await createWebClient({ network: arbConfig, solver, prover });
const baseWallet = baseClient.createWallet({ spendingKey, walletClient });
const arbWallet = arbClient.createWallet({ spendingKey, walletClient: arbWalletClient });
baseWallet.BPK === arbWallet.BPK; // true — same identity, different networks
The prover and solver are chain-agnostic (they operate on math, not chain state), so sharing is safe. Network-specific resources (chain reader, tree service, relayer) are per-client and cannot be shared.
Contract Migration
A "network" is a deployment target — a contract address and circuit version on a chain. Two deployments on the same chain are two separate clients:
const currentClient = await createWebClient({
network: { ...NETWORKS.BASE, l3: { stakeCircuit: '/eb_stake.json', unstakeCircuit: '/eb_unstake.json' } },
solver, prover,
});
const legacyClient = await createWebClient({
network: {
...NETWORKS.BASE,
contract: '0xOldContract...',
circuits: { transfer: '/v1/eb_transfer.json', unshield: '/v1/eb_unshield.json' },
},
solver, prover,
});
// Migration: same identity, same chain, different contracts
const wallet = currentClient.createWallet({ spendingKey, walletClient });
const legacy = legacyClient.createWallet({ spendingKey, walletClient });
const balance = await legacy.getBalance();
await legacy.unshield(balance, myAddress);
await wallet.shield({ amount: balance });
Cross-Platform Implementation Details
Proof Generation
| Platform | Implementation | Notes |
|---|---|---|
| iOS | MoPro (native Rust via Expo modules) | Fastest, uses compiled Noir circuits |
| iOS/Android | WebViewMT (multi-threaded WASM in a hidden WebView) | Fallback, requires COOP/COEP headers for SharedArrayBuffer |
| Browser/Node | NoirJS + bb.js (WASM) | Default, no native dependencies |
Discrete-Log Solver
| Platform | Implementation | Notes |
|---|---|---|
| iOS | Native Rust via Expo modules | Uses precomputed tame DP tables |
| Browser | WASM, multi-threaded if SharedArrayBuffer available | Falls back to cooperative single-threaded mode |
| Node | WASM (node-specific build) | Direct import |
Key Storage
| Platform | Implementation |
|---|---|
| iOS | MMKV (encrypted persistent storage via react-native-mmkv) |
| Browser | IndexedDB |
| Node | Filesystem |
| Test/Fallback | In-memory Map |