Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.privacyboost.io/llms.txt

Use this file to discover all available pages before exploring further.

Performance

This guide covers performance optimization strategies for the Privacy Boost Android SDK. The native binary is small enough that bundle size is rarely the bottleneck — the costs to manage are SDK init, proof generation, balance refresh frequency, and coroutine hygiene.

SDK Lifecycle

One Instance Per Process

PrivacyBoost holds a JWT, identity keys, and an internal note cache. Initialize it once and inject it via your DI container of choice (Hilt, Koin, manual Application-scoped singleton).
class App : Application() {
    lateinit var privacyBoost: PrivacyBoost
        private set

    override fun onCreate() {
        super.onCreate()
        val config = PrivacyBoostConfig(
            serverUrl = "https://test-api.privacyboost.io",
            chainId = null,
            shieldContractAddress = null,
            wethContractAddress = "0x4200000000000000000000000000000000000006",
            teePublicKey = null,
            appId = "app_abc123xyz",
            persistenceStorage = null,
            persistenceUnlock = null,
        )
        privacyBoost = PrivacyBoost(config)
    }
}
Re-instantiating the SDK on every screen would re-derive caches, refetch the merkle tree, and force a fresh JWT.

Defer Init Until First Use

If your app has flows that don’t touch the privacy pool (onboarding, marketing screens), defer SDK construction until the first gated route. The Rust core does some setup work on first call — keep it off the cold-start path.
class PrivacyBoostHolder(private val config: PrivacyBoostConfig) {
    @Volatile private var instance: PrivacyBoost? = null

    fun get(): PrivacyBoost =
        instance ?: synchronized(this) {
            instance ?: PrivacyBoost(config).also { instance = it }
        }
}

Coroutine Scope Hygiene

All network/proof methods are suspend — call them from a structured scope, not a fire-and-forget GlobalScope.launch.

Use Lifecycle-Aware Scopes

class WalletViewModel(
    private val sdk: PrivacyBoost,
) : ViewModel() {
    fun deposit(token: String, amount: String) = viewModelScope.launch {
        runCatching { sdk.shield(token, amount) }
            .onSuccess { /* update UI */ }
            .onFailure { /* show error */ }
    }
}
viewModelScope cancels when the ViewModel is cleared, so a deposit that’s still proving when the user navigates away gets cleanly cancelled instead of leaking.

Run on the Right Dispatcher

The SDK does its own native work and is safe to call from Dispatchers.Main, but if you’re chaining heavy serialization or DB writes around it, hop to Dispatchers.Default or Dispatchers.IO:
val result = withContext(Dispatchers.IO) {
    sdk.shield(token, amount)
}

Balance Refresh

Don’t Poll From Every Composable

getBalance(token) and getAllBalances() hit the network. Centralize refreshes through a single repository / StateFlow and let UI subscribe.
class BalanceRepository(
    private val sdk: PrivacyBoost,
    private val staleness: Duration = 30.seconds,
) {
    private val _balances = MutableStateFlow<Map<String, TokenBalance>>(emptyMap())
    val balances: StateFlow<Map<String, TokenBalance>> = _balances.asStateFlow()

    private var lastFetch: Instant = Instant.DISTANT_PAST
    private val mutex = Mutex()

    suspend fun refresh(force: Boolean = false) = mutex.withLock {
        val now = Clock.System.now()
        if (!force && now - lastFetch < staleness) return@withLock
        val fresh = sdk.getAllBalances().associateBy { it.tokenAddress }
        _balances.value = fresh
        lastFetch = now
    }
}
The Mutex collapses concurrent calls — overlapping refreshes wait on the in-flight one.

Refresh on Resume, Not on a Timer

Tying refresh to a delay() loop drains battery. Tie it to lifecycle events:
@Composable
fun WalletScreen(repo: BalanceRepository) {
    val lifecycleOwner = LocalLifecycleOwner.current
    val balances by repo.balances.collectAsStateWithLifecycle()

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) {
                lifecycleOwner.lifecycleScope.launch { repo.refresh() }
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
    }

    BalanceList(balances.values.toList())
}
For periodic background refresh outside the app, use WorkManager with a minimum-interval periodic request — not a foreground service.

Network

Parallelize Independent Reads

Sequential awaits serialize unnecessarily. Use async:
coroutineScope {
    val balances = async { sdk.getAllBalances() }
    val history  = async { sdk.getTransactionHistory(null, 20u, null) }
    val fees     = async { sdk.getFees(tokenId) }

    Triple(balances.await(), history.await(), fees.await())
}

Page History — Don’t Fetch It All

Pass a small limit and use the returned cursor for pagination:
val firstPage = sdk.getTransactionHistory(
    txType = null,
    limit = 20u,
    cursor = null,
)
// later
firstPage.next?.let { cursor ->
    val nextPage = sdk.getTransactionHistory(
        txType = null,
        limit = 20u,
        cursor = cursor,
    )
}

Memory

Trim When Backgrounded

If your app stays in the background long enough to receive onTrimMemory(TRIM_MEMORY_BACKGROUND) or higher, drop non-essential caches — pending transaction lists, off-screen history pages — but keep the SDK instance.
class App : Application(), ComponentCallbacks2 {
    override fun onTrimMemory(level: Int) {
        if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
            balanceRepo.evictNonCurrentTokens()
            transactionRepo.evictBeyondVisibleWindow()
        }
    }
    override fun onConfigurationChanged(newConfig: Configuration) {}
    override fun onLowMemory() {}
}

Use Stable Keys in Lists

In LazyColumn, key transactions by txHash so Compose reuses items instead of rebuilding:
LazyColumn {
    items(transactions, key = { it.txHash }) { tx ->
        TransactionRow(tx)
    }
}

Operation Timing

Wrap SDK calls to observe timing in development:
suspend inline fun <T> timed(label: String, block: () -> T): T {
    val start = System.nanoTime()
    return try {
        block()
    } finally {
        val ms = (System.nanoTime() - start) / 1_000_000
        Log.d("PB", "$label: ${ms}ms")
    }
}

val result = timed("shield") { sdk.shield(token, amount) }
In production, route timings into your APM (Firebase Performance, Sentry Performance, etc.) to surface slow operations as percentile histograms.

What the SDK Already Optimizes

Things you do not need to layer on top:
  • Note cache — the SDK caches unspent notes locally and only refetches new merkle tree leaves.
  • Merkle tree pruning — proven nodes are kept; the rest are pruned.
  • JWT reuseclearSession() keeps identity keys so the next authenticate() is fast.

Best Practices Summary

  1. One SDK instance per process — hold it in Application or your DI graph.
  2. Defer SDK init until the first privacy-pool screen.
  3. Use viewModelScope / lifecycleScope — never GlobalScope.
  4. Refresh balances on ON_RESUME and after writes, not on a timer.
  5. Deduplicate concurrent refreshes with a Mutex or single shared flow.
  6. Parallelize independent reads with async.
  7. Page transaction history; don’t fetch it all.
  8. Use clearSession() over logout() when re-auth is expected soon.

Next Steps