# Auditability
Source: https://docs.privacyboost.io/product/auditability
How authorized auditors query shielded pool data with onchain accountability
Most privacy tools are all-or-nothing: they hide activity from everyone, leaving no way to satisfy an auditor or regulator. Privacy Boost is built differently.
For regulated deployments (stablecoin networks, RWA platforms, payment systems), Privacy Boost provides **scoped disclosure**: authorized auditors get precise, account-level visibility where they need it, while every other user stays private by default. Every data-access query leaves an immutable onchain trail.
***
## What This Enables
**For regulators:** Verified, account-level access with cryptographic identity binding (EIP-712 signatures). No way to query without leaving a trail.
**For users:** Privacy by default. Only explicitly authorized auditors can access your data, and you can verify onchain exactly who accessed what and when.
***
## How It Works
The audit system combines three components:
1. **AuditGateway contract**: An onchain registry of authorized auditors. Only registered addresses can query.
2. **Audit API**: Server-side endpoints that return account-specific data to verified auditors.
3. **Onchain audit log**: Every data-access query (transaction history and balances) is logged immutably onchain.
Every authorized query produces a permanent, publicly verifiable onchain record. Logging is submitted in the background so auditors get fast responses, with the record typically settling onchain within seconds of the query.
### Access Control
Audit requests pass through three verification layers:
| Layer | Mechanism | Rejects if |
| - | - | - |
| **Server-level key** | Infrastructure secret header | Missing or invalid |
| **EIP-712 signature** | Auditor signs the target account and action with their EOA key | Signature invalid or address mismatch |
| **Onchain registry** | Contract checks auditor registration | Address not registered as auditor |
***
## What Auditors Can Access
Auditors can query two things for a specific account:
### Transaction History
Paginated list of all transactions (deposits, transfers, withdrawals) for a given account, including:
* Transaction type and direction (in/out)
* Token ID and amount
* Counterparty (account ID for shielded transfers, Ethereum address for deposits/withdrawals)
* Timestamp
### Current Balances
Aggregated token balances for a given account, including the number of unspent notes per token. These balances count only confirmed notes and exclude notes reserved by in-flight requests, so they can be lower than the wallet's available balance while a transfer is preconfirming.
***
## Auditors Hold No Keys
Auditors get answers, not access. They never touch viewing keys or onchain ciphertexts. The attested TEE decrypts transaction metadata during its normal indexing and serves only the one account a verified, signed query is scoped to. Even a compromised auditor key cannot read the pool.
Here's what happens:
1. The TEE server has already decrypted all transaction metadata as part of its normal indexing process. This data is stored inside the TEE's encrypted database, organized by account.
2. When an auditor submits a signed query for a specific account, the TEE verifies their authorization.
3. If authorized, the TEE returns only the requested data for the specified account, such as its history or balance.
4. The query is logged onchain asynchronously.
The auditor never sees encryption keys, other accounts' data, or anything beyond the specific account they queried.
***
## Hard Limits on Auditor Power
Auditor access is constrained by the protocol itself, not by policy or trust. Auditors can view only the accounts they are authorized to access. They cannot move funds under any circumstances, and every account-data query creates an onchain record.
| Can | Cannot |
| - | - |
| View transaction history for a specific account | Access private keys or viewing keys |
| View current balances for a specific account | Move funds or modify pool state |
| Filter transactions by type | Query without leaving an onchain audit trail |
| | Perform blanket surveillance across all accounts |
| | Decrypt onchain ciphertexts directly |
***
## Onchain Audit Log
After every data-access query, the server logs an immutable onchain record that:
* **Cannot be deleted or modified** by anyone
* **Is publicly verifiable**: anyone can check the audit trail
* **Holds auditors accountable**: excessive or unauthorized access is permanently recorded
The AuditGateway contract is standalone. It has no connection to the shielded pool and cannot affect fund storage or movement.
***
## Auditor Lifecycle
| Action | Who | How |
| - | - | - |
| **Add auditor** | Contract owner | Register address in AuditGateway |
| **Remove auditor** | Contract owner | Remove address from AuditGateway |
| **Query data** | Registered auditor | Sign EIP-712 request, call audit API |
| **Review audit trail** | Anyone | Read audit events from the contract |
Audit queries use EIP-712 typed data signatures, so the auditor's wallet displays structured, human-readable fields instead of opaque hex. The domain separator prevents cross-chain replay.
***
## Why This Matters for Regulated Deployments
Privacy Boost lets you offer users real onchain privacy while giving your legal and risk teams a verifiable, account-scoped audit path, where every authorized access is permanently recorded onchain, so disclosure is provable rather than promised. You get the user-facing privacy of a shielded pool and the verifiable accountability regulators look for, in one protocol.
***
## Next Steps
* [Trust & Security](/product/trust-and-security): Full trust model and TEE guarantees
* [Protocol Deep Dive](/technical-explainer/protocol): How the shielded pool works
* [Glossary](/technical-explainer/glossary): Technical terminology reference
# Privacy Boost
Source: https://docs.privacyboost.io/product/overview
A privacy SDK for any EVM chain. Regulatory-friendly, highly performant, composable, and self-custodial.
Privacy Boost enables applications to integrate private transfers and privacy-preserving DeFi workflows into public blockchains while keeping the data private from the public, viewable by the sponsoring app, and auditable by regulators.
Deposit, transfer, and withdraw ERC-20 tokens with amounts, counterparties, and balances fully hidden onchain.
Understand the protocol, cryptographic foundations, and architecture
Integrate private payments into your app with our multi-platform SDKs
## Watch the Explainer
A short visual walkthrough of how Privacy Boost keeps transfers private onchain while preserving app visibility and auditability.
***
## Who is this for?
On public blockchains, every transfer, every balance, and every counterparty relationship is visible to anyone. For applications handling real financial flows, this creates immediate business, competitive, and regulatory risk.
User balances and transaction history must remain private and never be broadcast onchain.
Payment and settlement flows require confidentiality between counterparties.
Institutional positions and counterparty identities are commercially sensitive.
Privacy must align with financial regulatory frameworks, not conflict with them.
***
## Why Privacy Boost?
Server-side ZK proof generation inside hardware-isolated enclaves. With no client-side proving, Privacy Boost works instantly on any platforms, including mobile devices.
Proof aggregation batches multiple transfers into a single onchain transaction, reducing gas costs.
Authorized auditors can query specific accounts. Every audit access is logged immutably onchain. Privacy for users, accountability for regulators.
Your keys, your funds. Forced withdrawal lets you exit directly through the smart contract, even if every server goes offline permanently.
A TEE-secured indexer decrypts and serves your balance and transaction history in real time. No blockchain scanning required.
Works with MetaMask, WalletConnect, any EOA account and smart accounts. TypeScript, React, React Native, iOS, Android, and CLI SDKs, with no new wallet software needed.
Use approved vaults and swap routes without publicly revealing your wallet, with your wallet identity hidden throughout the flow.
***
## Demo
[Try the live app →](https://app.privacyboost.io)
***
## How It Works
**1. Deposit**: Tokens move from a public wallet into the privacy layer and become encrypted private notes.
**2. Transfer Request**: The user signs or approves a transfer and submits it to the TEE Prover.
**3. ZK Proof Generation**: The TEE generates a zero-knowledge proof in \~500ms, batching many transactions into a single epoch proof for higher throughput.
**4. Onchain Verification**: The smart contract verifies the proof and records the result. Amounts, balances, and counterparties remain hidden.
**5. Encrypted State Indexing**: The TEE Indexer reads onchain state and indexes encrypted metadata, giving sender and recipient instant access to their balances and history.
**6. Auditability**: When required, authorized auditors can query specific accounts' balances and history through a [scoped disclosure mechanism](/product/auditability). Every query is logged immutably onchain.
***
## Why ZK + TEE?
Most privacy protocols force a tradeoff: client-side proving is trustless but costs 5–30+ seconds per transaction, can't serve balance queries, and has no auditability.
Privacy Boost combines both:
* **ZK proofs** provide the privacy guarantees: sender, recipient, amounts, and token types are all hidden onchain. The smart contract verifies every proof and holds all funds. This is the cryptographic foundation.
* **TEE** handles what ZK alone can't deliver: sub-500ms proof generation, instant balance and transaction history queries, and auditability through scoped audit access, all inside a hardware-isolated enclave.
The result: a privacy protocol with the speed and UX of a centralized system, the trustlessness of ZK, and the custody guarantees of a smart contract.
**Self-custody is unconditional.** Even if the TEE goes offline permanently, users can exit directly through the smart contract via forced withdrawal, using their own keys and public onchain data. Feature-specific recovery steps are in the [Portal Deposits](/sdk/concepts/portal-deposits) and [Claimable Transfers](/sdk/concepts/claimable-transfers) guides.
How the TEE works, what it can and can't see, forced withdrawal, and the full threat model
***
## More Ways to Move Funds
Available now in the SDKs. Audited by OpenZeppelin, see [Audit Reports](/technical-explainer/audit).
A reusable public deposit address, similar to an exchange deposit address. Anyone can send supported ERC-20 tokens to it, and the protocol sweeps the funds into your shielded balance without revealing which shielded account was credited. Available on networks where the portal delegate is deployed, see [Contract Addresses](/technical-explainer/contract-addresses).
Send privately to a wallet address, even if the recipient has not registered with Privacy Boost yet. The recipient can claim the funds by registering with that address, and the sender can request a refund after a set deadline while the gift remains unspent.
Swap between tokens straight from your shielded balance, private DeFi trading with your wallet never exposed.
***
## Explore the Docs
TEE guarantees, self-custody, forced withdrawal, and threat model
How auditors query data with onchain accountability
UTXO model, notes, nullifiers, Merkle trees, transaction lifecycle
Key hierarchy, dual-path ECDH encryption, privacy addresses
Get started with the Privacy Boost SDK
Build a private payment flow in 5 minutes
Add private payments to your React app
Scripting and automation via command-line
# Trust & Security
Source: https://docs.privacyboost.io/product/trust-and-security
TEE guarantees, self-custody, forced withdrawal, and threat model
Privacy Boost gives you three guarantees:
1. **The operator cannot access your data.** TEE hardware isolation prevents it.
2. **The operator cannot move your funds.** Every transfer requires your authorization.
3. **You can always exit.** Forced withdrawal bypasses the server entirely.
Below, each guarantee is broken down: the mechanism that enforces it, the trust assumptions it rests on, and how your funds stay safe even in worst-case scenarios.
***
## Guarantee 1: The Operator Cannot Access Your Data
The TEE (Trusted Execution Environment) is a hardware-isolated enclave, a protected region of the CPU where all memory is encrypted by the hardware itself. The operating system, hypervisor, and server administrator cannot read or modify what's inside.
### How It Works
**Hardware isolation:** The CPU encrypts all enclave memory with keys that only the CPU has access to. There is no API, debug interface, or admin backdoor to read enclave contents. Even a physical memory dump yields only encrypted data.
**Remote attestation:** You can verify a cryptographic attestation from the hardware that:
* The CPU is genuine TEE hardware (AMD SEV-SNP on Azure Confidential Computing)
* The software running inside the enclave matches an expected code hash
* The enclave has not been tampered with
Attestation is verified via Azure MAA (Microsoft Azure Attestation), a JWT-based attestation service that provides cryptographic proof of the enclave's integrity.
**Key management:** The TEE's private keys are derived from a root key released via Secure Key Release (SKR), where the hardware attestation service only releases the key to a verified enclave. The operator never sees these keys in plaintext.
### What This Means in Practice
| Scenario | Can the operator... | Answer |
| - | - | - |
| SSH into the server and dump memory | Read user data? | **No.** Enclave memory is hardware-encrypted. |
| Take a database backup | Decrypt transaction details? | **No.** The decryption key only exists inside the enclave. |
| Deploy malicious code | Steal user funds? | **No.** Spending requires user authorization. Attestation also fails if the code hash changes. |
| Shut down the server | Prevent users from accessing funds? | **Temporarily.** Users can always forced-withdraw directly from the contract. |
| Inspect network traffic | See transaction contents? | **No.** Every request and response is end-to-end encrypted to the attested enclave (X25519 + ChaCha20-Poly1305), so only the TEE can decrypt it, never the network or the operator. |
### What Stays Inside the Enclave
Transaction plaintext stays in the enclave. The TEE needs transaction details (amounts, counterparties) to generate ZK proofs and index balances, so it reads them inside the hardware-isolated enclave, but this data **never leaves** in unencrypted form.
The TEE **does not** hold users' viewing keys or EdDSA auth signing keys: these stay on the client and are never sent to the TEE. The TEE has only its own key (TxPrivKey), used to decrypt transaction ciphertexts.
***
## Guarantee 2: The Operator Cannot Move Your Funds
Every transfer and withdrawal requires your authorization, through a registered signing key or an account-owner spend approval. The ZK circuit verifies it, and the smart contract rejects any proof without valid authorization.
This is not a policy; it's a cryptographic impossibility. Even a fully compromised TEE cannot authorize transfers on behalf of users.
***
## Guarantee 3: You Can Always Exit
**Forced withdrawal** is the self-custody escape hatch. If the server goes down (temporarily or permanently), you can withdraw your funds using only:
* Your own private keys (a registered signing key, or an onchain spend approval the account owner submits at exit time)
* Public onchain data (available from any Ethereum node)
### How It Works
1. **Reconstruct your notes:** Scan onchain events and decrypt the receiver-targeted ciphertexts using your viewing key. Check the nullifier registry to find which notes are still unspent.
2. **Generate a ZK proof locally:** This runs on consumer hardware. No TEE needed.
3. **Submit to the contract:** The contract verifies the proof and starts a configurable delay period.
4. **Execute after the delay:** The contract transfers your tokens.
### Why There's a Delay
* **A built-in review window.** A forced withdrawal is never instant. It is requested onchain and must wait out a public delay before it can execute, which keeps the operation transparent and lets the account owner cancel a request they did not intend, from their registered owner account.
* **Clean handling of concurrent activity.** If those notes are spent through normal activity while a forced withdrawal is pending, the forced withdrawal simply finds nothing left to claim (the nullifiers are already spent), so there is no double-spend and no stuck funds.
### The Bottom Line
Even in the worst case (the TEE is permanently destroyed, the operator is malicious, the server is offline forever), you can recover every token you deposited. Funds are secured by the Ethereum smart contract, not by the TEE. Unclaimed gifts use the separate permissionless public gift-exit path.
***
## Security Boundaries
| Boundary | What Crosses | Protected By | If Broken |
| - | - | - | - |
| **User ↔ TEE** | Signed requests, balance queries, attestation | Enclave-bound encryption + remote attestation | Privacy loss for that session. Funds safe. |
| **TEE ↔ Contract** | Proofs, nullifiers, commitments, ciphertexts | Onchain proof verification | N/A. Contract independently verifies everything. |
| **User ↔ Contract** | Client-generated proofs (forced withdrawal) | Onchain proof verification + delay | N/A. Fully trustless path. |
***
## Privacy Properties
### What Is Hidden in Internal Transfers
| Property | How |
| - | - |
| Sender identity | Nullifier is unlinkable to commitment; proven in ZK |
| Recipient identity | Commitment hides the owner; requires viewing key to decrypt |
| Token type | Inside the commitment hash; not exposed onchain |
| Transfer amount | Inside the commitment hash; not exposed onchain |
| Which note was spent | Nullifier derived from secret key + leaf index; unlinkable to any commitment |
### What Is Not Hidden
| Property | Why |
| - | - |
| Deposit/withdrawal addresses | Public ERC-20 transfers |
| Deposit/withdrawal amounts | Public fields in the transaction |
| Number of transfers per batch | Public metadata |
| Transaction timing | Block timestamps |
| Private DeFi actions | The vault, token, and amount are public onchain, like a public withdrawal, but not linked to a wallet identity |
### Anonymity Set
The anonymity set is all UTXOs across all historical Merkle trees, a theoretical maximum of roughly 550 billion UTXOs at the deployed tree depth of 24.
***
## State Recovery
### TEE Recovery
If the TEE loses its state, it reconstructs everything from onchain events: replays deposit and transfer events, decrypts metadata using its TxPrivKey, and rebuilds the full Merkle tree and balance state. Portal and gift notes are rebuilt from the records saved when those funds were created (see [Portal Deposits](/sdk/concepts/portal-deposits) and [Claimable Transfers](/sdk/concepts/claimable-transfers)).
### User Recovery
Users can independently reconstruct their own notes using only onchain data and their viewing key. Portal and gift recovery additionally needs the records saved at funding time. This is the foundation of forced withdrawal and works without any server infrastructure. Feature-specific steps are in the [Portal Deposits](/sdk/concepts/portal-deposits) and [Claimable Transfers](/sdk/concepts/claimable-transfers) guides.
***
## Next Steps
* [Protocol Deep Dive](/technical-explainer/protocol): Transaction lifecycle and circuit details
* [Keys & Encryption](/technical-explainer/keys-and-encryption): Full encryption scheme
* [Auditability](/product/auditability): How regulated access works
* [Glossary](/technical-explainer/glossary): Technical terminology reference
# Error Handling Patterns
Source: https://docs.privacyboost.io/sdk/android/advanced/error-handling
Advanced error handling — classification, telemetry, and operational patterns
# Error Handling Patterns
This page covers advanced error handling — classification, telemetry, structured logging, and circuit breakers. For the basics (try/catch, retry, re-auth, user-friendly messages), see the [Error Handling guide](../guides/error-handling).
## Error Classification
`com.privacyboost.defaults` ships `SdkException.category` and `SdkException.isRetryable` extensions that bucket every variant into one of seven categories. Use them to make UI/operational decisions without exhaustively matching every case.
```kotlin theme={null}
import com.privacyboost.defaults.category
import com.privacyboost.defaults.isRetryable
import com.privacyboost.sdk.ErrorCategory
import com.privacyboost.sdk.SdkException
val cat: ErrorCategory = error.category
// Auth | Network | Validation | Permission | RateLimit | Server | Internal
val retryable: Boolean = error.isRetryable
// true for network, rate-limit, retryable api errors
```
| Category | Meaning | Recommended UI | Retry? |
| - | - | - | - |
| `Auth` | Session/auth flow failed | Re-prompt sign-in | No (but re-auth and try once) |
| `Network` | Connectivity / DNS / timeout | "Check your connection" | Yes, with backoff |
| `Validation` | Input is malformed | Inline form error | No |
| `Permission` | User cancelled / forbidden | Silent dismiss or contact support | No |
| `RateLimit` | Too many requests | Wait + auto-retry | Yes, after `retryAfterMs` |
| `Server` | Backend / proof / merkle failure | Generic error toast | Sometimes (check `isRetryable`) |
| `Internal` | Bug or invariant violation | "Something went wrong" + report | No |
## Structured Logging
Log SDK errors with stable fields so they're queryable in your log pipeline (Logcat → Crashlytics, Datadog, etc.).
```kotlin theme={null}
import android.util.Log
import com.privacyboost.defaults.category
import com.privacyboost.defaults.isRetryable
import com.privacyboost.sdk.SdkException
object PBLogger {
private const val TAG = "PrivacyBoost"
fun error(context: String, error: Throwable) {
if (error !is SdkException) {
Log.e(TAG, "[$context] non-sdk error", error)
return
}
Log.e(TAG, buildString {
append("[$context] ")
append("category=${error.category::class.simpleName} ")
append("retryable=${error.isRetryable} ")
append("variant=${variantTag(error)} ")
append("message=${userMessage(error)}")
}, error)
}
private fun variantTag(e: SdkException): String = when (e) {
is SdkException.NotConnected -> "NotConnected"
is SdkException.NotAuthenticated -> "NotAuthenticated"
is SdkException.AuthenticationFailed -> "AuthenticationFailed"
is SdkException.NetworkException -> "NetworkError"
is SdkException.WalletException -> "WalletError"
is SdkException.SignatureRejected -> "SignatureRejected"
is SdkException.InsufficientBalance -> "InsufficientBalance"
is SdkException.InvalidAddress -> "InvalidAddress"
is SdkException.InvalidAmount -> "InvalidAmount"
is SdkException.RateLimited -> "RateLimited"
is SdkException.ApiException -> "ApiError.${e.code}"
is SdkException.ShieldException -> "ShieldError.${e.code}"
is SdkException.TransferException -> "TransferError.${e.code}"
else -> e::class.simpleName ?: "Other"
}
private fun userMessage(e: SdkException): String = when (e) {
is SdkException.NetworkException -> e.message ?: ""
is SdkException.WalletException -> e.message ?: ""
is SdkException.ApiException -> e.message
is SdkException.ShieldException -> e.message
is SdkException.TransferException -> e.message
else -> ""
}
}
```
If your message field could include PII, scrub or omit it from logs.
## Error Reporting Integration
### Firebase Crashlytics
```kotlin theme={null}
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.privacyboost.defaults.category
import com.privacyboost.defaults.isRetryable
fun reportSdk(error: Throwable, operation: String) {
val crash = FirebaseCrashlytics.getInstance()
crash.setCustomKey("pb.operation", operation)
if (error is SdkException) {
crash.setCustomKey("pb.category", error.category::class.simpleName ?: "Unknown")
crash.setCustomKey("pb.retryable", error.isRetryable)
if (error is SdkException.ApiException) {
crash.setCustomKey("pb.api_code", error.code)
}
}
crash.recordException(error)
}
```
The `pb.category` custom key lets you build dashboards that group errors by class — "auth-class errors are spiking" is more actionable than "twelve different variants are spiking."
### Sentry
```kotlin theme={null}
import io.sentry.Sentry
import io.sentry.SentryEvent
import io.sentry.SentryLevel
fun reportSdk(error: Throwable, operation: String) {
val event = SentryEvent(error).apply {
level = SentryLevel.ERROR
setTag("pb.operation", operation)
if (error is SdkException) {
setTag("pb.category", error.category::class.simpleName ?: "Unknown")
setTag("pb.retryable", error.isRetryable.toString())
}
}
Sentry.captureEvent(event)
}
```
### Filter Out Noise
User cancellations (`SignatureRejected`) and validation errors aren't bugs — don't ship them to your error tracker:
```kotlin theme={null}
import com.privacyboost.sdk.ErrorCategory
fun shouldReport(error: SdkException): Boolean = when (error.category) {
is ErrorCategory.Permission, is ErrorCategory.Validation -> false
else -> true
}
```
## Operation-Scoped Wrapping
Wrap each meaningful SDK call so logging, reporting, and retry sit in one place.
```kotlin theme={null}
suspend inline fun runSdk(
operation: String,
crossinline block: suspend () -> T,
): T {
return try {
block()
} catch (e: SdkException) {
PBLogger.error(operation, e)
if (shouldReport(e)) reportSdk(e, operation)
throw e
} catch (e: Throwable) {
PBLogger.error(operation, e)
reportSdk(e, operation)
throw e
}
}
// Usage
val result = runSdk("shield") {
sdk.shield(token, amount)
}
```
## Circuit Breaker
If the backend is degraded, hammering it with retries makes things worse. Wrap operations in a circuit breaker that trips after consecutive server-class failures.
```kotlin theme={null}
import com.privacyboost.sdk.ErrorCategory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
class CircuitBreaker(
private val threshold: Int = 5,
private val cooldown: Duration = 1.minutes,
) {
private sealed class State {
object Closed : State()
data class Open(val until: Instant) : State()
object HalfOpen : State()
}
private val mutex = Mutex()
private var state: State = State.Closed
private var consecutiveFailures = 0
suspend fun canPass(): Boolean = mutex.withLock {
when (val s = state) {
is State.Closed, State.HalfOpen -> true
is State.Open -> {
if (Clock.System.now() >= s.until) {
state = State.HalfOpen
true
} else false
}
}
}
suspend fun recordSuccess() = mutex.withLock {
consecutiveFailures = 0
state = State.Closed
}
suspend fun recordFailure(error: SdkException) = mutex.withLock {
when (error.category) {
is ErrorCategory.Server, is ErrorCategory.Network -> {
consecutiveFailures++
if (consecutiveFailures >= threshold) {
state = State.Open(until = Clock.System.now() + cooldown)
}
}
else -> { /* user/validation/permission do not trip the breaker */ }
}
}
}
private val breaker = CircuitBreaker()
suspend fun guarded(block: suspend () -> T): T {
if (!breaker.canPass()) {
throw SdkException.NetworkException("circuit open")
}
return try {
val result = block()
breaker.recordSuccess()
result
} catch (e: SdkException) {
breaker.recordFailure(e)
throw e
}
}
```
Auth/validation/permission failures should not trip the breaker — they're user-class problems, not service degradation. The classification by `category` makes that distinction trivial.
## Telemetry: Operation Latency by Outcome
Pair errors with timings — slow successes are interesting too.
```kotlin theme={null}
data class OpMetric(
val operation: String,
val durationMs: Long,
val outcome: String,
)
suspend inline fun instrument(
operation: String,
sink: (OpMetric) -> Unit,
crossinline block: suspend () -> T,
): T {
val start = System.nanoTime()
return try {
block().also {
sink(OpMetric(
operation = operation,
durationMs = (System.nanoTime() - start) / 1_000_000,
outcome = "success",
))
}
} catch (e: SdkException) {
sink(OpMetric(
operation = operation,
durationMs = (System.nanoTime() - start) / 1_000_000,
outcome = e.category::class.simpleName ?: "Internal",
))
throw e
}
}
```
Forward `OpMetric` to Firebase Performance, Datadog RUM, or your own analytics pipeline.
## Best Practices
1. **Branch on `category`, not on individual variants** — UI logic stays small and stable across SDK upgrades.
2. **Filter user-class errors out of error reporters** — `Permission` and `Validation` are noise.
3. **Tag every report with `pb.category` and `pb.variant`** — makes dashboards actually useful.
4. **Trip circuit breakers only on `Server` and `Network`** — never on auth or validation.
5. **Pair errors with timings** — a slow success that turns into a timeout is worth catching upstream.
## Next Steps
* [Performance](./performance)
* [Error Handling guide](../guides/error-handling) — basics
* [Error Handling Concepts](/sdk/concepts/error-handling) — cross-platform error model
# Performance
Source: https://docs.privacyboost.io/sdk/android/advanced/performance
Performance optimization strategies for the Android SDK
# 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).
```kotlin theme={null}
class App : Application() {
lateinit var privacyBoost: PrivacyBoost
private set
override fun onCreate() {
super.onCreate()
val config = PrivacyBoostConfig(
serverUrl = "https://optimism.sepolia.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.
```kotlin theme={null}
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
```kotlin theme={null}
class WalletViewModel(
private val sdk: PrivacyBoost,
) : ViewModel() {
fun shield(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`:
```kotlin theme={null}
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.
```kotlin theme={null}
class BalanceRepository(
private val sdk: PrivacyBoost,
private val staleness: Duration = 30.seconds,
) {
private val _balances = MutableStateFlow>(emptyMap())
val balances: StateFlow> = _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:
```kotlin theme={null}
@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`:
```kotlin theme={null}
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:
```kotlin theme={null}
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.
```kotlin theme={null}
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:
```kotlin theme={null}
LazyColumn {
items(transactions, key = { it.txHash }) { tx ->
TransactionRow(tx)
}
}
```
## Operation Timing
Wrap SDK calls to observe timing in development:
```kotlin theme={null}
suspend inline fun 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 reuse** — `clearSession()` 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
* [Error Handling Patterns](./error-handling)
* [Wallet Integration](../guides/wallet-integration)
# Api reference
Source: https://docs.privacyboost.io/sdk/android/api-reference
# API Reference
Complete API reference for the Privacy Boost Android SDK.
## Generated Documentation
Full API documentation can be generated using `cargo doc`:
```bash theme={null}
# From rust-sdk directory
make docs-rust
# Android-specific docs
cargo doc --no-deps -p privacy-boost-android
```
***
## PrivacyBoost
Main SDK class for Android.
### Constructor
```kotlin theme={null}
PrivacyBoost(config: PrivacyBoostConfig)
```
Creates a new SDK instance.
**Parameters:**
* `config` - SDK configuration
### Properties
```kotlin theme={null}
fun isConnected(): Boolean
fun isAuthenticated(): Boolean
```
### Connection & Authentication Methods
#### authenticate
```kotlin theme={null}
fun authenticate(wallet: WalletDelegate, keySource: KeySource? = null, tokenProvider: TokenProvider? = null): AuthResult
```
Connect a wallet, derive privacy keys, and authenticate with the Privacy Boost backend in a single call.
**Parameters:**
* `wallet` - Implementation of `WalletDelegate` interface
* `keySource` - Optional key derivation source. If `null` and no persistence is configured, defaults to `KeySource.WalletDerived`. If `null` with persistence configured and no existing vault, an error is thrown.
* `tokenProvider` - Optional custom token provider. If `null`, the SDK sends the login payload directly to the Privacy Boost backend. Supply a `TokenProvider` to route authentication through your own server.
**Returns:** `AuthResult` - see [AuthResult](#authresult) for all possible outcomes.
**Throws:**
* `SDKError.WalletError` if signing fails
* `SDKError.InvalidConfig` if configuration is invalid or keySource is required but missing
* `SDKError.NetworkError` if backend unreachable
#### submitCredential
```kotlin theme={null}
fun submitCredential(credential: String, tokenProvider: TokenProvider? = null): LoginResult
```
Submit a credential when `authenticate()` returns `CredentialRequired`.
**Parameters:**
* `credential` - The credential string
* `tokenProvider` - Optional custom token provider for routing authentication through your own server
**Returns:** `LoginResult` with privacy address and MPK
#### logout
```kotlin theme={null}
fun logout()
```
End session completely, clear all state.
#### clearSession
```kotlin theme={null}
fun clearSession()
```
Clear JWT only, keep keys for quick re-auth.
#### lock
```kotlin theme={null}
fun lock()
```
Zeroize all resident key material and drop the session JWT, keeping the persisted encrypted vault so the user can unlock again without re-deriving keys. The next key access throws `SDKError.NotAuthenticated`; call `authenticate` again to reopen the vault. The idle and absolute timers in [PrivacyBoostConfig](#privacyboostconfig) invoke the same lock automatically. Chain-context handles created from this instance share the wiped keys but keep their own JWT, so call `clearSession()` on each of them separately.
### State Accessors
```kotlin theme={null}
fun getPrivacyAddress(): String?
fun getMpk(): String?
fun getWalletAddress(): String?
```
### Balance Methods
#### getBalance
```kotlin theme={null}
fun getBalance(tokenAddress: String): TokenBalance
```
Get balance for a specific token.
**Parameters:**
* `tokenAddress` - ERC-20 token contract address
**Returns:** `TokenBalance` with shielded and wallet amounts
#### getAllBalances
```kotlin theme={null}
fun getAllBalances(): List
```
Get all token balances.
### Fee Methods
#### getFees
```kotlin theme={null}
fun getFees(tokenAddress: String): FeeRates
```
Returns the ordinary transfer and unshield fee quote for a token.
#### getGatewayFees
```kotlin theme={null}
fun getGatewayFees(
tokenAddress: String,
gatewayAddress: String,
gatewaySelector: String? = null
): FeeRates
```
Returns the Gateway fee that replaces the ordinary unshield fee. Pass the
first four calldata bytes as `gatewaySelector` to resolve a selector-specific
override, or `null` for the Gateway-wide default.
### Vault Operations
#### deposit
```kotlin theme={null}
fun shield(tokenAddress: String, amount: String): ShieldResult
```
Deposit tokens into the shielded pool.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
**Returns:** `ShieldResult` with transaction hash
#### withdraw
```kotlin theme={null}
fun unshield(tokenAddress: String, amount: String, recipient: String): UnshieldResult
```
Withdraw tokens from the shielded pool.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
* `recipient` - Recipient Ethereum address
**Returns:** `UnshieldResult` with transaction hash
#### send
```kotlin theme={null}
fun send(tokenAddress: String, amount: String, recipientPrivacyAddress: String): TransferResult
```
Send a private transfer.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
* `recipientPrivacyAddress` - Recipient's 194-char privacy address
**Returns:** `TransferResult` with transaction hash
#### sendBatch
```kotlin theme={null}
fun sendBatch(tokenAddress: String, recipients: List): TransferResult
```
Send one private transfer paying several recipients: one request, one fee, one
set of input notes.
**Parameters:**
* `tokenAddress` - Token contract address
* `recipients` - `TransferRecipient(recipient, amount)` entries in output order, at least one
**Shape limits:** the transfer's output count is the recipient count plus a
change note, and the server only proves specific `(inputs, outputs)`
combinations, so a longer recipient list limits how many input notes can be
spent — past two recipients, at most four. Requests no enabled circuit can
serve fail with `UNSUPPORTED_TRANSFER_SHAPE` before submission.
**Returns:** `TransferResult` with transaction hash
#### prepareShield
```kotlin theme={null}
fun prepareShield(tokenAddress: String, amount: String, recipient: String?): PreparedShield
```
Build a deposit payload **without submitting it** — for callers that relay the
transaction themselves (a gasless relayer, an EIP-7702 session key, or a plain
raw transaction). Returns the calldata to relay plus the note commitment;
nothing is sent on-chain and no wallet is used. After relaying, pass the mined
receipt to the top-level `finalizeShield(...)` to recover the on-chain request
id.
**Parameters:**
* `tokenAddress` - Token contract address (zero address = native ETH)
* `amount` - Amount in wei (as string)
* `recipient` - (Optional) recipient privacy address. Pass `null` to shield to yourself.
**Returns:** `PreparedShield` with the calls to relay and the note commitment
### Earn and Gateway
Gateway earn operations deposit into or redeem from a yield vault from inside the pool. The catalog and history reads are public and work before login; `earnDeposit` and `earnRedeem` need an authenticated session and a `ChainReaderDelegate` registered with `setChainReader`. All of these are suspend functions; call them inside `withContext(Dispatchers.IO)`.
#### earnDeposit / earnRedeem
```kotlin theme={null}
suspend fun earnDeposit(vaultAddress: String, amount: String, slippageBps: UInt?): EarnResult
suspend fun earnRedeem(vaultAddress: String, amount: String, slippageBps: UInt?): EarnResult
```
For a deposit, `amount` is the net amount that enters the vault; the unshield fee is charged on top. For a redeem, `amount` is the share amount. `slippageBps` of `null` uses the gateway default.
#### listEarnVaults / getEarnVault
```kotlin theme={null}
suspend fun listEarnVaults(): EarnVaultCatalog
suspend fun getEarnVault(vaultAddress: String): EarnVaultDetail
```
The catalog with typed vault summaries and gateway-wide availability, or one vault with its detail fields. Check `availability.deposit.canSubmit` on a summary before offering a deposit; `retryable` says whether a closed window is expected to reopen on its own.
```kotlin theme={null}
val catalog = withContext(Dispatchers.IO) { sdk.listEarnVaults() }
for (vault in catalog.vaults.filter { it.listed }) {
val apy = vault.apy.net?.let { "%.2f%%".format(it * 100) } ?: "n/a"
val open = vault.availability.deposit.canSubmit
println("${vault.name} $apy deposits ${if (open) "open" else "closed"}")
}
```
#### getGatewayConfig
```kotlin theme={null}
suspend fun getGatewayConfig(): GatewayConfig
```
Gateway discovery configuration: executor address, adapters, request constraints, rescue delay, policy, and current availability.
#### getEarnVaultHistory / getEarnVaultCharts
```kotlin theme={null}
suspend fun getEarnVaultHistory(
vaultAddress: String,
metric: EarnHistoryMetric,
period: EarnHistoryPeriod? = null
): EarnVaultHistory
suspend fun getEarnVaultCharts(vaultAddress: String, period: EarnHistoryPeriod? = null): EarnVaultCharts
```
One cached series (`metric` is `EarnHistoryMetric.TVL_USD` or `NET_APY`) or both series fetched together. `period` is `ONE_WEEK`, `ONE_MONTH`, `THREE_MONTHS`, or `ALL`; `null` uses the server default. The selectors are enums here and wire strings on the `*Json` accessors, which keep the signatures they shipped with. APY values are fractions (0.05 is 5%), a `null` point value is an upstream gap, and `metadata.state` reports freshness. A cold cache can briefly fail with a retryable error.
The `listEarnVaultsJson`, `getGatewayConfigJson`, `getEarnVaultHistoryJson`, and `getEarnVaultChartsJson` methods return the same data as canonical JSON strings for callers that parse the wire shape themselves.
### Transaction History
```kotlin theme={null}
fun getTransactionHistory(
txType: String?,
tokenAddress: String?,
limit: UInt?
): List
```
Get transaction history.
**Parameters:**
* `txType` - Filter by type: "deposit", "withdraw", "transfer"
* `tokenAddress` - Filter by token
* `limit` - Maximum results
#### queryTransactionHistory
```kotlin theme={null}
fun queryTransactionHistory(query: TransactionHistoryQuery): TransactionsResult
```
History under the full query: type, token id and direction filters, page size and cursor, and whether each Gateway operation's two rows are folded into one. Use it for any filter `getTransactionHistory` does not take. `null` fields are unfiltered. Suspend, so call it inside `withContext(Dispatchers.IO)`.
**Parameters:**
* `query` - See [TransactionHistoryQuery](#transactionhistoryquery)
**Returns:** `TransactionsResult` with the page of transactions and the cursor for the next page
```kotlin theme={null}
// Incoming transactions for token id 1, 25 per page, gateway operations collapsed
val page = withContext(Dispatchers.IO) {
sdk.queryTransactionHistory(TransactionHistoryQuery(
tokenId = 1u,
direction = TransactionDirection.INCOMING,
limit = 25u,
collapseGateway = true
))
}
for (tx in page.data) {
println(tx.txHash)
}
// page.next is the cursor for the following page; null at the end
```
### Session Persistence
#### exportSession
```kotlin theme={null}
fun exportSession(): ExportedSession?
```
Export session data for persistence.
**Returns:** `ExportedSession` or `null` if not authenticated
#### importSession
```kotlin theme={null}
fun importSession(session: ExportedSession): Boolean
```
Import a previously exported session.
**Returns:** `true` if session is valid and imported
### Private Treasury
Per-wallet contacts and transaction labels, stored encrypted on the server and readable only with the session's keys. All methods are suspend functions (call them inside `withContext(Dispatchers.IO)`), throw `SDKError`, and require an authenticated session. A row whose `decryptionFailed` is `true` is one the server could no longer open: show it as unreadable, not as missing.
#### listContacts
```kotlin theme={null}
fun listContacts(limit: UInt? = null, cursor: String? = null): ContactPage
```
Page through the wallet's contacts. Both arguments default, so `listContacts()` is the first page at the server's page size; pass the previous page's `nextCursor` after that.
#### createContact
```kotlin theme={null}
fun createContact(input: CreateContactInput): Contact
```
Create one contact. `privacyAddress` and `nickname` are required and come first; everything after them is defaulted, including `clientId`, a caller-chosen correlation id that bulk results echo back.
#### bulkCreateContacts
```kotlin theme={null}
fun bulkCreateContacts(contacts: List): BulkContactsResult
```
Create several contacts in one request. A contact whose privacy address already exists is reported under `skipped` with the existing row's id rather than failing the batch.
#### updateContact
```kotlin theme={null}
fun updateContact(id: String, input: UpdateContactInput): Contact
```
Patch one contact. Every field is a patch value (`StringPatch.Keep`, `StringPatch.Clear`, or `StringPatch.Set(value)`, and the `MetadataPatch` equivalents), so "leave alone" is always `Keep` and never a `null` that could be read as a clear. `nickname` and `emoji` are the two the API can only replace: `Clear` on either throws `SdkError.InvalidInput` naming the field rather than being silently downgraded to `Keep`.
#### deleteContact
```kotlin theme={null}
fun deleteContact(id: String)
```
#### listLabels
```kotlin theme={null}
fun listLabels(limit: UInt? = null, cursor: String? = null): TransactionLabelPage
```
Page through the wallet's transaction labels, using `cursor` the same way as `listContacts`.
#### upsertLabel
```kotlin theme={null}
fun upsertLabel(txHash: String, input: UpsertLabelInput): TransactionLabel
```
Create or replace the label on a transaction hash. The whole label is replaced, so an absent field means "no value".
#### deleteLabel
```kotlin theme={null}
fun deleteLabel(txHash: String)
```
```kotlin theme={null}
withContext(Dispatchers.IO) {
// Save a contact, then label a transfer to them.
val alice = sdk.createContact(CreateContactInput(
privacyAddress = "0x04...alice",
nickname = "Alice",
ethAddress = "0x1234...abcd",
metadata = mapOf("team" to "design")
))
sdk.upsertLabel(transfer.txHash, UpsertLabelInput(
contactId = alice.id,
memo = "Dinner split"
))
// Later: clear the ETH address and keep everything else.
sdk.updateContact(alice.id, UpdateContactInput(
ethAddress = StringPatch.Clear,
nickname = StringPatch.Keep,
emoji = StringPatch.Keep,
metadata = MetadataPatch.Keep
))
// Page through contacts.
var cursor: String? = null
do {
val page = sdk.listContacts(limit = 50u, cursor = cursor)
page.contacts
.filterNot { it.decryptionFailed }
.forEach { println("${it.nickname} ${it.privacyAddress ?: ""}") }
cursor = page.nextCursor
} while (cursor != null)
}
```
### Campaigns
#### getStartaleCampaignProgress
```kotlin theme={null}
fun getStartaleCampaignProgress(): StartaleCampaignProgress
```
Lifetime Startale mission progress for the authenticated user and app. The deposit mission is priced at query time, so it is not monotonic: record the first `true` your app sees and never downgrade it. `campaignTokenId` is `null` only when no campaign token is configured, in which case every shielded token counted toward the deposit total. Suspend, so call it inside `withContext(Dispatchers.IO)`.
```kotlin theme={null}
val progress = withContext(Dispatchers.IO) { sdk.getStartaleCampaignProgress() }
if (progress.hasDepositedAtLeast10Usd) missions.markComplete(Mission.Deposit)
if (progress.hasMadeTransfer) missions.markComplete(Mission.Transfer)
```
### Address Lookup
#### resolveIdentity
```kotlin theme={null}
fun resolveIdentity(identifier: String): IdentityResult
```
Look up a user's privacy address by MPK or Ethereum address.
**Parameters:**
* `identifier` - MPK or Ethereum address
**Returns:** `IdentityResult` with privacy address and public keys
### Chain Context
#### createChainContext
```kotlin theme={null}
fun createChainContext(config: ChainContextConfig): ChainContextHandle
```
Open a handle for a second chain that shares this instance's identity keys and wallet but has its own server, JWT, and chain state. Authenticate it separately with `authenticate(wallet)`, and clear it separately with `clearSession()`; `lock()` on the parent wipes the shared keys but leaves the handle's JWT in place. Synchronous; throws `SDKError` if `serverUrl` is empty or not an HTTPS URL, or if a contract address is present but malformed. Every call returns a new handle, so create each one once and keep it.
**Parameters:**
* `config` - See [ChainContextConfig](#chaincontextconfig)
**Returns:** [ChainContextHandle](#chaincontexthandle)
See the [Multi-Chain guide](./guides/multi-chain) for setup and cross-chain patterns.
### Utilities
```kotlin theme={null}
fun isValidPrivacyAddress(address: String): Boolean
fun isValidAddress(address: String): Boolean
fun parseAmount(amount: String, decimals: UByte): String
fun formatAmount(wei: String, decimals: UByte): String
```
***
## ChainContextHandle
Returned by `createChainContext(config)`. A chain-scoped facade over one `PrivacyBoost` identity: it carries the operations whose state is per chain and shares the parent's keys, so the wallet signs once per chain to obtain that chain's JWT. Portal deposits, earn and swap, audit, and the private treasury stay on the parent instance, as do `resolveIdentity`, the amount utilities, `setChainReader`, and session export. Methods that return a network result are suspend functions; call them inside `withContext(Dispatchers.IO)`.
```kotlin theme={null}
fun chainId(): ULong
fun isAuthenticated(): Boolean
fun isRegistered(): Boolean
fun authenticate(wallet: WalletDelegate, externalToken: String? = null): LoginResult
fun clearSession()
// Transactions
fun shield(tokenAddress: String, amount: String): ShieldResult
fun prepareShield(tokenAddress: String, amount: String, recipient: String?): PreparedShield
fun unshield(tokenAddress: String, amount: String, recipient: String): UnshieldResult
fun send(tokenAddress: String, amount: String, recipientPrivacyAddress: String): TransferResult
fun sendBatch(tokenAddress: String, recipients: List): TransferResult
fun unwrapWeth(amount: String, recipient: String): String
fun consolidateNotes(tokenAddress: String, maxRounds: UInt): ConsolidateResult
// Balances, tokens, and fees
fun getBalance(tokenAddress: String): TokenBalance
fun getAllBalances(): List
fun getRegisteredTokens(): List
fun getTokenByAddress(address: String): RegisteredToken
fun getFees(tokenAddress: String): FeeRates
fun getGatewayFees(tokenAddress: String, gatewayAddress: String, gatewaySelector: String? = null): FeeRates
// Status and history
fun getShieldStatus(requestId: String): ShieldStatus
fun getTransferStatus(id: String): TransactionStatus
fun getUnshieldStatus(id: String): TransactionStatus
fun getTransactionHistory(txType: String?, limit: UInt?, cursor: String?): TransactionsResult
fun getCollapsedTransactionHistory(txType: String?, limit: UInt?, cursor: String?): TransactionsResult
fun queryTransactionHistory(query: TransactionHistoryQuery): TransactionsResult
fun getUnspentNotes(tokenAddress: String?): List
// Pending transactions
fun addPendingTransaction(tx: PendingTransaction)
fun getPendingTransactions(): List
fun getAllPendingTransactions(): List
fun refreshPendingTransactions(): UInt
fun refreshPendingTransactionsDetailed(): RefreshPendingSummary
fun waitForPreconfirmation(requestId: String, timeoutMs: ULong): PendingTransaction
fun waitForFinality(requestId: String, timeoutMs: ULong): PendingTransaction
```
The claimable-transfer methods (`giftFund`, `giftFundToWallet`, `giftFundSecretBearer`, `giftClaim`, `giftClaimByCGift`, `giftClaimFromLink`, `giftRefund`, `giftRefundByRecord`, `getPendingGifts`, `decodeGiftLink`) have the same signatures as on `PrivacyBoost`; see [Gift Methods](#gift-methods). The handle adds `importGiftRecords(records: List)`, which rehydrates its sender ledger from persisted records so unclaimed gifts stay refundable through `giftRefundByRecord` across restarts. The handle's `getGiftRecords()` throws while the account is locked or expired instead of returning an empty list, so an app that persists the ledger never overwrites its saved copy with nothing.
`authenticate(wallet, externalToken)` takes only the wallet delegate; the key source and token provider come from the parent. `externalToken` carries an external identity provider's token on this chain's login and defaults to `null`.
***
## Module Functions
```kotlin theme={null}
fun sdkVersion(): String
fun generateMnemonic(): String
fun registerKeychainDelegate(delegate: KeychainDelegate, service: String? = null)
fun hasKeychainDelegate(): Boolean
```
* `sdkVersion()` — Returns the SDK version string
* `generateMnemonic()` — Generates a random 12-word BIP-39 mnemonic
* `registerKeychainDelegate(delegate, service)`: registers the app's Keystore implementation for `StorageBackend.OS_KEYCHAIN` persistence. Process-wide; call it once, before constructing a `PrivacyBoost` whose config selects `OS_KEYCHAIN`, or construction throws, and a later call replaces the delegate for every instance. `service` namespaces the stored items; `null` uses the SDK's own name, and an app running two SDK instances should pass its own. `PrivacyBoostDefaults.create(context, config)` in `com.privacyboost.defaults` registers `DefaultKeystoreDelegate(context)` for you. See [KeychainDelegate Interface](#keychaindelegate-interface).
* `hasKeychainDelegate()`: whether a delegate is registered
### Stateless shield helpers
Top-level functions for building a deposit payload without a signed-in session —
for a backend that holds only a recipient's privacy address. No wallet, no
private keys.
```kotlin theme={null}
fun buildShieldPayload(
tokenAddress: String,
amount: String,
recipient: String,
tokenId: UShort,
shieldContractAddress: String,
wethContractAddress: String?,
teePublicKey: String,
minShieldAmount: String?,
emitApprove: Boolean
): PreparedShield
fun finalizeShield(
receipt: TransactionReceipt,
shieldContract: String,
commitment: String
): ShieldResult
```
`buildShieldPayload` builds a recipient-targeted deposit payload from public
material only. Resolve `tokenId`, `shieldContractAddress`, and `teePublicKey`
from the token catalog / config. An ephemeral sender keypair is generated and
discarded internally, and the note is spendable only by `recipient`.
`wethContractAddress` is required only when shielding native ETH;
`minShieldAmount` (per-token minimum in wei) rejects below-minimum deposits;
`emitApprove` emits the ERC-20 approve call (pass `true` by default).
`finalizeShield` recovers the on-chain shield request id from the mined receipt
of a relayed deposit. Stateless — the caller obtained the receipt from its own
relay/RPC. Throws if the deposit reverted or the `DepositRequested` log is
absent.
***
## Types
### PrivacyBoostConfig
```kotlin theme={null}
data class PrivacyBoostConfig(
val serverUrl: String,
val wethContractAddress: String,
val appId: String,
val chainId: ULong? = null,
val shieldContractAddress: String? = null,
val teePublicKey: String? = null,
val persistenceStorage: StorageBackend? = null,
val persistenceUnlock: UnlockMethod? = null,
val approvalOnly: Boolean = false,
val idleTtlSecs: ULong? = null,
val absoluteTtlSecs: ULong? = null
)
```
Set `approvalOnly` to `true` for approval-only EOA accounts. The SDK discovers
the chain-specific AuthRegistry from the configured server.
`persistenceStorage = StorageBackend.OS_KEYCHAIN` requires a `KeychainDelegate`
registered with `registerKeychainDelegate` (or through
`PrivacyBoostDefaults.create`) before construction; `LOCAL_STORAGE` and
`INDEXED_DB` are browser backends and are rejected. `persistenceUnlock` may be
`NONE`, `PIN`, `PASSWORD`, or `BIOMETRIC`; `PASSKEY` is rejected.
`idleTtlSecs` and `absoluteTtlSecs` drive auto-lock, in seconds: the first is
how long the resident keys may go unused, the second how long after an unlock
they may stay resident at all. `null` or `0u` disables the respective timer.
Both are checked lazily, on the next key access: once one has elapsed, that
access zeroizes the keys and throws `SDKError.NotAuthenticated`, exactly as
`lock()` would, and the persisted vault is kept so `authenticate` can reopen it.
They bound key use rather than key residency; call `lock()` from your own timer
when the keys must be gone at a fixed time. See
[Auto-lock](./guides/session-storage#auto-lock). Both fields default to `null`,
so existing constructor calls keep compiling.
### ChainContextConfig
```kotlin theme={null}
data class ChainContextConfig(
val serverUrl: String,
val chainId: ULong,
val shieldContractAddress: String?,
val wethContractAddress: String?,
val rpcUrl: String?,
val teePublicKey: String?,
val timeoutMs: ULong
)
```
Per-chain configuration for `createChainContext`. `chainId` and `timeoutMs`
(request timeout in milliseconds) are required; the optional fields are
discovered from that chain's server when `null`. A contract address you do
pass has to parse — a malformed one is rejected rather than dropped, so a typo
can never fall back to the server-advertised pool.
### KeySource
```kotlin theme={null}
sealed class KeySource {
object WalletDerived : KeySource()
data class Mnemonic(val phrase: String) : KeySource()
data class RawSeed(val hexSeed: String) : KeySource()
}
```
Specifies how privacy keys are derived. Passed as an optional parameter to `authenticate()`.
* **WalletDerived** - Derive keys from a deterministic wallet signature (default when no persistence configured)
* **Mnemonic** - Derive keys from a BIP-39 mnemonic phrase
* **RawSeed** - Derive keys from raw hex entropy (for testing)
### AuthResult
```kotlin theme={null}
sealed class AuthResult {
data class Authenticated(val loginResult: LoginResult) : AuthResult()
data class CredentialRequired(val challenge: CredentialChallenge) : AuthResult()
data class MnemonicGenerated(val mnemonic: String) : AuthResult()
data class RecoveryRequired(val challenge: RecoveryChallenge) : AuthResult()
data class AccountCreationRequired(val challenge: AccountCreationChallenge) : AuthResult()
}
```
`AccountCreationRequired` contains the account ID, canonical account salt, and
a prepared EOA transaction (`chainId`, `to`, `value`, `data`). The SDK validates
the target against the AuthRegistry advertised by this deployment's `/api/v1/info`.
### LoginResult
```kotlin theme={null}
data class LoginResult(
val privacyAddress: String,
val mpk: String
)
```
### TokenBalance
```kotlin theme={null}
data class TokenBalance(
val tokenAddress: String,
val shieldedBalance: String,
val walletBalance: String,
val symbol: String?,
val decimals: UByte
)
```
### FeeRates
```kotlin theme={null}
data class FeeRates(
val transferFeeFlat: String,
val unshieldFeeBps: UShort,
val feeSchemaVersion: UShort?,
val requiresFeeSchemaVersion: UShort?,
val transferRequiresFeeSchemaVersion: UShort?,
val unshieldRequiresFeeSchemaVersion: UShort?,
val transferFeeMode: String?,
val transferFeeBps: UShort?,
val unshieldFeeMode: String?,
val unshieldFeeFlat: String?,
val gatewayAddress: String?,
val gatewaySelector: String?,
val gatewayFeeMode: String?,
val gatewayFeeFlat: String?,
val gatewayFeeRate: UInt?,
val gatewayFeeRateDenominator: UInt?,
val gatewayFeeBase: String?,
val gatewayRequiresFeeSchemaVersion: UShort?
)
```
Gateway fields are populated only by `getGatewayFees`. `gatewayFeeBase` is
`flat`, `unshield_amount`, or `zero` and identifies the amount used for fee
calculation.
### ShieldResult
Returned by `shield` and `finalizeShield`.
```kotlin theme={null}
data class ShieldResult(
val txHash: String,
val commitment: String,
val warning: String?,
val requestId: String, // on-chain shield request id
val wrapTxHash: String? // present when native ETH was wrapped
)
```
### UnshieldResult
```kotlin theme={null}
data class UnshieldResult(
val txHash: String,
val fee: String
)
```
### TransferResult
```kotlin theme={null}
data class TransferResult(
val requestId: String,
val txHash: String,
val fee: String,
val giftRecord: GiftRecord?, // populated by gift funding; null for an ordinary send()
val claimLink: String? // pbgift:v1:... populated by gift funding
)
```
### Call
A single on-chain call to relay. `value` and `data` are 0x-hex.
```kotlin theme={null}
data class Call(
val to: String,
val value: String,
val data: String
)
```
### PreparedShield
Returned by `prepareShield` and `buildShieldPayload`.
```kotlin theme={null}
data class ShieldFinalizeMeta(
val shieldContract: String,
val tokenId: UShort
)
data class PreparedShield(
val approve: Call?, // ERC-20 approve(pool, amount); null when managed out of band
val wrap: Call?, // WETH deposit() wrap; present only when shielding native ETH
val shield: Call, // the requestDeposit(...) call
val commitment: String,
val finalize: ShieldFinalizeMeta
)
```
### TransactionReceipt
The mined-receipt type passed to `finalizeShield`.
```kotlin theme={null}
data class TransactionLog(
val address: String,
val topics: List,
val data: String
)
data class TransactionReceipt(
val txHash: String,
val status: Boolean,
val logs: List
)
```
### Transaction
```kotlin theme={null}
data class Transaction(
val txHash: String,
val txType: String,
val tokenAddress: String,
val amount: String,
val direction: String,
val senderPubKey: String,
val receiverPubKeys: List,
val createdAt: ULong
)
```
### TransactionsResult
One page of history, returned by `getTransactionHistory`,
`getCollapsedTransactionHistory`, and `queryTransactionHistory`.
```kotlin theme={null}
data class TransactionsResult(
val data: List,
val limit: UInt,
val next: String? // cursor for the next page; null at the end
)
```
### TransactionHistoryQuery
```kotlin theme={null}
data class TransactionHistoryQuery(
val txType: String? = null, // filter by transaction type
val tokenId: UShort? = null, // registered token id, from getRegisteredTokens()
val direction: TransactionDirection? = null, // relative to the wallet
val limit: UInt? = null, // page size
val cursor: String? = null, // previous page's next
val collapseGateway: Boolean = false // fold each Gateway operation's two rows into one
)
enum class TransactionDirection { INCOMING, OUTGOING }
```
`null` fields are unfiltered. Every field is defaulted, so name only the filters you want: `TransactionHistoryQuery(tokenId = 1u)` is a valid query.
### IdentityResult
```kotlin theme={null}
data class IdentityResult(
val mpk: String,
val ethereumAddress: String,
val viewingPublicKeyX: String,
val viewingPublicKeyY: String,
val privacyAddress: String
)
```
### ExportedSession
```kotlin theme={null}
data class ExportedSession(
val version: UInt,
val data: String
)
```
`data` is an opaque serialized core session. Store and pass it back unchanged;
do not parse or persist individual key fields.
### Private Treasury Types
```kotlin theme={null}
data class Contact(
val id: String,
val privacyAddress: String?,
val ethAddress: String?,
val nickname: String,
val emoji: String,
val metadata: Map?,
val updatedAt: String,
val decryptionFailed: Boolean // the server could not open this row; show it as unreadable
)
data class CreateContactInput(
val privacyAddress: String,
val nickname: String,
val ethAddress: String? = null,
val emoji: String? = null,
val metadata: Map? = null,
val clientId: String? = null // caller-chosen correlation id echoed back in bulk results
)
// A PATCH field: leave it alone, clear it, or replace it.
sealed class StringPatch {
object Keep : StringPatch()
object Clear : StringPatch()
data class Set(val value: String) : StringPatch()
}
sealed class MetadataPatch {
object Keep : MetadataPatch()
object Clear : MetadataPatch()
data class Set(val value: Map) : MetadataPatch()
}
// Every field is a patch. `Clear` on nickname or emoji throws: the API can
// only replace those two.
data class UpdateContactInput(
val ethAddress: StringPatch,
val nickname: StringPatch,
val emoji: StringPatch,
val metadata: MetadataPatch
)
data class BulkContactRef(
val clientId: String?,
val id: String
)
// A contact whose privacy address already exists lands in `skipped` with the existing row's id.
data class BulkContactsResult(
val created: List,
val skipped: List
)
data class ContactPage(
val contacts: List,
val nextCursor: String?
)
data class TransactionLabel(
val txHash: String?,
val contactId: String?,
val memo: String?,
val metadata: Map?,
val updatedAt: String,
val decryptionFailed: Boolean
)
// A PUT replaces the whole label, so an absent field means "no value".
data class UpsertLabelInput(
val contactId: String?,
val memo: String?,
val metadata: Map?
)
data class TransactionLabelPage(
val labels: List,
val nextCursor: String?
)
```
### Earn and Gateway Types
```kotlin theme={null}
// Selectors for the typed history reads. The `*Json` accessors take the wire
// names ("tvlUsd" / "netApy", "1w" / "1m" / "3m" / "all") instead.
enum class EarnHistoryMetric { TVL_USD, NET_APY }
enum class EarnHistoryPeriod { ONE_WEEK, ONE_MONTH, THREE_MONTHS, ALL }
data class EarnResult(
val requestId: String,
val action: String, // "deposit" | "redeem"
val vaultAddress: String,
val inputTokenId: UShort,
val outputTokenId: UShort,
val amount: String,
val fee: String,
val expectedOutputAmount: String,
val minOutputAmount: String,
val expiryBlock: ULong,
)
data class EarnVaultCatalog(
val vaults: List,
val gateway: GatewayAvailability,
)
data class EarnVaultSummary(
val chainId: ULong,
val vaultAddress: String,
val asset: EarnVaultAsset, // address, symbol, decimals, logoUri
val name: String,
val symbol: String,
val description: String?,
val imageUrl: String?,
val apy: EarnApy,
val tvlUsd: Double?,
val liquidity: String?, // smallest unit, decimal string
val liquidityUsd: Double?,
val fees: EarnFees, // performanceFee, managementFee
val curators: List, // name, image, aumUsd
val warnings: List, // warningType, level
val listed: Boolean,
val capabilities: EarnVaultCapabilities, // deposit, redeem
val availability: EarnVaultAvailability, // deposit, redeem: GatewayAvailability
val metadata: EarnVaultMetadata, // state, updatedAt
val protocol: String,
val protocolVersion: String,
val updatedAt: String?,
)
data class EarnApy(
val net: Double?, // fractions: 0.05 is 5%
val netExcludingRewards: Double?,
val net7d: Double?,
val net30d: Double?,
val net90d: Double?,
val rewards: List, // assetAddress, assetSymbol, supplyApr
)
data class GatewayAvailability(
val state: String,
val canSubmit: Boolean,
val reason: String,
val retryable: Boolean, // a closed window expected to reopen on its own
val checkedAt: String?,
)
data class EarnVaultDetail(
val summary: EarnVaultSummary,
val owner: String?,
val curator: String?,
val deployedAt: String?,
val timelockSeconds: ULong?,
val guardian: String?,
val feeRecipient: String?,
val sharePrice: Double?,
val sharePriceUsd: Double?,
val gateway: GatewayAvailability,
)
data class GatewayConfig(
val enabled: Boolean,
val executorAddress: String?,
val adapters: List, // id, version, quoteMode, availability
val constraints: GatewayConstraints, // calldata, slippage, and expiry limits
val rescue: GatewayRescueInfo, // cancelDelayBlocks
val policy: GatewayPolicyInfo,
val availability: GatewayAvailability,
)
data class EarnVaultHistory(
val chainId: ULong,
val vaultAddress: String,
val protocol: String,
val protocolVersion: String,
val metric: String, // "tvlUsd" | "netApy"
val period: String,
val interval: String,
val unit: String,
val lookbackHours: Long?,
val periodAverage: Double?,
val points: List, // timestamp, value (null is an upstream gap)
val metadata: VaultHistoryMetadata, // state, updatedAt
)
data class EarnVaultCharts(
val tvlUsd: EarnVaultHistory,
val netApy: EarnVaultHistory,
)
```
### StartaleCampaignProgress
```kotlin theme={null}
data class StartaleCampaignProgress(
val hasDepositedAtLeast10Usd: Boolean,
val hasMadeTransfer: Boolean,
val campaignTokenId: UShort? // null only when no campaign token is configured
)
```
***
## WalletDelegate Interface
```kotlin theme={null}
interface WalletDelegate {
suspend fun getAddress(): String
suspend fun getChainId(): ULong
suspend fun signMessage(message: String): String
suspend fun signTypedData(typedDataJson: String): String
suspend fun sendTransaction(toAddress: String, value: String, data: String): String
suspend fun waitForTransactionReceipt(txHash: String): TransactionReceipt
}
data class TransactionLog(
val address: String,
val topics: List,
val data: String
)
data class TransactionReceipt(
val txHash: String,
val status: Boolean,
val logs: List
)
```
***
## TokenProvider Interface
```kotlin theme={null}
interface TokenProvider {
suspend fun getToken(loginPayloadJson: String): TokenResponse
}
```
Implement this interface to route authentication through your own server. The SDK serializes the login payload as a JSON string and passes it to `getToken()`. Your implementation should forward this payload to your backend, which adds its own credentials and calls the Privacy Boost API.
When the call fails for a transient reason (your backend is unreachable, times out, or answers 5xx), throw `SdkException.NetworkException(message)`, or `SdkException.RateLimited(retryAfterMs)` for a rate limit, so `authenticate` fails with an error worth retrying. A plain Kotlin exception, such as the `IOException` from a request that never got an answer, counts as transient too. To refuse the user, throw `SdkException.AuthenticationFailed(message)`: it, and any other `SdkException` you throw, reaches your app as `SdkException.AuthenticationFailed`, which is not retryable.
### TokenResponse
```kotlin theme={null}
data class TokenResponse(
val token: String, // JWT access token
val expiresIn: ULong // Token lifetime in seconds
)
```
### Example
```kotlin theme={null}
class MyTokenProvider : TokenProvider {
override suspend fun getToken(loginPayloadJson: String): TokenResponse {
val response = try {
yourHttpClient.post("https://your-server.com/api/privacy-auth") {
contentType(ContentType.Application.Json)
setBody(loginPayloadJson)
}
} catch (e: IOException) {
throw SdkException.NetworkException(e.message ?: "auth backend unreachable")
}
if (response.status.value >= 500) {
throw SdkException.NetworkException("auth backend answered ${response.status.value}")
}
val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject
return TokenResponse(
token = json["token"]!!.jsonPrimitive.content,
expiresIn = json["expiresIn"]!!.jsonPrimitive.long.toULong()
)
}
}
val result = sdk.authenticate(
wallet = walletDelegate,
tokenProvider = MyTokenProvider()
)
```
***
## KeychainDelegate Interface
```kotlin theme={null}
interface KeychainDelegate {
suspend fun store(key: String, value: ByteArray, requireBiometry: Boolean)
suspend fun load(key: String): ByteArray?
suspend fun remove(key: String)
suspend fun contains(key: String): Boolean
fun isAvailable(): Boolean
fun supportsBiometry(): Boolean
}
```
Serves `persistenceStorage = StorageBackend.OS_KEYCHAIN`. Register an implementation process-wide with `registerKeychainDelegate` before constructing the SDK, or let `PrivacyBoostDefaults.create(context, config)` register `DefaultKeystoreDelegate(context)`. Values are opaque bytes; `requireBiometry` is `true` when the config selects `UnlockMethod.BIOMETRIC`, and the item must then be stored under a Keystore key that requires a class-3 biometric. The default delegate expects the app to complete its own `BiometricPrompt` before the SDK reads or writes such an item. See [Session Storage](./guides/session-storage#sdk-managed-persistence).
***
## SDKError
```kotlin theme={null}
sealed class SDKError : Exception() {
object NotConnected : SDKError()
object NotAuthenticated : SDKError()
data class AuthenticationFailed(override val message: String) : SDKError()
object InvalidConfig : SDKError()
data class ConfigError(override val message: String) : SDKError()
data class NetworkError(override val message: String) : SDKError()
data class WalletError(override val message: String) : SDKError()
object SignatureRejected : SDKError()
data class InsufficientBalance(override val message: String) : SDKError()
object InvalidAddress : SDKError()
object InvalidAmount : SDKError()
data class InvalidInput(override val message: String) : SDKError()
data class SerializationError(override val message: String) : SDKError()
data class AuthServerError(val code: String, override val message: String) : SDKError()
data class ShieldError(val code: String, override val message: String) : SDKError()
data class TransferError(val code: String, override val message: String) : SDKError()
data class NoteError(val code: String, override val message: String) : SDKError()
data class MerkleError(val code: String, override val message: String) : SDKError()
data class ApiError(val code: String, override val message: String, val retryable: Boolean) : SDKError()
data class RateLimited(val retryAfterMs: ULong) : SDKError()
data class Forbidden(override val message: String) : SDKError()
data class ResourceNotFound(override val message: String) : SDKError()
data class WaitTimeout(val requestId: String, val waited: String) : SDKError()
data class TransactionFailed(val requestId: String, override val message: String) : SDKError()
data class InternalError(override val message: String) : SDKError()
data class TransactionReverted(override val message: String) : SDKError()
data class CryptoError(override val message: String) : SDKError()
data class StateError(override val message: String) : SDKError()
data class OperationError(override val message: String) : SDKError()
}
```
***
## Gift Methods
Claimable-transfer (gift) methods on the `PrivacyBoost` instance. See the
[Claimable Transfers guide](./guides/claimable-transfers) for usage and the
[concept page](/sdk/concepts/claimable-transfers) for the trust model. Suspend
methods should be called inside `withContext(Dispatchers.IO)`.
#### giftFundToWallet
```kotlin theme={null}
fun giftFundToWallet(
tokenAddress: String,
amount: String,
recipientWallet: String,
refundAfterBlock: ULong,
currentBlock: ULong
): TransferResult
```
Fund a gift bound to a recipient wallet address that may not be registered yet.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
* `recipientWallet` - Recipient's Ethereum address the gift binds to (irrevocable)
* `refundAfterBlock` - Block height after which an unclaimed gift may be reclaimed
* `currentBlock` - Current chain head; pre-validates the refund delay
**Returns:** `TransferResult` with `giftRecord` (persist it) and `claimLink`
#### giftFund
```kotlin theme={null}
fun giftFund(
tokenAddress: String,
amount: String,
recipientPrivacyAddress: String,
recipientWallet: String,
refundAfterBlock: ULong,
currentBlock: ULong
): TransferResult
```
Fund a gift when the recipient is already a Privacy Boost user. Seals the gift
ciphertext to their viewing key for the smoothest discovery.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
* `recipientPrivacyAddress` - Recipient's 194-char privacy address (ECDH target)
* `recipientWallet` - Recipient's Ethereum address the gift binds to (irrevocable)
* `refundAfterBlock` - Block height after which an unclaimed gift may be reclaimed
* `currentBlock` - Current chain head; pre-validates the refund delay
**Returns:** `TransferResult` with `giftRecord` (persist it) and `claimLink`
#### giftClaim
```kotlin theme={null}
fun giftClaim(index: UInt, acknowledgeUnknownSender: Boolean): TransferResult
```
Claim a pending gift by its position in `getPendingGifts()`.
**Parameters:**
* `index` - Position in the pending-gift list
* `acknowledgeUnknownSender` - Must be `true`; consents to funds from a hidden sender
#### giftClaimByCGift
```kotlin theme={null}
fun giftClaimByCGift(cGift: String, acknowledgeUnknownSender: Boolean): TransferResult
```
Claim a pending gift by its stable commitment `cGift`. Preferred over `index`
when the pending list may shift as gifts settle.
**Parameters:**
* `cGift` - Stable gift commitment
* `acknowledgeUnknownSender` - Must be `true`
#### giftClaimFromLink
```kotlin theme={null}
fun giftClaimFromLink(link: String, acknowledgeUnknownSender: Boolean): TransferResult
```
Claim directly from a `pbgift:v1:...` claim link without a server lookup first.
**Parameters:**
* `link` - A `pbgift:v1:...` claim link
* `acknowledgeUnknownSender` - Must be `true`
#### giftRefundByRecord
```kotlin theme={null}
fun giftRefundByRecord(index: UInt): TransferResult
```
Refund an unclaimed gift using the local gift record saved at funding time. Only
valid after `refundAfterBlock` has elapsed.
**Parameters:**
* `index` - Position in `getGiftRecords()`
#### giftRefund
```kotlin theme={null}
fun giftRefund(
recipientWallet: String,
blind: String,
refundAfterBlock: ULong,
tokenId: UShort,
amount: String,
fundingTreeNumber: ULong,
giftLeafIndex: ULong
): TransferResult
```
Refund an unclaimed gift by supplying every field manually — the fallback when
the local gift record has been lost.
#### getPendingGifts
```kotlin theme={null}
fun getPendingGifts(): List
```
List pending gifts addressed to the authenticated wallet (TEE discovery).
#### getGiftRecords
```kotlin theme={null}
fun getGiftRecords(): List
```
List the local gift records the SDK persisted for gifts you funded. These are
what make an unclaimed gift refundable and are included in an exported session.
While the account is locked or its session has expired, this returns an empty
list. The SDK still holds the records and hands them back after the next
unlock, so merge what it returns into your saved copy and never replace the
saved copy with it.
#### decodeGiftLink
```kotlin theme={null}
fun decodeGiftLink(link: String): GiftLinkPreview
```
Decode a `pbgift:v1:...` claim link into a preview. **Synchronous and throwing**
— works fully offline, so it does not need `withContext(Dispatchers.IO)`.
**Parameters:**
* `link` - A `pbgift:v1:...` claim link
**Returns:** `GiftLinkPreview`
`giftFundSecretBearer(...)` also exists. It is a **bearer instrument**
(whoever holds the link can claim), selected only by that explicit method. See
the [concept page](/sdk/concepts/claimable-transfers) before considering it.
### Gift Types
```kotlin theme={null}
data class GiftRecord(
val recipientWallet: String,
val blind: String,
val refundAfterBlock: ULong,
val tokenId: UShort,
val amount: String,
val cGift: String,
val giftNpk: String,
val requestId: String,
val fundingTreeNumber: ULong?,
val giftLeafIndex: ULong?,
val status: String?
)
data class PendingGift(
val index: UInt,
val treeNumber: ULong,
val leafIndex: ULong,
val cGift: String,
val tokenId: UShort,
val amount: String,
val refundAfterBlock: ULong,
val recipientWallet: String
)
data class GiftLinkPreview(
val server: String,
val chainId: ULong,
val cGift: String,
val recipientWallet: String,
val tokenId: UShort,
val amount: String,
val refundAfterBlock: ULong
)
```
The gift fields are added to the existing `TransferResult` returned by the
fund / claim / refund methods:
```kotlin theme={null}
data class TransferResult(
val requestId: String,
val txHash: String,
val fee: String,
val giftRecord: GiftRecord?, // present on a fund; persist it to refund later
val claimLink: String? // pbgift:v1:... present on a wallet-bound fund
)
```
***
## Portal
Portal deposit addresses. Android exposes the portal lifecycle as methods on
`PrivacyBoost` — `createPortal` owns the EIP-7702 delegation and the gasless
registration for you. Low-level portal functions are limited to custodial escape
hatches. See the [Portal Deposits guide](./guides/portal-deposits) for the
lifecycle and the [concept page](/sdk/concepts/portal-deposits) for the trust
model and [current
limitations](/sdk/concepts/portal-deposits#current-limitations).
### Instance Methods
#### publishPortalRegistry
```kotlin theme={null}
fun publishPortalRegistry(portal: String, index: UInt)
```
Publish the private discovery registry entry so the indexer can credit deposits
to the owner's account. Suspend — call inside `withContext(Dispatchers.IO)`.
**Parameters:**
* `portal` - The portal deposit address `E`
* `index` - The seed-derivation index of `portal`
#### portalDelegateAddress
```kotlin theme={null}
fun portalDelegateAddress(): String?
```
Returns the portal delegate address advertised by the server's `/info`, or `null`
if the server does not support portals.
### Portal lifecycle
```kotlin theme={null}
fun createPortal(index: UInt?): Portal
fun listPortalsPage(cursor: String?, limit: UInt): PortalInfoPage
fun listPortalDepositsPage(portal: String, cursor: String?, limit: UInt): PortalDepositPage
fun getPortalStatus(portal: String): PortalStatus
fun sweepPortal(portal: String, tokenId: UShort): String
fun reclaimPortalDeposit(depositId: String): String
fun withdrawPortal(
index: UInt,
token: String,
to: String,
amount: String,
nonce: String,
gasLimit: String,
maxFeePerGas: String,
maxPriorityFeePerGas: String
): String
```
* `createPortal(index)` — Derive portal `E`, gaslessly delegate and register it
via the server relays, then publish the discovery entry. Pass `null` to use the
next free derivation index.
* `listPortalsPage(cursor, limit)` — One page of the authenticated account's
portals with status and derivation index. `limit` must be between 1 and 100;
pass the page's `nextCursor` back as `cursor` to continue, and treat an absent
`nextCursor` as the final page.
* `listPortalDepositsPage(portal, cursor, limit)` — One page of the deposits
observed at a portal. Same cursor and limit semantics.
* `getPortalStatus(portal)` — Return registry/on-chain status for one portal.
* `sweepPortal(portal, tokenId)` — Send `requestPortalDeposit` from the connected
wallet and return the transaction hash.
* `reclaimPortalDeposit(depositId)` — Send `cancelPortalDeposit` from the
connected wallet and return the transaction hash.
* `withdrawPortal(...)` — Build a signed EIP-1559 transaction that withdraws raw
funds resting at seed-derived portal `E`. Broadcast the returned raw
transaction yourself.
### Top-Level Functions
These package-level functions are low-level escape hatches for custodial
integrations that manage their own portal EOA key. The seed-derived
`createPortal` flow does not expose a private key.
```kotlin theme={null}
fun generatePortalEoa(): PortalEoa
fun encodePortalWithdraw(token: String, to: String, amount: String): String
```
* `generatePortalEoa()` — Generate a fresh portal address `E` and its **secret**
private key for custodial integrations.
* `encodePortalWithdraw(token, to, amount)` — Encode `PortalDelegate.withdraw`
calldata for a custodial integration that already controls `E`.
### Portal Types
```kotlin theme={null}
data class Portal(
val address: String,
val index: UInt,
val bindH: String
)
data class PortalInfo(
val address: String,
val index: UInt,
val status: PortalStatus
)
data class PortalInfoPage(
val items: List,
val nextCursor: String?
)
data class PortalStatus(
val registered: Boolean,
val delegated: Boolean,
val published: Boolean
)
enum class PortalDepositState {
PendingSweep,
Credited,
Cancellable,
Cancelled,
Unknown
}
data class PortalDeposit(
val portalDepositId: String,
val portal: String,
val tokenId: UShort,
val grossAmount: String,
val netAmount: String,
val sweepFeeBps: UShort, // rate snapshotted when this deposit was swept
val feeAmount: String, // grossAmount - netAmount
val state: PortalDepositState,
val requestBlock: ULong,
val cancellableAtBlock: ULong
)
data class PortalDepositPage(
val items: List,
val nextCursor: String?
)
data class PortalEoa(
val privateKey: String, // SECRET — custody and sign with it; never persisted by the SDK
val address: String // the portal deposit address E
)
```
***
## Deposit Cancellation
`shield(...)` moves the depositor's tokens into the pool and holds them there
until the sequencer settles the request into the note tree. Almost always that
happens within a couple of blocks. When it doesn't, because of a commitment
mismatch, a malformed ciphertext, or a stalled sequencer, the tokens stay in
escrow and the depositor reclaims them by cancelling the request on-chain.
The server will tell you a shield *failed* — `getShieldStatus(requestId)` returns
`failed` for a commitment mismatch or a malformed ciphertext — but it says
nothing about the escrow behind it: whether the pool still holds it, who may
reclaim it, and when. A stalled sequencer doesn't even produce that `failed` row.
So use `getShieldStatus` to notice something went wrong, and these two methods to
find out what you can do about it. They read the pool directly and tell you what
it would do before you ask anyone to sign.
`getDepositCancellation` reads chain state, so it needs a `ChainReaderDelegate`
registered with `setChainReader`. `cancelDeposit` works without one — it just
skips its preflight. It's the same optional delegate earn uses, and any
RPC-connected wallet can back it.
```kotlin theme={null}
interface ChainReaderDelegate {
suspend fun getBlockNumber(): ULong
suspend fun readContract(contractAddress: String, data: String): String
}
fun setChainReader(reader: ChainReaderDelegate)
```
### Instance Methods
#### listShieldsByStatus
```kotlin theme={null}
fun listShieldsByStatus(statuses: List): List
```
Find a `depositRequestId` you never kept. Lists the authenticated wallet's own
shield requests in the given public statuses (`pending`, `processing`,
`preconfirmed`, `completed`, `failed`), oldest first. An empty list means the
server's default of `pending`.
Pass `failed` to list the shields whose escrow may still be sitting in the pool,
then feed each `requestId` to `getDepositCancellation`. Served by the server and
scoped to the session, so it needs no `ChainReaderDelegate` and cannot enumerate
anyone else's deposits.
`failed` is a public status the server folds several internal ones into, so it
also covers shields rejected as below-minimum or blocked. A stalled sequencer
produces no failed row at all — those stay `pending`.
**Returns:** `UnsettledShield` per row: `requestId`, `status`, and the optional
`txHash`, `blockNumber`, `createdAt` and `error`.
#### depositRequestIdsFromTransaction
```kotlin theme={null}
fun depositRequestIdsFromTransaction(txHash: String): List
```
The fallback when the app kept the shield's transaction hash but not its request
id. Reads the receipt and returns every `DepositRequested` id the pool emitted,
in log order — more than one when an aggregating relayer settled several callers
in the same transaction.
Empty means the transaction emitted no `DepositRequested` from the configured
pool, which is the answer for a hash that was never a shield rather than an
error, so candidate hashes can be swept. Reads the chain, so it needs the same
`ChainReaderDelegate` as `getDepositCancellation`.
#### getDepositCancellation
```kotlin theme={null}
fun getDepositCancellation(depositRequestId: String): DepositCancellation
```
Read the pool and report whether `cancelDeposit` would succeed for the connected
wallet right now, without sending anything. Costs three `eth_call`s plus one
`eth_blockNumber`, and throws if no `ChainReaderDelegate` is set. Suspend, so
call it inside `withContext(Dispatchers.IO)`.
**Parameters:**
* `depositRequestId` - The `DepositRequested` event's indexed `topic1`, a `uint256`. It's the same value `shield(...)` and `finalizeShield(...)` return as `ShieldResult.requestId`. Decimal or `0x`-hex; the result normalizes it to decimal.
**Returns:** `DepositCancellation` with the escrowed amount, the block the delay
expires at, and a `reason` you can show the user directly
#### cancelDeposit
```kotlin theme={null}
fun cancelDeposit(depositRequestId: String): String
```
Send `cancelDeposit` from the connected wallet and return the transaction hash.
The full escrowed amount goes back to the depositor. Suspend, so call it inside
`withContext(Dispatchers.IO)`.
The call preflights through `getDepositCancellation` and throws locally, before
anything is signed, when the pool would revert. The thrown message is the status
`reason`, so a stuck deposit explains itself instead of burning gas on an opaque
revert. Without a `ChainReaderDelegate` there's no preflight: the transaction is
submitted anyway and the chain arbitrates.
**Parameters:**
* `depositRequestId` - Same id `getDepositCancellation` takes
**Returns:** Transaction hash
### Who can cancel, and when
Only the address that submitted the deposit can cancel it, and the refund goes to
that address. For a relayer-submitted shield that's the relayer, not the note
recipient, so a user whose deposit is genuinely theirs can still get
`NotDepositor` back.
The cancel delay is measured in **blocks** and read from the pool's
`cancelDelay()`. Read the deployed value instead of hardcoding a delay, using
`cancelDelay`, `cancellableAtBlock`, and `blocksRemaining` from the returned status.
### Cancellation states
`state` mirrors the reverts in the pool's `cancelDeposit`, reported
permanent-blocker first rather than in the pool's own check order. The pool
checks the depositor before anything else, and that check would mask the two
conditions no caller can clear.
| State | Meaning |
| - | - |
| `Cancellable` | Every precondition holds; the transaction should land. |
| `NotFound` | The pool holds no escrow under this id. It was never created, or it's already been cancelled or rescued. Settling does not clear the record, so a settled deposit reports `AlreadyProcessed` instead. |
| `AlreadyProcessed` | The sequencer already settled this deposit into the note tree, so the funds are in a note rather than in escrow. This blocks the rescue path too, so it's reported ahead of `GatewayOrigin`. |
| `GatewayOrigin` | A gateway-origin deposit. It carries a rescue commitment and has to be recovered with `rescueGatewayDeposit` by its rescue authority instead. |
| `NotDepositor` | Escrow exists but belongs to another address. |
| `TooEarly` | Escrow is reclaimable, but the cancel delay hasn't elapsed yet. The one rejecting state that resolves on its own. |
A gateway-origin deposit stores the gateway contract as its depositor, never the
wallet whose funds routed through it, so it reports `GatewayOrigin` for every
caller and at every point in the delay window. Waiting never makes it
cancellable.
### Deposit Cancellation Types
```kotlin theme={null}
enum class DepositCancelState {
Cancellable,
NotFound,
NotDepositor,
AlreadyProcessed,
TooEarly,
GatewayOrigin
}
data class DepositCancellation(
val depositRequestId: String, // the queried id, normalized to decimal
val state: DepositCancelState,
val cancellable: Boolean, // true exactly when state is Cancellable
val depositor: String?, // null when no escrow exists
val caller: String, // the address the status was evaluated against
val tokenId: UShort,
val totalAmount: String, // escrow that would be refunded; "0" when absent
val requestBlock: ULong,
val cancelDelay: ULong, // the pool's cancelDelay(), in blocks
val cancellableAtBlock: ULong, // requestBlock + cancelDelay
val currentBlock: ULong, // chain head the status was evaluated at
val blocksRemaining: ULong, // 0 once the delay has elapsed
val reason: String // human-readable explanation of state
)
```
## See Also
* [Getting Started](./getting-started) - Quick start guide
* [Wallet Integration](./guides/wallet-integration) - Wallet delegate implementation
* [Session Storage](./guides/session-storage) - Session persistence
* [Claimable Transfers](./guides/claimable-transfers) - Gift methods
* [Portal Deposits](./guides/portal-deposits) - Portal building blocks
* [Multi-Chain](./guides/multi-chain) - Chain contexts
# Getting started
Source: https://docs.privacyboost.io/sdk/android/getting-started
# Getting Started
This guide walks you through integrating the Privacy Boost Android SDK into your app.
For a minimal copy-paste example, see the [Android Quickstart](/sdk/quickstart/android). This guide explains each step in detail.
## Prerequisites
* The SDK [installed](/sdk/android/installation) in your project
* An [App ID](/sdk/concepts/app-setup) and server URL (see [Configuration](/sdk/concepts/configuration))
* A wallet integration (WalletConnect, custom, etc.)
* Familiarity with [authentication options](/sdk/concepts/authentication) (optional for development, required for production)
## Overview
The Android SDK follows a simple flow:
1. **Initialize** - Create SDK instance with configuration
2. **Authenticate** - Connect wallet and authenticate via `WalletDelegate`
3. **Use** - Deposit, unshield, transfer tokens
## Basic Setup
### 1. Configure the SDK
```kotlin theme={null}
import com.privacyboost.sdk.*
val config = PrivacyBoostConfig(
serverUrl = "https://optimism.sepolia.privacyboost.io",
wethContractAddress = "0x4200000000000000000000000000000000000006",
appId = "app_abc123xyz"
)
```
### 2. Initialize
```kotlin theme={null}
val sdk = PrivacyBoost(config)
```
## Connecting a Wallet
The SDK uses the `WalletDelegate` interface to interact with wallets:
```kotlin theme={null}
import com.privacyboost.sdk.*
class MyWalletDelegate : WalletDelegate {
override suspend fun getAddress(): String {
// Return the connected wallet address
return "0x..."
}
override suspend fun getChainId(): ULong {
// Return the current chain ID
return 1UL
}
override suspend fun signMessage(message: String): String {
// Sign using EIP-191 personal_sign
// Return signature as hex string with 0x prefix
return "0x..."
}
override suspend fun signTypedData(typedDataJson: String): String {
// Sign using EIP-712 eth_signTypedData_v4
return "0x..."
}
override suspend fun sendTransaction(toAddress: String, value: String, data: String): String {
// Submit transaction and return hash
return "0x..."
}
override suspend fun waitForTransactionReceipt(txHash: String): TransactionReceipt {
// Poll eth_getTransactionReceipt until the transaction is mined
return TransactionReceipt(txHash = txHash, status = true, logs = emptyList())
}
}
```
Then authenticate:
```kotlin theme={null}
val wallet = MyWalletDelegate()
try {
// Default (wallet-derived keys)
val result = sdk.authenticate(wallet = wallet)
// Or with a specific key source
// val result = sdk.authenticate(
// wallet = wallet,
// keySource = KeySource.Mnemonic(phrase = "your twelve word mnemonic ...")
// )
when (result) {
is AuthResult.Authenticated -> {
println("Privacy Address: ${result.loginResult.privacyAddress}")
println("MPK: ${result.loginResult.mpk}")
}
is AuthResult.CredentialRequired -> {
// Handle credential challenge
val loginResult = sdk.submitCredential(credential)
println("Privacy Address: ${loginResult.privacyAddress}")
}
}
} catch (e: SDKError) {
println("Authentication failed: ${e.message}")
}
```
## Core Operations
### Check Balance
```kotlin theme={null}
try {
val balance = sdk.getBalance("0x...")
println("Shielded: ${balance.shieldedBalance}")
println("Wallet: ${balance.walletBalance}")
} catch (e: SDKError) {
println("Failed to get balance: ${e.message}")
}
```
### Deposit Tokens
Move tokens from wallet to shielded pool:
```kotlin theme={null}
try {
val result = sdk.shield(
tokenAddress = "0x...",
amount = "1000000000000000000" // 1 token (18 decimals)
)
println("Deposit TX: ${result.txHash}")
} catch (e: SDKError) {
println("Deposit failed: ${e.message}")
}
```
### Withdraw Tokens
Move tokens from shielded pool to wallet:
```kotlin theme={null}
try {
val result = sdk.unshield(
tokenAddress = "0x...",
amount = "500000000000000000",
recipient = "0x..." // Recipient address
)
println("Withdraw TX: ${result.txHash}")
} catch (e: SDKError) {
println("Withdraw failed: ${e.message}")
}
```
### Private Transfer
Send tokens privately to another user:
```kotlin theme={null}
try {
val result = sdk.send(
tokenAddress = "0x...",
amount = "250000000000000000",
recipientPrivacyAddress = "0x04..." // 194-char privacy address
)
println("Transfer TX: ${result.txHash}")
} catch (e: SDKError) {
println("Transfer failed: ${e.message}")
}
```
## Using with Coroutines
The SDK methods are blocking. Use coroutines for non-blocking calls:
```kotlin theme={null}
import kotlinx.coroutines.*
class PrivacyBoostManager(private val sdk: PrivacyBoost) {
suspend fun shield(token: String, amount: String): ShieldResult =
withContext(Dispatchers.IO) {
sdk.shield(token, amount)
}
suspend fun getBalance(token: String): TokenBalance =
withContext(Dispatchers.IO) {
sdk.getBalance(token)
}
}
// Usage
lifecycleScope.launch {
try {
val result = manager.shield(token, amount)
// Update UI on main thread
binding.txHashText.text = result.txHash
} catch (e: SDKError) {
showError(e.message)
}
}
```
## Complete Example
```kotlin theme={null}
import com.privacyboost.sdk.*
import kotlinx.coroutines.*
class PrivacyBoostRepository(config: PrivacyBoostConfig) {
private val sdk = PrivacyBoost(config)
private var walletDelegate: WalletDelegate? = null
val isConnected: Boolean
get() = sdk.isConnected()
val isAuthenticated: Boolean
get() = sdk.isAuthenticated()
val privacyAddress: String?
get() = sdk.getPrivacyAddress()
suspend fun authenticate(wallet: WalletDelegate): AuthResult =
withContext(Dispatchers.IO) {
walletDelegate = wallet
sdk.authenticate(wallet)
}
suspend fun shield(token: String, amount: String): ShieldResult =
withContext(Dispatchers.IO) {
sdk.shield(token, amount)
}
suspend fun unshield(token: String, amount: String, recipient: String): UnshieldResult =
withContext(Dispatchers.IO) {
sdk.unshield(token, amount, recipient)
}
suspend fun send(token: String, amount: String, to: String): TransferResult =
withContext(Dispatchers.IO) {
sdk.send(token, amount, to)
}
suspend fun getBalance(token: String): TokenBalance =
withContext(Dispatchers.IO) {
sdk.getBalance(token)
}
fun logout() {
sdk.logout()
walletDelegate = null
}
}
```
## Error Handling
The SDK throws `SDKError` for various failure conditions:
```kotlin theme={null}
try {
val result = sdk.shield(token, amount)
} catch (e: SDKError.NotConnected) {
println("Wallet not connected")
} catch (e: SDKError.NotAuthenticated) {
println("Not logged in")
} catch (e: SDKError.InsufficientBalance) {
println("Insufficient balance")
} catch (e: SDKError.InvalidAmount) {
println("Invalid amount format")
} catch (e: SDKError.WalletError) {
println("Wallet error: ${e.message}")
} catch (e: SDKError.NetworkError) {
println("Network error: ${e.message}")
} catch (e: SDKError) {
println("Unknown error: ${e.message}")
}
```
## Session Persistence
Save and restore sessions to avoid re-signing:
```kotlin theme={null}
// Export session
val session = sdk.exportSession()
session?.let {
saveToSecureStorage(it)
}
// Import session
loadFromSecureStorage()?.let { session ->
try {
val success = sdk.importSession(session)
if (success) {
println("Session restored")
}
} catch (e: SDKError) {
println("Session expired or invalid")
}
}
```
## Next Steps
Detailed WalletDelegate implementation
Android Keystore storage and biometric unlock
Complete API documentation
Key derivation, persistence options, and recovery
# Balances
Source: https://docs.privacyboost.io/sdk/android/guides/balances
# Balance Management
This guide covers querying and managing token balances in the Privacy Boost Android SDK.
## Understanding Balances
Privacy Boost tracks two types of balances:
| Balance Type | Description |
| - | - |
| **Shielded** | Private balance in the privacy pool — only you can see it |
| **Wallet** | Public balance in your connected wallet on-chain |
## Get Single Token Balance
```kotlin theme={null}
val balance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = "0x...")
}
println("Token: ${balance.tokenAddress}")
println("Shielded: ${balance.shieldedBalance}")
println("Wallet: ${balance.walletBalance}")
println("Symbol: ${balance.symbol}")
println("Decimals: ${balance.decimals}")
```
## Get All Balances
```kotlin theme={null}
val balances = withContext(Dispatchers.IO) {
sdk.getAllBalances()
}
for (balance in balances) {
println("${balance.symbol ?: balance.tokenAddress}:")
println(" Shielded: ${balance.shieldedBalance}")
println(" Wallet: ${balance.walletBalance}")
}
```
## Balance Type
```kotlin theme={null}
data class TokenBalance(
val tokenAddress: String, // Token contract address
val shieldedBalance: String, // Private balance in wei
val walletBalance: String, // Public balance in wei
val symbol: String?, // Token symbol (e.g., "USDC")
val decimals: UByte // Token decimals (e.g., 18)
)
```
## Formatting Amounts
Convert between wei strings and human-readable format:
```kotlin theme={null}
// Parse human-readable to wei
val weiAmount = sdk.parseAmount("1.5", decimals = 18u)
// Returns: "1500000000000000000"
// Format wei to human-readable
val humanAmount = sdk.formatAmount("1500000000000000000", decimals = 18u)
// Returns: "1.5"
// Format a balance for display
val balance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
val formatted = sdk.formatAmount(balance.shieldedBalance, decimals = balance.decimals)
println("Shielded: $formatted ${balance.symbol ?: ""}")
```
## Checking Sufficient Balance
Before performing operations, verify the user has enough balance:
```kotlin theme={null}
suspend fun hasSufficientBalance(
tokenAddress: String,
requiredAmount: String
): Boolean {
val balance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
val shielded = balance.shieldedBalance.toBigIntegerOrNull() ?: return false
val required = requiredAmount.toBigIntegerOrNull() ?: return false
return shielded >= required
}
// Usage
if (hasSufficientBalance(token, amount)) {
val result = withContext(Dispatchers.IO) {
sdk.unshield(
tokenAddress = token,
amount = amount,
recipient = recipient
)
}
}
```
## ViewModel Integration
```kotlin theme={null}
class BalanceViewModel(private val sdk: PrivacyBoost) : ViewModel() {
private val _balances = MutableStateFlow>(emptyList())
val balances: StateFlow> = _balances.asStateFlow()
private val _isLoading = MutableStateFlow(true)
val isLoading: StateFlow = _isLoading.asStateFlow()
fun loadBalances() {
viewModelScope.launch {
_isLoading.value = true
try {
val result = withContext(Dispatchers.IO) {
sdk.getAllBalances()
}
_balances.value = result
} catch (e: SDKError) {
println("Failed to load balances: $e")
}
_isLoading.value = false
}
}
fun formatBalance(balance: TokenBalance): String {
return try {
sdk.formatAmount(balance.shieldedBalance, decimals = balance.decimals)
} catch (e: Exception) {
"0"
}
}
}
```
## Displaying Balances in Compose
```kotlin theme={null}
@Composable
fun BalanceList(viewModel: BalanceViewModel) {
val balances by viewModel.balances.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadBalances()
}
if (isLoading) {
CircularProgressIndicator()
} else if (balances.isEmpty()) {
Text("No balances found", color = Color.Gray)
} else {
LazyColumn {
items(balances) { balance ->
BalanceRow(balance = balance, viewModel = viewModel)
}
}
}
}
@Composable
fun BalanceRow(balance: TokenBalance, viewModel: BalanceViewModel) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = balance.symbol ?: balance.tokenAddress.take(10),
style = MaterialTheme.typography.titleMedium
)
Column(horizontalAlignment = Alignment.End) {
Text("Shielded: ${viewModel.formatBalance(balance)}")
Text(
text = "Wallet: ${sdk.formatAmount(balance.walletBalance, balance.decimals)}",
color = Color.Gray
)
}
}
}
```
## Error Handling
```kotlin theme={null}
try {
val balance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
} catch (e: SDKError) {
when (e) {
is SDKError.NotAuthenticated ->
println("Please log in first")
is SDKError.NetworkError ->
println("Network error: ${e.message}")
else ->
println("Balance error: $e")
}
}
```
## Best Practices
### 1. Refresh After Operations
```kotlin theme={null}
// Refresh balance after a deposit
val shieldResult = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = tokenAddress, amount = amount)
}
val updatedBalance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
```
### 2. Handle Zero Balances
```kotlin theme={null}
fun displayBalance(balance: TokenBalance): String {
if (balance.shieldedBalance == "0" && balance.walletBalance == "0") {
return "0"
}
return try {
sdk.formatAmount(balance.shieldedBalance, decimals = balance.decimals)
} catch (e: Exception) {
"0"
}
}
```
### 3. Filter Non-Zero Balances
```kotlin theme={null}
val allBalances = withContext(Dispatchers.IO) { sdk.getAllBalances() }
val nonZero = allBalances.filter { balance ->
balance.shieldedBalance != "0" || balance.walletBalance != "0"
}
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transactions Guide](./transactions)
* [Error Handling](./error-handling)
# Claimable transfers
Source: https://docs.privacyboost.io/sdk/android/guides/claimable-transfers
# Claimable Transfers (Gifts)
Send shielded funds to an Ethereum wallet address that has not registered with
Privacy Boost yet. The recipient claims by proving ownership of that address; you
reclaim the funds if they never do. See
[Claimable Transfers](/sdk/concepts/claimable-transfers) for the concept and
trust model.
## Sending a gift
The most common case: you know the recipient's wallet address but they have not
joined Privacy Boost. Use `giftFundToWallet`. The refund deadline is a block
height after which you can reclaim an unclaimed gift; the min/max delay is
advertised from the server's `/info` endpoint. Pass the current chain head as
`currentBlock` so the SDK can pre-validate the refund delay before submitting.
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.giftFundToWallet(
tokenAddress = "0x...token-address",
amount = "1000000000000000000", // 1 token, in wei
recipientWallet = "0xRecipientEoaAddress",
refundAfterBlock = currentBlock + 50_000UL,
currentBlock = currentBlock
)
}
println("Funded gift: ${result.txHash}")
println("Share this claim link with the recipient: ${result.claimLink}")
// Persist result.giftRecord so you can refund later (see "Refunding" below).
saveGiftRecord(result.giftRecord)
} catch (e: SDKError) {
println("Gift funding failed: $e")
}
```
If the recipient is already a Privacy Boost user, fund with their privacy address
too (`giftFund`) so the gift ciphertext is sealed to their viewing key for the
smoothest discovery:
```kotlin theme={null}
val result = withContext(Dispatchers.IO) {
sdk.giftFund(
tokenAddress = "0x...token-address",
amount = "1000000000000000000",
recipientPrivacyAddress = "0x04...recipient-privacy-address", // 194-char privacy address
recipientWallet = "0xRecipientEoaAddress",
refundAfterBlock = currentBlock + 50_000UL,
currentBlock = currentBlock
)
}
```
### Funding parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | Token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
| `recipientPrivacyAddress` | `String` | `giftFund` only | Recipient's 194-char privacy address (the ciphertext's ECDH target) |
| `recipientWallet` | `String` | Yes | Recipient's Ethereum address the gift binds to (may be unregistered) |
| `refundAfterBlock` | `ULong` | Yes | Block height after which you may reclaim an unclaimed gift |
| `currentBlock` | `ULong` | Yes | Current chain head; pre-validates the refund delay before submitting |
The gift binds **irrevocably** to `recipientWallet`. A wrong-but-valid address
can be claimed by whoever controls it, and only an unclaimed gift is
refundable. Show the exact checksummed address and require explicit
confirmation before funding.
### Funding result
`giftFund` / `giftFundToWallet` return a `TransferResult` carrying two gift
fields in addition to the usual transfer fields:
```kotlin theme={null}
data class TransferResult(
val requestId: String,
val txHash: String,
val fee: String,
val giftRecord: GiftRecord?, // persist this — it makes the gift refundable
val claimLink: String? // pbgift:v1:... — share with the recipient
)
```
## Sharing the claim link
`result.claimLink` is a `pbgift:v1:...` string that carries the encrypted opening
the recipient needs. Share it out of band (message, QR code). Treat it like a
password — it reveals the gift's amount and recipient to anyone who reads it.
## Claiming a gift
The recipient registers Privacy Boost with the same wallet address, then claims.
List the pending gifts addressed to the authenticated wallet:
```kotlin theme={null}
val pending = withContext(Dispatchers.IO) {
sdk.getPendingGifts()
}
for (gift in pending) {
println("#${gift.index}: ${gift.amount} of token ${gift.tokenId}")
}
```
Claim one by its position in that list, or — more robustly — by its stable
commitment `cGift` (the list can shift as gifts settle):
```kotlin theme={null}
// By index (positional):
withContext(Dispatchers.IO) {
sdk.giftClaim(index = 0u, acknowledgeUnknownSender = true)
}
// By stable commitment (preferred when the list may change):
withContext(Dispatchers.IO) {
sdk.giftClaimByCGift(cGift = pending[0].cGift, acknowledgeUnknownSender = true)
}
```
Or claim straight from a claim link, without a server lookup first:
```kotlin theme={null}
withContext(Dispatchers.IO) {
sdk.giftClaimFromLink(link = "pbgift:v1:...", acknowledgeUnknownSender = true)
}
```
`acknowledgeUnknownSender` must be `true` to claim. The hidden-sender model
cannot reveal who funded a gift, so the recipient explicitly acknowledges
accepting funds from an unknown sender. Surface this as a consent prompt.
A successful claim re-mints the gift into a normal shielded note — from then on it
behaves like any other note in the recipient's balance.
## Refunding an unclaimed gift
A claim and a refund spend the same nullifier, so only one can ever settle. After
the refund deadline passes, reclaim an unclaimed gift using the local record you
saved at funding time. `giftRefundByRecord` takes the record's index:
```kotlin theme={null}
// Records the SDK persisted for gifts you funded:
val records = withContext(Dispatchers.IO) {
sdk.getGiftRecords()
}
// Refund the one at index 0:
withContext(Dispatchers.IO) {
sdk.giftRefundByRecord(index = 0u)
}
```
Gift records are what make a gift refundable. They are included in an
[exported session](/sdk/android/guides/session-storage), so persisting and restoring
the session keeps your unclaimed gifts refundable across restarts. If you ever
lose the record, you can still refund by supplying every field manually via
`giftRefund(recipientWallet, blind, refundAfterBlock, tokenId, amount, fundingTreeNumber, giftLeafIndex)`.
## Previewing a claim link offline
Decode a claim link into a preview without any network call — useful to show the
recipient what they are about to accept. `decodeGiftLink` is synchronous and
throwing (it works fully offline), so it does not need `withContext`:
```kotlin theme={null}
val preview = sdk.decodeGiftLink(link = "pbgift:v1:...")
println("${preview.amount} of token ${preview.tokenId} to ${preview.recipientWallet}, refundable after ${preview.refundAfterBlock}")
```
## Types
```kotlin theme={null}
data class GiftRecord(
val recipientWallet: String,
val blind: String,
val refundAfterBlock: ULong,
val tokenId: UShort,
val amount: String,
val cGift: String,
val giftNpk: String,
val requestId: String,
val fundingTreeNumber: ULong?,
val giftLeafIndex: ULong?,
val status: String?
)
data class PendingGift(
val index: UInt,
val treeNumber: ULong,
val leafIndex: ULong,
val cGift: String,
val tokenId: UShort,
val amount: String,
val refundAfterBlock: ULong,
val recipientWallet: String
)
data class GiftLinkPreview(
val server: String,
val chainId: ULong,
val cGift: String,
val recipientWallet: String,
val tokenId: UShort,
val amount: String,
val refundAfterBlock: ULong
)
```
A third funding mode, `giftFundSecretBearer`, binds a gift to a secret instead
of a wallet so it can be claimed before the recipient has any wallet. It is a
**bearer instrument** (whoever holds the link can claim), selected only by
that explicit method. See the [concept
page](/sdk/concepts/claimable-transfers) before considering it.
# Deposits
Source: https://docs.privacyboost.io/sdk/android/guides/deposits
# Deposits
This guide covers depositing tokens from your wallet into your private balance.
## Basic Deposit
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.shield(
tokenAddress = "0x...token-address",
amount = "1000000000000000000" // 1 token (18 decimals)
)
}
println("Transaction hash: ${result.txHash}")
println("Commitment: ${result.commitment}")
} catch (e: SDKError) {
println("Deposit failed: $e")
}
```
## Deposit Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | ERC-20 token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
## Deposit Steps
When a deposit is submitted, the SDK executes these steps in order:
| Step | Description |
| - | - |
| Wrapping | Wrapping ETH to WETH (if depositing ETH) |
| Approving | Approving token spending on the shield contract |
| Shielding | Executing the deposit transaction |
| Registering | Registering the deposit with the indexer |
| Compliance | Waiting for compliance verification |
## Deposit Result
```kotlin theme={null}
data class ShieldResult(
val txHash: String, // Main deposit transaction hash
val commitment: String, // Note commitment
val fee: String // Fee paid (in wei)
)
```
## Depositing ETH
To deposit native ETH, use the zero address. The SDK automatically wraps ETH to WETH:
```kotlin theme={null}
val ethAddress = "0x0000000000000000000000000000000000000000"
val result = withContext(Dispatchers.IO) {
sdk.shield(
tokenAddress = ethAddress,
amount = "1000000000000000000" // 1 ETH
)
}
println("Deposit tx: ${result.txHash}")
```
The SDK automatically:
1. Wraps ETH to WETH
2. Approves WETH spending
3. Deposits WETH to the shield contract
## Parsing Amounts
Use helper functions to convert between human-readable and wei formats:
```kotlin theme={null}
// Parse human-readable amount to wei string
val weiAmount = sdk.parseAmount("1.5", decimals = 18u)
// Returns: "1500000000000000000"
// Format wei string to human-readable
val formatted = sdk.formatAmount("1500000000000000000", decimals = 18u)
// Returns: "1.5"
// For USDC (6 decimals)
val usdcWei = sdk.parseAmount("100.0", decimals = 6u)
// Returns: "100000000"
```
## Error Handling
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = tokenAddress, amount = amount)
}
} catch (e: SDKError) {
when (e) {
is SDKError.InsufficientBalance ->
println("Not enough tokens in wallet")
is SDKError.InvalidAmount ->
println("Invalid amount format")
is SDKError.WalletError ->
println("Wallet error: ${e.message}")
is SDKError.SignatureRejected ->
println("User rejected the transaction")
is SDKError.NetworkError ->
println("Network error: ${e.message}")
else ->
println("Deposit error: $e")
}
}
```
## Best Practices
### 1. Validate Amounts Before Depositing
```kotlin theme={null}
fun validateDepositAmount(amount: String, walletBalance: String) {
val amountValue = amount.toBigIntegerOrNull()
?: throw SDKError.InvalidAmount
require(amountValue > BigInteger.ZERO) { "Amount must be positive" }
val balanceValue = walletBalance.toBigIntegerOrNull()
?: throw SDKError.InsufficientBalance("Invalid wallet balance")
require(amountValue <= balanceValue) { "Insufficient wallet balance" }
}
```
### 2. Refresh Balance After Deposit
```kotlin theme={null}
val result = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = tokenAddress, amount = amount)
}
// Refresh balance to reflect the deposit
val updatedBalance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
println("New shielded balance: ${updatedBalance.shieldedBalance}")
```
### 3. Always Use Background Dispatcher
SDK methods are blocking calls. Always wrap them in `withContext(Dispatchers.IO)` to avoid blocking the main thread:
```kotlin theme={null}
// In a ViewModel
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = token, amount = amount)
}
// Update UI on main thread
_depositState.value = DepositState.Success(result.txHash)
}
```
## Relayed Deposits (build calldata, relay yourself)
If your users don't submit transactions directly — they hold funds at addresses
you relay for (a gasless relayer, an EIP-7702 session key, a custom pipeline) —
build the deposit calldata without submitting it, relay it through your own
infrastructure, then finalize from the mined receipt.
```kotlin theme={null}
// 1. Build the calldata (nothing submitted)
val prepared = withContext(Dispatchers.IO) {
sdk.prepareShield(
tokenAddress = "0x...",
amount = "1000000000000000000",
recipient = null // pass a privacy address to shield to someone else
)
}
// prepared.wrap / prepared.approve / prepared.shield are Call(to, value, data)
// values — relay them in order (wrap?, approve?, shield).
val calls = listOfNotNull(prepared.wrap, prepared.approve, prepared.shield)
val receipt = myRelayer.submit(calls)
// 2. Finalize from the mined receipt to recover the request id
val result = finalizeShield(
receipt = receipt,
shieldContract = prepared.finalize.shieldContract,
commitment = prepared.commitment
)
println("Request ID: ${result.requestId}")
```
Each `Call` is `(to, value, data)`, so the list drops directly into an
EIP-5792 / Porto `wallet_prepareCalls` bundle.
### Server-side (no session)
A backend that holds only a recipient's privacy address can build a deposit on
their behalf with the top-level `buildShieldPayload` — no signed-in session and
no private keys:
```kotlin theme={null}
val prepared = buildShieldPayload(
tokenAddress = "0x...",
amount = "1000000000000000000",
recipient = userPrivacyAddress,
tokenId = tokenId, // from the token catalog
shieldContractAddress = shieldContractAddress,
wethContractAddress = null, // required only for native ETH
teePublicKey = teePublicKey,
minShieldAmount = minShieldAmount, // optional: reject below the per-token minimum
emitApprove = true
)
```
The note's owner is bound cryptographically to the recipient's privacy address,
so only they can spend it; the ephemeral sender used to encrypt the deposit is
generated and discarded internally.
## Next Steps
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Error handling
Source: https://docs.privacyboost.io/sdk/android/guides/error-handling
# Error Handling
This guide covers error handling patterns for the Privacy Boost Android SDK. For the cross-platform error code reference, see [Error Handling](/sdk/concepts/error-handling).
## SDKError
All SDK errors are thrown as `SDKError` sealed class instances:
```kotlin theme={null}
sealed class SDKError : Exception() {
object NotConnected : SDKError() // Wallet not connected
object NotAuthenticated : SDKError() // Not logged in
data class AuthenticationFailed(override val message: String) : SDKError()
object InvalidConfig : SDKError() // Invalid SDK configuration
data class ConfigError(override val message: String) : SDKError()
data class NetworkError(override val message: String) : SDKError()
data class WalletError(override val message: String) : SDKError()
object SignatureRejected : SDKError() // User rejected signing
data class InsufficientBalance(override val message: String) : SDKError()
object InvalidAddress : SDKError() // Invalid address format
object InvalidAmount : SDKError() // Invalid amount format
data class InvalidInput(override val message: String) : SDKError()
data class ApiError(val code: String, override val message: String, val retryable: Boolean) : SDKError()
data class RateLimited(val retryAfterMs: ULong) : SDKError()
data class ShieldError(val code: String, override val message: String) : SDKError()
data class TransferError(val code: String, override val message: String) : SDKError()
data class InternalError(override val message: String) : SDKError()
// ... and more
}
```
## Type-Safe Error Handling
Use Kotlin's `when` expression for exhaustive error matching:
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = tokenAddress, amount = amount)
}
} catch (e: SDKError) {
when (e) {
is SDKError.SignatureRejected -> {
// User cancelled — do nothing
return
}
is SDKError.InsufficientBalance -> {
showToast("Not enough balance")
}
is SDKError.WalletError -> {
showToast("Wallet error: ${e.message}")
}
is SDKError.NetworkError -> {
showToast("Network error. Please try again.")
}
is SDKError.RateLimited -> {
val seconds = e.retryAfterMs / 1000u
showToast("Too many requests. Try again in $seconds seconds.")
}
is SDKError.ApiError -> {
if (e.retryable) {
// Retry the operation
} else {
showToast(e.message)
}
}
else -> {
showToast("An unexpected error occurred")
}
}
}
```
## Retry with Exponential Backoff
For retryable errors, use exponential backoff:
```kotlin theme={null}
suspend fun withRetry(
maxRetries: Int = 3,
operation: suspend () -> T
): T {
for (attempt in 1..maxRetries) {
try {
return operation()
} catch (e: SDKError.NetworkError) {
if (attempt == maxRetries) throw e
delay(1000L * attempt)
} catch (e: SDKError.ApiError) {
if (!e.retryable || attempt == maxRetries) throw e
delay(1000L * attempt)
} catch (e: SDKError.RateLimited) {
if (attempt == maxRetries) throw e
delay(e.retryAfterMs.toLong())
}
}
throw SDKError.InternalError("Unreachable")
}
// Usage
val balance = withRetry {
withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = token)
}
}
```
## Auto Re-Authentication
Handle expired sessions by re-authenticating automatically:
```kotlin theme={null}
suspend fun withAuth(
wallet: WalletDelegate,
operation: suspend () -> T
): T {
return try {
operation()
} catch (e: SDKError.NotAuthenticated) {
// Session expired — re-authenticate
withContext(Dispatchers.IO) {
sdk.authenticate(wallet = wallet)
}
operation()
}
}
// Usage
val balance = withAuth(myWallet) {
withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = token)
}
}
```
## User-Friendly Error Messages
Map SDK errors to messages suitable for showing to users:
```kotlin theme={null}
fun userMessage(error: SDKError): String = when (error) {
is SDKError.NotConnected -> "Please connect your wallet"
is SDKError.NotAuthenticated -> "Session expired. Please log in again."
is SDKError.SignatureRejected -> "Request cancelled"
is SDKError.InsufficientBalance -> "Not enough balance"
is SDKError.InvalidAddress -> "Invalid address"
is SDKError.InvalidAmount -> "Invalid amount"
is SDKError.NetworkError -> "Connection failed. Please check your internet and try again."
is SDKError.RateLimited -> "Too many requests. Please wait a moment."
is SDKError.WalletError -> "Wallet error: ${error.message}"
else -> "Something went wrong. Please try again."
}
```
## Categorizing Errors
Group errors by whether user action can resolve them:
```kotlin theme={null}
enum class ErrorCategory {
USER_ACTION, // User can fix (e.g., connect wallet, add funds)
RETRYABLE, // Transient, retry may work
FATAL // Requires developer attention
}
fun categorize(error: SDKError): ErrorCategory = when (error) {
is SDKError.NotConnected,
is SDKError.NotAuthenticated,
is SDKError.SignatureRejected,
is SDKError.InsufficientBalance,
is SDKError.InvalidAddress,
is SDKError.InvalidAmount -> ErrorCategory.USER_ACTION
is SDKError.NetworkError,
is SDKError.RateLimited -> ErrorCategory.RETRYABLE
is SDKError.ApiError -> if (error.retryable) ErrorCategory.RETRYABLE else ErrorCategory.FATAL
else -> ErrorCategory.FATAL
}
```
## ViewModel Error Handling Pattern
```kotlin theme={null}
class VaultViewModel(
private val sdk: PrivacyBoost,
private val wallet: WalletDelegate
) : ViewModel() {
private val _error = MutableStateFlow(null)
val error: StateFlow = _error.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow = _isLoading.asStateFlow()
fun shield(tokenAddress: String, amount: String) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
val result = withRetry {
withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = tokenAddress, amount = amount)
}
}
println("Deposit successful: ${result.txHash}")
} catch (e: SDKError.SignatureRejected) {
// User cancelled — no error to show
} catch (e: SDKError) {
_error.value = userMessage(e)
}
_isLoading.value = false
}
}
}
```
## Best Practices
### 1. Always Handle `SignatureRejected` Silently
When a user cancels a wallet signature, don't show an error:
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = token, amount = amount)
}
} catch (e: SDKError.SignatureRejected) {
return // User cancelled, nothing to report
} catch (e: SDKError) {
showToast(userMessage(e))
}
```
### 2. Log Errors for Debugging
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = token, amount = amount)
}
} catch (e: SDKError) {
// Log full error for debugging
Log.e("PrivacyBoost", "Deposit failed", e)
// Show user-friendly message
showToast(userMessage(e))
}
```
### 3. Use Error Categories for UI Decisions
```kotlin theme={null}
try {
// some operation
} catch (e: SDKError) {
when (categorize(e)) {
ErrorCategory.USER_ACTION -> showToast(userMessage(e))
ErrorCategory.RETRYABLE -> showRetryButton()
ErrorCategory.FATAL -> showContactSupport()
}
}
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [API Reference](/sdk/android/api-reference)
* [Error Handling Concepts](/sdk/concepts/error-handling)
# Multi-Chain
Source: https://docs.privacyboost.io/sdk/android/guides/multi-chain
Operate across multiple blockchains with the Android SDK
# Multi-Chain
This guide covers using `ChainContextHandle` to operate on multiple blockchains from a single Android SDK instance.
For an overview of multi-chain concepts, see [Multi-Chain Concepts](/sdk/concepts/multi-chain). The same identity keys and privacy address apply on every chain.
## Setup
Initialize the parent SDK against your primary chain, then open a chain context for each additional chain:
```kotlin theme={null}
val sdk = PrivacyBoost(PrivacyBoostConfig(
serverUrl = "https://op.example.com",
chainId = null, // discovered from server
shieldContractAddress = null,
wethContractAddress = null,
teePublicKey = null,
appId = "my-app",
persistenceStorage = null,
persistenceUnlock = null
))
// After authenticating the parent SDK once, open per-chain contexts.
val ethereum = sdk.createChainContext(ChainContextConfig(
serverUrl = "https://eth.example.com",
chainId = 1u
))
val base = sdk.createChainContext(ChainContextConfig(
serverUrl = "https://base.example.com",
chainId = 8453u
))
```
`serverUrl` and `chainId` are the only fields you have to give: an omitted contract address is discovered from that chain's server, and `timeoutMs` defaults to the SDK-wide 30s. A contract address you do pass has to parse, so a typo fails rather than falling back to the server-advertised pool. `createChainContext` is synchronous and throws `SDKError` if `serverUrl` is empty or not an HTTPS URL. Every call returns a new handle with its own session, so create each one once and keep it, for example in the repository or ViewModel that owns `sdk`.
## Authentication
Each chain context authenticates before it can operate. Authentication reuses the parent SDK's privacy keys but obtains a chain-specific JWT and registration, so the user signs once per chain. The calls below are suspend functions; run them inside a coroutine:
```kotlin theme={null}
// Authenticate the parent SDK once; this establishes the identity keys.
sdk.authenticate(
wallet = walletDelegate,
keySource = KeySource.WalletDerived,
tokenProvider = null
)
// Authenticate each chain context with the same wallet.
val ethLogin = ethereum.authenticate(wallet = walletDelegate)
val baseLogin = base.authenticate(wallet = walletDelegate)
println("Ethereum chain id: ${ethereum.chainId()}")
println("Ethereum authenticated: ${ethereum.isAuthenticated()}")
println("Ethereum registered: ${ethereum.isRegistered()}")
```
`ChainContextHandle.authenticate(wallet)` takes only a `WalletDelegate`; the key source and token provider come from the parent SDK. Apps that authenticate through an external identity provider pass that provider's token as `externalToken`.
## Operations
Once authenticated, a `ChainContextHandle` exposes the same per-chain operations as the parent SDK.
### Deposits
```kotlin theme={null}
val amount = sdk.parseAmount("1.0", decimals = 18u)
val result = ethereum.shield(
tokenAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH on Ethereum
amount = amount
)
println("Ethereum shield tx: ${result.txHash}")
```
### Private Transfers
```kotlin theme={null}
// resolveIdentity lives on the parent SDK; the handle shares its identity.
val recipient = sdk.resolveIdentity("0x1234...abcd")
val transfer = base.send(
tokenAddress = "0x...token-on-base",
amount = sdk.parseAmount("0.5", decimals = 18u),
recipientPrivacyAddress = recipient.privacyAddress
)
println("Base transfer tx: ${transfer.txHash}")
```
### Withdrawals
```kotlin theme={null}
ethereum.unshield(
tokenAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
amount = sdk.parseAmount("0.25", decimals = 18u),
recipient = "0x...destination"
)
```
### Balances
```kotlin theme={null}
val ethBalance = ethereum.getBalance("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
val baseBalance = base.getBalance("0x...token-on-base")
println("Ethereum shielded: ${ethBalance.shieldedBalance}")
println("Base shielded: ${baseBalance.shieldedBalance}")
```
### Status Polling and History
```kotlin theme={null}
val status = ethereum.getShieldStatus(result.requestId)
val history = base.getTransactionHistory(txType = null, limit = 20u, cursor = null)
val notes = ethereum.getUnspentNotes(tokenAddress = null)
// Token and direction filters, and collapsed gateway rows, go through the full query.
val incoming = base.queryTransactionHistory(TransactionHistoryQuery(
direction = TransactionDirection.INCOMING,
limit = 20u,
collapseGateway = true
))
```
## What the Handle Carries
A handle covers the operations whose state is per chain: `shield`, `prepareShield`, `unshield`, `send` and `sendBatch`, `unwrapWeth`, `consolidateNotes`, balances and the token catalog, fees, status polling, history and unspent notes, pending-transaction tracking (including `waitForPreconfirmation` and `waitForFinality`), and the claimable-transfer (gift) methods.
It does not carry portal deposits, earn and swap, audit, or the private treasury (contacts and labels). Those stay on the parent `PrivacyBoost`, as do `resolveIdentity`, `parseAmount`, `formatAmount`, address validation, `setChainReader`, and session export. The parent's pending transactions and token registry reflect its own chain only, so read each chain's pending transactions and balances through that chain's handle rather than the parent.
The handle has no vault of its own: it shares the parent's resident keys, so `lock()` and the auto-lock timers on the parent apply to it as well. Its JWT is separate, which is why `clearSession()` on the parent does not reach it.
## Cross-Chain Patterns
### Parallel Operations
Chain contexts are independent, so fan out with coroutines:
```kotlin theme={null}
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
suspend fun shieldBoth(): Pair = coroutineScope {
val ethTask = async { ethereum.shield(ethToken, ethAmount) }
val baseTask = async { base.shield(baseToken, baseAmount) }
Pair(ethTask.await(), baseTask.await())
}
```
### Aggregating Balances
```kotlin theme={null}
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.math.BigInteger
data class ChainEntry(val ctx: ChainContextHandle, val token: String)
suspend fun totalShielded(chains: List): BigInteger = coroutineScope {
chains
.map { entry -> async { entry.ctx.getBalance(entry.token).shieldedBalance } }
.awaitAll()
.map { BigInteger(it) }
.fold(BigInteger.ZERO, BigInteger::add)
}
val total = totalShielded(listOf(
ChainEntry(ethereum, "0x..."),
ChainEntry(base, "0x...")
))
```
### Chain Selection
```kotlin theme={null}
val chains = mapOf(
"ethereum" to ethereum,
"base" to base
)
fun chain(name: String): ChainContextHandle =
chains[name] ?: throw IllegalArgumentException("Unknown chain: $name")
chain("ethereum").shield(
tokenAddress = "0x...",
amount = sdk.parseAmount("1.0", decimals = 18u)
)
```
## Cleanup
Each chain context can drop its JWT independently while the parent SDK identity stays intact:
```kotlin theme={null}
ethereum.clearSession() // drop just Ethereum's JWT
sdk.clearSession() // drop the parent JWT (does not clear contexts)
sdk.lock() // wipe the shared keys; contexts keep their JWT until cleared
sdk.logout() // end the whole session and clear the shared identity
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [Multi-Chain Concepts](/sdk/concepts/multi-chain)
# Portal deposits
Source: https://docs.privacyboost.io/sdk/android/guides/portal-deposits
# Portal Deposit Addresses
Give a user a reusable, public, exchange-style deposit address. Anyone can fund
it with standard ERC-20 transfers, and the funds are credited into the owner's
shielded balance without revealing which account was credited. See
[Portal Deposit Addresses](/sdk/concepts/portal-deposits) for the concept and
trust model.
Android exposes the supported portal lifecycle as methods on your `PrivacyBoost`
instance. `createPortal` derives the portal EOA from the recovered nullifying key and keeps
the key inside core.
## Lifecycle at a glance
1. **Check support** — `sdk.portalDelegateAddress()`.
2. **Create** — `sdk.createPortal(null)` derives `E`, gaslessly delegates and
registers it, then publishes discovery.
3. **Share** — senders fund the returned `portal.address` with ordinary ERC-20.
4. **Sweep / credit** — a keeper normally runs this, or you can call
`sdk.sweepPortal(...)` as a backstop.
5. **Recover** — use `sdk.reclaimPortalDeposit(...)` for uncredited swept
deposits, then `sdk.withdrawPortal(...)` if raw funds are resting at `E`.
## Check support
```kotlin theme={null}
val delegate = sdk.portalDelegateAddress()
?: throw IllegalStateException("This server does not support portal deposits.")
```
## Create a portal
```kotlin theme={null}
val portal = withContext(Dispatchers.IO) {
sdk.createPortal(index = null) // or index = 0u
}
println(portal.address) // share this public deposit address
println(portal.index) // seed-derivation index for recovery actions
println(portal.bindH) // public on-chain binding
```
`createPortal` is resumable. If the same seed-derived portal is already created,
the method returns it as a no-op. Passing an explicit index recreates that
deterministic portal; passing `null` picks the next free index.
## List portals and deposits
Each response contains `items` and an optional `nextCursor`. Pass the cursor
back unchanged to fetch the next page, stopping when it is `null`.
```kotlin theme={null}
val portals = withContext(Dispatchers.IO) { sdk.listPortalsPage(cursor = null, limit = 50u) }
val deposits = withContext(Dispatchers.IO) {
sdk.listPortalDepositsPage(portal = portal.address, cursor = null, limit = 50u)
}
val status = withContext(Dispatchers.IO) { sdk.getPortalStatus(portal.address) }
println(portals.items.size)
```
## Sweep and reclaim
Sweeping is permissionless, and deployments normally run a keeper. If you need
to trigger it yourself:
```kotlin theme={null}
val txHash = withContext(Dispatchers.IO) {
sdk.sweepPortal(portal = portal.address, tokenId = tokenId)
}
```
If a swept deposit remains uncredited past the cancel delay:
```kotlin theme={null}
val txHash = withContext(Dispatchers.IO) {
sdk.reclaimPortalDeposit(depositId = portalDepositId)
}
```
`reclaimPortalDeposit` returns funds to the portal address `E`, not to the caller.
To move raw funds from `E`, build a signed transaction and broadcast it yourself:
```kotlin theme={null}
val rawTx = sdk.withdrawPortal(
index = portal.index,
token = tokenAddress,
to = destination,
amount = amountWei,
nonce = nonce,
gasLimit = gasLimit,
maxFeePerGas = maxFeePerGasWei,
maxPriorityFeePerGas = maxPriorityFeePerGasWei
)
```
`E` must hold native gas for the withdrawal transaction.
## Low-level escape hatches
The package still exports two low-level helpers for custodial integrations that
manage their own portal EOA key:
```kotlin theme={null}
val eoa = generatePortalEoa() // privateKey is SECRET
val calldata = encodePortalWithdraw(
token = tokenAddress,
to = destination,
amount = amountWei
)
```
The seed-derived `createPortal` flow does not use or expose `eoa.privateKey`.
## Types
```kotlin theme={null}
data class Portal(
val address: String,
val index: UInt,
val bindH: String
)
data class PortalInfo(
val address: String,
val index: UInt?,
val status: PortalStatus
)
data class PortalStatus(
val registered: Boolean,
val delegated: Boolean,
val published: Boolean
)
data class PortalDeposit(
val portalDepositId: String,
val portal: String,
val tokenId: UShort,
val grossAmount: String,
val netAmount: String,
val state: PortalDepositState,
val requestBlock: ULong,
val cancellableAtBlock: ULong
)
```
# Session storage
Source: https://docs.privacyboost.io/sdk/android/guides/session-storage
# Session Storage
This guide covers securely storing and restoring Privacy Boost sessions using Android Keystore, expanding on the persistence options in [Key Management](/sdk/concepts/key-management#persistence--protection).
## Why Keystore?
Session data includes sensitive cryptographic keys. Android Keystore provides:
* Hardware-backed encryption (TEE/Secure Element)
* Key material never leaves secure hardware
* Biometric authentication support
* Protection against extraction
There are two ways to use it. The SDK can own the vault in Keystore-backed storage itself through a `KeychainDelegate`, which is the path described next, or the app can export the session and store it with its own code, which the rest of this guide covers.
## SDK-Managed Persistence
Select `persistenceStorage = StorageBackend.OS_KEYCHAIN` and an unlock method, and register a `KeychainDelegate` before constructing the SDK. `PrivacyBoostDefaults.create(context, config)` registers `DefaultKeystoreDelegate(context)` for you unless the app already registered its own, then constructs the SDK:
```kotlin theme={null}
import com.privacyboost.defaults.PrivacyBoostDefaults
import com.privacyboost.sdk.*
val config = PrivacyBoostConfig(
serverUrl = "https://indexer.example.com",
chainId = null,
shieldContractAddress = null,
wethContractAddress = null,
teePublicKey = null,
appId = "my-app",
persistenceStorage = StorageBackend.OS_KEYCHAIN,
persistenceUnlock = UnlockMethod.PIN
)
val sdk = PrivacyBoostDefaults.create(applicationContext, config)
```
`DefaultKeystoreDelegate` stores entries in `EncryptedSharedPreferences` (`privacyboost_vault` unless you pass another `preferenceName`) under an Android Keystore master key. To use your own implementation instead, implement `KeychainDelegate` and register it yourself:
```kotlin theme={null}
registerKeychainDelegate(MyKeystoreDelegate(applicationContext))
val sdk = PrivacyBoost(config)
```
`registerKeychainDelegate` takes an optional `service` that namespaces the vault items. Omit it and the SDK uses its own name; pass your own when two SDK instances share the process, so their items cannot collide.
The registration is process-wide: do it once, before the first `PrivacyBoost` whose config selects `OS_KEYCHAIN`, and note that a later call replaces the delegate for every instance. Construction throws `SDKError.ConfigError` naming `register_keychain_delegate` when no delegate is registered; `hasKeychainDelegate()` tells you whether one is. `LOCAL_STORAGE` and `INDEXED_DB` are browser backends and are rejected on Android, as is `PASSKEY` unlock.
### Unlock methods
| `persistenceUnlock` | On the next launch |
| - | - |
| `UnlockMethod.PIN`, `UnlockMethod.PASSWORD` | `authenticate` returns `AuthResult.CredentialRequired`; collect the credential and call `submitCredential`. |
| `UnlockMethod.BIOMETRIC` | The vault is written to a second store whose Keystore key requires a class-3 biometric (`BIOMETRIC_STRONG`) and is invalidated by new enrollment. There is no credential challenge: `authenticate` asks the wallet for one signature to derive the vault key and returns `Authenticated`. |
| `UnlockMethod.NONE` | One wallet signature, then `Authenticated`. |
`DefaultKeystoreDelegate` does not show a biometric prompt itself. With `BIOMETRIC`, complete your own class-3 `BiometricPrompt` immediately before `authenticate`, on the first launch as well as later ones: the gated store stays reachable for `authValiditySeconds` (30 by default) after a successful prompt, and outside that window the delegate throws rather than returning nothing.
### Unlocking on the next launch
With `PIN` or `PASSWORD`, the first launch returns `CredentialRequired` with `challenge.action == CredentialAction.SETUP`, where the user chooses the credential, and every later launch returns it with `CredentialAction.UNLOCK`. `submitCredential` completes the login either way and can be retried after a wrong credential without calling `authenticate` again:
```kotlin theme={null}
suspend fun signIn(): LoginResult {
val result = withContext(Dispatchers.IO) {
sdk.authenticate(
wallet = walletDelegate,
keySource = KeySource.WalletDerived, // ignored once a vault exists
tokenProvider = null
)
}
return when (result) {
is AuthResult.Authenticated -> result.loginResult
is AuthResult.CredentialRequired -> {
val pin = promptForPin(isNew = result.challenge.action == CredentialAction.SETUP)
withContext(Dispatchers.IO) { sdk.submitCredential(pin) }
}
else -> {
// MnemonicGenerated, RecoveryRequired, AccountCreationRequired:
// see Getting Started.
throw IllegalStateException("unhandled auth result: $result")
}
}
}
```
The vault survives `logout()` and `lock()`; call `deleteVault()` to erase it, for example when the user removes the account from the device. [Auto-lock](#auto-lock) below wipes the decrypted keys on a timer and sends the user through this same unlock on the next key access.
## EncryptedSharedPreferences
The simplest approach using AndroidX Security:
### Setup
Add dependency:
```kotlin theme={null}
dependencies {
implementation("androidx.security:security-crypto:1.1.0-alpha06")
}
```
### Implementation
```kotlin theme={null}
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.google.gson.Gson
class SessionStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val sharedPrefs = EncryptedSharedPreferences.create(
context,
"privacy_boost_session",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private val gson = Gson()
fun saveSession(session: ExportedSession) {
val json = gson.toJson(session)
sharedPrefs.edit()
.putString(KEY_SESSION, json)
.apply()
}
fun loadSession(): ExportedSession? {
val json = sharedPrefs.getString(KEY_SESSION, null) ?: return null
return try {
gson.fromJson(json, ExportedSession::class.java)
} catch (e: Exception) {
null
}
}
fun deleteSession() {
sharedPrefs.edit()
.remove(KEY_SESSION)
.apply()
}
fun hasSession(): Boolean {
return sharedPrefs.contains(KEY_SESSION)
}
companion object {
private const val KEY_SESSION = "session_data"
}
}
```
## Biometric Authentication
Add biometric protection for session access:
### Setup
Add dependencies:
```kotlin theme={null}
dependencies {
implementation("androidx.biometric:biometric:1.1.0")
}
```
### BiometricSessionStorage
```kotlin theme={null}
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class BiometricSessionStorage(private val context: Context) {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private val gson = Gson()
// MARK: - Biometric Availability
fun isBiometricAvailable(): Boolean {
val biometricManager = BiometricManager.from(context)
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
}
// MARK: - Save with Biometrics
fun saveSession(
activity: FragmentActivity,
session: ExportedSession,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
try {
val cipher = result.cryptoObject?.cipher
?: throw Exception("No cipher available")
val json = gson.toJson(session)
val encrypted = cipher.doFinal(json.toByteArray())
val iv = cipher.iv
// Store encrypted data and IV
getPrefs().edit()
.putString(KEY_ENCRYPTED_SESSION, encrypted.toBase64())
.putString(KEY_IV, iv.toBase64())
.apply()
onSuccess()
} catch (e: Exception) {
onError(e.message ?: "Encryption failed")
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
override fun onAuthenticationFailed() {
// Called when biometric is valid but not recognized
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Save Session")
.setSubtitle("Authenticate to save your Privacy Boost session")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
// Get cipher for encryption
val cipher = getCipherForEncryption()
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
// MARK: - Load with Biometrics
fun loadSession(
activity: FragmentActivity,
onSuccess: (ExportedSession) -> Unit,
onError: (String) -> Unit
) {
val prefs = getPrefs()
val encryptedBase64 = prefs.getString(KEY_ENCRYPTED_SESSION, null)
val ivBase64 = prefs.getString(KEY_IV, null)
if (encryptedBase64 == null || ivBase64 == null) {
onError("No session found")
return
}
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
try {
val cipher = result.cryptoObject?.cipher
?: throw Exception("No cipher available")
val encrypted = encryptedBase64.fromBase64()
val decrypted = cipher.doFinal(encrypted)
val json = String(decrypted)
val session = gson.fromJson(json, ExportedSession::class.java)
onSuccess(session)
} catch (e: Exception) {
onError(e.message ?: "Decryption failed")
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Access Session")
.setSubtitle("Authenticate to access your Privacy Boost session")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
// Get cipher for decryption
val iv = ivBase64.fromBase64()
val cipher = getCipherForDecryption(iv)
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
// MARK: - Key Management
private fun getOrCreateKey(): SecretKey {
keyStore.getKey(KEY_ALIAS, null)?.let {
return it as SecretKey
}
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val keySpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(
0, // Timeout (0 = every use)
KeyProperties.AUTH_BIOMETRIC_STRONG
)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGenerator.init(keySpec)
return keyGenerator.generateKey()
}
private fun getCipherForEncryption(): Cipher {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
return cipher
}
private fun getCipherForDecryption(iv: ByteArray): Cipher {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
return cipher
}
private fun getPrefs() = context.getSharedPreferences(
"privacy_boost_biometric",
Context.MODE_PRIVATE
)
fun deleteSession() {
getPrefs().edit().clear().apply()
keyStore.deleteEntry(KEY_ALIAS)
}
fun hasSession(): Boolean {
return getPrefs().contains(KEY_ENCRYPTED_SESSION)
}
companion object {
private const val KEY_ALIAS = "privacy_boost_session_key"
private const val KEY_ENCRYPTED_SESSION = "encrypted_session"
private const val KEY_IV = "iv"
}
}
// Extension functions
private fun ByteArray.toBase64(): String =
android.util.Base64.encodeToString(this, android.util.Base64.NO_WRAP)
private fun String.fromBase64(): ByteArray =
android.util.Base64.decode(this, android.util.Base64.NO_WRAP)
```
## Usage in Repository
```kotlin theme={null}
class PrivacyBoostRepository(
private val sdk: PrivacyBoost,
private val sessionStorage: SessionStorage,
private val biometricStorage: BiometricSessionStorage
) {
suspend fun restoreSession(): Boolean = withContext(Dispatchers.IO) {
// Try regular storage first
sessionStorage.loadSession()?.let { session ->
return@withContext try {
sdk.importSession(session)
} catch (e: Exception) {
sessionStorage.deleteSession()
false
}
}
false
}
fun restoreSessionWithBiometrics(
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
if (!biometricStorage.isBiometricAvailable()) {
onError("Biometrics not available")
return
}
biometricStorage.loadSession(
activity = activity,
onSuccess = { session ->
try {
val success = sdk.importSession(session)
if (success) onSuccess() else onError("Session expired")
} catch (e: Exception) {
onError(e.message ?: "Import failed")
}
},
onError = onError
)
}
fun saveCurrentSession(useBiometrics: Boolean = false, activity: FragmentActivity? = null) {
val session = sdk.exportSession() ?: return
if (useBiometrics && activity != null && biometricStorage.isBiometricAvailable()) {
biometricStorage.saveSession(
activity = activity,
session = session,
onSuccess = { /* Saved */ },
onError = { /* Fall back to regular storage */
sessionStorage.saveSession(session)
}
)
} else {
sessionStorage.saveSession(session)
}
}
fun clearSession() {
sessionStorage.deleteSession()
biometricStorage.deleteSession()
}
}
```
## ViewModel Integration
```kotlin theme={null}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: PrivacyBoostRepository
) : ViewModel() {
private val _sessionRestored = MutableStateFlow(false)
val sessionRestored: StateFlow = _sessionRestored
fun tryRestoreSession() {
viewModelScope.launch {
_sessionRestored.value = repository.restoreSession()
}
}
fun restoreWithBiometrics(activity: FragmentActivity) {
repository.restoreSessionWithBiometrics(
activity = activity,
onSuccess = { _sessionRestored.value = true },
onError = { /* Show error */ }
)
}
fun saveSession(useBiometrics: Boolean, activity: FragmentActivity?) {
repository.saveCurrentSession(useBiometrics, activity)
}
}
```
## Auto-lock
Persisting a session keeps the encrypted vault on disk; auto-lock bounds how long the decrypted keys stay in memory. Two optional `PrivacyBoostConfig` fields drive it, both in seconds:
* `idleTtlSecs`: how long the keys may go unused before the SDK wipes them.
* `absoluteTtlSecs`: how long after an unlock the SDK wipes them, regardless of activity.
`null` or `0u` disables the respective timer, and both default to `null`. The timers are checked lazily, on the next key access: once one has elapsed, that access zeroizes the resident keys and throws `SDKError.NotAuthenticated`. They bound key use rather than key residency, so a screen that needs the keys gone at a fixed time should call `lock()` from its own timer.
```kotlin theme={null}
val config = PrivacyBoostConfig(
serverUrl = "https://indexer.example.com",
chainId = null,
shieldContractAddress = null,
wethContractAddress = null,
teePublicKey = null,
appId = "my-app",
persistenceStorage = StorageBackend.OS_KEYCHAIN,
persistenceUnlock = UnlockMethod.BIOMETRIC,
idleTtlSecs = 300u, // wipe keys after 5 minutes without use
absoluteTtlSecs = 43_200u // and 12 hours after unlock, no matter what
)
```
`lock()` does the same on demand. It zeroizes the key material and drops the session JWT but keeps the persisted vault, which makes it the right call when the app moves to the background:
```kotlin theme={null}
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
ProcessLifecycleOwner.get().lifecycle.addObserver(
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) sdk.lock()
}
)
```
To unlock, call `authenticate` again. With a persisted vault the SDK reopens it instead of re-deriving keys: PIN and password vaults return `CredentialRequired`, and the other unlock methods complete inside `authenticate`. Compare `clearSession()`, which drops the JWT but leaves the keys in memory for a quick re-auth, and `logout()`, which ends the session entirely.
Chain-context handles created with `createChainContext` share the resident keys, so auto-lock and `lock()` apply to them too. A lock also ends each handle's own session, so call `authenticate` on the handle again after unlocking. `clearSession()` on a handle drops its session without locking the account.
## Security Best Practices
1. **Use BIOMETRIC\_STRONG** - Requires Class 3 biometrics
2. **Set invalidatedByBiometricEnrollment** - Invalidate key if new biometric enrolled
3. **No timeout** - Require auth every time for sensitive data
4. **Handle key invalidation** - Clear session if key is invalidated
5. **Don't store unencrypted** - Always encrypt sensitive data
## Next Steps
* [API Reference](../api-reference) - Complete API documentation
* [Android Getting Started](../getting-started) - Jetpack Compose integration
# Transactions
Source: https://docs.privacyboost.io/sdk/android/guides/transactions
# Transaction History
This guide covers querying and filtering transaction history in the Privacy Boost Android SDK.
## Basic Usage
```kotlin theme={null}
val transactions = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(
txType = null,
tokenAddress = null,
limit = null
)
}
for (tx in transactions) {
println("${tx.txType}: ${tx.amount} - ${tx.txHash}")
}
```
## Transaction Type
```kotlin theme={null}
data class Transaction(
val txHash: String, // Transaction hash
val txType: String, // "deposit", "withdraw", or "transfer"
val tokenAddress: String, // Token contract address
val amount: String, // Amount in wei
val direction: String, // "incoming" or "outgoing"
val senderPubKey: String, // Sender's public key
val receiverPubKeys: List, // Receiver public keys
val createdAt: ULong // Unix timestamp
)
```
## Filtering History
### By Transaction Type
```kotlin theme={null}
// Only deposits
val deposits = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(txType = "deposit", tokenAddress = null, limit = null)
}
// Only withdrawals
val withdrawals = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(txType = "withdraw", tokenAddress = null, limit = null)
}
// Only transfers
val transfers = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(txType = "transfer", tokenAddress = null, limit = null)
}
```
### By Token
```kotlin theme={null}
val usdcHistory = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(
txType = null,
tokenAddress = "0x...usdc-address",
limit = null
)
}
```
### With Limit
```kotlin theme={null}
// Get last 10 transactions
val recent = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(txType = null, tokenAddress = null, limit = 10u)
}
```
### Combined Filters
```kotlin theme={null}
// Last 5 USDC deposits
val recentUsdcDeposits = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(
txType = "deposit",
tokenAddress = "0x...usdc-address",
limit = 5u
)
}
```
### By Token ID, Direction, or Collapsed Gateway Rows
For the filters `getTransactionHistory` does not take, pass a `TransactionHistoryQuery` to `queryTransactionHistory`. It accepts the same type filter and page size plus `tokenId` (the registered token id, from `getRegisteredTokens()`), `direction` (`TransactionDirection.INCOMING` or `OUTGOING` relative to the wallet), a `cursor` for the next page, and `collapseGateway`, which folds each Gateway operation's two rows into one so an activity list can show a swap as a single entry. `null` fields are unfiltered, and every field is defaulted, so name only the ones you want.
```kotlin theme={null}
// Incoming USDC only, 25 per page, gateway operations collapsed
val page = withContext(Dispatchers.IO) {
sdk.queryTransactionHistory(TransactionHistoryQuery(
tokenId = 1u,
direction = TransactionDirection.INCOMING,
limit = 25u,
collapseGateway = true
))
}
for (tx in page.data) {
println("${tx.txType}: ${tx.txHash}")
}
// page.next is the cursor for the following page; null at the end
```
`ChainContextHandle` exposes the same method for other chains.
## Filter Parameters
| Parameter | Type | Description |
| - | - | - |
| `txType` | `String?` | Filter by type: `"deposit"`, `"withdraw"`, `"transfer"` |
| `tokenAddress` | `String?` | Filter by token contract address |
| `limit` | `UInt?` | Maximum number of results |
`TransactionHistoryQuery` adds:
| Field | Type | Description |
| - | - | - |
| `tokenId` | `UShort?` | Filter by registered token id |
| `direction` | `TransactionDirection?` | `INCOMING` or `OUTGOING` |
| `cursor` | `String?` | Continuation token from the previous page's `next` |
| `collapseGateway` | `Boolean` | Fold each Gateway operation's two rows into one (defaults to `false`) |
## ViewModel Integration
```kotlin theme={null}
class TransactionViewModel(private val sdk: PrivacyBoost) : ViewModel() {
private val _transactions = MutableStateFlow>(emptyList())
val transactions: StateFlow> = _transactions.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow = _isLoading.asStateFlow()
private val _filter = MutableStateFlow("all")
val filter: StateFlow = _filter.asStateFlow()
fun setFilter(type: String) {
_filter.value = type
loadTransactions()
}
fun loadTransactions() {
viewModelScope.launch {
_isLoading.value = true
try {
val typeFilter = if (_filter.value == "all") null else _filter.value
val result = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(
txType = typeFilter,
tokenAddress = null,
limit = 50u
)
}
_transactions.value = result
} catch (e: SDKError) {
println("Failed to load transactions: $e")
}
_isLoading.value = false
}
}
}
```
## Displaying Transactions in Compose
```kotlin theme={null}
@Composable
fun TransactionHistory(viewModel: TransactionViewModel) {
val transactions by viewModel.transactions.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val filter by viewModel.filter.collectAsState()
val filters = listOf("all", "deposit", "withdraw", "transfer")
LaunchedEffect(Unit) {
viewModel.loadTransactions()
}
Column {
// Filter tabs
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
filters.forEach { type ->
FilterChip(
selected = filter == type,
onClick = { viewModel.setFilter(type) },
label = { Text(type.replaceFirstChar { it.uppercase() }) }
)
}
}
// Transaction list
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
} else if (transactions.isEmpty()) {
Text("No transactions found", color = Color.Gray)
} else {
LazyColumn {
items(transactions) { tx ->
TransactionRow(transaction = tx)
}
}
}
}
}
@Composable
fun TransactionRow(transaction: Transaction) {
val icon = when (transaction.txType) {
"deposit" -> Icons.Default.ArrowDownward
"withdraw" -> Icons.Default.ArrowUpward
"transfer" -> Icons.Default.SwapHoriz
else -> Icons.Default.Circle
}
val dateFormatter = SimpleDateFormat("MMM d, yyyy HH:mm", Locale.getDefault())
val formattedDate = dateFormatter.format(Date(transaction.createdAt.toLong() * 1000))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = icon, contentDescription = transaction.txType)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = transaction.txType.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleSmall
)
Text(
text = formattedDate,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
Text(
text = transaction.amount,
style = MaterialTheme.typography.bodyMedium
)
}
}
```
## Error Handling
```kotlin theme={null}
try {
val transactions = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(txType = null, tokenAddress = null, limit = null)
}
} catch (e: SDKError) {
when (e) {
is SDKError.NotAuthenticated ->
println("Please log in first")
is SDKError.NetworkError ->
println("Network error: ${e.message}")
else ->
println("Failed to get history: $e")
}
}
```
## Best Practices
### 1. Paginate Results
For apps with many transactions, use the `limit` parameter:
```kotlin theme={null}
suspend fun loadPage(pageSize: UInt = 20u): List {
return withContext(Dispatchers.IO) {
sdk.getTransactionHistory(txType = null, tokenAddress = null, limit = pageSize)
}
}
```
### 2. Refresh After Operations
Fetch updated history after deposits, withdrawals, or transfers:
```kotlin theme={null}
val shieldResult = withContext(Dispatchers.IO) {
sdk.shield(tokenAddress = tokenAddress, amount = amount)
}
// Refresh transaction list
val updatedHistory = withContext(Dispatchers.IO) {
sdk.getTransactionHistory(txType = null, tokenAddress = null, limit = 20u)
}
```
## Next Steps
* [Balances Guide](./balances)
* [Deposits Guide](./deposits)
* [Error Handling](./error-handling)
* [API Reference](/sdk/android/api-reference)
# Transfers
Source: https://docs.privacyboost.io/sdk/android/guides/transfers
# Private Transfers
This guide covers sending tokens privately between privacy addresses.
## Basic Transfer
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.send(
tokenAddress = "0x...token-address",
amount = "1000000000000000000", // 1 token
recipientPrivacyAddress = "0x04..." // 194-char privacy address
)
}
println("Transaction hash: ${result.txHash}")
} catch (e: SDKError) {
println("Transfer failed: $e")
}
```
## Transfer Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | Token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
| `recipientPrivacyAddress` | `String` | Yes | Recipient's 194-character privacy address |
## Transfer Result
```kotlin theme={null}
data class TransferResult(
val txHash: String, // Transaction hash
val fee: String // Fee paid (in wei)
)
```
## Paying Several Recipients at Once
`sendBatch` pays every recipient from one set of input notes, in a single
request: one fee instead of one per recipient, and one epoch slot instead of
several. Output order follows the list, and the same recipient may appear more
than once.
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.sendBatch(
tokenAddress = "0x...token-address",
recipients = listOf(
TransferRecipient("0x04...alice", "400000000000000000"),
TransferRecipient("0x04...bob", "500000000000000000"),
)
)
}
println("Transaction hash: ${result.txHash}")
} catch (e: SDKError) {
println("Batch transfer failed: $e")
}
```
### How many recipients fit
The server proves a fixed set of `(inputs, outputs)` shapes, and a transfer has
to fit inside one of them. Its output count is your recipient count plus a
change note, so a longer recipient list leaves room for fewer input notes:
| Outputs (recipients + change) | Input notes that can be spent |
| - | - |
| 1-2 | up to 15 |
| 3-11 | up to 4 |
So 5 inputs by 3 outputs is not provable, even though 15 inputs and 11 outputs
are each individually fine. In practice: past two recipients, the transfer has
to be coverable by at most four notes. Consolidate fragmented notes first if a
batch is rejected with `UNSUPPORTED_TRANSFER_SHAPE`, or split the payout across
two batches.
## Privacy Address Validation
Always validate privacy addresses before sending:
```kotlin theme={null}
val recipientAddress = "0x04..."
require(sdk.isValidPrivacyAddress(recipientAddress)) { "Invalid privacy address" }
val result = withContext(Dispatchers.IO) {
sdk.send(
tokenAddress = "0x...",
amount = "1000000000000000000",
recipientPrivacyAddress = recipientAddress
)
}
```
## Looking Up Privacy Addresses
If you have a user's MPK or Ethereum address, you can look up their privacy address:
```kotlin theme={null}
val identity = withContext(Dispatchers.IO) {
sdk.resolveIdentity(identifier = "0x...ethereum-address")
}
println("Privacy address: ${identity.privacyAddress}")
// Now send to their privacy address
val result = withContext(Dispatchers.IO) {
sdk.send(
tokenAddress = "0x...",
amount = "1000000000000000000",
recipientPrivacyAddress = identity.privacyAddress
)
}
```
## Checking Balance Before Transfer
```kotlin theme={null}
suspend fun safeTransfer(
tokenAddress: String,
amount: String,
recipient: String
): TransferResult {
val balance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
val shielded = balance.shieldedBalance.toBigInteger()
val requested = amount.toBigInteger()
require(requested <= shielded) {
"Insufficient balance. Have: ${balance.shieldedBalance}, Need: $amount"
}
return withContext(Dispatchers.IO) {
sdk.send(
tokenAddress = tokenAddress,
amount = amount,
recipientPrivacyAddress = recipient
)
}
}
```
## Transfer Privacy
Private transfers provide:
* **Sender Privacy**: Your wallet address is not linked to the transaction
* **Recipient Privacy**: The recipient's wallet address is not revealed
* **Amount Privacy**: The transfer amount is encrypted
* **Relationship Privacy**: No public link between sender and recipient
Only the sender and recipient can see the transfer details.
## Error Handling
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.send(
tokenAddress = tokenAddress,
amount = amount,
recipientPrivacyAddress = recipient
)
}
} catch (e: SDKError) {
when (e) {
is SDKError.InsufficientBalance ->
println("Not enough shielded balance")
is SDKError.InvalidAddress ->
println("Invalid privacy address")
is SDKError.InvalidAmount ->
println("Invalid amount")
is SDKError.WalletError ->
println("Wallet error: ${e.message}")
is SDKError.NetworkError ->
println("Network error: ${e.message}")
is SDKError.TransferError ->
println("Transfer error [${e.code}]: ${e.message}")
else ->
println("Transfer error: $e")
}
}
```
## Best Practices
### 1. Validate All Inputs
```kotlin theme={null}
fun validateTransfer(
tokenAddress: String,
amount: String,
recipient: String
) {
require(sdk.isValidAddress(tokenAddress)) { "Invalid token address" }
require(sdk.isValidPrivacyAddress(recipient)) { "Invalid privacy address" }
val value = amount.toBigIntegerOrNull()
requireNotNull(value) { "Invalid amount format" }
require(value > BigInteger.ZERO) { "Amount must be positive" }
}
```
### 2. Confirm Large Transfers
```kotlin theme={null}
suspend fun transferWithConfirmation(
tokenAddress: String,
amount: String,
recipient: String,
onConfirm: suspend (String) -> Boolean
): TransferResult {
val formatted = sdk.formatAmount(amount, decimals = 18u)
val confirmed = onConfirm(
"Send $formatted tokens to ${recipient.take(10)}...?"
)
require(confirmed) { "Transfer cancelled by user" }
return withContext(Dispatchers.IO) {
sdk.send(
tokenAddress = tokenAddress,
amount = amount,
recipientPrivacyAddress = recipient
)
}
}
```
### 3. Handle Network Delays
```kotlin theme={null}
suspend fun transferWithRetry(
tokenAddress: String,
amount: String,
recipient: String,
maxRetries: Int = 3
): TransferResult {
for (attempt in 1..maxRetries) {
try {
return withContext(Dispatchers.IO) {
sdk.send(
tokenAddress = tokenAddress,
amount = amount,
recipientPrivacyAddress = recipient
)
}
} catch (e: SDKError.NetworkError) {
if (attempt == maxRetries) throw e
delay(2000L * attempt)
}
}
throw SDKError.NetworkError("Max retries exceeded")
}
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Wallet integration
Source: https://docs.privacyboost.io/sdk/android/guides/wallet-integration
# Wallet Integration
The `WalletDelegate` interface is the bridge between the Privacy Boost SDK and your wallet. This guide covers implementing it for various wallet types, expanding on the brief examples in [Authentication](/sdk/concepts/authentication).
## Interface Definition
```kotlin theme={null}
interface WalletDelegate {
/** Get the connected wallet address */
suspend fun getAddress(): String
/** Get the current chain ID */
suspend fun getChainId(): ULong
/** Sign a personal message (EIP-191) */
suspend fun signMessage(message: String): String
/** Sign EIP-712 typed data */
suspend fun signTypedData(typedDataJson: String): String
/** Send a transaction */
suspend fun sendTransaction(toAddress: String, value: String, data: String): String
/** Wait until a transaction is mined and return its receipt */
suspend fun waitForTransactionReceipt(txHash: String): TransactionReceipt
}
```
## Implementation Requirements
### Return Values
* **Addresses**: Checksummed hex strings with `0x` prefix (42 characters)
* **Signatures**: Hex strings with `0x` prefix (132 characters for 65 bytes)
* **Transaction hashes**: Hex strings with `0x` prefix (66 characters)
* **Transaction receipts**: Mined receipt status and logs from `eth_getTransactionReceipt`
* **Chain ID**: ULong matching expected network
### Suspend Behavior
All WalletDelegate methods are `suspend` functions. The SDK calls them within a coroutine context, so your implementation can use Kotlin coroutines natively.
The SDK calls `waitForTransactionReceipt` after `sendTransaction` to confirm on-chain inclusion and read emitted events.
## WalletConnect Integration
Example using WalletConnect Kotlin SDK:
```kotlin theme={null}
import com.walletconnect.web3.wallet.client.Web3Wallet
import kotlinx.coroutines.runBlocking
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class WalletConnectDelegate(
private val sessionTopic: String,
private val account: String
) : WalletDelegate {
override suspend fun getAddress(): String = account
override suspend fun getChainId(): ULong {
// Parse from account (eip155:1:0x...)
val parts = account.split(":")
return parts.getOrNull(1)?.toULongOrNull() ?: 1UL
}
override suspend fun signMessage(message: String): String {
val latch = CountDownLatch(1)
var result: String? = null
var error: Throwable? = null
val request = Web3Wallet.Request.PersonalSign(
sessionTopic = sessionTopic,
message = message,
address = getAddress()
)
Web3Wallet.request(request,
onSuccess = { signature ->
result = signature
latch.countDown()
},
onError = { e ->
error = e
latch.countDown()
}
)
if (!latch.await(60, TimeUnit.SECONDS)) {
throw WalletException("Signing timeout")
}
error?.let { throw it }
return result ?: throw WalletException("No signature returned")
}
override suspend fun signTypedData(typedDataJson: String): String {
val latch = CountDownLatch(1)
var result: String? = null
var error: Throwable? = null
val request = Web3Wallet.Request.SignTypedData(
sessionTopic = sessionTopic,
data = typedDataJson,
address = getAddress()
)
Web3Wallet.request(request,
onSuccess = { signature ->
result = signature
latch.countDown()
},
onError = { e ->
error = e
latch.countDown()
}
)
if (!latch.await(60, TimeUnit.SECONDS)) {
throw WalletException("Signing timeout")
}
error?.let { throw it }
return result ?: throw WalletException("No signature returned")
}
override suspend fun sendTransaction(toAddress: String, value: String, data: String): String {
val latch = CountDownLatch(1)
var result: String? = null
var error: Throwable? = null
val request = Web3Wallet.Request.SendTransaction(
sessionTopic = sessionTopic,
from = getAddress(),
to = toAddress,
value = value,
data = data
)
Web3Wallet.request(request,
onSuccess = { txHash ->
result = txHash
latch.countDown()
},
onError = { e ->
error = e
latch.countDown()
}
)
if (!latch.await(120, TimeUnit.SECONDS)) {
throw WalletException("Transaction timeout")
}
error?.let { throw it }
return result ?: throw WalletException("No transaction hash returned")
}
override suspend fun waitForTransactionReceipt(txHash: String): TransactionReceipt {
// Poll eth_getTransactionReceipt via your RPC client until it returns a receipt.
return pollReceipt(txHash)
}
}
class WalletException(message: String) : Exception(message)
```
## Web3j Integration
Example using Web3j with a local keystore:
```kotlin theme={null}
import org.web3j.crypto.Credentials
import org.web3j.crypto.Sign
import org.web3j.protocol.Web3j
import org.web3j.protocol.core.DefaultBlockParameterName
import org.web3j.protocol.http.HttpService
import org.web3j.tx.RawTransactionManager
import org.web3j.utils.Numeric
class Web3jDelegate(
private val credentials: Credentials,
private val web3j: Web3j
) : WalletDelegate {
override suspend fun getAddress(): String = credentials.address
override suspend fun getChainId(): ULong {
return web3j.ethChainId().send().chainId.toLong().toULong()
}
override suspend fun signMessage(message: String): String {
val messageBytes = message.toByteArray()
val prefix = "\u0019Ethereum Signed Message:\n${messageBytes.size}"
val prefixedMessage = (prefix + message).toByteArray()
val signature = Sign.signPrefixedMessage(prefixedMessage, credentials.ecKeyPair)
val r = Numeric.toHexStringNoPrefix(signature.r)
val s = Numeric.toHexStringNoPrefix(signature.s)
val v = Numeric.toHexStringNoPrefix(byteArrayOf(signature.v[0]))
return "0x$r$s$v"
}
override suspend fun signTypedData(typedDataJson: String): String {
// Parse and sign EIP-712 typed data
val typedData = parseTypedData(typedDataJson)
val structHash = hashTypedData(typedData)
val signature = Sign.signMessage(structHash, credentials.ecKeyPair, false)
val r = Numeric.toHexStringNoPrefix(signature.r)
val s = Numeric.toHexStringNoPrefix(signature.s)
val v = Numeric.toHexStringNoPrefix(byteArrayOf(signature.v[0]))
return "0x$r$s$v"
}
override suspend fun sendTransaction(toAddress: String, value: String, data: String): String {
val nonce = web3j.ethGetTransactionCount(
credentials.address,
DefaultBlockParameterName.LATEST
).send().transactionCount
val gasPrice = web3j.ethGasPrice().send().gasPrice
val gasLimit = estimateGas(toAddress, value, data)
val txManager = RawTransactionManager(web3j, credentials, getChainId().toLong())
val txHash = txManager.sendTransaction(
gasPrice,
gasLimit,
toAddress,
data,
value.toBigInteger()
).transactionHash
return txHash
}
override suspend fun waitForTransactionReceipt(txHash: String): TransactionReceipt {
// Poll eth_getTransactionReceipt via Web3j until it returns a receipt.
return pollReceipt(txHash)
}
private fun estimateGas(to: String, value: String, data: String): java.math.BigInteger {
val estimate = web3j.ethEstimateGas(
org.web3j.protocol.core.methods.request.Transaction.createFunctionCallTransaction(
credentials.address,
null,
null,
null,
to,
value.toBigInteger(),
data
)
).send()
return estimate.amountUsed.multiply(java.math.BigInteger.valueOf(120)).divide(java.math.BigInteger.valueOf(100))
}
private fun parseTypedData(json: String): TypedData {
// Implementation depends on your JSON parsing library
TODO("Parse EIP-712 typed data")
}
private fun hashTypedData(typedData: TypedData): ByteArray {
// Implementation of EIP-712 hashing
TODO("Hash typed data")
}
}
```
## Testing with Mock Wallet
For testing, create a mock delegate:
```kotlin theme={null}
class MockWalletDelegate(
private val mockAddress: String = "0x1234567890123456789012345678901234567890",
private val mockChainId: ULong = 1UL
) : WalletDelegate {
var signMessageHandler: ((String) -> String)? = null
var signTypedDataHandler: ((String) -> String)? = null
var sendTransactionHandler: ((String, String, String) -> String)? = null
override suspend fun getAddress(): String = mockAddress
override suspend fun getChainId(): ULong = mockChainId
override suspend fun signMessage(message: String): String {
return signMessageHandler?.invoke(message)
?: "0x" + "ab".repeat(65)
}
override suspend fun signTypedData(typedDataJson: String): String {
return signTypedDataHandler?.invoke(typedDataJson)
?: "0x" + "cd".repeat(65)
}
override suspend fun sendTransaction(toAddress: String, value: String, data: String): String {
return sendTransactionHandler?.invoke(toAddress, value, data)
?: "0x" + "ef".repeat(32)
}
override suspend fun waitForTransactionReceipt(txHash: String): TransactionReceipt {
return TransactionReceipt(txHash = txHash, status = true, logs = emptyList())
}
}
```
## Error Handling
Throw appropriate exceptions from delegate methods:
```kotlin theme={null}
sealed class WalletDelegateException(message: String) : Exception(message) {
class NotConnected : WalletDelegateException("Wallet not connected")
class UserRejected : WalletDelegateException("User rejected the request")
class NetworkError(message: String) : WalletDelegateException("Network error: $message")
class InvalidSignature : WalletDelegateException("Invalid signature returned")
class TransactionFailed(message: String) : WalletDelegateException("Transaction failed: $message")
class Timeout : WalletDelegateException("Operation timed out")
}
```
## Best Practices
1. **Validate inputs** - Check addresses and data formats before processing
2. **Handle timeouts** - Set reasonable timeouts for wallet operations
3. **User feedback** - Show loading indicators during signing/transactions
4. **Error messages** - Provide clear error messages for user-facing errors
5. **Thread safety** - Delegate methods are called from background threads
## Next Steps
* [Session Storage](./session-storage) - Secure session storage
# Withdrawals
Source: https://docs.privacyboost.io/sdk/android/guides/withdrawals
# Withdrawals
This guide covers withdrawing tokens from your private balance to any public Ethereum address.
## Basic Withdrawal
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.unshield(
tokenAddress = "0x...token-address",
amount = "1000000000000000000", // 1 token
recipient = "0x...recipient-address"
)
}
println("Transaction hash: ${result.txHash}")
} catch (e: SDKError) {
println("Withdrawal failed: $e")
}
```
## Withdrawal Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | Token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
| `recipient` | `String` | Yes | Destination Ethereum address |
## Withdrawal Steps
Withdrawals involve zero-knowledge proof generation, which can take up to 30 seconds:
| Step | Description |
| - | - |
| Preparing | Preparing withdrawal data |
| Signing | Signing transaction data |
| Proving | Generating zero-knowledge proof |
| Unshielding | Executing withdrawal transaction |
| Unwrapping | Unwrapping WETH to ETH (if applicable) |
## Withdrawal Result
```kotlin theme={null}
data class UnshieldResult(
val txHash: String, // Main withdrawal transaction hash
val fee: String // Fee paid (in wei)
)
```
## Withdraw to Self
Withdraw to your connected wallet address:
```kotlin theme={null}
val myAddress = sdk.getWalletAddress()!!
val result = withContext(Dispatchers.IO) {
sdk.unshield(
tokenAddress = "0x...",
amount = "1000000000000000000",
recipient = myAddress
)
}
```
## Withdraw to Any Address
You can withdraw to any valid Ethereum address:
```kotlin theme={null}
val recipientAddress = "0x..."
// Validate address first
require(sdk.isValidAddress(recipientAddress)) { "Invalid recipient address" }
val result = withContext(Dispatchers.IO) {
sdk.unshield(
tokenAddress = "0x...",
amount = "1000000000000000000",
recipient = recipientAddress
)
}
```
## Partial Withdrawals
Withdraw any amount up to your shielded balance:
```kotlin theme={null}
// Check available balance first
val balance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
val shielded = balance.shieldedBalance.toBigInteger()
val unshieldAmount = amount.toBigInteger()
require(unshieldAmount <= shielded) { "Insufficient shielded balance" }
val result = withContext(Dispatchers.IO) {
sdk.unshield(
tokenAddress = tokenAddress,
amount = amount,
recipient = recipientAddress
)
}
```
## Error Handling
```kotlin theme={null}
try {
val result = withContext(Dispatchers.IO) {
sdk.unshield(
tokenAddress = tokenAddress,
amount = amount,
recipient = recipient
)
}
} catch (e: SDKError) {
when (e) {
is SDKError.InsufficientBalance ->
println("Not enough shielded balance")
is SDKError.InvalidAddress ->
println("Invalid recipient address")
is SDKError.InvalidAmount ->
println("Invalid amount format")
is SDKError.WalletError ->
println("Wallet error: ${e.message}")
is SDKError.NetworkError ->
println("Network error: ${e.message}")
else ->
println("Withdrawal error: $e")
}
}
```
## Best Practices
### 1. Validate Balance Before Withdrawal
```kotlin theme={null}
suspend fun validateWithdrawal(tokenAddress: String, amount: String) {
val balance = withContext(Dispatchers.IO) {
sdk.getBalance(tokenAddress = tokenAddress)
}
val shielded = balance.shieldedBalance.toBigInteger()
val requested = amount.toBigInteger()
require(requested <= shielded) {
"Insufficient balance. Available: ${balance.shieldedBalance}, Requested: $amount"
}
}
```
### 2. Validate Recipient Address
```kotlin theme={null}
fun validateRecipient(address: String) {
require(sdk.isValidAddress(address)) { "Invalid Ethereum address" }
require(address != "0x0000000000000000000000000000000000000000") {
"Cannot withdraw to zero address"
}
}
```
### 3. Handle Long Operations
Withdrawals include zero-knowledge proof generation which can take up to 30 seconds. Show a loading indicator and inform users about the expected wait time.
```kotlin theme={null}
// In a ViewModel
viewModelScope.launch {
_state.value = WithdrawState.Loading("Generating proof... This may take up to 30 seconds.")
try {
val result = withContext(Dispatchers.IO) {
sdk.unshield(
tokenAddress = tokenAddress,
amount = amount,
recipient = recipient
)
}
_state.value = WithdrawState.Success(result.txHash)
} catch (e: SDKError) {
_state.value = WithdrawState.Error(e)
}
}
```
## Next Steps
* [Deposits Guide](./deposits)
* [Transfers Guide](./transfers)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Installation
Source: https://docs.privacyboost.io/sdk/android/installation
# Installation
This guide covers installing the Privacy Boost Android SDK in your project.
## Requirements
* Android API 21+ (Android 5.0 Lollipop)
* Kotlin 1.8+
* Android Studio Arctic Fox (2020.3.1) or later
## Gradle Setup
### Kotlin DSL (build.gradle.kts)
Add the dependency to your app's `build.gradle.kts`:
```kotlin theme={null}
dependencies {
implementation("io.privacy-boost:privacy-boost-android:1.0.0")
}
```
### Groovy DSL (build.gradle)
```groovy theme={null}
dependencies {
implementation 'io.privacy-boost:privacy-boost-android:1.0.0'
}
```
### Repository Configuration
If not using Maven Central, add the repository:
```kotlin theme={null}
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
// Or custom repository
maven {
url = uri("https://maven.privacy-boost.io/releases")
}
}
}
```
## ProGuard/R8 Rules
The SDK includes consumer ProGuard rules. If you need custom rules:
```proguard theme={null}
# Keep SDK classes
-keep class com.privacyboost.sdk.** { *; }
# Keep UniFFI generated code
-keep class uniffi.** { *; }
```
## Native Library Loading
The SDK includes native libraries for multiple architectures:
* `armeabi-v7a`
* `arm64-v8a`
* `x86`
* `x86_64`
To reduce APK size, use ABI splits:
```kotlin theme={null}
android {
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a", "x86_64")
isUniversalApk = false
}
}
}
```
Or use App Bundles (recommended):
```kotlin theme={null}
android {
bundle {
abi {
enableSplit = true
}
}
}
```
## Verifying Installation
Create a test to verify the SDK loads correctly:
```kotlin theme={null}
import com.privacyboost.sdk.*
class SDKTest {
@Test
fun testSDKInitialization() {
val config = PrivacyBoostConfig(
serverUrl = "https://optimism.sepolia.privacyboost.io",
wethContractAddress = "0x...",
appId = "app_abc123xyz"
)
val sdk = PrivacyBoost(config)
assertNotNull(sdk)
assertFalse(sdk.isConnected())
}
}
```
## Permissions
Add required permissions to `AndroidManifest.xml`:
```xml theme={null}
```
For Keystore access (optional):
```xml theme={null}
```
## Troubleshooting
### UnsatisfiedLinkError
If you see native library loading errors:
1. Ensure the correct architecture is included
2. Check that the AAR is properly unpacked
3. Try cleaning and rebuilding: `./gradlew clean build`
### Dependency Conflicts
If you have Kotlin coroutines version conflicts:
```kotlin theme={null}
configurations.all {
resolutionStrategy {
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
}
```
### Multidex
For API \< 21 or large apps, enable multidex:
```kotlin theme={null}
android {
defaultConfig {
multiDexEnabled = true
}
}
dependencies {
implementation("androidx.multidex:multidex:2.0.1")
}
```
## Next Steps
* [Getting Started](./getting-started) - Initialize and use the SDK
* [Wallet Integration](./guides/wallet-integration) - Implement wallet delegate
# Android SDK
Source: https://docs.privacyboost.io/sdk/android/overview
Native Kotlin SDK for privacy-preserving transactions on Android
# Android SDK
The Privacy Boost Android SDK is a native Kotlin library that wraps the Rust core via UniFFI. It exposes idiomatic Kotlin APIs (`suspend` functions, sealed classes, data classes) for shielding, unshielding, and private transfers.
## Features
* Native Kotlin library — no JavaScript bridge, no WebView
* `suspend` functions integrated with Kotlin coroutines
* `SdkException` sealed class — exhaustive `when` matching on error variants
* Optional default delegate implementations for Android Keystore and CredentialManager Passkey
* Wallet-agnostic — implement `WalletDelegate` once for any signer
* Identical core to the TypeScript / iOS / React Native SDKs (same proofs, same network protocol)
## Installation
Add the Maven artifact to your `build.gradle.kts`. See the [installation guide](./installation) for full details.
```kotlin theme={null}
dependencies {
implementation("io.sunnyside:privacy-boost-android:0.2.0")
}
```
Then import:
```kotlin theme={null}
import com.privacyboost.sdk.PrivacyBoost
import com.privacyboost.sdk.PrivacyBoostConfig
import com.privacyboost.defaults.* // optional delegate helpers
```
## Quick Example
```kotlin theme={null}
import com.privacyboost.sdk.*
val config = PrivacyBoostConfig(
serverUrl = "https://optimism.sepolia.privacyboost.io",
chainId = null, // discovered from server
shieldContractAddress = null, // discovered from server
wethContractAddress = "0x4200000000000000000000000000000000000006",
teePublicKey = null,
appId = "app_abc123xyz",
persistenceStorage = null,
persistenceUnlock = null
)
val sdk = PrivacyBoost(config)
// Authenticate with a WalletDelegate you implement
val result = sdk.authenticate(
wallet = myWalletDelegate,
keySource = KeySource.WalletDerived(),
tokenProvider = null
)
when (result) {
is AuthResult.Authenticated -> {
println("Privacy address: ${result.loginResult.privacyAddress}")
}
is AuthResult.CredentialRequired -> {
// Prompt user for PIN/biometric, then call sdk.submitCredential(...)
}
is AuthResult.MnemonicGenerated -> {
// First-time user — display mnemonic, then call sdk.proceedAfterMnemonic(...)
}
is AuthResult.RecoveryRequired -> {
// Vault is in a recovery state — see Authentication concepts
}
is AuthResult.AccountCreationRequired -> {
// Submit result.challenge.createAccountTransaction, wait for indexing,
// then retry authenticate().
}
}
// Shield 1 ETH-equivalent of WETH
val amount = sdk.parseAmount("1.0", decimals = 18u)
val shielded = sdk.shield(
tokenAddress = "0x4200000000000000000000000000000000000006",
amount = amount
)
println("Shield tx: ${shielded.txHash}")
```
## Core Operations
| Method | Purpose |
| - | - |
| `sdk.shield(tokenAddress, amount)` | Deposit tokens into the shielded pool |
| `sdk.unshield(tokenAddress, amount, recipient)` | Withdraw to a public address |
| `sdk.send(tokenAddress, amount, recipientPrivacyAddress)` | Private transfer |
| `sdk.getBalance(tokenAddress)` | Single-token shielded + wallet balance |
| `sdk.getAllBalances()` | All tracked balances |
| `sdk.getTransactionHistory(...)` | Paginated history |
| `sdk.resolveIdentity(identifier)` | Look up a privacy address |
All operations are `suspend` functions — call them from a coroutine scope (`lifecycleScope`, `viewModelScope`, etc.).
## Documentation
### Getting Started
* [Installation](./installation)
* [Getting Started](./getting-started)
### Guides
* [Deposits](./guides/deposits)
* [Withdrawals](./guides/withdrawals)
* [Transfers](./guides/transfers)
* [Balances](./guides/balances)
* [Transactions](./guides/transactions)
* [Multi-Chain](./guides/multi-chain)
* [Wallet Integration](./guides/wallet-integration)
* [Session Storage](./guides/session-storage)
* [Error Handling](./guides/error-handling)
### Advanced
* [Performance](./advanced/performance)
* [Error Handling Patterns](./advanced/error-handling)
### Reference
* [API Reference](./api-reference)
## Requirements
* Android API 26+ (Android 8.0 Oreo)
* API 34+ (Android 14) required for Passkey delegate helpers
* Kotlin 1.9+
* AndroidX
## Related
* [Multi-Chain Concepts](/sdk/concepts/multi-chain) — architecture overview
* [Authentication Concepts](/sdk/concepts/authentication) — auth flow details
* [Quickstart](/sdk/quickstart/android) — 5-minute integration
# Api reference
Source: https://docs.privacyboost.io/sdk/cli/api-reference
# API Reference
Complete API reference for the Privacy Boost CLI library (Rust).
## Generated Documentation
Full API documentation can be generated using `cargo doc`:
```bash theme={null}
# From rust-sdk directory
make docs-rust
# CLI-specific docs
cargo doc --no-deps -p privacy-boost-cli
```
***
## PrivacyBoostCLI
Main CLI SDK interface for Rust applications.
### Constructor
```rust theme={null}
pub fn new(config: CliConfig) -> Result
```
Creates a new CLI SDK instance.
**Parameters:**
* `config` - CLI configuration
**Returns:** `Result`
**Example:**
```rust theme={null}
use privacy_boost_cli::{PrivacyBoostCLI, CliConfig};
let config = CliConfig::new(
"https://optimism.sepolia.privacyboost.io".into(),
0,
String::new(),
"https://sepolia.optimism.io".into(),
);
let sdk = PrivacyBoostCLI::new(config)?;
```
### Connection & Authentication Methods
#### authenticate
```rust theme={null}
pub fn authenticate(
&self,
private_key: &str,
key_source: Option,
token_provider: Option<&dyn TokenProvider>,
) -> Result
```
Connect wallet using a private key, derive privacy keys, and authenticate with the Privacy Boost backend in a single call.
**Parameters:**
* `private_key` - Hex-encoded private key
* `key_source` - Optional key derivation source. `None` defaults to `WalletDerived`. Options: `KeySource::WalletDerived`, `KeySource::Mnemonic { phrase }`, `KeySource::RawSeed { hex_seed }`
* `token_provider` - Optional custom token provider for server-mediated auth flows
**Returns:** `Result` - either `Authenticated(LoginResult)` or `CredentialRequired(CredentialChallenge)`
#### submit\_credential
```rust theme={null}
pub fn submit_credential(&self, credential: &str) -> Result
```
Submit a credential when `authenticate()` returns `CredentialRequired`.
**Returns:** `Result`
#### logout
```rust theme={null}
pub fn logout(&self)
```
End session completely, clear all state.
#### clear\_session
```rust theme={null}
pub fn clear_session(&self)
```
Clear JWT only, keep keys for quick re-auth.
#### is\_authenticated
```rust theme={null}
pub fn is_authenticated(&self) -> bool
```
Check if authenticated.
### State Accessors
```rust theme={null}
pub fn get_privacy_address(&self) -> Option
pub fn get_mpk(&self) -> Option
pub fn get_wallet_address(&self) -> Option
pub fn get_status(&self) -> StatusInfo
```
### Balance Methods
#### get\_balance
```rust theme={null}
pub fn get_balance(&self, token_address: &str) -> Result
```
Get balance for a specific token.
#### get\_all\_balances
```rust theme={null}
pub fn get_all_balances(&self) -> Result, CliError>
```
Get all token balances.
### Vault Operations
#### deposit
```rust theme={null}
pub fn shield(
&self,
token_address: &str,
amount: &str,
) -> Result
```
Deposit tokens into the shielded pool.
**Parameters:**
* `token_address` - Token contract address
* `amount` - Amount in wei (as string)
#### withdraw
```rust theme={null}
pub fn unshield(
&self,
token_address: &str,
amount: &str,
recipient: &str
) -> Result
```
Withdraw tokens from the shielded pool.
**Parameters:**
* `token_address` - Token contract address
* `amount` - Amount in wei (as string)
* `recipient` - Recipient Ethereum address
#### send
```rust theme={null}
pub fn send(
&self,
token_address: &str,
amount: &str,
recipient_privacy_address: &str
) -> Result
```
Send a private transfer.
**Parameters:**
* `token_address` - Token contract address
* `amount` - Amount in wei (as string)
* `recipient_privacy_address` - Recipient's 194-char privacy address
#### send\_batch
```rust theme={null}
pub fn send_batch(
&self,
token_address: &str,
recipients: &[(String, String)]
) -> Result
```
Send one private transfer paying several recipients: one request, one fee, one
set of input notes.
**Parameters:**
* `token_address` - Token contract address
* `recipients` - `(recipient, amount)` pairs in output order, at least one. Amounts are wei strings
**Shape limits:** the transfer's output count is the recipient count plus a
change note, and the server only proves specific `(inputs, outputs)`
combinations, so a longer recipient list limits how many input notes can be
spent — past two recipients, at most four. Requests no enabled circuit can
serve fail with `UNSUPPORTED_TRANSFER_SHAPE` before submission.
#### prepare\_shield
```rust theme={null}
pub fn prepare_shield(
&self,
token_address: &str,
amount: &str,
recipient: Option<&str>,
) -> Result
```
Build a deposit payload **without submitting it** — for relaying the transaction
yourself (a gasless relayer, an EIP-7702 session key, or a plain raw
transaction). Returns the wrap/approve/`requestDeposit` calldata plus the note
commitment; nothing is sent on-chain. Needs an authenticated session (keys) but
no wallet. After relaying, settle from the mined receipt with
`privacy_boost_core::operations::finalize_shield`.
**Parameters:**
* `token_address` - Token contract address (use `0x0` for ETH)
* `amount` - Amount in wei (as string)
* `recipient` - Recipient privacy address; `None` shields to yourself
> CLI subcommands `prepare-shield` and `finalize-shield` wrap these for shell
> use — see the [Commands Reference](./guides/commands#prepare-shield).
#### list\_shields\_by\_status
```rust theme={null}
pub fn list_shields_by_status(
&self,
statuses: &[&str],
) -> Result, CliError>
```
Find a deposit request id the app never kept. Lists the authenticated wallet's
own shield requests in the given public statuses (`pending`, `processing`,
`preconfirmed`, `completed`, `failed`), oldest first; an empty slice asks the
server for its default of `pending`. Served by the server, so it needs no chain
access.
**Returns:** `UnsettledShieldResponse` per row — `request_id`, `status`, and the
optional `tx_hash`, `block_number`, `created_at`, `updated_at` and `error`.
#### deposit\_request\_ids\_from\_transaction
```rust theme={null}
pub fn deposit_request_ids_from_transaction(
&self,
tx_hash: &str,
) -> Result, CliError>
```
The fallback when the app has the shield's transaction hash but not its request
id. Reads the receipt and returns every `DepositRequested` id the pool emitted,
in log order — more than one when an aggregating relayer settled several callers
in one transaction. Empty means the transaction emitted no `DepositRequested`
from the configured pool.
> CLI subcommands `deposit list` and `deposit ids` wrap these for shell use.
> See the [Commands Reference](./guides/commands#deposit-list).
#### get\_deposit\_cancellation
```rust theme={null}
pub fn get_deposit_cancellation(
&self,
deposit_request_id: &str,
) -> Result
```
Report whether a stuck shield deposit can be cancelled right now. A shield whose
deposit never settles into the note tree leaves the tokens escrowed in the pool.
`get_shield_status` will tell you the shield `failed`, but nothing server-side
describes the escrow behind it — whether the pool still holds it, who may reclaim
it, and when — so this reads the pool directly through the connected wallet.
Costs three `eth_call`s plus one `eth_blockNumber`.
**Parameters:**
* `deposit_request_id` - The `DepositRequested` event's indexed topic1, a `uint256`. It's the same request id a shield reports. Decimal or `0x`-hex; the result normalizes it to decimal.
**Returns:** `DepositCancellation` with the escrowed amount, the block the delay
expires at, and a `reason` you can print as-is
#### cancel\_deposit
```rust theme={null}
pub fn cancel_deposit(&self, deposit_request_id: &str) -> Result
```
Cancel a stuck shield deposit, refunding its full escrowed amount to the
depositor. Returns the transaction hash.
Preflights the same checks as `get_deposit_cancellation` and fails locally,
before anything is signed, when the pool would revert. The error carries the
status `reason`, so a deposit that can't be cancelled reports why instead of
burning gas on an opaque revert. If the wallet can't run the preflight, the
transaction is submitted anyway and the chain arbitrates.
Only the address that submitted the deposit can cancel it, and the refund goes to
that address. For a relayer-submitted shield that's the relayer, not the note
recipient.
> CLI subcommands `deposit status` and `deposit cancel` wrap these for shell use.
> See the [Commands Reference](./guides/commands#deposit-status).
### Transaction History
```rust theme={null}
pub fn get_transaction_history(
&self,
tx_type: Option<&str>,
token_address: Option<&str>,
limit: Option
) -> Result, CliError>
```
Get transaction history.
### Session Persistence
#### export\_session
```rust theme={null}
pub fn export_session(&self) -> Option
```
Export session data for persistence.
#### import\_session
```rust theme={null}
pub fn import_session(&self, session: &ExportedSession) -> Result
```
Import session from persistence.
### Address Lookup
#### resolve\_identity
```rust theme={null}
pub fn resolve_identity(&self, identifier: &str) -> Result
```
Look up a user's privacy address by MPK or Ethereum address.
**Parameters:**
* `identifier` - MPK or Ethereum address
**Returns:** `Result`
### Utilities
```rust theme={null}
pub fn is_valid_privacy_address(&self, address: &str) -> bool
pub fn is_valid_address(&self, address: &str) -> bool
pub fn parse_amount(&self, amount: &str, decimals: u8) -> Result
pub fn format_amount(&self, wei: &str, decimals: u8) -> Result
```
***
## Module Functions
```rust theme={null}
pub fn sdk_version() -> String
```
Returns the SDK version string.
***
## CliConfig
CLI configuration struct.
```rust theme={null}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacyBoostConfig {
pub server_url: String,
#[serde(default)]
pub chain_id: u64, // 0 = auto-discover from server
#[serde(default)]
pub shield_contract_address: String, // empty = auto-discover from server
pub weth_contract_address: Option,
pub rpc_url: String,
pub timeout_secs: u64,
pub app_id: Option,
pub tee_public_key: Option,
pub persistence_storage: Option,
pub persistence_unlock: Option,
}
```
### Constructor
```rust theme={null}
pub fn new(
server_url: String,
chain_id: u64,
shield_contract_address: String,
rpc_url: String,
) -> Self
```
### Builder Methods
```rust theme={null}
pub fn with_weth_contract_address(self, weth_contract_address: String) -> Self
pub fn with_timeout(self, timeout_secs: u64) -> Self
```
### File Operations
```rust theme={null}
pub fn default_config_path() -> Result
pub fn load_from_file(path: &PathBuf) -> Result
pub fn load_from_default() -> Result, CliError>
pub fn save_to_file(&self, path: &PathBuf) -> Result<(), CliError>
pub fn save_to_default(&self) -> Result
```
### Environment
```rust theme={null}
pub fn from_environment() -> Self
```
***
## NetworkPreset
Network preset enumeration.
```rust theme={null}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkPreset {
Local,
OpSepolia,
}
```
### Methods
```rust theme={null}
pub fn config(&self) -> CliConfig
pub fn parse(s: &str) -> Option
```
**Parse aliases:**
* `Local`: `"local"`, `"localhost"`, `"dev"`
* `OpSepolia`: `"op-sepolia"`, `"optimism-sepolia"`, `"opsepolia"`
**Example:**
```rust theme={null}
let preset = NetworkPreset::parse("op-sepolia").unwrap();
let config = preset.config();
```
***
## Types
### KeySource
```rust theme={null}
pub enum KeySource {
WalletDerived,
Mnemonic { phrase: String },
RawSeed { hex_seed: String },
}
```
Key derivation source, passed to `authenticate()`. `WalletDerived` derives keys from a wallet signature, `Mnemonic` from a BIP-39 phrase, and `RawSeed` from a raw 32-byte hex seed.
### AuthResult
```rust theme={null}
pub enum AuthResult {
Authenticated(LoginResult),
CredentialRequired(CredentialChallenge),
}
```
### LoginResult
```rust theme={null}
pub struct LoginResult {
pub wallet_address: String,
pub privacy_address: String,
pub mpk: String,
}
```
### TokenBalance
```rust theme={null}
pub struct TokenBalance {
pub token_address: String,
pub shielded_balance: String,
pub wallet_balance: String,
pub symbol: Option,
pub decimals: u8,
}
```
### ShieldResult
```rust theme={null}
pub struct ShieldResult {
pub tx_hash: String,
pub commitment: String,
}
```
### UnshieldResult
```rust theme={null}
pub struct UnshieldResult {
pub tx_hash: String,
pub fee: String,
}
```
### TransferResult
```rust theme={null}
pub struct TransferResult {
pub tx_hash: String,
pub fee: String,
}
```
### PreparedShield
Returned by `prepare_shield`. Re-exported from
`privacy_boost_core::operations`. Carries no private material, so it is safe to
hand to a relayer. Each `Call` is a single on-chain call to relay; `value` and
`data` are 0x-hex.
```rust theme={null}
pub struct Call {
pub to: String,
pub value: String, // 0x-hex wei quantity ("0x0" for non-payable calls)
pub data: String, // ABI-encoded calldata (0x-prefixed)
}
pub struct ShieldFinalizeMeta {
pub shield_contract: String, // the DepositRequested emitter
pub token_id: u16,
}
pub struct PreparedShield {
pub approve: Option, // ERC-20 approve(pool, amount); omitted when suppressed
pub wrap: Option, // WETH deposit() wrap; present only for native ETH
pub shield: Call, // the requestDeposit(...) call
pub commitment: String,
pub finalize: ShieldFinalizeMeta,
}
```
### DepositCancellation
Returned by `get_deposit_cancellation`. Re-exported from
`privacy_boost_core::operations`. Amounts are decimal strings and blocks are
absolute chain heights.
`DepositCancelState` mirrors the reverts in the pool's `cancelDeposit`, reported
permanent-blocker first rather than in the pool's own check order: the pool
checks the depositor before anything else, and that check would mask the two
conditions no caller can clear. A gateway-origin deposit stores the gateway
contract as its depositor, so it reports `GatewayOrigin` for every caller and at
every point in the delay window — waiting never makes it cancellable.
```rust theme={null}
pub enum DepositCancelState {
Cancellable, // every precondition holds; the transaction should land
NotFound, // no escrow under this id: never created, or cancelled/rescued
NotDepositor, // escrow exists but belongs to another address
AlreadyProcessed, // already settled into the note tree; funds are in a note
TooEarly, // reclaimable, but the cancel delay has not elapsed
GatewayOrigin, // needs rescueGatewayDeposit by the rescue authority instead
}
pub struct DepositCancellation {
pub deposit_request_id: String, // the queried id, normalized to decimal
pub state: DepositCancelState,
pub cancellable: bool, // true exactly when state is Cancellable
pub depositor: Option, // None when no escrow exists
pub caller: String, // the address the status was evaluated against
pub token_id: u16,
pub total_amount: String, // escrow that would be refunded; "0" when absent
pub request_block: u64,
pub cancel_delay: u64, // the pool's cancelDelay(), in blocks
pub cancellable_at_block: u64, // request_block + cancel_delay
pub current_block: u64, // chain head the status was evaluated at
pub blocks_remaining: u64, // 0 once the delay has elapsed
pub reason: String, // human-readable explanation of state
}
```
The cancel delay is measured in blocks and read from the pool's `cancelDelay()`.
Read the deployed value instead of hardcoding a delay, using `cancel_delay`,
`cancellable_at_block`, and `blocks_remaining` from the status.
### Transaction
```rust theme={null}
pub struct Transaction {
pub tx_hash: String,
pub tx_type: String,
pub token_address: String,
pub amount: String,
pub direction: String,
pub sender_pub_key: String,
pub receiver_pub_keys: Vec,
pub created_at: u64,
}
```
### StatusInfo
```rust theme={null}
pub struct StatusInfo {
pub connected: bool,
pub authenticated: bool,
pub wallet_address: Option,
pub privacy_address: Option,
pub mpk: Option,
}
```
### IdentityResult
```rust theme={null}
pub struct IdentityResult {
pub mpk: String,
pub ethereum_address: String,
pub viewing_public_key_x: String,
pub viewing_public_key_y: String,
pub privacy_address: String,
}
```
### ExportedSession
Re-exported from `privacy_boost_core::sdk_state::ExportedSession`:
```rust theme={null}
pub struct ExportedSession {
pub wallet_public_key_x: String,
pub wallet_public_key_y: String,
pub viewing_key: String,
pub viewing_public_key_x: String,
pub viewing_public_key_y: String,
pub nullifying_key: String,
pub nullifying_public_key_x: String,
pub nullifying_public_key_y: String,
pub mpk: String,
pub account_id: String,
pub jwt: String,
pub jwt_expiry: u64,
pub wallet_address: String,
}
```
***
## CliError
```rust theme={null}
pub enum CliError {
NotConnected,
NotAuthenticated,
InvalidConfig { message: String },
InvalidPrivateKey { message: String },
NetworkError { message: String },
WalletError { message: String },
SigningError { message: String },
TransactionFailed { message: String },
InsufficientBalance,
InvalidAddress,
InvalidAmount,
SerializationError { message: String },
InternalError { message: String },
ConfigFileError { message: String },
}
```
***
## Default Values
```rust theme={null}
pub mod defaults {
pub const RPC_URL: &str = "http://localhost:8545";
pub const SERVER_URL: &str = "http://localhost:8080";
pub const WETH_CONTRACT_ADDRESS: &str = "0x36c02da8a0983159322a80ffe9f24b1acff8b570";
pub const TEST_TOKEN_CONTRACT: &str = "0x36c02da8a0983159322a80ffe9f24b1acff8b570";
}
```
***
## Complete Example
```rust theme={null}
use privacy_boost_cli::{PrivacyBoostCLI, CliConfig, NetworkPreset};
fn main() -> Result<(), Box> {
// Load configuration from preset
let config = NetworkPreset::OpSepolia.config();
// Or load from file
// let config = CliConfig::load_from_default()?.unwrap();
// Create SDK
let sdk = PrivacyBoostCLI::new(config)?;
// Authenticate with private key
let private_key = std::env::var("PRIVATE_KEY")?;
let auth_result = sdk.authenticate(&private_key, None, None)?;
match auth_result {
AuthResult::Authenticated(login_result) => {
println!("Privacy Address: {}", login_result.privacy_address);
}
AuthResult::CredentialRequired(challenge) => {
let login_result = sdk.submit_credential(&credential)?;
println!("Privacy Address: {}", login_result.privacy_address);
}
}
// Check balance
let token = "0x4200000000000000000000000000000000000006";
let balance = sdk.get_balance(token)?;
println!("Balance: {} wei", balance.shielded_balance);
// Deposit
let deposit_result = sdk.shield(token, "1000000000000000000")?;
println!("Deposit TX: {}", deposit_result.tx_hash);
// Export session for later
if let Some(session) = sdk.export_session() {
let json = serde_json::to_string(&session)?;
std::fs::write("session.json", json)?;
}
// Logout
sdk.logout();
Ok(())
}
```
## Gift & Portal Commands
Claimable transfers and portal deposit addresses are exposed as CLI
subcommands:
* [Claimable Transfers](./guides/claimable-transfers) — `gift-fund-to-wallet`,
`gift-fund`, `gift-list`, `gift-claim`, `gift-claim-from-link`,
`gift-refund-by-record`, `gift-refund`, `gift-decode-link`
* [Portal Deposits](./guides/portal-deposits) — `portal create | list | status |
deposits | sweep | reclaim | withdraw`
## See Also
* [Getting Started](./getting-started) - Basic CLI usage
* [Commands Reference](./guides/commands) - CLI commands
* [Claimable Transfers](./guides/claimable-transfers) - Send to an unregistered wallet
* [Portal Deposits](./guides/portal-deposits) - Reusable public deposit addresses
* [Network Presets](./guides/network-presets) - Network configuration
# Getting started
Source: https://docs.privacyboost.io/sdk/cli/getting-started
# Getting Started
This guide walks you through basic CLI usage for Privacy Boost operations.
For a minimal copy-paste example, see the [CLI Quickstart](/sdk/quickstart/cli). This guide explains each step in detail.
## Prerequisites
* The CLI [installed](/sdk/cli/installation)
* An [App ID](/sdk/concepts/app-setup) and server URL (see [Configuration](/sdk/concepts/configuration))
* A private key for the wallet you want to use
## Overview
The Privacy Boost CLI provides command-line access to all SDK functionality:
* Wallet connection and key derivation
* Authentication with the backend
* Deposits, withdrawals, and transfers
* Balance queries and transaction history
## Quick Start
### 1. Configure Network
Set up your environment:
```bash theme={null}
# Set environment variables
export SERVER_URL="https://optimism.sepolia.privacyboost.io"
export RPC_URL="https://sepolia.optimism.io"
export WETH_CONTRACT_ADDRESS="0x4200000000000000000000000000000000000006"
# Your private key (keep secure!)
export PRIVATE_KEY="0x..."
```
### 2. Login
Connect your wallet and authenticate with the Privacy Boost backend:
```bash theme={null}
privacy-boost login --private-key $PRIVATE_KEY
```
Output:
```
Logged in!
Wallet Address: 0x1234...5678
Privacy Address: 0x9abc...def0
MPK: 0xfedc...ba98
Session saved! Future commands won't require --private-key.
```
### 3. Check Balance
Query your shielded balance:
```bash theme={null}
# Check specific token
privacy-boost balance --token 0x4200000000000000000000000000000000000006
# Check all balances
privacy-boost balances
```
Output:
```
Token: 0xC02a...Cc2 (WETH)
Shielded Balance: 1.5 ETH
Wallet Balance: 0.5 ETH
```
### 4. Deposit
Deposit tokens into the shielded pool:
```bash theme={null}
privacy-boost shield \
--token 0x4200000000000000000000000000000000000006 \
--amount 1.0 \
--human
```
Output:
```
Deposit submitted!
Transaction: 0x1234...5678
Commitment: 0xabcd...ef01
Waiting for confirmation...
Deposit confirmed in block 12345678
```
### 5. Withdraw
Withdraw tokens from the shielded pool:
```bash theme={null}
privacy-boost unshield \
--token 0x4200000000000000000000000000000000000006 \
--amount 0.5 \
--recipient 0x1234567890123456789012345678901234567890 \
--human
```
Output:
```
Building proof... (this may take a moment)
Proof generated successfully
Withdraw submitted!
Transaction: 0x5678...9abc
Amount: 0.5 ETH
Recipient: 0x1234...7890
```
### 6. Send Private Transfer
Send tokens privately to another privacy address:
```bash theme={null}
privacy-boost send \
--token 0x4200000000000000000000000000000000000006 \
--amount 0.1 \
--recipient 0x[194-character-privacy-address] \
--human
```
Output:
```
Building proof... (this may take a moment)
Proof generated successfully
Transfer submitted!
Transaction: 0x9abc...def0
Amount: 0.1 ETH
Recipient: 0x9abc...ef01 (privacy address)
```
## Common Workflows
### Check Status
View current connection and authentication status:
```bash theme={null}
privacy-boost status
```
Output:
```
Status
------
Connected: Yes
Authenticated: Yes
Wallet Address: 0x1234...5678
Privacy Address: 0x9abc...def0
MPK: 0xfedc...ba98
Chain ID: 1
Session Expires: 2024-01-15 10:30:00 UTC
```
| Field | Meaning |
| - | - |
| `Connected` | A wallet has been connected to the SDK (private-key, mnemonic, or external). |
| `Authenticated` | A valid server session is active. |
| `Wallet Address` | EOA controlling the funds. |
| `Privacy Address` | The 194-character private receiving address that others use to send to you. |
| `MPK` | Master Public Key — a hex-encoded compressed public key derived from your wallet identity. |
| `Chain ID` | EVM chain the SDK is currently scoped to. |
| `Session Expires` | When the current JWT becomes invalid. After expiry, run `privacy-boost login` again. |
Pass `--output json` to receive the same fields as a structured object suitable for scripting.
### View Transaction History
```bash theme={null}
# All transactions
privacy-boost history
# Filter by type
privacy-boost history --tx-type shield
privacy-boost history --tx-type unshield
privacy-boost history --tx-type transact
# Filter by token
privacy-boost history --token 0x4200000000000000000000000000000000000006
# Limit results
privacy-boost history --limit 10
```
### List Sessions
```bash theme={null}
privacy-boost sessions
```
### Logout
```bash theme={null}
privacy-boost logout
```
## Working with Amounts
Amounts default to wei. Use `--human` for human-readable format:
```bash theme={null}
# 1 ETH (human-readable)
privacy-boost shield --token $TOKEN --amount 1.0 --human
# 0.5 ETH
privacy-boost shield --token $TOKEN --amount 0.5 --human
# Small amounts
privacy-boost shield --token $TOKEN --amount 0.001 --human
```
Or specify directly in wei (default):
```bash theme={null}
privacy-boost shield --token $TOKEN --amount 1000000000000000000
```
## Error Handling
### Common Errors
**Not Authenticated**
```bash theme={null}
Error: Not authenticated. Run 'privacy-boost login' first.
```
**Insufficient Balance**
```bash theme={null}
Error: Insufficient shielded balance. Have: 0.5 ETH, Need: 1.0 ETH
```
**Network Error**
```bash theme={null}
Error: Failed to connect to server at https://optimism.sepolia.privacyboost.io
Check your network connection and SERVER_URL configuration.
```
## Next Steps
Deposit tokens into the shielded pool
Withdraw tokens from the shielded pool
Send tokens privately between users
Query token balances
View and filter transaction history
Authentication and key management
Session persistence
Error codes and recovery patterns
# Balances
Source: https://docs.privacyboost.io/sdk/cli/guides/balances
# Balance Management
This guide covers querying and displaying token balances using the Privacy Boost CLI.
## CLI Commands
### Check Specific Token Balance
```bash theme={null}
privacy-boost balance --token 0x4200000000000000000000000000000000000006
```
Output:
```
Token: 0x4200...0006 (WETH)
Shielded Balance: 1.5 ETH
Wallet Balance: 0.5 ETH
```
### Check All Balances
```bash theme={null}
privacy-boost balances
```
Output:
```
Token Shielded Wallet
0x4200...0006 (WETH) 1.5 ETH 0.5 ETH
0xA0b8...4321 (USDC) 100.0 USDC 50.0 USDC
```
## Programmatic Usage (Rust)
### Single Token
```rust theme={null}
let balance = sdk.get_balance("0x4200000000000000000000000000000000000006")?;
println!("Shielded: {} wei", balance.shielded_balance);
println!("Wallet: {} wei", balance.wallet_balance);
// Format for display
let formatted = sdk.format_amount(&balance.shielded_balance, balance.decimals)?;
println!("Shielded: {} {}", formatted, balance.symbol.unwrap_or_default());
```
### All Tokens
```rust theme={null}
let balances = sdk.get_all_balances()?;
for balance in &balances {
let formatted = sdk.format_amount(&balance.shielded_balance, balance.decimals)?;
println!("{}: {} (shielded)", balance.symbol.as_deref().unwrap_or("???"), formatted);
}
```
### TokenBalance
```rust theme={null}
pub struct TokenBalance {
pub token_address: String,
pub shielded_balance: String, // Amount in wei
pub wallet_balance: String, // Amount in wei
pub symbol: Option,
pub decimals: u8,
}
```
## Formatting Amounts
```rust theme={null}
// Parse human-readable to wei
let wei = sdk.parse_amount("1.5", 18)?; // "1500000000000000000"
// Format wei to human-readable
let eth = sdk.format_amount("1500000000000000000", 18)?; // "1.5"
// For USDC (6 decimals)
let usdc_wei = sdk.parse_amount("100.0", 6)?; // "100000000"
```
## Error Handling
```rust theme={null}
match sdk.get_balance(token_address) {
Ok(balance) => {
let formatted = sdk.format_amount(&balance.shielded_balance, balance.decimals)?;
println!("Shielded: {}", formatted);
}
Err(CliError::NotAuthenticated) => println!("Please login first"),
Err(CliError::NetworkError { message }) => println!("Network error: {}", message),
Err(e) => println!("Balance error: {:?}", e),
}
```
## Best Practices
### 1. Use Format Helpers
Always use `sdk.format_amount()` to convert wei strings for display.
### 2. Refresh After Operations
Check balances after deposits, withdrawals, or transfers.
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transactions Guide](./transactions)
* [Error Handling](./error-handling)
# Claimable transfers
Source: https://docs.privacyboost.io/sdk/cli/guides/claimable-transfers
# Claimable Transfers (Gifts)
Send shielded funds to an Ethereum wallet address that has not registered with
Privacy Boost yet, and reclaim the funds if they go unclaimed. See
[Claimable Transfers](/sdk/concepts/claimable-transfers) for the concept and
trust model.
All commands below require an authenticated session (run `privacy-boost login`
first, or pass `--private-key`).
## Funding a gift
When you know the recipient's wallet but they have not joined Privacy Boost, use
`gift-fund-to-wallet`. The refund block is a height after which you can reclaim an
unclaimed gift; the allowed range is advertised from the server's `/info`.
```bash theme={null}
privacy-boost gift-fund-to-wallet \
--token 0x4200000000000000000000000000000000000006 \
--amount 1.0 \
--human \
--recipient-wallet 0xRecipientEoaAddress \
--refund-after-block 12395678 \
--current-block 12345678
```
Output:
```
Gift funded!
Transaction: 0x1234...5678
Claim link: pbgift:v1:eyJ...
Refundable after block: 12395678
```
Share the **claim link** with the recipient out of band. Treat it like a
password — it reveals the amount and recipient to anyone who reads it.
If the recipient is already a Privacy Boost user, include their privacy address
with `gift-fund` so the ciphertext is sealed to their viewing key:
```bash theme={null}
privacy-boost gift-fund \
--token 0x4200000000000000000000000000000000000006 \
--amount 1.0 \
--human \
--recipient 0x04...recipient-privacy-address \
--recipient-wallet 0xRecipientEoaAddress \
--refund-after-block 12395678 \
--current-block 12345678
```
### Funding parameters
| Flag | Required | Description |
| - | - | - |
| `--token` | Yes | ERC-20 token contract address |
| `--amount` | Yes | Amount (wei, or decimal with `--human`) |
| `--recipient` | `gift-fund` only | Recipient's 194-char privacy address |
| `--recipient-wallet` | Yes | Recipient's Ethereum address the gift binds to |
| `--refund-after-block` | Yes | Block height after which you may reclaim |
| `--current-block` | Yes | Current chain head (pre-validates the refund delay) |
| `--human` | No | Interpret `--amount` as decimal |
| `--yes` | No | Skip the recipient-wallet confirmation prompt |
The gift binds **irrevocably** to `--recipient-wallet`. The CLI shows the
checksummed address and asks you to confirm; a wrong-but-valid address can be
claimed by whoever owns it, and only an unclaimed gift is refundable.
## Claiming a gift
List the pending gifts addressed to your wallet:
```bash theme={null}
privacy-boost gift-list
```
Output:
```
Pending gifts:
[0] tokenId=1 amount=1000000000000000000 refundAfterBlock=12395678
```
Claim one by its index in that list. `--acknowledge-unknown-sender` is required —
the hidden-sender model cannot reveal who funded the gift, so you explicitly
accept funds from an unknown sender:
```bash theme={null}
privacy-boost gift-claim --index 0 --acknowledge-unknown-sender
```
Or claim straight from a claim link, without listing first:
```bash theme={null}
privacy-boost gift-claim-from-link \
--link pbgift:v1:eyJ... \
--acknowledge-unknown-sender
```
## Refunding an unclaimed gift
A claim and a refund spend the same nullifier, so only one can settle. After the
refund deadline, reclaim using the saved record's index in your local gift ledger
(persisted in your session file, in the order you funded them):
```bash theme={null}
privacy-boost gift-refund-by-record --index 0
```
If you no longer have the local record, refund by supplying every field manually:
```bash theme={null}
privacy-boost gift-refund \
--recipient-wallet 0xRecipientEoaAddress \
--blind 0x... \
--refund-after-block 12395678 \
--token-id 1 \
--amount 1.0 --human \
--tree-number 0 \
--leaf-index 42
```
## Previewing a claim link offline
Decode and decrypt a claim link into a preview, with no network call:
```bash theme={null}
privacy-boost gift-decode-link --link pbgift:v1:eyJ...
```
## Notes
* The secret-bearer funding mode is intentionally **not exposed in the CLI**. It
is a bearer instrument, selected only by the explicit `fundSecretBearer` SDK
method. See the
[concept page](/sdk/concepts/claimable-transfers).
* Keep your [session file](/sdk/cli/guides/session-storage) — it stores the gift
records that make your unclaimed gifts refundable.
## Next steps
* [Transfers Guide](./transfers)
* [Portal Deposits Guide](./portal-deposits)
* [Session Storage](./session-storage)
# Commands
Source: https://docs.privacyboost.io/sdk/cli/guides/commands
# Commands Reference
Complete reference for all Privacy Boost CLI commands.
## Global Options
Available for all commands:
```
privacy-boost [OPTIONS]
privacy-boost [OPTIONS]
Options:
-n, --network Network preset (local, op-sepolia, op-mainnet, base-mainnet, base-sepolia, soneium-mainnet, ink-mainnet)
-c, --config Configuration file path
-k, --private-key Private key (hex string, with or without 0x prefix)
--server-url Server URL (overrides config)
--rpc-url RPC URL (overrides config)
--chain-id Chain ID (overrides config)
--shield-contract-address Shield contract address (overrides config)
--weth-contract-address WETH contract address (overrides config)
-o, --output Output format: json, text (default: text)
-h, --help Print help
-V, --version Print version
```
***
## Configuration Commands
### init
Initialize a configuration file at `~/.privacy-boost/config.toml`.
```bash theme={null}
privacy-boost init [OPTIONS]
```
**Options:**
* `-n, --network ` - Network preset to use (default: `local`)
If a config file already exists, the command prints a warning and does nothing. Delete the existing file first to reinitialize.
You can also import an existing config file:
```bash theme={null}
privacy-boost init --config /path/to/my-config.toml
```
**Examples:**
```bash theme={null}
# Initialize with local dev defaults
privacy-boost init
# Initialize with OP Sepolia preset
privacy-boost init --network op-sepolia
# Import an existing config as the default
privacy-boost init --config ~/my-project-config.toml
```
***
## Authentication Commands
### login
Connect wallet, derive privacy keys, and authenticate with the backend. Saves the session so future commands don't require `--private-key`.
```bash theme={null}
privacy-boost login --private-key
```
**Examples:**
```bash theme={null}
# Using flag
privacy-boost login --private-key 0x...
# Using environment variable
export PRIVATE_KEY="0x..."
privacy-boost login
```
**Output:**
```
Logged in!
Wallet Address: 0x1234...5678
Privacy Address: 0x9abc...def0
MPK: 0xfedc...ba98
Session saved! Future commands won't require --private-key.
```
### logout
Clear saved session.
```bash theme={null}
privacy-boost logout
```
### sessions
List saved sessions and their expiry status.
```bash theme={null}
privacy-boost sessions
```
### status
Show current connection and authentication status.
```bash theme={null}
privacy-boost status
```
**Output:**
```
Status
Connected: Yes
Authenticated: Yes
Wallet Address: 0x1234...5678
Privacy Address: 0x9abc...def0
```
***
## Balance Commands
### balance
Query shielded balance for a specific token.
```bash theme={null}
privacy-boost balance --token
```
**Options:**
* `-t, --token ` - Token contract address (use `0x0` for ETH)
**Examples:**
```bash theme={null}
privacy-boost balance --token 0x4200000000000000000000000000000000000006
```
### balances
Query all token balances.
```bash theme={null}
privacy-boost balances
```
### fees
Query the ordinary fee schedule for a registered token ID, or bind the quote
to an ExternalCallGateway address and optional 4-byte calldata selector.
```bash theme={null}
privacy-boost fees
privacy-boost fees --gateway-address [--gateway-selector <0xSELECTOR>]
```
`--gateway-selector` requires `--gateway-address`. An exact selector policy is
used when configured; otherwise the Gateway-wide default applies.
***
## Vault Commands
### shield
Deposit tokens into the shielded pool.
```bash theme={null}
privacy-boost shield [OPTIONS]
```
**Options:**
* `-t, --token ` - Token contract address (required)
* `-a, --amount ` - Amount in wei
* `--human` - Parse amount as human-readable (e.g., `1.5` instead of wei)
* `--decimals ` - Decimals for human-readable parsing (default: 18)
**Examples:**
```bash theme={null}
# Deposit in wei
privacy-boost shield --token $WETH --amount 1000000000000000000
# Deposit in human-readable format
privacy-boost shield --token $WETH --amount 1.0 --human
```
### unshield
Withdraw tokens from the shielded pool to a public address.
```bash theme={null}
privacy-boost unshield [OPTIONS]
```
**Options:**
* `-t, --token ` - Token contract address (required)
* `-a, --amount ` - Amount in wei
* `-r, --recipient ` - Recipient Ethereum address (required)
* `--human` - Parse amount as human-readable
* `--decimals ` - Decimals for human-readable parsing (default: 18)
**Examples:**
```bash theme={null}
privacy-boost unshield \
--token $WETH \
--amount 0.5 \
--recipient 0x1234... \
--human
```
### send
Send a private transfer to a privacy address or Ethereum address.
```bash theme={null}
privacy-boost send [OPTIONS]
```
**Options:**
* `-t, --token ` - Token contract address (required)
* `-a, --amount ` - Amount in wei
* `-r, --recipient ` - Recipient privacy address (194 chars) or Ethereum address (42 chars)
* `--human` - Parse amount as human-readable
* `--decimals ` - Decimals for human-readable parsing (default: 18)
**Examples:**
```bash theme={null}
privacy-boost send \
--token $WETH \
--amount 0.1 \
--recipient 0x[194-char-privacy-address] \
--human
```
### send-batch
Send one private transfer paying several recipients: one request, one fee, one
set of input notes.
```bash theme={null}
privacy-boost send-batch [OPTIONS]
```
**Options:**
* `-t, --token ` - Token contract address (required)
* `--to ` - Recipient and amount, repeatable (required). Recipient is a privacy address (194 chars) or an Ethereum address (42 chars)
* `--human` - Parse amounts as human-readable
* `--decimals ` - Decimals for human-readable parsing (default: 18)
**Examples:**
```bash theme={null}
privacy-boost send-batch \
--token $WETH \
--to 0x[194-char-privacy-address]:0.1 \
--to 0x[194-char-privacy-address]:0.25 \
--human
```
The transfer's output count is the recipient count plus a change note, and the
server only proves specific `(inputs, outputs)` combinations, so past two
recipients at most four input notes can be spent. A batch that can't be proven
fails with `UNSUPPORTED_TRANSFER_SHAPE`; run `consolidate` first, or split the
payout.
### prepare-shield
Build a deposit payload **without submitting it**, for relaying the transaction
yourself (a gasless relayer, an EIP-7702 session key, or a plain raw
transaction). Prints the wrap/approve/`requestDeposit` calls plus the note
commitment; nothing is sent on-chain. Requires your wallet private key
(`--private-key`, `PRIVATE_KEY`, or the prompt): the commitment is built from
your keys, which a saved `login` session keeps locked. After relaying, settle it
with `finalize-shield`.
```bash theme={null}
privacy-boost prepare-shield [OPTIONS]
```
**Options:**
* `-t, --token ` - Token contract address (use `0x0` for ETH) (required)
* `-a, --amount ` - Amount in wei
* `-r, --recipient ` - Recipient privacy address (omit to shield to yourself)
* `--human` - Parse amount as human-readable
* `--decimals ` - Decimals for human-readable parsing (default: 18)
**Examples:**
```bash theme={null}
# Prepare a self-shield and print the payload as JSON
privacy-boost prepare-shield --token $WETH --amount 1.0 --human --output json
# Prepare a deposit targeted at another user's privacy address
privacy-boost prepare-shield \
--token $WETH \
--amount 1000000000000000000 \
--recipient 0x[194-char-privacy-address]
```
With `--output json` the full `PreparedShield` is printed (the `approve`, `wrap`,
and `shield` calls, each as `{ to, value, data }`, plus `commitment` and
`finalize`), ready to feed into a relayer.
### finalize-shield
Recover the on-chain shield request id from the mined receipt of a relayed
deposit (see `prepare-shield`). Stateless — no authentication required.
```bash theme={null}
privacy-boost finalize-shield [OPTIONS]
```
**Options:**
* `--receipt ` - Mined receipt as inline JSON `{transactionHash,status,logs:[{address,topics,data}]}`, or `@path` to a file (required)
* `--commitment <0x..>` - Commitment from the prepared shield being settled (required)
* `--shield-contract ` - Shield pool contract (defaults to `--shield-contract-address`)
**Examples:**
```bash theme={null}
# Finalize from a receipt file
privacy-boost finalize-shield \
--receipt @receipt.json \
--commitment 0xabcd...ef01
# Finalize from inline receipt JSON, with explicit shield contract
privacy-boost finalize-shield \
--receipt '{"transactionHash":"0x...","status":true,"logs":[...]}' \
--commitment 0xabcd...ef01 \
--shield-contract 0x1234...
```
***
## History Commands
### history
View transaction history.
```bash theme={null}
privacy-boost history [OPTIONS]
```
**Options:**
* `-t, --tx-type ` - Filter by type (`shield`, `unshield`, `transact`)
* `--token-id ` - Only rows for this registry token id
* `--direction ` - Only `incoming` or `outgoing` rows, relative to this wallet
* `-l, --limit ` - Maximum number of results
* `--collapse-gateway` - Show each Gateway operation as one row instead of its two epochs
**Examples:**
```bash theme={null}
# All transactions
privacy-boost history
# Deposits only
privacy-boost history --tx-type shield
# Outgoing rows for token id 1, as JSON
privacy-boost history --token-id 1 --direction outgoing --output json
# JSON output with limit
privacy-boost history --limit 10 --output json
```
***
## Utility Commands
### resolve-identity
Look up a user's identity by Ethereum address or MPK.
```bash theme={null}
privacy-boost resolve-identity
```
**Arguments:**
* `` - Ethereum address (42 chars) or MPK (66 chars)
**Examples:**
```bash theme={null}
privacy-boost resolve-identity 0x1234567890123456789012345678901234567890
```
### version
Show version information.
```bash theme={null}
privacy-boost version
```
***
## Gift Commands
[Claimable transfers](/sdk/concepts/claimable-transfers) — send to an
unregistered wallet.
### gift-fund-to-wallet
Fund a gift bound to a wallet that has not joined Privacy Boost.
```bash theme={null}
privacy-boost gift-fund-to-wallet [OPTIONS]
```
**Options:**
* `-t, --token ` - Token contract address (required)
* `-a, --amount ` - Amount in wei (or decimal with `--human`)
* `--recipient-wallet ` - Recipient Ethereum address the gift binds to
* `--refund-after-block ` - Block after which you may reclaim an unclaimed gift
* `--current-block ` - Current chain head (pre-validates the refund delay)
* `--human` - Parse amount as human-readable
* `--yes` - Skip the recipient-wallet confirmation prompt
### gift-fund
Like `gift-fund-to-wallet`, but also takes the recipient's privacy address
(`-r, --recipient`) so the gift ciphertext is sealed to their viewing key.
### gift-list
List the claimable (pending) gifts addressed to your wallet.
```bash theme={null}
privacy-boost gift-list
```
### gift-claim
Claim a pending gift by its index in `gift-list`.
```bash theme={null}
privacy-boost gift-claim --index 0 --acknowledge-unknown-sender
```
`--acknowledge-unknown-sender` is required — the sender is hidden, so you
explicitly accept funds from an unknown source.
### gift-claim-from-link
Claim directly from an out-of-band claim link.
```bash theme={null}
privacy-boost gift-claim-from-link --link pbgift:v1:... --acknowledge-unknown-sender
```
### gift-refund-by-record
Reclaim a gift you funded that went unclaimed, by its index in your local gift
ledger (persisted in your session file).
```bash theme={null}
privacy-boost gift-refund-by-record --index 0
```
### gift-refund
Reclaim an unclaimed gift by supplying every field manually (when you no longer
have the local record): `--recipient-wallet`, `--blind`, `--refund-after-block`,
`--token-id`, `--amount`, `--tree-number`, `--leaf-index`.
### gift-decode-link
Decode and decrypt a claim link into a preview, with no network call.
```bash theme={null}
privacy-boost gift-decode-link --link pbgift:v1:...
```
***
## Deposit Commands
Inspect or reclaim a shield deposit still escrowed in the pool. A shield holds
your tokens in the pool until the sequencer settles the request into the note
tree; when settlement never happens, these two commands get them back. Both read
the pool directly, because nothing server-side describes the escrow behind a
shield that didn't settle, and both require an authenticated session.
Only the address that submitted the deposit can cancel it, and the refund goes to
that address. For a relayer-submitted shield that's the relayer, not the note
recipient. A deposit that entered through the gateway has the gateway contract as
its depositor, so no wallet can cancel it — those report `GatewayOrigin` and have
to go through `rescueGatewayDeposit`.
### deposit list
Find a deposit request id you never kept. Lists your own shields by status,
oldest first.
```bash theme={null}
privacy-boost deposit list --status failed
```
`--status` takes a comma-separated list of public statuses (`pending`,
`processing`, `preconfirmed`, `completed`, `failed`) and defaults to `pending`.
`failed` is the recovery entry point — those are the shields whose escrow may
still be sitting in the pool. Text output prints `requestIdstatus` per row;
`--output json` prints the full records including `txHash` and `blockNumber`.
`failed` also covers shields the server rejected as below-minimum or blocked,
which it folds into the same public status. A stalled sequencer produces no
failed row at all, so a sweep for everything unsettled wants
`--status pending,failed`.
### deposit ids
Recover the deposit request ids a shield transaction emitted, for when you kept
the transaction hash but not the request id.
```bash theme={null}
privacy-boost deposit ids
```
Prints one id per line, in log order. More than one appears when an aggregating
relayer settled several callers' deposits in the same transaction. No output
means the transaction emitted no `DepositRequested` from the pool, which is the
answer for a hash that was never a shield.
### deposit status
Show whether a pending deposit can be cancelled, and why not if it can't. Prints
the `reason` line, plus the target block and countdown while the cancel delay is
still running. `--output json` prints the full status: `state`, `cancellable`,
`totalAmount`, `cancelDelay`, `cancellableAtBlock`, `blocksRemaining`, and more.
```bash theme={null}
privacy-boost deposit status
```
`DEPOSIT_REQUEST_ID` is the `depositRequestId` from the `DepositRequested` log,
decimal or `0x`-hex.
The cancel delay is measured in blocks and read from the pool's `cancelDelay()`.
Read it from the command's output rather than hardcoding a delay.
### deposit cancel
Cancel a pending deposit, refunding its full escrow to the original depositor.
Prints the transaction hash.
```bash theme={null}
privacy-boost deposit cancel
```
The command runs the `deposit status` checks first and fails locally, before
anything is signed, when the pool would revert, so a deposit that can't be
cancelled tells you why instead of costing gas on an opaque revert. A deposit
that entered through the gateway can't be cancelled at all; it has to be
recovered with `rescueGatewayDeposit` by its rescue authority.
## Portal Commands
[Portal deposit addresses](/sdk/concepts/portal-deposits) — reusable public
deposit addresses. All require an authenticated session.
### portal create
Derive a portal address `E`, delegate it, register, and publish — in one call.
```bash theme={null}
privacy-boost portal create [--index ] [--no-publish]
```
### portal list
List your portals. Without pagination options, the CLI accumulates pages within
the SDK safety cap. Supplying either option fetches one page and prints the
opaque `next_cursor` when another page is available.
```bash theme={null}
privacy-boost portal list [--cursor ] [--limit <1-100>]
```
### portal status
Show the registered / delegated / published status of a portal.
```bash theme={null}
privacy-boost portal status
```
### portal deposits
List the deposits observed at a portal. Pagination options have the same
one-page behavior as `portal list`.
```bash theme={null}
privacy-boost portal deposits [--cursor ] [--limit <1-100>]
```
### portal sweep
Self-service sweep backstop, since a keeper normally handles this: sweep a
token's balance at the portal into the pool.
```bash theme={null}
privacy-boost portal sweep
```
### portal reclaim
Reclaim an un-credited deposit after the cancel delay; funds return to the portal.
```bash theme={null}
privacy-boost portal reclaim
```
### portal withdraw
Build a signed raw EIP-1559 tx that withdraws funds resting at `E` (escape
hatch); prints the raw tx to broadcast yourself.
```bash theme={null}
privacy-boost portal withdraw
```
## Earn Commands
[Earn (ERC-4626 vaults)](/sdk/concepts/earn) — move a shielded balance into a
yield vault and back through the external call gateway. V2 feature, enabled per
deployment.
`vaults` and `history` are public reads. `deposit`, `withdraw`,
and `status` require an authenticated session.
### earn vaults
List the earn vault catalog (APY, TVL, per-operation availability).
```bash theme={null}
privacy-boost earn vaults
```
### earn deposit
Deposit assets into a vault: spends an asset note, credits a vault-share note.
`--amount` is **net** — exactly what enters the vault; the Gateway fee replaces
the regular unshield fee and is charged on top.
```bash theme={null}
privacy-boost earn deposit --vault --amount [--human --decimals ] [--slippage-bps ]
```
### earn withdraw
Withdraw from a vault: redeems share notes, credits an asset note. `--amount` is
the share amount to redeem.
```bash theme={null}
privacy-boost earn withdraw --vault --amount [--human --decimals ] [--slippage-bps ]
```
### earn status
Show an earn request's status, including the gateway settlement and credit
projection. `creditStatus=completed` means private credit succeeded, while
`creditStatus=rescued` means escrow was paid to the rescue destination.
Both end the wait for private credit.
```bash theme={null}
privacy-boost earn status
```
### earn history
One vault's TVL and/or net-APY history series (chart data; public read). APY
values are decimal ratios (`0.05` = 5%). Omit `--metric` to fetch both series
concurrently. `--period` is `1w`, `1m`, `3m`, or `all` (default `1w`).
```bash theme={null}
privacy-boost earn history --vault [--metric tvlUsd|netApy] [--period 1w|1m|3m|all]
```
## Contacts Commands
A private address book. Contacts are stored encrypted per wallet on the
server; they never touch keys or notes, so every command here runs on a saved
session (`login` first, or pass `--private-key`). Text output prints one line
per contact; `--output json` prints the server records with camelCase fields.
A contact the server can no longer decrypt is still listed, flagged
`decryptionFailed`, rather than dropped.
### contacts list
List contacts, newest first. `--limit` and `--cursor` page through the
list; the opaque `next_cursor` is printed when another page exists.
```bash theme={null}
privacy-boost contacts list [--limit ] [--cursor ]
```
### contacts create
Create a contact. The privacy address must be unique per wallet. `--metadata`
is repeatable and takes `key=value` pairs; a repeated key is rejected.
`--correlation-id` is stored as the contact's `clientId`; it is deliberately
not called `--client-id`, which is the global api\_secret auth flag.
```bash theme={null}
privacy-boost contacts create \
--privacy-address \
--nickname \
[--eth-address ] [--emoji ] [--correlation-id ] \
[--metadata key=value ...]
```
### contacts update
Patch a contact by id. Only the flags you pass change. `--eth-address` and
`--metadata` replace the stored value; `--clear-eth-address` and
`--clear-metadata` remove it. Each pair is mutually exclusive, and at least one
flag is required.
```bash theme={null}
privacy-boost contacts update \
[--nickname ] [--emoji ] \
[--eth-address | --clear-eth-address] \
[--metadata key=value ... | --clear-metadata]
```
### contacts delete
Delete a contact. Labels that referenced it keep their other fields.
```bash theme={null}
privacy-boost contacts delete
```
### contacts import
Bulk-create contacts from a JSON file holding an array of contact objects in
the server's request shape:
```json theme={null}
[
{ "privacyAddress": "0x...", "nickname": "Alice", "emoji": "🦊" },
{ "privacyAddress": "0x...", "nickname": "Bob", "ethAddress": "0x...", "clientId": "row-2",
"metadata": { "team": "ops" } }
]
```
```bash theme={null}
privacy-boost contacts import --file contacts.json
```
An entry whose privacy address already exists is reported as skipped with the
existing contact's id, not as an error. `clientId` is echoed back per row so a
script can match results to its input.
## Labels Commands
Private memos and contact links keyed by transaction hash. Same storage and
session model as contacts.
### labels list
```bash theme={null}
privacy-boost labels list [--limit ] [--cursor ]
```
### labels set
Create or replace the label on a transaction hash. A label is replaced whole,
so any field you omit is cleared on an existing label.
```bash theme={null}
privacy-boost labels set [--contact-id ] [--memo ] [--metadata key=value ...]
```
### labels delete
```bash theme={null}
privacy-boost labels delete
```
## Campaign Commands
### campaign progress
Startale mission progress for the authenticated user and app: whether at least
\$10 has been shielded and whether a transfer has been made. There is no user
argument; the server reads the user from the session.
```bash theme={null}
privacy-boost campaign progress
```
The deposit mission is priced against current token prices at query time, so
it can flip back to `false`; anything that awards on it should record the
first `true`. `campaignTokenId` is absent when no campaign token is configured,
in which case every shielded token counted toward the deposit total.
## Environment Variables
| Variable | Description |
| - | - |
| `PRIVATE_KEY` | Wallet private key |
| `SERVER_URL` | Privacy Boost server URL |
| `RPC_URL` | RPC URL |
| `WETH_CONTRACT_ADDRESS` | WETH contract address |
| `SHIELD_CONTRACT_ADDRESS` | Shield contract address (auto-discovered if omitted) |
| `PRIVACY_BOOST_APP_ID` or `APP_ID` | App ID for multi-tenant auth |
| `PRIVACY_BOOST_TEE_PUBLIC_KEY` or `TEE_PUBLIC_KEY` | TEE public key for deposit encryption |
## Next Steps
* [Network Presets](./network-presets) - Network configuration
* [Scripting Guide](./scripting) - Automation examples
* [API Reference](../api-reference) - Library API documentation
# Deposits
Source: https://docs.privacyboost.io/sdk/cli/guides/deposits
# Deposits
This guide covers depositing tokens from your wallet into your private balance using the Privacy Boost CLI.
## CLI Command
```bash theme={null}
privacy-boost shield \
--token 0x4200000000000000000000000000000000000006 \
--amount 1.0 \
--human
```
Output:
```
Deposit submitted!
Transaction: 0x1234...5678
Commitment: 0xabcd...ef01
Waiting for confirmation...
Deposit confirmed in block 12345678
```
## Command Parameters
| Flag | Required | Description |
| - | - | - |
| `--token` | Yes | ERC-20 token contract address |
| `--amount` | Yes | Amount to deposit |
| `--human` | No | Interpret amount as human-readable (default: wei) |
## Deposit Steps
When a deposit is submitted, the SDK executes these steps:
| Step | Description |
| - | - |
| Wrapping | Wrapping ETH to WETH (if depositing ETH) |
| Approving | Approving token spending on the shield contract |
| Shielding | Executing the deposit transaction |
| Registering | Registering the deposit with the indexer |
| Compliance | Waiting for compliance verification |
## Programmatic Usage (Rust)
```rust theme={null}
use privacy_boost_cli::PrivacyBoostCLI;
let result = sdk.shield(
"0x4200000000000000000000000000000000000006",
"1000000000000000000", // 1 token in wei
)?;
println!("Deposit TX: {}", result.tx_hash);
println!("Commitment: {}", result.commitment);
```
### ShieldResult
```rust theme={null}
pub struct ShieldResult {
pub tx_hash: String,
pub commitment: String,
}
```
## Depositing ETH
To deposit native ETH, use the zero address:
```bash theme={null}
privacy-boost shield \
--token 0x0000000000000000000000000000000000000000 \
--amount 1.0 \
--human
```
The SDK automatically wraps ETH to WETH before depositing.
## Working with Amounts
```bash theme={null}
# Human-readable (with --human flag)
privacy-boost shield --token $TOKEN --amount 1.5 --human
# Wei (default)
privacy-boost shield --token $TOKEN --amount 1500000000000000000
```
Programmatic conversion:
```rust theme={null}
let wei = sdk.parse_amount("1.5", 18)?; // "1500000000000000000"
let eth = sdk.format_amount(&wei, 18)?; // "1.5"
```
## Error Handling
```rust theme={null}
match sdk.shield(token_address, amount) {
Ok(result) => println!("Deposit TX: {}", result.tx_hash),
Err(CliError::InsufficientBalance) => println!("Not enough tokens in wallet"),
Err(CliError::InvalidAmount) => println!("Invalid amount format"),
Err(CliError::NotAuthenticated) => println!("Please login first"),
Err(CliError::NetworkError { message }) => println!("Network error: {}", message),
Err(e) => println!("Deposit failed: {:?}", e),
}
```
## Best Practices
### 1. Use `--human` for Interactive Use
The `--human` flag makes amounts more readable and less error-prone.
### 2. Check Balance After Deposit
```bash theme={null}
privacy-boost shield --token $TOKEN --amount 1.0 --human
privacy-boost balance --token $TOKEN
```
## Next Steps
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Error handling
Source: https://docs.privacyboost.io/sdk/cli/guides/error-handling
# Error Handling
This guide covers error handling patterns for the Privacy Boost CLI. For the cross-platform error code reference, see [Error Handling](/sdk/concepts/error-handling).
## CliError
All SDK operations return `Result`:
```rust theme={null}
pub enum CliError {
NotConnected,
NotAuthenticated,
InvalidConfig { message: String },
InvalidPrivateKey { message: String },
NetworkError { message: String },
WalletError { message: String },
SigningError { message: String },
TransactionFailed { message: String },
InsufficientBalance,
InvalidAddress,
InvalidAmount,
SerializationError { message: String },
InternalError { message: String },
ConfigFileError { message: String },
}
```
## Basic Error Handling
```rust theme={null}
match sdk.shield(token_address, amount) {
Ok(result) => {
println!("Deposit TX: {}", result.tx_hash);
}
Err(CliError::NotAuthenticated) => {
println!("Please run 'privacy-boost login' first");
}
Err(CliError::InsufficientBalance) => {
println!("Not enough tokens in wallet");
}
Err(CliError::InvalidAmount) => {
println!("Invalid amount format");
}
Err(CliError::NetworkError { message }) => {
println!("Network error: {}", message);
}
Err(e) => {
println!("Deposit failed: {:?}", e);
}
}
```
## CLI Error Messages
Common CLI error outputs:
```
Error: Not authenticated. Run 'privacy-boost login' first.
Error: Insufficient shielded balance. Have: 0.5 ETH, Need: 1.0 ETH
Error: Invalid privacy address format.
Error: Failed to connect to indexer at https://...
Error: Invalid private key format.
```
## Retry Pattern
For network errors, use a retry loop:
```rust theme={null}
fn with_retry(max_retries: u32, operation: F) -> Result
where
F: Fn() -> Result,
{
for attempt in 1..=max_retries {
match operation() {
Ok(result) => return Ok(result),
Err(CliError::NetworkError { .. }) if attempt < max_retries => {
let delay = std::time::Duration::from_secs(attempt as u64);
std::thread::sleep(delay);
continue;
}
Err(e) => return Err(e),
}
}
operation()
}
// Usage
let balance = with_retry(3, || sdk.get_balance(token_address))?;
```
## Categorizing Errors
```rust theme={null}
enum ErrorCategory {
UserAction, // User can fix (login, add funds)
Retryable, // Transient, retry may work
Fatal, // Requires developer attention
}
fn categorize(error: &CliError) -> ErrorCategory {
match error {
CliError::NotConnected
| CliError::NotAuthenticated
| CliError::InsufficientBalance
| CliError::InvalidAddress
| CliError::InvalidAmount
| CliError::InvalidPrivateKey { .. } => ErrorCategory::UserAction,
CliError::NetworkError { .. } => ErrorCategory::Retryable,
_ => ErrorCategory::Fatal,
}
}
```
## User-Friendly Error Messages
```rust theme={null}
fn user_message(error: &CliError) -> String {
match error {
CliError::NotConnected => "Wallet not connected".into(),
CliError::NotAuthenticated => "Please run 'privacy-boost login' first".into(),
CliError::InsufficientBalance => "Not enough balance".into(),
CliError::InvalidAddress => "Invalid address format".into(),
CliError::InvalidAmount => "Invalid amount format".into(),
CliError::InvalidPrivateKey { message } => format!("Invalid private key: {}", message),
CliError::NetworkError { message } => format!("Network error: {}", message),
CliError::ConfigFileError { message } => format!("Config error: {}", message),
_ => "An unexpected error occurred".into(),
}
}
```
## Best Practices
### 1. Always Match on Specific Errors
```rust theme={null}
match sdk.shield(token, amount) {
Ok(result) => println!("Success: {}", result.tx_hash),
Err(CliError::InsufficientBalance) => {
eprintln!("Not enough balance. Check with: privacy-boost balance --token {}", token);
}
Err(e) => eprintln!("Error: {}", user_message(&e)),
}
```
### 2. Log Errors for Debugging
```rust theme={null}
match sdk.shield(token, amount) {
Ok(result) => println!("Deposit TX: {}", result.tx_hash),
Err(e) => {
eprintln!("[DEBUG] {:?}", e);
eprintln!("{}", user_message(&e));
}
}
```
### 3. Handle Network Errors with Retry
Network errors are transient. Use the retry pattern for automated scripts.
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [Commands Reference](./commands)
* [Error Handling Concepts](/sdk/concepts/error-handling)
# Network presets
Source: https://docs.privacyboost.io/sdk/cli/guides/network-presets
# Network Presets
The Privacy Boost CLI includes built-in network presets for common configurations.
## Available Presets
| Preset | Aliases | Server URL | RPC URL | Chain ID |
| - | - | - | - | - |
| `local` | `localhost`, `dev` | Environment or `http://localhost:8080` | Environment or `http://localhost:8545` | Discovered |
| `op-sepolia` | `optimism-sepolia`, `opsepolia` | `https://optimism.sepolia.privacyboost.io` | `https://sepolia.optimism.io` | 11155420 |
| `op-mainnet` | `optimism-mainnet`, `op`, `optimism` | `https://optimism.privacyboost.io` | `https://mainnet.optimism.io` | 10 |
| `base-mainnet` | `base` | `https://base.privacyboost.io` | `https://mainnet.base.org` | 8453 |
| `base-sepolia` | `basesepolia` | `https://base.sepolia.privacyboost.io` | `https://sepolia.base.org` | 84532 |
| `soneium-mainnet` | `soneium` | `https://soneium.privacyboost.io` | `https://rpc.soneium.org` | 1868 |
| `ink-mainnet` | `ink` | `https://ink.privacyboost.io` | `https://rpc-gel.inkonchain.com` | 57073 |
Preset names are case-insensitive. All hosted presets use WETH at `0x4200000000000000000000000000000000000006`. The chain IDs above identify the deployments; `chain_id` and `shield_contract_address` are auto-discovered from each server's `/api/v1/info` endpoint at runtime.
```bash theme={null}
privacy-boost --network ink-mainnet --app-id YOUR_APP_ID login
privacy-boost --network base-mainnet balances
privacy-boost --network base-sepolia balances
privacy-boost --network op-mainnet balances
privacy-boost --network soneium-mainnet balances
```
The Rust CLI library exposes the same presets:
```rust theme={null}
use privacy_boost_cli::{NetworkPreset, PrivacyBoost};
let mut config = NetworkPreset::InkMainnet.config();
config.app_id = Some("YOUR_APP_ID".into());
let sdk = PrivacyBoost::new(config)?;
```
Other hosted variants are `OpSepolia`, `OpMainnet`, `BaseMainnet`, `BaseSepolia`, and `SoneiumMainnet`.
## Custom Configuration
### Configuration File
Create a custom configuration at `~/.privacy-boost/config.toml`:
```toml theme={null}
# Custom network configuration
server_url = "https://my-indexer.example.com"
chain_id = 1
shield_contract_address = "0x..."
weth_contract_address = "0x4200000000000000000000000000000000000006"
rpc_url = "https://mainnet.infura.io/v3/YOUR_KEY"
timeout_secs = 60
```
Load automatically or specify path:
```bash theme={null}
# Uses ~/.privacy-boost/config.toml automatically
privacy-boost status
# Specify custom path
privacy-boost --config /path/to/config.toml status
```
### Environment Variables
Override any configuration with environment variables:
```bash theme={null}
export PRIVACY_BOOST_SERVER_URL="https://custom-indexer.com"
export PRIVACY_BOOST_CHAIN_ID=1
export PRIVACY_BOOST_RPC_URL="https://mainnet.infura.io/v3/YOUR_KEY"
export PRIVACY_BOOST_SHIELD_CONTRACT_ADDRESS="0x..."
export PRIVACY_BOOST_WETH_CONTRACT_ADDRESS="0x..."
privacy-boost status
```
### Priority Order
The CLI selects one configuration source, in this order:
1. `--network` (or `PRIVACY_BOOST_NETWORK`)
2. `--config` (or `PRIVACY_BOOST_CONFIG`)
3. `~/.privacy-boost/config.toml`
4. The local preset
Passing `--network` bypasses config files. Explicit field flags such as `--rpc-url`, and their corresponding `PRIVACY_BOOST_*` environment variables, override the selected source. A flag takes precedence over its environment variable.
## Initialize Configuration
Create a configuration file from a preset:
```bash theme={null}
# Create OP Sepolia config
privacy-boost init --network op-sepolia
# Or create Ink mainnet config
privacy-boost init --network ink-mainnet
# View created config
cat ~/.privacy-boost/config.toml
```
If `~/.privacy-boost/config.toml` already exists, `init` will skip and warn you. Delete the existing file first to reinitialize.
You can also import an existing config file as the default:
```bash theme={null}
privacy-boost init --config /path/to/my-config.toml
```
## Multiple Configurations
Use the `--config` flag to point at different config files per command:
```bash theme={null}
privacy-boost --config ~/.privacy-boost/mainnet.toml balances
privacy-boost --config ~/.privacy-boost/sepolia.toml balances
```
## RPC Provider Recommendations
### Public RPC Endpoints
| Network | Provider | URL |
| - | - | - |
| OP Sepolia | Optimism | `https://sepolia.optimism.io` |
| OP Sepolia | PublicNode | `https://optimism-sepolia.publicnode.com` |
### Private RPC Providers
For higher rate limits, use a dedicated RPC provider:
* **Alchemy**: `https://opt-sepolia.g.alchemy.com/v2/YOUR_KEY`
* **Infura**: `https://optimism-sepolia.infura.io/v3/YOUR_KEY`
## Chain ID Reference
| Network | Chain ID |
| - | - |
| OP Mainnet | 10 |
| OP Sepolia | 11155420 |
| Ink Mainnet | 57073 |
| Base Mainnet | 8453 |
| Base Sepolia | 84532 |
| Soneium Mainnet | 1868 |
## Next Steps
* [Commands Reference](./commands) - All CLI commands
* [Scripting Guide](./scripting) - Automation examples
# Portal deposits
Source: https://docs.privacyboost.io/sdk/cli/guides/portal-deposits
# Portal Deposit Addresses
Create a reusable, public, exchange-style deposit address. Anyone can fund it
with standard ERC-20 transfers, and the funds are credited into your shielded
balance without revealing which account was credited. See
[Portal Deposit Addresses](/sdk/concepts/portal-deposits) for the concept and
trust model.
All commands require an authenticated session (`privacy-boost login`, or
`--private-key`).
The `portal` subcommands orchestrate the full lifecycle — including the EIP-7702
delegation and gasless registration — for you.
## Creating a portal
`portal create` derives a fresh portal address `E`, delegates it, registers the
owner binding, and publishes the discovery entry, in one call:
```bash theme={null}
privacy-boost portal create
```
Output:
```
Portal created:
address: 0xPortalDepositAddressE
index: 0
bindH: 0xabc...def
```
Share the **address** publicly — that is where senders deposit. Pin a derivation
index with `--index ` to re-derive a specific portal, or `--no-publish` to
register and delegate without publishing the discovery entry (an escape-hatch /
custodial setup; deposits swept into it rest uncredited and reclaimable).
## Listing portals and status
```bash theme={null}
privacy-boost portal list
privacy-boost portal status 0xPortalDepositAddressE
```
## Receiving and listing deposits
A sender funds your portal with an ordinary ERC-20 transfer to its address — they
need nothing from Privacy Boost. List the deposits observed at a portal:
```bash theme={null}
privacy-boost portal deposits 0xPortalDepositAddressE
```
Each deposit is `pending_sweep`, `credited`, `cancellable`, `cancelled`, or
`unknown`.
The credited amount is the swept amount minus the sweep fee snapshotted at
sweep time, which is zero on a deployment that has not set a rate.
## Sweeping (self-service backstop)
Sweeping is permissionless and deployments normally run a keeper, so you rarely
sweep yourself. The command is available as a backstop — it sweeps a token's
balance at the portal into the pool:
```bash theme={null}
privacy-boost portal sweep 0xPortalDepositAddressE 1
```
(the trailing `1` is the registered token id.)
## Reclaiming an un-credited deposit
Reclaim a swept-but-uncredited deposit after the cancel delay; funds return to
the portal address `E`. Use the `portalDepositId` from `portal deposits`:
```bash theme={null}
privacy-boost portal reclaim 0xPortalDepositId
```
## Withdrawing raw funds (escape hatch)
For funds resting at `E` that can never be swept (for example an unregistered
token), `portal withdraw` builds a **signed raw EIP-1559 transaction** and prints
it for you to broadcast. Read the nonce, gas limit, and fees from your RPC:
```bash theme={null}
privacy-boost portal withdraw \
0 \
0x...token-address \
0xDestination \
1000000000000000000 \
```
(The first argument `0` is the portal's seed-derivation index.)
## Next steps
* [Claimable Transfers Guide](./claimable-transfers)
* [Deposits Guide](./deposits)
* [Session Storage](./session-storage)
# Scripting
Source: https://docs.privacyboost.io/sdk/cli/guides/scripting
# Scripting Guide
Automate Privacy Boost operations with shell scripts and batch processing.
## JSON Output
Use `--output json` (or `-o json`) for machine-readable output:
```bash theme={null}
# Get all balances as JSON
privacy-boost balances --output json
# Parse with jq
privacy-boost balances -o json | jq '.[] | select(.symbol == "WETH")'
# Get specific field
privacy-boost status -o json | jq -r '.privacy_address'
```
## Environment Setup
### Configuration Script
Create a setup script:
```bash theme={null}
#!/bin/bash
# setup-env.sh
export NETWORK="${1:-op-sepolia}"
case $NETWORK in
op-sepolia)
export SERVER_URL="https://optimism.sepolia.privacyboost.io"
export RPC_URL="https://sepolia.optimism.io"
export WETH_CONTRACT_ADDRESS="0x4200000000000000000000000000000000000006"
;;
esac
echo "Environment configured for $NETWORK"
```
Usage:
```bash theme={null}
source setup-env.sh op-sepolia
```
### Loading the Private Key
The CLI takes the key from one of three places, best first:
1. **Interactive prompt.** Leave both `--private-key` and `PRIVATE_KEY` unset and
the CLI prompts for the key with echo disabled, so it never reaches argv,
shell history, or the terminal scrollback. Use this for anything you run by
hand. The prompt reads the controlling terminal directly, so it is not
available under CI and cannot be fed from a pipe.
2. **`PRIVATE_KEY` environment variable.** The non-interactive fallback. The CLI
accepts it and prints a warning at startup.
3. **`--private-key` on the command line.** Avoid it. Arguments are readable by
every process on the machine through `ps`, and they land in shell history and
process accounting logs. The flag is hidden from `--help` for that reason.
None of these encrypt the key at rest. A key kept in a file is a plaintext
secret that grants full control of the account, so restrict it to your own user
and prefer a real secret store wherever you have one.
```bash theme={null}
#!/bin/bash
# load-key.sh -- source this, do not execute it; it exports PRIVATE_KEY.
# Already provided (CI secret, systemd credential, and so on).
if [ -n "${PRIVATE_KEY:-}" ]; then
return 0 2>/dev/null || exit 0
fi
# A password manager keeps the key off disk in the clear. Prefer this.
if command -v pass &> /dev/null; then
PRIVATE_KEY=$(pass show crypto/privacy-boost)
export PRIVATE_KEY
return 0 2>/dev/null || exit 0
fi
# Plaintext file fallback. Create it owner-only:
# mkdir -p ~/.privacy-boost && chmod 700 ~/.privacy-boost
# touch ~/.privacy-boost/key && chmod 600 ~/.privacy-boost/key
KEY_FILE="${HOME}/.privacy-boost/key"
if [ -f "$KEY_FILE" ]; then
# Refuse a group- or world-readable key file rather than silently using it.
PERMS=$(stat -f '%Lp' "$KEY_FILE" 2>/dev/null || stat -c '%a' "$KEY_FILE")
if [ "$PERMS" != "600" ]; then
echo "Refusing to read $KEY_FILE: mode $PERMS, want 600" >&2
return 1 2>/dev/null || exit 1
fi
PRIVATE_KEY=$(cat "$KEY_FILE")
export PRIVATE_KEY
return 0 2>/dev/null || exit 0
fi
echo "No key available. Run privacy-boost interactively to be prompted." >&2
return 1 2>/dev/null || exit 1
```
## Automation Scripts
### Daily Deposit Script
```bash theme={null}
#!/bin/bash
# daily-deposit.sh
set -e
source setup-env.sh mainnet
source load-key.sh
TOKEN="0x4200000000000000000000000000000000000006" # WETH
AMOUNT="0.1"
# Login
# PRIVATE_KEY is already exported; do not repeat it on the command line.
privacy-boost login
# Check balance
BALANCE=$(privacy-boost balance --token "$TOKEN" --output json | jq -r '.shielded_balance')
echo "Shielded balance: $BALANCE"
# Deposit
echo "Depositing $AMOUNT..."
TX=$(privacy-boost shield --token "$TOKEN" --amount "$AMOUNT" --human --output json | jq -r '.tx_hash')
echo "Deposit TX: $TX"
# Logout
privacy-boost logout
```
### Batch Transfer Script
```bash theme={null}
#!/bin/bash
# batch-transfer.sh
set -e
TRANSFERS_FILE="${1:-transfers.csv}"
TOKEN="0x4200000000000000000000000000000000000006"
# CSV format: privacy_address,amount
# 0x1234...,0.1
# 0x5678...,0.2
source setup-env.sh mainnet
source load-key.sh
# PRIVATE_KEY is already exported; do not repeat it on the command line.
privacy-boost login
# Read and process transfers
while IFS=',' read -r recipient amount; do
# Skip header or empty lines
[[ "$recipient" == "privacy_address" ]] && continue
[[ -z "$recipient" ]] && continue
echo "Sending $amount to $recipient..."
TX=$(privacy-boost send \
--token "$TOKEN" \
--amount "$amount" \
--recipient "$recipient" \
--output json | jq -r '.tx_hash')
echo " TX: $TX"
# Small delay between transactions
sleep 2
done < "$TRANSFERS_FILE"
privacy-boost logout
echo "Batch complete!"
```
### Monitor Balance Script
```bash theme={null}
#!/bin/bash
# monitor-balance.sh
TOKEN="${1:-0x4200000000000000000000000000000000000006}"
THRESHOLD="${2:-1.0}"
INTERVAL="${3:-300}" # 5 minutes
source setup-env.sh mainnet
source load-key.sh
# PRIVATE_KEY is already exported; do not repeat it on the command line.
privacy-boost login
while true; do
BALANCE=$(privacy-boost balance --token "$TOKEN" --output json | jq -r '.shielded_balance')
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$TIMESTAMP] Shielded balance: $BALANCE wei"
sleep "$INTERVAL"
done
```
## Error Handling
### Retry Logic
```bash theme={null}
#!/bin/bash
# retry.sh
MAX_RETRIES=3
RETRY_DELAY=5
retry() {
local n=1
local cmd="$@"
until [ $n -gt $MAX_RETRIES ]; do
echo "Attempt $n: $cmd"
if eval "$cmd"; then
return 0
fi
echo "Failed, waiting ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
((n++))
done
echo "Failed after $MAX_RETRIES attempts"
return 1
}
# Usage
retry "privacy-boost shield --token $TOKEN --amount 1.0 --human"
```
### Error Notifications
```bash theme={null}
#!/bin/bash
# with-notification.sh
notify_error() {
local message="$1"
# Slack webhook
if [ -n "$SLACK_WEBHOOK" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Privacy Boost Error: $message\"}" \
"$SLACK_WEBHOOK"
fi
# Email (using mail command)
# echo "$message" | mail -s "Privacy Boost Error" admin@example.com
}
# Trap errors
trap 'notify_error "Script failed at line $LINENO"' ERR
# Your operations
privacy-boost shield --token "$TOKEN" --amount 1.0 --human
```
## Cron Jobs
### Daily Balance Report
```cron theme={null}
# Run every day at 9 AM
0 9 * * * /path/to/balance-report.sh >> /var/log/privacy-boost.log 2>&1
```
```bash theme={null}
#!/bin/bash
# balance-report.sh
source /path/to/setup-env.sh mainnet
source /path/to/load-key.sh
# PRIVATE_KEY is already exported; do not repeat it on the command line.
privacy-boost login
echo "=== Daily Balance Report ==="
echo "Date: $(date)"
echo ""
privacy-boost balances
privacy-boost logout
```
### Auto-Deposit on Low Balance
```cron theme={null}
# Check every hour
0 * * * * /path/to/auto-deposit.sh >> /var/log/privacy-boost.log 2>&1
```
```bash theme={null}
#!/bin/bash
# auto-deposit.sh
MIN_SHIELDED="0.5" # ETH
DEPOSIT_AMOUNT="1.0"
TOKEN="0x4200000000000000000000000000000000000006"
source /path/to/setup-env.sh mainnet
source /path/to/load-key.sh
# PRIVATE_KEY is already exported; do not repeat it on the command line.
privacy-boost login
BALANCE=$(privacy-boost balance --token "$TOKEN" --output json | jq -r '.shielded_balance')
echo "$(date): Shielded balance: $BALANCE wei"
privacy-boost logout
```
## Integration Examples
### With GitHub Actions
```yaml theme={null}
# .github/workflows/daily-deposit.yml
name: Daily Deposit
on:
schedule:
- cron: '0 9 * * *'
workflow_dispatch:
jobs:
deposit:
runs-on: ubuntu-latest
steps:
- name: Download CLI
run: |
curl -L https://github.com/sunnyside-io/privacy-boost-sdk/releases/latest/download/privacy-boost-linux-x64.tar.gz | tar xz
chmod +x privacy-boost
- name: Run Deposit
env:
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
SERVER_URL: ${{ vars.SERVER_URL }}
RPC_URL: ${{ secrets.RPC_URL }}
run: |
# The CLI reads PRIVATE_KEY from the environment. Passing it as an
# argument instead would expose it to `ps` on the runner.
./privacy-boost login
./privacy-boost shield --token ${{ vars.TOKEN }} --amount 1000000000000000000
```
## Next Steps
* [Commands Reference](./commands) - All CLI commands
* [API Reference](../api-reference) - Library API documentation
# Session storage
Source: https://docs.privacyboost.io/sdk/cli/guides/session-storage
# Session Storage
This guide covers session persistence so you don't need to re-authenticate on every run using the Privacy Boost CLI.
## Automatic Session Persistence
After `privacy-boost login`, the CLI automatically saves the session. Subsequent commands use the saved session:
```bash theme={null}
# First time — requires private key
privacy-boost login --private-key $PRIVATE_KEY
# Subsequent commands — no private key needed
privacy-boost balance --token $TOKEN
privacy-boost shield --token $TOKEN --amount 1.0 --human
```
## Programmatic Session Export/Import (Rust)
### Export Session
```rust theme={null}
if let Some(session) = sdk.export_session() {
let json = serde_json::to_string(&session)?;
std::fs::write("session.json", json)?;
println!("Session saved");
}
```
### Import Session
```rust theme={null}
let json = std::fs::read_to_string("session.json")?;
let session: ExportedSession = serde_json::from_str(&json)?;
match sdk.import_session(&session) {
Ok(true) => println!("Session restored"),
Ok(false) => println!("Session invalid or expired"),
Err(e) => println!("Import failed: {:?}", e),
}
```
### ExportedSession
```rust theme={null}
pub struct ExportedSession {
pub wallet_public_key_x: String,
pub wallet_public_key_y: String,
pub viewing_key: String,
pub viewing_public_key_x: String,
pub viewing_public_key_y: String,
pub nullifying_key: String,
pub nullifying_public_key_x: String,
pub nullifying_public_key_y: String,
pub mpk: String,
pub account_id: String,
pub jwt: String,
pub jwt_expiry: u64,
pub wallet_address: String,
}
```
## Configuration File
The CLI stores configuration at the default path:
```rust theme={null}
let config_path = CliConfig::default_config_path()?;
println!("Config at: {:?}", config_path);
// Save config
config.save_to_default()?;
// Load config
let config = CliConfig::load_from_default()?;
```
## Logout
```bash theme={null}
# End session, clear saved state
privacy-boost logout
```
| Method | What it does | Next login |
| - | - | - |
| `logout()` | Clears everything | Full re-auth required |
| `clear_session()` | Clears JWT only | Quick re-auth (no signing) |
```rust theme={null}
// Full logout
sdk.logout();
// Quick session clear
sdk.clear_session();
```
Session files contain private keys. Store them securely and restrict file permissions.
## Best Practices
### 1. Protect Session Files
```bash theme={null}
chmod 600 session.json
```
### 2. Handle Expired Sessions
```rust theme={null}
match sdk.import_session(&session) {
Ok(true) => println!("Session restored"),
Ok(false) | Err(_) => {
// Re-authenticate
sdk.authenticate(&private_key, None, None)?;
}
}
```
### 3. Clean Up on Exit
```rust theme={null}
// Delete session file on logout
sdk.logout();
std::fs::remove_file("session.json").ok();
```
## Next Steps
* [Wallet Integration](./wallet-integration)
* [Error Handling](./error-handling)
* [Network Presets](./network-presets)
* [Commands Reference](./commands)
# Transactions
Source: https://docs.privacyboost.io/sdk/cli/guides/transactions
# Transaction History
This guide covers querying and filtering transaction history using the Privacy Boost CLI.
## CLI Command
```bash theme={null}
# All transactions
privacy-boost history
# Filter by type (shield=deposit, unshield=withdraw, transact=transfer)
privacy-boost history --tx-type shield
privacy-boost history --tx-type unshield
privacy-boost history --tx-type transact
# Filter by token
privacy-boost history --token 0x4200000000000000000000000000000000000006
# Limit results
privacy-boost history --limit 10
```
Output:
```
Type Token Amount Direction Date
deposit WETH 1.0 ETH in 2024-01-15 10:30:00
transfer WETH 0.5 ETH out 2024-01-15 11:00:00
withdraw WETH 0.25 ETH out 2024-01-15 12:00:00
```
## Programmatic Usage (Rust)
### Basic Usage
```rust theme={null}
let transactions = sdk.get_transaction_history(None, None, None)?;
for tx in &transactions {
println!("{}: {} - {}", tx.tx_type, tx.amount, tx.tx_hash);
}
```
### Filtering
```rust theme={null}
// Only deposits
let deposits = sdk.get_transaction_history(Some("deposit"), None, None)?;
// Only withdrawals
let withdrawals = sdk.get_transaction_history(Some("withdraw"), None, None)?;
// Last 10 transactions for a specific token
let recent = sdk.get_transaction_history(
None,
Some("0x4200000000000000000000000000000000000006"),
Some(10),
)?;
```
### Transaction Type
```rust theme={null}
pub struct Transaction {
pub tx_hash: String,
pub tx_type: String, // "deposit", "withdraw", or "transfer"
pub token_address: String,
pub amount: String, // Amount in wei
pub direction: String, // "in" or "out"
pub sender_pub_key: String,
pub receiver_pub_keys: Vec,
pub created_at: u64, // Unix timestamp
}
```
## Filter Parameters
| Parameter | Type | Description |
| - | - | - |
| `tx_type` | `Option<&str>` | Filter by type: `"deposit"`, `"withdraw"`, `"transfer"` |
| `token_address` | `Option<&str>` | Filter by token contract address |
| `limit` | `Option` | Maximum number of results |
## Error Handling
```rust theme={null}
match sdk.get_transaction_history(None, None, None) {
Ok(transactions) => {
for tx in &transactions {
println!("{}: {}", tx.tx_type, tx.amount);
}
}
Err(CliError::NotAuthenticated) => println!("Please login first"),
Err(CliError::NetworkError { message }) => println!("Network error: {}", message),
Err(e) => println!("Failed to get history: {:?}", e),
}
```
## Best Practices
### 1. Use Limits for Large Histories
Always use the `--limit` flag or `limit` parameter to avoid loading too many transactions at once.
### 2. Refresh After Operations
Check transaction history after deposits, withdrawals, or transfers to verify completion.
## Next Steps
* [Balances Guide](./balances)
* [Deposits Guide](./deposits)
* [Error Handling](./error-handling)
* [Commands Reference](./commands)
# Transfers
Source: https://docs.privacyboost.io/sdk/cli/guides/transfers
# Private Transfers
This guide covers sending tokens privately between privacy addresses using the Privacy Boost CLI.
## CLI Command
```bash theme={null}
privacy-boost send \
--token 0x4200000000000000000000000000000000000006 \
--amount 0.1 \
--recipient 0x04...194-char-privacy-address \
--human
```
Output:
```
Building proof... (this may take a moment)
Proof generated successfully
Transfer submitted!
Transaction: 0x9abc...def0
Amount: 0.1 ETH
Recipient: 0x04ab...ef01 (privacy address)
```
## Command Parameters
| Flag | Required | Description |
| - | - | - |
| `--token` | Yes | Token contract address |
| `--amount` | Yes | Amount to send |
| `--recipient` | Yes | Recipient's 194-character privacy address |
| `--human` | No | Interpret amount as human-readable (default: wei) |
## Paying Several Recipients at Once
`send-batch` pays every recipient from one set of input notes, in a single
request: one fee instead of one per recipient, and one epoch slot instead of
several. Repeat `--to` once per recipient.
```bash theme={null}
privacy-boost send-batch \
--token 0x4200000000000000000000000000000000000006 \
--to 0x04...alice:0.1 \
--to 0x04...bob:0.25 \
--human
```
| Flag | Required | Description |
| - | - | - |
| `--token` | Yes | Token contract address |
| `--to` | Yes | `:`, repeatable. Recipient is a 194-character privacy address or a 42-character Ethereum address |
| `--human` | No | Interpret amounts as human-readable (default: wei) |
| `--decimals` | No | Decimals for `--human` parsing (default: 18) |
The whole batch resolves to one request id, printed the same way `send` prints
its result.
### How many recipients fit
The server proves a fixed set of `(inputs, outputs)` shapes, and a transfer has
to fit inside one of them. Its output count is your recipient count plus a
change note, so a longer recipient list leaves room for fewer input notes:
| Outputs (recipients + change) | Input notes that can be spent |
| - | - |
| 1-2 | up to 15 |
| 3-11 | up to 4 |
So 5 inputs by 3 outputs is not provable, even though 15 inputs and 11 outputs
are each individually fine. In practice: past two recipients, the transfer has
to be coverable by at most four notes. Consolidate fragmented notes first if a
batch is rejected with `UNSUPPORTED_TRANSFER_SHAPE`, or split the payout across
two batches.
## Programmatic Usage (Rust)
```rust theme={null}
let result = sdk.send(
"0x4200000000000000000000000000000000000006",
"100000000000000000", // 0.1 tokens in wei
"0x04...recipient-privacy-address",
)?;
println!("Transfer TX: {}", result.tx_hash);
println!("Fee: {}", result.fee);
```
Multi-recipient sends take `(recipient, amount)` pairs:
```rust theme={null}
let result = sdk.send_batch(
"0x4200000000000000000000000000000000000006",
&[
("0x04...alice".to_string(), "100000000000000000".to_string()),
("0x04...bob".to_string(), "250000000000000000".to_string()),
],
)?;
```
### TransferResult
```rust theme={null}
pub struct TransferResult {
pub tx_hash: String,
pub fee: String,
}
```
## Privacy Address Validation
Validate the recipient address before sending:
```rust theme={null}
if !sdk.is_valid_privacy_address(recipient) {
println!("Invalid privacy address");
return;
}
let result = sdk.send(token_address, amount, recipient)?;
```
## Address Lookup
Look up a user's privacy address:
```rust theme={null}
let identity = sdk.resolve_identity("0x...wallet-address")?;
println!("Privacy address: {}", identity.privacy_address);
sdk.send(token_address, amount, &identity.privacy_address)?;
```
## Error Handling
```rust theme={null}
match sdk.send(token_address, amount, recipient_privacy_address) {
Ok(result) => println!("Transfer TX: {}", result.tx_hash),
Err(CliError::InsufficientBalance) => println!("Not enough shielded balance"),
Err(CliError::InvalidAddress) => println!("Invalid privacy address"),
Err(CliError::NotAuthenticated) => println!("Please login first"),
Err(CliError::NetworkError { message }) => println!("Network error: {}", message),
Err(e) => println!("Transfer failed: {:?}", e),
}
```
## Best Practices
### 1. Validate Addresses Before Sending
Always validate the privacy address before attempting a transfer.
### 2. Check Balance After Transfer
```bash theme={null}
privacy-boost send --token $TOKEN --amount 0.1 --recipient $PRIVACY_ADDR --human
privacy-boost balance --token $TOKEN
```
## Next Steps
* [Deposits Guide](./deposits)
* [Balances Guide](./balances)
* [Transactions Guide](./transactions)
* [Error Handling](./error-handling)
# Wallet integration
Source: https://docs.privacyboost.io/sdk/cli/guides/wallet-integration
# Wallet Integration
This guide covers wallet connection and authentication for the Privacy Boost CLI.
## Private Key Authentication
The CLI uses private keys for authentication (no interactive wallet prompts):
```bash theme={null}
privacy-boost login --private-key $PRIVATE_KEY
```
Output:
```
Logged in!
Wallet Address: 0x1234...5678
Privacy Address: 0x9abc...def0
MPK: 0xfedc...ba98
Session saved! Future commands won't require --private-key.
```
## Programmatic Usage (Rust)
```rust theme={null}
use privacy_boost_cli::{PrivacyBoostCLI, CliConfig, AuthResult};
let config = CliConfig::new(
"https://optimism.sepolia.privacyboost.io".into(),
0,
String::new(),
"https://sepolia.optimism.io".into(),
);
let sdk = PrivacyBoostCLI::new(config)?;
let private_key = std::env::var("PRIVATE_KEY")?;
let result = sdk.authenticate(&private_key, None, None)?;
match result {
AuthResult::Authenticated(login) => {
println!("Wallet: {}", login.wallet_address);
println!("Privacy Address: {}", login.privacy_address);
println!("MPK: {}", login.mpk);
}
AuthResult::CredentialRequired(challenge) => {
let login = sdk.submit_credential(&credential)?;
println!("Privacy Address: {}", login.privacy_address);
}
}
```
## Key Sources
```rust theme={null}
use privacy_boost_cli::KeySource;
// Default: derive keys from wallet signature
sdk.authenticate(&private_key, None, None)?;
// From mnemonic
sdk.authenticate(&private_key, Some(KeySource::Mnemonic {
phrase: "your twelve word mnemonic phrase goes here ...".into(),
}), None)?;
// From raw seed (testing)
sdk.authenticate(&private_key, Some(KeySource::RawSeed {
hex_seed: "0x...".into(),
}), None)?;
```
## Network Presets
```rust theme={null}
use privacy_boost_cli::NetworkPreset;
// Use a network preset
let config = NetworkPreset::OpSepolia.config();
let sdk = PrivacyBoostCLI::new(config)?;
// Or local development
let config = NetworkPreset::Local.config();
```
Preset aliases:
* **Local**: `"local"`, `"localhost"`, `"dev"`
* **OpSepolia**: `"op-sepolia"`, `"optimism-sepolia"`, `"opsepolia"`
## Environment Configuration
```bash theme={null}
export SERVER_URL="https://optimism.sepolia.privacyboost.io"
export RPC_URL="https://sepolia.optimism.io"
export WETH_CONTRACT_ADDRESS="0x4200000000000000000000000000000000000006"
export PRIVATE_KEY="0x..."
```
## Logout
```bash theme={null}
# Full logout
privacy-boost logout
```
```rust theme={null}
sdk.logout(); // Clears all state
// Or just clear the JWT (keeps keys)
sdk.clear_session();
```
## Check Status
```bash theme={null}
privacy-boost status
```
Output:
```
Status
------
Connected: Yes
Authenticated: Yes
Wallet Address: 0x1234...5678
Privacy Address: 0x9abc...def0
MPK: 0xfedc...ba98
```
```rust theme={null}
let status = sdk.get_status();
println!("Connected: {}", status.connected);
println!("Authenticated: {}", status.authenticated);
```
## Error Handling
```rust theme={null}
match sdk.authenticate(&private_key, None, None) {
Ok(result) => { /* handle auth result */ }
Err(CliError::InvalidPrivateKey { message }) => {
println!("Invalid private key: {}", message);
}
Err(CliError::NetworkError { message }) => {
println!("Network error: {}", message);
}
Err(CliError::InvalidConfig { message }) => {
println!("Configuration error: {}", message);
}
Err(e) => println!("Auth failed: {:?}", e),
}
```
## Best Practices
### 1. Use Environment Variables for Keys
Never hardcode private keys. Use environment variables or config files.
### 2. Save Sessions
After authentication, sessions are saved automatically. Future commands don't need `--private-key`.
## Next Steps
* [Session Storage](./session-storage)
* [Deposits Guide](./deposits)
* [Network Presets](./network-presets)
* [Commands Reference](./commands)
# Withdrawals
Source: https://docs.privacyboost.io/sdk/cli/guides/withdrawals
# Withdrawals
This guide covers withdrawing tokens from your private balance back to a wallet address using the Privacy Boost CLI.
## CLI Command
```bash theme={null}
privacy-boost unshield \
--token 0x4200000000000000000000000000000000000006 \
--amount 0.5 \
--recipient 0x1234567890123456789012345678901234567890 \
--human
```
Output:
```
Building proof... (this may take a moment)
Proof generated successfully
Withdraw submitted!
Transaction: 0x5678...9abc
Amount: 0.5 ETH
Recipient: 0x1234...7890
```
## Command Parameters
| Flag | Required | Description |
| - | - | - |
| `--token` | Yes | ERC-20 token contract address |
| `--amount` | Yes | Amount to withdraw |
| `--recipient` | Yes | Recipient Ethereum address |
| `--human` | No | Interpret amount as human-readable (default: wei) |
## Programmatic Usage (Rust)
```rust theme={null}
let result = sdk.unshield(
"0x4200000000000000000000000000000000000006",
"500000000000000000", // 0.5 tokens in wei
"0x1234567890123456789012345678901234567890",
)?;
println!("Withdraw TX: {}", result.tx_hash);
println!("Fee: {}", result.fee);
```
### UnshieldResult
```rust theme={null}
pub struct UnshieldResult {
pub tx_hash: String,
pub fee: String,
}
```
## Withdraw to Self
Withdraw to the connected wallet:
```bash theme={null}
privacy-boost unshield \
--token $TOKEN \
--amount 0.5 \
--recipient $(privacy-boost status --json | jq -r .wallet_address) \
--human
```
Programmatically:
```rust theme={null}
let wallet = sdk.get_wallet_address().unwrap();
let result = sdk.unshield(token_address, amount, &wallet)?;
```
## Error Handling
```rust theme={null}
match sdk.unshield(token_address, amount, recipient) {
Ok(result) => println!("Withdraw TX: {}", result.tx_hash),
Err(CliError::InsufficientBalance) => println!("Not enough shielded balance"),
Err(CliError::InvalidAddress) => println!("Invalid recipient address"),
Err(CliError::NotAuthenticated) => println!("Please login first"),
Err(CliError::NetworkError { message }) => println!("Network error: {}", message),
Err(e) => println!("Withdrawal failed: {:?}", e),
}
```
## Best Practices
### 1. Check Shielded Balance First
```bash theme={null}
privacy-boost balance --token $TOKEN
privacy-boost unshield --token $TOKEN --amount 0.5 --recipient $ADDR --human
```
### 2. Use `--human` for Readability
Avoid mistakes with large wei numbers by using human-readable amounts.
## Next Steps
* [Deposits Guide](./deposits)
* [Transfers Guide](./transfers)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Installation
Source: https://docs.privacyboost.io/sdk/cli/installation
# Installation
This guide covers installing the Privacy Boost CLI tool.
## Prerequisites
* Rust 1.70 or later (for building from source)
* An Ethereum RPC endpoint
* A private key with funds for transactions
## Installation Methods
### From Pre-built Binary
Download the latest release for your platform:
```bash theme={null}
# macOS (Apple Silicon)
curl -L https://github.com/sunnyside-io/privacy-boost-sdk/releases/latest/download/privacy-boost-darwin-arm64.tar.gz | tar xz
sudo mv privacy-boost /usr/local/bin/
# macOS (Intel)
curl -L https://github.com/sunnyside-io/privacy-boost-sdk/releases/latest/download/privacy-boost-darwin-x64.tar.gz | tar xz
sudo mv privacy-boost /usr/local/bin/
# Linux (x86_64)
curl -L https://github.com/sunnyside-io/privacy-boost-sdk/releases/latest/download/privacy-boost-linux-x64.tar.gz | tar xz
sudo mv privacy-boost /usr/local/bin/
# Linux (ARM64)
curl -L https://github.com/sunnyside-io/privacy-boost-sdk/releases/latest/download/privacy-boost-linux-arm64.tar.gz | tar xz
sudo mv privacy-boost /usr/local/bin/
```
### From Source (Currently unavailable)
Build from the repository:
```bash theme={null}
# Clone repository
git clone https://github.com/sunnyside-io/privacy-boost-sdk.git
cd privacy-boost
# Build CLI using Make
make cli
# Binary will be at dist/cli/privacy-boost
./dist/cli/privacy-boost --version
```
### Cargo Install (Currently unavailable)
To install the privacy-boost crate from crates.io:
```bash theme={null}
cargo install privacy-boost-cli
```
## Verify Installation
```bash theme={null}
privacy-boost --version
# privacy-boost 0.1.0
privacy-boost --help
```
## Configuration
### Configuration File
Create a configuration file at `~/.privacy-boost/config.toml`:
```toml theme={null}
# Network configuration
server_url = "https://optimism.sepolia.privacyboost.io"
weth_contract_address = "0x4200000000000000000000000000000000000006"
rpc_url = "https://sepolia.optimism.io"
timeout_secs = 30
```
### Environment Variables
Override configuration with environment variables:
```bash theme={null}
export SERVER_URL="https://optimism.sepolia.privacyboost.io"
export RPC_URL="https://sepolia.optimism.io"
export WETH_CONTRACT_ADDRESS="0x4200000000000000000000000000000000000006"
export PRIVATE_KEY="0x..."
```
## Quick Example
```bash theme={null}
# Login with private key
privacy-boost login --private-key $PRIVATE_KEY
# Check status
privacy-boost status
# Check balance
privacy-boost balance --token 0x...
# Deposit tokens
privacy-boost shield --token 0x... --amount 1000000000000000000
# Send privately
privacy-boost send --token 0x... --amount 500000000000000000 --recipient 0x04...
# Withdraw
privacy-boost unshield --token 0x... --amount 250000000000000000 --recipient 0x...
# View history
privacy-boost history
```
## Commands
| Command | Description |
| - | - |
| `init` | Initialize configuration file |
| `login` | Connect wallet, derive privacy keys, and authenticate |
| `logout` | Clear session |
| `status` | Show connection status |
| `balance` | Check a token balance |
| `balances` | Check all token balances |
| `deposit` | Deposit tokens to private balance |
| `withdraw` | Withdraw to public address |
| `send` | Send private transfer |
| `history` | View transaction history |
| `sessions` | List saved sessions |
| `resolve-identity` | Look up identity by address or MPK |
## Next Steps
* [Getting Started](./getting-started) - Basic CLI usage
# App Setup
Source: https://docs.privacyboost.io/sdk/concepts/app-setup
Register your app with Privacy Boost and choose an authentication method
# App Setup
Before writing any SDK code, you need two things: an **App ID** and an **authentication method**. These are configured once when you register your app with Privacy Boost.
## Getting an App ID
The App ID is a unique identifier for your application (e.g., `app_abc123xyz`). You pass it to the SDK at initialization, and the backend uses it to identify your app, apply rate limits, and enforce access controls.
To get one, contact the Privacy Boost team with your application name and use case.
## Choosing an Authentication Method
When your app is created, you choose how users authenticate. This controls what credentials are required when the SDK logs in a user.
### Which method should I use?
| Your situation | Auth method | Backend required? | Guide |
| - | - | - | - |
| Development, testing, or prototyping | **Direct** | No | Built-in — just call `authenticate()` |
| Your backend manages users, no third-party auth | **API Secret** | Yes | [Setup guide](/sdk/guides/api-secret) |
| You use [Privy](https://www.privy.io) for login | **Privy** | No | [Setup guide](/sdk/guides/privy) |
| You use [Dynamic](https://www.dynamic.xyz) for login | **Dynamic** (Custom JWT) | No | [Setup guide](/sdk/guides/dynamic) |
| You use Auth0, Firebase, Supabase, Clerk, or another OIDC provider | **Custom JWT** | No | [Setup guide](/sdk/guides/custom-jwt) |
Direct authentication does not verify who is calling your app. Use one of the other methods in production if you need to control access.
API Secret requires a [token provider](/sdk/concepts/authentication#token-providers) — a function in your SDK code that routes authentication through your backend, keeping the secret out of client-side code. Privy, Dynamic, and Custom JWT do not require a backend — you configure your JWKS URL with Privacy Boost, and the server validates tokens directly.
## How Authentication Works Under the Hood
Every `authenticate()` call goes through two steps:
1. **App verification** — The backend checks your app's credentials (App ID only, API secret, or JWT depending on your method). This confirms the request comes from a legitimate app.
2. **Key verification** — The SDK proves the user owns their privacy keys by signing a cryptographic challenge. This happens automatically inside `authenticate()`.
For direct auth, both steps are handled entirely by the SDK. For all other methods, step 1 routes through your backend via a [token provider](/sdk/concepts/authentication#token-providers).
## Next Steps
Initialize the SDK with your App ID and endpoints
The authenticate() flow, wallet popups, and session management
# Authentication
Source: https://docs.privacyboost.io/sdk/concepts/authentication
Connect a wallet, derive keys, and log in to Privacy Boost
# Authentication
Authentication is a single `signIn()` call: the user signs one message with their wallet and the SDK obtains a session from the server. After sign-in, the user has a **privacy address** — a public identifier (separate from their Ethereum address) that others use to send them private transfers — and your app can read their balances and history. The user's privacy keys are not derived until something needs them: the first send, withdrawal, or shield unlocks the vault on demand, and a wallet-signature vault does that with one more wallet signature. See [Key Management](/sdk/concepts/key-management) for details on key derivation.
Your app manages one thing, the session. It never manages whether keys are in memory.
## Choosing an Auth Method
Before integrating authentication, choose how your app verifies users. See the [auth method comparison table](/sdk/concepts/app-setup#which-method-should-i-use) in App Setup.
For development, the SDK authenticates directly — no backend needed. For production, you'll route authentication through your backend using a [token provider](#token-providers).
## User Experience
From the user's perspective, authentication looks like this:
1. **Your app calls `signIn()`**
2. **User signs one message** in their wallet
3. **User is signed in** — balances and history load, with no further prompts
The first action that needs keys (a send, a withdrawal, a shield, a new portal address) unlocks the vault at that moment, through the same wallet or, for a PIN vault, through a prompt your app provides. Reads never prompt: opening a screen or polling in the background can never pop a wallet.
### Wallet Popups
| Moment | What the user sees | Popups |
| - | - | - |
| Sign-in | "Sign message" popup | 1 |
| Returning user with a saved session | Nothing | 0 |
| First write after sign-in, `walletDerived` vault | "Sign message" popup (vault backup) | 1 |
| First write after sign-in, `mnemonic` or `rawSeed` vault | Nothing extra (keys come from the phrase or seed) | 0 |
| First-ever shield on this chain | "Sign typed data" popup (registers an authorization key on-chain) | 1 |
The sign-in message names the deployment (chain and pool) and states that it authorizes access to the account's financial data and to editing contacts and labels. Servers that predate view sessions still accept the older message; against them `signIn()` unlocks the vault immediately, so every read works either way.
### Returning Users
When a user returns and their session is still saved, `signIn()` signs nothing: the saved session reads the account straight away. Keys are never persisted by the session; the first write unlocks as usual. Persistence is on by default and can be turned off with `session: { persist: false }`.
Approval-only accounts are an exception: a new device, or a device without resident keys, signs once for login and once to authorize the one-time escrow export. The export signature is bound to a short-lived, single-use nonce; an unexpired bearer token alone cannot release permanent keys.
### Locked and unlocked
`sdk.auth.state` is the one thing to render from:
| State | Meaning |
| - | - |
| `{ status: 'signedOut' }` | No session. Reads and writes fail with `NOT_AUTHENTICATED`; show sign-in. |
| `{ status: 'signedIn', locked: true, account }` | The account can be read. No keys are in memory; the next write unlocks. |
| `{ status: 'signedIn', locked: false, account }` | Keys are in memory. |
Subscribe to `sdk.on('authChange', state => ...)` to follow it. In React, `useAuth().status` reports `'locked'` for a signed-in session whose keys aren't resident — gate your signed-in shell on that too, not only on `'authenticated'`. `auth.lock()`, and the idle or absolute auto-lock configured with `idleTtlSecs` and `absoluteTtlSecs`, drop the keys and keep the session: balances stay on screen and the next write prompts again. `auth.signOut()` ends everything.
## Basic Usage
```typescript TypeScript theme={null}
const outcome = await sdk.auth.signIn(walletAdapter);
if (outcome.status === 'signedIn') {
console.log(outcome.account.privacyAddress);
}
// A brand-new account can resolve 'setupRequired' instead; see "New accounts" below.
const balances = await sdk.vault.getAllBalances(); // reads never prompt
await sdk.vault.send(params); // unlocks first if needed
```
```swift iOS theme={null}
let wallet = MyWalletDelegate()
// For a PIN vault or custom onboarding UI, install the handlers first with
// sdk.setPrompts(credential:recovery:accountCreation:) — each is optional and
// a wallet-signature vault needs none. `signIn` takes no prompts argument.
let result = try await sdk.signIn(wallet: wallet)
print(result.account.privacyAddress)
print(result.locked) // true: reads work, the first send unlocks
```
```kotlin Android theme={null}
val wallet = MyWalletDelegate()
// For a PIN vault or custom onboarding UI, install the handlers first with
// sdk.setPrompts(credential, recovery, accountCreation) — each is optional and
// a wallet-signature vault needs none. `signIn` takes no prompts argument.
val result = sdk.signIn(wallet)
println(result.account.privacyAddress)
println(result.locked) // true: reads work, the first send unlocks
```
```bash CLI theme={null}
export PRIVACY_BOOST_APP_ID=app_abc123xyz
privacy-boost login
```
## Approval-only EOA multichain authentication
Set `approvalOnly: true` on a root SDK instance. The SDK discovers `chainId` and the chain's AuthRegistry from that server's `/api/v1/info`; applications must not supply an AuthRegistry address.
An EOA has exactly one global approval-only identity. Every chain must contain the account derived from that identity's same owner and salt. Deployments fail closed with a conflict if an EOA has multiple approval-only accounts or if a chain contains a different account ID; such accounts require an operator-assisted migration and cannot be selected through this API.
When the identity exists globally but not on the current chain, authentication returns a normal EOA transaction request:
```typescript theme={null}
let result = await sdk.auth.authenticate(adapter);
if (result.status === 'accountCreationRequired') {
const currentChain = await adapter.getChainId();
if (BigInt(currentChain) !== BigInt(result.createAccountTransaction.chainId)) {
await switchWalletChain(result.createAccountTransaction.chainId);
}
await adapter.sendTransaction(result.createAccountTransaction);
await waitForAccountCreatedIndexing(result.accountId);
result = await sdk.auth.authenticate(adapter); // obtains a fresh basic session
}
if (result.status !== 'authenticated') {
// Handle credentialRequired, mnemonicGenerated, and recoveryRequired.
throw new Error(`Authentication incomplete: ${result.status}`);
}
```
The SDK deliberately clears the bootstrap JWT before returning `accountCreationRequired`, so retrying after a slow wallet transaction or indexer delay always performs a fresh login.
### Multiple chains
Approval-only mode currently uses one root SDK per chain; `forChain()` rejects this mode because approval plans and submission state are owned by the root KeyVault. For each chain:
1. Create a root SDK with that chain's `serverUrl` and `approvalOnly: true`.
2. Switch the wallet to the server-advertised chain before submitting a prepared transaction.
3. Authenticate and retain that root only while using the chain.
4. Call `logout()` and `dispose()` before discarding it. Do not share a root instance or its session between chains.
### Approval-only trust model
Approval-only multichain recovery is an explicit exception to split-key vault confidentiality. The TEE stores a server-decryptable viewing key and can read the nullifying key associated with the identity. Always Encrypted protects these values from a SQL operator, but a compromised TEE process can recover them and becomes the account's privacy root. These keys reveal transaction history and ownership relationships but do not authorize spends: approval-only accounts still require an on-chain approval for every spend.
## PIN / Password Unlock
If the vault is protected by a PIN or password (`persistence.unlock` set to `'pin'` or `'password'`), the SDK asks for it through the `prompts.credential` handler you configure, at the moment it needs it:
* **First time** — when the vault is created, to encrypt it
* **Returning user** — the first write after sign-in, to decrypt it
The handler receives `action` (`'setup'` or `'unlock'`), `unlockType`, and `attempt`, which counts up on each wrong entry so your dialog can say "try again". The SDK does the re-asking, so your handler needs no loop of its own; after ten attempts it stops and the unlock fails with `INVALID_CREDENTIAL`. Without a handler, a PIN vault fails with `CREDENTIAL_REQUIRED`. Reject with an error whose `code` is `USER_CANCELLED` when the user dismisses the dialog; the action is dropped and the app's input is untouched.
### Where to register prompts
`PrivacyBoostConfig.prompts` is convenient when you have the handlers at construction. Often you don't: the UI that answers a prompt mounts *after* the SDK is created, changes with locale or route, and remounts. Register them where they're rendered instead:
```typescript theme={null}
await sdk.auth.setPrompts({ credential: askPin }); // callable at any time
await sdk.auth.setPrompts(null); // back to the defaults
```
For one operation only, scope them:
```typescript theme={null}
await sdk.auth.withPrompts({ credential: askBiometric }, () => sdk.vault.send(params));
```
In React, `useAuthPrompts()` does the registration, re-registration and cleanup for you, and is the place to put the promise bridge between the SDK's callback and your modal:
```tsx theme={null}
import { useState } from 'react';
import { useAuthPrompts, type PendingCredential } from '@sunnyside-io/privacy-boost-react';
import { PrivacyBoostError, ErrorCodes } from '@sunnyside-io/privacy-boost';
// Your own modal.
const PinModal = (_props: {
attempt: number;
onSubmit: (pin: string) => void;
onCancel: () => void;
}) => null;
function PinGate() {
const [pending, setPending] = useState(null);
useAuthPrompts({
credential: (ctx) =>
new Promise((resolve, reject) => setPending({ ctx, resolve, reject })),
});
if (!pending) return null;
return (
{ pending.resolve(pin); setPending(null); }}
onCancel={() => {
// The SDK reads this code as "the user dismissed it": nothing changes
// and the action can simply be retried.
pending.reject(new PrivacyBoostError(ErrorCodes.USER_CANCELLED, 'dismissed'));
setPending(null);
}}
/>
);
}
```
### New accounts: inline, or driven by your app
Creating an account is not a question with an answer — it's a flow: show twelve words, wait for the user to store them properly, then create the vault. So it has two shapes.
**Inline.** Wire `prompts.recoveryPhrase` and `signIn()` does the whole thing in one call, showing the phrase through your handler:
```typescript theme={null}
const outcome = await sdk.auth.signIn(adapter); // status: 'signedIn'
```
**Driven by your app.** Wire no `recoveryPhrase` handler, and `signIn()` stops and hands you the phrase instead. Nothing exists yet — not on the server, not on chain:
```typescript theme={null}
const outcome = await sdk.auth.signIn(adapter);
if (outcome.status === 'setupRequired') {
showPhraseScreen(outcome.setup.mnemonic); // your own screen, your own navigation
// ...later, when the user confirms they've stored it...
await outcome.setup.complete(); // vault, backup signature, session
}
```
`complete()` is **safe to retry**. A failure leaves the phrase staged and creates nothing, so you can keep the user on the same screen and let them try again — and it will be the same account, not a new one. That property is why this exists: retrying through `signIn()` alone could otherwise hand the user a second phrase and silently strand the first.
`setup.abandon()` discards the phrase, so the next `signIn()` starts a fresh account. Starting over is deliberate, precisely because a written-down phrase has to stay valid.
Only a brand-new account ever produces `setupRequired`. A returning user always resolves to `signedIn`, so the branch runs once in an account's lifetime.
In a browser on the default iframe key vault it never runs at all: handing the phrase to your page is exactly what that vault exists to prevent, so with no `recoveryPhrase` handler the vault draws its own phrase screen and sign-in resolves `signedIn`. The app-driven flow is the one to use in Node, in `local` mode, and on iOS, Android and React Native.
### What belongs in a prompt
A prompt answers a question with a value. Keep to that:
* **Don't call the SDK from inside one.** An unlock is in progress and holds a single-flight gate, so a handler that calls an execution method deadlocks against it until the prompt times out.
* **Always settle.** A dialog dismissed without resolving or rejecting is bounded — a few minutes for a question, longer for a first-time recovery phrase, and shorter still when the prompt was raised from inside an operation that has its own deadline — and then fails with `PROMPT_TIMED_OUT`. The operation is stuck until then, so don't rely on it.
* **Don't clear the user's input on the failure path.** `USER_CANCELLED` promises the app's input is intact; that promise is yours to keep on your side.
```typescript TypeScript theme={null}
const sdk = await PrivacyBoost.create({
// ... required fields
persistence: {
storage: 'localStorage',
unlock: 'pin',
},
prompts: {
credential: ({ action, attempt }) => showPinDialog(action, attempt),
recoveryPhrase: (phrase) => showAndConfirmPhrase(phrase), // new users
},
});
await sdk.auth.signIn(adapter); // no PIN yet: reads work
await sdk.vault.send(params); // asks for the PIN, then sends
```
```swift iOS theme={null}
let result = try await sdk.authenticate(wallet: wallet, keySource: .walletDerived())
switch result {
case .credentialRequired(let challenge):
// challenge.action: .setup (first time) or .unlock (returning)
// challenge.unlockType: .pin or .password
let pin = showPinDialog(for: challenge)
let login = try await sdk.submitCredential(pin)
case .authenticated(_):
break
}
```
If persistence uses `biometric` or `passkey` unlock, the platform prompt appears automatically — no extra code needed.
## Token Providers
A token provider is a function you pass to `authenticate()` that routes the login request through your backend. Your backend adds credentials (API secret, Privy token, or custom JWT) before forwarding to Privacy Boost. This keeps secrets out of client-side code.
**When do you need one?** Only if your app uses API secret, Privy, or custom JWT authentication. For direct auth (development), no token provider is needed. See [App Setup](/sdk/concepts/app-setup) for details.
### How it works
### Implementation
```typescript TypeScript theme={null}
const tokenProvider = async (payload) => {
const res = await fetch('https://your-server.com/api/privacy-auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return await res.json(); // { token, expiresIn }
};
await sdk.auth.authenticate(adapter, { keySource: { type: 'walletDerived' }, tokenProvider });
```
```swift iOS theme={null}
class MyTokenProvider: TokenProvider {
func getToken(loginPayloadJson: String) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: "https://your-server.com/api/privacy-auth")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = loginPayloadJson.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
return TokenResponse(
token: json["token"] as! String,
expiresIn: json["expiresIn"] as! UInt64
)
}
}
try await sdk.authenticate(wallet: wallet, tokenProvider: MyTokenProvider())
```
```kotlin Android theme={null}
class MyTokenProvider : TokenProvider {
override suspend fun getToken(loginPayloadJson: String): TokenResponse {
val response = httpClient.post("https://your-server.com/api/privacy-auth") {
contentType(ContentType.Application.Json)
setBody(loginPayloadJson)
}
val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject
return TokenResponse(
token = json["token"]!!.jsonPrimitive.content,
expiresIn = json["expiresIn"]!!.jsonPrimitive.long.toULong()
)
}
}
sdk.authenticate(wallet, tokenProvider = MyTokenProvider())
```
The token provider receives the SDK's login payload (a JSON object with all the cryptographic proofs) and must return `{ token: string, expiresIn: number }`.
Never expose API secrets in client-side code. Use a custom token provider to route authentication through your backend.
### Unlocking ahead of time
Execution methods unlock on their own, so an app never has to. `auth.ensureUnlocked()` exists for when the prompt should land at a moment you choose rather than inside the operation — a "Confirm in your wallet" step you want to render first, or warming the vault while a review screen is up. It's a no-op once the keys are resident.
```typescript theme={null}
await sdk.auth.ensureUnlocked(); // wallet popup here, not mid-send
setStatus('Sending…');
await sdk.vault.send(params);
```
Native has the same call (`ensureUnlocked()` on iOS, Android and React Native), and `useAuth()` exposes it in React.
## Locking and Signing Out
| Method | What it does | Next step |
| - | - | - |
| `lock()` | Wipes keys from memory; the session and its reads stay | The next write unlocks (one wallet popup or the PIN prompt) |
| `signOut()` | Ends everything (keys, session, persisted session) | `signIn()` again |
| `clearSession()` | Deprecated. Clears the auth token but keeps keys in memory | Fast re-login |
| `logout()` | Same as `signOut()` | `signIn()` again |
Use `lock()` for a "lock wallet" control or when the app goes to the background; the `idleTtlSecs` and `absoluteTtlSecs` timeouts do the same on their own. Use `signOut()` for a full sign-out.
```typescript TypeScript theme={null}
await sdk.auth.lock(); // balances still readable; next send unlocks
await sdk.auth.signOut(); // full sign-out
```
```swift iOS theme={null}
sdk.clearSession()
sdk.logout()
```
```kotlin Android theme={null}
sdk.clearSession()
sdk.logout()
```
## Migrating from `authenticate()`
`authenticate()` and its result union (`credentialRequired`, `mnemonicGenerated`, `recoveryRequired`, `accountCreationRequired`) still work and are deprecated. A session it opens unlocks eagerly and ends on any lock, exactly as before. The four continuation cases map one-to-one onto the four `prompts`:
```typescript theme={null}
// Before
const result = await sdk.auth.authenticate(adapter, { keySource: { type: 'walletDerived' } });
switch (result.status) {
case 'credentialRequired': return result.submit(await ui.askPin(result.action));
case 'mnemonicGenerated': await ui.showPhrase(result.mnemonic); return result.proceed();
case 'recoveryRequired': return result.recover(await ui.askPhrase(result.reason));
case 'accountCreationRequired': await wallet.sendTransaction(result.createAccountTransaction); break;
case 'authenticated': return result;
}
sdk.on('sessionExpired', () => ui.showLoggedOut());
// After
const sdk = await PrivacyBoost.create({
...config,
prompts: {
credential: (ctx) => ui.askPin(ctx.action),
recoveryPhrase: (phrase) => ui.showPhrase(phrase),
recover: (reason) => ui.askPhrase(reason),
},
});
await sdk.auth.signIn(adapter);
sdk.on('authChange', (state) => ui.render(state));
```
An app that never handled PIN or recovery passes no `prompts`; the SDK sends the account-creation transaction itself with the signed-in wallet, and a new user's phrase remains available through `revealRecoveryPhrase()`.
One thing to re-check while migrating: anything that gates a **read** on `isAuthenticated`. That flag still means "keys are in memory", which a returning user's `signIn()` deliberately does not give them. Gate reads on `status === 'signedIn'` (in React, `useAuth().state`, or the `signedIn` flag on the wallet store) and keep `isAuthenticated` only where resident keys are genuinely required.
## Next Steps
If you're using a production auth method, set up your integration next:
Server-to-server authentication with client credentials
Auth0, Firebase, Supabase, Clerk, or any OIDC provider
Social login and embedded wallets via Privy
Wallet connection and embedded wallets via Dynamic
Then continue with setup:
* [Key Management](/sdk/concepts/key-management) — Choose a key source and configure persistence
* [Error Handling](/sdk/concepts/error-handling) — Handle auth and operation errors
* [Keys & Encryption](/technical-explainer/keys-and-encryption) — Deep dive into how auth keys, viewing keys, and privacy addresses work
# Claimable Transfers
Source: https://docs.privacyboost.io/sdk/concepts/claimable-transfers
Send shielded funds to a wallet address that has not joined Privacy Boost yet — the recipient claims by proving ownership of the address, and the sender can reclaim an unclaimed transfer after a deadline.
# Claimable Transfers
A claimable transfer (a **gift**) lets you send shielded funds to an Ethereum
wallet address that is **not yet a registered Privacy Boost account**. You need
only the recipient's wallet address and an amount. The recipient later registers
Privacy Boost with that same address and claims the funds into a normal shielded
note; if they never claim, you reclaim the funds after a deadline you set.
Like a normal private transfer, the recipient and amount stay hidden on chain.
Unlike a normal transfer, the recipient does not have to exist on Privacy Boost
when you send.
## How it works
An ordinary private transfer needs the recipient's privacy address. A wallet
address alone does not supply the MPK and viewing key needed to construct that
note. Claimable transfers solve this with a two-step **fund → claim** flow built
around a *gift note*:
```text theme={null}
SENDER ON-CHAIN / NOTE TREE RECIPIENT
│ 1. fund a gift note bound to the recipient wallet W
│ (ordinary private transfer; amount + recipient hidden)
│ ────────────────────────────▶ gift note appended
│ 2. share a claim link (optional, off-chain) ────────────▶ 3. register PB with wallet W
│ 4. discover the pending gift
│ claim ◀───────────────────── 5. claim → receive a normal note
│
│ 6. if unclaimed past the refund deadline → refund ──────▶ funds return to sender
```
1. **Fund.** You build a gift note bound to the recipient's wallet address `W`
and a fresh hiding factor, then fund it through an ordinary shielded transfer.
On chain it is indistinguishable from any other transfer — the amount and the
recipient are hidden. The SDK records the gift locally so you can always
refund it, and returns an off-chain **claim link** carrying the gift opening.
2. **Claim.** The recipient registers Privacy Boost with the same wallet `W`,
discovers the pending gift, and claims it. Claiming proves control of `W` with
a zero-knowledge proof and re-mints the gift into a normal shielded note. The
amount stays hidden in a private claim. Wallet-recipient claims require a
registered signing key, so an approval-only account cannot claim this way.
3. **Refund.** You pick a refund deadline (a block height) at funding time. If
the gift is still unclaimed after it, you reclaim the funds. A claim and a
refund spend the *same* nullifier, so **exactly one of them can ever settle** —
the recipient can't claim a refunded gift, and you can't refund a claimed one.
SDK claims require the recipient's explicit acknowledgment that the sender is
unknown before proceeding.
If the relay or TEE is unavailable, a recipient can always recover the funds
through a permissionless **public exit** (which reveals the amount, like a forced
withdrawal), using the gift opening and a registered signing key. Wallet-bound
funds are self-custodial end to end: only the user's own authorization plus a
valid proof move them. The TEE assists discovery and can never seize or redirect
a wallet-bound gift.
## Funding modes
The SDK exposes three ways to fund a gift. Pick the one that matches what you
know about the recipient.
| Mode | You provide | When to use |
| - | - | - |
| **Wallet + privacy address** | recipient wallet `W` **and** their privacy address | The recipient is already a Privacy Boost user — the ciphertext is sealed to their viewing key for the smoothest discovery. |
| **Wallet only** | recipient wallet `W` | The recipient has an EOA but has not joined Privacy Boost. The claim link supplies the opening. Claiming also requires registration and the recipient's signing key. |
| **Secret-bearer** | nothing but an amount | The recipient has no wallet at all yet. See the warning below. |
**Anyone who holds a secret gift's claim link can claim its funds.** Only the
explicit secret-funding methods (`fundSecretBearer` in TypeScript and React,
`giftFundSecretBearer` on the other platforms) select this mode, while
ordinary gift methods bind to a wallet.
Secret claims settle only through the public exit and reveal the
token, amount, and destination. The SDK CLI does not expose secret funding.
## Discovering a gift
A recipient finds a pending gift in one of three ways, in increasing order of
trustlessness:
* **TEE discovery.** Behind a normal authenticated session for wallet `W`, the
server lists the pending gifts addressed to that wallet. This is the default
path the SDK's "list pending gifts" call uses.
* **Claim link.** The sender shares a `pbgift:v1:...` link out of band (message,
QR code). The recipient can decode it offline to preview the gift, then claim
directly from it — no server lookup required to read it. **Treat a claim link
like a password:** for wallet-bound gifts the recipient still needs their own
keys to claim, but the link reveals the amount and recipient to anyone who sees
it.
* **Trustless recovery.** Using the gift opening, a registered signing key, and
public on-chain data, a wallet-bound recipient can build a public exit with no
TEE and no relay. This is the ultimate liveness backstop.
## Refunds and the deadline
You choose `refundAfterBlock` when you fund. The deadline opens the sender
refund path without expiring the recipient claim. After it passes, either can
settle first while the gift remains unspent.
The server advertises minimum and maximum refund delays through `/info` and
checks them at funding. The refund proof enforces that the selected deadline
has elapsed, so the sender cannot refund early.
To refund, the SDK uses the **local gift record** it saved at funding time
(`getGiftRecords()` / `gift_refund_by_record`). Keep these records: they are what
makes an unclaimed gift refundable. They travel inside an
exported session (see your platform's session-storage guide), and a chain context can
rehydrate them with `import_gift_records`. If you lose them, you can still refund
by supplying the gift's fields manually (`gift_refund`).
A secret-bearer gift returns no local record. Its claim link is the funder's
refund credential, so keep the link if you may need to refund.
## What observers and the TEE learn
| Observer learns | Funding | Private claim | Public exit |
| - | - | - | - |
| Recipient wallet `W` | hidden | hidden | the authorized payout destination is public, and may differ from `W` |
| Amount | hidden | hidden | revealed |
| That a settlement happened | as a transfer | yes (one event) | yes |
| Whether it was a claim or a refund | — | indistinguishable | — |
| Which authorization key was used | hidden | hidden | revealed (zero for secret-bearer) |
A private claim and a private refund are indistinguishable on chain. The TEE
learns the recipient wallet `W` during discovery. For an ordinary transfer it
learns the recipient's master public key rather than a wallet address, along
with the token and amount it already decrypts. A reusable funding pattern
carries the usual send-to-address correlation caveat: a checksummed-address
confirmation prompt and an optional unknown-sender warning on claim blunt it,
but funding any address is inherently address-bound.
A public exit reveals the authorization leaf, which identifies the settling
account, along with the token, amount, and destination.
## Using it from the SDK
The full method surface — `giftFund`, `giftFundToWallet`, `giftClaim`,
`giftClaimFromLink`, `giftRefundByRecord`, `getPendingGifts`, `decodeGiftLink`,
and the gift fields added to the transfer result — is documented per platform:
# Configuration
Source: https://docs.privacyboost.io/sdk/concepts/configuration
SDK initialization parameters and network setup
# Configuration
Once you have an [App ID](/sdk/concepts/app-setup), initialize the SDK with a configuration object specifying your endpoints and target chain.
## Required Parameters
| Parameter | Type | Description |
| - | - | - |
| `serverUrl` | `string` | URL of the Privacy Boost server |
| `appId` | `string` | Your application identifier (see [App Setup](/sdk/concepts/app-setup)) |
## Optional Parameters
| Parameter | Type | Description |
| - | - | - |
| `chainId` | `number` | EVM chain ID for the target network (auto-discovered from server) |
| `shieldContractAddress` | `string` | Address of the deployed Shield contract (auto-discovered from server) |
| `wethContractAddress` | `string` | WETH contract address (required for native ETH deposits) |
| `rpcUrl` | `string` | JSON-RPC URL for on-chain reads (token registry lookups) |
| `logLevel` | `string` | Console log verbosity: `"silent"` (default), `"error"`, `"warn"`, `"info"`, `"debug"` |
## Example
```typescript TypeScript theme={null}
import { PrivacyBoost } from '@sunnyside-io/privacy-boost';
const sdk = await PrivacyBoost.create({
serverUrl: 'https://optimism.sepolia.privacyboost.io',
wethContractAddress: '0x4200000000000000000000000000000000000006',
appId: 'app_abc123xyz',
});
```
```swift iOS theme={null}
import PrivacyBoost
let config = PrivacyBoostConfig(
serverUrl: "https://optimism.sepolia.privacyboost.io",
wethContractAddress: "0x4200000000000000000000000000000000000006",
appId: "app_abc123xyz"
)
let sdk = try await PrivacyBoost(config: config)
```
```kotlin Android theme={null}
import com.privacyboost.sdk.*
val config = PrivacyBoostConfig(
serverUrl = "https://optimism.sepolia.privacyboost.io",
wethContractAddress = "0x4200000000000000000000000000000000000006",
appId = "app_abc123xyz"
)
val sdk = PrivacyBoost(config)
```
```bash CLI theme={null}
# Via environment variables
export PRIVACY_BOOST_APP_ID=app_abc123xyz
export SERVER_URL=https://optimism.sepolia.privacyboost.io
export WETH_CONTRACT_ADDRESS=0x4200000000000000000000000000000000000006
export RPC_URL=https://sepolia.optimism.io
privacy-boost login
# Or use a network preset
privacy-boost --network op-sepolia login
```
## Supported Networks
| Network | Chain ID | `serverUrl` | Description |
| - | - | - | - |
| OP Mainnet | `10` | `https://optimism.privacyboost.io` | Optimism mainnet (production) |
| OP Sepolia | `11155420` | `https://optimism.sepolia.privacyboost.io` | Optimism Sepolia testnet (recommended for development) |
## CLI Environment Variables
The CLI reads configuration from environment variables:
| Variable | Description |
| - | - |
| `PRIVACY_BOOST_APP_ID` or `APP_ID` | Application identifier |
| `SERVER_URL` | Privacy Boost server URL |
| `SHIELD_CONTRACT_ADDRESS` | Shield contract address (auto-discovered if omitted) |
| `WETH_CONTRACT_ADDRESS` | WETH contract address |
| `RPC_URL` | JSON-RPC URL |
```bash theme={null}
export PRIVACY_BOOST_APP_ID=app_abc123xyz
export SERVER_URL=https://optimism.sepolia.privacyboost.io
privacy-boost login
```
## Logging
By default, the SDK produces **no console output** (`silent`). Set `logLevel` to control verbosity during development or debugging:
| Level | Output |
| - | - |
| `"silent"` | No output (default) |
| `"error"` | Errors only |
| `"warn"` | Errors and warnings |
| `"info"` | Errors, warnings, and informational messages |
| `"debug"` | All messages including verbose debug output |
```typescript TypeScript / React theme={null}
const sdk = await PrivacyBoost.create({
serverUrl: 'https://optimism.sepolia.privacyboost.io',
appId: 'app_abc123xyz',
logLevel: 'debug', // Enable verbose logging for development
});
```
Use `"debug"` during development to trace SDK operations. In production, leave it as `"silent"` (the default) or `"error"` to avoid leaking internal state to the browser console.
All log messages are tagged with the originating module (e.g., `[Vault]`, `[Auth]`) for easy filtering in browser dev tools.
## Multi-Chain Configuration
To operate on additional chains beyond your primary one, create chain clients with `sdk.forChain()`. Only `serverUrl` is required — all other parameters are auto-discovered:
```typescript theme={null}
const ethereum = sdk.forChain({ serverUrl: 'https://eth.example.com' });
const base = sdk.forChain({
serverUrl: 'https://base.example.com',
chainId: 8453,
wethContractAddress: '0x4200000000000000000000000000000000000006',
});
```
| Parameter | Type | Required | Description |
| - | - | - | - |
| `serverUrl` | `string` | Yes | URL of this chain's server |
| `chainId` | `number` | No | EVM chain ID (auto-discovered) |
| `shieldContractAddress` | `string` | No | Shield contract address (auto-discovered) |
| `wethContractAddress` | `string` | No | WETH contract address |
| `rpcUrl` | `string` | No | RPC URL for on-chain reads |
| `tokenRegistryAddress` | `string` | No | Token registry contract (auto-discovered) |
| `teePublicKey` | `string` | No | TEE public key (auto-discovered) |
See [Multi-Chain](/sdk/concepts/multi-chain) for the full guide.
## Next Steps
Connect a wallet and log in
Key derivation, persistence, and recovery
Operate across multiple blockchains
# Earn (ERC-4626 Vaults)
Source: https://docs.privacyboost.io/sdk/concepts/earn
Move a shielded balance into an ERC-4626 yield vault and back without leaving the pool. The external call gateway spends a private note, runs the vault call, and credits the measured output as a new private note — the depositor's account stays hidden.
# Earn (ERC-4626 Vaults)
**Earn** lets a user put a shielded balance to work in an ERC-4626 yield vault
and pull it back out — without the funds ever leaving the shielded pool. A
deposit spends a private asset note, routes it
through the vault via the **external call gateway**, and credits the vault
shares back as a new private note; a withdraw redeems those share notes the
same way. The vault call itself is public on-chain, but the account that owns
the deposited note is not.
Earn is a v2 feature enabled per deployment. The server advertises
`externalGatewayAddress` in [`/info`](/sdk/concepts/configuration) when the
gateway is enabled. Use `sdk.vault.getGatewayConfig()` and each operation's
availability to detect support before offering it.
`EARN_VAULT_MODE` controls the optional vault metadata catalog, not Gateway
execution. A deployment can leave it disabled and still support
`earnDeposit()` and `earnRedeem()` for ERC-4626 routes advertised by
`getRegisteredTokens()`.
## How it works
An earn deposit is a **gateway unshield**. Instead of paying a private note out
to a public address, the SDK signs an authorization that lets the gateway run
the vault call and credit the result straight back into the pool:
```text theme={null}
asset note (private) ──spend──▶ gateway executes vault deposit ──▶ share note (private)
│
failure / expiry / below minOut
▼
input refunded as a private note
```
1. **Preview & authorize.** The SDK reads the vault on-chain (`asset()`,
`previewDeposit` / `previewRedeem`), derives a `minOutputAmount` floor from
your slippage bound, and signs the `PB:WITHDRAW:GATEWAY:v2` authorization for
exactly that request.
2. **Execute.** The pool spends the input note under the epoch proof, and the
gateway calls the vault. The output is **measured on-chain**, not trusted
from the preview.
3. **Credit.** The measured vault output creates a pending gateway deposit. A
later deposit proof credits it as a new private note. The owner's identifying
keys never appear in the transaction.
4. **Refund on failure.** If the vault reverts, the request expires, or the fill
would land below the signed `minOutputAmount`, the input is recorded as a
fallback deposit and credited as a private note by a later deposit proof.
Funds never leave the pool uncredited.
A withdraw is the mirror image: it redeems share notes and credits the asset
output.
## SDK API shape
The earn surface lives on the **vault resource** (`sdk.vault`). Gateway and
token capability discovery are public. When configured, catalog and history
reads are public too. The two write operations require an authenticated
session. Settlement is tracked with `sdk.transactions.getUnshieldStatus`,
which also requires a session.
| Method | Auth | Purpose |
| - | - | - |
| `getGatewayConfig()` | public | Gateway discovery: executor address, request constraints, rescue delay, availability. Use it to feature-gate. |
| `getRegisteredTokens()` | public | Registered tokens and their executable Gateway capabilities. Use this to discover ERC-4626 routes. |
| `listEarnVaults()` | public | Optional metadata catalog: APY, TVL, warnings, and per-operation availability. Requires the Earn catalog service. |
| `getEarnVault(vault)` | public | Optional metadata detail for one vault. Requires the Earn catalog service. |
| `getEarnVaultHistory(vault, metric, period?)` | public | One cached chart series (TVL or net APY) for a vault. Requires the Earn catalog service. |
| `getEarnVaultCharts(vault, period?)` | public | Both chart series for a vault, fetched concurrently. Requires the Earn catalog service. |
| `earnDeposit(params)` | session | Deposit assets into a vault; credits a share note. |
| `earnRedeem(params)` | session | Redeem share notes; credits an asset note. |
`earnDeposit` / `earnRedeem` take an `EarnParams`. **`amount` is net** — exactly
what enters the vault call (assets for a deposit, shares for a redeem); the
Gateway fee replaces the regular unshield fee and is charged on top, so your
notes must cover `amount + fee`.
`maxSlippageBps` bounds the fill below the on-chain preview (default `100` =
1%).
```typescript theme={null}
// Detect executable ERC-4626 routes — no session required
const gateway = await sdk.vault.getGatewayConfig();
if (!gateway.enabled || !gateway.availability.canSubmit) {
throw new Error(gateway.availability.reason || 'Gateway unavailable');
}
const tokens = await sdk.vault.getRegisteredTokens();
const deposits = tokens.flatMap((token) =>
(token.capabilities ?? [])
.filter((capability) =>
capability.adapter === 'erc4626' &&
capability.direction === 'deposit' &&
capability.vault !== undefined
)
.map((capability) => ({
asset: token,
vaultAddress: capability.vault!,
shareTokenId: capability.shareTokenId,
}))
);
// Deposit 100 USDC (net) into a vault; the fee is charged on top
const result = await sdk.vault.earnDeposit({
vaultAddress: '0xVault…',
amount: 100_000000n, // net assets entering the vault (6-decimal USDC)
maxSlippageBps: 100, // reject a fill more than 1% below the preview
});
// Later: redeem shares back to the underlying asset
await sdk.vault.earnRedeem({ vaultAddress: '0xVault…', amount: shares });
```
Before submitting a deposit, also read the vault's `maxDeposit()` using
`gateway.executorAddress` as the receiver. A policy-approved route may still
have no live capacity; disable deposits when `maxDeposit()` is below the
requested asset amount. The SDK separately validates `asset()` and reads
`previewDeposit()` or `previewRedeem()` while building the signed request.
```bash theme={null}
# Requires the optional Earn catalog
privacy-boost earn vaults
# Execution does not require the catalog
privacy-boost earn deposit --vault 0xVault… --amount 100 --human --decimals 6
privacy-boost earn withdraw --vault 0xVault… --amount 50 --human
```
### Tracking settlement
An earn operation returns an `EarnResult` with a `requestId`. Poll the unshield
status and read the `gateway` projection, since credit lands in a later epoch.
`gateway.creditStatus === 'completed'` means the private note was credited.
`gateway.creditStatus === 'rescued'` is a terminal recovery outcome: the escrow
was paid to the rescue destination, so stop waiting for private credit.
The top-level `completed` flag only confirms the original unshield epoch.
```bash theme={null}
privacy-boost earn status
```
## Vault history charts
This section applies only when the deployment enables the optional Earn
catalog service. With `EARN_VAULT_MODE=disabled`, catalog and history endpoints
are not mounted and return `404`; this does not disable Gateway execution.
`getEarnVaultHistory` and `getEarnVaultCharts` return cached TVL and net-APY
series suitable for rendering a chart. Both are **public** — usable before
login.
* `metric` is `'tvlUsd'` or `'netApy'`; `period` is `'1w' | '1m' | '3m' | 'all'`
(server default `1w`). `getEarnVaultCharts` fetches both metrics at once and
rejects if either fails — call `getEarnVaultHistory` twice if you want partial
results.
* **APY values are decimal ratios** (`0.05` = 5%); TVL values are USD. Points
ascend by timestamp.
* A `null` `points[].value` is an **upstream gap** — render it as a gap rather
than interpolate across it.
* Check `metadata.state` before presenting the data as current. Today it is one
of `fresh`, `stale` (upstream unavailable — this is the last good series),
`synthetic` (deterministic testnet data), or `unsupported`. It is an **open
set**: treat an unrecognized state as renderable data of unknown freshness,
not an error.
* While the server's cache warms, a cold request may answer `503` with a
retry-after hint; the SDK's standard retry policy already honors it.
```typescript theme={null}
const { tvlUsd, netApy } = await sdk.vault.getEarnVaultCharts('0xVault…', '1m');
for (const point of netApy.points) {
if (point.value === null) continue; // upstream gap
plot(point.timestamp, point.value); // decimal ratio: 0.05 = 5%
}
```
```bash theme={null}
# One series
privacy-boost earn history --vault 0xVault… --metric netApy --period 1m
# Both series (omit --metric)
privacy-boost earn history --vault 0xVault…
```
## Trust and privacy
Earn is a **deposit-and-credit** extension: the gateway can only spend the note
you authorize and credit the measured result back into the pool. It grants no
new authority to move or seize funds.
| Actor | Can | Cannot |
| - | - | - |
| **Account owner** | authorize a deposit or redemption and spend the credited note | spend another account's notes |
| **Gateway owner** | install or replace call policies, unpause, and sweep stranded balances to the pool while paused | bypass the pool's proof and receipt checks |
| **Gateway guardian** | pause execution and remove a call policy | bypass the pool's proof and receipt checks |
| **Pool gateway-route manager** | enable or disable the gateway route on the pool | bypass the pool's proof and receipt checks |
| **Relay / prover** | submit an authorized epoch and later deposit proof | substitute another target, amount, or credit receipt |
| **TEE** | read authorized request metadata and construct the credit witness | forge a user's signing-key or account-owner authorization |
A gateway-origin note joins the **same shared note tree** as every other deposit
and transfer, so its anonymity set is the full mixed pool. Earn hides *who*
deposited into the vault, **not** *that the vault was used* — the vault call
(target and amount) is public on-chain. The `minOutputAmount` floor is signed,
so no operator can settle a deposit into a worse-than-authorized fill; the worst
case is a refund of the input. An unprocessed gateway deposit can be rescued
after the cancel delay using its designated rescue authority.
## Launch limitations
These are constraints of the current implementation:
* **Enabled per deployment.** A deployment without a gateway has no
`externalGatewayAddress` in `/info`, and `getGatewayConfig()` returns
`enabled: false`. Always feature-gate on `enabled` and the operation's
`availability.canSubmit` value.
* **Operator-approved routes.** Only ERC-4626 calls allowed by the effective
Gateway policy and backed by registered pool tokens are usable. Discover
those routes from `getRegisteredTokens()` capabilities. If the optional
catalog is enabled, also honor the availability and warnings returned by
`listEarnVaults()`.
* **Slippage is a floor, not a guarantee of execution.** A fill below the signed
`minOutputAmount` refunds the input via the fallback receipt instead of
executing; surface that outcome rather than treating the request as failed.
* **History is a best-effort cached read.** Freshness is reported by
`metadata.state`, and a cold cache can briefly `503`. Do not treat a `stale`
or unknown state as an error.
* **Single-chain.** Earn works on a chain that hosts both a pool and a deployed
gateway.
## Using it from the SDK
* **TypeScript** — the earn methods on `sdk.vault`. See the
[TypeScript API reference](/sdk/typescript/api-reference).
* **WASM** — the same methods exposed top-level on `sdk` (`sdk.earnDeposit`,
`sdk.earnRedeem`, `sdk.getGatewayConfig`). See the
[WASM API reference](/sdk/wasm/api-reference).
* **CLI** — the `earn` subcommands. See the
[Commands reference](/sdk/cli/guides/commands#earn-commands).
* **iOS / Android / React Native** — `earnDeposit` / `earnRedeem` plus typed
catalog and history reads (`listEarnVaults`, `getEarnVault`,
`getGatewayConfig`, `getEarnVaultHistory`, `getEarnVaultCharts`). The
`*Json` variants of the reads remain for callers that parse the wire shape
themselves. See the platform API references.
# Error Handling
Source: https://docs.privacyboost.io/sdk/concepts/error-handling
Handle SDK errors with structured error codes and recovery patterns
# Error Handling
The Privacy Boost SDK uses structured error codes so you can handle errors programmatically. Every error has a stable `code` string that won't change between SDK versions.
## Quick Example
```typescript theme={null}
import { PrivacyBoostError, ErrorCodes } from '@sunnyside-io/privacy-boost';
try {
await sdk.vault.shield({ tokenAddress, amount });
} catch (error) {
if (error instanceof PrivacyBoostError) {
switch (error.code) {
case ErrorCodes.TRANSACTION_REJECTED:
// User cancelled — do nothing
return;
case ErrorCodes.INSUFFICIENT_BALANCE:
showToast('Not enough balance');
return;
default:
if (error.retryable) {
// Network issue — retry after a moment
await retryLater();
} else {
showError(error.message);
}
}
}
}
```
## Error Types by Platform
| Platform | Error Type | How to check the code |
| - | - | - |
| TypeScript | `PrivacyBoostError` | `error.code`, `error.retryable`, `error.isAuthError()` |
| iOS (Swift) | `SDKError` | `catch SDKError.networkError(let msg) { ... }` |
| Android (Kotlin) | `SDKError` | `catch (e: SDKError.NetworkError) { ... }` |
### TypeScript: PrivacyBoostError
```typescript theme={null}
class PrivacyBoostError extends Error {
code: string; // e.g. 'WALLET_NOT_CONNECTED'
message: string; // Human-readable description
cause?: Error; // Original error if wrapped
retryable: boolean; // Can this operation be retried?
isAuthError(): boolean; // Is this an auth/session error?
isWalletError(): boolean; // Is this a wallet error?
}
```
## Error Codes
### Wallet Errors
These occur when interacting with the user's wallet.
| Code | What happened | User action |
| - | - | - |
| `WALLET_NOT_CONNECTED` | No wallet connected | Show "Connect Wallet" button |
| `WALLET_CONNECTION_FAILED` | Wallet refused or failed to connect | Retry or try a different wallet |
| `WRONG_NETWORK` | Wallet is on the wrong chain | Show "Switch Network" prompt |
| `TRANSACTION_REJECTED` | User rejected a signature or transaction | Do nothing (intentional) |
| `TRANSACTION_FAILED` | On-chain transaction failed | Show error, check gas/params |
| `INSUFFICIENT_GAS` | Not enough gas for the transaction | Prompt user to add funds |
| `GAS_FUNDS_REQUIRED` | Sign-in stopped before asking the wallet to create an approval-only account, because the wallet can't pay the gas. `error.gasFunds` has the address, chain, `required` and `available` (wei) | Ask the user to add ETH to that address on that chain, then sign in again |
| `APPROVAL_ACCOUNT_CREATION_FAILED` | The wallet wouldn't send, or reverted, the `createAccount` transaction for an approval-only account. `error.gasFunds.available` is the balance read afterwards, when it could be read | Usually a custody wallet that can't cover the fee it reserves. Ask the user to add ETH and sign in again |
### Authentication Errors
These occur during login or when a session expires.
| Code | What happened | User action |
| - | - | - |
| `NOT_AUTHENTICATED` | Tried to use SDK before logging in | Call `authenticate()` first |
| `SESSION_EXPIRED` | JWT expired | Re-authenticate (can be automatic) |
| `AUTHENTICATION_FAILED` | Login failed | Retry or check configuration |
### Network Errors
These are transient and usually retryable.
| Code | What happened | Retryable? |
| - | - | - |
| `NETWORK_ERROR` | Connection failed (DNS, timeout, refused) | Yes |
| `TIMEOUT` | Request took too long | Yes |
| `RATE_LIMITED` | Too many requests (429) | Yes (with backoff) |
| `SERVICE_UNAVAILABLE` | Backend is temporarily down | Yes (with backoff) |
### Operation Errors
These occur during deposits, transfers, or withdrawals.
| Code | What happened | User action |
| - | - | - |
| `INSUFFICIENT_BALANCE` | Not enough shielded balance | Show balance and suggest depositing more |
| `INVALID_ADDRESS` | Malformed Ethereum or privacy address | Prompt user to check the address |
| `INVALID_AMOUNT` | Amount is zero, negative, or malformed | Validate input before calling the SDK |
| `INVALID_CONFIG` | SDK configuration is invalid | Check `appId`, `serverUrl`, and other config values |
### Compliance Errors
These occur when a transaction fails compliance screening.
| Code | What happened | User action |
| - | - | - |
| `COMPLIANCE_BLOCKED` | Address or transaction blocked by compliance screening | Contact support |
| `TRANSFER_COMPLIANCE_FAILED` | Transfer failed compliance check | Contact support |
### Crypto Errors
These indicate failures in the cryptographic layer.
| Code | What happened | User action |
| - | - | - |
| `CRYPTO_ERROR` | General cryptographic operation failed | Report the issue |
| `ENCRYPTION_ERROR` | Failed to encrypt data | Retry or re-authenticate |
| `DECRYPTION_ERROR` | Failed to decrypt data (wrong keys or corrupted) | Re-authenticate or restore keys |
| `KEY_DERIVATION_ERROR` | Failed to derive privacy keys | Re-authenticate |
### Shield Errors
These occur during shield (deposit) operations.
| Code | What happened | User action |
| - | - | - |
| `SHIELD_NOT_FOUND` | Shield request not found on-chain | Check transaction hash |
| `SHIELD_ALREADY_CLAIMED` | Shield has already been claimed | No action needed |
| `SHIELD_PROOF_EXPIRED` | Shield proof has expired | Re-initiate the shield |
| `SHIELD_COMMITMENT_NOT_FOUND` | On-chain commitment not found | Wait for indexer to sync |
### Transfer Errors
These occur during private transfers.
| Code | What happened | User action |
| - | - | - |
| `TRANSFER_NOT_FOUND` | Transfer not found | Check request ID |
| `TRANSFER_PROOF_FAILED` | ZK proof generation or verification failed | Retry the transfer |
| `TRANSFER_SUBMISSION_FAILED` | Transfer could not be submitted to the server | Retry |
| `BELOW_MIN_TRANSFER_AMOUNT` | Amount is below minimum transfer threshold | Increase amount |
### Merkle Tree Errors
These occur during proof construction.
| Code | What happened | Retryable? |
| - | - | - |
| `MERKLE_LEAF_NOT_FOUND` | Note's leaf not found in the Merkle tree | Yes (indexer may be syncing) |
| `MERKLE_PROOF_FAILED` | Failed to construct Merkle proof | Yes |
| `MERKLE_ROOT_STALE` | Merkle root is outdated | Yes (auto-retried by SDK) |
### Note Errors
These occur when accessing private notes.
| Code | What happened | User action |
| - | - | - |
| `NOTE_NOT_FOUND` | Note does not exist | Verify the transaction |
| `NOTE_ALREADY_SPENT` | Note has already been spent (nullifier exists) | No action — note was used |
| `NOTE_ACCESS_DENIED` | Cannot decrypt this note (wrong keys) | Verify correct account |
### Internal Errors
These indicate SDK bugs or unexpected server responses.
| Code | What happened | User action |
| - | - | - |
| `SERIALIZATION_ERROR` | Failed to serialize/deserialize data | Report the issue |
| `OPERATION_FAILED` | Generic operation failure | Check the error message for details |
| `INTERNAL_ERROR` | Unexpected internal error | Report the issue with full error details |
## Recovery Patterns
### Handle user rejections gracefully
When a user cancels a wallet popup, don't show an error dialog — it's intentional.
```typescript theme={null}
try {
await sdk.auth.authenticate(adapter, { keySource: { type: 'walletDerived' } });
} catch (error) {
if (error.code === 'TRANSACTION_REJECTED') {
return; // User cancelled
}
showError(error.message);
}
```
### Retry on network errors
Use `error.retryable` with exponential backoff:
```typescript theme={null}
async function withRetry(fn: () => Promise, maxRetries = 3): Promise {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (!error.retryable || attempt === maxRetries) throw error;
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
throw new Error('Unreachable');
}
const balance = await withRetry(() => sdk.vault.getBalance(token));
```
### Re-authenticate on session expiry
```typescript theme={null}
async function withAuth(fn: () => Promise): Promise {
try {
return await fn();
} catch (error) {
if (error.code === 'SESSION_EXPIRED') {
await sdk.auth.authenticate(adapter, { keySource: { type: 'walletDerived' } });
return await fn();
}
throw error;
}
}
```
### Map errors to user-friendly messages
```typescript theme={null}
function userMessage(error: PrivacyBoostError): string {
const messages: Record = {
WALLET_NOT_CONNECTED: 'Please connect your wallet',
TRANSACTION_REJECTED: 'Request cancelled',
WRONG_NETWORK: 'Please switch to the correct network',
SESSION_EXPIRED: 'Session expired, please log in again',
INSUFFICIENT_BALANCE: 'Not enough balance',
NETWORK_ERROR: 'Connection failed, please try again',
RATE_LIMITED: 'Too many requests, please wait',
};
return messages[error.code] || error.message;
}
```
## Best Practices
1. **Match on `error.code`, not `error.message`.** Codes are stable across versions; messages may change.
2. **Never show raw error messages to users.** Map codes to context-appropriate messages.
3. **Handle `TRANSACTION_REJECTED` silently.** The user intentionally cancelled.
4. **Use `error.retryable` for retry logic.** The SDK marks transient errors as retryable.
5. **Log the full error internally.** Capture `code`, `message`, and `cause` for debugging, even when showing a simple message to the user.
## Next Steps
You've completed the Setup section. Now dive into your platform SDK:
Full web SDK guide
Hooks and components
Native Swift SDK
Native Kotlin SDK
React Native SDK
Command-line tool
For deeper TypeScript error handling patterns (retry logic, WASM loading, caching), see the [TypeScript Error Handling guide](/sdk/typescript/guides/error-handling).
# Key Management
Source: https://docs.privacyboost.io/sdk/concepts/key-management
Key derivation, storage, protection, and recovery
# Key Management
Privacy Boost derives a set of privacy keys from a single seed. These keys give the user a private identity — separate from their public wallet address — that can send, receive, and hold shielded tokens.
## What Gets Derived
From the seed, the SDK derives three keys that together form the user's private identity:
| Key | Purpose | Scope |
| - | - | - |
| **Viewing key** | Encrypts and decrypts transaction metadata. Included in your [privacy address](/technical-explainer/keys-and-encryption#privacy-address) so others can send you private transfers. | Controls **visibility** — who can see your balance and transaction history. |
| **Nullifying key** | Links your identity to your notes. Used to compute nullifiers when spending. | Controls **identity** — ties specific notes in the shielded pool to your account. |
| **Auth key** | Authorizes transfers and withdrawals inside ZK proofs. Registered on-chain in the AuthRegistry. | Controls **spending** — required to move funds. Can be rotated or revoked via your wallet. |
These keys are designed with **separation of concerns**: the viewing key handles privacy, the nullifying key handles identity, and the auth key handles fund custody. This means a single key being exposed doesn't give full access — for example, leaking the viewing key reveals your balance but doesn't allow anyone to spend your funds. For the full security model and threat analysis, see [Keys & Encryption](/technical-explainer/keys-and-encryption).
Your **Master Public Key (MPK)** and **privacy address** are derived from these keys. The MPK is your public identity in Privacy Boost — safe to share. The privacy address is what you give to others so they can send you private transfers.
## Key Sources
The `keySource` parameter controls where the seed comes from.
| Key Source | Best for | What the user needs to back up |
| - | - | - |
| `walletDerived` | Most apps | Nothing — same wallet always produces same keys |
| `mnemonic` | Cross-wallet portability | The 12-word phrase |
| `rawSeed` | Testing and development | The hex seed |
### walletDerived (recommended)
The SDK asks the wallet to sign a deterministic message and uses the signature as the seed. The same wallet always produces the same signature, so the same privacy keys are derived every time. This is the simplest option because the wallet itself is the backup — there's nothing extra for the user to write down.
### mnemonic
The SDK derives keys from a BIP-39 mnemonic phrase. This is useful when you want keys to be portable across different wallets, but the user must securely store the phrase.
### rawSeed
A raw 32-byte hex seed. Intended for testing.
```typescript TypeScript theme={null}
// Wallet-derived (recommended)
await sdk.auth.authenticate(adapter, { keySource: { type: 'walletDerived' } });
// From mnemonic
await sdk.auth.authenticate(adapter, { keySource: { type: 'mnemonic', phrase: 'your twelve word phrase ...' } });
```
```swift iOS theme={null}
// Wallet-derived
try await sdk.authenticate(wallet: wallet, keySource: .walletDerived())
// From mnemonic
try await sdk.authenticate(wallet: wallet, keySource: .mnemonic(phrase: "your twelve word phrase ..."))
```
```kotlin Android theme={null}
// Wallet-derived
sdk.authenticate(wallet, keySource = KeySource.WalletDerived())
// From mnemonic
sdk.authenticate(wallet, keySource = KeySource.Mnemonic(phrase = "your twelve word phrase ..."))
```
## Persistence & Protection
Without persistence, the SDK re-derives keys every time the user opens your app (requiring wallet signature popups). With persistence enabled, keys are encrypted and stored locally so returning users can log in silently.
Persistence has two parts: **where** keys are stored and **how** they're protected.
### Storage
| Storage | Platform | Notes |
| - | - | - |
| `localStorage` | Web | Simple, cleared when users clear site data |
| `indexedDB` | Web | Not yet served by the WASM build; selecting it fails at construction |
| `osKeychain` | iOS, Android, React Native | Platform-native secure storage (Keychain / Keystore) through a registered `KeychainDelegate`. iOS and Android ship defaults; see [Configuration](#configuration) |
### Protection (Unlock Method)
You can require the user to unlock stored keys before they're used:
| Unlock Method | How it works |
| - | - |
| `none` | Keys are encrypted but accessible without user input |
| `pin` | User enters a numeric PIN each time |
| `password` | User enters a password each time |
| `biometric` | The stored vault sits behind the OS biometric prompt (iOS, Android, React Native); the vault key itself comes from a wallet signature, so a wallet is still needed to unlock |
| `passkey` | Reserved. Not available in this release on any platform: mobile rejects it at construction and the web vault does not offer it yet |
When `pin` or `password` is used, `authenticate()` returns a `credentialRequired` result so your app can show the appropriate input. See [Authentication — PIN / Password Unlock](/sdk/concepts/authentication#pin--password-unlock).
When `biometric` is used, the platform prompt appears when the stored vault is read.
On mobile the keychain is supplied by the app: register a `KeychainDelegate` once, before constructing an SDK whose config selects `osKeychain`, or use the platform defaults factory, which registers the bundled Keychain (iOS) or Keystore (Android) implementation. Constructing with `osKeychain` and no registered delegate fails with a configuration error that names the missing step.
### Configuration
```typescript TypeScript theme={null}
const sdk = await PrivacyBoost.create({
// ... required fields
persistence: {
storage: 'localStorage', // the WASM build serves localStorage only
unlock: 'pin', // 'none' | 'pin' | 'password'
},
});
```
```swift iOS theme={null}
import PrivacyBoostDefaults
let config = PrivacyBoostConfig(
// ... required fields
persistenceStorage: .osKeychain,
persistenceUnlock: .biometric
)
// Registers DefaultKeychainDelegate unless the app registered its own.
let sdk = try PrivacyBoost.withPlatformDefaults(config: config)
```
```kotlin Android theme={null}
import com.privacyboost.defaults.PrivacyBoostDefaults
val config = PrivacyBoostConfig(
// ... required fields
persistenceStorage = StorageBackend.OS_KEYCHAIN,
persistenceUnlock = UnlockMethod.BIOMETRIC
)
// Registers DefaultKeystoreDelegate unless the app registered its own.
val sdk = PrivacyBoostDefaults.create(context, config)
```
```typescript React Native theme={null}
import { PrivacyBoost, registerKeychainDelegate, StorageBackend, UnlockMethod } from '@sunnyside-io/privacy-boost-react-native';
// Implement KeychainDelegate over your keychain library of choice, then
// register it once before constructing the SDK.
registerKeychainDelegate(new MyKeychainDelegate());
const sdk = new PrivacyBoost({
// ... required fields
persistenceStorage: StorageBackend.OsKeychain,
persistenceUnlock: UnlockMethod.Pin,
});
```
### How Persistence Interacts with Key Source
| Returning user? | What happens |
| - | - |
| First visit (no stored keys) | SDK uses `keySource` to derive keys, then saves them |
| Returning (keys stored) | SDK loads keys from storage; `keySource` is ignored |
This means a user only needs to provide their mnemonic once. After that, keys are loaded from storage.
## Session Export & Import
If you don't want to use persistence, you can manually save and restore sessions on native platforms:
```swift iOS theme={null}
if let session = sdk.exportSession() {
saveToKeychain(session) // You handle secure storage
}
if let session = loadFromKeychain() {
try sdk.importSession(session)
}
```
```kotlin Android theme={null}
sdk.exportSession()?.let { saveToSecureStorage(it) }
loadFromSecureStorage()?.let { sdk.importSession(it) }
```
Exported sessions contain private keys. Always store them in the platform's secure storage (Keychain, Keystore, or encrypted storage).
## Recovery
Keys are derived deterministically, so recovery is straightforward as long as the original key source is available.
| Key Source | How to recover |
| - | - |
| `walletDerived` | Log in with the same wallet — same keys are derived automatically |
| `mnemonic` | Provide the same 12-word phrase |
| `rawSeed` | Provide the same hex seed |
**Funds are never lost.** Deposits live on-chain in the shielded pool. Once keys are re-derived, the SDK automatically finds and restores your balance.
### Common Recovery Scenarios
**Cleared app data or reinstalled the app?**
Re-authenticate with the same key source. A new vault is created automatically.
**Forgot vault PIN/password?**
Clear app storage, then re-authenticate with the original key source. The old vault is discarded and a new one is created. No funds are lost.
**Lost device?**
Your funds are safe — session data is encrypted on-device. Set up a new device and authenticate with the same wallet or mnemonic.
**Switching to a new device?**
Either re-authenticate with the same key source, or on native platforms use `exportSession()` / `importSession()` to transfer the session directly.
If you used `mnemonic` and lose the phrase without having an exported session, your keys cannot be recovered. Back up the phrase securely.
## Next Steps
* [Error Handling](/sdk/concepts/error-handling) — Handle SDK errors with structured error codes
* [Keys & Encryption](/technical-explainer/keys-and-encryption) — Deep dive into the key hierarchy, privacy addresses, and 3-way ECDH encryption
Platform-specific session and storage guides:
Secure Keychain storage and biometric unlock
Android Keystore storage and biometric unlock
AsyncStorage and Keychain persistence
# Portal Deposit Addresses
Source: https://docs.privacyboost.io/sdk/concepts/portal-deposits
Give a user a reusable, public, exchange-style deposit address. Anyone can fund it with standard ERC-20 transfers, and the funds are credited into the owner's shielded balance without revealing which account was credited.
# Portal Deposit Addresses
A **portal** is a reusable, public deposit address — like a centralized-exchange
deposit address, but self-custodial. The owner publishes one fixed address `E`;
any number of senders fund it with ordinary ERC-20 transfers (no Privacy Boost
software, no per-deposit ceremony); and the funds are later **swept** into the
owner's shielded balance. Sweeping is permissionless and crediting is submitted
by an allowed relay, both decoupled from the deposit, so the recipient does not
have to be online for any individual deposit.
Public observers see the portal address, token, amount, timing, and sender — but
**never which Privacy Boost account receives the funds**.
## How it works
A portal address `E` is a normal Ethereum EOA that the owner controls, upgraded
via [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) so it can do three
things: bind its recipient record once (a second binding reverts), sweep
supported tokens into the pool (wrapping plain ETH for the configured
wrapped-native token), and let the owner withdraw funds resting at it. It can
never spend from the pool.
```text theme={null}
Sender(s) ──ERC-20──▶ Portal address E (public, reusable, fixed)
│ sweep (permissionless)
▼
escrow in PrivacyBoost ──epoch proof──▶ owner credited (owner hidden)
│
refund to E ◀──────┘ if uncredited after the sweep's cancel delay
```
Funds still resting at `E` use its owner's direct withdrawal path without the
pool's cancellation delay.
1. **Create.** The owner derives a fresh portal address `E` from their nullifying key,
delegates it to the shared portal implementation, and registers a public
on-chain binding `H` that commits to their account without revealing it.
2. **Publish.** The owner publishes a private registry entry (`E →
(recipientMPK, blind)`, where the client supplies only `blind`) so the
server's indexer can credit deposits to the right account. A missing or wrong
entry only delays crediting — it can never send funds to the wrong account.
3. **Fund.** Anyone sends standard ERC-20 to `E`. This is an ordinary token
transfer; the sender needs nothing from Privacy Boost.
4. **Sweep.** Anyone calls the permissionless sweep, which measures the actual
received balance and escrows it in the pool. Deployments typically run a
keeper so owners do not have to.
5. **Credit.** The relay submits an epoch proof that credits the net amount
after the recorded sweep fee to the owner's shielded balance as a new note.
The owner's identifying key stays private throughout — the proof binds the
credit to the public `H` without revealing the account.
6. **Reclaim / withdraw.** If a swept deposit is never credited, anyone can
cancel it after the delay. The contract returns the full gross amount to `E`,
regardless of who calls. Funds that were never swept (e.g. an unsupported
token) can be withdrawn directly from `E`.
A swept deposit only ever ends **credited** or **reclaimed** — no third party can
force any other outcome, and an un-credited deposit is always recoverable.
Cancellation becomes eligible at `requestBlock + cancelDelay`, where
`requestBlock` is recorded at the sweep and `cancelDelay()` gives the pool's
configured delay in blocks.
For the configured wrapped-native token, a sweep can wrap native ETH above the
portal's `0.001 ETH` reserve. Receiving ETH alone does not wrap or credit it.
Ordinary ERC-20 sweeps leave native ETH untouched.
## SDK API shape
The SDK exposes the same high-level portal lifecycle across TypeScript, CLI,
WASM, iOS, Android, and React Native:
* `create` / `createPortal` derives `E`, gaslessly delegates and registers it,
and publishes discovery.
* `listPage` / `listPortalsPage`, `depositsPage` / `listPortalDepositsPage`, and
`status` / `getPortalStatus` expose portal and deposit state.
* `sweep` / `sweepPortal` triggers `requestPortalDeposit` from the connected
wallet when an app needs a self-sweep backstop.
* `reclaim` / `reclaimPortalDeposit` reclaims an uncredited swept deposit after
the cancel delay.
* `withdraw` / `withdrawPortal` signs an escape-hatch transaction from `E` for
raw funds resting at the portal.
The seed-derived high-level flow keeps `E`'s key inside core; it never crosses
the binding boundary. Low-level `generatePortalEoa` and `encodePortalWithdraw`
remain only as custodial escape hatches for integrations that intentionally
manage their own portal EOA key.
## Trust and privacy
The portal is a **deposit-only** extension: it can only add notes to the shared
note tree, never spend or alter existing notes, and it grants no new capability
to the relay or TEE.
| Actor | Can | Cannot |
| - | - | - |
| **Owner** | register once; spend credited funds; reclaim / self-withdraw | — |
| **Sender** | send ERC-20 to public `E` | learn which account `E` funds; spend it |
| **Sweeper** | move `E`'s balance into the pool | redirect the credit; learn the recipient; alter the amount |
| **Relay / prover** | build the proof from non-spend values; submit the epoch | change amount/recipient; credit a different account; spend |
| **TEE** | observe deposits; derive the owner's note via its registry | sign anything; custody, move, or seize funds |
A portal note joins the **same shared note tree** as every other deposit and
transfer, so its anonymity set is the full mixed pool. The portal hides *who
receives*, not *that `E` was funded*. Because a reusable public address makes
the `sender ↔ E` link public, use **one portal per counterparty** or rotate
portals when unlinkability between funders matters — these are product-layer
mitigations, not cryptographic guarantees. The discovery registry (`E →
(recipientMPK, blind)`) is privacy-load-bearing and should be treated as
confidential. Preserve `recipientMPK` and `blind` for recovery.
## Current limitations
These are known constraints of the current implementation; design around them:
* **`status.delegated` always reports `false`.** A portal you created **is**
delegated regardless; gate readiness on `registered` and `published`.
* **`deposit.cancellableAtBlock` is always `0`.** Treat it as *unknown*, not
"reclaimable now" — the chain still enforces the delay on reclaim.
* **The sweep fee is one global rate, not per-token.** `requestPortalDeposit` is
permissionless: the caller is recorded as the fee payee, and the rate is
snapshotted into the record at sweep time so a later rate change cannot alter
an already-escrowed sweep. The credited amount is
`gross - floor(gross * feeBps / 10000)`; cancellation returns the gross
amount. The rate is a single owner-set value capped at 10%, so a deployment
running it at zero credits the full gross amount. If fee payment fails at
settlement, the sweeper can claim the deferred fee later.
* **Registration is write-once per `E`.** A portal binding cannot be changed
after it is set; derive a new portal instead.
* **Not every network has a `PortalDelegate`.** Portals need the shared EIP-7702
delegate deployed on the target chain and advertised by the server. In
TypeScript and React, `sdk.portal.delegateAddress()` returns nothing when it
is absent, as does `portalDelegateAddress()` on the other platforms. See
[Contract Addresses](/technical-explainer/contract-addresses).
* **Same-chain credit.** Portals credit the pool on the portal's chain. Moving
funds between chains requires a separate bridge flow; multi-chain portals are
a planned additive extension.
## Using it from the SDK
# Transaction Lifecycle
Source: https://docs.privacyboost.io/sdk/concepts/transaction-lifecycle
How Privacy Boost transactions progress from submission to finality, and how to use preconfirmation to chain actions before on-chain settlement
# Transaction Lifecycle
Privacy Boost transactions don't settle the instant you submit them. Settlement takes several seconds in the steady state and longer under congestion, so the SDK surfaces intermediate states you can drive UI and dependent actions from.
This page explains what each state means and how to use **preconfirmation** — the intermediate state that lets you chain private actions before on-chain finality.
## The state machine
Every transaction carries a raw lifecycle `status` and a derived consumer-facing `phase`. Use `phase` for UI and business logic; use `status` only for debugging, audit, or history views.
```typescript theme={null}
enum TransactionStatus {
pending = 'pending',
preconfirmed = 'preconfirmed',
completed = 'completed',
failed = 'failed',
blocked = 'blocked',
}
enum TransactionPhase {
inFlight = 'inFlight',
spendable = 'spendable',
final = 'final',
failed = 'failed',
}
```
| Status | Phase | Meaning |
| - | - | - |
| `pending` | `inFlight` | Accepted by the SDK but not yet preconfirmed. |
| `preconfirmed` (transfer) | `spendable` | The output note can be selected as input for the next private action. |
| `preconfirmed` (shield, unshield) | `inFlight` | Progress only — no spendable output is produced. |
| `completed` | `final` | Settled on-chain. Terminal success. |
| `failed` | `failed` | Terminal failure — validation reject, revert, or unrecoverable reorg. |
| `blocked` | `inFlight` | Held by compliance review. See `complianceStatus` for the verdict. |
`phase` is recomputed automatically whenever `status` or `type` changes — you don't need to derive it yourself.
## What preconfirmation guarantees
When a transfer reaches `preconfirmed`, its output note is spendable: `getNotes`, `getBalance`, and `prepareTransfer` will select it as input for a chained operation, and the input notes it consumed are locked from reuse.
It does **not** mean the transaction is on-chain. A reorg can still force a re-batch or transition the transaction to `failed`, in which case any chained spend against the preconfirmed output also unwinds. The practical rule: chain against `spendable`, but keep polling the originating request until it reaches `final`.
Shields and unshields also pass through `preconfirmed`, but neither produces a spendable output — treat their preconfirmed state as progress UI only.
## Polling
The SDK polls in-flight transactions for you:
```typescript theme={null}
sdk.transactions.poll(); // returns { pending, resolved, preconfirmed }
```
* `pending` — still in flight (`pending` or `preconfirmed`).
* `resolved` — newly reached `completed` or `failed` since the previous poll.
* `preconfirmed` — newly transitioned into `preconfirmed` since the previous poll. Fires once per transaction.
The React SDK exposes the same signals through `useTransactions({ onPreconfirmed, onAllResolved })`. See the [React](/sdk/react/guides/transactions) and [TypeScript](/sdk/typescript/guides/transactions) guides for usage.
## Recommended UX patterns
* **Show two distinct states**, not one. "Submitted" (`inFlight`) and "Ready to use" (transfer-only `spendable`) communicate progress meaningfully; collapsing them into "pending" hides the preconfirmation win.
* **Gate chained actions on `phase === 'spendable'`**, not on `status === 'completed'`. The whole point of preconfirmation is to unblock the next action a few seconds earlier.
* **Mark preconfirmed transfers as not-yet-final in history.** A dim row or "finalizing" badge keeps the distinction visible until polling reports `completed`.
* **Treat shield `preconfirmed` as progress only.** Don't let the user spend against a shielded amount until it reaches `completed`.
* **Handle `blocked` explicitly** via `complianceStatus` rather than treating it as a generic failure.
## State transitions
```
pending ─► preconfirmed ─► completed (happy path)
│ │
│ └─► pending ─► … (reorg: re-batch into a later epoch)
│ │
│ └─► failed (revert / unrecoverable reorg)
│
├─► blocked ─► pending ─► … (compliance clears)
│ └────► failed (compliance rejects)
│
└─► failed (validation reject)
```
`phase` is authoritative — UI should react to transitions rather than caching past states. Input notes consumed by a transaction become spendable again whenever it lands in `failed`.
## See also
* [TypeScript: Transactions](/sdk/typescript/guides/transactions)
* [React: Transactions](/sdk/react/guides/transactions)
* [Protocol Deep Dive](/technical-explainer/protocol)
* [Glossary: Preconfirmation](/technical-explainer/glossary#preconfirmation)
# Server-to-Server Authentication
Source: https://docs.privacyboost.io/sdk/guides/api-secret
Authenticate Privacy Boost users with API credentials for backend integrations
# Server-to-Server Authentication
The `api_secret` auth method is designed for server-to-server integrations where your backend controls the authentication flow. Instead of relying on a third-party auth provider, your backend authenticates directly with the Privacy Boost API using a `client_id` and `client_secret` pair — similar to OAuth 2.0 client credentials.
This is the right choice when:
* Your backend already manages user authentication and you don't use a third-party auth provider
* You need programmatic access (scripts, bots, automated systems)
* You want full control over who can authenticate without depending on an external JWKS endpoint
## How It Works
## Setup
### 1. Get API Credentials
Contact the Privacy Boost team to configure your app with the `api_secret` auth method. You'll receive:
* **App ID** — Your application identifier (e.g., `app_abc123xyz`)
* **Client ID** — A unique identifier for your credentials (e.g., `pb_cred_abc123xyz...`)
* **Client Secret** — A secret key for authenticating requests
Store your `client_secret` securely. It should never be exposed in client-side code, committed to version control, or logged. Use environment variables or a secrets manager.
### 2. Store Credentials Securely
```bash theme={null}
# Environment variables (development)
export PB_CLIENT_ID="pb_cred_abc123xyz..."
export PB_CLIENT_SECRET="your-secret-here"
# Or use a secrets manager in production (AWS Secrets Manager, HashiCorp Vault, etc.)
```
## Client-Side Integration
If this runs in a browser, set `keyVault.mode` to `local` before passing a `tokenProvider`; the default browser iframe vault intentionally does not accept host-page token callbacks.
```typescript theme={null}
import { PrivacyBoost } from '@sunnyside-io/privacy-boost';
const sdk = new PrivacyBoost({
serverUrl: 'https://optimism.sepolia.privacyboost.io',
appId: 'app_abc123xyz',
keyVault: { mode: 'local' },
});
```
### Implement a Token Provider
Your token provider routes the SDK's login payload through your backend:
```typescript TypeScript theme={null}
const tokenProvider = async (loginPayload: any) => {
const res = await fetch('https://your-backend.com/api/privacy-boost/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${yourSessionToken}`, // Your own auth
},
body: JSON.stringify(loginPayload),
});
if (!res.ok) {
// Retry-worthy only when the backend was briefly unavailable.
throw Object.assign(new Error(`Auth backend answered ${res.status}`), {
retryable: res.status === 429 || res.status >= 500,
});
}
return await res.json(); // { token, expiresIn }
};
await sdk.auth.authenticate(adapter, {
keySource: { type: 'walletDerived' },
tokenProvider,
});
```
```swift iOS theme={null}
class APISecretTokenProvider: TokenProvider {
func getToken(loginPayloadJson: String) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: "https://your-backend.com/api/privacy-boost/auth")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(yourSessionToken)", forHTTPHeaderField: "Authorization")
request.httpBody = loginPayloadJson.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
return TokenResponse(
token: json["token"] as! String,
expiresIn: json["expiresIn"] as! UInt64
)
}
}
try await sdk.authenticate(
wallet: walletDelegate,
tokenProvider: APISecretTokenProvider()
)
```
```kotlin Android theme={null}
class APISecretTokenProvider : TokenProvider {
override suspend fun getToken(loginPayloadJson: String): TokenResponse {
val response = httpClient.post("https://your-backend.com/api/privacy-boost/auth") {
contentType(ContentType.Application.Json)
header("Authorization", "Bearer $yourSessionToken")
setBody(loginPayloadJson)
}
val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject
return TokenResponse(
token = json["token"]!!.jsonPrimitive.content,
expiresIn = json["expiresIn"]!!.jsonPrimitive.long.toULong()
)
}
}
sdk.authenticate(
wallet = walletDelegate,
tokenProvider = APISecretTokenProvider()
)
```
## Backend Endpoint
Your backend endpoint receives the SDK's login payload, adds your API credentials, and forwards to Privacy Boost:
```typescript theme={null}
// Express.js example
function toPrivacyBoostAuthPayload(payload: any) {
return {
grant_type: payload.grantType,
app_id: payload.appId,
client_id: payload.clientId,
client_secret: payload.clientSecret,
mpk: payload.mpk,
viewauth_signature: payload.viewauthSignature,
nonce: payload.nonce,
account_id: payload.accountId,
account_salt: payload.accountSalt,
viewing_public_key: payload.viewingPublicKey,
nullifying_key: payload.nullifyingKey,
wallet_address: payload.walletAddress,
auth_pk_x: payload.authPkX,
auth_pk_y: payload.authPkY,
auth_expiry: payload.authExpiry,
auth_signature: payload.authSignature,
};
}
app.post('/api/privacy-boost/auth', async (req, res) => {
// 1. Verify your own user auth (session, JWT, etc.)
const user = await verifyYourAuth(req.headers.authorization);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
// 2. Forward to Privacy Boost with API credentials
const pbResponse = await fetch(
'https://optimism.sepolia.privacyboost.io/auth/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...toPrivacyBoostAuthPayload(req.body), // SDK camelCase payload -> /auth/token wire payload
client_id: process.env.PB_CLIENT_ID, // Your API credential ID
client_secret: process.env.PB_CLIENT_SECRET, // Your API secret
}),
}
);
if (!pbResponse.ok) {
const err = await pbResponse.json();
return res.status(pbResponse.status).json(err);
}
const data = await pbResponse.json();
res.json({
token: data.access_token,
expiresIn: data.expires_in,
});
});
```
```python theme={null}
# FastAPI example
def to_privacy_boost_auth_payload(payload: dict) -> dict:
wire = {
"grant_type": payload.get("grantType"),
"app_id": payload.get("appId"),
"client_id": payload.get("clientId"),
"client_secret": payload.get("clientSecret"),
"mpk": payload.get("mpk"),
"viewauth_signature": payload.get("viewauthSignature"),
"nonce": payload.get("nonce"),
"account_id": payload.get("accountId"),
"account_salt": payload.get("accountSalt"),
"viewing_public_key": payload.get("viewingPublicKey"),
"nullifying_key": payload.get("nullifyingKey"),
"wallet_address": payload.get("walletAddress"),
"auth_pk_x": payload.get("authPkX"),
"auth_pk_y": payload.get("authPkY"),
"auth_expiry": payload.get("authExpiry"),
"auth_signature": payload.get("authSignature"),
}
return {key: value for key, value in wire.items() if value is not None}
@app.post("/api/privacy-boost/auth")
async def privacy_boost_auth(request: Request):
# 1. Verify your own user auth
user = await verify_your_auth(request.headers.get("authorization"))
if not user:
raise HTTPException(status_code=401)
# 2. Forward to Privacy Boost with API credentials
payload = to_privacy_boost_auth_payload(await request.json())
payload["client_id"] = os.environ["PB_CLIENT_ID"]
payload["client_secret"] = os.environ["PB_CLIENT_SECRET"]
async with httpx.AsyncClient() as client:
pb_response = await client.post(
"https://optimism.sepolia.privacyboost.io/auth/token",
json=payload,
)
data = pb_response.json()
return {"token": data["access_token"], "expiresIn": data["expires_in"]}
```
## Credential Management
### Rotation
API credentials can be rotated without downtime:
1. Request a new credential from the Privacy Boost team
2. Update your backend to use the new `client_id` and `client_secret`
3. Revoke the old credential
### Expiry
Credentials can optionally have an expiry date. If your credential expires, requests will fail with `invalid_credentials`. Request a new credential before the current one expires.
## Security Best Practices
1. **Never expose credentials in client-side code.** The `client_id` and `client_secret` must only exist on your backend.
2. **Use environment variables or a secrets manager.** Don't hardcode credentials.
3. **Rotate credentials periodically.** Treat them like passwords.
4. **Authenticate your own users first.** Your backend endpoint should verify the caller's identity (session token, JWT, etc.) before forwarding to Privacy Boost. Otherwise, anyone who discovers your endpoint can authenticate.
5. **Use HTTPS only.** All communication between your backend and Privacy Boost must be over HTTPS.
## Next Steps
Continue with setup:
* [Key Management](/sdk/concepts/key-management) — Configure key persistence for returning users
* [Error Handling](/sdk/concepts/error-handling) — Handle auth and operation errors
Or explore other auth methods:
* [Custom JWT](/sdk/guides/custom-jwt) — For Auth0, Firebase, Supabase, Clerk, or OIDC providers
* [Privy](/sdk/guides/privy) — For Privy social login and embedded wallets
# Custom JWT Integration
Source: https://docs.privacyboost.io/sdk/guides/custom-jwt
Authenticate Privacy Boost users with your own auth system using custom JWTs
# Custom JWT Integration
If you already have an authentication system that issues JWTs (Auth0, Firebase Auth, Supabase Auth, Clerk, or a custom backend), you can use the `custom_jwt` auth method to connect it to Privacy Boost. Your backend validates the user's identity with your auth provider and forwards a JWT to Privacy Boost, which verifies it via your JWKS endpoint.
## How It Works
## Server-Side Setup
Your app must be configured with the `custom_jwt` auth method. Contact the Privacy Boost team with:
* **JWKS URL** — The HTTPS endpoint serving your JSON Web Key Set (e.g., `https://your-auth.com/.well-known/jwks.json`). Privacy Boost fetches this to verify JWT signatures.
* **Audience** — Required expected `aud` claim in your JWTs. Only tokens with a matching audience are accepted.
* **Issuer** — Required expected `iss` claim in your JWTs. Only tokens with a matching issuer are accepted.
The backend configuration looks like:
```json theme={null}
{
"jwks_url": "https://your-auth.com/.well-known/jwks.json",
"audience": "your-app-id",
"issuer": "https://your-auth.com"
}
```
### JWT Requirements
Your JWT must:
* Be signed with RS256, RS384, RS512, ES256, ES384, or ES512
* Include a `kid` (Key ID) header matching a key in your JWKS
* Include a `sub` or `user_id` claim identifying the user
* Include an `exp` claim and be valid (not expired)
* Match the configured `aud` and `iss`
## Client-Side Integration
### 1. Configure Privacy Boost for Direct IdP Auth
Browser IdP callbacks are only supported in local key-vault mode. The default browser mode is the isolated iframe vault, which intentionally does not accept host-page token callbacks.
```typescript theme={null}
import { PrivacyBoost } from '@sunnyside-io/privacy-boost';
const sdk = new PrivacyBoost({
serverUrl: 'https://optimism.sepolia.privacyboost.io',
appId: 'app_abc123xyz',
keyVault: { mode: 'local' },
});
```
### 2. Provide the External JWT
For browser apps, pass `getExternalToken`. It returns the raw JWT from Auth0, Firebase, Supabase, Clerk, or your own auth system. The SDK attaches it to both Privacy Boost auth requests.
```typescript TypeScript theme={null}
const getExternalToken = async () => {
const token = await getYourAuthToken();
if (!token) throw new Error('Not logged in');
return token;
};
await sdk.auth.authenticate(adapter, {
keySource: { type: 'walletDerived' },
getExternalToken,
});
```
### Optional: Backend Token Provider
Use `tokenProvider` instead when your backend must perform the Privacy Boost token exchange itself. Do not pass both `tokenProvider` and `getExternalToken`.
Anything the provider throws is reported as a non-retryable `AUTHENTICATION_FAILED`, so set `retryable: true` on errors that are worth retrying, as below.
```typescript TypeScript theme={null}
const tokenProvider = async (loginPayload: any) => {
// Get your auth token (from your auth system)
const yourAuthToken = await getYourAuthToken();
// Send both to your backend
const res = await fetch('https://your-backend.com/api/privacy-boost/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${yourAuthToken}`,
},
body: JSON.stringify(loginPayload),
});
if (!res.ok) {
// Retry-worthy only when the backend was briefly unavailable.
throw Object.assign(new Error(`Auth backend answered ${res.status}`), {
retryable: res.status === 429 || res.status >= 500,
});
}
return await res.json(); // { token, expiresIn }
};
await sdk.auth.authenticate(adapter, {
keySource: { type: 'walletDerived' },
tokenProvider,
});
```
```swift iOS theme={null}
class CustomJWTTokenProvider: TokenProvider {
func getToken(loginPayloadJson: String) async throws -> TokenResponse {
let yourAuthToken = try await getYourAuthToken()
var request = URLRequest(url: URL(string: "https://your-backend.com/api/privacy-boost/auth")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(yourAuthToken)", forHTTPHeaderField: "Authorization")
request.httpBody = loginPayloadJson.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: request)
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
return TokenResponse(
token: json["token"] as! String,
expiresIn: json["expiresIn"] as! UInt64
)
}
}
```
```kotlin Android theme={null}
class CustomJWTTokenProvider : TokenProvider {
override suspend fun getToken(loginPayloadJson: String): TokenResponse {
val yourAuthToken = getYourAuthToken()
val response = httpClient.post("https://your-backend.com/api/privacy-boost/auth") {
contentType(ContentType.Application.Json)
header("Authorization", "Bearer $yourAuthToken")
setBody(loginPayloadJson)
}
val json = Json.parseToJsonElement(response.bodyAsText()).jsonObject
return TokenResponse(
token = json["token"]!!.jsonPrimitive.content,
expiresIn = json["expiresIn"]!!.jsonPrimitive.long.toULong()
)
}
}
```
### 3. Pass Native Token Providers to authenticate()
```swift iOS theme={null}
try await sdk.authenticate(
wallet: walletDelegate,
tokenProvider: CustomJWTTokenProvider()
)
```
```kotlin Android theme={null}
sdk.authenticate(
wallet = walletDelegate,
tokenProvider = CustomJWTTokenProvider()
)
```
### 4. Implement the Backend Endpoint
Your backend endpoint receives the SDK's login payload, attaches your JWT, and forwards to Privacy Boost:
```typescript theme={null}
// Express.js example
function toPrivacyBoostAuthPayload(payload: any) {
return {
grant_type: payload.grantType,
app_id: payload.appId,
client_id: payload.clientId,
client_secret: payload.clientSecret,
mpk: payload.mpk,
viewauth_signature: payload.viewauthSignature,
nonce: payload.nonce,
account_id: payload.accountId,
account_salt: payload.accountSalt,
viewing_public_key: payload.viewingPublicKey,
nullifying_key: payload.nullifyingKey,
wallet_address: payload.walletAddress,
auth_pk_x: payload.authPkX,
auth_pk_y: payload.authPkY,
auth_expiry: payload.authExpiry,
auth_signature: payload.authSignature,
};
}
app.post('/api/privacy-boost/auth', async (req, res) => {
// 1. Verify your own auth (e.g., check the Authorization header)
const user = await verifyYourAuth(req.headers.authorization);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
// 2. Get a JWT for this user from your auth system
const customJWT = await issueJWTForUser(user.id);
// 3. Forward to Privacy Boost with the custom JWT attached
const pbResponse = await fetch('https://optimism.sepolia.privacyboost.io/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...toPrivacyBoostAuthPayload(req.body), // SDK camelCase payload -> /auth/token wire payload
custom_jwt_token: customJWT, // Your JWT
}),
});
const data = await pbResponse.json();
res.json({
token: data.access_token,
expiresIn: data.expires_in,
});
});
```
## Testing
For development, you can use `app_id_only` to skip the token provider entirely. Switch to `custom_jwt` when moving to production.
## Next Steps
Continue with setup:
* [Key Management](/sdk/concepts/key-management) — Configure key persistence for returning users
* [Error Handling](/sdk/concepts/error-handling) — Handle auth and operation errors
Or explore other auth methods:
* [Privy](/sdk/guides/privy) — For Privy social login and embedded wallets
* [Dynamic](/sdk/guides/dynamic) — For Dynamic wallet connection and embedded wallets
* [API Secret](/sdk/guides/api-secret) — For server-to-server with client credentials
# Dynamic Integration
Source: https://docs.privacyboost.io/sdk/guides/dynamic
Authenticate Privacy Boost users with Dynamic
# Dynamic Integration
[Dynamic](https://www.dynamic.xyz) provides wallet connection, embedded wallets, and multi-chain auth for web3 apps. You can use Dynamic as your auth provider for Privacy Boost, letting users log in through Dynamic's UI while using Privacy Boost for private transactions.
## How It Works
Dynamic authenticates your users and issues JWTs. The SDK can attach the raw Dynamic JWT to Privacy Boost's login flow directly, and Privacy Boost validates it against Dynamic's JWKS endpoint using the `custom_jwt` auth method.
## Server-Side Setup
Your app must be configured with the `custom_jwt` auth method. Contact the Privacy Boost team with:
* **JWKS URL** — Dynamic's JWKS endpoint for your environment: `https://app.dynamic.xyz/api/v0/sdk//.well-known/jwks`
* **Issuer** — Required expected `iss` claim: `app.dynamic.xyz/`
* **Audience** — Required expected `aud` claim from your Dynamic access token. This is the token audience, not the `environment_id` claim.
The backend configuration looks like:
```json theme={null}
{
"jwks_url": "https://app.dynamic.xyz/api/v0/sdk//.well-known/jwks",
"issuer": "app.dynamic.xyz/",
"audience": ""
}
```
Find your Environment ID in the [Dynamic Dashboard](https://app.dynamic.xyz) under Developer > SDK & API Keys.
Decode a representative Dynamic access token and configure `audience` to match its exact `aud` claim. Do not use the `environment_id` claim unless your token's `aud` is actually the same value.
## Client-Side Integration
### 1. Set Up Dynamic
Follow [Dynamic's React quickstart](https://www.dynamic.xyz/docs/react) to set up `DynamicContextProvider`:
```tsx theme={null}
import { DynamicContextProvider } from '@dynamic-labs/sdk-react-core';
import { EthereumWalletConnectors } from '@dynamic-labs/ethereum';
function App() {
return (
);
}
```
### 2. Configure Privacy Boost for Direct IdP Auth
Browser IdP callbacks are only supported in local key-vault mode. The default browser mode is the isolated iframe vault, which intentionally does not accept host-page token callbacks.
```typescript theme={null}
import { PrivacyBoost } from '@sunnyside-io/privacy-boost';
const sdk = new PrivacyBoost({
serverUrl: 'https://optimism.sepolia.privacyboost.io',
appId: 'app_abc123xyz',
keyVault: { mode: 'local' },
});
```
### 3. Create an External Token Callback
The callback returns the raw Dynamic auth token. The SDK attaches it to both the `/auth/login` bootstrap and `/auth/token` exchange:
```typescript theme={null}
import { useDynamicContext } from '@dynamic-labs/sdk-react-core';
function usePrivacyBoostAuth() {
const { authToken } = useDynamicContext();
const getExternalToken = async () => {
if (!authToken) throw new Error('Not logged in to Dynamic');
return authToken;
};
return { getExternalToken };
}
```
### 4. Authenticate with Privacy Boost
```typescript theme={null}
const { getExternalToken } = usePrivacyBoostAuth();
await sdk.auth.authenticate(adapter, {
keySource: { type: 'walletDerived' },
getExternalToken,
});
```
### Optional: Backend Relay Instead
If you prefer to keep all auth exchanges behind your own backend, use `tokenProvider` instead of `getExternalToken`. The options are mutually exclusive.
Your backend receives the SDK's login payload, and forwards to Privacy Boost with the Dynamic JWT:
```typescript theme={null}
const tokenProvider = async (loginPayload: any) => {
const dynamicToken = await getExternalToken();
const res = await fetch('https://your-backend.com/api/privacy-boost/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${dynamicToken}`,
},
body: JSON.stringify(loginPayload),
});
if (!res.ok) {
// Retry-worthy only when the backend was briefly unavailable.
throw Object.assign(new Error(`Auth backend answered ${res.status}`), {
retryable: res.status === 429 || res.status >= 500,
});
}
return await res.json(); // { token, expiresIn }
};
app.post('/api/privacy-boost/auth', async (req, res) => {
const toPrivacyBoostAuthPayload = (payload: any) => ({
grant_type: payload.grantType,
app_id: payload.appId,
client_id: payload.clientId,
client_secret: payload.clientSecret,
mpk: payload.mpk,
viewauth_signature: payload.viewauthSignature,
nonce: payload.nonce,
account_id: payload.accountId,
account_salt: payload.accountSalt,
viewing_public_key: payload.viewingPublicKey,
nullifying_key: payload.nullifyingKey,
wallet_address: payload.walletAddress,
auth_pk_x: payload.authPkX,
auth_pk_y: payload.authPkY,
auth_expiry: payload.authExpiry,
auth_signature: payload.authSignature,
});
// 1. Extract Dynamic token from Authorization header
const dynamicToken = req.headers.authorization?.replace('Bearer ', '');
if (!dynamicToken) return res.status(401).json({ error: 'Missing Dynamic token' });
// 2. Forward to Privacy Boost with the Dynamic JWT attached
const pbResponse = await fetch('https://optimism.sepolia.privacyboost.io/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...toPrivacyBoostAuthPayload(req.body), // SDK camelCase payload -> /auth/token wire payload
custom_jwt_token: dynamicToken, // Dynamic JWT — validated via JWKS
}),
});
const data = await pbResponse.json();
res.json({
token: data.access_token,
expiresIn: data.expires_in,
});
});
```
### Using Dynamic's Wallet Connector
If your users connect wallets through Dynamic, you can use the connected wallet's provider with the Privacy Boost SDK:
```typescript theme={null}
import { useDynamicContext } from '@dynamic-labs/sdk-react-core';
function usePrivacyBoostWithDynamic() {
const { primaryWallet } = useDynamicContext();
const getAdapter = async () => {
if (!primaryWallet) throw new Error('No wallet connected');
const provider = await primaryWallet.getWalletClient();
return {
async connect() {
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const chainId = await provider.request({ method: 'eth_chainId' });
return { address: accounts[0], chainId: parseInt(chainId, 16) };
},
async disconnect() {},
async signMessage(message: string) {
const accounts = await provider.request({ method: 'eth_accounts' });
return provider.request({ method: 'personal_sign', params: [message, accounts[0]] });
},
async signTypedData(typedData: string) {
const accounts = await provider.request({ method: 'eth_accounts' });
return provider.request({ method: 'eth_signTypedData_v4', params: [accounts[0], typedData] });
},
async sendTransaction(tx: unknown) {
return provider.request({ method: 'eth_sendTransaction', params: [tx] });
},
async getAddress() {
const accounts = await provider.request({ method: 'eth_accounts' });
return accounts[0];
},
async getChainId() {
const chainId = await provider.request({ method: 'eth_chainId' });
return parseInt(chainId, 16);
},
};
};
return { getAdapter };
}
```
## Next Steps
Continue with setup:
* [Key Management](/sdk/concepts/key-management) — Configure key persistence for returning users
* [Error Handling](/sdk/concepts/error-handling) — Handle auth and operation errors
Or explore other auth methods:
* [Custom JWT](/sdk/guides/custom-jwt) — For Auth0, Firebase, Supabase, Clerk, or OIDC providers
* [Privy](/sdk/guides/privy) — For Privy social login and embedded wallets
# Privy Integration
Source: https://docs.privacyboost.io/sdk/guides/privy
Authenticate Privacy Boost users with Privy
# Privy Integration
[Privy](https://www.privy.io) provides embedded wallets, social login, and user management for web3 apps. You can use Privy as your auth provider for Privacy Boost, letting users log in with email, social accounts, or wallets through Privy while using Privacy Boost for private transactions.
## How It Works
Privy authenticates your users and issues JWTs. The SDK can attach the raw Privy token to Privacy Boost's login flow directly, and Privacy Boost validates the token against Privy's JWKS endpoint.
## Server-Side Setup
Your app must be configured with the `privy_jwt` auth method. Contact the Privacy Boost team with:
* **Privy App ID** — Your Privy application ID (found in the [Privy Dashboard](https://dashboard.privy.io))
* **JWKS URL** — Privy's JWKS endpoint: `https://auth.privy.io/api/v1/apps//.well-known/jwks.json`
The backend configuration looks like:
```json theme={null}
{
"privy_app_id": "clx...",
"jwks_url": "https://auth.privy.io/api/v1/apps/clx.../.well-known/jwks.json"
}
```
## Client-Side Integration
### 1. Set Up Privy
Follow [Privy's React quickstart](https://docs.privy.io/guide/react/quickstart) to set up `PrivyProvider`:
```tsx theme={null}
import { PrivyProvider } from '@privy-io/react-auth';
function App() {
return (
);
}
```
### 2. Configure Privacy Boost for Direct IdP Auth
Browser IdP callbacks are only supported in local key-vault mode. The default browser mode is the isolated iframe vault, which intentionally does not accept host-page token callbacks.
```typescript theme={null}
import { PrivacyBoost } from '@sunnyside-io/privacy-boost';
const sdk = new PrivacyBoost({
serverUrl: 'https://optimism.sepolia.privacyboost.io',
appId: 'app_abc123xyz',
keyVault: { mode: 'local' },
});
```
### 3. Create an External Token Callback
The callback returns the raw Privy auth token. The SDK attaches it to both the `/auth/login` bootstrap and `/auth/token` exchange:
```typescript theme={null}
import { usePrivy } from '@privy-io/react-auth';
function usePrivacyBoostAuth() {
const { getAccessToken } = usePrivy();
const getExternalToken = async () => {
const privyToken = await getAccessToken();
if (!privyToken) throw new Error('Not logged in to Privy');
return privyToken;
};
return { getExternalToken };
}
```
### 4. Authenticate with Privacy Boost
```typescript theme={null}
const { getExternalToken } = usePrivacyBoostAuth();
await sdk.auth.authenticate(adapter, {
keySource: { type: 'walletDerived' },
getExternalToken,
});
```
### Optional: Backend Relay Instead
If you prefer to keep all auth exchanges behind your own backend, use `tokenProvider` instead of `getExternalToken`. The options are mutually exclusive.
Your backend receives the SDK's login payload, verifies the Privy token, and forwards to Privacy Boost:
```typescript theme={null}
const tokenProvider = async (loginPayload: any) => {
const privyToken = await getExternalToken();
const res = await fetch('https://your-backend.com/api/privacy-boost/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${privyToken}`,
},
body: JSON.stringify(loginPayload),
});
if (!res.ok) {
// Retry-worthy only when the backend was briefly unavailable.
throw Object.assign(new Error(`Auth backend answered ${res.status}`), {
retryable: res.status === 429 || res.status >= 500,
});
}
return await res.json(); // { token, expiresIn }
};
app.post('/api/privacy-boost/auth', async (req, res) => {
const toPrivacyBoostAuthPayload = (payload: any) => ({
grant_type: payload.grantType,
app_id: payload.appId,
client_id: payload.clientId,
client_secret: payload.clientSecret,
mpk: payload.mpk,
viewauth_signature: payload.viewauthSignature,
nonce: payload.nonce,
account_id: payload.accountId,
account_salt: payload.accountSalt,
viewing_public_key: payload.viewingPublicKey,
nullifying_key: payload.nullifyingKey,
wallet_address: payload.walletAddress,
auth_pk_x: payload.authPkX,
auth_pk_y: payload.authPkY,
auth_expiry: payload.authExpiry,
auth_signature: payload.authSignature,
});
// 1. Extract Privy token from Authorization header
const privyToken = req.headers.authorization?.replace('Bearer ', '');
if (!privyToken) return res.status(401).json({ error: 'Missing Privy token' });
// 2. Forward to Privacy Boost with the Privy token attached
const pbResponse = await fetch('https://optimism.sepolia.privacyboost.io/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...toPrivacyBoostAuthPayload(req.body), // SDK camelCase payload -> /auth/token wire payload
privy_token: privyToken, // Privy JWT — Privacy Boost validates this via JWKS
}),
});
const data = await pbResponse.json();
res.json({
token: data.access_token,
expiresIn: data.expires_in,
});
});
```
You don't need to validate the Privy JWT yourself. Privacy Boost fetches Privy's JWKS and verifies the token signature, audience, and expiry.
### Using Privy's Embedded Wallet
If your users use Privy's embedded wallet (no MetaMask), you can create a wallet adapter from Privy's wallet provider:
```typescript theme={null}
import { useWallets } from '@privy-io/react-auth';
function usePrivyWalletAdapter() {
const { wallets } = useWallets();
const embeddedWallet = wallets.find((w) => w.walletClientType === 'privy');
const getAdapter = async () => {
if (!embeddedWallet) throw new Error('No embedded wallet');
const provider = await embeddedWallet.getEthereumProvider();
return {
async connect() {
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const chainId = await provider.request({ method: 'eth_chainId' });
return { address: accounts[0], chainId: parseInt(chainId, 16) };
},
async disconnect() {},
async signMessage(message: string) {
const accounts = await provider.request({ method: 'eth_accounts' });
return provider.request({ method: 'personal_sign', params: [message, accounts[0]] });
},
async signTypedData(typedData: string) {
const accounts = await provider.request({ method: 'eth_accounts' });
return provider.request({ method: 'eth_signTypedData_v4', params: [accounts[0], typedData] });
},
async sendTransaction(tx: unknown) {
return provider.request({ method: 'eth_sendTransaction', params: [tx] });
},
async getAddress() {
const accounts = await provider.request({ method: 'eth_accounts' });
return accounts[0];
},
async getChainId() {
const chainId = await provider.request({ method: 'eth_chainId' });
return parseInt(chainId, 16);
},
};
};
return { getAdapter, hasWallet: !!embeddedWallet };
}
```
## Next Steps
Continue with setup:
* [Key Management](/sdk/concepts/key-management) — Configure key persistence for returning users
* [Error Handling](/sdk/concepts/error-handling) — Handle auth and operation errors
Or explore other auth methods:
* [Custom JWT](/sdk/guides/custom-jwt) — For Auth0, Firebase, Supabase, Clerk, or OIDC providers
* [Dynamic](/sdk/guides/dynamic) — For Dynamic wallet connection and embedded wallets
# Error Handling Patterns
Source: https://docs.privacyboost.io/sdk/ios/advanced/error-handling
Advanced error handling — classification, telemetry, and operational patterns
# Error Handling Patterns
This page covers advanced error handling — classification, telemetry, structured logging, and circuit breakers. For the basics (try/catch, retry, re-auth, user-friendly messages), see the [Error Handling guide](../guides/error-handling).
## Error Classification
`PrivacyBoostDefaults` ships an `SdkError.category` extension that buckets every variant into one of seven categories. Use it to make UI/operational decisions without exhaustively matching every case.
```swift theme={null}
import PrivacyBoost
import PrivacyBoostDefaults
extension SdkError {
var category: ErrorCategory { /* .auth, .network, .validation, .permission, .rateLimit, .server, .internal */ }
var isRetryable: Bool { /* network, rate-limit, retryable api errors */ }
}
```
| Category | Meaning | Recommended UI | Retry? |
| - | - | - | - |
| `.auth` | Session/auth flow failed | Re-prompt sign-in | No (but re-auth and try once) |
| `.network` | Connectivity / DNS / timeout | "Check your connection" | Yes, with backoff |
| `.validation` | Input is malformed | Inline form error | No |
| `.permission` | User cancelled / forbidden | Silent dismiss or contact support | No |
| `.rateLimit` | Too many requests | Wait + auto-retry | Yes, after `retryAfterMs` |
| `.server` | Backend / proof / merkle failure | Generic error toast | Sometimes (check `isRetryable`) |
| `.internal` | Bug or invariant violation | "Something went wrong" + report | No |
## Structured Logging
Log SDK errors with stable fields so they're queryable in Splunk / Datadog / OS Console.
```swift theme={null}
import os.log
enum PBLogger {
private static let log = Logger(subsystem: "com.example.app", category: "privacy-boost")
static func error(_ context: String, _ error: Error) {
guard let sdk = error as? SdkError else {
log.error("[\(context, privacy: .public)] non-sdk error: \(error.localizedDescription, privacy: .public)")
return
}
log.error("""
[\(context, privacy: .public)] \
category=\(String(describing: sdk.category), privacy: .public) \
retryable=\(sdk.isRetryable) \
variant=\(self.variantTag(sdk), privacy: .public) \
message=\(self.userMessage(sdk), privacy: .public)
""")
}
private static func variantTag(_ error: SdkError) -> String {
switch error {
case .NotConnected: return "NotConnected"
case .NotAuthenticated: return "NotAuthenticated"
case .AuthenticationFailed: return "AuthenticationFailed"
case .NetworkError: return "NetworkError"
case .WalletError: return "WalletError"
case .SignatureRejected: return "SignatureRejected"
case .InsufficientBalance: return "InsufficientBalance"
case .InvalidAddress: return "InvalidAddress"
case .InvalidAmount: return "InvalidAmount"
case .RateLimited: return "RateLimited"
case .ApiError(let code, _, _): return "ApiError.\(code)"
case .ShieldError(let code, _): return "ShieldError.\(code)"
case .TransferError(let code, _): return "TransferError.\(code)"
default: return "Other"
}
}
private static func userMessage(_ error: SdkError) -> String {
switch error {
case .NetworkError(let m), .WalletError(let m), .InternalError(let m),
.Forbidden(let m), .ResourceNotFound(let m):
return m
case .ApiError(_, let m, _), .ShieldError(_, let m), .TransferError(_, let m):
return m
default:
return ""
}
}
}
```
`privacy: .public` on the variant/category fields keeps them queryable in Console.app; the message field is intentionally not marked public if it could include PII — review your own usage.
## Error Reporting Integration
### Sentry
```swift theme={null}
import Sentry
func reportSDK(_ error: Error, operation: String) {
let event = Event(level: .error)
event.message = SentryMessage(formatted: "PrivacyBoost.\(operation) failed")
if let sdk = error as? SdkError {
event.tags = [
"pb.category": String(describing: sdk.category),
"pb.retryable": String(sdk.isRetryable),
"pb.variant": variantTag(sdk),
]
if case .ApiError(let code, _, _) = sdk {
event.tags?["pb.api_code"] = code
}
}
SentrySDK.capture(event: event)
}
```
The `pb.category` tag lets you build a dashboard that groups errors by class — "auth-class errors are spiking" is more actionable than "twelve different variants are spiking."
### Filter Out Noise
User cancellations (`.signatureRejected`) and validation errors (`.invalidAmount`, `.invalidAddress`) aren't bugs — don't ship them to your error tracker. Use a `beforeSend`-style filter:
```swift theme={null}
func shouldReport(_ error: SdkError) -> Bool {
switch error.category {
case .permission, .validation: return false
default: return true
}
}
```
## Operation-Scoped Wrapping
Wrap each meaningful SDK call in a function that handles logging, reporting, and retry — application code stays clean.
```swift theme={null}
@discardableResult
func runSDK(
_ operation: String,
perform: () async throws -> T
) async throws -> T {
do {
return try await perform()
} catch let error as SdkError {
PBLogger.error(operation, error)
if shouldReport(error) {
reportSDK(error, operation: operation)
}
throw error
} catch {
PBLogger.error(operation, error)
reportSDK(error, operation: operation)
throw error
}
}
// Usage
let result = try await runSDK("shield") {
try await sdk.shield(tokenAddress: token, amount: amount)
}
```
## Circuit Breaker
If the backend is degraded, hammering it with retries makes things worse. Wrap operations in a circuit breaker that trips after consecutive server-class failures.
```swift theme={null}
actor CircuitBreaker {
enum State { case closed, open(until: Date), halfOpen }
private var state: State = .closed
private var consecutiveFailures = 0
private let threshold: Int
private let cooldown: TimeInterval
init(threshold: Int = 5, cooldown: TimeInterval = 60) {
self.threshold = threshold
self.cooldown = cooldown
}
func canPass() -> Bool {
switch state {
case .closed, .halfOpen:
return true
case .open(let until):
if Date() >= until {
state = .halfOpen
return true
}
return false
}
}
func recordSuccess() {
consecutiveFailures = 0
state = .closed
}
func recordFailure(_ error: SdkError) {
switch error.category {
case .server, .network:
consecutiveFailures += 1
if consecutiveFailures >= threshold {
state = .open(until: Date().addingTimeInterval(cooldown))
}
default:
break
}
}
}
let breaker = CircuitBreaker()
func guarded(_ op: () async throws -> T) async throws -> T {
guard await breaker.canPass() else {
throw SdkError.NetworkError(message: "circuit open")
}
do {
let result = try await op()
await breaker.recordSuccess()
return result
} catch let error as SdkError {
await breaker.recordFailure(error)
throw error
}
}
```
Auth/validation/permission failures should not trip the breaker — they're user-class problems, not service degradation. The classification by `category` makes that distinction trivial.
## Telemetry: Operation Latency by Outcome
Pair errors with timings — slow successes are interesting too.
```swift theme={null}
struct OpMetric {
let operation: String
let durationMs: Double
let outcome: String // "success" | category name
}
func instrument(
_ operation: String,
perform: () async throws -> T,
sink: (OpMetric) -> Void
) async throws -> T {
let start = ContinuousClock.now
do {
let result = try await perform()
let elapsed = ContinuousClock.now - start
sink(OpMetric(
operation: operation,
durationMs: elapsed.milliseconds,
outcome: "success"
))
return result
} catch let error as SdkError {
let elapsed = ContinuousClock.now - start
sink(OpMetric(
operation: operation,
durationMs: elapsed.milliseconds,
outcome: String(describing: error.category)
))
throw error
}
}
extension Duration {
var milliseconds: Double {
let comps = components
return Double(comps.seconds) * 1_000 + Double(comps.attoseconds) / 1e15
}
}
```
Forward `OpMetric` to MetricKit, Firebase Performance, or your own analytics pipeline.
## Best Practices
1. **Branch on `.category`, not on individual variants** — UI logic stays small and stable across SDK upgrades.
2. **Filter user-class errors out of error reporters** — `.permission` and `.validation` are noise.
3. **Tag every report with `pb.category` and `pb.variant`** — makes dashboards actually useful.
4. **Trip circuit breakers only on `.server` and `.network`** — never on auth or validation.
5. **Pair errors with timings** — a slow success that turns into a timeout is worth catching upstream.
## Next Steps
* [Performance](./performance)
* [Error Handling guide](../guides/error-handling) — basics
* [Error Handling Concepts](/sdk/concepts/error-handling) — cross-platform error model
# Performance
Source: https://docs.privacyboost.io/sdk/ios/advanced/performance
Performance optimization strategies for the iOS SDK
# Performance
This guide covers performance optimization strategies for the Privacy Boost iOS 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 main-thread hygiene.
## SDK Lifecycle
### One Instance Per Process
`PrivacyBoost` holds a JWT, identity keys, and an internal note cache. Initialize it once and hold it in a long-lived owner — usually an `@MainActor` singleton or an environment object.
```swift theme={null}
@MainActor
final class PrivacyBoostStore: ObservableObject {
static let shared = PrivacyBoostStore()
let sdk: PrivacyBoost
private init() {
let config = PrivacyBoostConfig(
serverUrl: "https://optimism.sepolia.privacyboost.io",
chainId: nil,
shieldContractAddress: nil,
wethContractAddress: "0x4200000000000000000000000000000000000006",
teePublicKey: nil,
appId: "app_abc123xyz",
persistenceStorage: nil,
persistenceUnlock: nil
)
// Force-unwrap is acceptable here — a missing app id at launch is fatal.
self.sdk = try! PrivacyBoost(config: 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.
```swift theme={null}
struct RootView: View {
@State private var store: PrivacyBoostStore?
var body: some View {
Group {
if let store {
WalletView()
.environmentObject(store)
} else {
OnboardingView(onContinue: {
store = PrivacyBoostStore.shared
})
}
}
}
}
```
## Off the Main Thread
All SDK operations are `async throws`. Run them via `Task { ... }` or `.task { ... }` modifiers, never on the main run loop. A misplaced `try await` inside a synchronous function will block the actor it's called on; let SwiftUI's structured concurrency schedule it.
```swift theme={null}
struct DepositButton: View {
@EnvironmentObject var store: PrivacyBoostStore
@State private var pending = false
var body: some View {
Button("Deposit") {
Task {
pending = true
defer { pending = false }
_ = try? await store.sdk.shield(
tokenAddress: token,
amount: amount
)
}
}
.disabled(pending)
}
}
```
For long-running proofs in widgets or notification extensions, hop off the main actor explicitly:
```swift theme={null}
let result = try await Task.detached(priority: .userInitiated) {
try await sdk.shield(tokenAddress: token, amount: amount)
}.value
```
## Balance Refresh
### Don't Poll From Every Screen
`getBalance(tokenAddress:)` and `getAllBalances()` hit the network. Centralize refreshes through a single observable store and let screens subscribe.
```swift theme={null}
@MainActor
final class BalanceStore: ObservableObject {
@Published private(set) var balances: [String: TokenBalance] = [:]
@Published private(set) var lastFetch: Date?
private let sdk: PrivacyBoost
private let staleness: TimeInterval = 30
init(sdk: PrivacyBoost) { self.sdk = sdk }
func refresh(force: Bool = false) async throws {
if !force,
let last = lastFetch,
Date().timeIntervalSince(last) < staleness {
return
}
let fresh = try await sdk.getAllBalances()
balances = Dictionary(uniqueKeysWithValues: fresh.map { ($0.tokenAddress, $0) })
lastFetch = Date()
}
}
```
### Refresh on Foreground, Not on a Timer
Repeated background polling drains battery. Refresh on `scenePhase == .active` and after operations that change balances:
```swift theme={null}
struct AppContent: View {
@EnvironmentObject var balanceStore: BalanceStore
@Environment(\.scenePhase) private var scenePhase
var body: some View {
ContentView()
.task(id: scenePhase) {
if scenePhase == .active {
try? await balanceStore.refresh()
}
}
}
}
```
### Coalesce Concurrent Refreshes
If multiple views hit refresh simultaneously, deduplicate so only one network call happens:
```swift theme={null}
actor BalanceCoordinator {
private var inflight: Task?
private let sdk: PrivacyBoost
init(sdk: PrivacyBoost) { self.sdk = sdk }
func refresh() async throws {
if let inflight { return try await inflight.value }
let task = Task {
_ = try await sdk.getAllBalances()
}
inflight = task
defer { inflight = nil }
_ = try await task.value
}
}
```
## Network
### Parallelize Independent Reads
Sequential awaits serialize unnecessarily. Use `async let`:
```swift theme={null}
async let balances = sdk.getAllBalances()
async let history = sdk.getTransactionHistory(txType: nil, limit: 20, cursor: nil)
async let fees = sdk.getFees(token: token)
let (b, h, f) = try await (balances, history, fees)
```
### Page History — Don't Fetch It All
Pass a small `limit` and use the returned cursor for pagination instead of asking for the full history at once:
```swift theme={null}
let firstPage = try await sdk.getTransactionHistory(
txType: nil,
limit: 20,
cursor: nil
)
// later
if let next = firstPage.next {
let nextPage = try await sdk.getTransactionHistory(
txType: nil,
limit: 20,
cursor: next
)
}
```
## Memory
### Drop Heavy Caches When Backgrounded
If your app stays in the background long enough to receive a memory warning, the SDK's internal state is the second-largest consumer after image caches. Clear non-identity state on `.didReceiveMemoryWarning` or scene-phase transitions.
```swift theme={null}
.onChange(of: scenePhase) { phase in
if phase == .background {
// Drop pending transaction views, transaction history pages, etc.
balanceStore.trimToCurrentToken()
}
}
```
### Don't Hold `Transaction` Arrays in `@State`
For long histories, store an `IdentifiedArray` keyed by `txHash` and flush old entries beyond the visible window. SwiftUI re-diffs `@State` arrays on every change.
## Operation Timing
Wrap SDK calls to observe timing in development:
```swift theme={null}
func timed(_ label: String, op: () async throws -> T) async throws -> T {
let start = ContinuousClock.now
defer {
let elapsed = ContinuousClock.now - start
print("[PB] \(label): \(elapsed)")
}
return try await op()
}
let result = try await timed("shield") {
try await sdk.shield(tokenAddress: token, amount: amount)
}
```
Hook into MetricKit (`MXMetricManager`) in production to surface slow operations as histograms rather than print statements.
## 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 reuse** — `clearSession()` keeps identity keys so the next `authenticate()` is fast.
## Best Practices Summary
1. One SDK instance per process — hold it in a long-lived owner.
2. Defer SDK init until the first privacy-pool screen.
3. All operations on a `Task` — never on the main run loop.
4. Refresh balances on foreground and after writes, not on a timer.
5. Deduplicate concurrent refreshes through an actor.
6. Parallelize independent reads with `async let`.
7. Page transaction history; don't fetch it all.
8. Use `clearSession()` over `logout()` when re-auth is expected soon.
## Next Steps
* [Error Handling Patterns](./error-handling)
* [Wallet Integration](../guides/wallet-integration)
# Api reference
Source: https://docs.privacyboost.io/sdk/ios/api-reference
# API Reference
Complete API reference for the Privacy Boost iOS SDK.
## Generated Documentation
Full API documentation can be generated using `cargo doc`:
```bash theme={null}
# From rust-sdk directory
make docs-rust
# iOS-specific docs
cargo doc --no-deps -p privacy-boost-ios
```
***
## PrivacyBoost
Main SDK class for iOS.
### Constructor
```swift theme={null}
init(config: PrivacyBoostConfig) throws
```
Creates a new SDK instance.
**Parameters:**
* `config` - SDK configuration
**Throws:** `SDKError.internalError` if initialization fails
### Properties
```swift theme={null}
var isConnected: Bool { get }
var isAuthenticated: Bool { get }
```
### Connection & Authentication Methods
#### authenticate
```swift theme={null}
func authenticate(wallet: WalletDelegate, keySource: KeySource? = nil, tokenProvider: TokenProvider? = nil) async throws -> AuthResult
```
Connect a wallet, derive privacy keys, and authenticate with the Privacy Boost backend in a single call.
**Parameters:**
* `wallet` - Implementation of `WalletDelegate` protocol
* `keySource` - Optional key derivation source. If `nil` and no persistence is configured, defaults to `.walletDerived`. If `nil` with persistence configured and no existing vault, an error is thrown.
* `tokenProvider` - Optional custom token provider. If `nil`, the SDK sends the login payload directly to the Privacy Boost backend. Supply a `TokenProvider` to route authentication through your own server.
**Returns:** `AuthResult` - see [AuthResult](#authresult) for all possible outcomes.
**Throws:**
* `SDKError.walletError` if signing fails
* `SDKError.invalidConfig` if configuration is invalid or keySource is required but missing
* `SDKError.networkError` if backend unreachable
#### submitCredential
```swift theme={null}
func submitCredential(_ credential: String, tokenProvider: TokenProvider? = nil) async throws -> LoginResult
```
Submit a credential when `authenticate()` returns `.credentialRequired`.
**Parameters:**
* `credential` - The credential string
* `tokenProvider` - Optional custom token provider for routing authentication through your own server
**Returns:** `LoginResult` with privacy address and MPK
#### logout
```swift theme={null}
func logout()
```
End session completely, clear all state.
#### clearSession
```swift theme={null}
func clearSession()
```
Clear JWT only, keep keys for quick re-auth.
#### lock
```swift theme={null}
func lock()
```
Zeroize all resident key material and drop the session JWT, keeping the persisted encrypted vault so the user can unlock again without re-deriving keys. The next key access throws `SDKError.notAuthenticated`; call `authenticate` again to reopen the vault. The idle and absolute timers in [PrivacyBoostConfig](#privacyboostconfig) invoke the same lock automatically. Chain-context handles created from this instance share the wiped keys but keep their own JWT, so call `clearSession()` on each of them separately.
### State Accessors
```swift theme={null}
func getPrivacyAddress() -> String?
func getMpk() -> String?
func getWalletAddress() -> String?
```
### Balance Methods
#### getBalance
```swift theme={null}
func getBalance(tokenAddress: String) async throws -> TokenBalance
```
Get balance for a specific token.
**Parameters:**
* `tokenAddress` - ERC-20 token contract address
**Returns:** `TokenBalance` with shielded and wallet amounts
#### getAllBalances
```swift theme={null}
func getAllBalances() async throws -> [TokenBalance]
```
Get all token balances.
### Fee Methods
#### getFees
```swift theme={null}
func getFees(tokenAddress: String) async throws -> FeeRates
```
Returns the ordinary transfer and unshield fee quote for a token.
#### getGatewayFees
```swift theme={null}
func getGatewayFees(
tokenAddress: String,
gatewayAddress: String,
gatewaySelector: String? = nil
) async throws -> FeeRates
```
Returns the Gateway fee that replaces the ordinary unshield fee. Pass the
first four calldata bytes as `gatewaySelector` to resolve a selector-specific
override, or `nil` for the Gateway-wide default.
### Vault Operations
#### deposit
```swift theme={null}
func shield(tokenAddress: String, amount: String) async throws -> ShieldResult
```
Deposit tokens into the shielded pool.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
**Returns:** `ShieldResult` with transaction hash
#### withdraw
```swift theme={null}
func unshield(tokenAddress: String, amount: String, recipient: String) async throws -> UnshieldResult
```
Withdraw tokens from the shielded pool.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
* `recipient` - Recipient Ethereum address
**Returns:** `UnshieldResult` with transaction hash
#### send
```swift theme={null}
func send(tokenAddress: String, amount: String, recipientPrivacyAddress: String) async throws -> TransferResult
```
Send a private transfer.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
* `recipientPrivacyAddress` - Recipient's 194-char privacy address
**Returns:** `TransferResult` with transaction hash
#### sendBatch
```swift theme={null}
func sendBatch(tokenAddress: String, recipients: [TransferRecipient]) async throws -> TransferResult
```
Send one private transfer paying several recipients: one request, one fee, one
set of input notes.
**Parameters:**
* `tokenAddress` - Token contract address
* `recipients` - `TransferRecipient(recipient:amount:)` entries in output order, at least one
**Shape limits:** the transfer's output count is the recipient count plus a
change note, and the server only proves specific `(inputs, outputs)`
combinations, so a longer recipient list limits how many input notes can be
spent — past two recipients, at most four. Requests no enabled circuit can
serve fail with `UNSUPPORTED_TRANSFER_SHAPE` before submission.
**Returns:** `TransferResult` with transaction hash
#### prepareShield
```swift theme={null}
func prepareShield(tokenAddress: String, amount: String, recipient: String?) async throws -> PreparedShield
```
Build a deposit payload **without submitting it** — for callers that relay the
transaction themselves (a gasless relayer, an EIP-7702 session key, or a plain
raw transaction). Returns the calldata to relay plus the note commitment;
nothing is sent on-chain and no wallet is used. After relaying, pass the mined
receipt to the top-level `finalizeShield(...)` to recover the on-chain request
id.
**Parameters:**
* `tokenAddress` - Token contract address (zero address = native ETH)
* `amount` - Amount in wei (as string)
* `recipient` - (Optional) recipient privacy address. Pass `nil` to shield to yourself.
**Returns:** `PreparedShield` with the calls to relay and the note commitment
### Earn and Gateway
Gateway earn operations deposit into or redeem from a yield vault from inside the pool. The catalog and history reads are public and work before login; `earnDeposit` and `earnRedeem` need an authenticated session and a `ChainReaderDelegate` registered with `setChainReader`.
#### earnDeposit / earnRedeem
```swift theme={null}
func earnDeposit(vaultAddress: String, amount: String, slippageBps: UInt32?) async throws -> EarnResult
func earnRedeem(vaultAddress: String, amount: String, slippageBps: UInt32?) async throws -> EarnResult
```
For a deposit, `amount` is the net amount that enters the vault; the unshield fee is charged on top. For a redeem, `amount` is the share amount. `slippageBps` of `nil` uses the gateway default.
#### listEarnVaults / getEarnVault
```swift theme={null}
func listEarnVaults() async throws -> EarnVaultCatalog
func getEarnVault(vaultAddress: String) async throws -> EarnVaultDetail
```
The catalog with typed vault summaries and gateway-wide availability, or one vault with its detail fields. Check `availability.deposit.canSubmit` on a summary before offering a deposit; `retryable` says whether a closed window is expected to reopen on its own.
```swift theme={null}
let catalog = try await sdk.listEarnVaults()
for vault in catalog.vaults where vault.listed {
let apy = vault.apy.net.map { String(format: "%.2f%%", $0 * 100) } ?? "n/a"
let open = vault.availability.deposit.canSubmit
print("\(vault.name) \(apy) deposits \(open ? "open" : "closed")")
}
```
#### getGatewayConfig
```swift theme={null}
func getGatewayConfig() async throws -> GatewayConfig
```
Gateway discovery configuration: executor address, adapters, request constraints, rescue delay, policy, and current availability.
#### getEarnVaultHistory / getEarnVaultCharts
```swift theme={null}
func getEarnVaultHistory(
vaultAddress: String,
metric: EarnHistoryMetric,
period: EarnHistoryPeriod? = nil
) async throws -> EarnVaultHistory
func getEarnVaultCharts(vaultAddress: String, period: EarnHistoryPeriod? = nil) async throws -> EarnVaultCharts
```
One cached series (`metric` is `.tvlUsd` or `.netApy`) or both series fetched together. `period` is `.oneWeek`, `.oneMonth`, `.threeMonths`, or `.all`; `nil` uses the server default. The selectors are enums here and wire strings on the `*Json` accessors, which keep the signatures they shipped with. APY values are fractions (0.05 is 5%), a `nil` point value is an upstream gap, and `metadata.state` reports freshness. A cold cache can briefly fail with a retryable error.
The `listEarnVaultsJson`, `getGatewayConfigJson`, `getEarnVaultHistoryJson`, and `getEarnVaultChartsJson` methods return the same data as canonical JSON strings for callers that parse the wire shape themselves.
### Transaction History
```swift theme={null}
func getTransactionHistory(
txType: String?,
tokenAddress: String?,
limit: UInt32?
) async throws -> [Transaction]
```
Get transaction history.
**Parameters:**
* `txType` - Filter by type: "deposit", "withdraw", "transfer"
* `tokenAddress` - Filter by token
* `limit` - Maximum results
#### queryTransactionHistory
```swift theme={null}
func queryTransactionHistory(query: TransactionHistoryQuery) async throws -> TransactionsResult
```
History under the full query: type, token id and direction filters, page size and cursor, and whether each Gateway operation's two rows are folded into one. Use it for any filter `getTransactionHistory` does not take. `nil` fields are unfiltered.
**Parameters:**
* `query` - See [TransactionHistoryQuery](#transactionhistoryquery)
**Returns:** `TransactionsResult` with the page of transactions and the cursor for the next page
```swift theme={null}
// Incoming transactions for token id 1, 25 per page, gateway operations collapsed
let page = try await sdk.queryTransactionHistory(query: TransactionHistoryQuery(
tokenId: 1,
direction: .incoming,
limit: 25,
collapseGateway: true
))
for tx in page.data {
print(tx.txHash)
}
// page.next is the cursor for the following page; nil at the end
```
### Session Persistence
#### exportSession
```swift theme={null}
func exportSession() -> ExportedSession?
```
Export session data for persistence.
**Returns:** `ExportedSession` or `nil` if not authenticated
#### importSession
```swift theme={null}
func importSession(_ session: ExportedSession) throws -> Bool
```
Import a previously exported session.
**Returns:** `true` if session is valid and imported
### Private Treasury
Per-wallet contacts and transaction labels, stored encrypted on the server and readable only with the session's keys. All methods are async, throw `SDKError`, and require an authenticated session. A row whose `decryptionFailed` is `true` is one the server could no longer open: show it as unreadable, not as missing.
#### listContacts
```swift theme={null}
func listContacts(limit: UInt32? = nil, cursor: String? = nil) async throws -> ContactPage
```
Page through the wallet's contacts. Both arguments default, so `listContacts()` is the first page at the server's page size; pass the previous page's `nextCursor` after that.
#### createContact
```swift theme={null}
func createContact(input: CreateContactInput) async throws -> Contact
```
Create one contact. `privacyAddress` and `nickname` are required and come first; everything after them is defaulted, including `clientId`, a caller-chosen correlation id that bulk results echo back.
#### bulkCreateContacts
```swift theme={null}
func bulkCreateContacts(contacts: [CreateContactInput]) async throws -> BulkContactsResult
```
Create several contacts in one request. A contact whose privacy address already exists is reported under `skipped` with the existing row's id rather than failing the batch.
#### updateContact
```swift theme={null}
func updateContact(id: String, input: UpdateContactInput) async throws -> Contact
```
Patch one contact. Every field is a patch value (`.keep`, `.clear`, or `.set(value:)`), so "leave alone" is always `.keep` and never a `nil` that could be read as a clear. `nickname` and `emoji` are the two the API can only replace: `.clear` on either throws `SDKError.InvalidInput` naming the field rather than being silently downgraded to `.keep`.
#### deleteContact
```swift theme={null}
func deleteContact(id: String) async throws
```
#### listLabels
```swift theme={null}
func listLabels(limit: UInt32? = nil, cursor: String? = nil) async throws -> TransactionLabelPage
```
Page through the wallet's transaction labels, using `cursor` the same way as `listContacts`.
#### upsertLabel
```swift theme={null}
func upsertLabel(txHash: String, input: UpsertLabelInput) async throws -> TransactionLabel
```
Create or replace the label on a transaction hash. The whole label is replaced, so an absent field means "no value".
#### deleteLabel
```swift theme={null}
func deleteLabel(txHash: String) async throws
```
```swift theme={null}
// Save a contact, then label a transfer to them.
let alice = try await sdk.createContact(input: CreateContactInput(
privacyAddress: "0x04...alice",
nickname: "Alice",
ethAddress: "0x1234...abcd",
metadata: ["team": "design"]
))
_ = try await sdk.upsertLabel(txHash: transfer.txHash, input: UpsertLabelInput(
contactId: alice.id,
memo: "Dinner split"
))
// Later: clear the ETH address and keep everything else.
_ = try await sdk.updateContact(id: alice.id, input: UpdateContactInput(
ethAddress: .clear,
nickname: .keep,
emoji: .keep,
metadata: .keep
))
// Page through contacts.
var cursor: String? = nil
repeat {
let page = try await sdk.listContacts(limit: 50, cursor: cursor)
for contact in page.contacts where !contact.decryptionFailed {
print(contact.nickname, contact.privacyAddress ?? "")
}
cursor = page.nextCursor
} while cursor != nil
```
### Campaigns
#### getStartaleCampaignProgress
```swift theme={null}
func getStartaleCampaignProgress() async throws -> StartaleCampaignProgress
```
Lifetime Startale mission progress for the authenticated user and app. The deposit mission is priced at query time, so it is not monotonic: record the first `true` your app sees and never downgrade it. `campaignTokenId` is `nil` only when no campaign token is configured, in which case every shielded token counted toward the deposit total.
```swift theme={null}
let progress = try await sdk.getStartaleCampaignProgress()
if progress.hasDepositedAtLeast10Usd { missions.markComplete(.deposit) }
if progress.hasMadeTransfer { missions.markComplete(.transfer) }
```
### Address Lookup
#### resolveIdentity
```swift theme={null}
func resolveIdentity(identifier: String) async throws -> IdentityResult
```
Look up a user's privacy address by MPK or Ethereum address.
**Parameters:**
* `identifier` - MPK or Ethereum address
**Returns:** `IdentityResult` with privacy address and public keys
### Chain Context
#### createChainContext
```swift theme={null}
func createChainContext(config: ChainContextConfig) throws -> ChainContextHandle
```
Open a handle for a second chain that shares this instance's identity keys and wallet but has its own server, JWT, and chain state. Authenticate it separately with `authenticate(wallet:)`, and clear it separately with `clearSession()`; `lock()` on the parent wipes the shared keys but leaves the handle's JWT in place. Synchronous; throws if `serverUrl` is empty or not an HTTPS URL, or if a contract address is present but malformed. Every call returns a new handle, so create each one once and keep it.
**Parameters:**
* `config` - See [ChainContextConfig](#chaincontextconfig)
**Returns:** [ChainContextHandle](#chaincontexthandle)
See the [Multi-Chain guide](./guides/multi-chain) for setup and cross-chain patterns.
### Utilities
```swift theme={null}
func isValidPrivacyAddress(_ address: String) -> Bool
func isValidAddress(_ address: String) -> Bool
func parseAmount(_ amount: String, decimals: UInt8) throws -> String
func formatAmount(_ wei: String, decimals: UInt8) throws -> String
```
***
## ChainContextHandle
Returned by `createChainContext(config:)`. A chain-scoped facade over one `PrivacyBoost` identity: it carries the operations whose state is per chain and shares the parent's keys, so the wallet signs once per chain to obtain that chain's JWT. Portal deposits, earn and swap, audit, and the private treasury stay on the parent instance, as do `resolveIdentity`, the amount utilities, `setChainReader`, and session export.
```swift theme={null}
func chainId() -> UInt64
func isAuthenticated() -> Bool
func isRegistered() -> Bool
func authenticate(wallet: WalletDelegate, externalToken: String? = nil) async throws -> LoginResult
func clearSession()
// Transactions
func shield(tokenAddress: String, amount: String) async throws -> ShieldResult
func prepareShield(tokenAddress: String, amount: String, recipient: String?) async throws -> PreparedShield
func unshield(tokenAddress: String, amount: String, recipient: String) async throws -> UnshieldResult
func send(tokenAddress: String, amount: String, recipientPrivacyAddress: String) async throws -> TransferResult
func sendBatch(tokenAddress: String, recipients: [TransferRecipient]) async throws -> TransferResult
func unwrapWeth(amount: String, recipient: String) async throws -> String
func consolidateNotes(tokenAddress: String, maxRounds: UInt32) async throws -> ConsolidateResult
// Balances, tokens, and fees
func getBalance(tokenAddress: String) async throws -> TokenBalance
func getAllBalances() async throws -> [TokenBalance]
func getRegisteredTokens() async throws -> [RegisteredToken]
func getTokenByAddress(address: String) async throws -> RegisteredToken
func getFees(tokenAddress: String) async throws -> FeeRates
func getGatewayFees(tokenAddress: String, gatewayAddress: String, gatewaySelector: String? = nil) async throws -> FeeRates
// Status and history
func getShieldStatus(requestId: String) async throws -> ShieldStatus
func getTransferStatus(id: String) async throws -> TransactionStatus
func getUnshieldStatus(id: String) async throws -> TransactionStatus
func getTransactionHistory(txType: String?, limit: UInt32?, cursor: String?) async throws -> TransactionsResult
func getCollapsedTransactionHistory(txType: String?, limit: UInt32?, cursor: String?) async throws -> TransactionsResult
func queryTransactionHistory(query: TransactionHistoryQuery) async throws -> TransactionsResult
func getUnspentNotes(tokenAddress: String?) async throws -> [UnspentNote]
// Pending transactions
func addPendingTransaction(tx: PendingTransaction)
func getPendingTransactions() -> [PendingTransaction]
func getAllPendingTransactions() -> [PendingTransaction]
func refreshPendingTransactions() async throws -> UInt32
func refreshPendingTransactionsDetailed() async throws -> RefreshPendingSummary
func waitForPreconfirmation(requestId: String, timeoutMs: UInt64) async throws -> PendingTransaction
func waitForFinality(requestId: String, timeoutMs: UInt64) async throws -> PendingTransaction
```
The claimable-transfer methods (`giftFund`, `giftFundToWallet`, `giftFundSecretBearer`, `giftClaim`, `giftClaimByCGift`, `giftClaimFromLink`, `giftRefund`, `giftRefundByRecord`, `getPendingGifts`, `decodeGiftLink`) have the same signatures as on `PrivacyBoost`; see [Gift Methods](#gift-methods). The handle adds `importGiftRecords(records: [GiftRecord])`, which rehydrates its sender ledger from persisted records so unclaimed gifts stay refundable through `giftRefundByRecord` across restarts. The handle's `getGiftRecords()` throws while the account is locked or expired instead of returning an empty list, so an app that persists the ledger never overwrites its saved copy with nothing.
`authenticate(wallet:externalToken:)` takes only the wallet delegate; the key source and token provider come from the parent. `externalToken` carries an external identity provider's token on this chain's login and defaults to `nil`.
***
## Module Functions
```swift theme={null}
func sdkVersion() -> String
func generateMnemonic() throws -> String
func registerKeychainDelegate(delegate: KeychainDelegate, service: String? = nil)
func hasKeychainDelegate() -> Bool
```
* `sdkVersion()` — Returns the SDK version string
* `generateMnemonic()` — Generates a random 12-word BIP-39 mnemonic
* `registerKeychainDelegate(delegate:service:)`: registers the app's Keychain implementation for `.osKeychain` persistence. Process-wide; call it once, before constructing a `PrivacyBoost` whose config selects `.osKeychain`, or construction throws, and a later call replaces the delegate for every instance. `service` namespaces the Keychain items; `nil` uses the SDK's own name, and an app running two SDK instances or sharing a Keychain group with an extension should pass its own. `PrivacyBoost.withPlatformDefaults(config:)` in the `PrivacyBoostDefaults` product registers `DefaultKeychainDelegate()` for you. See [KeychainDelegate Protocol](#keychaindelegate-protocol).
* `hasKeychainDelegate()`: whether a delegate is registered
### Stateless shield helpers
Top-level functions for building a deposit payload without a signed-in session —
for a backend that holds only a recipient's privacy address. No wallet, no
private keys.
```swift theme={null}
func buildShieldPayload(
tokenAddress: String,
amount: String,
recipient: String,
tokenId: UInt16,
shieldContractAddress: String,
wethContractAddress: String?,
teePublicKey: String,
minShieldAmount: String?,
emitApprove: Bool
) throws -> PreparedShield
func finalizeShield(
receipt: TransactionReceipt,
shieldContract: String,
commitment: String
) throws -> ShieldResult
```
`buildShieldPayload` builds a recipient-targeted deposit payload from public
material only. Resolve `tokenId`, `shieldContractAddress`, and `teePublicKey`
from the token catalog / config. An ephemeral sender keypair is generated and
discarded internally, and the note is spendable only by `recipient`.
`wethContractAddress` is required only when shielding native ETH;
`minShieldAmount` (per-token minimum in wei) rejects below-minimum deposits;
`emitApprove` emits the ERC-20 approve call (pass `true` by default).
`finalizeShield` recovers the on-chain shield request id from the mined receipt
of a relayed deposit. Stateless — the caller obtained the receipt from its own
relay/RPC. Throws if the deposit reverted or the `DepositRequested` log is
absent.
***
## Types
### PrivacyBoostConfig
```swift theme={null}
struct PrivacyBoostConfig {
let serverUrl: String
let wethContractAddress: String
let appId: String
let chainId: UInt64?
let shieldContractAddress: String?
let teePublicKey: String?
let persistenceStorage: StorageBackend?
let persistenceUnlock: UnlockMethod?
let approvalOnly: Bool
let idleTtlSecs: UInt64?
let absoluteTtlSecs: UInt64?
}
```
| Field | Type | Description |
| - | - | - |
| `serverUrl` | String | Privacy Boost server URL |
| `wethContractAddress` | String | WETH contract address |
| `appId` | String | Application ID (required) |
| `chainId` | UInt64? | EIP-155 chain ID (optional, auto-discovered from server) |
| `shieldContractAddress` | String? | Shield contract address (optional, auto-discovered from server) |
| `teePublicKey` | String? | TEE public key for encryption (optional) |
| `persistenceStorage` | StorageBackend? | Where the encrypted vault is stored: `.osKeychain` after `registerKeychainDelegate`; `nil` disables persistence |
| `persistenceUnlock` | UnlockMethod? | How the vault is protected: `.none`, `.pin`, `.password`, or `.biometric` (`.passkey` is rejected) |
| `approvalOnly` | Bool | Enables approval-only EOA global identity (defaults to `false`) |
| `idleTtlSecs` | UInt64? | Auto-lock: seconds without a key access before resident keys are zeroized (`nil` or `0` disables; the default) |
| `absoluteTtlSecs` | UInt64? | Auto-lock: seconds since unlock before resident keys are zeroized regardless of activity (`nil` or `0` disables; the default) |
Both auto-lock timers are checked lazily, on the next key access: once one has elapsed, that access zeroizes the keys and throws `SDKError.notAuthenticated`, exactly as `lock()` would, and the persisted vault is kept so `authenticate` can reopen it. They bound key use rather than key residency; call `lock()` from your own timer when the keys must be gone at a fixed time. See [Auto-lock](./guides/session-storage#auto-lock). The two fields default to `nil`, so existing initializer calls keep compiling.
### ChainContextConfig
```swift theme={null}
struct ChainContextConfig {
let serverUrl: String
let chainId: UInt64
let shieldContractAddress: String?
let wethContractAddress: String?
let rpcUrl: String?
let teePublicKey: String?
let timeoutMs: UInt64
}
```
Per-chain configuration for `createChainContext`. `chainId` and `timeoutMs` (request timeout in milliseconds) are required; the optional fields are discovered from that chain's server when `nil`. A contract address you do pass has to parse — a malformed one is rejected rather than dropped, so a typo can never fall back to the server-advertised pool.
### KeySource
```swift theme={null}
enum KeySource {
case walletDerived
case mnemonic(phrase: String)
case rawSeed(hexSeed: String)
}
```
Specifies how privacy keys are derived. Passed as an optional parameter to `authenticate()`.
* **walletDerived** - Derive keys from a deterministic wallet signature (default when no persistence configured)
* **mnemonic** - Derive keys from a BIP-39 mnemonic phrase
* **rawSeed** - Derive keys from raw hex entropy (for testing)
### AuthResult
```swift theme={null}
enum AuthResult {
case authenticated(LoginResult)
case credentialRequired(CredentialChallenge)
case mnemonicGenerated(mnemonic: String)
case recoveryRequired(challenge: RecoveryChallenge)
case accountCreationRequired(challenge: AccountCreationChallenge)
}
```
`accountCreationRequired` contains the account ID, canonical account salt, and
a prepared EOA transaction (`chainId`, `to`, `value`, `data`). The SDK validates
the target against the AuthRegistry advertised by this deployment's `/api/v1/info`.
### LoginResult
```swift theme={null}
struct LoginResult {
let privacyAddress: String
let mpk: String
}
```
### TokenBalance
```swift theme={null}
struct TokenBalance {
let tokenAddress: String
let shieldedBalance: String
let walletBalance: String
let symbol: String?
let decimals: UInt8
}
```
### FeeRates
```swift theme={null}
struct FeeRates {
let transferFeeFlat: String
let unshieldFeeBps: UInt16
let feeSchemaVersion: UInt16?
let requiresFeeSchemaVersion: UInt16?
let transferRequiresFeeSchemaVersion: UInt16?
let unshieldRequiresFeeSchemaVersion: UInt16?
let transferFeeMode: String?
let transferFeeBps: UInt16?
let unshieldFeeMode: String?
let unshieldFeeFlat: String?
let gatewayAddress: String?
let gatewaySelector: String?
let gatewayFeeMode: String?
let gatewayFeeFlat: String?
let gatewayFeeRate: UInt32?
let gatewayFeeRateDenominator: UInt32?
let gatewayFeeBase: String?
let gatewayRequiresFeeSchemaVersion: UInt16?
}
```
Gateway fields are populated only by `getGatewayFees`. `gatewayFeeBase` is
`flat`, `unshield_amount`, or `zero` and identifies the amount used for fee
calculation.
### ShieldResult
Returned by `shield` and `finalizeShield`.
```swift theme={null}
struct ShieldResult {
let txHash: String
let commitment: String
let warning: String?
let requestId: String // on-chain shield request id
let wrapTxHash: String? // present when native ETH was wrapped
}
```
### UnshieldResult
```swift theme={null}
struct UnshieldResult {
let txHash: String
let fee: String
}
```
### TransferResult
```swift theme={null}
struct TransferResult {
let requestId: String
let txHash: String
let fee: String
let giftRecord: GiftRecord? // populated by gift funding; persist to refund
let claimLink: String? // pbgift:v1:... — populated by gift funding
}
```
For an ordinary `send()`, `giftRecord` and `claimLink` are `nil`; they are
populated by the [gift funding methods](#gift-methods).
### Call
A single on-chain call to relay. `value` and `data` are 0x-hex.
```swift theme={null}
struct Call {
let to: String
let value: String
let data: String
}
```
### PreparedShield
Returned by `prepareShield` and `buildShieldPayload`.
```swift theme={null}
struct ShieldFinalizeMeta {
let shieldContract: String
let tokenId: UInt16
}
struct PreparedShield {
let approve: Call? // ERC-20 approve(pool, amount); nil when managed out of band
let wrap: Call? // WETH deposit() wrap; present only when shielding native ETH
let shield: Call // the requestDeposit(...) call
let commitment: String
let finalize: ShieldFinalizeMeta
}
```
### TransactionReceipt
The mined-receipt record passed to `finalizeShield`.
```swift theme={null}
struct TransactionLog {
let address: String
let topics: [String]
let data: String
}
struct TransactionReceipt {
let txHash: String
let status: Bool
let logs: [TransactionLog]
}
```
### Transaction
```swift theme={null}
struct Transaction {
let txHash: String
let txType: String
let tokenAddress: String
let amount: String
let direction: String
let senderPubKey: String
let receiverPubKeys: [String]
let createdAt: UInt64
}
```
### TransactionsResult
One page of history, returned by `getTransactionHistory`, `getCollapsedTransactionHistory`, and `queryTransactionHistory`.
```swift theme={null}
struct TransactionsResult {
let data: [Transaction]
let limit: UInt32
let next: String? // cursor for the next page; nil at the end
}
```
### TransactionHistoryQuery
```swift theme={null}
struct TransactionHistoryQuery {
let txType: String? // filter by transaction type
let tokenId: UInt16? // registered token id, from getRegisteredTokens()
let direction: TransactionDirection? // .incoming or .outgoing, relative to the wallet
let limit: UInt32? // page size
let cursor: String? // previous page's next
let collapseGateway: Bool // fold each Gateway operation's two rows into one
}
enum TransactionDirection {
case incoming
case outgoing
}
```
`nil` fields are unfiltered. Every field is defaulted, so name only the filters you want: `TransactionHistoryQuery(tokenId: 1)` is a valid query.
### IdentityResult
```swift theme={null}
struct IdentityResult {
let mpk: String
let ethereumAddress: String
let viewingPublicKeyX: String
let viewingPublicKeyY: String
let privacyAddress: String
}
```
### ExportedSession
```swift theme={null}
struct ExportedSession {
let version: UInt32
let data: String
}
```
`data` is an opaque serialized core session. Store and pass it back unchanged;
do not parse or persist individual key fields.
### Private Treasury Types
```swift theme={null}
struct Contact {
let id: String
let privacyAddress: String?
let ethAddress: String?
let nickname: String
let emoji: String
let metadata: [String: String]?
let updatedAt: String
let decryptionFailed: Bool // the server could not open this row; show it as unreadable
}
struct CreateContactInput {
let privacyAddress: String
let nickname: String
let ethAddress: String?
let emoji: String?
let metadata: [String: String]?
let clientId: String? // caller-chosen correlation id echoed back in bulk results
}
// A PATCH field: leave it alone, clear it, or replace it.
enum StringPatch {
case keep
case clear
case set(value: String)
}
enum MetadataPatch {
case keep
case clear
case set(value: [String: String])
}
// Every field is a patch. `.clear` on nickname or emoji throws: the API can
// only replace those two.
struct UpdateContactInput {
let ethAddress: StringPatch
let nickname: StringPatch
let emoji: StringPatch
let metadata: MetadataPatch
}
struct BulkContactRef {
let clientId: String?
let id: String
}
// A contact whose privacy address already exists lands in `skipped` with the existing row's id.
struct BulkContactsResult {
let created: [BulkContactRef]
let skipped: [BulkContactRef]
}
struct ContactPage {
let contacts: [Contact]
let nextCursor: String?
}
struct TransactionLabel {
let txHash: String?
let contactId: String?
let memo: String?
let metadata: [String: String]?
let updatedAt: String
let decryptionFailed: Bool
}
// A PUT replaces the whole label, so an absent field means "no value".
struct UpsertLabelInput {
let contactId: String?
let memo: String?
let metadata: [String: String]?
}
struct TransactionLabelPage {
let labels: [TransactionLabel]
let nextCursor: String?
}
```
### Earn and Gateway Types
```swift theme={null}
// Selectors for the typed history reads. The `*Json` accessors take the wire
// names ("tvlUsd" / "netApy", "1w" / "1m" / "3m" / "all") instead.
enum EarnHistoryMetric {
case tvlUsd
case netApy
}
enum EarnHistoryPeriod {
case oneWeek
case oneMonth
case threeMonths
case all
}
struct EarnResult {
let requestId: String
let action: String // "deposit" | "redeem"
let vaultAddress: String
let inputTokenId: UInt16
let outputTokenId: UInt16
let amount: String
let fee: String
let expectedOutputAmount: String
let minOutputAmount: String
let expiryBlock: UInt64
}
struct EarnVaultCatalog {
let vaults: [EarnVaultSummary]
let gateway: GatewayAvailability
}
struct EarnVaultSummary {
let chainId: UInt64
let vaultAddress: String
let asset: EarnVaultAsset // address, symbol, decimals, logoUri
let name: String
let symbol: String
let description: String?
let imageUrl: String?
let apy: EarnApy
let tvlUsd: Double?
let liquidity: String? // smallest unit, decimal string
let liquidityUsd: Double?
let fees: EarnFees // performanceFee, managementFee
let curators: [EarnCurator] // name, image, aumUsd
let warnings: [EarnWarning] // warningType, level
let listed: Bool
let capabilities: EarnVaultCapabilities // deposit, redeem
let availability: EarnVaultAvailability // deposit, redeem: GatewayAvailability
let metadata: EarnVaultMetadata // state, updatedAt
let `protocol`: String
let protocolVersion: String
let updatedAt: String?
}
struct EarnApy {
let net: Double? // fractions: 0.05 is 5%
let netExcludingRewards: Double?
let net7d: Double?
let net30d: Double?
let net90d: Double?
let rewards: [EarnReward] // assetAddress, assetSymbol, supplyApr
}
struct GatewayAvailability {
let state: String
let canSubmit: Bool
let reason: String
let retryable: Bool // a closed window expected to reopen on its own
let checkedAt: String?
}
struct EarnVaultDetail {
let summary: EarnVaultSummary
let owner: String?
let curator: String?
let deployedAt: String?
let timelockSeconds: UInt64?
let guardian: String?
let feeRecipient: String?
let sharePrice: Double?
let sharePriceUsd: Double?
let gateway: GatewayAvailability
}
struct GatewayConfig {
let enabled: Bool
let executorAddress: String?
let adapters: [GatewayAdapterInfo] // id, version, quoteMode, availability
let constraints: GatewayConstraints // calldata, slippage, and expiry limits
let rescue: GatewayRescueInfo // cancelDelayBlocks
let policy: GatewayPolicyInfo
let availability: GatewayAvailability
}
struct EarnVaultHistory {
let chainId: UInt64
let vaultAddress: String
let `protocol`: String
let protocolVersion: String
let metric: String // "tvlUsd" | "netApy"
let period: String
let interval: String
let unit: String
let lookbackHours: Int64?
let periodAverage: Double?
let points: [VaultHistoryPoint] // timestamp, value (nil is an upstream gap)
let metadata: VaultHistoryMetadata // state, updatedAt
}
struct EarnVaultCharts {
let tvlUsd: EarnVaultHistory
let netApy: EarnVaultHistory
}
```
### StartaleCampaignProgress
```swift theme={null}
struct StartaleCampaignProgress {
let hasDepositedAtLeast10Usd: Bool
let hasMadeTransfer: Bool
let campaignTokenId: UInt16? // nil only when no campaign token is configured
}
```
***
## WalletDelegate Protocol
```swift theme={null}
protocol WalletDelegate: AnyObject {
func getAddress() async throws -> String
func getChainId() async throws -> UInt64
func signMessage(message: String) async throws -> String
func signTypedData(typedDataJson: String) async throws -> String
func sendTransaction(toAddress: String, value: String, data: String) async throws -> String
func waitForTransactionReceipt(txHash: String) async throws -> TransactionReceipt
}
struct TransactionLog {
let address: String
let topics: [String]
let data: String
}
struct TransactionReceipt {
let txHash: String
let status: Bool
let logs: [TransactionLog]
}
```
***
## TokenProvider Protocol
```swift theme={null}
protocol TokenProvider: AnyObject {
func getToken(loginPayloadJson: String) async throws -> TokenResponse
}
```
Implement this protocol to route authentication through your own server. The SDK serializes the login payload as a JSON string and passes it to `getToken()`. Your implementation should forward this payload to your backend, which adds its own credentials and calls the Privacy Boost API.
When the call fails for a transient reason (your backend is unreachable, times out, or answers 5xx), throw `SdkError.NetworkError(message:)`, or `SdkError.RateLimited(retryAfterMs:)` for a rate limit, so `authenticate` fails with an error worth retrying. A plain Swift `Error`, such as the `URLError` from a request that never got an answer, counts as transient too. To refuse the user, throw `SdkError.AuthenticationFailed(message:)`: it, and any other `SdkError` you throw, reaches your app as `SdkError.AuthenticationFailed`, which is not retryable.
### TokenResponse
```swift theme={null}
struct TokenResponse {
let token: String // JWT access token
let expiresIn: UInt64 // Token lifetime in seconds
}
```
### Example
```swift theme={null}
class MyTokenProvider: TokenProvider {
func getToken(loginPayloadJson: String) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: "https://your-server.com/api/privacy-auth")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = loginPayloadJson.data(using: .utf8)
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw SdkError.NetworkError(message: error.localizedDescription)
}
if let http = response as? HTTPURLResponse, http.statusCode >= 500 {
throw SdkError.NetworkError(message: "auth backend answered \(http.statusCode)")
}
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
return TokenResponse(
token: json["token"] as! String,
expiresIn: json["expiresIn"] as! UInt64
)
}
}
let result = try await sdk.authenticate(
wallet: walletDelegate,
tokenProvider: MyTokenProvider()
)
```
***
## KeychainDelegate Protocol
```swift theme={null}
protocol KeychainDelegate: AnyObject {
func store(key: String, value: Data, requireBiometry: Bool) async throws
func load(key: String) async throws -> Data?
func remove(key: String) async throws
func contains(key: String) async throws -> Bool
func isAvailable() -> Bool
func supportsBiometry() -> Bool
}
```
Serves `persistenceStorage: .osKeychain`. Register an implementation process-wide with `registerKeychainDelegate(delegate:)` before constructing the SDK, or let `PrivacyBoost.withPlatformDefaults(config:)` register `DefaultKeychainDelegate()`. Values are opaque bytes; `requireBiometry` is `true` when the config selects `.biometric` unlock, and the item must then be stored behind the OS biometric prompt. See [Session Storage](./guides/session-storage#sdk-managed-persistence).
***
## SDKError
```swift theme={null}
enum SDKError: Error {
case notConnected
case notAuthenticated
case authenticationFailed(message: String)
case invalidConfig
case configError(message: String)
case networkError(message: String)
case walletError(message: String)
case signatureRejected
case insufficientBalance(message: String)
case invalidAddress
case invalidAmount
case invalidInput(message: String)
case serializationError(message: String)
case authServerError(code: String, message: String)
case shieldError(code: String, message: String)
case transferError(code: String, message: String)
case noteError(code: String, message: String)
case merkleError(code: String, message: String)
case apiError(code: String, message: String, retryable: Bool)
case rateLimited(retryAfterMs: UInt64)
case forbidden(message: String)
case resourceNotFound(message: String)
case waitTimeout(requestId: String, waited: String)
case transactionFailed(requestId: String, message: String)
case internalError(message: String)
case transactionReverted(message: String)
case cryptoError(message: String)
case stateError(message: String)
case operationError(message: String)
}
```
***
## Gift Methods
Claimable transfers (gifts). See the
[Claimable Transfers guide](./guides/claimable-transfers) and the
[concept page](/sdk/concepts/claimable-transfers).
These methods may not be present in the checked-in generated Swift bindings.
Each fund/claim/refund method returns a `TransferResult` whose `giftRecord` and
`claimLink` fields are populated for gift operations.
#### giftFundToWallet
```swift theme={null}
func giftFundToWallet(
tokenAddress: String,
amount: String,
recipientWallet: String,
refundAfterBlock: UInt64,
currentBlock: UInt64
) async throws -> TransferResult
```
Fund a gift bound to an Ethereum wallet address that may not be registered with
Privacy Boost.
**Parameters:**
* `tokenAddress` - Token contract address
* `amount` - Amount in wei (as string)
* `recipientWallet` - Recipient's Ethereum address the gift binds to
* `refundAfterBlock` - Block height after which an unclaimed gift may be reclaimed
* `currentBlock` - Current chain head; pre-validates the refund delay
**Returns:** `TransferResult` with `claimLink` and `giftRecord` populated
#### giftFund
```swift theme={null}
func giftFund(
tokenAddress: String,
amount: String,
recipientPrivacyAddress: String,
recipientWallet: String,
refundAfterBlock: UInt64,
currentBlock: UInt64
) async throws -> TransferResult
```
Fund a gift when the recipient is already a Privacy Boost user, sealing the
ciphertext to their viewing key. The argument label is `recipientPrivacyAddress`.
**Parameters:**
* `recipientPrivacyAddress` - Recipient's 194-char privacy address (the ciphertext's ECDH target)
* Other parameters as in `giftFundToWallet`
**Returns:** `TransferResult` with `claimLink` and `giftRecord` populated
#### giftClaim
```swift theme={null}
func giftClaim(index: UInt32, acknowledgeUnknownSender: Bool) async throws -> TransferResult
```
Claim a pending gift by its position in `getPendingGifts()`.
**Parameters:**
* `index` - Position in the pending-gifts list
* `acknowledgeUnknownSender` - Must be `true`; acknowledges accepting funds from an unknown sender
#### giftClaimByCGift
```swift theme={null}
func giftClaimByCGift(cGift: String, acknowledgeUnknownSender: Bool) async throws -> TransferResult
```
Claim a pending gift by its stable commitment `cGift` (preferred when the list
may shift as gifts settle).
#### giftClaimFromLink
```swift theme={null}
func giftClaimFromLink(link: String, acknowledgeUnknownSender: Bool) async throws -> TransferResult
```
Claim a gift directly from a `pbgift:v1:...` claim link, without a server lookup
first.
#### giftRefundByRecord
```swift theme={null}
func giftRefundByRecord(index: UInt32) async throws -> TransferResult
```
Refund an unclaimed gift after its deadline, using a local gift record. `index`
is the positional index into `getGiftRecords()`.
#### giftRefund
```swift theme={null}
func giftRefund(
recipientWallet: String,
blind: String,
refundAfterBlock: UInt64,
tokenId: UInt16,
amount: String,
fundingTreeNumber: UInt64,
giftLeafIndex: UInt64
) async throws -> TransferResult
```
Refund an unclaimed gift by supplying every field manually — the fallback when
the local gift record is unavailable.
#### getPendingGifts
```swift theme={null}
func getPendingGifts() async throws -> [PendingGift]
```
List the pending gifts addressed to the authenticated wallet.
#### getGiftRecords
```swift theme={null}
func getGiftRecords() -> [GiftRecord]
```
Return the local records the SDK persisted for gifts you funded. These make a
gift refundable and travel inside an exported session. Synchronous.
While the account is locked or its session has expired, this returns an empty
list. The SDK still holds the records and hands them back after the next
unlock, so merge what it returns into your saved copy and never replace the
saved copy with it.
#### decodeGiftLink
```swift theme={null}
func decodeGiftLink(link: String) throws -> GiftLinkPreview
```
Decode a claim link into a preview offline, with no network call. Synchronous and
throwing.
A third funding mode, `giftFundSecretBearer`, binds a gift to a secret instead
of a wallet. It is a **bearer instrument** (whoever holds the link can claim),
selected only by that explicit method. See the
[concept page](/sdk/concepts/claimable-transfers) before considering it.
### GiftRecord
```swift theme={null}
struct GiftRecord {
let recipientWallet: String
let blind: String
let refundAfterBlock: UInt64
let tokenId: UInt16
let amount: String
let cGift: String
let giftNpk: String
let requestId: String
let fundingTreeNumber: UInt64?
let giftLeafIndex: UInt64?
let status: String?
}
```
### PendingGift
```swift theme={null}
struct PendingGift {
let index: UInt32
let treeNumber: UInt64
let leafIndex: UInt64
let cGift: String
let tokenId: UInt16
let amount: String
let refundAfterBlock: UInt64
let recipientWallet: String
}
```
### GiftLinkPreview
```swift theme={null}
struct GiftLinkPreview {
let server: String
let chainId: UInt64
let cGift: String
let recipientWallet: String
let tokenId: UInt16
let amount: String
let refundAfterBlock: UInt64
}
```
The gift fields on `TransferResult` are documented under
[TransferResult](#transferresult).
***
## Portal
Portal deposit addresses. See the
[Portal Deposits guide](./guides/portal-deposits) and the
[concept page](/sdk/concepts/portal-deposits).
iOS exposes the portal lifecycle as methods on `PrivacyBoost`. Low-level portal
functions are limited to custodial escape hatches.
The supported path is `createPortal`, which derives the portal EOA from the
recovered nullifying key and keeps the key inside core.
#### createPortal
```swift theme={null}
func createPortal(index: UInt32?) async throws -> Portal
```
Derive portal `E`, gaslessly delegate and register it via the server relays, then
publish the discovery entry. Pass `nil` to use the next free derivation index.
#### listPortalsPage
```swift theme={null}
func listPortalsPage(cursor: String?, limit: UInt32) async throws -> PortalInfoPage
```
One page of the authenticated account's portals with status and derivation
index. `limit` must be between 1 and 100; pass the page's `nextCursor` back as
`cursor` to continue, and treat an absent `nextCursor` as the final page.
#### listPortalDepositsPage
```swift theme={null}
func listPortalDepositsPage(
portal: String,
cursor: String?,
limit: UInt32
) async throws -> PortalDepositPage
```
One page of the deposits observed at `portal`. Same cursor and limit semantics.
#### getPortalStatus
```swift theme={null}
func getPortalStatus(portal: String) async throws -> PortalStatus
```
Return registry/on-chain status for one portal.
#### sweepPortal
```swift theme={null}
func sweepPortal(portal: String, tokenId: UInt16) async throws -> String
```
Send `requestPortalDeposit` from the connected wallet and return the transaction
hash.
#### reclaimPortalDeposit
```swift theme={null}
func reclaimPortalDeposit(depositId: String) async throws -> String
```
Send `cancelPortalDeposit` from the connected wallet and return the transaction
hash.
#### withdrawPortal
```swift theme={null}
func withdrawPortal(
index: UInt32,
token: String,
to: String,
amount: String,
nonce: String,
gasLimit: String,
maxFeePerGas: String,
maxPriorityFeePerGas: String
) throws -> String
```
Build a signed EIP-1559 transaction that withdraws raw funds resting at the
seed-derived portal `E`. Broadcast the returned raw transaction yourself.
### Low-level escape hatches
These top-level functions are for custodial integrations that manage their own
portal EOA key. The seed-derived `createPortal` flow does not expose a private
key.
#### generatePortalEoa
```swift theme={null}
func generatePortalEoa() -> PortalEoa
```
Generate a fresh portal EOA `E` and its secret private key.
#### encodePortalWithdraw
```swift theme={null}
func encodePortalWithdraw(token: String, to: String, amount: String) throws -> String
```
Encode raw withdraw calldata for a custodial integration that already controls
its own portal EOA.
#### publishPortalRegistry
```swift theme={null}
func publishPortalRegistry(portal: String, index: UInt32) async throws
```
Publish the discovery registry entry (`E → blind`) — the supported submit path so
the indexer can credit deposits to the right account.
#### portalDelegateAddress
```swift theme={null}
func portalDelegateAddress() -> String?
```
Return the portal delegate address advertised by the server's `/info`, or `nil`
if the server does not support portals.
### PortalEoa
```swift theme={null}
struct Portal {
let address: String
let index: UInt32
let bindH: String
}
struct PortalInfo {
let address: String
let index: UInt32
let status: PortalStatus
}
struct PortalInfoPage {
let items: [PortalInfo]
let nextCursor: String?
}
struct PortalStatus {
let registered: Bool
let delegated: Bool
let published: Bool
}
enum PortalDepositState {
case pendingSweep
case credited
case cancellable
case cancelled
case unknown
}
struct PortalDeposit {
let portalDepositId: String
let portal: String
let tokenId: UInt16
let grossAmount: String
let netAmount: String
let sweepFeeBps: UInt16 // rate snapshotted when this deposit was swept
let feeAmount: String // grossAmount - netAmount
let state: PortalDepositState
let requestBlock: UInt64
let cancellableAtBlock: UInt64
}
struct PortalDepositPage {
let items: [PortalDeposit]
let nextCursor: String?
}
struct PortalEoa {
let privateKey: String // SECRET — custody it; never persisted by the SDK
let address: String // the portal deposit address E
}
```
***
## Deposit Cancellation
`shield(...)` moves the depositor's tokens into the pool and holds them there
until the sequencer settles the request into the note tree. Almost always that
happens within a couple of blocks. When it doesn't, because of a commitment
mismatch, a malformed ciphertext, or a stalled sequencer, the tokens stay in
escrow and the depositor reclaims them by cancelling the request on-chain.
The server will tell you a shield *failed* — `getShieldStatus(requestId)` returns
`failed` for a commitment mismatch or a malformed ciphertext — but it says
nothing about the escrow behind it: whether the pool still holds it, who may
reclaim it, and when. A stalled sequencer doesn't even produce that `failed` row.
So use `getShieldStatus` to notice something went wrong, and these two methods to
find out what you can do about it. They read the pool directly and tell you what
it would do before you ask anyone to sign.
`getDepositCancellation` reads chain state, so it needs a `ChainReaderDelegate`
registered with `setChainReader(reader:)`. `cancelDeposit` works without one —
it just skips its preflight. It's the same optional delegate earn uses, and any
RPC-connected wallet can back it.
```swift theme={null}
protocol ChainReaderDelegate {
func getBlockNumber() async throws -> UInt64
func readContract(contractAddress: String, data: String) async throws -> String
}
func setChainReader(reader: ChainReaderDelegate)
```
#### listShieldsByStatus
```swift theme={null}
func listShieldsByStatus(statuses: [String]) async throws -> [UnsettledShield]
```
Find a `depositRequestId` you never kept. Lists the authenticated wallet's own
shield requests in the given public statuses (`pending`, `processing`,
`preconfirmed`, `completed`, `failed`), oldest first. An empty list means the
server's default of `pending`.
Pass `failed` to list the shields whose escrow may still be sitting in the pool,
then feed each `requestId` to `getDepositCancellation`. Served by the server and
scoped to the session, so it needs no `ChainReaderDelegate` and cannot enumerate
anyone else's deposits.
`failed` is a public status the server folds several internal ones into, so it
also covers shields rejected as below-minimum or blocked. A stalled sequencer
produces no failed row at all — those stay `pending`.
**Returns:** `UnsettledShield` per row: `requestId`, `status`, and the optional
`txHash`, `blockNumber`, `createdAt` and `error`.
#### depositRequestIdsFromTransaction
```swift theme={null}
func depositRequestIdsFromTransaction(txHash: String) async throws -> [String]
```
The fallback when the app kept the shield's transaction hash but not its request
id. Reads the receipt and returns every `DepositRequested` id the pool emitted,
in log order — more than one when an aggregating relayer settled several callers
in the same transaction.
Empty means the transaction emitted no `DepositRequested` from the configured
pool, which is the answer for a hash that was never a shield rather than an
error, so candidate hashes can be swept. Reads the chain, so it needs the same
`ChainReaderDelegate` as `getDepositCancellation`.
#### getDepositCancellation
```swift theme={null}
func getDepositCancellation(depositRequestId: String) async throws -> DepositCancellation
```
Read the pool and report whether `cancelDeposit` would succeed for the connected
wallet right now, without sending anything. Costs three `eth_call`s plus one
`eth_blockNumber`, and throws if no `ChainReaderDelegate` is set.
**Parameters:**
* `depositRequestId` - The `DepositRequested` event's indexed `topic1`, a `uint256`. It's the same value `shield(...)` and `finalizeShield(...)` return as `ShieldResult.requestId`. Decimal or `0x`-hex; the result normalizes it to decimal.
**Returns:** `DepositCancellation` with the escrowed amount, the block the delay
expires at, and a `reason` you can show the user directly
#### cancelDeposit
```swift theme={null}
func cancelDeposit(depositRequestId: String) async throws -> String
```
Send `cancelDeposit` from the connected wallet and return the transaction hash.
The full escrowed amount goes back to the depositor.
The call preflights through `getDepositCancellation` and throws locally, before
anything is signed, when the pool would revert. The thrown message is the status
`reason`, so a stuck deposit explains itself instead of burning gas on an opaque
revert. Without a `ChainReaderDelegate` there's no preflight: the transaction is
submitted anyway and the chain arbitrates.
**Parameters:**
* `depositRequestId` - Same id `getDepositCancellation` takes
**Returns:** Transaction hash
### Who can cancel, and when
Only the address that submitted the deposit can cancel it, and the refund goes to
that address. For a relayer-submitted shield that's the relayer, not the note
recipient, so a user whose deposit is genuinely theirs can still get
`.notDepositor` back.
The cancel delay is measured in **blocks** and read from the pool's
`cancelDelay()`. Read the deployed value instead of hardcoding a delay, using
`cancelDelay`, `cancellableAtBlock`, and `blocksRemaining` from the returned status.
### Cancellation states
`state` mirrors the reverts in the pool's `cancelDeposit`, reported
permanent-blocker first rather than in the pool's own check order. The pool
checks the depositor before anything else, and that check would mask the two
conditions no caller can clear.
| State | Meaning |
| - | - |
| `.cancellable` | Every precondition holds; the transaction should land. |
| `.notFound` | The pool holds no escrow under this id. It was never created, or it's already been cancelled or rescued. Settling does not clear the record, so a settled deposit reports `.alreadyProcessed` instead. |
| `.alreadyProcessed` | The sequencer already settled this deposit into the note tree, so the funds are in a note rather than in escrow. This blocks the rescue path too, so it's reported ahead of `.gatewayOrigin`. |
| `.gatewayOrigin` | A gateway-origin deposit. It carries a rescue commitment and has to be recovered with `rescueGatewayDeposit` by its rescue authority instead. |
| `.notDepositor` | Escrow exists but belongs to another address. |
| `.tooEarly` | Escrow is reclaimable, but the cancel delay hasn't elapsed yet. The one rejecting state that resolves on its own. |
A gateway-origin deposit stores the gateway contract as its depositor, never the
wallet whose funds routed through it, so it reports `.gatewayOrigin` for every
caller and at every point in the delay window. Waiting never makes it
cancellable.
### DepositCancellation
```swift theme={null}
enum DepositCancelState {
case cancellable
case notFound
case notDepositor
case alreadyProcessed
case tooEarly
case gatewayOrigin
}
struct DepositCancellation {
let depositRequestId: String // the queried id, normalized to decimal
let state: DepositCancelState
let cancellable: Bool // true exactly when state is .cancellable
let depositor: String? // nil when no escrow exists
let caller: String // the address the status was evaluated against
let tokenId: UInt16
let totalAmount: String // escrow that would be refunded; "0" when absent
let requestBlock: UInt64
let cancelDelay: UInt64 // the pool's cancelDelay(), in blocks
let cancellableAtBlock: UInt64 // requestBlock + cancelDelay
let currentBlock: UInt64 // chain head the status was evaluated at
let blocksRemaining: UInt64 // 0 once the delay has elapsed
let reason: String // human-readable explanation of state
}
```
***
## See Also
* [Getting Started](./getting-started) - Quick start guide
* [Wallet Integration](./guides/wallet-integration) - Wallet delegate implementation
* [Multi-Chain](./guides/multi-chain) - Chain contexts
* [Session Storage](./guides/session-storage) - Persistence and auto-lock
# Getting started
Source: https://docs.privacyboost.io/sdk/ios/getting-started
# Getting Started
This guide walks you through integrating the Privacy Boost iOS SDK into your app.
For a minimal copy-paste example, see the [iOS Quickstart](/sdk/quickstart/ios). This guide explains each step in detail.
## Prerequisites
* The SDK [installed](/sdk/ios/installation) in your project
* An [App ID](/sdk/concepts/app-setup) and server URL (see [Configuration](/sdk/concepts/configuration))
* A wallet integration (WalletConnect, custom, etc.)
* Familiarity with [authentication options](/sdk/concepts/authentication) (optional for development, required for production)
## Overview
The iOS SDK follows a simple flow:
1. **Initialize** - Create SDK instance with configuration
2. **Authenticate** - Connect wallet and authenticate via `WalletDelegate`
3. **Use** - Deposit, unshield, transfer tokens
## Basic Setup
### 1. Import the SDK
```swift theme={null}
import PrivacyBoost
```
### 2. Configure the SDK
```swift theme={null}
let config = PrivacyBoostConfig(
serverUrl: "https://optimism.sepolia.privacyboost.io",
wethContractAddress: "0x4200000000000000000000000000000000000006",
appId: "app_abc123xyz"
)
```
### 3. Initialize
```swift theme={null}
do {
let sdk = try PrivacyBoost(config: config)
} catch {
print("Failed to initialize SDK: \(error)")
}
```
## Connecting a Wallet
The SDK uses the `WalletDelegate` protocol to interact with wallets. You must implement this protocol:
```swift theme={null}
class MyWalletDelegate: WalletDelegate {
func getAddress() async throws -> String {
// Return the connected wallet address
return "0x..."
}
func getChainId() async throws -> UInt64 {
// Return the current chain ID
return 1
}
func signMessage(message: String) async throws -> String {
// Sign using EIP-191 personal_sign
// Return signature as hex string with 0x prefix
return "0x..."
}
func signTypedData(typedDataJson: String) async throws -> String {
// Sign using EIP-712 eth_signTypedData_v4
return "0x..."
}
func sendTransaction(toAddress: String, value: String, data: String) async throws -> String {
// Submit transaction and return hash
return "0x..."
}
func waitForTransactionReceipt(txHash: String) async throws -> TransactionReceipt {
// Poll eth_getTransactionReceipt until the transaction is mined
return TransactionReceipt(txHash: txHash, status: true, logs: [])
}
}
```
You can choose any web3 libraries in Swift to integrate with this delegate.
Then authenticate:
```swift theme={null}
let wallet = MyWalletDelegate()
do {
// Default (wallet-derived keys)
let result = try await sdk.authenticate(wallet: wallet)
// Or with a specific key source
// let result = try await sdk.authenticate(
// wallet: wallet,
// keySource: .mnemonic(phrase: "your twelve word mnemonic ...")
// )
switch result {
case .authenticated(let loginResult):
print("Privacy Address: \(loginResult.privacyAddress)")
print("MPK: \(loginResult.mpk)")
case .credentialRequired(let challenge):
// Handle credential challenge
let loginResult = try await sdk.submitCredential(credential)
print("Privacy Address: \(loginResult.privacyAddress)")
}
} catch {
print("Authentication failed: \(error)")
}
```
## Core Operations
### Check Balance
```swift theme={null}
do {
let balance = try await sdk.getBalance(tokenAddress: "0x...")
print("Shielded: \(balance.shieldedBalance)")
print("Wallet: \(balance.walletBalance)")
} catch {
print("Failed to get balance: \(error)")
}
```
### Deposit Tokens
Move tokens from wallet to shielded pool:
```swift theme={null}
do {
let result = try await sdk.shield(
tokenAddress: "0x...",
amount: "1000000000000000000" // 1 token (18 decimals)
)
print("Deposit TX: \(result.txHash)")
} catch {
print("Deposit failed: \(error)")
}
```
### Withdraw Tokens
Move tokens from shielded pool to wallet:
```swift theme={null}
do {
let result = try await sdk.unshield(
tokenAddress: "0x...",
amount: "500000000000000000",
recipient: "0x..." // Recipient address
)
print("Withdraw TX: \(result.txHash)")
} catch {
print("Withdraw failed: \(error)")
}
```
### Private Transfer
Send tokens privately to another user:
```swift theme={null}
do {
let result = try await sdk.send(
tokenAddress: "0x...",
amount: "250000000000000000",
recipientPrivacyAddress: "0x04..." // 194-char privacy address
)
print("Transfer TX: \(result.txHash)")
} catch {
print("Transfer failed: \(error)")
}
```
## Error Handling
The SDK throws `SDKError` for various failure conditions:
```swift theme={null}
do {
let result = try await sdk.shield(tokenAddress: token, amount: amount)
} catch SDKError.notConnected {
print("Wallet not connected")
} catch SDKError.notAuthenticated {
print("Not logged in")
} catch SDKError.insufficientBalance {
print("Insufficient balance")
} catch SDKError.invalidAmount {
print("Invalid amount format")
} catch SDKError.walletError(let message) {
print("Wallet error: \(message)")
} catch SDKError.networkError(let message) {
print("Network error: \(message)")
} catch {
print("Unknown error: \(error)")
}
```
## Session Persistence
Save and restore sessions to avoid re-signing:
```swift theme={null}
// Export session
if let session = sdk.exportSession() {
// Store securely (e.g., Keychain)
saveToKeychain(session)
}
// Import session
if let session = loadFromKeychain() {
do {
let success = try sdk.importSession(session)
if success {
print("Session restored")
}
} catch {
print("Session expired or invalid")
}
}
```
## Next Steps
Detailed WalletDelegate implementation
Secure Keychain storage and biometric unlock
Complete API documentation
Key derivation, persistence options, and recovery
# Balances
Source: https://docs.privacyboost.io/sdk/ios/guides/balances
# Balance Management
This guide covers querying and managing token balances in the Privacy Boost iOS SDK.
## Understanding Balances
Privacy Boost tracks two types of balances:
| Balance Type | Description |
| - | - |
| **Shielded** | Private balance in the privacy pool — only you can see it |
| **Wallet** | Public balance in your connected wallet on-chain |
## Get Single Token Balance
```swift theme={null}
do {
let balance = try await sdk.getBalance(tokenAddress: "0x...")
print("Token: \(balance.tokenAddress)")
print("Shielded: \(balance.shieldedBalance)")
print("Wallet: \(balance.walletBalance)")
print("Symbol: \(balance.symbol ?? "Unknown")")
print("Decimals: \(balance.decimals)")
} catch {
print("Failed to get balance: \(error)")
}
```
## Get All Balances
```swift theme={null}
do {
let balances = try await sdk.getAllBalances()
for balance in balances {
print("\(balance.symbol ?? balance.tokenAddress):")
print(" Shielded: \(balance.shieldedBalance)")
print(" Wallet: \(balance.walletBalance)")
}
} catch {
print("Failed to get balances: \(error)")
}
```
## Balance Type
```swift theme={null}
struct TokenBalance {
let tokenAddress: String // Token contract address
let shieldedBalance: String // Private balance in wei
let walletBalance: String // Public balance in wei
let symbol: String? // Token symbol (e.g., "USDC")
let decimals: UInt8 // Token decimals (e.g., 18)
}
```
## Formatting Amounts
Convert between wei strings and human-readable format:
```swift theme={null}
// Parse human-readable to wei
let weiAmount = try sdk.parseAmount("1.5", decimals: 18)
// Returns: "1500000000000000000"
// Format wei to human-readable
let humanAmount = try sdk.formatAmount("1500000000000000000", decimals: 18)
// Returns: "1.5"
// Format a balance for display
let balance = try await sdk.getBalance(tokenAddress: tokenAddress)
let formatted = try sdk.formatAmount(balance.shieldedBalance, decimals: balance.decimals)
print("Shielded: \(formatted) \(balance.symbol ?? "")")
```
## Checking Sufficient Balance
Before performing operations, verify the user has enough balance:
```swift theme={null}
func hasSufficientBalance(
tokenAddress: String,
requiredAmount: String
) async throws -> Bool {
let balance = try await sdk.getBalance(tokenAddress: tokenAddress)
guard let shielded = UInt64(balance.shieldedBalance),
let required = UInt64(requiredAmount) else {
return false
}
return shielded >= required
}
// Usage
if try await hasSufficientBalance(tokenAddress: token, requiredAmount: amount) {
let result = try await sdk.unshield(
tokenAddress: token,
amount: amount,
recipient: recipient
)
}
```
## Displaying Balances in SwiftUI
```swift theme={null}
struct BalanceListView: View {
@State private var balances: [TokenBalance] = []
@State private var isLoading = true
var body: some View {
List {
if isLoading {
ProgressView("Loading balances...")
} else if balances.isEmpty {
Text("No balances found")
.foregroundColor(.secondary)
} else {
ForEach(balances, id: \.tokenAddress) { balance in
BalanceRow(balance: balance)
}
}
}
.task {
await loadBalances()
}
.refreshable {
await loadBalances()
}
}
private func loadBalances() async {
isLoading = true
do {
balances = try await sdk.getAllBalances()
} catch {
print("Failed to load balances: \(error)")
}
isLoading = false
}
}
struct BalanceRow: View {
let balance: TokenBalance
var formattedShielded: String {
(try? sdk.formatAmount(balance.shieldedBalance, decimals: balance.decimals)) ?? "0"
}
var formattedWallet: String {
(try? sdk.formatAmount(balance.walletBalance, decimals: balance.decimals)) ?? "0"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(balance.symbol ?? balance.tokenAddress)
.font(.headline)
HStack {
Label("Shielded: \(formattedShielded)", systemImage: "lock.shield")
Spacer()
Label("Wallet: \(formattedWallet)", systemImage: "wallet.pass")
}
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
```
## Error Handling
```swift theme={null}
do {
let balance = try await sdk.getBalance(tokenAddress: tokenAddress)
} catch SDKError.notAuthenticated {
print("Please log in first")
} catch SDKError.networkError(let message) {
print("Network error: \(message)")
} catch {
print("Balance error: \(error)")
}
```
## Best Practices
### 1. Refresh After Operations
```swift theme={null}
// Refresh balance after a deposit
let shieldResult = try await sdk.shield(
tokenAddress: tokenAddress,
amount: amount
)
let updatedBalance = try await sdk.getBalance(tokenAddress: tokenAddress)
```
### 2. Handle Zero Balances
```swift theme={null}
func displayBalance(_ balance: TokenBalance) -> String {
if balance.shieldedBalance == "0" && balance.walletBalance == "0" {
return "0"
}
return (try? sdk.formatAmount(
balance.shieldedBalance,
decimals: balance.decimals
)) ?? "0"
}
```
### 3. Filter Non-Zero Balances
```swift theme={null}
let allBalances = try await sdk.getAllBalances()
let nonZero = allBalances.filter { balance in
balance.shieldedBalance != "0" || balance.walletBalance != "0"
}
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transactions Guide](./transactions)
* [Error Handling](./error-handling)
# Claimable transfers
Source: https://docs.privacyboost.io/sdk/ios/guides/claimable-transfers
# Claimable Transfers (Gifts)
Send shielded funds to an Ethereum wallet address that has not registered with
Privacy Boost yet. The recipient claims by proving ownership of that address; you
reclaim the funds if they never do. See
[Claimable Transfers](/sdk/concepts/claimable-transfers) for the concept and
trust model.
Gift methods are called directly on the `PrivacyBoost` instance (`sdk` below).
They may not be present in the checked-in generated Swift bindings, so the code
blocks here are illustrative and not verified against the published build.
## Sending a gift
The most common case: you know the recipient's wallet address but they have not
joined Privacy Boost. Use `giftFundToWallet`. Every fund/claim/refund call
returns a `TransferResult`.
```swift theme={null}
// Read the current chain head from your own RPC / chain client.
// Refund deadline: a block height after which you can reclaim an unclaimed gift.
// The min/max delay is advertised from the server's /info endpoint.
let currentBlock: UInt64 = try await myRpcClient.blockNumber()
let refundAfterBlock = currentBlock + 50_000
do {
let result = try await sdk.giftFundToWallet(
tokenAddress: "0x...token-address",
amount: "1000000000000000000", // 1 token, in wei
recipientWallet: "0xRecipientEoaAddress",
refundAfterBlock: refundAfterBlock,
currentBlock: currentBlock
)
print("Funded gift: \(result.txHash)")
if let link = result.claimLink {
print("Share this claim link with the recipient: \(link)")
}
// Persist result.giftRecord so you can refund later (see "Refunding" below).
if let record = result.giftRecord {
saveGiftRecord(record)
}
} catch {
print("Gift funding failed: \(error)")
}
```
If the recipient is already a Privacy Boost user, fund with their privacy address
too (`giftFund`) so the gift ciphertext is sealed to their viewing key for the
smoothest discovery. Note the argument label is `recipientPrivacyAddress` — the
194-char privacy address — alongside the recipient's wallet:
```swift theme={null}
let result = try await sdk.giftFund(
tokenAddress: "0x...token-address",
amount: "1000000000000000000",
recipientPrivacyAddress: "0x04...recipient-privacy-address", // 194-char privacy address
recipientWallet: "0xRecipientEoaAddress",
refundAfterBlock: refundAfterBlock,
currentBlock: currentBlock
)
```
### Funding parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | Token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
| `recipientPrivacyAddress` | `String` | `giftFund` only | Recipient's 194-char privacy address (the ciphertext's ECDH target) |
| `recipientWallet` | `String` | Yes | Recipient's Ethereum address the gift binds to (may be unregistered) |
| `refundAfterBlock` | `UInt64` | Yes | Block height after which you may reclaim an unclaimed gift |
| `currentBlock` | `UInt64` | Yes | Current chain head; pre-validates the refund delay before submitting |
The gift binds **irrevocably** to `recipientWallet`. A wrong-but-valid address
can be claimed by whoever controls it, and only an unclaimed gift is
refundable. Show the exact checksummed address and require explicit
confirmation before funding.
### Funding result
`giftFund` / `giftFundToWallet` return a `TransferResult` carrying two optional
gift fields:
```swift theme={null}
struct TransferResult {
let requestId: String
let txHash: String
let fee: String // Fee paid (in wei)
let giftRecord: GiftRecord? // persist this — it makes the gift refundable
let claimLink: String? // pbgift:v1:... — share with the recipient
}
```
## Sharing the claim link
`result.claimLink` is a `pbgift:v1:...` string that carries the encrypted opening
the recipient needs. Share it out of band (message, QR code). Treat it like a
password — it reveals the gift's amount and recipient to anyone who reads it.
## Claiming a gift
The recipient registers Privacy Boost with the same wallet address, then claims.
List the pending gifts addressed to the authenticated wallet:
```swift theme={null}
let pending = try await sdk.getPendingGifts()
for gift in pending {
print("#\(gift.index): \(gift.amount) of token \(gift.tokenId)")
}
```
Claim one by its position in that list, or — more robustly — by its stable
commitment `cGift` (the list can shift as gifts settle):
```swift theme={null}
// By index (positional):
let claimed = try await sdk.giftClaim(index: 0, acknowledgeUnknownSender: true)
// By stable commitment (preferred when the list may change):
let claimed = try await sdk.giftClaimByCGift(
cGift: pending[0].cGift,
acknowledgeUnknownSender: true
)
```
Or claim straight from a claim link, without a server lookup first:
```swift theme={null}
let claimed = try await sdk.giftClaimFromLink(
link: "pbgift:v1:...",
acknowledgeUnknownSender: true
)
```
`acknowledgeUnknownSender` must be `true` to claim. The hidden-sender model
cannot reveal who funded a gift, so the recipient explicitly acknowledges
accepting funds from an unknown sender. Surface this as a consent prompt.
A successful claim re-mints the gift into a normal shielded note — from then on it
behaves like any other note in the recipient's balance.
## Refunding an unclaimed gift
A claim and a refund spend the same nullifier, so only one can ever settle. After
the refund deadline passes, reclaim an unclaimed gift using the local record you
saved at funding time.
```swift theme={null}
// Records the SDK persisted for gifts you funded:
let records = sdk.getGiftRecords()
// Refund the one at index 0 (a positional UInt32, not a record value):
let refunded = try await sdk.giftRefundByRecord(index: 0)
```
Gift records are what make a gift refundable. They are included in an
[exported session](/sdk/ios/guides/session-storage), so persisting and restoring
the session keeps your unclaimed gifts refundable across restarts. If you ever
lose the record, you can still refund by supplying every field manually via
`giftRefund(recipientWallet:, blind:, refundAfterBlock:, tokenId:, amount:, fundingTreeNumber:, giftLeafIndex:)`.
## Previewing a claim link offline
Decode a claim link into a preview with no network call — useful to show the
recipient what they are about to accept. `decodeGiftLink` is synchronous and
offline (throwing, no `await`):
```swift theme={null}
let preview = try sdk.decodeGiftLink(link: "pbgift:v1:...")
print(preview.amount, preview.tokenId, preview.recipientWallet, preview.refundAfterBlock)
```
## Types
```swift theme={null}
struct GiftRecord {
let recipientWallet: String
let blind: String
let refundAfterBlock: UInt64
let tokenId: UInt16
let amount: String
let cGift: String
let giftNpk: String
let requestId: String
let fundingTreeNumber: UInt64?
let giftLeafIndex: UInt64?
let status: String?
}
struct PendingGift {
let index: UInt32
let treeNumber: UInt64
let leafIndex: UInt64
let cGift: String
let tokenId: UInt16
let amount: String
let refundAfterBlock: UInt64
let recipientWallet: String
}
struct GiftLinkPreview {
let server: String
let chainId: UInt64
let cGift: String
let recipientWallet: String
let tokenId: UInt16
let amount: String
let refundAfterBlock: UInt64
}
```
A third funding mode, `giftFundSecretBearer`, binds a gift to a secret instead
of a wallet so it can be claimed before the recipient has any wallet. It is a
**bearer instrument** (whoever holds the link can claim), selected only by
that explicit method. See the [concept
page](/sdk/concepts/claimable-transfers) before considering it.
# Deposits
Source: https://docs.privacyboost.io/sdk/ios/guides/deposits
# Deposits
This guide covers depositing tokens from your wallet into your private balance.
## Basic Deposit
```swift theme={null}
do {
let result = try await sdk.shield(
tokenAddress: "0x...token-address",
amount: "1000000000000000000" // 1 token (18 decimals)
)
print("Transaction hash: \(result.txHash)")
print("Commitment: \(result.commitment)")
} catch {
print("Deposit failed: \(error)")
}
```
## Deposit Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | ERC-20 token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
## Deposit Steps
When a deposit is submitted, the SDK executes these steps in order:
| Step | Description |
| - | - |
| Wrapping | Wrapping ETH to WETH (if depositing ETH) |
| Approving | Approving token spending on the shield contract |
| Shielding | Executing the deposit transaction |
| Registering | Registering the deposit with the indexer |
| Compliance | Waiting for compliance verification |
## Deposit Result
```swift theme={null}
struct ShieldResult {
let txHash: String // Main deposit transaction hash
let commitment: String // Note commitment
let fee: String // Fee paid (in wei)
}
```
## Depositing ETH
To deposit native ETH, use the zero address. The SDK automatically wraps ETH to WETH:
```swift theme={null}
let ethAddress = "0x0000000000000000000000000000000000000000"
do {
let result = try await sdk.shield(
tokenAddress: ethAddress,
amount: "1000000000000000000" // 1 ETH
)
print("Deposit tx: \(result.txHash)")
} catch {
print("ETH deposit failed: \(error)")
}
```
The SDK automatically:
1. Wraps ETH to WETH
2. Approves WETH spending
3. Deposits WETH to the shield contract
## Parsing Amounts
Use helper functions to convert between human-readable and wei formats:
```swift theme={null}
// Parse human-readable amount to wei string
let weiAmount = try sdk.parseAmount("1.5", decimals: 18)
// Returns: "1500000000000000000"
// Format wei string to human-readable
let formatted = try sdk.formatAmount("1500000000000000000", decimals: 18)
// Returns: "1.5"
// For USDC (6 decimals)
let usdcWei = try sdk.parseAmount("100.0", decimals: 6)
// Returns: "100000000"
```
## Error Handling
```swift theme={null}
do {
let result = try await sdk.shield(
tokenAddress: tokenAddress,
amount: amount
)
} catch SDKError.insufficientBalance {
print("Not enough tokens in wallet")
} catch SDKError.invalidAmount {
print("Invalid amount format")
} catch SDKError.walletError(let message) {
print("Wallet error: \(message)")
} catch SDKError.signatureRejected {
print("User rejected the transaction")
} catch SDKError.networkError(let message) {
print("Network error: \(message)")
} catch {
print("Deposit error: \(error)")
}
```
## Best Practices
### 1. Validate Amounts Before Depositing
```swift theme={null}
func validateDepositAmount(_ amount: String, walletBalance: String) throws {
guard let amountValue = UInt64(amount), amountValue > 0 else {
throw SDKError.invalidAmount
}
guard let balanceValue = UInt64(walletBalance),
amountValue <= balanceValue else {
throw SDKError.insufficientBalance(message: "Insufficient wallet balance")
}
}
```
### 2. Refresh Balance After Deposit
```swift theme={null}
let result = try await sdk.shield(
tokenAddress: tokenAddress,
amount: amount
)
// Refresh balance to reflect the deposit
let updatedBalance = try await sdk.getBalance(tokenAddress: tokenAddress)
print("New shielded balance: \(updatedBalance.shieldedBalance)")
```
### 3. Handle Long Operations
Deposits involve multiple on-chain transactions and may take time. Show appropriate loading states in your UI and avoid blocking the main thread.
## Relayed Deposits (build calldata, relay yourself)
If your users don't submit transactions directly — they hold funds at addresses
you relay for (a gasless relayer, an EIP-7702 session key, a custom pipeline) —
build the deposit calldata without submitting it, relay it through your own
infrastructure, then finalize from the mined receipt.
```swift theme={null}
// 1. Build the calldata (nothing submitted)
let prepared = try await sdk.prepareShield(
tokenAddress: "0x...",
amount: "1000000000000000000",
recipient: nil // pass a privacy address to shield to someone else
)
// prepared.wrap / prepared.approve / prepared.shield are Call { to, value, data }
// values — relay them in order (wrap?, approve?, shield).
let calls = [prepared.wrap, prepared.approve, prepared.shield].compactMap { $0 }
let receipt = try await myRelayer.submit(calls)
// 2. Finalize from the mined receipt to recover the request id
let result = try finalizeShield(
receipt: receipt,
shieldContract: prepared.finalize.shieldContract,
commitment: prepared.commitment
)
print("Request ID: \(result.requestId)")
```
Each `Call` is `{ to, value, data }`, so the array drops directly into an
EIP-5792 / Porto `wallet_prepareCalls` bundle.
### Server-side (no session)
A backend that holds only a recipient's privacy address can build a deposit on
their behalf with the top-level `buildShieldPayload` — no signed-in session and
no private keys:
```swift theme={null}
let prepared = try buildShieldPayload(
tokenAddress: "0x...",
amount: "1000000000000000000",
recipient: userPrivacyAddress,
tokenId: tokenId, // from the token catalog
shieldContractAddress: shieldContractAddress,
wethContractAddress: nil, // required only for native ETH
teePublicKey: teePublicKey,
minShieldAmount: minShieldAmount, // optional: reject below the per-token minimum
emitApprove: true
)
```
The note's owner is bound cryptographically to the recipient's privacy address,
so only they can spend it; the ephemeral sender used to encrypt the deposit is
generated and discarded internally.
## Next Steps
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Error handling
Source: https://docs.privacyboost.io/sdk/ios/guides/error-handling
# Error Handling
This guide covers error handling patterns for the Privacy Boost iOS SDK. For the cross-platform error code reference, see [Error Handling](/sdk/concepts/error-handling).
## SDKError
All SDK errors are thrown as `SDKError` enum cases:
```swift theme={null}
enum SDKError: Error {
case notConnected // Wallet not connected
case notAuthenticated // Not logged in
case invalidConfig // Invalid SDK configuration
case networkError(String) // Network/connectivity issues
case walletError(String) // Wallet operation failed
case signatureRejected // User rejected signing
case insufficientBalance // Not enough balance
case invalidAddress // Invalid address format
case invalidAmount // Invalid amount format
case serializationError(String) // Data encoding/decoding failed
case authServerError(code: String, message: String)
case shieldError(code: String, message: String)
case transferError(code: String, message: String)
case noteError(code: String, message: String)
case merkleError(code: String, message: String)
case apiError(code: String, message: String, retryable: Bool)
case rateLimited(retryAfterMs: UInt64) // Too many requests
case forbidden(String) // Access denied
case resourceNotFound(String) // Resource not found
case internalError(String) // Internal SDK error
}
```
## Type-Safe Error Handling
Use Swift's pattern matching to handle specific error cases:
```swift theme={null}
do {
let result = try await sdk.shield(
tokenAddress: tokenAddress,
amount: amount
)
} catch SDKError.signatureRejected {
// User cancelled — do nothing
return
} catch SDKError.insufficientBalance {
showAlert("Not enough balance")
} catch SDKError.walletError(let message) {
showAlert("Wallet error: \(message)")
} catch SDKError.networkError(let message) {
showAlert("Network error. Please try again.")
} catch SDKError.rateLimited(let retryAfterMs) {
let seconds = retryAfterMs / 1000
showAlert("Too many requests. Try again in \(seconds) seconds.")
} catch SDKError.apiError(_, let message, let retryable) {
if retryable {
// Retry the operation
} else {
showAlert(message)
}
} catch {
showAlert("An unexpected error occurred")
}
```
## Retry with Exponential Backoff
For retryable errors, use exponential backoff:
```swift theme={null}
func withRetry(
maxRetries: Int = 3,
operation: () async throws -> T
) async throws -> T {
for attempt in 1...maxRetries {
do {
return try await operation()
} catch SDKError.networkError(_) where attempt < maxRetries {
let delay = UInt64(1_000_000_000 * attempt) // 1s, 2s, 3s
try await Task.sleep(nanoseconds: delay)
continue
} catch SDKError.apiError(_, _, let retryable) where retryable && attempt < maxRetries {
let delay = UInt64(1_000_000_000 * attempt)
try await Task.sleep(nanoseconds: delay)
continue
} catch SDKError.rateLimited(let retryAfterMs) where attempt < maxRetries {
try await Task.sleep(nanoseconds: retryAfterMs * 1_000_000)
continue
}
}
// Last attempt — let errors propagate
return try await operation()
}
// Usage
let balance = try await withRetry {
try await sdk.getBalance(tokenAddress: token)
}
```
## Auto Re-Authentication
Handle expired sessions by re-authenticating automatically:
```swift theme={null}
func withAuth(
wallet: WalletDelegate,
operation: () async throws -> T
) async throws -> T {
do {
return try await operation()
} catch SDKError.notAuthenticated {
// Session expired — re-authenticate
_ = try await sdk.authenticate(wallet: wallet)
return try await operation()
}
}
// Usage
let balance = try await withAuth(wallet: myWallet) {
try await sdk.getBalance(tokenAddress: token)
}
```
## User-Friendly Error Messages
Map SDK errors to messages suitable for showing to users:
```swift theme={null}
func userMessage(for error: Error) -> String {
guard let sdkError = error as? SDKError else {
return "An unexpected error occurred"
}
switch sdkError {
case .notConnected:
return "Please connect your wallet"
case .notAuthenticated:
return "Session expired. Please log in again."
case .signatureRejected:
return "Request cancelled"
case .insufficientBalance:
return "Not enough balance"
case .invalidAddress:
return "Invalid address"
case .invalidAmount:
return "Invalid amount"
case .networkError:
return "Connection failed. Please check your internet and try again."
case .rateLimited:
return "Too many requests. Please wait a moment."
case .walletError(let message):
return "Wallet error: \(message)"
default:
return "Something went wrong. Please try again."
}
}
```
## Categorizing Errors
Group errors by whether user action can resolve them:
```swift theme={null}
enum ErrorCategory {
case userAction // User can fix (e.g., connect wallet, add funds)
case retryable // Transient, retry may work
case fatal // Requires developer attention
}
func categorize(_ error: SDKError) -> ErrorCategory {
switch error {
case .notConnected, .notAuthenticated, .signatureRejected,
.insufficientBalance, .invalidAddress, .invalidAmount:
return .userAction
case .networkError, .rateLimited:
return .retryable
case .apiError(_, _, let retryable):
return retryable ? .retryable : .fatal
default:
return .fatal
}
}
```
## SwiftUI Error Handling Pattern
```swift theme={null}
class VaultViewModel: ObservableObject {
@Published var errorMessage: String?
@Published var isLoading = false
func shield(tokenAddress: String, amount: String) async {
isLoading = true
errorMessage = nil
do {
let result = try await withRetry {
try await sdk.shield(
tokenAddress: tokenAddress,
amount: amount
)
}
print("Deposit successful: \(result.txHash)")
} catch {
errorMessage = userMessage(for: error)
}
isLoading = false
}
}
```
## Best Practices
### 1. Always Handle `signatureRejected` Silently
When a user cancels a wallet signature, don't show an error — just return to the previous state:
```swift theme={null}
do {
let result = try await sdk.shield(tokenAddress: token, amount: amount)
} catch SDKError.signatureRejected {
return // User cancelled, nothing to report
} catch {
showAlert(userMessage(for: error))
}
```
### 2. Log Errors for Debugging
```swift theme={null}
do {
let result = try await sdk.shield(tokenAddress: token, amount: amount)
} catch {
// Log full error for debugging
print("[PrivacyBoost] Deposit failed: \(error)")
// Show user-friendly message
showAlert(userMessage(for: error))
}
```
### 3. Use Error Categories for UI Decisions
```swift theme={null}
do {
try await someOperation()
} catch let error as SDKError {
switch categorize(error) {
case .userAction:
showAlert(userMessage(for: error))
case .retryable:
showRetryButton()
case .fatal:
showContactSupport()
}
} catch {
showContactSupport()
}
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [API Reference](/sdk/ios/api-reference)
* [Error Handling Concepts](/sdk/concepts/error-handling)
# Multi-Chain
Source: https://docs.privacyboost.io/sdk/ios/guides/multi-chain
Operate across multiple blockchains with the iOS SDK
# Multi-Chain
This guide covers using `ChainContextHandle` to operate on multiple blockchains from a single iOS SDK instance.
For an overview of multi-chain concepts, see [Multi-Chain Concepts](/sdk/concepts/multi-chain). The same identity keys and privacy address apply on every chain.
## Setup
Initialize the parent SDK against your primary chain, then open a chain context for each additional chain:
```swift theme={null}
import PrivacyBoost
let sdk = try PrivacyBoost(config: PrivacyBoostConfig(
serverUrl: "https://op.example.com",
chainId: nil, // discovered from server
shieldContractAddress: nil,
wethContractAddress: nil,
teePublicKey: nil,
appId: "my-app",
persistenceStorage: nil,
persistenceUnlock: nil
))
// After authenticating the parent SDK once, open per-chain contexts.
let ethereum = try sdk.createChainContext(config: ChainContextConfig(
serverUrl: "https://eth.example.com",
chainId: 1
))
let base = try sdk.createChainContext(config: ChainContextConfig(
serverUrl: "https://base.example.com",
chainId: 8453
))
```
`serverUrl` and `chainId` are the only fields you have to give: an omitted contract address is discovered from that chain's server, and `timeoutMs` defaults to the SDK-wide 30s. A contract address you do pass has to parse, so a typo fails rather than falling back to the server-advertised pool. `createChainContext` is synchronous and throws if `serverUrl` is empty or not an HTTPS URL. Every call returns a new handle with its own session, so create each one once and keep it, for example on the actor or object that owns `sdk`.
## Authentication
Each chain context authenticates before it can operate. Authentication reuses the parent SDK's privacy keys but obtains a chain-specific JWT and registration, so the user signs once per chain:
```swift theme={null}
// Authenticate the parent SDK once; this establishes the identity keys.
_ = try await sdk.authenticate(
wallet: walletDelegate,
keySource: .walletDerived,
tokenProvider: nil
)
// Authenticate each chain context with the same wallet.
let ethLogin = try await ethereum.authenticate(wallet: walletDelegate)
let baseLogin = try await base.authenticate(wallet: walletDelegate)
print("Ethereum chain id:", ethereum.chainId())
print("Ethereum authenticated:", ethereum.isAuthenticated())
print("Ethereum registered:", ethereum.isRegistered())
```
`ChainContextHandle.authenticate(wallet:)` takes only a `WalletDelegate`; the key source and token provider come from the parent SDK. Apps that authenticate through an external identity provider pass that provider's token as `externalToken:`.
## Operations
Once authenticated, a `ChainContextHandle` exposes the same per-chain operations as the parent SDK.
### Deposits
```swift theme={null}
let amount = try sdk.parseAmount("1.0", decimals: 18)
let result = try await ethereum.shield(
tokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH on Ethereum
amount: amount
)
print("Ethereum shield tx:", result.txHash)
```
### Private Transfers
```swift theme={null}
// resolveIdentity lives on the parent SDK; the handle shares its identity.
let recipient = try await sdk.resolveIdentity(identifier: "0x1234...abcd")
let transfer = try await base.send(
tokenAddress: "0x...token-on-base",
amount: try sdk.parseAmount("0.5", decimals: 18),
recipientPrivacyAddress: recipient.privacyAddress
)
print("Base transfer tx:", transfer.txHash)
```
### Withdrawals
```swift theme={null}
_ = try await ethereum.unshield(
tokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
amount: try sdk.parseAmount("0.25", decimals: 18),
recipient: "0x...destination"
)
```
### Balances
```swift theme={null}
let ethBalance = try await ethereum.getBalance(
tokenAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
)
let baseBalance = try await base.getBalance(tokenAddress: "0x...token-on-base")
print("Ethereum shielded:", ethBalance.shieldedBalance)
print("Base shielded:", baseBalance.shieldedBalance)
```
### Status Polling and History
```swift theme={null}
let status = try await ethereum.getShieldStatus(requestId: result.requestId)
let history = try await base.getTransactionHistory(txType: nil, limit: 20, cursor: nil)
let notes = try await ethereum.getUnspentNotes(tokenAddress: nil)
// Token and direction filters, and collapsed gateway rows, go through the full query.
let incoming = try await base.queryTransactionHistory(query: TransactionHistoryQuery(
direction: .incoming,
limit: 20,
collapseGateway: true
))
```
## What the Handle Carries
A handle covers the operations whose state is per chain: `shield`, `prepareShield`, `unshield`, `send` and `sendBatch`, `unwrapWeth`, `consolidateNotes`, balances and the token catalog, fees, status polling, history and unspent notes, pending-transaction tracking (including `waitForPreconfirmation` and `waitForFinality`), and the claimable-transfer (gift) methods.
It does not carry portal deposits, earn and swap, audit, or the private treasury (contacts and labels). Those stay on the parent `PrivacyBoost`, as do `resolveIdentity`, `parseAmount`, `formatAmount`, address validation, `setChainReader`, and session export. The parent's pending transactions and token registry reflect its own chain only, so read each chain's pending transactions and balances through that chain's handle rather than the parent.
The handle has no vault of its own: it shares the parent's resident keys, so `lock()` and the auto-lock timers on the parent apply to it as well. Its JWT is separate, which is why `clearSession()` on the parent does not reach it.
## Cross-Chain Patterns
### Parallel Operations
Chain contexts are independent, so fan out with `async let`:
```swift theme={null}
async let ethTask = ethereum.shield(tokenAddress: "0x...", amount: ethAmount)
async let baseTask = base.shield(tokenAddress: "0x...", amount: baseAmount)
let (ethResult, baseResult) = try await (ethTask, baseTask)
```
### Aggregating Balances
```swift theme={null}
struct ChainEntry {
let ctx: ChainContextHandle
let token: String
}
func totalShielded(across chains: [ChainEntry]) async throws -> String {
try await withThrowingTaskGroup(of: String.self) { group in
for chain in chains {
group.addTask {
let bal = try await chain.ctx.getBalance(tokenAddress: chain.token)
return bal.shieldedBalance
}
}
var sum: UInt64 = 0
for try await wei in group {
sum &+= UInt64(wei) ?? 0
}
return String(sum)
}
}
let total = try await totalShielded(across: [
ChainEntry(ctx: ethereum, token: "0x..."),
ChainEntry(ctx: base, token: "0x..."),
])
```
For amounts that exceed `UInt64`, use `BigInt` from a library such as [BigInt](https://github.com/attaswift/BigInt).
### Chain Selection
```swift theme={null}
let chains: [String: ChainContextHandle] = [
"ethereum": ethereum,
"base": base,
]
func chain(_ name: String) throws -> ChainContextHandle {
guard let ctx = chains[name] else {
throw SDKError.invalidInput(message: "Unknown chain: \(name)")
}
return ctx
}
_ = try await chain("ethereum").shield(
tokenAddress: "0x...",
amount: try sdk.parseAmount("1.0", decimals: 18)
)
```
## Cleanup
Each chain context can drop its JWT independently while the parent SDK identity stays intact:
```swift theme={null}
ethereum.clearSession() // drop just Ethereum's JWT
sdk.clearSession() // drop the parent JWT (does not clear contexts)
sdk.lock() // wipe the shared keys; contexts keep their JWT until cleared
sdk.logout() // end the whole session and clear the shared identity
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Transfers Guide](./transfers)
* [Multi-Chain Concepts](/sdk/concepts/multi-chain)
# Portal deposits
Source: https://docs.privacyboost.io/sdk/ios/guides/portal-deposits
# Portal Deposit Addresses
Give a user a reusable, public, exchange-style deposit address. Anyone can fund
it with standard ERC-20 transfers, and the funds are credited into the owner's
shielded balance without revealing which account was credited. See
[Portal Deposit Addresses](/sdk/concepts/portal-deposits) for the concept and
trust model.
iOS exposes the supported portal lifecycle as methods on your `PrivacyBoost`
instance. `createPortal` derives the portal EOA from the recovered nullifying key and keeps
the key inside core.
## Lifecycle at a glance
1. **Check support** — `sdk.portalDelegateAddress()`.
2. **Create** — `try await sdk.createPortal(index: nil)` derives `E`, gaslessly
delegates and registers it, then publishes discovery.
3. **Share** — senders fund the returned `portal.address` with ordinary ERC-20.
4. **Sweep / credit** — a keeper normally runs this, or you can call
`sdk.sweepPortal(...)` as a backstop.
5. **Recover** — use `sdk.reclaimPortalDeposit(...)` for uncredited swept
deposits, then `sdk.withdrawPortal(...)` if raw funds are resting at `E`.
## Check support
```swift theme={null}
guard let delegate = sdk.portalDelegateAddress() else {
print("This server does not support portal deposits.")
return
}
```
## Create a portal
```swift theme={null}
let portal = try await sdk.createPortal(index: nil) // or index: 0
print(portal.address) // share this public deposit address
print(portal.index) // seed-derivation index for recovery actions
print(portal.bindH) // public on-chain binding
```
`createPortal` is resumable. If the same seed-derived portal is already created,
the method returns it as a no-op. Passing an explicit index recreates that
deterministic portal; passing `nil` picks the next free index.
## List portals and deposits
Each response contains `items` and an optional `nextCursor`. Pass the cursor
back unchanged to fetch the next page, stopping when it is `nil`.
```swift theme={null}
let portals = try await sdk.listPortalsPage(cursor: nil, limit: 50)
let deposits = try await sdk.listPortalDepositsPage(portal: portal.address, cursor: nil, limit: 50)
let status = try await sdk.getPortalStatus(portal: portal.address)
print(portals.items.count)
```
## Sweep and reclaim
Sweeping is permissionless, and deployments normally run a keeper. If you need
to trigger it yourself:
```swift theme={null}
let txHash = try await sdk.sweepPortal(portal: portal.address, tokenId: tokenId)
```
If a swept deposit remains uncredited past the cancel delay:
```swift theme={null}
let txHash = try await sdk.reclaimPortalDeposit(depositId: portalDepositId)
```
`reclaimPortalDeposit` returns funds to the portal address `E`, not to the caller.
To move raw funds from `E`, build a signed transaction and broadcast it yourself:
```swift theme={null}
let rawTx = try sdk.withdrawPortal(
index: portal.index,
token: tokenAddress,
to: destination,
amount: amountWei,
nonce: nonce,
gasLimit: gasLimit,
maxFeePerGas: maxFeePerGasWei,
maxPriorityFeePerGas: maxPriorityFeePerGasWei
)
```
`E` must hold native gas for the withdrawal transaction.
## Low-level escape hatches
The package still exports two low-level helpers for custodial integrations that
manage their own portal EOA key:
```swift theme={null}
let eoa = generatePortalEoa() // privateKey is SECRET
let calldata = try encodePortalWithdraw(
token: tokenAddress,
to: destination,
amount: amountWei
)
```
The seed-derived `createPortal` flow does not use or expose `eoa.privateKey`.
## Types
```swift theme={null}
struct Portal {
let address: String
let index: UInt32
let bindH: String
}
struct PortalInfo {
let address: String
let index: UInt32?
let status: PortalStatus
}
struct PortalStatus {
let registered: Bool
let delegated: Bool
let published: Bool
}
struct PortalDeposit {
let portalDepositId: String
let portal: String
let tokenId: UInt16
let grossAmount: String
let netAmount: String
let state: PortalDepositState
let requestBlock: UInt64
let cancellableAtBlock: UInt64
}
```
# Session storage
Source: https://docs.privacyboost.io/sdk/ios/guides/session-storage
# Session Storage
This guide covers securely storing and restoring Privacy Boost sessions using iOS Keychain, expanding on the persistence options in [Key Management](/sdk/concepts/key-management#persistence--protection).
## Why Keychain?
Session data includes sensitive cryptographic keys. Keychain provides:
* Hardware-backed encryption
* Access control (biometrics, passcode)
* Secure enclave support
* Data protection across app updates
There are two ways to use it. The SDK can own the vault in the Keychain itself through a `KeychainDelegate`, which is the path described next, or the app can export the session and store it with its own Keychain code, which the rest of this guide covers.
## SDK-Managed Persistence
Select `persistenceStorage: .osKeychain` and an unlock method, and register a `KeychainDelegate` before constructing the SDK. `PrivacyBoost.withPlatformDefaults(config:)` from the `PrivacyBoostDefaults` product registers `DefaultKeychainDelegate()` for you unless the app already registered its own, then constructs the SDK:
```swift theme={null}
import PrivacyBoost
import PrivacyBoostDefaults
let config = PrivacyBoostConfig(
serverUrl: "https://indexer.example.com",
chainId: nil,
shieldContractAddress: nil,
wethContractAddress: nil,
teePublicKey: nil,
appId: "my-app",
persistenceStorage: .osKeychain,
persistenceUnlock: .pin
)
let sdk = try PrivacyBoost.withPlatformDefaults(config: config)
```
`DefaultKeychainDelegate` stores items as generic passwords under one service name (`com.privacyboost.sdk` unless you pass another to its initializer) with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. To use your own Keychain code instead, conform to `KeychainDelegate` and register it yourself:
```swift theme={null}
registerKeychainDelegate(delegate: MyKeychainDelegate())
let sdk = try PrivacyBoost(config: config)
```
`registerKeychainDelegate` takes an optional `service:` that namespaces the vault items. Omit it and the SDK uses its own name, which is what a single-SDK app wants; pass your own (a bundle id, say) when two SDK instances or an app extension share the process or a Keychain group, so their items cannot collide.
The registration is process-wide: do it once, before the first `PrivacyBoost` whose config selects `.osKeychain`, and note that a later call replaces the delegate for every instance. Construction throws `SDKError.configError` naming `register_keychain_delegate` when no delegate is registered; `hasKeychainDelegate()` tells you whether one is. `.localStorage` and `.indexedDb` are browser backends and are rejected on iOS, as is `.passkey` unlock.
### Unlock methods
| `persistenceUnlock` | On the next launch |
| - | - |
| `.pin`, `.password` | `authenticate` returns `.credentialRequired`; collect the credential and call `submitCredential`. |
| `.biometric` | The vault item is written with an access control of `.biometryCurrentSet`, so reading it shows the Face ID or Touch ID prompt. There is no credential challenge: `authenticate` asks the wallet for one signature to derive the vault key and returns `.authenticated`. |
| `.none` | As `.biometric` without the prompt: one wallet signature, then `.authenticated`. |
### Unlocking on the next launch
With `.pin` or `.password`, the first launch returns `.credentialRequired` with `challenge.action == .setup`, where the user chooses the credential, and every later launch returns it with `challenge.action == .unlock`. `submitCredential` completes the login either way and can be retried after a wrong credential without calling `authenticate` again:
```swift theme={null}
func signIn() async throws -> LoginResult {
let result = try await sdk.authenticate(
wallet: walletDelegate,
keySource: .walletDerived, // ignored once a vault exists
tokenProvider: nil
)
switch result {
case .authenticated(let login):
return login
case .credentialRequired(let challenge):
let pin = try await promptForPin(isNew: challenge.action == .setup)
return try await sdk.submitCredential(pin)
default:
// .mnemonicGenerated, .recoveryRequired, .accountCreationRequired:
// see Getting Started.
throw SDKError.stateError(message: "unhandled auth result")
}
}
```
The vault survives `logout()` and `lock()`; call `deleteVault()` to erase it, for example when the user removes the account from the device. [Auto-lock](#auto-lock) below wipes the decrypted keys on a timer and sends the user through this same unlock on the next key access.
## Basic Keychain Wrapper
Create a Keychain helper:
```swift theme={null}
import Foundation
import Security
enum KeychainError: Error {
case duplicateItem
case itemNotFound
case unexpectedStatus(OSStatus)
case invalidData
}
class KeychainManager {
static let shared = KeychainManager()
private let service = "com.yourapp.privacyboost"
private init() {}
// MARK: - Save
func save(_ data: Data, for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
// Update existing item
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.unexpectedStatus(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - Load
func load(for key: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}
guard let data = result as? Data else {
throw KeychainError.invalidData
}
return data
}
// MARK: - Delete
func delete(for key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - Exists
func exists(for key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: false
]
let status = SecItemCopyMatching(query as CFDictionary, nil)
return status == errSecSuccess
}
}
```
## Session Storage
Store and retrieve Privacy Boost sessions:
```swift theme={null}
import PrivacyBoost
class SessionStorage {
static let shared = SessionStorage()
private let keychain = KeychainManager.shared
private let sessionKey = "privacyboost.session"
private init() {}
// MARK: - Save Session
func saveSession(_ session: ExportedSession) throws {
let data = try encodeSession(session)
try keychain.save(data, for: sessionKey)
}
// MARK: - Load Session
func loadSession() throws -> ExportedSession {
let data = try keychain.load(for: sessionKey)
return try decodeSession(data)
}
// MARK: - Delete Session
func deleteSession() throws {
try keychain.delete(for: sessionKey)
}
// MARK: - Has Session
func hasSession() -> Bool {
return keychain.exists(for: sessionKey)
}
// MARK: - Encoding
private func encodeSession(_ session: ExportedSession) throws -> Data {
let dict: [String: Any] = [
"version": session.version,
"data": session.data
]
return try JSONSerialization.data(withJSONObject: dict)
}
private func decodeSession(_ data: Data) throws -> ExportedSession {
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw KeychainError.invalidData
}
return ExportedSession(
version: dict["version"] as? UInt32 ?? 1,
data: dict["data"] as? String ?? ""
)
}
}
```
## Biometric Protection
Add Face ID/Touch ID protection:
```swift theme={null}
import LocalAuthentication
class BiometricSessionStorage {
static let shared = BiometricSessionStorage()
private let keychain = KeychainManager.shared
private let sessionKey = "privacyboost.session.biometric"
private init() {}
// MARK: - Save with Biometrics
func saveSession(_ session: ExportedSession) throws {
let data = try encodeSession(session)
// Create access control with biometrics
var error: Unmanaged?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
throw error!.takeRetainedValue() as Error
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.yourapp.privacyboost.biometric",
kSecAttrAccount as String: sessionKey,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl
]
// Delete existing and add new
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
// MARK: - Load with Biometrics
func loadSession() async throws -> ExportedSession {
return try await withCheckedThrowingContinuation { continuation in
let context = LAContext()
context.localizedReason = "Access your Privacy Boost session"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.yourapp.privacyboost.biometric",
kSecAttrAccount as String: sessionKey,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context
]
DispatchQueue.global().async {
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data {
do {
let session = try self.decodeSession(data)
continuation.resume(returning: session)
} catch {
continuation.resume(throwing: error)
}
} else {
continuation.resume(throwing: KeychainError.unexpectedStatus(status))
}
}
}
}
// MARK: - Check Biometrics Available
static var biometricsAvailable: Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
static var biometricType: String {
let context = LAContext()
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
switch context.biometryType {
case .faceID:
return "Face ID"
case .touchID:
return "Touch ID"
case .opticID:
return "Optic ID"
default:
return "Biometrics"
}
}
// MARK: - Encoding (same as SessionStorage)
private func encodeSession(_ session: ExportedSession) throws -> Data {
let dict: [String: Any] = [
"version": session.version,
"data": session.data
]
return try JSONSerialization.data(withJSONObject: dict)
}
private func decodeSession(_ data: Data) throws -> ExportedSession {
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw KeychainError.invalidData
}
return ExportedSession(
version: dict["version"] as? UInt32 ?? 1,
data: dict["data"] as? String ?? ""
)
}
}
```
## Usage in App
Integrate with your SDK manager:
```swift theme={null}
actor SDKManager {
// ... existing code ...
func restoreSession() async throws -> Bool {
// Try biometric first
if BiometricSessionStorage.biometricsAvailable {
do {
let session = try await BiometricSessionStorage.shared.loadSession()
return try sdk.importSession(session)
} catch {
// Fall through to regular storage
}
}
// Try regular storage
if SessionStorage.shared.hasSession() {
let session = try SessionStorage.shared.loadSession()
return try sdk.importSession(session)
}
return false
}
func saveCurrentSession(withBiometrics: Bool = false) throws {
guard let session = sdk.exportSession() else {
return
}
if withBiometrics && BiometricSessionStorage.biometricsAvailable {
try BiometricSessionStorage.shared.saveSession(session)
} else {
try SessionStorage.shared.saveSession(session)
}
}
func clearSession() throws {
try SessionStorage.shared.deleteSession()
}
}
```
## Auto-lock
Persisting a session keeps the encrypted vault on disk; auto-lock bounds how long the decrypted keys stay in memory. Two optional `PrivacyBoostConfig` fields drive it, both in seconds:
* `idleTtlSecs`: how long the keys may go unused before the SDK wipes them.
* `absoluteTtlSecs`: how long after an unlock the SDK wipes them, regardless of activity.
`nil` or `0` disables the respective timer, and both default to `nil`. The timers are checked lazily, on the next key access: once one has elapsed, that access zeroizes the resident keys and throws `SDKError.notAuthenticated`. They bound key use rather than key residency, so a screen that needs the keys gone at a fixed time should call `lock()` from its own timer.
```swift theme={null}
let config = PrivacyBoostConfig(
serverUrl: "https://indexer.example.com",
chainId: nil,
shieldContractAddress: nil,
wethContractAddress: nil,
teePublicKey: nil,
appId: "my-app",
persistenceStorage: .osKeychain,
persistenceUnlock: .biometric,
idleTtlSecs: 300, // wipe keys after 5 minutes without use
absoluteTtlSecs: 43_200 // and 12 hours after unlock, no matter what
)
```
`lock()` does the same on demand. It zeroizes the key material and drops the session JWT but keeps the persisted vault, which makes it the right call when the app moves to the background:
```swift theme={null}
func sceneDidEnterBackground(_ scene: UIScene) {
sdk.lock()
}
```
To unlock, call `authenticate` again. With a persisted vault the SDK reopens it instead of re-deriving keys: PIN and password vaults return `.credentialRequired`, and the other unlock methods complete inside `authenticate`. Compare `clearSession()`, which drops the JWT but leaves the keys in memory for a quick re-auth, and `logout()`, which ends the session entirely.
Chain-context handles created with `createChainContext` share the resident keys, so auto-lock and `lock()` apply to them too. A lock also ends each handle's own session, so call `authenticate` on the handle again after unlocking. `clearSession()` on a handle drops its session without locking the account.
## Security Best Practices
1. **Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`** - Data only accessible when device is unlocked
2. **Enable biometrics** - Add Face ID/Touch ID for sensitive operations
3. **Don't store in UserDefaults** - UserDefaults is not encrypted
4. **Clear on logout** - Delete Keychain items when user logs out
5. **Handle errors gracefully** - Don't expose Keychain errors to users
## Info.plist
Add Face ID usage description:
```xml theme={null}
NSFaceIDUsageDescription
We use Face ID to secure your Privacy Boost session
```
## Next Steps
* [API Reference](../api-reference) - Complete API documentation
* [iOS Getting Started](../getting-started) - Integrate with SwiftUI
# Transactions
Source: https://docs.privacyboost.io/sdk/ios/guides/transactions
# Transaction History
This guide covers querying and filtering transaction history in the Privacy Boost iOS SDK.
## Basic Usage
```swift theme={null}
do {
let transactions = try await sdk.getTransactionHistory(
txType: nil,
tokenAddress: nil,
limit: nil
)
for tx in transactions {
print("\(tx.txType): \(tx.amount) - \(tx.txHash)")
}
} catch {
print("Failed to get history: \(error)")
}
```
## Transaction Type
```swift theme={null}
struct Transaction {
let txHash: String // Transaction hash
let txType: String // "deposit", "withdraw", or "transfer"
let tokenAddress: String // Token contract address
let amount: String // Amount in wei
let direction: String // "incoming" or "outgoing"
let senderPubKey: String // Sender's public key
let receiverPubKeys: [String] // Receiver public keys
let createdAt: UInt64 // Unix timestamp
}
```
## Filtering History
### By Transaction Type
```swift theme={null}
// Only deposits
let deposits = try await sdk.getTransactionHistory(
txType: "deposit",
tokenAddress: nil,
limit: nil
)
// Only withdrawals
let withdrawals = try await sdk.getTransactionHistory(
txType: "withdraw",
tokenAddress: nil,
limit: nil
)
// Only transfers
let transfers = try await sdk.getTransactionHistory(
txType: "transfer",
tokenAddress: nil,
limit: nil
)
```
### By Token
```swift theme={null}
let usdcHistory = try await sdk.getTransactionHistory(
txType: nil,
tokenAddress: "0x...usdc-address",
limit: nil
)
```
### With Limit
```swift theme={null}
// Get last 10 transactions
let recent = try await sdk.getTransactionHistory(
txType: nil,
tokenAddress: nil,
limit: 10
)
```
### Combined Filters
```swift theme={null}
// Last 5 USDC deposits
let recentUsdcDeposits = try await sdk.getTransactionHistory(
txType: "deposit",
tokenAddress: "0x...usdc-address",
limit: 5
)
```
### By Token ID, Direction, or Collapsed Gateway Rows
For the filters `getTransactionHistory` does not take, pass a `TransactionHistoryQuery` to `queryTransactionHistory`. It accepts the same type filter and page size plus `tokenId` (the registered token id, from `getRegisteredTokens()`), `direction` (`.incoming` or `.outgoing` relative to the wallet), a `cursor` for the next page, and `collapseGateway`, which folds each Gateway operation's two rows into one so an activity list can show a swap as a single entry. `nil` fields are unfiltered, and every field is defaulted, so name only the ones you want.
```swift theme={null}
// Incoming USDC only, 25 per page, gateway operations collapsed
let page = try await sdk.queryTransactionHistory(query: TransactionHistoryQuery(
tokenId: 1,
direction: .incoming,
limit: 25,
collapseGateway: true
))
for tx in page.data {
print("\(tx.txType): \(tx.txHash)")
}
// page.next is the cursor for the following page; nil at the end
```
`ChainContextHandle` exposes the same method for other chains.
## Filter Parameters
| Parameter | Type | Description |
| - | - | - |
| `txType` | `String?` | Filter by type: `"deposit"`, `"withdraw"`, `"transfer"` |
| `tokenAddress` | `String?` | Filter by token contract address |
| `limit` | `UInt32?` | Maximum number of results |
`TransactionHistoryQuery` adds:
| Field | Type | Description |
| - | - | - |
| `tokenId` | `UInt16?` | Filter by registered token id |
| `direction` | `TransactionDirection?` | `.incoming` or `.outgoing` |
| `cursor` | `String?` | Continuation token from the previous page's `next` |
| `collapseGateway` | `Bool` | Fold each Gateway operation's two rows into one (defaults to `false`) |
## Displaying Transactions in SwiftUI
```swift theme={null}
struct TransactionHistoryView: View {
@State private var transactions: [Transaction] = []
@State private var isLoading = true
@State private var selectedFilter = "all"
let filters = ["all", "deposit", "withdraw", "transfer"]
var body: some View {
VStack {
// Filter picker
Picker("Filter", selection: $selectedFilter) {
ForEach(filters, id: \.self) { filter in
Text(filter.capitalized).tag(filter)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
// Transaction list
List {
if isLoading {
ProgressView("Loading transactions...")
} else if transactions.isEmpty {
Text("No transactions found")
.foregroundColor(.secondary)
} else {
ForEach(transactions, id: \.txHash) { tx in
TransactionRow(transaction: tx)
}
}
}
}
.task { await loadTransactions() }
.onChange(of: selectedFilter) { _ in
Task { await loadTransactions() }
}
.refreshable {
await loadTransactions()
}
}
private func loadTransactions() async {
isLoading = true
do {
let typeFilter = selectedFilter == "all" ? nil : selectedFilter
transactions = try await sdk.getTransactionHistory(
txType: typeFilter,
tokenAddress: nil,
limit: 50
)
} catch {
print("Failed to load transactions: \(error)")
}
isLoading = false
}
}
struct TransactionRow: View {
let transaction: Transaction
var icon: String {
switch transaction.txType {
case "deposit": return "arrow.down.circle.fill"
case "withdraw": return "arrow.up.circle.fill"
case "transfer": return "arrow.left.arrow.right.circle.fill"
default: return "circle.fill"
}
}
var formattedDate: String {
let date = Date(timeIntervalSince1970: TimeInterval(transaction.createdAt))
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: date)
}
var body: some View {
HStack {
Image(systemName: icon)
.foregroundColor(transaction.txType == "deposit" ? .green : .blue)
VStack(alignment: .leading) {
Text(transaction.txType.capitalized)
.font(.headline)
Text(formattedDate)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(transaction.amount)
.font(.subheadline)
.monospacedDigit()
}
}
}
```
## Error Handling
```swift theme={null}
do {
let transactions = try await sdk.getTransactionHistory(
txType: nil,
tokenAddress: nil,
limit: nil
)
} catch SDKError.notAuthenticated {
print("Please log in first")
} catch SDKError.networkError(let message) {
print("Network error: \(message)")
} catch {
print("Failed to get history: \(error)")
}
```
## Best Practices
### 1. Paginate Results
For apps with many transactions, use the `limit` parameter to load in pages:
```swift theme={null}
func loadPage(offset: Int, pageSize: UInt32 = 20) async throws -> [Transaction] {
return try await sdk.getTransactionHistory(
txType: nil,
tokenAddress: nil,
limit: pageSize
)
}
```
### 2. Refresh After Operations
Fetch updated history after deposits, withdrawals, or transfers to show the latest state:
```swift theme={null}
let shieldResult = try await sdk.shield(
tokenAddress: tokenAddress,
amount: amount
)
// Refresh transaction list
let updatedHistory = try await sdk.getTransactionHistory(
txType: nil,
tokenAddress: nil,
limit: 20
)
```
## Next Steps
* [Balances Guide](./balances)
* [Deposits Guide](./deposits)
* [Error Handling](./error-handling)
* [API Reference](/sdk/ios/api-reference)
# Transfers
Source: https://docs.privacyboost.io/sdk/ios/guides/transfers
# Private Transfers
This guide covers sending tokens privately between privacy addresses.
## Basic Transfer
```swift theme={null}
do {
let result = try await sdk.send(
tokenAddress: "0x...token-address",
amount: "1000000000000000000", // 1 token
recipientPrivacyAddress: "0x04..." // 194-char privacy address
)
print("Transaction hash: \(result.txHash)")
} catch {
print("Transfer failed: \(error)")
}
```
## Transfer Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | Token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
| `recipientPrivacyAddress` | `String` | Yes | Recipient's 194-character privacy address |
## Transfer Result
```swift theme={null}
struct TransferResult {
let txHash: String // Transaction hash
let fee: String // Fee paid (in wei)
}
```
## Paying Several Recipients at Once
`sendBatch` pays every recipient from one set of input notes, in a single
request: one fee instead of one per recipient, and one epoch slot instead of
several. Output order follows the list, and the same recipient may appear more
than once.
```swift theme={null}
do {
let result = try await sdk.sendBatch(
tokenAddress: "0x...token-address",
recipients: [
TransferRecipient(recipient: "0x04...alice", amount: "400000000000000000"),
TransferRecipient(recipient: "0x04...bob", amount: "500000000000000000"),
]
)
print("Transaction hash: \(result.txHash)")
} catch {
print("Batch transfer failed: \(error)")
}
```
### How many recipients fit
The server proves a fixed set of `(inputs, outputs)` shapes, and a transfer has
to fit inside one of them. Its output count is your recipient count plus a
change note, so a longer recipient list leaves room for fewer input notes:
| Outputs (recipients + change) | Input notes that can be spent |
| - | - |
| 1-2 | up to 15 |
| 3-11 | up to 4 |
So 5 inputs by 3 outputs is not provable, even though 15 inputs and 11 outputs
are each individually fine. In practice: past two recipients, the transfer has
to be coverable by at most four notes. Consolidate fragmented notes first if a
batch is rejected with `UNSUPPORTED_TRANSFER_SHAPE`, or split the payout across
two batches.
## Privacy Address Validation
Always validate privacy addresses before sending:
```swift theme={null}
let recipientAddress = "0x04..."
guard sdk.isValidPrivacyAddress(recipientAddress) else {
print("Invalid privacy address")
return
}
let result = try await sdk.send(
tokenAddress: "0x...",
amount: "1000000000000000000",
recipientPrivacyAddress: recipientAddress
)
```
## Looking Up Privacy Addresses
If you have a user's MPK or Ethereum address, you can look up their privacy address:
```swift theme={null}
do {
let identity = try await sdk.resolveIdentity(identifier: "0x...ethereum-address")
print("Privacy address: \(identity.privacyAddress)")
// Now send to their privacy address
let result = try await sdk.send(
tokenAddress: "0x...",
amount: "1000000000000000000",
recipientPrivacyAddress: identity.privacyAddress
)
} catch {
print("Address lookup failed: \(error)")
}
```
## Checking Balance Before Transfer
```swift theme={null}
func safeTransfer(
tokenAddress: String,
amount: String,
recipient: String
) async throws -> TransferResult {
let balance = try await sdk.getBalance(tokenAddress: tokenAddress)
guard let shielded = UInt64(balance.shieldedBalance),
let requested = UInt64(amount),
requested <= shielded else {
throw SDKError.insufficientBalance(message: "Insufficient shielded balance")
}
return try await sdk.send(
tokenAddress: tokenAddress,
amount: amount,
recipientPrivacyAddress: recipient
)
}
```
## Transfer Privacy
Private transfers provide:
* **Sender Privacy**: Your wallet address is not linked to the transaction
* **Recipient Privacy**: The recipient's wallet address is not revealed
* **Amount Privacy**: The transfer amount is encrypted
* **Relationship Privacy**: No public link between sender and recipient
Only the sender and recipient can see the transfer details.
## Error Handling
```swift theme={null}
do {
let result = try await sdk.send(
tokenAddress: tokenAddress,
amount: amount,
recipientPrivacyAddress: recipient
)
} catch SDKError.insufficientBalance {
print("Not enough shielded balance")
} catch SDKError.invalidAddress {
print("Invalid privacy address")
} catch SDKError.invalidAmount {
print("Invalid amount")
} catch SDKError.walletError(let message) {
print("Wallet error: \(message)")
} catch SDKError.networkError(let message) {
print("Network error: \(message)")
} catch SDKError.transferError(let code, let message) {
print("Transfer error [\(code)]: \(message)")
} catch {
print("Transfer error: \(error)")
}
```
## Best Practices
### 1. Validate All Inputs
```swift theme={null}
func validateTransfer(
tokenAddress: String,
amount: String,
recipient: String
) throws {
guard sdk.isValidAddress(tokenAddress) else {
throw SDKError.invalidAddress
}
guard sdk.isValidPrivacyAddress(recipient) else {
throw SDKError.invalidAddress
}
guard let value = UInt64(amount), value > 0 else {
throw SDKError.invalidAmount
}
}
```
### 2. Confirm Large Transfers
```swift theme={null}
func transferWithConfirmation(
tokenAddress: String,
amount: String,
recipient: String,
onConfirm: (String) async -> Bool
) async throws -> TransferResult {
let formatted = try sdk.formatAmount(amount, decimals: 18)
guard await onConfirm("Send \(formatted) tokens to \(recipient.prefix(10))...?") else {
throw SDKError.signatureRejected
}
return try await sdk.send(
tokenAddress: tokenAddress,
amount: amount,
recipientPrivacyAddress: recipient
)
}
```
### 3. Handle Network Delays
```swift theme={null}
func transferWithRetry(
tokenAddress: String,
amount: String,
recipient: String,
maxRetries: Int = 3
) async throws -> TransferResult {
for attempt in 1...maxRetries {
do {
return try await sdk.send(
tokenAddress: tokenAddress,
amount: amount,
recipientPrivacyAddress: recipient
)
} catch SDKError.networkError(_) where attempt < maxRetries {
try await Task.sleep(nanoseconds: UInt64(2_000_000_000 * attempt))
continue
}
}
throw SDKError.networkError("Max retries exceeded")
}
```
## Next Steps
* [Deposits Guide](./deposits)
* [Withdrawals Guide](./withdrawals)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Wallet integration
Source: https://docs.privacyboost.io/sdk/ios/guides/wallet-integration
# Wallet Integration
The `WalletDelegate` protocol is the bridge between the Privacy Boost SDK and your wallet. This guide covers implementing it for various wallet types, expanding on the brief examples in [Authentication](/sdk/concepts/authentication).
## Protocol Definition
```swift theme={null}
public protocol WalletDelegate: AnyObject {
/// Get the connected wallet address
func getAddress() async throws -> String
/// Get the current chain ID
func getChainId() async throws -> UInt64
/// Sign a personal message (EIP-191)
func signMessage(message: String) async throws -> String
/// Sign EIP-712 typed data
func signTypedData(typedDataJson: String) async throws -> String
/// Send a transaction
func sendTransaction(toAddress: String, value: String, data: String) async throws -> String
/// Wait until a transaction is mined and return its receipt
func waitForTransactionReceipt(txHash: String) async throws -> TransactionReceipt
}
```
## Implementation Requirements
### Return Values
* **Addresses**: Checksummed hex strings with `0x` prefix (42 characters)
* **Signatures**: Hex strings with `0x` prefix (132 characters for 65 bytes)
* **Transaction hashes**: Hex strings with `0x` prefix (66 characters)
* **Transaction receipts**: Mined receipt status and logs from `eth_getTransactionReceipt`
* **Chain ID**: Integer matching expected network
### Async Behavior
All WalletDelegate methods are `async throws`. The SDK awaits each call, so your implementation can use native Swift concurrency (async/await).
The SDK calls `waitForTransactionReceipt` after `sendTransaction` to confirm on-chain inclusion and read emitted events.
## WalletConnect Integration
Example using WalletConnect Swift SDK:
```swift theme={null}
import WalletConnectSwift
class WalletConnectDelegate: WalletDelegate {
private let session: Session
private let client: Client
init(session: Session, client: Client) {
self.session = session
self.client = client
}
func getAddress() async throws -> String {
guard let account = session.walletInfo?.accounts.first else {
throw WalletError.notConnected
}
return account
}
func getChainId() async throws -> UInt64 {
guard let chainId = session.walletInfo?.chainId else {
throw WalletError.notConnected
}
return UInt64(chainId)
}
func signMessage(message: String) async throws -> String {
let semaphore = DispatchSemaphore(value: 0)
var result: Result?
let address = try await getAddress()
try client.personal_sign(
url: session.url,
message: message,
account: address
) { response in
switch response {
case .success(let signature):
result = .success(signature)
case .failure(let error):
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
switch result {
case .success(let signature):
return signature
case .failure(let error):
throw error
case .none:
throw WalletError.timeout
}
}
func signTypedData(typedDataJson: String) async throws -> String {
let semaphore = DispatchSemaphore(value: 0)
var result: Result?
let address = try await getAddress()
try client.eth_signTypedData(
url: session.url,
account: address,
message: typedDataJson
) { response in
switch response {
case .success(let signature):
result = .success(signature)
case .failure(let error):
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
switch result {
case .success(let signature):
return signature
case .failure(let error):
throw error
case .none:
throw WalletError.timeout
}
}
func sendTransaction(toAddress: String, value: String, data: String) async throws -> String {
let semaphore = DispatchSemaphore(value: 0)
var result: Result?
let address = try await getAddress()
let transaction = Client.Transaction(
from: address,
to: toAddress,
data: data,
gas: nil,
gasPrice: nil,
value: value,
nonce: nil
)
try client.eth_sendTransaction(
url: session.url,
transaction: transaction
) { response in
switch response {
case .success(let txHash):
result = .success(txHash)
case .failure(let error):
result = .failure(error)
}
semaphore.signal()
}
semaphore.wait()
switch result {
case .success(let txHash):
return txHash
case .failure(let error):
throw error
case .none:
throw WalletError.timeout
}
}
func waitForTransactionReceipt(txHash: String) async throws -> TransactionReceipt {
// Poll eth_getTransactionReceipt via your RPC client until it returns a receipt.
return try await pollReceipt(txHash: txHash)
}
}
enum WalletError: Error {
case notConnected
case timeout
case userRejected
}
```
## Web3.swift Integration
Example using web3.swift with a local keystore:
```swift theme={null}
import web3swift
class Web3Delegate: WalletDelegate {
private let web3: web3
private let keystoreManager: KeystoreManager
private let address: EthereumAddress
init(web3: web3, keystoreManager: KeystoreManager, address: EthereumAddress) {
self.web3 = web3
self.keystoreManager = keystoreManager
self.address = address
}
func getAddress() async throws -> String {
return address.address
}
func getChainId() async throws -> UInt64 {
guard let chainId = web3.provider.network?.chainID else {
throw Web3Error.connectionError
}
return UInt64(chainId)
}
func signMessage(message: String) async throws -> String {
let messageData = message.data(using: .utf8)!
let signature = try web3.wallet.signPersonalMessage(
messageData,
account: address,
password: "" // Or prompt user
)
return signature.toHexString().addHexPrefix()
}
func signTypedData(typedDataJson: String) async throws -> String {
// Parse typed data and sign
let data = typedDataJson.data(using: .utf8)!
let typedData = try JSONDecoder().decode(EIP712TypedData.self, from: data)
let signature = try web3.wallet.signTypedData(
typedData,
account: address,
password: ""
)
return signature.toHexString().addHexPrefix()
}
func sendTransaction(toAddress: String, value: String, data: String) async throws -> String {
var transaction = EthereumTransaction(
to: EthereumAddress(toAddress)!,
value: BigUInt(value)!,
data: Data(hex: data)
)
let result = try web3.eth.sendTransaction(transaction)
return result.hash
}
func waitForTransactionReceipt(txHash: String) async throws -> TransactionReceipt {
// Poll eth_getTransactionReceipt via your web3 provider until it returns a receipt.
return try await pollReceipt(txHash: txHash)
}
}
```
## Testing with Mock Wallet
For testing, create a mock delegate:
```swift theme={null}
class MockWalletDelegate: WalletDelegate {
let mockAddress = "0x1234567890123456789012345678901234567890"
let mockChainId: UInt64 = 1
var signMessageHandler: ((String) -> String)?
var signTypedDataHandler: ((String) -> String)?
var sendTransactionHandler: ((String, String, String) -> String)?
func getAddress() async throws -> String {
return mockAddress
}
func getChainId() async throws -> UInt64 {
return mockChainId
}
func signMessage(message: String) async throws -> String {
if let handler = signMessageHandler {
return handler(message)
}
// Return a valid-looking signature
return "0x" + String(repeating: "ab", count: 65)
}
func signTypedData(typedDataJson: String) async throws -> String {
if let handler = signTypedDataHandler {
return handler(typedDataJson)
}
return "0x" + String(repeating: "cd", count: 65)
}
func sendTransaction(toAddress: String, value: String, data: String) async throws -> String {
if let handler = sendTransactionHandler {
return handler(toAddress, value, data)
}
return "0x" + String(repeating: "ef", count: 32)
}
func waitForTransactionReceipt(txHash: String) async throws -> TransactionReceipt {
return TransactionReceipt(txHash: txHash, status: true, logs: [])
}
}
```
## Error Handling
Throw appropriate errors from delegate methods:
```swift theme={null}
enum WalletDelegateError: Error, LocalizedError {
case notConnected
case userRejected
case networkError(String)
case invalidSignature
case transactionFailed(String)
var errorDescription: String? {
switch self {
case .notConnected:
return "Wallet not connected"
case .userRejected:
return "User rejected the request"
case .networkError(let message):
return "Network error: \(message)"
case .invalidSignature:
return "Invalid signature returned"
case .transactionFailed(let message):
return "Transaction failed: \(message)"
}
}
}
```
## Best Practices
1. **Validate inputs** - Check addresses and data formats before processing
2. **Handle timeouts** - Set reasonable timeouts for wallet operations
3. **User feedback** - Show loading indicators during signing/transactions
4. **Error messages** - Provide clear error messages for user-facing errors
5. **Thread safety** - Ensure delegate methods can be called from any thread
## Next Steps
* [Getting Started](../getting-started) - iOS SDK overview
# Withdrawals
Source: https://docs.privacyboost.io/sdk/ios/guides/withdrawals
# Withdrawals
This guide covers withdrawing tokens from your private balance to any public Ethereum address.
## Basic Withdrawal
```swift theme={null}
do {
let result = try await sdk.unshield(
tokenAddress: "0x...token-address",
amount: "1000000000000000000", // 1 token
recipient: "0x...recipient-address"
)
print("Transaction hash: \(result.txHash)")
} catch {
print("Withdrawal failed: \(error)")
}
```
## Withdrawal Parameters
| Parameter | Type | Required | Description |
| - | - | - | - |
| `tokenAddress` | `String` | Yes | Token contract address |
| `amount` | `String` | Yes | Amount in wei (smallest unit) |
| `recipient` | `String` | Yes | Destination Ethereum address |
## Withdrawal Steps
Withdrawals involve zero-knowledge proof generation, which can take up to 30 seconds:
| Step | Description |
| - | - |
| Preparing | Preparing withdrawal data |
| Signing | Signing transaction data |
| Proving | Generating zero-knowledge proof |
| Unshielding | Executing withdrawal transaction |
| Unwrapping | Unwrapping WETH to ETH (if applicable) |
## Withdrawal Result
```swift theme={null}
struct UnshieldResult {
let txHash: String // Main withdrawal transaction hash
let fee: String // Fee paid (in wei)
}
```
## Withdraw to Self
Withdraw to your connected wallet address:
```swift theme={null}
let myAddress = sdk.getWalletAddress()!
let result = try await sdk.unshield(
tokenAddress: "0x...",
amount: "1000000000000000000",
recipient: myAddress
)
```
## Withdraw to Any Address
You can withdraw to any valid Ethereum address:
```swift theme={null}
let recipientAddress = "0x..."
// Validate address first
guard sdk.isValidAddress(recipientAddress) else {
print("Invalid recipient address")
return
}
let result = try await sdk.unshield(
tokenAddress: "0x...",
amount: "1000000000000000000",
recipient: recipientAddress
)
```
## Partial Withdrawals
Withdraw any amount up to your shielded balance:
```swift theme={null}
// Check available balance first
let balance = try await sdk.getBalance(tokenAddress: tokenAddress)
guard let shielded = UInt64(balance.shieldedBalance),
let unshieldAmount = UInt64(amount),
unshieldAmount <= shielded else {
print("Insufficient shielded balance")
return
}
let result = try await sdk.unshield(
tokenAddress: tokenAddress,
amount: amount,
recipient: recipientAddress
)
```
## Error Handling
```swift theme={null}
do {
let result = try await sdk.unshield(
tokenAddress: tokenAddress,
amount: amount,
recipient: recipient
)
} catch SDKError.insufficientBalance {
print("Not enough shielded balance")
} catch SDKError.invalidAddress {
print("Invalid recipient address")
} catch SDKError.invalidAmount {
print("Invalid amount format")
} catch SDKError.walletError(let message) {
print("Wallet error: \(message)")
} catch SDKError.networkError(let message) {
print("Network error: \(message)")
} catch {
print("Withdrawal error: \(error)")
}
```
## Best Practices
### 1. Validate Balance Before Withdrawal
```swift theme={null}
func validateWithdrawal(tokenAddress: String, amount: String) async throws {
let balance = try await sdk.getBalance(tokenAddress: tokenAddress)
guard let shielded = UInt64(balance.shieldedBalance),
let requested = UInt64(amount),
requested <= shielded else {
throw SDKError.insufficientBalance(message: "Insufficient shielded balance")
}
}
```
### 2. Validate Recipient Address
```swift theme={null}
func validateRecipient(_ address: String) throws {
guard sdk.isValidAddress(address) else {
throw SDKError.invalidAddress
}
guard address != "0x0000000000000000000000000000000000000000" else {
throw SDKError.invalidAddress
}
}
```
### 3. Handle Long Operations
Withdrawals include zero-knowledge proof generation which can take up to 30 seconds. Show a loading indicator and inform users about the expected wait time.
```swift theme={null}
// Show estimated time in your UI
statusLabel.text = "Generating proof... This may take up to 30 seconds."
let result = try await sdk.unshield(
tokenAddress: tokenAddress,
amount: amount,
recipient: recipient
)
statusLabel.text = "Withdrawal complete!"
```
## Next Steps
* [Deposits Guide](./deposits)
* [Transfers Guide](./transfers)
* [Balances Guide](./balances)
* [Error Handling](./error-handling)
# Installation
Source: https://docs.privacyboost.io/sdk/ios/installation
# Installation
This guide covers installing the Privacy Boost iOS SDK in your project.
## Requirements
* iOS 13.0+
* Xcode 14+
* Swift 5.7+
## Swift Package Manager
### Using Xcode
1. Open your project in Xcode
2. Go to **File > Add Package Dependencies...**
3. Enter the repository URL:
```
https://github.com/sunnyside-io/privacy-boost-sdk.git
```
4. Select version requirements (e.g., "Up to Next Major Version" from `0.1.0`)
5. Click **Add Package**
6. Select your target and click **Add Package**
### Using Package.swift
Add the dependency to your `Package.swift`:
```swift theme={null}
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "YourApp",
platforms: [
.iOS(.v13)
],
dependencies: [
.package(
url: "https://github.com/sunnyside-io/privacy-boost-sdk.git",
from: "0.1.0"
)
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "PrivacyBoost", package: "privacy-boost-ios")
]
)
]
)
```
## Verifying Installation
After installation, verify the SDK is working:
```swift theme={null}
import PrivacyBoost
// Create a test configuration
let config = PrivacyBoostConfig(
serverUrl: "https://optimism.sepolia.privacyboost.io",
wethContractAddress: "0x4200000000000000000000000000000000000006",
appId: "app_abc123xyz"
)
// Initialize SDK
do {
let sdk = try PrivacyBoost(config: config)
print("SDK initialized successfully")
} catch {
print("SDK initialization failed: \(error)")
}
```
## Next Steps
* [Getting Started](./getting-started) - Initialize and use the SDK
# iOS SDK
Source: https://docs.privacyboost.io/sdk/ios/overview
Native Swift SDK for privacy-preserving transactions on iOS
# iOS SDK
The Privacy Boost iOS SDK is a native Swift framework that wraps the Rust core via UniFFI. It exposes idiomatic Swift APIs (`async throws`, structs, enums with associated values) for shielding, unshielding, and private transfers.
## Features
* Native Swift framework — no JavaScript bridge, no WebView
* `async`/`await` everywhere — works with Swift Concurrency and SwiftUI
* Strongly-typed `SDKError` enum with associated values for pattern matching
* Optional default delegate implementations for Keychain and Passkey
* Wallet-agnostic — implement `WalletDelegate` once for any signer
* Identical core to the TypeScript / Android / React Native SDKs (same proofs, same network protocol)
## Installation
Add the SwiftPM package to your project. See the [installation guide](./installation) for full details.
```swift theme={null}
// Package.swift
.package(url: "https://github.com/sunnyside-io/privacy-boost-ios", from: "0.2.0"),
```
Then import:
```swift theme={null}
import PrivacyBoost
import PrivacyBoostDefaults // optional delegate helpers
```
## Quick Example
```swift theme={null}
import PrivacyBoost
let config = PrivacyBoostConfig(
serverUrl: "https://optimism.sepolia.privacyboost.io",
chainId: nil, // discovered from server
shieldContractAddress: nil, // discovered from server
wethContractAddress: "0x4200000000000000000000000000000000000006",
teePublicKey: nil,
appId: "app_abc123xyz",
persistenceStorage: nil,
persistenceUnlock: nil
)
let sdk = try PrivacyBoost(config: config)
// Authenticate with a WalletDelegate you implement
let result = try await sdk.authenticate(
wallet: myWalletDelegate,
keySource: .walletDerived,
tokenProvider: nil
)
switch result {
case .authenticated(let login):
print("Privacy address:", login.privacyAddress)
case .credentialRequired(let challenge):
// Prompt user for PIN/biometric, then call sdk.submitCredential(...)
break
case .mnemonicGenerated(let mnemonic):
// First-time user — display mnemonic, then call sdk.proceedAfterMnemonic(...)
break
case .recoveryRequired:
// Vault is in a recovery state — see Authentication concepts
break
case .accountCreationRequired(let challenge):
// Submit challenge.createAccountTransaction, wait for indexing, then retry.
break
}
// Shield 1 ETH-equivalent of WETH
let amount = try sdk.parseAmount("1.0", decimals: 18)
let shielded = try await sdk.shield(
tokenAddress: "0x4200000000000000000000000000000000000006",
amount: amount
)
print("Shield tx:", shielded.txHash)
```
## Core Operations
| Method | Purpose |
| - | - |
| `sdk.shield(tokenAddress:amount:)` | Deposit tokens into the shielded pool |
| `sdk.unshield(tokenAddress:amount:recipient:)` | Withdraw to a public address |
| `sdk.send(tokenAddress:amount:recipientPrivacyAddress:)` | Private transfer |
| `sdk.getBalance(tokenAddress:)` | Single-token shielded + wallet balance |
| `sdk.getAllBalances()` | All tracked balances |
| `sdk.getTransactionHistory(...)` | Paginated history |
| `sdk.resolveIdentity(identifier:)` | Look up a privacy address |
## Documentation
### Getting Started
* [Installation](./installation)
* [Getting Started](./getting-started)
### Guides
* [Deposits](./guides/deposits)
* [Withdrawals](./guides/withdrawals)
* [Transfers](./guides/transfers)
* [Balances](./guides/balances)
* [Transactions](./guides/transactions)
* [Multi-Chain](./guides/multi-chain)
* [Wallet Integration](./guides/wallet-integration)
* [Session Storage](./guides/session-storage)
* [Error Handling](./guides/error-handling)
### Advanced
* [Performance](./advanced/performance)
* [Error Handling Patterns](./advanced/error-handling)
### Reference
* [API Reference](./api-reference)
## Requirements
* iOS 14+ (iOS 16+ required for Passkey delegate helpers)
* Swift 5.7+
* Xcode 14+
## Related
* [Multi-Chain Concepts](/sdk/concepts/multi-chain) — architecture overview
* [Authentication Concepts](/sdk/concepts/authentication) — auth flow details
* [Quickstart](/sdk/quickstart/ios) — 5-minute integration
# Privacy Boost SDK
Source: https://docs.privacyboost.io/sdk/overview
Privacy-preserving transaction SDK for EVM-compatible blockchains
# Privacy Boost SDK
Privacy Boost is a privacy-preserving transaction SDK that enables confidential token transfers on EVM-compatible blockchains. Build private payment flows across web, mobile, and CLI with a unified API.
## How It Works
Privacy Boost uses a **shielded pool** — a smart contract that holds deposited tokens using encrypted commitments — combined with **zero-knowledge proofs** to enable private transactions in three steps:
1. **Deposit** — Move tokens from your wallet into the shielded pool
2. **Send** — Transfer tokens privately to any privacy address (fully hidden from observers)
3. **Withdraw** — Move tokens back to any public address
Once tokens are in the shielded pool, transfers are completely private: no one can see the sender, recipient, or amount. [Learn more about the privacy model.](/product/overview)
## Core Concepts
Before diving in, here are the key terms you'll encounter throughout the SDK:
| Concept | What it means |
| - | - |
| **Shielded pool** | The on-chain smart contract that holds deposited tokens. Once tokens are in the pool, all transfers are private. |
| **Shielded balance** | Your private token balance inside the shielded pool — only visible to you. |
| **Privacy address** | A public address others use to send you private transfers. Derived from your wallet, similar to sharing a bank account number. Not the same as your Ethereum address. |
| **Vault** | The SDK module (`sdk.vault`) for all shielded pool operations — deposit, send, unshield, and balance queries. |
| **Wallet adapter** | A platform-specific object that bridges the SDK and your wallet. It provides signing functions (`signMessage`, `signTypedData`, `sendTransaction`). Each platform implements this interface differently. |
| **Chain client** | A chain-scoped client created via `sdk.forChain()` for [multi-chain](/sdk/concepts/multi-chain) operations. Each chain client has its own auth state and balances but shares identity keys with the parent SDK. |
For protocol-level details (UTXO model, notes, nullifiers, Merkle trees), see the [Protocol Deep Dive](/technical-explainer/protocol). For key hierarchy and encryption, see [Keys & Encryption](/technical-explainer/keys-and-encryption). For a full glossary, see [Glossary](/technical-explainer/glossary).
## Quick Example
```typescript TypeScript theme={null}
import { PrivacyBoost } from '@sunnyside-io/privacy-boost';
import { createWalletAdapter } from '@sunnyside-io/privacy-boost-react';
// Initialize
const sdk = await PrivacyBoost.create({
serverUrl: 'https://optimism.sepolia.privacyboost.io',
wethContractAddress: '0x4200000000000000000000000000000000000006',
appId: 'app_abc123xyz',
});
// Sign in with a browser wallet (MetaMask, etc.): one signature, after which
// the account can be read. The keys stay locked until an action needs them.
const adapter = createWalletAdapter();
await sdk.auth.signIn(adapter);
// Deposit 1 token into the shielded pool
await sdk.vault.shield({
tokenAddress: '0x...token-address',
amount: 1000000000000000000n, // 1 token (18 decimals)
});
// Send 0.5 tokens privately
await sdk.vault.send({
to: '0x04...recipient-privacy-address',
tokenAddress: '0x...token-address',
amount: 500000000000000000n, // 0.5 tokens
});
```
```tsx React theme={null}
import { PrivacyBoostProvider, useAuth, useVault } from '@sunnyside-io/privacy-boost-react';
function App() {
return (
);
}
function WalletUI() {
const { signInWithWalletAdapter, status, privacyAddress } = useAuth();
const { shield } = useVault();
// 'locked' is signed in with the keys not resident — a returning user's
// normal state. Reads work; the first write unlocks. Gating on
// `isAuthenticated` here would show the sign-in button to a signed-in user.
if (status === 'disconnected' || status === 'connected') {
return Connect Wallet ;
}
return (
Privacy Address: {privacyAddress}
shield({ tokenAddress: '0x...', amount: '1.0' })}>
Deposit 1 Token
);
}
```
***
## Getting Started Path
Pick your platform below and follow the 5-minute quickstart to get a working integration.
Read [App Setup](/sdk/concepts/app-setup) to get an App ID, then [Configuration](/sdk/concepts/configuration) and [Authentication](/sdk/concepts/authentication) to understand the setup options.
Each platform has a full Getting Started guide and topic-specific guides (vault operations, session management, error handling).
Choose an [auth integration](/sdk/guides/api-secret) for your backend, configure [key persistence](/sdk/concepts/key-management), and add [error handling](/sdk/concepts/error-handling).
***
## Choose Your Platform
Full-featured SDK for web applications. Works in browser and Node.js.
React hooks and components for seamless integration with your React app.
Native Swift SDK with async/await support for iOS applications.
Native Kotlin SDK with coroutines for Android applications.
React Native SDK with native wallet integration.
Command-line tool for scripting, automation, and testing.
***
## Multi-Chain Support
Privacy Boost supports operating across multiple EVM-compatible blockchains from a single SDK instance. Create chain-scoped clients to deposit, transfer, and withdraw on any supported chain while sharing your cryptographic identity.
```typescript theme={null}
const ethereum = sdk.forChain({ serverUrl: 'https://eth.example.com' });
await ethereum.authenticate(walletAdapter);
await ethereum.vault.shield({ tokenAddress: '0x...', amount: 1000n });
```
See the [Multi-Chain guide](/sdk/concepts/multi-chain) for details.
***
## Learn More
Operate across multiple blockchains with a single SDK instance
UTXO model, notes, nullifiers, Merkle trees, and full transaction lifecycle
Key hierarchy, privacy addresses, and 3-way ECDH encryption
TEE guarantees, self-custody, forced withdrawal, and threat model
Technical terminology and definitions
# Android Quickstart
Source: https://docs.privacyboost.io/sdk/quickstart/android
Get started with Privacy Boost in Android/Kotlin in 5 minutes
# Android Quickstart
Integrate private token transfers into your Android app with the Kotlin SDK.
This quickstart gets you running in 5 minutes with minimal explanation. For detailed walkthroughs of each step, see the [Android Getting Started guide](/sdk/android/getting-started). For background on configuration and auth options, see [Setup](/sdk/concepts/app-setup).
## Installation
### Gradle
Add to your `build.gradle.kts`:
```kotlin theme={null}
dependencies {
implementation("io.privacy-boost:privacy-boost-android:1.0.0")
}
```
Or for Groovy (`build.gradle`):
```groovy theme={null}
dependencies {
implementation 'io.privacy-boost:privacy-boost-android:1.0.0'
}
```
## Quick Example
### 1. Create Wallet Delegate
```kotlin theme={null}
import com.privacyboost.sdk.*
class MyWalletDelegate(private val wallet: YourWalletImplementation) : WalletDelegate {
override suspend fun getAddress(): String {
return wallet.address
}
override suspend fun getChainId(): ULong {
return wallet.chainId.toULong()
}
override suspend fun signMessage(message: String): String {
return wallet.sign(message)
}
override suspend fun signTypedData(typedDataJson: String): String {
return wallet.signTypedData(typedDataJson)
}
override suspend fun sendTransaction(toAddress: String, value: String, data: String): String {
return wallet.sendTransaction(toAddress, value, data)
}
override suspend fun waitForTransactionReceipt(txHash: String): TransactionReceipt {
val receipt = wallet.waitForReceipt(txHash)
return TransactionReceipt(
txHash = txHash,
status = receipt.status,
logs = receipt.logs,
)
}
}
```
### 2. Initialize and Use SDK
```kotlin theme={null}
import com.privacyboost.sdk.*
import kotlinx.coroutines.launch
// 1. Create configuration — server discovers chainId / contracts when null.
val config = PrivacyBoostConfig(
serverUrl = "https://optimism.sepolia.privacyboost.io",
chainId = null,
shieldContractAddress = null,
wethContractAddress = "0x4200000000000000000000000000000000000006",
teePublicKey = null,
appId = "app_abc123xyz",
persistenceStorage = null,
persistenceUnlock = null,
)
// 2. Initialize SDK
val sdk = PrivacyBoost(config)
// 3. Authenticate — keySource defaults to WalletDerived when persistence is off.
lifecycleScope.launch {
val walletDelegate = MyWalletDelegate(wallet)
val authResult = sdk.authenticate(
wallet = walletDelegate,
keySource = null,
tokenProvider = null,
)
when (authResult) {
is AuthResult.Authenticated -> {
println("Privacy address: ${authResult.loginResult.privacyAddress}")
}
is AuthResult.CredentialRequired -> {
// Prompt the user for their PIN / password, then submit it.
val pin = promptUserForPin(authResult.challenge)
val loginResult = sdk.submitCredential(pin, tokenProvider = null)
println("Privacy address: ${loginResult.privacyAddress}")
}
is AuthResult.MnemonicGenerated -> {
// First-time user — show the mnemonic, confirm they saved it,
// then proceed.
confirmMnemonicSaved(authResult.mnemonic)
val loginResult = sdk.proceedAfterMnemonic(
credential = null,
tokenProvider = null,
)
println("Privacy address: ${loginResult.privacyAddress}")
}
is AuthResult.RecoveryRequired -> {
// Local keys are missing but an account exists — prompt for the
// recovery mnemonic and re-authenticate with KeySource.Mnemonic.
println("Recovery required: ${authResult.challenge}")
}
is AuthResult.AccountCreationRequired -> {
// Send authResult.challenge.createAccountTransaction, wait for
// AccountCreated indexing, then call authenticate() again.
wallet.sendTransaction(authResult.challenge.createAccountTransaction)
}
}
}
```
`MnemonicGenerated` fires for first-time users. `CredentialRequired` only fires when persistence with PIN / password / biometric unlock is configured. `RecoveryRequired` fires when the device has an account record but local keys are missing — prompt for the mnemonic and re-authenticate with `KeySource.Mnemonic(phrase)`.
```kotlin theme={null}
// 4. Check balance
val balance = sdk.getBalance("0x...")
println("Shielded: ${balance.shieldedBalance}")
// 5. Deposit
val shieldResult = sdk.shield(
tokenAddress = "0x...",
amount = "1000000000000000000" // 1 token (18 decimals)
)
println("Deposit tx: ${shieldResult.txHash}")
// 6. Transfer
val transferResult = sdk.send(
tokenAddress = "0x...",
amount = "500000000000000000", // 0.5 tokens
recipientPrivacyAddress = recipientPrivacyAddress
)
println("Transfer tx: ${transferResult.txHash}")
// 7. Withdraw
val unshieldResult = sdk.unshield(
tokenAddress = "0x...",
amount = "250000000000000000", // 0.25 tokens
recipient = "0x..."
)
println("Withdraw tx: ${unshieldResult.txHash}")
```
## Jetpack Compose Example
```kotlin theme={null}
import androidx.compose.runtime.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.privacyboost.sdk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class WalletViewModel : ViewModel() {
private val _isAuthenticated = MutableStateFlow(false)
val isAuthenticated: StateFlow = _isAuthenticated
private val _privacyAddress = MutableStateFlow(null)
val privacyAddress: StateFlow = _privacyAddress
private var sdk: PrivacyBoost? = null
fun authenticate(wallet: WalletDelegate) {
viewModelScope.launch {
try {
val config = PrivacyBoostConfig(/* ... */)
sdk = PrivacyBoost(config)
val result = sdk?.authenticate(
wallet = wallet,
keySource = null,
tokenProvider = null,
)
when (result) {
is AuthResult.Authenticated -> {
_privacyAddress.value = result.loginResult.privacyAddress
_isAuthenticated.value = true
}
is AuthResult.MnemonicGenerated,
is AuthResult.CredentialRequired,
is AuthResult.RecoveryRequired,
is AuthResult.AccountCreationRequired -> {
// Handle multi-step auth flows — see the Quick Example above.
}
null -> {}
}
} catch (e: Exception) {
println("Authentication failed: ${e.message}")
}
}
}
fun shield(tokenAddress: String, amount: String) {
viewModelScope.launch {
sdk?.shield(tokenAddress = tokenAddress, amount = amount)
}
}
fun clearSession() {
sdk?.clearSession()
_isAuthenticated.value = false
_privacyAddress.value = null
}
}
@Composable
fun WalletScreen(viewModel: WalletViewModel = viewModel()) {
val isAuthenticated by viewModel.isAuthenticated.collectAsState()
val privacyAddress by viewModel.privacyAddress.collectAsState()
Column {
if (isAuthenticated) {
Text("Privacy Address:")
Text(privacyAddress ?: "", style = MaterialTheme.typography.caption)
Button(onClick = {
viewModel.shield("0x...", "1000000000000000000")
}) {
Text("Deposit 1 Token")
}
Button(onClick = { viewModel.clearSession() }) {
Text("Clear Session")
}
} else {
Button(onClick = {
viewModel.authenticate(MyWalletDelegate(wallet))
}) {
Text("Connect Wallet")
}
}
}
}
```
## Next Steps