Skip to main content

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:

InterfaceWeb / NodeiOS (React Native)Test
ISolverBackendDlogSolver (WASM)NativeSolverAdapter (Rust FFI via Expo modules)MockSolverBackend
IProverBackendWasmProverBackend (NoirJS + bb.js)MoProAdapter (native Rust)MockProverBackend
IStorageAdapterIndexedDBStorage / FileStorageMMKVStorage (encrypted, via react-native-mmkv)MemoryStorage

Services

Two high-level services wrap backends with domain logic:

  • DecryptService wraps ISolverBackend — performs ElGamal decryption followed by discrete-log solving, with lazy initialization.
  • ProofService wraps IProverBackend — 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.

FactorySolverProverStorage
createWebClientWASM (DlogSolver)NoirJS + bb.js (WasmProverBackend)IndexedDB
createNodeClientWASM (DlogSolver, node build)NoirJS + bb.js (WasmProverBackend)Filesystem
createMobileClientNative 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

PlatformImplementationNotes
iOSMoPro (native Rust via Expo modules)Fastest, uses compiled Noir circuits
iOS/AndroidWebViewMT (multi-threaded WASM in a hidden WebView)Fallback, requires COOP/COEP headers for SharedArrayBuffer
Browser/NodeNoirJS + bb.js (WASM)Default, no native dependencies

Discrete-Log Solver

PlatformImplementationNotes
iOSNative Rust via Expo modulesUses precomputed tame DP tables
BrowserWASM, multi-threaded if SharedArrayBuffer availableFalls back to cooperative single-threaded mode
NodeWASM (node-specific build)Direct import

Key Storage

PlatformImplementation
iOSMMKV (encrypted persistent storage via react-native-mmkv)
BrowserIndexedDB
NodeFilesystem
Test/FallbackIn-memory Map